目标:mcpp clean && mcpp build --release 从 79.9s 降到 50s 以内。
本文只用实测数字。每个结论后面都注明它是测出来的还是推算的,推算的给出推算方式。
| 工程 | mcpp 自身,138 个模块接口单元 + src/main.cpp,57k 行,全部 import std; |
| 主机 | 13th Gen Intel i9-13900K,32 逻辑 / 24 物理(异构),64 GiB |
| 工具链 | gcc@16.1.0(mcpp 自带载荷),release = -std=c++23 -fmodules -O2 |
| 冷构建 | 79.92s |
对照(同机、同源码,构建描述在 bench/projects/mcpp/):
| 引擎 | 冷构建 |
|---|---|
| mcpp | 80.0s |
| cmake 4.0.2 + ninja | 94.5s |
| xmake v3.0.7 | 94.6s |
四个引擎都在 15% 以内。 这说明瓶颈不在调度实现,而在所有引擎共有的那个东西 —— 图的形状。
bench --analyze:
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 路并行。
直接推论,每一条都实测印证过:
- 加核无效。
-j4/8/16/32= 101.3 / 81.0 / 80.0 / 79.9s。从 8 到 32 提升 1.4%。 - 分布式编译无效。 关键路径是串行依赖,不是资源不足。
- 换引擎无效。 cmake/xmake 走同一条链,差异 ≤ 18%。
换了编译器,形状也不变。 clang 下重测:
makespan : 32.20 s ← gcc 79.79 s
work (sum dur) : 125.49 s ← gcc 314.08 s
avg parallelism: 3.90 x ← gcc 3.94 x
critical path : 32.15 s = 100% of makespan
clang 不是调度得更好,而是每个模块便宜 2.5 倍;100% 关键路径、3.9 路并行这两个结构特征一模一样。 所以「换 clang」是把常数压小,不是把问题解决 —— 工程再长大一倍,它同样会顶到墙。
-ftime-report,src/build/prepare.cppm(16.2s,链上最贵的一跳):
| 阶段 | 秒 | 占比 |
|---|---|---|
| opt and generate | 14.08 | 86% |
| ├ callgraph functions expansion | 11.00 | 67% |
| └ callgraph ipa passes | 2.77 | 17% |
| phase parsing | 1.32 | 8% |
| template instantiation | 0.95 | 6% |
| module import | 0.51 | 3% |
一次模块接口编译的 86% 是代码生成 —— 而它的导入者一个字节都用不到。
这一点有独立的三重验证(见 §5.2 的方法):BMI 在编译的 15%–39%(中位 ~22%) 处 就已经原子落盘、与最终产物逐字节相同、且真实下游导入者能用它编译成功。
-fmodule-only 量「只产 BMI 要多久」,得到 99%,
据此判定「codegen 只占 1%,没有优化空间」。这是错的 —— GCC 的 -fmodule-only
不跳过后端,只是不写目标文件。这个 flag 不能用来回答这个问题。
按源码 import 关系建图,用实测编译耗时加权求最长路径:
最长导入链 = 19 个模块,链上编译耗时合计 74.6s(全图 306.0s)
1.02s mcpp.platform.env
2.84s mcpp.platform.process
0.02s mcpp.platform ← 纯 re-export 门面
1.98s mcpp.manifest.types
5.21s mcpp.manifest.toml
0.02s mcpp.manifest ← 纯 re-export 门面
1.82s mcpp.platform.xlings.runtime_selection
5.65s mcpp.platform.runtime_binding
4.03s mcpp.platform.elf_runtime
2.06s mcpp.build.loader_contract
6.08s mcpp.build.plan
2.82s mcpp.build.flags
4.76s mcpp.build.compile_commands
3.69s mcpp.build.ninja
16.41s mcpp.build.prepare ← 22% of the chain
4.70s mcpp.build.execute
2.21s mcpp.build.configure
3.49s mcpp.cli.cmd_build
5.77s mcpp.cli
(尾部还有 obj/main.o 4.78s + link 0.18s,不在模块链内但在关键路径上。)
两个观察:
- 这条链是真实的分层:platform → manifest → build → cli。它不是偶然的耦合, 压平它等于破坏架构。所以「重构掉这条链」不是一个可行方案。
- 成本是摊开的,不是集中的。 142 次模块编译:中位 1.99s,
top5 占 13%、top10 占 21%、top20 占 35%;44 个 <1s,66 个 1–3s,30 个 3–6s,2 个 >6s。
只有
build.prepare(16.4s)是真离群。 推论:「把最胖的模块拆了」不是通用解,它只在那一跳上有效。
| 杠杆 | 冷构建 | 依据 | 改动面 | |
|---|---|---|---|---|
| L1 | 默认工具链换 clang | 79.9 → 32.2s(2.48×) | 实测 | 一行 manifest |
| L2 | 引擎:下游在 BMI 可用时即开始 | gcc 80.5 → 39.2s(2.05×) | 实测(原型 A/B) | 引擎中等 |
| L3 | 源码:定义移出接口单元 | 链 74.6 → ~10.4s | 推算(§2 的 86%) | 138 个模块 |
| L4 | 源码:拆 build.prepare |
链 −8~11s | 推算 | 一个模块 |
L1 与 L2 可叠加(一个压常数、一个改形状),叠加后推算 ~15s。
零引擎改动,单独就达标。但它不改变形状(§1),而且有三个必须先回答的问题:
- mcpp 在三平台上的 llvm 载荷是否都可用、版本是否统一(目前
windows = "llvm@20.1.7", 与 Linux/macOS 的 22.1.8 不同)。 - 换默认工具链会让所有已发布包的指纹失效,全生态一次性重编。
- ABI:
-static-libstdc++与 libc++/libstdc++ 的选择;共享库不得内嵌 C++ 运行时 (已知问题,见origin-precedence-and-shared-lib-cxx-runtime)。
这是一个生态决策,不是性能决策。 它应当单独立项。
这是唯一一条既改变形状、又不需要改源码结构的路。
| 编译器 | BMI 可用时刻 | 机制 | 可移植性 |
|---|---|---|---|
| GCC 16.1 | 编译的 ~22% | 原子 rename + 甩开 codegen | 需要一个比本进程活得久的监督进程 |
| Clang 22.1 | 57% | 原生两阶段,两条普通 ninja 边 | 完全可移植,零进程把戏 |
Clang 实测:--precompile 0.78s、-c 自 .pcm 0.70s,两阶段总 CPU 比单阶段(1.36s)
多 9.6%,而下游解锁点从 100% 提前到 57%。
而且 clang 不能用 GCC 那套:strace 证实它以 O_TRUNC 直接写最终路径,没有 rename ——
「看文件出现」对它不成立,读者会读到写了一半的 .pcm。反过来,GCC 没有便宜的两阶段
(-fmodule-only 要 99% 的时间,见 §2 的错误推理)。两条路互为补集,不是二选一。
策略落在已有的 BmiTraits(src/toolchain/model.cppm)里,和 moduleOutputPrefix、
bmiSearchPrefix 并列 —— 那正是「同一个决策只推导一次」的位置:
clang → TwoPhase cxx_precompile: x.cppm -> x.pcm
cxx_object_pcm: x.pcm -> x.o
gcc → DetachCodegen cxx_module_bmi: 编译器启动 → BMI 原子落盘 → 本边退出 0
cxx_module_obj: 等该进程结束 → 回放输出 → 传播退出码
msvc → None(待调研:/ifcOnly 是否便宜、.ifc 是否原子发布)
两种形状下,下游 import 只依赖 BMI 边,link 依赖 object 边。
「比本进程活得久的进程」用派生一个 mcpp 监督子进程实现,不用 fork() ——
派生进程在 Windows 上同样成立,所以 DetachCodegen 也不是 POSIX 专属;
它的前提(BMI 原子发布)才是编译器专属的。
原型 A/B(同构建目录、同编译器、同 flags、同编译器并发上限、同源码集, 唯一差异是图的形状):
baseline rc=0 wall=80.51s ninja -j32 compilers<=32 binary=19362456
split rc=0 wall=39.23s ninja -j192 compilers<=32 binary=19362456
BMI 边: n=142 中位 940ms OBJ 边: n=143 中位 3091ms
产物可运行;残留游离编译器进程 0
- 后台子进程会继承 ninja 的管道。 ninja 认为一条边结束是管道 EOF,不是直接子进程退出。 继承管道会让提前退出完全不可见,每条 BMI 边被记成整条编译的耗时 —— 第一次原型 就是这样得出「这个想法不成立」的。子进程的 stdio 必须重定向到文件,由 obj 边回放。
ninja -j必须远大于编译器并发上限。 编译器一旦脱离,就不再占 ninja 的槽; 若-j等于上限,槽会被「正在睡觉的边」占满、就绪前沿饿死,调度退化成 baseline。 两条边的并发必须用独立的信号量(原子mkdir令牌)来限,而不是靠-j。- 失败会迟到。 BMI 落盘之后 codegen 才失败时,模块边已经报成功了。 obj 边必须收集并复现每一个失败,否则会变成链接期一堆看不懂的未定义符号。
- 图必须自己声明形态。
build.ninja是共享可变状态,快路径会重放它。 拆分与否必须写进# mcpp:graph=那一行,否则换了开关之后快路径会重放旧形状的图 (这正是 #387/#407 的形状)。
还有一个附带收益:现在的 BMI 等价性判断是写在 ninja 命令里的一段 POSIX shell,
Windows 上整段跳过。把它移进 mcpp 子命令后,Windows 也能享受级联抑制。
§2 说一次接口编译 86% 是 codegen。这些 codegen 之所以发生在接口单元里, 是因为定义写在接口里。移到实现单元后:
- 接口单元只剩 parse + 实例化 ≈ 现成本的 14%,它们才是链上的节点;
- codegen 搬到实现单元 —— 它们是 DAG 的叶子(没人 import),完全可并行;
- 总 work 不变,关键路径推算 74.6 × 0.14 ≈ 10.4s +
main.o4.78s ≈ 15s, 之后转为吞吐受限(314s / 24 线程 ≈ 13s)。
这与 bench 的 modules-impl 变体测的是同一件事。但它要动 138 个模块,
是一次跨越整个代码库的重构,不能一次做完,也不该为了性能而牺牲可读性 ——
它应当作为新代码的书写约定逐步生效,并优先用在链上那 19 个模块。
16.41s,占链的 22%,是唯一的真离群点。拆成互不依赖的兄弟模块可直接缩短关键路径。
- 先做 L2。 唯一改变形状、且不需要改源码结构的路;实测 2.05×,单独就把 80s 打到 39s, 达成 <50s 目标。附带把级联抑制带到 Windows。
- L4 紧随其后。 一个模块的改动,收益可直接测量,且与 L2 叠加。
- L1 单独立项。 是生态决策(指纹失效、三平台载荷、ABI),不该混进性能 PR。
- L3 作为约定长期生效,优先施加于链上的 19 个模块。
- 加核 / 更大的
-j/ 分布式编译。 已实测:-j8 → -j32只快 1.4%。 - 压平模块分层。 §3:那条链是真实的架构分层,不是偶然耦合。
- 为了性能降低优化档。 改的是产物,不是构建。
- 缓存自己的 BMI 跨构建复用。 冷构建的定义就是没有缓存;这条对 §0 的目标无效。
.gcm 出现在编译的 14% 处,但「出现」不等于「写完」。三步缺一不可:
- 轮询到大小连续 150ms 不变 → 快照;
cmp快照与编译结束后的成品 → 逐字节相同;- 把快照放回
gcm.cache/,编译一个真实下游导入者 → exit 0。
只做第 1 步会把「文件被创建」当成「BMI 可用」。
「改函数体后 BMI 差 2 字节」看起来像铁证。跑一次同一份源码编译两次的对照:
差的是同样两个偏移,上下文是 buildtime: / localtime: 的秒位。
没有这个对照就会得出相反结论。
用栈式 DFS 求最长路径(带防环)会把 76.5s / 26 节点读成 33.9s / 10 节点, 把「100% 关键路径」读成「44%」,结论完全反过来。必须用 Kahn 拓扑序松弛。