6.19. パフォーマンス

numpy をカメラ上で高速に動作させるための設計判断(配列全体に対するライブラリ呼び出し、パックされた型付きバッファ、ソースとデータを共有するビュー)は、同時に知っておく価値のある一連の習慣も浮き彫りにします。形状とストライド のページでは最終軸のレイアウト規則をすでに扱いました。このページでは、ストリーミングループで最も重要となるアロケーションと dtype の習慣をまとめます。

6.19.1. 適切な dtype を選ぶ

すべてのコンストラクタのデフォルト dtype は float です。本来 8 ビットや 16 ビットであるデータ(ADC サンプル、画像ピクセル、センサーの読み取り値)に対しては、整数型のいずれかを dtype= で明示的に渡してください:

adc = np.array(adc_samples, dtype=np.uint16)

4 バイトの float デフォルトと比べて、RAM の節約は uint16 で 2 倍、uint8 で 4 倍になります。numpy 内部の整数コードパスは汎用的な float のものよりタイトなため、演算も高速に実行されます。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 は入力を一度に 1 つの Python オブジェクトずつたどり、使用する前にそれぞれを数値へ変換せざるを得ません。ndarray に対しては変換がすでに済んでいるため、呼び出しはパックされたバッファをそのまま処理して進みます。

同じデータを複数回使う場合は、ndarray を一度だけ構築して渡し回してください。データが Python のリストとしてのみ存在し一度しか消費されない場合は、変換コストが高速化分を上回ることがあります。array() コンストラクタ自体がリストをたどってアロケーションを行う必要があるためです。

6.19.3. コピーよりビューを優先する

スライス、高ランク配列の単一軸インデックス、reshape()transpose()frombuffer() はいずれも、ソースとデータを共有する ビュー を返します。これらは事実上コストがかかりません。

copy()flatten()、ブールインデックス(a[mask])、およびあらゆる算術式は コピー をアロケートします。これらに頼るのは、独立したバッファが本当に必要なときだけにしてください。

迷ったときは、ndinfo() が基盤バッファの位置を表示します。同じアドレスを報告する 2 つの配列はデータを共有しています。ビューとコピーの完全な対応表は ビューとコピー にあります。

6.19.4. 一度アロケートして、その後に書き込む

カメラ上で最大のパフォーマンス上の落とし穴は、毎秒何度も実行されるループの内側で新しい配列をアロケートすることです。新しい ndarray はそれぞれカメラに RAM を要求し、頻繁な新規アロケーションは 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 + 1b と同じサイズの一時オブジェクトをアロケートし、コピーして再代入します。b += 1b を直接変更します:

# makes a temporary
b = b + 1

# no temporary
b += 1

同じ考え方が複合式にも当てはまります。a + b * cb * 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:] が興味深い行です。同じバッファへの 2 つの重なり合うビューであり、右辺のスライスは一方の端から読み取られ、もう一方の端へ書き込まれます。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