高速な動的言語インタプリタを作る方法
(zef-lang.dev)- ASTを直接走査するインタプリタでも、値表現、インラインキャッシュ、オブジェクトモデル、watchpoint、反復的な細部最適化だけで大きな性能向上が可能
- 性能をほとんど考慮していない Zef ベースラインは、CPython 3.10 より35倍、Lua 5.4.7 より80倍、QuickJS-ng 0.14.0 より23倍遅かったが、21段階の最適化の後に 16.646倍の高速化 を達成
- 最大の飛躍は オブジェクトモデルの再設計とインラインキャッシュの組み合わせ で生まれ、Storage と Offsets ベースのアクセス、キャッシュされた AST 特化、名前オーバーライド監視用 watchpoint の適用により、さらに 4.55 倍改善
- 追加改善として、文字列ベースのディスパッチ除去、Symbol 導入、引数受け渡し構造の変更、getter と setter の特化、ハッシュテーブル短縮経路、配列リテラルと
sqrt・toStringの特化まで累積適用 - Yolo-C++ 移植まで含めると ベースライン比で66.962倍高速 を記録し、CPython 3.10 より 1.889 倍高速、QuickJS-ng 0.14.0 より 2.968 倍高速だが、メモリ解放がないため長時間実行ワークロードには不向き
導入と評価方法論
- 最適化対象は ASTを直接走査するインタプリタ であり、遊びで作った動的言語 Zef を Lua、QuickJS、CPython と競争可能な水準 まで引き上げることが目標
- JIT コンパイラや成熟した GC の微調整よりも、基盤のない出発点でも適用できる最適化に集中
- 扱う手法は 値表現、インラインキャッシュ、オブジェクトモデル、watchpoint、常識的な最適化の反復適用
- 本文の手法だけで SSA、GC、バイトコード、マシンコードなしでも 大きな性能向上を達成
- 本文基準で 16倍高速化
- 未完成の Yolo-C++ 移植 を含めると 67倍高速化
- 性能評価には ScriptBench1 ベンチマークスイートを使用
- 含まれるベンチマークは Richards OS スケジューラ、DeltaBlue 制約ソルバ、N-Body 物理シミュレーション、Splay 二分木テスト
- JavaScript、Python、Lua 向けの既存ポートを使用
- Splay の Python および Lua ポートは Claude で生成
- 実験環境は Ubuntu 22.04.5、Intel Core Ultra 5 135U、32GB RAM、Fil-C++ 0.677
- Lua 5.4.7 は GCC 11.4.0 でコンパイル
- QuickJS-ng 0.14.0 は GitHub releases のバイナリを使用
- CPython 3.10 は Ubuntu 標準提供版を使用
- すべての実験は ランダムにシャッフルした30回実行の平均値 を使用
- ほとんどの比較は Fil-C++ でコンパイルした Zef インタプリタ と Yolo-C コンパイラでビルドした他のインタプリタ群 の間で実施
元の Zef インタプリタ
- 性能をほとんど考慮せずに書かれており、性能を意識した選択は2つだけだったと明記
-
値表現
- 64ビット tagged value を使用
- 格納できる値は double、32ビット整数、
Object*
- 格納できる値は double、32ビット整数、
- double は
0x1000000000000オフセット 方式で表現- JavaScriptCore から学んだ手法として紹介
- 文献では NuN tagging と呼ばれる
- 整数とポインタはネイティブ表現を使用
- ポインタ値が
0x100000000より小さくない という仮定に依存 - 危険な選択だと明言
- 代替として、整数に
0xffff000000000000の上位ビットタグを付ける案があったと言及
- ポインタ値が
- この表現により、数値演算で ビットテストベースの高速経路 を実装可能
- より重要な利点は、数値に対する ヒープ割り当ての回避
- 新しくインタプリタを作るときは、基本の値表現を初期段階で適切に選ぶこと が重要で、その後の変更は非常に難しい
- 動的型付き言語実装の出発点として 32ビットまたは64ビット tagged value を提示
- 64ビット tagged value を使用
-
実装言語の選択
- 最適化を十分に盛り込める言語として C++ 系 を選択
- Java は低レベル最適化の上限があるため選ばないと明記
- Rust は GC 言語実装に必要な グローバルな可変状態 と 循環参照 を持つヒープ表現のため選ばないと説明
- 多言語構成を受け入れるか大量の
unsafeコードを許容するなら、一部または全部で Rust を使う可能性には言及
- 多言語構成を受け入れるか大量の
-
性能エンジニアリング観点での誤った選択
- Fil-C++ の使用
- 素早く開発でき、GC をただで提供
- メモリ安全性違反を診断情報とスタックトレース付きで報告
- 未定義動作がない
- 性能コストは通常 約4倍
- 再帰的 AST ウォーキングインタプリタ
- 複数箇所でオーバーライドされる virtual
Node::evaluateメソッド構造
- 複数箇所でオーバーライドされる virtual
- 文字列の乱用
GetAST ノードが変数名を表すstd::stringを保持- 変数アクセスのたびにその文字列を使用
- ハッシュテーブルの乱用
Get実行時に文字列キーでstd::unordered_mapを検索
- 再帰呼び出しチェーンに基づくスコープ探索
- ほぼあらゆる構造のネストとクロージャを許可
- 関数 F の中のクラス A、クラス B の中の関数 G のようなネストでは、A のメソッドは A のフィールド、F のローカル変数、B のフィールド、G のローカル変数 を見られる
- 元の実装では、これを異なるスコープオブジェクトへ問い合わせる C++ の再帰関数 群で処理
- Fil-C++ の使用
-
元の実装の特性
- 誤った選択にもかかわらず、少ないコードで かなり複雑な言語インタプリタ を実装可能
- 最大のモジュールは parser
- それ以外は単純で明快なほう
-
初期性能
- 元のインタプリタは CPython 3.10 より35倍遅い
-
Lua 5.4.7 より80倍遅い
- QuickJS-ng 0.14.0 より23倍遅い
全体の最適化進行表
- 表は Zef Baseline から Zef Change #21: No Asserts、そして Zef in Yolo-C++ までの性能変化を整理
- 比較列は vs Zef Baseline、vs Python 3.10、vs Lua 5.4.7、vs QuickJS-ng 0.14.0
- 最終行基準で Zef Change #21: No Asserts はベースライン比で 16.646倍高速
-
Python 3.10 より 2.13 倍遅い
-
Lua 5.4.7 より 4.781 倍遅い
- QuickJS-ng 0.14.0 より 1.355 倍遅い
-
-
Zef in Yolo-C++** はベースライン比で**66.962倍高速
-
Python 3.10 より 1.889 倍高速
-
Lua 5.4.7 より 1.189 倍遅い
- QuickJS-ng 0.14.0 より 2.968 倍高速
-
初期最適化段階
-
最適化 #1: 演算子の直接呼び出し
- パーサーはもはや演算子を 演算子名を持つ
DotCallノード として作らず、各演算子ごとの 個別の AST ノード を生成 - Zef では
a + bとa.add(b)は同一- もともとは
a + bをDotCall(a, "add")と引数bとしてパース - すべての算術演算ごとに 演算子メソッド名の文字列参照 が発生
DotCallは文字列をValue::callMethodに渡すValue::callMethodは 複数回の文字列比較 を実行
- もともとは
- 変更後、パーサーは
Binary<>、Unary<>ノードを生成- テンプレートとラムダを活用し、演算子ごとに 異なる
Node::evaluateオーバーライド を提供 - 各ノードはその演算子に対応する
Valueの高速パス を直接呼び出す - 例として
a + bはBinary<add 用ラムダ>::evaluateを呼び出した後、Value::addを呼び出す
- テンプレートとラムダを活用し、演算子ごとに 異なる
- 性能効果は 17.5% 向上
- この時点の性能は CPython 3.10 より 30 倍遅い
- Lua 5.4.7 より 67 倍遅い
- QuickJS-ng 0.14.0 より 19 倍遅い
- パーサーはもはや演算子を 演算子名を持つ
-
最適化 #2: RMW 演算子の直接呼び出し
- 通常の演算子は高速化されたが、
a += bのような RMW 形式は依然として文字列ベースのディスパッチを使用 - パーサーが各 RMW ケース用の 個別ノード を生成するよう変更
- パーサーは LValue ノードが
makeRMW仮想呼び出しを通じて自身を RMW に置き換える よう要求 - RMW に変わる LValue は Get、Dot、Subscript
- Get は変数読み取り
idに対応 - Dot は
expr.idに対応 - Subscript は
expr[index]に対応
- Get は変数読み取り
- 各仮想呼び出しは
SPECIALIZE_NEW_RMWマクロを使用- SetRMW は
id += value - DotSetRMW は
expr.id += value - SubscriptRMW は
expr[index] += value
- SetRMW は
- 変更 #1 の演算子特殊化はラムダディスパッチを使用
- RMW は enum を使用
get、dot、subscriptの 3 経路をすべて処理し、enum を複数箇所に渡す必要があるためこの方式を選択- 最終的に
Value::callRMW<>テンプレート関数 が実際の RMW 演算子呼び出しディスパッチを実行
- 性能効果は 3.7% 向上
- この時点の性能は CPython 3.10 より 29 倍遅い
- Lua 5.4.7 より 65 倍遅い
- QuickJS-ng 0.14.0 より 18.5 倍遅い
- 開始時点比で 1.22 倍高速
- 通常の演算子は高速化されたが、
-
最適化 #3: IntObject チェックの回避
Valueの高速パスがisInt()を使い、その内部のisIntSlow()がObject::isInt()仮想呼び出し を行う点がボトルネック- もとの値表現には 4 つのケースが存在
- tagged int32
- tagged double
- int32 で表現できない int64 用の IntObject
- それ以外のすべてのオブジェクト
- IntObject の場合でも整数メソッドのディスパッチは
Valueが担当- すべての算術演算の実装を 1 か所、つまり
Valueに置くため
- すべての算術演算の実装を 1 か所、つまり
- 最適化後、
Valueの高速パスは int32 と double だけを考慮- IntObject 処理ロジックは
IntObject自身に移動 - メソッドディスパッチのたびに発生していた
isInt()呼び出しを回避
- IntObject 処理ロジックは
- 性能効果は 1% 向上
- この時点の性能は CPython 3.10 より 29 倍遅い
- Lua 5.4.7 より 65 倍遅い
- QuickJS-ng 0.14.0 より 18 倍遅い
- 開始時点比で 1.23 倍高速
-
最適化 #4: Symbol
- もとのインタプリタは
std::stringをほぼあらゆる場所で使用 - コストの高い文字列使用箇所は
Context::get,Context::set,Context::callFunction、Value::callMethod,Value::dot,Value::setDot、Value::callOperator<>、Object::callMethod系 - この構造では単純なハッシュテーブル参照ではなく、文字列キーのハッシュテーブル参照 になるため、実行中に 文字列ハッシュ化と比較が繰り返される
- 最適化では文字列ベースの参照を hash-consed な
Symbolオブジェクトポインタ に置き換え - 新しい
Symbolクラス を追加symbol.h,symbol.cppに実装- Symbol と文字列は相互変換可能
- 文字列を Symbol に変換する際、グローバルハッシュテーブル で hash consing を実行
- 結果として
Symbol*ポインタの同一性比較 だけで同じシンボルかどうかを判定
- 文字列リテラルの代わりに 事前に用意されたシンボル を使用
- 例として
"subscript"の代わりにSymbol::subscript
- 例として
- 多くの関数シグネチャが
const std::string&の代わりにSymbol*を使うよう変更 - 性能効果は 18% 向上
- この時点の性能は CPython 3.10 より 24 倍遅い
- Lua 5.4.7 より 54 倍遅い
- QuickJS-ng 0.14.0 より 15 倍遅い
- 開始時点比で 1.46 倍高速
- もとのインタプリタは
-
最適化 #5: Value のインライン化
- 核心は重要な関数群の インライン化を可能にすること
- ほぼすべての変更の中心は新しいヘッダー
valueinlines.hの導入 value.hと別ヘッダーに分けた理由は、value.hをインクルードしなければならないヘッダー群 を利用するため- 性能効果は 2.8% 向上
- この時点の性能は CPython 3.10 より 24 倍遅い
- Lua 5.4.7 より 53 倍遅い
- QuickJS-ng 0.14.0 より 15 倍遅い
- 開始時点比で 1.5 倍高速
オブジェクトモデルとキャッシュ構造の再設計
-
最適化 #6: オブジェクトモデル、インラインキャッシュ、Watchpoint
Object、ClassObject、Contextの動作方式を大規模に再構成し、オブジェクト割り当てコストを下げ、アクセス時のハッシュテーブル参照を回避- この変更は オブジェクトモデル、インラインキャッシュ、watchpoint の3機能を組み合わせたもの
-
オブジェクトモデル
- 以前は各レキシカルスコープごとに
Contextオブジェクトを割り当てていた- 各
Contextは、そのスコープの変数を格納するハッシュテーブルを保持
- 各
- オブジェクトはさらに複雑な構造だった
- 各オブジェクトが、自身がインスタンスであるクラス群を
Contextに対応付けるハッシュテーブルを保持
- 各オブジェクトが、自身がインスタンスであるクラス群を
- この構造が必要だった理由は継承とネストしたスコープにある
BarがFooを継承するとき、BarとFooがそれぞれ異なるスコープをクロージャとして捕捉する- 同名でも異なる private フィールドを持てる
- 新しい構造では
Storageの概念を導入- データは Offsets に従って格納される
- offset はどの
Contextかによって決まる
Contextは今も存在するが、オブジェクトやスコープ生成時ではなく、AST のresolveパスで事前に生成される- 実際のオブジェクトやスコープ生成時には、その
Contextが計算したサイズに合わせて Storage だけを割り当てる
- 以前は各レキシカルスコープごとに
-
インラインキャッシュ
expr.nameのようなコード位置で、最後に見たexprの動的型と、nameが解決された直前の offsetを記憶する手法- JIT の文脈で説明されることが多い古典的手法だが、ここではインタプリタに適用している
- 記憶した情報は、通常の AST ノード上に特殊化された AST ノードを placement construct する形で実装
-
インラインキャッシュの構成要素
CacheRecipe- 特定のアクセスが何を行ったか、キャッシュ可能かどうかを追跡
Context、ClassObject、Packageの各所にCacheRecipe呼び出しを挿入- アクセス過程の情報を収集
Dot::evaluateのような AST 評価関数は、自身が実行した多相演算から得たCacheRecipeをthisとともにconstructCache<>に渡すconstructCacheCacheRecipeに応じて新しい AST ノード特殊化をコンパイル- テンプレート機構で多様な特殊化 AST ノードを生成
- ローカル変数アクセスであれば、渡された
storageに対する直接ロード - 最後に見たクラスと同一か確認する class check
- その後、最後に見た関数への直接関数呼び出し
- 必要に応じて chain step と watchpoint を組み合わせる
- キャッシュ対象の AST ノードはそれぞれ cached variant を保持
- まず
cacheオブジェクトで高速呼び出しを試みる cacheオブジェクトの型はconstructCache<>が決定する
- まず
-
watchpoint
- レキシカルスコープに変数
xがあり、その中にクラスFooがあり、Fooのメソッドがxにアクセスする例を示す Foo内部にxという関数や変数がなければ、すぐ外側のxを読めるように見える- しかしサブクラスが getter
xを追加できる - その場合、アクセス結果は外側の
xではなく getter でなければならない - インラインキャッシュはこの変更可能性を処理するため、実行時に
Watchpointを設定する - この例では、この名前がオーバーライドされたかどうかを監視する watchpoint を使う
- レキシカルスコープに変数
-
3機能を同時に実装した理由
- 新しいオブジェクトモデルだけでは、インラインキャッシュがうまく機能しない限り意味のある改善は難しい
- インラインキャッシュも watchpoint がなければ、多くのキャッシュ条件を安全に扱いにくく、実利が小さい
- 新しいオブジェクトモデルと watchpoint は一緒にうまく動作する必要がある
-
実装の進行と難所
- 開始時点では、単純な
CacheRecipeバージョンの作成と、最終形に近い Storage、Offsets の設計から進めた - 最も難しい作業の1つは、intrinsic class の実装方式の置き換えだった
- 配列の例
- 以前は
ArrayObject::tryCallMethodがObject::tryCallMethodの仮想呼び出しを横取りする方式で、すべてのメソッドを実装していた - 新しいオブジェクトモデルでは
Objectに vtable も仮想メソッドもない - 代わりに
Object::tryCallMethodはobject->classObject()->tryCallMethod(object, ...)に委譲する - したがって
Arrayメソッドを提供するには、そのメソッドを持つ Array 用クラス自体を生成する必要がある
- 以前は
- 結果として intrinsic 機能のかなりの部分が、実装全体に散らばっていた構造から
makerootcontext.cpp中心へと移動した - これを好ましい結果と評価した理由は、オブジェクトの native/intrinsic 関数にもインラインキャッシュがそのまま適用されるため
- 性能効果は 4.55倍向上
- この時点の性能は CPython 3.10 より 5.2 倍遅い
- Lua 5.4.7 より 11.7 倍遅い
- QuickJS-ng 0.14.0 より 3.3 倍遅い
- 開始時点比で 6.8 倍高速
- Fil-C++ の損失幅は、他のインタプリタと比べて概ね Fil-C のコスト水準まで縮小したと評価している
- 開始時点では、単純な
呼び出しとアクセス経路の最適化
-
最適化 #7: 引数受け渡し構造の改善
- 変更前の Zef インタプリタは関数引数を
const std::optional<std::vector<Value>>&で受け渡していた optionalが必要だった理由は、一部のコーナーケースで次の 2 つを区別する必要があったためo.gettero.function()
- Zef ではたいてい両方とも関数呼び出しだが、例外として次のコードが存在する
o.NestedClasso.NestedClass()
- 1 つ目は
NestedClassオブジェクトそのもの を返す - 2 つ目は インスタンスを生成 する
- したがって 引数なしの関数呼び出し と getter 系呼び出しとして引数配列が空である場合 を区別しなければならない
- しかし既存の構造は非効率だった
- 呼び出し側が
vectorを割り当て - 呼び出される側がそのベクタをコピーした arguments scope を再度割り当てる
- 呼び出し側が
- 変更点は
Arguments型 の導入- 形は呼び出される側が作っていた arguments scope と完全に同一
- これで呼び出し側が直接その形で割り当てるようになった
- Yolo-C++ でも vector backing store の
mallocを除去 して割り当て回数を削減 - Fil-C++ では
std::optional自体がヒープ割り当てstd::optionalがなくてもconst std::vector<>&の受け渡しもやはり割り当て が発生- スタック割り当てされるものはヒープ割り当てされると明記
- 呼び出し側がベクタを事前にサイズ指定していないため 複数回再割り当て していた点にも言及
- 変更のかなりの部分は関数シグネチャを
Arguments*に置き換える作業 - 性能効果は 1.33 倍向上
- この時点の性能は CPython 3.10 より 3.9 倍遅い
- Lua 5.4.7 より 8.8 倍遅い
- QuickJS-ng 0.14.0 より 2.5 倍遅い
- 開始時点比で 9.05 倍高速
- 変更前の Zef インタプリタは関数引数を
-
最適化 #8: Getter の特殊化
- Zef は Ruby に似て インスタンスフィールドがデフォルトで private
- 例
class Foo { my f fn (inF) f = inF }- コンストラクタで受け取った値を インスタンスにだけ見えるローカル変数
fに保存する
- コンストラクタで受け取った値を インスタンスにだけ見えるローカル変数
- 同じ型のインスタンスでも他のオブジェクトの
fにはアクセスできない- 例
fn nope(o) o.f println(Foo(42).nope(Foo(666)))nope内のo.fはoのfにアクセスできない
- 例
- 理由は、フィールドが クラスメンバーのスコープチェーンに現れる方式 で動作するため
o.fはフィールド読み取りではなくfという名前のメソッド呼び出し要求
- そのため次のパターンがよく現れる
my ffn f f- つまり ローカル変数
fを返す、名前がfのメソッド
- より短い構文として
readable fmy fとfn f fの短縮形
- 多くのメソッド呼び出しは実質的に getter 呼び出し
- すべての getter が AST を評価して動作するのは無駄
- 最適化は getter の特殊化
- 中心は
UserFunction - 新しい
Node::inferGetterメソッドで、関数本体が単純な getter かどうかを推論
- 中心は
- 推論ルール
Block::inferGetterは、自身が含むものがすべて getter と推論可能なら getter と推論Get::inferGetterは自分自身を getter と推論し、読み込む offset を返すContext::tryGetFieldOffsetsは、getter が実行されるレキシカルスコープにそのフィールドが 確実に存在する場合にのみ、空でないOffsetsを返すUserFunctionは関数本体が getter と推論可能なら、既知の offset から直接読み取るだけの 特殊なFunctionサブクラス として resolve される
- 性能効果は 5.6% 向上
- この時点の性能は CPython 3.10 より 3.7 倍遅い
- Lua 5.4.7 より 8.3 倍遅い
- QuickJS-ng 0.14.0 より 2.4 倍遅い
- 開始時点比で 9.55 倍高速
-
最適化 #9: Setter の特殊化
- setter 推論では
fn set_fieldName(newValue) fieldName = newValueのパターンマッチが必要 UserFunctionの推論段階で setter のパラメータ名 を渡す必要があるSetの推論段階では ClassObject への書き込みではないこと を確認し、setter パラメータが set のソースとして使われているかも合わせて確認する必要がある- 性能効果は 3.4% 向上
- この時点基準で Zef は CPython 3.10 より 3.6 倍遅い
- Lua 5.4.7 より 8 倍遅い
- QuickJS-ng 0.14.0 より 2.3 倍遅い
- 開始時点比で 9.87 倍高速
- setter 推論では
-
最適化 #10:
callMethodのインライン化- 重要な関数を 1 行の変更 でインライン化
- 性能効果は 3.2% 向上
- この時点基準で Zef は CPython 3.10 より 3.5 倍遅い
- Lua 5.4.7 より 7.8 倍遅い
- QuickJS-ng 0.14.0 より 2.2 倍遅い
- 開始時点比で 10.2 倍高速
-
最適化 #11: ハッシュテーブル
- メソッド呼び出しの inline cache ミス が発生すると
ClassObject::tryCallMethodとClassObject::TryCallMethodDirectをたどる必要があり、どちらの経路も大きく複雑だった - 既存の探索コストは 階層の深さに比例する O(hierarchy depth)
- 階層の各クラスごとに、その呼び出しが メンバ関数として解釈されるか を確認するハッシュテーブル検索を行う
- 階層の各クラスごとに、その呼び出しが ネストしたクラスとして解釈されるか を確認するハッシュテーブル検索も行う
- 新しい変更で receiver class と symbol をキーに使うグローバルハッシュテーブルを導入
- 1 回の検索で callee を直接返す
classobject.hでは、全体のtryCallMethodSlowに落ちる前にまずこのグローバルテーブルを検索classobject.cppでは、成功した検索結果をグローバルテーブルに記録- グローバルハッシュテーブル自体は比較的単純な実装
- 性能効果は 15% 向上
- この時点基準で Zef は CPython 3.10 より 3 倍遅い
- Lua 5.4.7 より 6.8 倍遅い
- QuickJS-ng 0.14.0 より 1.9 倍遅い
- 開始時点比で 11.8 倍高速
- メソッド呼び出しの inline cache ミス が発生すると
-
最適化 #12:
std::optionalの回避- Fil-C++ では union 関連のコンパイラ病理現象 のため
std::optionalにヒープ割り当てが必要 - 一般に LLVM は union メモリアクセスの型を緩く扱うが、これが invisicaps と衝突する
- union 内のポインタが、プログラマ視点では予測しづらい形で capability を失う場合 がある
- その結果 Fil-C ではプログラマのミスがなくても null capability を持つオブジェクトの逆参照 で panic が発生
- これを緩和するため、Fil-C++ コンパイラは union 型ローカル変数の処理で LLVM が保守的に動作するよう intrinsics を挿入
- その後
FilPizlonatorパスが独自の escape analysis を行い、union 型ローカル変数をレジスタ割り当て可能にしようと試みる- ただしこの分析は一般的な LLVM の SROA 分析ほど完全ではない
- 結果として
std::optionalのような union を含むクラスの受け渡し は Fil-C++ ではメモリ割り当てにつながることが多い - 今回の変更は hot path で
std::optionalにつながるコードパス を回避 - 性能効果は 1.7% 向上
- この時点基準で Zef は CPython 3.10 より 3 倍遅い
- Lua 5.4.7 より 6.65 倍遅い
- QuickJS-ng 0.14.0 より 1.9 倍遅い
- Fil-C++ では union 関連のコンパイラ病理現象 のため
-
開始時点比で 12倍高速
-
最適化 #13: 特化された引数
- Zef のすべての built-in 関数 は 1 個または 2 個の引数を受け取り、ネイティブ実装ではそれを格納するための
Argumentsオブジェクトの割り当てが不要 - setter も常に 1 個の引数 を受け取り、setter 推論が行われた場合は specialized setter 実装 も
Argumentsオブジェクトなしで値引数だけを直接受け取れば十分 - 今回の変更で
ZeroArguments、OneArgument、TwoArgumentsの特化引数型を導入- callee が必要としない場合、caller は
Argumentsオブジェクトの割り当てを回避
- callee が必要としない場合、caller は
ZeroArgumentsは(Arguments*)nullptrと区別 するために必要- 従来は
(Arguments*)nullptrを getter 呼び出しの意味 で使っており、そのロジックを維持 - これからは
ZeroArgumentsが 引数なし関数呼び出しの意味
- 従来は
- 多くの変更は、引数を受け取る関数を テンプレート化 する作業で構成
ZeroArguments、OneArgument、TwoArguments、Arguments*それぞれについて 明示的インスタンス化 を実行- 既存コードのかなりの部分は
Value::getArgを引数抽出ヘルパーとして使っており、ここに 特化引数オーバーロード を追加 - 引数を使うネイティブコードの変更は比較的素直な修正
- 性能効果は 3.8% 向上
- この時点で Zef は CPython 3.10 より 2.9 倍遅い
- Lua 5.4.7 より 6.4 倍遅い
- QuickJS-ng 0.14.0 より 1.8 倍遅い
- 開始時点比で 12.4倍高速
- Zef のすべての built-in 関数 は 1 個または 2 個の引数を受け取り、ネイティブ実装ではそれを格納するための
Fil-Cの病理回避と詳細な特殊化
-
最適化 #14: 改良されたValue slow path
- もう1つのFil-Cの病理現象回避により大きな高速化を確保
- 変更前の
Valueのアウトオブラインslow pathはValueのメンバ関数で、暗黙の**const Value*引数**が必要だった - この構造ではcallerが
Valueをスタックに割り当てる必要がある - Fil-C++ではすべてのスタック割り当てがヒープ割り当てになる
- そのためslow pathを呼び出すコードは
Valueをヒープに割り当てることになる
- そのためslow pathを呼び出すコードは
- 変更後はこれらのメソッドを**
staticにし、Valueを値渡し**するようにした- その結果、追加の割り当てが不要になった
- 性能効果は10%向上
- この時点でZefはCPython 3.10より2.6倍遅い
- Lua 5.4.7より5.8倍遅い
- QuickJS-ng 0.14.0より1.65倍遅い
- 出発点比で13.6倍高速
-
最適化 #15:
DotSetRMWの重複排除- 一部の重複コードを削除
constructCache<>によって特殊化されるテンプレート関数では、機械語コードの削減が有利に働く可能性があると期待- 実際の結果は性能への影響なし
-
最適化 #16:
sqrtの特殊化- inline cacheは目的の関数へ呼び出しをうまくルーティングするが、オブジェクトに対してのみ動作する
- 非オブジェクトでは、
Binary<>、Unary<>、Value::callRMW<>のfast pathが、receiverが**intまたはdoubleかどうかを確認**する方式に依存する - この方式はパーサが認識する演算子にしか適用されない
value.sqrtのような形には適用されない
- 今回の変更により
Dotが**value.sqrt**に対して特殊化可能になった - 性能効果は1.6%向上
- この時点でZefはCPython 3.10より2.6倍遅い
- Lua 5.4.7より5.75倍遅い
- QuickJS-ng 0.14.0より1.6倍遅い
- 出発点比で13.8倍高速
-
最適化 #17:
toStringの特殊化- 前の最適化とほぼ同じ方法で**
toStringの特殊化**を適用 - この変更には、
intを文字列に変換する際に発生する割り当て回数を減らすロジックも含まれる - 性能効果は2.7%向上
- この時点でZefはCPython 3.10より2.5倍遅い
- Lua 5.4.7より5.6倍遅い
- QuickJS-ng 0.14.0より1.6倍遅い
- 出発点比で14.2倍高速
- 前の最適化とほぼ同じ方法で**
-
最適化 #18: 配列リテラルの特殊化
my whatever = [1, 2, 3]のようなコードでは、Zefの配列はエイリアス可能でミュータブルなため、新しい配列の割り当てが必要- 変更前は実行のたびにASTをたどって
1、2、3を毎回再帰的に評価していた - 今回の変更では
ArrayLiteralノードを定数配列の割り当てケースに特殊化 - 性能効果は8.1%向上
- この時点でZefはCPython 3.10より2.3倍遅い
- Lua 5.4.7より5.2倍遅い
- QuickJS-ng 0.14.0より1.5倍遅い
- 出発点比で15.35倍高速
-
最適化 #19:
Value::callOperatorの改善- 以前、
Valueを参照渡ししないことで高速化を得たのと同じ最適化を、callOperatorslow pathにも適用 - 性能効果は6.5%向上
- この時点でZefはCPython 3.10より2.2倍遅い
- Lua 5.4.7より4.9倍遅い
- QuickJS-ng 0.14.0より1.4倍遅い
- 出発点比で16.3倍高速
- 以前、
-
最適化 #20: より良いC++オプション
- Fil-C++では不要なRTTIとlibc++ hardeningを無効化
- C++コード自体の変更はなく、ビルドシステム設定の変更のみを含む
- 性能効果は1.8%向上
- この時点でZefはCPython 3.10より2.1倍遅い
- Lua 5.4.7より4.8倍遅い
- QuickJS-ng 0.14.0より1.35倍遅い
- 出発点比で16.6倍高速
-
最適化 #21: assertの無効化
- 最後の最適化としてassertionのデフォルト無効化を適用
- 既存コードはFil-C専用の**
ZASSERTマクロ**を使用- 常にassertを実行する構造だった
- 変更後は内部の**
ASSERTマクロ**を使用ASSERTS_ENABLEDが設定されている場合にのみassertを実行
- この変更には、コードがYolo-C++でビルドできるようにする別の修正も含まれる
- 予想に反して高速化はなかった
Yolo-C++の結果と限界
- コードを**Yolo-C++**でコンパイルした結果、4倍の高速化を確保
- ただしこの方式はsoundではなくsuboptimal
- soundでない理由は、既存のFil-C++ GC呼び出しが**
calloc呼び出し**に置き換わるため - その結果メモリが解放されず、十分に長く実行されるワークロードではインタプリタがメモリ枯渇に達する
- ScriptBench1ではテスト時間が短いためメモリ枯渇は発生しない
- soundでない理由は、既存のFil-C++ GC呼び出しが**
- suboptimalである理由は、実際のGCアロケータがglibc 2.35の**
callocより高速**だから - したがってYolo-C++ポートに実際のGCを追加すれば、4倍を超える高速化の可能性があると述べている
- この実験ではGCC 11.4.0を使用
- この時点でZefは
-
CPython 3.10より1.9倍高速
-
Lua 5.4.7より1.2倍遅い
-
QuickJS-ng 0.14.0より3倍高速
- 出発点比で67倍高速
-
生ベンチマークデータ
- ベンチマーク実行時間の単位は 秒
- 表には各インタプリタごとの
nbody、splay、richards、deltablue、geomeanを含む -
Python 3.10
nbody0.0364splay0.8326richards0.0822deltablue0.1135geomean0.1296
-
Lua 5.4.7
nbody0.0142splay0.4393richards0.0217deltablue0.0832geomean0.0577
-
QuickJS-ng 0.14.0
nbody0.0214splay0.7090richards0.7193deltablue0.1585geomean0.2036
-
Zef ベースライン
nbody2.9573splay13.0286richards1.9251deltablue5.9997geomean4.5927
-
Zef 変更 #1: Direct Operators
nbody2.1891splay12.0233richards1.6935deltablue5.2331geomean3.9076
-
Zef 変更 #2: Direct RMWs
nbody2.0130splay11.9987richards1.6367deltablue5.0994geomean3.7677
-
Zef 変更 #3: IntObject を回避
nbody1.9922splay11.8824richards1.6220deltablue5.0646geomean3.7339
-
Zef 変更 #4: Symbols
nbody1.5782splay9.9577richards1.4116deltablue4.4593geomean3.1533
-
Zef 変更 #5: Value Inline
nbody1.4982splay9.7723richards1.3890deltablue4.3536geomean3.0671
-
Zef 変更 #6: Object Model and Inline Caches
nbody0.3884splay3.3609richards0.2321deltablue0.6805geomean0.6736
-
Zef 変更 #7: Arguments
nbody0.3160splay2.6890richards0.1653deltablue0.4738geomean0.5077
-
Zef 変更 #8: Getters
nbody0.2988splay2.6919richards0.1564deltablue0.4260geomean0.4809
-
Zef 変更 #9: Setters
nbody0.2850splay2.6690richards0.1514deltablue0.4072geomean0.4651
-
Zef 変更 #10: callMethod のインライン化
nbody0.2533splay2.6711richards0.1513deltablue0.4032geomean0.4506
-
Zef 変更 #11: Hashtable
nbody0.1796splay2.6528richards0.1379deltablue0.3551geomean0.3906
-
Zef 変更 #12: std::optional を回避
nbody0.1689splay2.6563richards0.1379deltablue0.3518geomean0.3839
-
Zef 変更 #13: Specialized Arguments
nbody0.1610splay2.5823richards0.1350deltablue0.3372geomean0.3707
-
Zef 変更 #14: Improved Value Slow Paths
nbody0.1348splay2.5062richards0.1241deltablue0.3076geomean0.3367
-
Zef 変更 #15: DotSetRMW::evaluate の重複排除
nbody0.1342splay2.5047richards0.1256deltablue0.3079geomean0.3375
-
Zef 変更 #16: Fast sqrt
nbody0.1274splay2.5045richards0.1251deltablue0.3060geomean0.3322
-
Zef 変更 #17: Fast toString
nbody0.1282splay2.2664richards0.1275deltablue0.2964geomean0.3235
-
Zef 変更 #18: Array Literal Specialization
nbody0.1295splay1.6661richards0.1250deltablue0.2979geomean0.2992
-
Zef 変更 #19: Value callOperator Optimization
nbody0.1208splay1.6698richards0.1143deltablue0.2713geomean0.2810
-
Zef 変更 #20: Better C++ Configuration
nbody0.1186splay1.6521richards0.1127deltablue0.2635geomean0.2760
-
Zef 変更 #21: No Asserts
nbody0.1194splay1.6504richards0.1127deltablue0.2619geomean0.2759
-
Yolo-C++ 上の Zef
nbody0.0233splay0.3992richards0.0309deltablue0.0784geomean0.0686
1件のコメント
Hacker Newsのコメント
似た文脈で、Wrenインタプリタの性能を扱ったこのページもかなり興味深かった
Zefの記事が実装手法中心だとすると、Wrenの方は言語設計そのものが性能にどう寄与するかも示していると感じた
特にWrenはdynamic object shapesを捨てることでcopy-down inheritanceを可能にし、メソッド探索をはるかに単純化している点がよさそうだった
個人的にはかなり妥当なトレードオフだと思う。クラスが作られた後でメソッドを付け足す必要が実際どれほど頻繁にあるのか、という気がする
動的言語向けに非常に最適化されたVMは多いが、LuaJITが強いのはLuaが非常に小さく、最適化に向いた言語だからだと感じる
最適化しにくい機能も多少はあるが、数が少ないので力を注ぐ価値があるのが大きい
一方でPythonはまったく別物だと思う。少し大げさに言えば、高速なJITの可能性を最小化するように設計されたレベルで、何層もの動的性が重なって最適化が本当に難しそうだ
これだけ長い期間取り組んだ後でも、CPython 3.15 JITがx86_64でデフォルトのインタプリタより約5%速い程度だという点が、それをよく示していると感じる
もちろんRubyは速度最優先の言語として知られているわけではない、という点も同時に思い浮かぶ
逆に、ある型が適用可能な関数集合を閉じた形で持つという発想には、少し疑問も感じる
世の中には任意の関数を定義しておいて、最初の引数の型が合う変数にドット記法のメソッドのように付けて使える言語がかなりある
たとえばNimのマクロ、Scalaのimplicit classesとtype classes、Kotlinのextension functions、Rustのtraitsのような方式だ
複雑な動的言語はさまざまな形でその可能性を積極的に壊してくるので、最適化が難しくなりがちだと感じる
振り返ってみると、かなり当たり前の話にも思える
変更 #5 から #6 に移るところで、inline cachesとhidden-class object modelが性能向上の大半を生み出している点は、歴史的にV8やJSCが高速化したやり方と本当に似ていると感じた
素朴なインタプリタが死ぬポイントは結局プロパティアクセスの動的ディスパッチで、残りは比較的rounding errorに近いという印象だ
各段階がどれだけ寄与したのかを個別に見られるよう整理されているのもよかった。たいていの性能記事は最終的な数値だけ投げて終わることが多い
バイトコードインタプリタならバイトコード列の安定したオフセットをパッチすればよいので、ICの書き換え位置は自然だ
ところがここではキャッシュ位置がASTノードなので、@pizlonatorが
constructCache<>を通じてgenericノードの上にspecialized ASTノードをin-placeで構築する方式を取っていた点が印象的だった結局のところ、ASTレベルのself-modifying codeのように見えた
その代わりこの方式はmutable AST nodesを要求するため、サブツリー共有や並列コンパイルのように多くのコンパイラが期待するimmutable ASTという前提と衝突する
単一スレッドのインタプリタならきれいだが、同じASTをバックグラウンドスレッドでJITコンパイルしている間にインタプリタがノードを書き換える状況だと問題になりそうだ
私の考えでは、実際の業務コードの大半をうまく代表していない可能性もある
そう感じた理由は、sqrt最適化で1.6%改善したというくだりだった
その程度の改善が出るなら、そもそもベンチマーク時間の1.6%以上がそこに費やされているはずで、かなり驚いた
git repoを見ると、実際にnbodyシミュレーションでそうなっていたようだった
私も最近、自分のAST-walking interpreterの最初のバージョンを公開したばかりだったので、なおさら興味深く読めた
目標は、インタプリタ言語を作るのに何が必要かを基礎レベルで理解することだった
最適化の複雑さは入れたくなくて、単に自分のRustコードを自分で理解できるようにすることに集中していた
それでも好きな言語であるRustを使ったというだけで、性能がかなり良く出たのには驚いた
しかもRustがownershipとlifetimeを面倒見てくれるので、別途garbage collectorが不要だという点もボーナスのように感じた
もちろん今はclosureのような部分でlifetime地獄を避けるためにcloneにかなり保守的に頼っているが、それでも速度とメモリプロファイルは十分良好だと感じる
シンプルで理解しやすいRustベースのtree-walking interpreterに興味があるなら、私のインタプリタgluonscriptを見るとよい
記事は本当に素晴らしかった
特にArgumentsアーク、つまり#7から#13へ進む流れが、自分の経験ととてもよく重なった
以前Rustでasync step evaluatorを作っていたとき、普段はborrowが得になると信じて
Cow<'_, Input>に深く入り込んだことがあったマイクロベンチではよく見えたが、実ワークロードではCowのdiscriminantとlifetime周りの複雑さが最初の
await以降のすべてのcombinatorに広がり、インライン化が大きく崩れてCowを使う理由そのものが失われた結局、evaluatorの境界で
NoInput / OneInput / MultiInput(Vec)に切り替えたが、見た目は無骨でも結果的にはここでのZeroArguments / OneArgument / TwoArguments分離とほぼ同じ地点にたどり着いた1つずっと気になっているのは、native経路でarity specializationの上にtype specializationまで重ねてみたかどうかだ
たとえばbinaryスタイルにすると、
isIntチェック自体をなくせるかもしれない推測するに、コードサイズの計算が合わなかったか、オブジェクト側はICがすでにホットパスを十分に食っていてnative fast pathの影響が大きくなかったのかもしれない
どちらなのか気になった
これは本当に興味深く、よくできた仕事だと感じた
私も似たようなことをやったことがあるが、もっと関数型寄りの言語であるSchemeの方だった
ここではオブジェクト最適化が最大の利益をもたらしたが、私の場合はclosures最適化が最大の勝負どころだった
面白いことに、最適化のやり方自体はかなり似ていた
Schemeを十分に速くする答えはThree implementation models for schemeにほとんど全部入っていると思う
ただしこちらはある程度コンパイル段階を経るので、元のASTをそのままインタプリタ実行するモデルではないという違いはある
興味深かったし、共有してくれてありがたかった
私もいつかこのテーマを詳しく掘り下げてみたいと思った
それとGithub上ではrepoが99.7% HTMLで0.3% C++という点もかなり面白くて印象的だった
インタプリタが本当に小さい証拠のように見えた
ブラウザ向けコードを生成する都合で、サイト側が無駄に大きくなっている面がある
それでもインタプリタ自体は本当にとても小さい
この作業を通じて、fil c自体をより良くできそうなことを学んだのか気になった
それとvalue objectのメソッドをoutline callで処理するコストがかなり大きいということも学んだ
Luaが入っているのは見たが、LuaJITも一緒にあってほしかったと思った
いや、そのくらいのエンジニアリングが投入されていることを考えれば、むしろそうであるべきだという期待だ
入れられたランタイムは多かったが、全部は含めなかった
そしてPUC LuaがQuickJSやPythonよりかなり速いという点も相当に印象的だった
Fil-Cを実際に使ってみた経験がどんなものか、実務で実質的な有用性があるのか気になった
それでもこのプロジェクトではかなり実質的に役立った
メモリ安全性の問題をいくつも決定論的に捕まえてくれて、オブジェクトモデル設計がそうでなかった場合よりずっと簡単になった
さらに正確なGC付きのC++は本当に良いプログラミングモデルのように感じられた
普通のC++と比べて生産性が1.5倍くらい上がる感覚があり、他のGC言語と比べても1.2倍くらいは速く開発できる感じだった
理由はC++のAPIエコシステムが豊富で、lambdas、templates、class systemが非常に成熟しているからだと思う
もちろん多くの点で偏っていることも認める
Fil-C++を自分で作ったし、C++も35年ほど使ってきた
記事で触れられていた**YOLO-C/C++**コンパイラが何なのか気になった
検索してもあまり出てこないし、chatgptも知らないようだった