Build parallelism & per-job memory calibration#
Audience: anyone (human or AI agent) tempted to “speed up the build” by raising
job counts or lowering the per-job memory estimates in
linux/scripts/01-core/parallelism.sh.
Read this first. The per-job memory numbers look conservative but are calibrated to the host’s RAM. Lowering them the naive way OOM-kills a multi-hour build. This doc explains the model, the one trap that makes it counter-intuitive, and how to tune it safely with data.
TL;DR — the one rule#
Every memory-capped stage obeys one formula:
jobs = min( cores , usable_RAM / BUILD_MEM_DIVISOR / PEAK_per-TU_RAM )
The config knob is mb_per_job, which is PEAK_per-TU_RAM.
Lowering
mb_per_jobraisesjobs→ faster only if RAM allows it.If
jobs × PEAKexceeds RAM,cc1plusgets OOM-killed and the build dies.BUILD_MEM_DIVISOR(default 1) is the only concurrency knob: set it to the number of builds sharing the host RAM at once (see Max speed below).
Tune against the PEAK per-TU footprint, never the average. See the trap below.
The helpers (linux/scripts/01-core/parallelism.sh)#
All logic lives in one core function; the named helpers just pick a profile:
mem_capped_jobs <peak_mb> [requested] # the core: jobs = min(cores, usable/peak)
Named wrapper |
Profile / used for |
peak (normal) |
peak (aggressive) |
|---|---|---|---|
|
pure CPU-bound, no mem cap |
— (just |
— |
|
|
|
|
|
|
|
|
|
|
|
|
Peak values come from _profile_mb <profile>; the wrappers are one-liners over
mem_capped_jobs. Where:
cores=detect_available_cores()(respects cgroup CPU quota).usable_RAM=_usable_mem_mb()=_mem_available_mb()(MemAvailable, or the cgroup memory limit remaining — smaller wins) divided byBUILD_MEM_DIVISOR.
Each workload gets a different estimate because its peak translation unit
is different — torch’s aten/autograd TUs peak ~4 GB per cc1plus, while a
typical OpenCV TU is a few hundred MB.
AGGRESSIVE_PARALLELISM auto-enables at ≥ 16 GB RAM#
_auto_aggressive_parallelism() sets AGGRESSIVE_PARALLELISM=true automatically
when _mem_available_mb() — the host figure, before BUILD_MEM_DIVISOR — is
≥ 16 GB (unless you export it false). In aggressive mode the
generic and rust estimates drop (2000→800, 2500→1200), so on any
real-size host those stages run at full core count — they are not
RAM-capped and have no headroom to reclaim.
compute_cpp_heavy_jobs deliberately ignores aggressive mode and keeps the
4 GB floor: torch TUs do not get cheaper on a big host, so the only thing more
RAM buys is more of them at once — which the usable_RAM / mb_per_job formula
already grants automatically.
Override knobs#
Env var |
Effect |
|---|---|
|
Hard override — bypasses all auto-detection for every helper. |
|
Force aggressive on/off (auto ≥16 GB). |
|
Divide usable RAM by N. Set = #concurrent builds. See Max speed. |
|
Per-job estimate for generic C/C++. |
|
Per-job estimate for Rust. |
|
Per-job estimate for torch/heavy C++. See warning. |
🚀 Max speed — the real lever is --parallel-archs, not the per-job numbers#
On any ≥16 GB host the per-job math is already optimal: generic and rust run
at full core count (aggressive mode), and torch is RAM-bound at 4 GB/TU
(can’t go faster without more RAM — see below). There is no idle throughput
to reclaim by tuning *_MB_PER_JOB for a single build.
The wall-clock win is at the orchestration layer. build-cross-chain.sh
builds the three arches of each per-arch stage (sdk / media / android)
sequentially by default. --parallel-archs runs them concurrently —
close to a 3× speedup on those stages.
The catch it used to have: each per-arch build runs its own
parallelism.sh, which reads the full host RAM. Three concurrent torch builds
would each launch RAM/4096 jobs → 3× overcommit → OOM at hour six.
The fix (now built in): BUILD_MEM_DIVISOR. When the orchestrator runs N
arches in parallel it passes a BUILD_MEM_DIVISOR of at least N into each build
(N × PAR_INTRA_STEP_BUDGET since PAR4, below); every helper then sizes against
usable_RAM / divisor, so the sum of all N builds’ jobs still fits one host.
Sequential builds pass divisor 1 → unchanged.
# 62 GB host, 3 arches in parallel:
# without divisor: 3 × (62000/4096)=15 jobs × 4 GB = 184 GB → OOM
# with DIVISOR=3: 3 × (20666/4096)= 5 jobs × 4 GB = 61 GB → fits
Because a container can’t know how many siblings share the host, the divisor
must be injected — it is the one number auto-detection can’t supply. Wiring:
cross_build_mem_divisor (stage-defs.sh) computes min(--max-parallel-archs, #arches) × PAR_INTRA_STEP_BUDGET and forwards it as a build-arg →
ENV BUILD_MEM_DIVISOR in each heavy Dockerfile.
Before the first
--parallel-archsrun, validate the divisor actually reaches the container (grep a build log for the job count on a cheap stage), and watchdmesg | grep -i oom. Trading 3× disk + bandwidth too — the parallel arches all push large images at once.
⚠️ The trap: average RAM usage lies#
free//proc/meminfo during a heavy build shows low average usage and lots
of “free” RAM — because heavy TUs are staggered, so most samples catch the
light ones. This is not slack you can reclaim.
Worked example (this host, 2026-07, riscv64 torch build):
Host: 32 cores, 62 GB RAM.
compute_cpp_heavy_jobs@ 4096 MB/job → ~14 concurrent jobs.freesnapshots during the build: ~14–18 GB used, ~44 GB free. 🚩 looks wasteful.But torch’s aten/autograd TUs peak ~4 GB each. Worst case when the heavy ones align:
14 × 4 GB = 56 GB— fits under 62 GB with a thin margin. ✅Lower it to 2500 “because 44 GB is free” →
~24 jobs × 4 GB = 96 GB→ OOM, build dies at hour six. ❌
This exact failure is already recorded in code: at the generic 2 GB/job estimate,
27 jobs × 4 GB overcommitted a 60 GB host and the OOM-killer terminated
cc1plus (see the comment above compute_cpp_heavy_jobs). 4096 is the tightest
value that keeps the worst case under 62 GB. It is not padding.
⚠️⚠️ The second-order trap: the divisor ignores INTRA-build step parallelism (PAR4 — OOM incident 2026-08-18)#
BUILD_MEM_DIVISOR = min(MAX_PARALLEL_ARCHS, n_arch) sizes each build’s job
pools as if that build ran one step at a time. It does not: buildkitd’s
max-parallelism = 4 lets EVERY build run up to 4 independent Dockerfile
stages concurrently. Worst case under 3-way --parallel-archs:
3 builds × up to 4 heavy steps × jobs sized for RAM/3 — a multiple of
physical RAM.
The incident (wave3b, 2026-08-18): the first run with the PAR2 cache-mount
id split. Before PAR2, the shared sharing=locked apt mounts accidentally
SERIALIZED the lanes’ heavy phases — a hidden safety net. With PAR2 fixed, all
3 media lanes reached their heaviest phase (IREE wheelhouse) simultaneously at
~2h09m, and the kernel OOM-killed cc1plus on arm64 AND riscv64
(g++: fatal error: Killed signal terminated program cc1plus; both lanes’
media builds failed; recovery rode the retry+salvage staggering). Lesson:
removing a contention bug can surface a latent memory overcommit — the two
bugs were load-bearing for each other.
Interim operator rule (until the PAR4 fix lands): for 3-way parallel media
either set BUILD_MEM_DIVISOR=5 (or higher) explicitly, or exclude media from
parallelism via PARALLEL_STAGES=sdk,android. sdk/android phases have not
OOMed under 3-way (validated 2026-08-17/18).
The fix (PAR4, LANDED 2026-08-18, VALIDATED through wave-4 2026-08-21 —
ONE isolated OOM kill across ~12 parallel media rounds, absorbed by
retries; plus the 2026-08-19 amend: SHARED stages get divisor 1):
cross_build_mem_divisor now multiplies the arch count by
PAR_INTRA_STEP_BUDGET (default 2 = assumed concurrent heavy steps per
build; observed 2-4, and an effective ×5 held through the android×3 recovery
run). 3-way parallel → divisor 6. Parallel-archs only; the sequential path
(bounded by max-parallelism alone) is empirically fine and unchanged.
Field note 2026-09-19 (3-way media on the 60 GB host): the collapse is real
and expensive. The arm64 wheelhouse’s IREE host build ran ninja -j1 for ~6 h
while 32 cores sat idle — the wheelhouse builds in a tmpfs that eats
MemAvailable, and BUILD_MEM_DIVISOR divided what was left. In the same round
riscv64’s media lane died on a transient IREE-clone TLS error (fatal, unlike the
tolerated torch/TVM cc1plus OOM kills), and the retry for riscv64 alone
reported JOBS=28 and finished. On this host: serialize media, keep
sdk/android parallel.
Escalation if a lane still OOMs: PAR_INTRA_STEP_BUDGET=3 or
PARALLEL_STAGES=sdk,android. The stronger options (systemd-run MemoryHigh
per build, a global compile-job governor) stay on the backlog as PAR4-hard.
⚠️⚠️⚠️ The third-order trap: the divisor outlives the lanes it was sized for (PAR5 — OPEN; the obvious fix was tried and REVERTED 2026-08-23)#
The symptom (real, still unfixed). PAR4 sizes the divisor from the
launch-time lane count. Lanes do not finish together — amd64 media typically
lands hours before riscv64 — so the survivor keeps dividing the whole host by 6
long after it is alone on it. Observed twice and costed in hours: wave4f
(arm64) and wave5h (riscv64) each spent hours on a lone media wheelhouse at 1-2
compile jobs while the rest of the machine idled. MemAvailable recovers when
a sibling exits; / 6 does not.
VERDICT: do not re-attempt “shrink the divisor when a sibling lane finishes”#
It cannot work at this layer. An attempt (PAR5: a live lane.<arch> marker
registry in run_parallel_arch_loop’s flag dir, which cross_build_mem_divisor
clamped to) was written, reviewed, reproduced and reverted the same day.
Three findings, in the order that matters:
1. The divisor is fixed at lane start BY CONSTRUCTION.
cross_build_mem_divisor() has exactly ONE production call site —
append_cross_build_args(), reached from cross_stage_build_args() ←
cross-stage-build.sh:572 — and it runs once per
stage build, to emit --build-arg BUILD_MEM_DIVISOR=N. Inside the image that
value is ARG → ENV in the base stage of every Dockerfile
(Dockerfile.media:123,171; likewise .sdk:12,26, .android:15,76,
.toolchain:31,52) and parallelism.sh reads it at each RUN. A build-arg
is bound when the build starts; nothing on the host can move it afterwards.
The step that actually crawls — the app wheelhouse, Dockerfile.media:729 —
is hours downstream of that ENV, inside the same build. So “adapt when lanes
finish” is unachievable at this layer, no matter how the host-side number is
computed.
2. The clamp could not fire where it was meant to. The chain runs one
run_parallel_arch_loop per stage and joins before the next
(build-cross-chain.sh:397), and that loop starts every lane at t0. With
MAX_PARALLEL_ARCHS >= #arches — the shipped 3-arch topology — all lanes are
launched before any lane retires, so the lone survivor still reads the full
static divisor. The exact case PAR5 existed for was the one case it could not
reach.
3. Where it COULD fire it was worse than nothing. Only with
MAX_PARALLEL_ARCHS < #arches does a lane start after a sibling has retired.
There the clamp made BUILD_MEM_DIVISOR a function of wall-clock sibling
timing — nondeterministic between runs, and (see below) part of the BuildKit
cache key — and it could land below the PAR4 value: clamping to one live
lane skips the × PAR_INTRA_STEP_BUDGET multiplication entirely (6 → 1). That
is an N-times RAM overcommit in exactly the direction PAR4 exists to prevent —
the 2026-08-18 incident OOM-killed cc1plus in two lanes. Intra-build step
concurrency is a property of ONE build; it does not shrink when siblings exit,
so a live-lane count is the wrong input for it.
Regression guard: linux/scripts/tests/test-stage-defs.sh asserts the divisor
is a pure function of PARALLEL_ARCHS / TARGET_ARCHES / MAX_PARALLEL_ARCHS
/ PAR_INTRA_STEP_BUDGET and that flag-dir state cannot reach it.
The cache-key coupling (why the manual workaround doesn’t exist either)#
Every host→build channel (build-args, bind mounts) is part of the BuildKit
cache key. ENV BUILD_MEM_DIVISOR sits in base (Dockerfile.media:171),
above all 40 downstream RUN steps, so a divisor that varies with lane
liveness would cache-miss the whole media chain on every run. The same
coupling removes the obvious hand recovery: killing the lone lane and
relaunching it with a smaller divisor does not resume from cache — the changed
ENV invalidates its whole media chain, so you would trade a 6× throttle for a
full rebuild.
What IS achievable (both unbuilt; pick one and validate with a real build)#
PAR4-hard — a host-level memory governor. Stop encoding “how much RAM may I use” in an immutable build-arg and enforce it from outside, where it can change while a build runs:
systemd-run --scope -p MemoryHigh=…per build, or a global compile-job governor. This is the only real answer to “the host’s free RAM changed mid-build”.Re-size at STAGE boundaries. The only honest in-band re-sizing point is a container-build boundary, and there is one at every stage (all lanes join, the next stage’s builds compute a fresh divisor). Finer granularity means splitting the long stage (media) into more, smaller builds — Dockerfile work, and each new boundary is a cache-key boundary too.
Anything that “tells the running build” a new number needs a channel that is off the cache key (per-
RUNsecret mount, a divisor file in atype=cachemount, a governor the container polls). Dockerfile-level work; it wants one real build to validate. Not reachable from the shell layer.
Interim operator rule. Both levers are launch-time — decide before the run, because there is no mid-run recovery that keeps the cache. If the arches desync predictably (riscv64 is always last), either run the slow arch as its own chain invocation from the start — it then gets its own correct sizing at t0 — or accept the throttle. Do not kill and relaunch a straggler to “fix” its divisor.
How to tune safely (procedure for an agent)#
Identify the binding stage. Only stages using
compute_cpp_heavy_jobsare RAM-capped on a ≥16 GB host. Generic-C++ and Rust already run at core count — no config change speeds them up; don’t touch their estimates.Measure the PEAK, not the average. While the target stage compiles, sample the largest single
cc1plus/rustcRSS, not total used:# peak RSS (MB) of the biggest compiler process, sampled repeatedly for _ in $(seq 60); do ps -eo rss,comm --sort=-rss | awk '/cc1plus|cc1|rustc|lto/{print $1/1024; exit}' sleep 5 done | sort -n | tail -1
Apply the invariant. Only lower
mb_per_jobif:(target_jobs) × (measured_peak_MB) ≤ usable_RAM_MB × 0.9 # keep ~10% headroom
Solve for the max safe
target_jobs, then setmb_per_job = usable_RAM_MB / target_jobs. Never set it below the measured peak.Verify on a real build. Watch
dmesg -w | grep -i oomand the compiler logs forKilled/signal 9on the first run after a change. If anything is OOM-killed, revert immediately.
RAM upgrade guidance (corrected)#
torch build time is peak-RAM-bound. You can only fit
usable_RAM / 4 GBheavy TUs at once. On 62 GB that’s ~14 jobs on 32 cores — cores sit idle not because of a bad estimate, but because RAM can’t feed more 4 GB TUs. → More RAM genuinely speeds up the torch stage (e.g. 128 GB → ~30 concurrent → roughly 2× torch parallelism). This is the only real lever for torch; the config estimate must stay at 4 GB.Everything else is already core-bound (aggressive mode), so more RAM does not help OpenCV / ONNX / Rust / GStreamer.
Bottlenecks that more RAM does not fix at all: disk headroom (the cross images are large), registry upload bandwidth (each per-arch stage ends in a slow push), and single-threaded serial work (GCC mega-TUs, final links).
Do not “free up” the torch estimate. If torch speed matters, add RAM.
Capping CPU so the host stays usable#
The levers above size work to fit RAM. A separate problem is a build saturating every core and making the machine unusable for anything else. That is a container limit, not a job-count setting — the job counts stay as calibrated and the runtime throttles the whole build.
For a one-off docker build:
docker build --cpu-period=100000 --cpu-quota=100000 -t my-image . # one core's worth
docker build --cpuset-cpus="0" -t my-image . # pin to a specific core
docker build has no --cpus (that is docker run/docker update);
--cpu-quota/--cpu-period is a share of total CPU time; --cpuset-cpus pins
to physical cores. Prefer --cpuset-cpus when you want the remaining cores
genuinely free, since a quota still spreads across every core.
For BuildKit builds the flags do not apply — the work happens inside the builder container, not the client. Create a dedicated builder, then constrain that container:
# create a dedicated builder on the container driver
docker buildx create --name sixteen --driver docker-container --use
docker buildx inspect sixteen --bootstrap
# find the buildkit container backing it
docker ps --filter "name=buildx_buildkit" --format "{{.Names}}"
# constrain it (adjust the cpuset to the host's core count)
docker update --cpus=16 --cpuset-cpus="0-15" buildx_buildkit_sixteen0
docker update applies to a running container, so the builder must be
bootstrapped first. The limit persists for the life of that builder — every
subsequent buildx build --builder sixteen inherits it.
Interaction with
--parallel-archs: the CPU cap is applied to the shared builder, so N parallel arch lanes divide the capped budget, not the host’s. Capping to half the cores and running three arches in parallel gives each lane roughly a sixth of the machine. Size the cap for the whole run, not per lane.
The other end: building on a memory-constrained host#
Everything above assumes a large build host and tunes down from core count. On an SBC or a small VM the problem inverts — the machine hangs or the OOM killer fires before the build gets anywhere. The levers are different.
Give it swap before touching job counts#
A build that hangs under load on a Raspberry Pi is usually swap-starved rather
than genuinely out of memory. On Raspberry Pi OS, swap is managed by
dphys-swapfile:
sudo dphys-swapfile swapoff
sudo nano /etc/dphys-swapfile # CONF_SWAPSIZE=2048 (or 4096)
sudo dphys-swapfile setup
sudo dphys-swapfile swapon
On a generic distro, a plain swapfile:
sudo fallocate -l 8G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
Removing it again:
sudo swapoff /swapfile && sudo rm -f /swapfile
If the board uses zram instead, resize the compressed device rather than
adding a file — writing to flash to back a swap file wears it out:
sudo swapoff /dev/zram0
echo $((2 * 1024 * 1024 * 1024)) | sudo tee /sys/block/zram0/disksize # 2 GiB
sudo mkswap /dev/zram0
sudo swapon /dev/zram0
swapon --show && zramctl
/tmp fills on these hosts too — see
Linux Host Setup § /tmp is full during a build.
Then serialize, per tool#
Swap buys headroom; it does not stop N parallel compilers from all being resident at once. Force the job count down at the tool that spawns them, since each has its own knob:
meson compile -C build -j1 # ninja via meson
cmake --build build -j1 # ninja/make via cmake
UV_THREADPOOL_SIZE=1 npm run build
Pinning to a single core is the blunt version, and it works for tools with no job flag at all:
sudo taskset -c 0 apt install <package>
taskset -c 0 ./build.sh
taskset constrains placement, not memory, so it helps only where the memory
pressure comes from concurrency. If one single translation unit does not fit,
neither -j1 nor taskset saves it — that needs more RAM or more swap.