RFC-029a: 模块缓存与增量重编译
摘要
兑现 RFC-029「子 RFC 规划」预留的 029a 行:在已稳定的编排器之上,为编译器定义 分层缓存与增量重编译语义——L1 进程内分析结果、L2 会话内模块级、L3 跨进程磁盘三层, 配统一的键定义、失效规则与强制度量钩子,收敛当前四处互不相通的缓存散点(#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 嵌入链被重复 typecheck 19 遍;- runtime-error 类测试文件走
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 键 = 三元组整体:版本或指纹任一不匹配即 miss,整键重编;
- 键不齐拒绝写入:L3 条目缺版本或缺指纹一律不落盘——宁可 miss 不落旧产物, 与诊断 i18n 缺翻译拒绝编译同一构造期拒绝原则。
3. L1:validate_source 是唯一前端入口
所有需要「源码 → (诊断, AST)」的组件必须经 validate_source,不允许自建前端链路。 现状它已存在且带进程级缓存,但只有 formatter 消费;本 RFC 将三个绕行者收编:
| 消费方 | 现状 | 收编后 |
|---|---|---|
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的字节码探针路径原样消费), 不发明新序列化格式; - 加载 = 读文件 + 校验文件名三元组与内容一致;任一不符按 miss 处理并删除坏条目;
- 首批只缓存 std 模块:嵌入源内容稳定,版本锚定,命中率恒定;项目模块缓存在 开放问题解决后再启。
6. 失效规则
| 规则 | 触发 | 动作 |
|---|---|---|
| R1 | 源内容变化 | 无主动失效——键含内容哈希,旧条目自然不可达 |
| R2 | 依赖模块变化(L2) | 沿 use 图向下游标记脏,仅重编脏模块及其下游 |
| R3 | 编译器版本变化(L3) | 全量 miss(键含版本,不做跨版本迁移) |
| 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路径,字节级同构;- 缓存全 miss 时,行为与今日完全一致(缓存必须透明——这是验收项,不是愿景);
- 可观测变化仅两处:耗时下降;
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 磁盘条目带版本守卫,版本升级后旧条目自然 miss,无需清理工具 (
yaoxiang cache clean语义由 RFC-014 既有命令延伸到compile/段)。
权衡
优点
- 直接消除三处实测浪费:同进程多入口重复全链路、std 嵌入链 N 次重复编译 (19 文件 × 20 ms → 1 × 20 ms)、借用检查大函数 O(V²) 重复 BFS;
- 缓存策略单源化:键定义、失效规则、度量口径全仓唯一,五个散点收编进同一套语义;
- 度量强制使缓存收益与腐烂都可见。
缺点
- L1 进程级缓存引入共享状态:
--parallel下为锁热点。缓解:条目Arc不可变、 键为u64,锁粒度=单桶; - L3 引入「旧产物被加载」的风险面。缓解:版本 + 指纹双守卫,宁 miss 勿错, 且字节码自带魔数校验;
- 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收编三消费方 +FunctionCheckSession+CacheStats最小版。风险低——纯内部重构,全 miss 行为不变; - 第二波(L2):
CompileSession+ std 嵌入单次编译。风险中——编排器状态化, 需 #232/#243-245 全量回归; - 第三波(L3 + 增量):磁盘缓存(std 优先)+
incremental_scheduler+ DocumentCache 迁移。风险高——跨进程产物正确性完全依赖版本守卫。
开放问题
- [ ] L3 产物是否纳入类型环境序列化(决定
check类消费者能否命中 L3,还是仅run可命中) - [ ]
config_fingerprint的语义位清单:哪些配置项改变编译产物语义 - [ ] DocumentCache 迁移随第三波走,还是独立小步先行
- [ ] 命中率归因口径:按调用计数还是按去重键计数(影响 metrics 的可解释性)
非目标
- 函数级增量解析(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 | 晨煦 |
| 度量强制 | 无 metrics 不许合入 | 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 §代码缓存(运行时层划界)
