oidn-web 0.3.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +140 -4
  3. package/benchmarks/compare.mjs +651 -0
  4. package/benchmarks/leak.mjs +255 -0
  5. package/benchmarks/results/before-spatial.json +391 -0
  6. package/benchmarks/results/before-spatial.md +47 -0
  7. package/benchmarks/results/int8-scan.json +2007 -0
  8. package/benchmarks/results/int8-scan.md +160 -0
  9. package/benchmarks/results/int8-w8a8-scan.json +2007 -0
  10. package/benchmarks/results/int8-w8a8-scan.md +160 -0
  11. package/benchmarks/results/int8-weight-channel.json +1413 -0
  12. package/benchmarks/results/int8-weight-channel.md +118 -0
  13. package/benchmarks/results/int8-weight-only.json +1437 -0
  14. package/benchmarks/results/int8-weight-only.md +118 -0
  15. package/benchmarks/results/kernel-webnn-final.json +1115 -0
  16. package/benchmarks/results/kernel-webnn-final.md +104 -0
  17. package/benchmarks/results/latest-optimized.json +375 -0
  18. package/benchmarks/results/latest-optimized.md +47 -0
  19. package/benchmarks/results/latest.json +391 -0
  20. package/benchmarks/results/latest.md +47 -0
  21. package/benchmarks/results/profile-baseline.json +331 -0
  22. package/benchmarks/results/profile-baseline.md +12 -0
  23. package/benchmarks/results/profile-conv2x.json +331 -0
  24. package/benchmarks/results/profile-conv2x.md +12 -0
  25. package/benchmarks/results/profile-fast-init.json +385 -0
  26. package/benchmarks/results/profile-fast-init.md +47 -0
  27. package/benchmarks/results/profile-fp16-fma.json +369 -0
  28. package/benchmarks/results/profile-fp16-fma.md +47 -0
  29. package/benchmarks/results/profile-fp16-tiled-decoder.json +369 -0
  30. package/benchmarks/results/profile-fp16-tiled-decoder.md +47 -0
  31. package/benchmarks/results/profile-fp16-tiled-encoder.json +369 -0
  32. package/benchmarks/results/profile-fp16-tiled-encoder.md +47 -0
  33. package/benchmarks/results/profile-fp16-unfused-pool.json +385 -0
  34. package/benchmarks/results/profile-fp16-unfused-pool.md +47 -0
  35. package/benchmarks/results/profile-input-major.json +347 -0
  36. package/benchmarks/results/profile-input-major.md +12 -0
  37. package/benchmarks/results/profile-k16.json +347 -0
  38. package/benchmarks/results/profile-k16.md +12 -0
  39. package/benchmarks/results/profile-k4.json +347 -0
  40. package/benchmarks/results/profile-k4.md +12 -0
  41. package/benchmarks/results/profile-pool-reuse.json +331 -0
  42. package/benchmarks/results/profile-pool-reuse.md +12 -0
  43. package/benchmarks/results/profile-precompiled.json +385 -0
  44. package/benchmarks/results/profile-precompiled.md +47 -0
  45. package/benchmarks/results/profile-static-channels.json +385 -0
  46. package/benchmarks/results/profile-static-channels.md +47 -0
  47. package/benchmarks/results/profile-static-io.json +385 -0
  48. package/benchmarks/results/profile-static-io.md +47 -0
  49. package/benchmarks/results/profile-tiled-conv.json +331 -0
  50. package/benchmarks/results/profile-tiled-conv.md +12 -0
  51. package/benchmarks/results/profile-tiled-decoder.json +347 -0
  52. package/benchmarks/results/profile-tiled-decoder.md +12 -0
  53. package/benchmarks/results/profile-tiled-matmul.json +331 -0
  54. package/benchmarks/results/profile-tiled-matmul.md +12 -0
  55. package/benchmarks/results/profile-unfused-decoder.json +379 -0
  56. package/benchmarks/results/profile-unfused-decoder.md +12 -0
  57. package/benchmarks/results/profile-unfused-pool.json +347 -0
  58. package/benchmarks/results/profile-unfused-pool.md +12 -0
  59. package/benchmarks/results/spatial-auto.json +575 -0
  60. package/benchmarks/results/spatial-auto.md +61 -0
  61. package/benchmarks/results/subgroup-smoke.json +1094 -0
  62. package/benchmarks/results/subgroup-smoke.md +104 -0
  63. package/benchmarks/results/webnn-smoke.json +739 -0
  64. package/benchmarks/results/webnn-smoke.md +76 -0
  65. package/dist/oidn.js +4189 -22603
  66. package/dist/oidn.umd.cjs +784 -5807
  67. package/lib/UNet.d.ts +66 -15
  68. package/lib/UNet.js +162 -257
  69. package/lib/UNet.js.map +1 -1
  70. package/lib/WGPUComputePass.d.ts +1 -1
  71. package/lib/WGPUComputePass.js +6 -4
  72. package/lib/WGPUComputePass.js.map +1 -1
  73. package/lib/backend.d.ts +8 -4
  74. package/lib/backend.js +36 -44
  75. package/lib/backend.js.map +1 -1
  76. package/lib/graphOptimizer.d.ts +54 -0
  77. package/lib/graphOptimizer.js +216 -0
  78. package/lib/graphOptimizer.js.map +1 -0
  79. package/lib/main.d.ts +33 -10
  80. package/lib/main.js +5 -0
  81. package/lib/main.js.map +1 -1
  82. package/lib/modelSpec.d.ts +80 -0
  83. package/lib/modelSpec.js +270 -0
  84. package/lib/modelSpec.js.map +1 -0
  85. package/lib/nativeUNet.d.ts +67 -0
  86. package/lib/nativeUNet.js +1735 -0
  87. package/lib/nativeUNet.js.map +1 -0
  88. package/lib/process.js +38 -35
  89. package/lib/process.js.map +1 -1
  90. package/lib/resourceTracker.d.ts +26 -0
  91. package/lib/resourceTracker.js +65 -0
  92. package/lib/resourceTracker.js.map +1 -0
  93. package/lib/tileScheduler.d.ts +33 -0
  94. package/lib/tileScheduler.js +86 -0
  95. package/lib/tileScheduler.js.map +1 -0
  96. package/lib/webnnUNet.d.ts +52 -0
  97. package/lib/webnnUNet.js +535 -0
  98. package/lib/webnnUNet.js.map +1 -0
  99. package/package.json +9 -5
  100. package/scripts/inspect-model.mjs +64 -0
  101. package/src/UNet.ts +236 -339
  102. package/src/WGPUComputePass.ts +6 -4
  103. package/src/backend.ts +42 -55
  104. package/src/graphOptimizer.ts +301 -0
  105. package/src/main.ts +71 -11
  106. package/src/modelSpec.ts +414 -0
  107. package/src/nativeUNet.ts +2256 -0
  108. package/src/process.ts +38 -36
  109. package/src/resourceTracker.ts +94 -0
  110. package/src/tileScheduler.ts +138 -0
  111. package/src/webnnUNet.ts +812 -0
  112. package/tests/modelSpec.test.mjs +128 -0
  113. package/tests/resourceLifecycle.test.mjs +383 -0
  114. package/tests/tileScheduler.test.mjs +90 -0
  115. package/lib/helper.d.ts +0 -4
  116. package/lib/helper.js +0 -33
  117. package/lib/helper.js.map +0 -1
  118. package/lib/kernels.d.ts +0 -1
  119. package/lib/kernels.js +0 -26
  120. package/lib/kernels.js.map +0 -1
  121. package/src/helper.ts +0 -43
  122. package/src/kernels.ts +0 -31
@@ -0,0 +1,651 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
3
+ import { createServer } from 'node:http';
4
+ import { existsSync } from 'node:fs';
5
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import process from 'node:process';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { chromium } from 'playwright-core';
11
+
12
+ const benchmarkDirectory = path.dirname(fileURLToPath(import.meta.url));
13
+ const projectRoot = path.resolve(benchmarkDirectory, '..');
14
+
15
+ function printHelp() {
16
+ console.log(`Usage: npm run benchmark -- [options]
17
+
18
+ Options:
19
+ --width <n> Input width (default: 512)
20
+ --height <n> Input height (default: 512)
21
+ --tile-size <n> Fixed output tile edge (default: 512)
22
+ --warmup <n> Warmup executions per runtime (default: 1)
23
+ --runs <n> Measured executions per runtime (default: 5)
24
+ --baseline <commit> TFJS commit (default: nearest TFJS ancestor)
25
+ --chrome <path> Chrome/Chromium executable
26
+ --output <path> JSON output (default: benchmarks/results/latest.json)
27
+ --help Show this message
28
+ `);
29
+ }
30
+
31
+ function parseArgs(argv) {
32
+ const options = {
33
+ width: 512,
34
+ height: 512,
35
+ tileSize: 512,
36
+ warmup: 1,
37
+ runs: 5,
38
+ output: path.join(benchmarkDirectory, 'results/latest.json')
39
+ };
40
+ const names = {
41
+ width: 'width',
42
+ height: 'height',
43
+ 'tile-size': 'tileSize',
44
+ warmup: 'warmup',
45
+ runs: 'runs',
46
+ baseline: 'baseline',
47
+ chrome: 'chrome',
48
+ output: 'output'
49
+ };
50
+ for (let index = 0; index < argv.length; index++) {
51
+ const argument = argv[index];
52
+ if (argument === '--help' || argument === '-h') {
53
+ options.help = true;
54
+ continue;
55
+ }
56
+ if (!argument.startsWith('--') || !names[argument.slice(2)]) {
57
+ throw new Error(`Unknown option ${argument}`);
58
+ }
59
+ const key = argument.slice(2);
60
+ const value = argv[++index];
61
+ if (value === undefined || value.startsWith('--')) {
62
+ throw new Error(`Missing value for ${argument}`);
63
+ }
64
+ options[names[key]] = ['width', 'height', 'tile-size', 'warmup', 'runs'].includes(key)
65
+ ? Number(value)
66
+ : value;
67
+ }
68
+ for (const key of ['width', 'height', 'tileSize', 'runs']) {
69
+ if (!Number.isInteger(options[key]) || options[key] <= 0) {
70
+ throw new Error(`${key} must be a positive integer`);
71
+ }
72
+ }
73
+ if (!Number.isInteger(options.warmup) || options.warmup < 0) {
74
+ throw new Error('warmup must be a non-negative integer');
75
+ }
76
+ options.output = path.resolve(projectRoot, options.output);
77
+ return options;
78
+ }
79
+
80
+ function defaultChrome() {
81
+ return [
82
+ process.env.CHROME_PATH,
83
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
84
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
85
+ '/usr/bin/google-chrome',
86
+ '/usr/bin/chromium',
87
+ '/usr/bin/chromium-browser'
88
+ ].find((candidate) => candidate && existsSync(candidate));
89
+ }
90
+
91
+ function run(command, args, { cwd = projectRoot, quiet = false } = {}) {
92
+ return new Promise((resolve, reject) => {
93
+ const child = spawn(command, args, {
94
+ cwd,
95
+ stdio: quiet ? ['ignore', 'pipe', 'pipe'] : 'inherit'
96
+ });
97
+ let stdout = '';
98
+ let stderr = '';
99
+ if (quiet) {
100
+ child.stdout.on('data', (data) => { stdout += data; });
101
+ child.stderr.on('data', (data) => { stderr += data; });
102
+ }
103
+ child.on('error', reject);
104
+ child.on('exit', (code, signal) => {
105
+ if (code === 0) resolve({ stdout, stderr });
106
+ else reject(new Error(
107
+ `${command} exited with ${code ?? signal}${stderr ? `\n${stderr.slice(-4000)}` : ''}`
108
+ ));
109
+ });
110
+ });
111
+ }
112
+
113
+ async function gitText(args) {
114
+ return (await run('git', args, { quiet: true })).stdout.trim();
115
+ }
116
+
117
+ async function findTFJSBaseline(explicitCommit) {
118
+ if (explicitCommit) {
119
+ await gitText(['rev-parse', '--verify', `${explicitCommit}^{commit}`]);
120
+ return gitText(['rev-parse', explicitCommit]);
121
+ }
122
+ const commits = (await gitText(['rev-list', 'HEAD', '--', 'package.json']))
123
+ .split('\n')
124
+ .filter(Boolean);
125
+ for (const commit of commits) {
126
+ const packageJson = JSON.parse(await gitText(['show', `${commit}:package.json`]));
127
+ const dependencies = {
128
+ ...packageJson.dependencies,
129
+ ...packageJson.devDependencies
130
+ };
131
+ if (Object.keys(dependencies).some((name) => name.startsWith('@tensorflow/tfjs'))) {
132
+ return commit;
133
+ }
134
+ }
135
+ throw new Error('Could not find a TensorFlow.js ancestor; pass --baseline <commit>');
136
+ }
137
+
138
+ function contentType(filePath) {
139
+ if (filePath.endsWith('.js')) return 'text/javascript; charset=utf-8';
140
+ if (filePath.endsWith('.tza')) return 'application/octet-stream';
141
+ return 'text/html; charset=utf-8';
142
+ }
143
+
144
+ async function startServer(files) {
145
+ const server = createServer(async (request, response) => {
146
+ const pathname = new URL(request.url, 'http://127.0.0.1').pathname;
147
+ if (pathname === '/') {
148
+ response.writeHead(200, { 'content-type': contentType('.html') });
149
+ response.end('<!doctype html><meta charset="utf-8"><link rel="icon" href="data:,"><title>OIDN benchmark</title>');
150
+ return;
151
+ }
152
+ if (pathname === '/favicon.ico') {
153
+ response.writeHead(204);
154
+ response.end();
155
+ return;
156
+ }
157
+ const filePath = files.get(pathname);
158
+ if (!filePath) {
159
+ response.writeHead(404);
160
+ response.end('Not found');
161
+ return;
162
+ }
163
+ try {
164
+ response.writeHead(200, {
165
+ 'content-type': contentType(filePath),
166
+ 'cache-control': 'no-store'
167
+ });
168
+ response.end(await readFile(filePath));
169
+ } catch (error) {
170
+ response.writeHead(500);
171
+ response.end(String(error));
172
+ }
173
+ });
174
+ await new Promise((resolve, reject) => {
175
+ server.once('error', reject);
176
+ server.listen(0, '127.0.0.1', resolve);
177
+ });
178
+ return {
179
+ origin: `http://127.0.0.1:${server.address().port}`,
180
+ close: () => new Promise((resolve, reject) =>
181
+ server.close((error) => error ? reject(error) : resolve())
182
+ )
183
+ };
184
+ }
185
+
186
+ function summarize(times) {
187
+ const sorted = [...times].sort((a, b) => a - b);
188
+ const middle = Math.floor(sorted.length / 2);
189
+ return {
190
+ minMs: sorted[0],
191
+ medianMs: sorted.length % 2
192
+ ? sorted[middle]
193
+ : (sorted[middle - 1] + sorted[middle]) / 2,
194
+ meanMs: sorted.reduce((sum, value) => sum + value, 0) / sorted.length,
195
+ p95Ms: sorted[Math.ceil(sorted.length * 0.95) - 1],
196
+ maxMs: sorted[sorted.length - 1]
197
+ };
198
+ }
199
+
200
+ function compareSamples(reference, candidate) {
201
+ if (!reference || !candidate || reference.length !== candidate.length) {
202
+ return {
203
+ passed: false,
204
+ reason: 'output sample count mismatch'
205
+ };
206
+ }
207
+ let absoluteError = 0;
208
+ let squaredError = 0;
209
+ let maxAbsoluteError = 0;
210
+ for (let index = 0; index < reference.length; index++) {
211
+ const error = Math.abs(reference[index] - candidate[index]);
212
+ if (!Number.isFinite(error)) {
213
+ return { passed: false, reason: `non-finite output at sample ${index}` };
214
+ }
215
+ absoluteError += error;
216
+ squaredError += error * error;
217
+ maxAbsoluteError = Math.max(maxAbsoluteError, error);
218
+ }
219
+ return {
220
+ sampleCount: reference.length,
221
+ meanAbsoluteError: absoluteError / reference.length,
222
+ rootMeanSquaredError: Math.sqrt(squaredError / reference.length),
223
+ maxAbsoluteError
224
+ };
225
+ }
226
+
227
+ async function benchmarkVariant(browser, origin, options, variant) {
228
+ const page = await browser.newPage();
229
+ const messages = [];
230
+ page.on('console', (message) => {
231
+ if (message.type() === 'warning' || message.type() === 'error') {
232
+ messages.push(`${message.type()}: ${message.text()}`);
233
+ }
234
+ });
235
+ page.on('pageerror', (error) => messages.push(`pageerror: ${error.message}`));
236
+ await page.goto(origin, { waitUntil: 'load' });
237
+ try {
238
+ const result = await page.evaluate(async (config) => {
239
+ if (!navigator.gpu) throw new Error('WebGPU is unavailable');
240
+ if (config.engine === 'webnn' && !navigator.ml) {
241
+ return { skipped: 'WebNN is unavailable' };
242
+ }
243
+ const adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance' });
244
+ if (!adapter) throw new Error('No WebGPU adapter is available');
245
+ const supportsFP16 = adapter.features.has('shader-f16');
246
+ if (config.precision === 'fp16' && !supportsFP16) {
247
+ return { skipped: 'shader-f16 is unavailable' };
248
+ }
249
+ const requiredFeatures = config.precision === 'fp16' ? ['shader-f16'] : [];
250
+ if (config.kernel === 'subgroup') {
251
+ if (!adapter.features.has('subgroups')) {
252
+ return { skipped: 'subgroups is unavailable' };
253
+ }
254
+ requiredFeatures.push('subgroups');
255
+ }
256
+ if (adapter.features.has('timestamp-query')) requiredFeatures.push('timestamp-query');
257
+ const device = await adapter.requestDevice({
258
+ requiredFeatures,
259
+ requiredLimits: {
260
+ maxComputeWorkgroupStorageSize: adapter.limits.maxComputeWorkgroupStorageSize,
261
+ maxComputeWorkgroupsPerDimension: adapter.limits.maxComputeWorkgroupsPerDimension,
262
+ maxStorageBufferBindingSize: adapter.limits.maxStorageBufferBindingSize,
263
+ maxBufferSize: adapter.limits.maxBufferSize,
264
+ maxComputeWorkgroupSizeX: adapter.limits.maxComputeWorkgroupSizeX,
265
+ maxComputeInvocationsPerWorkgroup: adapter.limits.maxComputeInvocationsPerWorkgroup
266
+ }
267
+ });
268
+ const adapterInfo = adapter.info ?? await adapter.requestAdapterInfo?.() ?? {};
269
+ const oidn = await import(config.moduleUrl);
270
+ const initStartedAt = performance.now();
271
+ const runtimeOptions = {
272
+ aux: true,
273
+ hdr: true,
274
+ maxTileSize: config.tileSize
275
+ };
276
+ if (!config.baseline) {
277
+ runtimeOptions.engine = config.engine ?? 'wgsl';
278
+ runtimeOptions.precision = config.precision;
279
+ runtimeOptions.kernel = config.kernel;
280
+ runtimeOptions.dynamicTile = false;
281
+ }
282
+ let unet;
283
+ try {
284
+ unet = await oidn.initUNetFromURL(
285
+ config.modelUrl,
286
+ { device, adapterInfo },
287
+ runtimeOptions
288
+ );
289
+ } catch (error) {
290
+ if (config.engine === 'webnn') {
291
+ return { skipped: String(error) };
292
+ }
293
+ throw error;
294
+ }
295
+ const initializationMs = performance.now() - initStartedAt;
296
+
297
+ const pixelCount = config.width * config.height;
298
+ const makePixels = (kind) => {
299
+ const values = new Float32Array(pixelCount * 4);
300
+ for (let index = 0; index < pixelCount; index++) {
301
+ const x = index % config.width;
302
+ const y = Math.floor(index / config.width);
303
+ const offset = index * 4;
304
+ if (kind === 'color') {
305
+ values[offset] = 0.1 + 4 * x / Math.max(1, config.width - 1);
306
+ values[offset + 1] = 0.05 + 2 * y / Math.max(1, config.height - 1);
307
+ values[offset + 2] = 0.2 + ((x * 17 + y * 13) % 97) / 97;
308
+ } else if (kind === 'albedo') {
309
+ values[offset] = 0.2 + 0.7 * x / Math.max(1, config.width - 1);
310
+ values[offset + 1] = 0.3 + 0.6 * y / Math.max(1, config.height - 1);
311
+ values[offset + 2] = 0.55;
312
+ } else {
313
+ values[offset] = 0.5;
314
+ values[offset + 1] = 0.5;
315
+ values[offset + 2] = 1;
316
+ }
317
+ values[offset + 3] = 1;
318
+ }
319
+ return values;
320
+ };
321
+ const createInputBuffer = (kind) => {
322
+ const values = makePixels(kind);
323
+ const buffer = device.createBuffer({
324
+ size: values.byteLength,
325
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
326
+ mappedAtCreation: true
327
+ });
328
+ new Float32Array(buffer.getMappedRange()).set(values);
329
+ buffer.unmap();
330
+ return buffer;
331
+ };
332
+ const buffers = {
333
+ color: createInputBuffer('color'),
334
+ albedo: createInputBuffer('albedo'),
335
+ normal: createInputBuffer('normal')
336
+ };
337
+ const image = (data) => ({ data, width: config.width, height: config.height });
338
+ let lastOutput;
339
+ const execute = async () => {
340
+ const startedAt = performance.now();
341
+ await new Promise((resolve, reject) => {
342
+ try {
343
+ unet.tileExecute({
344
+ color: image(buffers.color),
345
+ albedo: image(buffers.albedo),
346
+ normal: image(buffers.normal),
347
+ done: (output) => {
348
+ lastOutput = output;
349
+ resolve();
350
+ }
351
+ });
352
+ } catch (error) {
353
+ reject(error);
354
+ }
355
+ });
356
+ await device.queue.onSubmittedWorkDone();
357
+ return performance.now() - startedAt;
358
+ };
359
+ for (let index = 0; index < config.warmup; index++) await execute();
360
+ const timesMs = [];
361
+ for (let index = 0; index < config.runs; index++) timesMs.push(await execute());
362
+ let executionProfile;
363
+ if (unet.profileNextExecution?.()) {
364
+ await execute();
365
+ executionProfile = await unet.getLastExecutionProfile?.();
366
+ }
367
+ if (!lastOutput?.data) throw new Error('OIDN did not return an output buffer');
368
+ const outputReadback = device.createBuffer({
369
+ size: pixelCount * 16,
370
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
371
+ });
372
+ const outputEncoder = device.createCommandEncoder();
373
+ outputEncoder.copyBufferToBuffer(
374
+ lastOutput.data,
375
+ 0,
376
+ outputReadback,
377
+ 0,
378
+ pixelCount * 16
379
+ );
380
+ device.queue.submit([outputEncoder.finish()]);
381
+ await outputReadback.mapAsync(GPUMapMode.READ);
382
+ const outputValues = new Float32Array(outputReadback.getMappedRange());
383
+ const outputSamples = [];
384
+ const sampleStride = Math.max(1, Math.floor(pixelCount / 2048));
385
+ for (let pixel = 0; pixel < pixelCount; pixel += sampleStride) {
386
+ const offset = pixel * 4;
387
+ outputSamples.push(
388
+ outputValues[offset],
389
+ outputValues[offset + 1],
390
+ outputValues[offset + 2]
391
+ );
392
+ }
393
+ outputReadback.unmap();
394
+ outputReadback.destroy();
395
+ const runtimeInfo = unet.getRuntimeInfo?.();
396
+ unet.dispose?.();
397
+ Object.values(buffers).forEach((buffer) => buffer.destroy());
398
+ device.destroy();
399
+ return {
400
+ initializationMs,
401
+ timesMs,
402
+ executionProfile,
403
+ outputSamples,
404
+ runtimeInfo,
405
+ supportsFP16,
406
+ adapter: {
407
+ vendor: adapterInfo.vendor ?? '',
408
+ architecture: adapterInfo.architecture ?? '',
409
+ device: adapterInfo.device ?? '',
410
+ description: adapterInfo.description ?? ''
411
+ }
412
+ };
413
+ }, {
414
+ moduleUrl: `${origin}/${variant.bundle}/oidn.js`,
415
+ modelUrl: `${origin}/weights/rt_hdr_calb_cnrm_large.tza`,
416
+ baseline: variant.baseline,
417
+ precision: variant.precision,
418
+ kernel: variant.kernel,
419
+ engine: variant.engine,
420
+ width: options.width,
421
+ height: options.height,
422
+ tileSize: options.tileSize,
423
+ warmup: options.warmup,
424
+ runs: options.runs
425
+ });
426
+ if (result.skipped) return { ...variant, skipped: result.skipped, messages };
427
+ return {
428
+ ...variant,
429
+ ...result,
430
+ summary: summarize(result.timesMs),
431
+ messages
432
+ };
433
+ } finally {
434
+ await page.close();
435
+ }
436
+ }
437
+
438
+ function formatNumber(value) {
439
+ return value == null ? '-' : value.toFixed(2);
440
+ }
441
+
442
+ function formatExponential(value) {
443
+ return Number.isFinite(value) ? value.toExponential(2) : '-';
444
+ }
445
+
446
+ function markdownReport(report) {
447
+ const baselineMedian = report.results.find((result) => result.baseline)?.summary?.medianMs;
448
+ const rows = report.results.map((result) => {
449
+ if (result.skipped) return `| ${result.label} | skipped | - | - | - | ${result.skipped} |`;
450
+ const speedup = baselineMedian / result.summary.medianMs;
451
+ return `| ${result.label} | ${formatNumber(result.initializationMs)} | ${formatNumber(result.summary.medianMs)} | ${formatNumber(result.summary.p95Ms)} | ${speedup.toFixed(2)}x | ${result.runtimeInfo?.precision ?? 'fp32'} |`;
452
+ });
453
+ const validationRows = report.results
454
+ .filter((result) => !result.baseline && !result.skipped)
455
+ .map((result) => {
456
+ const validation = result.validation;
457
+ const status = validation.passed ? 'pass' : 'FAIL';
458
+ return `| ${result.label} | ${status} | ${formatExponential(validation.meanAbsoluteError)} | ${formatExponential(validation.rootMeanSquaredError)} | ${formatExponential(validation.maxAbsoluteError)} | ${validation.sampleCount ?? '-'} |`;
459
+ });
460
+ const profileSections = report.results
461
+ .filter((result) => result.executionProfile)
462
+ .map((result) => {
463
+ const hotLayers = [...result.executionProfile.layers]
464
+ .sort((left, right) => right.durationMs - left.durationMs)
465
+ .slice(0, 5)
466
+ .map((layer) => `| ${layer.id} | ${formatNumber(layer.durationMs)} |`)
467
+ .join('\n');
468
+ return `### ${result.label}\n\n` +
469
+ `Profiled GPU total: ${formatNumber(result.executionProfile.totalMs)} ms\n\n` +
470
+ `| Node | GPU ms |\n| --- | ---: |\n${hotLayers}`;
471
+ });
472
+ return `# oidn-web benchmark\n\n` +
473
+ `- Current: \`${report.currentCommit}\`\n` +
474
+ `- TFJS baseline: \`${report.baselineCommit}\`\n` +
475
+ `- Input: ${report.settings.width}x${report.settings.height}, fixed tile ${report.settings.tileSize}, ${report.settings.runs} runs after ${report.settings.warmup} warmup(s)\n` +
476
+ `- Adapter: ${report.adapter.description || report.adapter.device || report.adapter.vendor || 'unknown'}\n\n` +
477
+ `| Runtime | Init ms | Median ms | P95 ms | Speedup | Precision |\n` +
478
+ `| --- | ---: | ---: | ---: | ---: | --- |\n${rows.join('\n')}\n\n` +
479
+ `## Output validation\n\n` +
480
+ `Compared against sampled TFJS FP32 output.\n\n` +
481
+ `| Runtime | Status | MAE | RMSE | Max error | Samples |\n` +
482
+ `| --- | --- | ---: | ---: | ---: | ---: |\n${validationRows.join('\n')}\n\n` +
483
+ `## GPU hot layers\n\n${profileSections.join('\n\n') || 'Timestamp queries unavailable.'}\n`;
484
+ }
485
+
486
+ async function main() {
487
+ const options = parseArgs(process.argv.slice(2));
488
+ if (options.help) {
489
+ printHelp();
490
+ return;
491
+ }
492
+ const chrome = options.chrome || defaultChrome();
493
+ if (!chrome) throw new Error('Chrome/Chromium not found; pass --chrome or CHROME_PATH');
494
+ const baselineCommit = await findTFJSBaseline(options.baseline);
495
+ const currentCommit = await gitText(['rev-parse', 'HEAD']);
496
+ console.log(`TFJS baseline: ${baselineCommit.slice(0, 12)}`);
497
+ console.log(`Current runtime: ${currentCommit.slice(0, 12)} + working tree`);
498
+
499
+ await run('npm', ['run', 'build']);
500
+ const temporaryRoot = await mkdtemp(path.join(os.tmpdir(), 'oidn-benchmark-'));
501
+ const baselineRoot = path.join(temporaryRoot, 'baseline');
502
+ let worktreeAdded = false;
503
+ let server;
504
+ let browser;
505
+ try {
506
+ // The repository stores model files through Git LFS, but the benchmark
507
+ // serves the current checkout's weights. Disable LFS filters for this
508
+ // source-only historical worktree so git-lfs is not a prerequisite.
509
+ await run('git', [
510
+ '-c', 'filter.lfs.smudge=',
511
+ '-c', 'filter.lfs.process=',
512
+ '-c', 'filter.lfs.required=false',
513
+ '-c', 'core.hooksPath=/dev/null',
514
+ 'worktree', 'add', '--detach', baselineRoot, baselineCommit
515
+ ]);
516
+ worktreeAdded = true;
517
+ await run('npm', ['ci', '--ignore-scripts'], { cwd: baselineRoot });
518
+ await run('npm', ['run', 'build'], { cwd: baselineRoot });
519
+
520
+ server = await startServer(new Map([
521
+ ['/current/oidn.js', path.join(projectRoot, 'dist/oidn.js')],
522
+ ['/baseline/oidn.js', path.join(baselineRoot, 'dist/oidn.js')],
523
+ ['/weights/rt_hdr_calb_cnrm_large.tza', path.join(projectRoot, 'weights/rt_hdr_calb_cnrm_large.tza')]
524
+ ]));
525
+ browser = await chromium.launch({
526
+ executablePath: chrome,
527
+ headless: true,
528
+ args: [
529
+ '--enable-unsafe-webgpu',
530
+ '--enable-features=Vulkan,UseSkiaRenderer,WebMachineLearningNeuralNetwork'
531
+ ]
532
+ });
533
+ const variants = [
534
+ { label: `TFJS (${baselineCommit.slice(0, 7)})`, bundle: 'baseline', baseline: true },
535
+ { label: 'WGSL FP32 Auto', bundle: 'current', baseline: false, precision: 'fp32' },
536
+ {
537
+ label: 'WGSL FP32 Direct',
538
+ bundle: 'current',
539
+ baseline: false,
540
+ precision: 'fp32',
541
+ kernel: 'direct'
542
+ },
543
+ {
544
+ label: 'WGSL FP16 Direct',
545
+ bundle: 'current',
546
+ baseline: false,
547
+ precision: 'fp16',
548
+ kernel: 'direct'
549
+ },
550
+ {
551
+ label: 'WGSL FP16 Implicit GEMM',
552
+ bundle: 'current',
553
+ baseline: false,
554
+ precision: 'fp16',
555
+ kernel: 'implicit-gemm'
556
+ },
557
+ {
558
+ label: 'WGSL FP16 Spatial (experimental)',
559
+ bundle: 'current',
560
+ baseline: false,
561
+ precision: 'fp16',
562
+ kernel: 'spatial'
563
+ },
564
+ {
565
+ label: 'WGSL FP16 Subgroup (experimental)',
566
+ bundle: 'current',
567
+ baseline: false,
568
+ precision: 'fp16',
569
+ kernel: 'subgroup'
570
+ },
571
+ {
572
+ label: 'WebNN FP16 (experimental)',
573
+ bundle: 'current',
574
+ baseline: false,
575
+ precision: 'fp16',
576
+ engine: 'webnn'
577
+ }
578
+ ];
579
+ const results = [];
580
+ for (const variant of variants) {
581
+ console.log(`Benchmarking ${variant.label}...`);
582
+ results.push(await benchmarkVariant(browser, server.origin, options, variant));
583
+ }
584
+ const referenceSamples = results.find((result) => result.baseline)?.outputSamples;
585
+ for (const result of results) {
586
+ if (!result.baseline && !result.skipped) {
587
+ const comparison = compareSamples(referenceSamples, result.outputSamples);
588
+ const maxMeanError = result.precision === 'fp16' ? 5e-3 : 1e-4;
589
+ const maxAbsoluteError = result.precision === 'fp16' ? 5e-2 : 1e-3;
590
+ const hasComparableOutput =
591
+ Number.isFinite(comparison.meanAbsoluteError) &&
592
+ Number.isFinite(comparison.maxAbsoluteError);
593
+ result.validation = {
594
+ ...comparison,
595
+ passed:
596
+ hasComparableOutput &&
597
+ comparison.meanAbsoluteError <= maxMeanError &&
598
+ comparison.maxAbsoluteError <= maxAbsoluteError,
599
+ thresholds: { maxMeanError, maxAbsoluteError }
600
+ };
601
+ }
602
+ delete result.outputSamples;
603
+ }
604
+ const firstCompleted = results.find((result) => !result.skipped);
605
+ const report = {
606
+ generatedAt: new Date().toISOString(),
607
+ currentCommit,
608
+ baselineCommit,
609
+ settings: {
610
+ width: options.width,
611
+ height: options.height,
612
+ tileSize: options.tileSize,
613
+ warmup: options.warmup,
614
+ runs: options.runs,
615
+ model: 'rt_hdr_calb_cnrm_large.tza',
616
+ gpuQueueCompletionIncluded: true
617
+ },
618
+ browser: chrome,
619
+ adapter: firstCompleted?.adapter ?? {},
620
+ results
621
+ };
622
+ const markdown = markdownReport(report);
623
+ await mkdir(path.dirname(options.output), { recursive: true });
624
+ await writeFile(options.output, JSON.stringify(report, null, 2));
625
+ const markdownPath = options.output.endsWith('.json')
626
+ ? options.output.slice(0, -5) + '.md'
627
+ : options.output + '.md';
628
+ await writeFile(markdownPath, markdown);
629
+ console.log(`\n${markdown}`);
630
+ console.log(`JSON: ${options.output}`);
631
+ console.log(`Markdown: ${markdownPath}`);
632
+ const failedValidation = results.find(
633
+ (result) => result.validation && !result.validation.passed
634
+ );
635
+ if (failedValidation) {
636
+ throw new Error(`${failedValidation.label} output validation failed`);
637
+ }
638
+ } finally {
639
+ await browser?.close();
640
+ await server?.close();
641
+ if (worktreeAdded) {
642
+ await run('git', ['worktree', 'remove', '--force', baselineRoot], { quiet: true });
643
+ }
644
+ await rm(temporaryRoot, { recursive: true, force: true });
645
+ }
646
+ }
647
+
648
+ main().catch((error) => {
649
+ console.error(error instanceof Error ? error.stack : error);
650
+ process.exitCode = 1;
651
+ });