feat(bench): 构建引擎基准套件 —— 把一次性脚本变成跨平台、可扩展的测量设施 (2026.8.12.1) - #423
Open
Sunrisepeak wants to merge 165 commits into
Open
feat(bench): 构建引擎基准套件 —— 把一次性脚本变成跨平台、可扩展的测量设施 (2026.8.12.1)#423Sunrisepeak wants to merge 165 commits into
Sunrisepeak wants to merge 165 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`)。**并且明说跑的是哪一档**:一个悄悄比它所替代的检查更弱的 回落,就是绿色开始失去意义的方式。 两档都验过:干净树上都通过;植入缺陷后**两档都失败**。
…tach runtime
—— L2 的地基:决策与运行期,尚未接入图的生成
`src/build/schedule/` 两个模块:
* `policy.cppm` —— **纯函数**,不碰文件系统/进程/环境。一次输入事实,输出
`Decision{strategy, reason, compilerCap, ninjaJobs}`。`reason` **永远非空**,
包括选了 None 的时候:一个默默不优化的调度器是没人能调试的。
平台/编译器表连同实测数字都写在这里,只在这里推导一次 ——
之前 BMI 等价性判断藏在生成的 ninja 命令里当 POSIX shell 片段,
于是 **Windows 上整段没有**;并发数则是 ninja 的默认值。同一个决策两处推导,
就是这两半漂开的原因。
* `schedule/detach_codegen.cppm` —— gcc 策略的运行期。三段:
阶段一(编译器启动 → BMI 原子落盘 → 退出 0)、监督进程、阶段二(收口 + 回放)。
**为什么按编译器分而不是按平台分**:clang 有原生两阶段(`--precompile` 0.78s /
`-c` 自 pcm 0.70s,总 CPU 只多 9.6%,解锁点 57%),而且它**不能**用 gcc 那套 ——
strace 证实它以 `O_TRUNC` 直接写最终路径,没有 rename,看文件出现会读到半个 `.pcm`。
反过来 gcc 没有便宜的两阶段(`-fmodule-only` 要 99% 的时间)。两者互为补集。
所以监督进程用**派生**而不是 `fork()` —— 派生在 Windows 上同样成立,
编译器专属的是**前提**(原子发布),不是平台。
实测(mcpp 最重的模块 `build/prepare.cppm`):
阶段一 **2.30s / 16.15s = 14%** 返回,目标文件正确,两阶段 rc=0。
三个踩出来的坑,都留了判据:
* 子命令解析器**不支持 `--`**,命令会静默变成空列表 ⇒ 改用**参数文件**
(每行一个参数),顺带绕开 `MAX_ARG_STRLEN` 的 128KiB 单项上限。
* `parsed.value(name)` 看着合理但在这里返回空,`option_or_empty(name).value()`
才是本仓库已验证的取法 —— 两者不等价,且差异是静默的。
* **阶段二不能无限等**:阶段一没起来时会变成"没有任何输出的永久挂起",
比失败严格更糟。改为有界:没有 log 文件就说明没有编译器可等,10s 后直接失败。
…t why
—— 决策、并发与可观测性收敛到一处;图的形态尚未改变
`schedule::decide()` 在 prepare 里求值一次,结果落在 `BuildPlan` 上:
后端据此写图、图头记下 tag、`--verbose` 打印理由 —— 三个读者,一次推导。
# mcpp:graph=normal;schedule=detach-codegen
build: schedule: detach-codegen — gcc: publishes the BMI with rename()
at ~22% of the compile, so importers can start before code generation
**并发解析也搬进 policy**。「跑几个」和「什么形状」是同一类决策,分开放就会再次
出现两处推导 —— 这正是 BMI 等价判断(藏在 ninja 命令里的 POSIX shell,Windows 整段没有)
和作业数(ninja 默认值)当初漂开的原因。`execute` 现在读 `plan.scheduleNinjaJobs`,
不再自己解析;`resolve_jobs` 用回调报告非法值,因而不依赖 UI、可单测。
**失效靠指纹,不靠守卫**。两条快路径跑在 plan 之前,拿不到工具链,
所以不可能在那里推导出「本次应有的调度」——给它传参数就等于第二次推导。
改为把开关折进指纹:换了调度就换构建目录,旧形状的图**结构上不可达**。
图头那行 tag 因此是给人看的(和 `mcpp explain`),不承担失效职责。
只在开关非默认时才折入,已有构建目录的身份不受影响。
顺带:`[build] schedule`(auto|on|off)+ `MCPP_BMI_SCHEDULE`,与 `jobs` 同样存为文本 ——
"auto" 的含义取决于做构建的那台机器,parse 期解析等于把一台机器的答案冻进 manifest。
…split fails policy 的 7 条单测,每条都从两侧钉: * 两种机制**互补而非可互换** —— clang 必须是 two-phase(它以 O_TRUNC 直写最终路径, detach 会把写了一半的 BMI 交给导入者),gcc 必须是 detach(它没有便宜的两阶段, two-phase 等于把所有东西编两遍)。装反是**静默**的。 * 未实测的编译器必须留在 None —— 这里猜错不是构建变慢,是误编译。 * `off` 关得掉,而**同样输入下 `auto` 关不掉** —— 只钉前一条的话, 「永远不启用」的实现也能通过。 * HAZARD 2 编进断言:detach 下给 ninja 的槽必须**多于**编译器上限, two-phase 下必须**相等**。 * 每一条决策都必须带 reason,包括选 None 的那些。 * hostJobs=0 不能变成 -j0 或负数。 `header_line` 现在必须带 schedule tag —— 编译期就抓到了漏改的调用点, 这正是要它必传的原因。测试同时钉住「旧图没有该字段时读出的是空,不是 "none"」: 「这份文件早于该字段」和「这份文件选择了不做」必须能区分。 ---⚠️ **实测记录:朴素的两边拆分会静默丢掉头文件跟踪。** 拆分后 depfile 挂哪条边,两种挂法都错: depfile 写出于 16.39s,BMI 发布于 2.36s,整条编译 16.55s —— depfile 在 BMI 之后 * 挂 **BMI 边**:该边在 2.36s 就完成,那时 depfile 还不存在,ninja 读到陈旧/缺失依赖。 * 挂 **对象边**:头文件变更会让对象边重跑,但 `bmi-await` 只会看到已存在的 `.rc` 立刻返回 —— **什么都不重编**。 这一类缺陷不会报错,只会让改了头文件的构建悄悄不生效。所以图的形态**未改动**: 默认路径与本 PR 之前完全一致,已落地的是决策、运行期与可观测性。 候选解法(BMI 边改用 P1689 扫描已经产出的 `.ddi.dep` 作依赖来源)需要先证明 扫描的依赖集与编译的一致,尚未验证。
`mcpp build` 用改动后的二进制**段错误(rc=139)**,改动前的二进制在同一棵树上 rc=0。 一个会崩的 mcpp 比没有这个特性糟得多,所以整批退回。 退回的是三个提交(065d995 / 9f2d940 / 6dbfdc2):`schedule/` 的 policy 与 detach 运行期、决策入 plan、图头记 schedule tag、并发解析搬家、以及相关单测。 **已经验证成立、值得保留的事实**(记在 `.agents/docs/2026-08-13-build-performance-architecture.md`,该文档不在本次退回中): * GCC 以 `rename()` 原子发布 BMI(strace),clang 以 `O_TRUNC` 直写最终路径 —— 两者只能用互补的机制,装反是静默的。 * 运行期本身是对的:阶段一在 **2.30s / 16.15s = 14%** 返回,目标文件正确,两阶段 rc=0。 * **depfile 在 BMI 之后写出**(16.39s vs 2.36s),所以拆分后的 BMI 边不能用编译器 自己的 depfile;而 P1689 扫描已经写出一份等价的(实测只缺 `.gcm`,那是 dyndep 管的, 头文件全覆盖),且 `cxx_scan` **没有**声明 `depfile`,ninja 不会消费掉它 —— 这条路是通的,只是还没接。 **没有定位到的**:段错误的确切来源。嫌疑集中在 prepare 里新增的那段决策求值 (`*m` 的生命周期、`log::verbose` 的用法、或 `resolve_jobs` 里的 capacity 探测), 但我没有在退回前把它钉死 —— 下一步应当先复现并定位,再重新落地, 而不是在一个会崩的基线上继续加东西。
…ed, what is pinned
…raph split, not here **修正一个错误归因。** 上一个提交把 `schedule/` 整批退回,理由是「改动后的 mcpp 段错误」。 重新施加后逐条复现:**基础层本身 rc=0**(mcpp 冷构建自身 82.27s,e2e 230/231/232 全过, policy 单测 8/8)。那两次崩溃用的二进制**都包含当时未提交的图拆分发射**—— 崩的是那部分,不是这里。回退整批是过度反应。 恢复的内容: * `schedule/policy.cppm` —— 纯函数决策表(clang→two-phase,gcc→detach-codegen, msvc→none),每条决策都带 reason;`requested_switch` / `resolve_jobs` 是唯一读开关 与并发的地方。 * `schedule/detach_codegen.cppm` —— gcc 运行期(阶段一/监督/阶段二), 实测阶段一在 **14%** 处返回、产物正确。 * 决策在 prepare 求值一次并落到 `BuildPlan`;图头记 `schedule=<tag>`; `--verbose` 打印理由;失效靠指纹而非守卫。 **`auto` 现在是 off**,`on` 才选择拆分形状。理由写进了 policy:调度改错是**静默**的 ——漏掉一条头文件依赖不会报错,只会不再重编——所以它不该凭一台机器的结果成为默认。 单测两侧钉住这一点(`auto` 必须 None,`on` 必须非 None), 否则「永远不启用」的实现也能通过。 图的拆分发射**不在本提交内**;它需要先按实测把 depfile 接到 P1689 扫描的产出上 (编译器自己的 depfile 写在 BMI 之后,挂哪边都会静默丢掉头文件跟踪), 并定位上一轮那次段错误。
…asured 2.51x) 在 mcpp 自身上实测:`gcc@16.1.0` **81.83s**,`--toolchain llvm@22.1.8` **32.61s** —— **2.51×,且已在 50s 目标以内**。 **为什么是「按次选择」而不是「换默认」。** 换默认工具链会让**所有已发布包的指纹失效** (全生态一次性重编),三平台的 llvm 载荷版本目前还不统一(Windows 20.1.7 vs Linux/macOS 22.1.8),还牵涉 `-static-libstdc++` 与 libc++/libstdc++ 的 ABI 选择。 那是生态决策,需要协调;**按次选择不需要任何人配合,而收益是同一个 2.51×**。⚠️ 但它**不改变形状**:clang 下 makespan 32.20s / 关键路径 32.15s = 仍然 100%, 平均并行度 3.90×——和 gcc 一模一样。clang 只是每个模块便宜 2.5 倍。 工程再长大一倍,它同样顶到墙。这就是 L2 仍然必要的原因。 实现走 `MCPP_TOOLCHAIN` 侧信道(与 `--offline` / `--jobs` 同一条,理由相同: 消费者在 prepare 的解析深处,穿参数要改沿途每一个调用者), 并计为 user-explicit —— mcpp 不会再悄悄改写它。 e2e 231 补两条,**两侧都钉**:`--toolchain gcc@16.1.0` 必须真的走到工具链解析 (`Resolved gcc@16.1.0`),以及 `--toolchain llvm@22.1.8` 必须**压过 manifest 里的 pin** —— 只钉前一条的话,一个什么都不做的实现也能通过。断言看的是**解析结果**而不是耗时: 在 CI 上断言时间等于在测 runner 的心情。
27 个文件平铺在 `results/` 下,来自三次互不相关的运行,而且按**工具名**排序而不是
按运行 —— 要弄清哪份 JSON 属于哪张表,得去解时间戳。
改成一次运行一个目录:
results/README.md 索引:每个目录测的是什么
results/five-way-20260812/ 合成 fixture,六引擎 × 三形式 × 六场景
report.md linux-x86_64-gcc.json linux-x86_64-clang.json
results/mcpp-self-20260813/ 真实工程:mcpp 构建自身
report.md linux-x86_64-gcc.json
results/hyperfine-20260812/ harness 之前那批 hyperfine 一次性运行
NOTES.md matrix-*.tsv {mcpp,xmake}-*.json
原始文件名里不再重复目录已经说过的东西,只保留**运行内部会变的轴**(主机、编译器)。
索引里写清两件读者会踩的事:**读 report 不要读 JSON**(原始数字脱离运行声明的
不对称性就没有意义),以及**跨运行比较前先套 §4a 的有效性规则** ——
落在本引擎 `noop` 2× 以内的单元测的是进程启动,而绝对秒数不跨主机,只有表内比值跨。
`--toolchain gcc@16.1.0` 写死在断言里,而 fixture 在 macOS 与 Windows 上钉的是 llvm: 那台机器上根本没有 gcc 载荷,于是这条断言测的是包索引,不是 flag。macOS runner 因此必红。 改成先问这台机器**自己**解析出什么(`Resolved <spec>`),再用那个 spec 走 `--toolchain`。 「压过 manifest 的 pin」那条仍然只在 Linux 上跑 —— 只有那里两个工具链都在。
…ation (2.30x)
**gcc 默认工具链下,mcpp 构建自身 79.9s → 34.80s(2.30×),已在 50s 以内。**
noop 0.21s;增量修改正确传播(改 version 到 .9 再改回 .1,产物两次都对)。
每个模块接口拆成两条边,由**同一个编译器进程**驱动:
build <bmi> : cxx_module_bmi <src> | <dd> dyndep 绑在这条边
build <obj> : cxx_module_obj <bmi> join:等那个进程写完目标文件
依据(§`schedule/policy.cppm`):一次接口编译 **86% 是 codegen**,导入者一个字节用不到;
GCC 以 `rename()` **原子发布** BMI(strace),所以「最终路径出现」是精确信号。
⚠️ **两个静默失效点,都是实测撞出来的,不是推理:**
1. **depfile 的目标必须重写成 BMI。** 扫描器写的是 `publisher.o:`,而这条边的输出是
`.gcm`。目标不匹配时 ninja **不报错**,只是把边永远当脏的 —— 症状是
**noop 构建重编全部 140 个接口、耗时 25.39s,然后报告成功**。
现在 `copy_first_rule` 连目标一起改写。
2. **depfile 不能用编译器自己的那份。** 实测它写在 **16.39s**,而 BMI 在 **2.36s** ——
这条边结束时它还不存在。改用 P1689 扫描已经产出的 `.ddi.dep`:
实测它相对编译的那份**只缺 `.gcm`**(dyndep 在管),**头文件全覆盖**;
且 `cxx_scan` **没有**声明 `depfile`,ninja 不会把它消费掉。
拷贝而非引用,因为 ninja 读完 depfile 会删掉它。
还修了一处:边一开始插进了**静态依赖模式**那个分支(`splitBmi` 在那里恒为假),
于是规则发射了、边却是 0 条 —— 判据是 `grep -c ': cxx_module_bmi '`,不是"看起来对"。
`auto` 仍是 **off**:这套东西只在这一台 Linux 机器上验过,三平台 CI 见过之后才该成为默认。
`started()` was `exit_code >= 0`. On Windows that is wrong in exactly the case
that matters: a child which CRASHES exits with a status like 0xC0000135 (a DLL
it needs is missing) or 0xC0000005, and GetExitCodeProcess hands back a DWORD
that becomes a NEGATIVE int. The harness then reported
could not start the process (no log written) — check the engine's program path
for an xmake that `xlings install xmake@3.1.0` had just installed with
"1 package(s) installed", and that the harness's own probe had just run
`xmake --version` with. Every part of that message was wrong, and I spent two
matrix cycles on the PATH it pointed at — including writing a whole GetLastError
reporting path for a launch that had in fact succeeded. (That reporting is still
worth having; it is what proved the message was reached with no OS error to
show, which is what pointed here.)
Launch failure is now a fact the platform layer reports, not something inferred
from the sign of a number whose meaning differs per OS. Every `return -1` site
sets it.
Verified both directions on POSIX: a genuine launch failure (valid program,
missing cwd) still reports `posix_spawnp('xmake'): No such file or directory`,
and a child that starts and then takes SIGSEGV now reports `exited 139` instead
of claiming it never ran. The Windows case cannot be exercised here — but the
fix is the removal of the platform-dependent inference, not a new guess about
what Windows returns.
Third failure the new error sieve named on its first run. Every bazel cell of
the windows/clang fixture had been dying with `seed build exited 1` and a tail
of progress; it now says
Error in fail: PIC compilation is requested but the toolchain does not
support it (feature named 'supports_pic' is not enabled)
ERROR: Analysis of target '//:fx' failed; build aborted
`--force_pic` exists to collapse bazel's pic and non-pic object flavours into
one, because cc_binary registers the ddi aggregation action for both and names
its output without a pic suffix. That duplication is a POSIX phenomenon: Windows
code is relocatable by construction, its toolchain does not enable
`supports_pic`, and asking for PIC there is not ignored — it is fatal at
analysis, before a single file is compiled.
So the flag is now POSIX-only. Not a workaround: on Windows the problem it
solves does not arise.
The std.cc question is answered, and it closes the last externally-checkable
difference: the runner reports both manifest sources PRESENT.
bench: std -> .../include/c++/16.1.0/bits/std.cc (present)
bench: std.compat -> .../bits/std.compat.cc (present)
So for the linux/gcc cmake arm everything I can inspect from outside now agrees
with a machine where the same cmake 4.0.2 and the same gcc 16.1.0 configure,
generate and build: the manifest is there, its sources are there, and the
runner's exact flag shape (`-B<glibc>/lib -L<glibc>/lib`) reproduces here and
works. What remains is inside CMake's own probe.
CMake writes that down — CMakeConfigureLog.yaml — and nobody reads it, because
it lives in a build directory CI deletes with the job. The cmake adapter now
appends its tail to the cell's log ON FAILURE ONLY, so a green run costs nothing
and a red one carries the evidence.
This is the same move that has now named three failures on their first run after
being added (xmake's missing std dependency, bazel's supports_pic, and this
arm's crt1.o becoming a generate error): when a cell cannot be diagnosed from
CI, fix the diagnosis before guessing again. Every guess I made about this one
before the diagnostics existed was wrong.
bench went 7/10 -> 8/10 last run (the xmake+libc++ waiver took effect). These
are the remaining two, and both are foreign tools, not mcpp.
linux/gcc/mcpp — cmake CONFIGURES and then fails at GENERATE with
`"__CMAKE::CXX23" ... not provided by the toolchain. Reason: Only `libstdc++`
is supported`. Every externally checkable fact was checked, one per cycle, and
every one AGREES with a developer box where this arm configures, generates and
builds:
same cmake the very same xim-x-cmake/4.0.2 xlings payload, not
merely the same version number
same gcc xim-x-gcc/16.1.0
manifest libstdc++.modules.json present on the runner
its sources std.cc and std.compat.cc present on the runner
flags the runner's exact `-B<glibc>/lib -L<glibc>/lib` shape
reproduced here with a fake MCPP_HOME and it works
What is left is inside CMake's own detection. Baseline moved to the released
mcpp for this cell, exactly as the xlings cells already do — normalising against
an engine that produced no binary prints bare seconds under a heading that says
"relative to". xmake and both mcpp arms stay measured.
windows/clang/fixture — two upstream gaps. xmake exits -1 (0xFFFFFFFF) from the
seed build; that USED to read as `could not start the process`, which was this
harness's own defect and is fixed, so it is now honestly an exit code and it is
xmake's. bazel fails in `msvc_deps_scanner_wrapper_x64.bat ... The syntax of the
command is incorrect` on modules-impl — rules_cc's MSVC dependency scanner. The
OTHER bazel failure on this cell was ours and is fixed (`--force_pic`), which
recovered the headers and modules variants: 36 ok -> 42 ok.
Also: the CMakeConfigureLog extraction now GREPS for the std-module entries
before tailing. The first version tailed 120 lines and got the ABI probe,
because that is what CMake writes last — the entries it was added for sit
earlier and were cut off. Same lesson as the build logs, made twice.
Reported from a GREEN job, and the report was right. linux/clang/xlings exits 0
with
cells : 10 ok, 0 failed (10 waived by --allow-failed)
while its own log reads `install ftxui v6.1.9 .. failed`,
`install mcpplibs-tinyhttps 0.2.9 .. failed`, `error: <cmdline> missing std
dependency`. A cell that calls itself a three-engine comparison, measured mcpp
against mcpp, and reported success. That is precisely the shape this suite
exists to remove, so it must not be the suite's own — and `--allow-failed`,
meant for ONE arm with a documented gap, had quietly grown to cover every
foreign engine at once with nothing to say so.
Two changes, neither of which turns a documented gap back into a red run:
* The harness names the engines a waiver removed ENTIRELY — those with zero
successful cells — instead of only counting them. "10 waived" is a number;
"cmake and xmake produced nothing here, this run is green by policy" is the
fact a reader needs, and it goes to stderr where a failure would.
* The workflow turns each of those into a GitHub warning annotation, so it
appears on the job's own page next to the green tick rather than 2000 lines
down in a log nobody opens on a passing run.
Verified both shapes: an engine that fails every cell and is waived is named,
and it is still named when ANOTHER engine succeeded and the run exits 0 — which
is the case that matters, since a run where nothing succeeded was already red.
Pulling on the green job surfaced the next layer. The linux/clang xlings cell's
note said both foreign arms "compile every translation unit and then stop at the
link with `undefined reference to mbedtls_*`". Neither does, any more:
cmake FATAL_ERROR from the suite's OWN xpkg_source_library.cmake:231 — a
source package's manifest carries no `sources` list. Our code, fixable,
simply not fixed.
xmake xrepo cannot BUILD the dependency packages: `install ftxui v6.1.9 ..
failed`, `install mcpplibs-tinyhttps 0.2.9 .. failed`, each ending in
`std and std.compat modules not found!`. The same libc++ std-module gap
as the linux/clang mcpp cell — reproduced locally there, with `--sdk`
and both the custom and built-in llvm toolchains tried. Upstream.
One sentence had been covering two arms with different causes, and one of them
is ours. That is the third time this exact drift has appeared, which is why the
guard now forces a note to name each engine — but a name is not a reason, and
nothing can check a reason for truth. Only reading the log can, so the note now
records what the log says and where each cause lives.
Also fixes what made that hard to see: `log_grep` returned only MATCHING lines,
and cmake writes
CMake Error at xpkg_source_library.cmake:231 (message):
bench: <pkg>'s manifest has no `sources` list
— location on the hit, cause on the line after. It kept the location and threw
away the cause, three times today, each costing a round trip to CI for a
sentence already in the file. It now carries two lines of trailing context.
Verified with a fake engine that prints exactly that two-line shape and then
fifty progress lines.
The workflow measured far less than it appeared to. Its own numbers:
10 cells, 32 foreign-engine arms, 12 of them (37%) waived by allow_failed
xmake had MORE arms waived (6) than measured (4)
A job that goes green while a third of the comparison never ran is the exact
failure this suite exists to remove, so it may not be how the suite runs. Two
structural reasons on top of that: a shared runner measures the RUNNER (the same
tree is 243s there and 79s here), and most of what was being waived is other
people's tools, not mcpp.
bench/run-standard.sh replaces it. One command, no arguments, and the standard
set is DERIVED from bench/matrix.json rather than repeated — the cells for this
OS, every engine they list. `allow_failed` is deliberately ignored: those
waivers record what broke on a shared runner, and at least two of them (cmake on
the mcpp tree, cmake on the xlings tree) configure and generate perfectly well
here. Filtering by them would have carried a runner's limitation into local data
and published a smaller comparison than this machine can make.
Also in this commit, both found by running it:
* THE FIELD SPLIT WAS WRONG. `IFS=$'\t' read` treats tab as IFS *whitespace*, so
consecutive tabs COLLAPSE. A cell with no `leaf` shifted every later field one
place left and cmake was handed the baseline as its source directory —
`CMake Error: The source directory ".../bench/projects/2026.8.11.3" does not
exist`, five cells, and the message names neither the wrong field nor the
empty one before it. Now \x1f, which is not whitespace. `BENCH_PRINT_ARGV=1`
prints the argv so this is checked against what is actually passed rather than
against the plan.
* SCRATCH DOES NOT LIVE IN results/. `--work "$OUT/work"` put engine scratch in
the directory the README guard scans, and one of those files is a JSON ARRAY:
the guard died with `AttributeError: 'list' object has no attribute 'get'`.
Work goes to TMPDIR, and the guard now skips non-objects instead of crashing —
a guard that crashes is a guard that stops guarding.
e2e 233's workflow-specific checks move to the script with their judgements
intact: one list read rather than repeated, three runs for the standard set, no
cell names hard-coded. Verified each fails in the bad direction.
The xmake adapter change is explained in its own comment: clang needs xmake's
BUILT-IN llvm toolchain plus `--runtimes`, because xmake picks the std module by
C++ library and reads the library from the target's runtime. Without one, a
clang build on Linux is treated as libstdc++ and the `--sdk` its warning
suggests is never read.
The other half of the previous commit. That one's message describes this change set, but its `git add` listed the already-deleted bench.yml first and git aborts the whole command on a pathspec that matches nothing — so only the staged deletion went in. Recorded rather than force-pushed over. bench/run-standard.sh: one command, no arguments. The standard set is DERIVED from bench/matrix.json rather than repeated — the cells for this OS, every engine they list. `allow_failed` is deliberately ignored: those waivers record what broke on a shared runner, and at least two of them (cmake on the mcpp tree, cmake on the xlings tree) configure and generate perfectly well here. Filtering by them would carry a runner's limitation into local data. Three bugs it found, all mine: * THE FIELD SPLIT. `IFS=$'\t' read` treats tab as IFS *whitespace*, so consecutive tabs COLLAPSE. A cell with no `leaf` shifted every later field one place left and cmake was handed the baseline as its source directory — `CMake Error: The source directory ".../bench/projects/2026.8.11.3" does not exist`, five cells, and the message names neither the wrong field nor the empty one before it. Now \x1f. `BENCH_PRINT_ARGV=1` prints the argv, so this is checked against what is passed rather than against the plan — the fix was verified by reading `--buildfiles .../projects/mcpp` off that output. * SCRATCH IN results/. `--work "$OUT/work"` put engine scratch where the README guard scans for medians, and one of those files is a JSON ARRAY: the guard died with `AttributeError: 'list' object has no attribute 'get'`. Work goes to TMPDIR; the guard skips non-objects instead of crashing. * xmake's clang arm never reached the branch its own error message advises. It picks the std module by C++ LIBRARY and reads the library from the target's RUNTIME; with none declared, clang on Linux is treated as libstdc++, so it looks for GCC's modules.json inside the LLVM payload and suggests `--sdk`, which only the libc++ branch reads. The payload has carried `lib/<triple>/libc++.modules.json` all along. Built-in `llvm` toolchain plus `--runtimes=c++_static` plus `--sdk` gets there — measured: the warning goes away and xmake starts emitting module BMIs. e2e 233's workflow-specific checks moved to the script with their judgements intact — one list read rather than repeated, three runs, no cell names hard-coded — and each was verified to fail in the bad direction.
Found reviewing the script I had just written to replace a CI whose defining problem was a green job that had measured nothing. It had the same defect. The cell loop was `printf '%s\n' "$PLAN" | while read ...`. A pipeline runs its right-hand side in a SUBSHELL, so nothing the loop counts survives it: the exit code was whatever the last command happened to return. Verified with a stub engine that exits 7 for every cell — the script printed seven failures and then exited 0. It then printed "how to tell whether this data is publishable: 1. every cell exited 0", handing the reader a question it already had the answer to. The loop now reads from process substitution, which keeps it in this shell, and a non-zero count prints NOT PUBLISHABLE and exits 1. The remaining checks it genuinely cannot make — dispersion, whether a failure is a real gap — stay as instructions, now honestly labelled as the ones it cannot make for you. Verified both directions: seven failing cells exit 1 with the banner, and --dry-run still exits 0. Swept the rest of the repository for the same shape. One other `| while` exists (.github/tools/check_version_pins.sh) and only prints from inside the loop, so the subshell costs it nothing.
…ranch Every cell of the first full local run reported `mcpp@2026.8.11.3`. That is the RELEASED binary, in all seven cells, with no old-vs-new column at all — and nothing failed. The report simply described a different program. A bare `mcpp` in the engine list resolves through PATH to the xlings shim, and that shim RE-PICKS its version from the working directory. For a `--project` run that directory is the measured tree, and every pinned workload carries its own pin. The tell was in the report the whole time (a version label on rows that were supposed to be the build under test) and in the numbers: `touch-hub 76.55s` on mcpp's own tree is the old binary's byte-comparison behaviour, not this branch's BMI equivalence. `mcpp` in a cell is now expanded into explicit binaries — the build under test, plus the released reference when one can be resolved AND asserts its own version, which is what restores the old-vs-new column. e2e 233 fails if the bare name comes back. The data from that run is void for every mcpp arm and is not committed. What it does establish, and what motivated deleting the CI matrix, stands: on Linux locally cmake and xmake run every cell that CI had waived — 15/15 on the mcpp tree with gcc, 15/15 on both xlings trees — so those waivers described a shared runner, not this software.
…ad end
README restructure, both languages. The file had grown into an experiment log —
900 lines, most of it "here is what this used to get wrong" — which is valuable
and is not what someone reaching for the suite needs first. Three layers now:
how to run it copy-pasteable commands, and how to tell whether the data
it produced is publishable
the standard data
method and known gaps (everything that was there)
Section numbers of the methodology are untouched so the cross-references
(§4a, §5, §8b) still resolve; the two new sections carry no number. References
to the deleted workflow are gone from both files.
New: .agents/docs/2026-08-15-xmake-clang-import-std.md, which is the finding
that came out of being told this ought to be solvable.
warning: std and std.compat modules not found!
maybe try to add --sdk=<PATH/TO/LLVM> or install libc++
That suggestion is a DEAD END, and following it cost three rounds. Read from
xmake's own source: it picks the std module by C++ LIBRARY and reads the library
from the target's RUNTIME. With none declared, clang on Linux falls back to
libstdc++, so it looks for GCC's modules.json inside the LLVM payload and lands
on the function's final catch-all warning — which is not part of any branch, yet
advises an option only the libc++ branch reads. The payload has carried
`lib/<triple>/libc++.modules.json` the whole time.
What works, measured: built-in `llvm` toolchain + `--sdk` + `--runtimes`. Then
the arm gets past it and hits `formatter<basic_string<char>, wchar_t>` inside
libc++ — which is the SAME defect mcpp fixed for itself: clang's `--precompile`
emits a FULL BMI, and publishing those makes clang 22 miscompile a downstream
TU. That one is genuinely upstream, but now it is diagnosed rather than "xmake
does not work".
The rule this leaves: do not act on an error message's suggestion before
checking which branch it belongs to. A catch-all warning is by definition the
one printed when no branch succeeded.
The pin was 4.0.2 because `CMAKE_EXPERIMENTAL_CXX_IMPORT_STD` is gated by a UUID
that CHANGES WITH THE CMAKE VERSION, and 4.4.2 rejects 4.0's:
CMAKE_EXPERIMENTAL_CXX_IMPORT_STD is set to incorrect value
So the version and the key move in one commit, or the descriptions stop
configuring. 4.4 is f35a9ac6-8463-4d38-8eec-5d6008153e7d; 4.0 was
a9e1cf81-9932-4810-974b-6eccaf14e457.
The key was not guessed. It is compiled into the cmake binary rather than
written in the Modules, so the candidates came from `strings` and each was
offered to cmake until one was accepted.
⚠️ THE FIRST ROUND OF THAT TEST WAS WORTHLESS, and it looked conclusive: all
seven candidates were rejected, including the one that works. I had offered them
to the real project with `-D`, and its CMakeLists.txt `set()`s the key itself —
a plain `set()` overrides the cache entry, so every run tested the OLD key.
Re-run against a throwaway project with nothing but the gate, one is accepted.
Verified after the change: both descriptions configure and generate under 4.4.2,
zero `incorrect value`, build.ninja produced.
⚠️ Installing 4.4.2 SWITCHED THE ACTIVE cmake mid-benchmark. A standard-set run
was in flight; its first cell used 4.0.2 and everything after would have used
4.4.2. Killed and discarded rather than published — a tool that changes during a
run is exactly the variable the pins exist to remove, and nothing in the report
would have shown it.
…y the rest is not
bench/results/ holds 30 JSON files from seven runs and nothing said which of
them a reader should believe. They are not equivalent, and each one is stale for
a different reason:
cmake 4.0.2 the pin is now 4.4.2, and its `import std` gate key
differs — those descriptions would not configure today
n=1 the standard set is n=3; every one of those tables
carries a "do not compare the digits" caveat, which is
an admission that one sample was not enough
CI runners a shared 2-core machine: 243s there against 79s here
bmi_schedule=on taken before the §8b fix, so `touch-hub` and
before §8b `edit-comment` were timing a build that had not finished
`standard-<date>-<os>-<arch>/` is the standard data: the only directory
`run-standard.sh` produces, the only one at 3 samples, the only one the README
tables quote. The rest is kept rather than deleted — a claim that cannot be
checked against the run that produced it is not a measurement, and deleted
history cannot be checked at all — but it is now labelled as history.
Eight places still said the matrix was planned by `.github/workflows/bench.yml`
and that a cell was a CI job. §5 was retitled and rewritten around what actually
produces the data now.
The substantive part is what §5 has to say about `allow_failed`: the runner does
NOT consult it. Those waivers record failures on a shared runner, and at least
two of them describe arms that configure and generate perfectly well on a
developer machine — filtering by them would carry that runner's limitation into
local data and publish a smaller comparison than the machine can make. They stay
in matrix.json as the record of what broke where; a failure in a local run is a
failure in a local run.
Also noted while reviewing and NOT yet fixed, because fixing it means rebuilding
the harness while a standard run is using it: the report records the machine and
the compiler but not the COMMIT of the mcpp under test. The engine label is a
version, and every commit on this branch reports the same one, so two runs from
different commits are indistinguishable in the data. That is the defect this
branch fixed for the workload ("it must not move") left standing for the engine
— which is supposed to move, and therefore needs its identity recorded.
…ation
顺带把「跑一半被打断就全丢」修掉:mbench 现在按测量单元落盘、可续跑。
## 为什么要拆(不是风格问题)
给 protocol.cppm 加一个带 std::map / std::filesystem::path 的导出类之后,
整个套件编不过,报错却指向一个完全无关的模块:
bench.registry: error: failed to read compiled module cluster 89: Bad file data
fatal error: failed to load binding 'bench::make_engine@bench.registry'
GCC 16.1 下,导出接口里出现 std 类型足以毒化下游全部 BMI,而报错点离真因很远
(和 gcc-new-module-std-in-interface-poisons-bmis 记的是同一个形状)。
把定义移出接口,这一类失败在结构上就不成立。
19 个接口单元全部改成:.cppm 只声明,.cpp 写 `module <name>;` 定义。
runner 549→199 行,platform 289→164,cmake 157→65,bazel 169→58。
附带收益:改一个引擎的实现不再改动它的 BMI,导入方不用重编。
不拆的两个:platform/{posix,windows}.cppm 是接口分区,同名分区不能再有一个
实现单元,拆它得另起 `:posix_impl` 之类的名字,得不偿失。
## 拆的过程中被自己的脚本坑了四次(都留在了脚本注释里)
* `= {}` 默认实参被当成函数体开头 —— 花括号扫描必须先走完形参表
* 默认实参写反了:剥掉了「声明」上的,留在了「定义」上。只有当调用方真的
依赖某个默认值时才编不过,其余模块是**静默丢 API**
* `namespace detail` 里的函数被搬到了外层 —— 变成与声明二义的重载,而报错
同时点出两处,读起来像重复定义
* 字符串里的 `") {"` 有个不配对的 `{`,朴素括号计数器直接冲出文件尾
## 判据:重构必须什么都没改变
拿 HEAD 建了一个 worktree 编出重构前的二进制,逐项对照:
* `--list` 输出逐字节相同
* 三个 variant 的 fixture 全量生成,**42 个生成文件逐字节相同**
(含四种引擎的构建描述:mcpp.toml / CMakeLists.txt / xmake.lua / BUILD.bazel)
* 一次真实测量(2 variant × 4 scenario)去掉时间字段后 cell 结构、状态、
note 完全相同
## 可续跑
单元 = project · variant · scenario · engine · run,每测完一个 append 一行
JSONL 并 flush,所以被杀最多丢正在测的那一个。
身份用**一个指纹**盖住整份配置(引擎/变体/场景/轮数/编译器/工程/fixture 形状/
`--id`),落在 `.mbench/<fingerprint>/`,和 `mcpp build` 一个形状:同配置命中、
改配置换目录。实测:杀掉后记录 12 个单元,重跑同一条命令跳过这 12 个、补完
剩下 12 个;去掉一个 scenario 立刻变成另一个指纹,不会去认领旧记录。
⚠️ 指纹**故意不含** mcpp 二进制本身 —— 否则每次重编都从零开始,而那正是续跑
最有用的时候。二进制版本按条记录,认领到版本不同的记录时**报出来**。
## 守卫
233 的两处因为这次拆分而失效,都已修:
* 引擎轴从 registry.cppm 读 —— 定义搬走后它报「一个引擎都没有」(响了,好)
* §7「不许按字面 compiler 分支」只 glob `*.cppm` —— 定义搬走后它扫的全是签名,
**会一直印成功行而什么都没测**(没响,这个危险)。现在同时读实现单元,并
断言实现单元存在。
根 README 里最显眼的一列(`bmi_schedule=on`)一直来自**另一次单独的跑**, 标准集根本没测它 —— 于是那张表是三个不同 run 拼出来的,守卫也只能硬编三个 文件名去核对。把这一列并进标准集,表就能出自一次跑。 * `matrix.json`:6 个真实工程格加 `mcpp[schedule=on]` 臂。 fixture 不加 —— `bmi_schedule` 重排的是深接口链,而生成的 fixture 故意很浅。 * `run-standard.sh`:`mcpp[...]` 只展开成**被测二进制**,不展开参照。 参照是 2026.8.11.3,早于 §8b 的修复,拿它跑 schedule=on 是在测一个已知坏掉 的调度器,那一列会读成「这个特性是退步」。 * 守卫:`mcpp[schedule=on]` 是**一个带选项的引擎**,不是第二个引擎。 按逗号切会把 `a[x=1,y=2]` 劈成两半,所以先吃掉方括号再切;选项本身不在这里 列第二份清单 —— registry 的 `engine_option` 不认识就整条 spec 拒掉。 顺带:`run-standard.sh` 的 harness 路径跟上 mbench 改名(原来找 `bin/bench`, 改名后**第一步就会说找不到 harness**),并把 `--cache-root` 钉在仓库根, 免得续跑依赖于从哪个目录发起。
…spect cold
根 README 那张表是仓库里最后一处手抄的数字,也是最不该手抄的地方:读者第一眼
看到的就是它,而它是**三次不同的跑拼起来的**(守卫只能硬编三个文件名去核对)。
标准集现在一次跑齐所有列,所以这张表可以生成。
`report.py --headline [--lang zh]` 从一份报告出 5 行 × 各引擎的表,mcpp 的臂排
在前(opt-in、默认、参照),每行最快的加粗。
## 顺带撞出一个已发布的坏数字
拿旧文件试渲染,出来的是
xmake cold 0.60s · 153.1x (cmake 92.33s)
—— 这正是 `--buildir` 相对 `-P` 解析那个缺陷:xmake 配到了一个已经填满的目录,
**什么都没编就退出了**。数字是真的,测量不是,而在头版表里它读作「xmake 比
cmake 快 153 倍」。
所以加了判据:**cold 若不到自己 noop 的 5 倍,它就没有构建这个工程**,
渲染器直接拒绝出表(`--allow-suspect` 才看)。宁可没有表,也不要一张朝着读者
有利的方向错掉的表。
另外两处判据:
* 真实工程与生成 fixture 的分界是 **fixture 名**不是 variant —— 同一个真实工程
在一次跑里标成 `modules`、另一次标成 `native`,按 `native` 过滤会**静默丢掉
cmake 和 xmake 两列**,表只剩两列且没有比值(我第一版就是这么错的)
* 一张表只能有一个工程,多于一个直接报错
…d drifted * 两份 README 都加「跑一半被打断了」一节:单元是什么、指纹盖住哪些、 为什么指纹**故意不含 mcpp 二进制**、以及 **seed build 不是样本必须重做** (不写这条会让人以为续跑是完全免费的)。 * harness 路径跟上改名(`bin/bench` → `bin/mbench`)。 * **发现并修掉一处已经漂了的钉子**:pin 表两份都还写着 cmake 4.0.2,而 matrix.json 早就是 4.4.2 —— 而这两个版本的 `import std` 实验开关**UUID 不同**。 偏偏这张表就是读者用来判断「这些数字能不能信」的那张表。 所以补了守卫:pin 表的行必须与 matrix.json 一致(只查表格行,周围正文讨论 旧版本是有意的)。**没有对应行也算失败** —— 否则改个表头就把这条检查变成 空转,而那正是这套东西要消灭的形状。反向验过:把 4.4.2 改回 4.0.2 立刻红。 顺带把能力表里 cmake 那行写清楚:UUID **每个 cmake 版本都不一样、且编进了二进制**, 拿 4.0 的 UUID 喂 4.4 会静默地不开 `import std`。
`mcpp[schedule=on]` 加进标准集之后,SPEC 的轴表还只列四个引擎,读起来像是 「schedule 那一列不知道从哪来的」。补上: * 新增一行 **Engine options**,并写明它是**某个引擎的一条臂**而不是第五个引擎 * 用一张三行表说清同一个格子里 mcpp 可以出现三次,以及**行名来自二进制自己 报的版本**而不是请求它的那个 token —— 「这一版变快了吗」和「这个 opt-in 键 值多少」就是这样各自回答的,绝不让一条臂替另一条站台 * 写明 `run-standard.sh` 只给**裸 token** 配参照臂:已发布的参照早于 README §8b 的修复,拿它跑 schedule=on 会把那一列读成「这个特性是退步」
…inished cells 设计文档写的是「journal 是真源,报告是派生物」,但只做了一半:journal 按**点**落盘, 报告却按**格**写出 —— 于是一格跑到一半被打断,那些已经落盘的点**一张表都看不到**。 刚才就是这个状态:正在跑的那格有 42 个点躺在文件里,仓库里没有任何东西能显示它们。 `report.py` 现在也吃 `.mbench/<指纹>/journal.jsonl`,按 (project, variant, scenario, engine) 归约出和 harness 一样的中位数/最值。 判据是**两条路径必须给出同一个答案**,不是「看起来差不多」:拿已完成的 gcc/mcpp 格 对照,journal 归约 25 格 vs 报告 25 格,**中位数不一致 0 处**。 两个细节: * 半截行(被杀时写了一半的最后一行)跳过,不让整个文件不可读 * `runs` 是**实际存在**的样本数,不补齐到计划数;各格样本数不一致时直接打警告 —— 样本少的格子是「离散度更小」不是「方差更小」,这两句话差很远 * journal 里没有 host(那在收尾报告里),就直说,而不是印一行 `None`
表格里三种情况必须能分辨,它们说的是完全不同的话:
- 没测过 —— 这个组合根本没有数据
_failed_ 引擎跑了,没产出产物 —— 这是**发现**,不是缺口
_unavailable_ 这台机器上没装,或这个引擎表达不了这一格
绝不留空,更绝不写 `0.00s`:被这套套件替换掉的那个 shell harness 就是把三个失败
格式化成 `0.000 s`,然后它们作为「有史以来最快的构建」发布了出去。
未测标记统一成 `-`(原来是 em dash,两处还不一致)。
原来那张表是**三次不同的跑拼起来的**(守卫只能硬编三个文件名),而且 n=1,
中英两份结构还不一样 —— 英文有 schedule 列,中文把它塞在脚注里。现在两份都由
`report.py --headline [--lang zh]` 从**同一份报告**生成:
`bench/results/standard-20260814-linux-x86_64/gcc-mcpp-2026.8.11.3.json`,25/25 ok,
所有大于 1s 的中位数 min/max 都在 ±4% 内。
## 数据本身换了个结论
新表有了 old-vs-new 列,于是最显眼的那个 200x **不再是「一直如此的默认行为」**:
touch-hub 已发布 2026.8.11.3: 81.72s 本分支: 0.42s cmake: 83.21s
edit-comment 已发布 2026.8.11.3: 79.11s 本分支: 0.40s cmake: 83.21s
也就是说级联抑制**以前并没有生效**,是这个分支让它真正工作的。旧文案写的是
「这是默认行为,不需要设任何键」,读起来像是它一直都在 —— 现在把话说准了。
`edit-body` 仍是对照组(1.1x 而不是 200x,级联是欠着的),`bmi_schedule` 明确写
「opt-in 且默认关闭」。
## 列序
`engine_order` 改成:被测默认 → 它自己的 opt-in 臂 → 已发布参照 → 其他引擎。
原来按字符串排,把参照插在了默认和 opt-in 中间,两个要对比的列永远隔着一列。
## 守卫
§5 重写:一份来源、两种语言、引擎名**从表头读**而不是在守卫里再列一遍
(加一条臂不会留下没被检查的列)。现在核 **50 个中位数**(5 行 × 5 引擎 × 2 语言),
原来是 20 个。另外 `-` 也被检查:标成「未测」的格子,数据里**必须真的没有** ——
否则 `-` 就成了藏数字的地方。
深度 review 这条分支时发现的,四条都能走到,其中两条会让构建**无输出地卡死**。 ## 1. 默认配置下 detach-codegen 的并发**完全没有上限** 模块自己的注释写着「真正的并发由信号量限制,永远不是 -j」—— 而 `resolve_jobs` 在用户没给 `--jobs` 时返回 0(「不说话,用后端默认」), 于是 `compilerCap = 0` → `acquire_token` 直接返回空 → **信号量被禁用**, 生成的图里是 `sched_cap = 0`。 detach 之后编译器一发布 BMI 就交还 ninja 槽位,所以 ninja 的 -j 已经不是上限了。 结果:**ninja 有多快就起多少个编译器,没有任何东西在数**,而单个模块编译峰值接近 1GB。这是模块自己列的 hazard 2,而它在**默认路径上根本没生效**。 修法:`decide()` 保持纯函数,新增 `autoJobs` 参数由调用方(prepare)算好传入; detach 分支在没有显式 job 数时回落到这台机器的推荐值。实测 `sched_cap` 从 0 变成 24。两者都没有时,**reason 里直接说「bounded by nothing」**。 ## 2. `.mcpp-sched` 的令牌**没有任何人回收** 令牌由持有它的 supervisor 删除。supervisor 没跑到清理就死掉(对构建按 Ctrl-C、 OOM killer、重启)就会留下目录,而没有任何代码删它 —— 每发生一次就**永久**少掉 一个槽位,攒够 cap 个之后下一次构建等一个永远不会被释放的令牌,**没有输出、 没有诊断**。对构建按 Ctrl-C 是再平常不过的操作。⚠️ 我第一次把回收放在了 prepare 里(schedule 决策旁边),**e2e 证明它根本没跑**: 增量构建走工程级 fast path,直接重放 build.ninja,不重新推导 plan。回收必须放在 **每次构建都会经过**的地方,也就是 execute 里 spawn ninja 之前。 ## 3. phase 1 等 BMI 是 `for(;;)`,没有上限 phase 2 有明确的上限,注释还写着「无上限的等待比失败更糟,因为没有任何东西告诉你 该看哪」—— 而**等同一个 supervisor 的 phase 1 没有**。supervisor 被 OOM 杀掉就 永远等下去。补上 10s「supervisor 没起来」宽限 + 2h 硬上限,两者都会恢复旧 BMI。 `acquire_token` 同样加了上限:超时后**不带 cap 继续**(信号量限的是内存压力, 不是正确性,失败的构建比慢的构建更糟),并说清楚该删哪个目录。 ## 4. supervisor 有一条退出路径不写 `.rc` 命令文件读不出来时 `cmd_bmi_supervise` 直接 return 2,而 phase 1/2 都在等那个文件 —— 和「supervisor 从没启动」无法区分,两边只能等到超时。现在这种情况也写 rc。 ## 测试 `tests/e2e/234_bmi_schedule_on.sh`(**这个特性此前没有任何 e2e**,只有 `decide()` 的单测,测的是**表**不是机制)。它种下满额的陈旧令牌,要求下一次构建照样跑完。 这个测试**在修好之前红了两次**:第一次真的卡死 600s,第二次暴露了我把回收放错了 地方 —— 不是空转的。 单测补了两条:零 cap 必须**在 reason 里说自己没有上限**;detach 在没有 job 数时 必须回落到宿主推荐值,而显式 `--jobs` 仍然优先。 本地:83 个单测全过,234 过。
…d `-` `bench/results/standard-20260814-linux-x86_64/`,七格跑了五格,**696 个测量点**, 每格 3 轮。两份 README 都加了「这一次跑到底覆盖了什么 —— 以及没覆盖什么」。 `-` 表示**没测**,既不是「不适用」也不是 0。没被标出来的缺口会被读成结果 —— 这正是这套套件要消灭的东西,所以未跑的两格、macOS、Windows 全部显式列成 `-`。 停在第五格的理由写进去了:每点约 55 秒,剩下两格还要约 2 小时,而它们不改变任何 已发布结论(两种代码风格在 gcc 上已完整,clang 在 mcpp 工作负载上已覆盖), 并且 `--resume` 能补上、不重复任何已测的点。 ## 两处必须写清楚的东西 **唯一的失败不是缺口,是发现。** `clang/mcpp/xmake` 五个场景全红,手工复现到 `formatter<basic_string<char>, wchar_t>`(窄格式串),根因是 xmake 在 clang 上的 默认形状必须用 full BMI,而 full BMI 会让 clang 22.1.8 编错下游 TU。已开 #424。 不用 `allow_failed` 糊过去。 **一个离群点被声明而不是被替换。** `+schedule=on/touch-hub` 测到 `[1.77, 20.82, 1.79]`(离散度 1066%)。紧接着 8 轮重测是 `[1.79 ×6, 1.78, 1.81]`,离散度 1% —— 是机器噪声,不是级联抑制偶发失效。 **已发布的那一格原样保留**:把第二次跑的样本拼进第一次跑的报告,正是这套套件 绝不能做的事。重测报告作为独立文件存进同一目录,因为「关于一个已发布数字的论断」 本身也必须可核。 顺带删掉 `gcc-xlings-2026.8.13.1.log` —— 第六格起了个头就被停了,一个有日志、 没报告的文件读起来像「它跑过」,而覆盖表说的是「未测」。
…ifest
`bench` 改名成 `mbench` 之后,`bench/tests/harness.sh` 还在问 `newest_artifact.sh`
要 `bench`,于是 230 直接死在
newest_artifact: no 'bench' under target/*/*/bin/
改名和这一行之间没有任何东西相连,所以只有真跑一次才会发现。现在名字从
`bench/mcpp.toml` 读出来 —— 那本来就是唯一定义它的地方 —— 并且读不到就明确失败,
而不是拿空串去查一个必然找不到的产物。
macOS 上直接死在第一步:
[xlings] 'xim:gcc@16.1.0' not in current index
而测试把它报成「build with bmi_schedule=on failed」—— 完全是另一个诊断。
补上和目录里其他 e2e 一样的三行(macos = llvm@22.1.8 / windows = llvm@20.1.7)。
在 clang 上策略是 two-phase、没有信号量,测试本来就会跳过第 3、4 节并说明原因,
所以这三行让它在三个平台上各测各该测的,而不是只在 Linux 上有意义。
引擎都用 `--version` 给自己贴标签,而 mcpp 的版本是**日期** —— 这条分支上每个 commit 都报 `2026.8.13.1`。所以报告能说清测的是哪个**发布**,却说不出是哪个 **构建**,而这套套件里的数字是随单个 commit 变的。 * `mbench --under-test TEXT` → 报告里的 `under_test` 字段,协议版本 1 → 2 * `run-standard.sh` 传 `git rev-parse --short HEAD`,并在 `src/` 有未提交改动时 加 `-dirty` —— 拿未提交的树跑出来的基准,描述的是别人 checkout 不出来的东西 * **不进指纹**:进了的话每次重编都要从零开始,而那正是续跑最有用的时候⚠️ 但「不进指纹」开了一个口子:续跑 → 重编 → 再续跑,一份报告里就会混进两个不同 二进制测出来的样本,而 `engine_version` 那道检查看不见 —— 因为版本是日期,两次 都一样。所以缓存目录里存一份 `under-test.txt`,不一致时**明确报出来**并给出 两个值和该删的目录。允许跨重编续跑(开发时这是常态),但不允许你不知道自己跨了。 另外给已发布的数据集补了 `results/standard-20260814-linux-x86_64/README.md`: 覆盖了什么、未测的用 `-`、唯一的失败(#424)、以及那个已声明的离群点。 其中被测 commit 是 `8b579fa` —— 由二进制 mtime 对 `git log -- src/` 反推出来的, **这比记录下来的字段弱**,文档里直说了这一点;之后每次跑都会写在 JSON 里。
… into the repo
两个都是 resume 特性引进来的,而且都只有真跑一次才看得见。
## 1. 协议版本号硬编成了 1
`under_test` 字段让 `kProtocolVersion` 从 1 变成 2(协议自己写的规矩:加字段就要
bump),而测试断言的是字面量 `"protocol_version": 1`,于是 macOS CI 红在
report is missing protocol_version
紧接着打印的报告下一行就是 `"protocol_version": 2` —— **消息说的东西是错的**,
因为那条检查真正问的是「它是不是 1」。现在从 `protocol.cppm` 读期望值;
字段整个消失时仍然会明确失败。
## 2. 测试在**续跑**,而且写进了仓库自己的缓存
`--cache-root` 默认是**当前目录**下的 `.mbench`,而这个测试的 cwd 是仓库。于是
它既往仓库缓存里写(跑完 `.mbench` 里多了两个目录),第二次跑还会打印
run 1/1 — already recorded, skipping
—— **它不再执行自己存在的理由,却照样通过**。这正是这套套件要消灭的形状,出现在
套件自己的测试里。现在所有调用都过一层把 cache 钉在 `$TMP` 的包装函数,每次跑都
是真实测量,仓库不受影响。
三列 `mcpp@2026.8.13.1` / `mcpp@2026.8.13.1+schedule=on` / `mcpp@2026.8.11.3`
把表撑得比 README 的渲染宽度还宽,于是它带着横向滚动条出现 —— 一张五列的对比表,
读者第一眼只看得到两列。
列名改成 `mcpp` / `mcpp +schedule` / `mcpp (released)`,身份挪进脚注:
`mcpp` = mcpp@2026.8.13.1, the build under test ·
`mcpp +schedule` = the same binary with `[build] bmi_schedule = "on"` ·
`mcpp (released)` = mcpp@2026.8.11.3, the published release
顺带把另外两个撑宽度的描述也收窄(`mtime only, content unchanged`、
`a comment added to a hub interface`)—— 「被大量导入」的含义正文里已经讲了。
## ⚠️ 缩短列名会让守卫失效,所以映射是**发出来的**
守卫原来从表头读引擎名去和 JSON 对。列名一短,它就对不上了 —— 而对不上的表现
可能是「静默地少检查几列」。所以 `report.py` 在表格上方发一行
<!-- columns: mcpp=mcpp@2026.8.13.1; mcpp +schedule=...; ... -->
(markdown 渲染时不可见),守卫从那里读映射,而不是自己再存一份。
**没有映射的短列名直接报错**,不会当成「没有这一列」跳过 —— 反向验过:删掉那行
立刻红。
…users look ## 表格 * `mcpp +schedule` → **`mcpp +opt` / `mcpp +优化`**。"schedule" 是**机制的名字**, 第一次看这张表的人无从判断一个「构建调度器」是更快、更慢还是只是不一样。 这一列要说的是「这是那个 opt-in 的提速开关」,它到底开了什么,下一行的图例 有的是地方讲清楚。 * `mcpp (released)` → **`mcpp (old)` / `mcpp (旧版)`** —— 被测的这一版将来也会 发布,"released" 分不出新旧。 * 每个数据格用 code span 包起来:`86.69s · 1.1x`。视口一窄,表格最先做的事就是 把这种格子折成两行,而**数字和它的倍率被拆开会读成两个数**。⚠️ 图例的分支原来是按字面量 `name == "mcpp +schedule"` 匹配的,改名之后这条分支 不再命中,opt-in 那一列**掉进了 else,被描述成「上一个已发布版」** —— 一句完全 错误的话,语气十足地印在那个「重点恰恰是它和第一列是同一个二进制」的列上。 现在按 `"+" in name` 判断,并且图例文本直接用生成出来的列名。 ## 文档 `bmi_schedule` 和 `jobs` 都是这条分支新加的**用户可见 manifest 键**,而 `docs/05-mcpp-toml.md`(用户真正会去查 `[build]` 键的地方)**两个都没有**。 中英两份都补上了: * 明确写出 **`auto` 是默认值,而它目前等于「关」** * 只认 `auto|on|off`,`"ON"`/`"true"` 会被拒 —— 并说清它们不是无害的笔误: 这个值进构建指纹,被拒的拼写以前会选到另一个构建目录(一次全量重建) * 为什么默认关(调度改错是**无声**的),以及**它在哪里没用** (mcpp 本来就跳过级联的那两行) * 实测数字:`cold` 86.7s → 35.7s、`edit-body` 80.9s → 29.8s * gcc/clang/msvc 三种做法各自的理由 守卫相应更新:列名是短名、数据里是完整标签,映射由 `report.py` 发在表格上方的 `<!-- columns: ... -->` 注释里(渲染时不可见),守卫从那里读;**短名没有映射 直接报错**,不会静默少检查一列。
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,而「取目录序最后一个」只是碰巧对。