Skip to content

Commit 7fb0a42

Browse files
committed
docs: architecture-level build performance analysis and plan (2026-08-13)
1 parent 05cd005 commit 7fb0a42

2 files changed

Lines changed: 255 additions & 1 deletion

File tree

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
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 拓扑序松弛。

bench/proto-bmi-release/run_proto.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
# One compiler process per module in both arms — total CPU work is identical.
99
set -u
1010
PROTO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
11-
R="$(cd "$PROTO/../../.." && pwd)"
11+
# bench/proto-bmi-release -> two levels up is the repository root.
12+
R="$(cd "$PROTO/../.." && pwd)"
1213
NINJA=$(command -v ninja)
1314
J=${J:-$(nproc)}
1415

0 commit comments

Comments
 (0)