feat(bench): 构建引擎基准套件 —— 把一次性脚本变成跨平台、可扩展的测量设施 (2026.8.12.1) - #423
Open
Sunrisepeak wants to merge 14 commits into
Open
feat(bench): 构建引擎基准套件 —— 把一次性脚本变成跨平台、可扩展的测量设施 (2026.8.12.1)#423Sunrisepeak wants to merge 14 commits into
Sunrisepeak wants to merge 14 commits into
Conversation
起因是一次实测:mcpp 的自举构建**不是吞吐瓶颈,是延迟瓶颈**。关键路径 = 100% 墙钟,
后 55% 的时间里 32 个硬件线程上只有 1 个编译进程在跑;而这条关键路径上 77% 的时间
在生产**没有任何下游需要的 `.o`** —— 下游真正需要的 BMI 在编译进度 22.8% 处就已经
原子 rename 就位(strace 证实:之后 982 个系统调用无一再碰它)。
同样的病理在 xlings(110 模块、独立作者、独立代码库)上完整复现:并行度 3.16×、
关键路径 100%。所以这不是某一家构建系统的实现问题,而是「C++23 命名模块 + GCC
单阶段 + 边完成即释放」这一组合的结构性结果。
完整分析见 .agents/docs/2026-08-12-modular-build-performance-deep-analysis.md,
架构与实施计划见 .agents/docs/2026-08-12-bench-suite-architecture-and-plan.md。
## 为什么要重写而不是扩展
上一轮用的是 bash + hyperfine 的一次性脚本,四个缺陷都是结构性的:只支持两个引擎
(加第三个要动主体)、**Windows 上根本跑不了**(而 mcpp 是三平台产品)、被测对象只
有 mcpp 自己(答不了「模块化 vs 头文件」这个真问题)、结果是随手加字段的 TSV
(跨机器无法合并)。
## bench/ 的设计
**协议先行。** `bench.protocol` 带 `protocol_version`,并把三条不变量写进类型而不是
留给约定 —— 每一条都被旧脚本违反过:
1. 失败不得伪装成数据。`status` 与 timing 是分开的字段,非 ok 的格**没有 median 键**
(而不是 0)。旧脚本把失败格式化成 "0.000 s",三个这样的格子进了结果文件,
看起来像是有史以来最快的构建。
2. 跳过必须带原因。"bazel 没装" 与 "bazel 跑挂了" 是相反的结论。
3. 结果与宿主同生共死,含**异构 CPU 标记** —— 13900K 的 32 线程不是 32 个同构核,
所有并行度数字都要照着它读。
**加一个引擎 = 加一个文件。** `bench.engines.Engine` + `registry.cppm` 一行,runner /
协议 / 场景 / CI 全不动。已接入 mcpp、mcpp-opt(优化前后成为矩阵的一个正交维度)、
cmake、xmake、meson、bazel。
**同一工程三种形态,生成而非手写**:`headers` / `modules` / `modules-impl`。手写两份
「等价」代码几乎必然在某处不等价,而那正是被测量的东西。第三种变体直接对应实测结论:
GCC 与 Clang 的模块接口单元 BMI **都**携带函数体,所以改任何一行函数体都会级联到全部
导入者,且没有编译器开关能解决(`-fmodules-reduced-bmi` 实测无效)。
**平台差异只在叶子。** 按 xlings `src/platform/*.cppm` 的既定约定:模块分区 + 整文件
宏控,非目标平台**不导出任何符号**。于是任一构建中每个名字只有一份定义、编译期自动
选中 —— 不需要 stub,也不需要 `if constexpr` 派发。`#if defined(_WIN32)` 只出现在
那两个分区里,runner / engines / protocol / fixture 全部零平台条件。
**`--analyze`**:同一个二进制还能剖析任意 ninja 构建目录(工作量 / makespan / 关键
路径 / 并发曲线),并固化了五个会**反转结论**的解析陷阱 —— 其中最狠的一个是:最长路径
必须按拓扑序松弛,栈式 DFS 的防环写法会把未算完的依赖记 0,把 76.5s/26 节点读成
33.9s/10 节点,把「100% 延迟瓶颈」读成「44%」。它是靠与独立 Python 实现交叉验证抓到的
—— 其余所有指标都吻合,唯独这一个差 2.3 倍。
## 实现过程中被实测推翻的三件事
- `-fmodule-only` 文档说「只产 CMI」,实测**照样跑完整个 codegen 再把结果丢弃**
(15.93s vs 完整 15.95s)。GCC 16.1 没有廉价产出 BMI 的开关;Clang 有。
- 「BMI 太大所以导入慢」不成立:`import std`(31.5MB BMI)只多 4.8ms —— GCC 的模块
导入本来就是惰性的。真正的驱动因素是代码量(corr(LOC, t_total) = 0.825)。
- 降优化档不是出路:`-O0` 相对 `-O2` 只快 1.75×,而产物运行时性能全丢。
## CI
`.github/workflows/bench.yml`,**仅手动触发**、覆盖 linux/macOS/windows、
**不设性能阈值**。基准是重活且噪声大,挂进每个 PR 只会淹没它要产出的信号;而在共享
runner 上设阈值,等于把正常方差变成人人学会忽略的红叉。
## 验证
- `mcpp build` 通过,`mcpp test` **80 passed / 0 failed**
- 新增 e2e `230_bench_harness.sh`:构建 harness、真实测量、并从**两侧**断言协议不变量
(只断言 ok 格有 median 会放过一个「给所有格都发 median」的实现)
- 六个引擎在本机全部实测跑通(mcpp / mcpp-opt / cmake / xmake / meson / bazel)
- `check_version_pins.sh` OK:xlings pin 已是最新发布 2026.8.11.2,无需变更
顺带保留仓库根的 `xmake.lua`(用 xmake 构建 mcpp 本身的对照臂)。它从 mcpp.toml 读取
`[toolchain] default` 来钉编译器 —— registry 里有多个 GCC,而「取目录序最后一个」只是
碰巧对。
## 1. mcpp 侧:一个从未生效过的机制
`cxx_module` 规则保留上一份 BMI、重编、内容相同则换回旧文件,让 ninja 的 restat
判定输出未变、从而不重建导入者。这套机制 2026-05-12 就设计并实现了,判据是 `cmp -s`。
**它一次都没走通过。** GCC 把 wall-clock 写进 BMI 的内容:
buildtime: 2026/08/12 02:25:01 UTC
localtime: 2026/08/12 02:25:01 UTC
同一份源码相隔一秒的两次编译,BMI 差恰好 4 个字节,`cmp` 永远报「变了」。当年的
设计说明只预见到 GCC 会重写文件(mtime 抖动)并据此开出内容比较的药方,没有预见到
时间戳本身就是内容 —— 所以药方按原样写出来就不可能生效。
新增 `mcpp bmi-equal`(内部子命令,由 ninja 规则调用),比较时掩掉这两个字段。
刻意不用 `SOURCE_DATE_EPOCH`:那会把整个编译的 epoch 钉死,从而改变**用户代码**里
`__DATE__` / `__TIME__` 的展开;掩码只改变 mcpp 认为「什么算相等」,别的都不动。
构造上保守:找不到预期字段、或两份文件对字段位置判断不一致时回落为严格比较 ——
可以把等价的判成不同,但绝不会把不同的判成相同。
实测(`bench --project` 测 mcpp 构建 mcpp 自身,touch 一个 46 导入者、内容未变的文件):
scenario 2026.8.11.3 2026.8.12.1
noop 0.27s 0.19s
touch-hub 73.99s 0.45s ~164x
单测从两侧钉死(8 例):只测「等价的判相等」会放过一个恒返回 true 的实现,而那比
原缺陷更糟 —— 它会静默吞掉所有真实的级联。
## 2. bench 侧:测二进制,不测模拟
删掉 `mcpp-opt` 引擎。它靠在构建前后设 `SOURCE_DATE_EPOCH` 来**模拟**优化 ——
在 harness 里模拟一个改动,测的是 harness 对该改动的理解,而且一旦真实实现与之
分叉就会静默地不再跟踪。
改为**按二进制参数化**:`--engines mcpp=<路径A>,mcpp=<路径B>` 注册两个引擎,各自
向自己的二进制询问版本并据此标注(`mcpp@2026.8.11.3` / `mcpp@2026.8.12.1`),
两行永远不会塌成一行。上面那张对比表就是这么测出来的。
新增 `--project <dir>`:就地测量一个已存在的工程,mcpp 自身即基础用例。该模式下
variant 轴坍缩为 `native`(工程就是它现在的样子,在它之上生成会毁掉被测对象);
需要扰动文件的场景必须显式指定 `--hub/--leaf/--body`,否则报 `skipped` **并说明
原因**,而不是挑一个文件产出一个看着有效的数字。
`edit-body` 会改源文件。项目模式下那是用户的文件,因此逐字节保存并在退出时恢复 ——
包括构建失败的路径,那正是遗留改动最容易被漏掉的时候。
## 3. 顺带修掉的两个真实问题
- **生成的 fixture 没钉工具链**,依赖机器的全局默认。本机绿、CI 红(`seed build
exited 1`)。现在与本仓库其他 mcpp 工程一样显式 pin。
- **e2e 失败时只打印子进程日志的路径**,而那个 tmpdir 在 trap 里已被删除 ——
在 CI 上等于没有信息。现在直接转储内容。
验证:`mcpp build` ✅ · 新增 8 个单测 ✅ · e2e 230 ✅ · 六引擎本机实测 ✅
ninja_backend 的 Windows 分支一直跳过 BMI restat 优化,因为整套 backup/compare/restore 是用 shell 的 if/cp/cmp 拼的。现在 bmi-equal 已是 mcpp 子命令,把 backup/restore 也收进一个 bmi-guard 子命令即可让两个平台 共用同一条规则 —— 直接后续,无需新设计。
CI 三个 e2e 分片全红,原因由新加的子进程日志转储一次点明:
[error] xlings: 'mcpp' is not installed
[error] hint: xlings install mcpp
bench 默认用裸名 `mcpp`,在 e2e 沙箱里那是个 xlings shim,解析不到任何东西;
而 $MCPP 才是这次要测的构建产物。改为 `--engines mcpp=$MCPP` —— 这本来就是
更正确的语义:e2e 应当测它构建出来的那个二进制,不是环境里碰巧装了什么。
顺带证明了上一提交加的日志转储是必要的:在此之前,失败信息只有一个指向
已被 trap 删除的 tmpdir 的路径。
mcpp 从来没给 ninja 传过 -j,于是一直用 ninja 的默认 nproc+2。实测这在两个
方向上都是错的:
内存:单个模块编译峰值 RSS 实测 prepare.cppm 1,057 MB / plan.cppm 561 MB。
64 核 / 32 GB 的机器会跑 66 路 × ~0.5-1 GB —— 换页。默认值在核多内存
少的机器上是主动有害的。
异构:i9-13900K 报 32 个逻辑 CPU,实为 8 P-core + 16 E-core。把它们当成 32
个等价 worker,会把可用并行度高估一倍以上。
而且对这个工程,多出来的并发根本没用:实测冷构建 -j8 = 81.0s,-j32 = 79.9s ——
4 倍 worker 换 1.4%。所以 auto 不是在牺牲速度换安全,是同样的时间下把内存占用
降到 1/4。
新增 mcpp.platform.capacity:核数(逻辑/物理/是否异构)与可用内存的跨平台探测。
接口只用整型 —— 仓库记录过 GCC 16.1 下新模块导出 std 类型会毒化下游 BMI。
公式:jobs = clamp(min(异构 ? 物理核 : 逻辑核,
(available - 2GiB) / 768MiB), 1, 64)
用 available 而非 total(构建通常不是机器上唯一的东西);per-job 估值来自本仓库
实测,且是参数而非常量,别的工程可按自己的规模调整。本机 auto → -j24。
--jobs 走与 --offline 相同的环境变量侧信道,理由也相同(消费方在 mcpp.build.execute
深处,逐层穿参要动中间每一个调用者)—— cli.cppm 里那条注释就是这么写的。
默认不变:改变所有人的并发是行为变更,先作为可选项。无效值会警告而不是静默回落,
否则一个拼写错误会变成「构建莫名其妙变慢」。
单测 8 例,针对合成的机器画像而不是跑测试的这台机器 —— 后者等于把答案复述一遍,
而且每个 CI runner 结论都不同。
顺带修正冷构建方案文档里一条被我自己的数据推翻的要求:我曾把原型第一次的
78.99s 归因于「管道继承」和「-j 必须远大于上限」两件事。单独扫描 -j 轴后:
-j32=37.84s(最快)/ -j64=38.23 / -j128=38.39 / -j192=39.06 —— -j 越大越慢。
那次失败几乎全部是管道继承,第二条基本不成立。
…ut of the measured tree
两个都是本轮自己引入的问题,CI 抓到的。
1. Windows 编译失败。`mcpp.platform.capacity` 的 Win32 分支用 malloc/free 处理
GetLogicalProcessorInformationEx 的两段式调用,但 clang 不会从 windows.h
拿到它们:
error: no member named 'malloc' in the global namespace; did you mean '_alloca'?
error: no type named 'free' in the global namespace
补 <stdlib.h>。POSIX 侧同一类问题上一轮已经在 Darwin 上踩过一次
(setenv/unsetenv 藏在 <_stdlib.h> 里),同样的修法。
2. bench 的 --project 模式把子进程日志写进了被测项目的根目录,于是
`bench-child.log` 被 git add -A 顺手提交了进来。
仅仅 gitignore 是治标:被测的那棵树在 --project 模式下就是用户的仓库,
往里面丢文件本身才是问题。日志改为落在 work 目录下的 logs/,按
引擎-场景命名;顺带 gitignore 兜底,并把已提交的那份删掉。
…ink fix
## --baseline
新增 `--baseline NAME`:在人类可读的摘要后追加一列归一化比值,按
(variant, scenario) 分组 —— 比值只有在同一源码形态、同一扰动下才有意义。
一列秒数回答「多久」,一列比值回答「相对什么」,而后者才是构建引擎对比真正
在问的问题。找不到基准格时明说「ratios omitted」,不静默省略整组。
## 五方对比结果(cmake 为基准)
bench/results/five-way-20260812.md + 原始 JSON(protocol v1,54 格)。
同一台机器、同一个 g++ 二进制、-std=c++23 -O2 -j24,生成式 fixture 40 单元:
modules / cold mcpp 3.58s · cmake 13.43s(3.7x)· xmake 11.43s
modules / touch-hub mcpp 0.30s · cmake 10.40s(34.8x)· xmake 11.16s
modules / edit-body mcpp 0.29s · cmake 10.43s(35.7x)· xmake 11.22s
四条结论写在文档里,其中两条是对 mcpp 自己不利的:
* 模块化让每个引擎都付出 4-5 倍代价(headers 0.49-4.90s vs modules
3.58-13.43s)—— 这是 C++20 模块今天的状态,不是某家构建系统的属性。
* mcpp 冷构建比 cmake 快 3.7x,但两者都远未触底:都走 GCC 单阶段,
BMI 要等整个编译(含无人等待的 codegen)退出才释放。这条优化对
cmake / xmake 同样可做,只是**谁都还没做**。
35x 那一栏专门验过不是「跳过了该做的工作」:把 unit_0 函数体里的一个值改掉,
产物输出随之改变(285733232 → 215499472),级联正确穿过全部 40 个模块。
## Windows 链接修复
`RegOpenKeyExA` / `RegQueryValueExA` 在 advapi32,lld-link 默认不链,
bench.exe 链接失败。用 `#pragma comment(lib, "advapi32.lib")` 就地声明,
让这个分区保持自包含,而不是把 ldflag 推给每个使用者的 manifest。
顺带压掉 MSVC CRT 对 std::getenv 的 deprecation 噪声。
## 基准源码快照
bench/README 补一节:测量要用**钉住 commit 的源码快照**,不要用你正在编辑的
工作树。这不只是噪声问题 —— 本轮一次 job-count 扫描连续三次报 rc=1,读起来像
「并发超过 16 就失败」,真因是两次运行之间工作树多了一个新模块,每一格都在用
一份不认识该模块的 build.ninja 构建。`git archive` 到外部目录即可,不用 clone
或 worktree:没有 .git,没有共享状态,仓库里切分支也够不到它。
…t it means —— 四处让这份 benchmark 从「看起来对」变成「说得清」 **bazel 其实支持模块,写死的 `supports=false` 抹掉了一整列真实数据。** 实测 bazel 9.2.0 + rules_cc 0.2.22:`module_interfaces` 属性存在, 配 `--experimental_cpp_modules` + `--features=cpp_modules`(缺任一个报不同的错) 与 **clang** 能构建并运行模块程序;配 **gcc** 则死在它自己的扫描器: `aggregate-ddi failed ... Invalid JSON string` —— 它解析不了 GCC 的 P1689 输出。 所以能力判断不是引擎的属性,而是引擎×编译器的属性,`supports()` 因此收下 compiler。 meson 1.10.2 的理由也改成实测原文(`module 'fx.a' not found`),不再是断言。 `--force_pic` 是模块单元能跑起来的前提:cc_binary 为 PIC 与非 PIC 两套目标文件 各注册一次 ddi 聚合动作,却共用 `<target>.CXXModules.json` 这一个输出名, 分析阶段就崩(`unit_0.pic.ddi` vs `unit_0.ddi`, `Outputs: are equal`)。 选 PIC 而不是 `-supports_pic`,因为它产出 PIE —— 和其他引擎的默认产物一致。 **`edit-body` 插的是注释,于是每一个「改代码快 N 倍」的数字其实在说注释。** 拆成两个场景:`edit-body` 插入带 nonce 的 `volatile` 语句(真改 codegen, nonce 在标识符里 —— 固定名字会在第 2 轮重复声明把构建打挂), `edit-comment` 往被广泛导入的接口单元插注释(字节变、接口没变)。 顺带记下一个反直觉的实测结论:GCC 16.1 **不把导出非模板函数的函数体写进 BMI**, 所以改函数体不重编导入者是**对的**。判据必须带对照组 —— 同一份源码编译两遍, 差的是同样两个偏移,落在 `buildtime:`/`localtime:` 的秒位上。 **相对路径的引擎二进制一直是不可用的。** 每条被测命令的 cwd 都是被测工程, 所以 `--engines mcpp=./mcpp-old` 解析到了 fixture 目录,整个矩阵报 `exited -1` 而日志是空的。规格转引擎的那一处统一锚定成绝对路径;裸名仍走 PATH。 同时把「起不来」和「跑了但失败」在措辞上分开——前者不再指向一个从未写入的日志。 **`touch-leaf` 定义了、文档写了、`--help` 也列了,却从未跑过**:它不在默认场景表里。 默认表改成全部六个,CI 的 `scenarios` 默认值同步。 引擎版本现在由引擎自己写进结果文件(cmake 4.0.2 / xmake v3.0.7+HEAD / bazel 9.2.0), 之前只记了 "cmake + ninja",数据自己说不清是哪个 cmake 产的;xmake 的彩色 banner 要剥 CSI,而按 `@`-`~` 直接扫会停在 `[` 上、留下每个 reset 的 "0m"。 结果:`bench/results/five-way-20260812.md` 两张完整矩阵(gcc / clang × 六引擎 × 三变体 × 六场景,cmake 为基准)。gcc 模块增量 mcpp 0.29s vs cmake 10.29s(35×), vs 上一版 mcpp 3.65s(12.5×);clang 下 cmake 冷构建自身快 3.3×,bazel 3.19s 参赛, xmake 每一个模块增量都是 ~12.6s。 测试:e2e 230 增两项 —— 相对引擎路径必须解析,引擎 note 不得含 ANSI 转义。 本地 82/82 单测通过,e2e 230 通过。
…ng at two —— 自我复审抓到的两处 `--jobs` 走的是 `--offline` 那条 env 侧信道,而那个预扫描会一路扫完整个 argv, 于是 `mcpp run -- -j 4` 里属于**被运行程序**的 `-j` 被 mcpp 当成了自己的并发设置。 `-j` 是个足够常见的 flag,这是「什么时候撞上」而不是「会不会撞上」的问题。 预扫描遇到裸 `--` 即停;`--quiet`/`--offline` 同样受益(它们本来也不该越过分隔符)。 `bmi_equivalent` 只掩蔽长得像时间戳的字节,但没限制**数量** —— 用户代码里一个 形如 `"buildtime: 2020/01/01 00:00:00 UTC"` 的字符串常量也会被掩掉, 于是改动它不会传播给导入者。实测真实 BMI 从 10 KiB 到 645 KiB 都**恰好 2 处** (一个 buildtime + 一个 localtime),超过就说明来源不是 GCC 的头部,退回严格比较。 测试:新增 e2e 231(`--jobs N|auto` 生效、坏值必须**告警而非静默降级**、 `--` 之后的参数必须原样送达程序且 mcpp 不得解读), 单测新增 `MoreStampsThanGccEmitsFallsBackToStrictCompare`(两侧都钉: 两处必须掩、三处必须不掩 —— 只钉一侧的话「什么都不掩」的实现也能通过)。 本地:e2e 230/231 通过,test_bmi_equivalent 9/9 通过。
… never parsed
—— 三个「看起来在测,其实没在测」
**fixture 几乎不含编译。** 实测单个 TU 0.23s,其中 0.17s 是 g++ 启动;
而 `weight` 这个旋钮推不动它 —— 它只产生 O(weight²) 次同一个平凡 constexpr 递归的
实例化(weight=40 也才几百次),编译器微秒级做完。真实对照(gcc 16.1,x86_64):
空模块 .................................. 0.17s
旧 fixture 单元 weight=6 ................ 0.23s ← 74% 是启动
旧 fixture 单元 weight=40 ............... 0.28s ← 6.7× 的旋钮只买到 20%
带真实 global module fragment 的单元 ..... 0.97s
mcpp 自己的单元(57k 行 / 139 个)........ 0.57s
只有 units 是线性的(0.088s/个)。**这套东西过去主要在测 g++ 启动。**
工作负载改成真实 C++ 的成本来源:标准库头 + 按**不同类型**实例化
(共用类型的话编译器只实例化一次,后面全免费 —— 这正是旧旋钮失效的原因)。
现在是 `0.38s + 0.066s × weight`,并且有实测扫描钉住:20 units 下
weight 0/4/12 = 4.7s/18.0s/31.4s。默认 weight=4 让单元成本落在 0.64s,
和真实工程同一量级。
**`.github/workflows/bench.yml` 从提交那天起就不是合法 YAML** ——
`run: "$BENCH" --list` 被读成一个带引号的标量后面跟垃圾。这个 workflow
一次都没能启动过,而且**没有任何东西会说** :GitHub 仍把坏 workflow 列为 active,
`workflow_dispatch`-only 的 workflow 不会被 push 触发,也没有测试看过它。
新增 e2e 232 逐个 parse `.github/workflows/*.yml`,并要求每个都声明了 jobs
(能 parse 但没有 jobs 是同一类「静默的什么都不做」)。两侧都验过:
修好的文件通过,坏形态必失败。
**尺寸必须有名字。** 自由三元组 (units, fanin, weight) 无法在两个人之间比较。
加 `--preset smoke|standard|large`,并让**默认形状就等于 standard** ——
否则「没带参数」和「--preset standard」会是两个不同的东西。
bench/README 补成一份真正的规范:§1a 工作负载必须真的是工作负载(新旋钮必须
附实测扫描,否则默认认定为惰性)、§1b 命名尺寸、§4a **有效性规则**
(R1 分辨率:落在本引擎 noop 2× 以内的单元测的是进程启动不是构建;
R2 离散度:极差/中位数 > 20% 只支持数量级结论)、§4b 明确不做的事、
§4c 采纳了哪些既有实践(SPEC 的全披露与禁止针对性调优、hyperfine 的
预热与离散度报告)以及**这不是什么**(没有审计、单机、跨机器只比表内比值)。
同时修掉一处被自己实测推翻的旧论断:注释里写「GCC 和 Clang 的 BMI 都携带函数体」,
实测 GCC 16.1 **不**携带导出非模板函数的函数体 —— 所以 modules-impl 变体量的是
两种决策规则(比 BMI 内容 vs 信 mtime)的差别,不是编译器限制。
e2e 230 的相对路径检查改成从二进制自身目录运行:relpath 在 Windows 跨盘符
直接抛 `path is on mount 'D:'`,在 macOS 上 `mktemp -d` 给 /var/… 而真实 cwd 是
/private/var/…(深一层)会让 `..` 少一级 —— 这两个 CI 红都是测试自己的缺陷。
…d files under bench/
—— 真实工程那条臂终于有基准了,外加一处被自己实测推翻的结论
**cmake 现在能构建 mcpp。** 之前 cmake 只能建合成 fixture,而真正有意义的负载是
mcpp 自己:138 个接口单元、57k 行、每一个都 `import std;`。没有这份 CMakeLists,
「以 cmake 为基准」在真实工程上根本无从谈起。
冷构建(mcpp 源码,gcc@16.1.0,release,同一编译器二进制):
mcpp 2026.8.12.1 80.0s 0.85x
cmake 4.0.2+ninja 94.0s 1.00x (基准)
xmake v3.0.7 91.6s 0.97x
**注意这和 fixture 上的 0.26x 相差极远** —— 合成负载上的四倍优势在真实工程上只剩 15%。
**构建文件挪到 bench/projects/mcpp/。** mcpp 由 mcpp 构建,仓库根上再放一份
CMakeLists 和 xmake.lua 是每个贡献者都要学会忽略的东西。为此给 harness 加了
`Job::buildfile_dir` 与 `--buildfiles DIR`:cmake 用 `-S`、xmake 用 `-P` 指向它,
mcpp 仍读工程自己的 manifest。另一个选项是运行期把它们拷进被测树,
但那会往用户仓库里写东西,而这个 harness 明确拒绝这么做。
踩到的两个真问题:
* FILE_SET 要求文件位于 base 目录下,而 cmdline 依赖在工程外 ⇒ 单独一个 file set。
* **`add_compile_options()` 到不了 CMake 自己生成的 `std` 模块目标** ⇒ std 用默认
libc 头、mcpp 单元用 `--sysroot` 的头,构建死在 `_IO_FILE` 类型冲突上,
而报错既不点名那个 flag 也不点名那个目标。改用 `CMAKE_CXX_FLAGS`。
**⚠️ 纠正:bazel 能构建 `import std;`,我先前写的「没有等价物」是错的。**
bazel 的 modmap 生成器确实会报 `Module not found: std`,但 libc++ 把 std 模块
以**普通源码**形式发布,可以当作任意接口单元来建 —— 实测在 bazel 9.2.0 上
构建并运行成功(配方记在 MODULE.bazel 里)。真正决定 bazel 不进这张表的是别的:
它的模块只能配 clang(解析不了 GCC 的 P1689),而这张表是 gcc 的,
放进来就违反「同一编译器二进制」这条不变式 —— 它属于另一张 clang 基准的表。
**冷构建性能分析(.agents/docs,附录 A)。** `bench --analyze`:
关键路径 79.73s = makespan 的 **100%**,32 线程上平均并行度仅 3.94 ——
加核与分布式全部无效。关键链 26 跳,`mcpp.build.prepare` 单文件 16.1s 占 20%。
其中我先用 `-fmodule-only` 判定「codegen 只占 1%,提前释放没空间」,**这是错的**:
GCC 的 `-fmodule-only` 不跳过后端,只是不写目标文件。正确判据是三步 ——
BMI 何时**写完**(轮询到大小稳定)、是否与成品**逐字节相同**、以及**下游能否用它编译**。
三步全过:`prepare` 的 BMI 在 2.50s / 16.20s = **15%** 处即完成且可用。
关键链最重的 8 个模块采样,中位约 **22%** —— 下游在等的 78% 是它不需要的代码生成。
据此头寸为 **80s → 25–35s(2.3–3.2×)**,实施形状与三个已知坑一并记录。
macOS runner 没有 PyYAML,`ModuleNotFoundError: No module named 'yaml'` 让这条新测试 在那台机器上必红。硬依赖一个开发库的测试,最后会被删掉而不是被修好。 改成两档:有 PyYAML 就整份解析(并要求声明了 jobs),没有就退化成针对性 lint —— 正则命中的正是这条测试存在的那个缺陷形态:引号标量闭合后还有内容 (`run: "$BENCH" --list`)。**并且明说跑的是哪一档**:一个悄悄比它所替代的检查更弱的 回落,就是绿色开始失去意义的方式。 两档都验过:干净树上都通过;植入缺陷后**两档都失败**。
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
起因
一次实测:mcpp 的自举构建不是吞吐瓶颈,是延迟瓶颈。
.o—— 下游真正需要的 BMI 在编译进度 22.8% 处就已经原子rename就位(strace证实:之后 982 个系统调用无一再碰它)⇒ 这不是某一家构建系统的实现问题,而是「C++23 命名模块 + GCC 单阶段 + 边完成即释放」这一组合的结构性结果。
完整分析:
2026-08-12-modular-build-performance-deep-analysis.md· 架构与计划:2026-08-12-bench-suite-architecture-and-plan.md为什么重写而不是扩展
上一轮的 bash + hyperfine 脚本有四个结构性缺陷:
bench/的设计协议先行 —— 三条不变量写进类型,不是留给约定
每一条都被旧脚本违反过:
status与 timing 是分开的字段,非 ok 的格没有median_s键(而不是 0)。旧脚本把失败格式化成0.000 s,三个这样的格子进了结果文件,看起来像是有史以来最快的构建。unavailable≠failed,两者都必须带 note。加一个引擎 = 加一个文件
bench.engines.Engine+registry.cppm一行,runner / 协议 / 场景 / CI 全不动。已接入 mcpp、mcpp-opt、cmake、xmake、meson、bazel ——
mcppvsmcpp-opt让「优化前后」成为矩阵里的一个正交维度,而不是另做一次实验。同一工程三种形态,生成而非手写
headers/modules/modules-impl。手写两份「等价」代码几乎必然在某处不等价,而那正是被测量的东西。第三种变体直接对应实测结论:GCC 与 Clang 的模块接口单元 BMI 都携带函数体,所以改任何一行函数体都会级联到全部导入者,且没有编译器开关能解决(
-fmodules-reduced-bmi实测无效)。平台差异只在叶子
按 xlings
src/platform/*.cppm的既定约定:模块分区 + 整文件宏控,非目标平台不导出任何符号。于是任一构建中每个名字只有一份定义、编译期自动选中 —— 不需要 stub,也不需要if constexpr派发。#if defined(_WIN32)只出现在那两个分区里;runner / engines / protocol / fixture 零平台条件。--analyze同一个二进制还能剖析任意 ninja 构建目录(工作量 / makespan / 关键路径 / 并发曲线),并固化了五个会反转结论的解析陷阱。最狠的一个:
过程中被实测推翻的三件事
-fmodule-only文档说「只产 CMI」,实测照样跑完整个 codegen 再把结果丢弃(15.93s vs 完整 15.95s)。GCC 16.1 没有廉价产出 BMI 的开关;Clang 有(--precompile,1.80s vs 单阶段 7.18s)。import std(31.5 MB BMI)只多 4.8 ms —— GCC 的模块导入本来就是惰性的。真正的驱动因素是代码量(corr(LOC, t_total) = 0.825)。-O0相对-O2只快 1.75×,而产物运行时性能全丢。CI
.github/workflows/bench.yml—— 仅手动触发、覆盖 linux/macOS/windows、不设性能阈值。基准是重活且噪声大,挂进每个 PR 只会淹没它要产出的信号;而在共享 runner 上设阈值,等于把正常方差变成人人学会忽略的红叉。
验证
mcpp buildmcpp test230_bench_harness.shcheck_version_pins.sh2026.8.11.2,无需变更附带
xmake.lua(用 xmake 构建 mcpp 本身的对照臂)。它从mcpp.toml读[toolchain] default来钉编译器 —— registry 里有多个 GCC,而「取目录序最后一个」只是碰巧对。