2026-08-12 前置:模块化构建性能深度分析 · bench 套件 范围:冷构建(
mcpp clean && mcpp build)。增量侧的级联抑制已在 2026.8.12.1 落地。
bench --project . --engines mcpp=<旧>,mcpp=<新> --scenarios cold,mcpp 构建 mcpp 自身:
| 版本 | 冷构建中位数 |
|---|---|
| 2026.8.11.3 | 78.70s |
2026.8.12.1(含 bmi-equal) |
78.57s |
持平,而且必然持平。 bmi-equal 修的是「重编后 BMI 未变则不级联」;冷构建里没有「上一份 BMI」,这条路径压根不适用。冷构建要快,必须解决另一组约束。
edges : 423
makespan : 76.54 s
work (sum dur) : 303.36 s
avg parallelism: 3.96 x (of 32 hw threads)
critical path : 76.48 s = 100% of makespan
后 55% 的时间里,32 个硬件线程上只有 1 个编译进程在跑。这不是调度器不够聪明,是图本身就是一条链。
BMI 在编译进度 22.8% 处就被原子 rename 就位(strace 证实:此后 982 个系统调用没有一个再碰它),而 ninja 的依赖模型只认「边结束」。于是每个导入者都要多等一段纯 codegen。
这在本机是 34,在 62 GB 内存上没问题。但实测单个模块编译的峰值常驻内存:
| 模块 | 峰值 RSS |
|---|---|
build/prepare.cppm(最重) |
1,057 MB |
build/plan.cppm(中位偏上) |
561 MB |
⇒ 一台 64 核 / 32 GB 的机器会跑 66 路并发 × ~0.5–1 GB = 换页甚至 OOM。nproc + 2 在核多内存少的机器上是主动有害的默认值。
C3 与 C1/C2 正交:在图仍是一条链时,调低
-j不会更慢(反正用不满),调高也不会更快。所以 C3 首先是安全属性,只有在 C1/C2 解决之后才变成性能属性。
机械改写 build.ninja、拆边、同一编译器进程,bench/proto-bmi-release/:
| 方案 | 墙钟 | 产物 |
|---|---|---|
| baseline(边完成即释放) | 77.42s | 19,347,008 B |
| split(BMI 落盘即释放) | 36.56s | 19,347,008 B,可运行 |
2.12×,零额外 CPU(每个模块仍然只有一个 g++ -c)。
rule cxx_module_bmi # 编译器起跑,BMI 落盘即返回
command = $mcpp compile-module --phase=spawn --slot $slot --bmi $out --obj $obj_out -- $cxx ...
restat = 1
rule cxx_module_obj # 等同一个编译器收尾,传播退出码
command = $mcpp compile-module --phase=wait --slot $slot --obj $out
restat = 1
build $bmi : cxx_module_bmi $src | $ddi_dd
dyndep = $ddi_dd
build $obj : cxx_module_obj $bmi下游不需要改动:dyndep 本来就让导入者依赖 gcm.cache/X.gcm,它们只是提前约 4 倍就绪。link 依赖 obj/*.m.o,仍然等全部对象。
唯一不显然的改动:dyndep 把依赖挂在扫描阶段记录的 -fdeps-target 上,也就是 obj/X.m.o。若不动它,导入依赖会去门禁那条只负责等待的边,而真正做编译的边在没有任何 BMI 的情况下起跑。所以扫描边的 -fdeps-target 要改指向 BMI。
cxx_scan 规则把 $compile_target 用了两次 —— 一次 -fdeps-target=,一次 -o。改共享变量会把预处理输出对准 BMI 路径、有截断风险。必须拆成两个变量,只改 -fdeps-target。(原型里就是这么做的。)
陷阱 1:分离出去的编译器继承了构建系统的 stdout/stderr 管道。
ninja 判定一条边结束的依据是管道 EOF,而不是直接子进程退出。第一阶段即使提前 exit 0,只要后台编译器还持有那个 fd,ninja 就认为边还在跑。原型第一次运行就栽在这里:BMI 边中位数 2018 ms(= 完整编译时长),看起来像「这个想法没用」,实际是测量被伪装成了 baseline。
⇒ 后台进程的 stdout/stderr 必须重定向到文件,由第二阶段回放(否则编译器警告与错误静默消失)。
陷阱 2(已被实测缩小):-j 与编译器上限的关系。
我最初把原型第一次的 78.99s 归因于两件事:管道继承 和 「-j 必须远大于上限」。后来把 -j 单独扫了一遍,结论是第二条基本不成立:
ninja -j |
编译器上限 | 墙钟 |
|---|---|---|
| 32 | 32 | 37.84s ← 最快 |
| 64 | 32 | 38.23s |
| 128 | 32 | 38.39s |
| 192 | 32 | 39.06s |
-j 越大反而略慢(多出来的休眠边只是调度开销)。也就是说那次 78.99s 几乎全部是陷阱 1,我把一个原因写成了两个。
正确的规则很简单:-j 取编译器上限即可;它不需要更大,也不应该更大。
这是设计里最容易做错的一处。
需求只有一条:别占住 ninja 的管道。它不要求 setsid()。
保持在同一进程组的直接好处:ninja 收到 Ctrl-C 时会把 SIGINT 发给整个进程组,分离出去的编译器照样收到。若为了「干净」而 setsid(),反而要自己实现中断清理,并且会留下孤儿编译器继续吃满 CPU。
- POSIX:
fork→ 子进程把 stdio 重定向到日志 →exec编译器;不调用setsid。父进程(spawn 阶段)轮询 BMI 后退出,编译器被 init 收养但仍在原进程组。 - Windows:
CreateProcess不带DETACHED_PROCESS,句柄重定向到文件,继承控制台 ⇒ Ctrl-C 正常。后续可加 Job Object 做强保证。
编译器可能在 BMI 落盘之后才失败(codegen 阶段的 ICE —— xlings 迁移前的 xmake.lua 就因 GCC 15 在 -O1/-O2 + modules 上 tree-ssa-dce ICE 而强制 -Og)。此时:
- 导入者已经拿着一份合法的 BMI 开始编译 —— 前端成功过,BMI 有效
--phase=wait拿到非零退出码,该边失败,构建整体失败
⇒ 失败仍然被报告,只是更晚,且下游做的是无害的额外工作。诊断顺序可能颠倒(下游错误先于上游失败出现),这一点要写进发布说明。
跨平台、无外部依赖:原子 mkdir 令牌目录。mkdir 在 POSIX 与 Windows 上都是原子的「要么成功要么 EEXIST」。令牌在编译器退出时释放(由 spawn 阶段的后台段释放),不是在 wait 边被调度时 —— 否则上限会被 ninja 的调度延迟放大。
不会死锁:持有令牌的进程从不等待另一个令牌。
两个原因,都不是「保守一点更好」这种口味问题:
- 内存:实测每编译 0.5–1 GB(§1 C3)。
nproc+2在 64 核/32 GB 上必然换页。 - 异构:i9-13900K 是 8 P-core + 16 E-core。
nproc报 32,但 E-core 编译吞吐约为 P-core 的 40%,SMT 兄弟核约 25%。把 32 当成 32 个同构核,会把有效并行度估高一倍以上。
jobs_auto = clamp( min( cpu_budget, mem_budget ), 1, 64 )
cpu_budget = 异构 ? physical_cores // E-core 不按整核计
: logical_cores
mem_budget = max( 1, (available_ram - reserve) / per_job_estimate )
reserve = 2 GiB
per_job_estimate = 768 MiB // 实测中位 561 MB / 峰值 1057 MB
- 用 available(不是 total)内存:构建通常不是机器上唯一的东西
per_job_estimate是可配置常量,不是猜测:它来自本仓库的实测,并且在文档里注明了来源,便于其他工程按自己的规模调整- 上限 64:再高时 ninja 自身的调度开销与文件系统争用开始显现
[build]
jobs = "auto" # 或一个整数;缺省 = 当前行为(不传 -j,由 ninja 决定)mcpp build --jobs N|auto
默认不变。 这是刻意的:改变默认并发会改变所有人的构建时长与内存占用,属于行为变更,应当先作为可选项验证一段时间。文档里把 auto 标为推荐值。
A 落地后仍然只需要一个数。§2.3 的实测表明 -j 取编译器上限就是最优,更大反而略慢,所以:
compiler_cap = jobs_auto
ninja_jobs = compiler_cap // 不放大
(我一度以为这里需要 cap * 6,那是把陷阱 1 的症状误记到了陷阱 2 上;扫描数据见 §2.3。)
ninja 在多条就绪边之间的选择顺序是任意的。当图很窄时无所谓(现状后半段并发度 1.0),但 A 落地后图会变宽,此时先跑关键路径上的边就有价值。
mcpp 已经在扫描阶段拿到了完整模块图,算一次最长路径几乎免费。ninja 没有优先级 API,但可以通过边的声明顺序施加弱影响。
收益不确定,成本极低,排在 A 之后作为微调。
codegen 占全部工作量的 77%。bmi-equal 让 BMI 稳定之后,.o 也可按内容哈希缓存。mcpp 已有 ~/.mcpp/build-cache/v1/ 用于依赖包,扩展到根包即可。
只对重复的冷构建有效(切分支来回、revert、CI 缓存恢复),对首次冷构建无效。
Cached。
| 方向 | 为什么不做 |
|---|---|
| 降低优化档位 | 实测 -O0 相对 -O2 只快 1.75×,而产物运行时性能全丢 |
| 缩小 BMI / 降扇入 | 实测 import std(31.5 MB BMI)只多 4.8 ms —— GCC 导入本来就是惰性的 |
| 优化 scan / dyndep 阶段 | 合计 0.8% 的工作量 |
| 分布式编译(distcc/icecc) | 关键路径 100% ⇒ 在 A 之前是负收益(只增加网络延迟) |
-fmodule-only 两阶段 |
实测它照样跑完整个 codegen 再丢弃(15.93s vs 完整 15.95s) |
| # | 动作 | 预期 | 验收判据 |
|---|---|---|---|
| A1 | mcpp compile-module --phase=spawn/wait + 拆边 + 扫描 -fdeps-target 重定向 |
78.6s → ~37s | 产物字节一致;BMI 边中位数远小于 OBJ 边(否则第一阶段没有提前退出,测的是 baseline);构建后无残留编译器进程;Ctrl-C 不留孤儿 |
| A2 | Windows 侧(Job Object) | 同上 | Windows e2e 绿 |
| B ✅ | --jobs N|auto + [build] jobs(2026.8.12.1 已实施) |
安全属性;A 之后转为性能属性 | 本机 auto → -j24(异构 ⇒ 物理核 24,内存预算更大);默认行为不变;8 个单测覆盖公式的每条分支 |
| C | 关键路径优先的边序 | 微调 | A 之后重测,无提升则回退 |
| D | 对象缓存 | 重复冷构建 | 命中时确实跳过编译,不是日志说了算 |
见 bench/results/。要点:xmake 在同样的图形状下也是延迟瓶颈(它同样走 GCC 单阶段),所以 A 不是「追平 xmake」,而是两者都还没做的事。
上文写 A(BMI 提前释放)时,依据是一次原型测量。这次在 mcpp 自身 80s 冷构建上 重新逐条量过,结论是 A 成立,而且比原来写的更硬;但中间我先得出过一个相反且错误 的结论,过程值得记下来。
bench --analyze 于 mcpp 的 release 构建目录:
edges : 426
makespan : 79.79 s
work (sum dur) : 314.08 s
avg parallelism: 3.94 x (of 32 hw threads)
critical path : 79.73 s = 100% of makespan
关键路径就是墙钟本身。 32 个硬件线程上平均并行度只有 3.94 —— 加核、加机器、
分布式编译全部无效。关键链 26 跳,几乎全是 cxx_module,其中
mcpp.build.prepare 单个模块 16.1s,占整个构建的 20%。
第一反应是量「只产 BMI」要多久:
prepare BMI-only 16.18s full 16.27s → 99%
cli BMI-only 5.50s full 5.59s → 98%
plan BMI-only 5.36s full 5.43s → 99%
据此我一度判定 A 不成立:BMI 几乎就是全部成本,代码生成只有 1%,提前释放没有空间。
这是错的。 GCC 的 -fmodule-only 并不跳过后端,它跑完整条流水线、只是不写目标文件。
用它测「BMI 什么时候好」等于什么都没测。
三步,缺一不可:
- 何时出现 —— 轮询
.gcm:prepare的 BMI 在 2.31s / 16.19s = 14% 处出现。 但「出现」不等于「写完」(GCC 早创建、可能持续写)。 - 何时写完 —— 轮询到大小连续 150ms 不变。快照 785488 字节,与编译结束后的成品 逐字节相同。
- 是否可用 —— 把早期快照放回
gcm.cache/,编译一个真实下游导入者 (mcpp.build.execute):exit 0。
采样关键链上最重的 8 个模块:
| 模块 | full | BMI 写完 | 占比 |
|---|---|---|---|
| mcpp.build.prepare | 16.20s | 2.50s | 15% |
| mcpp.cli | 5.67s | 2.22s | 39% |
| mcpp.build.plan | 5.47s | 1.07s | 20% |
| mcpp.build.compile_commands | 4.74s | 1.03s | 22% |
| mcpp.build.execute | 4.52s | 1.19s | 26% |
| mcpp.libs.toml | 2.28s | 0.53s | 23% |
| mcpp.modgraph.scanner | 3.23s | 0.66s | 20% |
| mcpp.build.ninja | 3.65s | 1.00s | 27% |
中位约 22%。下游在等的 78% 是它根本不需要的代码生成。
关键链 24 个模块节点合计 ~74.7s。若在 BMI 写完即解锁:
74.7 × 0.22 ≈ 16.4s + obj/main.o 4.78s + link 0.18s ≈ 21s。
此后构建转为吞吐受限,下限是 work / 线程数 = 314 / 32 ≈ 9.8s,按 60–70% 并行效率
落在 15–20s。综合预期 80s → 25–35s(2.3–3.2×)。
ninja 认为一条边完成 = 进程退出,所以必须让「BMI 好了」成为一个可观测事件:
- 信号:GCC 的
-fmodule-mapper(P1184)在 BMI 落盘时发MODULE-COMPILED。 这是设计好的机制,不需要轮询文件大小(轮询只适合做上面这种一次性测量)。 - 边的形状:
cxx_module改为跑一个 mcpp 助手,它代管 mapper 协议,收到MODULE-COMPILED后把余下的 codegen 甩到后台并退出 0。 - 收口:
cxx_link前置一条await-objects边,等所有后台 codegen 结束。 链接本来就在最后,目标文件是并行完成的,所以这条边通常不阻塞。
- 甩到后台的子进程会继承 ninja 的管道 —— 上一次原型就栽在这里:BMI 边的耗时 被记成整条编译的耗时,数字变成 78.99s,看起来像「这个想法不成立」。 子进程的 stdio 必须重定向到文件。
- 失败会迟到 —— 后台 codegen 失败时,
cxx_module边已经报成功了。await-objects必须收集并复现每个失败,否则会变成链接期的一堆未定义符号。 - 作业槽会超订 —— ninja 以为边结束了,后台进程仍在吃 CPU。这在当前
3.94× 的并行度下是想要的,但在
--jobs很大时需要重新标定。
mcpp.build.prepare一个文件 16.2s,占 20%。 拆开它直接缩短关键链, 且不引入任何调度复杂度。- 换编译器。 clang 在同类工程上整体快约 2.4×(此前测量),而关键链的形状不变。