改一处实现,重编多少?—— 模块写法、编译器、与 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:重编 objectclang:重编 object
① 单 .cppm,声明+定义写在一起1 / 119 / 12
② 单 .cppm,声明与定义分开写1 / 119 / 12
.cppm + .cpp 实现单元2 / 122 / 13

clang 上 ② 与 ① 完全一样。 边界在翻译单元,不在文件内的位置:模块接口 单元里的一切都属于同一个 TU,clang 把其中的定义序列化进 BMI,与它写在第几行无关。 实现单元(module X;,无 export)是另一个 TU,内容不进任何 BMI,因此没有 任何导入方能依赖它。

gcc 上 ①② 便宜,是因为 gcc 不把非模板函数体写进 BMI —— 那是实现选择,不是语言 保证,而 clang 不这么做。


2. ⚠️ gcc 的「便宜」比看起来窄得多:插一行就级联

§1 里 gcc 的 1/11 是用同行替换测出来的(a+=i%3a+=i%3+1)。改成插入 一行——也就是日常写代码的样子——结论翻转:

编辑后续声明是否移位重编 objectBMI 重写
同行替换,不改行数1 / 110
函数体内插一行10 / 119
声明之间插一行10 / 119
文件末尾追加一行1 / 110

真因: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-body0.94s(不级联),而 mcpp 真实 工程是 80.87s。同一个场景名,差两个数量级。

原因:fixture 的 unit_0.cppm 里,被扰动的函数是文件中最后一个声明,插入点之后 没有任何声明可以移位,所以 BMI 不变。

fixture 在这一点上不保真,而这正是 --project 模式存在的理由。任何从 fixture 的 edit-body 推出的关于真实工程的结论都不成立。


5. 同一个场景名在量两件相反的事

edit-body 按 variant 改的文件不同(bench/src/fixture/generate.cpp):

variant改的文件实际问的问题
headersunit_0.cpp普通 TU,必不级联
modulesunit_0.cppm接口单元 → 可能级联,取决于是否移位 / 编译器
modules-implunit_0_impl.cpp实现单元 → 必不级联

modulesmodules-impl 的期望结果是相反的,却共用一行。文档必须写明, 否则读者会把两个不同的问题当成同一个指标的两次测量。


6. 被推翻的中间结论(留档,免得重走)

  1. 「GCC 16.1 的 BMI 不含函数体 ⇒ 改函数体不必级联」 —— 只对不改行数的编辑 成立。之前那条记录是用同源两编 + 同行替换得到的,不能推广到日常编辑。
  2. Version::str() 是导出类的成员函数,所以 GCC 把它序列化进 BMI」 —— 错。 §1 的 ② 直接反证:成员函数体同行替换不级联。真因是行号移位(§2)。
  3. 「插入一行只要不动 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),把「语义变了」与 「行号移了」分开量 —— 现在它们混在一行里