This commit is contained in:
Kalle
2026-09-24 17:32:36 +03:00
parent d4f23b92d4
commit 95bb2a31e4
46 changed files with 4640 additions and 1253 deletions

View File

@@ -0,0 +1,142 @@
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
/**
* GPU parity over every fixture frame: each detector gates the frame, then
* parses it twice — synchronously on the CPU (runSync) and through the WebGPU
* matcher (separate detector instances, so memo state never crosses). Both
* drivers score exactly, so the two event lists must be byte-identical, raw
* scores included. Also checks the GPU frame upscale
* (worker/gpu-frame-scaler.ts) pixel-for-pixel against normalizeFrame on each
* frame. WebGPU comes from Dawn (node/webgpu.ts: WEBGPU_NODE).
*
* Usage: pnpm scanner:gpu-parity [--verbose]
*/
import { readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { loadOpenCV, type Mat } from "../../app/features/scanner/core/cv";
import { createAllDetectors } from "../../app/features/scanner/core/detectors/registry";
import { normalizeFrame, toMat } from "../../app/features/scanner/core/image";
import { FIXTURES_DIR } from "../../app/features/scanner/node/fixtures";
import { readImage } from "../../app/features/scanner/node/image-io";
import { loadScoreboardResources } from "../../app/features/scanner/node/resources";
import { nodeGpu } from "../../app/features/scanner/node/webgpu";
import { createGpuFrameScaler } from "../../app/features/scanner/worker/gpu-frame-scaler";
import { createGpuMatcher } from "../../app/features/scanner/worker/gpu-matcher";
/** Fields that carry raw match scores; everything else is a decision. */
const SCORE_FIELDS = new Set([
"confidence",
"score",
"ncc",
"teamColor",
"debug",
]);
const verbose = process.argv.includes("--verbose");
await loadOpenCV();
const resources = await loadScoreboardResources();
const matcher = await createGpuMatcher(nodeGpu());
const scaler = await createGpuFrameScaler(matcher.device);
let rows = 0;
let identicalRows = 0;
let decisionRows = 0;
let pixelMismatchFrames = 0;
const failures: string[] = [];
let cpuMs = 0;
let gpuMs = 0;
for (const group of fixtureGroups()) {
// fresh instances per fixture folder, like the suites: memos and layout
// latches never carry from one detector's cases into another's
const cpuDetectors = createAllDetectors(resources);
const gpuDetectors = createAllDetectors(resources);
for (const path of group) {
const src = toMat(await readImage(path));
const frame = normalizeFrame(src);
const scaled = await scaler.normalize(src);
if (!samePixels(frame, scaled)) {
pixelMismatchFrames++;
failures.push(`${path}: GPU upscale differs from normalizeFrame`);
}
scaled.delete();
src.delete();
for (const [i, cpuDetector] of cpuDetectors.entries()) {
const gpuDetector = gpuDetectors[i]!;
const gate = cpuDetector.gate(frame);
gpuDetector.gate(frame);
rows++;
if (!gate.pass) {
identicalRows++;
decisionRows++;
continue;
}
let start = performance.now();
const cpuEvents = cpuDetector.parse(frame, 0, gate);
cpuMs += performance.now() - start;
start = performance.now();
const gpuEvents = await matcher.run(
gpuDetector.parseSteps(frame, 0, gate, true),
);
gpuMs += performance.now() - start;
if (decisions(cpuEvents) === decisions(gpuEvents)) decisionRows++;
if (JSON.stringify(cpuEvents) === JSON.stringify(gpuEvents)) {
identicalRows++;
} else {
failures.push(`${path} ${cpuDetector.id}: events differ`);
if (verbose) {
console.log("CPU", JSON.stringify(cpuEvents));
console.log("GPU", JSON.stringify(gpuEvents));
}
}
}
frame.delete();
}
}
for (const failure of failures) console.log(failure);
console.log(
`${rows} detector × frame rows: ${identicalRows} byte-identical, ${decisionRows} with identical decisions; ${pixelMismatchFrames} frames with upscale mismatches`,
);
console.log(
`parse time: CPU ${(cpuMs / 1000).toFixed(1)} s, GPU ${(gpuMs / 1000).toFixed(1)} s · ${JSON.stringify(matcher.stats)}`,
);
matcher.destroy();
process.exit(failures.length === 0 ? 0 : 1);
/** Fixture frames grouped by detector folder, in a stable order. */
function fixtureGroups(): string[][] {
return readdirSync(FIXTURES_DIR)
.filter((dir) => statSync(join(FIXTURES_DIR, dir)).isDirectory())
.sort()
.map((dir) =>
readdirSync(join(FIXTURES_DIR, dir))
.sort()
.flatMap((fixture) =>
["frame.png", "frame.jpg", "frame.jpeg"]
.map((name) => join(FIXTURES_DIR, dir, fixture, name))
.filter((path) => {
try {
return statSync(path).isFile();
} catch {
return false;
}
})
.slice(0, 1),
),
);
}
function decisions(events: unknown): string {
return JSON.stringify(events, (key, value) =>
SCORE_FIELDS.has(key) ? undefined : value,
);
}
function samePixels(a: Mat, b: Mat): boolean {
const x = a.data as Uint8Array;
const y = b.data as Uint8Array;
if (x.length !== y.length) return false;
for (let i = 0; i < x.length; i++) if (x[i] !== y[i]) return false;
return true;
}

View File

@@ -0,0 +1,268 @@
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
/**
* Replay benchmark for the GPU matcher: replays a recorded match-request
* corpus (scan-vod --record) through worker/gpu-matcher.ts, times it, and
* checks every score of a sample of runs bit-for-bit against an exact JS
* reference (integer sums, one f64 normalization with OpenCV's guards, f32
* result). Any kernel change must keep 0 mismatches. WebGPU comes from Dawn
* (node/webgpu.ts: WEBGPU_NODE).
*
* Usage: pnpm scanner:gpu-replay <corpus-dir> [--check-every N] [--threads T] [--cpu]
* --check-every: exact-check every N-th run (default 1 = all; the reference is slow)
* --cpu: also run the CPU driver (runSync) on the same corpus: timed, and every
* score compared with the GPU's
*/
import { cpus } from "node:os";
import { Worker } from "node:worker_threads";
import {
getCV,
loadOpenCV,
type Mat,
} from "../../app/features/scanner/core/cv";
import {
type MatchRequest,
type MatchSteps,
runSync,
} from "../../app/features/scanner/core/match-steps";
import { nodeGpu } from "../../app/features/scanner/node/webgpu";
import { createGpuMatcher } from "../../app/features/scanner/worker/gpu-matcher";
import { type CorpusRequest, loadMatchCorpus } from "./match-corpus";
const EXACT_WORKER = /* js */ `
const { parentPort, workerData } = require("node:worker_threads");
const { images } = workerData;
const stats = new Map();
function templateStats(id) {
let s = stats.get(id);
if (s) return s;
const t = images[id];
const n = t.rows * t.cols;
const sum = new Array(t.ch).fill(0);
const sq = new Array(t.ch).fill(0);
for (let i = 0; i < n; i++) for (let c = 0; c < t.ch; c++) {
const v = t.data[i * t.ch + c];
sum[c] += v;
sq[c] += v * v;
}
let varInt = 0;
for (let c = 0; c < t.ch; c++) varInt += n * sq[c] - sum[c] * sum[c];
s = { n, sum, varInt };
stats.set(id, s);
return s;
}
function normalize(num, wvar, tvar) {
if (tvar === 0) return 1;
if (wvar <= 0) return 0;
const r = num / (Math.sqrt(wvar) * Math.sqrt(tvar));
const a = Math.abs(r);
if (a < 1) return Math.fround(r);
if (a < 1.125) return r > 0 ? 1 : -1;
return 0;
}
function windowMax(img, t, s, lo, hi) {
const ch = img.ch;
const rowLen = t.cols * ch;
let best = -Infinity;
for (let y = 0; y + t.rows <= img.rows; y++) {
for (let x = lo; x <= hi; x++) {
let num = 0;
let wvar = 0;
for (let c = 0; c < ch; c++) {
let S = 0, Q = 0, P = 0;
for (let ty = 0; ty < t.rows; ty++) {
const ib = ((y + ty) * img.cols + x) * ch + c;
const tb = ty * rowLen + c;
for (let k = 0; k < rowLen; k += ch) {
const iv = img.data[ib + k];
S += iv;
Q += iv * iv;
P += iv * t.data[tb + k];
}
}
num += s.n * P - S * s.sum[c];
wvar += s.n * Q - S * S;
}
const v = normalize(num, wvar, s.varInt);
if (v > best) best = v;
}
}
return best;
}
parentPort.on("message", ({ run, steps }) => {
const out = [];
for (const step of steps) for (const r of step) {
const img = images[r.image];
for (const [k, id] of r.templates.entries()) {
const t = images[id];
const [lo, hi] = r.windows?.[k] ?? [0, img.cols - t.cols];
out.push(windowMax(img, t, templateStats(id), lo, hi));
}
}
parentPort.postMessage({ run, scores: Float32Array.from(out) });
});
`;
const options = parseArgs(process.argv.slice(2));
if (!options) {
console.error(
"usage: pnpm scanner:gpu-replay <corpus-dir> [--check-every N] [--threads T] [--cpu]",
);
process.exit(1);
}
await loadOpenCV();
const cv = getCV();
const corpus = loadMatchCorpus(options.dir);
const mats = new Map<number, Mat>();
const matOf = (id: number): Mat => {
let mat = mats.get(id);
if (!mat) {
const image = corpus.images[id]!;
mat = new cv.Mat(
image.rows,
image.cols,
image.ch === 1 ? cv.CV_8UC1 : cv.CV_8UC3,
);
mat.data.set(image.data);
mats.set(id, mat);
}
return mat;
};
const matcher = await createGpuMatcher(nodeGpu(), { timestamps: true });
// warm-up (pipeline compilation, template upload), then a clean timed pass
await matcher.run(replay(0, []));
for (const key of Object.keys(
matcher.stats,
) as (keyof typeof matcher.stats)[]) {
matcher.stats[key] = 0;
}
const gpuScores: number[][] = [];
const gpuStart = performance.now();
for (let run = 0; run < corpus.runs.length; run++) {
const scores: number[] = [];
await matcher.run(replay(run, scores));
gpuScores.push(scores);
}
const gpuMs = performance.now() - gpuStart;
const requests = corpus.runs.reduce(
(n, r) => n + r.steps.reduce((m, s) => m + s.length, 0),
0,
);
console.log(
`GPU: ${gpuMs.toFixed(0)} ms for ${corpus.runs.length} runs (${requests} requests)`,
);
console.log(JSON.stringify(matcher.stats));
if (options.cpu) {
// the CPU driver scores exactly too: every score must match the GPU's bit for bit
const cpuStart = performance.now();
let cpuMismatches = 0;
for (let run = 0; run < corpus.runs.length; run++) {
const scores: number[] = [];
runSync(replay(run, scores));
for (const [i, score] of scores.entries()) {
if (!Object.is(score, gpuScores[run]![i])) cpuMismatches++;
}
}
console.log(
`CPU (runSync): ${(performance.now() - cpuStart).toFixed(0)} ms, ${cpuMismatches} scores differing from the GPU's`,
);
}
const checked = corpus.runs
.map((_, run) => run)
.filter((run) => run % options.checkEvery === 0);
const exact = await exactScores(checked);
let compared = 0;
let mismatches = 0;
for (const [run, reference] of exact) {
const scores = gpuScores[run]!;
for (let i = 0; i < reference.length; i++) {
compared++;
if (!Object.is(Math.fround(scores[i]!), reference[i]!)) {
mismatches++;
if (mismatches <= 5)
console.log(
`mismatch run ${run} #${i}: GPU ${scores[i]} exact ${reference[i]}`,
);
}
}
}
console.log(
`exact check: ${checked.length}/${corpus.runs.length} runs, ${compared} scores, ${mismatches} mismatches`,
);
matcher.destroy();
process.exit(mismatches === 0 ? 0 : 1);
function* replay(run: number, out: number[]): MatchSteps<void> {
for (const step of corpus.runs[run]!.steps) {
const batch: MatchRequest[] = step.map((r) => ({
image: matOf(r.image),
templates: r.templates.map(matOf),
windows: r.windows,
key: r.key,
}));
const scores = yield batch;
for (const [i, r] of step.entries()) {
for (let k = 0; k < r.templates.length; k++) out.push(scores[i]!(k));
}
}
}
async function exactScores(runs: number[]): Promise<Map<number, Float32Array>> {
const results = new Map<number, Float32Array>();
const queue = [...runs];
const workers = Array.from(
{ length: Math.min(options!.threads, runs.length) },
() =>
new Worker(EXACT_WORKER, {
eval: true,
workerData: { images: corpus.images },
}),
);
await Promise.all(
workers.map(
(worker) =>
new Promise<void>((resolve, reject) => {
const next = () => {
const run = queue.shift();
if (run === undefined) {
void worker.terminate();
resolve();
return;
}
worker.postMessage({
run,
steps: corpus.runs[run]!.steps as CorpusRequest[][],
});
};
worker.on("message", ({ run, scores }) => {
results.set(run, scores);
next();
});
worker.on("error", reject);
next();
}),
),
);
return results;
}
function parseArgs(argv: string[]) {
let dir: string | undefined;
let checkEvery = 1;
let threads = Math.max(1, cpus().length - 2);
let cpu = false;
// biome-ignore lint/style/useForOf: the index advances inside the loop to consume flag values
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]!;
if (arg === "--check-every") checkEvery = Number(argv[++i]);
else if (arg === "--threads") threads = Number(argv[++i]);
else if (arg === "--cpu") cpu = true;
else if (!arg.startsWith("--") && dir === undefined) dir = arg;
else return null;
}
if (!dir || !(checkEvery >= 1) || !(threads >= 1)) return null;
return { dir, checkEvery, threads, cpu };
}

View File

@@ -0,0 +1,137 @@
/**
* Match-request corpora for the GPU matcher's replay benchmark: every run's
* steps (requests with image bytes, template bytes, windows, keys), recorded
* from a real scan (scan-vod --record) and replayed by gpu-replay.ts. On disk:
* <dir>/index.json (runs, blob table) + <dir>/data.bin (pixels, deduped by
* content).
*/
import { createHash } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { getCV, type Mat } from "../../app/features/scanner/core/cv";
import type { StepsRunner } from "../../app/features/scanner/core/detectors/frame-pass";
import type {
MatchScores,
MatchSteps,
} from "../../app/features/scanner/core/match-steps";
export interface CorpusImage {
rows: number;
cols: number;
ch: number;
data: Uint8Array;
}
export interface CorpusRequest {
image: number;
templates: number[];
windows?: [number, number][];
key?: string;
}
export interface MatchCorpus {
images: CorpusImage[];
runs: { steps: CorpusRequest[][] }[];
}
interface BlobMeta {
rows: number;
cols: number;
ch: number;
off: number;
}
/** Wraps `inner` so every run it answers is recorded; `save` writes the corpus to `dir`. */
export function recordingRunner(inner: StepsRunner, dir: string) {
const blobs: BlobMeta[] = [];
const chunks: Uint8Array[] = [];
let dataLength = 0;
const byHash = new Map<string, number>();
const templateIds = new Map<Mat, number>();
const runs: MatchCorpus["runs"] = [];
const blobOf = (mat: Mat): number => {
const copy = new (getCV().Mat)();
mat.copyTo(copy);
const data = new Uint8Array(copy.data);
const meta = { rows: copy.rows, cols: copy.cols, ch: copy.channels() };
copy.delete();
const hash = createHash("sha1")
.update(`${meta.rows}x${meta.cols}x${meta.ch}`)
.update(data)
.digest("hex");
const known = byHash.get(hash);
if (known !== undefined) return known;
blobs.push({ ...meta, off: dataLength });
chunks.push(data);
dataLength += data.length;
byHash.set(hash, blobs.length - 1);
return blobs.length - 1;
};
const templateOf = (mat: Mat) => {
let id = templateIds.get(mat);
if (id === undefined) {
id = blobOf(mat);
templateIds.set(mat, id);
}
return id;
};
function* recorded<T>(steps: MatchSteps<T>): MatchSteps<T> {
const run: MatchCorpus["runs"][number] = { steps: [] };
runs.push(run);
let scores: MatchScores[] | undefined;
for (;;) {
const step = scores === undefined ? steps.next() : steps.next(scores);
if (step.done) return step.value;
run.steps.push(
step.value.map((request) => ({
image: blobOf(request.image),
templates: request.templates.map(templateOf),
windows: request.windows?.map(([lo, hi]) => [lo, hi]),
key: request.key,
})),
);
scores = yield step.value;
}
}
return {
run: (<T>(steps: MatchSteps<T>) => inner(recorded(steps))) as StepsRunner,
save(): string {
mkdirSync(dir, { recursive: true });
const data = new Uint8Array(dataLength);
let off = 0;
for (const chunk of chunks) {
data.set(chunk, off);
off += chunk.length;
}
writeFileSync(`${dir}/data.bin`, data);
writeFileSync(`${dir}/index.json`, JSON.stringify({ blobs, runs }));
const requests = runs.reduce(
(n, r) => n + r.steps.reduce((m, s) => m + s.length, 0),
0,
);
return `recorded ${runs.length} runs, ${requests} requests, ${blobs.length} images (${(dataLength / 1e6).toFixed(1)} MB) to ${dir}`;
},
};
}
export function loadMatchCorpus(dir: string): MatchCorpus {
const index = JSON.parse(readFileSync(`${dir}/index.json`, "utf8")) as {
blobs: BlobMeta[];
runs: MatchCorpus["runs"];
};
// shared, so worker threads receive the pixels without a copy each
const file = readFileSync(`${dir}/data.bin`);
const data = new Uint8Array(new SharedArrayBuffer(file.length));
data.set(file);
return {
images: index.blobs.map(({ rows, cols, ch, off }) => ({
rows,
cols,
ch,
data: data.subarray(off, off + rows * cols * ch),
})),
runs: index.runs,
};
}

View File

@@ -8,8 +8,13 @@
*
* Requires ffmpeg (and ffprobe for the progress percentage) on PATH.
*
* Usage: pnpm scanner:scan-vod <video> [--fps 8] [--start T] [--duration S] [--out file.csv] [--telemetry]
* Usage: pnpm scanner:scan-vod <video> [--fps 8] [--start T] [--duration S] [--out file.csv] [--telemetry] [--gpu] [--record dir]
* --telemetry prints the VoD tab's ?telemetry=true scan counters after the run.
* --gpu matches templates on WebGPU (worker/gpu-matcher.ts) like the browser's
* GPU path; needs Dawn (node/webgpu.ts: WEBGPU_NODE). The CSV must stay
* byte-identical to a CPU scan's.
* --record writes every template-match request as a replay corpus for
* `pnpm scanner:gpu-replay` (match-corpus.ts).
*/
import { spawn } from "node:child_process";
import { writeFileSync } from "node:fs";
@@ -19,19 +24,21 @@ import {
eventsToCsv,
} from "../../app/features/scanner/core/csv/events";
import { loadOpenCV } from "../../app/features/scanner/core/cv";
import { runDetectorPass } from "../../app/features/scanner/core/detectors/frame-pass";
import { MAP_START_EVENT_TYPE } from "../../app/features/scanner/core/detectors/map-start/index";
import {
createAllDetectors,
SCOREBOARD_EVENT_TYPES,
} from "../../app/features/scanner/core/detectors/registry";
import { DetectorScheduler } from "../../app/features/scanner/core/detectors/scheduler";
import {
createScanTelemetry,
detectorTelemetry,
} from "../../app/features/scanner/core/detectors/telemetry";
import { createScanTelemetry } from "../../app/features/scanner/core/detectors/telemetry";
import { normalizeFrame, toMat } from "../../app/features/scanner/core/image";
import { runSync } from "../../app/features/scanner/core/match-steps";
import { TimelineBuilder } from "../../app/features/scanner/core/timeline/index";
import { loadScoreboardResources } from "../../app/features/scanner/node/resources";
import { nodeGpu } from "../../app/features/scanner/node/webgpu";
import { createGpuMatcher } from "../../app/features/scanner/worker/gpu-matcher";
import { recordingRunner } from "./match-corpus";
const FRAME_WIDTH = 1920;
const FRAME_HEIGHT = 1080;
@@ -43,13 +50,30 @@ const PROGRESS_INTERVAL_SECONDS = 60;
const options = parseArgs(process.argv.slice(2));
if (!options) {
console.error(
"usage: pnpm scanner:scan-vod <video> [--fps 8] [--start T] [--duration S] [--out file.csv] [--telemetry]",
"usage: pnpm scanner:scan-vod <video> [--fps 8] [--start T] [--duration S] [--out file.csv] [--telemetry] [--gpu] [--record dir]",
);
process.exit(1);
}
const { videoPath, fps, start, duration, outPath, collectTelemetry } = options;
const {
videoPath,
fps,
start,
duration,
outPath,
collectTelemetry,
gpu,
recordDir,
} = options;
await loadOpenCV();
const matcher = gpu ? await createGpuMatcher(nodeGpu()) : null;
const recorder = recordDir
? recordingRunner(
matcher?.run ?? (async (steps) => runSync(steps)),
recordDir,
)
: null;
const runSteps = recorder?.run ?? matcher?.run;
const detectors = createAllDetectors(await loadScoreboardResources());
const scheduler = new DetectorScheduler(detectors, {
matchOpeningTypes: [MAP_START_EVENT_TYPE],
@@ -58,6 +82,8 @@ const scheduler = new DetectorScheduler(detectors, {
scheduler.reset(start);
const timeline = new TimelineBuilder();
const telemetry = collectTelemetry ? createScanTelemetry() : null;
/** per detector: the whole frame pass's latency on each frame it parsed */
const passLatencies = new Map<string, number[]>();
const totalSeconds = await probeDurationSeconds(videoPath);
const scanEnd =
@@ -104,7 +130,7 @@ for await (const chunk of ffmpeg.stdout) {
offset += take;
if (frameFill < FRAME_BYTES) continue;
frameFill = 0;
processFrame(start + frameIndex / fps);
await processFrame(start + frameIndex / fps);
frameIndex++;
}
}
@@ -127,6 +153,8 @@ function parseArgs(argv: string[]): {
duration: number | undefined;
outPath: string;
collectTelemetry: boolean;
gpu: boolean;
recordDir: string | undefined;
} | null {
let parsedVideoPath: string | undefined;
let parsedFps = DEFAULT_FPS;
@@ -134,6 +162,8 @@ function parseArgs(argv: string[]): {
let parsedDuration: number | undefined;
let parsedOutPath: string | undefined;
let parsedCollectTelemetry = false;
let parsedGpu = false;
let parsedRecordDir: string | undefined;
// biome-ignore lint/style/useForOf: the index advances inside the loop to consume flag values
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]!;
@@ -142,6 +172,8 @@ function parseArgs(argv: string[]): {
else if (arg === "--duration") parsedDuration = Number(argv[++i]);
else if (arg === "--out") parsedOutPath = argv[++i];
else if (arg === "--telemetry") parsedCollectTelemetry = true;
else if (arg === "--gpu") parsedGpu = true;
else if (arg === "--record") parsedRecordDir = argv[++i];
else if (!arg.startsWith("--") && parsedVideoPath === undefined)
parsedVideoPath = arg;
else return null;
@@ -164,6 +196,8 @@ function parseArgs(argv: string[]): {
parsedOutPath ??
`${basename(parsedVideoPath).replace(/\.[^.]+$/, "")}-events.csv`,
collectTelemetry: parsedCollectTelemetry,
gpu: parsedGpu,
recordDir: parsedRecordDir,
};
}
@@ -194,7 +228,7 @@ function probeDurationSeconds(path: string): Promise<number | null> {
});
}
function processFrame(t: number): void {
async function processFrame(t: number): Promise<void> {
if (t >= nextProgressT) {
const percent =
scanEnd === null
@@ -226,31 +260,26 @@ function processFrame(t: number): void {
});
const frame = normalizeFrame(src);
src.delete();
for (const detector of detectors) {
if (!due.includes(detector.id)) continue;
const counters = telemetry
? detectorTelemetry(telemetry, detector.id)
: null;
const gateStart = counters ? performance.now() : 0;
const gate = detector.gate(frame);
if (counters) {
counters.checks++;
counters.gateMs += performance.now() - gateStart;
const passStart = performance.now();
const outcomes = await runDetectorPass({
frame,
t,
detectors,
due,
scheduler,
telemetry,
runSteps,
});
if (telemetry) {
const ms = performance.now() - passStart;
for (const { detector, parsed } of outcomes) {
if (!parsed) continue;
const list = passLatencies.get(detector.id) ?? [];
list.push(ms);
passLatencies.set(detector.id, list);
}
scheduler.recordGate(detector.id, t, gate.pass, gate.signature);
if (!gate.pass) continue;
if (counters) counters.gatePasses++;
if (!scheduler.shouldParse(detector.id, t)) {
if (counters) counters.suppressedParses++;
continue;
}
const parseStart = counters ? performance.now() : 0;
const events = detector.parse(frame, t, gate);
if (counters) {
counters.parses++;
counters.parseMs += performance.now() - parseStart;
}
scheduler.recordParse(detector.id, t, events);
}
for (const { events } of outcomes) {
for (const event of events) {
const action = timeline.push(event);
if (action.action === "added" || action.action === "replaced") {
@@ -277,6 +306,8 @@ function printSummary(): void {
console.error(`timeline events: ${timeline.events.length} (${countText})`);
console.error(`wrote ${outPath}`);
if (telemetry) printTelemetry();
if (matcher) console.error(`GPU matcher: ${JSON.stringify(matcher.stats)}`);
if (recorder) console.error(recorder.save());
console.error(`next: pnpm scanner:status-audit ${outPath}`);
}
@@ -318,4 +349,17 @@ function printTelemetry(): void {
.join(" ");
console.error(line(header));
for (const row of rows) console.error(line(row));
// how long a frame waits for its results when the detector parses it: the
// whole pass (every gate and parse of the frame, GPU round trips included)
for (const [id, list] of [...passLatencies].sort(([a], [b]) =>
a.localeCompare(b),
)) {
const sorted = [...list].sort((a, b) => a - b);
const mean = sorted.reduce((sum, ms) => sum + ms, 0) / sorted.length;
const p95 =
sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))]!;
console.error(
`frame pass with a ${id} parse: mean ${mean.toFixed(1)} ms · p95 ${p95.toFixed(1)} ms · max ${sorted[sorted.length - 1]!.toFixed(1)} ms (${sorted.length} frames)`,
);
}
}