3.4 性能、内存、视图与副本
NumPy 简洁的语法可能隐藏一个重要区别:两个数组显示的值完全相同,内存所有权关系却可能不同。一个数组拥有自己的数据,一个可能只是指向这些数据的视图,另一个则可能保存独立副本。首先要保证正确性,因为意外共享的缓冲区会让一次看似普通的赋值修改其他位置的数据。
ndarray 由数据与元数据共同组成
从概念上说,ndarray 把原始数据缓冲区与下列元数据组合起来:
dtype决定每个元素的大小和解释方式。
shape给出每条轴的长度。
- 步幅(strides)表示沿每条轴前进一个位置时要跨过多少字节。
- 所有权信息,以及可选的基数组引用。
import numpy as np
a = np.arange(12, dtype=np.int64).reshape(3, 4)
print(a.shape) # (3, 4)
print(a.strides) # 通常为 (32, 8):4 * 8 字节,然后 8 字节
print(a.itemsize) # 每个元素 8 字节
print(a.nbytes) # 数组有效载荷为 96 字节nbytes 约等于 size * itemsize。它只计算元素有效载荷,不包含 Python 对象、内存分配器或进程的全部额外开销。
视图(view)具有新的数组元数据,却引用其他对象拥有的存储空间;副本(copy)拥有独立缓冲区。基本切片通常会创建视图:
a = np.array([10, 20, 30, 40, 50, 60])
window = a[1:4]
window[0] = 99
print(a) # [10 99 30 40 50 60]赋值修改了 a,因为 window 与它共享缓冲区。这种行为让切片很高效,但也建立了需要在代码中明确说明的所有权契约。
整数数组和布尔数组构成的进阶索引(advanced indexing)通常会创建副本:
a = np.array([10, 20, 30, 40, 50, 60])
picked = a[[1, 3, 5]]
picked[0] = 99
print(a) # 保持不变显式调用 .copy() 表示下游代码可以独立修改结果:
owned = a[1:4].copy()可以用 np.shares_memory 判断两个数组的存储区域是否确实重叠:
print(np.shares_memory(a, a[1:4])) # True
print(np.shares_memory(a, a[[1, 3, 5]])) # False.base 有时能提供线索,却不是检查所有权链的完整公共方法;shares_memory 能更直接地表达问题。
reshape 与 transpose 也可能返回视图
当所需索引模式只需新的形状和步幅元数据即可描述时,reshape、ravel 与 transpose 往往会创建视图。转置通常只需交换步幅,不必移动数值:
a = np.arange(12).reshape(3, 4)
columns_first = a.T
print(columns_first.shape) # (4, 3)
print(columns_first.strides) # 各轴现在以不同方式跨过内存转置后的数组通常不再按 C 顺序连续。有些后续操作可以直接处理这种布局,另一些则可能生成连续副本。reshape 能否返回视图取决于当前步幅与要求的顺序,因此,不要让程序正确性依赖“它总是共享”或“它总会复制”的假设。
当一个 API 接收数组时,应记录其修改策略:
- 借用且只读(borrowed read-only):函数读取输入,但不得修改。
- 借用且可修改(borrowed mutable):调用方允许函数对输入进行已记录的修改。
- 拥有结果(owned result):函数返回独立存储空间,供调用方修改。
这套词汇既能避免到处进行防御性复制,也能让修改行为保持明确。
向量化转移了工作位置,但表达式可能分配内存
第 2 章说明,向量化 NumPy 操作会把迭代从 Python 移入编译后的循环。这通常能提升速度,但一条紧凑表达式也可能创建大型临时数组:
standardized = (x - x.mean()) / x.std()暂时忽略标量归约和实现细节,这段代码可能生成一个保存 x - mean 的全尺寸结果,再生成一个除法结果。对于一千万个 float64 值,一个数组的有效载荷为
因此,即使表达式只有一行,多个同时存在的缓冲区也可能带来很高的峰值内存和内存流量。
通用函数(universal function,也称 ufunc)支持 out 参数,从而复用缓冲区:
mean = x.mean()
std = x.std()
result = np.empty_like(x)
np.subtract(x, mean, out=result)
np.divide(result, std, out=result)这样既保留了 x,又复用了 result。如果调用方明确允许修改输入,原地操作能使用更少存储:
x -= mean
x /= std原地代码并不会自动变得更好。它会破坏原值,可能因 dtype 转换不兼容而失败,还可能意外影响共享缓冲区的其他视图。应把修改视为接口决策,而不是局部炫技式优化。
测量真实瓶颈
优化应当建立在证据上。先使用有代表性的数据,并保留一份正确的参考实现:
reference = (x - x.mean()) / x.std()
mean = x.mean()
std = x.std()
optimized = np.empty_like(x)
np.subtract(x, mean, out=optimized)
np.divide(optimized, std, out=optimized)
assert np.allclose(reference, optimized)然后测量与工作负载有关的指标:
- 多次运行的实际耗时,并把准备代码与被测语句分开。
- 峰值内存,而不只是最终数组的
nbytes。
- 当瓶颈是内存带宽时,关注内存分配次数和全数组遍历次数。
- 数值准确性,包括容差、缺失值、溢出和 dtype 变化。
可以用 timeit 进行聚焦计时,用内存分析器观察分配行为。预热、缓存、硬件、NumPy 版本、链接的数值库以及输入形状都会影响结果。一个微型示例无法证明同一种方法在生产规模上仍然更快。
如果整个数据集无法舒适地放入内存,可按块处理,但分块边界必须保持计算含义。彼此独立的逐元素转换很容易分块;全局统计量则需要规划:先计算数值稳定的聚合状态,再逐块转换,或使用可靠的流式算法。
避免这些常见优化陷阱:
- 只凭猜测,就把清晰的向量化代码改写成 Python 循环。
- 未检查范围与精度要求,就把 dtype 改为
float32。
- 误以为
np.vectorize能提供编译循环性能;它主要是 Python 调用的便捷包装。
- 只在不现实的小输入上测量一次。
- 为节省内存而修改所有权共享或不明确的数组。
第 3 章现场检查表
在信任一条实用 NumPy 管道之前,先问:
- 经过 reshape、transpose、stack 或 split 后,每条轴是否仍保有记录清楚的业务含义?
- 排序置换是否让所有对齐数组一起移动,并明确了边界和并列规则?
- 能否通过生成器上下文与抽样设计审计每个随机结果?
- 修改是否出于明确意图?所有权重要时,是否测试了视图与副本?
- 是否在有代表性的数据上测量性能,同时检查数值等价性?
这些问题把正确性、可复现性与效率连接起来。下一章中,pandas 会给行列附加标签,但它不会消除我们对对齐、缺失、所有权和证据的思考需求。