LLVM 上の TypeScript

JIT エンジン向けに設計された言語を Perry がどのように LLVM IR へ下げているのか——単相化、NaN-boxing、インラインローワリング ——そしてなぜ Cranelift を離れたのか。

なぜ TypeScript に LLVM なのか?

事前(AOT)コンパイラは JIT とはまったく異なる領域で生きて います。JIT はユーザーが待っている間にコンパイルするため、 コンパイルのレイテンシそのものが制約になります。Perry のよう な AOT コンパイラは、開発者のマシン上や CI 上で一度だけ コンパイルし、そのバイナリはその後何百万回も実行されます。 この非対称性こそ、重量級のオプティマイザが元を取れる場所です。

LLVM は20年分のミドルエンドの成果をもたらします:ループの ベクトル化、ループ不変コードの移動、グローバル値番号付け、 疎な条件付き定数伝播、積極的なインライン化、エイリアス解析。 Perry の仕事は、実際に最適化できる IR をその機構に渡すこと です——ここで TypeScript の型情報が生きてきます。

ローワリングパイプライン

ソースは SWC でパースされたのち、型付きの高レベル IR(HIR) へと下げられます。興味深い決定はすべて、LLVM がコードを目に する前にここで行われます。

  • 単相化。 ジェネリックな関数やクラスは、具体的なインスタンス化ごとに 特殊化されます。Rust や C++ が使うのと同じ戦略です。Stack<number> Stack<string> は2つの独立した完全に型付けされた関数になります——そのため オプティマイザは汎用のディスパッチの塊ではなく具体的な型を 扱うことになり、ジェネリクスは実行時に一切コストがかかり ません。
  • 静的ディスパッチ。 レシーバーの型がコンパイル時に判明している場合、メソッド 呼び出しはハッシュテーブルの参照ではなく、LLVM がインライン 化できる直接呼び出しにコンパイルされます。
  • 直接的なフィールド アクセス。 オブジェクトのフィールドはコンパイル時のインデックスに解決 されるため、プロパティの読み取りは辞書の参照ではなく固定 オフセットのロードになります。

NaN-boxing とインラインローワリング

値が動的な場合、Perry は NaN-boxing を使用します:すべての値 は64ビットのワードです。倍精度浮動小数点数はそのまま格納され、 オブジェクト、文字列、真偽値、nullundefined は IEEE 754 の quiet NaN の未使用ビットパターンにエンコード されます。数値はゼロコストです——ボクシングも、算術のための アロケーションも発生しません。

問題は、数値以外の値に対する操作は unpack-operate-repack の ビット列を必要とすることです。これらの列が別途コンパイルされ たランタイムへの呼び出しとして存在していると、LLVM からは 不透明なブラックボックスに見え、その内側をまたいで最適化する ことができません。そこで Perry は、プロパティ読み取り、 メソッドディスパッチ、オブジェクト割り当てといったホットな 操作を、オプティマイザが融合・単純化できるインラインの LLVM IR として出力します。たとえばオブジェクト割り当ては、インライン のスレッドローカルなバンプアロケーションへとコンパイルされ ます。

LLVM IR — inline bump allocation
%off_ptr = getelementptr i8, ptr %state, i64 8
%offset  = load i64, ptr %off_ptr        ; current bump offset
%new_off = add i64 %offset, 96           ; headers + 8 fields
%sz_ptr  = getelementptr i8, ptr %state, i64 16
%size    = load i64, ptr %sz_ptr         ; block capacity
%fits    = icmp ule i64 %new_off, %size
br i1 %fits, label %fast, label %slow

なぜ Cranelift ではないのか?

Perry の最初のバックエンドは Cranelift でした——wasmtime を 支えるコード生成基盤で、高速かつ予測可能なコンパイルのために 作られています。出発点としては正しい選択であり、JIT やサンド ボックス化されたランタイムにとっては今も優れた選択肢です。 2つの要因が乗り換えを迫りました。

  • オプティマイザの天井。 Cranelift は意図的に高速な単一ティアのコンパイラです: 「まともなコードを素早く」というのは、JIT にとっては正しい トレードオフであり、ネイティブパフォーマンスを売りにする AOT コンパイラにとっては誤ったトレードオフです。
  • arm64_32。 Apple Watch は Cranelift がサポートしていない ABI (64ビット命令、32ビットポインタ)を使用しています。 watchOS をターゲットとして成立させるには LLVM が必要で あり、2つのバックエンドを維持することは2組のバグ、テスト、 パフォーマンス基準を維持することを意味しました。

この移行は無償ではありませんでした。ホットな操作が当初は 不透明なランタイムヘルパー呼び出し経由になっていたため、 最初の LLVM 専用リリースは一部のベンチマークで最大70倍も 後退しました。そこから回復する過程——インラインローワリング、 上記のバンプアロケータ、より良いインライン化の境界——で バックエンドは Cranelift の数値を超え、落ち着いた頃には Perry はスイート内のすべてのベンチマークで Node.js を1.7倍 から24.6倍上回り、2つのタイがありました(2026年4月)。 この後日談は一読の価値があります:From Cranelift to LLVM

さらに詳しく

コンパイラ内部構造のページ では、NaN-boxing、単相化、静的ディスパッチをさらに詳しく解説 しています。ブログでは、Optimizing Everything がリリースごとの最適化作業を追いかけており、Gen GC, lazy JSON, and defensible benchmarks ではベンチマーク手法(RUNS=11、中央値 + p95)について説明して います。全体像をつかむには、TypeScript ネイティブコンパイラ の概要から始めてください。

出力を自分の目で確かめよう

perry compile main.ts ——ネイティブなマシンコード、エンジンは接続されません。