mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 10:14:30 +00:00
After #916 moved `assertSwiftShader` from `renderChunk()`'s eager probe session into `executeWorkerTask`, every parallel worker began running its own `chrome://gpu` / canvas-WebGL probe. At `chunkWorkerCount=6` (texture launch at chunks=3) that's 6 concurrent CDP page-loads per chunk × 3 chunks = 18 simultaneous probes. Bench data on dev (12 producer pods × 22 vCPU) showed c=3 worst-case wall-clock at 67.3s, 24.7s above c=6 worst (42.6s) — pod_total inflates 100s → 147s uniformly across all three chunks per slow iter, the signature of cluster-level CDP contention rather than within-pod contention. Workers within a chunk share the same Chrome binary, flags, and OS/driver state on a single pod, so worker 0's success is representative for the rest. Gate the probe via `shouldVerifyWorkerGpu(workerId, config)` so only worker 0 navigates to the probe page; workers 1..N-1 skip it. The fail-fast contract still holds at the chunk level (worker 0 still aborts the chunk if SwiftShader didn't load) — just without the concurrent CDP traffic. Expected wall-clock impact: c=3 worst drops from ~67s to in line with c=6 worst (~42-44s). c=6 (3 workers/pod) and c=8 (2 workers/pod) should see smaller wins; c=12 (1 worker/pod, sequential branch) is unaffected. Closes #955.
392 lines
14 KiB
TypeScript
392 lines
14 KiB
TypeScript
/**
|
||
* Parallel Coordinator Service
|
||
*
|
||
* Coordinates parallel frame capture across multiple Puppeteer sessions.
|
||
* Auto-detects optimal worker count based on CPU/memory.
|
||
*/
|
||
|
||
import { cpus, freemem, totalmem } from "os";
|
||
import { existsSync, mkdirSync, readdirSync } from "fs";
|
||
import { copyFile, rename } from "fs/promises";
|
||
import { join } from "path";
|
||
|
||
import {
|
||
createCaptureSession,
|
||
initializeSession,
|
||
closeCaptureSession,
|
||
captureFrame,
|
||
captureFrameToBuffer,
|
||
getCapturePerfSummary,
|
||
type CaptureSession,
|
||
type CaptureOptions,
|
||
type CapturePerfSummary,
|
||
type BeforeCaptureHook,
|
||
} from "./frameCapture.js";
|
||
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
|
||
import { assertSwiftShader } from "../utils/assertSwiftShader.js";
|
||
import { readWebGlVendorInfoFromCanvas } from "../utils/readWebGlVendorInfoFromCanvas.js";
|
||
|
||
export interface WorkerTask {
|
||
workerId: number;
|
||
startFrame: number;
|
||
endFrame: number;
|
||
outputDir: string;
|
||
/**
|
||
* Offset subtracted from the absolute frame index when naming the captured
|
||
* file (`frame_<i - outputFrameOffset>.{ext}`). Default 0. Distributed
|
||
* chunks set this to the chunk's absolute startFrame so file names land
|
||
* 0-indexed within the chunk's range — the encoder reads frames
|
||
* sequentially without an `-start_number` override. The per-frame TIME
|
||
* calculation still uses the absolute frame index.
|
||
*/
|
||
outputFrameOffset?: number;
|
||
}
|
||
|
||
export interface WorkerResult {
|
||
workerId: number;
|
||
framesCaptured: number;
|
||
startFrame: number;
|
||
endFrame: number;
|
||
durationMs: number;
|
||
perf?: CapturePerfSummary;
|
||
error?: string;
|
||
}
|
||
|
||
export interface ParallelProgress {
|
||
totalFrames: number;
|
||
capturedFrames: number;
|
||
activeWorkers: number;
|
||
workerProgress: Map<number, number>;
|
||
}
|
||
|
||
export interface WorkerSizingConfig extends Partial<
|
||
Pick<
|
||
EngineConfig,
|
||
"concurrency" | "coresPerWorker" | "minParallelFrames" | "largeRenderThreshold"
|
||
>
|
||
> {
|
||
/**
|
||
* Relative per-frame capture cost for auto worker sizing. Values above 1
|
||
* represent compositions that put more CPU pressure on each Chrome worker
|
||
* than a plain DOM screenshot. Explicit --workers requests ignore this hint.
|
||
*/
|
||
captureCostMultiplier?: number;
|
||
}
|
||
|
||
const MEMORY_PER_WORKER_MB = 256;
|
||
const MIN_WORKERS = 1;
|
||
// Hard ceiling on explicit `--workers N` requests. Above this, the cost of
|
||
// CDP-protocol dispatch through Node's main event loop and OS scheduling
|
||
// noise overwhelms any further parallelism. Bumped from 10 → 24 in hf#732
|
||
// follow-up so high-core hosts (32-96+ cores) can actually surface the
|
||
// hardware to renders that are CPU-bound on DOM capture.
|
||
const ABSOLUTE_MAX_WORKERS = 24;
|
||
// `auto` concurrency picks this many workers as the upper bound. Bumped
|
||
// from a hardcoded 6 → CPU-scaled value (floor(cpuCount/8), floor at 6,
|
||
// ceiling at 16) in hf#732 follow-up. Rationale: the prior fixed cap of 6
|
||
// left ~90 cores idle on the validation host and forced users to pass
|
||
// `--workers N` to opt in. Now `auto` matches what a thoughtful operator
|
||
// would pick by hand. The /8 divisor leaves headroom for each Chrome
|
||
// worker's SwiftShader compositor + the shader-blend thread pool, both of
|
||
// which are themselves CPU-heavy.
|
||
function defaultSafeMaxWorkers(): number {
|
||
return Math.max(6, Math.min(16, Math.floor(cpus().length / 8)));
|
||
}
|
||
const MIN_FRAMES_PER_WORKER = 30;
|
||
|
||
export function calculateOptimalWorkers(
|
||
totalFrames: number,
|
||
requested?: number,
|
||
config?: WorkerSizingConfig,
|
||
): number {
|
||
// Resolve effective values: config overrides → DEFAULT_CONFIG fallback.
|
||
const effectiveMaxWorkers = (() => {
|
||
const concurrency = config?.concurrency ?? DEFAULT_CONFIG.concurrency;
|
||
if (concurrency !== "auto") {
|
||
return Math.max(MIN_WORKERS, Math.min(ABSOLUTE_MAX_WORKERS, Math.floor(concurrency)));
|
||
}
|
||
return defaultSafeMaxWorkers();
|
||
})();
|
||
const effectiveCoresPerWorker = config?.coresPerWorker ?? DEFAULT_CONFIG.coresPerWorker;
|
||
const effectiveMinParallelFrames = config?.minParallelFrames ?? DEFAULT_CONFIG.minParallelFrames;
|
||
const effectiveLargeRenderThreshold =
|
||
config?.largeRenderThreshold ?? DEFAULT_CONFIG.largeRenderThreshold;
|
||
const captureCostMultiplier = Math.max(1, config?.captureCostMultiplier ?? 1);
|
||
|
||
if (requested !== undefined) {
|
||
return Math.max(MIN_WORKERS, Math.min(effectiveMaxWorkers, requested));
|
||
}
|
||
|
||
if (totalFrames < MIN_FRAMES_PER_WORKER * 2) return 1;
|
||
|
||
const cpuCount = cpus().length;
|
||
const cpuBasedWorkers = Math.max(1, cpuCount - 2);
|
||
|
||
// Use total memory instead of free memory — macOS reports misleadingly low
|
||
// freemem() because it aggressively caches files in "inactive" memory that
|
||
// is immediately reclaimable.
|
||
const totalMemoryMB = Math.round(totalmem() / (1024 * 1024));
|
||
const memoryBasedWorkers = Math.max(1, Math.floor((totalMemoryMB * 0.5) / MEMORY_PER_WORKER_MB));
|
||
|
||
const frameBasedWorkers = Math.floor(totalFrames / MIN_FRAMES_PER_WORKER);
|
||
|
||
const optimal = Math.min(cpuBasedWorkers, memoryBasedWorkers, frameBasedWorkers);
|
||
const minWorkersForJob = totalFrames >= effectiveMinParallelFrames ? 2 : MIN_WORKERS;
|
||
let finalWorkers = Math.max(minWorkersForJob, Math.min(effectiveMaxWorkers, optimal));
|
||
|
||
// Adaptive scaling: cap workers for large or expensive renders to prevent
|
||
// CPU contention. Each Chrome process (with SwiftShader) is CPU-heavy; too
|
||
// many concurrent captures can starve the compositor and surface as CDP
|
||
// protocol timeouts. Scale proportionally to CPU count and composition cost:
|
||
// 8 cores → 2 workers, 16 cores → 5 workers, 32 cores → 10 workers.
|
||
const weightedFrames = totalFrames * captureCostMultiplier;
|
||
const contentionThreshold = Math.max(
|
||
effectiveMinParallelFrames,
|
||
Math.floor(effectiveLargeRenderThreshold / 3),
|
||
);
|
||
if (totalFrames >= effectiveLargeRenderThreshold || weightedFrames >= contentionThreshold) {
|
||
const weightedCoresPerWorker = effectiveCoresPerWorker * captureCostMultiplier;
|
||
const cpuScaledMax = Math.max(MIN_WORKERS, Math.floor(cpuCount / weightedCoresPerWorker));
|
||
if (finalWorkers > cpuScaledMax) {
|
||
finalWorkers = cpuScaledMax;
|
||
}
|
||
}
|
||
|
||
return finalWorkers;
|
||
}
|
||
|
||
export function distributeFrames(
|
||
totalFrames: number,
|
||
workerCount: number,
|
||
workDir: string,
|
||
rangeStart: number = 0,
|
||
): WorkerTask[] {
|
||
const tasks: WorkerTask[] = [];
|
||
const framesPerWorker = Math.ceil(totalFrames / workerCount);
|
||
|
||
for (let i = 0; i < workerCount; i++) {
|
||
const startFrame = rangeStart + i * framesPerWorker;
|
||
const endFrame = Math.min(rangeStart + (i + 1) * framesPerWorker, rangeStart + totalFrames);
|
||
if (startFrame >= rangeStart + totalFrames) break;
|
||
|
||
tasks.push({
|
||
workerId: i,
|
||
startFrame,
|
||
endFrame,
|
||
outputDir: join(workDir, `worker-${i}`),
|
||
outputFrameOffset: rangeStart,
|
||
});
|
||
}
|
||
|
||
return tasks;
|
||
}
|
||
|
||
/**
|
||
* Decide whether a parallel worker should run the per-worker SwiftShader
|
||
* assertion. Gated to worker 0 only: workers within a chunk share the same
|
||
* Chrome binary, flags, and OS/driver state, so one verification per chunk
|
||
* is sufficient. See `heygen-com/hyperframes#955`.
|
||
*/
|
||
export function shouldVerifyWorkerGpu(workerId: number, config?: Partial<EngineConfig>): boolean {
|
||
return config?.browserGpuMode === "software" && workerId === 0;
|
||
}
|
||
|
||
async function executeWorkerTask(
|
||
task: WorkerTask,
|
||
serverUrl: string,
|
||
captureOptions: CaptureOptions,
|
||
createBeforeCaptureHook: () => BeforeCaptureHook | null,
|
||
signal?: AbortSignal,
|
||
onFrameCaptured?: (workerId: number, frameIndex: number) => void,
|
||
onFrameBuffer?: (frameIndex: number, buffer: Buffer) => Promise<void>,
|
||
config?: Partial<EngineConfig>,
|
||
): Promise<WorkerResult> {
|
||
const startTime = Date.now();
|
||
let framesCaptured = 0;
|
||
|
||
if (!existsSync(task.outputDir)) mkdirSync(task.outputDir, { recursive: true });
|
||
|
||
let session: CaptureSession | null = null;
|
||
let perf: CapturePerfSummary | undefined;
|
||
|
||
try {
|
||
session = await createCaptureSession(
|
||
serverUrl,
|
||
task.outputDir,
|
||
captureOptions,
|
||
createBeforeCaptureHook(),
|
||
config,
|
||
);
|
||
// Per-worker SwiftShader assertion, gated to worker 0 only.
|
||
// When `browserGpuMode: "software"` is declared, the chunk's GL backend
|
||
// must be verified as SwiftShader before the first frame — a host that
|
||
// falls back to a hardware GL backend (or silently fails to load
|
||
// SwiftShader) would otherwise produce non-deterministic pixels and
|
||
// break the distributed byte-identical-retry contract. Running this
|
||
// probe on every worker means N concurrent navigations to a WebGL
|
||
// probe page per chunk; with `chunkWorkerCount=6` × 3 chunks, that's
|
||
// 18 simultaneous CDP page-loads, which inflated c=3 worst-case wall
|
||
// by ~24s vs c=6/c=8 on the texture-launch bench. Workers in the same
|
||
// chunk share the same Chrome binary, flags, and OS/driver state, so
|
||
// worker 0's success is representative — gate it there and skip the
|
||
// rest. See `heygen-com/hyperframes#955` for the bench data and the
|
||
// pre-warmup probe interaction (which `renderChunk` already skips
|
||
// when `chunkWorkerCount > 1`).
|
||
if (shouldVerifyWorkerGpu(task.workerId, config)) {
|
||
await assertSwiftShader(session.page, readWebGlVendorInfoFromCanvas);
|
||
}
|
||
await initializeSession(session);
|
||
|
||
const outputOffset = task.outputFrameOffset ?? 0;
|
||
for (let i = task.startFrame; i < task.endFrame; i++) {
|
||
if (signal?.aborted) {
|
||
throw new Error("Parallel worker cancelled");
|
||
}
|
||
// captureOptions.fps is an Fps rational; collapse to decimal for the
|
||
// frame-index → time math. The 1-in-1001 ULP loss for NTSC is invisible
|
||
// at our scales (frame count tops out at single-digit thousands).
|
||
const time = (i * captureOptions.fps.den) / captureOptions.fps.num;
|
||
const fileFrameIdx = i - outputOffset;
|
||
|
||
if (onFrameBuffer) {
|
||
// The streaming-encode callback receives the absolute index `i`
|
||
// (not `fileFrameIdx`) so the encoder sequences frames against the
|
||
// composition's timeline.
|
||
const { buffer } = await captureFrameToBuffer(session, fileFrameIdx, time);
|
||
await onFrameBuffer(i, buffer);
|
||
} else {
|
||
await captureFrame(session, fileFrameIdx, time);
|
||
}
|
||
framesCaptured++;
|
||
|
||
if (onFrameCaptured) onFrameCaptured(task.workerId, i);
|
||
}
|
||
|
||
perf = getCapturePerfSummary(session);
|
||
return {
|
||
workerId: task.workerId,
|
||
framesCaptured,
|
||
startFrame: task.startFrame,
|
||
endFrame: task.endFrame,
|
||
durationMs: Date.now() - startTime,
|
||
perf,
|
||
};
|
||
} catch (error) {
|
||
const errMsg = error instanceof Error ? error.message : String(error);
|
||
return {
|
||
workerId: task.workerId,
|
||
framesCaptured,
|
||
startFrame: task.startFrame,
|
||
endFrame: task.endFrame,
|
||
durationMs: Date.now() - startTime,
|
||
perf,
|
||
error: errMsg,
|
||
};
|
||
} finally {
|
||
if (session) await closeCaptureSession(session).catch(() => {});
|
||
}
|
||
}
|
||
|
||
export async function executeParallelCapture(
|
||
serverUrl: string,
|
||
workDir: string,
|
||
tasks: WorkerTask[],
|
||
captureOptions: CaptureOptions,
|
||
createBeforeCaptureHook: () => BeforeCaptureHook | null,
|
||
signal?: AbortSignal,
|
||
onProgress?: (progress: ParallelProgress) => void,
|
||
onFrameBuffer?: (frameIndex: number, buffer: Buffer) => Promise<void>,
|
||
config?: Partial<EngineConfig>,
|
||
): Promise<WorkerResult[]> {
|
||
const totalFrames = tasks.reduce((sum, t) => sum + (t.endFrame - t.startFrame), 0);
|
||
const workerProgress = new Map<number, number>();
|
||
|
||
for (const task of tasks) workerProgress.set(task.workerId, 0);
|
||
|
||
const onFrameCaptured = (workerId: number, _frameIndex: number) => {
|
||
const current = workerProgress.get(workerId) || 0;
|
||
workerProgress.set(workerId, current + 1);
|
||
|
||
if (onProgress) {
|
||
const capturedFrames = Array.from(workerProgress.values()).reduce((a, b) => a + b, 0);
|
||
onProgress({
|
||
totalFrames,
|
||
capturedFrames,
|
||
activeWorkers: tasks.length,
|
||
workerProgress: new Map(workerProgress),
|
||
});
|
||
}
|
||
};
|
||
|
||
const results = await Promise.all(
|
||
tasks.map((task) =>
|
||
executeWorkerTask(
|
||
task,
|
||
serverUrl,
|
||
captureOptions,
|
||
createBeforeCaptureHook,
|
||
signal,
|
||
onFrameCaptured,
|
||
onFrameBuffer,
|
||
config,
|
||
),
|
||
),
|
||
);
|
||
|
||
const errors = results.filter((r) => r.error);
|
||
if (errors.length > 0) {
|
||
const errorMessages = errors.map((e) => `Worker ${e.workerId}: ${e.error}`).join("; ");
|
||
throw new Error(`[Parallel] Capture failed: ${errorMessages}`);
|
||
}
|
||
|
||
return results;
|
||
}
|
||
|
||
export async function mergeWorkerFrames(
|
||
workDir: string,
|
||
tasks: WorkerTask[],
|
||
outputDir: string,
|
||
): Promise<number> {
|
||
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
|
||
|
||
let totalFrames = 0;
|
||
const sortedTasks = [...tasks].sort((a, b) => a.startFrame - b.startFrame);
|
||
|
||
for (const task of sortedTasks) {
|
||
if (!existsSync(task.outputDir)) {
|
||
continue;
|
||
}
|
||
|
||
const files = readdirSync(task.outputDir)
|
||
.filter((f) => f.startsWith("frame_") && (f.endsWith(".jpg") || f.endsWith(".png")))
|
||
.sort();
|
||
const copyTasks = files.map(async (file) => {
|
||
const sourcePath = join(task.outputDir, file);
|
||
const targetPath = join(outputDir, file);
|
||
try {
|
||
await rename(sourcePath, targetPath);
|
||
} catch {
|
||
await copyFile(sourcePath, targetPath);
|
||
}
|
||
});
|
||
await Promise.all(copyTasks);
|
||
totalFrames += files.length;
|
||
}
|
||
|
||
return totalFrames;
|
||
}
|
||
|
||
export function getSystemResources(): {
|
||
cpuCores: number;
|
||
totalMemoryMB: number;
|
||
freeMemoryMB: number;
|
||
recommendedWorkers: number;
|
||
} {
|
||
return {
|
||
cpuCores: cpus().length,
|
||
totalMemoryMB: Math.round(totalmem() / (1024 * 1024)),
|
||
freeMemoryMB: Math.round(freemem() / (1024 * 1024)),
|
||
recommendedWorkers: calculateOptimalWorkers(1000),
|
||
};
|
||
}
|