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 內部的整數程式碼路徑比通用的浮點路徑更精簡。Dtype 資料型別 所介紹的整數溢位規則同樣適用——在可能溢位的算術運算之前,先轉換成更寬的型別。

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)——每個運算子都會配置一個新的 bool 陣列。當遮罩會被重複使用時,請建立一次並保留它:

mask = a < threshold
foo[mask] = 0
bar[mask] = 1