ARM64_Lab

NumPy 2.4.1 の matmul は 512 で一度沈んだ

この記事の見出し
  1. 正方行列を @ で掛ける
  2. 下にいたのは scipy-openblas
  3. 512 だけ谷に見える表
  4. 速いと喜ぶ前に測定を疑う
  5. 次はスレッド数を固定する

NumPy 2.4.1 の float64 行列積を測ったら、256x256 で78.03 GFLOPS、512x512 で74.77 GFLOPS、1024x1024 で135.83 GFLOPS、2048x2048 で149.66 GFLOPS になった。

サイズを上げれば素直に速く見えるはず、と思っていたら外れた。256x256 の中央値は0.43ms しかなく、512x512 は3.59ms。計算量が増えれば実時間が増えるのは当然だが、GFLOPS で見ると512x512 だけ256x256 を下回る谷になる。1024x1024 からまた上がるので、単調増加を期待していた目には奇妙に映る。

この非単調な形がいちばん面白かった。小さい行列は呼び出しコストやタイマー分解能の影響を強く受ける。256x256 の78.03 GFLOPS をそのまま「小さいほど効率がいい」と読むのは危なくて、むしろ0.43ms という短さを見た時点で測定側の限界を疑うべきだった。それに気づいたのは後からだ。

計測ログの日付は2026年6月14日、タイムスタンプは2026-06-14T20:17:54。この記事はベンチマーク結果そのものに絞り、なぜ中間サイズだけ沈むのかは次回の宿題にする。

正方行列を @ で掛ける

測ったのは正方行列どうしの @、つまり NumPy の行列積。型は float64 だけにした。n を256、512、1024、2048 に変えて、それぞれ5回ずつ実行して中央値を取っている。

GFLOPS は次の式で計算した。

GFLOPS = 2 * n^3 / seconds / 1e9

行列積は積和を大量に行うので、よくある近似として 2 * n^3 FLOP を使った。seconds は中央値の実行時間。たとえば n が2048 で中央値が114.79ms なら、その時間でどれだけの浮動小数点演算を進めたかを見る。

Python は3.12.10、NumPy は2.4.1。マシンは Surface Pro 11th Edition の ARM64 Windows 環境で、普段使いのまま測った。電源や温度を詰め切った実験ではないので、絶対性能の表ではなく、私の環境ではこう見えたというログとして読んでほしい。

下にいたのは scipy-openblas

config.show_config を見ると、NumPy が使っていた BLAS は scipy-openblas だった。中身は OpenBLAS 0.3.30 で、構成文字列は NO_AFFINITY ARMV8 MAX_THREADS=24 になっている。LAPACK 側も同じ scipy-openblas を見ていた。

NumPy の @ が速いか遅いかは、Python のループが速いかではなく、下で選ばれた BLAS カーネルに強く左右される。今回のログではビルド側もホスト側も aarch64、SIMD の baseline には NEON、NEON_FP16、NEON_VFPV4、ASIMD が並んでいた。

少なくとも x64 エミュレーションで BLAS を叩いている話ではなく、そこは切り分け済みだ。一方で、not found には ASIMDH P、ASIMDDP、ASIMDH M が出ている。ARM64 なら何でも最大限の命令を使う、という雑な理解は捨てた。OpenBLAS が ARMV8 向けとして動いていることまでは読めるが、Snapdragon X 向けにどこまで尖ったカーネルを選べているかは、このログだけでは判断できない。

512 だけ谷に見える表

5回分をそのまま置いておく。中央値だけを見るときれいに見えるが、同じサイズでもばらつきが大きい。

n runs_ms median_ms GFLOPS
256 0.35 / 0.52 / 0.43 / 0.37 / 0.56 0.43 78.03
512 5.16 / 3.59 / 5.75 / 2.26 / 1.99 3.59 74.77
1024 24.37 / 12.33 / 15.16 / 21.64 / 15.81 15.81 135.83
2048 130.34 / 114.79 / 93.62 / 105.87 / 140.4 114.79 149.66

512x512 のばらつきが特に目立った。最短は1.99ms、最長は5.75ms で、同じ処理なのにかなり離れている。中央値3.59ms から計算した74.77 GFLOPS だけを見て「512は遅い」と言い切るには根拠が薄い。スレッド数を固定しなかったのが痛くて、OpenBLAS が勝手に並列化できる余地があるまま走らせている。

それでも表全体の形としては谷が残る。1024x1024 では中央値15.81ms で135.83 GFLOPS、2048x2048 では114.79ms で149.66 GFLOPS まで伸びた。512x512 だけが256x256 の78.03 GFLOPS を下回っている。数字はそうなっている。

速いと喜ぶ前に測定を疑う

最初に78.03 GFLOPS を見たときは、256x256 でも案外出るなと思った。けれど0.43ms という実時間を見直すと、ベンチマークとしてかなり短い。呼び出しコスト、配列の状態、タイマー分解能、直前のスレッド状態が全部混ざる。ここを軽く見たのは失敗だった。

512x512 の谷も、OpenBLAS の内部しきい値に当たったのか、スレッドの起動や分割の都合なのか、単に5回では足りないのか、今回のログからは分からない。「原因はこれ」と書くと間違える。言えるのは、NumPy 2.4.1 と OpenBLAS 0.3.30 の組み合わせで、少なくともこの5回測定では非単調に見えた、というところまでだ。

スレッド数を固定していないのも大きな見落としだった。MAX_THREADS=24 と出ているので、1コアあたり何 GFLOPS という話にするのは無理がある。測る前に決めておくべき条件で、後から気づく失敗としてはかなり大きい。

float32 も測っておらず、機械学習や画像処理の感覚で見るなら float32 のほうが気になる場面も多いが、このログは float64 だけにした。なぜ512だけ谷に見えたのかは、混ぜて語ると別の記事になる。

次はスレッド数を固定する

スレッド数を固定して測り直したい。1スレッド固定、OpenBLAS 任せ、できれば複数の固定値で分ける。そうしないと512x512 の谷がカーネル選択の話なのか、並列化の立ち上がりの話なのかを切り分けられない。

この結果だけで NumPy が速い、遅いと決めるつもりはない。ただ、ARM64 Windows で普通に pip から入れた NumPy 2.4.1 が、OpenBLAS 0.3.30 の ARMV8 構成で行列積を処理し、2048x2048 では149.66 GFLOPS まで出た。そこは実際に測った価値がある。

気をつけるのは、短すぎるベンチマークを見て喜ばないこと。256x256 の0.43ms は速く見えるが、そこには測定の都合がかなり乗っている。512x512 の74.77 GFLOPS という谷も含めて、次に同じ条件で回したときに再現するかを確認するのが先だ。

a
arm64lab — 個人運営

Surface Pro 11th Edition(Snapdragon X Elite)を2025年5月から常用機にしている個人の記録です。ARM64 版 Windows で詰まったところと、その場で測った値をそのまま書き残しています。特定の企業・団体とは関係がなく、いかなる組織を代表する見解でもありません。