
2026/07/20 22:59
Meta ガベージコレクション:OCaml の GC を用いて Rust をガベージコレクする
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
Soteria Rust は、Tree Borrows のエイリアシングモデルを用いて Rust プログラムを検証するためのシンボル実行ツールであり、初期の二次的な時間計算量を解決することで著しいパフォーマンスの飛躍を達成しました。当初、Tokio といった大規模なプロジェクトでは OCaml の状態機械における無制限な状態成長により、初期化が遅く悩まされていましたが、現在は複雑なコードベースも効率的に処理できるようになりました。ボトルネックの要因は、安全性を保証するためにコンパイラの最適化を無効化すると、
std::ops::Range などの例で暗黙的な再転送(implicit reborrows)が生じ、木構造が二次的に成長してしまうものでした。完全なガベージコレクタを再実装することなくこれを修正するため、開発者は OCaml の既存の GC にカスタム「バックオフ」機構を統合し、木が特定の閾値を超えた場合のみ主要なコレクションをトリガーしました(最適な閾値は 256 ノード).このアプローチにより、単純なベンチマークループの実行時間はオーバーヘッド 87%から線形時間へ削減され、Tokio の初期化速度は約 8〜10 倍に向上しました。この解決策はウィークセットやエフェメロン構造を活用し、わずか約 40 行のコードで実現できました。パフォーマンスが特に重要なケースでは、--ignore-aliasingフラグにより Tree Borrows の追跡を無効化できるほか、堅牢な安全性保証を保ちながら標準的なガベージコレクションのパターンがニッチな検証タスクに適応可能であることを示しました。本文
Soteria Rust: Tree Borrows の二次的計算コスト問題を「メタ・ガーベージコレクション」で解決しました
Soteria Rust は、Rust プログラムの検証を行うシンボリック実行ツールです。未定義動作(UB)やエイリアシングエラーを検出するために、すべての実行パスを探索しますが、Tree Borrows という最新の高度なエイリアシングモデルを実装すると、計算コストが二次的(O(N²))に膨れ上がるという奇妙な現象が発生しました。
この問題を解決するため、約 40 行のコード改変で OCaml の標準機能を活用し、計算時間を線形(O(N))に変化させました。最大10 倍もの速度向上を実現しました!
以下に発見の経緯と解決策について詳しく解説します。
問題発見:ベンチマーク例での異常な遅延
最初の問題を特定したきっかけは、単純な増分ループの実行でした。
ベンチマークコード
以下の関数は、変数
r を受け取り、一度リセットしてから N 回増分し、結果が N に等しいことをアサートします。
fn loop_incr<const N: u32>(r: &mut u32) { let _old = *r; *r = 0; for _ in 0..N { *r += 1; } assert_eq!(*r, N); }
結果:O(N²) の爆発的な遅延
この関数を N で実行すると、実行時間は N に応じて二乗して増加していました。これは直感的に不合理です。
- N = 1000の場合の分析:
- 全所要時間:3.02 秒
- Tree Borrows の実装部分での消費時間:2.62 秒(全体の 87%)
この 87% は、本来必要ではない計算リソースが Tree Borrows の内部状態追跡に浪費されていた証拠です。
原因分析:Tree Borrows と木構造の肥大化
Tree Borrows は、borrow checker が無力なコードにおけるエイリアシングルールを定義する機能で、参照やポインタの関係を「木構造」で追跡します。各メモリアクセス時にノードの状態が遷移しますが、この状態機械が膨れ上がるのが問題の原因でした。
MIR での肥大化の正体
最適化を無効にした中間表現(MIR)を見ると、ループ内で Range オブジェクトに対して行われる以下の操作が木構造にノードを追加し続けています。
- 関数呼び出し時のリボロー
- メソッド呼び出し(自動参照含む)
- フィールドアクセス
特に
<Range as Iterator>::next の呼び出しが原因で、各反復ごとに木に 9 つの新しいノードが追加され、それぞれについて状態遷移(アクセス)が行われます。
- N = 1000の場合:
- 木のノード数:9,010
- アクセス回数:10,012
これにより、ノードアクセスの総量は約 $45 \times N^2$ となり、二次的な計算負荷が生まれました。
なぜガーベージコレクションが必要なのか?
Tree Borrows の状態機械では、参照の状態が右端の「↯(未定義動作)」に到達した場合のみ UB が検出されます。 重要: UB に至る経路は、木構造上でのローカルアクセス(↓)のみです。
ループ終了後、各反復で作られた木ノードの一部は、ライブな参照が指していないため「到達不能」になります。しかし、単純に探索し続けると、これらが UB 検出への寄与がないにもかかわらず計算コストとして無駄にカウントされ続けてしまいます。したがって、到達不能なノードを安全に削除(ガーベージコレクション)する必要があります。
解決策:OCaml のメタ・ガーベージコレクション
専用 GC を実装するよりも、Soteria Rust が OCaml で書かれているという事実を活かした方が効率的です。OCaml の標準的な**「メタ・ガーベージコレクション(meta garbage collection)」**を利用しました。
実装の核心:2 つのポイント
- Rust の参照は OCaml での強参照として保持される
- Rust 上の可変参照が生きている限り、対応する OCaml オブジェクトも GC されません。これが安全を保証します。
- Tree Borrows の木ノードは OCaml の「弱参照」で格納される
セットやWeak
を使い、木に含まれるノードが相互に強循環(GC の阻止要因)を作らないように設計しました。Ephemeron
これにより、Rust 側の手動管理なしで、OCaml GC が自動的に到達不能な木ノードを回収できます。
性能チューニングと結果
OCaml の標準 GC は「マイナーコレクション」と「メジャーコレクション」の二段階で行われますが、到達不能な古いノードを回収するには**
Gc.major()(メジャーコレクション)**の実行が必要です。
また、木が小さすぎる状態での GC はオーバーヘッドが大きいため、木サイズがしきい値 T を超えるときに実行するよう調整しました。GC が何も回収できない場合はバックオフ(実行間隔を延長)を行い、スループットを最適化しています。
ベンチマーク結果比較(N = 1000)
| 状態 | 実行時間 | 相対速度 |
|---|---|---|
| Tree Borrows GC なし (O(N²)) | 3.02 秒 | 1x |
| 最適化後 (T=256) | 0.54 秒 | 5.6x 向上 |
さらに N = 2000 の場合、10.6 倍の高速化が実現し、計算時間は N に対して**線形(O(N))**に収束しました。木構造は T に達するまで成長し、そこで GC が働き「鋸歯状」なパターンを描きますが、全体としての性能は劇的に向上しています。
他のベンチマークでの効果と教訓
このアプローチを他のベンチマークにも適用した結果、大部分でプラスの結果(高速化)を得られました。一部の場合弱参照のコストによる遅延が見られることもありますが、二次的成長が回避されることは大きな成功です。
実例:Tokio の初期化コード
配列サイズを 64 から 1024 に増やしたストレステストでの結果です。
| ツール設定 | 実行時間 | 改善率 |
|---|---|---|
| Tree Borrows (無) | 14.8 秒 | - |
| Tree Borrows (同分野類似ツール) | 15.4 秒 | - |
| Soteria Rust (Tree Borrows + GC) | 1.7 秒 | 約 8.7 倍高速化 |
まとめと教訓
- 既存技術の活用: Tree Borrows への GC は Miri などで実装されていますが、OCaml をホスト言語にするため、手動実装ではなく簡易的な「メタ・GC」で実現できました。
- ホスト言語の恩恵: Soteria Rust の設計上の重要な決定が「ホスト言語をフル活用すること」です。これにより複雑な GC ロジックを 40 行で完結させ、高度なカスタマイズが可能になりました。
- 戦略: ホスト言語の特性を理解し、難しい部分(メモリ管理など)は任せる価値は常にあります。
注記: Tree Borrows が依然として過剰に遅い場合、ユーザーは
フラグを使って機能を無効にできます。--ignore-aliasing