Skip to content

Commit 67ac7e0

Browse files
vanceingallsclaude
andcommitted
feat(engine,producer): verified interleaved parallel drawElement streaming (opt-in)
Step 2 of the DE engagement plan: multi-worker drawElement capture through the streaming encoder, with the full runtime self-verification net riding along — the confinement rule that kept the parallel clamp in place is now satisfied on this path. Opt-in via HF_DE_PARALLEL_STREAM=true; default routing (including the #2026 single-worker inversion) is unchanged. Mechanism: - distributeFramesInterleaved + WorkerTask.frameStride: worker i captures frames i, i+N, i+2N... — seek-based capture makes stride free and the ordered writer's reorder window shrinks from totalFrames/N to N (contiguous chunks serialize workers behind the writer). - Depth-2 pipelined worker-encode produce in the parallel worker loop (the same shape as the sequential loop; frame k's in-page encode overlaps k+stride's produce). HF_DE_PAR_DEBUG=1 traces the first frames per worker. - Drain guard extracted to createDrainFrameGuard (session-parameterized): every parallel frame gets the SAME blank-guard + PSNR self-verify as the sequential drain, against its owning worker's pre-injection ground truth (all sessions arm identical sample indices from CaptureOptions.compositionDurationSeconds). - FrameReorderBuffer.abort(err): a failed worker (e.g. verification error) rejects all parked and future waiters — without this, peers park forever in waitForFrame and the pool (which awaits ALL workers before surfacing errors) deadlocks. Found by the verify-trip test; unit-tested. - The typed DrawElementVerificationError is preserved past the pool's error-string flattening so the orchestrator's verify-retry recognizes it. - Static-dedup stride hazard fixed: lastEncodeResult reuse now requires EVERY frame in (lastEncodeResultFrame, i] to be predicted-static (sequential capture reduces to the old has(i) check). - Workers get separate browser PROCESSES under the flag: pages co-tenant in one browser starve non-active pages of BeginFrames on the paint-wait path (measured 86s vs 30s on a 3,245-frame rAF comp). Validation: - Happy path W3: verify samples pass across workers (4x inf on the 2,381f comp), output vs single-worker DE = 59.3dB (encode noise floor) — the interleave + dedup-stride produce identical pixels. - Verify-trip (marginal comp + HF_DE_VERIFY_MIN_DB=45): fails at frame 649 (32.2dB < 45), peers abort instead of deadlocking, whole render retries via parallel screenshot, RENDER_OK in 42.6s. - Canary suite 7/7 with the flag off (default paths untouched); producer orchestrator tests 99/99; engine suite 909 passed (14 pre-existing main failures, stash-A/B verified); reorder-buffer abort unit tests. Perf note: capture-only parallel speedup measured 1.38x (W2) / 1.52x (W3) over single-worker DE in the spike; end-to-end numbers on this machine are currently noisy (separate-browser init overhead + bench load) — clean benchmarks before any default routing change. The flag stays explicit opt-in; promoting it into the router replaces the #2026 W=1 pin for the same cohort. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 2b9c51d commit 67ac7e0

7 files changed

Lines changed: 340 additions & 73 deletions

File tree

packages/engine/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ export type {
176176
export {
177177
calculateOptimalWorkers,
178178
distributeFrames,
179+
distributeFramesInterleaved,
179180
executeParallelCapture,
180181
mergeWorkerFrames,
181182
getSystemResources,

packages/engine/src/services/frameCapture.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,11 @@ export interface CaptureSession {
160160
* worker-encode path (mirrors `lastFrameBuffer` on the screenshot path). Only set
161161
* when static-frame dedup is armed on the drawElement path. */
162162
lastEncodeResult?: Promise<Buffer>;
163+
/** Frame index lastEncodeResult belongs to — static-dedup reuse must verify
164+
* every frame in (lastEncodeResultFrame, i] is predicted-static (under the
165+
* interleaved parallel stride the "previous" produced frame is i−N, and
166+
* reusing it for frame i is only valid when the whole gap is static). */
167+
lastEncodeResultFrame?: number;
163168
/** Per-render self-verification ground truth (ungated-release safety net):
164169
* K screenshot frames captured at init BEFORE the drawElement canvas is
165170
* injected (the only window where a page screenshot shows the live DOM, not
@@ -2640,9 +2645,23 @@ export async function captureFrameToBufferPipelined(
26402645
// and skip the seek + drawElement + encode entirely. Same predicate as the serial
26412646
// path; clip-cut frames are excluded from staticFrames so they always capture.
26422647
if (session.staticFrames?.has(frameIndex) && session.lastEncodeResult) {
2643-
session.staticDedupCount = (session.staticDedupCount ?? 0) + 1;
2644-
session.capturePerf.frames += 1;
2645-
return { encodeResult: session.lastEncodeResult, captureTimeMs: Date.now() - startTime };
2648+
// Reuse is valid only when EVERY frame in (lastEncodeResultFrame, i] is
2649+
// predicted-static — then all of them (and the reused buffer) share the
2650+
// same pixels. Sequential capture reduces to has(i) (gap = {i}); the
2651+
// interleaved parallel stride makes the gap N frames wide.
2652+
const lastIdx = session.lastEncodeResultFrame ?? frameIndex - 1;
2653+
let gapStatic = true;
2654+
for (let j = lastIdx + 1; j <= frameIndex; j++) {
2655+
if (!session.staticFrames.has(j)) {
2656+
gapStatic = false;
2657+
break;
2658+
}
2659+
}
2660+
if (gapStatic) {
2661+
session.staticDedupCount = (session.staticDedupCount ?? 0) + 1;
2662+
session.capturePerf.frames += 1;
2663+
return { encodeResult: session.lastEncodeResult, captureTimeMs: Date.now() - startTime };
2664+
}
26462665
}
26472666

26482667
try {
@@ -2677,7 +2696,10 @@ export async function captureFrameToBufferPipelined(
26772696
session.capturePerf.frameMs.push(boundaryMs);
26782697
}
26792698
const boundaryResult = Promise.resolve(buffer);
2680-
if (session.staticFrames) session.lastEncodeResult = boundaryResult;
2699+
if (session.staticFrames) {
2700+
session.lastEncodeResult = boundaryResult;
2701+
session.lastEncodeResultFrame = frameIndex;
2702+
}
26812703
return { encodeResult: boundaryResult, captureTimeMs: Date.now() - startTime };
26822704
}
26832705

@@ -2704,7 +2726,10 @@ export async function captureFrameToBufferPipelined(
27042726
session.capturePerf.frameMs.push(captureTimeMs);
27052727

27062728
// Task B: retain this encode result so a following static frame can reuse it.
2707-
if (session.staticFrames) session.lastEncodeResult = encodeResult;
2729+
if (session.staticFrames) {
2730+
session.lastEncodeResult = encodeResult;
2731+
session.lastEncodeResultFrame = frameIndex;
2732+
}
27082733

27092734
return { encodeResult, captureTimeMs };
27102735
} catch (captureError) {

packages/engine/src/services/parallelCoordinator.ts

Lines changed: 102 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
initializeSession,
1616
closeCaptureSession,
1717
captureFrame,
18+
captureFrameToBufferPipelined,
1819
captureFrameToBuffer,
1920
getCapturePerfSummary,
2021
type CaptureSession,
@@ -42,6 +43,13 @@ export interface WorkerTask {
4243
* calculation still uses the absolute frame index.
4344
*/
4445
outputFrameOffset?: number;
46+
/**
47+
* Frame stride for interleaved distribution (HF_DE_PARALLEL_STREAM spike):
48+
* the worker captures startFrame, startFrame+stride, … < endFrame. Default 1
49+
* (contiguous range). Interleaving keeps the ordered streaming writer's
50+
* reorder window at O(workerCount) frames instead of O(totalFrames/N).
51+
*/
52+
frameStride?: number;
4553
}
4654

4755
export interface WorkerResult {
@@ -234,6 +242,34 @@ export function distributeFrames(
234242
return tasks;
235243
}
236244

245+
/**
246+
* Interleaved (round-robin) distribution: worker i captures frames
247+
* i, i+N, i+2N, …. Seek-based capture makes stride access free (every frame
248+
* is an absolute seek), and the streaming reorder window shrinks from
249+
* totalFrames/N to N — contiguous chunks serialize workers behind the
250+
* ordered writer (worker 1's first frame waits for ALL of worker 0's).
251+
* HF_DE_PARALLEL_STREAM spike; disk-path capture keeps contiguous chunks.
252+
*/
253+
export function distributeFramesInterleaved(
254+
totalFrames: number,
255+
workerCount: number,
256+
workDir: string,
257+
rangeStart: number = 0,
258+
): WorkerTask[] {
259+
const tasks: WorkerTask[] = [];
260+
for (let i = 0; i < workerCount && i < totalFrames; i++) {
261+
tasks.push({
262+
workerId: i,
263+
startFrame: rangeStart + i,
264+
endFrame: rangeStart + totalFrames,
265+
frameStride: workerCount,
266+
outputDir: join(workDir, `worker-${i}`),
267+
outputFrameOffset: rangeStart,
268+
});
269+
}
270+
return tasks;
271+
}
272+
237273
/**
238274
* Decide whether a parallel worker should run the per-worker SwiftShader
239275
* assertion. Gated to worker 0 only: workers within a chunk share the same
@@ -244,24 +280,77 @@ export function shouldVerifyWorkerGpu(workerId: number, config?: Partial<EngineC
244280
return config?.browserGpuMode === "software" && workerId === 0;
245281
}
246282

283+
// fallow-ignore-next-line complexity
247284
async function captureFrameRange(
248285
session: CaptureSession,
249286
task: WorkerTask,
250287
captureOptions: CaptureOptions,
251288
signal: AbortSignal | undefined,
252289
onFrameCaptured: ((workerId: number, frameIndex: number) => void) | undefined,
253-
onFrameBuffer: ((frameIndex: number, buffer: Buffer) => Promise<void>) | undefined,
290+
onFrameBuffer:
291+
| ((frameIndex: number, buffer: Buffer, session: CaptureSession) => Promise<void>)
292+
| undefined,
254293
): Promise<number> {
255294
let framesCaptured = 0;
256295
const outputOffset = task.outputFrameOffset ?? 0;
257-
for (let i = task.startFrame; i < task.endFrame; i++) {
296+
const stride = task.frameStride ?? 1;
297+
// Depth-2 pipelined drawElement produce (HF_DE_PARALLEL_STREAM spike): frame
298+
// k's in-page worker encode overlaps frame k+stride's produce phase — the
299+
// same shape as the sequential worker-encode loop. Only engaged when the
300+
// session's encode worker initialized (drawElement mode) and frames stream
301+
// back via onFrameBuffer; the ordered writer's waitForFrame provides the
302+
// cross-worker backpressure (each worker runs at most `stride` frames ahead).
303+
if (onFrameBuffer && session.workerEncodeEnabled) {
304+
const dbg = process.env.HF_DE_PAR_DEBUG === "1";
305+
const dbgT0 = Date.now();
306+
const dbgWin = 40 * stride;
307+
let prev: { idx: number; encodeResult: Promise<Buffer> } | null = null;
308+
for (let i = task.startFrame; i < task.endFrame; i += stride) {
309+
if (signal?.aborted) throw new Error("Parallel worker cancelled");
310+
const time = (i * captureOptions.fps.den) / captureOptions.fps.num;
311+
if (dbg && i < task.startFrame + dbgWin) {
312+
console.log(`[par:w${task.workerId}] +${Date.now() - dbgT0}ms produce ${i} start`);
313+
}
314+
const { encodeResult } = await captureFrameToBufferPipelined(session, i - outputOffset, time);
315+
if (dbg && i < task.startFrame + dbgWin) {
316+
console.log(`[par:w${task.workerId}] +${Date.now() - dbgT0}ms produce ${i} kicked`);
317+
}
318+
if (prev) {
319+
if (dbg && prev.idx < task.startFrame + dbgWin) {
320+
console.log(
321+
`[par:w${task.workerId}] +${Date.now() - dbgT0}ms drain ${prev.idx} await-encode`,
322+
);
323+
}
324+
const buf = await prev.encodeResult;
325+
if (dbg && prev.idx < task.startFrame + dbgWin) {
326+
console.log(
327+
`[par:w${task.workerId}] +${Date.now() - dbgT0}ms drain ${prev.idx} encoded ${buf.length}B`,
328+
);
329+
}
330+
await onFrameBuffer(prev.idx, buf, session);
331+
if (dbg && prev.idx < task.startFrame + dbgWin) {
332+
console.log(`[par:w${task.workerId}] +${Date.now() - dbgT0}ms drain ${prev.idx} written`);
333+
}
334+
framesCaptured++;
335+
if (onFrameCaptured) onFrameCaptured(task.workerId, prev.idx);
336+
}
337+
prev = { idx: i, encodeResult };
338+
}
339+
if (prev) {
340+
await onFrameBuffer(prev.idx, await prev.encodeResult, session);
341+
framesCaptured++;
342+
if (onFrameCaptured) onFrameCaptured(task.workerId, prev.idx);
343+
}
344+
return framesCaptured;
345+
}
346+
for (let i = task.startFrame; i < task.endFrame; i += stride) {
258347
if (signal?.aborted) throw new Error("Parallel worker cancelled");
259348
const time = (i * captureOptions.fps.den) / captureOptions.fps.num;
260349
const fileFrameIdx = i - outputOffset;
261350

262351
if (onFrameBuffer) {
263352
const { buffer } = await captureFrameToBuffer(session, fileFrameIdx, time);
264-
await onFrameBuffer(i, buffer);
353+
await onFrameBuffer(i, buffer, session);
265354
} else {
266355
await captureFrame(session, fileFrameIdx, time);
267356
}
@@ -278,7 +367,7 @@ async function executeWorkerTask(
278367
createBeforeCaptureHook: () => BeforeCaptureHook | null,
279368
signal?: AbortSignal,
280369
onFrameCaptured?: (workerId: number, frameIndex: number) => void,
281-
onFrameBuffer?: (frameIndex: number, buffer: Buffer) => Promise<void>,
370+
onFrameBuffer?: (frameIndex: number, buffer: Buffer, session: CaptureSession) => Promise<void>,
282371
config?: Partial<EngineConfig>,
283372
parallel?: boolean,
284373
): Promise<WorkerResult> {
@@ -309,11 +398,19 @@ async function executeWorkerTask(
309398
createBeforeCaptureHook(),
310399
workerConfig,
311400
);
401+
if (process.env.HF_DE_PAR_DEBUG === "1") {
402+
console.log(`[par:w${task.workerId}] session created`);
403+
}
312404
// Worker-0-only SwiftShader assertion — see `shouldVerifyWorkerGpu` and #955.
313405
if (shouldVerifyWorkerGpu(task.workerId, workerConfig)) {
314406
await assertSwiftShader(session.page, readWebGlVendorInfoFromCanvas);
315407
}
316408
await initializeSession(session);
409+
if (process.env.HF_DE_PAR_DEBUG === "1") {
410+
console.log(
411+
`[par:w${task.workerId}] init done (mode=${session.captureMode} workerEncode=${session.workerEncodeEnabled === true})`,
412+
);
413+
}
317414
framesCaptured = await captureFrameRange(
318415
session,
319416
task,
@@ -358,7 +455,7 @@ export async function executeParallelCapture(
358455
createBeforeCaptureHook: () => BeforeCaptureHook | null,
359456
signal?: AbortSignal,
360457
onProgress?: (progress: ParallelProgress) => void,
361-
onFrameBuffer?: (frameIndex: number, buffer: Buffer) => Promise<void>,
458+
onFrameBuffer?: (frameIndex: number, buffer: Buffer, session: CaptureSession) => Promise<void>,
362459
config?: Partial<EngineConfig>,
363460
): Promise<WorkerResult[]> {
364461
const totalFrames = tasks.reduce((sum, t) => sum + (t.endFrame - t.startFrame), 0);

packages/engine/src/services/streamingEncoder.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -905,3 +905,23 @@ describe("spawnStreamingEncoder lifecycle and cleanup", () => {
905905
}
906906
});
907907
});
908+
909+
describe("createFrameReorderBuffer abort (interleaved parallel drain)", () => {
910+
it("rejects parked waiters so a failed worker cannot deadlock its peers", async () => {
911+
const buf = createFrameReorderBuffer(0, 100);
912+
const parked = buf.waitForFrame(5);
913+
const err = new Error("verify failed");
914+
buf.abort(err);
915+
await expect(parked).rejects.toThrow("verify failed");
916+
// Future waiters reject immediately too.
917+
await expect(buf.waitForFrame(6)).rejects.toThrow("verify failed");
918+
await expect(buf.waitForAllDone()).rejects.toThrow("verify failed");
919+
});
920+
921+
it("in-order waiters still resolve before an abort", async () => {
922+
const buf = createFrameReorderBuffer(0, 10);
923+
await expect(buf.waitForFrame(0)).resolves.toBeUndefined();
924+
buf.advanceTo(1);
925+
await expect(buf.waitForFrame(1)).resolves.toBeUndefined();
926+
});
927+
});

packages/engine/src/services/streamingEncoder.ts

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -52,35 +52,48 @@ export interface FrameReorderBuffer {
5252
waitForFrame: (frame: number) => Promise<void>;
5353
advanceTo: (frame: number) => void;
5454
waitForAllDone: () => Promise<void>;
55+
/**
56+
* Reject every parked and future waiter with `err`. Required by the
57+
* interleaved parallel drain: when one worker fails (e.g. drawElement
58+
* self-verification), its frames will never be written — peers parked in
59+
* waitForFrame would otherwise deadlock the whole capture (the worker pool
60+
* awaits ALL workers before surfacing the failure).
61+
*/
62+
abort: (err: Error) => void;
5563
}
5664

5765
export function createFrameReorderBuffer(startFrame: number, endFrame: number): FrameReorderBuffer {
5866
let cursor = startFrame;
59-
const pending = new Map<number, Array<() => void>>();
67+
let aborted: Error | null = null;
68+
const pending = new Map<number, Array<{ resolve: () => void; reject: (e: Error) => void }>>();
6069

61-
const enqueueAt = (frame: number, resolve: () => void): void => {
70+
const enqueueAt = (frame: number, resolve: () => void, reject: (e: Error) => void): void => {
6271
const list = pending.get(frame);
6372
if (list === undefined) {
64-
pending.set(frame, [resolve]);
73+
pending.set(frame, [{ resolve, reject }]);
6574
} else {
66-
list.push(resolve);
75+
list.push({ resolve, reject });
6776
}
6877
};
6978

7079
const flushAt = (frame: number): void => {
7180
const list = pending.get(frame);
7281
if (list === undefined) return;
7382
pending.delete(frame);
74-
for (const resolve of list) resolve();
83+
for (const waiter of list) waiter.resolve();
7584
};
7685

7786
const waitForFrame = (frame: number): Promise<void> =>
78-
new Promise<void>((resolve) => {
87+
new Promise<void>((resolve, reject) => {
88+
if (aborted) {
89+
reject(aborted);
90+
return;
91+
}
7992
if (frame === cursor) {
8093
resolve();
8194
return;
8295
}
83-
enqueueAt(frame, resolve);
96+
enqueueAt(frame, resolve, reject);
8497
});
8598

8699
const advanceTo = (frame: number): void => {
@@ -89,15 +102,28 @@ export function createFrameReorderBuffer(startFrame: number, endFrame: number):
89102
};
90103

91104
const waitForAllDone = (): Promise<void> =>
92-
new Promise<void>((resolve) => {
105+
new Promise<void>((resolve, reject) => {
106+
if (aborted) {
107+
reject(aborted);
108+
return;
109+
}
93110
if (cursor >= endFrame) {
94111
resolve();
95112
return;
96113
}
97-
enqueueAt(endFrame, resolve);
114+
enqueueAt(endFrame, resolve, reject);
98115
});
99116

100-
return { waitForFrame, advanceTo, waitForAllDone };
117+
const abort = (err: Error): void => {
118+
if (aborted) return;
119+
aborted = err;
120+
for (const [frame, list] of pending) {
121+
pending.delete(frame);
122+
for (const waiter of list) waiter.reject(err);
123+
}
124+
};
125+
126+
return { waitForFrame, advanceTo, waitForAllDone, abort };
101127
}
102128

103129
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)