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)

N 确实无法事先得知时,可以写入一个 Python list,最后用 array() 一次性转换。

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