Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions packages/engine/src/services/parallelCoordinator.heap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, expect, it, vi } from "vitest";
import { getHeapStatistics } from "v8";
import { computeWorkerSizing } from "./parallelCoordinator.js";

vi.mock("os", async (importOriginal) => ({
...(await importOriginal<typeof import("os")>()),
cpus: () =>
Array.from({ length: 18 }, () => ({
model: "test",
speed: 3000,
times: { user: 0, nice: 0, sys: 0, idle: 0, irq: 0 },
})),
}));
vi.mock("./systemMemory.js", () => ({ getSystemTotalMb: () => 24576 }));
vi.mock("v8", async (importOriginal) => {
const original = await importOriginal<typeof import("v8")>();
return { ...original, getHeapStatistics: vi.fn(original.getHeapStatistics) };
});

function withHeapLimit(mb: number): void {
vi.mocked(getHeapStatistics).mockReturnValueOnce({
...getHeapStatistics(),
heap_size_limit: mb * 1024 * 1024,
});
}

describe("parent heap worker cap", () => {
it("caps the reported 18-core/24GB host at four workers with a 4GB heap", () => {
withHeapLimit(4096);
const result = computeWorkerSizing(1800);
expect(result).toMatchObject({
workers: 4,
heapBasedWorkers: 4,
boundBy: "heap",
exceedsHeapAdvisory: false,
});
});

it("allows one worker below the heap reserve despite the parallel floor", () => {
withHeapLimit(512);
expect(computeWorkerSizing(1800)).toMatchObject({ workers: 1, boundBy: "heap" });
});

it("keeps the CPU contention cap when the heap has sufficient room", () => {
withHeapLimit(16384);
expect(computeWorkerSizing(1800)).toMatchObject({ workers: 6 });
});

it("honors explicit workers and retains the exceeded-budget diagnostic", () => {
withHeapLimit(1024);
expect(computeWorkerSizing(1800, 6)).toMatchObject({
workers: 6,
boundBy: "explicit",
exceedsHeapAdvisory: true,
});
});

it("does not let configured concurrency bypass the heap budget", () => {
withHeapLimit(1024);
expect(computeWorkerSizing(1800, undefined, { concurrency: 12 })).toMatchObject({
workers: 1,
boundBy: "heap",
});
});
});
32 changes: 24 additions & 8 deletions packages/engine/src/services/parallelCoordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,18 @@ const MEMORY_PER_WORKER_MB = 1536;
const HEAP_RESERVED_MB = 1024;
// Parent-process V8 heap consumed per worker (protocol buffers + in-flight
// frame buffers). Derived from the field OOM: 6 workers exhausted a ~4GB
// default heap ⇒ >~500MB/worker + base. ponytail: advisory-only until the
// workers_heap_* telemetry added alongside this constant validates the figure
// — enforcing a guessed budget could silently cut worker counts fleet-wide.
// TODO(PRINFRA-341): decide enforcement after ~2 weeks of fleet soak.
// default heap ⇒ >~500MB/worker + base. ENFORCED as a cap on auto-sizing
// below; an explicit `--workers N` bypasses it and stays authoritative.
//
// This and HEAP_RESERVED_MB come from a single field report, so know what
// they cost fleet-wide before trusting or changing them:
// - a default ~4GB Node heap selects 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;
// - any host whose heap_size_limit is under ~1664MB selects 1 worker, which
// overrides the two-worker parallel floor and serializes long renders.
// PRINFRA-341 validates both figures against workers_heap_* fleet telemetry
// before this leaves draft; replace this note with what validated them.
const HEAP_PER_WORKER_MB = 640;
const MIN_WORKERS = 1;
const MAX_WORKER_DIAGNOSTIC_LINES = 8;
Expand Down Expand Up @@ -272,6 +280,7 @@ export type WorkerSizingBound =
| "too_few_frames"
| "cpu"
| "memory"
| "heap"
| "frames"
| "max_workers"
| "min_parallel_floor"
Expand All @@ -290,17 +299,16 @@ export interface WorkerSizing {
frameBasedWorkers: number;
effectiveMaxWorkers: number;
/**
* ADVISORY, not enforced (see HEAP_PER_WORKER_MB): how many workers the
* parent process's V8 heap could feed. Compare against `workers` in
* telemetry to validate the budget before enforcement.
* Auto-sizing cap: how many workers the parent process's V8 heap could
* feed. Explicit worker requests may exceed this budget.
*/
heapBasedWorkers: number;
/** V8 `heap_size_limit` for the parent process, MB. */
heapLimitMb: number;
totalMemoryMb: number;
cpuCount: number;
captureCostMultiplier: number;
/** true when the chosen count exceeds the advisory heap budget. */
/** true when an explicit worker count exceeds the heap budget. */
exceedsHeapAdvisory: boolean;
}

Expand Down Expand Up @@ -405,6 +413,14 @@ export function computeWorkerSizing(
}
}

// Apply after the two-worker parallel floor and CPU contention cap so a
// small parent heap can select one worker even for a long render. Chrome's
// RSS budget above does not account for buffers retained by the parent.
if (finalWorkers > heapBasedWorkers) {
finalWorkers = heapBasedWorkers;
boundBy = "heap";
}

return finish(finalWorkers, boundBy, effectiveMaxWorkers);
}

Expand Down
19 changes: 14 additions & 5 deletions packages/producer/src/services/render/captureCost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,20 @@ function combineCaptureCostEstimates(
* - Auto-sized renders only (`requestedWorkers === undefined`) — the field
* failure was auto sizing, and an explicit `--workers N` is the operator's
* own call.
* - Not enforced as a cap yet — the per-worker budget constant is derived
* from one field report; the `workers_heap_*` telemetry emitted with the
* sizing decides whether to enforce (see the TODO on HEAP_PER_WORKER_MB in
* @hyperframes/engine's parallelCoordinator). The message gives the
* operator the actionable knobs today.
* - Only when the chosen count exceeds the heap budget.
*
* Those two conditions are now mutually exclusive, so this returns undefined
* for every real render: auto-sizing enforces the heap cap, so the auto path
* can no longer exceed `heapBasedWorkers`, and the explicit `--workers N`
* path — the only one that still can — is guarded out by the first condition.
*
* Kept rather than deleted because enforcement is PRINFRA-341's open
* question: if fleet telemetry rejects the 640MB/worker + 1024MB reserve
* figures and the cap reverts to advisory, this is the live path again.
* Delete it once enforcement ships validated. The `exceedsHeapAdvisory` flag
* it reads stays live either way — CLI telemetry reports it as
* `workersExceedHeapAdvisory`, and it is still true for an over-budget
* explicit `--workers N`.
*
* Pure so the message shape + firing condition are unit-testable with a
* synthetic `WorkerSizing` (the real one depends on the host's heap).
Expand Down
Loading