NumPy: ndarray 的内存模型
NumPy 为什么快?
一种常见的解释:它将逐元素运算交给编译后的 C 代码执行,减少了 Python 循环中解释执行与 Python 对象开销。然而,其并未揭露数据在内存中的组织和访问对于性能的影响。
因此,以下简要分析 ndarray 的内存布局、及多维索引与元素地址之间的关系,期以内存角度上间接述清 NumPy 的高效性。
1. 从索引到内存
对于以下三行四列的整型数组:
=
# (3, 4)
# (16, 4)
shape 表示各维度的长度,strides 则记录沿各轴移动一个索引单元时,元素地址的字节增量。在这个数组中,每个 int32 占四字节,元素按行连续存储。列索引增加一,地址增加四字节;行索引增加一,地址增加十六字节。因此,a[2, 1] 相对首元素的偏移为:
为验证上述寻址计算,我们通过 a.ctypes.data 取得数据地址,并使用标准库 ctypes 直接读取 a[2, 1]:
= 2 * + 1 *
= +
= .
# 9
assert ==
其结果与 NumPy一致。
2. 视图与步长
二维转置满足 b[j, i] == a[i, j]。按照上节寻址公式,只需 b 沿用 a 的数据地址并交换两个轴的步长,即可维持这一对应关系,而无须重新排列元素。结果如下:
=
# (4, 3)
# (4, 16)
# True
b[1, 2] 的偏移变成 1 * 4 + 2 * 16,经计算仍为三十六字节,与 a[2, 1] 指向同一个位置。这里需要区分数组对象与元素数据。b 是新的数组对象,具有自己的形状和步长,但与 a 共享元素数据。这类数组被称为视图(view)。
NumPy v2.5.2 numpy/_core/src/multiarray/shape.c中,PyArray_Transpose 在创建返回数组时复用了 PyArray_DATA(ap),随后根据轴顺序填写维度和步长:
for
其中,ap 是原数组,ret 是返回数组。二维转置的 permutation 为 [1, 0],因此两个轴的长度和步长分别交换。循环遍历的是轴,而非数组中的元素。
切片地址变化
切片同样可通过修改步长和数据起点实现。如,a[:, ::2] 保留每隔一列的元素。新数组的列索引增加一,对应原数组的列索引增加二,因此列步长由四字节变为八字节:
=
# (3, 2)
# (16, 8)
元素仍位于原来的位置,并没有被复制到一块新的内存中。切片后相邻元素之间可以存在间隔。
而对于 a[::-1] ,其首元素对应 a[2, 0],所以数据指针需要增加三十二字节;行索引递增时,访问顺序与原数组相反,行步长变为负数:
=
# (3, 4)
# (-16, 4)
# 32
原有的寻址公式仍然适用。如:
因此,d[2, 1] 对应 a[0, 1],其值为 1。
该例指明,数据指针应被理解为当前非空数组的逻辑首元素地址,而非底层内存分配的起始地址。a 与 d 的数据指针虽然不同,访问的内存仍然重叠。判断是否共享内存,不能只比较这两个地址:
assert
assert
assert
同一块存储由此产生了转置、间隔切片和反向切片三种视图。它们的差异都体现在索引与地址的映射中。
3. dtype 与元素布局
前文实验在计算地址后,使用 ctypes.c_int32 读取数据。这个类型并不是由地址决定的:同一位置的字节,采用不同类型解释,可以得到不同的值。
=
=
=
# 1.0
# 1065353216
# 1
assert
assert not
x 与 y 共享四个字节,分别按 float32 和 uint32 解释。view 没有进行数值转换;1065353216 是原有浮点表示被当作无符号整数读取的结果。
astype 则是将数值 1.0 转换成整数 1,本例中为转换结果分配了独立存储。重新解释已有字节与转换数值,是两种不同的操作。
dtype 描述的就是元素的解释规则,包括类型、大小、字节序等。此外,dtype 还可以描述一个元素内部的布局。
结构化 dtype
下面的数组将 id 与 score 组织在同一个元素中:
=
=
# 12
# 4
# (12,)
每条记录占十二字节,id 位于起点,score 的字段偏移为四字节。访问 records[1]["score"] 时,先根据数组步长定位第二条记录,再加上字段偏移:
将 score 字段单独取出,便可观察到元素大小与步长的区别:
=
# 8
# (12,)
# 4
scores 是共享原数据的字段视图。每个分数占八字节,相邻分数却相距十二字节,因为记录中还保存了编号。元素大小描述元素自身的存储长度,步长描述沿某个轴移动时的地址增量,两者不能等同。
dtype 默认采用 align=False,字段之间没有对齐填充。因此每条记录为十二字节。与 C 代码交换数据时,应核对字段偏移和结构体大小,不能直接假定它与同字段的 C 结构体布局一致。
C API 中的元素读取
C 扩展可以自行计算元素地址,再将类型相关的读取交给 NumPy。下面的片段假定 NumPy C API 已初始化,arr 为二维数组,i、j 为经过边界检查的非负 npy_intp 索引:
char *address = + i * + j *
return ;
地址计算与前文相同,但不再将地址处的数据固定解释为 int32。PyArray_GETITEM 根据数组的 dtype 读取该位置并返回 Python 对象。
NumPy v2.5.2 在 numpy/_core/include/numpy/ndarrayobject.h 中为下游扩展提供的实现如下:
static inline PyObject *
PyArray_DESCR 取得 dtype descriptor,PyDataType_GetArrFuncs 取得对应的操作函数表,最终调用其中的 getitem。类型差异由 dtype 对应的读取函数处理,外部代码不必为每种类型重复实现寻址逻辑。
这个接口的取值语义对应 ndarray.item(),并不完整复现普通索引。例如,float32 数组的普通索引返回 NumPy 的 float32 标量,而 item() 返回 Python 的 float。
4. 内存所有权与生命周期
视图共享已有存储,也因此依赖这块存储的有效性。除了数据地址,NumPy 还需要保留相应的对象引用,避免数据在视图仍被使用时释放。
可以通过 OWNDATA 和 base 观察这种关系:
=
=
# True
# True
# False
# True
owner 拥有数据内存,view 不拥有数据,而是通过 base 引用 owner。
删除变量 owner 后,视图仍然有效:
del
# [0 2 4]
# [0 1 2 3 4 5]
del owner 解除的是变量名与对象的绑定。原数组仍被 view.base 引用,因此对象及其数据继续存活;这里既没有复制数据,也没有转移数据所有权。
这与第一节保存裸地址整数的做法不同。地址整数不持有数组引用,而视图通过对象引用维持共享存储的生命周期。
base 不一定是创建当前视图时的直接来源。NumPy 可以沿已有的 base 链追溯到底层对象,因此不能用它还原数组经历过的切片或转置操作。
这一机制也意味着,小切片可能使整块原始数据继续占用内存。需要解除这种依赖时,可以复制切片;新副本拥有独立存储,原始数据则在不再被其他对象引用后才具备释放条件。
结语
ndarray 将元素数据与数组描述分开管理。数据指针和步长用于寻址,dtype 用于解释元素,形状与引用关系分别约束索引范围和存储的生命周期。
这种设计允许多个数组对象以不同布局访问同一份数据。转置和基本切片因此可以避免元素复制:它们的效率不仅来自执行代码的语言,也来自内存模型本身。
参考资料
- What is NumPy?
- The N-dimensional array
- numpy.ndarray.ctypes
- Copies and views
- NumPy v2.5.2:shape.c
- numpy.shares_memory
- numpy.ndarray.view
- numpy.ndarray.astype
- Data type objects
- Structured arrays
- Array API
- NumPy v2.5.2:ndarrayobject.h
- NumPy 2.0 migration guide
- Python:The del statement
- Indexing on ndarrays
[end]