Skip to content

Latest commit

 

History

History
334 lines (222 loc) · 15.7 KB

File metadata and controls

334 lines (222 loc) · 15.7 KB

mcpp 冷构建深度优化方案

2026-08-12 前置:模块化构建性能深度分析 · bench 套件 范围:冷构建(mcpp clean && mcpp build)。增量侧的级联抑制已在 2026.8.12.1 落地。


0. 现状

bench --project . --engines mcpp=<旧>,mcpp=<新> --scenarios cold,mcpp 构建 mcpp 自身:

版本 冷构建中位数
2026.8.11.3 78.70s
2026.8.12.1(含 bmi-equal) 78.57s

持平,而且必然持平。 bmi-equal 修的是「重编后 BMI 未变则不级联」;冷构建里没有「上一份 BMI」,这条路径压根不适用。冷构建要快,必须解决另一组约束。


1. 三个互相独立的约束

C1 —— 关键路径 = 100% 墙钟

edges          : 423
makespan       : 76.54 s
work (sum dur) : 303.36 s
avg parallelism: 3.96 x   (of 32 hw threads)
critical path  : 76.48 s  = 100% of makespan

后 55% 的时间里,32 个硬件线程上只有 1 个编译进程在跑。这不是调度器不够聪明,是图本身就是一条链。

C2 —— 关键路径上 77% 的时间在生产无人等待的 .o

BMI 在编译进度 22.8% 处就被原子 rename 就位(strace 证实:此后 982 个系统调用没有一个再碰它),而 ninja 的依赖模型只认「边结束」。于是每个导入者都要多等一段纯 codegen。

C3 —— mcpp 从不传 -j,ninja 用默认的 nproc + 2

这在本机是 34,在 62 GB 内存上没问题。但实测单个模块编译的峰值常驻内存:

模块 峰值 RSS
build/prepare.cppm(最重) 1,057 MB
build/plan.cppm(中位偏上) 561 MB

⇒ 一台 64 核 / 32 GB 的机器会跑 66 路并发 × ~0.5–1 GB = 换页甚至 OOMnproc + 2 在核多内存少的机器上是主动有害的默认值。

C3 与 C1/C2 正交:在图仍是一条链时,调低 -j 不会更慢(反正用不满),调高也不会更快。所以 C3 首先是安全属性,只有在 C1/C2 解决之后才变成性能属性。


2. 优化 A —— BMI 落盘即释放下游(最大项)

2.1 收益已实测,不是模拟

机械改写 build.ninja、拆边、同一编译器进程,bench/proto-bmi-release/:

方案 墙钟 产物
baseline(边完成即释放) 77.42s 19,347,008 B
split(BMI 落盘即释放) 36.56s 19,347,008 B,可运行

2.12×,零额外 CPU(每个模块仍然只有一个 g++ -c)。

2.2 图的形状

rule cxx_module_bmi                     # 编译器起跑,BMI 落盘即返回
  command = $mcpp compile-module --phase=spawn --slot $slot --bmi $out --obj $obj_out -- $cxx ...
  restat = 1
rule cxx_module_obj                     # 等同一个编译器收尾,传播退出码
  command = $mcpp compile-module --phase=wait --slot $slot --obj $out
  restat = 1

build $bmi : cxx_module_bmi $src | $ddi_dd
  dyndep = $ddi_dd
build $obj : cxx_module_obj $bmi

下游不需要改动:dyndep 本来就让导入者依赖 gcm.cache/X.gcm,它们只是提前约 4 倍就绪。link 依赖 obj/*.m.o,仍然等全部对象。

唯一不显然的改动:dyndep 把依赖挂在扫描阶段记录的 -fdeps-target 上,也就是 obj/X.m.o。若不动它,导入依赖会去门禁那条只负责等待的边,而真正做编译的边在没有任何 BMI 的情况下起跑。所以扫描边的 -fdeps-target 要改指向 BMI。

⚠️ 改这个要小心:cxx_scan 规则把 $compile_target 用了两次 —— 一次 -fdeps-target=,一次 -o。改共享变量会把预处理输出对准 BMI 路径、有截断风险。必须拆成两个变量,只改 -fdeps-target。(原型里就是这么做的。)

2.3 两个会让方案「看起来没用」的实现陷阱

陷阱 1:分离出去的编译器继承了构建系统的 stdout/stderr 管道。 ninja 判定一条边结束的依据是管道 EOF,而不是直接子进程退出。第一阶段即使提前 exit 0,只要后台编译器还持有那个 fd,ninja 就认为边还在跑。原型第一次运行就栽在这里:BMI 边中位数 2018 ms(= 完整编译时长),看起来像「这个想法没用」,实际是测量被伪装成了 baseline。 ⇒ 后台进程的 stdout/stderr 必须重定向到文件,由第二阶段回放(否则编译器警告与错误静默消失)。

陷阱 2(已被实测缩小):-j 与编译器上限的关系。

我最初把原型第一次的 78.99s 归因于两件事:管道继承 -j 必须远大于上限」。后来把 -j 单独扫了一遍,结论是第二条基本不成立:

ninja -j 编译器上限 墙钟
32 32 37.84s ← 最快
64 32 38.23s
128 32 38.39s
192 32 39.06s

-j 越大反而略慢(多出来的休眠边只是调度开销)。也就是说那次 78.99s 几乎全部是陷阱 1,我把一个原因写成了两个。

正确的规则很简单:-j 取编译器上限即可;它不需要更大,也不应该更大。

2.4 进程生命周期:分离,但不脱离进程组

这是设计里最容易做错的一处。

需求只有一条:别占住 ninja 的管道。它要求 setsid()

保持在同一进程组的直接好处:ninja 收到 Ctrl-C 时会把 SIGINT 发给整个进程组,分离出去的编译器照样收到。若为了「干净」而 setsid(),反而要自己实现中断清理,并且会留下孤儿编译器继续吃满 CPU。

  • POSIX:fork → 子进程把 stdio 重定向到日志 → exec 编译器;不调用 setsid。父进程(spawn 阶段)轮询 BMI 后退出,编译器被 init 收养但仍在原进程组。
  • Windows:CreateProcess 不带 DETACHED_PROCESS,句柄重定向到文件,继承控制台 ⇒ Ctrl-C 正常。后续可加 Job Object 做强保证。

2.5 失败语义

编译器可能在 BMI 落盘之后才失败(codegen 阶段的 ICE —— xlings 迁移前的 xmake.lua 就因 GCC 15 在 -O1/-O2 + modules 上 tree-ssa-dce ICE 而强制 -Og)。此时:

  • 导入者已经拿着一份合法的 BMI 开始编译 —— 前端成功过,BMI 有效
  • --phase=wait 拿到非零退出码,该边失败,构建整体失败

失败仍然被报告,只是更晚,且下游做的是无害的额外工作。诊断顺序可能颠倒(下游错误先于上游失败出现),这一点要写进发布说明。

2.6 并发上限用什么实现

跨平台、无外部依赖:原子 mkdir 令牌目录mkdir 在 POSIX 与 Windows 上都是原子的「要么成功要么 EEXIST」。令牌在编译器退出时释放(由 spawn 阶段的后台段释放),不是在 wait 边被调度时 —— 否则上限会被 ninja 的调度延迟放大。

不会死锁:持有令牌的进程从不等待另一个令牌。


3. 优化 B —— 硬件感知的并发选择(可选项)

3.1 为什么 nproc + 2 是错的

两个原因,都不是「保守一点更好」这种口味问题:

  1. 内存:实测每编译 0.5–1 GB(§1 C3)。nproc+2 在 64 核/32 GB 上必然换页。
  2. 异构:i9-13900K 是 8 P-core + 16 E-core。nproc 报 32,但 E-core 编译吞吐约为 P-core 的 40%,SMT 兄弟核约 25%。把 32 当成 32 个同构核,会把有效并行度估高一倍以上。

3.2 auto 的取值

jobs_auto = clamp( min( cpu_budget, mem_budget ), 1, 64 )

cpu_budget = 异构 ? physical_cores            // E-core 不按整核计
                  : logical_cores
mem_budget = max( 1, (available_ram - reserve) / per_job_estimate )
             reserve            = 2 GiB
             per_job_estimate   = 768 MiB     // 实测中位 561 MB / 峰值 1057 MB
  • available(不是 total)内存:构建通常不是机器上唯一的东西
  • per_job_estimate可配置常量,不是猜测:它来自本仓库的实测,并且在文档里注明了来源,便于其他工程按自己的规模调整
  • 上限 64:再高时 ninja 自身的调度开销与文件系统争用开始显现

3.3 配置面

[build]
jobs = "auto"     # 或一个整数;缺省 = 当前行为(不传 -j,由 ninja 决定)
mcpp build --jobs N|auto

默认不变。 这是刻意的:改变默认并发会改变所有人的构建时长与内存占用,属于行为变更,应当先作为可选项验证一段时间。文档里把 auto 标为推荐值。

3.4 与优化 A 的关系

A 落地后仍然只需要一个数。§2.3 的实测表明 -j 取编译器上限就是最优,更大反而略慢,所以:

compiler_cap = jobs_auto
ninja_jobs   = compiler_cap        // 不放大

(我一度以为这里需要 cap * 6,那是把陷阱 1 的症状误记到了陷阱 2 上;扫描数据见 §2.3。)


4. 优化 C —— 关键路径感知的调度顺序

ninja 在多条就绪边之间的选择顺序是任意的。当图很窄时无所谓(现状后半段并发度 1.0),但 A 落地后图会变宽,此时先跑关键路径上的边就有价值。

mcpp 已经在扫描阶段拿到了完整模块图,算一次最长路径几乎免费。ninja 没有优先级 API,但可以通过边的声明顺序施加弱影响。

收益不确定,成本极低,排在 A 之后作为微调。


5. 优化 D —— 对象级缓存

codegen 占全部工作量的 77%bmi-equal 让 BMI 稳定之后,.o 也可按内容哈希缓存。mcpp 已有 ~/.mcpp/build-cache/v1/ 用于依赖包,扩展到根包即可。

只对重复的冷构建有效(切分支来回、revert、CI 缓存恢复),对首次冷构建无效。

⚠️ 历史教训:本仓库出现过「命中也 100% 重编」的假缓存,骗了三个月。验收判据必须是命中时确实跳过了编译,不是日志里出现 Cached


6. 明确不做

方向 为什么不做
降低优化档位 实测 -O0 相对 -O2 只快 1.75×,而产物运行时性能全丢
缩小 BMI / 降扇入 实测 import std(31.5 MB BMI)只多 4.8 ms —— GCC 导入本来就是惰性的
优化 scan / dyndep 阶段 合计 0.8% 的工作量
分布式编译(distcc/icecc) 关键路径 100% ⇒ 在 A 之前是负收益(只增加网络延迟)
-fmodule-only 两阶段 实测它照样跑完整个 codegen 再丢弃(15.93s vs 完整 15.95s)

7. 实施顺序与验收判据

# 动作 预期 验收判据
A1 mcpp compile-module --phase=spawn/wait + 拆边 + 扫描 -fdeps-target 重定向 78.6s → ~37s 产物字节一致;BMI 边中位数远小于 OBJ 边(否则第一阶段没有提前退出,测的是 baseline);构建后无残留编译器进程;Ctrl-C 不留孤儿
A2 Windows 侧(Job Object) 同上 Windows e2e 绿
B --jobs N|auto + [build] jobs(2026.8.12.1 已实施) 安全属性;A 之后转为性能属性 本机 auto-j24(异构 ⇒ 物理核 24,内存预算更大);默认行为不变;8 个单测覆盖公式的每条分支
C 关键路径优先的边序 微调 A 之后重测,无提升则回退
D 对象缓存 重复冷构建 命中时确实跳过编译,不是日志说了算

8. 与其他构建系统的对照(同一台机器、同一编译器)

bench/results/。要点:xmake 在同样的图形状下也是延迟瓶颈(它同样走 GCC 单阶段),所以 A 不是「追平 xmake」,而是两者都还没做的事


附录:2026-08-13 复测 —— 优化 A 的依据被重新确立,并纠正一处错误推理

上文写 A(BMI 提前释放)时,依据是一次原型测量。这次在 mcpp 自身 80s 冷构建上 重新逐条量过,结论是 A 成立,而且比原来写的更硬;但中间我先得出过一个相反且错误 的结论,过程值得记下来。

A.1 现状:100% 延迟受限

bench --analyze 于 mcpp 的 release 构建目录:

edges          : 426
makespan       : 79.79 s
work (sum dur) : 314.08 s
avg parallelism: 3.94 x  (of 32 hw threads)
critical path  : 79.73 s  = 100% of makespan

关键路径就是墙钟本身。 32 个硬件线程上平均并行度只有 3.94 —— 加核、加机器、 分布式编译全部无效。关键链 26 跳,几乎全是 cxx_module,其中 mcpp.build.prepare 单个模块 16.1s,占整个构建的 20%。

A.2 ⚠️ 错误推理:用 -fmodule-only 判定「codegen 占多少」

第一反应是量「只产 BMI」要多久:

prepare   BMI-only 16.18s   full 16.27s   → 99%
cli       BMI-only  5.50s   full  5.59s   → 98%
plan      BMI-only  5.36s   full  5.43s   → 99%

据此我一度判定 A 不成立:BMI 几乎就是全部成本,代码生成只有 1%,提前释放没有空间。

这是错的。 GCC 的 -fmodule-only 并不跳过后端,它跑完整条流水线、只是不写目标文件。 用它测「BMI 什么时候好」等于什么都没测。

A.3 正确判据:BMI 文件何时写完,以及下游能否用

三步,缺一不可:

  1. 何时出现 —— 轮询 .gcm:prepare 的 BMI 在 2.31s / 16.19s = 14% 处出现。 但「出现」不等于「写完」(GCC 早创建、可能持续写)。
  2. 何时写完 —— 轮询到大小连续 150ms 不变。快照 785488 字节,与编译结束后的成品 逐字节相同
  3. 是否可用 —— 把早期快照放回 gcm.cache/,编译一个真实下游导入者 (mcpp.build.execute):exit 0

采样关键链上最重的 8 个模块:

模块 full BMI 写完 占比
mcpp.build.prepare 16.20s 2.50s 15%
mcpp.cli 5.67s 2.22s 39%
mcpp.build.plan 5.47s 1.07s 20%
mcpp.build.compile_commands 4.74s 1.03s 22%
mcpp.build.execute 4.52s 1.19s 26%
mcpp.libs.toml 2.28s 0.53s 23%
mcpp.modgraph.scanner 3.23s 0.66s 20%
mcpp.build.ninja 3.65s 1.00s 27%

中位约 22%。下游在等的 78% 是它根本不需要的代码生成。

A.4 头寸

关键链 24 个模块节点合计 ~74.7s。若在 BMI 写完即解锁: 74.7 × 0.22 ≈ 16.4s + obj/main.o 4.78s + link 0.18s ≈ 21s。 此后构建转为吞吐受限,下限是 work / 线程数 = 314 / 32 ≈ 9.8s,按 60–70% 并行效率 落在 15–20s。综合预期 80s → 25–35s(2.3–3.2×)。

A.5 实施形状(未实施)

ninja 认为一条边完成 = 进程退出,所以必须让「BMI 好了」成为一个可观测事件:

  • 信号:GCC 的 -fmodule-mapper(P1184)在 BMI 落盘时发 MODULE-COMPILED。 这是设计好的机制,不需要轮询文件大小(轮询只适合做上面这种一次性测量)。
  • 边的形状:cxx_module 改为跑一个 mcpp 助手,它代管 mapper 协议,收到 MODULE-COMPILED把余下的 codegen 甩到后台并退出 0
  • 收口:cxx_link 前置一条 await-objects 边,等所有后台 codegen 结束。 链接本来就在最后,目标文件是并行完成的,所以这条边通常不阻塞。

⚠️ 三个已知坑:

  1. 甩到后台的子进程会继承 ninja 的管道 —— 上一次原型就栽在这里:BMI 边的耗时 被记成整条编译的耗时,数字变成 78.99s,看起来像「这个想法不成立」。 子进程的 stdio 必须重定向到文件。
  2. 失败会迟到 —— 后台 codegen 失败时,cxx_module 边已经报成功了。 await-objects 必须收集并复现每个失败,否则会变成链接期的一堆未定义符号。
  3. 作业槽会超订 —— ninja 以为边结束了,后台进程仍在吃 CPU。这在当前 3.94× 的并行度下是想要的,但在 --jobs 很大时需要重新标定。

A.6 顺带:两条不需要改引擎的路

  • mcpp.build.prepare 一个文件 16.2s,占 20%。 拆开它直接缩短关键链, 且不引入任何调度复杂度。
  • 换编译器。 clang 在同类工程上整体快约 2.4×(此前测量),而关键链的形状不变。