|
| 1 | +# mcpp 冷构建深度优化方案 |
| 2 | + |
| 3 | +> 2026-08-12 |
| 4 | +> 前置:[模块化构建性能深度分析](./2026-08-12-modular-build-performance-deep-analysis.md) · [bench 套件](./2026-08-12-bench-suite-architecture-and-plan.md) |
| 5 | +> 范围:**冷构建**(`mcpp clean && mcpp build`)。增量侧的级联抑制已在 2026.8.12.1 落地。 |
| 6 | +
|
| 7 | +--- |
| 8 | + |
| 9 | +## 0. 现状 |
| 10 | + |
| 11 | +`bench --project . --engines mcpp=<旧>,mcpp=<新> --scenarios cold`,mcpp 构建 mcpp 自身: |
| 12 | + |
| 13 | +| 版本 | 冷构建中位数 | |
| 14 | +|---|---| |
| 15 | +| 2026.8.11.3 | 78.70s | |
| 16 | +| 2026.8.12.1(含 `bmi-equal`) | 78.57s | |
| 17 | + |
| 18 | +**持平,而且必然持平。** `bmi-equal` 修的是「重编后 BMI 未变则不级联」;冷构建里没有「上一份 BMI」,这条路径压根不适用。冷构建要快,必须解决另一组约束。 |
| 19 | + |
| 20 | +--- |
| 21 | + |
| 22 | +## 1. 三个互相独立的约束 |
| 23 | + |
| 24 | +### C1 —— 关键路径 = 100% 墙钟 |
| 25 | + |
| 26 | +``` |
| 27 | +edges : 423 |
| 28 | +makespan : 76.54 s |
| 29 | +work (sum dur) : 303.36 s |
| 30 | +avg parallelism: 3.96 x (of 32 hw threads) |
| 31 | +critical path : 76.48 s = 100% of makespan |
| 32 | +``` |
| 33 | + |
| 34 | +后 55% 的时间里,32 个硬件线程上只有 **1 个**编译进程在跑。**这不是调度器不够聪明,是图本身就是一条链。** |
| 35 | + |
| 36 | +### C2 —— 关键路径上 77% 的时间在生产无人等待的 `.o` |
| 37 | + |
| 38 | +BMI 在编译进度 **22.8%** 处就被**原子 `rename`** 就位(`strace` 证实:此后 982 个系统调用没有一个再碰它),而 ninja 的依赖模型只认「边结束」。于是每个导入者都要多等一段纯 codegen。 |
| 39 | + |
| 40 | +### C3 —— mcpp 从不传 `-j`,ninja 用默认的 `nproc + 2` |
| 41 | + |
| 42 | +这在**本机**是 34,在 62 GB 内存上没问题。但实测单个模块编译的峰值常驻内存: |
| 43 | + |
| 44 | +| 模块 | 峰值 RSS | |
| 45 | +|---|---| |
| 46 | +| `build/prepare.cppm`(最重) | **1,057 MB** | |
| 47 | +| `build/plan.cppm`(中位偏上) | **561 MB** | |
| 48 | + |
| 49 | +⇒ 一台 64 核 / 32 GB 的机器会跑 66 路并发 × ~0.5–1 GB = **换页甚至 OOM**。`nproc + 2` 在核多内存少的机器上是**主动有害**的默认值。 |
| 50 | + |
| 51 | +> C3 与 C1/C2 正交:在图仍是一条链时,调低 `-j` 不会更慢(反正用不满),调高也不会更快。所以 C3 首先是**安全属性**,只有在 C1/C2 解决之后才变成性能属性。 |
| 52 | +
|
| 53 | +--- |
| 54 | + |
| 55 | +## 2. 优化 A —— BMI 落盘即释放下游(最大项) |
| 56 | + |
| 57 | +### 2.1 收益已实测,不是模拟 |
| 58 | + |
| 59 | +机械改写 `build.ninja`、拆边、同一编译器进程,`bench/proto-bmi-release/`: |
| 60 | + |
| 61 | +| 方案 | 墙钟 | 产物 | |
| 62 | +|---|---|---| |
| 63 | +| baseline(边完成即释放) | **77.42s** | 19,347,008 B | |
| 64 | +| **split(BMI 落盘即释放)** | **36.56s** | 19,347,008 B,可运行 | |
| 65 | + |
| 66 | +**2.12×,零额外 CPU**(每个模块仍然只有一个 `g++ -c`)。 |
| 67 | + |
| 68 | +### 2.2 图的形状 |
| 69 | + |
| 70 | +```ninja |
| 71 | +rule cxx_module_bmi # 编译器起跑,BMI 落盘即返回 |
| 72 | + command = $mcpp compile-module --phase=spawn --slot $slot --bmi $out --obj $obj_out -- $cxx ... |
| 73 | + restat = 1 |
| 74 | +rule cxx_module_obj # 等同一个编译器收尾,传播退出码 |
| 75 | + command = $mcpp compile-module --phase=wait --slot $slot --obj $out |
| 76 | + restat = 1 |
| 77 | +
|
| 78 | +build $bmi : cxx_module_bmi $src | $ddi_dd |
| 79 | + dyndep = $ddi_dd |
| 80 | +build $obj : cxx_module_obj $bmi |
| 81 | +``` |
| 82 | + |
| 83 | +下游**不需要改动**:dyndep 本来就让导入者依赖 `gcm.cache/X.gcm`,它们只是提前约 4 倍就绪。link 依赖 `obj/*.m.o`,仍然等全部对象。 |
| 84 | + |
| 85 | +**唯一不显然的改动**:dyndep 把依赖挂在扫描阶段记录的 `-fdeps-target` 上,也就是 `obj/X.m.o`。若不动它,**导入依赖会去门禁那条只负责等待的边,而真正做编译的边在没有任何 BMI 的情况下起跑**。所以扫描边的 `-fdeps-target` 要改指向 BMI。 |
| 86 | + |
| 87 | +⚠️ 改这个要小心:`cxx_scan` 规则把 `$compile_target` **用了两次** —— 一次 `-fdeps-target=`,一次 `-o`。改共享变量会把预处理输出对准 BMI 路径、有截断风险。必须拆成两个变量,只改 `-fdeps-target`。(原型里就是这么做的。) |
| 88 | + |
| 89 | +### 2.3 两个会让方案「看起来没用」的实现陷阱 |
| 90 | + |
| 91 | +**陷阱 1:分离出去的编译器继承了构建系统的 stdout/stderr 管道。** |
| 92 | +ninja 判定一条边结束的依据是**管道 EOF,而不是直接子进程退出**。第一阶段即使提前 `exit 0`,只要后台编译器还持有那个 fd,ninja 就认为边还在跑。原型第一次运行就栽在这里:BMI 边中位数 2018 ms(= 完整编译时长),看起来像「这个想法没用」,实际是**测量被伪装成了 baseline**。 |
| 93 | +⇒ 后台进程的 stdout/stderr 必须重定向到文件,由第二阶段回放(否则编译器警告与错误静默消失)。 |
| 94 | + |
| 95 | +**陷阱 2(已被实测缩小):`-j` 与编译器上限的关系。** |
| 96 | + |
| 97 | +我最初把原型第一次的 78.99s 归因于两件事:管道继承 **和** 「`-j` 必须远大于上限」。后来把 `-j` 单独扫了一遍,结论是**第二条基本不成立**: |
| 98 | + |
| 99 | +| ninja `-j` | 编译器上限 | 墙钟 | |
| 100 | +|---|---|---| |
| 101 | +| 32 | 32 | **37.84s** ← 最快 | |
| 102 | +| 64 | 32 | 38.23s | |
| 103 | +| 128 | 32 | 38.39s | |
| 104 | +| 192 | 32 | 39.06s | |
| 105 | + |
| 106 | +**`-j` 越大反而略慢**(多出来的休眠边只是调度开销)。也就是说那次 78.99s **几乎全部是陷阱 1**,我把一个原因写成了两个。 |
| 107 | + |
| 108 | +正确的规则很简单:**`-j` 取编译器上限即可**;它不需要更大,也不应该更大。 |
| 109 | + |
| 110 | +### 2.4 进程生命周期:分离,但**不脱离进程组** |
| 111 | + |
| 112 | +这是设计里最容易做错的一处。 |
| 113 | + |
| 114 | +需求只有一条:**别占住 ninja 的管道**。它**不**要求 `setsid()`。 |
| 115 | + |
| 116 | +保持在同一进程组的直接好处:ninja 收到 Ctrl-C 时会把 SIGINT 发给整个进程组,**分离出去的编译器照样收到**。若为了「干净」而 `setsid()`,反而要自己实现中断清理,并且会留下孤儿编译器继续吃满 CPU。 |
| 117 | + |
| 118 | +- **POSIX**:`fork` → 子进程把 stdio 重定向到日志 → `exec` 编译器;**不调用 `setsid`**。父进程(spawn 阶段)轮询 BMI 后退出,编译器被 init 收养但仍在原进程组。 |
| 119 | +- **Windows**:`CreateProcess` **不带** `DETACHED_PROCESS`,句柄重定向到文件,继承控制台 ⇒ Ctrl-C 正常。后续可加 Job Object 做强保证。 |
| 120 | + |
| 121 | +### 2.5 失败语义 |
| 122 | + |
| 123 | +编译器可能在 BMI 落盘**之后**才失败(codegen 阶段的 ICE —— xlings 迁移前的 xmake.lua 就因 GCC 15 在 `-O1/-O2` + modules 上 `tree-ssa-dce` ICE 而强制 `-Og`)。此时: |
| 124 | + |
| 125 | +- 导入者已经拿着一份**合法的** BMI 开始编译 —— 前端成功过,BMI 有效 |
| 126 | +- `--phase=wait` 拿到非零退出码,该边失败,构建整体失败 |
| 127 | + |
| 128 | +⇒ **失败仍然被报告,只是更晚**,且下游做的是无害的额外工作。诊断顺序可能颠倒(下游错误先于上游失败出现),这一点要写进发布说明。 |
| 129 | + |
| 130 | +### 2.6 并发上限用什么实现 |
| 131 | + |
| 132 | +跨平台、无外部依赖:**原子 `mkdir` 令牌目录**。`mkdir` 在 POSIX 与 Windows 上都是原子的「要么成功要么 EEXIST」。令牌在编译器**退出时**释放(由 spawn 阶段的后台段释放),不是在 `wait` 边被调度时 —— 否则上限会被 ninja 的调度延迟放大。 |
| 133 | + |
| 134 | +不会死锁:持有令牌的进程从不等待另一个令牌。 |
| 135 | + |
| 136 | +--- |
| 137 | + |
| 138 | +## 3. 优化 B —— 硬件感知的并发选择(可选项) |
| 139 | + |
| 140 | +### 3.1 为什么 `nproc + 2` 是错的 |
| 141 | + |
| 142 | +两个原因,都不是「保守一点更好」这种口味问题: |
| 143 | + |
| 144 | +1. **内存**:实测每编译 0.5–1 GB(§1 C3)。`nproc+2` 在 64 核/32 GB 上必然换页。 |
| 145 | +2. **异构**:i9-13900K 是 8 P-core + 16 E-core。`nproc` 报 32,但 E-core 编译吞吐约为 P-core 的 40%,SMT 兄弟核约 25%。**把 32 当成 32 个同构核,会把有效并行度估高一倍以上。** |
| 146 | + |
| 147 | +### 3.2 `auto` 的取值 |
| 148 | + |
| 149 | +``` |
| 150 | +jobs_auto = clamp( min( cpu_budget, mem_budget ), 1, 64 ) |
| 151 | +
|
| 152 | +cpu_budget = 异构 ? physical_cores // E-core 不按整核计 |
| 153 | + : logical_cores |
| 154 | +mem_budget = max( 1, (available_ram - reserve) / per_job_estimate ) |
| 155 | + reserve = 2 GiB |
| 156 | + per_job_estimate = 768 MiB // 实测中位 561 MB / 峰值 1057 MB |
| 157 | +``` |
| 158 | + |
| 159 | +- 用 **available**(不是 total)内存:构建通常不是机器上唯一的东西 |
| 160 | +- `per_job_estimate` 是**可配置常量**,不是猜测:它来自本仓库的实测,并且在文档里注明了来源,便于其他工程按自己的规模调整 |
| 161 | +- 上限 64:再高时 ninja 自身的调度开销与文件系统争用开始显现 |
| 162 | + |
| 163 | +### 3.3 配置面 |
| 164 | + |
| 165 | +```toml |
| 166 | +[build] |
| 167 | +jobs = "auto" # 或一个整数;缺省 = 当前行为(不传 -j,由 ninja 决定) |
| 168 | +``` |
| 169 | + |
| 170 | +``` |
| 171 | +mcpp build --jobs N|auto |
| 172 | +``` |
| 173 | + |
| 174 | +**默认不变。** 这是刻意的:改变默认并发会改变所有人的构建时长与内存占用,属于行为变更,应当先作为可选项验证一段时间。文档里把 `auto` 标为推荐值。 |
| 175 | + |
| 176 | +### 3.4 与优化 A 的关系 |
| 177 | + |
| 178 | +A 落地后仍然只需要**一个**数。§2.3 的实测表明 `-j` 取编译器上限就是最优,更大反而略慢,所以: |
| 179 | + |
| 180 | +``` |
| 181 | +compiler_cap = jobs_auto |
| 182 | +ninja_jobs = compiler_cap // 不放大 |
| 183 | +``` |
| 184 | + |
| 185 | +(我一度以为这里需要 `cap * 6`,那是把陷阱 1 的症状误记到了陷阱 2 上;扫描数据见 §2.3。) |
| 186 | + |
| 187 | +--- |
| 188 | + |
| 189 | +## 4. 优化 C —— 关键路径感知的调度顺序 |
| 190 | + |
| 191 | +ninja 在多条就绪边之间的选择顺序是任意的。当图很窄时无所谓(现状后半段并发度 1.0),但 **A 落地后图会变宽**,此时先跑关键路径上的边就有价值。 |
| 192 | + |
| 193 | +mcpp 已经在扫描阶段拿到了完整模块图,算一次最长路径几乎免费。ninja 没有优先级 API,但可以通过**边的声明顺序**施加弱影响。 |
| 194 | + |
| 195 | +收益不确定,成本极低,排在 A 之后作为微调。 |
| 196 | + |
| 197 | +--- |
| 198 | + |
| 199 | +## 5. 优化 D —— 对象级缓存 |
| 200 | + |
| 201 | +codegen 占全部工作量的 **77%**。`bmi-equal` 让 BMI 稳定之后,`.o` 也可按内容哈希缓存。mcpp 已有 `~/.mcpp/build-cache/v1/` 用于依赖包,扩展到根包即可。 |
| 202 | + |
| 203 | +**只对重复的冷构建有效**(切分支来回、revert、CI 缓存恢复),对首次冷构建无效。 |
| 204 | + |
| 205 | +⚠️ 历史教训:本仓库出现过「命中也 100% 重编」的假缓存,骗了三个月。验收判据必须是**命中时确实跳过了编译**,不是日志里出现 `Cached`。 |
| 206 | + |
| 207 | +--- |
| 208 | + |
| 209 | +## 6. 明确不做 |
| 210 | + |
| 211 | +| 方向 | 为什么不做 | |
| 212 | +|---|---| |
| 213 | +| 降低优化档位 | 实测 `-O0` 相对 `-O2` 只快 **1.75×**,而产物运行时性能全丢 | |
| 214 | +| 缩小 BMI / 降扇入 | 实测 `import std`(31.5 MB BMI)只多 **4.8 ms** —— GCC 导入本来就是惰性的 | |
| 215 | +| 优化 scan / dyndep 阶段 | 合计 **0.8%** 的工作量 | |
| 216 | +| 分布式编译(distcc/icecc) | 关键路径 100% ⇒ **在 A 之前是负收益**(只增加网络延迟) | |
| 217 | +| `-fmodule-only` 两阶段 | 实测它**照样跑完整个 codegen 再丢弃**(15.93s vs 完整 15.95s) | |
| 218 | + |
| 219 | +--- |
| 220 | + |
| 221 | +## 7. 实施顺序与验收判据 |
| 222 | + |
| 223 | +| # | 动作 | 预期 | 验收判据 | |
| 224 | +|---|---|---|---| |
| 225 | +| **A1** | `mcpp compile-module --phase=spawn/wait` + 拆边 + 扫描 `-fdeps-target` 重定向 | 78.6s → **~37s** | 产物字节一致;**BMI 边中位数远小于 OBJ 边**(否则第一阶段没有提前退出,测的是 baseline);构建后无残留编译器进程;Ctrl-C 不留孤儿 | |
| 226 | +| **A2** | Windows 侧(Job Object) | 同上 | Windows e2e 绿 | |
| 227 | +| **B** ✅ | `--jobs N\|auto` + `[build] jobs`(**2026.8.12.1 已实施**) | 安全属性;A 之后转为性能属性 | 本机 `auto` → `-j24`(异构 ⇒ 物理核 24,内存预算更大);默认行为不变;8 个单测覆盖公式的每条分支 | |
| 228 | +| **C** | 关键路径优先的边序 | 微调 | A 之后重测,无提升则回退 | |
| 229 | +| **D** | 对象缓存 | 重复冷构建 | **命中时确实跳过编译**,不是日志说了算 | |
| 230 | + |
| 231 | +--- |
| 232 | + |
| 233 | +## 8. 与其他构建系统的对照(同一台机器、同一编译器) |
| 234 | + |
| 235 | +见 `bench/results/`。要点:xmake 在**同样的图形状**下也是延迟瓶颈(它同样走 GCC 单阶段),所以 A 不是「追平 xmake」,而是**两者都还没做的事**。 |
0 commit comments