改一处实现,重编多少?—— 模块写法、编译器、与 BMI 的实际行为(2026-08-15)
August 15, 2026 · View on GitHub
状态:实测结论。每一条都有对照,推翻的中间结论也记在这里。
被测:mcpp 2026.8.13.1(本分支自举产物)· gcc 16.1.0 / llvm 22.1.8 · Linux x86_64
0. 一句话
让「改实现不级联」这件事跨编译器成立的,只有把定义放进
.cpp实现单元。 在单个.cppm里把声明和定义分开写,对增量构建零收益。
1. 三种写法的对照(8 个导入者,只改函数体)
| 写法 | gcc:重编 object | clang:重编 object |
|---|---|---|
① 单 .cppm,声明+定义写在一起 | 1 / 11 | 9 / 12 |
② 单 .cppm,声明与定义分开写 | 1 / 11 | 9 / 12 |
③ .cppm + .cpp 实现单元 | 2 / 12 | 2 / 13 |
clang 上 ② 与 ① 完全一样。 边界在翻译单元,不在文件内的位置:模块接口
单元里的一切都属于同一个 TU,clang 把其中的定义序列化进 BMI,与它写在第几行无关。
实现单元(module X;,无 export)是另一个 TU,内容不进任何 BMI,因此没有
任何导入方能依赖它。
gcc 上 ①② 便宜,是因为 gcc 不把非模板函数体写进 BMI —— 那是实现选择,不是语言 保证,而 clang 不这么做。
2. ⚠️ gcc 的「便宜」比看起来窄得多:插一行就级联
§1 里 gcc 的 1/11 是用同行替换测出来的(a+=i%3 → a+=i%3+1)。改成插入
一行——也就是日常写代码的样子——结论翻转:
| 编辑 | 后续声明是否移位 | 重编 object | BMI 重写 |
|---|---|---|---|
| 同行替换,不改行数 | 否 | 1 / 11 | 0 |
| 在函数体内插一行 | 是 | 10 / 11 | 9 |
| 在声明之间插一行 | 是 | 10 / 11 | 9 |
| 在文件末尾追加一行 | 否 | 1 / 11 | 0 |
真因:GCC 的 BMI 记录后续声明的源码行号。 只要插入使它们移位,BMI 就变, 级联就发生 —— 与被改函数体的语义无关。追加到文件末尾之后没有任何声明,所以不变。
于是 §1 gcc 那一列要这样读:只有当编辑不改变行数时才成立,而增删一行代码是
常态。实际开发中,gcc 上单 .cppm 同样会级联。
.cppm + .cpp 不受影响:实现单元根本不产 BMI,行号移位无处可去。
3. 这解释了 mcpp 自己 edit-body 为什么是 80 秒
基准的 edit-body 扰动是插入一条语句(insert_into_first_body),不是同行替换。
在 mcpp-2026.8.11.3/src/version_req.cppm 上,插入点落在 Version::str() 的
for 循环里,其后还有大量声明 —— 于是 BMI 变了,整图级联,80.87s。
- BMI 确实变了:
mcpp bmi-equal判为不等价; - 对照成立:同一份源码编两次,BMI 逐字节相同(没有时间戳噪声)。
mcpp 没有缺陷。 BMI 真的不同,restat 正确地拒绝抑制。级联的原因是行号移位, 不是「函数体的语义变了」。
4. ⚠️ 生成的 fixture 没有复现这个行为
标准集里 gcc/fixture 的 modules × edit-body 是 0.94s(不级联),而 mcpp 真实
工程是 80.87s。同一个场景名,差两个数量级。
原因:fixture 的 unit_0.cppm 里,被扰动的函数是文件中最后一个声明,插入点之后
没有任何声明可以移位,所以 BMI 不变。
fixture 在这一点上不保真,而这正是 --project 模式存在的理由。任何从 fixture
的 edit-body 推出的关于真实工程的结论都不成立。
5. 同一个场景名在量两件相反的事
edit-body 按 variant 改的文件不同(bench/src/fixture/generate.cpp):
| variant | 改的文件 | 实际问的问题 |
|---|---|---|
headers | unit_0.cpp | 普通 TU,必不级联 |
modules | unit_0.cppm | 接口单元 → 可能级联,取决于是否移位 / 编译器 |
modules-impl | unit_0_impl.cpp | 实现单元 → 必不级联 |
modules 与 modules-impl 的期望结果是相反的,却共用一行。文档必须写明,
否则读者会把两个不同的问题当成同一个指标的两次测量。
6. 被推翻的中间结论(留档,免得重走)
- 「GCC 16.1 的 BMI 不含函数体 ⇒ 改函数体不必级联」 —— 只对不改行数的编辑 成立。之前那条记录是用同源两编 + 同行替换得到的,不能推广到日常编辑。
- 「
Version::str()是导出类的成员函数,所以 GCC 把它序列化进 BMI」 —— 错。 §1 的 ② 直接反证:成员函数体同行替换不级联。真因是行号移位(§2)。 - 「插入一行只要不动
export声明就没事」 —— 错。移位的是声明的位置, 不是声明的内容。
7. 方法学:这一轮差点得出三个错误结论
| 差点错在哪 | 怎么发现的 |
|---|---|
sed '2a\' 空追加在这台机器上是空操作,于是「插空行不级联」 | 打印改动前后的行数,发现 6→6 |
插入的注释把同一行剩下的 } return a; } 注释掉了,代码不成立,object=1 是构建失败的假象 | 每次测量都断言构建成功,而不只看重编了几个 |
| 用同行替换代表「改函数体」,得出 gcc 不级联 | 换成与基准同形的插入扰动,结论翻转 |
三条都是同一个形状:扰动本身没有被验证。测「改一处会重编多少」时,必须先证明 那一处真的按预期被改了,且改完仍然能编。
8. 结论与待办
给写模块的人:
- 要「改实现不级联」,把定义放进
.cpp实现单元。这是唯一跨编译器成立的办法。 - 在单个
.cppm里分开写声明和定义是代码组织上的整洁,增量构建上零收益。 - gcc 上单
.cppm看起来便宜,只在编辑不改行数时成立。
给这个套件:
- SPEC 写明
edit-body按 variant 改的是哪个文件,以及两者期望相反(§5) - README 修正:gcc 上
edit-body的级联来自行号移位,不是「接口变了」(§3) - fixture 的
modules变体应在被扰动函数之后放置声明,否则不保真(§4); 改了会让已发布的edit-body数据不可比,需要重测并声明 - 考虑增加一个不改行数的扰动(如
edit-body-inplace),把「语义变了」与 「行号移了」分开量 —— 现在它们混在一行里