feat(bench): cross-platform benchmark suite, and the defects it exposed (2026.8.15.1) - #423
Merged
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 见过之后才该成为默认。
…d drifted * 两份 README 都加「跑一半被打断了」一节:单元是什么、指纹盖住哪些、 为什么指纹**故意不含 mcpp 二进制**、以及 **seed build 不是样本必须重做** (不写这条会让人以为续跑是完全免费的)。 * harness 路径跟上改名(`bin/bench` → `bin/mbench`)。 * **发现并修掉一处已经漂了的钉子**:pin 表两份都还写着 cmake 4.0.2,而 matrix.json 早就是 4.4.2 —— 而这两个版本的 `import std` 实验开关**UUID 不同**。 偏偏这张表就是读者用来判断「这些数字能不能信」的那张表。 所以补了守卫:pin 表的行必须与 matrix.json 一致(只查表格行,周围正文讨论 旧版本是有意的)。**没有对应行也算失败** —— 否则改个表头就把这条检查变成 空转,而那正是这套东西要消灭的形状。反向验过:把 4.4.2 改回 4.0.2 立刻红。 顺带把能力表里 cmake 那行写清楚:UUID **每个 cmake 版本都不一样、且编进了二进制**, 拿 4.0 的 UUID 喂 4.4 会静默地不开 `import std`。
`mcpp[schedule=on]` 加进标准集之后,SPEC 的轴表还只列四个引擎,读起来像是 「schedule 那一列不知道从哪来的」。补上: * 新增一行 **Engine options**,并写明它是**某个引擎的一条臂**而不是第五个引擎 * 用一张三行表说清同一个格子里 mcpp 可以出现三次,以及**行名来自二进制自己 报的版本**而不是请求它的那个 token —— 「这一版变快了吗」和「这个 opt-in 键 值多少」就是这样各自回答的,绝不让一条臂替另一条站台 * 写明 `run-standard.sh` 只给**裸 token** 配参照臂:已发布的参照早于 README §8b 的修复,拿它跑 schedule=on 会把那一列读成「这个特性是退步」
…inished cells 设计文档写的是「journal 是真源,报告是派生物」,但只做了一半:journal 按**点**落盘, 报告却按**格**写出 —— 于是一格跑到一半被打断,那些已经落盘的点**一张表都看不到**。 刚才就是这个状态:正在跑的那格有 42 个点躺在文件里,仓库里没有任何东西能显示它们。 `report.py` 现在也吃 `.mbench/<指纹>/journal.jsonl`,按 (project, variant, scenario, engine) 归约出和 harness 一样的中位数/最值。 判据是**两条路径必须给出同一个答案**,不是「看起来差不多」:拿已完成的 gcc/mcpp 格 对照,journal 归约 25 格 vs 报告 25 格,**中位数不一致 0 处**。 两个细节: * 半截行(被杀时写了一半的最后一行)跳过,不让整个文件不可读 * `runs` 是**实际存在**的样本数,不补齐到计划数;各格样本数不一致时直接打警告 —— 样本少的格子是「离散度更小」不是「方差更小」,这两句话差很远 * journal 里没有 host(那在收尾报告里),就直说,而不是印一行 `None`
表格里三种情况必须能分辨,它们说的是完全不同的话:
- 没测过 —— 这个组合根本没有数据
_failed_ 引擎跑了,没产出产物 —— 这是**发现**,不是缺口
_unavailable_ 这台机器上没装,或这个引擎表达不了这一格
绝不留空,更绝不写 `0.00s`:被这套套件替换掉的那个 shell harness 就是把三个失败
格式化成 `0.000 s`,然后它们作为「有史以来最快的构建」发布了出去。
未测标记统一成 `-`(原来是 em dash,两处还不一致)。
原来那张表是**三次不同的跑拼起来的**(守卫只能硬编三个文件名),而且 n=1,
中英两份结构还不一样 —— 英文有 schedule 列,中文把它塞在脚注里。现在两份都由
`report.py --headline [--lang zh]` 从**同一份报告**生成:
`bench/results/standard-20260814-linux-x86_64/gcc-mcpp-2026.8.11.3.json`,25/25 ok,
所有大于 1s 的中位数 min/max 都在 ±4% 内。
## 数据本身换了个结论
新表有了 old-vs-new 列,于是最显眼的那个 200x **不再是「一直如此的默认行为」**:
touch-hub 已发布 2026.8.11.3: 81.72s 本分支: 0.42s cmake: 83.21s
edit-comment 已发布 2026.8.11.3: 79.11s 本分支: 0.40s cmake: 83.21s
也就是说级联抑制**以前并没有生效**,是这个分支让它真正工作的。旧文案写的是
「这是默认行为,不需要设任何键」,读起来像是它一直都在 —— 现在把话说准了。
`edit-body` 仍是对照组(1.1x 而不是 200x,级联是欠着的),`bmi_schedule` 明确写
「opt-in 且默认关闭」。
## 列序
`engine_order` 改成:被测默认 → 它自己的 opt-in 臂 → 已发布参照 → 其他引擎。
原来按字符串排,把参照插在了默认和 opt-in 中间,两个要对比的列永远隔着一列。
## 守卫
§5 重写:一份来源、两种语言、引擎名**从表头读**而不是在守卫里再列一遍
(加一条臂不会留下没被检查的列)。现在核 **50 个中位数**(5 行 × 5 引擎 × 2 语言),
原来是 20 个。另外 `-` 也被检查:标成「未测」的格子,数据里**必须真的没有** ——
否则 `-` 就成了藏数字的地方。
深度 review 这条分支时发现的,四条都能走到,其中两条会让构建**无输出地卡死**。 ## 1. 默认配置下 detach-codegen 的并发**完全没有上限** 模块自己的注释写着「真正的并发由信号量限制,永远不是 -j」—— 而 `resolve_jobs` 在用户没给 `--jobs` 时返回 0(「不说话,用后端默认」), 于是 `compilerCap = 0` → `acquire_token` 直接返回空 → **信号量被禁用**, 生成的图里是 `sched_cap = 0`。 detach 之后编译器一发布 BMI 就交还 ninja 槽位,所以 ninja 的 -j 已经不是上限了。 结果:**ninja 有多快就起多少个编译器,没有任何东西在数**,而单个模块编译峰值接近 1GB。这是模块自己列的 hazard 2,而它在**默认路径上根本没生效**。 修法:`decide()` 保持纯函数,新增 `autoJobs` 参数由调用方(prepare)算好传入; detach 分支在没有显式 job 数时回落到这台机器的推荐值。实测 `sched_cap` 从 0 变成 24。两者都没有时,**reason 里直接说「bounded by nothing」**。 ## 2. `.mcpp-sched` 的令牌**没有任何人回收** 令牌由持有它的 supervisor 删除。supervisor 没跑到清理就死掉(对构建按 Ctrl-C、 OOM killer、重启)就会留下目录,而没有任何代码删它 —— 每发生一次就**永久**少掉 一个槽位,攒够 cap 个之后下一次构建等一个永远不会被释放的令牌,**没有输出、 没有诊断**。对构建按 Ctrl-C 是再平常不过的操作。⚠️ 我第一次把回收放在了 prepare 里(schedule 决策旁边),**e2e 证明它根本没跑**: 增量构建走工程级 fast path,直接重放 build.ninja,不重新推导 plan。回收必须放在 **每次构建都会经过**的地方,也就是 execute 里 spawn ninja 之前。 ## 3. phase 1 等 BMI 是 `for(;;)`,没有上限 phase 2 有明确的上限,注释还写着「无上限的等待比失败更糟,因为没有任何东西告诉你 该看哪」—— 而**等同一个 supervisor 的 phase 1 没有**。supervisor 被 OOM 杀掉就 永远等下去。补上 10s「supervisor 没起来」宽限 + 2h 硬上限,两者都会恢复旧 BMI。 `acquire_token` 同样加了上限:超时后**不带 cap 继续**(信号量限的是内存压力, 不是正确性,失败的构建比慢的构建更糟),并说清楚该删哪个目录。 ## 4. supervisor 有一条退出路径不写 `.rc` 命令文件读不出来时 `cmd_bmi_supervise` 直接 return 2,而 phase 1/2 都在等那个文件 —— 和「supervisor 从没启动」无法区分,两边只能等到超时。现在这种情况也写 rc。 ## 测试 `tests/e2e/234_bmi_schedule_on.sh`(**这个特性此前没有任何 e2e**,只有 `decide()` 的单测,测的是**表**不是机制)。它种下满额的陈旧令牌,要求下一次构建照样跑完。 这个测试**在修好之前红了两次**:第一次真的卡死 600s,第二次暴露了我把回收放错了 地方 —— 不是空转的。 单测补了两条:零 cap 必须**在 reason 里说自己没有上限**;detach 在没有 job 数时 必须回落到宿主推荐值,而显式 `--jobs` 仍然优先。 本地:83 个单测全过,234 过。
…d `-` `bench/results/standard-20260814-linux-x86_64/`,七格跑了五格,**696 个测量点**, 每格 3 轮。两份 README 都加了「这一次跑到底覆盖了什么 —— 以及没覆盖什么」。 `-` 表示**没测**,既不是「不适用」也不是 0。没被标出来的缺口会被读成结果 —— 这正是这套套件要消灭的东西,所以未跑的两格、macOS、Windows 全部显式列成 `-`。 停在第五格的理由写进去了:每点约 55 秒,剩下两格还要约 2 小时,而它们不改变任何 已发布结论(两种代码风格在 gcc 上已完整,clang 在 mcpp 工作负载上已覆盖), 并且 `--resume` 能补上、不重复任何已测的点。 ## 两处必须写清楚的东西 **唯一的失败不是缺口,是发现。** `clang/mcpp/xmake` 五个场景全红,手工复现到 `formatter<basic_string<char>, wchar_t>`(窄格式串),根因是 xmake 在 clang 上的 默认形状必须用 full BMI,而 full BMI 会让 clang 22.1.8 编错下游 TU。已开 #424。 不用 `allow_failed` 糊过去。 **一个离群点被声明而不是被替换。** `+schedule=on/touch-hub` 测到 `[1.77, 20.82, 1.79]`(离散度 1066%)。紧接着 8 轮重测是 `[1.79 ×6, 1.78, 1.81]`,离散度 1% —— 是机器噪声,不是级联抑制偶发失效。 **已发布的那一格原样保留**:把第二次跑的样本拼进第一次跑的报告,正是这套套件 绝不能做的事。重测报告作为独立文件存进同一目录,因为「关于一个已发布数字的论断」 本身也必须可核。 顺带删掉 `gcc-xlings-2026.8.13.1.log` —— 第六格起了个头就被停了,一个有日志、 没报告的文件读起来像「它跑过」,而覆盖表说的是「未测」。
…ifest
`bench` 改名成 `mbench` 之后,`bench/tests/harness.sh` 还在问 `newest_artifact.sh`
要 `bench`,于是 230 直接死在
newest_artifact: no 'bench' under target/*/*/bin/
改名和这一行之间没有任何东西相连,所以只有真跑一次才会发现。现在名字从
`bench/mcpp.toml` 读出来 —— 那本来就是唯一定义它的地方 —— 并且读不到就明确失败,
而不是拿空串去查一个必然找不到的产物。
macOS 上直接死在第一步:
[xlings] 'xim:gcc@16.1.0' not in current index
而测试把它报成「build with bmi_schedule=on failed」—— 完全是另一个诊断。
补上和目录里其他 e2e 一样的三行(macos = llvm@22.1.8 / windows = llvm@20.1.7)。
在 clang 上策略是 two-phase、没有信号量,测试本来就会跳过第 3、4 节并说明原因,
所以这三行让它在三个平台上各测各该测的,而不是只在 Linux 上有意义。
引擎都用 `--version` 给自己贴标签,而 mcpp 的版本是**日期** —— 这条分支上每个 commit 都报 `2026.8.13.1`。所以报告能说清测的是哪个**发布**,却说不出是哪个 **构建**,而这套套件里的数字是随单个 commit 变的。 * `mbench --under-test TEXT` → 报告里的 `under_test` 字段,协议版本 1 → 2 * `run-standard.sh` 传 `git rev-parse --short HEAD`,并在 `src/` 有未提交改动时 加 `-dirty` —— 拿未提交的树跑出来的基准,描述的是别人 checkout 不出来的东西 * **不进指纹**:进了的话每次重编都要从零开始,而那正是续跑最有用的时候⚠️ 但「不进指纹」开了一个口子:续跑 → 重编 → 再续跑,一份报告里就会混进两个不同 二进制测出来的样本,而 `engine_version` 那道检查看不见 —— 因为版本是日期,两次 都一样。所以缓存目录里存一份 `under-test.txt`,不一致时**明确报出来**并给出 两个值和该删的目录。允许跨重编续跑(开发时这是常态),但不允许你不知道自己跨了。 另外给已发布的数据集补了 `results/standard-20260814-linux-x86_64/README.md`: 覆盖了什么、未测的用 `-`、唯一的失败(#424)、以及那个已声明的离群点。 其中被测 commit 是 `8b579fa` —— 由二进制 mtime 对 `git log -- src/` 反推出来的, **这比记录下来的字段弱**,文档里直说了这一点;之后每次跑都会写在 JSON 里。
… into the repo
两个都是 resume 特性引进来的,而且都只有真跑一次才看得见。
## 1. 协议版本号硬编成了 1
`under_test` 字段让 `kProtocolVersion` 从 1 变成 2(协议自己写的规矩:加字段就要
bump),而测试断言的是字面量 `"protocol_version": 1`,于是 macOS CI 红在
report is missing protocol_version
紧接着打印的报告下一行就是 `"protocol_version": 2` —— **消息说的东西是错的**,
因为那条检查真正问的是「它是不是 1」。现在从 `protocol.cppm` 读期望值;
字段整个消失时仍然会明确失败。
## 2. 测试在**续跑**,而且写进了仓库自己的缓存
`--cache-root` 默认是**当前目录**下的 `.mbench`,而这个测试的 cwd 是仓库。于是
它既往仓库缓存里写(跑完 `.mbench` 里多了两个目录),第二次跑还会打印
run 1/1 — already recorded, skipping
—— **它不再执行自己存在的理由,却照样通过**。这正是这套套件要消灭的形状,出现在
套件自己的测试里。现在所有调用都过一层把 cache 钉在 `$TMP` 的包装函数,每次跑都
是真实测量,仓库不受影响。
三列 `mcpp@2026.8.13.1` / `mcpp@2026.8.13.1+schedule=on` / `mcpp@2026.8.11.3`
把表撑得比 README 的渲染宽度还宽,于是它带着横向滚动条出现 —— 一张五列的对比表,
读者第一眼只看得到两列。
列名改成 `mcpp` / `mcpp +schedule` / `mcpp (released)`,身份挪进脚注:
`mcpp` = mcpp@2026.8.13.1, the build under test ·
`mcpp +schedule` = the same binary with `[build] bmi_schedule = "on"` ·
`mcpp (released)` = mcpp@2026.8.11.3, the published release
顺带把另外两个撑宽度的描述也收窄(`mtime only, content unchanged`、
`a comment added to a hub interface`)—— 「被大量导入」的含义正文里已经讲了。
## ⚠️ 缩短列名会让守卫失效,所以映射是**发出来的**
守卫原来从表头读引擎名去和 JSON 对。列名一短,它就对不上了 —— 而对不上的表现
可能是「静默地少检查几列」。所以 `report.py` 在表格上方发一行
<!-- columns: mcpp=mcpp@2026.8.13.1; mcpp +schedule=...; ... -->
(markdown 渲染时不可见),守卫从那里读映射,而不是自己再存一份。
**没有映射的短列名直接报错**,不会当成「没有这一列」跳过 —— 反向验过:删掉那行
立刻红。
…users look ## 表格 * `mcpp +schedule` → **`mcpp +opt` / `mcpp +优化`**。"schedule" 是**机制的名字**, 第一次看这张表的人无从判断一个「构建调度器」是更快、更慢还是只是不一样。 这一列要说的是「这是那个 opt-in 的提速开关」,它到底开了什么,下一行的图例 有的是地方讲清楚。 * `mcpp (released)` → **`mcpp (old)` / `mcpp (旧版)`** —— 被测的这一版将来也会 发布,"released" 分不出新旧。 * 每个数据格用 code span 包起来:`86.69s · 1.1x`。视口一窄,表格最先做的事就是 把这种格子折成两行,而**数字和它的倍率被拆开会读成两个数**。⚠️ 图例的分支原来是按字面量 `name == "mcpp +schedule"` 匹配的,改名之后这条分支 不再命中,opt-in 那一列**掉进了 else,被描述成「上一个已发布版」** —— 一句完全 错误的话,语气十足地印在那个「重点恰恰是它和第一列是同一个二进制」的列上。 现在按 `"+" in name` 判断,并且图例文本直接用生成出来的列名。 ## 文档 `bmi_schedule` 和 `jobs` 都是这条分支新加的**用户可见 manifest 键**,而 `docs/05-mcpp-toml.md`(用户真正会去查 `[build]` 键的地方)**两个都没有**。 中英两份都补上了: * 明确写出 **`auto` 是默认值,而它目前等于「关」** * 只认 `auto|on|off`,`"ON"`/`"true"` 会被拒 —— 并说清它们不是无害的笔误: 这个值进构建指纹,被拒的拼写以前会选到另一个构建目录(一次全量重建) * 为什么默认关(调度改错是**无声**的),以及**它在哪里没用** (mcpp 本来就跳过级联的那两行) * 实测数字:`cold` 86.7s → 35.7s、`edit-body` 80.9s → 29.8s * gcc/clang/msvc 三种做法各自的理由 守卫相应更新:列名是短名、数据里是完整标签,映射由 `report.py` 发在表格上方的 `<!-- columns: ... -->` 注释里(渲染时不可见),守卫从那里读;**短名没有映射 直接报错**,不会静默少检查一列。
上一次的修法方向错了:把每个格子包成 code span,是为了防止 `86.69s · 1.1x` 被折成两行 —— 但 code span 渲染成**等宽字体**,同样的字符比比例字体更宽, 于是它**加宽了那张它本来要救的表**。防折行的正确做法是**减少列**,不是把格子 变硬。 * 去掉 `what changed` 这一列 —— 它是最宽的一列(「a real edit inside a function body」),而内容永远是同样那五句话。改成表格下面一行紧凑的图例。 * 数据格去掉反引号。 现在是 6 列而不是 7 列,且没有等宽格子。 守卫两处跟着改,而且都改成**不再依赖具体形状**: * 表头只锚定第一列 —— 原来整行匹配 `| scenario | what changed |`,少一列就报 「表格解析不了」,这话是真的但没用:表没问题,是那个模式在描述一个没人承诺 会保持的形状。 * 数值**从右边数** len(engines) 个,而不是固定的 `cells[2:]` —— 否则删一列会让 每个值错位一格,守卫报出来的是「值的个数和表头对不上」,对的抱怨、错的原因。
…ult change) `auto` 仍然等于「关」,对所有人都是。这一行只改 **mcpp 自己怎么被编出来**。 ## 为什么现在做 这个键的处境是循环的:它保持 opt-in「直到在所有平台过完 CI」,而**没有任何东西 在用它**,所以永远攒不出那份证据。mcpp 自己的构建是取得这份证据最便宜的地方 —— 这个调度产出的二进制,接下来要过 83 个单测和整套 e2e,在 Linux(detach-codegen, gcc)、macOS 和 Windows(two-phase,clang)各跑一遍,每个 PR 都跑。⚠️ 这张安全网是必须的,不是修饰:调度出错不会响,它只是**不再重建某个东西**。 已经发生过的那次(bench/README §8b)是 object 边只以 BMI 为输入,于是 restat 清掉 了 object、ninja 在编译器还在跑的时候就退出了 —— 一个**静默缺斤少两的二进制**。 拿产出的二进制去跑它自己的测试套件,才能把这类缺陷变成红 job 而不是坏发布。 ## 本地已验证(自举) schedule: detach-codegen — gcc: publishes the BMI with rename() at ~22% # mcpp:graph=normal;schedule=detach-codegen Finished release [optimized] in 35.40s (基线约 80s) test result ok. 83 passed; 0 failed⚠️ 第一次「验证」是假的:`mcpp build` 走 PATH 上的 xlings shim,解析到 **2026.8.11.3(旧发布版)**,它根本不认识这个键,只打印 `unsupported key 'bmi_schedule' (ignored)` 然后按旧行为编;图里是 `# mcpp:graph=normal`,没有任何调度。必须用自举的二进制才测得到这件事 —— bench 里那条「引擎必须按二进制指名,绝不用裸名」的规则,在这里又咬了一次。 出问题就改回这一行。
Sunrisepeak
force-pushed
the
feat/bench-suite
branch
from
August 15, 2026 05:44
3be343c to
30ec5a0
Compare
两类问题,都在中文文档里。
## 1. `pin` 译成「钉住」
包管理语境下 `pin` 的通用中文是**锁定**;「钉住」是口语直译,而它出现在
「锁定的版本」这种正是要精确的地方。全部统一:
钉住了什么 → 锁定了哪些内容 钉到 → 锁定为
钉住的版本 → 锁定的版本 钉住的工作负载 → 锁定的工作负载
钉死在 → 固定在
## 2. 网络化/情绪化表述
技术文档用陈述句,不用比喻和口语。逐条改写:
是一天时间的去处 → 两行由级联抑制决定
级联是欠着的 → 级联是必需的
它买不到任何东西 → 该键不产生收益
用 allow_failed 糊过去 → 以 allow_failed 豁免
整整丢过三轮 → 曾有三轮完整数据因中断而作废
调度改错是无声的 → 调度错误的表现是静默失效而非报错
CI 去哪了 → 关于 CI
英文侧同样处理(`where the day goes` → `accounts for the ... rows`,
`work it owed` → `work it owes`,`buys nothing` → `yields no improvement`)。
内容与数字一处未动,只改表述。
…a real defect
一个 CI 周期就把它否掉了。`cross-build-test` 的
**windows 宿主 → x86_64-linux-musl** 那个 job 红在 BMI 边:
failed: gcm.cache/mcpp.libs.json.gcm
src/libs/json.cppm:3: fatal error: json.hpp: No such file or directory
即**分离出来的 BMI 边在那个宿主上拿不到 `include_dirs`**。已记为 #425,
含定位到规则的分析(detach 的 BMI 边把整条命令写进 `$out.cmd`,而 windows 上
普通编译边是把 `$local_includes` 放进另一个 rspfile 再 `@` 引用 —— 两条路径对
它的处理本来就不一样)。
⚠️ 这条恰好证明了那条规则的价值,也证明了我上一个提交的证据是不够的:
linux 自举 + 83 个单测 绿
macOS / windows 原生 e2e 绿(clang ⇒ two-phase)
windows 宿主交叉到 linux-musl 红
我在提交信息里写了「本地已验证」,而本地验证覆盖不到这个组合。
「opt-in 直到在**所有平台**过完 CI」说的就是这件事,不是「直到它在我这儿能跑」。
对所有人的默认值 `auto`(= 关)本来就不受影响。mcpp.toml 里那一行换成了
说明为什么不开的注释,修好 #425 之后再开。
…line numbers 排查 `version_req.cppm` 在 gcc 上「只改函数体却全量级联」的真因,顺带把 「模块该怎么写才增量得快」这件事测清楚了。报告: `.agents/docs/2026-08-15-module-edit-granularity.md`。 ## 真因:GCC 的 BMI 记录后续声明的源码行号 | 编辑 | 后续声明移位 | 重编 object | BMI | |---|---|---|---| | 同行替换 | 否 | 1 / 11 | 0 | | 函数体内插一行 | 是 | **10 / 11** | **9** | | 声明之间插一行 | 是 | **10 / 11** | **9** | | 文件末尾追加 | 否 | 1 / 11 | 0 | 基准的 `edit-body` 扰动是**插入**语句,所以级联来自**行号移位**,与被改函数体的 语义无关。**mcpp 没有缺陷** —— BMI 真的不同(`bmi-equal` 判不等价,且同源两编 逐字节相同作对照),restat 正确地拒绝抑制。 ## 顺带测清楚的:三种写法 | 写法 | gcc | clang | |---|---|---| | 单 `.cppm`,声明+定义写一起 | 1 / 11 | **9 / 12** | | 单 `.cppm`,声明与定义**分开写** | 1 / 11 | **9 / 12** | | `.cppm` + `.cpp` 实现单元 | 2 / 12 | **2 / 13** | **在单个 `.cppm` 里分开写声明和定义,对增量构建零收益** —— 边界在翻译单元, 不在文件内的位置。要让「改实现不级联」跨编译器成立,只有把定义放进 `.cpp`。 而 gcc 那两个 1/11 只在**不改行数**时成立(见上表),所以实际开发中同样会级联。 ## 三条被推翻的旧结论,以及一条 fixture 保真度问题 * 「GCC 的 BMI 不含函数体 ⇒ 改函数体不必级联」—— 只对不改行数的编辑成立 * 「`Version::str()` 是成员函数所以进了 BMI」—— 错,成员函数体同行替换不级联 * 「不动 export 声明就没事」—— 错,移位的是声明的**位置**不是内容 *⚠️ **fixture 不保真**:它被扰动的函数是文件里最后一个声明,插入点之后无物可移, 所以 BMI 不变(0.94s),而 mcpp 真实工程同一场景是 80.87s。从 fixture 的 `edit-body` 推真实工程的结论一律不成立。 ## 方法学 这一轮差点得出三个错误结论,三条同一形状 —— **扰动本身没有被验证**: `sed '2a\'` 空追加在本机是空操作;插入的注释把同一行剩下的 `}` 注释掉、代码不成立 而 `object=1` 是构建失败的假象;用同行替换代表「改函数体」。现在每次测量都断言 **行数确实变了**且**构建成功**。 ## 已修正的对外说法 * 根 README 中英两份:`edit-body` 那条不再说「接口确实发生变化」,改为说清级联 来自行号移位 * `bench/SPEC.md`:写明 `edit-body` 按 variant 改的是**不同文件**,`modules` 与 `modules-impl` 的期望结果**相反**,以及 fixture 在这一点上不保真
detach-codegen 的 supervisor 在 Windows 上这样起编译器:
join_command({"cmd.exe", "/c", command})
`join_command` 按 **CreateProcess** 的规则处理:整条编译命令被当成**一个 argv
元素**,外面加引号、里面的 `"` 转义成 `\"`。而 `cmd.exe /c` 不认这套 —— 它的规则
是数原始字符串里的引号。命令里一旦含带引号的路径,cmd 就会解析错,而表现**不是
启动失败**,是编译器少了几个 flag 照常跑起来:
failed: gcm.cache/mcpp.libs.json.gcm
src/libs/json.cppm:3: fatal error: json.hpp: No such file or directory
`-I` 没了,报出来的却是缺头文件。只在 windows 宿主交叉编译到 x86_64-linux-musl
时出现,linux 与三个平台的原生 e2e 都是绿的。
ninja 自己是**原样拼接**(`subprocess-win32.cc`),而这条边的存在意义正是「跑
ninja 本来会跑的那条命令」,所以照做。
另外把**实际执行的命令写进日志首行**(`+ cmd.exe /c ...`)。phase 2 会重放这个
文件,于是远端失败自带确切的调用 —— 没有它,这次连「问题在哪一层」都只能靠猜,
定位就花掉一个 CI 周期。
`mcpp.toml` 的 `bmi_schedule = "on"` 同时恢复:关掉它等于把这个缺陷藏回去,而
它正是开着才被发现的。本地(自举):234 过、83 个单测过。
`.agents/docs/2026-08-15-issues-412-422-analysis.md`。全部**对 HEAD 重新核实**, 不是照抄 —— issue 是 8-11/8-12 写的,分支此后动过。核实过程推翻了三处。 ## 三处对 issue 自身的修正(实测) * **#416**:issue 把「纯 C 的 compat 包多一条 `NEEDED libstdc++.so.6`」归因给 `std.o`。实测 `std.o` 是 1280 字节、定义 1 个符号(`_ZGIW3std`,模块初始化器)、 **0 个未定义符号** —— 它拖不动任何共享库。真正的嫌疑是链接一律用 `$cxx`(g++) 而 `--as-needed` 只用在 `-latomic` 一处。而且本机 `libXau.so.6` 的 NEEDED 里 **根本没有 libstdc++**,头号症状复现不出来。⇒ 先复现再动,判据要拆成两条。 * **#412**:方案建议「补一条 219」,但 **219 已被占用**;应为 235。 * **#418**:`cxxRuntimeTests` 这个名字下有**两个**字段,`BuildConfig` 那个是**活的** (解析+读取俱在),只有 `TargetEntry` 那个是死的。按名字 grep 一把删会删错。 ## 一处让方案变简单的发现 **#422**:std 的缓存身份键里**已经含 `std_build_commands`(实际命令行)**。所以 issue 给的两条路(「让 std 构建遵守 cxx_runtime」或「让缓存按它分键」)做第一条 就白送第二条,不需要单独设计缓存键。真因也定死了:MSVC 构建 std 的命令里 **一个 `/MT` `/MD` 都没有**,用的是 cl 默认。 ## 一处文档承诺了不存在的能力 **#421**:`docs/05-mcpp-toml.md:414` 写着 `defines` 进 P1689 扫描 「which is what makes a macro-guarded `import` resolvable」。**对 mcpp 不成立** —— mcpp 自己的前置扫描器看到条件块里有 `import` 就直接拒,根本走不到宏求值。 这是七条里唯一一个「文档承诺了不存在的能力」,建议优先级最高(改一句话)。 ## 贯穿的观察 七条里有四条本质是同一件事:**写下来的状态与实际行为脱节** —— 过期的 note、 不生效的字段、承诺了不存在能力的文档、自称唯一排序却漏了最关键一项的模型。 它们的共同代价是:读的人据此做决定,而浪费掉的时间不会归因到这里。
….8.15.1 四个 issue 的实现,以及一处 issue 自身的机制描述被推翻。 ## #422 — CRT 模型从不到达 std 模块(比 issue 说的更广) issue 归因给 `cxx_runtime`。核实发现**它根本不参与这个决定**: flags.cppm:588 msvc_base += (linkage == "static") ? " /MT" : " /MD"; 工程 TU 的 CRT 模型来自 **`linkage`**;而构建 std 的命令里 **一个 `/M` 开关都没有**,拿的是 cl 默认(`/MT`)。所以**默认配置就已经不匹配** —— 默认 linkage 是 dynamic ⇒ 工程 `/MD`、std `/MT`。cl 先给 C5050 警告,再在 ucrt 头文件里给出真正的 C2375。不是 host-coupled 独有。 e2e 99 用了 `import std` 且是绿的,因为它的程序太小、碰不到 `free` 那条重定义。 **测试存在,但没走到会失败的形状。** 修法与 `macos_deployment_target` 完全同形 —— `stdmod::ensure_built` 早就为同一类 问题接过一个参数,注释写着「必须与 flags.cppm 给普通 TU 发的一致」。现在: * `dialect` 新增 `dynamicRuntime` 与 `msvc_crt_flag(dialect, staticLinkage)`, **linkage → CRT 模型只在这一处推导**,flags.cppm 与 std 构建共用 * CRT flag 进入 `std_build_commands`,而它本来就是 std 缓存身份的一部分 ⇒ **两种模型不可能再共用一个缓存目录**。issue 给的第二条路(单独设计缓存键) 不需要做,做第一条就白送 单测两条:flag 必须出现在命令里;`/MT` 与 `/MD` 必须产出**不同的命令串** (相同就意味着相同缓存键,也就是这次要消灭的静默背离);GNU 方言必须产出空串。 ## #418 — 一个死字段 + 一个只写不读的观测量 * 删 `TargetEntry::cxxRuntimeTests`(解析处不读、应用处不用)。⚠️ 同名的 `BuildConfig::cxxRuntimeTests` 是**活的**,按名字 grep 一把删会删错。 * `[target.<triple>]` 此前**没有未知键检查**(只有 `[targets.<name>]` 有), 所以写在那里的键是被静默吞掉的。补上,现在报: `[target.x86_64-linux-gnu] has unsupported key 'cxx_runtime_tests' (ignored)` * `CompileFlags::contractByRole` 接进 `resolution.json` 的 `runtime.cxx_runtime_by_role`。实测输出里 `shared-library` 是 `toolchain-coupled` 而其余是 `self-contained` —— 正是 #414 之后用户会问 「我这个 .so 到底拿到哪一档」的那个观测量,此前只能靠 readelf 猜。 ## #421-A / #412 — 写下来的状态与实际行为脱节 * `docs/05-mcpp-toml.md` 中英两份都写着 `defines` 进 P1689 扫描 「这正是被宏保护的 `import` 能被解析的前提」。**对 mcpp 不成立** —— mcpp 自己的词法预扫描看到条件块里有 `import` 就直接拒,根本走不到宏求值。 改成说明真实行为,并给出替代写法(条件放在 GMF 的 `#include` 上)。 * 删 `toolchain default msvc` 里那句「native MSVC 尚不支持」—— 它是假的(e2e 99 就是一次完整的原生模块构建),实际效果是劝退一个能用的功能。 * 删 `95_msvc_system_toolchain.sh` 头注释里与自身断言矛盾的那行。 ## 顺带 * 取消跟踪 `bench/tools/__pycache__`,并加进 `.gitignore` * 删掉误入 PR 的 `bench/results/*.superseded-225002/`(中止那次跑的残留)
… can be compared
`runtime_search` 自称是搜索顺序的唯一决定处 ——
Search order = decreasing immutability. This is the one invariant in this
module, and it is not stylistic.
—— 而**最关键的那个目录不在它的模型里**。`$ORIGIN` 由 `shared_library_link_flags`
在另一条 per-unit 通道发出,从不进闭包。#414 修的正是它排错了位置:farm 曾排在
`$ORIGIN` 前面,产物链接了一份 libX11、运行期加载了另一份。
直接后果是 `resolution.json` 与 DT_RPATH **天生不可逐项比对**,e2e 219 只能退而
断言「最后一个**绝对路径**条目」—— 而那个放宽本身对它命名的那个缺陷是盲的。
## 改动
* `Origin` 新增 `Artifact` 档,`rank()` 置于 `Package` 与 `SubosFarm` 之间
* ⚠️ `is_machine_local(Artifact)` 返回 **false** —— `$ORIGIN` 随产物走,到哪台
机器都是同一个含义;`pack` 恰恰是把别的条目**改写成**这个形式的。标成
machine-local 会让 `pack` 拒绝它唯一想要的那一项
* `runtime_search_closure()` 加入产物输出目录
⚠️ **并且只在真的会发出 `$ORIGIN` 时才记。** 它是按共享库消费者发的,一个不产
共享库的工程根本没有。无条件记录只会把这个缺陷换成它的镜像 —— 记录里多一项、
产物里没有,219 照样需要例外。两个方向都验了。
## 判据(#415 原文)
> e2e 219 能把「记录的闭包」与「产物的 DT_RPATH」**逐项**比对并通过,
> 而不必对 `$ORIGIN` 做任何过滤或例外。
219 新增 invariant 2b,实测输出:
closure == DT_RPATH, 4 entries, item by item
记录: payload → payload → artifact → subos_farm
产物: payload → payload → $ORIGIN → subos_farm
`artifact` 是闭包对 `$ORIGIN` 的拼法,除此之外不过滤、不翻译、不豁免。
**重档不做**:让闭包成为唯一的 rpath 生产者要给它引入 per-unit 概念,
改动量与风险明显更大,而收益只是消灭「两个生产者」。这次关的是**记录**的口子。
本地:83 个单测过,219 过。
…ort std` `obj/std.o` 此前的追加条件只是「这条工具链有预建的 std 模块」,与链接单元自身 是否需要无关。于是一个一行 `import std` 都没有的单元也会被链进模块的全局初始化器。⚠️ **判据必须是传递的。** 单元自己不写 `import std`,但它 import 的模块写了, 链接时同样需要 —— 只看本单元源码就是 #405 那个「边存在但没有任何人依赖它」的 同一形状,而且错的方向会把构建搞坏。现在按模块图算可达性。 写错的方向是安全的:`std.o` 只定义一个符号(`_ZGIW3std`,实测),而 import 的 TU 会引用它,所以漏判得到的是**链接期 undefined** —— 响亮的失败,不是静默错误。 真正可能悄悄退化的是反向(多链),所以测试两个方向都断言。 新增 `tests/e2e/235_std_object_only_when_needed.sh`: * 完全不用 std 的工程 → 图里**没有** std.o * 通过 `main → trans.b → trans.a → std` **间接**到达的工程 → 图里有,且**跑得起来** (`greet()` 返回 std::string,初始化器没跑是崩溃而不是链接错) ##⚠️ 这条 issue 的另一半不成立,已拆出去 issue 的判据是「纯 C 的 compat 包 `readelf -d` 里没有 libstdc++.so.6」。 按方案先复现,结果是**症状真、归因假**: 纯 C 共享库:NEEDED libstdc++.so.6 ← 在 build.ninja 里 obj/std.o 出现次数:0 ← 根本没链 产物里 _ZGIW3std 符号数:0 真因是链接一律走 `$cxx`(g++,隐式 `-lstdc++`),而 `--as-needed` 全仓只用在 `-latomic` 一处。已另开 #426,并把实测贴回 #416。本提交只解决 std.o 那一半。
…not one per artifact 全新 MCPP_HOME 的第一次构建里,`binding.loader` 与 `binding.libraryDirs` 都是空的 (第二次构建就有了),而 rule B 把这件事**按产物**报告 —— 每个产物两条,一个图形 工程 13 个产物就是 26 行同一句话,出现在用户的**第一次**构建上。 这是 binding 自身的属性,不是任何产物的属性。改成进入产物循环前说**一次**, 并说清「这在全新 MCPP_HOME 的首次构建上是预期的,第二次构建即可解决」。 rule B 仍然照常跑:它还有别的输入(PT_INTERP 同一性),整条跳过是拿噪声换盲区。⚠️ **真因未定位,本提交不动时序。** issue 自己写了「精确接缝还需要一次探针确认 —— 不要照着这个推理直接改」。当前证据只支持:`search_dirs` 有值而 `loader` / `library_dirs` 没有,说明填充它们的不是同一段代码或同一时刻。判据的另一半 (「要么 rule B 给出真实判决」)留给探针之后。⚠️ 并且要写在这里:**即使 rule B 完全正常,它也抓不到 #414 那个崩溃** —— `elf_runtime.cppm` 只比对 libc / PT_INTERP 的同一性,不管其它 SONAME 解析到谁。 这条修复不能替代 #414 的顺序修复,也不是它的兜底。
…hain `217_module_extensions.sh` 声明 `# requires: gcc`,而 `run_all.sh` **刻意**不在 Windows / macOS 上授予 `gcc`(那边的 g++ 分别是 MinGW 和 Apple Clang)。所以 `module_extensions` **只在 Linux 上被测过**,而「声明 `.ixx` 然后在 MSVC 上构建」 —— 一个从 MSVC 工程迁过来的人实际会走的路 —— 从来没有任何测试走过。 它推理上必然通(`.ixx` 是 cl 自己的约定,而 mcpp 无条件发 `/interface /TP`), 但推理不是测量。这与 #411 指出 #272 的是同一形状:`# requires: elf gcc` 让 Clang / MSVC 两条腿从未被走到。 新测试**不给自己授予任何能力**,用各平台的默认工具链(Windows→msvc、 macOS→llvm、Linux→gcc),因此三个平台都会跑。⚠️ 断言不止于「构建退出 0」。Clang 对不认识的 `.ixx` 是**当链接输入**、 警告、退出 0 且不产 BMI —— 所以测试要求那个模块**真的产出了 BMI** (`.gcm`/`.pcm`/`.ifc`)并且二进制**跑得出预期输出**。零退出码在这里证明不了编译。 编号:issue 建议 219,但 219 已被 `219_runtime_search_farm_is_last.sh` 占用, 235 被本轮的 std.o 测试占用,故为 236。
…local status 两条新不变量,第二条如果写反比它关掉的那个缺口更糟: * **rank 位置**:`Package < Artifact < SubosFarm`,且 `Payload < Artifact`。 这正是 #414 的那个顺序 —— farm 排在 `$ORIGIN` 前面时,产物链接了一份 libX11、 运行期加载了另一份;而钉住的 payload 仍然赢,因为它是唯一没人会重新指向的。 * **`is_machine_local(Artifact) == false`**:`$ORIGIN` 由加载器相对产物解析, 产物拷到哪台机器含义都一样 —— `pack` 恰恰是把**别的**条目改写成这个形式的。 标成 machine-local 会让 pack 拒绝它唯一想要产出的那一项。 `to_string(Artifact) == "artifact"` 也钉住:那些字符串是 `resolution.json` 里 `origin` 字段的取值,改了就是改协议。 顺带核过:`rank()` 全仓只用于相对比较,`resolution.json` 写的是字符串而非数值, 所以这次重编号(SubosFarm 2→3、HostDefault 3→4)不影响任何持久化数据。
…itional channel
自我 review 抓到的假阳性 —— 我上一个提交引入的。
`[target.<triple>]` 的未知键检查按「已知键清单」判定,而 TOML 会把
`[target.'cfg(unix)'.dependencies]` 呈现为 `[target.'cfg(unix)']` 的一个**键**。
于是一个文档化、且有 e2e 覆盖(195)的功能被报成未知键:
warning: [target.cfg(unix)] has unsupported key 'dependencies' (ignored)
⚠️ 硬列清单必然再漂。合法的嵌套子表现在是五个(`build` / `dependencies` /
`dev-dependencies` / `build-dependencies` / `feature-deps`),以后还会加 ——
而「conditional 读取方自己维护一份 [build] 的子集然后悄悄落后」正是
`ConditionalConfig` 注释里记录过**两次**的事(#258、#359)。我刚好第三次踩上。
改成**只检查标量键**:表一律跳过。这样新增 conditional 段不需要改这里,而被报告的
那个情况(一个什么都不做的标量)照样抓得住。这不是放宽,是把耦合去掉。
单测同时钉住两个方向:`cxx_runtime_tests` 必须报,`dependencies` / `build`
必须不报。
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,而「取目录序最后一个」只是碰巧对。