Skip to content

fix(engine): cap auto workers by parent heap budget - #3803

Draft
vanceingalls wants to merge 2 commits into
mainfrom
fix/prinfra-338-heap-worker-cap
Draft

fix(engine): cap auto workers by parent heap budget#3803
vanceingalls wants to merge 2 commits into
mainfrom
fix/prinfra-338-heap-worker-cap

Conversation

@vanceingalls

@vanceingalls vanceingalls commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

The reported 18-core/24GB host selected six capture workers despite a roughly 4GB parent V8 heap. Raising the Chrome RSS budget alone still selects six. Apply the existing parent-heap estimate after the parallel floor and contention cap; that host now selects four workers, and a heap below the reserve selects one. Explicit --workers remains authoritative, and sizing telemetry reports boundBy: heap.

Related: PRINFRA-338, PRINFRA-341.


Why this is still a draft: the gate ran but the instrument was blind

Both draft blockers were executed. The honest outcome is "not decidable yet" — not a pass, and not the "this is wrong" I briefly concluded and have since retracted on PRINFRA-341.

The original report is worker-causal

From PRINFRA-300 fault mode #8: darwin/arm64 18c/24GB, --quality high --fps 30 --strict-all, auto-selected 6 workers, FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory, and --workers 1 produced correct output. Lowering the worker count fixed it. The mechanism this PR targets is real.

What the fleet could and could not tell us

Queried the Hyperframes project (2,676,589 render_complete, 317,253 render_error, 2026-07-21 → 2026-09-11):

  • 25.1% of renders cross the advisory line and would be throttled by enforcement.
  • Heap limits: p05 2,240MB, p50 4,192MB, p95 4,288MB. 9,292 renders (0.35%) sit under 1,664MB and would be forced to a single worker.
  • A local 6-vs-4-worker benchmark measured ~+21% wall — but at 540p/600 frames, far lighter than the reporter's --quality high workload.
  • Zero heap OOMs in 317,253 failures — uninformative, because a fatal V8 OOM aborts before telemetry flushes.

What I cannot tell you is whether 640MB/worker is the right magnitude, because peak_memory_mb is process.memoryUsage.rss() sampled at completion — after per-frame buffering has drained. Worker-driven parent buffering is a mid-render phenomenon, so the field is blind to it by construction. An earlier revision of this description leaned on that data to argue the constant was wrong by ~250×; that claim is withdrawn.

Telemetry gap, now fixed separately

workers_bound_by, workers_heap_based, workers_heap_limit_mb and workers_exceed_heap_advisory ship on render_complete only — 0 of 317,253 render_error events carry them, so advisory-true renders can never be correlated with failures.

Both problems are addressed in fix/render-error-sizing-telemetry: sizing on the failure path, and the existing sampler's true running peak instead of the teardown snapshot (plus peak_heap_used_mb). That branch is independent of this one and worth landing regardless of what happens here.

What would settle this PR

  1. Land the telemetry branch.
  2. Reproduce the reporter's shape locally — 1080p, --quality high, long render, 6 workers, ~4GB heap — reading the true peak. That directly tests the constant and needs no fleet data.
  3. Retune HEAP_PER_WORKER_MB to whatever that measures, then merge or drop this.

The mechanism is sound and the code implements it correctly; the open question is purely the magnitude of the constant.


Validation performed

56 coordinator tests passed, including five deterministic heap-budget cases; engine and producer typechecks, scoped oxlint/oxfmt, and the fallow new-issue gate passed. CI green 60/60.

Review feedback applied in df205869: the HEAP_PER_WORKER_MB comment no longer says "validate before rollout" next to live enforcement, and the buildHeapAdvisoryWarning doc block is corrected — its requestedWorkers !== undefined guard and the newly enforced cap are mutually exclusive, so the warning is unreachable rather than "live only for explicit --workers". The exceedsHeapAdvisory flag it reads stays live via CLI telemetry.

What the constants would do fleet-wide, for the record

  • Default ~4GB heap → 4 workers for every auto-sized render regardless of core count (a 32-core host the contention path would size to 10 also lands at 4).
  • Heap under ~1664MB → 1 worker, overriding the two-worker parallel floor.

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment-only: this is a draft, so no stamp from me. Read the full parallelCoordinator.ts sizing path and the new test at head.

Your own description already names the central risk — that 640MB/worker and the 1024MB reserve come from one field report, that PRINFRA-341 deliberately deferred enforcement, and that this can materially reduce concurrency on low-default-heap hosts. I'm not going to restate that back at you. Everything below is what I found on top of it.

1. The in-source comment will contradict the code the moment this merges

The replacement comment on HEAP_PER_WORKER_MB reads "Validate this estimate against the workers_heap_ fleet telemetry before rollout (PRINFRA-341)"* while :409-412 now enforces the cap. The merge gate lives in the PR description, but the description is not what the next reader sees — the comment is, and it will say validation is still pending while enforcement is live. The old comment was self-consistent because it said "advisory-only" and the code was advisory. Two ways to keep that property: gate enforcement behind a config knob so the comment stays true until you flip it, or land it with the comment replaced by what validated the figure.

2. The fleet-visible thresholds are wider than the reported host's 6 → 4

The cap is Math.max(1, Math.floor((heapLimitMb - 1024) / 640)) (:323-326), so:

  • On a default ~4GB Node heap it is 4 workers for every auto-sized render, regardless of core count. A 32-core host that the contention path sizes to 10 (per the comment's own "32 cores → 10 workers") also lands at 4. That is a much broader statement than the 18-core host going 6 → 4.
  • Any host whose heap_size_limit is below ~1664MB gets exactly 1 worker, because that is where the floor takes over. That also overrides the two-worker parallel floor by design (:403-412, pinned by "allows one worker below the heap reserve despite the parallel floor"), so a long render on such a host becomes fully serial.

Both numbers are worth putting in the benchmark plan explicitly, since they are what the fleet will actually experience.

3. exceedsHeapAdvisory becomes unreachable for auto-sized renders

:347 computes workers > heapBasedWorkers. Once enforcement guarantees finalWorkers <= heapBasedWorkers, that can only be true via the explicit-requested path, so the captureCost.ts warning is live only for explicit --workers. Your updated comment says as much, and keeping it is reasonable — flagging only because "defensive warning for a future alternate policy" is exactly the shape that reads as dead code to whoever touches it next. A one-line note naming the explicit path as its only live caller would prevent that.

4. Interaction with #3801, since these were sent together

Safe, and worth stating why: #3801's initialization retry sets currentWorkers = getNextRetryWorkerCount(currentWorkers) directly and never re-enters computeWorkerSizing, and it only ever halves — so the heap cap cannot be re-raised on retry, and the two changes compose in the same direction. The thing to notice is that they stack: on a default-heap host this PR sizes to 4, and a Network.enable timeout then halves to 2, so the retry concurrency the field sees is set by both PRs together rather than by either one.

5. The test is genuinely deterministic — I checked the two things that usually make this shape lie

  • parallelCoordinator.ts:8 imports cpus from bare "os", which is what vi.mock("os", …) intercepts. Had it imported node:os, the mock would silently not apply and the CPU-cap case would be scored against this host's real core count.
  • getHeapStatistics is called exactly once per computeWorkerSizing (:322), so mockReturnValueOnce is sufficient and no assertion falls through to the real host heap.

Both fine as written. Mentioning it so the next person doesn't have to re-derive it.

Also minor: WorkerSizingBound is publicly exported (packages/engine/src/index.ts:258) and "heap" is a new member. The only consumer is telemetry passthrough (packages/cli/src/commands/render.ts:1525) — no exhaustive switch to break — so it is additive for JS consumers and mildly breaking only for a TS consumer switching exhaustively over the union.

Head reviewed: 5c8e8752d30ecf7f347e785271acd7792547fb7c

Verdict: COMMENT
Reasoning: The mechanism is correct, the floor prevents a zero-worker path, and the tests control their inputs properly — but it is a draft and the enforcement decision is the open question you already gated it on. Items 1 and 2 are the ones I would want resolved before it leaves draft.

— Rames Jusso

@vanceingalls
vanceingalls marked this pull request as ready for review September 9, 2026 07:45
@vanceingalls
vanceingalls marked this pull request as draft September 9, 2026 07:45
Review feedback on #3803.

The HEAP_PER_WORKER_MB comment still read "validate this estimate before
rollout" while the sizing path now enforces the cap, so the next reader
would see pending validation next to live enforcement. State that the cap
is enforced, that an explicit `--workers N` bypasses it, and record the two
fleet-wide thresholds the constants imply: a default ~4GB heap selects 4
workers for every auto-sized render regardless of core count, and a
heap_size_limit under ~1664MB selects 1 worker, overriding the two-worker
parallel floor. PRINFRA-341 remains the pre-merge validation gate.

Also correct the buildHeapAdvisoryWarning doc block. It claimed the warning
stayed live as a defensive path, but its `requestedWorkers !== undefined`
guard and the newly enforced cap are mutually exclusive: the auto path can
no longer exceed heapBasedWorkers and the explicit path is guarded out, so
the warning is unreachable for every real render. Keep it, because reverting
the cap to advisory is PRINFRA-341's open question, and note that the
exceedsHeapAdvisory flag itself stays live through CLI telemetry.

Comments only; no behavior change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants