RFC-029a: モジュールキャッシュとインクリメンタル再コンパイル
概要
RFC-029「サブRFCの計画」において確保された 029a 行を実現する:すでに安定しているオーケストレータの上に、階層化キャッシュとインクリメンタル再コンパイルのセマンティクスをコンパイラに定義する――L1 プロセス内解析結果、L2 セッション内モジュールレベル、L3 プロセス間ディスクの3層に、統一されたキー定義・無効化ルール・強制メトリクスフックを組み合わせ、現在互いに連携していない4箇所のキャッシュ散在を解消する(#251 P0-6 監査)。
動機
ホストと境界の根拠
RFC-029(承認済み)は本トピックを自身の境界から切り出し、本サブRFCのスロットを明示的に確保している:
含まない:キャッシュ、ファイル監視、ホットリロード、インクリメンタル再コンパイル、パッケージ間循環依存処理。―― RFC-029 §中核原則
| サブRFC | 能力 | 前提 |
|---|---|---|
| 029a | モジュールキャッシュとインクリメンタル再コンパイル | オーケストレータの安定 |
前提「オーケストレータの安定」はすでに満たされている:オーケストレータは RFC-029 に伴って実装され、#232/#243/#244/#245 の納品を通じて検証済み。
現状の問題
キャッシュの散在が互いに連携していない:
| 既存 | 層 | 実際の消費側 | 問題 |
|---|---|---|---|
VALIDATE_CACHE(validate.rs) | プロセス内 | formatter のみ | CLI の check/run もオーケストレータも通らない |
| Z3Backend クエリキャッシュ | 検査セッション | 証明パイプライン | 無効化ルールと予算設計がない(RFC-009a の一文のみ) |
DocumentCache(RFC-017) | LSP セッション | LSP | CLI のコンパイル経路と完全に連携していない |
~/.yaoxiang/cache(RFC-014) | ディスク | パッケージダウンロード | コンパイルと無関係 |
| コードキャッシュ(RFC-028) | ランタイム | JIT | コンパイル時層ではない |
複数エントリポイントでの全工程重複(2026-09-07 実測):
check_files_with_diagnosticsループ内で各ファイルごとに新規Compilerを生成:同一プロセスで19個のライブラリ層ファイルを検証する場合、std 埋め込みチェーンが19回重複して型検査される;- ランタイムエラー系のテストファイルは
checkとrunの二つの子プロセスを通り、全工程が二度実行される; - 借用検査
fast_path_checkは各書き込み操作ごとに全グラフ BFS を独立に実行し、結果キャッシュがない(#251 P0-6 監査の原発見;RFC-009a には「一度の BFS 結果はキャッシュして同一トークンの複数クエリで再利用できる」との一文のみで、機構設計はない)。
コスト解剖(--version ベースライン法):プロセス spawn + CLI 初期化で約50 ms;素のファイル check で約60 ms(フロントエンドコンパイル ≈ 10 ms);std.test チェーンファイルで約80 ms(埋め込みチェーン ≈ +20 ms)。テストループ170ファイルを実測で9.8 s ≈ 58 ms/ファイル、約85% がプロセスライフサイクルコスト。これが本RFCの便益ポジショニングを決定する:複数エントリポイントの重複と std チェーンの重複コンパイルを撲滅すること。プロセス spawn コストはキャッシュの射程外(子プロセス隔離は RFC-036 §6 の設計選択であり、非目標を参照)。
提案
1. 三層キャッシュモデル
キャッシュはライフサイクルによって階層化し、各層は唯一のホストと明確なエントリを持つ:
| 層 | ライフサイクル | ホスト | 典型エントリ |
|---|---|---|---|
| L1 | 単一検査セッション | validate_source / 証明パイプライン | 検証結果(診断 + AST)、借用 unsafe 集合、SMT クエリ |
| L2 | 単一オーケストレーション(プロセス内で再利用可) | CompileSession | Registry、モジュール単位の検証結果 |
| L3 | run をまたぐ | ディスク cache/compile/ | モジュールバイトコード |
階層化の判定基準:エントリを誰が消費し、誰とともに無効化されるか。L1 エントリは単一検査でのみ消費される;L2 エントリは単一オーケストレーション内の複数エントリポイントで消費される;L3 エントリはプロセスをまたいで消費される。層をまたいでエントリは共有しない――同一の意味的生成物が複数層に同時に存在することは許可するが、各層は独立にヒットし、独立にカウントされる。
2. キャッシュキー:コンテンツハッシュがそのままアイデンティティ
struct CacheKey {
content_hash: u64, // FNV-1a、validate.rs の既存実装
compiler_version: Option<Version>, // L3 のみ付与
config_fingerprint: Option<u64>, // L3 のみ付与:セマンティクスに影響する設定ビットのハッシュ
}- L1/L2 キー =
content_hash:ソースがそのままアイデンティティ、同内容は必ずヒット、経路に依存しない; - L3 キー = 三要素全体:バージョンまたはフィンガープリントのいずれかが一致しなければミス、キー全体を再コンパイル;
- キー不備は書き込み拒否:L3 エントリにバージョンまたはフィンガープリントが欠落している場合は一切ディスクに書き出さない――ミスしても旧い生成物を残すよりはまし、これは診断 i18n で翻訳欠落時にコンパイルを拒否する原則と同種の構築時拒否原則である。
3. L1:validate_source は唯一のフロントエンドエントリポイント
「ソースコード → (診断, AST)」を必要とする全コンポーネントは必ず validate_source 経由とし、自前のフロントエンド経路の構築を許可しない。現状すでに存在し、プロセスレベルキャッシュを持つが、消費しているのは formatter のみである;本RFCは3つの迂回者を編入する:
| 消費側 | 現状 | 編入後 |
|---|---|---|
check_files_with_diagnostics | ファイルごとに Compiler::new() | validate_source 経由で検索/充填 |
| オーケストレータのファイル単位 typecheck | Registry を自前で重複構築 | validate_source 経由で検索/充填 |
| LSP 診断 | 独立した DocumentCache 体系 | validate_source 経由(第三波) |
借用解析結果のキャッシュは L1 の第二の軸である。関数検査セッションを借用キャッシュのライフサイクル単位として定義する:
struct FunctionCheckSession {
/// 全グラフ BFS で求めた unsafe 集合――関数進入時に作成、関数脱出時に破棄
unsafe_set: HashSet<TokenId>,
/// SMT クエリキャッシュ(RFC-027 の予算内の線形算術クエリ)
smt_cache: HashMap<u64, SMTResult>,
}同一関数の複数書き込み操作が一度の BFS を共有する(RFC-009a の設計文を実現);SMT クエリキャッシュは同一セッションオブジェクトに編入され、セッションとともに破棄される。セッションは関数をまたいで生存しない――関数内コードの不変性は型検査フロー自体によって保証される。
4. L2:セッション内モジュールキャッシュ
オーケストレーションセッションは第一級エンティティであり、以降は暗黙的に再構築しない:
pub struct CompileSession {
/// キー = (モジュールパス, content_hash);パスは同名コンテンツの異なるファイルを区別するために使用
modules: HashMap<(PathBuf, u64), Arc<ModuleEntry>>,
}
struct ModuleEntry {
validated: Arc<ValidateResult>,
}- std 埋め込みソース(
yx_sourcesinclude_str!)のコンテンツは恒常であり、そのハッシュはプロセス内で一度だけ計算される(LazyLock静的テーブル)――N 個のインポートファイルが一度のコンパイルを共有する; - 同一プロセスでの二度目の
compile_project(テストループの check + run、yx_runner の複数ファイルなど)は、モジュール単位にCompileSessionを検索し、内容が変更されていなければモジュール全体を再利用; - セッションはプロセスとともに消滅し、ディスク書き出しも、プロセス間を継続する義務もない。
5. L3:ディスクバイトコードキャッシュ
~/.yaoxiang/cache/compile/<content_hash>-<version>-<fp>.yxbc- 生成物 = 既存の
BytecodeFileマジック番号フォーマット(runのバイトコードプローブ経路をそのまま消費)、新しいシリアライズ形式を発明しない; - ロード = ファイル読み込み + ファイル名三要素と内容の一致を検証;いずれかが一致しなければミスとして処理し、壊れたエントリを削除;
- 初期は std モジュールのみキャッシュ:埋め込みソースの内容が安定しており、バージョンアンカリングがあり、ヒット率が恒常的;プロジェクトモジュールのキャッシュはオープンイシュー解決後に開始。
6. 無効化ルール
| ルール | トリガー | 動作 |
|---|---|---|
| R1 | ソースコンテンツの変更 | 能動的な無効化なし――キーにコンテンツハッシュを含むため、旧エントリは自然に到達不能 |
| R2 | 依存モジュールの変更(L2) | use グラフに沿って下流をダーティとマーク、ダーティモジュールとその下流のみ再コンパイル |
| R3 | コンパイラバージョンの変更(L3) | 全量ミス(キーにバージョンを含み、バージョンをまたぐ移行は行わない) |
| R4 | キー不備(L3) | 書き込み拒否 |
無効化の伝播は L2 依存グラフ内にのみ存在する;L3 はモジュール間依存を追跡しない――バージョンガードがボトムラインとなり、セマンティクスは旧い生成物の「まだ有効」に決して依存しない。
7. メトリクス:観測不能なキャッシュはマージ禁止
pub struct CacheStats {
pub hits: u64,
pub misses: u64,
pub cached_bytes: u64,
}check --json と test --json の summary に cache フィールドを付加:
{
"cache": {
"l1": { "hits": 18, "misses": 1 },
"l2": { "hits": 0, "misses": 19 },
"l3": { "hits": 57, "misses": 3, "cached_bytes": 245760 }
}
}メインブランチにマージされるキャッシュエントリの PR にはすべて、当該フィールドのヒット証拠を添付しなければならない。「キャッシュされているが誰にもヒットされない」サイレント腐敗を防ぐため(#289 可観測性の整合)。
詳細設計
型システムへの影響
なし。本RFCは言語の型、構文、セマンティクスの変更を導入しない;キャッシュエントリの型はすべて既存の ValidateResult / ModuleRegistry / BytecodeFile をそのまま使用する。
実行時挙動
runの実行セマンティクスは不変;L3 ヒットは「ディスクからバイトコードを読み込む」ことと等価であり、既存のBytecodeFile::probe/load経路を辿り、バイトレベルで同型;- キャッシュが全てミスの場合、挙動は今日と完全に一致する(キャッシュは透過でなければならない――これは受入項目であり、ビジョンではない);
- 観測可能な変化は2箇所のみ:所要時間の低下;
check/testの--json出力にcacheフィールドが追加される(純粋な追加であり、旧い消費者には影響しない)。
コンパイラ変更
| コンポーネント | 変更 |
|---|---|
src/frontend/validate.rs | 唯一のフロントエンドエントリポイントになる(消費側編入、提案 §3 参照) |
src/util/diagnostic/mod.rs | check_files_with_diagnostics からファイルごとの Compiler::new() を削除 |
src/frontend/module/orchestrator.rs | compile_project を CompileSession(L2)に接続 |
src/frontend/core/typecheck/proof/ | FunctionCheckSession(L1 借用/SMT キャッシュ) |
src/frontend/pipeline/compilation_cache.rs | 新規――L3 読み書きと CacheStats(プレースホルダテストは確保済み) |
src/frontend/pipeline/incremental_scheduler.rs | 新規――use グラフに沿ったダーティファイル判定(プレースホルダテストは確保済み) |
src/util/cache.rs | DocumentCache を L1 消費側に移行(第三波、RFC-017 編入) |
src/main.rs / src/util/test_runner.rs | JSON レポートに cache フィールドを付加 |
後方互換性
- ✅ 言語面ゼロ変化、CLI 面ゼロ破壊、JSON フィールドは純粋な追加;
- ✅ キャッシュ層は全体として降格可能(クリアすればキャッシュなし挙動に戻る)、マイグレーション経路の負担なし;
- L3 ディスクエントリはバージョンガードを備え、バージョンアップグレード後は旧エントリが自然にミスとなり、清理ツールは不要(
yaoxiang cache cleanのセマンティクスは RFC-014 既存コマンドがcompile/セグメントに拡張する形)。
トレードオフ
利点
- 実測で3箇所の浪費を直接解消:同一プロセス内の複数エントリポイントでの全工程重複、std 埋め込みチェーンの N 回重複コンパイル(19ファイル × 20 ms → 1 × 20 ms)、借用検査の大規模関数での O(V²) 重複 BFS;
- キャッシュポリシーを単一ソース化:キー定義、無効化ルール、メトリクス基準を全リポジトリで唯一に、5つの散在を同一セマンティクスに編入;
- メトリクス強制により、キャッシュの便益と腐敗の両方が可視化される。
欠点
- L1 プロセスレベルキャッシュは共有状態を導入する:
--parallel環境下ではロックのホットスポットとなる。緩和策:エントリをArc不変とし、キーをu64として、ロック粒度=単一バケット; - L3 は「旧い生成物が読み込まれる」リスク面を導入する。緩和策:バージョン + フィンガープリントの二重ガード、ミスしても誤りなしを優先、かつバイトコード自体がマジック番号検証を備える;
- L2 はオーケストレータをステートレスからステートフルに変えるため、回帰テストの対象は #232/#243-245 の全量シナリオをカバーする必要がある。
代替案
| 案 | 採用しない理由 |
|---|---|
| L3 ディスクキャッシュのみ | 実測で85%のコストがプロセスライフサイクル、L3 は std チェーンの約20 ms/ファイルにのみ触れる;複数エントリポイントの重複を解決しない |
| 常駐コンパイルサービス(daemon) | 便益上限が最高(spawn コストまで吸収)だが、サービスライフサイクル管理を導入し、RFC-036 の子プロセス隔離モデルと衝突;将来独立した RFC として保留 |
| 各散在を個別に発展、統一しない | すなわち現状――キー/無効化が三系統、#293 が指摘するギャップそのもの |
| テストループをプロセス内 runner に変更 | RFC-036 隔離モデルの裁定範囲、本RFCは越境しない |
実装戦略
依存関係
- 前提:RFC-029 オーケストレータ(安定済み)、RFC-009a 証明パイプライン(BFS キャッシュホスト);
- 並行:#247
useトラッキング発見――インクリメンタル再コンパイルの依存グラフの正確性はこれに依存、未着地の場合は R2 のダーティ判定は保守的に全量とする; - 消費側:#290/#292 借用検査実装。
波次とリスク
- 第一波(L1):
validate_sourceによる3消費側の編入 +FunctionCheckSession+CacheStats最小版。リスク低――純粋な内部リファクタリング、全ミス時の挙動は不変; - 第二波(L2):
CompileSession+ std 埋め込みの単回コンパイル。リスク中――オーケストレータの状態化、#232/#243-245 の全量回帰が必要; - 第三波(L3 + インクリメンタル):ディスクキャッシュ(std 優先)+
incremental_scheduler+ DocumentCache 移行。リスク高――プロセス間生成物の正当性は完全にバージョンガードに依存。
オープンイシュー
- [ ] L3 生成物に型環境シリアライゼーションを含めるか(
check系消費側が L3 をヒットできるか、それともrunのみヒットできるかを決定) - [ ]
config_fingerprintのセマンティックビット一覧:コンパイル生成物のセマンティクスを変更する設定項目 - [ ] DocumentCache 移行は第三波と同時に進めるか、独立した小ステップ先行か
- [ ] ヒット率の帰属基準:呼び出しカウント単位か、重複排除キー単位か(メトリクスの解釈可能性に影響)
非目標
- 関数レベルのインクリメンタル解析(RFC-017 の既存裁定:全ファイル解析は数ミリ秒、増分化する価値なし);
- JIT / ランタイムキャッシュ(RFC-028 の管轄);
- テストループの子プロセス隔離の覆す(RFC-036 §6 の設計選択;常駐サービス案は代替案参照、別RFC);
- パッケージをまたぐ無効化伝播(RFC-029 境界外);
- プロセス spawn コスト最適化(キャッシュ課題ではない)。
付録B:設計意思決定記録
| 意思決定 | 決定 | 日付 | 記録者 |
|---|---|---|---|
| ホスト | RFC-029 が確保した 029a 行、新規トップレベル RFC を作らない | 2026-09-07 | 晨煦 |
| 階層モデル | L1 プロセス内 / L2 セッションモジュールレベル / L3 ディスク | 2026-09-07 | 晨煦 |
| 便益ポジショニング | 主戦場 = 複数エントリポイントの再利用と std チェーンの撲滅 | 2026-09-07 | 晨煦 |
| 子プロセス隔離を覆さない | L3 は子プロセスモデルで唯一のプロセス間便益チャネル | 2026-09-07 | 晨煦 |
| L3 はモジュール間無効化伝播を行わない | バージョンガードによる全キー無効化 | 2026-09-07 | 晨煦 |
| メトリクス強制 | メトリクスなしはマージ禁止 | 2026-09-07 | 晨煦 |
参考文献
- #293(本RFCの issue);#251 P0-6 監査(借用 BFS キャッシュ不在の発見)
- RFC-029 §中核原則(境界宣言)と §サブRFC計画(029a 行)
- RFC-009a(借用証明パイプライン;BFS キャッシュ設計文)
- RFC-017 §2(LSP DocumentCache とファイルレベル簡略化裁定)
- RFC-014 §グローバルキャッシュ(ディスクディレクトリ);RFC-028 §コードキャッシュ(ランタイム層の境界画定)
