本文报告 L1–L4 四条杠杆的当前状态、每条的依据、以及一次被回退的实施。
架构与方案在 2026-08-13-build-performance-architecture.md;这里只讲做到了哪里。
目标达成。 同一份 pinned 源码(@8219584)、同一台机器、eef8a4c 上复测:
| schedule=off | schedule=on | 比值 | |
|---|---|---|---|
| gcc 16.1.0 | 80.58s | 35.13s | 2.29× |
| llvm 22.1.8 | 32.40s | 18.04s | 1.80× |
L1 与 L2 叠加:gcc/off 80.58s → clang/on 18.04s = 4.47×,远在 50s 线内。 两者正交 —— L1 换编译器(压常数),L2 改图的形状,所以相乘而不是相加。
拆分调度下 noop 重建 0 个产物(.o/.gcm/.pcm 一个都没动)。
| 工程 | 规模 | schedule=off | schedule=on | 比值 |
|---|---|---|---|---|
| mcpp | 138 模块 / 57k 行 | 79.9s | 34.80s | 2.30× |
| xlings | 110 模块 / 46k 行 | 112.92s | 33.41s | 3.38× |
xlings 是独立作者、独立代码库的对照(openxlings/xlings @ b1563fe),
效果比开发它的那个工程更大。两个工程都 noop 无重建
(mcpp 0.21s;xlings 的 10.77s 全部是依赖解析开销,.ninja_log 增量 0 条边)。
mcpplibs.xpkg(5 个单元),
on 那次命中了缓存。5 个单元相对 80s 的差值可以忽略,但记在这里而不是抹掉。
auto 为 off、on 才启用拆分形状。
两个编译器各支持其中一种机制,不可互换,policy 决策一次:
| 编译器 | 形状 | 为什么只能是它 |
|---|---|---|
| gcc | detach-codegen |
无廉价的 BMI-only 模式(-fmodule-only 要花掉整编译的 99%),但 gcc 用 rename() 发布 BMI,所以"文件出现"是可靠信号 |
| clang | two-phase |
反过来:clang 用 O_TRUNC 就地写 BMI(读者会看到半个文件),但它有真正廉价的 BMI-only 调用 |
坑 1:--precompile 发出来的不是同一种 BMI。
-fmodule-output= … -c 7.35s BMI 9,102,984 B (reduced)
--precompile 1.81s BMI 18,402,920 B (FULL)
--precompile -Xclang -emit-reduced-module-interface
1.67s BMI 9,102,968 B (reduced)
(clang 22.1.8,src/build/prepare.cppm)
--precompile 单独用又快又对看起来成立,实际上它发的是 full BMI ——
因为它的产物本来是要喂回去做 codegen 的。把 full BMI 发布给下游不是等价替换:
小模块上体积涨约 16 倍(mcpp.platform 19,424 → 313,452 B),而且在 mcpp 自己的
模块图上直接让 clang 22.1.8 编错一个下游 TU:
error: call to implicitly-deleted default constructor of
'formatter<basic_string<char>, wchar_t>'
—— 一个窄格式串,报错点在 std 里面,离真因三个文件远。同一个 TU 对着 reduced BMI
编译通过。所以 reduced 不是优化,是契约:bmiOnlyFlags 必须逐字节复现它。
坑 2:object 边只能重编源码,不能读 BMI。
reduced BMI 不能拿去 codegen,于是 object 边是 -c <源码>(不带 -fmodule-output,
BMI 归 A 边所有,两条边不能写同一个文件)。代价是前端跑两遍;收益是下游只等 A 边。
两条边彼此独立(object 边不等 BMI 边),所以 codegen 可以整体落在图的后面。
P1689 只知道 object(primary-output 取自被扫描命令的 -o),BMI 边没有记录时
ninja 不是警告而是整图拒绝:
ninja: build stopped: 'pcm.cache/mcpp.version_req.pcm' not mentioned in
its dyndep file 'obj/version_req.cppm.ddi.dd'
—— 报的是边,不是缺失的记录,指向的是无辜的一侧。
解法是 mcpp dyndep --split-module:给 BMI 和 primaryOutput 各写一条记录。
不是"把目标改成 BMI" —— 两条边都要解析同一批 import,都需要同一批隐式输入。
不要去改扫描的 -o:GCC 那边共用 -o 与 -fdeps-target 已经造成过
"扫描去写还不存在的 gcm.cache/" —— 在 mcpp 自己的仓库上不暴露
(那个目录早被上一次构建建好),换个全新工程立刻失败。
| 并发 | schedule=off | schedule=on | 比值 |
|---|---|---|---|
-j4 |
56.34s | 37.60s | 1.50× |
-j8 |
34.00s | 25.55s | 1.33× |
-j32 |
32.03s | 17.95s | 1.78× |
前端跑两遍要多花 CPU,但在试过的每个并发档位上都是净赢,所以没有加核数门槛。
正确性判据是产物而不是退出码:两条臂的 130 个目标文件逐字节相同。 (BMI 有 101 个不同 —— 两臂在不同的指纹目录下,BMI 里烙了输出目录的绝对路径; 这正是"对照放两个目录会让路径冒充差异"那条,所以 BMI 差异在这里不构成证据。)
| 杠杆 | 状态 | 依据 | |
|---|---|---|---|
| L1 | 按次选择工具链 --toolchain |
已实施 | 实测 81.8 → 32.6s(2.51×) |
| L2 | 下游在 BMI 可用时即开始 | 已实施(bmi_schedule = "on",gcc + clang) |
gcc 79.9 → 34.8s(2.30×);clang 32.0 → 17.95s(1.78×) |
| L3 | 定义移出接口单元 | 不做 —— 已量出它治的是 L2 同一个病 | 实测:对 mcpp −6.2%,对 cmake +92.3% |
| L4 | 拆 build.prepare |
已实施(架构收益;性能上为零) | 实测:0,原因见下 |
mcpp build --toolchain llvm@22.1.8 —— 实测 81.83s → 32.61s(2.51×),已达标。
换默认才是那个不能做的动作:它让所有已发布包的指纹失效(全生态一次性重编),
三平台 llvm 版本还不统一(Windows 20.1.7 vs Linux/macOS 22.1.8),
且牵涉 -static-libstdc++ 与 libc++/libstdc++ 的 ABI 选择 —— 需要协调。
按次选择不需要任何人配合,收益却是同一个 2.51×。
L3 指的是改 mcpp 自己的 138 个模块——把定义从接口单元移到实现单元。 它不是构建引擎的能力,而是被构建工程的写法。
决定:待合入的 PR 不动 mcpp 源码的实现风格。 理由: 本轮的目标是优化 mcpp 的构建性能(引擎能力),而"改被测工程的结构来提速" 是另一件事——它对所有用 mcpp 的工程都适用,却要求每个工程改写自己的代码。 把两者混进同一个 PR,会让一次引擎改动挟带一次跨全库的风格变更。
可以做的:拉一个临时分支/PR,只为量出具体收益(推算是链 74.6s → ~10.4s), 测完即弃,不合入。收益数字回填到本文。
下面那段用未标定的旧 fixture得出"L3 只值 +8%",那个数字不可信 ——
那份 fixture 每个 TU 有 74% 是编译器启动、weight 旋钮推不动成本(见 bench/README §1a)。
用标定后的 fixture(--preset standard)重测的 2×2:
modules(定义在接口) |
modules-impl(定义移到实现单元) |
|
|---|---|---|
| schedule=off | 17.76s | 5.30s |
| schedule=on | 12.45s | 5.00s |
- L3 单独:3.35× · L2 单独:1.43× · L2+L3:3.55×
它们叠加,但只叠一点点,因为两者治的是同一份浪费、只是从两头下手: L3 把 codegen 从接口单元搬走,L2 是不等那份 codegen。做了任何一个, 另一个就没多少可买。而单项收益 L3 远大于 L2。
所以"极致性能"的答案是:引擎侧 L2 + 工程侧 L3,而 L3 是更大的那一半。 L3 仍然不进这个 PR(它改的是被构建工程的写法),但它的定位从 "给没有 L2 的引擎准备的绕行方案"更正为最有效的单项优化, 文档提示应当据此改写。
不用拉分支:bench 的 modules-impl 变体测的正是 L3(定义写在接口单元 vs 移到实现
单元),数据已经在 bench/results/five-way-20260812/ 里。同一 fixture、同一编译器:
| 场景 | mcpp:modules → modules-impl | cmake:modules → modules-impl |
|---|---|---|
| cold (gcc) | 3.53 → 3.25s (+7.8%) | 13.05 → 12.80s (+2.0%) |
| cold (clang) | 2.50 → 2.19s (+12.3%) | 4.00 → 3.96s (+0.9%) |
| edit-body (gcc) | 0.29 → 0.31s (−6.2%) | 10.29 → 0.79s (+92.3%) |
| edit-body (clang) | 0.46 → 0.31s (+32.5%) | 2.62 → 0.42s (+84.0%) |
看 edit-body 那两行。 把定义移出接口单元,给 cmake 带来 92% 的提升,
给 mcpp 带来 −6%(即没有)。原因是同一个:改函数体时接口没变,
cmake 按 BMI 的 mtime 级联,mcpp 比 BMI 的内容。L3 是给没有 L2 的引擎准备的绕行方案。
引擎做了这件事之后,工程再去重构,在这条轴上什么都买不到。
真正留下来的是 cold 上 +8%~12% —— 接口单元变薄,关键链就变短。这是真的, 但它是"顺手的好设计",不是一条值得为性能去改 138 个模块的理由。
所以 L3 的文档提示是:先要引擎的 L2,再谈重构。 顺序反了会做很多白工。
抽出 mcpp.build.prepare_inputs(341 行,cfg() 谓词 + 指纹规范化),
prepare.cppm 6521 → 6186 行,两个函数 re-export 所以调用方零改动。
构建时间没有变化(off 79.23s / on 34.54s)。原因在拆分前就分析出来了,
实测只是确认:抽出来的东西成了 prepare 的依赖,链只会变长不会变短 ——
… → prepare_inputs → prepare → … 仍然串行,prepare 少掉的成本正好由新模块付掉。
要缩短关键路径,抽出的部分必须是 prepare 的兄弟(被 prepare 的导入者直接用)。
已查清:configure 只用 BuildContext,execute 用 BuildContext + prepare_build,
其余只用 prepare_build;而链上是 prepare → execute → configure,
execute 离不开 prepare_build,所以抽类型也没人能离开这条链。
真正有效的是拆 prepare_build 本身。
而且 L2 落地后这件事的收益又小了一截:一个接口现在只阻塞导入者约 22% 的编译, 不是全部。所以这次拆分按架构理由留下(6500 行的模块本就该拆),不按性能理由。
L2 是通用的:引擎侧的 2.30× / 3.38× 在两个互不相关的工程上都成立, 不要求任何人改写自己的代码。L3/L4 只对被改写的那个工程生效。
所以 L3 和 L4 都不进这个 PR,它们降级为文档里的提示: 想更快的工程可以这么做,收益在临时分支上量、量完即弃,数字回填到本文。
两者仍然不是同一类动作,区别记在下面:
-
L4 是架构改动 ——
build.prepare6521 行、16.4s、占关键链 22%,是唯一的真离群点。 形状比"把大文件拆小"苛刻得多:⚠️ 把一部分抽成 prepare 的依赖,是让链变长而不是变短。… → 新模块 → prepare → …仍然串行,只是多了一跳;prepare 少掉的那点成本 被新模块自己的成本抵掉。抽出来的东西必须是 prepare 的兄弟 —— 被 prepare 的导入者直接使用,才能与 prepare 并行编译。实际调查(谁 import prepare、用了什么):
configure.cppm 只用 BuildContext(一个类型) execute.cppm 用 BuildContext + prepare_build doctor / pack / cli.cmd_build 只用 prepare_build所以把
BuildContext抽成叶子模块能让configure不再依赖 prepare —— 但链上是 prepare → execute → configure,而execute需要prepare_build, configure 仍在 execute 之后。净收益为零。真正能缩短关键路径的是拆
prepare_build本身,让execute只依赖它的一部分。 那是对一个 6521 行模块的深度重构,不是一次抽取,需要单独立项 —— 而且按上面那条界线,它属于工程侧建议,不属于本轮的引擎优化。 -
L3 是实现风格改动 —— 把定义从接口单元移到实现单元,要动 138 个模块。 它对代码的组织方式提出要求,而收益只对改写了的那个工程生效。 不做;要量收益就在临时分支上测,测完即弃。
bench/projects/mcpp/ 指向的
被测树的快照)。那份源码是测量基准:改了它,前后两次测量就不再是同一个工作负载,
所有比值失效。
L2 与它们的区别:引擎侧的 2.30× 对每一个 mcpp 工程都生效, 不要求任何人改写自己的代码;L3/L4 只对被改写的那个工程生效。
已实施并验证通过的部分:
src/build/schedule/policy.cppm—— 纯函数决策表(每个编译器一种机制), 7 条单测两侧钉死;requested_switch是唯一读开关的地方。src/build/schedule/detach_codegen.cppm—— gcc 的运行期。 实测:阶段一在 2.30s / 16.15s = 14% 返回,目标文件正确,两阶段 rc=0。- 可观测性:
# mcpp:graph=normal;schedule=detach-codegen写进图头,--verbose打印决策理由。 - 失效靠指纹而不是守卫:换调度就换构建目录,旧形状的图结构上不可达。
已全部接上。(下段保留当时的记录,因为两个失效点值得留证)
历史记录 —— 曾经未接上的:图的拆分发射。它需要按 §2.3/§2.4 把 BMI 边的 depfile 接到
P1689 扫描的产出上;第一版发射(未提交)会让 mcpp build 段错误(rc=139),
而同一棵树上不含它的二进制 rc=0 —— 这一点是逐条复现出来的,
先前把整批基础层当成元凶是错误归因。
auto 现在是 off:调度改错是静默的(漏掉一条头文件依赖不会报错,
只会不再重编),不该凭一台机器的结果成为默认。
这些是本轮最有价值的产出,全部有实测支撑:
-
GCC 原子发布 BMI,clang 不是。 strace:GCC 写
<name>.gcm~再rename();clang 以O_TRUNC直写最终路径。 ⇒ 「看文件出现」对 GCC 成立、对 clang 不成立(会读到写了一半的.pcm)。 -
两种机制互补,不是二选一。 clang 有便宜的 BMI-only 调用(实测
src/build/prepare.cppm:1.67s vs 7.35s); GCC 没有(-fmodule-only要 99% 的时间 —— 它不跳过后端,只是不写目标文件), 但 GCC 原子发布 BMI 而 clang 不。装反是静默的。⚠️ 早先这一条写的是「--precompile0.78s /-c自 pcm 0.70s,总 CPU 只多 9.6%」。 那条路走不通:--precompile发的是 full BMI(见 §0b 坑 1)。真实数字是 1.67s + 7.31s ≈ 多 22% CPU,object 边重编源码而不是读 BMI。 -
depfile 在 BMI 之后写出。 实测:depfile 16.39s,BMI 2.36s,整条编译 16.55s。 ⇒ 拆分后的 BMI 边不能用编译器自己的 depfile(边结束时它还不存在); 挂到对象边则头文件变更时
bmi-await立刻返回、什么都不重编。 两种朴素挂法都会静默丢掉头文件跟踪。 -
P1689 扫描的 depfile 是可用的替代来源。 实测对照:它相对编译的 depfile 只缺
.gcm(那是 dyndep 在管的), 头文件全覆盖;且cxx_scan没有声明depfile,ninja 不会消费掉它。 ⇒ 这条路是通的,只是还没接上。
不属于构建引擎,但属于同一个问题的同一种病 ——「每次都重做一件上一次已经做完的事」。
用户报「每次运行单元测试都很慢,是不是没有并行测试功能」。先量,分解就把前提否掉了:
finished in 189.74s (build 187.70s + run 1.78s)
83 个测试的运行阶段只有 1.78s。「没有并行执行」属实,但它不是慢的原因。
给 NinjaBackend::build 加了分段计时(-v → build/stage:,留在代码里),一次热跑:
loader-tags total=158701ms calls=85 <-- 98%
ninja total= 575ms calls=85
compile-commands total= 443ms calls=85
runtime-validate total= 160ms calls=85
emit-ninja total= 88ms calls=85
没有这个分解只能猜 —— 我先后猜过 emit_ninja / compile_commands / hermetic,全错 (三者合计 < 15ms)。
三处修复:
| # | 改动 | 收益 |
|---|---|---|
| 1 | rule E(check_and_record_loader_tags)只解析 stat 变了的产物;没变的从 resolution.json 把判定读回来 |
189.7s → 5.3s |
| 2 | -k 0 批量构建成功 ⇒ 跳过每个测试的复驱动(~39ms × 83) |
5.3s → 3.9s |
| 3 | 并行跑测试(>1 个时捕获输出、整块打印;=1 个时保持前台流式) | 3.9s → 2.15s |
引擎侧:
- L1
--toolchain SPEC/MCPP_TOOLCHAIN:按次选工具链,不动 manifest、不动指纹。 - L2
bmi_schedule = "on"/MCPP_BMI_SCHEDULE:gccdetach-codegen+ clangtwo-phase, 决策集中在src/build/schedule/policy.cppm,运行期在src/build/schedule/。 默认auto= off。 - L4 抽出
src/build/prepare_inputs.cppm(架构收益,性能为零 —— 见 §1)。 --jobs N|auto。- BMI 等价性判断改用
mcpp bmi-equal,替代永远不可能成功的cmp -s(GCC 把时间戳写进 BMI 内容)。真实工程实测:touch-hub84.53s → 0.44s(对 cmake 192×,对上一版 mcpp 174×)。⚠️ edit-body无提升(18.29s vs 18.30s)且这是对的 —— 改函数体确实改变 BMI,级联是必需的。这一行是区分 「避免不必要的工作」与「避免工作」的对照。 mcpp test的三处提速(§3)。
其余是 bench 套件、规范与数据(见 bench/README.md、bench/results/)。
curl: (52) Empty reply from server那一类红是引导下载失败,12 秒内即挂、与代码 无关。判据:失败 job 的日志里没有任何测试名,只有 curl 的退出码。已由.github/tools/fetch_release.sh(--retry-all-errors+ 归档校验)根治。⚠️ 更正:此前这里写的是「反复出现的红全部是下载失败」,那是错的。 bench 那条线上真正的问题是没有红 —— 见 §7。把所有说不清的红都归给网络, 正是让那件事多存活了几周的原因。- 未合入,按要求。
用户报「bench 卡住而且没有进度」。查下去发现卡住只是最表层的症状。
bench (macos/clang/fixture) 报成功,实际是 6 ok / 48 failed / 18 unavailable;
唯一过的 6 个格子全是 cmake 的 headers 变体。三个 xlings job 报成功,一个测量
都没有。这个状态持续了数周。
| # | 缺陷 | 为什么没被发现 |
|---|---|---|
| 1 | 每个引擎拿到的编译器不同 —— CI 用 command -v g++ = runner 的 gcc 13.3.0,而 mcpp 用自己 registry 的 16.1.0 |
cmake 配不出 modules、xmake 把 gcc 编崩,都被记成对引擎的「真实发现」 |
| 2 | 构建工具版本随 runner 漂移(镜像自带 cmake 3.31.6,没有 4.0 的 import std 键) |
同上 |
| 3 | 被测工程运行时从默认分支 clone —— --hub src/xlings.cppm 早就不存在 |
每个格子报 skipped,harness 退出 0 |
| 4 | --hub/--body 按 harness 的 cwd 解析,不是按工程目录 |
只有「测你正站着的树」时才对,即 mcpp 测自己 |
| 5 | harness 永远返回 0 | 「测到了东西」这件事从来没有被断言过 |
| 6 | xmake 的 --buildir 相对 -P 解析,clean() 删的是另一个目录 |
cold 0.60s 状态 ok、带样本 —— 每一个 xmake 真实工程 cold 数字都是假的 |
每一个都是「失败看起来像成功」,而不是「失败没被处理」。 套件的协议不变量 1
写着「失败不得看起来像测量」,但它只覆盖了单个 cell 的 status 字段 —— 没覆盖
退出码、没覆盖「量到的是不是真的那件事」。
现在补上的断言,按发现顺序:
failed或「一个 ok 都没有」⇒ 非零退出;已知缺口写进allow_failed且必须带KNOWN GAP说明(守卫检查)。cold必须大于同引擎同 variant 的2 × noop—— 否则它没重建。不是性能阈值, 是内部一致性。hub/body必须在钉住的树里真实存在(靠子模块才可检查)。- 工具版本必须是精确版本;
reference_mcpp必须等于.xlings.json的 bootstrap pin。 - 扰动形态写进 note ——
edit-comment在有函数体的单元里插注释(行号全移、BMI 真的变了、级联是对的)和在没有函数体的单元末尾追加(什么都没动)是两个不同的 问题,而套件用一个名字同时回答了它们。
- 进度实时打到 stderr 并逐行 flush;
- 每条 configure/build 有超时(默认 1800s),超时 kill 并报
TIMED OUT after Ns; - 失败时直接打出子进程日志尾部;
- 子进程日志改为追加、由 runner 每个 cell 清空一次 —— 此前计时构建那一行
build ok, spent 0.111s会把前面 configure 的输出整个擦掉,这正是 xmake 那条 0.60s 一开始无法诊断的原因。
edit-comment 在 mcpp 自己的工程上是 199×,在 xlings 上是 1.00×。
不是优化时灵时不灵 —— 是 mcpp 的 hub 恰好没有函数体。mcpp 测自己永远看不到
这件事,这就是独立控制目标存在的全部理由。
auto是否翻成 on:这是发布决策不是技术缺口 —— 指纹里带了 schedule, 翻默认会让所有已发布包全量重建一次。等三平台 CI 见过on之后再单独立项。- msvc:
/ifcOnly的代价与.ifc是否原子发布都还没测。猜错是静默的 (半个 BMI 不是诊断,是编错),所以保持None。 - L3 作为书写约定:优先施加于链上那 19 个模块(见 §1),不回改存量。
- 换默认工具链单独立项(生态决策,见 §L1)。
bmi_schedule = "on" 在增量重建上会让导入者撞到不存在的 BMI:
failed: gcm.cache/fx.unit_1.gcm
fx.unit_0: error: failed to read compiled module: No such file or directory
fx.unit_0: note: imports must be built before being imported
复现(生成的 fixture,modules variant,四个场景稳定失败):
bench --engines 'mcpp[schedule=on]=<mcpp>' --variants modules \
--scenarios touch-hub,touch-leaf,edit-body,edit-comment \
--preset standard --runs 2 --compiler payload:gcc
- 不是编译器之间的竞态:
-j1一样复现。 - 窗口是设计带来的、而且很大:phase 1 在 spawn 编译器之前就把旧 BMI
rename 进
.bak,直到编译器发布新的为止,这个模块在磁盘上没有 BMI。 实测一次增量重建中该文件消失约 208ms(2ms 采样 × 104 次命中)。 - 失败模态是安全的那一种:该路径下 BMI 是缺失而不是陈旧,所以永远 是响亮的失败,不会产出一个「成功但错误」的构建。这一点是量出来的,不是希望。
- 真实工程(mcpp 自己、xlings 两种风格)上没有复现 —— 只有 fixture 的紧密 unit_0→unit_1 链会撞上。这就是合成 fixture 的价值,我此前把它当成 「不如真实工程可信的那一半」,是错的。
compile_release_at_bmi 的 read_rc 分支返回成功却从不 settle_bmi(见
8c9f239)。它确实是个真缺陷 —— 上一份 BMI 一直停在 .bak,而且那个单元的
等价性检查从未运行,也就是说级联抑制对最便宜的那些单元是静默关闭的。修完
之后 .bak 残留归零,但四个场景照样失败。
在 -j1、且 dyndep 明确写着 unit_1.gcm: dyndep | unit_0.gcm 的情况下,导入者
为什么仍然会在那 208ms 的窗口里被 ninja 调度。下一步应当是 ninja -d explain
配合边级时间线,而不是继续静态推理 —— 这一条我已经猜错过一次。
auto绝不能翻成 on,直到这条修好;- 所有已发布的
bmi_schedule数字都是在缺陷存在时取的,README 里已标注不可引用; - 修法方向:要么让 BMI 在整个重建期间保持可读(先编译到临时路径、成功后再原子 替换,而不是先把旧的挪走),要么让导入者的边真正等到 BMI 重新发布之后。 前者更像是对的 —— 「先移走再重建」本身就在制造一个不存在的中间态。
已修好的两件事(独立成立,与下面那条无关):
compile_release_at_bmi的read_rc分支返回成功却不settle_bmi—— 上一份 BMI 停在.bak,而且那个单元的等价性检查从未运行,级联抑制对最便宜 的单元静默关闭。- 编译失败时不再把单元留在「完全没有 BMI」的状态。
「导入者读不到 BMI」已经修好。 原设计在 spawn 编译器之前就把旧 BMI
rename 走,于是模块在磁盘上有约 208ms 没有 BMI(实测)。改成复制一份到
.bak、原件留在原地,并用「文件身份(size+mtime)发生变化」而不是「文件存在」
来判断发布。failed to read compiled module 不再出现。
std::filesystem::file_size(p, ec)失败时返回(uintmax_t)-1。我在检查ec之前就把它写进结构体,于是「文件不存在」与默认构造的哨兵不相等 —— phase 1 第一次轮询就认为「变了」,在编译器产出任何东西之前返回,所有 object 边报no compiler was started … phase 1 did not run。copy_file给副本盖的是拷贝时刻的 mtime。而settle_bmi恢复这份副本正是 为了让 mtime 不前进、让 ninja 的 restat 掐断级联。不显式把原 mtime 带过去, 恢复反而把 mtime 推前 ——touch-hub变成 12.61s(冷构建 12.37s), 六个格子全报ok。状态列抓不到这个,只有数字能。
仍然没修好的 已于 2026-08-14 修好。 上一版这里写的方向是对的,并且就是最终
的修法:BMI 的 restat 抑制不能连带抑制这个单元自己的 object 边。
ninja_backend.cppm 里 object 边原本是
build <obj> : cxx_module_obj <bmi> # 唯一输入是 BMI
现在是
build <obj> : cxx_module_obj <source> | <bmi>
源码作为输入给了它一个 restat 清不掉的「脏」的理由;BMI 降为隐式输入,顺序不变
(bmi-await 不能跑在 phase 1 之前)。
export int leaf_value() { return 1; } // 改成 42,重建
Finished dev in 0.02s <- 报成功,8 条边只跑了 3 条
./repro -> 1 <- 源码写的是 42
undefined reference 只是「符号原本就不存在」时的特例。记录一个缺陷时,记最一般
的形态,不要记你第一次撞见的那个 —— 后者会让所有人低估它。
bmi_schedule=on 的
touch-hub 0.22s / edit-comment 0.18s 在修复后是 0.44s / 0.44s,比默认档还略
慢 —— 因为那两行本来就没有级联可省。cold(35.43→36.36)与
edit-body(30.17→30.48)不受影响,头条结论成立。
job.build_dir 是给 cmake/xmake 的 -o,mcpp 写的是
<project>/target;(2) 窗口取 300ms,而要检测的尾巴本身就是一次编译,实测
1.24s 才落盘;(3) 改成轮询到稳定、上限 3s、按引擎声明的产物目录 —— 诊断确认它
确实在跑、确实拿到了基线,但对仍带缺陷的二进制在本套件的 touch-hub 流程里
不触发。于是撤掉:一条无法被证明能抓到目标的守卫,和没有守卫无法区分。
顺带订正我自己的一句话:我先前把「已发布数字量的是没跑完的构建」当成结论写进了
README。手工重建确实能观察到 mcpp build 0.56s 返回、cc1plus 仍在跑、object
1.24s 后落盘;但在 harness 自己的流程里复现不出来。把一次手工观察当成对已发布
数字的解释,是过度归因 —— 能站住的只有「object 边和链接被 restat 清掉了」。
因此 CI 里暂时不跑 +schedule=on 这条臂。 修好之后可以放回去;门槛仍是 §8 的
复现全绿。
这条 bug 我连错五次(误诊 settle_bmi、哨兵不匹配、mtime 没带过去、object 边、 以及把症状记成了链接错误)。下一个人应该从「object 边与 BMI 边的 restat 语义不同」 开始。
这两个都不是这次改动引入的 —— 是矩阵此前根本没在测,所以从来没人看见。
bench (macos/clang/xlings-2026.8.13.1):
error: dependency 'xpkg': build.mcpp failed to compile (exit 1):
dyld[21445]: Symbol not found: __ZdaPv
clang++: error: unable to execute command: Abort trap: 6
__ZdaPv 是 operator delete[](void*)。助手链接过了,运行期找不到 libc++。
与 [[build-mcpp-helper-self-containment]] 同一类问题(glibc 靠 rpath、musl 与 PE
才需 -static),但 macOS 这条此前没有覆盖。
影响面比 bench 大:任何在 macOS 上依赖带 build.mcpp 的包的工程都会踩到。
不只是 build.mcpp 助手。bench 的 macOS 格子里 cmake / bazel / 参照 mcpp
三个引擎一起挂在同一处:
dyld: Symbol not found: __ZdaPv
Referenced from: …/XcodeDefault.xctoolchain/usr/bin/ld ← Apple 自己的链接器
Expected in: …/registry/…/lib/libc++.1.0.dylib
Apple 的 ld 自己就链 libc++,而 registry 的那份 libc++ 进了动态加载器的搜索
路径,于是 ld 还没开始链接就 abort。判据:被测的 mcpp 那条臂在同一次运行里
全绿 —— 三个引擎同样地挂、一个不挂,说明问题在环境而不在任何一个引擎。
DYLD_*(很可能是 mcpp/xlings 为了让自己的载荷
二进制跑起来而设的),然后被所有子进程继承。
macOS 的 bench 格子因此进 excluded,原因写在 matrix.json 里。没有继续猜 ——
本机是 Linux,复现不了,而这条已经让我烧掉好几轮 CI。
上面两条把现象记全了,也记了"没有继续猜"。issue #437 把最后一步补上了 —— 污染是真的,但可以绕开:不要用那个会被污染的链接器。
根因链(对着当前源码核过):
build_program.cppm:154把 host helper 的cfgBypass设成LinuxOnly;- 于是 macOS 上
bypassCfg == false,hostflags.cppm命中else if (dm.hasCfg) return out;—— 返回空 link token,没有-fuse-ld=lld; - clang++(payload)于是用默认链接器 = Xcode 的
/usr/bin/ld; - 那个
ld自己就是链 libc++ 的 Mach-O,而它跑在 payload 工具链设好的DYLD_*里,dyld 把它的 libc++ 解析到 payload 那份 —— 缺__ZdaPv, ld 还没开始链接就 abort。
为什么主构建不挂:flags.cppm 的 macOS 分支刻意用 -fuse-ld=lld,
注释原文就写着 "Xcode 15.4's ld aborting at launch on macos-14 CI when its
libc++ resolution was diverted"。也就是说 —— 同一个坑,主构建早就绕过去了,
host helper 这条路没跟上。
判据上这一条最说明问题:mcpp 用 llvm 能把整个工程构建出来,却构建不了 自己的
build.mcpp。 同一个工具链、同一个环境,两条链接路径给出不同结果, 那不是"macOS 环境问题",是这两条路径没有对齐。
修法(已实施):hostflags.cppm::host_link_tokens 的 trust-cfg 分支在 macOS 上
追加 -fuse-ld=lld。cfg 选的是 runtime,从来没选过链接器;lld 随做编译的那套
工具链一起发布,不可能被解析到它没链过的 libc++ 上。
没有配单元测试:判断在 if constexpr (is_macos) 里,在 Linux 上整段编译掉,
测试不可能失败。判据是 macOS CI 的 e2e(89/92/110/111/125/143/144/145/181/186/194
都带 build.mcpp)与 issue 报告者的复现。
mbedtls-3.6.1/CMakeLists.txt:304
framework/CMakeLists.txt not found.
Run `git submodule update --init` from the source tree.
挡住的是 xlings cmake arm 的最后四分之一(ftxui / libarchive / lua 三个已经接好、 能用)。注意 mcpp 自己构建 mbedtls 不会踩到,说明 mcpp 走的根本不是 mbedtls 的 CMake 路径。
解法有三条,都需要决策而不是我单方面选:vendor 那个不大的 framework 目录、
改走 mbedtls 的 Makefile、或者让 registry 把完整树打进包里。
判据(同一份分支代码,只换编译器):
| 编译器 | mcpp test |
|---|---|
| clang 22.1.8(仓库默认) | unit/test_elf_runtime SIGSEGV,82 passed / 1 failed |
| gcc 16.1.0 | unit/test_elf_runtime ... ok,83 passed / 0 failed |
backtrace 落在 libc++ 的 __assign_with_sentinel,调用方是
mcpp::platform::elf::inspect_elf_runtime@mcpp.platform.elf_runtime ——
那个文件本分支一行没改(git log origin/main..HEAD -- src/platform/elf_runtime.cppm
为空),而 main 用同一个 clang 是 80 passed / 0 failed(在独立 worktree 里实测,
不是看 CI)。也就是说:别处的新增改变了这个函数的代码生成。这与仓库里已记录的
clang-modules-unused-fn-miscompile 是同一类现象。
崩的那个用例叫 RejectsUnsupportedOrTruncatedElfWithoutGuessing —— 它只对一个
9 字节文本和一个 6 字节文件调 inspect_elf_runtime,而头部检查
(bytes.size() < 0x40)本该直接挡住。
转折点是给 CI 加了一个 continue-on-error 的 verbose 探针,它把崩溃钉在
stage("runtime-validate") 打印之前 —— 即 validate_changed_artifacts 内部。
顺着这条线才在本地找到那个稳定复现的单测。
在此之前逐条否掉的七个假设(每条都有实验,不是推理):
--jobs auto导致 OOM ——mcpp.toml根本没设jobs,默认不走该路径- xmake 3.1.0 —— 切过去后 fixture 本地 1 ok / 0 failed
- mcpp-index 变动 —— 最后一次提交 08-12,不在窗口内
- xim 索引载荷 —— 近期无 llvm/glibc 变动
- 容器镜像 —— 在同一个
debian:stable-slim里精确复现 CI 那一步:hello 195,rc=0 - 全新空 registry(强制重下全部载荷)—— rc=0
- 「这是本分支的代码缺陷」—— 被同一批日志否掉:
toolchain: gcc里崩的是已发布的 2026.8.11.3,hermetic 里崩的是本分支构建的,两个版本都崩
mcpp test 有 1 个失败:
unit/test_elf_runtime」,当时把它当孤立小问题;它和 CI 的 exit 139 是同一个
信号、同一个子系统。一个和 CI 症状同信号的本地失败,永远值得先连起来看。
git bisect(判据=该单测是否 SIGSEGV)指向 f51e6ab,而它对 mcpp.toml 的改动是:
[toolchain]
-default = "gcc@16.1.0"
+default = "llvm@22.1.8"
这一行与那个提交的主题(xmake 臂、libarchive 覆盖包)毫无关系,提交说明里一个字
都没提 —— 是 git add -A 扫进去的。它把 mcpp 自身的默认工具链从 gcc 换成了
clang,于是每一次 CI 构建都撞上 clang 22 的模块误编译。
这也解释了那个「纯文档提交让 9 个核心 job 全红」的怪事:工具链早在它的上一个 提交就被换掉了,文档提交只是第一个跑完整套核心 job 的提交。我当时的推理 「文档提交不可能弄坏构建 ⇒ 必是环境问题」前半句是对的,但我从没想到去查前一个 提交里混进了什么。
改回 gcc@16.1.0 后:83 passed; 0 failed。
git add -A 会把无关改动带进一个主题明确的提交。 这次带进去的是
一行工具链切换,代价是几小时的排查,而排查方向一直被"提交说明说它只改了 bench"
误导。提交前看 --stat 里有没有主题之外的文件,是最便宜的防线。
CI 里那个诊断探针(ci-linux-e2e.yml 的 "diagnose: verbose trace")已在根因确认后
删除 —— 它的使命就是把崩溃钉在 stage("runtime-validate") 打印之前,做到了。
修复确认:改回 gcc 后,先前全红的六个核心 job(build+unit tests / toolchain: gcc / hermetic e2e / integration / toolchain: musl+llvm / cross-build aarch64)在 CI 上 全部转绿。