Skip to content

Commit 060b6f8

Browse files
committed
fix(engine): self-verify parallel disk drawElement samples (PRINFRA-352)
1 parent f003422 commit 060b6f8

5 files changed

Lines changed: 212 additions & 34 deletions

File tree

packages/engine/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ export type {
209209
export {
210210
calculateOptimalWorkers,
211211
computeWorkerSizing,
212+
selectVerifySampleIndicesForTask,
212213
distributeFrames,
213214
distributeFramesInterleaved,
214215
executeParallelCapture,
@@ -273,6 +274,10 @@ export {
273274

274275
export { trackChildProcess, killTrackedProcesses } from "./utils/processTracker.js";
275276

277+
// drawElement self-verify comparison — shared by the streaming drain
278+
// (producer) and the parallel disk-path verify (parallelCoordinator).
279+
export { psnrDb, resolveDeVerifyMinDb } from "./utils/psnr.js";
280+
276281
export {
277282
decodePng,
278283
decodePngToRgb48le,

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
expectedFramesForTask,
77
flagSilentWorkerExits,
88
formatWorkerFailure,
9+
selectVerifySampleIndicesForTask,
910
selectWorkerDiagnostics,
1011
shouldDisableBrowserPoolForParallelWorker,
1112
shouldVerifyWorkerGpu,
@@ -349,6 +350,35 @@ describe("flagSilentWorkerExits", () => {
349350
});
350351
});
351352

353+
describe("selectVerifySampleIndicesForTask", () => {
354+
it("keeps only samples inside the task's contiguous range, sorted", () => {
355+
// 2-worker split of 3032 frames: worker 1 owns [1516, 3032).
356+
expect(
357+
selectVerifySampleIndicesForTask([2274, 758, 1516, 3031, 3032], {
358+
startFrame: 1516,
359+
endFrame: 3032,
360+
}),
361+
).toEqual([1516, 2274, 3031]);
362+
});
363+
364+
it("respects the stride lattice for interleaved tasks", () => {
365+
// Worker 1 of a 3-way interleave over [1, 30): captures 1, 4, 7, ...
366+
expect(
367+
selectVerifySampleIndicesForTask([1, 2, 4, 6, 7, 28, 29], {
368+
startFrame: 1,
369+
endFrame: 30,
370+
frameStride: 3,
371+
}),
372+
).toEqual([1, 4, 7, 28]);
373+
});
374+
375+
it("returns empty when no samples fall in the range", () => {
376+
expect(
377+
selectVerifySampleIndicesForTask([0, 10, 20], { startFrame: 100, endFrame: 200 }),
378+
).toEqual([]);
379+
});
380+
});
381+
352382
describe("shouldVerifyWorkerGpu", () => {
353383
const softwareConfig: Partial<EngineConfig> = { browserGpuMode: "software" };
354384

packages/engine/src/services/parallelCoordinator.ts

Lines changed: 130 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import { cpus, freemem } from "os";
99
import { existsSync, mkdirSync, readdirSync } from "fs";
10-
import { copyFile, rename } from "fs/promises";
10+
import { copyFile, readFile, rename } from "fs/promises";
1111
import { join } from "path";
1212
import { getHeapStatistics } from "v8";
1313

@@ -19,11 +19,13 @@ import {
1919
captureFrameToBufferPipelined,
2020
captureFrameToBuffer,
2121
getCapturePerfSummary,
22+
DrawElementVerificationError,
2223
type CaptureSession,
2324
type CaptureOptions,
2425
type CapturePerfSummary,
2526
type BeforeCaptureHook,
2627
} from "./frameCapture.js";
28+
import { psnrDb, resolveDeVerifyMinDb } from "../utils/psnr.js";
2729
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
2830
import { assertSwiftShader } from "../utils/assertSwiftShader.js";
2931
import { readWebGlVendorInfoFromCanvas } from "../utils/readWebGlVendorInfoFromCanvas.js";
@@ -551,6 +553,126 @@ async function captureFrameRange(
551553
return framesCaptured;
552554
}
553555

556+
/**
557+
* The armed self-verify sample indices this task actually captured: inside
558+
* `[startFrame, endFrame)` and on the task's stride lattice. Mirrors the
559+
* capture loop in `captureFrameRange` (`i += stride` from `startFrame`).
560+
*/
561+
export function selectVerifySampleIndicesForTask(
562+
sampleIndices: Iterable<number>,
563+
task: Pick<WorkerTask, "startFrame" | "endFrame" | "frameStride">,
564+
): number[] {
565+
const stride = task.frameStride ?? 1;
566+
const selected: number[] = [];
567+
for (const idx of sampleIndices) {
568+
if (idx < task.startFrame || idx >= task.endFrame) continue;
569+
if ((idx - task.startFrame) % stride !== 0) continue;
570+
selected.push(idx);
571+
}
572+
return selected.sort((a, b) => a - b);
573+
}
574+
575+
/**
576+
* Disk-path drawElement self-verification (PRINFRA-352). Parallel DISK
577+
* workers arm the same pre-injection ground-truth samples as the streaming
578+
* path (`resolveParallelDeVerifySamples` even raises the density for
579+
* multi-worker capture) — but only the streaming drain ever CHECKED them,
580+
* so an explicit `--experimental-fast-capture --workers N` render shipped
581+
* unverified drawElement frames. On a 16GB host, two concurrent
582+
* hardware-GPU Chrome instances hit the documented compositor-tile-eviction
583+
* damage class (frames displaced into vertical strips for one worker's
584+
* whole range — reads as "corruption from the exact worker boundary").
585+
*
586+
* After a worker's range completes, re-read its captured files for the
587+
* sampled indices and PSNR-compare against the session's ground truth.
588+
* A breach throws `DrawElementVerificationError`, which the orchestrator's
589+
* existing pinned-fallback retry converts into a screenshot re-render —
590+
* the same recovery the streaming drain gets.
591+
*/
592+
/** HF_DE_PAR_DEBUG=1 gated per-worker trace line (message built lazily). */
593+
function logParDebug(message: () => string): void {
594+
if (process.env.HF_DE_PAR_DEBUG === "1") console.log(message());
595+
}
596+
597+
/**
598+
* Throw the verification error for a sample below the PSNR floor; log the
599+
* pass otherwise. Split from the sampling loop for the complexity gate.
600+
*/
601+
function assertDiskSampleAboveFloor(
602+
db: number,
603+
verifyMinDb: number,
604+
idx: number,
605+
workerId: number,
606+
): void {
607+
if (db < verifyMinDb) {
608+
// Message keeps the contiguous "drawElement self-verify" phrase —
609+
// captureFailure's VERIFICATION_ERROR_PATTERNS classifies on it.
610+
throw new DrawElementVerificationError(
611+
`drawElement self-verify failed at frame ${idx} (disk path, worker ${workerId}): ` +
612+
`${db.toFixed(1)}dB < ${verifyMinDb}dB vs pre-injection screenshot`,
613+
{ kind: "psnr", frameIndex: idx, failedDb: db, verifyThresholdDb: verifyMinDb },
614+
);
615+
}
616+
console.log(
617+
`[Parallel] drawElement disk self-verify passed (worker ${workerId}, frame ${idx}, ` +
618+
`${db === Infinity ? "inf" : db.toFixed(1)}dB)`,
619+
);
620+
}
621+
622+
/**
623+
* Compare one captured frame file against its ground truth. Returns the
624+
* PSNR, or null on infrastructure failure (missing file already surfaces
625+
* via the frame completeness check; ffmpeg spawn/tmpdir here) — a skipped
626+
* sample is not damage evidence and must not fail the capture.
627+
*/
628+
async function psnrForDiskSample(
629+
framePath: string,
630+
truth: Buffer,
631+
workerId: number,
632+
idx: number,
633+
): Promise<number | null> {
634+
try {
635+
return await psnrDb(await readFile(framePath), truth);
636+
} catch (err) {
637+
console.warn(
638+
`[Parallel] drawElement disk self-verify sample skipped (worker ${workerId}, ` +
639+
`frame ${idx}): ${err instanceof Error ? err.message : String(err)}`,
640+
);
641+
return null;
642+
}
643+
}
644+
645+
// Branches are the gate conditions themselves (mode/armed/streaming guards +
646+
// per-sample skip/breach) — already decomposed into psnrForDiskSample +
647+
// assertDiskSampleAboveFloor; further splitting obscures the check.
648+
// fallow-ignore-next-line complexity
649+
async function verifyDiskDrawElementSamples(
650+
session: CaptureSession,
651+
task: WorkerTask,
652+
streaming: boolean,
653+
): Promise<void> {
654+
// Streaming capture verifies every sampled frame in the drain guard already.
655+
if (streaming || session.captureMode !== "drawelement") return;
656+
const truths = session.deVerifyFrames;
657+
if (!truths || truths.size === 0) return;
658+
const verifyMinDb = resolveDeVerifyMinDb();
659+
const ext = session.options.format === "png" ? "png" : "jpg";
660+
const offset = task.outputFrameOffset ?? 0;
661+
for (const idx of selectVerifySampleIndicesForTask(truths.keys(), task)) {
662+
const truth = truths.get(idx);
663+
if (!truth) continue;
664+
const framePath = join(task.outputDir, `frame_${String(idx - offset).padStart(6, "0")}.${ext}`);
665+
const db = await psnrForDiskSample(framePath, truth, task.workerId, idx);
666+
if (db === null) continue;
667+
assertDiskSampleAboveFloor(db, verifyMinDb, idx, task.workerId);
668+
}
669+
}
670+
671+
// Inherited worker-lifecycle shape (session create → verify GPU → init →
672+
// capture → self-verify → perf, with a classifying catch + closing finally);
673+
// flagged only because the disk self-verify call shifted its line range into
674+
// the changed-code audit. Not restructured by this PR.
675+
// fallow-ignore-next-line complexity
554676
async function executeWorkerTask(
555677
task: WorkerTask,
556678
serverUrl: string,
@@ -590,19 +712,16 @@ async function executeWorkerTask(
590712
createBeforeCaptureHook(),
591713
workerConfig,
592714
);
593-
if (process.env.HF_DE_PAR_DEBUG === "1") {
594-
console.log(`[par:w${task.workerId}] session created`);
595-
}
715+
logParDebug(() => `[par:w${task.workerId}] session created`);
596716
// Worker-0-only SwiftShader assertion — see `shouldVerifyWorkerGpu` and #955.
597717
if (shouldVerifyWorkerGpu(task.workerId, workerConfig)) {
598718
await assertSwiftShader(session.page, readWebGlVendorInfoFromCanvas);
599719
}
600720
await initializeSession(session);
601-
if (process.env.HF_DE_PAR_DEBUG === "1") {
602-
console.log(
603-
`[par:w${task.workerId}] init done (mode=${session.captureMode} workerEncode=${session.workerEncodeEnabled === true})`,
604-
);
605-
}
721+
logParDebug(
722+
() =>
723+
`[par:w${task.workerId}] init done (mode=${session?.captureMode} workerEncode=${session?.workerEncodeEnabled === true})`,
724+
);
606725
framesCaptured = await captureFrameRange(
607726
session,
608727
task,
@@ -612,6 +731,8 @@ async function executeWorkerTask(
612731
onFrameBuffer,
613732
);
614733

734+
await verifyDiskDrawElementSamples(session, task, Boolean(onFrameBuffer));
735+
615736
perf = getCapturePerfSummary(session);
616737
return {
617738
workerId: task.workerId,

packages/engine/src/utils/psnr.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { execFile } from "node:child_process";
2+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { promisify } from "node:util";
6+
import { getFfmpegBinary } from "./ffmpegBinaries.js";
7+
8+
const execFileP = promisify(execFile);
9+
10+
/**
11+
* PSNR (average, dB) between two same-dimension encoded images via ffmpeg.
12+
* Infinity means bit-identical pixels. Single source of truth for every
13+
* drawElement self-verify comparison (streaming drain + parallel disk path).
14+
*/
15+
export async function psnrDb(a: Buffer, b: Buffer): Promise<number> {
16+
const dir = await mkdtemp(join(tmpdir(), "hf-de-verify-"));
17+
try {
18+
const pa = join(dir, "a.jpg");
19+
const pb = join(dir, "b.jpg");
20+
await Promise.all([writeFile(pa, a), writeFile(pb, b)]);
21+
const { stderr } = await execFileP(
22+
getFfmpegBinary(),
23+
["-hide_banner", "-i", pa, "-i", pb, "-lavfi", "psnr", "-f", "null", "-"],
24+
{ maxBuffer: 4 * 1024 * 1024 },
25+
);
26+
const m = /average:(inf|[\d.]+)/.exec(stderr);
27+
if (!m) throw new Error(`psnr parse failed: ${stderr.slice(-300)}`);
28+
return m[1] === "inf" ? Infinity : Number(m[1]);
29+
} finally {
30+
await rm(dir, { recursive: true, force: true }).catch(() => {});
31+
}
32+
}
33+
34+
/**
35+
* The drawElement self-verify PSNR floor (dB). HF_DE_VERIFY_MIN_DB overrides,
36+
* clamped to [10, 60]; out-of-range or unset falls back to 32 — the threshold
37+
* every prior eval used to separate real compositor damage from encoder noise.
38+
*/
39+
export function resolveDeVerifyMinDb(): number {
40+
const raw = Number(process.env.HF_DE_VERIFY_MIN_DB ?? "32");
41+
return Number.isFinite(raw) && raw >= 10 && raw <= 60 ? raw : 32;
42+
}

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

Lines changed: 5 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,9 @@
4141
* into a shared module so the stages can import without reaching back.
4242
*/
4343

44-
import { execFile } from "node:child_process";
45-
import { mkdtemp, rm, writeFile } from "node:fs/promises";
44+
import { mkdtemp, writeFile } from "node:fs/promises";
4645
import { tmpdir } from "node:os";
4746
import { join } from "node:path";
48-
import { promisify } from "node:util";
4947
import {
5048
type BeforeCaptureHook,
5149
type CaptureOptions,
@@ -64,7 +62,7 @@ import {
6462
distributeFramesInterleaved,
6563
executeParallelCapture,
6664
getCapturePerfSummary,
67-
getFfmpegBinary,
65+
psnrDb,
6866
recaptureDrawElementFrameForVerify,
6967
completeDeferredDrawElementInit,
7068
initializeSession,
@@ -223,27 +221,9 @@ export type CaptureStreamingStageResult =
223221
success: false;
224222
};
225223

226-
const execFileP = promisify(execFile);
227-
228-
/** PSNR (average, dB) between two same-dimension encoded images via ffmpeg. */
229-
async function psnrDb(a: Buffer, b: Buffer): Promise<number> {
230-
const dir = await mkdtemp(join(tmpdir(), "hf-de-verify-"));
231-
try {
232-
const pa = join(dir, "a.jpg");
233-
const pb = join(dir, "b.jpg");
234-
await Promise.all([writeFile(pa, a), writeFile(pb, b)]);
235-
const { stderr } = await execFileP(
236-
getFfmpegBinary(),
237-
["-hide_banner", "-i", pa, "-i", pb, "-lavfi", "psnr", "-f", "null", "-"],
238-
{ maxBuffer: 4 * 1024 * 1024 },
239-
);
240-
const m = /average:(inf|[\d.]+)/.exec(stderr);
241-
if (!m) throw new Error(`psnr parse failed: ${stderr.slice(-300)}`);
242-
return m[1] === "inf" ? Infinity : Number(m[1]);
243-
} finally {
244-
await rm(dir, { recursive: true, force: true }).catch(() => {});
245-
}
246-
}
224+
// psnrDb moved to @hyperframes/engine (utils/psnr.ts) so the parallel
225+
// disk-path verify (parallelCoordinator) and this drain guard share one
226+
// comparison implementation.
247227

248228
// ── drawElement drain-time safety checks (ungated-release safety net) ──
249229
// Shared by the sequential worker-encode loop and the interleaved parallel

0 commit comments

Comments
 (0)