|
| 1 | +# mcpp 构建性能:架构层面的分析与方案(2026-08-13) |
| 2 | + |
| 3 | +目标:`mcpp clean && mcpp build --release` 从 **79.9s** 降到 **50s 以内**。 |
| 4 | + |
| 5 | +本文只用实测数字。每个结论后面都注明它是**测出来的**还是**推算的**,推算的给出推算方式。 |
| 6 | + |
| 7 | +--- |
| 8 | + |
| 9 | +## 0. 基线 |
| 10 | + |
| 11 | +| | | |
| 12 | +|---|---| |
| 13 | +| 工程 | mcpp 自身,138 个模块接口单元 + `src/main.cpp`,57k 行,全部 `import std;` | |
| 14 | +| 主机 | 13th Gen Intel i9-13900K,32 逻辑 / 24 物理(异构),64 GiB | |
| 15 | +| 工具链 | `gcc@16.1.0`(mcpp 自带载荷),release = `-std=c++23 -fmodules -O2` | |
| 16 | +| 冷构建 | **79.92s** | |
| 17 | + |
| 18 | +对照(同机、同源码,构建描述在 `bench/projects/mcpp/`): |
| 19 | + |
| 20 | +| 引擎 | 冷构建 | |
| 21 | +|---|---| |
| 22 | +| mcpp | 80.0s | |
| 23 | +| cmake 4.0.2 + ninja | 94.5s | |
| 24 | +| xmake v3.0.7 | 94.6s | |
| 25 | + |
| 26 | +**四个引擎都在 15% 以内。** 这说明瓶颈不在调度实现,而在所有引擎共有的那个东西 —— 图的形状。 |
| 27 | + |
| 28 | +--- |
| 29 | + |
| 30 | +## 1. 结构性事实:100% 关键路径 |
| 31 | + |
| 32 | +`bench --analyze`: |
| 33 | + |
| 34 | +``` |
| 35 | +edges : 426 |
| 36 | +makespan : 79.79 s |
| 37 | +work (sum dur) : 314.08 s |
| 38 | +avg parallelism: 3.94 x (of 32 hw threads) |
| 39 | +critical path : 79.73 s = 100% of makespan |
| 40 | +``` |
| 41 | + |
| 42 | +**关键路径等于墙钟本身。** 32 个硬件线程上平均只跑到 3.94 路并行。 |
| 43 | + |
| 44 | +直接推论,每一条都实测印证过: |
| 45 | + |
| 46 | +* **加核无效。** `-j4/8/16/32` = 101.3 / 81.0 / 80.0 / 79.9s。从 8 到 32 提升 1.4%。 |
| 47 | +* **分布式编译无效。** 关键路径是串行依赖,不是资源不足。 |
| 48 | +* **换引擎无效。** cmake/xmake 走同一条链,差异 ≤ 18%。 |
| 49 | + |
| 50 | +**换了编译器,形状也不变。** clang 下重测: |
| 51 | + |
| 52 | +``` |
| 53 | +makespan : 32.20 s ← gcc 79.79 s |
| 54 | +work (sum dur) : 125.49 s ← gcc 314.08 s |
| 55 | +avg parallelism: 3.90 x ← gcc 3.94 x |
| 56 | +critical path : 32.15 s = 100% of makespan |
| 57 | +``` |
| 58 | + |
| 59 | +clang 不是调度得更好,而是**每个模块便宜 2.5 倍**;100% 关键路径、3.9 路并行这两个结构特征一模一样。 |
| 60 | +所以「换 clang」是把常数压小,不是把问题解决 —— 工程再长大一倍,它同样会顶到墙。 |
| 61 | + |
| 62 | +--- |
| 63 | + |
| 64 | +## 2. 成本解剖:一次模块编译的钱花在哪 |
| 65 | + |
| 66 | +`-ftime-report`,`src/build/prepare.cppm`(16.2s,链上最贵的一跳): |
| 67 | + |
| 68 | +| 阶段 | 秒 | 占比 | |
| 69 | +|---|---|---| |
| 70 | +| **opt and generate** | 14.08 | **86%** | |
| 71 | +| ├ callgraph functions expansion | 11.00 | 67% | |
| 72 | +| └ callgraph ipa passes | 2.77 | 17% | |
| 73 | +| phase parsing | 1.32 | 8% | |
| 74 | +| template instantiation | 0.95 | 6% | |
| 75 | +| module import | 0.51 | 3% | |
| 76 | + |
| 77 | +**一次模块接口编译的 86% 是代码生成 —— 而它的导入者一个字节都用不到。** |
| 78 | + |
| 79 | +这一点有独立的三重验证(见 §5.2 的方法):BMI 在编译的 **15%–39%(中位 ~22%)** 处 |
| 80 | +就已经原子落盘、与最终产物**逐字节相同**、且**真实下游导入者能用它编译成功**。 |
| 81 | + |
| 82 | +⚠️ 一个错误推理值得记下来:我先用 `-fmodule-only` 量「只产 BMI 要多久」,得到 99%, |
| 83 | +据此判定「codegen 只占 1%,没有优化空间」。**这是错的** —— GCC 的 `-fmodule-only` |
| 84 | +不跳过后端,只是不写目标文件。这个 flag 不能用来回答这个问题。 |
| 85 | + |
| 86 | +--- |
| 87 | + |
| 88 | +## 3. 图的形状:19 跳的导入链 |
| 89 | + |
| 90 | +按源码 `import` 关系建图,用实测编译耗时加权求最长路径: |
| 91 | + |
| 92 | +``` |
| 93 | +最长导入链 = 19 个模块,链上编译耗时合计 74.6s(全图 306.0s) |
| 94 | +
|
| 95 | + 1.02s mcpp.platform.env |
| 96 | + 2.84s mcpp.platform.process |
| 97 | + 0.02s mcpp.platform ← 纯 re-export 门面 |
| 98 | + 1.98s mcpp.manifest.types |
| 99 | + 5.21s mcpp.manifest.toml |
| 100 | + 0.02s mcpp.manifest ← 纯 re-export 门面 |
| 101 | + 1.82s mcpp.platform.xlings.runtime_selection |
| 102 | + 5.65s mcpp.platform.runtime_binding |
| 103 | + 4.03s mcpp.platform.elf_runtime |
| 104 | + 2.06s mcpp.build.loader_contract |
| 105 | + 6.08s mcpp.build.plan |
| 106 | + 2.82s mcpp.build.flags |
| 107 | + 4.76s mcpp.build.compile_commands |
| 108 | + 3.69s mcpp.build.ninja |
| 109 | + 16.41s mcpp.build.prepare ← 22% of the chain |
| 110 | + 4.70s mcpp.build.execute |
| 111 | + 2.21s mcpp.build.configure |
| 112 | + 3.49s mcpp.cli.cmd_build |
| 113 | + 5.77s mcpp.cli |
| 114 | +``` |
| 115 | +(尾部还有 `obj/main.o` 4.78s + link 0.18s,不在模块链内但在关键路径上。) |
| 116 | + |
| 117 | +两个观察: |
| 118 | + |
| 119 | +1. **这条链是真实的分层**:platform → manifest → build → cli。它不是偶然的耦合, |
| 120 | + 压平它等于破坏架构。**所以「重构掉这条链」不是一个可行方案。** |
| 121 | +2. **成本是摊开的,不是集中的。** 142 次模块编译:中位 1.99s, |
| 122 | + top5 占 13%、top10 占 21%、top20 占 35%;44 个 <1s,66 个 1–3s,30 个 3–6s,2 个 >6s。 |
| 123 | + 只有 `build.prepare`(16.4s)是真离群。 |
| 124 | + **推论:「把最胖的模块拆了」不是通用解**,它只在那一跳上有效。 |
| 125 | + |
| 126 | +--- |
| 127 | + |
| 128 | +## 4. 四条杠杆 |
| 129 | + |
| 130 | +| | 杠杆 | 冷构建 | 依据 | 改动面 | |
| 131 | +|---|---|---|---|---| |
| 132 | +| **L1** | 默认工具链换 clang | 79.9 → **32.2s**(2.48×) | **实测** | 一行 manifest | |
| 133 | +| **L2** | 引擎:BMI 落盘即释放下游 | 80.5 → **39.2s**(2.05×) | **实测**(原型 A/B) | 引擎中等 | |
| 134 | +| **L3** | 源码:定义移出接口单元 | 链 74.6 → ~10.4s | 推算(§2 的 86%) | 138 个模块 | |
| 135 | +| **L4** | 源码:拆 `build.prepare` | 链 −8~11s | 推算 | 一个模块 | |
| 136 | + |
| 137 | +L1 与 L2 **可叠加**(一个压常数、一个改形状),叠加后推算 ~15s。 |
| 138 | + |
| 139 | +### L1 —— 换 clang(实测 2.48×) |
| 140 | + |
| 141 | +零引擎改动,单独就达标。但它**不改变形状**(§1),而且有三个必须先回答的问题: |
| 142 | + |
| 143 | +* mcpp 在三平台上的 llvm 载荷是否都可用、版本是否统一(目前 `windows = "llvm@20.1.7"`, |
| 144 | + 与 Linux/macOS 的 22.1.8 不同)。 |
| 145 | +* 换默认工具链会让**所有已发布包的指纹失效**,全生态一次性重编。 |
| 146 | +* ABI:`-static-libstdc++` 与 libc++/libstdc++ 的选择;共享库不得内嵌 C++ 运行时 |
| 147 | + (已知问题,见 `origin-precedence-and-shared-lib-cxx-runtime`)。 |
| 148 | + |
| 149 | +**这是一个生态决策,不是性能决策。** 它应当单独立项。 |
| 150 | + |
| 151 | +### L2 —— BMI 落盘即释放下游(实测 2.05×) |
| 152 | + |
| 153 | +**这是唯一一条既改变形状、又不需要改源码结构的路。** |
| 154 | + |
| 155 | +依据(§2):GCC 把 BMI 写到 `<name>.gcm~` 然后 `rename()` 到位 —— strace 证实, |
| 156 | +**原子发布**。所以「最终路径出现」就是「BMI 完整且可用」的精确信号: |
| 157 | +不需要 P1184 mapper 协议,不需要「大小连续 N 毫秒不变」这种启发式。 |
| 158 | + |
| 159 | +形状:每个模块接口单元拆成两条 ninja 边,**由同一个编译器进程驱动**: |
| 160 | + |
| 161 | +``` |
| 162 | + cxx_module_bmi 编译器启动 → BMI 落盘 → 本边退出 0(codegen 仍在后台跑) |
| 163 | + cxx_module_obj 等待该进程结束 → 回放它的输出 → 传播退出码 |
| 164 | + 下游 import 只依赖 bmi 边;link 依赖 obj 边 |
| 165 | +``` |
| 166 | + |
| 167 | +原型 A/B(同构建目录、同编译器、同 flags、同编译器并发上限、同源码集, |
| 168 | +**唯一差异是图的形状**): |
| 169 | + |
| 170 | +``` |
| 171 | +baseline rc=0 wall=80.51s ninja -j32 compilers<=32 binary=19362456 |
| 172 | +split rc=0 wall=39.23s ninja -j192 compilers<=32 binary=19362456 |
| 173 | +BMI 边: n=142 中位 940ms OBJ 边: n=143 中位 3091ms |
| 174 | +产物可运行;残留游离编译器进程 0 |
| 175 | +``` |
| 176 | + |
| 177 | +⚠️ **四个已知坑,全部踩过:** |
| 178 | + |
| 179 | +1. **后台子进程会继承 ninja 的管道。** ninja 认为一条边结束是**管道 EOF**,不是直接子进程退出。 |
| 180 | + 继承管道会让提前退出**完全不可见**,每条 BMI 边被记成整条编译的耗时 —— 第一次原型 |
| 181 | + 就是这样得出「这个想法不成立」的。子进程的 stdio 必须重定向到文件,由 obj 边回放。 |
| 182 | +2. **`ninja -j` 必须远大于编译器并发上限。** 编译器一旦脱离,就不再占 ninja 的槽; |
| 183 | + 若 `-j` 等于上限,槽会被「正在睡觉的边」占满、就绪前沿饿死,调度退化成 baseline。 |
| 184 | + 两条边的并发必须用**独立的信号量**(原子 `mkdir` 令牌)来限,而不是靠 `-j`。 |
| 185 | +3. **失败会迟到。** BMI 落盘之后 codegen 才失败时,模块边已经报成功了。 |
| 186 | + obj 边必须收集并**复现**每一个失败,否则会变成链接期一堆看不懂的未定义符号。 |
| 187 | +4. **图必须自己声明形态。** `build.ninja` 是共享可变状态,快路径会重放它。 |
| 188 | + 拆分与否必须写进 `# mcpp:graph=` 那一行,否则换了开关之后快路径会重放旧形状的图 |
| 189 | + (这正是 #387/#407 的形状)。 |
| 190 | + |
| 191 | +**还有一个附带收益**:现在的 BMI 等价性判断是写在 ninja 命令里的一段 POSIX shell, |
| 192 | +**Windows 上整段跳过**。把它移进 `mcpp` 子命令后,Windows 也能享受级联抑制。 |
| 193 | + |
| 194 | +### L3 —— 把定义移出接口单元(推算,链 74.6 → ~10.4s) |
| 195 | + |
| 196 | +§2 说一次接口编译 86% 是 codegen。这些 codegen 之所以发生在**接口单元**里, |
| 197 | +是因为定义写在接口里。移到实现单元后: |
| 198 | + |
| 199 | +* 接口单元只剩 parse + 实例化 ≈ 现成本的 14%,**它们才是链上的节点**; |
| 200 | +* codegen 搬到实现单元 —— 它们是 DAG 的**叶子**(没人 import),完全可并行; |
| 201 | +* 总 work 不变,关键路径推算 74.6 × 0.14 ≈ 10.4s + `main.o` 4.78s ≈ **15s**, |
| 202 | + 之后转为吞吐受限(314s / 24 线程 ≈ 13s)。 |
| 203 | + |
| 204 | +这与 bench 的 `modules-impl` 变体测的是同一件事。**但它要动 138 个模块**, |
| 205 | +是一次跨越整个代码库的重构,不能一次做完,也不该为了性能而牺牲可读性 —— |
| 206 | +它应当作为**新代码的书写约定**逐步生效,并优先用在**链上那 19 个模块**。 |
| 207 | + |
| 208 | +### L4 —— 拆 `build.prepare`(推算,链 −8~11s) |
| 209 | + |
| 210 | +16.41s,占链的 22%,是唯一的真离群点。拆成互不依赖的兄弟模块可直接缩短关键路径。 |
| 211 | +**⚠️ 拆成链式的两个模块等于什么都没做** —— 必须是兄弟。 |
| 212 | + |
| 213 | +--- |
| 214 | + |
| 215 | +## 5. 建议顺序 |
| 216 | + |
| 217 | +1. **先做 L2。** 唯一改变形状、且不需要改源码结构的路;实测 2.05×,单独就把 80s 打到 39s, |
| 218 | + 达成 <50s 目标。附带把级联抑制带到 Windows。 |
| 219 | +2. **L4 紧随其后。** 一个模块的改动,收益可直接测量,且与 L2 叠加。 |
| 220 | +3. **L1 单独立项。** 是生态决策(指纹失效、三平台载荷、ABI),不该混进性能 PR。 |
| 221 | +4. **L3 作为约定长期生效**,优先施加于链上的 19 个模块。 |
| 222 | + |
| 223 | +## 6. 明确不做 |
| 224 | + |
| 225 | +* **加核 / 更大的 `-j` / 分布式编译。** 已实测:`-j8 → -j32` 只快 1.4%。 |
| 226 | +* **压平模块分层。** §3:那条链是真实的架构分层,不是偶然耦合。 |
| 227 | +* **为了性能降低优化档。** 改的是产物,不是构建。 |
| 228 | +* **缓存自己的 BMI 跨构建复用。** 冷构建的定义就是没有缓存;这条对 §0 的目标无效。 |
| 229 | + |
| 230 | +--- |
| 231 | + |
| 232 | +## 7. 方法学附注 |
| 233 | + |
| 234 | +### 7.1 判据必须是「下游能不能用」,不是「文件在不在」 |
| 235 | + |
| 236 | +`.gcm` **出现**在编译的 14% 处,但「出现」不等于「写完」。三步缺一不可: |
| 237 | + |
| 238 | +1. 轮询到大小连续 150ms 不变 → 快照; |
| 239 | +2. `cmp` 快照与编译结束后的成品 → **逐字节相同**; |
| 240 | +3. 把快照放回 `gcm.cache/`,编译一个**真实下游导入者** → **exit 0**。 |
| 241 | + |
| 242 | +只做第 1 步会把「文件被创建」当成「BMI 可用」。 |
| 243 | + |
| 244 | +### 7.2 对照组 |
| 245 | + |
| 246 | +「改函数体后 BMI 差 2 字节」看起来像铁证。跑一次**同一份源码编译两次**的对照: |
| 247 | +差的是**同样两个偏移**,上下文是 `buildtime:` / `localtime:` 的秒位。 |
| 248 | +没有这个对照就会得出相反结论。 |
| 249 | + |
| 250 | +### 7.3 关键路径必须按拓扑序松弛 |
| 251 | + |
| 252 | +用栈式 DFS 求最长路径(带防环)会把 76.5s / 26 节点读成 33.9s / 10 节点, |
| 253 | +把「100% 关键路径」读成「44%」,结论完全反过来。必须用 Kahn 拓扑序松弛。 |
0 commit comments