Skip to content

Commit c459cf2

Browse files
authored
feat(bench): cross-platform benchmark suite, and the defects it exposed (2026.8.15.1)
一个跨平台、可扩展的构建引擎基准设施,以及**用它跑出来的、和 review 它时发现的** 一批缺陷修复。版本 2026.8.15.1。 ## 1. bench/ —— 把一次性脚本变成测量设施 C++23 写、由 mcpp 构建,所以三个平台跑法一致(它替换掉的 shell 脚本只能在 Linux 跑)。 * **可断续**:测量单元 = `工程·variant·场景·引擎·轮次`,测完即 append+flush; 整份配置一个指纹,落 `.mbench/<指纹>/`。同配置命中续跑,改配置换目录。 实测杀掉后记录 12 个点,重跑跳过这 12 个、补完剩下 12 个。 * **接口/实现分离**:19 个模块单元全部拆成 `.cppm` 声明 + `.cpp` 定义。 判据是行为不变:拿重构前的二进制对照,`--list` 逐字节相同、**42 个生成文件 逐字节相同**、一次真实测量的 cell 结构完全相同。 * **Linux 标准数据集**:696 个测量点、每格 3 轮。未跑的格子、macOS、Windows 在表里一律标 `-`(未测),不留给读者当成结果。 * **CI 里的 bench matrix 已删**:它 10 格 32 条外部引擎臂里**豁免了 12 条** (xmake 被豁免的比在测的还多)而 job 是绿的。改为本地 `run-standard.sh`。 ## 2. 数据本身推翻了一个已发布的说法 新表有 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 级联抑制以前并没有生效,是这个分支让它真正工作的。根 README 中英两份的表格现在 由 `report.py --headline` 从**同一份报告**生成,守卫核 50 个中位数。 ## 3. bmi_schedule:开着它跑 CI,修到全绿 `[build] bmi_schedule = "on"` 打开后被 CI 在一个周期内否掉两次,两次都是真缺陷: * **默认配置下并发完全没有上限** —— 模块自己写着「上限是信号量」,而没给 `--jobs` 时 `sched_cap = 0`,信号量被禁用,ninja 有多快就起多少个编译器 * **`.mcpp-sched` 令牌没有任何人回收** —— 对构建按一次 Ctrl-C 就永久少一个槽位, 攒够 cap 个之后下一次构建**无输出卡死**(e2e 实测卡满 600s) * **phase 1 等 BMI 无上限**(phase 2 反而有界)、**supervisor 有一条退出路径不写 `.rc`** * **`cmd.exe /c` 不用 CreateProcess 的引号规则** —— Windows 宿主交叉构建时编译器 少了 `-I`,报成「找不到头文件」 ## 4. 七个 issue 的核实与修复 核实过程推翻了 issue 自己的三处说法(详见 `.agents/docs/2026-08-15-issues-412-422-analysis.md`): * **#422**:归因给 `cxx_runtime` 是错的 —— CRT 模型来自 `linkage`,而构建 std 的 命令里**一个 `/M` 开关都没有**。所以**默认配置就已经不匹配**,不是 host-coupled 独有。修法与 `macos_deployment_target` 同形,且 CRT flag 进入 `std_build_commands` ⇒ 缓存键自动分叉。 * **#416**:归因给 `std.o` 是错的 —— 实测 `std.o` 有 **0 个未定义符号**,拖不动 任何库;纯 C 库的 `libstdc++.so.6` 来自链接一律用 g++。已拆出 #426。 本 PR 只做 std.o 按需链接(带传递可达性)。 * **#418**:`cxxRuntimeTests` 有**两个**同名字段,只有 `TargetEntry` 那个是死的。 * **#415**:`$ORIGIN` 进闭包,e2e 219 现在能**逐项**比对 (`closure == DT_RPATH, 4 entries, item by item`),不需要任何例外。 * **#417**:binding 无法求值是**一个事实**,不是每个产物一条(26 行 → 1 行)。 真因未定位,不动时序。 * **#421**:文档承诺了一个不存在的能力(宏保护的 `import` 其实被前置扫描直接拒), 中英两份都改对。 * **#412**:删掉一句劝退用户的假 note、一条与自身断言矛盾的注释,并补上 `module_extensions` 在**非 gcc 平台**的覆盖(此前只在 Linux 测过)。 ## 5. 新增测试 `234`(bmi_schedule 端到端 + 陈旧令牌)、`235`(std.o 按需链接,含传递性)、 `236`(module_extensions 走各平台默认工具链)、`233` 的多处守卫加固, 以及单测若干(MSVC CRT 单一真源、`Origin::Artifact` 的 rank 与 `is_machine_local`、per-target 未知标量键)。 上游缺陷开了 #424(clang 两条)、#425(已修)、#426
1 parent 8219584 commit c459cf2

188 files changed

Lines changed: 33456 additions & 490 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
# `bench/` 构建引擎基准套件 —— 架构与实施计划
2+
3+
> 2026-08-12
4+
> 前置分析:[2026-08-12-modular-build-performance-deep-analysis.md](./2026-08-12-modular-build-performance-deep-analysis.md)
5+
> 目标:把一次性的对比脚本,变成一套**可复用、跨平台、可扩展**的构建引擎基准。
6+
7+
---
8+
9+
## 0. 为什么要重做一遍
10+
11+
上一轮分析用的一次性脚本(bash + hyperfine)能回答"mcpp 和 xmake 谁快",但它有四个结构性缺陷,直接决定了它不能长期用下去:
12+
13+
| 缺陷 | 后果 |
14+
|---|---|
15+
| 只支持 2 个引擎,加第 3 个要改 `run.sh` 主体 | 每加一个对比对象都动核心逻辑 |
16+
| bash + hyperfine | **Windows 上跑不了**;而 mcpp 是三平台产品 |
17+
| 被测对象只有 mcpp 自己 | 无法回答"模块化 vs 头文件"这个真正的问题 |
18+
| 结果是 TSV,字段随手加 | 跨机器/跨时间的数据无法可靠合并 |
19+
20+
新套件按四个角度设计:**优雅(加引擎=加一个文件)、架构稳定(协议与实现解耦)、兼容(旧数据可读)、跨平台(不依赖 shell)**
21+
22+
---
23+
24+
## 1. 顶层结构
25+
26+
```
27+
bench/ ← 顶层目录,与 src/ tests/ docs/ 平级
28+
README.md 基准规范(可复用的那份文档)
29+
mcpp.toml 基准工具本身就是一个 mcpp 工程
30+
src/
31+
main.cpp
32+
protocol.cppm ★ 协议:结果 schema / 版本 / 序列化
33+
spec.cppm 矩阵与场景定义(数据,不是代码)
34+
runner.cppm 计时循环:预热、重复、中位数
35+
registry.cppm 引擎注册表
36+
engines/
37+
engine.cppm 适配器契约
38+
mcpp.cppm cmake.cppm xmake.cppm meson.cppm bazel.cppm
39+
fixture/
40+
generate.cppm 同一工程 → 头文件版 / 模块版
41+
emit_buildfiles.cppm 为每个引擎生成构建描述
42+
analysis/
43+
ninjalog.cppm graph.cppm report.cppm 构建剖析(--analyze)
44+
platform.cppm 门面(主模块,export import 各分区)
45+
platform/
46+
posix.cppm 分区:整文件宏控,非 POSIX 上不导出任何符号
47+
windows.cppm 分区:同上
48+
results/ 结果 + NOTES.md
49+
```
50+
51+
**为什么基准工具本身用 mcpp 写**:它要在 Linux/macOS/Windows 上跑同一套逻辑。bash 在 Windows 上不可用,hyperfine 需要额外安装,而 mcpp 是本仓库必然存在的东西。**用 mcpp 构建 mcpp 的基准工具,顺带也是一次 dogfooding。**
52+
53+
---
54+
55+
## 2. 协议模块(`bench.protocol`)—— 架构稳定性的锚点
56+
57+
这是整套设计里唯一"必须先定、之后不能随便改"的东西。
58+
59+
```cpp
60+
export module bench.protocol;
61+
62+
// 结果 schema 的版本。字段增删必须动它,读取侧据此决定兼容策略。
63+
export inline constexpr int kProtocolVersion = 1;
64+
65+
export struct HostInfo { // 结果只有配上宿主才有意义
66+
std::string os, arch, cpu_model;
67+
int logical_cores{}, physical_cores{};
68+
bool heterogeneous{}; // 13900K 的 8P+16E 不能当 24 个同构核读
69+
std::uint64_t ram_bytes{};
70+
};
71+
72+
export struct CellKey { // 一个测量单元的完整坐标
73+
std::string engine, compiler, profile, scenario, fixture, variant;
74+
};
75+
76+
export struct Sample { double wall_s{}; int exit_code{}; };
77+
78+
export struct CellResult {
79+
CellKey key;
80+
std::vector<Sample> samples;
81+
double median_s{}, min_s{}, max_s{};
82+
std::string status; // ok | failed | skipped | unavailable
83+
std::string note; // 失败或跳过的原因,必填
84+
};
85+
```
86+
87+
**三条不变量**,写死在协议里:
88+
89+
1. **失败不得伪装成数据。** `status` 与 `median_s` 是两个字段;上一轮 `run.sh` 把失败写成 `0.000s`,就是因为没有这一层。
90+
2. **跳过必须带原因。** "bazel 不在这台机器上"和"bazel 跑失败了"是完全不同的结论。
91+
3. **宿主信息与结果同生共死。** 单独一个数字没有意义。
92+
93+
序列化为 JSON,字段名即上面的名字,顶层带 `protocol_version`。
94+
95+
---
96+
97+
## 3. 引擎适配器契约
98+
99+
```cpp
100+
export struct Engine {
101+
virtual ~Engine() = default;
102+
virtual std::string_view name() const = 0;
103+
// 这台机器上有没有?没有就 unavailable,不是 failed。
104+
virtual Availability probe() const = 0;
105+
// 是否支持这个 fixture 变体(headers / modules)
106+
virtual bool supports(Variant) const = 0;
107+
virtual Result configure(const Job&) const = 0;
108+
virtual Result build(const Job&) const = 0;
109+
virtual Result clean(const Job&) const = 0;
110+
};
111+
```
112+
113+
**加一个引擎 = 新增一个 `engines/<name>.cppm` + 在 `registry.cppm` 注册一行。** 不动 runner、不动协议、不动 CI。
114+
115+
`supports(Variant, compiler)` 是必要的,而且**编译器是这个问题的一部分** —— 实测:bazel 9.2 + rules_cc 0.2.22
116+
配 clang 能构建 C++20 模块,配 gcc 则死在它自己的扫描器里(`aggregate-ddi: Invalid JSON string`,
117+
它解析不了 GCC 的 P1689 输出);meson 1.10.2 两个编译器都不行(`module 'fx.a' not found`)。
118+
所以"bazel 支不支持模块"没有脱离具体运行的答案。不支持时报 `unavailable` **并附上得出该结论的那次测量**,
119+
而不是硬跑出一个误导性的数字。
120+
121+
---
122+
123+
## 4. Fixture:同一工程的两种形态
124+
125+
**生成而非手写。** 手写两份"等价"的代码,几乎必然在某处不等价,而那正是被测量的东西。
126+
127+
生成器参数:单元数 `N`、依赖深度 `D`、每单元代码量 `L`。产出:
128+
129+
```
130+
fixtures/synth-<N>x<D>/
131+
headers/ include/unit_k.hpp + src/unit_k.cpp (传统头文件 + 分离实现)
132+
modules/ src/unit_k.cppm (模块接口单元)
133+
modules-impl/ src/unit_k.cppm + src/unit_k_impl.cpp (接口 + 实现单元 ★)
134+
```
135+
136+
第三种变体直接对应上一轮分析的 **F4 / §6.3**:把实现移出接口单元。有了它,"改一行函数体"的代价差异就是**测出来的**,不是推断的。
137+
138+
同时提供 **`--project <dir>` 模式**:直接就地测量一个已存在的工程(mcpp 自身即基础用例),因为真实工程的依赖形状不是合成器能编出来的。该模式下 variant 轴坍缩为 `native`,并且 `edit-body` 会在测量前后**逐字节保存并恢复**被改的源文件 —— 包括构建失败的路径,那正是遗留改动最容易被忽略的时候。
139+
140+
---
141+
142+
## 5. 场景矩阵
143+
144+
| 维度 | 取值 |
145+
|---|---|
146+
| engine | `mcpp=<binary>`(可给多个,自动按版本标注)、cmake、xmake、meson、bazel |
147+
| variant | headers, modules, modules-impl |
148+
| profile | release, debug |
149+
| scenario | cold, noop, touch-hub, edit-body, touch-leaf |
150+
| compiler | gcc, clang, msvc(平台可用者) |
151+
152+
**"优化前后"用两个真实二进制表达,不用模拟。** `--engines mcpp=<旧>,mcpp=<新>` 会注册两个引擎,各自向自己的二进制询问版本并据此标注(`mcpp@2026.8.11.3` / `mcpp@2026.8.12.1`)。
153+
154+
早期设计里有一个 `mcpp-opt` 引擎,靠在构建前后设 `SOURCE_DATE_EPOCH`**模拟**优化。已删除:**在 harness 里模拟一个改动,测的是 harness 对该改动的理解**,而且一旦真实实现与之分叉,它会静默地不再跟踪。优化属于 mcpp,基准测的是二进制。
155+
156+
矩阵是笛卡尔积但**不是全跑**:`spec.cppm` 用显式的 include/exclude 规则裁剪,CI 默认跑一个小集合,`workflow_dispatch` 可放开。
157+
158+
---
159+
160+
## 6. 平台拆分
161+
162+
采用 **xlings `src/platform/*.cppm` 的既定约定**:模块分区 + 整文件宏控。
163+
164+
| 关注点 | 位置 |
165+
|---|---|
166+
| 进程启动 + 墙钟计时 + 退出码 | `platform/posix.cppm``platform/windows.cppm` |
167+
| CPU 型号 / 核数 / 异构判定 | 同上 |
168+
| 环境变量读写 | 同上(`setenv` vs `SetEnvironmentVariableA`) |
169+
| 组装与可移植部分(std::filesystem) | 主模块 `platform.cppm` |
170+
171+
每个分区把**整个 body** 包在一个宏里,非目标平台**不导出任何符号**;两侧导出同名函数,于是任一构建中每个名字只有一份定义,**编译期自动选中**——不需要 stub,也不需要 `if constexpr` 派发。主模块 `export import :posix; :windows;` 后用 `export using` 提升。
172+
173+
结果:`#if defined(_WIN32)` 只出现在这两个分区里,runner / engines / protocol / fixture 全部零平台条件。
174+
175+
---
176+
177+
## 7. CI
178+
179+
新增 `.github/workflows/bench.yml`:
180+
181+
- `on: workflow_dispatch`(**只手动触发** —— 基准是重活,不该挂在每个 PR 上)
182+
- 输入:`engines``scenarios``variants``fixture_size``runs`
183+
- 矩阵:`ubuntu-24.04` × `macos-14` × `windows-2022`,各自的默认工具链
184+
- 产出:上传 `results/*.json` 为 artifact
185+
- **不设阈值断言**:基准用于观察趋势,不用于 gate。把噪声变成红叉只会让人忽略它。
186+
187+
---
188+
189+
## 8. 实施阶段
190+
191+
| 阶段 | 内容 | 完成判据 |
192+
|---|---|---|
193+
| **A** | `bench/` 骨架:protocol + platform + runner + registry + mcpp 引擎 | 三平台能跑 `bench --engine mcpp --scenario cold --fixture self` 并产出合法 JSON |
194+
| **B** | fixture 生成器(headers / modules / modules-impl) | 三个变体编译产物行为一致(同一断言集通过) |
195+
| **C** | cmake / xmake / meson / bazel 适配器 | 缺失工具报 `unavailable` 且带原因,不是崩溃 |
196+
| **D** | 构建剖析并入 `--analyze` + 结果合并 | 关键路径与 Python 实现交叉验证一致 |
197+
| **E** | `bench.yml` CI | 手动触发在三平台跑通并上传 artifact |
198+
| **F** | 文档 / 测试 / 版本 / PR / 验证 / 合入 / 发布 | 见目标清单 |
199+
200+
**顺序是有依赖的**:A 定协议,之后所有阶段都写向它;B 之前 C 无处可跑;D 依赖 A 的结果格式。
201+
202+
---
203+
204+
## 9. 明确不做
205+
206+
- **不把基准挂进 PR CI**。噪声会淹没信号。
207+
- **不设性能回归阈值**。宿主差异(异构 CPU、云厂商邻居噪声)远大于多数真实回归。
208+
- **不重新实现计时统计学**。中位数 + min/max 足够;不做置信区间,因为样本量本来就小。
209+
- **不追求引擎功能对等**。引擎跑不了某个变体就报 unavailable —— 强行凑一个数字比没有数字更糟。
210+
但"跑不了"必须是**测出来的**,不是假设的:最初这里写死了 `bazel supports(modules) = false`,
211+
而实际上加上 `module_interfaces` + `--experimental_cpp_modules --features=cpp_modules` 之后,
212+
bazel 配 clang 是能构建并运行模块程序的。写死的能力判断会把一整列真实数据变成空白。

0 commit comments

Comments
 (0)