アーキテクチャ概要
Lambda SmalltalkはLuaJITやNode.js V8に匹敵する性能を実現するため、複数の最適化技術を組み合わせている。
Cranelift JIT
ホットメソッドはCraneliftを使用して自動的にネイティブコードにコンパイルされる。CraneliftはWasmtimeやRustの代替コード生成にも使われている同じバックエンド。
- ホットメソッド検出: 10回以上呼び出されたメソッドがJITコンパイル対象
- クロスプラットフォーム: x86-64, ARM64, RISC-V
- 投機的実行: 高速パス最適化付きオーバーフローチェック
Fast Path(オーバーフローなし):
sadd_overflow a, b -> (result, overflow_flag)
brif overflow_flag -> deopt
tag result as SmallInteger
continue
脱最適化(オーバーフロー時):
全レジスタをフレームに保存
再開IPと共にDEOPTセンチネルを返す
分岐予測によりオーバーフローチェックのコストはほぼゼロ。Smalltalkの無限精度整数セマンティクスを維持しながらネイティブ速度を実現。
True Deoptimization(真の脱最適化)
JITコンパイルされたコードがオーバーフローや非SmallInteger引数に遭遇した場合、真の脱最適化(LuaJITスタイル)を実行する:
- レジスタ状態を保存 - 現在のフレームに保存
- 再開IPを記録 - オーバーフローが発生した正確なバイトコード命令
- DEOPTセンチネルを返す - VMへ通知
- インタプリタが再開 - 保存されたIPからLargeInteger完全サポートで実行
これは単純な「メソッドを再起動」アプローチとは異なる。Lambda SmalltalkはJITが失敗した正確な地点から実行を再開し、全ての中間結果を保持する。
例: x := (2^31 - 1) + 1
JIT実行: LOADI r0, 2147483647
ADDI r0, r0, 1 ← ここでオーバーフロー検出
[DEOPT: r0を保存, resume_ip=1]
インタプリタが ip=1 から再開:
ADDI r0, r0, 1 ← LargeIntで再実行
RETURN r0 → LargeInteger(2147483648)
脱最適化は実際にはまれ(ほとんどのコードはSmallIntを使用)だが、発生した場合でも正確で効率的。
NaN Boxing
全ての値はNaN boxingを使って8バイトに収まる:
| 型 | 表現 |
|---|---|
| Integer | 下位32ビット(SmallInt)またはヒープオブジェクト(LargeInt) |
| Float | 標準IEEE 754 double |
| Object | ペイロードにオブジェクトIDを持つNaN |
| Symbol | ペイロードにシンボルIDを持つNaN |
| Bool/Nil | 特殊なNaNパターン |
型タグなし、プリミティブ値へのポインタ追跡なし。算術演算はマシン整数で直接実行。
インラインキャッシュ + PIC
メソッドディスパッチは3段階キャッシュシステムを使用:
- モノモーフィック高速パス: 単一クラスIDチェック + 直接ジャンプ(2026年1月追加)
- インラインキャッシュ(256エントリ): コールサイト用の直接マップキャッシュ
- ポリモーフィックインラインキャッシュ(サイトあたり4エントリ): 多態的ディスパッチを処理
SEND実行時:
1. レシーバクラス == キャッシュされたクラス をチェック(モノモーフィック高速パス)
→ メソッドに直接ジャンプ(1回の比較、1回の分岐)
2. インラインキャッシュをチェック(ヒット = 直接ジャンプ)
3. PICをチェック(ヒット = 4方向ルックアップ)
4. 完全なメソッド探索(ミス)
5. キャッシュを更新
モノモーフィック高速パスは最も一般的なケースを最適化:同じコールサイトで同じクラス。ベンチマークではメソッド集約的なコード(コレクション反復など)で15-20%の改善を示す。
レジスタベースVM
256レジスタ/フレームを持つ32ビット固定長命令:
Format A: op(8) + rd(8) + ra(8) + rb(8) [3レジスタ]
Format B: op(8) + rd(8) + ra(8) + imm8 [2レジスタ + 即値]
Format C: op(8) + rd(8) + imm16 [1レジスタ + 即値]
- スタック操作オーバーヘッドなし
- フリーリスト再利用によるレジスタ割り当て
- コピー伝播とデッドストア除去
統合ディスパッチ
プリミティブを含む全ての操作がメッセージ送信を経由:
Sqlite open: 'db.sqlite'
コンパイラはSENDを発行。VMはSqliteクラスでopen:を探し、プリミティブだと判明すると、フレーム割り当てなしでRust実装を直接呼び出す。
性能
ベンチマークは載せない。自分で動かして、速さを体感してほしい。
性能最適化
コアアーキテクチャに加えて、Lambda Smalltalkはホットパスに対する的を絞った最適化を複数採用している:
正規表現コンパイル済みキャッシュ
正規表現は一度コンパイルされ、キャッシュされる:
pub compiled_regex_cache: HashMap<String, Regex>,
こう書くと:
'hello world' match: /\w+/
最初のマッチで正規表現をコンパイルして保存。以降のマッチはコンパイル済みパターンを再利用 — 繰り返しパターンでゼロオーバーヘッド。
ParseTree専用オブジェクト型
Grammar マッチングはパースツリーを生成する。これは元々Dictオブジェクトだったが、プロファイリングでツリー構築と走査に大きなオーバーヘッドがあることが判明。
Lambda Smalltalkは現在、専用のRegHeapObject::ParseTreeを持つ:
RegHeapObject::ParseTree {
rule: String,
text: String,
children: Vec<NanValue>,
captures: HashMap<String, NanValue>,
}
直接フィールドアクセス(ハッシュルックアップなし)+ 最適化されたプリミティブメソッド = 文法マッチングが3-5倍高速化。
プリコンパイルされた Grammar
文法はGrammar from:時に内部表現にコンパイルされる:
grammar := Grammar from: '
expr: @left NUM OP @right NUM
NUM: /[0-9]+/
OP: /[+\-*/]/
'.
パースは一度だけ実行。その後のGrammar replace:in:with:やfindAll:in:操作はプリコンパイル済み構造を使用 — 再パースオーバーヘッドなし。
範囲ループ最適化
コンパイラは1 to: N do: [:i | ...]のような一般的なパターンを検出して、専用バイトコードを生成:
- 直接整数ループ(boxing/unboxingなし)
- コンパイル時に判明する境界
- ループ変数はレジスタに留まる
これによりC言語のforループと同等の速度で反復処理を実現。
Rust と動的言語の融合
「Rustでガベージコレクタを実装できるのか?」できる。こうやって。
所有権の問題
Rustの所有権モデルはガベージコレクションと相容れないように見える。全ての値には唯一の所有者が必要だが、GCは循環参照や共有参照を含む任意のオブジェクトグラフを追跡しなければならない。
Lambda Smalltalkはこれを明確に分離して解決した:Rustがメモリを所有し、Smalltalkが参照を所有する。
NaN Boxing:統一の鍵
全てのSmalltalk値は8バイトに収まる。整数や真偽値はレジスタに即値として直接置かれる。オブジェクトはヒープベクターを指す32ビットのIDとして表現される。
pub struct NanValue(u64);
// Integer: QNAN | SIGN_BIT | 32ビット値(メモリ割り当てなし)
// Object: QNAN | TAG | 32ビットヒープID(ただの数値)
GCが「これはオブジェクトか?」と問うとき、答えは1ビットのチェックで出る。ポインタを辿る必要なし。型タグのデコードも不要。ビット演算一発。
ヒープ:シンプルなベクター
pub heap: Vec<RegHeapObject>
これだけだ。Rustがベクターを所有する。Smalltalkコードはインデックスしか見ない。Array new と書くと、返ってくるのは数値 — このベクターへのインデックスだ。
この分離はエレガントだ:Rustの借用チェッカーは満足(Vecを所有している)、SmalltalkのGCも満足(数値を並べ替えるだけ)。
Cheneyのアルゴリズム:生きているものをコピー
GCはCheneyスタイルのコピーコレクションを使用:
- 全レジスタとフレームを走査し、ルートオブジェクトIDを収集
- 到達可能な各オブジェクトを新しいヒープにコピー
- フォワーディングテーブルを構築:古いID → 新しいID
- 全ての参照を新しいIDに置き換え
- 古いヒープを破棄
死んだオブジェクト?触りもしない。古いヒープは単に消える。マークフェーズなし、スイープフェーズなし。コピーして忘れる。
// GCの動作
let mut new_heap = Vec::new();
let mut forwarding = HashMap::new();
// 到達可能オブジェクトをコピー
for old_id in worklist {
let new_id = new_heap.len();
new_heap.push(self.heap[old_id].clone());
forwarding.insert(old_id, new_id);
}
// ヒープを交換(古いヒープはここでドロップ)
self.heap = new_heap;
デフォルト閾値:1600万オブジェクト(ヒープメタデータで約128MB)。なぜこんなに高い?GCがコピー方式だからだ — 生きているオブジェクトを全てクローンする。生存率50%なら、GC中に64MBのメモリ割り当てが発生する。GCを頻繁に走らせるとメモリ帯域を食い潰す。
最適解:ヒープを大きく成長させてから、一度に大掃除。ほとんどのスクリプトはGCを一度も起動しない。
外部リソース:Rc<RefCell>
ファイル。ネットワークソケット。データベース接続。これらは通常のオブジェクトのようにコピーできない。
RegHeapObject::FileStream {
reader: Option<Rc<RefCell<BufReader<File>>>>,
}
Rc(参照カウント)が共有を処理する。複数のSmalltalkオブジェクトが同じファイルハンドルを参照できる。最後の参照がGCされると、RustのDropトレイトが自動的にファイルを閉じる。
明示的なcloseは不要。リソースリークもなし。RustのRAIIとSmalltalkのGCの出会い。
Unsafe:外科的精度
そう、unsafeはある。正確に3箇所だけ:
- ホットループでのレジスタアクセス — 毎命令の境界チェックは遅すぎる
- 命令フェッチ —
get_unchecked()がナノ秒を節約、それが効く - JIT呼び出し — 生成されたマシンコードへの生ポインタ渡し
全てのunsafeブロックは、事前検証済みのインデックスに対してのみ動作する。バイトコード検証はロード時に実行される。検証が通れば、インデックスは有効であることが保証される。
// これが安全な理由:
// 1. ipは検証器で境界チェック済み
// 2. codeベクターは実行中に変更されない
let inst = unsafe { *code.get_unchecked(ip) };
Rust原理主義者は眉をひそめるかもしれない。ベンチマーク結果は黙らせる。
クラスシステム
仮想メタクラス
伝統的なSmalltalkは、並列階層を形成する明示的なメタクラスを持つ。Lambda Smalltalkは異なるアプローチを取る:ロード時に生成される仮想メタクラス。
pub struct RegClass {
pub name: u32, // インターン済みシンボルID
pub superclass: Option<u32>, // 親クラスID
pub methods: HashMap<u32, usize>, // インスタンスメソッド
pub class_methods: HashMap<u32, usize>, // クラスサイドメソッド
pub ivar_count: u16, // 継承分を含む
pub metaclass_id: Option<u32>, // 仮想メタクラス
}
VMがクラスをロードするとき:
- クラスを作成し、インスタンスメソッドを登録
- メタクラスを生成 —
"ClassName class"という名前 - クラスメソッドをメタクラスに移行 — インスタンスメソッドとして
- メタクラスの継承関係をリンク — クラス階層をミラーリング
Object <── Object class
↑ ↑
Collection <── Collection class
↑ ↑
Array <── Array class
つまりArray classは実際のクラスであり、そのインスタンスはクラスオブジェクトだ。Array newを呼ぶとき、VMはArrayクラスオブジェクトにnewを送り、メタクラス経由でディスパッチする。
インスタンス変数レイアウト
インスタンス変数はインデックスアクセス。継承した変数が先:
Person (ivars: name, age)
↑
Employee (ivars: salary)
Employeeインスタンスのレイアウト:
[0] name (Personから継承)
[1] age (Personから継承)
[2] salary (自身の)
コンパイラはivar名をインデックスにコンパイル時に解決。GETIVARとSETIVARは8ビットインデックスを使用 — 高速、キャッシュフレンドリー、ハッシュルックアップなし。
ブロックとクロージャ
Upvalueセルパターン
ブロックが外側の変数をキャプチャするとき、Lambda Smalltalkはヒープにupvalueセルを割り当てる:
RegHeapObject::Upvalue(NanValue) // 可変セル
| counter |
counter := 0.
[ counter := counter + 1. counter ]
何が起きるか:
counterはローカル変数(レジスタ)- ブロック作成時、
counterの値はUpvalueセルでラップされる - ブロックはこのセルへの参照を保持
- 外側のスコープとブロックは同じセルを共有
複数のブロックが同じ変数をキャプチャできる:
| x getter setter |
x := 10.
getter := [ x ].
setter := [:v | x := v ].
getter value. "→ 10"
setter value: 20.
getter value. "→ 20"
両方のブロックが同じUpvalueセルを参照。変更は全ての場所から見える。
ブロック構造
RegHeapObject::Block {
method_idx: usize, // コンパイル済みバイトコード
upvalues: Vec<u32>, // キャプチャしたセルのヒープID
home_frame_idx: usize, // 非ローカルリターン用
home_frame_id: u64, // エスケープしたブロックの検出
home_receiver: NanValue, // ブロック内の`self`
}
home_receiverは注目に値する。ブロック内でselfは、ブロック自身ではなく囲んでいるメソッドのレシーバーを参照する:
Object subclass: #Counter instanceVariableNames: 'value'.
Counter >> increment
[ value := value + 1 ] value. "← `value`はselfのivar"
非ローカルリターン
Smalltalkのブロックは囲んでいるメソッドからリターンできる:
findFirst: aBlock in: aCollection
aCollection do: [:each |
(aBlock value: each) ifTrue: [ ^ each ] "← findFirst:in:からリターン"
].
^ nil
BLOCKRET opcodeがhome_frame_idまでスタックを巻き戻す。そのフレームが既にリターン済み(ブロックがエスケープした)なら、エラー:
makeBlock
^ [ ^ 42 ] "ブロックがエスケープ"
makeBlock value "エラー: 死んだフレームからの非ローカルリターン"
シンボルテーブル
全てのメソッド名、クラス名、シンボル値は一度だけインターンされる:
pub struct SymbolTable {
lookup: HashMap<String, SymbolId>, // 文字列 → ID
strings: Vec<String>, // ID → 文字列
}
:fooを初めて使うとき、シンボルテーブルは:
- "foo"が存在するかチェック(O(1)ハッシュルックアップ)
- なければ、次のIDを割り当てて文字列を保存
- 32ビットの
SymbolIdを返す
以降、:foo = :fooの比較は単なる整数比較。
高速ハッシュ
シンボルインターンはFxHashスタイルのハッシュを使用:
fn hash(&mut self, bytes: &[u8]) {
for &byte in bytes {
self.0 = (self.0.rotate_left(5) ^ (byte as u64))
.wrapping_mul(0x517cc1b727220a95);
}
}
メソッドディスパッチは絶え間なく起きる。全てのSENDはセレクタでメソッドを探す。インターン済みシンボルなら、そのルックアップは:
Hash(class_id, selector_id) → キャッシュエントリ → メソッドインデックス
ホットパスで文字列比較なし。
メソッドルックアップとdoesNotUnderstand:
ルックアップアルゴリズム
メッセージを送るとき:
array add: 42
VMは継承チェーンを歩く:
fn lookup_method(&self, class_id: u32, selector: u32) -> Option<usize> {
let mut current = Some(class_id);
while let Some(cid) = current {
if let Some(&idx) = self.classes[cid].methods.get(&selector) {
return Some(idx); // 見つかった
}
current = self.classes[cid].superclass; // 親を試す
}
None // どこにもなかった
}
クラスメソッドは同様のアルゴリズムだが、メタクラス階層を探索。
doesNotUnderstand: — 究極のフォールバック
ルックアップが失敗すると、VMはパニックしない。同じレシーバーにdoesNotUnderstand:を送る:
fn try_does_not_understand(&mut self, class_id: u32, selector: u32, ...) {
// セレクタと引数を持つMessageオブジェクトを作成
let message = self.create_message_object(selector, args, receiver);
// 同じクラスでdoesNotUnderstand:を探す
let dnu_method = self.lookup_method(class_id, self.dnu_selector_id)?;
// Messageを渡して呼び出す
Some((dnu_method, message))
}
これにより強力なパターンが可能:
Object >> doesNotUnderstand: aMessage
"委譲、ログ、またはエラーを発生"
Transcript show: 'Unknown: ', aMessage selector.
^ nil
Messageオブジェクトは全てを含む:
selector— 見つからなかったメソッド名arguments— 渡された値receiver— メッセージを受けたオブジェクト
例外処理
ハンドラスタックアーキテクチャ
例外ハンドラはコールスタックとは別のスタックを形成:
pub struct ExceptionHandler {
frame_idx: usize, // どのフレームがインストールしたか
handler_block: NanValue, // レスキューブロック
exception_class: u32, // フィルタ(0 = 全てキャッチ)
resume_ip: usize, // 処理後の継続位置
}
以下を書くと:
[ self riskyOperation ]
on: Error
do: [:exc | self handleError: exc ]
コンパイラは以下を生成:
PUSHHANDLER (ハンドラをインストール)
... リスキーなコード ...
POPHANDLER (正常終了、ハンドラを削除)
JUMP past-handler
handler-code:
... エラー処理 ...
スタック巻き戻し
signalが呼ばれると:
- ハンドラを検索 — スタックの上から
- 例外クラスをチェック — フィルタにマッチするか?
- フレームを巻き戻し — ハンドラのフレームまでポップ
- ハンドラブロックを実行 — 例外オブジェクトを渡して
- 再開 —
resume_ipから
Error signal: 'Something went wrong'
// 簡略化した巻き戻し
while let Some(handler) = self.exception_handlers.last() {
if handler.matches(exception_class) {
self.unwind_to(handler.frame_idx);
return self.call_block(handler.handler_block, exception);
}
self.exception_handlers.pop(); // これではない、外側を試す
}
// ハンドラが見つからない — スタックトレースでクラッシュ
ensure: — 必ず実行
ensure:パターンはクリーンアップを保証:
file := File open: 'data.txt'.
[ self process: file ]
ensure: [ file close ]
process:が例外をシグナルしても、ファイルは閉じられる。VMは別のensure_handlersスタックを維持:
pub struct EnsureHandler {
frame_idx: usize,
ensure_block: NanValue,
}
巻き戻し中、ensureブロックはLIFO順で実行。例外は伝播を続ける。
パーサー
手書き、生成器なし
Lambda Smalltalkは手書きの再帰下降パーサーを使用 — PEGなし、パーサージェネレーターなし。Smalltalk独特の文法を精密に制御できる。
優先順位ルール
Smalltalkにはちょうど3つの優先レベルがある:
unary > binary > keyword
それだけだ。演算子優先順位テーブルなし。明確さのための括弧も不要。
2 + 3 * 4 "→ 20, 14じゃない"
array at: 1 + 2 "→ array at: 3"
パーサーは3つの相互再帰関数でこれを実装:
fn parse_keyword(&mut self) -> Expr {
let mut expr = self.parse_binary()?; // まずbinary
// それからkeywordパートを収集
}
fn parse_binary(&mut self) -> Expr {
let mut expr = self.parse_unary()?; // まずunary
// それからbinary演算子を収集(左から右)
}
fn parse_unary(&mut self) -> Expr {
let mut expr = self.parse_primary()?; // リテラル、変数
// それからunaryメッセージを収集
}
カスケード:複数メッセージ、一つのレシーバー
array
add: 1;
add: 2;
add: 3
セミコロンは「同じレシーバーに別のメッセージを送る」という意味。パーサーはこれを脱糖:
Cascade {
receiver: array,
messages: [
(add:, [1]),
(add:, [2]),
(add:, [3]),
]
}
各メッセージは結果を返すが、カスケードは元のレシーバーを返す。
Lambda Smalltalkの拡張
クラシックSmalltalkからの逸脱:
メソッド途中のtemporaries:
x := 10.
| y | "← ここで許可"
y := 20.
標準Smalltalkは全てのtemporariesをメソッド先頭に要求。Lambda Smalltalkはどこでも許可、nilで初期化。
暗黙の変数宣言:
x := 10. "← | x | 不要"
PythonやRubyのように、最初の代入で変数が作られる。明示的宣言はオプション。
実装統計
- ~80 opcode(JIT最適化されたコア操作)
- ~200 プリミティブ操作
- 43 組み込みクラス
- 4つの最適化パスを持つシングルパスバイトコードコンパイラ