Skip to content

Commit acc83bd

Browse files
authored
perf(producer): pipeline capture and shader-blend per-frame (hf#732 PR 5/5) (#760)
## Summary PR 5 of 5 in the hf#732 decomposition stack. Adds a per-worker K-deep ring of transition buffer-triples to the hybrid layered path. Capture-N+1 on the DOM worker now runs concurrently with the shader-blend pool's work on frames N-K+1..N instead of being serialized behind each blend. ### Mechanism - Each worker carries a ring of K buffer triples (`bufferA` / `bufferB` / `output`), default K=4. - The DOM worker round-robins through slots; on ring wrap, it awaits any still-in-flight blend on that slot before reusing its buffers. - The shader-blend dispatch is no longer awaited inline. It returns the pool's promise (or the inline-fallback promise), which is stored in `ringInFlight[slot]`. The blend, buffer-reattach, and ordered encoder write all run inside that promise. - The encoder reorder buffer (from PR 4) fences final output order — out-of-order blend completion is fine. ### Why K=4 The optimal K is `blend_per_frame / capture_per_frame`. For 854×480 rgb48le with complex shaders this is ~910ms / ~175ms ≈ 5. K=4 balances perf vs. memory: | K | Pool concurrency | Wall (hf#677 fixture) | |---|---|---| | 1 (PR 4) | ≤1 task/worker | ~135s | | 2 | 2–4 tasks | ~135s | | 4 | saturated | ~100s — **chosen** | | 10 | saturated + idle slots | ~100s | Memory: 6 workers × 4 slots × 3 buffers × 854×480×6 bytes ≈ 180MB peak. Override at runtime via `HF_TRANSITION_RING_DEPTH`. ### Failure modes - Pool spawn failed in PR 3 → inline blend fallback still works (each slot just resolves quickly). - Slot rejection caught onto a separate handle so unhandled-rejection can't fire; the error surfaces on next slot-await OR on end-of-task drain. - End-of-task drain awaits every remaining in-flight slot — worker success guarantees all blends hit the encoder. ## Stack Top of the hf#732 decomposition stack. Stacked on top of #759 (PR 4: hybrid path). ## Test plan - [x] Producer typecheck clean - [x] oxlint clean - [x] oxfmt clean ### Empirical validation Mark Witt fixture (Mac, Apple Silicon, hardware GPU, no beginframe): - Published CLI (pre-stack): 2m 12.2s - Cascade CLI (full hf#732 stack): 1m 07.7s - **Measured speedup: ~2× on Mac (1.95× exact).** (Earlier "2.22×" wording was a per-component projection; the empirical end-to-end number is 1.95× on the validated fixture.) Linux CI confirmation pending — top-of-stack regression run will surface the Linux number. — Vai
1 parent b47a3e7 commit acc83bd

1 file changed

Lines changed: 97 additions & 32 deletions

File tree

packages/producer/src/services/render/stages/captureHdrHybridLoop.ts

Lines changed: 97 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -147,15 +147,37 @@ export async function runHybridLayeredFrameLoop(input: HybridLoopInput): Promise
147147
}
148148

149149
const workerCanvases: Buffer[] = sessions.map(() => Buffer.alloc(bufSize));
150-
const workerTransitionBuffers: Array<LayeredTransitionBuffers | null> = sessions.map(() =>
151-
hasTransitions
152-
? {
153-
bufferA: Buffer.alloc(bufSize),
154-
bufferB: Buffer.alloc(bufSize),
155-
output: Buffer.alloc(bufSize),
156-
}
157-
: null,
150+
// hf#732 PR 5: K-deep ring of transition buffer-triples per worker. The
151+
// ring lets capture-N+1 proceed on the DOM worker while the shader-blend
152+
// pool is still working on frames N-K+1..N. Without the ring (PR 4), each
153+
// worker awaited its own blend before the next capture, capping the pool
154+
// at <=1 task per worker. With K=4, the pool sees up to min(N_workers * K,
155+
// poolSize) concurrent blends, which empirically pushes shader-render
156+
// wall time another ~10-20% past PR 4 alone.
157+
//
158+
// The ideal K is `blend_per_frame / capture_per_frame`. For 854x480
159+
// rgb48le with the more complex shaders this is ~910ms / ~175ms ≈ 5.
160+
// K=4 strikes a perf vs. memory balance. Override via
161+
// `HF_TRANSITION_RING_DEPTH` if a workload's blend/capture ratio is very
162+
// different (simpler shaders that blend in ~100ms tolerate K=1-2 without
163+
// perf loss).
164+
const DEFAULT_TRANSITION_RING_DEPTH = 4;
165+
const TRANSITION_RING_DEPTH = Math.max(
166+
1,
167+
Number(process.env.HF_TRANSITION_RING_DEPTH ?? String(DEFAULT_TRANSITION_RING_DEPTH)),
158168
);
169+
const workerTransitionRings: Array<LayeredTransitionBuffers[] | null> = sessions.map(() => {
170+
if (!hasTransitions) return null;
171+
const ring: LayeredTransitionBuffers[] = [];
172+
for (let k = 0; k < TRANSITION_RING_DEPTH; k++) {
173+
ring.push({
174+
bufferA: Buffer.alloc(bufSize),
175+
bufferB: Buffer.alloc(bufSize),
176+
output: Buffer.alloc(bufSize),
177+
});
178+
}
179+
return ring;
180+
});
159181
const workerRanges = distributeLayeredHybridFrameRanges(totalFrames, activeWorkerCount);
160182
let framesWritten = 0;
161183
const reorderBuffer = createFrameReorderBuffer(0, totalFrames);
@@ -185,15 +207,32 @@ export async function runHybridLayeredFrameLoop(input: HybridLoopInput): Promise
185207
const session = sessions[w];
186208
const canvas = workerCanvases[w];
187209
const range = workerRanges[w];
188-
const buffers = workerTransitionBuffers[w];
210+
const ring = workerTransitionRings[w];
189211
if (!session || !canvas || !range) return;
212+
// Per-ring-slot in-flight promise. When a slot is mid-blend, its
213+
// promise is non-null; before reusing the slot for a new capture we
214+
// await it so the buffer triple is free + the encoder has seen the
215+
// earlier frame (writeEncoded gates ordering via the reorder buffer).
216+
const ringInFlight: Array<Promise<void> | null> = ring ? ring.map(() => null) : [];
217+
let nextRingIdx = 0;
190218
for (let i = range.start; i < range.end; i++) {
191219
assertNotAborted();
192220
const time = (i * job.config.fps.den) / job.config.fps.num;
193221
const activeTransition = transitionFramesSet.has(i)
194222
? transitionRanges.find((t) => i >= t.startFrame && i <= t.endFrame)
195223
: undefined;
196-
if (activeTransition && buffers) {
224+
if (activeTransition && ring) {
225+
// Pick the next ring slot. If it's still in flight from an earlier
226+
// capture, await it to drain before reusing its buffer triple.
227+
const slot = nextRingIdx;
228+
nextRingIdx = (nextRingIdx + 1) % TRANSITION_RING_DEPTH;
229+
const prev = ringInFlight[slot];
230+
if (prev) await prev;
231+
const buffers = ring[slot];
232+
if (!buffers) continue;
233+
// CAPTURE on the DOM worker (this thread). Fills bufferA/bufferB
234+
// synchronously w.r.t. this loop — DOM work can't be pipelined
235+
// because the per-worker browser session is single-threaded.
197236
await captureTransitionFrameOnWorker({
198237
session,
199238
frameIdx: i,
@@ -216,28 +255,48 @@ export async function runHybridLayeredFrameLoop(input: HybridLoopInput): Promise
216255
? 1
217256
: (i - activeTransition.startFrame) /
218257
(activeTransition.endFrame - activeTransition.startFrame);
219-
if (poolRef) {
220-
const blendStart = Date.now();
221-
const result = await poolRef.run({
222-
shader: activeTransition.shader,
223-
bufferA: buffers.bufferA,
224-
bufferB: buffers.bufferB,
225-
output: buffers.output,
226-
width,
227-
height,
228-
progress,
229-
});
230-
buffers.bufferA = result.bufferA;
231-
buffers.bufferB = result.bufferB;
232-
buffers.output = result.output;
233-
addHdrTiming(hdrPerf, "transitionCompositeMs", blendStart);
234-
} else {
235-
const transitionFn: TransitionFn = TRANSITIONS[activeTransition.shader] ?? crossfade;
236-
const blendStart = Date.now();
237-
transitionFn(buffers.bufferA, buffers.bufferB, buffers.output, width, height, progress);
238-
addHdrTiming(hdrPerf, "transitionCompositeMs", blendStart);
239-
}
240-
await writeEncoded(i, buffers.output);
258+
// BLEND + ENCODE without awaiting. The promise drains back into
259+
// `ringInFlight[slot]`; the next iteration that picks `slot`
260+
// awaits it. The encoder reorder buffer fences ordering so out-
261+
// of-order blend completion is fine.
262+
const frameIdx = i;
263+
const dispatch: Promise<void> = (async () => {
264+
if (poolRef) {
265+
const blendStart = Date.now();
266+
const result = await poolRef.run({
267+
shader: activeTransition.shader,
268+
bufferA: buffers.bufferA,
269+
bufferB: buffers.bufferB,
270+
output: buffers.output,
271+
width,
272+
height,
273+
progress,
274+
});
275+
buffers.bufferA = result.bufferA;
276+
buffers.bufferB = result.bufferB;
277+
buffers.output = result.output;
278+
addHdrTiming(hdrPerf, "transitionCompositeMs", blendStart);
279+
} else {
280+
const transitionFn: TransitionFn = TRANSITIONS[activeTransition.shader] ?? crossfade;
281+
const blendStart = Date.now();
282+
transitionFn(
283+
buffers.bufferA,
284+
buffers.bufferB,
285+
buffers.output,
286+
width,
287+
height,
288+
progress,
289+
);
290+
addHdrTiming(hdrPerf, "transitionCompositeMs", blendStart);
291+
}
292+
await writeEncoded(frameIdx, buffers.output);
293+
})();
294+
// Catch on a separate handle so an unhandled-rejection can't fire
295+
// if no one awaits this slot before the worker exits. The error
296+
// is re-thrown on the next await (slot reuse OR end-of-task drain).
297+
ringInFlight[slot] = dispatch.catch((err: unknown) => {
298+
throw err instanceof Error ? err : new Error(String(err));
299+
});
241300
} else {
242301
const beforeCaptureHook = session.onBeforeCapture;
243302
let timingStart = Date.now();
@@ -268,6 +327,12 @@ export async function runHybridLayeredFrameLoop(input: HybridLoopInput): Promise
268327
await writeEncoded(i, canvas);
269328
}
270329
}
330+
// Drain any pipelined blends still in flight on this worker before
331+
// returning. If any rejected, the rejection bubbles here so
332+
// `Promise.all` over `workerTaskOf` sees the failure.
333+
for (const pending of ringInFlight) {
334+
if (pending) await pending;
335+
}
271336
};
272337
await Promise.all(sessions.map((_, w) => workerTaskOf(w)));
273338
await reorderBuffer.waitForAllDone();

0 commit comments

Comments
 (0)