Repository navigation
perf(engine): size render workers from measured heap and the container's CPU quota - #6641
Conversation
…ners Auto worker sizing caps capture workers at (V8 heap - 1024 MB) / 640 MB. Node sizes its default heap from visible memory, so in a 10 GB container the heap is 2,096 MB and every render captures with one worker, whatever the core count. When no --workers is given, no --docker, no heap was set by hand, and the heap is the only bound holding workers back, render relaunches itself once with --max-old-space-size set to what CPU and memory allow (capped at half of memory). The engine exposes heapMbForAutoWorkers() for this.
Edit accuracy: accurate 271 (base branch 271), smooth 137 of thoseThe gate passes. Quarantined, measured but not gated (0) |
…alive The relaunched child skips the CLI's command telemetry, update and skills notices, and app-history notice; the parent already records and prints them. A detached render no longer gets SIGHUP forwarded, which its default handler would treat as a kill. A heap set as --max_old_space_size counts as set by hand.
jrusso1020
left a comment
There was a problem hiding this comment.
Looks good. We checked the parts that could go wrong:
- Memory:
getSystemTotalMbreads the cgroup limit, so the half-memory cap uses the container's 10 GB, not the host's. - Signals: a terminal Ctrl-C reaches both parent and child, so the child sees SIGINT twice. That's safe because the cancellation
abortonly fires once. The parent's listeners also stop it from dying before the child exits, and they are removed on exit. - Relaunch guard:
HF_RENDER_HEAP_MBplus the hand-set check (both flag spellings, NODE_OPTIONS and execArgv) rule out a double relaunch. Telemetry and notices are skipped in the child. preHeapWorkersonly adds a field. Existing sizing is unchanged.
Nit, fine as a follow-up: heapMbForAutoWorkers sizes for an unbounded frame count, so a short render that will only ever use 1 worker still relaunches in a small container. That costs one extra Node startup and produces the same output. To skip it, render.ts could only relaunch when the composition is long enough to get more than one worker, but that needs the frame count before the plan is built, so it isn't worth blocking on.
miguel-heygen
left a comment
There was a problem hiding this comment.
Requesting changes. The relaunch works around the per-worker heap figure instead of fixing it, and that figure is what is wrong.
Blocker: fix the budget, not the heap size. HEAP_PER_WORKER_MB = 640 (packages/engine/src/services/parallelCoordinator.ts:155) was inferred from one field OOM. The only measured figure in the same file is ~15-25 MB of parent heap per worker (APPLE_DISK_HEAP_PER_WORKER_MB, line 157). Your two runs already record the real number: please post peakHeapUsedMb from each run's perf summary next to its worker count. If it sits well under 2,096 MB at 2-3 workers, the heap was never the limit, and a per-worker figure derived from that data gives the same 2-3 workers in a 10 GB container with no second process. That fix also reaches every caller of computeWorkerSizing (the producer server and other embedders), while the relaunch only covers hyperframes render.
Blocker: the CPU bound reads the host, not the container. cpuBasedWorkers is cpus().length - 2 (lines 324, 338), which ignores a cgroup CPU quota, while memory already reads the cgroup limit (line 328). In a container started with --cpus 4 on a 32-core host, the CPU bound says 30. Today the heap clamp hides this. Lifting the heap, by relaunch or by a better figure, exposes it and can oversubscribe a CPU-limited container: that shows up as CFS throttling, not speed. Read cpu.max (cgroup v2) or cpu.cfs_quota_us / cpu.cfs_period_us (v1) next to the memory read, with a test for each.
With both fixed, I expect nothing in this PR is still needed: the relaunch, signal forwarding, child telemetry skip and HF_RENDER_HEAP_MB guard all exist to satisfy a budget that looks several times too high.
Important, only if the relaunch stays: heapMbForAutoWorkers always multiplies by HEAP_PER_WORKER_MB (line 431 on this branch), ignoring the Apple-disk figure the sizer itself uses, so the two disagree on that path. The branch also conflicts with main, and Comments is red.
The speedups in the body are real for that container. The point is to get them from the right bound, for every caller.
Verdict: REQUEST CHANGES
Reasoning: The worker cap is a wrong per-worker heap constant plus a CPU bound that ignores container quotas; fixing those two in the sizer is smaller, covers every caller, and makes the relaunch unnecessary.
…r's CPU quota Auto worker sizing capped workers at (heap - 1024) / 640 MB, so a 10 GB container (Node's default heap 2,096 MB) captured with one worker. The 640 figure came from one field OOM. Measured with --trace-gc in a 10 GB, 3.5-CPU container, the parent heap grows 21 MB per worker on a short film and 228 MB on a 1080x1920 video-heavy one (427, 879, 1,110 MB at 1, 3 and 4 workers), all well under the default heap. The figure is now 320 MB, which gives 3 workers in that container with no relaunch. The CPU bound read the host's cores and ignored a cgroup quota, which the heap clamp used to hide. It now reads cpu.max (v2) or cfs quota/period (v1) beside the memory limit and bounds workers at the quota's whole CPUs: at --cpus 3.5, 3 workers rendered faster than 2 or 4.
|
@miguel-heygen thanks, you were right on both. Reworked at
The Apple-disk mismatch disappears with |
jrusso1020
left a comment
There was a problem hiding this comment.
Re-reviewed at 55a40c0. This version is simpler and we like it better than the relaunch.
parseCgroupCpus/getCgroupCpuLimit: v2cpu.maxfirst, then v1 quota over period, andmax,-1, zero and malformed values all come back as null. A fractional quota under 1 floors to 0 andMath.max(1, ...)keeps one worker. Good.min(cores - 2, floor(quota))only ever lowers the CPU bound, so hosts without a quota are unchanged.- The 3.5 CPU bench (3 workers beat 2 and 4) backs the floor.
One thing to watch, not blocking. Going from 640 to 320 also lifts the heap bound on bigger hosts. With a ~4 GB default heap it goes from 4 workers to 9, and on a 48+ core box auto can pick 6 again. 6 workers on a ~4 GB heap is the field OOM the 640 figure came from. Your ig03 slope says 6 workers need about 1.6 GB, so that film fits fine. The field composition used over 500 MB per worker, though, and the PR notes it wasn't re-run. If it comes back, a cheap guard is to stop adding workers when heap_used passes about 75% of heap_size_limit during the first chunk, or to log heapBasedWorkers and peak heap per render so we can see it. Fine as a follow-up.
— Rames
miguel-heygen
left a comment
There was a problem hiding this comment.
Re-review at 55a40c0d: this is the right fix. The relaunch is gone, the per-worker figure now comes from a measurement, and the CPU bound reads the container's quota.
Strengths
systemMemory.ts:parseCgroupCpushandles v2cpu.max(includingmax) and v1 quota/period, and returns null when there's no quota, so hosts without one are unchanged. Tests cover each format.parallelCoordinator.ts:HEAP_PER_WORKER_MB = 320cites the measurement it came from (427 to 1,110 MB going from 1 to 4 workers on the heavy film).
Important, fine as a follow-up: two other terms in the same sizer still read the host's cores instead of the quota:
- the contention clamp (
cpuScaledMax = floor(cpuCount / weightedCoresPerWorker), line 419); - the auto max (
defaultSafeMaxWorkers, line 175).
With a 6-CPU quota on a 32-core host, the clamp sees 32 cores. Routing them through the quota would change worker counts on CPU-limited hosts, so that change should come with its own measurement. We're benchmarking exactly that on 6-CPU containers now and will follow up in a separate PR that builds on this one.
Nit: the 640 figure came from a field OOM that couldn't be rerun, as you noted. The workers_heap_* telemetry will show whether 320 holds across real compositions.
Verdict: APPROVE
Reasoning: Both earlier blockers are fixed at the root, with measurements and tests; what's left is making the rest of the sizer use the quota, which needs its own data.
Inside Electron the heap is a hard 4,192 MB cage, and that is where the field OOM behind the old figure happened (#5149: 5 auto workers on a heavy export). The new 320 MB figure is measured only in a system Node, so the desktop app keeps 640 MB (4 workers there) until a heavy desktop export is measured. Containers and plain Node keep 320.
…uns the CLI on plain Node
jerrai-bot-heygen
left a comment
There was a problem hiding this comment.
The desktop client tag reaches the packaged plain-Node render and preserves the prior 640 MB worker allowance; the new test covers that sizing branch. The original composition and the vendored Node heap remain unmeasured. — Jerrai
Problem
Auto worker sizing capped capture workers at
(V8 heap − 1024 MB) / 640 MB. In a 10 GB container Node's default heap is 2,096 MB, so every render captured with one worker on any number of cores.The 640 MB figure came from one field OOM. Measured, the parent heap needs far less (below), so the heap was never the real limit.
Change (engine only)
HEAP_PER_WORKER_MB640 → 320, from measured peaks. In the 10 GB container with the default 2,096 MB heap that allows 3 workers, with no relaunch or second process. Every caller ofcomputeWorkerSizinggets it, not onlyhyperframes render.process.versions.electronor by theHYPERFRAMES_CLIENT=desktop/…tag it sets on every CLI call.getCgroupCpuLimit()readscpu.max(cgroup v2) orcpu.cfs_quota_us/cpu.cfs_period_us(v1) beside the memory limit; with a quota, CPU-bound workers aremin(cores − 2, floor(quota)). Without one, unchanged.The relaunch, signal forwarding, child telemetry skip and
HF_RENDER_HEAP_MBfrom the first version are gone.Measured
Peak parent V8 heap from
node --trace-gc(max heap-used before a GC), in the bench container:--memory 10g --cpus 3.5, default heap 2,096 MB,--workers N.320 MB keeps 1.4× over the heaviest measured slope, and the 1,024 MB reserve sits well above the measured base (~200 MB).
CPU, same container, johnny: 1 worker 138 s, 2 → 125 s, 3 → 96 s, 4 → 132 s, so
floor(3.5) = 3is the right bound there.Not exercised: the field-OOM composition behind the old 640 figure (not available), and cgroup v1 on a real host (unit-tested only).
Tests
systemMemory.test.ts:parseCgroupCpus(v2, v1, unlimited, malformed) andgetCgroupCpuLimit(quota,max, non-Linux).parallelCoordinator.test.ts: the heap-bound case is now the container case (2,096 MB → 3 workers); a new case checks the desktop app's 4,192 MB heap still gives 4 inside Electron and 4 on plain Node taggeddesktop/…(9 otherwise). Engine 88 pass withsystemMemory.