2026-08-12 — mcpp 2026.8.11.3 自举构建 / xmake v3.0.7 对照 实测宿主:Intel i9-13900K(8 P-core + 16 E-core,32 线程)、62 GB RAM、Linux 6.8 编译器:GCC 16.1.0(mcpp hermetic payload)、Clang 22.1.8 被测工程:mcpp 自身 —— 137 个
.cppm模块接口单元 + 1 个main.cpp,56.6k 行
mcpp 的自举构建不是吞吐瓶颈,是延迟瓶颈:关键路径 = 100% 墙钟时间,后 55% 的时间里 32 个硬件线程上只有 1 个编译进程在跑。而这条关键路径上 77% 的时间在生成没有任何下游需要的 .o —— 下游真正需要的 BMI 平均在编译进度 22.8% 处就已经原子落盘。
由此得到的最高价值优化不是"编得更快",而是**"更早释放下游"**。这条已经用真实原型验证过,不只是模拟:同一编译器、同样的编译器并发上限、产物一致,冷构建 77.42s → 36.56s(2.12×),零额外 CPU 工作量。
第二个发现更廉价也更刺眼:仓库里 2026-05-12 就设计并实现了"接口不变则不级联重编"的 BMI restat 机制,但它从未生效过——因为 GCC 把 wall-clock 时间戳写进了 BMI 文件内容本身。一个 SOURCE_DATE_EPOCH 让 touch 场景从 73.0s 变成 0.22s。
面对"构建慢",默认假设通常是"编译器慢 / 代码太多"。这个假设在本例中是错的,而且错得很具体。分析按以下顺序推进,每一步都要求可证伪:
| 步骤 | 问题 | 判据 | 结果 |
|---|---|---|---|
| 1 | 工作量 vs 墙钟 | sum(edge duration) / makespan |
309s / 79s = 3.91×,32 线程只用上 12% |
| 2 | 是并行度不足还是关键路径长? | 计算真实关键路径 | 关键路径 = 79.01s = 100% 墙钟 → 纯延迟瓶颈 |
| 3 | 关键路径上的时间花在哪? | -ftime-report + -fmodule-only 对照 |
86% 在 opt and generate |
| 4 | 下游真的需要等 codegen 吗? | 轮询 BMI 落盘时刻 + strace |
不需要,BMI 在 22.8% 处原子就位 |
| 5 | 能否让下游早走? | GCC 模块映射器协议实测 | 可以,MODULE-COMPILED 就是该信号 |
| 6 | 增量为何也这么慢? | 字节比对连续两次编译的 BMI | GCC 嵌了时间戳 → 级联抑制永久失效 |
这些不是花絮,是复现本报告时必须避开的坑:
-
多输出边在
.ninja_log里每个输出各写一行,起止时间相同。按行求和会把编译耗时从 302s 读成 604s。必须按(start, end, command_hash)去重。 -
模块的真实依赖边不在
build.ninja里,而在构建期生成的 dyndep 文件obj/*.ddi.dd中。只读build.ninja算出的关键路径是 22s,折入 dyndep 后是 79s——差 3.6 倍,足以得出完全相反的结论("并行调度有问题" vs "关键路径就是全部")。 -
dyndep 把依赖挂在
obj/X.m.o上,而导入者依赖的是同一条边的另一个输出gcm.cache/X.gcm。 不把同一条边的多个输出合并成单个图节点,最长路径走两跳就断了。 -
ninja 是追加写
.ninja_log的,且每次调用时钟从 0 重启。 多次构建混在一起会算出"关键路径 > makespan"这种不可能的读数(xlings 那份日志第一次跑出 136%)。必须只取最后一次调用。 -
最长路径必须按拓扑序松弛,不能用栈式 DFS。 DFS 里"跳过已在栈上的节点"这个防环写法,会把兄弟分支压入但尚未算完的依赖也当成 0,导致路径提前终止。我的 C++ 版最初就是这样,报出 33.9s / 10 节点,而真值是 76.5s / 26 节点 —— 把"100% 延迟瓶颈"读成了"44%",结论直接反转成"加核有用"。
第 5 条是靠交叉验证抓到的:同一份日志,
bench --analyze(C++)与独立的 Python 分析器在 makespan、工作量、逐规则耗时、并发曲线上全部吻合,唯独关键路径差 2.3 倍。旁证是并发曲线——末尾 40 秒的 1.0× 串行尾巴不可能与 34 秒的关键路径共存。凡是计算关键路径的东西,都要用第二个实现交叉验证。 这五条已固化进
bench/src/analysis/与bench/README.md。
假设:GCC 的 -fmodule-only("Only emit Compiled Module Interface")能跳过 codegen,从而低成本地拿到 BMI,做成两阶段编译。
实测:-fmodule-only 确实不产出 .o,但 -ftime-report 显示它照样完整执行 phase opt and generate(13.65s / 86%)然后把结果丢弃。总耗时 15.93s vs 完整编译 15.95s。
完整编译 : 15.95s → .o + .gcm
-fmodule-only : 15.93s → 只有 .gcm(codegen 白做)
-fsyntax-only : 2.04s → 什么都不产出(证明前端只要 2s)
结论:GCC 在 16.1 上没有廉价产出 BMI 的开关。这是 QoI 缺陷,值得向上游报告。Clang 有(--precompile)。
| 工具 | 用途 | 关键用法 |
|---|---|---|
.ninja_log + 自研分析器 |
每条边的起止毫秒 → 工作量/关键路径/并发曲线 | bench --analyze <build-dir> |
hyperfine 1.18 |
统计严谨的墙钟计时(中位数、prepare/cleanup 钩子) | 所有矩阵单元 |
strace -f -tt -e trace=openat,write,close,rename |
单次编译内 BMI 文件的生命周期 | 证明 BMI 是原子 rename 就位 |
g++ -ftime-report |
cc1plus 内部分阶段耗时 | 定位 86% 在 codegen |
-fmodule-mapper=|<prog> |
实测 P1184 模块映射器协议 | 证明 MODULE-COMPILED 信号存在 |
逐字节 cmp -l |
BMI 可复现性 | 定位到 4 字节时间戳 |
| 离散事件调度模拟器 | 用实测 t_bmi/t_total + 真实依赖图预测收益 | 贪心表调度,P 可扫 |
| 图改写原型 | 把模拟结论变成实测:机械拆边后跑真实构建 | bench/proto-bmi-release/ |
perf / bpftrace |
备用 —— 本轮没有用上:瓶颈在调度与 I/O 时序,不在 CPU 采样能看到的地方 | — |
关于
perf:提前开了perf_event_paranoid=-1,但整个分析没有用到采样剖析。定位靠的是构建图的时间结构(.ninja_log)和单进程内的文件生命周期(strace -tt)。这本身是一条方法论结论:构建性能问题通常不是"哪段代码热",而是"谁在等谁"。
bench --analyze 输出(可复现):
edges : 423 (137 cxx_module + 138 cxx_scan + 138 cxx_dyndep + 1 cxx_object + 1 link + 8 stage)
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
verdict : LATENCY-bound. More cores will not help.
| rule | count | total_s | avg_ms | max_ms | %work |
|---|---|---|---|---|---|
cxx_module |
137 | 296.29 | 2162.7 | 15892 | 97.7% |
cxx_object |
1 | 4.53 | 4527.0 | 4527 | 1.5% |
cxx_scan |
138 | 1.75 | 12.7 | 105 | 0.6% |
cxx_dyndep |
138 | 0.60 | 4.4 | 9 | 0.2% |
cxx_link |
1 | 0.16 | 163.0 | 163 | 0.1% |
stage_file |
8 | 0.02 | 2.8 | 12 | 0.0% |
早前一次剖析读数为 makespan 79.07s / work 309.0s / CP 79.01s,同一结论;差异是运行间噪声。hyperfine 3 次中位数为 77.55s。
扫描阶段只占 0.8%。 每个 TU 三次进程调用(scan → dyndep → compile)的开销常被当成嫌疑犯,实测不是。这条要写进结论,以免有人去优化一个 1.8 秒的阶段。
t= 0.0s 17.4x |#################################
t= 4.0s 9.7x |##################
t= 7.9s 5.2x |##########
t= 11.9s 6.2x |############
t= 15.8s 4.9x |#########
t= 19.8s 8.7x |################
t= 23.7s 7.3x |##############
t= 27.7s 2.4x |####
t= 31.6s 2.0x |####
t= 35.6s 1.0x |## ← 此后 44 秒(墙钟的 55%)全程单线程
...
t= 75.1s 1.0x |##
关键路径 24 层深:
shell → linux → platform → manifest.types → manifest.toml → manifest → runtime_selection → runtime_binding → elf_runtime → loader_contract → plan → flags → compile_commands → ninja_backend → prepare(16.1s) → execute → configure → cmd_build → cli → main.o → link
§2.1 的 79.07s 是被剖析的那一次完整重建;§4 表格里的 77.55s 是 hyperfine 3 次的中位数。两者一致,前者用于结构分析,后者用于对比。
方法:逐个模块单独编译,轮询其
.gcm出现的时刻。这些是隔离测量,没有 32 路并发下的内存带宽争用,因此合计 258.9s 低于真实构建的 309.0s(约 16%)。这个偏差对 §6.2 的模型 A 和 B 同向作用,所以那里的加速比(2.98×)比绝对秒数更可信。
| 秒 | |
|---|---|
| BMI 产出耗时合计 | 59.1 |
| 完整编译耗时合计 | 258.9 |
| 下游白等的 codegen | 199.8(77.2%) |
| BMI 平均就绪进度 | 22.8% |
最热的几个:
| 模块 | t_bmi | t_total | BMI 占比 |
|---|---|---|---|
build/prepare.cppm |
2.29 | 15.99 | 14.3% |
cli.cppm |
1.98 | 5.50 | 35.9% |
build/plan.cppm |
0.92 | 5.44 | 17.0% |
platform/runtime_binding.cppm |
0.98 | 5.40 | 18.1% |
doctor.cppm |
1.05 | 5.07 | 20.8% |
manifest/toml.cppm |
0.59 | 4.67 | 12.7% |
strace 抓到的 build/plan.cppm 编译过程:
10:25:01.058 编译开始
10:25:01.855 openat("gcm.cache/mcpp.build.plan.gcm~", O_RDWR|O_CREAT|O_TRUNC)
10:25:01.950 close(fd)
10:25:01.950 rename("...gcm~", "...gcm") ← BMI 原子就位
10:25:06.527 进程退出 ← 又跑了 4.58 秒纯 codegen
写临时文件 + rename() 意味着 BMI 要么不存在、要么完整,不存在撕裂读。这让"看到 BMI 就放行下游"在构造上是安全的,不需要额外加锁或校验。
离散事件模拟(真实依赖图 + 实测每模块耗时):
P=8 72.0s → 并行度不是约束
P=16 72.0s
P=24 72.0s
P=32 72.0s
P=64 72.0s ← 加到 64 线程,一秒都不会快
见 §2.3 / §2.4。GCC 单阶段模型下,BMI 与 .o 由同一个进程产出;ninja 的依赖模型只认"边结束",于是导入者被迫等到 codegen 收尾。
build.ninja 的 cxx_module 规则实现了 2026-05-12 文档设计的 copy-if-different:
cp -p $bmi_out $bmi_out.bak && <compile> && \
if cmp -s "$bmi_out" "$bmi_out.bak"; then mv "$bmi_out.bak" "$bmi_out"; else rm -f "$bmi_out.bak"; fi同一条命令连编两次,BMI 有 4 字节不同:
buildtime: 2026/08/12 02:25:01 UTC localtime: 2026/08/12 02:25:01 UTC
buildtime: 2026/08/12 02:25:33 UTC localtime: 2026/08/12 02:25:33 UTC
^^ ^^
2026-05-12 的文档写道:「GCC 每次都会重新生成 BMI 文件(即使内容相同时间戳也变),所以必须在构建系统层面做 copy_if_different」。该判断只覆盖了文件 mtime,漏掉了时间戳被写进文件内容,因此 cmp -s 同样必然失败,restat 永远认为 BMI 变了。
实测后果(touch 一个被 46 个模块导入、内容完全未变的 platform.cppm):
| 墙钟 | 重跑边数 | |
|---|---|---|
| 现状 | 73.0s | 180 |
SOURCE_DATE_EPOCH=<固定值> |
0.22s | 5 |
332×,且正确性不变:真实接口变更仍然完整级联(180 边),回退亦然。
在 src/ui.cppm(27 个导入者)的非 inline 函数体内加一行注释,接口完全未动:
| 墙钟 | 重跑边数 | |
|---|---|---|
| body-only 编辑 | 47.6s | 66 |
GCC 的 BMI 携带函数体(为了跨模块内联),所以任何编辑都会改变 BMI 字节。SOURCE_DATE_EPOCH 修不了这一类。
真正的根因是工程结构:mcpp 有 137 个模块接口单元,却只有 1 个实现 TU(main.cpp)——全部实现代码都写在接口单元里。因此每一次日常编辑的代价都是 O(导入者数),而不是 O(1)。
cxx_scan 1.83s + cxx_dyndep 0.48s = 全部工作量的 0.8%。每 TU 三次进程调用的设计不需要优化。
16.1s,扇入 61 个 BMI(含 31MB 的 std.gcm 与 17MB 的 mcpp.libs.json.gcm)。
同一份源码(137 .cppm + main.cpp)、同一个 g++ 二进制(xim-x-gcc/16.1.0,由 xmake.lua 从 mcpp.toml 的 [toolchain] default 读取并钉死)、同样 -std=c++23 -fmodules -O2、同样 -j32。hyperfine 中位数,每格 3 次。
两侧各产出 141 个 BMI —— 模块集合一致,xmake 的 culling 没有偷偷少编东西。
| 场景 | mcpp | xmake | 判读 |
|---|---|---|---|
冷构建(release -O2) |
77.55s | 88.94s | mcpp 快 1.15× |
冷构建(debug -O0 -g) |
44.23s | 46.30s | mcpp 快 1.05× |
| no-op | 0.430s | 0.381s | xmake 略快 |
| touch hub 模块(46 导入者,内容未变) | 73.79s | 81.70s | 两者都退化到接近全量重建 |
| 改函数体(27 导入者,接口未动) | 47.75s | 52.19s | 两者都完整级联 |
touch main.cpp |
5.40s | 5.28s | 持平 |
-O0相对-O2只快 1.75×(77.55 → 44.23)。对一个 codegen 占 77% 工作量的构建,这个比例偏低,再次印证前端与关键路径结构才是主导——降优化档不是出路(§6.5)。另可注意:release 档 mcpp 领先 14.7%,debug 档只领先 4.7%。优化档位越高,两个引擎的差距越明显。
| 不对称 | 实测值 | 结论 |
|---|---|---|
mcpp 从全局缓存 stage std.gcm,xmake 自己编译 |
编译 std 只要 2.04s |
只解释约 2s,不是主因 |
| mcpp 有全局依赖构建缓存 | mcpp build --cache=off 冷构建 = 78.46s(vs 77.55s) |
缓存只值 0.9s,不是主因 |
⇒ 11.4s 的差距扣除上述约 2s 后仍有约 9s(~10%),是真实的引擎差异,不是缓存优势。
touch-hub 一栏是全表最关键的信息:一个内容完全没变的文件,mcpp 花 73.79s、xmake 花 81.70s,而冷构建分别是 77.55s / 88.94s —— 增量 ≈ 全量。
两个独立实现的构建引擎表现几乎一致,说明这不是某一家的实现质量问题,而是 C++ 命名模块 + GCC 的结构性问题(§3 的 F3/F4)。这也意味着:
在 GCC 上,任何构建系统都无法靠"更聪明的调度"解决增量问题——必须解决 BMI 的确定性(F3)与 BMI 携带函数体(F4)。
只测一个项目得出的"结构性结论"不可信。xlings 是理想的对照:独立作者、独立代码库、同量级规模,且已从 xmake 迁移到 mcpp(用户给出的 xmake.lua 是迁移前的 ca25ab7)。
| mcpp | xlings | |
|---|---|---|
| 模块接口单元 / LOC | 137 / 56 555 | 110 / 46 253 |
| 冷构建 makespan | 79.07s | 51.74s |
| 总工作量 | 309.0s | 163.6s |
| 平均并行度(32 线程) | 3.91× | 3.16× |
| 关键路径占墙钟 | 100% | 100% |
| 编译占总工作量 | 97.6% | 95.5% |
| 扫描 + dyndep 占比 | 0.8% | 1.7% |
| 关键链深度 | 24 | 24 |
两个项目的病理完全一致。 这不是某个代码库的偶然结构,而是"C++23 命名模块 + GCC 单阶段 + 边完成即释放"这一组合的固有结果。
投入之前先砍掉三条看起来合理、实测无效的路线。每条都有具体判据。
直觉:std.gcm 31.5MB 被 134/138 个 TU 导入,mcpp.libs.json.gcm 17.1MB 被 17 个导入,反序列化必然很贵。
实测(空模块 vs 逐个加 import):
| TU 内容 | 编译耗时 |
|---|---|
| 空模块(无 import) | 12.1 ms |
+ import std(31.5 MB BMI) |
16.9 ms(+4.8 ms) |
+ import mcpp.libs.json(17.1 MB) |
19.2 ms(+2.3 ms) |
GCC 的模块导入本来就是惰性的(mmap + 按需具现)。BMI 体积基本不影响导入成本;真正花钱的是这个 TU 自己的代码。
佐证:corr(LOC, t_total) = 0.825,平均 4.6 ms/行。编译时间由代码量驱动,不是由扇入驱动。
推论:
-fmodule-lazy大概率也没有收益(默认已惰性)。
每个 TU 三次进程调用(scan → dyndep → compile)看着浪费,实测 cxx_scan 1.83s + cxx_dyndep 0.48s = 全部工作量的 0.8%。不要动。
关键路径 = 100% 墙钟意味着任何时刻可并行的工作都已经并行完了。模拟显示 P=64 与 P=16 完全同速。分布式编译在 F2 解决之前是纯粹的负收益(加了网络延迟)。
顺序很重要:必须先做 §6.2,分布式才有意义。
按 收益 / 成本 排序。每条都给出判据与验证方式。
问题:F3。仅适用于 GCC —— Clang 的 .pcm 实测字节稳定(§7.2),那边的级联抑制本来就在工作。
已按方案 A 实施(2026.8.12.1):把"BMI 是否相等"的判据从裸 cmp 换成时间戳无关比较。新增 mcpp bmi-equal <a> <b>(内部子命令),跳过 BMI 内的 buildtime: / localtime: 字段。cxx_module 规则里把
cmp -s "$bmi_out" "$bmi_out.bak"换成
$mcpp bmi-equal "$bmi_out" "$bmi_out.bak"方案 B(附赠可复现构建)——注入 SOURCE_DATE_EPOCH。取值不能是当前时间(那等于没改),候选:git commit 时间 / manifest version 派生的常量。加 [build] reproducible = true 开关。
副作用:方案 B 会改变 __DATE__ / __TIME__ 的值。用户代码可能依赖,因此不宜作为默认。方案 A 无此问题,应作为默认;方案 B 作为可选项。
实测收益(落地后,由 bench --project 测 mcpp 构建自身):
| 场景 | 2026.8.11.3 | 2026.8.12.1 |
|---|---|---|
noop |
0.27s | 0.19s |
touch-hub(46 导入者,内容未变) |
73.99s | 0.45s |
正确性由单测从两侧钉死(tests/unit/test_bmi_equivalent.cpp):真实接口变更仍完整级联。
⚠️ 目前仅 POSIX。ninja_backend的 Windows 分支写着 "skip BMI restat optimization (requires POSIX shell)" —— 整套 backup/compare/restore 是用 shell 的if/cp/cmp拼的,cmd.exe 上没有对应写法,所以 Windows 一直没有任何级联抑制。这个限制现在可以解除了:
bmi-equal已经是 mcpp 的子命令,把剩下的 backup/restore 也收进一个mcpp bmi-guard --bmi <path> -- <compile cmd>里,整个序列就变成一个进程、零 shell,两个平台共用同一条规则。这是本条优化的直接后续,不需要新的设计。
验证方式:bench/run.sh --scenario touch-hub,并必须同时验证接口变更场景仍然级联——只测 touch 分不清"级联被正确抑制"和"级联坏了"。
问题:F1 + F2。这是全部方案里收益最高的一条。
核心事实(已实测):
- BMI 在编译进度 22.8% 处原子 rename 就位(§2.4),之后 77% 的时间下游在空等
- GCC 的模块映射器协议主动发送
MODULE-COMPILED <name>(实测报文见 §7),这正是"CMI 就绪"信号 - 同一份 CPU 工作量,一个编译进程都不多
模拟收益(真实依赖图 + 实测每模块 t_bmi/t_total):
| 模型 | makespan | 加速 |
|---|---|---|
| A 边完成即释放(现状) | 72.0s | 1.00× |
| B BMI 落盘即释放 | 24.1s | 2.98× |
| C codegen 完全离开关键路径(理论上界) | 15.4s | 4.66× |
且核数重新变得有意义:现状 P=16 与 P=64 同为 72s;方案 B 下 P=8→42.7s、P=32→24.1s。
把 mcpp 生成的 build.ninja 机械改写成"每模块两条边、共用一个编译进程",在同一个构建目录、同一编译器、**同样的编译器并发上限(≤32)**下 A/B:
| 方案 | 墙钟 | 产物 |
|---|---|---|
| baseline(边完成即释放) | 77.42s | 19,347,008 B |
| split(BMI 落盘即释放) | 36.56s | 19,347,008 B,--version 正常 |
实测 2.12×,零额外 CPU 工作量(每个模块仍然只有一个 g++ -c)。自检:BMI 边中位数 883ms vs OBJ 边中位数 3041ms —— 确认第一阶段真的提前退出了。
实测 2.12× 低于模拟 2.98×,差距来自原型的 bash 轮询(5ms)与 mkdir 信号量开销,以及模拟使用的是隔离编译耗时(§2.3 注)。生产实现(用映射器协议或原生 job control)应更接近模拟值。
陷阱 1:分离出去的编译器继承了构建系统的 stdout/stderr 管道。
ninja 判定一条边结束的依据是管道 EOF,而不是直接子进程退出。第一阶段即使提前 exit 0,只要后台编译器还持有那个 fd,ninja 就认为边还在跑。第一次原型运行就栽在这里:BMI 边的中位数是 2018ms(= 完整编译时长),看起来像"这个想法没用",实际是测量被伪装成了 baseline。
修法:后台进程的 stdout/stderr 重定向到文件,由第二阶段回放(否则编译器警告与错误会静默消失)。
陷阱 2:ninja 的 -j 必须远大于编译器并发上限。
编译器一旦分离就不再占用 ninja 槽位,并发改由信号量约束。若 -j 与信号量上限相同,槽位会被"卡在信号量上"和"等 codegen"的休眠边占满,就绪前沿饿死,调度退化成 baseline。原型第一次正是 -j32 + 上限 32 ⇒ 78.99s(比 baseline 还慢)。
修法:-j 取编译器上限的数倍(原型用 6×),CPU 并行度仍由信号量精确控制。
三条实现路径:
| 机制 | 成本 | 风险 | |
|---|---|---|---|
| (A) 拆边 + 监督进程 | 每个模块拆成 cxx_module_bmi(BMI 落盘即退出)+ cxx_module_obj(等 codegen 收尾并传播退出码),共用一个编译进程 |
改 ninja_backend + 一个新 helper 子命令 |
ninja 槽位记账;中断时需清理后台进程;Windows 无 fork(用 Job Object) |
| (B) Clang 原生两阶段 ⭐ | --precompile → .pcm,再 -c x.pcm → .o。Clang 本来就支持,mcpp 目前只在 std 模块上用了它,项目模块走的是单阶段 -fmodule-output= |
只改构建图,无需监督进程/信号量/映射器 | 已实测:总 CPU 只多 7%,关键路径份额降到 25%(§7.3),风险接近零 |
| (C) mcpp 作为模块映射器服务 | -fmodule-mapper=<socket>,mcpp 阻塞应答 MODULE-IMPORT 直到 BMI 就绪,生产者发 MODULE-COMPILED 时立即放行 |
最大改动 | 死锁:并发槽被"等 import"的编译器占满而生产者排不进来 ⇒ 必须按拓扑序做准入控制 |
建议路线:(B) 先落地(Clang 上零风险拿到收益,macOS/Windows 默认即 llvm)→ (A) 覆盖 GCC → (C) 作为下一代架构。
给 GCC 上游的反馈:-fmodule-only 文档写的是 "Only emit Compiled Module Interface",实际仍完整执行 codegen 再丢弃(§1.3 实测)。若上游修复,方案 A 可退化成两条普通 ninja 边,复杂度大降,并直接逼近模型 C 的 4.66×。
问题:F4。body-only 编辑仍然 47.8s。
根因是工程结构:mcpp 有 137 个模块接口单元、1 个实现 TU。GCC 的 BMI 携带函数体(为跨模块内联),所以接口单元里的任何编辑都改变 BMI 字节 ⇒ 代价 O(导入者),而不是 O(1)。
方案:非 inline、非模板的函数体迁往模块实现单元(module mcpp.ui;,不带 export)。实现单元不产生 BMI,编辑它只重编 1 个 TU。
优先靶点(按 LOC × 扇入):
| 模块 | LOC | 扇入 | 现状单次编辑代价 |
|---|---|---|---|
build/prepare.cppm |
6476(占全项目 11%) | — | 15.99s 自身 + 关键路径 20% |
ui.cppm |
723 | 27 | 47.8s(实测) |
platform/platform.cppm |
— | 46 | 73.0s(实测,内容未变时) |
manifest/manifest.cppm |
— | 30 | — |
成本:重构工作量大,可增量推进(先动上表 4 个)。需要先确认 mcpp 的 scanner / glob 对实现单元的支持体验。
注意:prepare.cppm 6476 行本身就是独立问题——它是关键路径末端最重的单点(占 20%),即使不迁实现,拆分它也直接缩短关键路径。
codegen 占全部工作量的 77%。BMI 稳定后(§6.1),.o 也可按内容哈希缓存。mcpp 已有 ~/.mcpp/build-cache/v1/ 用于依赖包,扩展到根包即可。
收益场景:切分支来回、revert、CI 缓存恢复。不改善首次冷构建。
实测否定了这条:-O0 相对 -O2 只快 1.75×(77.55s → 44.23s)。对一个 codegen 占 77% 工作量的构建,这个比例说明降档拿不到成比例的收益——前端与关键路径结构才是主导。而代价是产物运行时性能全丢。
⇒ 不值得。相比之下 §6.2(2.12× 实测)与 §7.3(Clang 两阶段)既不牺牲产物质量,收益也更大。
生态先例仅作参考:xlings 迁移前的 xmake.lua 因 GCC 15 在 -O1/-O2 + C++23 modules 上 ICE(tree-ssa-dce)而在 Linux 上强制 -Og。那是规避编译器崩溃,不是性能选择。
mcpp build,同一工程、同一 -O2、同样 137+1 个编译单元、同样的边数,只换工具链:
| GCC 16.1.0 | Clang 22.1.8 | ||
|---|---|---|---|
| 冷构建墙钟 | 77.55s | 32.08s | 2.42× |
| 总工作量(所有边耗时之和) | 309.0s | 123.0s | 2.51× |
| 平均并行度 | 3.91× | 3.89× | 一样低 |
| 关键路径占墙钟 | 100% | 100% | 一样是延迟瓶颈 |
最重单点 prepare.m.o |
16.1s | 7.1s | 2.27× |
| 产物大小 | 19.3 MB | 5.7 MB | (libc++ + 默认 strip 差异) |
这两组数字要一起读:
- Clang 让每个单元便宜 2.5 倍 —— 这是纯粹的编译器前端/后端效率差距,零工程成本;
- 但 Clang 的构建结构性病理与 GCC 完全一样:并行度 3.89×、关键路径 100%。换编译器不解决 F1/F2。
⇒ §6.2 的 BMI 提前释放与"换 Clang"是正交的、可以相乘的,不是二选一。粗略叠加后 mcpp 自举冷构建有望进入 15s 量级(当前 77.55s)。
| 能力 | GCC 16.1.0 | Clang 22.1.8 |
|---|---|---|
| 廉价"只产 BMI" | ✗ -fmodule-only 仍完整跑 codegen 再丢弃(15.93s vs 15.95s) |
✓ --precompile 1.80s vs 单阶段 7.18s(3.99×) |
| BMI 在编译进度多早落盘 | 22.8% | 25.3% —— 同样的问题 |
| BMI 字节可复现 ⇒ F3 | ✗ 嵌 buildtime/localtime,需 §6.1 |
✓ 字节稳定,无需任何处理 |
| 精简 BMI 能否免掉 body 编辑级联 ⇒ F4 | ✗ | ✗ -fmodules-reduced-bmi 实测无效 |
| 模块映射器协议(P1184) | ✓ 实测可用,MODULE-COMPILED 即就绪信号 |
✗(用 -fmodule-file=) |
| 惰性导入 | ✓ 默认即惰性(§5.1) | ✓ |
对同一个 build/prepare.cppm:
| 耗时 | 落在关键路径上的部分 | |
|---|---|---|
单阶段 -fmodule-output(mcpp 现状) |
7.18s | 7.18s |
两阶段 · phase 1 --precompile |
1.80s | 1.80s |
两阶段 · phase 2 .pcm → .o |
5.90s | 0(可完全并行) |
| 两阶段合计 | 7.71s | — |
总 CPU 只多 7%,关键路径上的份额降到 25%。 而且实现上只需要把一条 ninja 边拆成两条——不需要监督进程、不需要信号量、不需要映射器服务,§6.2 那两个实现陷阱一个都不会遇到。
⇒ 这是整份报告里性价比最高的一条:Clang 上改构建图即可,风险接近零。
早期设计文档(2026-05-12 §3.4)寄望于 Clang 的 -fmodules-reduced-bmi 来消除"改实现也级联"。实测不成立:
| 编辑方式 | GCC BMI 变化 | Clang BMI 变化 | Clang + reduced-bmi |
|---|---|---|---|
| 函数体内加一行注释(移动行号) | 变 | 变 21 974 B | 变 21 974 B |
| 不改变行数的函数体内编辑 | 变 14 B | 变 14 354 B | 变 14 354 B |
两个编译器都会变 ⇒ 都会级联。F4 只能靠 §6.3 的工程结构调整解决(把实现移出接口单元),没有编译器开关可用。
- F3(BMI 不确定性)是 GCC 独有的 —— Clang 上 mcpp 现有的级联抑制本来就在工作。这意味着 §6.1 是 GCC 专项修复。
- F1/F2(延迟瓶颈)两个编译器都有,且 mcpp 目前在 Clang 上也走单阶段(
-fmodule-output=),白白放弃了--precompile。 - 换编译器与改调度是正交的:Clang 已经快 2.42×,叠加两阶段后关键路径还能再降约 4×。
- Windows 无
fork,§6.2(A) 的监督进程需 Job Object;但 Windows 默认已是llvm@20.1.7,走 §7.3 的两阶段即可绕开。 - macOS 默认
llvm@22.1.8,同样直接受益。 - musl / 交叉目标 不影响本分析任何结论(瓶颈在前端与调度,不在 libc)。
跨平台注意:
- Windows 无
fork,§6.2(A) 的监督进程需用 Job Object 保证中断时不残留;MSVC 两阶段天然 - macOS 默认 llvm ⇒ §6.2(B) 收益立即可得
- musl / 交叉目标 不影响本分析的任何结论(瓶颈在前端与调度,不在 libc)
按"收益 ÷ 风险"排,每条都给出验证判据——判据不满足就不算做完。
| # | 动作 | 适用 | 实测/预估收益 | 风险 | 验证判据 |
|---|---|---|---|---|---|
| 1 | §7.3 Clang 走原生两阶段(--precompile + -c x.pcm) |
Clang(macOS/Windows 默认,Linux 可选) | 关键路径份额 7.18s→1.80s(3.99×),总 CPU 仅 +7% | 极低:只改构建图 | 冷构建墙钟下降;.pcm 与单阶段产物等价;mcpp test 全绿 |
| 2 | §6.1 BMI 比较忽略时间戳(mcpp bmi-equal) |
仅 GCC | touch 场景 73.0s → 0.22s | 低 | 必须双侧钉:内容未变→不级联;接口变更→仍完整级联(只测前者分不清"修好了"和"坏了") |
| 3 | §6.2(A) GCC 侧 BMI 落盘即释放 | GCC | 实测 2.12×(77.42→36.56s) | 中:进程生命周期管理 | 产物一致 + 构建后无残留编译器进程 + 中断可清理; |
| 4 | §6.3 实现移出接口单元(先动 prepare.cppm 6476 行) |
全平台 | body 编辑 47.8s → 预计个位数秒 | 中:重构量大,可增量 | 改一个实现单元后重编 TU 数 = 1 |
| 5 | §6.4 对象级缓存 | 全平台 | 切分支/revert 场景 | 低 | 命中时确实跳过编译( |
| 6 | §6.2(C) mcpp 作为模块映射器服务 | GCC | 逼近模型 C(4.66×) | 高:需拓扑序准入控制防死锁 | 大规模工程下无死锁、无饥饿 |
明确不做:§5.1 缩小 BMI/降扇入、§5.2 优化扫描阶段、§5.3 分布式编译(在 #3 之前无效)、§6.5 降优化档。
关于默认工具链:Linux 默认 gcc@16.1.0 比 llvm@22.1.8 慢 2.42×(§7.1)。这是个值得单独评估的决策——但它与上表正交,不是替代关系。
本报告成文时用的是一次性 bash + hyperfine 脚本;它已被
bench/取代 —— 一套用 mcpp 写的跨平台基准套件,见 bench 架构与实施计划。下列命令是当前的复现方式。
# 基准(同一个二进制,跨三平台)
cd bench && mcpp build
./target/*/*/bin/bench --list # 本机装了哪些引擎
./target/*/*/bin/bench --engines mcpp,mcpp-opt,cmake,xmake \
--variants headers,modules,modules-impl \
--scenarios cold,noop,touch-hub,edit-body
# 构建剖析器(同一个二进制的 --analyze 模式)
./target/*/*/bin/bench --analyze ../target/x86_64-linux-gnu/<fingerprint>
# BMI 提前释放原型的 A/B
bench/proto-bmi-release/run_proto.sh测量契约见 bench/README.md;结果按运行分目录,索引见 bench/results/README.md,
本文这批数据的出处在 bench/results/hyperfine-20260812/NOTES.md。
sum t_bmi = 59.1 s sum t_total = 258.9 s BMI 占比 22.8% 白等 199.8 s
单元 138 依赖边 736 平均扇出 5.3 总 LOC 56 555 平均 4.6 ms/行
corr(LOC, t_total) = 0.825
扇出 top3 prepare 60 · doctor 27 · ninja_backend 24
扇入 top4 std 134 · mcpp.platform 46 · mcpp.manifest 30 · mcpp.ui 27
纯聚合模块 platform.cppm(9 条 export import,0 行实码)· pm.cppm · manifest.cppm
空模块 12.1 ms → + import std(31.5 MB)16.9 ms → + import json(17.1 MB)19.2 ms
连续两次相同编译,BMI 差 4 字节:
buildtime: 2026/08/12 02:25:01 UTC localtime: 2026/08/12 02:25:01 UTC
buildtime: 2026/08/12 02:25:33 UTC localtime: 2026/08/12 02:25:33 UTC
设 SOURCE_DATE_EPOCH 后:字节完全一致,且 localtime 字段消失
10:25:01.058 编译开始
10:25:01.855 openat("gcm.cache/mcpp.build.plan.gcm~", O_RDWR|O_CREAT|O_TRUNC)
10:25:01.950 close → rename(...gcm~, ...gcm) BMI 原子就位
10:25:06.527 进程退出 之后 982 个系统调用,无一再碰它
--> HELLO 1 GCC '' ; <-- HELLO 1 mapper-probe gcm.cache ;
--> MODULE-REPO <-- PATHNAME gcm.cache
--> MODULE-EXPORT probe.a <-- PATHNAME probe.a.gcm
--> MODULE-COMPILED probe.a <-- OK ← 这就是「CMI 已就绪」信号
; 表示批处理续行,应答必须镜像该标记,否则 GCC 会把第 N 个应答配到第 N+1 个请求上。