/**
* ECSeg onnxruntime-web benchmark harness — the page the driver (bench_browser.mjs) automates.
*
* Configured to match the AnnotateIt production ECSeg session exactly, because a benchmark of a
* differently-configured runtime describes software nobody ships:
* - session options mirror packages/smart-tools/src/segment-anything/session.ts:311-321
* (executionProviders ['cpu'], graphOptimizationLevel 'all', logSeverityLevel 3,
* executionMode 'sequential' iff numThreads===1 else 'parallel')
* - env.wasm.simd/numThreads/wasmPaths mirror session.ts:266-294 + wasm-utils.ts
* - inference timing includes reading ALL FOUR outputs back out (getData), because that is what
* the app awaits before it can parse instances — a run() whose outputs have not been read is
* not necessarily finished
* - the input tensor is a precomputed app-faithful preprocessing (identical bytes to the Python
* correctness run), so cross-runtime numeric agreement can be checked, not just latency
*
* Query params: model=, input=, threads=auto|1,
* repeats=N, warmups=N, budgetMs=N.
*/
import * as ort from '/ort/ort.all.bundle.min.mjs';
const params = new URLSearchParams(location.search);
const MODEL = params.get('model');
const INPUT = params.get('input') ?? '000000000139.f32';
const THREADS = params.get('threads') ?? 'auto';
const REPEATS = Number(params.get('repeats') ?? 12);
const WARMUPS = Number(params.get('warmups') ?? 2);
const BUDGET_MS = Number(params.get('budgetMs') ?? 180_000);
const log = (m) => { document.getElementById('log').textContent = m; };
const percentile = (values, p) => {
const sorted = [...values].sort((a, b) => a - b);
if (sorted.length === 0) return null;
const idx = Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1);
return sorted[Math.max(0, idx)];
};
const mean = (v) => v.reduce((a, b) => a + b, 0) / v.length;
const fetchBuffer = async (path) => {
const r = await fetch(path);
if (!r.ok) throw new Error(`${path}: HTTP ${r.status}`);
return r.arrayBuffer();
};
const measureMemory = async () => {
if (typeof performance.measureUserAgentSpecificMemory !== 'function') return null;
try {
const res = await performance.measureUserAgentSpecificMemory();
return { bytes: res.bytes };
} catch { return null; }
};
/**
* Compact, cross-runtime-comparable fingerprint of one inference: the arrays the app's parser reads
* plus, for each of the 300 queries, the count of mask pixels above the logit>0 cut (computed in
* the same 160×160 space the model emits). Lets the driver confirm ORT-web produces the SAME
* instances as Python, not merely that the graph loaded.
*/
const fingerprint = (outputs) => {
const labels = outputs.labels; // BigInt64Array
const scores = outputs.scores; // Float32Array
const boxes = outputs.boxes; // Float32Array [300*4]
const masks = outputs.masks; // Float32Array [300*160*160]
const Q = scores.length;
const plane = masks.length / Q; // 160*160 = 25600
const labelsNum = new Array(Q);
const maskPix = new Array(Q);
for (let q = 0; q < Q; q++) {
labelsNum[q] = Number(labels[q]);
let c = 0;
const base = q * plane;
for (let i = 0; i < plane; i++) if (masks[base + i] > 0) c++;
maskPix[q] = c;
}
// Instances above conf 0.4 (mirror parseEdgecrafterSeg score filter, no NMS).
const inst = [];
for (let q = 0; q < Q; q++) {
if (!(scores[q] >= 0.4)) continue;
const cls = labelsNum[q];
if (cls < 0 || cls >= 80) continue;
inst.push({
q,
cls,
score: Number(scores[q].toFixed(5)),
box: [boxes[q * 4], boxes[q * 4 + 1], boxes[q * 4 + 2], boxes[q * 4 + 3]].map((v) => Number(v.toFixed(5))),
maskPix: maskPix[q],
});
}
let nan = false;
for (let i = 0; i < scores.length; i++) if (!Number.isFinite(scores[i])) { nan = true; break; }
return {
numInstances: inst.length,
instances: inst,
scoresTop5: [...scores].sort((a, b) => b - a).slice(0, 5).map((v) => Number(v.toFixed(5))),
anyNaNInf: nan,
outputDtypes: {
labels: outputs._types.labels,
boxes: outputs._types.boxes,
scores: outputs._types.scores,
masks: outputs._types.masks,
},
};
};
const EM_OVERRIDE = params.get('em'); // optional 'sequential'|'parallel' override for diagnosis
const run = async () => {
if (!MODEL) throw new Error('missing ?model=');
// THREADS: 'auto' => 0 (ORT auto-sizes the pool), '1' => single thread, any other number => fixed.
const singleThread = THREADS === '1';
const numThreads = THREADS === 'auto' ? 0 : Number(THREADS);
// Serve every ORT asset (wasm + pthread-worker glue mjs) from /ort/. A directory string lets ORT
// resolve the pthread worker's own wasm fetch correctly inside the worker context; this is the form
// the proven scripts/benchmark-sam2.mjs harness uses to bring the threaded pool up. Kernel speed is
// identical to the app once the pool is live — wasmPaths only affects whether the pool starts.
ort.env.wasm.wasmPaths = '/ort/';
ort.env.wasm.simd = true;
ort.env.wasm.numThreads = numThreads;
ort.env.logLevel = 'error';
const environment = {
model: MODEL,
input: INPUT,
threadsRequested: THREADS,
crossOriginIsolated: globalThis.crossOriginIsolated === true,
sharedArrayBuffer: typeof SharedArrayBuffer !== 'undefined',
hardwareConcurrency: navigator.hardwareConcurrency,
userAgent: navigator.userAgent,
ortVersion: ort.env.versions?.common ?? null,
wasmSimd: ort.env.wasm.simd,
numThreadsRequested: ort.env.wasm.numThreads,
};
log(`fetching model ${MODEL}`);
const modelBuf = await fetchBuffer(`/models/${MODEL}`);
const inputBuf = await fetchBuffer(`/input/${INPUT}`);
const inputData = new Float32Array(inputBuf);
const baselineMemory = await measureMemory();
const executionMode = EM_OVERRIDE ?? (singleThread ? 'sequential' : 'parallel');
log(`creating session ${MODEL} (threads=${THREADS}, em=${executionMode})`);
const createStart = performance.now();
let session;
try {
// Bound session.create so a hung threaded-pool bring-up fails fast (reported) instead of
// stalling the whole cell to the driver timeout.
session = await Promise.race([
ort.InferenceSession.create(modelBuf, {
executionProviders: ['cpu'],
graphOptimizationLevel: 'all',
executionMode,
logSeverityLevel: 3,
}),
new Promise((_, rej) => setTimeout(() => rej(new Error('session.create timed out (30s)')), 30_000)),
]);
} catch (e) {
throw new Error(`session.create failed: ${e?.message ?? e}`);
}
const sessionCreateMs = performance.now() - createStart;
const afterLoadMemory = await measureMemory();
const makeInput = () => new ort.Tensor('float32', inputData.slice(), [1, 3, 640, 640]);
const OUT_NAMES = ['labels', 'boxes', 'scores', 'masks'];
// read run() outputs -> plain typed arrays (this is the await the app pays before parsing)
const readOutputs = async (results) => {
const out = { _types: {} };
for (const name of OUT_NAMES) {
const t = results[name];
out._types[name] = t.type;
out[name] = await t.getData();
}
return out;
};
// Warm-ups (untimed): first inference pays one-time allocation/JIT. Reported separately.
log(`warmup ${MODEL}`);
let firstOutputs = null;
let coldMs = null;
for (let i = 0; i < Math.max(1, WARMUPS); i++) {
const t = performance.now();
const results = await session.run({ images: makeInput() });
const outs = await readOutputs(results);
const dt = performance.now() - t;
if (i === 0) { coldMs = dt; firstOutputs = outs; }
}
// Timed warm inference.
const samples = [];
const started = performance.now();
for (let i = 0; i < REPEATS; i++) {
if (i > 0 && performance.now() - started > BUDGET_MS) break;
const t = performance.now();
const results = await session.run({ images: makeInput() });
await readOutputs(results);
samples.push(performance.now() - t);
log(`${MODEL} warm ${i + 1}/${REPEATS} — ${Math.round(samples[i])} ms`);
}
const afterRunMemory = await measureMemory();
return {
environment,
sessionCreateMs,
coldInferenceMs: coldMs,
warm: {
n: samples.length,
p50: percentile(samples, 50),
p90: percentile(samples, 90),
p95: percentile(samples, 95),
min: Math.min(...samples),
max: Math.max(...samples),
mean: mean(samples),
samples,
},
fingerprint: fingerprint(firstOutputs),
memory: { baseline: baselineMemory, afterLoad: afterLoadMemory, afterRun: afterRunMemory },
};
};
globalThis.benchmarkPromise = run().then(
(result) => { log('done'); globalThis.benchmarkResult = { ok: true, result }; return globalThis.benchmarkResult; },
(error) => {
log(`failed: ${error?.message ?? error}`);
globalThis.benchmarkResult = { ok: false, error: String(error?.stack ?? error?.message ?? error) };
return globalThis.benchmarkResult;
}
);