6.19. 性能¶
那些让 numpy 在摄像头上运行得快的设计决策——整数组的库调用、紧凑的类型化缓冲区、与源共享数据的视图——同时也带来了一些值得了解的使用习惯。形状与步幅 页面已经介绍了最后一个轴的内存布局规则;本页则汇总了在流式循环中最为重要的内存分配和 dtype 习惯。
6.19.1. 选择合理的 dtype¶
每个构造函数的默认 dtype 都是 float。对于本质上是 8 位或 16 位的数据——ADC 采样值、图像像素、传感器读数——应显式传入 dtype= 指定为某种整数类型:
adc = np.array(adc_samples, dtype=np.uint16)
相比 4 字节的 float 默认值,uint16 可节省 2 倍 RAM,uint8 可节省 4 倍。运算速度也更快,因为 numpy 内部的整数代码路径比通用的浮点路径更精简。数据类型(Dtypes) 中介绍的整数溢出规则在此同样适用——在可能溢出的算术运算之前,先转换为更宽的类型。
6.19.2. 优先使用 ndarray 而非可迭代对象¶
大多数归约运算和通用函数既接受可迭代对象,也接受 ndarray:
np.sum([1, 2, 3, 4, 5]) # works, but slow
np.sum(np.array([1, 2, 3, 4, 5])) # ~3x faster
可迭代对象的形式会迫使 numpy 逐个遍历输入中的 Python 对象,先将每个对象转换为数字才能使用。而对于 ndarray,转换已经完成,调用可以直接贯穿紧凑的缓冲区。
当同一份数据被多次使用时,应只构建一次 ndarray 并传递它。当数据仅以 Python 列表形式存在且只使用一次时,转换的开销可能会抵消提速带来的好处——array() 构造函数本身就必须遍历整个列表并分配内存。
6.19.3. 优先使用视图而非副本¶
切片、对高维数组进行单轴索引、reshape()、transpose() 以及 frombuffer() 都返回与源共享数据的视图。它们的开销基本可以忽略不计。
copy()、flatten()、布尔索引(a[mask])以及任何算术表达式都会分配一份副本。只有在确实需要一个独立缓冲区时才使用它们。
如有疑问,ndinfo() 会打印出底层缓冲区的地址;两个报告相同地址的数组是共享数据的。完整的视图与副本对照表见 视图与副本。
6.19.4. 先分配,再写入¶
在摄像头上最大的性能陷阱,就是在每秒运行多次的循环内分配新数组。每个新的 ndarray 都会向摄像头请求 RAM,频繁的新分配会造成浪费。
大多数通用函数都接受 out= 参数,从而可以将结果写入一个已经存在的数组中:
x = np.linspace(0, 2 * np.pi, num=512)
y = np.zeros(512) # allocate once
while True:
np.sin(x, out=y)
# use y ...
image.Image.to_ndarray() 出于同样的原因接受 buffer= 参数;spectrogram() 以及 from_int32_buffer() 这类转换函数同时接受 out= 和 scratchpad=。把所有东西都只分配一次然后复用。
6.19.5. 使用原地运算符¶
b = b + 1 会分配一个与 b 同样大小的临时数组,进行复制,然后重新赋值。b += 1 则直接修改 b:
# makes a temporary
b = b + 1
# no temporary
b += 1
同样的思路也适用于复合表达式。a + b * c 会为 b * c 分配一个临时数组。将表达式拆分成若干个写入预分配缓冲区的简单子赋值,就可以消除这些临时数组:
# one temporary for (a + b), another for the ``* 2``
out = (a + b) * 2
# zero temporaries
out[:] = a
out += b
out *= 2
6.19.6. 构建结果,而不是向其追加¶
ndarray 没有 append——这是有意为之。扩展数组将意味着要分配一个更大的新缓冲区,并把旧内容复制进去。在微控制器上,应预先分配最终大小,然后填充它:
out = np.zeros(N, dtype=np.float)
for i in range(N):
out[i] = some_calculation(i)
6.19.7. 用切片赋值代替新建数组¶
许多“从其他数组的片段构建新数组”的模式,都可以表达为对预分配缓冲区的切片赋值,而不必每次调用都进行新的分配。
对样本流进行滚动窗口处理——移动平均滤波器的基础——就是典型的例子。缓冲区保存最后 N 个样本;每次迭代丢弃最旧的并追加最新的。最直观的写法每次迭代都会重建缓冲区:
while True:
sample = read_sample()
buf = np.concatenate((buf[1:], # new buffer every loop
np.array([sample])))
avg = np.mean(buf)
这相当于每个样本一次新的分配——以及对 N - 1 个元素的复制。切片赋值的形式则是原地移位:
N = 16
buf = np.zeros(N, dtype=np.float) # allocate once
while True:
sample = read_sample()
buf[:-1] = buf[1:] # shift left by one
buf[-1] = sample # append at the end
avg = np.mean(buf)
buf[:-1] = buf[1:] 是关键的一行:两个指向同一缓冲区的重叠视图,右侧切片从一端读取并写入到另一端。numpy 会以使原地移位安全的顺序遍历底层内存。循环内部不会分配任何新数组。
6.19.8. 注意流式循环中的布尔掩码¶
布尔索引和 where() 每次调用都会生成一个新数组——结果的大小取决于数据,所以没有任何预分配缓冲区能够吸收这次分配。在流式循环中反复构建掩码会用一次性数组填满 RAM。周期性地调用 gc.collect() 可以回收这些空间:
import gc
for i in range(1000):
mask = a < threshold
_ = a[mask]
if i % 100 == 0:
gc.collect()
同样的告诫也适用于像 (a > lo) & (a < hi) 这样的复合布尔表达式——每个运算符都会分配一个新的布尔数组。当某个掩码会被复用时,应只构建一次并保留它:
mask = a < threshold
foo[mask] = 0
bar[mask] = 1