🐍

Pythonのコンパイラを作りたい #7 - ベンチマークと最適化

2025/02/15に公開

こんにちは。前回 (「#6 - ランタイムとメモリ管理(Boehm GC の導入経緯)」) では、Boehm GC を使うことでメモリ管理がどれほど楽になるか、またどのようなメリットがあるかについて説明しました。

今回は少し趣向を変えて、ベンチマークの話題に焦点を当ててみます。コンパイラを自作するうえでは、やはり「実際にどれくらいの速度が出るのか?」は大きな関心事ですよね。ここでは、pyc で簡単なフィボナッチ計算を行い、ほかの言語や実装と比較した結果を紹介します。

1. フィボナッチ数列のベンチマーク

1-1. 計測対象: fib(35)

プログラミング言語の性能比較ではお馴染みの「再帰でフィボナッチ数列を計算する」手法を用いました。それぞれ以下のようなコードで試しています。:

pyfib.py:

def fib(n: int) -> int:
    if n <= 1:
        return n
    return fib(n - 1) + fib(n - 2)

print(fib(35))

jsfib.js:

function fib(n) {
    if (n <= 1) {
        return n;
    }
    return fib(n - 1) + fib(n - 2);
}

console.log(fib(35));

cfib.c:

#include <stdio.h>

int fib(int n) {
    if (n <= 1) {
        return n;
    }
    return fib(n - 1) + fib(n - 2);
}

int main() {
    int result = fib(35);
    printf("%d\n", result);
    return 0;
}

llfib.ll:

@.str = private unnamed_addr constant [4 x i8] c"%d\0A\00", align 1

; Function Attrs: noinline nounwind optnone ssp uwtable(sync)
define i32 @fib(i32 noundef %0) #0 {
  ; 基本ケースの直接比較と返却
  %2 = icmp sle i32 %0, 1
  br i1 %2, label %base, label %recurse

base:
  ret i32 %0

recurse:
  %3 = sub nsw i32 %0, 1
  %4 = call i32 @fib(i32 noundef %3)
  %5 = sub nsw i32 %0, 2
  %6 = call i32 @fib(i32 noundef %5)
  %7 = add nsw i32 %4, %6
  ret i32 %7
}

; Function Attrs: noinline nounwind optnone ssp uwtable(sync)
define i32 @main() #0 {
  %1 = call i32 @fib(i32 noundef 35)
  %2 = call i32 (ptr, ...) @printf(ptr noundef @.str, i32 noundef %1)
  ret i32 0
}

declare i32 @printf(ptr noundef, ...) #1
  • n=35 あたりにすると再帰呼び出しの回数が多くなり、関数コールのオーバーヘッドや実装の違いがはっきり表れやすい
  • 実際のアプリケーションでこれほど単純な再帰を多用することは珍しいものの、言語実装や関数呼び出しの速度を手軽に測る指標としてよく使われる

ただし、あくまで単一のベンチマークであり、「これだけで言語全体の性能を評価するのは不十分」 という点は留意が必要です。IO やクラス構造、例外処理などが絡む複雑なケースではまた違った結果になるでしょう。

1-2. 計測方法

本プロジェクトでは、bench.py というスクリプトを用意しており、以下のような流れで計測を行っています。

  1. 各言語や各コンパイルオプションで fib のコードをビルド
  2. 1度ウォームアップした後で、5回繰り返し実行して平均実行時間を計算
  3. 結果をテーブル形式で比較

計測対象:

  • C (clang) with -O0, -O1, -O2, -O3
  • LLVM IR (clang) with -O0, -O1, -O2, -O3
  • Python(pyc): 本プロジェクトが生成したバイナリ
  • CPython (インタプリタ)
  • Node.js, Deno, Bun などの JavaScript 実行環境
  • Python(no GIL) (あくまで実験的な 3.13+ のオプション)

これらの計測はあくまで「簡易的なもの」であり、測定環境 (CPU、OS、同時プロセス負荷など) によって結果が大きく変わる可能性があります。ここでは一例として、実行結果を共有しています。

2. ベンチマーク結果

2-1. サンプルの結果テーブル

以下は私の環境で実行したときの結果です。

              🚀 Benchmark Results              
┏━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━┓
┃ runtime        ┃ time              ┃ result   ┃
┡━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━┩
│ LLVM(O1)       │ 15.71ms (x0.69)   │ 9227465  │
│ C(O1)          │ 16.37ms (x0.72)   │ 9227465  │
│ LLVM(O3)       │ 17.17ms (x0.76)   │ 9227465  │
│ C(O3)          │ 17.30ms (x0.76)   │ 9227465  │
│ C(O2)          │ 17.88ms (x0.79)   │ 9227465  │
│ LLVM(O2)       │ 20.32ms (x0.90)   │ 9227465  │
│ Python(pyc)    │ 22.68ms (x1.00)   │ 9227465  │ < 基準値
│ LLVM(O0)       │ 23.15ms (x1.02)   │ 9227465  │
│ C(O0)          │ 33.34ms (x1.47)   │ 9227465  │
│ Bun            │ 54.52ms (x2.40)   │ 9227465  │
│ Deno           │ 80.33ms (x3.54)   │ 9227465  │
│ Node.js        │ 94.68ms (x4.17)   │ 9227465  │
│ Python         │ 611.73ms (x26.97) │ 9227465  │
│ Python(no GIL) │ 884.05ms (x38.97) │ 9227465  │
└────────────────┴───────────────────┴──────────┘
  • time: 平均実行時間 (ミリ秒)。
  • (x○.○○): Python(pyc) の結果を 1.00 とした相対倍率
  • result: fib(35) の戻り値 (9227465)

上記のように、ネイティブコンパイル(C/LLVM)で最適化が効いた場合 (-O1, -O2, -O3) は非常に速く、20ms 前後で完了します。一方、インタプリタ型の Python は 600ms 以上かかるなど、数十倍以上の差が生じています。

2-2. Python(pyc) の立ち位置

  • Python(pyc) が約 22.68ms と測定されており、最適化された C や LLVM IR に近い速度が出ているのが興味深い結果です。
  • これは、あくまで「純粋な再帰呼び出しによる小さな数値演算のみ」だからこそ恩恵を得やすいという点に留意が必要です。大掛かりなオブジェクト操作や文字列処理など、動的言語的な要素が絡むと、もっと遅くなる可能性があります。

2-3. Python(no GIL) が遅くなっている?

表を見ると、Python(no GIL) (CPython3.13 以降からの実験的機能であるGIL無効化版) が通常の CPython よりさらに遅いという結果が出ています。これは一見不思議に思えますが、GIL を取り除くための実装がまだ最適化されておらず、マルチスレッドサポートやロック管理のオーバーヘッドなどが増えている 可能性が考えられます。実験的な段階のため、再帰的な関数呼び出しのようにスレッドの恩恵が得られにくい処理だと、かえって処理系の負担が大きくなっているのかもしれません。今後の開発と最適化で、パフォーマンスがどう変わるかは注目ポイントと言えるでしょう。

3. 再帰呼び出しの最適化と -O フラグ

3-1. pyc 自体は特別な最適化をしていない

現在の pyc の実装では、特に再帰を最適化するような特別なパスを設けていません。生成された LLVM IR を clang/llc でコンパイルし、-O2 などの最適化オプションを付けているだけです。
それでも、fib(35) のように処理が単純な再帰コードでは、LLVM の最適化パイプラインが十分に効果を発揮し、高速化が可能になっているわけです。

3-2. -O0 ~ -O3 の違い

表を見てもわかるように、-O0 (最適化なし) と -O3 (最高レベルの最適化) で倍以上の速度差が出ています。これは C や LLVM IR に限らず、多くのコンパイラ・言語で共通の傾向です。

  • -O0: デバッグ向けに最適化を行わないため、遅い
  • -O1 ~ -O3: 段階的に最適化が強くなり、特にループや再帰などの箇所で速度差が顕著になる

pyc が出力した IR でも、最終的には clang/llc によって同様の最適化を施されるため、やはり -O2-O3 と同等レベルで高速です。

4. 比較対象: JavaScript や CPython

4-1. Node.js / Deno / Bun

JavaScript 環境でも同じ fib(35) のコードを測定すると、だいたい 50 ~ 100ms 程度の結果が出るケースが多いです (各ランタイムのエンジンによって差はあります)。これは JIT コンパイラが優秀とはいえ、デフォルト設定だと関数の再帰がそれほど得意ではない可能性があります。

4-2. CPython の遅さ

純粋な CPython インタプリタが 数百ms ~ 1 秒 前後かかるのは、再帰呼び出しごとにオブジェクトチェックなどが行われるためです。これは Python の動的型付けを支える仕組みのコストですが、型を明示してネイティブ化できる pyc との速度差がクローズアップされる結果となりました。

5. WebAssembly の話

5-1. wasm ターゲットでの計測

まだ本格的には測定していませんが、将来的には WebAssembly ターゲットでこの計算を走らせ、JavaScript エンジン上の実行速度を比較する計画があります。

  • fib(35) が wasm でどれくらい動くのか
  • GC やランタイムまわりをどう扱うか

前回触れたように、Boehm GC を wasm 上で利用するには制限や追加作業が必要なため、現時点では対応が不完全です。今後 wasm GC が標準化された際には、pyc -> wasm の可能性がさらに広がるかもしれません。

5-2. wasm で最適化する余地

WebAssembly は LLVM の出力ターゲットの一つとしてサポートされているので、llc -march=wasm32 などを使えば .ll -> .wasm は生成可能です。
ただしネイティブ機能と違って、ブラウザやランタイムの実装次第でパフォーマンス特性が変わるため、さらに詳しい検証が必要になります。単純な数値演算だけであれば意外と速いものの、オブジェクト操作のようなところで差が出るかもしれません。

6. ベンチマークの意義と限界

6-1. フィボナッチ再帰の意味合い

今回の結果を見ると、「ネイティブコンパイルが圧倒的に速い」「インタプリタ言語は遅い」などの印象を持つかもしれません。確かに純粋な再帰呼び出しを大量に行うケースでは、コンパイラ最適化が効果を発揮しやすく、インタプリタはどうしても不利です。
とはいえ、リアルなアプリケーションではもっと複雑なロジックや外部 IO 、ライブラリ呼び出しなどが混在するため、この結果だけ で実用シーンを完全に評価するのは難しいです。ベンチマークはあくまで「ある一面」を測るものと考えましょう。

6-2. 追加ベンチマークの必要性

  • リスト・辞書の操作速度
  • 文字列操作 (連結・切り出し)
  • メモリアロケーションが多いケース
  • マルチスレッド/並列処理 (Python(no GIL) の効果など)

など、今後さらに多面的なベンチマークを整備していく予定です。それによって、pyc がどの程度 Python のいろいろな構文をサポートし、どの程度高速化できているかをより正確に判断できるようになるでしょう。

7. まとめ & 今後の展開

今回のベンチマークでは、再帰的なフィボナッチ計算という単純なテストケースで、言語・実装ごとの速度を比較しました。

  • ネイティブコンパイル (C, LLVM IR) がやはり速い
  • Python(pyc) もインタプリタ Python と比べて大幅に高速化されている
  • JavaScript 系は JIT の影響か、そこそこ速いがネイティブよりは遅め
  • 最適化 (-O1, -O2, -O3) を使うと速度が上がり、-O0 だと遅くなる

こうした結果は、「Python のコードをある程度静的に扱えるなら、ネイティブバイナリ化するメリットがあるかもしれない」という可能性を示唆しています。
一方で、現実的なアプリケーションはこうした再帰計算に留まらないため、より複雑なケースの検証が必要です。

今後の展開

  • WebAssembly 対応: Boehm GC と合わせてブラウザ上でどこまで高速化できるか実験
  • より多彩なベンチマーク: リスト操作、辞書操作、クラス・メソッド呼び出し、例外処理などを含めて総合的に評価
  • コンパイラ側の最適化: 現在は clang/llc に丸投げだが、将来的には Python 構文特化の最適化パスなどを自前で実装してみる

#8 では、「クラスとオブジェクト指向への対応」 の話題を扱う予定です。Python の大きな特徴であるクラス、継承、メソッド呼び出しをコンパイラでどう扱うかは大きな課題ですが、一部で試行錯誤を始めているので、そのあたりの進捗を共有できればと思います。

次回(番外編):
https://zenn.dev/t3tra/articles/b5e10394e6212a

次回(#8):
https://zenn.dev/t3tra/articles/0a88c8a389fe43

Discussion