numpy 2.4.1 の float64 配列で見たメモリ帯域
numpy 2.4.1 で 1024MB の float64 配列をコピーすると中央値 120.50ms、読み書き込み換算で 16.60GB/s だった。
copy はサイズを大きくしても 19GB/s 前後で踏みとどまったが、sum は 16MB で 22.98GB/s、64MB で 8.85GB/s、256MB で 25.13GB/s と素直な並びから外れている。配列サイズを 4 倍にすれば時間もだいたい 4 倍になる、という単純な見方をしていたので、64MB から 256MB の sum が 7.06ms から 9.95ms で済んだのは予想と違うところ。
16MB はキャッシュにかなり助けられる側として見える。1024MB は完全にメインメモリをなめる側だろう。境界はきれいではなく、Surface Pro の ARM64 Windows で Python を動かし、NumPy の配列演算を呼ぶと、CPU コア、キャッシュ、メモリ、ライブラリ側の実装がまとめて混ざる(ここを分離する測定にはなっていない)。今回は「この環境で numpy の配列コピーと合計を走らせたときの見かけの帯域」として読むことにした。
計測ログの時刻は 2026年6月3日 20:17 JSTで、2026-06-03 に一度通しただけの測定なので、数字はそこで取った実測値だけを使った(別日の平均ではない)。
float64 をコピーし、足し合わせる
測った対象は float64 の配列。サイズは 16MB、64MB、256MB、1024MB の 4 種類で、それぞれ copy と sum を 5 回ずつ走らせた。中央値を見て、外れ気味の 1 回に引きずられすぎないようにしている。
sizes_mb = [16, 64, 256, 1024]
for size_mb in sizes_mb:
a = numpy.ones(size_mb * 1024 * 1024 // 8, dtype=numpy.float64)
b = numpy.empty_like(a)
numpy.copyto(b, a)
a.sum()
copy_GBps は読み込みと書き込みの両方が発生する前提で、配列サイズを 2 倍して計算している。つまり 16MB の copy は 16MB を読んで 16MB を書いた扱い。sum_GBps は配列を読むだけとして計算した。この前提だけでも結果の見え方は変わるし、copyto は NumPy の最適化された経路を通るので、素の DRAM 帯域そのものとは別物として見たほうがよい(数字の単位は同じでも、中身は少し違う)。
もうひとつ、ウォームアップやスレッド制御を細かく詰めた測定ではなかった。Python 3.12.10 の ARM64 実行環境で NumPy 2.4.1 を呼び、出てきた時間をそのまま見ている。SIMD がどの経路で有効になったかまでは踏み込めずにいる(このへんは後で調べるつもりで止まっている)。
5回ぶんを残して眺める
表には 5 回分の runs_ms を全部置き、中央値だけ見ると見落とす揺れがあるため、64MB の sum が 2.04ms から 9.68ms まで広いこともそのまま見えるようにした(都合の悪い揺れを消さないため)。
| サイズ | 操作 | runs_ms 全5回 | median_ms | GB/s |
|---|---|---|---|---|
| 16MB | copy | 1.49 / 1.22 / 1.86 / 2.04 / 1.60 | 1.60ms | 19.53 |
| 16MB | sum | 0.57 / 0.78 / 0.72 / 0.68 / 0.66 | 0.68ms | 22.98 |
| 64MB | copy | 6.91 / 6.27 / 3.97 / 6.80 / 4.22 | 6.27ms | 19.94 |
| 64MB | sum | 9.68 / 7.21 / 7.06 / 2.04 / 5.07 | 7.06ms | 8.85 |
| 256MB | copy | 24.47 / 23.78 / 32.60 / 27.13 / 25.80 | 25.80ms | 19.38 |
| 256MB | sum | 9.95 / 9.26 / 14.97 / 9.75 / 13.44 | 9.95ms | 25.13 |
| 1024MB | copy | 105.43 / 98.29 / 139.27 / 120.50 / 123.03 | 120.50ms | 16.60 |
| 1024MB | sum | 59.55 / 58.66 / 48.60 / 45.66 / 50.08 | 50.08ms | 19.97 |
copy は比較的読みやすい。16MB で 1.60ms、64MB で 6.27ms、256MB で 25.80ms、1024MB で 120.50ms。最初の 3 点はほぼ 4 倍刻みに近い。最後だけ 256MB から 1024MB で約 4.7 倍に伸び、GB/s も 19.38 から 16.60 に落ちる。ここでメインメモリ側の重さが見え始めた。
sum はもっと変だった。16MB は 0.68ms と速い。64MB では 7.06ms まで増え、サイズ 4 倍に対して時間は約 10.4 倍になった。ところが 256MB では 9.95ms にとどまり、64MB からの伸びは約 1.4 倍で済んでいる。1024MB まで行くと 50.08ms になり、256MB から約 5.0 倍に戻る。
この凸凹は、キャッシュに載るかどうかだけで一行に片づけるには少し乱暴だ。16MB の sum が 0.68msで、1024MB の sum が50.08msなら、サイズ 64 倍に対して時間は約 73.6 倍。完全な比例とは少し違うようで、キャッシュ、プリフェッチ、NumPy 内部のループ、Windows 側のスケジューリングが重なった結果として見るほうが安全な気がする。
64MB の sum を捨てたくなる
いちばんの勘違いは、sum のほうが copy より常に軽く、サイズに対してなめらかに増えるはずだと思っていたこと。読みだけなら単純に速い、と雑に考えていた。実際には 64MB の sum が 7.06ms で 8.85GB/s まで落ち、256MB では 9.95ms なのに 25.13GB/s まで戻る。
失敗というほど大きなエラーではないが、最初に表を見たときは 64MB の sum を測定ミスとして捨てたくなった。5 回の中に 2.04ms がある一方で 9.68ms もある。ばらつきが大きいからだ。けれど JSON にはその 5 回が残っている。都合の悪い行だけ消すと、実験ログではなく感想になるので、今回は残した。
copy の安定感も少し意外だった。16MB から 256MB までは 19.53GB/s、19.94GB/s、19.38GB/s と近い。1024MB で 16.60GB/s に下がるものの、崩れ方は sum より読みやすい。copyto が最適化された経路を通るぶん、Python から見た処理としてはきれいに走っているのかもしれない。ただ、この時点で SIMD 命令の種類やメモリチャネルの実効値まで断定する材料は足りなかった(そこまで読むには別の測り方が要る)。
キャッシュの残り方を疑う
16MB の sum が 0.68ms で終わるのは、メインメモリだけを読んでいる感覚とは違う。すでに触った配列がキャッシュ階層に残り、合計処理がそこから読めている可能性が高い。逆に 1024MB はキャッシュに収まりきらない。50.08msという時間は、配列全体を本当に読み直している感触に近い。
とはいえ、64MB と 256MB の逆転気味の見え方は説明が難しい。64MB の sum は 5 回の振れが大きく、中央値も低くない。256MB は 9.26ms から 14.97ms の範囲に収まり、中央値 9.95ms で済んだ。配列サイズだけを変えたつもりでも、測る順番、直前の copy、OS の状態でキャッシュの残り方が変わる。このあたりはハマった箇所として覚えておく。
NumPy の計算を「メモリ帯域で頭打ち」と呼ぶ前に、対象サイズを変えて見る必要がある。16MB だけなら 22.98GB/s の速い世界に見える。64MB だけなら 8.85GB/s の遅い世界。1024MB を混ぜると、ようやくメインメモリ寄りの数字として 19.97GB/s が出てくる。なぜここまで揺れるのか、次は順番を崩して測ってみる必要がある。
次は順番を崩す
今回の運用上の結論は単純。小さい配列の結果を見て、大きい配列の処理時間をそのまま外挿するのは無理がある。16MB の sum が 0.68msだからといって、1024MB を43.52msと見積もると外れる。実測は50.08msだった。
copy についても、19GB/s 台がずっと続くと決めつけるのは無理がある。1024MB では16.60GB/sまで落ちた。大量の配列を何本もコピーする処理なら、この差は効くはず。
次にやるなら、NumPy の copyto だけでなく、スライス代入や a.copy() も同じ表に並べたい。sum も順番を入れ替え、明示的なキャッシュ破棄に近い処理を入れて測り直すべきだろう。今回は numpy 2.4.1 の見かけの帯域を記録したところで止め、内部実装まで読めていないので断定は避ける(次は順番を崩して測ってみる)。