ブログに戻る
compilersllvmcraneliftperformancemilestone

CraneliftからLLVMへ:Perryが24倍高速になるまで

PerryのバックエンドがCraneliftからLLVMへの移行を完了しました。v0.5.12時点でLLVMが唯一のコード生成バックエンドとなり、Perryは15のベンチマーク中14で Node.jsに勝利しています。その差は1.06倍から24.6倍に及びます。

ここに至る道のりは一直線ではありませんでした。v0.5.0での初回切り替えでは、いくつかのベンチマークが置き換え前のCranelift版より70倍遅くなりました。この記事では、何が起こったのか、なぜそれでも切り替えたのか、何が壊れたのか、何が修正したのか、そして最終的な数値がどうなったのかを詳しく説明します。

コンパイラを開発している方、codegenバックエンドを評価している方、あるいは「LLVMに切り替える」がなぜ見かけほど簡単ではないのか気になる方に向けた記事です。

パート1:そもそもなぜ切り替えるのか?

PerryはTypeScriptを直接ネイティブマシンコードにコンパイルします。Node不要、V8不要、Electron不要、WebView不要。「TypeScriptを書いて、ネイティブバイナリを出荷する」という価値提案は、そのバイナリが実際に高速でなければ崩壊します。

Perryの初期のマイナーバージョンでは、codegenバックエンドはCraneliftでした。Craneliftは優れたツールです。wasmtimeのcodegenを支え、SpiderMonkeyのベースラインJITにも使われており、高速で予測可能なコンパイルとクリーンな組み込みが必要な場合の選択肢です。新しい言語を立ち上げるプロジェクトにとって、正しい出発点でした。

しかし、最終的に2つの理由でCraneliftから離れることになりました。

1. オプティマイザの限界

Craneliftは意図的に高速な単一ティアの最適化コンパイラです。その使命は「まともなコードを素早く生成すること」であり、「無制限の時間をかけて最良のコードを生成すること」ではありません。JITにとっては正しいトレードオフです。しかし、ネイティブパフォーマンスを最大の売りにするAOTコンパイラにとっては不適切なトレードオフです。

LLVMには20年以上の開発が注ぎ込まれています。ループベクトル化、LICM、GVN、SCCP、命令結合、インライン化ヒューリスティクス、fast-mathの再結合、エイリアス解析など。小さなプロジェクトが追いつける現実的な世界はありません。Perryが「Nodeより速い」と主張するなら、その機構が必要です。

2. arm64_32の問題

直接的なきっかけはApple Watchでした。arm64_32はAppleがSeries 4以降で導入したABIで、64ビット命令と32ビットポインタを持ちます。Craneliftはこれをサポートしておらず、サポートが実現する現実的な見込みもありませんでした。Perryが「1つのコードベースから9プラットフォーム」を正当に主張するには、watchOSを欠くことはできません。LLVMはarm64_32を標準でサポートしています。

一部のターゲットにLLVMが必要であることを受け入れた時点で、2つのバックエンドを維持することは持続不可能になりました。2つのバックエンドは2セットのバグ、2セットの最適化パス、2つのテストマトリクス、2つのパフォーマンスベースラインを意味します。正直な答えは「1つを選ぶ」でした。

LLVMを選びました。

パート2:Craneliftについてひとこと

先に進む前に、この記事はCraneliftを批判するものではありません。Craneliftは見事なエンジニアリングの成果であり、JIT、サンドボックスランタイム、あるいはコンパイルレイテンシがピークスループットより重要なものを構築しているなら、候補リストの上位に入れるべきです。wasmtimeが採用しているのには理由があります。Bytecode Allianceは模範的な仕事をしています。

Perryのニーズが異なるだけです。私たちはAOTでコンパイルし、バイナリを一度出荷し、ユーザーはそれを何百万回も実行します。この非対称性――コンパイルはまれに、実行は常に――こそがLLVMの重いオプティマイザが元を取る領域です。異なる仕事に異なるツールということです。

パート3:切り替えの災難

v0.5.0はLLVMを唯一のバックエンドとした最初のリリースでした。コンパイル時間はわずかに増加し、ランタイムパフォーマンスは大幅に改善されると予想していました。後者については正反対の結果になりました。

当時は公開したくなかった表がこちらです:

BenchmarkCraneliftLLVM v0.5.0Delta
method_calls16ms1,084ms68x slower
object_create5ms318ms64x slower
matrix_multiply61ms184ms3x slower
math_intensive370ms131ms2.8x faster
nested_loops32ms57ms1.8x slower
fibonacci(40)505ms1,156ms2.3x slower

一部のワークロードは高速化しました。しかし大半は劇的に悪化しました。method_callsは、一般的なTypeScriptのクラス使用を表すため最も重要なベンチマークの1つですが、2リリース前に出荷したものより約70倍悪化していました。

実際に何が問題だったのか

Perryは値表現にNaN-boxingを使用しています。すべてのTypeScriptの値は64ビットワードです。f64の数値はそのまま格納され、それ以外(オブジェクト、文字列、ブーリアン、undefined、null)はIEEE 754のquiet NaNの未使用ビットにエンコードされます。

利点:数値はゼロコストです。ボクシングなし、タグ付けなし、算術でのアロケーションなし。

欠点:数値以外の値に対する操作はすべて、アンパック、操作、リパックのためのビット操作が必要です。これらのシーケンスがcodegenのインラインIRとして存在すれば、オプティマイザはそれらを融合・簡素化できます。しかし、ランタイムヘルパー関数への呼び出しとして存在すると、オプティマイザは不透明な呼び出しとして扱い、最適化を諦めます。

Craneliftバックエンドはホットなオペレーションのためのインラインローワリングを多数持っていました。プロパティのロード、メソッドディスパッチ、オブジェクト割り当て、f64タグ付き値の整数演算などです。LLVM切り替え時には、まず正しいコードを出力することを優先し、これらのほぼすべてをperry-runtimeのランタイムヘルパー経由としました。各ヘルパーはLLVM IRにおけるcall命令になります。

LLVMは優秀ですが、本体を見たことのない関数をインライン化することはできません。perry-runtimeは別途コンパイルされ、最後にリンクされるため、オプティマイザの視点からはすべてのヘルパー呼び出しがブラックボックスです。その結果、Craneliftバックエンドが約5命令のインライン算術にコンパイルしていたホットループが、関数呼び出し――レジスタ退避、スタックフレームセットアップなど――にコンパイルされ、それが数百万回繰り返されることになりました。

70倍の原因はそこにあります。悪いcodegenではなく、悪いインライン化境界です。

パート4:修正

Craneliftの数値を回復し、それを超えるための作業は、大まかに6つのカテゴリに分かれます。どれも特殊なものではありません。ほとんどは教科書的なコンパイラ最適化を適切な場所に適用しただけです。

1. オブジェクト割り当て用のインラインバンプアロケータ

object_createmethod_callsに次いで最大の後退でした。従来のパスはすべてのnew Point()に対してjs_object_alloc_class_with_keysを呼び出していました。関数呼び出し、スレッドローカルなアリーナアクセス、シェイプキャッシュの検索、GCヘッダとオブジェクトヘッダの書き込みが含まれます。

修正:バンプアロケーションをLLVM IRのインラインとして出力します。オブジェクトを割り当てる各関数は、スレッドローカルのInlineArenaState構造体へのキャッシュ済みポインタを取得します。アロケーションは以下のようになります:

; state is a ptr to InlineArenaState { data: ptr, offset: i64, size: i64 }
%off_ptr = getelementptr i8, ptr %state, i64 8
%offset  = load i64, ptr %off_ptr           ; current bump offset
%new_off = add i64 %offset, 96              ; GcHeader(8) + ObjectHeader(24) + 8 fields(64)
%sz_ptr  = getelementptr i8, ptr %state, i64 16
%size    = load i64, ptr %sz_ptr            ; current block capacity
%fits    = icmp ule i64 %new_off, %size
br i1 %fits, label %fast, label %slow
fast:
  store i64 %new_off, ptr %off_ptr          ; bump the offset
  %data = load ptr, ptr %state              ; data pointer at offset 0
  %raw  = getelementptr i8, ptr %data, i64 %offset
  store i64 <packed_gc_header>, ptr %raw    ; GcHeader as one i64
slow:
  call ptr @js_inline_arena_slow_alloc(ptr %state, i64 96, i64 8)

ファストパスはLLVMが見て、スケジューリングし、ループ外にホイストできる約13命令のインラインIRです。object_createは318msから9msになりました。

2. i32ループカウンタ

NaN-boxingにより、すべてのTypeScriptの数値はf64です。ループカウンタも含まれます。f64の誘導変数を持つfor (let i = 0; i < 100_000_000; i++)ループは大惨事です。f64のインクリメント、f64の比較、配列インデックス時のf64からi64への変換が毎回発生します。

codegenは、誘導変数が整数値であることが証明可能なforループを検出し、並列i32スタックスロットを割り当てます。ループ条件がfcmpからicmp slt i32に切り替わり、f64カウンタが完全に排除されます。

これによりarray_writeは11msから3msに、nested_loopsは18msから9msに、array_readは11msから4msになりました。

3. fast-mathフラグ

すべてのf64算術命令にreassoc contractフラグを付与しています。reassocはLLVMがシリアルなアキュムレータチェーンを並列に分割することを許可し、contractは積和演算の融合を許可します。PerryはNaNビットを値のタグとして使用するため、nnanninfはオフにしています。

これらのフラグにより、LLVMのループベクトライザがmath_intensiveで発動し、131msから14msに低下。Nodeの3.5倍の速さを達成しました。

4. 整数剰余演算のファストパス

JavaScriptにおけるf64の%fmodであり、ARMではlibm呼び出しです。しかし、整数値のf64オペランドに対しては、fptosi → srem → sitofpで完全にlibmのラウンドトリップをスキップできます。codegenは静的解析で整数値オペランドを検出します。ランタイムチェックは不要です。

これがfactorialが1,553msから24msになった理由であり、Nodeの591msに対する24ms、Node.jsの24.6倍高速を実現した理由です。

5. ネストされたループのLICM

LLVMはloop-invariant code motionを標準で行いますが、NaN-boxingが構造を隠してしまいます。arr.lengthはNaN-boxedポインタを通じたタグチェック付きのロードに展開され、明らかにループ不変とは見なされません。

codegenはfor (...; i < arr.length; ...)パターンを検出し、ループの前に長さをスタックスロットにプリロードします。静的ウォーカーがループ本体で配列の長さが変更されないことを検証します。カウンタがこのホイストされた長さで制約されている場合、IndexGet/IndexSetは境界チェックを完全にスキップします。

6. シェイプキャッシュされたオブジェクト

codegenがオブジェクトのクラスを知っている場合、コンパイル時にフィールドオフセットを解決し、直接インデックスロードを出力します。ランタイムディスパッチは不要です。メソッドディスパッチでは、obj.method(args)が直接のcall @perry_method_Class_name(this, args)になります。vtableなし、インラインキャッシュなし、ハッシュルックアップなし。

LLVMへの切り替えでこれが汎用スローパスに後退していました。静的ディスパッチの復元により、method_callsが1,084msから1msに回復。Node.jsの11倍高速を達成しました。

パート5:現在の数値

3回実行の中央値、macOS ARM64(Apple Silicon、M1 Max)、Node.js v25:

BenchmarkPerryNode.jsvs Node
factorial24ms591ms24.6x
method_calls1ms11ms11x
loop_overhead12ms53ms4.4x
math_intensive14ms49ms3.5x
array_read4ms13ms3.2x
closure97ms303ms3.1x
array_write3ms8ms2.6x
string_concat1ms2ms2x
nested_loops9ms16ms1.7x
prime_sieve4ms7ms1.7x
matrix_multiply21ms34ms1.6x
fibonacci(40)932ms991ms1.06x
binary_trees9ms9mstied
mandelbrot24ms24mstied
object_create9ms8ms0.9x

15戦14勝。唯一の敗北はobject_createで、V8のアロケータが本当に優秀であり、差は12%以内です。

パート6:コンパイル時間の問題

人々がLLVMよりCraneliftを選ぶ最大の理由はコンパイル速度です。では、それについて話しましょう。

LLVMによりPerryのファイルあたりのコンパイル時間は20-50ms、おおよそ8-19%増加しました。5倍ではありません。2倍でもありません。一桁から低い二桁のパーセントです。

理由は、codegenがPerryのパイプラインにおけるボトルネックではないからです。典型的なファイルの内訳は以下の通りです:

  • SWCパース:約30%
  • HIRローワリング(AST → IR、型推論):約25%
  • IR変換パス(クロージャ変換、async lowering、インライン化):約15%
  • Codegen(LLVM IRテキスト出力 + clang -c -O3):約20%
  • リンク(cc + ランタイムライブラリ):約10%

Codegenは5つのうちの1つのスライスです。そのスライスを倍にしても全体は5-10%しか増えません。ユーザーがperry compileを一度入力し、バイナリを永遠に実行するAOTコンパイラを構築しているなら、計算は明白です。コンパイル時間に25ms多く費やし、毎回の実行で最大24倍の高速化を得る。

パート7:今やり直すならどうするか

もし今日Perryを始めて、いきなりLLVMに行けるとしても、そうはしないでしょう。Craneliftフェーズは本当に価値がありました。LLVMの複雑さの税なしにフロントエンドの反復を可能にし、比較対象となる動作するベースラインを与えてくれ、HIRをバックエンド間で移植できるほどクリーンに保つことを強制しました。

やり直すとすれば、切り替え自体です。v0.5.0ではほとんどのオペレーションをランタイムヘルパー呼び出し経由にして、後でインライン化する予定でした。それは間違いでした。正しい順序は、まずホットパスを特定し、切り替え前にインライン化し、LLVMバックエンドが少なくとも同等になってからリリースすることでした。

教訓は退屈なものです。最適化境界はオプティマイザの品質より重要です。LLVMは驚異的なソフトウェアですが、見えないコードに対しては何もできません。codegenがすべてを不透明なランタイム呼び出しに経由させるなら、ソースプログラムと存在するすべての最適化パスの間に壁を作ったことになります。

まとめ

PerryはLLVM専用となり、15のベンチマーク中14でNode.jsより高速で、出荷を続けています。移行は予定より長くかかり、途中で予想以上の痛みを伴いましたが、振り返れば間違いなく正しい判断でした。Craneliftがv0.5まで導いてくれました。LLVMがその先を引き継ぎます。

Perryを試してみたい方へ:

brew install perryts/perry/perry
perry init my-app && cd my-app
perry compile src/main.ts -o my-app && ./my-app

ソース:github.com/PerryTS/perry -- ドキュメント:docs.perryts.com -- ベンチマークを自分で実行:cd benchmarks/suite && ./run_benchmarks.sh

質問やバグ報告、codegenバックエンドについて議論したい場合は、GitHub issueが開かれています。すべて読んでいます。

-- Ralph