Skip to content

feat(bench): 构建引擎基准套件 —— 把一次性脚本变成跨平台、可扩展的测量设施 (2026.8.12.1) - #423

Open
Sunrisepeak wants to merge 113 commits into
mainfrom
feat/bench-suite
Open

feat(bench): 构建引擎基准套件 —— 把一次性脚本变成跨平台、可扩展的测量设施 (2026.8.12.1)#423
Sunrisepeak wants to merge 113 commits into
mainfrom
feat/bench-suite

Conversation

@Sunrisepeak

Copy link
Copy Markdown
Member

起因

一次实测:mcpp 的自举构建不是吞吐瓶颈,是延迟瓶颈

  • 关键路径 = 100% 墙钟,后 55% 的时间里 32 个硬件线程上只有 1 个编译进程在跑
  • 这条关键路径上 77% 的时间在生产没有任何下游需要的 .o —— 下游真正需要的 BMI 在编译进度 22.8% 处就已经原子 rename 就位(strace 证实:之后 982 个系统调用无一再碰它)
  • 同样的病理在 xlings(110 模块、独立作者、独立代码库)上完整复现:并行度 3.16×、关键路径 100%

⇒ 这不是某一家构建系统的实现问题,而是「C++23 命名模块 + GCC 单阶段 + 边完成即释放」这一组合的结构性结果

完整分析:2026-08-12-modular-build-performance-deep-analysis.md · 架构与计划:2026-08-12-bench-suite-architecture-and-plan.md

为什么重写而不是扩展

上一轮的 bash + hyperfine 脚本有四个结构性缺陷:

缺陷 后果
只支持 2 个引擎 加第三个要动主体逻辑
bash + hyperfine Windows 上根本跑不了,而 mcpp 是三平台产品
被测对象只有 mcpp 自己 答不了「模块化 vs 头文件」这个真问题
结果是随手加字段的 TSV 跨机器 / 跨时间无法可靠合并

bench/ 的设计

协议先行 —— 三条不变量写进类型,不是留给约定

每一条都被旧脚本违反过:

  1. 失败不得伪装成数据。 status 与 timing 是分开的字段,非 ok 的格没有 median_s(而不是 0)。旧脚本把失败格式化成 0.000 s,三个这样的格子进了结果文件,看起来像是有史以来最快的构建。
  2. 跳过必须带原因。 「bazel 没装」与「bazel 跑挂了」是相反的结论 —— unavailablefailed,两者都必须带 note。
  3. 结果与宿主同生共死,含异构 CPU 标记:13900K 的 32 线程不是 32 个同构核,所有并行度数字都要照着它读。

加一个引擎 = 加一个文件

bench.engines.Engine + registry.cppm 一行,runner / 协议 / 场景 / CI 全不动。
已接入 mcpp、mcpp-opt、cmake、xmake、meson、bazel —— mcpp vs mcpp-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 / 关键路径 / 并发曲线),并固化了五个会反转结论的解析陷阱。最狠的一个:

最长路径必须按拓扑序松弛。栈式 DFS 的「跳过已在栈上的节点」防环写法会把兄弟分支压入但尚未算完的依赖记成 0,把 76.5s / 26 节点读成 33.9s / 10 节点 —— 把「100% 延迟瓶颈」读成「44%」,结论直接反转成「加核有用」。

它是靠与独立 Python 实现交叉验证抓到的:其余所有指标(makespan、工作量、逐规则耗时、并发曲线)全部吻合,唯独这一个差 2.3 倍。

过程中被实测推翻的三件事

  • -fmodule-only 文档说「只产 CMI」,实测照样跑完整个 codegen 再把结果丢弃(15.93s vs 完整 15.95s)。GCC 16.1 没有廉价产出 BMI 的开关;Clang 有(--precompile,1.80s vs 单阶段 7.18s)。
  • 「BMI 太大所以导入慢」不成立: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 build
mcpp test 80 passed / 0 failed
新增 e2e 230_bench_harness.sh ✅ 构建 harness、真实测量、从两侧断言协议不变量
六个引擎本机实测 ✅ mcpp / mcpp-opt / cmake / xmake / meson / bazel 全部跑通
check_version_pins.sh ✅ xlings pin 已是最新发布 2026.8.11.2,无需变更

e2e 从两侧断言是刻意的:只断言 ok 格有 median,会放过一个「给所有格都发 median」的实现 —— 而那正是这套协议要杜绝的 bug。

附带

起因是一次实测: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 探测),
但我没有在退回前把它钉死 —— 下一步应当先复现并定位,再重新落地,
而不是在一个会崩的基线上继续加东西。
…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 见过之后才该成为默认。
… compile fails

`compile_release_at_bmi` polls two things in order — `file_exists(bmi)` then
`read_rc(slot)`. 一个编译时间短于一个 2ms 轮询间隔的单元会落进第二个分支:第一
次检查时 BMI 还没出现,紧接着编译器既发布了 BMI 又写了返回码。

那个分支**返回成功却从不调用 `settle_bmi`**,于是:

* 上一份 BMI 一直停在 `<name>.gcm.bak` 里(实测:构建结束后 `.bak` 仍在);
* 那个单元的 BMI 等价性检查**根本没跑** —— 也就是说,驱动 restat 抑制级联的那
  套逻辑,对**恰好最便宜的那些单元**是静默关闭的。

另修:编译失败时不再把单元留在「完全没有 BMI」的状态 —— 之前 `rc != 0` 直接
返回,`.bak` 里那份上一次的 BMI 就再也回不来了,每个导入者都会撞上一个不存在的
文件。现在恢复备份。

⚠️ **这没有修好那个导入者报错**,必须说清楚:

    fx.unit_0: error: failed to read compiled module: No such file or directory
                note: imports must be built before being imported

`.bak` 泄漏确实修掉了(构建后 0 个残留),但 fixture 的 modules variant 上
touch-hub / touch-leaf / edit-body / edit-comment 四个场景**仍然失败**,而且
`-j1` 一样复现 —— 所以不是编译器之间的竞态。

已经确定的事实:phase 1 在 spawn 编译器**之前**就把旧 BMI 挪进 `.bak`,实测
该文件在一次增量重建中消失约 **208ms**(2ms 采样 × 104 次)。这个窗口是设计
本身带来的;窗口里被 ninja 调度到的任何导入者都会失败。还没搞清楚的是:为什么
在 `-j1` 下,声明了 `unit_1.gcm: dyndep | unit_0.gcm` 之后,导入者仍然会在这个
窗口里被调度。

在此之前:**`bmi_schedule` 不应被推荐开启,它的数字也不应被当作可引用的结论**
—— 而且此前所有已测的 `bmi_schedule` 数字都是在这个缺陷存在的情况下取的。
把结论从文档里撤回来:`bmi_schedule` 的数字目前不可引用,`auto` 也绝不能翻成
on。根 README、bench/README 都加了警示,状态文档新增 §8 记录复现步骤、已确定的
事实(不是竞态、208ms 窗口是设计带来的、失败模态是安全的那一种)、以及**还没
搞清楚的那一条**。

顺带纠正我自己的一个判断:真实工程测不出这条,只有 fixture 的紧密依赖链能撞上。
我此前把 fixture 说成「不如真实工程可信的那一半」——今天它连着抓出两个缺陷
(edit-comment 的扰动形态歧义、以及这条),两半各抓不同的类,谁也不冗余。
…shim is not an engine

CI 上 fixture 的 cmake/xmake 全红,`cannot find crt1.o / crti.o / -lm`。

**我的公平性修复只做了一半。** `--compiler payload:gcc` 把 mcpp registry 里的
编译器交给了每个引擎,但**生成的** fixture 描述里没有任何 payload 接线 ——
`bench_hermetic_payload()` 只存在于 checked-in 的 bench/projects/ 里。一个裸的
registry gcc 不知道自己的汇编器、链接器和 libc 在哪,而 cmake 把这个报成
「编译器无法编译一个简单程序」,只字不提 sysroot。

`bench.toolchain::payload_flags()` 现在算出这些 flag,`emit_cmake` 在
**`project()` 之前**写入(cmake 的编译器探测就发生在 project() 里),
`emit_xmake` 通过 add_cxflags/add_ldflags 写入。gcc 拿 -B + --sysroot,
clang 拿显式的 libc++ include 链 —— 与 hermetic_payload.cmake 同一套判断,
注释里写明了「同一个决策在两处」以及为什么不能靠 include 一个绝对路径。

**另一条:xlings 的 shim 会替一个没装的程序回答。** 问它 `--version`,它打印
`[error] xlings: 'bazel' is not installed` 然后**退出 0**。照单全收就是
「present,版本 = 那句错误」,于是该引擎的每个格子都跑了、都失败了,并被记成
**对引擎的发现**,而不是「这台机器上没装」—— macOS 每个 job 18 个格子。
`probe_program` 现在把这种 banner 当作 absent。

本机验证:fixture 的 cmake/xmake modules cold 从「configure 失败」变成
4.29s / 3.07s。
…t configuration

L2 的正确性缺陷第四次尝试后仍未修好,所以:

* CI 的引擎展开里去掉 `mcpp[schedule=on]` —— 它是 opt-in、默认关闭,用户拿到的
  东西不受影响,但 **CI 不应该去测一个构建不起来的配置**。放回去是一行,门槛写在
  workflow 注释里:§8 的复现六个场景全绿。
* 两个 README 的表格改回引用**默认配置**(cold 79.54s · 1.2x,
  touch-hub 0.40s · 208x,edit-comment 0.38s · 218x),并注明 schedule 能把 cold
  降到 35.4s 但因缺陷未修而不引用。中文 README 补上 edit-comment 一行。

进展与坑记录在 §8b:导入者读不到 BMI 的那条**已经修好**(旧设计在 spawn 之前就把
BMI rename 走,实测消失 ~208ms;改成复制+按文件身份判断发布)。仍未修好的是
`touch-leaf`/`edit-body` 的**链接**失败 —— 方向是「BMI 的 restat 抑制不能连带
抑制这个单元自己的 object 边」。

⚠️ 中途两个坑值得单独记:`file_size` 失败返回 `(uintmax_t)-1`,我在检查 `ec`
之前就写进了结构体;`copy_file` 盖的是拷贝时刻的 mtime,不显式带过去就把 restat
抑制整个废掉 —— 而那次**六个格子全绿**,只有数字(touch-hub 12.61s vs 冷构建
12.37s)能看出来。
…real lib dir

把 payload flags 从 hermetic_payload.cmake 移植到 C++ 时漏了两处,fixture 的
clang 格子因此全红:

* **`-isystem<llvm>/include/<triple>/c++/v1`** —— `__config_site` 住在那里,
  libc++ 的 `__config` 要 include 它。缺了就是每个 TU 都死在
  `__config:13:10: fatal error: '__config_site' file not found`,报错点在标准库
  里面,而不是指向缺的那个 flag。cmake 那份是 glob 出来的,我没把那行带过来。

* **`-L<llvm>/lib/<triple>`** —— 这个载荷里 libc++ 在
  `lib/x86_64-unknown-linux-gnu/`,不在 `lib/`。clang **驱动**自己找得到,所以
  cmake 那条臂只用 `-L…/lib` 也能过;换一个驱动链接就不行,xmake 于是挂在
  `ld: cannot find -lc++`。两个目录都写上,flag 就与「谁来链接」无关了。

**另一处同形状的**:「shim 报 not installed 但退出 0」的判断我只加在了
`probe_program` 里,而 mcpp 和 bazel 各有自己的 `probe()` —— 于是 macOS 上每个
job 仍有 36 个格子被记成对引擎的失败。收敛成一个 `looks_uninstalled()`,三处共用。

**macos/clang/xlings-\* 移入 excluded**:那不是 bench 的问题,是 mcpp 在 macOS
上编不了依赖的 `build.mcpp`(`dyld: Symbol not found: __ZdaPv`,见 §9a)。
按仓库既有规矩:能跑但还没跑通的格子进 `excluded` 并写清原因,而不是留在矩阵里
用 allow_failed 一直红。

本机验证:cmake+clang / xmake+clang 的 modules cold 从「configure 失败 / 链接
失败」变成 4.29s / 3.23s。
…d needs both

macOS 上 cmake / mcpp / bazel **三个引擎全部**挂在同一个地方:

    dyld: Symbol not found: __ZdaPv        (operator delete[])

三个一起挂,就是「问题在 flag 而不在某个引擎」的指纹。

我的 payload flags 只给了 `-L<libdir> -lc++`。libc++ 住在 registry 里,动态加载器
默认根本不往那儿看 —— 于是链接干干净净地过了,产出的二进制一跑就死。`-L` 回答的
是「链接器去哪找」,`-rpath` 回答的是「加载器去哪找」,这是两个问题,这个载荷两个
都得回答。

每个 `-L` 现在都配一个 `-Wl,-rpath`,并且 libc++ 的探测把 `.dylib` 也算上
(此前只认 `.so`/`.a`,macOS 上一个都匹配不到)。

本机 clang 验证:cmake 4.34s / xmake 3.19s,均通过。
表格是生成的(bench/tools/report.py),就是为了没人手抄 —— 但人还是会**粘贴**
那份输出。而一个和它声称来源的那次运行对不上的数字,任何别的测试都证伪不了:
数字照样打印出来,只是量的是别的东西 —— 这正是这整个套件的失败模式的缩影。

233 新增第 5 节:把根 README 表格里每个 mcpp / cmake 数字,逐个对回
`bench/results/pinned-workloads-20260813/mcpp-linux-gcc-5way.json`。表格形状变了
也会红(正则解析不到就失败),因为一个「解析不到所以什么都没查」的守卫比没有守卫
更糟。

用「把 cold 改成 42.00s」验证过会变红,改回去恢复绿。
…d links against libc++ too

上一版给 clang 加 `-L<registry>/lib -Wl,-rpath,...` 之后,macOS 上 cmake / bazel /
参照 mcpp 依旧全挂,而日志终于说清了是谁在挂:

    dyld: Symbol not found: __ZdaPv
      Referenced from: /Applications/Xcode_15.4.app/.../usr/bin/ld
      Expected in:     .../registry/.../lib/libc++.1.0.dylib

**是 Apple 自己的 `ld` 被解析到了载荷的 libc++ 上**,还没开始链接就 abort 了。
把 registry 的 lib 目录摆到平台工具链够得着的地方,就是在给系统里塞第二份
libc++,而 `ld` 自己就链 libc++。

判据在同一次运行里:**被测的 mcpp 那条臂 18 个格子全绿** —— 同一台 runner、同一个
载荷。也就是说 mcpp 在 macOS 上根本不发这些 flag。载荷里的 clang 自己知道它的
libc++ 在哪,`-isysroot` 也由 cmake 自己补。

所以 macOS 上不发 libc++ 相关的任何 payload flag。同样的隐患也存在于
`hermetic_payload.cmake` 的 Clang 分支(此前 macOS 的 projects 臂本来就没跑通,
所以没暴露),一并加上 `AND NOT APPLE`。
把 bench/README 的五臂表逐格对回 JSON,25 个数字里查出 1 个:
`touch-hub/xmake` 写的是 82.07s,实际 median 是 82.075 → 82.08。四舍五入的手抄
偏差,大小上无关紧要 —— 但这正是我声称已经消灭的那种漂移,所以改掉(三份文档
里的这一格都改)。

⚠️ 顺带记一件事:我第一版核对脚本的正则一行都没匹配上,于是打印了
「mismatches: none」—— **我自己的校验脚本给出了一个假绿**。只因为我顺手打印了
「解析到几行」才发现。e2e 233 里那个守卫没有这个问题(解析不到会显式失败),
但这说明「没有报错」和「检查过了」永远是两回事,连写检查的人自己也会踩。
macOS 上 cmake / bazel / 参照 mcpp **三个引擎一起**挂在:

    dyld: Symbol not found: __ZdaPv
      Referenced from: …/XcodeDefault.xctoolchain/usr/bin/ld
      Expected in:     …/registry/…/lib/libc++.1.0.dylib

Apple 自己的 `ld` 就链 libc++,而 registry 那份进了加载器的搜索路径。判据是
**被测的 mcpp 那条臂在同一次运行里全绿** —— 三个挂、一个不挂,问题在环境。

⚠️ 上一版把 macOS 的 payload libc++ flag 全去掉之后**照样发生**,所以污染是经由
`DYLD_*` 进来的、被所有子进程继承,不是经由链接 flag。这和 §9a 那个
`build.mcpp` 助手是同一个符号、同一个机制,只是面更大。

macOS 格子进 excluded,原因完整写在 matrix.json 里。**不再继续猜**:本机是
Linux 复现不了,而这一条已经烧掉好几轮 CI。矩阵剩 11 个格子、10 条有原因的排除。

另修一条**确定的**问题(Linux 也受影响):`xlings install` 此前在
`$GITHUB_WORKSPACE` 下执行,而那里有 `.xlings.json` ⇒ 装成 **workspace 级**,
而 workspace 的 shim 只在 cwd 位于该 workspace 内时才解析得到。harness 跑引擎时
cwd 是被测工程(在 $RUNNER_TEMP 下)⇒ `bench --list` 看到
`bazel 9.2.0 yes`,而每一次真正的构建都收到
`[error] xlings: 'bazel' is not installed`。改成从 $RUNNER_TEMP 安装。
… — and call the reference mcpp by path

两条,都是 CI 上 `linux/clang/mcpp-2026.8.11.3` 那个格子暴露的(该格子的 mcpp
两条臂已经全绿,失败的是外部引擎):

**1. cmake 回退到 4.0.2。** `CMAKE_EXPERIMENTAL_CXX_IMPORT_STD` 的 UUID **随 cmake
版本变化**,而 projects/ 里的描述带的是 4.0 那把钥匙。4.4.2 直接拒绝它
(`this CMake's version of the feature`),工程配不出来。4.0.2 同时也是
bench/results/ 里**每一个已发布数字**实际使用的版本 —— 钉它能让表格和矩阵描述
同一个工具。以后升 cmake,必须在同一个提交里把所有描述的 UUID 一起改。

(我当初把 pin 写成 4.4.2 是因为它是 `latest`。「最新」不是判据,「和描述里的
UUID 对得上」才是。)

**2. 参照 mcpp 改用二进制路径,不再用裸 `mcpp` shim。** 被测的工作负载自己带
`.xlings.json`,而 harness 跑引擎时 cwd 就在工作负载里 —— 于是裸 `mcpp` 按**那个
workspace** 的 pin 解析:

    xlings: version '2026.8.11.2' not found for 'mcpp'
      available: 2026.8.11.3

`$MCPP` 就是 bootstrap 装的那个二进制,而它**就是** reference_mcpp(两者同源于
.xlings.json,由 233 断言)。这也正是 bench/tests/harness.sh 注释里早就写下的
那条规矩:引擎按**二进制**指定,不要靠 PATH 查找。
…xmake arm's dependency

四个缺陷,都是同一个形状 —— **成功地什么也没做**。

**1. bazel 在 mcpp 工作负载上是个幽灵数字。**
`bench/projects/mcpp/BUILD.bazel` 里一条规则都没有(bazel 不能 glob 工作区外
的源码,`import std;` 也没有 bazel 写法),而 `bazel build //...` 对一个**没有
规则的包会退出 0**。于是矩阵发布了:

    bazel/clang/release/cold/mcpp-2026.8.11.3   0.43s   ok

旁边是 mcpp 的 12s 和 cmake 的 94s。每一层都「正确」:bazel 成功了,runner
计了时,报告打印了它。

- 矩阵里把 bazel 从 mcpp 格子移除,理由写进 `excluded`;
- 新增 `Engine::unbuildable_reason(Job)` —— `supports()` 只看得到 variant 和
  编译器,**看不到工程**,这正是缺口所在。bazel 用 `bazel query kind(rule,//...)`
  回答,无规则则报 `unavailable`(有理由)而不是 `ok`(有数字);
- e2e 233 加静态守卫:cell 若给 bazel 排了一个无规则的 BUILD.bazel 就红。
  **两个方向都钉过**:把 bazel 加回去,守卫确实变红。

**2. 那条「cold 必须 > 自己的 noop 两倍」的不变式抓不到它。** 它是**单引擎内部
的相对判据**,而一个什么都不编的引擎 cold 和 noop 都便宜、比值健康:0.43 对
0.22,以 0.01 秒之差擦过判据。新增**跨引擎**判据:同一 cell 里别的引擎编同一份
源码,快 20 倍就不是引擎快,是工作量小(mcpp 实测最好成绩是 3.1×,阈值远在其外)。

**3. xmake 的 mcpplibs.cmdline 版本是硬编码的,而那是「需求」不是「解析结果」。**
`mcpp.toml` 写 `= "0.0.1"` 是**版本要求**;我把它当成了解析结果写死。开发机上
恰好躺着 0.0.1 所以一直是绿的,干净 runner 上 `bench_package_root` 返回 nil,
`if` 静默地一个文件都没加,137 个单元之后死于

    missing mcpplibs.cmdline dependency for module mcpp.cli.cmd_cache

—— 指着消费者,不提版本也不提注册表。改为**读被测树自己的 mcpp.lock**(mcpp
在这棵树上实际做出的解析),两条臂由构造保证编同一份代码;读不到就 `raise`,
不再静默跳过。

⚠️ 读文件必须放进 `on_load`:描述作用域里 `io` 是 nil。这个坑
`common/xmake/payload.lua` 的注释里白纸黑字写着,我还是又踩了一次。

**4. bench.yml 的 `push:` 没限分支。** 仓库里另外 8 个 workflow 全都是
`branches: [main]`;不限的话,一个开着 PR 的分支会为**同一个 commit** 同时触发
push 和 pull_request 两个 run,而 `concurrency` 去不掉重 —— 两个事件的
`github.ref` 不同(`refs/heads/…` vs `refs/pull/N/merge`)。对一个两小时的矩阵
来说这是最贵的一种重复。

附带:`harness.sh` 的 `$MCPP` 未设置时死在 `line 22: : command not found`,
既不说变量名也不说怎么修。**不回落到 PATH** —— `command -v mcpp` 拿到的是
xlings shim,而 shim 按调用时的工作目录重新挑版本,bench 又刻意在被测树里跑
子进程,于是 cell 报 `mcpp@2026.8.11.2 | unknown command: build`(实测),
和今天早些时候 CI 上参照臂那个缺陷是同一个。改成必须显式给,并把相对路径在
`cd` **之前**转绝对;230 delegator 自己填默认值,这样手跑也成立。
…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 上全部转绿。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants