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だけ谷に見える(OpenBLAS のしきい値を疑いたくなる形だった)。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 を見ていた。

この点は大事な点(float64 だけの結果として切っておく)。NumPyの @ が速いか遅いかは、Pythonのループが速いかではなく、下で選ばれたBLASカーネルに強く左右される。今回のログではビルド側もホスト側もaarch64、SIMDのbaselineにはNEON、NEON_FP16、NEON_VFPV4、ASIMDが並んでいた。

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

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は遅い」と言い切ると、少し強すぎるかもしれない(スレッド数を固定しなかったのが痛い)。

それでも、表全体の形としては谷が残る。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回では足りないのか、ここでは分からない(float64 だけの結果として切っておく)。ここで「原因はこれ」と書くと、たぶん間違える。今回のログから言えるのは、NumPy 2.4.1とOpenBLAS 0.3.30の組み合わせで、少なくともこの5回測定では非単調に見えた、というところまで。

もうひとつの限界は、スレッド数を固定していないこと(GFLOPS の見た目ほど単純ではなかった)。MAX_THREADS=24 と出ているので、OpenBLASが勝手に並列化できる余地がある。環境変数で1スレッドに縛っていないため、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まで見えた。そこは実際に測った価値がある。

使い分けとしては、今のところNumPyの行列積を避ける理由はない(短すぎる測定を速さとして読みかけていた)。気をつけるのは、短すぎるベンチマークを見て喜ばないこと(五回だけでは谷の説明まで届かなかった)。256x256の0.43msは速く見えるが、そこには測定の都合がかなり乗っている。512x512の74.77 GFLOPSという谷も含めて、次に同じ条件で回したときに再現するかを先に確認してみるつもりだ。 これは次回への持ち越しの話。

a
arm64lab — 個人運営

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