Skip to content

Commit a783dc7

Browse files
committed
feat(build): --jobs N|auto —— 按核数与可用内存选择并发(可选)
mcpp 从来没给 ninja 传过 -j,于是一直用 ninja 的默认 nproc+2。实测这在两个 方向上都是错的: 内存:单个模块编译峰值 RSS 实测 prepare.cppm 1,057 MB / plan.cppm 561 MB。 64 核 / 32 GB 的机器会跑 66 路 × ~0.5-1 GB —— 换页。默认值在核多内存 少的机器上是主动有害的。 异构:i9-13900K 报 32 个逻辑 CPU,实为 8 P-core + 16 E-core。把它们当成 32 个等价 worker,会把可用并行度高估一倍以上。 而且对这个工程,多出来的并发根本没用:实测冷构建 -j8 = 81.0s,-j32 = 79.9s —— 4 倍 worker 换 1.4%。所以 auto 不是在牺牲速度换安全,是同样的时间下把内存占用 降到 1/4。 新增 mcpp.platform.capacity:核数(逻辑/物理/是否异构)与可用内存的跨平台探测。 接口只用整型 —— 仓库记录过 GCC 16.1 下新模块导出 std 类型会毒化下游 BMI。 公式:jobs = clamp(min(异构 ? 物理核 : 逻辑核, (available - 2GiB) / 768MiB), 1, 64) 用 available 而非 total(构建通常不是机器上唯一的东西);per-job 估值来自本仓库 实测,且是参数而非常量,别的工程可按自己的规模调整。本机 auto → -j24。 --jobs 走与 --offline 相同的环境变量侧信道,理由也相同(消费方在 mcpp.build.execute 深处,逐层穿参要动中间每一个调用者)—— cli.cppm 里那条注释就是这么写的。 默认不变:改变所有人的并发是行为变更,先作为可选项。无效值会警告而不是静默回落, 否则一个拼写错误会变成「构建莫名其妙变慢」。 单测 8 例,针对合成的机器画像而不是跑测试的这台机器 —— 后者等于把答案复述一遍, 而且每个 CI runner 结论都不同。 顺带修正冷构建方案文档里一条被我自己的数据推翻的要求:我曾把原型第一次的 78.99s 归因于「管道继承」和「-j 必须远大于上限」两件事。单独扫描 -j 轴后: -j32=37.84s(最快)/ -j64=38.23 / -j128=38.39 / -j192=39.06 —— -j 越大越慢。 那次失败几乎全部是管道继承,第二条基本不成立。
1 parent 999a71a commit a783dc7

8 files changed

Lines changed: 662 additions & 2 deletions

File tree

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
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」,而是**两者都还没做的事**

bench/src/platform/posix.cppm

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,16 @@ module;
1616
#include <fcntl.h>
1717
#include <spawn.h>
1818
#include <sys/wait.h>
19+
#include <stdlib.h> // setenv / unsetenv — needed on Darwin too, where they
20+
// live in <_stdlib.h> and are NOT reachable through the
21+
// other POSIX headers this file pulls in
1922
#include <time.h>
2023
#include <unistd.h>
2124
#if defined(__APPLE__)
2225
#include <sys/sysctl.h>
2326
#include <sys/types.h>
2427
#else
2528
#include <stdio.h>
26-
#include <stdlib.h>
2729
#include <string.h>
2830
#endif
2931
extern "C" char** environ;

src/build/execute.cppm

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import mcpp.platform.xlings.subos_info;
2929
import mcpp.platform.runtime_binding;
3030
import mcpp.log;
3131
import mcpp.platform;
32+
import mcpp.platform.capacity;
3233
import mcpp.fetcher.progress;
3334
import mcpp.project;
3435
import mcpp.ui;
@@ -375,6 +376,47 @@ compute_subos_env(const mcpp::build::BuildPlan& plan) {
375376
// Compile a prepared BuildContext. Shared between `mcpp build` and `mcpp run`
376377
// so the latter doesn't call prepare_build twice (and re-print the toolchain
377378
// resolution banner).
379+
// How many compiles to run at once.
380+
//
381+
// Precedence: `--jobs` (arriving as MCPP_JOBS, same channel --offline uses and
382+
// for the same reason — the consumers span subsystems) > `[build] jobs` >
383+
// 0, which means "say nothing" and leaves ninja's own default (nproc + 2).
384+
// The default is deliberately unchanged: altering everyone's concurrency is a
385+
// behaviour change, and this lands as an opt-in first.
386+
//
387+
// `auto` is resolved HERE, against the machine doing the build, never frozen
388+
// into a manifest. Measured on this repository: the cold self-build takes
389+
// 81.0s at -j8 and 79.9s at -j32 — 4x the workers for 1.4%, because the build
390+
// is latency-bound. Meanwhile a single module compile peaks at 0.5-1.0 GB, so
391+
// the extra jobs are pure memory pressure; on a high-core, modest-RAM machine
392+
// ninja's default swaps.
393+
std::size_t resolve_parallel_jobs(const mcpp::build::BuildPlan& plan) {
394+
auto from_text = [&](std::string_view v) -> std::optional<std::size_t> {
395+
if (v.empty()) return std::nullopt;
396+
if (v == "auto") {
397+
const auto cap = mcpp::platform::capacity::host_capacity();
398+
return static_cast<std::size_t>(
399+
mcpp::platform::capacity::recommended_jobs(cap));
400+
}
401+
std::size_t n = 0;
402+
const auto* first = v.data();
403+
const auto* last = v.data() + v.size();
404+
if (auto [p, ec] = std::from_chars(first, last, n);
405+
ec == std::errc{} && p == last && n > 0)
406+
return n;
407+
// A malformed value must not silently become "use the default" — that
408+
// is how a typo turns into a build that is mysteriously slower.
409+
mcpp::ui::warning(std::format(
410+
"ignoring invalid job count '{}' (expected a positive number or 'auto')", v));
411+
return std::nullopt;
412+
};
413+
414+
if (const char* e = std::getenv("MCPP_JOBS"))
415+
if (auto n = from_text(e)) return *n;
416+
if (auto n = from_text(plan.manifest.buildConfig.jobs)) return *n;
417+
return 0;
418+
}
419+
378420
export int run_build_plan(BuildContext& ctx, bool verbose, bool no_cache,
379421
std::string_view targetOverride = "") {
380422
// `--cache=off` means a cold build: no global cache, and target/ cleared —
@@ -446,6 +488,7 @@ export int run_build_plan(BuildContext& ctx, bool verbose, bool no_cache,
446488

447489
mcpp::build::BuildOptions opts;
448490
opts.verbose = verbose;
491+
opts.parallelJobs = resolve_parallel_jobs(ctx.plan);
449492
auto r = be->build(ctx.plan, opts);
450493
if (!r) {
451494
std::fflush(stdout);

src/cli.cppm

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ void print_usage() {
8989
std::println(" --no-cache Deprecated alias for --cache=off (clears the build dir)");
9090
std::println(" --no-color Disable colored output");
9191
std::println(" --offline Never touch the network (also: MCPP_OFFLINE=1)");
92+
std::println(" --jobs N|auto, -j Concurrent compiles ('auto' = cores + free RAM)");
9293
std::println("");
9394
std::println("Docs: https://github.com/mcpp-community/mcpp/tree/main/docs");
9495
}
@@ -114,6 +115,16 @@ int run(int argc, char** argv) {
114115
// need a parameter threaded down. Same shape as MCPP_VERBOSE above, and
115116
// it makes `MCPP_OFFLINE=1` and `--offline` literally the same switch.
116117
else if (a == "--offline") mcpp::platform::env::set("MCPP_OFFLINE", "1");
118+
// --jobs rides the same side channel as --offline, for the same reason
119+
// recorded there: its consumer is deep in mcpp.build.execute and
120+
// threading a parameter down would touch every caller in between.
121+
// Accepts `--jobs N`, `--jobs=N`, `-j N` and `-jN`.
122+
else if (a == "--jobs" || a == "-j") {
123+
if (i + 1 < argc) mcpp::platform::env::set("MCPP_JOBS", argv[++i]);
124+
}
125+
else if (a.starts_with("--jobs=")) mcpp::platform::env::set("MCPP_JOBS", std::string(a.substr(7)));
126+
else if (a.starts_with("-j") && a.size() > 2)
127+
mcpp::platform::env::set("MCPP_JOBS", std::string(a.substr(2)));
117128
}
118129
// Decline xlings' linker-wrapper path injection, for this process and
119130
// everything it spawns (openxlings/xlings#540).
@@ -265,6 +276,8 @@ int run(int argc, char** argv) {
265276
.help("Show toolchain fingerprint and 11 inputs"))
266277
.option(cl::Option("cache").takes_value().value_name("MODE")
267278
.help("Global dependency cache: global (default) | local | off"))
279+
.option(cl::Option("jobs").short_name('j').takes_value().value_name("N")
280+
.help("Concurrent compiles: a number, or 'auto' to size from cores + free RAM"))
268281
.option(cl::Option("no-cache")
269282
.help("Deprecated alias for --cache=off (also clears the build dir)"))
270283
.option(cl::Option("target").takes_value().help(

src/manifest/toml.cppm

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1042,6 +1042,12 @@ std::expected<Manifest, ManifestError> parse_string(std::string_view content,
10421042
}
10431043
if (auto v = doc->get_string("build.c_standard")) m.buildConfig.cStandard = *v;
10441044
if (auto v = doc->get_string("build.target")) m.buildConfig.target = *v;
1045+
// `jobs` accepts a number or "auto"; both arrive as text and are validated
1046+
// where they are used, so a bad value warns at build time instead of making
1047+
// the whole manifest unloadable. (A published package carrying an unknown
1048+
// key must never break an older mcpp — same rule the dependency keys follow.)
1049+
if (auto v = doc->get_string("build.jobs")) m.buildConfig.jobs = *v;
1050+
else if (auto n = doc->get_int("build.jobs")) m.buildConfig.jobs = std::to_string(*n);
10451051
if (auto v = doc->get_string("build.default-profile")) m.buildConfig.defaultProfile = *v;
10461052
else if (auto v = doc->get_string("build.profile")) m.buildConfig.defaultProfile = *v; // accepted alias
10471053
if (auto v = doc->get_string("build.cache")) m.buildConfig.cacheMode = *v;
@@ -1074,7 +1080,7 @@ std::expected<Manifest, ManifestError> parse_string(std::string_view content,
10741080
"allow_host_libs", "build_program_timeout", "c_standard", "cache",
10751081
"cflags", "cxxflags", "cxx_runtime", "default-profile", "defines",
10761082
"dialect_cxxflags", "flags", "include_dirs", "include_dirs_after",
1077-
"ldflags", "macos_deployment_target", "module_extensions", "profile",
1083+
"jobs", "ldflags", "macos_deployment_target", "module_extensions", "profile",
10781084
"sources", "static_stdlib", "target",
10791085
};
10801086
if (auto* bt = doc->get_table("build")) {

src/manifest/types.cppm

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,13 @@ struct Resources {
353353
// is read in ~150 places, and a BuildConfig genuinely IS a set of build
354354
// inputs plus the selection axis and resolved policy scalars.
355355
struct BuildConfig : BuildInputs {
356+
// `[build] jobs` — how many compiles to run at once. A decimal count,
357+
// "auto", or empty (the default) meaning "let the backend decide".
358+
//
359+
// Kept as TEXT rather than a number so that "auto" survives into the build
360+
// that actually runs: resolving it at parse time would freeze one machine's
361+
// core count into a value that then travels with the manifest.
362+
std::string jobs;
356363
// feature name → extra source globs gated by that feature. A glob listed
357364
// here is EXCLUDED from the default build and only compiled/linked when the
358365
// feature is active for this package (resolved in prepare_build). Lets a

0 commit comments

Comments
 (0)