Skip to content

Latest commit

 

History

History
628 lines (425 loc) · 34.4 KB

File metadata and controls

628 lines (425 loc) · 34.4 KB

模块化 C++ 构建性能深度分析与优化方案

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 行


0. 一句话结论

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. 分析方法与策略

1.1 策略:先证伪"编译器很慢",再定位"等待很久"

面对"构建慢",默认假设通常是"编译器慢 / 代码太多"。这个假设在本例中是错的,而且错得很具体。分析按以下顺序推进,每一步都要求可证伪:

步骤 问题 判据 结果
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 嵌了时间戳 → 级联抑制永久失效

1.2 五个把结论带偏的测量陷阱(每一个都真的改变过结论)

这些不是花絮,是复现本报告时必须避开的坑:

  1. 多输出边在 .ninja_log 里每个输出各写一行,起止时间相同。按行求和会把编译耗时从 302s 读成 604s。必须按 (start, end, command_hash) 去重。

  2. 模块的真实依赖边不在 build.ninja,而在构建期生成的 dyndep 文件 obj/*.ddi.dd 中。只读 build.ninja 算出的关键路径是 22s,折入 dyndep 后是 79s——差 3.6 倍,足以得出完全相反的结论("并行调度有问题" vs "关键路径就是全部")。

  3. dyndep 把依赖挂在 obj/X.m.o 上,而导入者依赖的是同一条边的另一个输出 gcm.cache/X.gcm 不把同一条边的多个输出合并成单个图节点,最长路径走两跳就断了。

  4. ninja 是追加写 .ninja_log 的,且每次调用时钟从 0 重启。 多次构建混在一起会算出"关键路径 > makespan"这种不可能的读数(xlings 那份日志第一次跑出 136%)。必须只取最后一次调用。

  5. 最长路径必须按拓扑序松弛,不能用栈式 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

1.3 一个被推翻的假设(保留在此以免后人重走)

假设: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)。

1.4 工具链

工具 用途 关键用法
.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)。这本身是一条方法论结论:构建性能问题通常不是"哪段代码热",而是"谁在等谁"。


2. 基线数据

2.1 mcpp 自举构建(GCC 16.1,-O2,-j32)

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 秒的阶段。

2.2 并发度塌陷

  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 次的中位数。两者一致,前者用于结构分析,后者用于对比。

2.3 BMI 落盘时刻 vs 编译总时长(全部 137 个模块,-O2)

方法:逐个模块单独编译,轮询其 .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%

2.4 BMI 是原子就位的(可安全提前消费)

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 就放行下游"在构造上是安全的,不需要额外加锁或校验。


3. 根因

F1 — 构建是延迟瓶颈,加核完全无效

离散事件模拟(真实依赖图 + 实测每模块耗时):

P=8    72.0s → 并行度不是约束
P=16   72.0s
P=24   72.0s
P=32   72.0s
P=64   72.0s   ← 加到 64 线程,一秒都不会快

F2 — 关键路径上 77% 的时间在生产无人等待的 .o

见 §2.3 / §2.4。GCC 单阶段模型下,BMI 与 .o 由同一个进程产出;ninja 的依赖模型只认"边结束",于是导入者被迫等到 codegen 收尾。

F3 — GCC 把时间戳写进 BMI,级联抑制机制从未生效

build.ninjacxx_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 边),回退亦然。

F4 — 改函数体照样全量级联(架构问题,不是 bug)

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)。

F5 — 扫描/dyndep 阶段不是瓶颈

cxx_scan 1.83s + cxx_dyndep 0.48s = 全部工作量的 0.8%。每 TU 三次进程调用的设计不需要优化

F6 — prepare.cppm 单点占关键路径 20%

16.1s,扇入 61 个 BMI(含 31MB 的 std.gcm 与 17MB 的 mcpp.libs.json.gcm)。


4. mcpp vs xmake 实测对比

同一份源码(137 .cppm + main.cpp)、同一个 g++ 二进制(xim-x-gcc/16.1.0,由 xmake.luamcpp.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%。优化档位越高,两个引擎的差距越明显。

4.1 冷构建差距的归因(两个已声明的不对称,都已量化)

不对称 实测值 结论
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%),是真实的引擎差异,不是缓存优势。

4.2 最重要的判读:两个引擎在增量场景下一起失败

touch-hub 一栏是全表最关键的信息:一个内容完全没变的文件,mcpp 花 73.79s、xmake 花 81.70s,而冷构建分别是 77.55s / 88.94s —— 增量 ≈ 全量

两个独立实现的构建引擎表现几乎一致,说明这不是某一家的实现质量问题,而是 C++ 命名模块 + GCC 的结构性问题(§3 的 F3/F4)。这也意味着:

在 GCC 上,任何构建系统都无法靠"更聪明的调度"解决增量问题——必须解决 BMI 的确定性(F3)与 BMI 携带函数体(F4)。

4.3 结论在第二个独立项目上复现:xlings

只测一个项目得出的"结构性结论"不可信。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 单阶段 + 边完成即释放"这一组合的固有结果。



5. 被证伪的优化方向(先说不要做什么)

投入之前先砍掉三条看起来合理、实测无效的路线。每条都有具体判据。

✗ 5.1 "缩小 BMI / 降低扇入"——BMI 体积几乎不要钱

直觉: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 大概率也没有收益(默认已惰性)。

✗ 5.2 "优化 scan / dyndep 阶段"——它只占 0.8%

每个 TU 三次进程调用(scan → dyndep → compile)看着浪费,实测 cxx_scan 1.83s + cxx_dyndep 0.48s = 全部工作量的 0.8%。不要动。

✗ 5.3 "上分布式编译(distcc/icecc)"——关键路径 100%,分布式无处可分

关键路径 = 100% 墙钟意味着任何时刻可并行的工作都已经并行完了。模拟显示 P=64 与 P=16 完全同速。分布式编译在 F2 解决之前是纯粹的负收益(加了网络延迟)。

顺序很重要:必须先做 §6.2,分布式才有意义。


6. 优化方案

收益 / 成本 排序。每条都给出判据与验证方式。

6.1 ✅【已实施 · 2026.8.12.1】让级联抑制真正生效(仅 GCC)

问题: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 分不清"级联被正确抑制"和"级联坏了"。

6.2 【L1·最大收益】BMI 落盘即释放下游

问题: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.oClang 本来就支持,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×。

6.3 【L2·架构】把实现移出模块接口单元

问题: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%),即使不迁实现,拆分它也直接缩短关键路径。

6.4 【L1】对象级缓存

codegen 占全部工作量的 77%。BMI 稳定后(§6.1),.o 也可按内容哈希缓存。mcpp 已有 ~/.mcpp/build-cache/v1/ 用于依赖包,扩展到根包即可。

收益场景:切分支来回、revert、CI 缓存恢复。不改善首次冷构建。

6.5 【不推荐】降低优化档位

实测否定了这条:-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。那是规避编译器崩溃,不是性能选择。


7. 跨平台与不同编译器

7.1 实测:同一份代码,Clang 比 GCC 快 2.42×

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)。

7.2 编译器能力矩阵(全部实测,不是查文档)

能力 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)

7.3 关键实测:Clang 的两阶段编译几乎是白送的

对同一个 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 上改构建图即可,风险接近零。

7.4 关于 F4 的更正

早期设计文档(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 的工程结构调整解决(把实现移出接口单元),没有编译器开关可用。

7.5 平台结论

  • F3(BMI 不确定性)是 GCC 独有的 —— Clang 上 mcpp 现有的级联抑制本来就在工作。这意味着 §6.1 是 GCC 专项修复。
  • F1/F2(延迟瓶颈)两个编译器都有,且 mcpp 目前在 Clang 上也走单阶段(-fmodule-output=),白白放弃了 --precompile
  • 换编译器与改调度是正交的:Clang 已经快 2.42×,叠加两阶段后关键路径还能再降约 4×。
  • Windowsfork,§6.2(A) 的监督进程需 Job Object;但 Windows 默认已是 llvm@20.1.7,走 §7.3 的两阶段即可绕开。
  • macOS 默认 llvm@22.1.8,同样直接受益。
  • musl / 交叉目标 不影响本分析任何结论(瓶颈在前端与调度,不在 libc)。

跨平台注意:

  • Windowsfork,§6.2(A) 的监督进程需用 Job Object 保证中断时不残留;MSVC 两阶段天然
  • macOS 默认 llvm ⇒ §6.2(B) 收益立即可得
  • musl / 交叉目标 不影响本分析的任何结论(瓶颈在前端与调度,不在 libc)

8. 建议落地顺序

按"收益 ÷ 风险"排,每条都给出验证判据——判据不满足就不算做完。

# 动作 适用 实测/预估收益 风险 验证判据
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) 中:进程生命周期管理 产物一致 + 构建后无残留编译器进程 + 中断可清理;⚠️ 必检 BMI 边耗时是否真的远小于 OBJ 边
4 §6.3 实现移出接口单元(先动 prepare.cppm 6476 行) 全平台 body 编辑 47.8s → 预计个位数秒 中:重构量大,可增量 改一个实现单元后重编 TU 数 = 1
5 §6.4 对象级缓存 全平台 切分支/revert 场景 命中时确实跳过编译(⚠️ 历史上出现过"命中也 100% 重编"的假缓存)
6 §6.2(C) mcpp 作为模块映射器服务 GCC 逼近模型 C(4.66×) 高:需拓扑序准入控制防死锁 大规模工程下无死锁、无饥饿

明确不做:§5.1 缩小 BMI/降扇入、§5.2 优化扫描阶段、§5.3 分布式编译(在 #3 之前无效)、§6.5 降优化档。

关于默认工具链:Linux 默认 gcc@16.1.0llvm@22.1.82.42×(§7.1)。这是个值得单独评估的决策——但它与上表正交,不是替代关系。


附录 A:复现方式

本报告成文时用的是一次性 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

附录 B:关键原始数据

B1 每模块 BMI 落盘时刻(-O2,137 个单元合计)

sum t_bmi = 59.1 s     sum t_total = 258.9 s     BMI 占比 22.8%     白等 199.8 s

B2 模块图形态

单元 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

B3 模块导入的边际成本(证伪"BMI 太大"这条路线)

空模块 12.1 ms  →  + import std(31.5 MB)16.9 ms  →  + import json(17.1 MB)19.2 ms

B4 GCC 的 BMI 非确定性

连续两次相同编译,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 字段消失

B5 BMI 的原子提交(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  →  rename(...gcm~, ...gcm)        BMI 原子就位
10:25:06.527  进程退出                                  之后 982 个系统调用,无一再碰它

B6 GCC 模块映射器协议实测报文

--> 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 个请求上。