Skip to content

Commit 48de5d2

Browse files
committed
feat(bench): 构建引擎基准套件 —— 把一次性脚本变成跨平台、可扩展的测量设施 (2026.8.12.1)
起因是一次实测: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 parent 8219584 commit 48de5d2

57 files changed

Lines changed: 4984 additions & 2 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: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
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)` 是必要的:并非所有引擎都支持 C++20 模块(bazel 的模块支持仍很有限),此时应报 `unavailable` 并说明,而不是硬跑出一个误导性的数字。
116+
117+
---
118+
119+
## 4. Fixture:同一工程的两种形态
120+
121+
**生成而非手写。** 手写两份"等价"的代码,几乎必然在某处不等价,而那正是被测量的东西。
122+
123+
生成器参数:单元数 `N`、依赖深度 `D`、每单元代码量 `L`。产出:
124+
125+
```
126+
fixtures/synth-<N>x<D>/
127+
headers/ include/unit_k.hpp + src/unit_k.cpp (传统头文件 + 分离实现)
128+
modules/ src/unit_k.cppm (模块接口单元)
129+
modules-impl/ src/unit_k.cppm + src/unit_k_impl.cpp (接口 + 实现单元 ★)
130+
```
131+
132+
第三种变体直接对应上一轮分析的 **F4 / §6.3**:把实现移出接口单元。有了它,"改一行函数体"的代价差异就是**测出来的**,不是推断的。
133+
134+
同时保留 `self` fixture —— 即 mcpp 自身(137 模块),因为真实工程的依赖形状不是合成器能编出来的。
135+
136+
---
137+
138+
## 5. 场景矩阵
139+
140+
| 维度 | 取值 |
141+
|---|---|
142+
| engine | mcpp, mcpp-opt(优化后), cmake, xmake, meson, bazel |
143+
| variant | headers, modules, modules-impl |
144+
| profile | release, debug |
145+
| scenario | cold, noop, touch-hub, edit-body, touch-leaf |
146+
| compiler | gcc, clang, msvc(平台可用者) |
147+
148+
**`mcpp` vs `mcpp-opt`**:同一份源码、同一编译器,区别只在是否启用上一轮验证过的优化(BMI 时间戳归一 + BMI 落盘即释放)。这让"优化前后"成为矩阵里的**一个正交维度**,而不是另做一次实验。
149+
150+
矩阵是笛卡尔积但**不是全跑**:`spec.cppm` 用显式的 include/exclude 规则裁剪,CI 默认跑一个小集合,`workflow_dispatch` 可放开。
151+
152+
---
153+
154+
## 6. 平台拆分
155+
156+
采用 **xlings `src/platform/*.cppm` 的既定约定**:模块分区 + 整文件宏控。
157+
158+
| 关注点 | 位置 |
159+
|---|---|
160+
| 进程启动 + 墙钟计时 + 退出码 | `platform/posix.cppm``platform/windows.cppm` |
161+
| CPU 型号 / 核数 / 异构判定 | 同上 |
162+
| 环境变量读写 | 同上(`setenv` vs `SetEnvironmentVariableA`) |
163+
| 组装与可移植部分(std::filesystem) | 主模块 `platform.cppm` |
164+
165+
每个分区把**整个 body** 包在一个宏里,非目标平台**不导出任何符号**;两侧导出同名函数,于是任一构建中每个名字只有一份定义,**编译期自动选中**——不需要 stub,也不需要 `if constexpr` 派发。主模块 `export import :posix; :windows;` 后用 `export using` 提升。
166+
167+
结果:`#if defined(_WIN32)` 只出现在这两个分区里,runner / engines / protocol / fixture 全部零平台条件。
168+
169+
---
170+
171+
## 7. CI
172+
173+
新增 `.github/workflows/bench.yml`:
174+
175+
- `on: workflow_dispatch`(**只手动触发** —— 基准是重活,不该挂在每个 PR 上)
176+
- 输入:`engines``scenarios``variants``fixture_size``runs`
177+
- 矩阵:`ubuntu-24.04` × `macos-14` × `windows-2022`,各自的默认工具链
178+
- 产出:上传 `results/*.json` 为 artifact
179+
- **不设阈值断言**:基准用于观察趋势,不用于 gate。把噪声变成红叉只会让人忽略它。
180+
181+
---
182+
183+
## 8. 实施阶段
184+
185+
| 阶段 | 内容 | 完成判据 |
186+
|---|---|---|
187+
| **A** | `bench/` 骨架:protocol + platform + runner + registry + mcpp 引擎 | 三平台能跑 `bench --engine mcpp --scenario cold --fixture self` 并产出合法 JSON |
188+
| **B** | fixture 生成器(headers / modules / modules-impl) | 三个变体编译产物行为一致(同一断言集通过) |
189+
| **C** | cmake / xmake / meson / bazel 适配器 | 缺失工具报 `unavailable` 且带原因,不是崩溃 |
190+
| **D** | 构建剖析并入 `--analyze` + 结果合并 | 关键路径与 Python 实现交叉验证一致 |
191+
| **E** | `bench.yml` CI | 手动触发在三平台跑通并上传 artifact |
192+
| **F** | 文档 / 测试 / 版本 / PR / 验证 / 合入 / 发布 | 见目标清单 |
193+
194+
**顺序是有依赖的**:A 定协议,之后所有阶段都写向它;B 之前 C 无处可跑;D 依赖 A 的结果格式。
195+
196+
---
197+
198+
## 9. 明确不做
199+
200+
- **不把基准挂进 PR CI**。噪声会淹没信号。
201+
- **不设性能回归阈值**。宿主差异(异构 CPU、云厂商邻居噪声)远大于多数真实回归。
202+
- **不重新实现计时统计学**。中位数 + min/max 足够;不做置信区间,因为样本量本来就小。
203+
- **不追求引擎功能对等**。bazel 不支持模块就报 unavailable —— 强行凑一个数字比没有数字更糟。

0 commit comments

Comments
 (0)