feat(bench): 构建引擎基准套件 —— 把一次性脚本变成跨平台、可扩展的测量设施 (2026.8.12.1) - #423
Open
Sunrisepeak wants to merge 126 commits into
Open
feat(bench): 构建引擎基准套件 —— 把一次性脚本变成跨平台、可扩展的测量设施 (2026.8.12.1)#423Sunrisepeak wants to merge 126 commits into
Sunrisepeak wants to merge 126 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 见过之后才该成为默认。
…ch a shim
**1. cmake + clang 建不了任何真实工程 —— 因为 `set()` 的顺序。**
`CMAKE_CXX_EXTENSIONS OFF` 写在 `project()` **之后**。CMake 为 std 模块合成的
那个目标是在 `project()` 里的编译器探测阶段建出来的,它捕获的是**那一刻**的
`CMAKE_CXX_EXTENSIONS` —— 默认 ON。于是 std.pcm 按 `gnu++23` 编,而所有真实目标
按 `c++23` 编,clang 拒绝加载:
error: GNU extensions was enabled in precompiled file 'std.pcm'
but is currently disabled
`import std;` 什么都没提供,构建在 19 处死于 `use of undeclared identifier 'std'`
—— 报错指着 mcpp 的源码,既不提 std.pcm 也不提 extensions。**同一个文件里紧挨着
的注释已经为实验性 key 写下了「必须在 project() 之前」,方言设置漏了同一条规矩。**
mcpp 与 xlings 两份描述都有,都已修。本地实测:改前 19 个错误 build 失败,
改后 `1 ok, 0 failed`,整棵 137 模块的树用 cmake+clang 编通。
**2. `$MCPP` 不是二进制,是 shim —— 上一次「按路径指定」的修复没有走出 shim。**
`$MCPP` = `<xlings home>/subos/default/bin/mcpp`,是一个**指向 `xlings` 的符号
链接**,按 argv[0] 分发并**依据被调用时的工作目录**重新决定 exec 哪个 mcpp。而
bench 刻意让每个引擎在**被测树里**跑,那些树各自带 `.xlings.json`。于是:
mcpp@2026.8.11.2 | [error] unknown command: build
上一版把裸 `mcpp` 换成 `$MCPP`,只是把解析提前了一步,**没有离开 shim** ——
症状从 `version not found` 变成 `unknown command`,缺陷没动。现在从 xlings 的
`data/runtimedir/mcpp-<ver>-<os>-<arch>/` 里解析出真正的 ELF,并且**断言它自己
报出的版本号等于 pin**(靠目录名叫这个版本是不够的)。解析不到就只跑单臂并
`::warning::`,不再拿一个会失败的引擎充数。
本地直接复现过:同一个目录下 shim 报 2026.8.11.2,解析出的二进制报 2026.8.11.3。
**3. xlings 的 lua_stdlib 生成器静默 return。** 包不在就跳过生成,构建随后死于
`missing mcpplibs.xpkg.lua_stdlib dependency`,指着消费者。与今天早些时候
mcpplibs.cmdline 那个是同一个形状,改成 raise。
一个 clang 崩溃会打出约 40 行栈回溯,而失败摘要只取最后 20 行 —— 于是 CI 上看到 的是 `#30..#36` 加一句 `clang frontend command failed with exit code 139`, **指认「在编哪个文件、崩在哪个 pass」的那几行早已滚过去了**。xlings/clang 那个 格子的崩溃至今没能定位,原因就是这个。 日志里出现崩溃特征(`PLEASE submit a bug report` / `Stack dump`)时取 80 行, 其余仍是 20 行 —— 普通编译错误不需要,多打只会淹没重点。 判据本身也验过:同一份含 37 帧的崩溃日志,20 行尾巴看不到 `Compiling module` 那 行,80 行看得到。
两条外部臂此前都不产出二进制,而 README 把这写成「已知缺口」。**它不是缺口,是 没做完的工作**,那句注释让它躺了很久。 **cmake 臂**(两棵树都实测:configure → build → `xlings --version` 输出对应版本) * `add_subdirectory` 这条路走不通,不是难走:mbedtls 3.6.1 对缺失的 `framework/` 子模块是**无条件 FATAL_ERROR**,与 `ENABLE_TESTING` 无关。改为按各包自己的 `.xpkg.lua` —— 也就是 mcpp 对「编哪些文件」的定义 —— 生成 STATIC target。 * **glob 是错的,不只是脆**:libarchive 目录里 132 个 `.c` 而清单是 127,lua 是 34 对 32,多出来的正是 `lua.c` / `luac.c`,各自带 `main()`。新的 `xpkg_source_library.cmake` 在 configure 期打印逐包计数(127/32/108/73/…共 467 个 TU),某个模式匹配到零个文件是 FATAL_ERROR 而不是静默变小。 * **libarchive 的五个压缩后端整个缺失**:它生成的 config 头把 `HAVE_LIBZ` 等置 1,于是留下 72 个 `deflate`/`BZ2_`/`LZ4`/`lzma_`/`ZSTD_` 未定义符号。 * **C 语言从来没启用**:`project(xlings CXX)` 对 `.c` 源码没有规则。而且因为 harness 只传 `-DCMAKE_CXX_COMPILER`,这里在 `project()` 之前从 C++ 驱动推出 同族 C 驱动 —— 否则 CMake 去 PATH 找宿主 `cc`,而 payload 又按 C++ 编译器给它 套 registry sysroot,正是那个文件注释里写的两套 libc 故障。 * 共享的 `bench_add_source_dep` **漏掉模块的实现单元**(只 glob `src/*.cppm`), 于是 capi.lua 那个 428 行的 `src/capi/lua.cpp` 从未被编译。 **bazel 臂**(同样两棵树实测通过) * 「工作区边界」是**跨过去**的,不是绕过去:`@xlings_tree` 用 repository rule 解析 `BENCH_PROJECT_ROOT`(和另外两份描述读同一个变量),`@mcpp_deps` 同样 从 `.xpkg.lua` 派生 13 个包。 * **`import std;` 不是墙**:`@mcpp_deps//:std` 直接编 libc++ 自己的 `std.cppm`, BMI 沿 `cc_library` 依赖传播。 * **`--compilation_mode=opt` 原本根本编不过**,而 harness 的 release 一律传 opt: bazel 附加 `-D_FORTIFY_SOURCE=1`,glibc 的 `__fortify_function` 是内部链接, libc++ 的 std 模块无法再导出。只测 debug 永远看不到这条。 * clang 的 BMI 会**重新打开自己的源文件**,而那不是声明过的输入。 `--spawn_strategy=local` 能编过 —— 这正是陷阱:沙箱外看着好了,依赖其实一直 没声明。用 `additional_compiler_inputs` 修。 **xmake 工具链缺了 `as`**:`-B<binutils>/bin` 只让 gcc 驱动找得到汇编器,而 xrepo 建包时是 xmake 自己解析程序,于是整个 configure 停在 `cannot get program for as` —— 既不提工具链也不提当时在建哪个包。 **e2e 233 的守卫判据写错了**:它找 `cc_binary|cc_library`,而能用的 bazel 描述 只声明 `alias` —— 那是 `bazel query kind(rule, //...)` 会返回的真实规则。要抓的 幽灵是**零规则**(`Found 0 targets`),不是「没有 cc_*」。改为匹配 `name =`, 并两个方向都重验过。
**xmake 臂现在能编出二进制**(`build rc=0`,100%,产物存在),依赖链全部装上: cmdline / capi-lua / tinyhttps / xpkg / mbedtls / zstd / xz / lzo / lua / libarchive 都 `ok`。这是靠三处: * **mcpplibs-index 的三个修复**(#15 #16 #17,均已合入):cmdline 装测试、 libxpkg 的 tag 没有 `v` 前缀、以及 xpkg 的 lua_stdlib 生成时机 —— 生成必须 发生在**描述被读取之前**,`before_build` 比模块扫描还晚。 * **`libarchive-xlings` 覆盖包**,直接沿用 xlings 自己那份 (openxlings/xlings@bb27e43 的 `xmake/packages/libarchive.lua`)。xmake-repo 的 libarchive 在 payload 工具链下死在 `CMake Error at CMakeLists.txt:1349: libgcc not found.` —— 覆盖包里的 `-DENABLE_LibGCC=OFF` 正是这条。 * **工具链补齐 `as`/`ranlib`/`nm`/`objcopy`**:`-B<binutils>/bin` 只让 gcc 驱动 找得到汇编器,xrepo 建包时是 xmake 自己解析程序,否则停在 `cannot get program for as`。 **仍未完成的一步:二进制起不来。** error while loading shared libraries: libbz2.so.1.0 已经定位清楚,不是猜的:宿主**有** `/usr/lib/x86_64-linux-gnu/libbz2.so.1.0`, 链接期 ld 找得到;但产物的解释器是 payload 的**私有 loader** (`xim-x-glibc/2.44/lib64/ld-linux-x86-64.so.2`),它的默认搜索路径是载荷前缀而 **不是 `/usr/lib`**。根因再往上一层:bzip2/lz4/xz 没有真正被 xrepo 装上 (`.xmake/packages/b/` 是空的),libarchive 于是回落到宿主的动态库。 `add_requireconfs("**", {configs = {shared = false}})` 已加,但这次没生效 —— 包已按动态装好、configure 全命中缓存直接短路(日志只有 119 字节)。要让它生效 必须先清掉这些包重装。 **另外两条臂的过期说法已更正。** bench/README 里写着 cmake/xmake 臂 "stop at the link" 并记为已知缺口 —— 那不是缺口,是没做完的工作,而"缺口"这个 词让它躺了很久。现在写明 cmake/bazel 已能编出可运行二进制,且**表中数字是那 之前测的、尚未重测**,不要把"没有 cmake/xmake 列"读成对那些引擎的结论。
… in zh 三条臂拿依赖的方式不同,于是它们的 `cold` 不是同一个量:cmake 和 bazel 按各包 自己的 `.xpkg.lua` 编源码(约 470 个依赖翻译单元进了 cold),xmake 经 xrepo 声明 (链接 xrepo 早先建好的库,cold 里不含它们)。`xmake clean` 不清 xrepo 包缓存, 所以这是稳定的**工作量差异**而非首跑假象 —— 但它不是引擎快慢。 写清了该怎么读:跨引擎比 cold 用**生成的 fixture**(没有任何第三方依赖);在 xlings 上比增量场景(那里没有任何一条臂重建依赖)。 中文版此前根本没有「已声明的不对称」这一节 —— 而这正是防止误读数字的那一节。 补上,并注明完整清单在英文版 §5。
… binary
六个组合(三引擎 × 两棵树)全部实测:`build rc=0`,二进制裸跑 `--version` 输出
**各自那棵树**的版本号 —— 证明确实在编对应的树,而不是同一份。
xmake 臂最后一环是 openssl 必须写进**覆盖包自己的 `add_deps`**。项目级
`add_requires("openssl")` 只把包装上,不会把它的库目录送进 libarchive 包自身的
cmake 构建;而 `set_base("libarchive")` 继承了上游的 openssl 依赖,`-lssl
-lcrypto` 照样上链接线,宿主又只有 `libssl.so.3` 没有 `libssl.so`,于是
ld: cannot find -lssl: No such file or directory
在这之前还踩了两处,都记在代码里:
* **zlib/bzip2/lz4 从未被安装**(`~/.xmake/packages/b/` 是空的)。只靠
`libarchive-xlings` 的 `add_deps` 不够,必须像 xlings 自己的 xmake.lua 那样
**显式 `add_requires`**。否则 `-lz -lbz2 -llz4` 落到宿主的 `.so`,链接成功而
产物起不来 —— payload 的私有 loader 不搜 `/usr/lib`。
* **`-DCMAKE_FIND_USE_CMAKE_SYSTEM_PATH=OFF` 是错的解法。** 它确实能挡住宿主库,
同时也挡住了 libarchive 合理探测的一切,包自己就构建失败。已撤回并在注释里
写明为什么不要再试。
另外更正一条我先前的误判:xmake@3.1.0 **装得上也跑得起来**。之前那句「本地装不上」
读错了报错 —— 真实原因是 `package 'ncurses' is ambiguous`(local/scode/xim 三个
仓库都提供),而且它早就装好了;正确切法是 `xlings use xmake 3.1`。这条很重要:
CI 上 `linux/gcc/fixture` 那个 3.1.0 特有的
`ld: failed to set dynamic section sizes` 因此**可以本地复现**,不必再靠读日志猜。
两处 harness 与描述之间的错配。都是「手工跑得通、harness 跑不通」——
这类差异最容易被读成「引擎不行」。
**1. `--buildfiles` 没转绝对路径。** 每个引擎都以该目录为 cwd 启动,而 xmake 又
把同一个路径当 `-P` 收下,于是相对路径解析两次:
--buildfiles bench/projects/xlings
→ bench/projects/xlings/bench/projects/xlings
error: project not found!
从仓库根手工跑同一条命令是好的 —— 那里没有可供翻倍的前缀。和这套件已经修过一次
的 `--buildir` 翻倍是同一个形状,所以修在解析处一次,而不是每个适配器里各修一遍。
**2. payload 驱动经 `CXX` 传给 xmake 是错的机制。** 真实工程的描述把 payload
定义成一个 xmake **工具链**(编译器 + 它的 `-B<binutils>`/`--sysroot`,见
../common/xmake/payload.lua);只设 `CXX` 等于把编译器给了 xrepo 而**不给那些
flag**,于是每个依赖包都用它构建并失败:
=> install cmdline 0.0.2 .. failed
=> install mbedtls v3.6.7 .. failed
=> install ftxui v6.1.9 .. failed
而 `xmake f --toolchain=mcpp-gcc` 手工跑是成功的,因为工具链把两半一起给了。
生成的 fixture 没有这个定义(bench.fixture.buildfiles 把 flag 内联写进描述),
所以它仍走 CXX —— 两种情况按「描述是否在树之外」区分。fixture 已回归验证未受影响。
两者管的不是同一段构建,缺一不可:
--toolchain=mcpp-* 工程自己的 target(payload 编译器 + 它的 -B/--sysroot)
CC / CXX xrepo 构建的依赖包 —— xmake **不会**把工程工具链应用到
它们身上
只给 toolchain,依赖包就用 PATH 上第一个 `cc`。在这个仓库里那是 workspace 的
xlings shim,它的 include 路径缺内核 UAPI 头,于是每个包都死在三层之下的头文件里:
.../xim-x-glibc/2.39/include/bits/local_lim.h:38:10:
fatal error: linux/limits.h: No such file or directory
> in src/lua.c
手工跑同一条命令看着是好的 —— 只是因为那些包已在 xrepo 缓存里、根本没重建。
加上 CC/CXX 后 `install lua v5.4.8 .. ok`。
⚠️ 仍未通:cmake 系的依赖包(ftxui / mbedtls)在 harness 下仍失败,而**手工跑
同一份配置是成功的**。最新线索是它们的日志里出现
warning: download failed due to ssl certificate verification
/bin/bash: line 10: : No such file or directory
后一条是**空变量被当成命令执行** —— 说明 harness 传给子进程的环境里有某个 xmake
包脚本依赖的变量是空的。这条还没定位到是哪一个。
判断写在了一个**会被提前改写掉的形式**上:`--compiler payload:gcc` 在 main.cpp
里早就解析成绝对驱动路径,引擎拿到的 `job.compiler` 根本不带 `payload:` 前缀。
于是 `starts_with("payload:")` 恒为假,`--toolchain=mcpp-gcc` 一次都没传出去 ——
而这段代码读起来完全正确,红的表现却是依赖包在别处炸。
改为按**解析后的路径**判断(驱动是否位于 registry 的 xpkgs 下),这个判据扛得住
那次改写。
结果:xmake 臂在 harness 下 `1 ok, 0 failed`(此前每个格子都 failed)。fixture
臂与 harness 自测均已回归验证未受影响。
配套的另一半(上一提交)是必须**同时**给 toolchain 和 CC/CXX:前者管工程自己的
target,后者管 xrepo 构建的依赖包 —— xmake 不会把工程工具链应用到依赖包上,
缺了它们就用 PATH 上第一个 `cc`,而在这个仓库里那是 include 路径不全的 workspace
shim。
…te tree
`--project` 是相对路径,而 `build_dir` 由它派生(`<project>/build`)并作为 `-o`
交给 xmake。**xmake 按自己的 cwd(buildfile 目录)解析它,clean() 按 harness 的
cwd 解析同一个字符串** —— 两者指向不同目录。xmake 于是写进
bench/projects/xlings/bench/projects/xlings/<tree>/build
而 clean() 删的地方从来没有人写过。每一个 `cold` 都是在已构建好的树上跑的:
0.76s,而 cmake 在同一份源码上是 103s。
和 `--buildfiles`(上一提交)同一个成因,所以修在解析处:两个路径都转绝对。
**是那两道不变式抓到的**,不是我看出来的:
cold=0.76s against its own noop=0.40s — a cold build cannot be that cheap
cold=0.76s while other engines building the same sources take 103.15s
修复后 cold 跑了 213 秒,两条告警都不再出现,翻倍目录也不再产生。
⚠️ 中途一个错判记在这里:我先认定是 xmake 的编译器缓存(`--ccache` 默认为 y、
且在工作区之外,clean() 够不着)。关掉之后**仍然是 0.76 秒** —— 假设不成立。
`--ccache=n` 保留了,因为它本身是对的(cmake 与 mcpp 在这套件里都没有工作区外
的编译器缓存,留着它就不是「xmake 更快」而是「xmake 没编译」),但它不是本缺陷
的成因。
15 ok / 0 failed / 零告警 —— 三条臂第一次同时产出可运行二进制,所以这是第一份 真实的三方数据。此前 cmake 与 xmake 两列一直是 `failed`,表只能是 mcpp 对 mcpp。 | 场景 | mcpp | cmake | xmake | |---|---|---|---| | cold | 92.49s · 1.29x | 119.46s | 105.02s · 1.14x | | noop | 0.74s · **0.49x** | 0.36s | 0.40s · 0.90x | | touch-hub | **1.79s · 54.96x** | 98.16s | 98.16s | | edit-body | 88.38s · 1.11x | 98.43s | 98.00s | | edit-comment | 93.81s · 1.04x | 97.98s | 97.97s | **两处是 mcpp 输,都写进了正文而不是脚注:** * `noop` 0.49x —— 什么都不做时 mcpp 是三者里最慢的(0.74s vs cmake 0.36s)。 这是每次调用的固定开销,也是用户在「改一行、构建一次」循环里唯一每次都感受 得到的数字。 * `edit-comment` 只有 1.04x,不是 mcpp 自身负载上的 200x。注释插进了 xlings 保留 在接口单元里的内联函数体,BMI 真的变了,**级联是欠的**。格子的 note 记录当次 是哪种形态,不要读成优化失效。 `touch-hub` 54.96x 才是真结果,而且 cmake 与 xmake 相差 **0.00s** —— 两个时间戳 驱动的引擎本就该长这样。 原始报告存进 bench/results/xlings-3way-20260814/;中英文两版同步。旧的 old→new 两风格对照表保留,并标明它两列都是 mcpp、不受外部臂当时未完成的影响。
split 树同样 15 ok / 0 failed / 零告警。两种代码风格现在都有真实三方数据。 | 场景 | mcpp | cmake | xmake | |---|---|---|---| | cold | 27.59s · 1.82x | 50.13s | 41.90s · 1.20x | | noop | 0.79s · **0.44x** | 0.34s | 0.50s · 0.68x | | touch-hub | **1.32s · 20.08x** | 26.60s | 31.68s · 0.84x | | edit-body | 1.79s · **0.75x** | 1.35s | 1.49s · 0.91x | | edit-comment | 24.17s · 1.09x | 26.35s | 31.28s · 0.84x | **最重要的一条不是 mcpp 赢在哪,而是重构本身的收益盖过了引擎选择**:mcpp 的 cold 从 92.49s(combined)降到 27.59s(3.35x),而 cmake 自己也从 119.46s 降到 50.13s (2.38x)。把实现搬出接口单元,比换构建工具更值 —— 这是这两个 pin 存在的意义, 也是这套件能给用户的最有用的一句话。 **`edit-body` 0.75x —— 这里 mcpp 比 cmake 慢。** 函数体在 `.cpp` 里,只重编一个 object、没有级联,于是场景量的是每次调用的固定开销而非图推理,和 `noop` 是同一笔 成本。在这条轴上 mcpp 没有优势,数字就该这么写。 `touch-hub` 20.08x 比 combined 的 54.96x 小,因为下游本来就没剩多少可跳的了。
hermetic e2e 稳定崩,而**本地同一条命令是成功的**;它的输出到
Compiling hello195 v0.1.0 (.)
Segmentation fault (core dumped)
就断了,没有任何一句话指出死在哪个阶段。e2e 的 job 刻意不开 `MCPP_VERBOSE`
(它们要断言 mcpp 默认的安静输出),所以这里另起一个**只做诊断、不判成败**的
步骤:同样的构建跑一次 verbose,`continue-on-error`,下一次 CI 就能报出阶段。
⚠️ 这是探针不是修复,理解之后要删掉。
**为什么需要它:我已经猜错四次。** 依次排除掉的:`--jobs auto` 的 OOM
(`mcpp.toml` 根本没设 jobs)、xmake 3.1.0(切过去后 fixture 本地全绿)、
mcpp-index 变动(最后一次是 08-12,不在窗口内)、以及「这是本分支的代码缺陷」
—— 最后这条被同一批日志自己否掉了:`toolchain: gcc` 里崩的是**已发布的
2026.8.11.3**,hermetic 里崩的是本分支构建的,**两个不同版本的 mcpp 都崩**,
指向环境而非本分支代码。
还有一个我至今解释不了的事实:`c86378b` 经核实**只有两个 markdown、38 行新增**
(`git show --stat`,不是凭记忆),却让 9 个核心 job 全红,而它前一个提交那 9 个
全绿 —— 且重跑可复现。确定性失败同样可以来自一个已经变了的外部状态,这一点是我
先前推理里缺的一环。
同一份分支代码,clang 22.1.8 下 `unit/test_elf_runtime` 段错误、gcc 16.1.0 下 83 passed / 0 failed;而该文件本分支一行没改、main 用同一个 clang 也是绿的(独立 worktree 实测)。backtrace 在 libc++ 的 `__assign_with_sentinel`,调用方带模块符号 `@mcpp.platform.elf_runtime` —— 别处的新增改变了这个函数的代码生成。 顺带记下七个被实验否掉的假设,以及两处我自己的推理错误:把「和 CI 同信号的本地 失败」当成孤立问题(它本可以早几小时定位),以及「确定性失败 ⇒ 不是环境」这一步 ——确定性失败同样可以来自一个已经变了的外部状态。
…ry core CI job
`f51e6ab` 的 `mcpp.toml` 里混进了一行:
[toolchain]
-default = "gcc@16.1.0"
+default = "llvm@22.1.8"
**与那个提交的主题(xmake 臂 / libarchive 覆盖包)毫无关系,提交说明里一字未提**
—— `git add -A` 扫进去的。它把 mcpp 自身的默认工具链换成了 clang,而 clang 22 在
这份代码上误编译 `mcpp.platform.elf_runtime`:`unit/test_elf_runtime` 段错误
(SIGSEGV),连带核心 CI 全红。
`git bisect`(判据=该单测是否 SIGSEGV)指到这个提交。改回 gcc 后本地
**83 passed; 0 failed**。
这也解释了那个「纯文档提交让 9 个核心 job 全红」的怪事:**工具链早在它上一个提交
就被换掉了**,文档提交只是第一个跑完整套核心 job 的提交 —— 我当时推理
「文档提交不可能弄坏构建」的前半句是对的,却从没想到去查前一个提交里混进了什么。
底层的 clang 缺陷本身仍然存在(gcc 下 83/0、clang 下段错误,而该文件本分支一行
没改、main 用同一个 clang 也是绿的),已记进 .agents/docs §10;这里只是不再默认
踩它。
**Windows 上两条外部臂都没有 `import std`,而这一直是红的。** cmake 死在 `project()` 里的 CXX_MODULE_STD 工具链支持探测 (CMakeTestCXXCompiler → CMakeDetermineCompilerSupport),xmake 死在 `missing std dependency for module ...`。Windows 载荷是 llvm@20.1.7 配 MSVC STL, 它不提供任何一条臂能构建的 std 模块。 mcpp 两条臂在该格子是好的(5 ok),所以**保留格子、豁免那两条臂**而不是把引擎删掉: 豁免的失败在报告里仍然可见,删掉的引擎不可见。矩阵要求 `allow_failed` 必须带 `KNOWN GAP` 说明(e2e 233 强制),原因已写进 note。 另外 `231_jobs_option.sh` 独立跑时死在 `line 45: : command not found` —— 和 `bench/tests/harness.sh` 早先那个是同一个成因:`$MCPP` 由 e2e 运行器导出,手跑时 没有。补上自解释的回退。⚠️ 这里有个刻意的不对称:**231 自己填默认值,bench 的 harness 不填**。后者的 `$MCPP` 就是被测对象,猜错了整个测量都是错的,所以它宁可报错也不猜。同一个症状, 两种正确处理。
根因确认后按当初写在注释里的约定删掉。它的价值在于把崩溃钉在
`stage("runtime-validate")` 打印**之前**(即 `validate_changed_artifacts` 内部),
顺着那条线才在本地找到稳定复现的单测。
修复确认:改回 gcc 后,先前全红的六个核心 job 在 CI 上全部转绿。
…d an unpacked dependency
**我先前说「CI 通过」是错的。** 当时还有 10 个 job 在跑,我看到「20 绿 0 红」就
下了结论,而那 10 个里就有这些格子。拿部分数据当结论,在这个整场都在讲「失败看
起来像成功」的分支上尤其不该发生。
三个都只在 CI 上出现,本地一直绿 —— 原因各不相同:
**1. fixture 的 xmake 臂:PIE 没有说出口。**
relocation R_X86_64_32 against `.rodata.str1.1' can not be used
when making a PIE object; recompile with -fPIE
ld: failed to set dynamic section sizes: bad value
载荷 gcc 在 CI runner 上默认 PIE 链接、在开发机上不是,所以编译不带 `-fPIE` 的
对象只在 CI 被拒。12 个格子红。mcpp 与 cmake 在这里都产出 PIE(bazel 适配器为此
传 `--force_pic`),所以把它显式写出来,四条臂产出同一种可执行文件。
**2. cmake 的载荷 flags 到得太晚。** `bench_hermetic_payload()` 依赖
`CMAKE_CXX_COMPILER_ID`,而它要 `project()` 之后才有 —— 但失败的正是 `project()`
里的那次探测:
/usr/bin/ld: cannot find crt1.o: No such file or directory
CMake 把它报成「编译器无法编译一个简单程序」,既不提 sysroot 也不提载荷。开发机
上因为宿主有 crt1.o 而通过。新增 `bench_hermetic_payload_preproject()`,按编译器
**路径**判断(调用方本来就用 `-DCMAKE_CXX_COMPILER` 给了它),在 project() 之前
补上 `-B`/`--sysroot`。
**3. 依赖根本没被解包。** mcpp 从全局构建缓存取 `mcpplibs.cmdline`,**缓存命中
不解包源码**,于是新 runner 上两条外部臂都拿不到那三个单元。xmake 臂的守卫如实
报了出来;cmake 臂当时只 warning、然后静默地少编三个单元 —— 已改成 FATAL_ERROR,
和 xmake 那条对齐。bench.yml 在矩阵开始前丢掉该包的缓存,让下一次 mcpp 构建从源码
编一次(三个翻译单元,发生在任何测量之前)。
…urce
clang frontend command failed with exit code 139
PLEASE submit a bug report to https://github.com/llvm/llvm-project/issues/
failed: obj/xlings/src/core/config.m.o pcm.cache/xlings.core.config.pcm
**同一棵树、同一份描述,gcc 16.1.0 下是绿的**(linux/gcc/xlings-2026.8.13.1
success),只有 clang 崩 —— 所以这个格子测不出任何关于构建引擎的东西,它测的是
一个编译器缺陷。
选择 excluded 而不是 waived:**崩掉的编译器不产出任何可看的数字**,而豁免是留给
「跑完了但没产物」的。llvm 修好后把格子加回来。
⚠️ 这条崩溃日志之所以能指出文件名,是因为本分支早先把「日志里出现崩溃特征时取
80 行而不是 20 行」加了进去 —— 在那之前 CI 上只看得到 `#30..#36` 和一句
`exit code 139`,连崩在哪个文件都不知道。
顺带:e2e 233 当场拦下了我第一版改动(「既是 cell 又在 excluded 里」),这正是
它存在的意义。
… mcpp badly 你问得对:上一版表是 **`bmi_schedule` 关着**测的,而那正是专门优化冷构建与级联的 开关。补测同一棵树、同样五个场景的 `+schedule=on` 对照臂,**10 ok / 0 failed**: | 场景 | schedule=on | 默认 | 对 cmake | |---|---|---|---| | cold | **37.56s** | 92.49s | 1.29x → **3.18x** | | touch-hub | **1.04s** | 1.79s | 54.96x → **93.93x** | | edit-body | **29.65s** | 88.38s | 1.11x → **3.32x** | | edit-comment | **30.44s** | 93.81s | 1.04x → **3.22x** | | noop | 0.72s | 0.74s | 0.49x → 0.50x(仍然输) | **我先前对 `edit-body`/`edit-comment` 的解释只对默认配置成立。** 我写的是「级联是 欠的,所以 mcpp 没有优势」—— 级联确实是欠的,但开了调度之后 mcpp 把**同一份欠下的 工作**做快了 3.3 倍(BMI 一产出就发布,而不是等代码生成结束)。只放默认列,读者会 得出「mcpp 在真实编辑场景上和 cmake 一样」的结论,那是错的。 两列并排,因为它们回答不同的问题:默认列是用户今天拿到的,调度列是这个开关能给 什么。`noop` 两列都输,照写。 **§8b 的正确性缺陷在这里没有复现**(十个格子全 ok)。它只在生成的 fixture 上出现 —— 那条紧密的 unit_0→unit_1 链会撞进窗口。该键因此仍然默认关闭:只有一个工作负载 能暴露的缺陷,仍然是缺陷。
…ecks both 和 bench/README 同样的问题:根 README 只放默认配置,把 `bmi_schedule=on` 降级成 脚注里的一句「cold 35.4s」。读者看到的是 `cold 1.2x`、`edit-body 1.1x`,而实际 可达的是 **3x** 和 **3x**。 | 场景 | schedule=on | 默认 | cmake | |---|---|---|---| | cold | **35.43s · 3x** | 79.54s · 1.2x | 92.33s | | touch-hub | **0.22s · 377x** | 0.40s · 207.9x | 83.39s | | edit-body | **30.17s · 3x** | 76.24s · 1.1x | 85.64s | | edit-comment | **0.18s · 458x** | 0.38s · 217.2x | 82.96s | 数据本来就在已发布的 JSON 里(五个场景全 ok),只是没被用。⚠️ **差点重新发布一个已知的假数字。** 按 `mcpp-linux-gcc-5way.json` 直接重建表时, xmake 的 cold 是 **0.60s · 153x** —— 那正是路径翻倍导致「测了一棵已构建好的树」的 幽灵。README 现用的 90.30s 来自 `mcpp-linux-gcc-xmake-refixed.json`,所以重建时 xmake 列必须继续取修正文件。**一份结果目录里同时躺着修正前后的两份数据,是个陷阱**; 两个文件名的区别是唯一的提示。 e2e 233 §5 当场拦下了表格变形(「did not parse — has its shape changed?」),这正是 它存在的意义。解析器已教会新形状,并且**新增的 schedule 列也纳入对数** —— 那是读者 目光最先落到的一列,不查它是最贵的一种漏。两个方向都验过:改掉该列的数字会红, 还原后绿。
**1. Windows 的 `import std` 不是缺口,是没告诉 xmake 去哪找。** 日志把解法写在
脸上,而我先前把它当成平台缺口豁免了:
warning: std and std.compat modules not found!
maybe try to add --sdk=<PATH/TO/LLVM> or install libc++
error: <mcpp> missing std dependency for module mcpp.build.provisions
载荷里**本来就带着** `share/libc++/v1/std.cppm`。报错点名的是被测工程的模块,
所以读起来像「这个工程坏了」,而不是「引擎不知道自己的标准库在哪」——
windows/clang 格子每个场景都这么红。SDK 根目录由解析后的驱动路径推出
(`…/xim-x-llvm/<ver>/bin/clang++` → `…/<ver>`),和工具链 pin 同源,不需要手工同步。
⚠️ **第一版补丁放在了控制流到不了的地方**:`--sdk` 加在 `own_description` 分支
**之后**,而那个分支提前 return —— 偏偏 Windows 上失败的 `bench/projects/mcpp`
正是 own_description。这是这个仓库记过的老形状(「修补放在控制流到不了的地方」),
已移到分支之前,两条路径都覆盖。
**2. 每个场景 5 轮改成 3 轮。** 真实工程上「增量」并不便宜 —— `edit-comment` 在
xlings 上要重建 45 个导入者,五轮就是五次近乎完整的重建,一个 windows/clang 格子
为此花掉半小时以上。多出来的样本买不到相称的精度:钉住的工作负载上跨轮离散度
低于 2%,而每张发布的表都取中位数。**没人愿意等的矩阵,就是没人会看的矩阵。**
`linux/clang/fixture` 84 ok / 6 failed:
g++: error: unrecognized command-line option '--no-default-config'
clang 格子里 xmake 用了 **g++**,而描述里写的是 clang 的 flags。成因和我上一个
提交修的 `payload_toolchain` **一模一样**:main.cpp 把 `--compiler payload:clang`
改写成绝对路径后,`job.compiler == "clang"` 在**恰恰需要它为真的那些格子里**永远
为假,于是 `--toolchain=llvm` 没传,xmake 回落到默认编译器。
**我上次只修了一处,没有回头查还有谁在比同一个字符串。** 这是同一个改写第三次
咬人,每次在 review 里都读着像对的 —— 因为被比较的正是用户敲进去的那个词。
所以补的不只是那一行,还有 e2e 233 §7:**禁止任何引擎适配器按字面量比较
`job.compiler`**(`engine.cppm` 的 `resolve_cxx` 除外 —— 它是入口归一化,在改写
之前运行)。判据剔除注释行,两个方向都验过:改回字面量比较会红,还原后绿。
…ould read
`ci-windows-e2e` went red on 233 with
UnicodeDecodeError: 'charmap' codec can't decode byte 0x8f in position 3037
from §7, whose `read_text()` names no encoding. Python then decodes with the
LOCALE encoding — UTF-8 on the Linux/macOS runners, cp1252 on the Windows one —
and every file this test reads (matrix.json, the engine adapters, the READMEs)
contains non-ASCII.
The crash is the benign half. cp1252 leaves only five byte values undefined, so
the reads that DON'T hit one of them succeed and return mojibake: the regex
matches nothing and the guard prints its success line while checking nothing.
§1 has been reading matrix.json that way from the start and stayed green purely
because its non-ASCII bytes missed those five. Same defect, opposite symptom —
and the silent one is the symptom this whole test exists to catch, so it must
not be the test's own failure mode.
Every read now names utf-8, including the `subprocess(text=True)` that decoded
git's stdout the same way. And the test exports PYTHONWARNDEFAULTENCODING /
PYTHONWARNINGS=error::EncodingWarning so an unspecified encoding is a hard error
on the FIRST machine that runs it — this class of bug should not be discoverable
only on Windows.
Verified both directions: with §7's encoding removed the test exits 1 locally
(EncodingWarning), restored it exits 0. The injection asserts its own anchor
first, because a sabotage that silently fails to apply reads exactly like a
passing test.
… the builddir Deep review of this branch. Four findings, all in code or docs it adds. 1. `bmi_schedule` had NO validation. "ON", "true", "yes" were accepted, treated as `auto` (i.e. OFF), and explained with "the split schedule is opt-in until it has been verified" — which reads as "you did not ask for it" to someone who just did. `resolve_jobs`, twenty lines below in the same file, already warns on a malformed value with the comment "a typo turns into a build that is mysteriously slower". Two switches, one file, opposite rules. It was worse than a no-op: prepare.cppm folds this value into the build fingerprint whenever it is not "auto", so a typo also picked a different build directory — a full rebuild that optimises nothing. `requested_switch` now returns one of auto|on|off and reports anything else; the fingerprint reads the normalised value, so both halves are fixed at once. 2. `cap * 6` was signed overflow. `auto` is bounded by recommended_jobs' ceiling of 64, but `--jobs` is only checked for `> 0`, so `--jobs 2000000000` reached `decide()` intact. Clamped before multiplying. 3. The three-engine table in bench/README spliced two runs without saying so. Every number is real, but `bmi_schedule=on` comes from xlings-schedule.json while the other three columns come from xlings-combined-3way.json — and the first file carries its own default arm that differs by ~1% (cold 91.61s vs 92.49s). A reader opening either file finds a mismatch. The schedule arm was never measured alongside cmake and xmake, so the splice is unavoidable; the footnote now states it and gives the within-run pair (2.44x). 4. Both bench READMEs commented on that table twice — I added the new schedule-column discussion and left the old block below the footnote, so the `noop` point was made in both. Merged; the `touch-hub` and `edit-comment` points were only in the old block and are kept. New guard, e2e 233 §8: every `NN.NNs` in a bench README table must exist as a median in bench/results/. §5 covers the root README's five rows; these two files carry about ninety and had nothing. This is the check that would have caught the xmake `cold 0.60s` phantom, which was one command away from being published. It refuses to pass when fewer than 50 medians are found, because an empty corpus would make every README trivially clean — the same silent pass it exists to stop. Verified in both directions: 13/13 unit tests pass, and with the validation reverted the typo tests fail (rc=1). §8 passes, fails on an invented number, and fails when bench/results/ is moved away. Each injection asserts its own anchor first — a sabotage that silently fails to apply reads exactly like a pass.
…ds the object
§8b, fixed — and it was not what the record said it was.
Under `bmi_schedule = "on"` with gcc each module interface unit gets two edges: a
BMI edge that returns when the compiler publishes the BMI, and an object edge
that waits for that same compiler. The object edge's only input was the BMI.
That is the file the cascade suppression deliberately leaves untouched. When the
new BMI is equivalent, mcpp restores the previous one so its mtime does not
advance — which is what stops 39 importers rebuilding, and is correct. ninja's
restat then cleans every edge whose only reason to be dirty was that output, and
the object edge was one. It was skipped, and the link with it.
Editing a function body does not change a GCC BMI, so this is the common case,
not a corner. Minimal repro (`export int leaf_value() { return 1; }` -> 42):
Finished dev in 0.02s <- reported success, 3 of 8 edges run
./repro -> 1 <- the source says 42
No link error, no diagnostic, a silently wrong binary. The detached compiler
wrote the correct object 0.2s later, after ninja had decided not to link it.
THE RECORD DESCRIBED ONLY THE FIXTURE'S SYMPTOM — `undefined reference to
unit_19_value@fx.unit_19()` — which is this same skip in the case where the
symbol did not exist beforehand. Writing down the first form I hit instead of the
general one is why this read as fixture-specific for a week.
Fix: `build <obj> : cxx_module_obj <source> | <bmi>`. The source gives the edge a
reason to be dirty that restat cannot clean; the BMI stays implicit so ninja
still orders it after phase 1.
AND IT WAS IN THE PUBLISHED NUMBERS. Timing an unfinished build makes it look
fast: measured, `mcpp build` returned in 0.56s with cc1plus still running and the
object landing 1.24s later. Re-measured on the pinned workload with the fix:
cold 35.43s -> 36.36s unchanged
noop 0.16s -> 0.16s unchanged
touch-hub 0.22s -> 0.44s was not finished
edit-body 30.17s -> 30.48s unchanged
edit-comment 0.18s -> 0.44s was not finished
The headline survives — cold and edit-body were doing real work. What does not is
"the schedule helps everywhere": on the two rows where mcpp already skips the
cascade it is now visibly slower than the default. All three READMEs updated, in
both languages, and bench/README gains the §8b section that three of them had
been pointing at without it existing.
Neither bench invariant could catch this: they compare a scenario to that
engine's own noop and to the other engines, and 0.22s against a 0.16s noop is not
anomalous. Catching "still running" means asserting on the artefact, not the
clock — recorded, not yet built.
e2e 233 §5 hardened while updating it. It required `**Ns**` in the schedule
column, so moving the bold to whichever column is actually faster dropped two of
five rows and it went on printing a success line for the remaining three. It now
strips emphasis, asserts the parsed row count against the table's own length, and
covers xmake, which it never checked at all: 3 rows x 3 engines -> 5 x 4.
Verified: 83/83 unit tests, e2e 231/232/233 green; the repro prints 42 and only
the edited unit's object is rebuilt (importers untouched, so the suppression
still works); §5 fails both on a wrong number and on a table it can only
partially parse.
…ed a sysroot by existence Three bench cells failed on the c83159e matrix. Two independent causes, both in code this branch added. 1. cmake/linux/gcc — all five cells died in cmake's own compiler probe: ld: cannot find crt1.o: No such file or directory ld: cannot find crti.o: No such file or directory ld: cannot find -lm: No such file or directory `bench_hermetic_payload_preproject()` passes `--sysroot` when the directory EXISTS. `--sysroot` does not add a search path, it replaces gcc's default one, so pointing it at a directory that exists but holds no libc removes the C runtime and says nothing about the flag that did it. mcpp creates `registry/subos/default` whether or not anything was installed into it, so the predicate was always true and the arm never configured. Now it probes for crt1.o under the usual lib dirs, and when the sysroot is present but empty it says so and links against the host runtime rather than failing — a different measurement, but one that announces itself. This repository has been bitten by this exact flag before (an install() source package lost its libc headers the same way). Existence was the wrong predicate then too. 2. xmake/linux/{gcc,clang} — all five cells: `mcpplibs.cmdline 0.0.1 is not unpacked`, in the same runs where the cmake arm compiled it fine. The two arms located the same required-identical sources two different ways: cmake names `cmdline-<ver>/src`, xmake globbed the version directory, sorted, and took the first entry — beside a tarball, a lock, and whatever partial directories unpacking leaves behind. It now names the directory, like cmake does. Widening the glob to "the first directory containing src/" was my first attempt and is no better: renaming the real tree to `cmdline-0.0.1.hidden` to test the error path made this file compile THAT instead, silently, and report success. That is how the fix got caught — the negative test found a defect in the fix. The diagnostic now names the expected path, the directories actually present, and whether the base exists. The old one gave a version and a registry root, which cannot distinguish "absent", "wrong version" and "there but different". Verified locally in both directions: xmake configures and scans `cmdline-0.0.1/src`; with that directory renamed it exits 255 with the new message instead of quietly compiling the decoy. The gcc/cmake case cannot be reproduced here — this machine's subos does hold a crt1.o, which is why the predicate looked correct when it was written.
The windows/clang/mcpp cell waives cmake and xmake under one `import std`
explanation. That reason is cmake's — it stops inside project() at the
CXX_MODULE_STD probe. xmake's actual failure on the last matrix was
seed build: could not start the process (no log written)
i.e. the program is not on the runner's PATH. An environment problem, and a
fixable one, sitting behind a reason about a language feature that will not
change for years. Nobody was going to look.
The guard could only ever check that a note EXISTS, not that it is still true.
Requiring it to mention each waived engine by name is the next best thing: it
stops one blanket sentence from covering two arms with different causes. All
four existing waivers already satisfy it; the windows note is rewritten to state
the two failures separately and to say which one is worth chasing.
Verified both directions: green as it stands, red when the note stops naming
xmake.
… works
Two corrections to my own §8b write-up.
1. I wrote that the published `bmi_schedule=on` numbers were "measuring a build
that had not finished". What I actually observed was a HAND-RUN rebuild:
`mcpp build` returning in 0.56s with cc1plus still running and the object
landing 1.24s later. That is real, but it is not the harness's flow, and I
could not reproduce it there. The defensible statement is narrower and is now
what the docs say: the object edge and the link were being skipped, and
`touch-hub`/`edit-comment` doubled when that work came back. Whether the
skipped work was still running or simply not done varies by scenario.
2. The invariant I wrote for it is removed. The idea was sound — poll the tree
the engine writes to, fail if anything lands after the engine exits — and it
failed three times:
* it watched `job.build_dir`, which is the `-B`/`-o` given to cmake, meson
and xmake; mcpp writes under `<project>/target`, so for the one engine it
existed for it watched an empty directory and skipped silently;
* then it slept a fixed 300ms, chosen as "far longer than mtime resolution
and far shorter than any compile" — but the thing being detected IS a
compile tail, so it is long by definition, and the 1.24s write landed
after the window closed;
* then it polled until stable for 3s against the engine-declared tree, and
a diagnostic confirmed it was running and had a baseline — and it still
did not fire on the binary that still has the defect.
Each attempt was caught only because I ran it against that defective binary.
A guard that cannot be shown to catch the case it was written for is
indistinguishable from no guard, and this branch has spent its whole length
removing things that look like coverage and are not. Shipping it would have
been one more.
The gap is now stated as open in both READMEs and in the working document,
together with what was tried, so the next attempt does not start from zero.
`--runs 0` meant "ask the harness", which is 3. Three samples across 10 cells x 5 scenarios, on a workload whose cold build is 80 seconds, is most of a two-hour matrix — spent on dispersion nobody reads on a pull request. A push or a PR asks "did this change break or move anything", and one sample answers that. Automatic triggers now pass 1. `workflow_dispatch` keeps the `runs` input, now defaulting to 1 and documented as the place to ask for 3 or more when the numbers are going into a table. That is the split that matters: n=1 has no dispersion at all, and bench/README §4a R2 already asks a reader not to compare digits without it — every published table here says `n=1` for exactly that reason. Pinned by e2e 233, because a cost default that nobody checks drifts back and is only ever noticed as "CI got slow again". While writing that guard I made the mistake it now warns about: the first version reached the input's `default:` with `grep -A4`, and the four comment lines above it pushed it out of range, so it failed on a correct file. It parses the block now. Verified four ways: green as it stands, red when `--runs` goes back to 0, red when the input default changes, and STILL GREEN when a comment line is inserted into the block — that last one is the case the first version got wrong. Also corrects a doc line I had already invalidated: README §4 still said "cold defaults to 3 runs, incremental scenarios to 5" after I flattened default_runs to 3. Both READMEs now state the harness default, the CI default, and which one a published number should come from.
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,而「取目录序最后一个」只是碰巧对。