2026-08-12 前置分析:2026-08-12-modular-build-performance-deep-analysis.md 目标:把一次性的对比脚本,变成一套可复用、跨平台、可扩展的构建引擎基准。
上一轮分析用的一次性脚本(bash + hyperfine)能回答"mcpp 和 xmake 谁快",但它有四个结构性缺陷,直接决定了它不能长期用下去:
| 缺陷 | 后果 |
|---|---|
只支持 2 个引擎,加第 3 个要改 run.sh 主体 |
每加一个对比对象都动核心逻辑 |
| bash + hyperfine | Windows 上跑不了;而 mcpp 是三平台产品 |
| 被测对象只有 mcpp 自己 | 无法回答"模块化 vs 头文件"这个真正的问题 |
| 结果是 TSV,字段随手加 | 跨机器/跨时间的数据无法可靠合并 |
新套件按四个角度设计:优雅(加引擎=加一个文件)、架构稳定(协议与实现解耦)、兼容(旧数据可读)、跨平台(不依赖 shell)。
bench/ ← 顶层目录,与 src/ tests/ docs/ 平级
README.md 基准规范(可复用的那份文档)
mcpp.toml 基准工具本身就是一个 mcpp 工程
src/
main.cpp
protocol.cppm ★ 协议:结果 schema / 版本 / 序列化
spec.cppm 矩阵与场景定义(数据,不是代码)
runner.cppm 计时循环:预热、重复、中位数
registry.cppm 引擎注册表
engines/
engine.cppm 适配器契约
mcpp.cppm cmake.cppm xmake.cppm meson.cppm bazel.cppm
fixture/
generate.cppm 同一工程 → 头文件版 / 模块版
emit_buildfiles.cppm 为每个引擎生成构建描述
analysis/
ninjalog.cppm graph.cppm report.cppm 构建剖析(--analyze)
platform.cppm 门面(主模块,export import 各分区)
platform/
posix.cppm 分区:整文件宏控,非 POSIX 上不导出任何符号
windows.cppm 分区:同上
results/ 结果 + NOTES.md
为什么基准工具本身用 mcpp 写:它要在 Linux/macOS/Windows 上跑同一套逻辑。bash 在 Windows 上不可用,hyperfine 需要额外安装,而 mcpp 是本仓库必然存在的东西。用 mcpp 构建 mcpp 的基准工具,顺带也是一次 dogfooding。
这是整套设计里唯一"必须先定、之后不能随便改"的东西。
export module bench.protocol;
// 结果 schema 的版本。字段增删必须动它,读取侧据此决定兼容策略。
export inline constexpr int kProtocolVersion = 1;
export struct HostInfo { // 结果只有配上宿主才有意义
std::string os, arch, cpu_model;
int logical_cores{}, physical_cores{};
bool heterogeneous{}; // 13900K 的 8P+16E 不能当 24 个同构核读
std::uint64_t ram_bytes{};
};
export struct CellKey { // 一个测量单元的完整坐标
std::string engine, compiler, profile, scenario, fixture, variant;
};
export struct Sample { double wall_s{}; int exit_code{}; };
export struct CellResult {
CellKey key;
std::vector<Sample> samples;
double median_s{}, min_s{}, max_s{};
std::string status; // ok | failed | skipped | unavailable
std::string note; // 失败或跳过的原因,必填
};三条不变量,写死在协议里:
- 失败不得伪装成数据。
status与median_s是两个字段;上一轮run.sh把失败写成0.000s,就是因为没有这一层。 - 跳过必须带原因。 "bazel 不在这台机器上"和"bazel 跑失败了"是完全不同的结论。
- 宿主信息与结果同生共死。 单独一个数字没有意义。
序列化为 JSON,字段名即上面的名字,顶层带 protocol_version。
export struct Engine {
virtual ~Engine() = default;
virtual std::string_view name() const = 0;
// 这台机器上有没有?没有就 unavailable,不是 failed。
virtual Availability probe() const = 0;
// 是否支持这个 fixture 变体(headers / modules)
virtual bool supports(Variant) const = 0;
virtual Result configure(const Job&) const = 0;
virtual Result build(const Job&) const = 0;
virtual Result clean(const Job&) const = 0;
};加一个引擎 = 新增一个 engines/<name>.cppm + 在 registry.cppm 注册一行。 不动 runner、不动协议、不动 CI。
supports(Variant, compiler) 是必要的,而且编译器是这个问题的一部分 —— 实测:bazel 9.2 + rules_cc 0.2.22
配 clang 能构建 C++20 模块,配 gcc 则死在它自己的扫描器里(aggregate-ddi: Invalid JSON string,
它解析不了 GCC 的 P1689 输出);meson 1.10.2 两个编译器都不行(module 'fx.a' not found)。
所以"bazel 支不支持模块"没有脱离具体运行的答案。不支持时报 unavailable 并附上得出该结论的那次测量,
而不是硬跑出一个误导性的数字。
生成而非手写。 手写两份"等价"的代码,几乎必然在某处不等价,而那正是被测量的东西。
生成器参数:单元数 N、依赖深度 D、每单元代码量 L。产出:
fixtures/synth-<N>x<D>/
headers/ include/unit_k.hpp + src/unit_k.cpp (传统头文件 + 分离实现)
modules/ src/unit_k.cppm (模块接口单元)
modules-impl/ src/unit_k.cppm + src/unit_k_impl.cpp (接口 + 实现单元 ★)
第三种变体直接对应上一轮分析的 F4 / §6.3:把实现移出接口单元。有了它,"改一行函数体"的代价差异就是测出来的,不是推断的。
同时提供 --project <dir> 模式:直接就地测量一个已存在的工程(mcpp 自身即基础用例),因为真实工程的依赖形状不是合成器能编出来的。该模式下 variant 轴坍缩为 native,并且 edit-body 会在测量前后逐字节保存并恢复被改的源文件 —— 包括构建失败的路径,那正是遗留改动最容易被忽略的时候。
| 维度 | 取值 |
|---|---|
| engine | mcpp=<binary>(可给多个,自动按版本标注)、cmake、xmake、meson、bazel |
| variant | headers, modules, modules-impl |
| profile | release, debug |
| scenario | cold, noop, touch-hub, edit-body, touch-leaf |
| compiler | gcc, clang, msvc(平台可用者) |
"优化前后"用两个真实二进制表达,不用模拟。 --engines mcpp=<旧>,mcpp=<新> 会注册两个引擎,各自向自己的二进制询问版本并据此标注(mcpp@2026.8.11.3 / mcpp@2026.8.12.1)。
早期设计里有一个 mcpp-opt 引擎,靠在构建前后设 SOURCE_DATE_EPOCH 来模拟优化。已删除:在 harness 里模拟一个改动,测的是 harness 对该改动的理解,而且一旦真实实现与之分叉,它会静默地不再跟踪。优化属于 mcpp,基准测的是二进制。
矩阵是笛卡尔积但不是全跑:spec.cppm 用显式的 include/exclude 规则裁剪,CI 默认跑一个小集合,workflow_dispatch 可放开。
采用 xlings src/platform/*.cppm 的既定约定:模块分区 + 整文件宏控。
| 关注点 | 位置 |
|---|---|
| 进程启动 + 墙钟计时 + 退出码 | platform/posix.cppm、platform/windows.cppm |
| CPU 型号 / 核数 / 异构判定 | 同上 |
| 环境变量读写 | 同上(setenv vs SetEnvironmentVariableA) |
| 组装与可移植部分(std::filesystem) | 主模块 platform.cppm |
每个分区把整个 body 包在一个宏里,非目标平台不导出任何符号;两侧导出同名函数,于是任一构建中每个名字只有一份定义,编译期自动选中——不需要 stub,也不需要 if constexpr 派发。主模块 export import :posix; :windows; 后用 export using 提升。
结果:#if defined(_WIN32) 只出现在这两个分区里,runner / engines / protocol / fixture 全部零平台条件。
新增 .github/workflows/bench.yml:
on: workflow_dispatch(只手动触发 —— 基准是重活,不该挂在每个 PR 上)- 输入:
engines、scenarios、variants、fixture_size、runs - 矩阵:
ubuntu-24.04×macos-14×windows-2022,各自的默认工具链 - 产出:上传
results/*.json为 artifact - 不设阈值断言:基准用于观察趋势,不用于 gate。把噪声变成红叉只会让人忽略它。
| 阶段 | 内容 | 完成判据 |
|---|---|---|
| A | bench/ 骨架:protocol + platform + runner + registry + mcpp 引擎 |
三平台能跑 bench --engine mcpp --scenario cold --fixture self 并产出合法 JSON |
| B | fixture 生成器(headers / modules / modules-impl) | 三个变体编译产物行为一致(同一断言集通过) |
| C | cmake / xmake / meson / bazel 适配器 | 缺失工具报 unavailable 且带原因,不是崩溃 |
| D | 构建剖析并入 --analyze + 结果合并 |
关键路径与 Python 实现交叉验证一致 |
| E | bench.yml CI |
手动触发在三平台跑通并上传 artifact |
| F | 文档 / 测试 / 版本 / PR / 验证 / 合入 / 发布 | 见目标清单 |
顺序是有依赖的:A 定协议,之后所有阶段都写向它;B 之前 C 无处可跑;D 依赖 A 的结果格式。
- 不把基准挂进 PR CI。噪声会淹没信号。
- 不设性能回归阈值。宿主差异(异构 CPU、云厂商邻居噪声)远大于多数真实回归。
- 不重新实现计时统计学。中位数 + min/max 足够;不做置信区间,因为样本量本来就小。
- 不追求引擎功能对等。引擎跑不了某个变体就报 unavailable —— 强行凑一个数字比没有数字更糟。
但"跑不了"必须是测出来的,不是假设的:最初这里写死了
bazel supports(modules) = false, 而实际上加上module_interfaces+--experimental_cpp_modules --features=cpp_modules之后, bazel 配 clang 是能构建并运行模块程序的。写死的能力判断会把一整列真实数据变成空白。