Python ライクな事前コンパイラ言語のパフォーマンスを比較してみた
Python ライクな文法だけど事前コンパイルして実行するタイプの言語が近年増えてきています。
ずいぶん前からあるのは Cython。ちょっと前から Codon。最近 Mojo という感じでしょうか。
プログラミング学習のハードルが低いことから初学者を始め Python もしくは Python の記法で書けると嬉しいというニーズはAIプログラミングという新潮流もあって高まっていると思います。
Python ニーズの高まりに相反して、素の Python で数値計算をさせるのは実用上難しいことが多いです。Python 3.13 が JIT (Just in Time) コンパイルに標準で対応する予定だという話も聞こえてきましたが、やはり JIT コンパイルでも数値計算、科学計算に本格的に用いるには物足りません。
そこで、Pythonをメイン言語に使ったときと開発体験がなるべく近くて事前コンパイルする静的言語たちの数値計算の実行性能を比較して、どんな言語がパフォーマンス面で他の言語に比べどれだけ優れているかを調べました。
この記事の内容は更新中です
比較に用いた言語は以下の通りです。
- Pure Python (CPython) 3.11.9
- Python, Numpy
- Cython 3.0.11, OpenMP
- Codon 0.17.0, OpenMP
- Cupy (cupy-cuda12x ver. 13.3.0)
- Mojo 24.5, SIMD, スレッド並列
- C++ ネイティブコード (予定)
Numpy はプロセスが複数スレッドに対応していれば、標準でスレッド並列実行されるので OpenMP を使う必要はありません。ただし並列スレッド数を明示して2通り試しました。
また Mojo についてですが、ネイティブコードより簡単にスレッド並列かつ SIMD のコードが書けたので今回の Mojo のベンチマーク用プログラムは全て 4スレッド並列SIMDになっています。
ランタイム環境
CPU
- 製品名: Ryzen 7 7730 U (平凡なラップトップ用CPUです)
- コア数/スレッド数: 8/16
- ベースクロック: 2000MHz
- ターボクロック: 4550MHz
RAM
DDR4 16GB
Cupy の検証に使用したGPU
- 製品名: NVIDIA RTX 3050 8G
- CUDA コア数:
- FP32 理論演算性能: 9.098 TFLOP/S
検証内容
前処理
粒子数 N のトラジェクトリーをランダムに生成する。
実行時間の計測対象の処理
全組み合わせ N x N 個の粒子ペアの距離の2乗を計算する。この処理の実行時間を計測する。
単純な2重ループの数値計算です。
比較対象
上記の処理を30回実行して、実行時間を平均したものをそのランタイムの平均実行時間として比較に用いる。
結果
以下のグラフは全て平均実行時間を表す縦軸が対数のグラフです。
まずは N=100 から。
後述する Mojo を除いて劇的な差はありませんが Python は 多重ループが苦手なので、Numpy にするだけで劇的に処理速度が向上しています。10倍以上高速化できています。また最適化した Codon は Numpy から更に一段と高速です。
Cython は N=100 のような小さな計算対象の場合、命令のオーバーヘッドの方がボトルネックとなっているのか、逆に Numpy より低速になっています。Cython は 12スレッドOpenMP並列実行させて、level 3 でコンパイル時に最適化しています。
ただし、N=100 つまり 10,000 の組み合わせごときでは、RAMとVRAM間でデータを移動させる処理と命令文のオーバーヘッドが大きいのか、SIMDかつスレッド並列 (CPU環境でかなりの最適化) ができている Mojo のほうが処理速度は上回っていました。
ちなみに 非SIMDスレッド並列版の Mojo プログラムは、どの大きさNでも Codon と同じくらいの実行時間でした。なので素で出せる速度は Codon と Mojo に大きな差はないと思われます。
N = 100 のときの計算量は
$2 \times 3 \times 100 \times 100 = 60,000 \text{ [FP32]} = 60 \text{ [Kilo FP32]}$
60 kilo FLOP に対して 数10~数百GFLOP/s の性能があるそこそこ最近のCPUであれば、マイクロ秒オーダーで計算を終わらせられるデータ量です。Numpy や Codon, Mojo ではちゃんと数~数百マイクロ秒で完了しているので、
テストコードの出来が極端な悪いようではなさそうです。
つぎに N=1,000 です。
差がかなり顕著になりました。Pure Python の2重ループ(シングルスレッド)に比べ Numpy は20倍以上高速になっています。単純に Pure Python のループを同じスレッド数だけ並列実行させても 20倍は速くならないので、やはり非GPU環境では Python の数値計算は Numpy を使って行うのがベターです。
■ 処理時間の傾向
Pure Python >> Numpy > Cython > Codon (release
flag なし) > Codon (release
flag あり) > Mojo (スレッド並列SIMD) > Cupy (matmul)
Cython はここでついに Numpy より高速になり Numpy より30%ほど高速になりました。 Cython を使って高速化を試みる場合は、命令文のオーバーヘッドと計算量を慎重に天秤にかける必要がありそうです。計算量に依っては返って Python より遅くなる場合があるということですね。
また、ここで遂に Cupy (GPU計算) がスレッド並列SIMD している Mojo を上回りました。
計算量は $6$ Mega FP32 なので、まだまだ CPU でいけるはずですがGPUの VRAM が GDDR6 だからでしょうか。
また、Mojo の方はうまく CPUキャッシュにデータが乗っていないのかもしれません。ただ最適化が面倒なのでこれ以上はやりませんでした。
3つめに N=10,000 です。
先ほどと同じような傾向です。
■ 処理時間の傾向
Pure Python >> Numpy > Cython > Codon (release
flag なし) > Codon (release
flag あり) >> Mojo (スレッド並列SIMD) >> Cupy (matmul)
しかし興味深いのはどちらも12スレッド並列させて、またコンパイル時に最適化させているのですが Codon は Cython より 10倍速いです。
インターネットの情報を漁ると、いろんなところで Codon は人が素直に書いたネイティブコードにかなり近いコードを生成することが多いと言われていました。今回実装したコードも2重ループで距離の2乗を計算するだけの超絶単純なコードなので、人が書く C++ のコードとほぼ全く同じコードを生成している可能性が高いです。
一方で、やはり Cython は自動生成されるネイティブコードが複雑になってしまう、最適化が効きにくいのか Codon ほど高速になりません。どちらも同じ事前コンパイラ言語なのですが。
また GPU vs CPU の話ですが、計算量はまだ 600 Mega FP32 程度ですが GPU が相当速くなりました。
Mojo の方は 9 GFLOP/S くらいの計算速度ということになるので、まだまだ最適化の余地はありそうです。
ただ Python のコードを書く感覚でスレッド並列SIMD ができるのは良いです。Python のときから 1423 倍速くなりました。
最後に N=14,000 です。
処理速度の傾向は引き続き同じです。
■ 処理時間の傾向
Pure Python >> Numpy > Cython > Codon (release
flag なし) > Codon (release
flag あり) > Mojo (スレッド並列SIMD) >> Cupy (matmul)
スレッド並列SIMD化した Mojo に覚える衝撃こそありませんが Codon も結構速いです。Mojo は頑張ってスレッド並列SIMDしたので、他のCPU環境ランタイムより超速くなっているわけですが、Codon はほぼ Python のまんまで Numpy より圧倒的に速くできました。また Cython も Numpy より倍以上は高速になりました。オーバーヘッドが計算量に対して無視できる度合いが大きいほど、Cython の Numpy からの高速化率は高くなりました。
Codon vs Mojo ですが -release
フラグでコンパイル時に最適化された Codon よりも スレッド並列SIMDした Mojo の方が約3倍速くなりましたが Codon の方はSIMDになっていないので、単純な比較はできません。
厳密には Mojo は SIMD x スレッド並列化でうまく実行環境の限界に近く高速化させるコードが書きやすいので、限られた検証時間において、Mojo がより速く出来たということです。
Codon でも Cython でも SIMD x スレッド並列最適化はできるだろうけど (Python, Numpy は無理ですが)
Mojo が簡単ぽいです。
N=100 のところでも述べましたが、非SIMD版のMojoプログラムは Codon とだいたい同じ実行時間だったので素の処理速度はそんなに差はないと思います。
また Cupy では NVidia CUDA の TF32 形式で計算できる matmul
関数を使って計算させたところ 約300GFLOP/S 出ている結果になりました。
数値計算でどの言語を採用すべきかを考えるときの論点
ここからは実験事実とは別軸の筆者の感想です。
計算対象の大きさ
まず第一に GPU が使えるなら、素直に GPU を使った方が良いです。Cupy は Numpy のような使用感で Python でGPGPUプログラミングが完結できますし、CPUより超速いです。
また Cupy は lowlevel の CUDA カーネルを触ることができるので、あまりないかもしれませんが cupy が用意した計算機能では十分な性能が出せないケースに出くわしても自分で部分的に python スクリプト内で cuda kernel を書くことができます。
非GPU環境でいえば、理想論では単純な行列演算の四則演算とか積分計算を大量のデータで行うような処理はシンプルな数値計算だが処理対象が大きい場合は、Codon, Mojo を用いるのが良さそうです。
(Python ライクな言語から選ぶならという話。)
ただし、Codon は Python ライブラリの全てに対応している訳ではありません。対応しているライブラリは限られています。ただ、これも Codon で書かれたコードをビルドしてモジュールとして Python コードから呼べば、ライブラリを使う複雑だが小さい処理は Python でやって単純だが重い処理のみ Codon で行うという方針を取れば問題ないでしょう。
また、Mojo は Python のスーパーセット言語になるよう開発が進んでいるようなので、Pythonのライブラリが今の段階でも結構使えます。ただ破壊的変更の頻度がものすごく高いようなので、Mojo は今後に期待です。
(Mojo ですが、インターネットに転がっていたサードパーティーの Mojo のコードがことごとく最新バージョンに対応しておらず色々大変でした)
Codon, Mojo が難しい場合は Cython が良さそうです。ただ一度の計算対象がそれほど大きくない場合は Numpy の方が高速な場合もあります。
開発規模、開発の用途
Codon はまだ v1.0 メジャーバージョンリリースをしていません。Mojo も同じく。今のバージョンでもほしい機能はあるように 思えますが、商用利用したいとか長期的かつチームで本格的な開発に用いる場合には慎重になるべきです。また Codon を開発しているのは Exaloop という営利企業です。良い意味でも気になる点です。
Mojo は今月書いたコードが来月の Mojo でも使えるか分かりません。また WSL2 環境ですが Mojo でマルチスレッドSIMDプログラムを作成していたところ、エラーに遭遇して格闘したが、原因は Mojo のバグのようで今回のバージョンでは妥協した点もありました。
なので今後に期待ですが商用、アカデミック、チーム開発にはまだ Mojo を本格的に使うことはできないかなと思いました。自分だけの趣味プロなら別ですが。
Cython は良い意味で枯れた技術ではあると思うので、その点では Cython を使うほうが良いでしょう。ただ本格的な開発には Cython ではなくて、自前で C++, Rust を書いた方が良いです。また勿論ですが GPU を使った方が良いです。
プログラミング経験
ほとんどなくて、時間を割いてある程度のところまで習得に至るより先に開発を優先したい場合は、Python, Numpy の組み合わせが良いです。なぜなら簡単な Python で完結するしドキュメントが最も豊富だからです。
ついで Cython です。Codon はモダンなので Cython より開発体験は良いですがドキュメントは少ないです。
Mojo は破壊的変更が数ヶ月に1度起きている感じなので全然おすすめできないです。Mojo のドキュメントや公式リポジトリの Issue で行われているディスカッション等を自分で参照して格闘できるなら Mojo 大丈夫です。
さいごに
この記事は更新中です。
ネイティブコード C++
でも検証予定です。また検証に使った一連のコードも併せて公開予定です。