HN 日本語サマリー

← 一覧へ戻る
プログラミング

RustのEnumを64ビットワードで置き換えたら、私のインタープリタが17%高速になった

Replacing a Rust Enum with a 64-Bit Word Made My Interpreter 17% Faster (pointersgonewild.com)

130 pointsby metrofun48 コメント

要約

この記事では、動的型付け言語PlushのインタープリタにおけるValue型の表現を、Rustのタグ付きEnumから64ビットワードに置き換えた最適化について解説しています。この変更により、メモリ使用量が削減され、インタープリタの実行速度が17%向上しました。低ビットタグ付けスキームの設計と実装、およびそのパフォーマンスへの影響について詳細に説明されています。

全文翻訳

Rustのenumを64ビットワードに置き換えたらインタープリタが17%高速になった

2026年8月25日

[画像]

この記事は、私がPlush言語のインタープリタと仮想マシンを構築・最適化するシリーズの第6弾です。前回の記事はPlushのガベージコレクタを高速化でした。前回の記事では、いくつかの簡単な変更でコピーGCが16倍以上高速になり、100万オブジェクトのコレクション時間を約7ミリ秒に短縮できたことを説明しました。私は単に最適化を楽しむためにPlushを最適化していますが、インタープリタ言語でありながら、リアルタイムで3Dアニメーションをレンダリングできるほど言語を高速化するという目標も念頭に置いています。

Plushは、Python、JavaScript、Ruby、Lua、Loxと同じファミリーに属する動的型付け言語です。このような動的言語では、型は変数ではなく値にアタッチされるため、プログラム内で値を伝播させるために、インタープリタは通常、言語に存在するあらゆる値を表現できるValue型を持っています。Plushの初期バージョンでは、プレーンなRustのタグ付きenumを使用しました。Rustはタグ付きenumの扱いに非常に便利なので、matchステートメントを使って異なるValueサブタイプにディスパッチできます。

// 以前のRustのValue型(タグ付きenum)
enum Value {
    Undef, // 初期化されていない変数またはフィールド、読み取りはエラーを引き起こす
    Nil,
    False,
    True,
    Int64(i64),
    Float64(f64),
    String(*const Str),        // 不変文字列
    HostFn(&'static HostFn),   // ホストVMによって公開された関数
    Fun(FunId),                // クロージャではないPlush関数
    Closure(*mut Closure),     // 変数をキャプチャするクロージャ
    Cell(*mut Value),          // クロージャによってキャプチャされた可変変数
    Object(*mut Object),       // クラスインスタンス
    Array(*mut Array),         // JS/Pythonスタイルの配列/リスト
    ByteArray(*mut ByteArray), // 高速な生のバイト配列(例:フレームバッファ)
    Dict(*mut Dict),           // JS/Pythonスタイルの辞書
    Class(ClassId),
}

上記のように、Plushは、まだおもちゃの言語と考えていますが、多くの異なる値型を持っています。この言語には、アクセスが効率的なオブジェクト(クラスインスタンス)がありますが、JSONスタイルの構文を可能にするJS/Pythonスタイルの辞書もあります。また、Int64とFloat64という2つの異なる数値型があります。この選択をしたのは、JavaScriptがすべてを倍精度浮動小数点数(double)であるかのように見せかける一方で、JSエンジンは実際には内部で整数かどうかを追跡しているという事実が、常に少し気になっていたからです。しかし、最も残念なのは、ここのenumバリアントの数ではなく、このenum全体が16バイト(128ビット)幅であることです。各enumバリアントは64ビットしか必要とせず、Rustが作成するenumタグは8ビットしか必要としませんが、メモリのアライメント制約のため、Rustは各値に128ビット全体を使用する必要があるかもしれません。これは大したことではないように思えるかもしれませんが、値の大きな配列がある場合、その配列には大量の空の無駄なバイトが含まれることになります。これはVMエンジニアが夜も眠れなくなるような種類の問題です。

しばらくの間、より効率的な低ビットタグ付けスキームを設計して、Value型が64ビットに収まるようにできると考えていました。64ビットシステムでは、ヒープオブジェクトのアドレスは通常8バイト境界にアラインメントされるため、アドレスの下位3ビットはゼロである必要があるという事実から派生した古典的なトリックがあります。これは、これらのビットを extra な情報をパックするために盗むことができることを意味します。また、整数値の最下位2ビットを借りることもできます。これは、整数値が64ビット範囲全体を必要とすることは非常にまれであるという仮定に基づいています。なぜなら、2^64 ~= 1.84 * 10^19という非常に大きな値だからです。例えば、テキストファイルの行数、ゲーム内の敵の数、その他の数値量を示す変数を考えてみてください。その値に到達することは非常に考えにくいです。現代のCPUはクロックサイクルあたり複数の命令をディスパッチできるため、for (uint64_t i = 0; i < UINT64_MAX; ++i)のようなループを実行した場合、そのループは完了するのに10年以上かかるでしょう。

より洗練されたタグ付けスキームは、明らかにメモリ使用量を削減できますが、整数、ポインタ、浮動小数点数を区別するためにビット演算を導入する必要があることも意味します。また、一部の値をアンパックして操作するために追加のビット演算も必要になります。これは、CPUが実行する必要のある追加命令を意味します。熟練したVMエンジニアは、整数にゼロをタグビットとして与えるのが賢明だと私に言いました。なぜなら、その場合、シフトされた整数の加算または減算は、単純なaddまたはsubマシン命令のままだからです。しかし、浮動小数点数のパックとアンパックはより複雑で、いくつかの命令が必要です。パフォーマンスへの影響を少し心配していましたが、メモリ効率を犠牲にしてパフォーマンスが低下するのではないかと懸念していました。この投稿の後半で見るように、私の恐れは完全に根拠のないものでした。

効率的な低ビットタグ付けスキーム

Claudeと私は、このソースファイルにある値の表現を共同で設計し、改良しました。これは、u64をラップするRustのnewtypeにうまく収まります。ご覧のとおり、Rustのenumの喪失を補うために、扱いを容易にする多くの便利なメソッドがあります。ほとんどのメソッドは、インタープリタループのどこでも使用されるため、パフォーマンスのためにalways inlineとしてマークされています。以下の図は、値の表現の構造をより詳細に示しています。

[画像: タグ付き値表現。]

タグ付き値表現。

私が選択した低ビットタグ付けスキームは、5種類の値をエンコードします:フィックスナム(fixnums)、フロナム(flonums)、イミディエイト(immediates)、および2種類のポインタです。フィックスナムは62ビット範囲に収まる符号付き整数です。フロナムは自己タグ付けスキームを使用してエンコードされた浮動小数点値です(後述)。イミディエイトは、nil、true、false、undef、関数ID、クラスID、およびPlushから呼び出すことができるRustで記述されたホスト関数などの値です。Plushでは、ドル記号プレフィックス(例:$read_file(file_name))を使用してホスト関数を呼び出すことができます。イミディエイトには、それがどの種類のイミディエイトであるかを示すための5ビットのサブタグがあります。これは5ビットです。なぜなら、最下位バイトの8ビット比較は現代のCPUでは1命令で済むため、必要に応じてタグビットとサブタグを1命令で比較できるからです。

この表現に2種類のポインタがある理由は、値間の等価性比較を高速化したいと考えたからです。これは、ループ条件、ifステートメント内のポインタ比較などが頻繁に発生するため、よく起こります。Plushのほとんどの値は単純な参照等価性で比較されますが、文字列は構造的等価性で比較されるように選択しました。これにより、将来的に文字列インターニングテーブルを実装する可能性が生まれます。これはJSと同様に、同じ値を持つ2つの文字列は等しいとみなされます。浮動小数点数も特別な処理が必要です。なぜなら、それらは符号ビットを持ち、+0.0は-0.0と同じでなければならないからです。

それを簡単に行う方法は、タグに1ビット(ビットインデックス1を選択)を持たせることです。これにより、2つの値が1つの比較命令で直接比較できることがわかります。これにより、整数、ポインタ、ハンドル、およびtrue、false、nil、undefのような小さなイミディエイト定数を、単一のマシン比較命令を使用して比較するための高速パスが得られます。ポインタ型の一部(文字列オブジェクト)は直接ポインタ等価性で比較できないため、そこにはわずかな微妙さがあります。また、整数または浮動小数点値がタグ付き値に収まる範囲を超えた場合、その値をボックス化するためにヒープオブジェクトを割り当てる必要があります。これは、異なるヒープオブジェクトによって表される2つの浮動小数点数が同じ値を持つ可能性があることを意味しますが、それらの値は==演算子で等しいとテストされなければなりません。

Plushに最終的にJITコンパイラが搭載された場合、if (p != nil)の生成コードは次のようになります。構造的に比較される値がnilと等しくなる可能性はないため、ここには遅いパスはありません。

; x0 = テスト中の値
; nil はイミディエイト 0x05
cmp     x0, #5
b.eq    .ELSE_BRANCH  ; 等しい場合はelseブランチにジャンプ

効率的なフィックスナム演算

前述のように、62ビットのフィックスナム範囲に収まる整数値は、最下位2ビットが00に設定され、n << 2として格納されます。そのため、加算、減算、比較、ビット単位のAND/OR/XORはタグ付きワード上で直接実行されます。64ビットオーバーフローは、結果が62ビットに収まらなくなるケースと正確に一致するため、オーバーフローをチェックするために標準のマシン命令(x86-64ではjo、ARM64ではbvs)を使用できます。

ループカウンタの比較i < nのような場合、ARM64のマシンコードは次のようになります。両方の値が整数であることをチェックするには、3つのマシン命令が必要です。

; `i < n` のための高速パス、5命令
; x0 = i, x1 = n
orr     x2, x0, x1    ; 両方のオペランドを結合
tst     x2, #3        ; 両方のオペランドはフィックスナムか?
b.ne    .Lslow_lt     ; 浮動小数点数、ボックス化された数値、文字列はこちらへ

cmp     x0, x1        ; フィックスナムを比較、`00`タグビットは関係ない
b.ge    .LOOP_EXIT    ; 符号付き比較、フィックスナムは62ビット符号付き

フィックスナムの加算の高速パスは次のようになります。Rustコンパイラは実際には両方のタグを結合できないかもしれませんが、JITコンパイラなら簡単にでき、実行時型情報があれば型チェックを完全に削除することさえできます。その場合、加算はC言語の整数加算と同じくらい効率的になります。

; フィックスナム整数加算の高速パス
; 高速パスでは5命令
; x0 = a, x1 = b、2つのタグ付き値
; フィックスナムは n << 2 なので、下位2ビットは 00
orr     x2, x0, x1      ; 両方のタグを結合、1回のテストで両方をカバー
tst     x2, #3          ; 両方のオペランドはフィックスナムか?
b.ne    .Lslow

adds    x0, x0, x1      ; (a << 2) + (b << 2) == (a + b) << 2
b.vs    .Loverflow      ; 符号付きオーバーフローは正確に62ビットオーバーフローである

; x0 は既にタグ付き結果を保持している。入力時にアンタグされておらず、
; 出力時に再タグ付けする必要もない

自己タグ付けフロナム表現

タグ付き値システムで浮動小数点値をタグ付けする方法はたくさんあります。その中で最も有名なスキームの1つはNaNボクシングです。これは、私の知る限り、Firefox JavaScriptエンジンで今日まで使用されています。NaNボクシングは、倍精度浮動小数点数がNaN(非数)とみなされるという事実を利用します。これは、11ビットの指数ビットがすべて1に設定され、仮数部がゼロでない場合に発生します。その条件を満たすビットパターンはすべて、ワードの残りの部分が何であってもNaNとして読み取られます。64ビット倍精度浮動小数点数の場合、仮数部には52ビットあり、任意のデータを格納するためのペイロードとして使用できます。ただし、実装では通常、最上位ビットをクワイエットNaN用に予約するため、実際には51ビットです。Crafting Interpretersの本では、最上位の符号ビットを追加のタグとして盗むこともできると述べています。これは巧妙なトリックであり、倍精度浮動小数点数演算を、タグ付けやアンタグ付けなしで、ほぼ無料で利用できるという利点があります。欠点は、他の種類の演算のペナルティが少し大きくなることです。現実的な事実は、典型的なJavaScript、Python、またはRubyプログラムは、浮動小数点演算をあまり行わないということです。整数とポインタの操作の方がはるかに多いです。

Plushでは、低ビットタグ付けで機能する浮動小数点値のタグ付けの他の方法を調査することにしました。IEEE倍精度浮動小数点数からいくつかの指数ビットを盗むことが可能なアプローチであることを既に知っていました。倍精度浮動小数点数が表現できる範囲は信じられないほど広大で、最大表現可能値は約1.8 * 10^308です。コンピュータプログラムで一般的に見られる値のほとんどはその範囲に近づかないと考えるのは妥当であり、したがって、指数部から数ビットを借りると、タグ付きフロナム用に小さくはなりますが、依然として巨大な表現可能な範囲が残ります。その範囲に収まらない倍精度浮動小数点数は、単純にヒープに割り当てることができますが、それはほとんど必要ないはずです。

効率的な方法を模索する中で、Olivier Melançon、Manuel Serrano、Marc FeeleyによるFloat Self-Taggingに関する比較的最近の論文に出くわしました。彼らが提示するスキームは、上位ビットを回転させてタグビット位置に配置し、バイアス値を追加することで、下位ビットが目的のタグになるようにします。これは巧妙で、シフト、マスク、その他の操作は必要ありません。何よりも、タグ付きフロナム値は、ヒープボクシングを必要とせずに、サブノーマル、無限大、さらにはNaNも表現できます。これは、これらの値が一部の計算で発生するため、非常に便利です。私のボクシングスキームでは、バイアス値を選択して、最下位2ビットが10になるようにしました。ビットインデックス1の1は、倍精度浮動小数点数が直接等価性で比較できないことを示します。驚くべきことに、指数部をわずか2ビットしか失わず、仮数部の完全な精度を維持するため、フロナム表現で有意な桁を失うことはありません。

[画像: 自己タグ付けを使用した浮動小数点数のボクシングとタグ付け、Float Self-Tagging (arXiv:2411.16544) より。]

自己タグ付けを使用した浮動小数点数のボクシングとタグ付け、Float Self-Tagging (arXiv:2411.16544) より。

2つのフロナムの加算は、タグ付けとアンタグ付けを元に戻して再適用するために追加の作業が必要なため、2つのフィックスナムの加算よりもコストがかかります。

; 高速パスで18命令、BIASは既にレジスタにある
; x0 = a, x1 = b、2つのタグ付き値
; x9 = BIAS, 0x6810_0000_0000_0000、一度マテリアライズされる
and     x2, x0, #3      ; a のタグビットを分離
and     x3, x1, #3      ; b のタグビットを分離
cmp     x2, #2          ; a のフロナムは下位2ビットが 10 である
ccmp    x3, #2, #0, eq  ; a がフロナムの場合のみ b を確認
b.ne    .Lslow

ror     x2, x0, #4      ; 回転を元に戻す
sub     x2, x2, x9      ; バイアスを元に戻し、IEEE 754 ビットを残す
fmov    d0, x2
ror     x3, x1, #4
sub     x3, x3, x9
fmov    d1, x3

fadd    d0, d0, d1      ; 実際のf64加算

fmov    x2, d0          ; ビットに戻す
add     x2, x2, x9      ; + BIAS
ror     x0, x2, #60     ; 左に4回転: ARM64は右回転のみ
and     x3, x0, #3      ; 結果のタグビットを分離
cmp     x3, #2          ; タグが 10 になったか、それともこのタグがボックス化するか?
b.ne    .Lbox

上記の機械語スニペットの最後の命令は、最下位2ビットが10になったかどうかをテストします。もしそうならなかった場合、それはエンコーディングでカバーできない2つの狭い範囲の大きさの値が得られたことを意味します。これは実際には非常にまれであるべきですが、もし発生した場合、ヒープボックスを割り当てて続行する遅いパスがあります。Plushでは、バンプアロケータを使用しているため、ヒープボクシングも実際には比較的速いです。

メモリ使用量への影響

Plushには、いくつかの合成ベンチマークがあります。そのうちのいくつかは非常に小さなマイクロベンチマークで、特定の機能のパフォーマンスをテストするために設計されています。他のものはもう少し広範です。例えば、fibは再帰的なフィボナッチ数マイクロベンチマークです(Marc Feeleyのお気に入り)。主にファンクションコールとインタープリタのスループットをベンチマークします。binary_treeベンチマークは、再帰的なファンクションコールとオブジェクトプロパティアクセス、およびポインタチェイシングとヒープ割り当てオブジェクトの走査の両方を含みます。mlpベンチマークは、浮動小数点演算を大量に行う、単純に実装された多層ニューラルネットワークです。また、sha256計算と、古典的なnbody天体シミュレーションも含まれています。最近、三角形ラスタライザベンチマーク(ここでは示されていません)を追加し、Plushでの3Dグラフィックスで何ができるかを知るためのアイデアを得ました。

以下に示す数値は、Value newtypeを導入したがまだ古いenumを使用している最後のコミットac75356と、リファクタリングそのものである6b71f8cを比較したものです。これら2つのコミットは隣接しているため、その間に他に何も変更されていません。実験は私のMacBook Air M5で、7回のインターリーブされたラウンドで行われました。

[画像: ベンチマークごとのピークRSS(少ない方が良い)。]

ベンチマークごとのピークRSS(少ない方が良い)。

最も重要な点は、二峰性分布があることです。一部のベンチマークでは、メモリ使用量が大幅に削減されています。特に、多くの配列を使用するmlpベンチマークでは最大37%削減されていますが、他のベンチマークではほとんど変化がありません。変化のないベンチマークは、単にメモリをあまり割り当てていないだけです。それらは最小のPlush GCヒープサイズを使用しており、ピークRSSは約10MBです。主な教訓は、ベースラインよりも多くのメモリを使用するベンチマークでは、 nice な削減が見られるということです。まあ、それは完全に真実ではありません。2つのベンチマークでは実際にはメモリ使用量が増加しました。それらはquicksortとsha256_unfixedです。quicksortの場合、ピークRSS測定はGCがコレクションサイクルの途中で行われたときに捉えられたため、これは不幸な外れ値のようなものです。しかし、sha256_unfixedは実際の回帰を示しています。これは、多くのボックス化された整数が生成され、それらの値へのポインタが配列に格納され、ボックス化された整数が生き続けるケースです。

私はsha256_unfixedを調べに行き、左シフトが整数オーバーフローを引き起こすような構造になっていたことを発見しました。ベンチマークを変更することは、コンパイラエンジニアリングでは罪と見なされますが、この場合、VMの設計を最大限に活用するために、62ビットフィックスナムの計算を最適化できるかどうかを知りたかったのです。計算はこれを回避するために簡単に並べ替えられることが判明しました。それは1行の修正で、メモリ使用量を最小ヒープサイズまで戻します。したがって、これはヒープボックス化表現の実際の欠点です。私たちはエンジニアリングのトレードオフを行っており、場合によっては、注意を払わないと、メモリ効率の悪いコードにつながる可能性があります。ただし、実際には、ほとんどの場合で私たちは利益を得ています。

パフォーマンスへの影響

以下のグラフは、新しい低ビットタグ付き値表現を使用したインタープリタの速度と、古いRust enumとの比較を示しています。1.0xより大きい数値はパフォーマンスの向上を示します。

[画像: ベンチマークごとのスピードアップ(高い方が良い)。]

ベンチマークごとのスピードアップ(高い方が良い)。

上記のグラフの結果は私を驚かせました。いくつかの遅延を予想していましたが、実際には、すべてのベンチマークが高速化されており、一部は大幅に高速化されています。これらの結果には、さらに興味深い点もあります。例えば、更新されたsha256_fixedベンチマークがsha256_unfixedよりも高速であることがわかります。とはいえ、sha256_unfixedは、約490ミリ秒の間に合計3,149,052個のボックス化された整数値を割り当てているにもかかわらず、古いバージョンよりも新しい値表現で12%高速に実行されています。それは私には少しクレイジーに思えましたが、コピーGCとバンプアロケータがあるということは、オブジェクトを非常に速く割り当てることができるということです。そこでのポジティブな点は、整数をボックス化する必要がある場合、少なくとも命令ディスパッチのオーバーヘッドが支配的なインタープリタシステムでは、パフォーマンスのペナルティは予想よりもそれほど大きくないということです。

しかし、それだけではありません。前に、mlpベンチマークは多層ニューラルネットワークであると述べました。これはタグ付き浮動小数点数で行列乗算を行っており、その数は非常に多いです。nbodyベンチマークも浮動小数点演算が多用されます。これらも明らかに高速化されています。各浮動小数点演算で2つの値をアンボックスして結果を再ボックス化する必要があるにもかかわらずです。これも私を驚かせました。新しいタグ付き表現の最悪のケースをいくつか扱っている場合でも、すべてが高速化されているという結論に至ります。このリファクタリングは明らかに成功と呼べますが、少なくとも私にとっては驚くべき結論です。

では、なぜすべてがそれほど高速になったのでしょうか?パフォーマンスの向上が最も大きいのはbinary_treeとlinked_listベンチマークに見られます。これは理にかなっています。これらのベンチマークは多数のオブジェクトを持ち、多くのポインタチェイシングを行い、いたるところで多くのキャッシュラインに触れます。新しい表現では、オブジェクトは以前必要だったメモリ量の約半分しか必要としません。これは、キャッシュ/メモリトラフィック量が約半分になることを意味します。多くのメモリに触れる場合、キャッシュフレンドリーさは大きな利点です。

しかし、fibやnbodyのように、あまりメモリに触れない他のベンチマークもあります。すべては明らかにL1キャッシュに収まるはずです。現代のプロセッサでは、L1キャッシュのレイテンシはわずか1〜4クロックサイクルです。それでも、L1キャッシュは高速ですが、瞬時にアクセスできるわけではありません。CPUレジスタほど速くはありません。したがって、インタープリタのスタックフレームやnbodyが持つ少数のオブジェクトでさえ縮小されたという事実から、小さな利点が得られる可能性があります。しかし、もう1つの疑念もあります。

Value型のリファクタリングの前は、さまざまなインタープリタ操作で型に基づいてディスパッチするために、タグ付きRust enumとmatchステートメントを使用していました。Rustコンパイラがこれを生成するコードは、あまり効率的ではない可能性があると疑っています。最も可能性が高いと思われる型をmatchステートメントの先頭近くに配置しましたが、それはrustcにとっておそらく何も意味しません。対照的に、現在のコードのフォーマットでは、ifステートメントで可能性の高いホットパスを最初に明示的にテストしています。matchステートメントディスパッチのためにrustcが生成したコードがひどいものであれば、それは多くのことを説明するでしょう。

これは、古いタグ付きenumバージョン(ac75356、src/vm.rs:1498)のadd命令を、Value型に基づいてディスパッチするためにmatchアームを使用したときの私のインタープリタの処理方法です。

Insn::add => {
    let mut v1 = pop!();
    let mut v0 = pop!();

    let r = match (v0, v1) {
        (Int64(v0), Int64(v1)) => Int64(v0 + v1),
        (Float64(v0), Float64(v1)) => Float64(v0 + v1),
        (Int64(v0), Float64(v1)) => Float64(v0 as f64 + v1),
        (Float64(v0), Int64(v1)) => Float64(v0 + v1 as f64),

        (Value::String(s0), Value::String(s1)) => {
            // ...文字列連結、省略
        }

        _ => error!("add", "unsupported operand types")
    };

    push!(r);
}

Int64 + Int64の高速パスの逆アセンブリを見ると、4つの分離した基本ブロックに分散しています。コードはかなり巨大で、ネイティブC/Rustスタックへの多くのスピルと、インタープリタスタックからのメモリアクセスが含まれています。

; ---- ブロック A @ 0x1a8cc : v1をポップし、次にv0をポップする -----------------------
ldr   x10, [x20]              ; len = stack.len()
cbz   x10, .Lunderflow
sub   x11, x10, #1
str   x11, [x19, #0x60]
ldr   x8,  [x19, #0x50]       ; stack base pointer
cmp   x11, x8
b.hs  .Lpanic
ldr   x12, [x19, #0x58]       ; stack base pointer
add   x13, x12, x11, lsl #4   ; &stack[len-1]、16バイトストライドに注意
ldr   w9,  [x13]              ; v1: タグ
ldr   w14, [x13, #0xc]        ; v1: ペイロードバイト 12..16
ldur  x13, [x13, #0x4]        ; v1: ペイロードバイト 4..12、アラインメントされていない
str   w9,  [sp, #0x2b8]       ; v1をスタックスロットにスピル...
ldr   x15, [sp, #0x48]        ; ...そのアドレス自体がスタックスロットにある
str   x13, [x15]
str   w14, [x15, #0x8]
cbz   x11, .Lunderflow
sub   x23, x10, #2
str   x23, [x20]
add   x11, x12, x23, lsl #4   ; &stack[len-2]
ldr   w10, [x11]              ; v0: タグ
ldr   w12, [x11, #0xc]
ldur  x11, [x11, #0x4]
str   w10, [sp, #0x2d0]       ; v0も同様にスピル
lldr  x13, [sp, #0x50]
str   x11, [x13]
str   w12, [x13, #0x8]

; ---- matchディスパッチ --------------------------------------------
lldr  x22, [sp, #0x2d8]       ; 先ほどスピルしたv0のペイロードをリロード
lldr  x0,  [sp, #0x2c0]       ; 先ほどスピルしたv1のペイロードをリロード
cmp   w10, #4                 ; v0はInt64か?
b.eq  .Lv0_int                ; 取られる、0x120cバイト先
cmp   w10, #5                 ; Float64か?
b.eq  .Lv0_float
cmp   w10, #6                 ; Stringか?
b.ne  .Ltype_error

; ---- ブロック B @ 0x1bad8 ---------------------------------------------
.Lv0_int:
cmp   w9, #4                  ; v1はInt64か?
b.eq  .Lint_int               ; 取られる、0x5a4バイト先

; ---- ブロック C @ 0x1c07c ---------------------------------------------
.Lint_int:
adds  x22, x22, x0            ; 実際の加算
b.vs  .Lpanic_add_overflow
mov   w24, #4                 ; 結果タグ = Int64
b     .Lpush                  ; 取られる、0x2c0バイト先

; ---- ブロック D @ 0x1c33c : push! -------------------------------------
.Lpush:
cmp   x23, x8
b.eq  .Lgrow
lldr  x8, [x19, #0x58]
add   x8, x8, x23, lsl #4
str   w24, [x8]               ; タグを格納
str   x22, [x8, #0x8]         ; ペイロードを格納
add   x8, x23, #1
b     .Ldispatch

この投稿で紹介した新しいタグ付きワードバージョン(6b71f8c、src/vm.rs:1391)のadd命令の高速パスは次のようになります。

Insn::add => {
    let v1 = pop!();
    let v0 = pop!();

    // タグ付きフィックスナムはそのまま加算され、64ビット
    // オーバーフローは、合計が1つのワードに収まらなくなるケースと正確に一致する
    if v0.is_fixnum() && v1.is_fixnum() {
        if let Some(sum) = (v0.raw() as i64).checked_add(v1.raw() as i64) {
            push!(Value::from_raw(sum as u64));
            continue;
        }
    }

    flonum_op!(v0, v1, +);

    let r = slow!("add", self.add_slow(v0, v1));
    push!(r);
}

高速パスの逆アセンブリを以下に示します。

; ---- ブロック A @ 0x19cc0 : v1をポップし、v0をポップし、タグをテストする ---------------
lldr  x10, [x5]               ; len = stack.len()
cbz   x10, .Lunderflow
sub   x8, x10, #1
str   x8, [x5]
lldr  x9, [x16]
cmp   x8, x9
b.hs  .Lpanic
cbz   x8, .Lunderflow
lldr  x9, [x28, #0x58]        ; stack base pointer
lldr  x3, [x9, x8,  lsl #3]   ; v1、単一ロード、8バイトストライド
sub   x10, x10, #2
str   x10, [x5]
lldr  x2, [x9, x10, lsl #3]   ; v0、単一ロード
and   x11, x2, #3             ; v0のタグビット
cmp   x11, #2                 ; フロナムか?
b.ne  .Lcheck_fixnum

; ---- ブロック B @ 0x1a624 ---------------------------------------------
.Lcheck_fixnum:
cmp   x11, #0                 ; v0はフィックスナムか? x11はまだv0 & 3
and   x11, x3, #3
ccmp  x11, #0, #0, eq         ; ...v1もか? 1つの分岐で両方をカバー
b.ne  .Lslow
adds  x11, x2, x3             ; 実際の加算、タグ付きワード上
b.vc  .Lpush

; ---- ブロック C @ 0x18030 : push! -------------------------------------
.Lpush:
str   x11, [x9, x10, lsl #3]  ; 単一ストア
str   x8,  [x5]               ; stack.len -= 1
; その後、インタープリタディスパッチに直接フォールスルーする

一見してわかるように、新しいバージョンの逆アセンブリははるかに短いです。古いバージョンでのmatchディスパッチコード自体は実際には問題ありませんでしたが、古いバージョンでは多くのスピルとスタックメモリアクセスが発生していました。真の利点は、値が単一のレジスタに収まるようになったことです。結果として生成されるコードははるかに効率的です。インタープリタスタックからポップした直後に値をネイティブC/Rustスタックにスピルしていません。LLVMが、ポストで議論した2つの値がフィックスナムであるかどうかをチェックするトリックを独立して見つけ、2つの型テストを単一の分岐に融合させたことも気づいたかもしれません。素晴らしいです!

浮動小数点数の高速パスの逆アセンブリ全体はここでは示しませんが、古いバージョンはタグ付けとアンタグ付けをまったく行わなかったのに対し、新しいバージョンは両方のオペランドをアンボックスして結果を再ボックス化する必要があることを指摘する価値があります。それにもかかわらず、新しいフロート高速パスは直線的なコードであり、最後に正確に1つの分岐があり、実際には古いバージョンよりも短くなっています。52命令、24メモリ操作、4回の分岐から、36命令、9メモリ操作、1回の分岐になりました。これはmlpとnbodyのパズルを解決します。

結論

結論として、このリファクタリングがうまくいったことに非常に満足しています。新しいタグ付きワード表現は、多くの配列やオブジェクトを使用するベンチマークのメモリ使用量を削減するだけでなく、すべてのベンチマークが高速化されるという、パフォーマンスの大きな向上でもあります。Rustコンパイラは古いバージョンでは効率的なコードを生成できませんでしたが、新しいバージョンでは値が単一のレジスタに収まるため、実際にはかなり良い生成コードが得られます。

整数をヒープボックス化する必要がある場合、場合によっては追加のメモリ割り当てが発生する可能性があります。特に、左シフトを実行するコードや、乱数生成などのためにオーバーフローに依存するコードは、これに遭遇する可能性があります。これらの問題は、VMの設計と制限を考慮した熟練したエンジニアによって簡単に回避できます。しかし、一部の値をボックス化することになる場合でも、ボクシングのパフォーマンスオーバーヘッドは、おそらく目立つことはないでしょう。独自のプログラミング言語を設計する場合、ネイティブ整数型を64ビット整数ではなく32ビットまたは62ビット整数にするなど、異なる設計上の選択をすることができます。これにより、ボックス化された整数パスが単純に削除され、オーバーフローが目に見えるエラーを生成するようになります。また、追加の精度が必要な場合は、ユーザーに明示的にヒープボックス化されたbignum(大きな数)型を使用するように要求することもできます。これは大きな設計空間であり、多くの可能なオプションがあります。

私のパフォーマンス作業の動機の一部は、単純なゲームを作成できるレベルまで3Dグラフィックスのパフォーマンスを向上させようとすることであり、これまでのところ結果は非常に良好です。Plushは、インタラクティブなフレームレートで約10,000個のフラットシェーディングポリゴンをレンダリングできます。それを利用して、無限の都市景観を走るハイウェイをバイクに乗っているような小さなゲームを作成しました。試してみたい場合は、Plushリポジトリをクローンし、cargo run --release examples/night_ride.pshを実行してください。

次のステップとしては、Plushインタープリタをスタックベースの設計からレジスタベースの設計に変換することを検討しています。これは大きなパフォーマンスブーストをもたらす可能性があると思います。また、Plush VMを使用して、新しい言語設計アイデアを探求するミニマルなLISP方言を構築できると考えています。その言語はJetLISPと名付けようと思っています。今後の情報にご期待ください。将来の投稿について通知を受け取りたい場合は、以下のメーリングリストを購読してください。

フォローアップ: Plushの新しいレジスタベースインタープリタが完成し、信じられないほど高速になりました!

メーリングリストを購読し、このブログをフォローしてください。

Copyright © 2011–2026 Maxime Chevalier-Boisvert. All rights reserved.