Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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",
});
});
});
22 changes: 14 additions & 8 deletions packages/engine/src/services/parallelCoordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,8 @@ 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. Validate this estimate against the
// workers_heap_* fleet telemetry before rollout (PRINFRA-341).
const HEAP_PER_WORKER_MB = 640;
const MIN_WORKERS = 1;
const MAX_WORKER_DIAGNOSTIC_LINES = 8;
Expand Down Expand Up @@ -272,6 +270,7 @@ export type WorkerSizingBound =
| "too_few_frames"
| "cpu"
| "memory"
| "heap"
| "frames"
| "max_workers"
| "min_parallel_floor"
Expand All @@ -290,17 +289,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 +403,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
7 changes: 2 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,8 @@ 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.
* - Auto-sizing now applies the heap cap. Keep this defensive warning for
* sizing values supplied by older callers or a future alternate policy.
*
* 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