fix(engine): realistic worker memory budget + sizing/feedback telemetry

This commit is contained in:
Vance Ingalls
2026-07-21 14:31:14 -07:00
parent ed32898439
commit 12e599a6ba
11 changed files with 387 additions and 23 deletions
@@ -16,9 +16,11 @@ import {
type CaptureOptions,
type CaptureSession,
type EngineConfig,
type WorkerSizing,
calculateOptimalWorkers,
captureFrameToBuffer,
closeCaptureSession,
computeWorkerSizing,
createCaptureSession,
initializeSession,
} from "@hyperframes/engine";
@@ -113,6 +115,8 @@ export function resolveRenderWorkerCount(
compiled: Pick<CompiledComposition, "hasShaderTransitions" | "renderModeHints">,
log: ProducerLogger = defaultLogger,
measuredCaptureCost?: CaptureCostEstimate,
/** Sink for the sizing provenance so the orchestrator can thread it into telemetry. */
onSizing?: (sizing: WorkerSizing) => void,
): number {
// TODO(htmlInCanvas): workaround — Chrome's experimental drawElementImage
// API (CanvasDrawElement) is non-deterministic across concurrent browser
@@ -144,10 +148,27 @@ export function resolveRenderWorkerCount(
estimateCaptureCostMultiplier(compiled),
measuredCaptureCost,
);
const workerCount = calculateOptimalWorkers(totalFrames, requestedWorkers, {
const sizing = computeWorkerSizing(totalFrames, requestedWorkers, {
...cfg,
captureCostMultiplier: captureCost.multiplier,
});
onSizing?.(sizing);
const workerCount = sizing.workers;
// Advisory heap check (field OOM, 0.7.66 on 24GB: auto-picked 6 workers
// blew Node's default ~4GB heap). Not enforced yet — the budget constant is
// derived from one field report; the workers_heap_* telemetry emitted with
// this sizing decides whether to enforce. The warning gives the operator
// the actionable knobs today.
if (sizing.exceedsHeapAdvisory) {
log.warn(
`[Render] ${workerCount} capture workers may exceed this process's V8 heap ` +
`(limit ${sizing.heapLimitMb}MB supports ~${sizing.heapBasedWorkers}). If the render ` +
`dies with "JavaScript heap out of memory", raise the heap ` +
`(NODE_OPTIONS=--max-old-space-size=8192) or pass --workers ${sizing.heapBasedWorkers}.`,
{ heapLimitMb: sizing.heapLimitMb, heapBasedWorkers: sizing.heapBasedWorkers },
);
}
if (requestedWorkers !== undefined || captureCost.multiplier <= 1) {
return workerCount;
@@ -4,7 +4,7 @@
*/
import { fpsToNumber } from "@hyperframes/core";
import type { CapturePerfSummary, SubTimelineWaitOutcome } from "@hyperframes/engine";
import type { CapturePerfSummary, SubTimelineWaitOutcome, WorkerSizing } from "@hyperframes/engine";
import type { CaptureCalibrationSample, CaptureCostEstimate } from "./captureCost.js";
import type {
CaptureAttemptSummary,
@@ -181,6 +181,8 @@ function aggregateBeginFrameReuse(
export function buildRenderPerfSummary(input: {
job: RenderJob;
workerCount: number;
/** Auto-sizing provenance; undefined when a pin (htmlInCanvas / low-memory) short-circuited sizing. */
workerSizing?: WorkerSizing;
enableChunkedEncode: boolean;
chunkedEncodeSize: number;
compositionDurationSeconds: number;
@@ -217,6 +219,7 @@ export function buildRenderPerfSummary(input: {
fps: fpsToNumber(input.job.config.fps),
quality: input.job.config.quality,
workers: input.workerCount,
workerSizing: input.workerSizing,
chunkedEncode: input.enableChunkedEncode,
chunkSizeFrames: input.enableChunkedEncode ? input.chunkedEncodeSize : null,
compositionDurationSeconds: input.compositionDurationSeconds,
@@ -76,6 +76,7 @@ import {
type CapturePerfSummary,
type CaptureWarning,
type SubTimelineWaitOutcome,
type WorkerSizing,
resolveBrowserGpuMode,
resolveHeadlessShellPath,
applyConcreteGpuScreenshotClamp,
@@ -373,6 +374,14 @@ export interface RenderPerfSummary {
fps: number;
quality: string;
workers: number;
/**
* Provenance of the auto worker-sizing decision (undefined when the
* htmlInCanvas / low-memory pins short-circuited sizing). `boundBy` names
* the binding constraint; the heap fields are the advisory budget being
* validated by fleet telemetry before enforcement — see
* `computeWorkerSizing` in @hyperframes/engine.
*/
workerSizing?: WorkerSizing;
chunkedEncode: boolean;
chunkSizeFrames: number | null;
compositionDurationSeconds: number;
@@ -1703,6 +1712,7 @@ async function executeRenderPipeline(input: {
// "routed" = the parallel router fired and held; "reverted" = fired but
// the self-verify retry rolled back; undefined = never fired.
let deParallelRouter: "routed" | "reverted" | undefined;
let workerSizing: WorkerSizing | undefined;
execution.defer("rollback staged artifact", () => artifactTransaction.rollback());
execution.defer("close file server", () => {
@@ -2475,6 +2485,9 @@ async function executeRenderPipeline(input: {
compiled,
log,
captureCalibration?.estimate,
(sizing) => {
workerSizing = sizing;
},
);
// DE priority inversion — see shouldPreferSingleWorkerDrawElement for the
// policy and benchmark rationale (eligibility resolved above, before
@@ -3281,6 +3294,7 @@ async function executeRenderPipeline(input: {
const perfSummary = buildRenderPerfSummary({
job,
workerCount,
workerSizing,
enableChunkedEncode,
chunkedEncodeSize,
compositionDurationSeconds: composition.duration,