Skip to content

Commit 2874523

Browse files
committed
perf(build): L2 covers clang —— 两条独立边,BMI 用 reduced 形态
gcc 侧的 detach-codegen 已经在跑,clang 侧 policy 决策出 two-phase 但后端不发射对应的边,所以 clang 上 `schedule=on` 等于没开。 补上之后有两个坑,都不是"接边"的问题: 1. `--precompile` 发的不是同一种 BMI。它发 *full* BMI(产物本来是要喂回去 做 codegen 的),体积在小模块上涨约 16 倍(mcpp.platform 19,424 → 313,452 B),并且在 mcpp 自己的模块图上让 clang 22.1.8 编错一个下游 TU: error: call to implicitly-deleted default constructor of 'formatter<basic_string<char>, wchar_t>' —— 一个**窄**格式串,报错点在 std 里面,离真因三个文件远。所以 reduced 不是优化是契约:`--precompile -Xclang -emit-reduced-module-interface` 逐字节复现单条边发出来的那个 BMI,而且只花 1.67s(单条边 7.35s)。 ⚠️ 这个坑的判据是**体积**不是"编过了":先做出来的版本 fixture 全过、 noop 干净、增量传播正确,在 137 个模块的真实工程上才炸。 2. reduced BMI 不能拿去 codegen,所以 object 边重编源码而不是读 BMI。 前端跑两遍,但两条边彼此独立,codegen 整体落到图后面。 dyndep:一个源码两条边,两条都要记录。P1689 只知道 object,BMI 边没记录时 ninja 整图拒绝(报的是边不是缺失的记录)。故 `--split-module` 给两者各写一条。 不改扫描的 `-o` —— GCC 那边共用 `-o` 与 `-fdeps-target` 已经造成过"扫描去写 还不存在的 gcm.cache/",且那个坑在 mcpp 自己仓库上不暴露。 实测(pinned @ 8219584,clang 22.1.8):-j4 56.34→37.60s、-j8 34.00→25.55s、 -j32 32.03→17.95s。前端跑两遍要多花 CPU,但每个并发档位都是净赢,故不设核数门槛。 正确性判据是产物:两条臂 **130 个目标文件逐字节相同**。 e2e 231 的 fixture 原来只有 .cpp,没有模块接口单元 ⇒ 不产生 BMI 边, "声明了拆分形状却一条边都没发"这种情况能蒙混过关。加了一个 .cppm。
1 parent 10f98f0 commit 2874523

9 files changed

Lines changed: 390 additions & 56 deletions

File tree

.agents/docs/2026-08-13-build-optimization-status.md

Lines changed: 63 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -31,40 +31,84 @@ on 那次命中了缓存。5 个单元相对 80s 的差值可以忽略,但记在
3131

3232
---
3333

34-
## 0b. L2 的覆盖面 —— 目前只有 gcc
34+
## 0b. L2 的覆盖面 —— gcc 与 clang 都已落地
3535

36-
`policy` 为 clang 决策出 `two-phase`,但**后端没有发射对应的边**,
37-
所以 clang 上 `schedule=on` 目前等于没开。这是真实的未完成部分,不是设计取舍。
36+
两个编译器各支持**其中一种**机制,不可互换,`policy` 决策一次:
3837

39-
试做过一次并撤回:两条边(`cxx_precompile` + `cxx_object_from_bmi`)本身很简单,
40-
卡在 dyndep 上 ——
38+
| 编译器 | 形状 | 为什么只能是它 |
39+
|---|---|---|
40+
| gcc | `detach-codegen` | 无廉价的 BMI-only 模式(`-fmodule-only` 要花掉整编译的 99%),但 gcc 用 `rename()` 发布 BMI,所以"文件出现"是可靠信号 |
41+
| clang | `two-phase` | 反过来:clang`O_TRUNC` 就地写 BMI(读者会看到半个文件),但它有真正廉价的 BMI-only 调用 |
42+
43+
### clang 这条的两个坑,都不是"接边"的问题
44+
45+
**坑 1:`--precompile` 发出来的不是同一种 BMI。**
46+
47+
-fmodule-output= … -c 7.35s BMI 9,102,984 B (reduced)
48+
--precompile 1.81s BMI 18,402,920 B (FULL)
49+
--precompile -Xclang -emit-reduced-module-interface
50+
1.67s BMI 9,102,968 B (reduced)
51+
(clang 22.1.8,src/build/prepare.cppm)
52+
53+
`--precompile` 单独用又快又对**看起来**成立,实际上它发的是 *full* BMI ——
54+
因为它的产物本来是要喂回去做 codegen 的。把 full BMI 发布给下游不是等价替换:
55+
小模块上体积涨约 16 倍(`mcpp.platform` 19,424 → 313,452 B),而且在 mcpp 自己的
56+
模块图上直接让 clang 22.1.8 编错一个下游 TU:
57+
58+
error: call to implicitly-deleted default constructor of
59+
'formatter<basic_string<char>, wchar_t>'
60+
61+
—— 一个****格式串,报错点在 `std` 里面,离真因三个文件远。同一个 TU 对着 reduced BMI
62+
编译通过。所以 reduced 不是优化,是契约:`bmiOnlyFlags` 必须逐字节复现它。
63+
64+
⚠️ 这个坑的判据是**体积**,不是"编过了"。先做出来的版本能跑完 fixture、
65+
noop 干净、增量传播正确,**在 137 个模块的真实工程上才炸**
66+
67+
**坑 2:object 边只能重编源码,不能读 BMI。**
68+
69+
reduced BMI 不能拿去 codegen,于是 object 边是 `-c <源码>`(不带 `-fmodule-output`,
70+
BMI 归 A 边所有,两条边不能写同一个文件)。代价是**前端跑两遍**;收益是下游只等 A 边。
71+
72+
两条边彼此**独立**(object 边不等 BMI 边),所以 codegen 可以整体落在图的后面。
73+
74+
### dyndep:一个源码两条边,两条都要记录
75+
76+
P1689 只知道 object(`primary-output` 取自被扫描命令的 `-o`),BMI 边没有记录时
77+
ninja 不是警告而是**整图拒绝**:
4178

4279
ninja: build stopped: 'pcm.cache/mcpp.version_req.pcm' not mentioned in
4380
its dyndep file 'obj/version_req.cppm.ddi.dd'
4481

45-
真因已定位到具体一行:**clang-scan-deps 的 P1689 primary-output 来自内层编译命令的
46-
`-o`(目标文件),没有 GCC `-fdeps-target` 那样的独立开关**
47-
(`src/build/ninja_backend.cppm` 的 clang 分支:`-- $cxx ... -c $in -o $compile_target`)。
48-
于是 `mcpp dyndep` 写出的 `build` 行指向**目标文件**,而 precompile 边的输出是 **BMI**
82+
—— 报的是边,不是缺失的记录,指向的是无辜的一侧。
4983

50-
**落地形状(未实施)**:给 `mcpp dyndep` 加一个开关,让 `--single``provides`
51-
推出的 BMI 作为那条 `build` 行的目标,而不是 `primaryOutput`;后端在 two-phase 时传它。
52-
不要去改扫描的 `-o` —— GCC 那边共用 `-o``-fdeps-target` 已经造成过
53-
"扫描去写还不存在的 gcm.cache/"。
84+
解法是 `mcpp dyndep --split-module`:给 BMI **** primaryOutput 各写一条记录。
85+
不是"把目标改成 BMI" —— 两条边都要解析同一批 import,都需要同一批隐式输入。
86+
**不要去改扫描的 `-o`**:GCC 那边共用 `-o``-fdeps-target` 已经造成过
87+
"扫描去写还不存在的 `gcm.cache/`" —— **在 mcpp 自己的仓库上不暴露**
88+
(那个目录早被上一次构建建好),换个全新工程立刻失败。
5489

55-
**撤回而不是留着**:一个会让 `schedule=on` 直接失败的形状,比没有更糟。
90+
### 实测(pinned 源码 @ 8219584,clang 22.1.8)
5691

57-
⚠️ 顺带记下:同一个 `deps_target` 一开始被我和扫描的 `-o` 共用,
58-
于是扫描去写还不存在的 `gcm.cache/` —— **在 mcpp 自己的仓库上不暴露**
59-
(那个目录早被上一次构建建好),换个全新工程立刻失败。已修,并且这正是
60-
"只在开发它的那个工程上验过"会漏掉的东西。
92+
| 并发 | schedule=off | schedule=on | 比值 |
93+
|---|---|---|---|
94+
| `-j4` | 56.34s | **37.60s** | 1.50× |
95+
| `-j8` | 34.00s | **25.55s** | 1.33× |
96+
| `-j32` | 32.03s | **17.95s** | **1.78×** |
97+
98+
前端跑两遍要多花 CPU,但**在试过的每个并发档位上都是净赢**,所以没有加核数门槛。
99+
100+
**正确性判据是产物而不是退出码**:两条臂的 **130 个目标文件逐字节相同**
101+
(BMI 有 101 个不同 —— 两臂在不同的指纹目录下,BMI 里烙了输出目录的绝对路径;
102+
这正是"对照放两个目录会让路径冒充差异"那条,所以 BMI 差异在这里不构成证据。)
103+
104+
---
61105

62106
## 1. 四条杠杆的状态
63107

64108
| | 杠杆 | 状态 | 依据 |
65109
|---|---|---|---|
66110
| **L1** | 按次选择工具链 `--toolchain` | **已实施** | 实测 81.8 → **32.6s**(2.51×) |
67-
| **L2** | 下游在 BMI 可用时即开始 | **已实施**(`schedule = "on"`) | 实测 79.9 → **34.8s**(2.30×) |
111+
| **L2** | 下游在 BMI 可用时即开始 | **已实施**(`schedule = "on"`,gcc + clang) | gcc 79.9 → **34.8s**(2.30×);clang 32.0 → **17.95s**(1.78×) |
68112
| **L3** | 定义移出接口单元 | **不做** —— 已量出它治的是 L2 同一个病 | 实测:对 mcpp **−6.2%**,对 cmake +92.3% |
69113
| **L4** |`build.prepare` | **已实施**(架构收益;性能上为零) | 实测:**0**,原因见下 |
70114

src/build/ninja_backend.cppm

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,22 @@ std::string emit_ninja_string(const BuildPlan& plan) {
403403
// dyndep is a precondition: without it nothing declares BMIs as outputs, so
404404
// there is no BMI edge for importers to depend on.
405405
const bool splitBmi = plan.scheduleTag == "detach-codegen" && dyndep;
406+
// The other split shape. Clang publishes no BMI early — it writes the BMI
407+
// at the end of the compile — so the GCC trick of releasing the file
408+
// mid-compile has nothing to release. What clang has instead is a driver
409+
// mode that stops once the BMI exists, so the split is two INDEPENDENT
410+
// PROCESSES over the same source: one emits the BMI (fast; importers wait
411+
// only on this), one emits the object (slow; nobody waits on it).
412+
//
413+
// The object edge recompiles the source rather than reading the BMI back.
414+
// That is not a missed shortcut — see BmiTraits::bmiOnlyFlags: the BMI that
415+
// CAN be read back is clang's *full* BMI, which is ~2x larger and makes
416+
// clang 22.1.8 miscompile a downstream TU on mcpp's own graph. MEASURED on
417+
// src/build/prepare.cppm: BMI edge 1.67s, object edge 7.31s, against 7.35s
418+
// for the single edge — so ~22% more CPU buys a 4.4x shorter critical path,
419+
// and both outputs are byte-identical to the single-edge build's.
420+
const bool twoPhase = plan.scheduleTag == "two-phase" && dyndep
421+
&& !traits.bmiOnlyFlags.empty();
406422
const auto& dial = mcpp::toolchain::dialect_for(plan.toolchain);
407423
std::string out;
408424
auto append = [&](std::string s) { out += std::move(s); };
@@ -503,9 +519,14 @@ std::string emit_ninja_string(const BuildPlan& plan) {
503519
append(" restat = 1\n\n");
504520

505521
// P1: per-file dyndep rule. Converts one .ddi → .dd independently.
522+
//
523+
// `$bind` is per-edge, not per-graph: under two-phase only the units that
524+
// are actually SPLIT bind their record to the BMI. An implementation unit
525+
// or a plain .cpp still compiles in one edge whose output is the object,
526+
// and a `--target-bmi` there would name an edge nobody declared.
506527
append(std::format(
507528
"rule cxx_dyndep\n"
508-
" command = $mcpp dyndep --single --bmi-dir {} --bmi-ext {} $expect --output $out $in\n"
529+
" command = $mcpp dyndep --single --bmi-dir {} --bmi-ext {} $bind $expect --output $out $in\n"
509530
" description = DYNDEP $out\n"
510531
" restat = 1\n\n",
511532
traits.bmiDir, traits.bmiExt));
@@ -751,6 +772,57 @@ std::string emit_ninja_string(const BuildPlan& plan) {
751772
append(" restat = 1\n\n");
752773
}
753774

775+
if (twoPhase) {
776+
// Edge A — the BMI, and nothing else. `$out` is the BMI here.
777+
append("rule cxx_precompile\n");
778+
if constexpr (mcpp::platform::is_windows) {
779+
const std::string payload = " $local_includes";
780+
append(std::format(
781+
" command = $cxx{} $cxxflags $unit_cxxflags{}{} $in {}$out\n",
782+
rsp_ref(payload), traits.bmiOnlyFlags, module_src_flags,
783+
dial.outputObjPrefix));
784+
append_rspfile(payload);
785+
append_deps();
786+
} else {
787+
// Same bak / bmi-equal / restore dance as cxx_module, and for the
788+
// same reason: ninja's `restat` compares the output's MTIME, and a
789+
// compiler that rewrites a byte-identical BMI still moves it. What
790+
// suppresses the cascade is putting the old file back.
791+
append(std::format(
792+
" command = "
793+
"if [ -f \"$out\" ]; then cp -p \"$out\" \"$out.bak\"; fi && "
794+
"$cxx $local_includes $cxxflags $unit_cxxflags{}{} {}$in {}$out && "
795+
"if [ -f \"$out.bak\" ] && $mcpp bmi-equal \"$out\" \"$out.bak\"; then "
796+
"mv \"$out.bak\" \"$out\"; "
797+
"else rm -f \"$out.bak\"; fi\n",
798+
traits.bmiOnlyFlags, module_src_flags, mmd_flag,
799+
dial.outputObjPrefix));
800+
append_cxx_deps();
801+
}
802+
append(" description = BMI $out\n");
803+
append(" restat = 1\n\n");
804+
805+
// Edge B — the object, compiled from the SAME SOURCE, with no
806+
// `-fmodule-output`: the BMI is edge A's output and two edges must not
807+
// write one file. Identical to cxx_object except for the language flag
808+
// that tells the driver this source is a module interface.
809+
append("rule cxx_module_object\n");
810+
if constexpr (mcpp::platform::is_windows) {
811+
const std::string payload = " $local_includes";
812+
append(std::format(" command = $cxx{} $cxxflags $unit_cxxflags{} {}\n",
813+
rsp_ref(payload), module_src_flags, compile_tail));
814+
append_rspfile(payload);
815+
append_deps();
816+
} else {
817+
append(std::format(
818+
" command = $cxx $local_includes $cxxflags $unit_cxxflags{} {}{}{}\n",
819+
module_src_flags, mmd_flag, compile_tail, mmd_filter));
820+
append_cxx_deps();
821+
}
822+
append(" description = OBJ $out\n");
823+
append(" restat = 1\n\n");
824+
}
825+
754826
append("rule cxx_object\n");
755827
if constexpr (mcpp::platform::is_windows) {
756828
const std::string payload = " $local_includes";
@@ -1279,10 +1351,27 @@ std::string emit_ninja_string(const BuildPlan& plan) {
12791351
if (exp.empty()) exp = "--expect-none";
12801352
ddi_expect[ddi] = std::move(exp);
12811353
}
1354+
// Which units get the split shape. Computed ONCE and consulted from
1355+
// both loops below: the dyndep record and the edge it augments have to
1356+
// agree on the target, and when they disagree ninja blames the edge
1357+
// ("'…pcm' not mentioned in its dyndep file") rather than the record.
1358+
std::set<std::string> two_phase_ddi;
1359+
if (twoPhase) {
1360+
for (auto& cu : plan.compileUnits) {
1361+
if (cu.servedFromCache) continue;
1362+
if (is_scan_exempt(cu)) continue;
1363+
if (!cu.providesModule) continue;
1364+
if (cu.kind != mcpp::SourceKind::ModuleInterface) continue;
1365+
two_phase_ddi.insert(
1366+
(cu.object.parent_path() / cu.source.filename()).string() + ".ddi");
1367+
}
1368+
}
12821369
for (auto& ddi : ddi_paths) {
12831370
auto dd = ddi + ".dd"; // e.g. obj/cli.cppm.ddi.dd
12841371
ddi_to_dd[ddi] = dd;
12851372
append(std::format("build {} : cxx_dyndep {}\n", dd, ddi));
1373+
if (two_phase_ddi.contains(ddi))
1374+
append(" bind = --split-module\n");
12861375
if (auto it = ddi_expect.find(ddi); it != ddi_expect.end())
12871376
append(std::format(" expect = {}\n", it->second));
12881377
}
@@ -1331,6 +1420,38 @@ std::string emit_ninja_string(const BuildPlan& plan) {
13311420
// shape rather than emitting a BMI edge nothing can order.
13321421
}
13331422

1423+
if (twoPhase && cu.providesModule &&
1424+
cu.kind == mcpp::SourceKind::ModuleInterface) {
1425+
const auto bmi = bmi_path(*cu.providesModule);
1426+
const auto obj = escape_ninja_path(cu.object);
1427+
const auto ddi = (cu.object.parent_path() / cu.source.filename())
1428+
.string() + ".ddi";
1429+
auto it = ddi_to_dd.find(ddi);
1430+
if (it != ddi_to_dd.end()) {
1431+
// Both edges read the same source and so need the same
1432+
// imported BMIs; `--split-module` made the .dd carry a
1433+
// record for each. They are otherwise INDEPENDENT — the
1434+
// object edge does not wait for the BMI edge, which is what
1435+
// lets codegen drift behind the front of the graph.
1436+
auto edge = [&](std::string_view rule, const std::string& out) {
1437+
std::string e = std::format("build {} : {} {} | {}",
1438+
out, rule,
1439+
escape_ninja_path(cu.source),
1440+
it->second);
1441+
e += stagedOrderOnly;
1442+
e += "\n dyndep = " + it->second + "\n";
1443+
if (auto inc = local_include_flags(cu, dial); !inc.empty())
1444+
e += " local_includes =" + inc + "\n";
1445+
if (auto fl = join_flags(cu.packageCxxflags); !fl.empty())
1446+
e += " unit_cxxflags =" + fl + "\n";
1447+
append(std::move(e));
1448+
};
1449+
edge("cxx_precompile", bmi);
1450+
edge("cxx_module_object", obj);
1451+
continue;
1452+
}
1453+
}
1454+
13341455
std::string out_line = "build " + escape_ninja_path(cu.object);
13351456
if (cu.providesModule) {
13361457
out_line += " | " + bmi_path(*cu.providesModule);

src/build/schedule/policy.cppm

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,22 @@
3030
// HOW that is done differs per compiler, and the two mechanisms are
3131
// COMPLEMENTARY — each family supports exactly one:
3232
//
33-
// clang TwoPhase `--precompile` emits the BMI, `-c x.pcm` emits the
34-
// object: two ordinary edges, no process machinery,
35-
// portable by construction. BMI ready at 57% of a
36-
// single-phase compile for +9.6% total CPU.
33+
// clang TwoPhase two ORDINARY edges over the same source: one emits
34+
// only the BMI, one emits only the object. No process
35+
// machinery, portable by construction.
36+
// MEASURED (22.1.8, src/build/prepare.cppm):
37+
// BMI edge 1.67 s vs 7.35 s for the single edge, and
38+
// the object edge is byte-identical to the one the
39+
// single edge produced.
40+
// The object edge recompiles the SOURCE rather than
41+
// reading the BMI back. `-c x.pcm` does work, but only
42+
// against clang's *full* BMI, and publishing those to
43+
// importers makes clang 22.1.8 miscompile a downstream
44+
// TU (see BmiTraits::bmiOnlyFlags). Front-end work is
45+
// therefore done twice — measured on the whole
46+
// project it still wins at every job count tried:
47+
// -j4 56.3 s → 37.6 s, -j8 34.0 s → 25.6 s,
48+
// -j32 32.0 s → 18.0 s.
3749
// clang CANNOT use DetachCodegen — strace shows it
3850
// writes the BMI to the final path with O_TRUNC, so a
3951
// reader can observe a half-written file.
@@ -145,8 +157,8 @@ Decision decide(const toolchain::Toolchain& tc, std::string_view requested, int
145157
switch (tc.compiler) {
146158
case toolchain::CompilerId::Clang:
147159
d.strategy = Strategy::TwoPhase;
148-
d.reason = "clang: --precompile publishes the BMI at ~57% of a "
149-
"single-phase compile (+9.6% total CPU)";
160+
d.reason = "clang: a BMI-only invocation costs ~23% of a full "
161+
"compile, so importers wait on that instead";
150162
d.compilerCap = cap;
151163
// Two ordinary edges: a compiler always holds a ninja slot, so the
152164
// ordinary job count is still the real bound.

src/cli.cppm

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,9 @@ int run(int argc, char** argv) {
618618
.help("BMI cache directory name (default: gcm.cache)"))
619619
.option(cl::Option("bmi-ext").takes_value().value_name("EXT")
620620
.help("BMI file extension (default: .gcm)"))
621+
.option(cl::Option("split-module")
622+
.help("Also emit a record for the provided BMI (two-phase "
623+
"schedule: BMI and object are separate edges)"))
621624
.option(cl::Option("expect-provides").takes_value().value_name("NAME")
622625
.help("(verification) planned provided module for this TU"))
623626
.option(cl::Option("expect-imports").takes_value().value_name("CSV")

src/cli/cmd_build.cppm

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,7 @@ export int cmd_dyndep(const mcpplibs::cmdline::ParsedArgs& parsed) {
372372
opts.bmiDir = bmiDirStorage;
373373
if (!bmiExtStorage.empty())
374374
opts.bmiExt = bmiExtStorage;
375+
opts.splitModuleEdges = parsed.is_flag_set("split-module");
375376

376377
std::expected<std::string, std::string> body;
377378
if (single) {

0 commit comments

Comments
 (0)