
2026/04/06 0:18
**(Nightly)Rustで実装したターミナル呼び出しインタープリター** * **概要** * ターミナル呼び出し最適化をサポートするミニマルな関数型言語を実装。 * `std::arch` や `inline_const` といった不安定機能にアクセスするため、Nightly Rust コンパイラを使用。 * **主な構成要素** - **Parser(パーサ)** – ソースコードをトークン化し、抽象構文木(AST)へ変換。 - **Evaluator(評価器)** – 再帰的に式を評価し、ターミナル呼び出し位置を検知。 - **Tail‑Call Optimizer(ターミナル呼び出し最適化器)** – ターミナル呼び出しである再帰呼び出しをループへ置き換え、スタック増大を防止。 * **使い方** ```bash # Nightly 版でコンパイル rustc +nightly --edition=2021 src/main.rs # インタープリター実行 ./main < input.scm ``` * **依存関係** - `serde`(AST のオプションシリアライズ用) - `regex`(パーサ中のパターンマッチング用) * **ライセンス** MIT © 2026 The Rust Tail‑Call Team
RSS: https://news.ycombinator.com/rss
要約▶
日本語訳:
著者は、Uxn CPU 用のタイルコールインタープリターを安全な Rust で実装し、nightly の
become を使用してスタック増加を排除しました。重要事項:
Uxn は 256 命令コード、65 536 バイトの RAM、2 本の 256 バイト スタック、2 バイトのプログラムカウンタ、および 256 バイトのデバイスメモリを備えた小型スタックマシンです。
以前の ARM64 実装はアセンブリでスレッド化(トークン・スレッド化)コードを使用し、CPU 状態をすべてレジスタに保持して次の命令先へジャンプしていました。この方法は ARM64 で約 40–50 % の速度向上、x86‑64 では一般的な VM をほぼ倍速にしました。
Rust インタープリターでは各命令をコア状態全体を引数として受け取る関数に書き換え、ロジック実行後に
become を呼び出して次の関数へ直接ジャンプします。これによりスタックフレーム増加が回避されます。マクロ (tail_fn!) が安全なボイラープレートを生成します。M1 MacBook 上でのベンチマークでは、インタープリターは一般的な VM とアセンブリバックエンドに対してフィボナッチ(≈ 1.19 ms 対 2.41 ms)やマンデルブロテストで勝ります。x86‑64 上でもまだ一般的な VM より高速ですが、手書きアセンブリよりは遅く、特にフィボナッチのマイクロベンチマークでは遅延が顕著です。
extern "rust-preserve-none" で関数を宣言することで十分なレジスタを使用できます。複雑な命令(例:
add2)は、未完成の nightly Rust 機能と LLVM バックエンド制限により大きなマシンコードを生成し、x86 のギャップを部分的に説明します。WebAssembly テストではタイルコールインタープリターがネイティブ Rust より 3.7–4.6 倍遅く動作します。
このインタープリターを追加するプルリクエストはマージされ、ARM64 でデフォルト有効化され、x86‑64 ではネイティブ機能が利用できない場合に二番目の選択肢として提供されます。著者はメールまたはソーシャルメディアでフィードバックを受け付け、x86 と WASM のパフォーマンス向上を図っています。
本文
Uxn エミュレーションの基礎
Uxn は 256 命令からなるシンプルなスタックマシンです。
CPU 全体は 64 KB 程度で、次のように分割されています。
- スタックが 2 本(各 256 バイト)とインデックスバイト
- データとプログラムテキスト両方に使う 65 536 バイトの RAM
- プログラムカウンタ(PC)は 2 バイト
- 周辺機器用に「デバイスメモリ」と呼ばれる 256 バイト
最も簡単なエミュレータは、PC で指し示す RAM の1バイトを読み取り、
それに対応する命令関数へ渡して実行させるという構造です。
fn run(core: &mut Uxn, dev: &mut Device, mut pc: u16) -> u16 { loop { let op = core.next(&mut pc); let Some(next) = core.op(op, dev, pc) else { break pc }; pc = next; } }
impl Uxn { fn op( &mut self, op: u8, dev: &mut Device, pc: u16, ) -> Option<u16> { match op { op::BRK => self.brk(pc), op::INC => self.inc::<0b000>(pc), op::POP => self.pop::<0b000>(pc), op::NIP => self.nip::<0b000>(pc), op::SWP => self.swp::<0b000>(pc), // … 等々 op::ORA2kr => self.ora::<0b111>(pc), op::EOR2kr => self.eor::<0b111>(pc), op::SFT2kr => self.sft::<0b111>(pc), } } }
命令は 256 種類あり、フラグでパラメータ化されているものが多いです。
たとえば
INC 命令(スタックトップのバイトをインクリメント)は次のように実装されています。
impl Uxn { pub fn inc<const FLAGS: u8>(&mut self, pc: u16) -> Option<u16> { let mut s = self.stack_view::<FLAGS>(); let v = s.pop(); s.push(v.wrapping_add(1)); Some(pc) } }
すべてのオペコード実装は
op 関数内にインライン化されていますが、メモリへのアクセスを減らし、ディスパッチブランチを予測可能にする余地があります。
アセンブリでのスレッド化コード
アセンブリ実装では「トークン・スレッド化」を採用しています。
CPU の状態はすべてレジスタに保持し、各命令の最後で次の命令へジャンプします。
; x0 | スタックポインタ ; x1 | スタックインデックス ; x4 | RAM ポインタ ; x5 | プログラムカウンタ ; x8 | オペコードテーブル _INC: ldrb w9, [x0, x1] ; スタックトップのバイトを読み取る add w9, w9, #1 ; インクリメント strb w9, [x0, x1] ; 書き戻し ldrb w9, [x4, x5] ; 次のオペコードをロード add x5, x5, #1 ; PC をインクリメント and x5, x5, #0xffff ; PC のラップ ldr x10, [x8, x9, lsl #3] ; オペコード実装のアドレスを取得 br x10 ; 次の命令へジャンプ
ディスパッチを各オペコードに分散させることで、ブランチプリディクタが
命令列を学習しやすくなります。結果として ARM64 で 40–50 % の高速化、
x86‑64 では約 2 倍の速度向上が実現しました。
ただし、コード量は約 2000 行に及び、非常に安全性に欠けます。
x86 版を移植した際にはデバイス RAM の外側に書き込むアウト・オブ・ボウンズ
エラーが発生し、特定のプログラム実行後にファザ―がセグフォルトしました。
Rust でのタイルコール
アセンブリと同等の動作(レジスタに状態を保持し、命令末尾でディスパッチ)を Rust だけで実現したい。そこで「タイルコールインタープリタ」というアイデアを採用します。
基本構成
- プログラム状態を関数引数に渡す
システムの呼び出し規約に合わせてレジスタへマッピングされる。 - 各関数の末尾で次の関数を呼び出す(タイルコール)
以下は
inc 命令の例です。
const TABLE: FunctionTable = FunctionTable([ brk, inc::<0b000>, pop::<0b000>, nip::<0b000>, swp::<0b000>, rot::<0b000>, dup::<0b000>, // …etc ]); fn inc<'a, const FLAGS: u8>( stack_data: &'a mut [u8; 256], stack_index: u8, rstack_data: &'a mut [u8; 256], rstack_index: u8, dev: &'a mut [u8; 256], ram: &'a mut [u8; 65536], mut pc: u16, vdev: &mut dyn Device, ) -> (Uxn<'a>, u16) { let mut core = Uxn { stack: Stack { data: stack_data, index: stack_index }, ret: Stack { data: rstack_data, index: rstack_index }, dev, ram, }; match core.inc::<FLAGS>(pc) { Some(pc) => { let op = core.next(&mut pc); TABLE.0[op as usize]( core.stack.data, core.stack.index, core.ret.data, core.ret.index, core.dev, core.ram, pc, vdev, ) } None => (core, pc), } }
この実装では既存の
Uxn のオペコード関数を再利用しつつ、
呼び出しごとに構築・解体を行うため、ボイラープレートが多くなります。
タイルコールで発生するスタックオーバーフロー
実際にコンパイルして実行すると、次のようなエラーになります。
thread 'snapshots::tailcall::mandelbrot' (67889685) has overflowed its stack fatal runtime error: stack overflow, aborting error: test failed, to rerun pass `-p raven-varvara --test snapshots`
リリースビルドでもコンパイラはスタックフレームを最適化せず、 呼び出しごとにスタック上に積み重ねてしまうため、オペコード数が増えるほど 深いスタックが生成されます。
この問題を解決するには「タイルコール」指示子
become(nightly 版で追加)を使用します。
match core.inc::<FLAGS>(pc) { Some(pc) => { let op = core.next(&mut pc); become TABLE.0[op as usize]( core.stack.data, core.stack.index, core.ret.data, core.ret.index, core.dev, core.ram, pc, vdev, ) } }
become を使うと、呼び出し側のスタックフレームをそのまま
呼び出される関数のフレームで置き換える(tail call)ことが保証され、
スタックオーバーフローは回避できます。
ボイラープレート削減マクロ
先ほど示した
inc のような関数をすべて手書きするのは煩雑です。そこで以下のようなマクロで自動生成します。
macro_rules! tail_fn { ($name:ident $(::<$flags:ident>)?) => { tail_fn!($name $(::<$flags>)?[][vdev: &mut dyn Device]); }; ($name:ident $(::<$flags:ident>)?($($arg:ident: $ty:ty),*)) => { tail_fn!($name $(::<$flags>)?[$($arg: $ty),*][]); }; ($name:ident $(::<$flags:ident>)?[$($arg0:ident: $ty0:ty),*][$($arg1:ident: $ty1:ty),*]) => { extern "rust-preserve-none" fn $name<'a, $(const $flags: u8)?>( stack_data: &'a mut [u8; 256], stack_index: u8, rstack_data: &'a mut [u8; 256], rstack_index: u8, dev: &'a mut [u8; 256], ram: &'a mut [u8; 65536], pc: u16, $($arg0: $ty0),* $($arg1: $ty1),* ) -> (UxnCore<'a>, u16) { let mut core = UxnCore { … }; match core.$name::<$($flags)?>(pc, $($arg0),*) { Some(mut pc) => { let op = core.next(&mut pc); become TABLE.0[op as usize]( core.stack.data, core.stack.index, core.ret.data, core.ret.index, core.dev, core.ram, pc, $($arg0),* $($arg1),* ) } None => (core, pc), } } }; }
マクロを使えば、次のように宣言できます。
tail_fn!(brk); // 引数なし tail_fn!(inc::<FLAGS>); // フラグ付き tail_fn!(dei::<FLAGS>(dev: &mut dyn Device)); // フラグ+追加引数
#![forbid(unsafe_code)] を残したままで安全な Rust の範囲内で動作します。
コンパイラのコード生成ノート
コンパイラはほぼ完全にボイラープレートを除去し、実際にはオペコードごと
に最小限のアセンブリを出力します。
例として
inc::<0> の生成結果です。
0000000100039c50 <raven_uxn::tailcall::inc::<0>>: and x8, x0, #0xff ; スタックポインタのマスク ldrb w9, [x21, x8] ; バイト読み取り add w9, w9, #1 ; インクリメント strb w9, [x21, x8] ; 書き戻し and x8, x2, #0xffff ; PC をマスク ldrb w8, [x24, x8] ; 次オペコード取得 adrp x9, 0x100279000 ; テーブルベースロード add x9, x9, #3776 ; オフセット ldr x3, [x9, x8, lsl #3] ; ジャンプ先取得 add w2, w2, #1 ; PC をインクリメント br x3 ; タイルコール
主な違いは、レジスタマスクを「使用前」に行っている点と、
テーブルベースがオフセットでロードされる点です。
x86 ではテーブルベースを引数に渡すことでパフォーマンスが向上します。
パフォーマンス評価
| プロファイル | VM(Rust) | アセンブリ | タイルコール |
|---|---|---|---|
| M1 Macbook | 2.41 ms / 125 ops | 1.32 ms / 87 ops | 1.19 ms / 76 ops |
Mandelbrot(大規模プログラム)
| プラットフォーム | VM(Rust) | アセンブリ | タイルコール |
|---|---|---|---|
| M1 Macbook | 4.70 ms / 264 ops | 1.84 ms / 168 ops | 3.23 ms / 175 ops |
ARM64 ではタイルコールが手書きアセンブリを上回りますが、x86 では やや遅くなる傾向があります。
ADD2
の低速化の原因
ADD2ADD2(スタックトップの 16 ビット値二つを加算する)については、手書きアセンブリとタイルコール版で大幅に長さが異なります。
_ADD2: movzx eax,BYTE PTR [rbx+r12*1] dec r12b movzx ecx,BYTE PTR [rbx+r12*1] dec r12b shl ecx,0x8 or eax,ecx movzx ecx,BYTE PTR [rbx+r12*1] lea r11,[r12-0x1] and r11,0xff movzx edx,BYTE PTR [rbx+r11*1] shl edx,0x8 or ecx,edx add ecx,eax mov BYTE PTR [rbx+r12*1],cl shr ecx,0x8 mov BYTE PTR [rbx+r11*1],cl movzx eax,BYTE PTR [r15+rbp*1] inc bp lea rcx,[rip+0x22e5c7] jmp QWORD PTR [rcx+rax*8]
タイルコール版は 121 バイトで、2 個の 64 ビットレジスタをスパイリング・リストアしています。
これは Rust の未完成機能(
become)に起因し、LLVM バックエンドが
スパイリングを回避できていないためです。
WebAssembly での実行
WASM もタイルコールをサポートしており、Raven は WASM にコンパイル可能です。
Mandelbrot の結果は次のとおりです。
| エンジン | VM(ms) | タイルコール(ms) |
|---|---|---|
| Firefox 149.0 | 264 | 311 |
| Chrome 146.0.7680.178 | 244 | 905 |
| Wasmtime (e9e1665c5) | 128 | 595 |
タイルコール版は Firefox で 1.2×、Chrome で 3.7×、Wasmtime では 4.6×遅く、 JIT が最適なマシン命令に落とし込めていないことが原因です。
結論
タイルコールインタープリタは PR としてマージされ、0.3.0 リリースでデプロイ済みです。
有効化すると ARM64 ではデフォルトの選択肢となり、x86‑64 では
ネイティブ機能が無効な場合に二番目の選択肢になります。
今後は x86 および WASM のパフォーマンス向上についてのご提案を歓迎します。
お気軽にメールや SNS でお知らせください。