Skip to content

Latest commit

 

History

History
274 lines (198 loc) · 12.4 KB

File metadata and controls

274 lines (198 loc) · 12.4 KB

mcpp 构建性能:架构层面的分析与方案(2026-08-13)

目标:mcpp clean && mcpp build --release79.9s 降到 50s 以内

本文只用实测数字。每个结论后面都注明它是测出来的还是推算的,推算的给出推算方式。


0. 基线

工程 mcpp 自身,138 个模块接口单元 + src/main.cpp,57k 行,全部 import std;
主机 13th Gen Intel i9-13900K,32 逻辑 / 24 物理(异构),64 GiB
工具链 gcc@16.1.0(mcpp 自带载荷),release = -std=c++23 -fmodules -O2
冷构建 79.92s

对照(同机、同源码,构建描述在 bench/projects/mcpp/):

引擎 冷构建
mcpp 80.0s
cmake 4.0.2 + ninja 94.5s
xmake v3.0.7 94.6s

四个引擎都在 15% 以内。 这说明瓶颈不在调度实现,而在所有引擎共有的那个东西 —— 图的形状。


1. 结构性事实:100% 关键路径

bench --analyze:

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 路并行。

直接推论,每一条都实测印证过:

  • 加核无效。 -j4/8/16/32 = 101.3 / 81.0 / 80.0 / 79.9s。从 8 到 32 提升 1.4%。
  • 分布式编译无效。 关键路径是串行依赖,不是资源不足。
  • 换引擎无效。 cmake/xmake 走同一条链,差异 ≤ 18%。

换了编译器,形状也不变。 clang 下重测:

makespan       : 32.20 s       ← gcc 79.79 s
work (sum dur) : 125.49 s      ← gcc 314.08 s
avg parallelism: 3.90 x        ← gcc 3.94 x
critical path  : 32.15 s  = 100% of makespan

clang 不是调度得更好,而是每个模块便宜 2.5 倍;100% 关键路径、3.9 路并行这两个结构特征一模一样。 所以「换 clang」是把常数压小,不是把问题解决 —— 工程再长大一倍,它同样会顶到墙。


2. 成本解剖:一次模块编译的钱花在哪

-ftime-report,src/build/prepare.cppm(16.2s,链上最贵的一跳):

阶段 占比
opt and generate 14.08 86%
├ callgraph functions expansion 11.00 67%
└ callgraph ipa passes 2.77 17%
phase parsing 1.32 8%
template instantiation 0.95 6%
module import 0.51 3%

一次模块接口编译的 86% 是代码生成 —— 而它的导入者一个字节都用不到。

这一点有独立的三重验证(见 §5.2 的方法):BMI 在编译的 15%–39%(中位 ~22%) 处 就已经原子落盘、与最终产物逐字节相同、且真实下游导入者能用它编译成功

⚠️ 一个错误推理值得记下来:我先用 -fmodule-only 量「只产 BMI 要多久」,得到 99%, 据此判定「codegen 只占 1%,没有优化空间」。这是错的 —— GCC 的 -fmodule-only 不跳过后端,只是不写目标文件。这个 flag 不能用来回答这个问题。


3. 图的形状:19 跳的导入链

按源码 import 关系建图,用实测编译耗时加权求最长路径:

最长导入链 = 19 个模块,链上编译耗时合计 74.6s(全图 306.0s)

  1.02s  mcpp.platform.env
  2.84s  mcpp.platform.process
  0.02s  mcpp.platform                      ← 纯 re-export 门面
  1.98s  mcpp.manifest.types
  5.21s  mcpp.manifest.toml
  0.02s  mcpp.manifest                      ← 纯 re-export 门面
  1.82s  mcpp.platform.xlings.runtime_selection
  5.65s  mcpp.platform.runtime_binding
  4.03s  mcpp.platform.elf_runtime
  2.06s  mcpp.build.loader_contract
  6.08s  mcpp.build.plan
  2.82s  mcpp.build.flags
  4.76s  mcpp.build.compile_commands
  3.69s  mcpp.build.ninja
 16.41s  mcpp.build.prepare                 ← 22% of the chain
  4.70s  mcpp.build.execute
  2.21s  mcpp.build.configure
  3.49s  mcpp.cli.cmd_build
  5.77s  mcpp.cli

(尾部还有 obj/main.o 4.78s + link 0.18s,不在模块链内但在关键路径上。)

两个观察:

  1. 这条链是真实的分层:platform → manifest → build → cli。它不是偶然的耦合, 压平它等于破坏架构。所以「重构掉这条链」不是一个可行方案。
  2. 成本是摊开的,不是集中的。 142 次模块编译:中位 1.99s, top5 占 13%、top10 占 21%、top20 占 35%;44 个 <1s,66 个 1–3s,30 个 3–6s,2 个 >6s。 只有 build.prepare(16.4s)是真离群。 推论:「把最胖的模块拆了」不是通用解,它只在那一跳上有效。

4. 四条杠杆

杠杆 冷构建 依据 改动面
L1 默认工具链换 clang 79.9 → 32.2s(2.48×) 实测 一行 manifest
L2 引擎:下游在 BMI 可用时即开始 gcc 80.5 → 39.2s(2.05×) 实测(原型 A/B) 引擎中等
L3 源码:定义移出接口单元 链 74.6 → ~10.4s 推算(§2 的 86%) 138 个模块
L4 源码:拆 build.prepare 链 −8~11s 推算 一个模块

L1 与 L2 可叠加(一个压常数、一个改形状),叠加后推算 ~15s。

L1 —— 换 clang(实测 2.48×)

零引擎改动,单独就达标。但它不改变形状(§1),而且有三个必须先回答的问题:

  • mcpp 在三平台上的 llvm 载荷是否都可用、版本是否统一(目前 windows = "llvm@20.1.7", 与 Linux/macOS 的 22.1.8 不同)。
  • 换默认工具链会让所有已发布包的指纹失效,全生态一次性重编。
  • ABI:-static-libstdc++ 与 libc++/libstdc++ 的选择;共享库不得内嵌 C++ 运行时 (已知问题,见 origin-precedence-and-shared-lib-cxx-runtime)。

这是一个生态决策,不是性能决策。 它应当单独立项。

L2 —— 让下游在 BMI 可用时就开始,而不是等编译器退出

这是唯一一条既改变形状、又不需要改源码结构的路。

⚠️ 这一节的形状改过一次。 最初写成「POSIX 用 fork 甩开 codegen,Windows 不支持」, 被指出「cmake / mcpp / xmake / bazel 不都是跨平台的吗」。追下去发现两件事, 它们把方案从「一个技巧勉强套两个编译器」改成了按编译器族选机制:

编译器 BMI 可用时刻 机制 可移植性
GCC 16.1 编译的 ~22% 原子 rename + 甩开 codegen 需要一个比本进程活得久的监督进程
Clang 22.1 57% 原生两阶段,两条普通 ninja 边 完全可移植,零进程把戏

Clang 实测:--precompile 0.78s、-c.pcm 0.70s,两阶段总 CPU 比单阶段(1.36s) 多 9.6%,而下游解锁点从 100% 提前到 57%

而且 clang 不能用 GCC 那套:strace 证实它以 O_TRUNC 直接写最终路径,没有 rename —— 「看文件出现」对它不成立,读者会读到写了一半的 .pcm。反过来,GCC 没有便宜的两阶段 (-fmodule-only 要 99% 的时间,见 §2 的错误推理)。两条路互为补集,不是二选一。

策略落在已有的 BmiTraits(src/toolchain/model.cppm)里,和 moduleOutputPrefixbmiSearchPrefix 并列 —— 那正是「同一个决策只推导一次」的位置:

  clang → TwoPhase       cxx_precompile: x.cppm -> x.pcm
                         cxx_object_pcm: x.pcm  -> x.o
  gcc   → DetachCodegen  cxx_module_bmi: 编译器启动 → BMI 原子落盘 → 本边退出 0
                         cxx_module_obj: 等该进程结束 → 回放输出 → 传播退出码
  msvc  → None(待调研:/ifcOnly 是否便宜、.ifc 是否原子发布)

两种形状下,下游 import 只依赖 BMI 边,link 依赖 object 边

「比本进程活得久的进程」用派生一个 mcpp 监督子进程实现,不用 fork() —— 派生进程在 Windows 上同样成立,所以 DetachCodegen 也不是 POSIX 专属; 它的前提(BMI 原子发布)才是编译器专属的。

原型 A/B(同构建目录、同编译器、同 flags、同编译器并发上限、同源码集, 唯一差异是图的形状):

baseline   rc=0  wall=80.51s  ninja -j32   compilers<=32  binary=19362456
split      rc=0  wall=39.23s  ninja -j192  compilers<=32  binary=19362456
BMI 边: n=142 中位 940ms      OBJ 边: n=143 中位 3091ms
产物可运行;残留游离编译器进程 0

⚠️ 四个已知坑,全部踩过:

  1. 后台子进程会继承 ninja 的管道。 ninja 认为一条边结束是管道 EOF,不是直接子进程退出。 继承管道会让提前退出完全不可见,每条 BMI 边被记成整条编译的耗时 —— 第一次原型 就是这样得出「这个想法不成立」的。子进程的 stdio 必须重定向到文件,由 obj 边回放。
  2. ninja -j 必须远大于编译器并发上限。 编译器一旦脱离,就不再占 ninja 的槽; 若 -j 等于上限,槽会被「正在睡觉的边」占满、就绪前沿饿死,调度退化成 baseline。 两条边的并发必须用独立的信号量(原子 mkdir 令牌)来限,而不是靠 -j
  3. 失败会迟到。 BMI 落盘之后 codegen 才失败时,模块边已经报成功了。 obj 边必须收集并复现每一个失败,否则会变成链接期一堆看不懂的未定义符号。
  4. 图必须自己声明形态。 build.ninja 是共享可变状态,快路径会重放它。 拆分与否必须写进 # mcpp:graph= 那一行,否则换了开关之后快路径会重放旧形状的图 (这正是 #387/#407 的形状)。

还有一个附带收益:现在的 BMI 等价性判断是写在 ninja 命令里的一段 POSIX shell, Windows 上整段跳过。把它移进 mcpp 子命令后,Windows 也能享受级联抑制。

L3 —— 把定义移出接口单元(推算,链 74.6 → ~10.4s)

§2 说一次接口编译 86% 是 codegen。这些 codegen 之所以发生在接口单元里, 是因为定义写在接口里。移到实现单元后:

  • 接口单元只剩 parse + 实例化 ≈ 现成本的 14%,它们才是链上的节点;
  • codegen 搬到实现单元 —— 它们是 DAG 的叶子(没人 import),完全可并行;
  • 总 work 不变,关键路径推算 74.6 × 0.14 ≈ 10.4s + main.o 4.78s ≈ 15s, 之后转为吞吐受限(314s / 24 线程 ≈ 13s)。

这与 bench 的 modules-impl 变体测的是同一件事。但它要动 138 个模块, 是一次跨越整个代码库的重构,不能一次做完,也不该为了性能而牺牲可读性 —— 它应当作为新代码的书写约定逐步生效,并优先用在链上那 19 个模块

L4 —— 拆 build.prepare(推算,链 −8~11s)

16.41s,占链的 22%,是唯一的真离群点。拆成互不依赖的兄弟模块可直接缩短关键路径。 ⚠️ 拆成链式的两个模块等于什么都没做 —— 必须是兄弟。


5. 建议顺序

  1. 先做 L2。 唯一改变形状、且不需要改源码结构的路;实测 2.05×,单独就把 80s 打到 39s, 达成 <50s 目标。附带把级联抑制带到 Windows。
  2. L4 紧随其后。 一个模块的改动,收益可直接测量,且与 L2 叠加。
  3. L1 单独立项。 是生态决策(指纹失效、三平台载荷、ABI),不该混进性能 PR。
  4. L3 作为约定长期生效,优先施加于链上的 19 个模块。

6. 明确不做

  • 加核 / 更大的 -j / 分布式编译。 已实测:-j8 → -j32 只快 1.4%。
  • 压平模块分层。 §3:那条链是真实的架构分层,不是偶然耦合。
  • 为了性能降低优化档。 改的是产物,不是构建。
  • 缓存自己的 BMI 跨构建复用。 冷构建的定义就是没有缓存;这条对 §0 的目标无效。

7. 方法学附注

7.1 判据必须是「下游能不能用」,不是「文件在不在」

.gcm 出现在编译的 14% 处,但「出现」不等于「写完」。三步缺一不可:

  1. 轮询到大小连续 150ms 不变 → 快照;
  2. cmp 快照与编译结束后的成品 → 逐字节相同;
  3. 把快照放回 gcm.cache/,编译一个真实下游导入者exit 0

只做第 1 步会把「文件被创建」当成「BMI 可用」。

7.2 对照组

「改函数体后 BMI 差 2 字节」看起来像铁证。跑一次同一份源码编译两次的对照: 差的是同样两个偏移,上下文是 buildtime: / localtime: 的秒位。 没有这个对照就会得出相反结论。

7.3 关键路径必须按拓扑序松弛

用栈式 DFS 求最长路径(带防环)会把 76.5s / 26 节点读成 33.9s / 10 节点, 把「100% 关键路径」读成「44%」,结论完全反过来。必须用 Kahn 拓扑序松弛。