プログラミング言語の実行速度比較(2023/5)

はじめに

先月、プログラミング言語の実行速度比較(2023/4)という記事を投稿した。 思ったより多くの方に見ていただき、有用なコメントやPull Requestをいただいたので、それらを踏まえ以下の更新を行い結果を再投稿する。
  • 明示的な型をもつ言語では、それぞれ32bit、64bit長型の変数を使った場合についてそれぞれ測定
  • C言語については前回の結果で固定長配列とrealloc()を使った場合で大きな違いがなかったのでrealloc()版のみエントリ
  • C/C++では、実用上ほぼ使用されない最適化なしをドロップ
  • Go版では、@kaoriyaさんから、数値の型(int)をint32に変えると性能が向上する旨のコメントを頂いた。Go版もint32とint64で測定した
  • Scala版では、@windymeltさんから、GraalVMを使った場合の性能向上について情報を頂いた。JVM上で動作するプログラムについては、実行環境としてGraalVMを使った場合の結果も追加した
  • PHP版では、sj-iさんからJITを有効にするPull Requestをいただいたのでそれも追加
  • Ruby版を追加
  • 前回、低消費電力が特徴のIntel Core i7-8559Uで測定を行った。これに加えて、比較的能力の高いIntel Core i9-12900Kでも測定を行った
また、当然だがこの投稿で使った素数の算出は様々なワークロードの中のひとつ。実用上、CPUだけぶん回しているタスクはむしろ少数派かも。

速度比較

方法

  • シングルスレッドで、逐次既知の素数で割ることで素数を100,000,000まで求める(ソースはここ)。いわゆる試し割り法
  • その際の実行時間と最大物理メモリ使用量を計測する
  • 素数の数は5,761,455個なので、数値が32bitの場合、それらを格納するには少なくとも約22MiBのメモリが必要(64bitの場合、約44MiB)
  • 言語の比較なので、なるべくユーザモードで動作するコードとして素数の算出を選んだ。カーネルが関係するのはメモリのアロケーション時が中心だが、実行時間の1%もない

評価環境

CPU1 (C1)

CPU2 (C2)

OS

  • Debian 12 (sid)

結果

表の横幅が画面に収まりきらない場合、横スクロールしてご覧ください。
Category 言語 数値型 時間 (sec) 最大使用
物理メモリ
(MiB)
算出素数格納
コンテナ
備考
C1 C2 C1 C2
AOT
Compile
C int 12.6 7.5 23.6 23.6 realloc() gcc 12.2.0 最適化-O3
C long 41.6 12.5 45.5 45.6 realloc()
C++ int 12.7 7.7 36.5 36.5 std::vector
C++ long 41.9 12.5 67.6 68.7 std::vector
Rust i32 14.2 7.6 25.8 24.0 Vec rust 1.63.0
Rust i64 14.1 7.6 46.2 46.0 Vec
Golang int32 19.2 7.7 105.8 92.4 slice C1: Go 1.19.6, C2: Go 1.19.8
Golang int64 48.9 12.7 171.5 202.0 slice
JVM Java int (Integer) 19.5 8.2 230.4 248.0 ArrayList
Java long (Long) 53.0 13.3 269.1 294.1 ArrayList
Java (GraalVM) int 21.8 8.2 247.2 260.1 ArrayList GraalVM CE 22.3.2
Java (GraalVM) long 49.3 13.2 281.2 306.4 ArrayList
Kotlin Int 19.6 8.1 256.1 276.6 List Kotlin 1.8.20
Kotlin Long 51.8 13.1 294.1 323.0 List
Kotlin (GraalVM) Int 17.3 8.1 272.0 294.8 List Kotlin 1.8.20, GraalVM CE 22.3.2
Kotlin (GraalVM) Long 46.6 13.1 310.2 330.9 List
Scala Int 24.9 9.5 1081.8 1449.0 mutable.ArrayBuffer Scala 3.2.2
Scala Long 59.8 14.3 662.7 1430.6 mutable.ArrayBuffer
Scala (GraalVM) Int 19.3 8.2 245.0 321.8 mutable.ArrayBuffer Scala 3.2.2, GraalVM CE 22.3.2
Scala (GraalVM) Long 50.8 13.4 609.8 706.0 mutable.ArrayBuffer
Script JavaScript (Node.js) Number 17.3 7.8 196.1 203.2 Array Node.js 18.13.0
PHP integer 108.7 43.3 149.1 149.2 array PHP 8.2.2
PHP (JIT) integer 71.9 13.4 152.4 152.5 array
Python3 int 289.4 160.0 231.5 232.8 list Python 3.11.2
Ruby Integer 380.0 160.8 57.6 57.7 Array Ruby 3.1.2

グラフ

薄い青: AOT Compile (32bit)  濃い青: AOT Compile (64bit)  薄い赤紫: JVM (32bit)  濃い赤紫: JVM (64bit)  薄い紫: JVM/GraalVM (32bit)  濃い紫: JVM/GraalVM (64bit)  橙色: Script

CPU1 (Core i7-8559U)での実行時間

CPU2 (Core i9-12900K)での実行時間

コメント・感想

全般的な実行時間の傾向

  • AOT (Ahead-Of-Time)コンパイル型の言語は、おおよそ同じオーダーの実行性能で速い
  • 次いでJVM上で実行される言語が続く。多少の差はあるが、これらの実行時間もだいたい同じ
  • GraalVM上での実行では、OpenJDKより若干よいケースもある
  • Script系の言語は、Node.jsが群を抜いて速い
  • PHPのJITも一定の効果はある(約1.5〜3倍)

32bit長と64bit長の整数型について

  • 概して64bit長の数値型を使った場合、32bit長の数値型を使った場合の1.5〜4倍程度、実行時間を要した
  • ただし、Rustでは、i32とi64を使った場合でほぼ同じ。最大メモリ使用量は期待に近いが、実行時間が32bitとほぼ同じなのはなぜ?(コードの誤り?)
  • Core i9-12900Kのほうが32bit長と64bit長整数の乖離が小さい傾向にある
  • メモリ使用量は、32bit長と64bit長整数で大きな差はない。JVM系に関しては64bit長のほうがやや使用量が多いように思える。これは実行環境の搭載メモリ量が多いで単に多くヒープを使用しているだけかもしれない

Template/Generics

  • C++, Rust, Goでは、32bit長と64bit長版をなるべく共通のコードとするためにTemplate/Genericsを用いた。同じ条件なら前回の結果と大きく変わらなかったので、Template/Genericsによる性能のオーバーヘッドもほぼないと考えられる
  • Java, Kotlinに関しては、そもそもGenericsで実現する方法が分からなかった。ベースクラスで基本的な計算をして、型に固有の処理をサブクラスでオーバーライドするような設計も考えたが、他の言語と実装が乖離しそうなのでそのアプローチも諦めた。単純に同じコードで数値型の異なるクラスを2つ用意した。
  • Scalaに関しては、Integral traitで実現しようとしたが、かなり(3〜4倍)実行時間が増加しそうだったので、Genericsの使用を諦めた

3 件のコメント:

匿名 さんのコメント...

興味深い検証ありがとうございます!

GraalVMを用いた高速化の部分で気になったのですが。
近年ではJITコンパイラによる動的な最適化が一般的になったことで、
インタプリタやJVM系の言語でも、起動時にオーバーヘッドはあれど
処理速度自体は寧ろかなり高速であり
また、動的な最適化を行うことのできないAOT型のGraalVM ネイティブイメージでは、
むしろJITよりも実行時のパフォーマンスは落ちるのかなと思ったのですが実際には違いました。

これは、今回取り上げたソースコードでは、まだJVM起動のオーバヘッドの影響が大きいのか
それとも、そもそもAOTの方が処理速度の面でも恩恵が大きいのか、またはそれ以外なのか
ご存知でしょうか?

ずーくん さんのコメント...

ご興味をもっていただき、ありがとうございます。

私も各実装の詳細を知っているわけでないので、そのあたりは正直よくわからないです。
以前からJITコンパイルのほうが、実行プロファイルに基づいて最適化できるので有利という話も聞きますので、同じような疑問を少し持ちました。

私としては、そのような最適化が顕著なワークロードってそんなに多くないかもというのと、CPU自体もマイクロアーキテクチャレベルで投機実行など動的な最適化を行っているので、AOTコンパイルでもいい感じに動作するのかもしれないと考えています。

匿名 さんのコメント...

試し割りループ内の除算(剰余算)の処理速度で律速しているように見えますね。

①試し割りで繰り返し利用する素数は10000以下(個数にして1200個ちょい)なので64bit整数でもL1キャッシュに乗るサイズ、なので素数リスト自体は大きくてもメモリアクセスは足を引っ張らない。
②条件分岐の分岐予測はこのあたりのCPU世代ならどれもほぼ同じ正解率になると思われるので結果に影響していないと思う。
③したがってループ内の演算処理速度がネックですが乗算よりも除算(剰余)のほうが数倍以上時間がかかる
④除数が32bitか64bitかでも処理速度が異なり、特にCoffeeLake世代あたりまでは64bit除数の除算命令は非常に遅い、かつAlderLakeではかなり除算命令の速度が改善されている(でも32ビット除数の除算命令よりは遅い)... のがそのまま C/C++ の結果に表れているように見える

Rustは64bit版でも除数が32bitに収まっていることを見抜いて32bit除算命令にしているんじゃないかと思います。バイナリを解析しないと事実関係はわからないですが。