oidn-webgpu 0.0.0-stage → 0.6.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 (95) hide show
  1. package/CHANGELOG.md +182 -0
  2. package/LICENSE +21 -0
  3. package/README.md +89 -2
  4. package/dist/oidn.js +6140 -0
  5. package/dist/oidn.umd.cjs +1513 -0
  6. package/lib/UNet.d.ts +161 -0
  7. package/lib/UNet.js +536 -0
  8. package/lib/UNet.js.map +1 -0
  9. package/lib/WGPUComputePass.d.ts +62 -0
  10. package/lib/WGPUComputePass.js +278 -0
  11. package/lib/WGPUComputePass.js.map +1 -0
  12. package/lib/WGPUFullQuadPass.d.ts +50 -0
  13. package/lib/WGPUFullQuadPass.js +261 -0
  14. package/lib/WGPUFullQuadPass.js.map +1 -0
  15. package/lib/backend.d.ts +1 -0
  16. package/lib/backend.js +39 -0
  17. package/lib/backend.js.map +1 -0
  18. package/lib/finalRgbShader.d.ts +13 -0
  19. package/lib/finalRgbShader.js +160 -0
  20. package/lib/finalRgbShader.js.map +1 -0
  21. package/lib/gpuTypes.d.ts +158 -0
  22. package/lib/gpuTypes.js +27 -0
  23. package/lib/gpuTypes.js.map +1 -0
  24. package/lib/graphOptimizer.d.ts +54 -0
  25. package/lib/graphOptimizer.js +216 -0
  26. package/lib/graphOptimizer.js.map +1 -0
  27. package/lib/hdr.d.ts +31 -0
  28. package/lib/hdr.js +340 -0
  29. package/lib/hdr.js.map +1 -0
  30. package/lib/hdrTransfer.d.ts +22 -0
  31. package/lib/hdrTransfer.js +128 -0
  32. package/lib/hdrTransfer.js.map +1 -0
  33. package/lib/main.d.ts +57 -0
  34. package/lib/main.js +24 -0
  35. package/lib/main.js.map +1 -0
  36. package/lib/modelSpec.d.ts +80 -0
  37. package/lib/modelSpec.js +270 -0
  38. package/lib/modelSpec.js.map +1 -0
  39. package/lib/native/denoise.d.ts +25 -0
  40. package/lib/native/denoise.js +223 -0
  41. package/lib/native/denoise.js.map +1 -0
  42. package/lib/native/program.d.ts +53 -0
  43. package/lib/native/program.js +310 -0
  44. package/lib/native/program.js.map +1 -0
  45. package/lib/native/recordingDevice.d.ts +125 -0
  46. package/lib/native/recordingDevice.js +247 -0
  47. package/lib/native/recordingDevice.js.map +1 -0
  48. package/lib/nativeUNet.d.ts +25 -0
  49. package/lib/nativeUNet.js +184 -0
  50. package/lib/nativeUNet.js.map +1 -0
  51. package/lib/nativeUNetCore.d.ts +202 -0
  52. package/lib/nativeUNetCore.js +2945 -0
  53. package/lib/nativeUNetCore.js.map +1 -0
  54. package/lib/process.d.ts +37 -0
  55. package/lib/process.js +328 -0
  56. package/lib/process.js.map +1 -0
  57. package/lib/resourceTracker.d.ts +26 -0
  58. package/lib/resourceTracker.js +65 -0
  59. package/lib/resourceTracker.js.map +1 -0
  60. package/lib/sanitize.d.ts +15 -0
  61. package/lib/sanitize.js +28 -0
  62. package/lib/sanitize.js.map +1 -0
  63. package/lib/tileScheduler.d.ts +61 -0
  64. package/lib/tileScheduler.js +199 -0
  65. package/lib/tileScheduler.js.map +1 -0
  66. package/lib/tza.d.ts +15 -0
  67. package/lib/tza.js +114 -0
  68. package/lib/tza.js.map +1 -0
  69. package/lib/webnnUNet.d.ts +52 -0
  70. package/lib/webnnUNet.js +535 -0
  71. package/lib/webnnUNet.js.map +1 -0
  72. package/package.json +43 -4
  73. package/src/UNet.ts +861 -0
  74. package/src/WGPUComputePass.ts +382 -0
  75. package/src/WGPUFullQuadPass.ts +348 -0
  76. package/src/backend.ts +42 -0
  77. package/src/finalRgbShader.ts +186 -0
  78. package/src/gpuTypes.ts +179 -0
  79. package/src/graphOptimizer.ts +301 -0
  80. package/src/hdr.ts +398 -0
  81. package/src/hdrTransfer.ts +169 -0
  82. package/src/main.ts +126 -0
  83. package/src/modelSpec.ts +414 -0
  84. package/src/native/core-profile.json +40 -0
  85. package/src/native/denoise.ts +268 -0
  86. package/src/native/program.ts +326 -0
  87. package/src/native/recordingDevice.ts +341 -0
  88. package/src/nativeUNet.ts +269 -0
  89. package/src/nativeUNetCore.ts +3735 -0
  90. package/src/process.ts +368 -0
  91. package/src/resourceTracker.ts +94 -0
  92. package/src/sanitize.ts +29 -0
  93. package/src/tileScheduler.ts +330 -0
  94. package/src/tza.ts +136 -0
  95. package/src/webnnUNet.ts +812 -0
@@ -0,0 +1,3735 @@
1
+ import {
2
+ BufferUsage,
3
+ MapMode,
4
+ type OIDNGPUAdapterInfo,
5
+ type OIDNGPUBindGroup,
6
+ type OIDNGPUBindGroupEntry,
7
+ type OIDNGPUBuffer,
8
+ type OIDNGPUComputePipeline,
9
+ type OIDNGPUDevice,
10
+ type OIDNGPUQuerySet
11
+ } from './gpuTypes.js';
12
+ import { createFinalRgbShader, sharedMemoryBytes as finalRgbSharedMemoryBytes } from './finalRgbShader.js';
13
+ import {
14
+ optimizeModelGraph,
15
+ planModelExecution,
16
+ type ExecutableModelNode,
17
+ type FusedUpsampleConcatConvNodeSpec,
18
+ type ModelExecutionPlan,
19
+ type ModelValueShape
20
+ } from './graphOptimizer.js';
21
+ import type {
22
+ Conv2DNodeSpec,
23
+ UNetModelGraph,
24
+ ValidatedConvTensor,
25
+ ValidatedUNetModel
26
+ } from './modelSpec';
27
+ import { HostTensor } from './tza.js';
28
+ import {
29
+ OIDNResourceTracker,
30
+ type OIDNResourceSnapshot
31
+ } from './resourceTracker.js';
32
+
33
+ export type NativeUNetPrecision = 'fp32' | 'fp16';
34
+ export type NativeUNetPrecisionSetting = NativeUNetPrecision | 'auto';
35
+ export type NativeUNetKernel =
36
+ | 'direct'
37
+ | 'implicit-gemm'
38
+ | 'spatial'
39
+ | 'subgroup'
40
+ | 'subgroup-matrix';
41
+ export type NativeUNetKernelSetting = NativeUNetKernel | 'auto';
42
+ export type NativeUNetGemmWorkgroup = readonly [4 | 8 | 16, 4 | 8];
43
+
44
+ export interface NativeUNetGemmOptions {
45
+ /** Internal per-layer tile selection; explicit workgroup sizes remain fixed by default. */
46
+ tilePolicy?: 'fixed' | 'output-aligned';
47
+ /** Internal decoder source-branch scheduling experiment. */
48
+ decoderLoad?: 'per-load' | 'source-first';
49
+ /** Sub-pixel GEMM decoder experiment; defaults to FP16 with K-major weights. */
50
+ decoderSubPixel?: boolean;
51
+ /** Expand register-tile rows for native shader robustness; off by default in browsers. */
52
+ unrollRows?: boolean;
53
+ /** Internal pooling dispatch experiment for the GEMM execution path. */
54
+ poolLayout?: 'spatial' | 'channels';
55
+ /** Internal shared-memory padding experiment; does not change storage ABI. */
56
+ sharedLayout?: 'linear' | 'padded' | 'padded-input' | 'padded-weights';
57
+ /** Internal loop scheduling experiment; each output keeps the same K order. */
58
+ accumulationOrder?: 'k-major' | 'row-major';
59
+ /** Internal final-convolution experiments; retains the original FP16 partial order. */
60
+ finalLayer?: 'direct' | 'shared-input' | 'shared-input-weights' | 'shared-auto';
61
+ /** Internal load-layout experiments; packed loads preserve the FP16 storage bits. */
62
+ loadMode?: 'native' | 'packed-weights' | 'packed-all';
63
+ /** Incremental addressing (default), or the original analytic path for A/B comparisons. */
64
+ addressMode?: 'analytic' | 'incremental' | 'base-offset';
65
+ /** K-major (default) coalesces neighboring output-block loads; output-major preserves the old ABI. */
66
+ weightLayout?: 'output-major' | 'k-major';
67
+ /** Register tile height (default 8). The K reduction grouping stays fixed. */
68
+ rowsPerThread?: 2 | 4 | 8;
69
+ /** Workgroup width/height (default [8, 8]); width also selects output channel blocks. */
70
+ workgroupSize?: NativeUNetGemmWorkgroup;
71
+ }
72
+
73
+ /** Square multiply-add shape selected from `chromium-experimental-subgroup-matrix`. */
74
+ export interface NativeSubgroupMatrixConfig {
75
+ componentType: 'f16' | 'f32';
76
+ resultType: 'f16' | 'f32';
77
+ /** M = N = K. */
78
+ size: 8 | 16;
79
+ subgroupSize: number;
80
+ /**
81
+ * WGSL spelling: Chrome's `chromium_experimental_subgroup_matrix`, or
82
+ * wgpu/Naga's `wgpu_cooperative_matrix` for native hosts.
83
+ */
84
+ dialect: 'tint' | 'naga';
85
+ }
86
+
87
+ /** Experimental subgroup-matrix tile tuning; not covered by semver. */
88
+ export interface NativeSubgroupMatrixOptions {
89
+ /** Output channel fragments per workgroup (default 2). */
90
+ outputFragments?: 1 | 2 | 4;
91
+ /** Preferred accumulation type when the adapter offers several. */
92
+ accumulation?: 'f32' | 'f16';
93
+ /** Sub-pixel decomposition of the upsampling decoder layers (default true). */
94
+ subPixel?: boolean;
95
+ /**
96
+ * Double-buffered staging with register prefetch when the workgroup memory
97
+ * limit allows it (default false: 6-10% slower on an Apple M4 Max).
98
+ */
99
+ doubleBuffer?: boolean;
100
+ }
101
+
102
+ export interface NativeUNetOptions {
103
+ precision?: NativeUNetPrecisionSetting;
104
+ /** Experimental `subgroup-matrix` kernel tuning. */
105
+ subgroupMatrix?: NativeSubgroupMatrixOptions;
106
+ /**
107
+ * Convolution kernel selection. `auto` uses implicit GEMM for FP16/FP32
108
+ * convolutions and the direct kernel for the final output layer.
109
+ */
110
+ kernel?: NativeUNetKernelSetting;
111
+ /** Optional implicit-GEMM tuning; other convolution kernels ignore it. */
112
+ gemm?: NativeUNetGemmOptions;
113
+ /** Maximum number of shape-dependent activation plans retained. */
114
+ shapeCacheSize?: number;
115
+ }
116
+
117
+ export interface NativeUNetLayerTiming {
118
+ id: string;
119
+ durationMs: number;
120
+ }
121
+
122
+ export interface NativeUNetExecutionProfile {
123
+ totalMs: number;
124
+ layers: NativeUNetLayerTiming[];
125
+ }
126
+
127
+ interface PackedConvBuffers {
128
+ weights: OIDNGPUBuffer;
129
+ bias: OIDNGPUBuffer;
130
+ weightLayout: NonNullable<NativeUNetGemmOptions['weightLayout']>;
131
+ }
132
+
133
+ export interface NativePipelineSpec {
134
+ key: string;
135
+ code: string;
136
+ kernel?: NativeUNetKernel;
137
+ }
138
+
139
+ interface ActivationSlot {
140
+ buffer: OIDNGPUBuffer;
141
+ capacity: number;
142
+ }
143
+
144
+ export interface CachedExecution {
145
+ plan: ModelExecutionPlan;
146
+ valueBuffers: Map<string, OIDNGPUBuffer>;
147
+ slots: ActivationSlot[];
148
+ nodeBindings: OIDNGPUBindGroup[];
149
+ nodePipelines: OIDNGPUComputePipeline[];
150
+ nodeKernels: NativeUNetKernel[];
151
+ /** Subgroup-matrix output fragments per node (0 for other kernels). */
152
+ nodeFragments: number[];
153
+ inputPipeline: OIDNGPUComputePipeline;
154
+ inputUniform: OIDNGPUBuffer;
155
+ ownedBuffers: OIDNGPUBuffer[];
156
+ cpuInputBuffers?: OIDNGPUBuffer[];
157
+ cpuReadbackBuffer?: OIDNGPUBuffer;
158
+ lastUsed: number;
159
+ }
160
+
161
+ const pipelineCachesByDevice = new WeakMap<OIDNGPUDevice, Map<string, OIDNGPUComputePipeline>>();
162
+
163
+ function sharedPipelineCache(device: OIDNGPUDevice) {
164
+ let cache = pipelineCachesByDevice.get(device);
165
+ if (!cache) {
166
+ cache = new Map();
167
+ pipelineCachesByDevice.set(device, cache);
168
+ }
169
+ return cache;
170
+ }
171
+
172
+ const WORKGROUP_SIZE = 8;
173
+ const TILED_CONV_K_BLOCKS = 8;
174
+ const SPATIAL_CONV_WORKGROUP = 8;
175
+ const SPATIAL_CONV_PATCH = SPATIAL_CONV_WORKGROUP + 2;
176
+
177
+ function gemmTile(options: Readonly<Required<NativeUNetGemmOptions>>) {
178
+ const [workgroupX, workgroupY] = options.workgroupSize;
179
+ return {
180
+ workgroupX,
181
+ workgroupY,
182
+ rowsPerThread: options.rowsPerThread,
183
+ tileM: workgroupY * options.rowsPerThread,
184
+ tileNBlocks: workgroupX,
185
+ // Keep the half partial accumulation grouping independent of tile tuning.
186
+ tileKBlocks: TILED_CONV_K_BLOCKS
187
+ };
188
+ }
189
+
190
+ // Holes change only workgroup addresses. Cooperative loads still visit the
191
+ // original dense logical tiles and never read the unused padding elements.
192
+ function gemmSharedSizes(options: Readonly<Required<NativeUNetGemmOptions>>) {
193
+ const tile = gemmTile(options);
194
+ const paddedInput = options.sharedLayout === 'padded' || options.sharedLayout === 'padded-input';
195
+ const paddedWeights = options.sharedLayout === 'padded' || options.sharedLayout === 'padded-weights';
196
+ return {
197
+ input: tile.tileM * tile.tileKBlocks + (paddedInput ? tile.workgroupY : 0),
198
+ weights: tile.tileKBlocks * tile.tileNBlocks * (paddedWeights ? 5 : 4)
199
+ };
200
+ }
201
+
202
+ function gemmSharedInputIndex(index: string, options: Readonly<Required<NativeUNetGemmOptions>>) {
203
+ return options.sharedLayout === 'padded' || options.sharedLayout === 'padded-input'
204
+ ? `(${index}) + (${index}) / ${options.rowsPerThread * TILED_CONV_K_BLOCKS}u`
205
+ : index;
206
+ }
207
+
208
+ function gemmSharedWeightIndex(index: string, options: Readonly<Required<NativeUNetGemmOptions>>) {
209
+ return options.sharedLayout === 'padded' || options.sharedLayout === 'padded-weights'
210
+ ? `(${index}) + (${index}) / 4u`
211
+ : index;
212
+ }
213
+
214
+ function roundUp(value: number, alignment: number) {
215
+ return Math.ceil(value / alignment) * alignment;
216
+ }
217
+
218
+ function blocksForChannels(channels: number) {
219
+ return Math.ceil(channels / 4);
220
+ }
221
+
222
+ function activationByteSize(
223
+ shape: ModelValueShape,
224
+ bytesPerScalar: number
225
+ ) {
226
+ return (
227
+ shape.width *
228
+ shape.height *
229
+ blocksForChannels(shape.channels) *
230
+ 4 *
231
+ bytesPerScalar
232
+ );
233
+ }
234
+
235
+ let float16ToFloat32Lookup: Float32Array | undefined;
236
+
237
+ function halfBitsToNumber(bits: number) {
238
+ const sign = bits & 0x8000 ? -1 : 1;
239
+ const exponent = (bits >>> 10) & 0x1f;
240
+ const fraction = bits & 0x3ff;
241
+ if (exponent === 0) {
242
+ return sign * fraction * 2 ** -24;
243
+ }
244
+ if (exponent === 0x1f) {
245
+ return fraction === 0 ? sign * Infinity : NaN;
246
+ }
247
+ return sign * (1 + fraction / 1024) * 2 ** (exponent - 15);
248
+ }
249
+
250
+ const halfConversionView = new DataView(new ArrayBuffer(4));
251
+
252
+ /** IEEE binary16 bits of a number, rounding to nearest even like Float16Array. */
253
+ function numberToHalfBits(value: number) {
254
+ halfConversionView.setFloat32(0, value);
255
+ const bits = halfConversionView.getUint32(0);
256
+ const sign = (bits >>> 16) & 0x8000;
257
+ const exponent = (bits >>> 23) & 0xff;
258
+ const mantissa = bits & 0x7fffff;
259
+ if (exponent === 0xff) {
260
+ return sign | 0x7c00 | (mantissa ? 0x200 : 0);
261
+ }
262
+ // Rounds through float32 first; this matches direct rounding except for rare
263
+ // double-rounding ties. Official OIDN weights are already binary16.
264
+ const halfExponent = exponent - 127 + 15;
265
+ if (halfExponent >= 0x1f) return sign | 0x7c00;
266
+ if (halfExponent <= 0) {
267
+ if (halfExponent < -10) return sign;
268
+ const full = mantissa | 0x800000;
269
+ const shift = 14 - halfExponent;
270
+ const half = full >>> shift;
271
+ const remainder = full & ((1 << shift) - 1);
272
+ const midpoint = 1 << (shift - 1);
273
+ const rounded = remainder > midpoint || (remainder === midpoint && (half & 1)) ? half + 1 : half;
274
+ return sign | rounded;
275
+ }
276
+ const half = (halfExponent << 10) | (mantissa >>> 13);
277
+ const remainder = mantissa & 0x1fff;
278
+ // A carry out of the mantissa correctly increments the exponent.
279
+ return sign | (remainder > 0x1000 || (remainder === 0x1000 && (half & 1)) ? half + 1 : half);
280
+ }
281
+
282
+ function halfLookup() {
283
+ if (!float16ToFloat32Lookup) {
284
+ float16ToFloat32Lookup = new Float32Array(1 << 16);
285
+ for (let bits = 0; bits < float16ToFloat32Lookup.length; bits++) {
286
+ float16ToFloat32Lookup[bits] = halfBitsToNumber(bits);
287
+ }
288
+ }
289
+ return float16ToFloat32Lookup;
290
+ }
291
+
292
+ function tensorFloat32Values(tensor: HostTensor): Float32Array {
293
+ if (tensor.desc.dataType === 'Float32') {
294
+ return new Float32Array(
295
+ tensor.data.buffer,
296
+ tensor.data.byteOffset,
297
+ tensor.data.byteLength / 4
298
+ );
299
+ }
300
+ const bits = new Uint16Array(
301
+ tensor.data.buffer,
302
+ tensor.data.byteOffset,
303
+ tensor.data.byteLength / 2
304
+ );
305
+ const values = new Float32Array(bits.length);
306
+ if (bits.length < 4096) {
307
+ for (let index = 0; index < bits.length; index++) {
308
+ values[index] = halfBitsToNumber(bits[index]);
309
+ }
310
+ } else {
311
+ const lookup = halfLookup();
312
+ for (let index = 0; index < bits.length; index++) {
313
+ values[index] = lookup[bits[index]];
314
+ }
315
+ }
316
+ return values;
317
+ }
318
+
319
+ function createMappedBuffer(
320
+ device: OIDNGPUDevice,
321
+ label: string,
322
+ data: Uint8Array,
323
+ usage: number
324
+ ) {
325
+ const size = roundUp(data.byteLength, 4);
326
+ const buffer = device.createBuffer({
327
+ label,
328
+ size,
329
+ usage,
330
+ mappedAtCreation: true
331
+ });
332
+ new Uint8Array(buffer.getMappedRange()).set(data);
333
+ buffer.unmap();
334
+ return buffer;
335
+ }
336
+
337
+ function createUniformBuffer(
338
+ device: OIDNGPUDevice,
339
+ label: string,
340
+ values: readonly number[]
341
+ ) {
342
+ const data = new Uint32Array(roundUp(values.length, 4));
343
+ data.set(values);
344
+ return createMappedBuffer(
345
+ device,
346
+ label,
347
+ new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
348
+ BufferUsage.UNIFORM
349
+ );
350
+ }
351
+
352
+ function packConvTensors(
353
+ device: OIDNGPUDevice,
354
+ id: string,
355
+ tensors: ValidatedConvTensor,
356
+ precision: NativeUNetPrecision,
357
+ weightLayout: NonNullable<NativeUNetGemmOptions['weightLayout']>
358
+ ): PackedConvBuffers {
359
+ const inputBlocks = blocksForChannels(tensors.inputChannels);
360
+ const outputBlocks = blocksForChannels(tensors.outputChannels);
361
+ const packedWeightCount =
362
+ outputBlocks *
363
+ tensors.kernelHeight *
364
+ tensors.kernelWidth *
365
+ inputBlocks *
366
+ 4 *
367
+ 4;
368
+ const canCopyHalfBits =
369
+ precision === 'fp16' && tensors.weight.desc.dataType === 'Float16';
370
+ const halfWeights = precision === 'fp16'
371
+ ? new Uint16Array(packedWeightCount)
372
+ : undefined;
373
+ const floatWeights = halfWeights
374
+ ? undefined
375
+ : new Float32Array(packedWeightCount);
376
+ const sourceHalfBits = canCopyHalfBits
377
+ ? new Uint16Array(
378
+ tensors.weight.data.buffer,
379
+ tensors.weight.data.byteOffset,
380
+ tensors.weight.data.byteLength / 2
381
+ )
382
+ : undefined;
383
+ const sourceValues = sourceHalfBits
384
+ ? undefined
385
+ : tensorFloat32Values(tensors.weight);
386
+
387
+ for (let outputBlock = 0; outputBlock < outputBlocks; outputBlock++) {
388
+ for (let y = 0; y < tensors.kernelHeight; y++) {
389
+ for (let x = 0; x < tensors.kernelWidth; x++) {
390
+ for (let inputBlock = 0; inputBlock < inputBlocks; inputBlock++) {
391
+ for (let outputLane = 0; outputLane < 4; outputLane++) {
392
+ const outputChannel = outputBlock * 4 + outputLane;
393
+ for (let inputLane = 0; inputLane < 4; inputLane++) {
394
+ const inputChannel = inputBlock * 4 + inputLane;
395
+ const packedBlock = weightLayout === 'k-major'
396
+ ? ((((y * tensors.kernelWidth + x) * inputBlocks + inputBlock) *
397
+ outputBlocks + outputBlock) * 16)
398
+ : ((((outputBlock * tensors.kernelHeight + y) *
399
+ tensors.kernelWidth +
400
+ x) *
401
+ inputBlocks +
402
+ inputBlock) *
403
+ 16);
404
+ // One vec4 contains the four output lanes for an input lane.
405
+ // Both layouts preserve the vec4's output lanes and the
406
+ // convolution's reduction order; only the vec4 address changes.
407
+ const packedIndex =
408
+ packedBlock + inputLane * 4 + outputLane;
409
+ if (
410
+ outputChannel < tensors.outputChannels &&
411
+ inputChannel < tensors.inputChannels
412
+ ) {
413
+ const sourceIndex =
414
+ ((outputChannel * tensors.inputChannels + inputChannel) *
415
+ tensors.kernelHeight +
416
+ y) *
417
+ tensors.kernelWidth +
418
+ x;
419
+ if (halfWeights) {
420
+ halfWeights[packedIndex] = sourceHalfBits
421
+ ? sourceHalfBits[sourceIndex]
422
+ : numberToHalfBits(sourceValues![sourceIndex]);
423
+ } else {
424
+ floatWeights![packedIndex] = sourceValues![sourceIndex];
425
+ }
426
+ }
427
+ }
428
+ }
429
+ }
430
+ }
431
+ }
432
+ }
433
+
434
+ // Bias stays f32 even for half activations because convolution accumulates
435
+ // in f32. Padded lanes are zero and never escape the final three channels.
436
+ const packedBias = new Float32Array(outputBlocks * 4);
437
+ packedBias.set(tensorFloat32Values(tensors.bias));
438
+
439
+ const weights = createMappedBuffer(
440
+ device,
441
+ `oidn/${id}/weights/${precision}/${weightLayout}`,
442
+ halfWeights
443
+ ? new Uint8Array(halfWeights.buffer)
444
+ : new Uint8Array(floatWeights!.buffer),
445
+ BufferUsage.STORAGE
446
+ );
447
+ try {
448
+ return {
449
+ weights,
450
+ weightLayout,
451
+ bias: createMappedBuffer(
452
+ device,
453
+ `oidn/${id}/bias`,
454
+ new Uint8Array(packedBias.buffer),
455
+ BufferUsage.STORAGE
456
+ )
457
+ };
458
+ } catch (error) {
459
+ weights.destroy();
460
+ throw error;
461
+ }
462
+ }
463
+
464
+ function storageVecType(precision: NativeUNetPrecision) {
465
+ return precision === 'fp16' ? 'vec4<f16>' : 'vec4<f32>';
466
+ }
467
+
468
+ function shaderPreamble(precision: NativeUNetPrecision) {
469
+ return precision === 'fp16' ? 'enable f16;\n' : '';
470
+ }
471
+
472
+ function storeExpression(
473
+ expression: string,
474
+ outputPrecision: NativeUNetPrecision
475
+ ) {
476
+ return outputPrecision === 'fp16'
477
+ ? `vec4<f16>(${expression})`
478
+ : expression;
479
+ }
480
+
481
+ function activationExpression(
482
+ expression: string,
483
+ activation: Conv2DNodeSpec['activation']
484
+ ) {
485
+ return activation === 'relu'
486
+ ? `max(${expression}, vec4<f32>(0.0))`
487
+ : expression;
488
+ }
489
+
490
+ function accumulationCode(
491
+ inputExpression: string,
492
+ weightBase: string,
493
+ precision: NativeUNetPrecision
494
+ ) {
495
+ if (precision === 'fp32') {
496
+ return /* wgsl */ `
497
+ let inputValue = vec4<f32>(${inputExpression});
498
+ let weightBase = ${weightBase};
499
+ acc = fma(vec4<f32>(weights[weightBase]), vec4<f32>(inputValue.x), acc);
500
+ acc = fma(vec4<f32>(weights[weightBase + 1u]), vec4<f32>(inputValue.y), acc);
501
+ acc = fma(vec4<f32>(weights[weightBase + 2u]), vec4<f32>(inputValue.z), acc);
502
+ acc = fma(vec4<f32>(weights[weightBase + 3u]), vec4<f32>(inputValue.w), acc);
503
+ `;
504
+ }
505
+ return /* wgsl */ `
506
+ let inputValue = vec4<f16>(${inputExpression});
507
+ let weightBase = ${weightBase};
508
+ var partial = vec4<f16>(0.0h);
509
+ partial = fma(weights[weightBase], vec4<f16>(inputValue.x), partial);
510
+ partial = fma(weights[weightBase + 1u], vec4<f16>(inputValue.y), partial);
511
+ partial = fma(weights[weightBase + 2u], vec4<f16>(inputValue.z), partial);
512
+ partial = fma(weights[weightBase + 3u], vec4<f16>(inputValue.w), partial);
513
+ acc += vec4<f32>(partial);
514
+ `;
515
+ }
516
+
517
+ function subgroupAccumulationCode(
518
+ inputExpression: string,
519
+ weightBase: string,
520
+ precision: NativeUNetPrecision
521
+ ) {
522
+ const inputType = precision === 'fp16' ? 'vec4<f16>' : 'vec4<f32>';
523
+ const accumulator = precision === 'fp16'
524
+ ? `var partial = vec4<f16>(0.0h);
525
+ partial = fma(subgroupBroadcast(weights[weightBase], 0u), ${inputType}(inputValue.x), partial);
526
+ partial = fma(subgroupBroadcast(weights[weightBase + 1u], 0u), ${inputType}(inputValue.y), partial);
527
+ partial = fma(subgroupBroadcast(weights[weightBase + 2u], 0u), ${inputType}(inputValue.z), partial);
528
+ partial = fma(subgroupBroadcast(weights[weightBase + 3u], 0u), ${inputType}(inputValue.w), partial);
529
+ acc += vec4<f32>(partial);`
530
+ : `acc = fma(subgroupBroadcast(weights[weightBase], 0u), vec4<f32>(inputValue.x), acc);
531
+ acc = fma(subgroupBroadcast(weights[weightBase + 1u], 0u), vec4<f32>(inputValue.y), acc);
532
+ acc = fma(subgroupBroadcast(weights[weightBase + 2u], 0u), vec4<f32>(inputValue.z), acc);
533
+ acc = fma(subgroupBroadcast(weights[weightBase + 3u], 0u), vec4<f32>(inputValue.w), acc);`;
534
+ return /* wgsl */ `
535
+ let inputValue = ${inputType}(${inputExpression});
536
+ let weightBase = ${weightBase};
537
+ ${accumulator}
538
+ `;
539
+ }
540
+
541
+ function gemmPackedLoad(
542
+ precision: NativeUNetPrecision,
543
+ gemm: Readonly<Required<NativeUNetGemmOptions>>,
544
+ weights: boolean
545
+ ) {
546
+ return precision === 'fp16' &&
547
+ (gemm.loadMode === 'packed-all' ||
548
+ (weights && gemm.loadMode === 'packed-weights'));
549
+ }
550
+
551
+ // vec2<u32> reinterprets exactly the same eight bytes as vec4<f16>.
552
+ function gemmLoadExpression(expression: string, packed: boolean) {
553
+ return packed ? `bitcast<vec4<f16>>(${expression})` : expression;
554
+ }
555
+
556
+ function tiledAccumulationCode(precision: NativeUNetPrecision, gemm: Readonly<Required<NativeUNetGemmOptions>>) {
557
+ const { rowsPerThread, tileNBlocks, tileKBlocks } = gemmTile(gemm);
558
+ if (!gemm.unrollRows || gemm.accumulationOrder === 'row-major') {
559
+ return rolledAccumulationCode(precision, gemm);
560
+ }
561
+ const weightBase = `(tileK * ${tileNBlocks}u + localId.x) * ${gemm.sharedLayout === 'padded' || gemm.sharedLayout === 'padded-weights' ? 5 : 4}u`;
562
+ // Constant register-tile indices avoid robust dynamic private-array
563
+ // accesses in Naga. Leave the K loop rolled to limit shader/register size;
564
+ // every row keeps the same four FMAs and the same FP16 fold boundaries.
565
+ const rows = Array.from({ length: rowsPerThread }, (_, row) => row);
566
+ const type = storageVecType(precision);
567
+ return /* wgsl */ `
568
+ ${precision === 'fp16' ? rows.map((row) => `var partial${row} = vec4<f16>(0.0h);`).join('\n ') : ''}
569
+ for (var tileK = 0u; tileK < ${tileKBlocks}u; tileK++) {
570
+ let weightBase = ${weightBase};
571
+ let w0 = weightTile[weightBase];
572
+ let w1 = weightTile[weightBase + 1u];
573
+ let w2 = weightTile[weightBase + 2u];
574
+ let w3 = weightTile[weightBase + 3u];
575
+ ${rows.map((row) => {
576
+ const index = gemmSharedInputIndex(`(localId.y * ${rowsPerThread}u + ${row}u) * ${tileKBlocks}u + tileK`, gemm);
577
+ const target = precision === 'fp16' ? `partial${row}` : `acc[${row}]`;
578
+ return `let v${row} = inputTile[${index}];\n ` +
579
+ ['x', 'y', 'z', 'w'].map((lane, k) => `${target} = fma(w${k}, ${type}(v${row}.${lane}), ${target});`).join('\n ');
580
+ }).join('\n ')}
581
+ }
582
+ ${precision === 'fp16' ? rows.map((row) => `acc[${row}] += vec4<f32>(partial${row});`).join('\n ') : ''}
583
+ `;
584
+ }
585
+
586
+ function rolledAccumulationCode(precision: NativeUNetPrecision, gemm: Readonly<Required<NativeUNetGemmOptions>>) {
587
+ const { rowsPerThread, tileNBlocks, tileKBlocks } = gemmTile(gemm);
588
+ const inputIndex = gemmSharedInputIndex(`tileSpatial * ${tileKBlocks}u + tileK`, gemm);
589
+ const weightBase = `(tileK * ${tileNBlocks}u + localId.x) * ${gemm.sharedLayout === 'padded' || gemm.sharedLayout === 'padded-weights' ? 5 : 4}u`;
590
+ if (gemm.accumulationOrder === 'row-major') {
591
+ const valueType = storageVecType(precision);
592
+ const target = precision === 'fp16' ? 'partial' : 'acc[row]';
593
+ return /* wgsl */ `
594
+ for (var row = 0u; row < ${rowsPerThread}u; row++) {
595
+ ${precision === 'fp16' ? 'var partial = vec4<f16>(0.0h);' : ''}
596
+ let tileSpatial = localId.y * ${rowsPerThread}u + row;
597
+ for (var tileK = 0u; tileK < ${tileKBlocks}u; tileK++) {
598
+ let weightBase = ${weightBase};
599
+ let inputValue = inputTile[${inputIndex}];
600
+ ${target} = fma(weightTile[weightBase], ${valueType}(inputValue.x), ${target});
601
+ ${target} = fma(weightTile[weightBase + 1u], ${valueType}(inputValue.y), ${target});
602
+ ${target} = fma(weightTile[weightBase + 2u], ${valueType}(inputValue.z), ${target});
603
+ ${target} = fma(weightTile[weightBase + 3u], ${valueType}(inputValue.w), ${target});
604
+ }
605
+ ${precision === 'fp16' ? 'acc[row] += vec4<f32>(partial);' : ''}
606
+ }
607
+ `;
608
+ }
609
+ if (precision === 'fp16') {
610
+ return /* wgsl */ `
611
+ var partial: array<vec4<f16>, ${rowsPerThread}>;
612
+ for (var row = 0u; row < ${rowsPerThread}u; row++) {
613
+ partial[row] = vec4<f16>(0.0h);
614
+ }
615
+ for (var tileK = 0u; tileK < ${tileKBlocks}u; tileK++) {
616
+ let weightBase =
617
+ ${weightBase};
618
+ for (var row = 0u; row < ${rowsPerThread}u; row++) {
619
+ let tileSpatial =
620
+ localId.y * ${rowsPerThread}u + row;
621
+ let inputValue =
622
+ inputTile[${inputIndex}];
623
+ partial[row] = fma(
624
+ weightTile[weightBase],
625
+ vec4<f16>(inputValue.x),
626
+ partial[row]
627
+ );
628
+ partial[row] = fma(
629
+ weightTile[weightBase + 1u],
630
+ vec4<f16>(inputValue.y),
631
+ partial[row]
632
+ );
633
+ partial[row] = fma(
634
+ weightTile[weightBase + 2u],
635
+ vec4<f16>(inputValue.z),
636
+ partial[row]
637
+ );
638
+ partial[row] = fma(
639
+ weightTile[weightBase + 3u],
640
+ vec4<f16>(inputValue.w),
641
+ partial[row]
642
+ );
643
+ }
644
+ }
645
+ for (var row = 0u; row < ${rowsPerThread}u; row++) {
646
+ acc[row] += vec4<f32>(partial[row]);
647
+ }
648
+ `;
649
+ }
650
+ return /* wgsl */ `
651
+ for (var tileK = 0u; tileK < ${tileKBlocks}u; tileK++) {
652
+ let weightBase =
653
+ ${weightBase};
654
+ for (var row = 0u; row < ${rowsPerThread}u; row++) {
655
+ let tileSpatial =
656
+ localId.y * ${rowsPerThread}u + row;
657
+ let inputValue =
658
+ inputTile[${inputIndex}];
659
+ acc[row] = fma(
660
+ weightTile[weightBase],
661
+ vec4<f32>(inputValue.x),
662
+ acc[row]
663
+ );
664
+ acc[row] = fma(
665
+ weightTile[weightBase + 1u],
666
+ vec4<f32>(inputValue.y),
667
+ acc[row]
668
+ );
669
+ acc[row] = fma(
670
+ weightTile[weightBase + 2u],
671
+ vec4<f32>(inputValue.z),
672
+ acc[row]
673
+ );
674
+ acc[row] = fma(
675
+ weightTile[weightBase + 3u],
676
+ vec4<f32>(inputValue.w),
677
+ acc[row]
678
+ );
679
+ }
680
+ }
681
+ `;
682
+ }
683
+
684
+ function createConvShader(
685
+ precision: NativeUNetPrecision,
686
+ outputPrecision: NativeUNetPrecision,
687
+ activation: Conv2DNodeSpec['activation'],
688
+ inputBlocks: number,
689
+ outputBlocks: number
690
+ ) {
691
+ const inputType = storageVecType(precision);
692
+ const weightType = storageVecType(precision);
693
+ const outputType = storageVecType(outputPrecision);
694
+ const stored = storeExpression(
695
+ activationExpression('acc', activation),
696
+ outputPrecision
697
+ );
698
+
699
+ return /* wgsl */ `${shaderPreamble(precision)}
700
+ struct Params {
701
+ inputWidth: u32,
702
+ inputHeight: u32,
703
+ outputWidth: u32,
704
+ outputHeight: u32,
705
+ inputBlocks: u32,
706
+ outputBlocks: u32,
707
+ }
708
+
709
+ @group(0) @binding(0) var<storage, read> inputData: array<${inputType}>;
710
+ @group(0) @binding(1) var<storage, read> weights: array<${weightType}>;
711
+ @group(0) @binding(2) var<storage, read> bias: array<vec4<f32>>;
712
+ @group(0) @binding(3) var<storage, read_write> outputData: array<${outputType}>;
713
+ @group(0) @binding(4) var<uniform> params: Params;
714
+
715
+ @compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE}, 1)
716
+ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
717
+ if (gid.x >= params.outputWidth || gid.y >= params.outputHeight || gid.z >= ${outputBlocks}u) {
718
+ return;
719
+ }
720
+ var acc = bias[gid.z];
721
+ for (var ky = 0u; ky < 3u; ky++) {
722
+ let inputY = i32(gid.y) + i32(ky) - 1;
723
+ if (inputY < 0 || inputY >= i32(params.inputHeight)) { continue; }
724
+ for (var kx = 0u; kx < 3u; kx++) {
725
+ let inputX = i32(gid.x) + i32(kx) - 1;
726
+ if (inputX < 0 || inputX >= i32(params.inputWidth)) { continue; }
727
+ let pixelBase = (u32(inputY) * params.inputWidth + u32(inputX)) * ${inputBlocks}u;
728
+ for (var inputBlock = 0u; inputBlock < ${inputBlocks}u; inputBlock++) {
729
+ ${accumulationCode(
730
+ 'inputData[pixelBase + inputBlock]',
731
+ `((((gid.z * 3u + ky) * 3u + kx) * ${inputBlocks}u + inputBlock) * 4u)`,
732
+ precision
733
+ )}
734
+ }
735
+ }
736
+ }
737
+ let outputIndex = (gid.y * params.outputWidth + gid.x) * ${outputBlocks}u + gid.z;
738
+ outputData[outputIndex] = ${stored};
739
+ }
740
+ `;
741
+ }
742
+
743
+ /** Direct convolution with subgroup-wide weight broadcast. */
744
+ function createSubgroupConvShader(
745
+ precision: NativeUNetPrecision,
746
+ outputPrecision: NativeUNetPrecision,
747
+ activation: Conv2DNodeSpec['activation'],
748
+ inputBlocks: number,
749
+ outputBlocks: number
750
+ ) {
751
+ const inputType = storageVecType(precision);
752
+ const weightType = storageVecType(precision);
753
+ const outputType = storageVecType(outputPrecision);
754
+ const stored = storeExpression(
755
+ activationExpression('acc', activation),
756
+ outputPrecision
757
+ );
758
+ return /* wgsl */ `${shaderPreamble(precision)}
759
+ enable subgroups;
760
+ struct Params {
761
+ inputWidth: u32,
762
+ inputHeight: u32,
763
+ outputWidth: u32,
764
+ outputHeight: u32,
765
+ inputBlocks: u32,
766
+ outputBlocks: u32,
767
+ }
768
+ @group(0) @binding(0) var<storage, read> inputData: array<${inputType}>;
769
+ @group(0) @binding(1) var<storage, read> weights: array<${weightType}>;
770
+ @group(0) @binding(2) var<storage, read> bias: array<vec4<f32>>;
771
+ @group(0) @binding(3) var<storage, read_write> outputData: array<${outputType}>;
772
+ @group(0) @binding(4) var<uniform> params: Params;
773
+
774
+ @compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE}, 1)
775
+ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
776
+ let outputInBounds =
777
+ gid.x < params.outputWidth && gid.y < params.outputHeight;
778
+ var acc = bias[gid.z];
779
+ for (var ky = 0u; ky < 3u; ky++) {
780
+ let inputY = i32(gid.y) + i32(ky) - 1;
781
+ let clampedY = u32(clamp(inputY, 0, i32(params.inputHeight) - 1));
782
+ for (var kx = 0u; kx < 3u; kx++) {
783
+ let inputX = i32(gid.x) + i32(kx) - 1;
784
+ let clampedX = u32(clamp(inputX, 0, i32(params.inputWidth) - 1));
785
+ let inputInBounds =
786
+ outputInBounds && inputX >= 0 && inputY >= 0 &&
787
+ inputX < i32(params.inputWidth) && inputY < i32(params.inputHeight);
788
+ let pixelBase =
789
+ (clampedY * params.inputWidth + clampedX) * ${inputBlocks}u;
790
+ for (var inputBlock = 0u; inputBlock < ${inputBlocks}u; inputBlock++) {
791
+ ${subgroupAccumulationCode(
792
+ `select(${inputType}(0.0), inputData[pixelBase + inputBlock], inputInBounds)`,
793
+ `((((gid.z * 3u + ky) * 3u + kx) * ${inputBlocks}u + inputBlock) * 4u)`,
794
+ precision
795
+ )}
796
+ }
797
+ }
798
+ }
799
+ if (outputInBounds) {
800
+ let outputIndex =
801
+ (gid.y * params.outputWidth + gid.x) * ${outputBlocks}u + gid.z;
802
+ outputData[outputIndex] = ${stored};
803
+ }
804
+ }
805
+ `;
806
+ }
807
+
808
+ /**
809
+ * A 2D convolution tile which loads the complete 3x3 halo into workgroup
810
+ * memory once. Kernel choice only depends on the operation shape, precision,
811
+ * and device limits; it is deliberately independent of OIDN model names.
812
+ */
813
+ function createSpatialConvShader(
814
+ precision: NativeUNetPrecision,
815
+ outputPrecision: NativeUNetPrecision,
816
+ activation: Conv2DNodeSpec['activation'],
817
+ inputBlocks: number,
818
+ outputBlocks: number
819
+ ) {
820
+ const inputType = storageVecType(precision);
821
+ const outputType = storageVecType(outputPrecision);
822
+ const stored = storeExpression(
823
+ activationExpression('acc', activation),
824
+ outputPrecision
825
+ );
826
+ const patchValues =
827
+ SPATIAL_CONV_PATCH * SPATIAL_CONV_PATCH * inputBlocks;
828
+ const workgroupThreads =
829
+ SPATIAL_CONV_WORKGROUP * SPATIAL_CONV_WORKGROUP;
830
+
831
+ return /* wgsl */ `${shaderPreamble(precision)}
832
+ struct Params {
833
+ inputWidth: u32,
834
+ inputHeight: u32,
835
+ outputWidth: u32,
836
+ outputHeight: u32,
837
+ inputBlocks: u32,
838
+ outputBlocks: u32,
839
+ }
840
+ @group(0) @binding(0) var<storage, read> inputData: array<${inputType}>;
841
+ @group(0) @binding(1) var<storage, read> weights: array<${inputType}>;
842
+ @group(0) @binding(2) var<storage, read> bias: array<vec4<f32>>;
843
+ @group(0) @binding(3) var<storage, read_write> outputData: array<${outputType}>;
844
+ @group(0) @binding(4) var<uniform> params: Params;
845
+
846
+ var<workgroup> inputPatch: array<${inputType}, ${patchValues}>;
847
+
848
+ @compute @workgroup_size(${SPATIAL_CONV_WORKGROUP}, ${SPATIAL_CONV_WORKGROUP}, 1)
849
+ fn main(
850
+ @builtin(local_invocation_id) localId: vec3<u32>,
851
+ @builtin(workgroup_id) workgroupId: vec3<u32>
852
+ ) {
853
+ let localLinear =
854
+ localId.y * ${SPATIAL_CONV_WORKGROUP}u + localId.x;
855
+ for (
856
+ var loadIndex = localLinear;
857
+ loadIndex < ${patchValues}u;
858
+ loadIndex += ${workgroupThreads}u
859
+ ) {
860
+ let patchPixel = loadIndex / ${inputBlocks}u;
861
+ let inputBlock = loadIndex % ${inputBlocks}u;
862
+ let patchX = patchPixel % ${SPATIAL_CONV_PATCH}u;
863
+ let patchY = patchPixel / ${SPATIAL_CONV_PATCH}u;
864
+ let inputX =
865
+ i32(workgroupId.x * ${SPATIAL_CONV_WORKGROUP}u + patchX) - 1;
866
+ let inputY =
867
+ i32(workgroupId.y * ${SPATIAL_CONV_WORKGROUP}u + patchY) - 1;
868
+ var value = ${inputType}(0.0);
869
+ if (
870
+ inputX >= 0 && inputX < i32(params.inputWidth) &&
871
+ inputY >= 0 && inputY < i32(params.inputHeight)
872
+ ) {
873
+ let inputIndex =
874
+ (u32(inputY) * params.inputWidth + u32(inputX)) *
875
+ ${inputBlocks}u + inputBlock;
876
+ value = inputData[inputIndex];
877
+ }
878
+ inputPatch[loadIndex] = value;
879
+ }
880
+ workgroupBarrier();
881
+
882
+ let outputX =
883
+ workgroupId.x * ${SPATIAL_CONV_WORKGROUP}u + localId.x;
884
+ let outputY =
885
+ workgroupId.y * ${SPATIAL_CONV_WORKGROUP}u + localId.y;
886
+ let outputBlock = workgroupId.z;
887
+ if (
888
+ outputX >= params.outputWidth || outputY >= params.outputHeight ||
889
+ outputBlock >= ${outputBlocks}u
890
+ ) {
891
+ return;
892
+ }
893
+
894
+ var acc = bias[outputBlock];
895
+ for (var ky = 0u; ky < 3u; ky++) {
896
+ for (var kx = 0u; kx < 3u; kx++) {
897
+ let patchBase =
898
+ ((localId.y + ky) * ${SPATIAL_CONV_PATCH}u + localId.x + kx) *
899
+ ${inputBlocks}u;
900
+ for (var inputBlock = 0u; inputBlock < ${inputBlocks}u; inputBlock++) {
901
+ ${accumulationCode(
902
+ 'inputPatch[patchBase + inputBlock]',
903
+ `((((outputBlock * 3u + ky) * 3u + kx) * ${inputBlocks}u + inputBlock) * 4u)`,
904
+ precision
905
+ )}
906
+ }
907
+ }
908
+ }
909
+ let outputIndex =
910
+ (outputY * params.outputWidth + outputX) * ${outputBlocks}u + outputBlock;
911
+ outputData[outputIndex] = ${stored};
912
+ }
913
+ `;
914
+ }
915
+
916
+ /** Precompute spatial predicates once, then advance the original flattened K order. */
917
+ function tiledAddressCacheCode(inputBlocks: number, decoder: boolean, gemm: Readonly<Required<NativeUNetGemmOptions>>, sources?: { blocks: readonly number[]; upsampled: 0 | 1 }) {
918
+ const { workgroupX, workgroupY, tileM, tileKBlocks } = gemmTile(gemm);
919
+ const width = decoder ? 'params.outputWidth' : 'params.inputWidth';
920
+ const height = decoder ? 'params.outputHeight' : 'params.inputHeight';
921
+ const baseOffset = gemm.addressMode === 'base-offset';
922
+ // Decoder parity travels in the high mask bits, avoiding extra cached arrays.
923
+ const baseAssignments = sources
924
+ ? sources.blocks.map((blocks, source) =>
925
+ `cachedBase${source}[load] = (${source === sources.upsampled ? 'y / 2u' : 'y'} * params.source${source}Width + ${source === sources.upsampled ? 'x / 2u' : 'x'}) * ${blocks}u;`
926
+ ).join('\n ')
927
+ : `cachedBase0[load] = (y * params.inputWidth + x) * ${inputBlocks}u;`;
928
+ const loads = tileM * tileKBlocks /
929
+ (workgroupX * workgroupY);
930
+ return /* wgsl */ `
931
+ ${baseOffset ? `var cachedBase0: array<u32, ${loads}>;
932
+ ${sources ? `var cachedBase1: array<u32, ${loads}>;` : ''}` : `var cachedX: array<u32, ${loads}>;
933
+ var cachedY: array<u32, ${loads}>;`}
934
+ var cachedMask: array<u32, ${loads}>;
935
+ for (var load = 0u; load < ${loads}u; load++) {
936
+ let loadIndex = localLinear + load * ${workgroupX * workgroupY}u;
937
+ let spatial = workgroupId.x * ${tileM}u + loadIndex / ${tileKBlocks}u;
938
+ let x = spatial % params.outputWidth;
939
+ let y = spatial / params.outputWidth;
940
+ ${baseOffset ? baseAssignments : `cachedX[load] = x;
941
+ cachedY[load] = y;`}
942
+ let columns =
943
+ select(0u, 0x049u, x > 0u && x - 1u < ${width}) |
944
+ select(0u, 0x092u, x < ${width}) |
945
+ select(0u, 0x124u, x + 1u < ${width});
946
+ let rows =
947
+ select(0u, 0x007u, y > 0u && y - 1u < ${height}) |
948
+ select(0u, 0x038u, y < ${height}) |
949
+ select(0u, 0x1c0u, y + 1u < ${height});
950
+ cachedMask[load] = select(0u, columns & rows, spatial < spatialCount)${baseOffset && sources ? ' | ((x & 1u) << 9u) | ((y & 1u) << 10u)' : ''};
951
+ }
952
+ var channelBlock = (localLinear % ${tileKBlocks}u) % ${inputBlocks}u;
953
+ var filterPosition = (localLinear % ${tileKBlocks}u) / ${inputBlocks}u;
954
+ `;
955
+ }
956
+
957
+ function tiledAddressAdvanceCode(inputBlocks: number, gemm: Readonly<Required<NativeUNetGemmOptions>>) {
958
+ const { tileKBlocks } = gemmTile(gemm);
959
+ // The remainder is less than inputBlocks, so at most one carry is needed.
960
+ return /* wgsl */ `
961
+ channelBlock += ${tileKBlocks % inputBlocks}u;
962
+ let carry = channelBlock >= ${inputBlocks}u;
963
+ channelBlock -= select(0u, ${inputBlocks}u, carry);
964
+ filterPosition += ${Math.floor(tileKBlocks / inputBlocks)}u + select(0u, 1u, carry);
965
+ `;
966
+ }
967
+
968
+ function tiledIncrementalInputCode(valueType: string, readValue: string, gemm: Readonly<Required<NativeUNetGemmOptions>>) {
969
+ const { workgroupX, workgroupY, tileM, tileKBlocks } = gemmTile(gemm);
970
+ const loads = tileM * tileKBlocks /
971
+ (workgroupX * workgroupY);
972
+ return /* wgsl */ `
973
+ let inputBlock = channelBlock;
974
+ let kernelY = filterPosition / 3u;
975
+ let kernelX = filterPosition % 3u;
976
+ for (var load = 0u; load < ${loads}u; load++) {
977
+ let loadIndex = localLinear + load * ${workgroupX * workgroupY}u;
978
+ var value = ${valueType}(0.0);
979
+ if (filterPosition < 9u && (cachedMask[load] & (1u << filterPosition)) != 0u) {
980
+ ${gemm.addressMode === 'base-offset' ? '' : `let inputX = i32(cachedX[load]) + i32(kernelX) - 1;
981
+ let inputY = i32(cachedY[load]) + i32(kernelY) - 1;`}
982
+ ${readValue}
983
+ }
984
+ inputTile[${gemmSharedInputIndex('loadIndex', gemm)}] = value;
985
+ }
986
+ `;
987
+ }
988
+
989
+ /**
990
+ * Implicit-GEMM convolution for FP16/FP32. This follows the packed WebGPU
991
+ * shape: configurable spatial rows and vec4 output blocks per workgroup.
992
+ * The eight-vec4 K tile stays fixed to preserve FP16 rounding across variants.
993
+ */
994
+ function createTiledConvShader(
995
+ precision: NativeUNetPrecision,
996
+ outputPrecision: NativeUNetPrecision,
997
+ activation: Conv2DNodeSpec['activation'],
998
+ inputBlocks: number,
999
+ outputBlocks: number,
1000
+ gemm: Readonly<Required<NativeUNetGemmOptions>>
1001
+ ) {
1002
+ const { workgroupX, workgroupY, rowsPerThread, tileM, tileNBlocks, tileKBlocks } = gemmTile(gemm);
1003
+ const { addressMode, weightLayout } = gemm;
1004
+ const inputType = storageVecType(precision);
1005
+ const outputType = storageVecType(outputPrecision);
1006
+ const packedInput = gemmPackedLoad(precision, gemm, false);
1007
+ const packedWeights = gemmPackedLoad(precision, gemm, true);
1008
+ const inputStorageType = packedInput ? 'vec2<u32>' : inputType;
1009
+ const weightStorageType = packedWeights ? 'vec2<u32>' : inputType;
1010
+ const stored = storeExpression(
1011
+ activationExpression('acc', activation),
1012
+ outputPrecision
1013
+ );
1014
+ const workgroupThreads =
1015
+ workgroupX * workgroupY;
1016
+ const inputTileValues = tileM * tileKBlocks;
1017
+ const weightTileValues =
1018
+ tileKBlocks * tileNBlocks * 4;
1019
+ return /* wgsl */ `${shaderPreamble(precision)}
1020
+ struct Params {
1021
+ inputWidth: u32,
1022
+ inputHeight: u32,
1023
+ outputWidth: u32,
1024
+ outputHeight: u32,
1025
+ inputBlocks: u32,
1026
+ outputBlocks: u32,
1027
+ }
1028
+ @group(0) @binding(0) var<storage, read> inputData: array<${inputStorageType}>;
1029
+ @group(0) @binding(1) var<storage, read> weights: array<${weightStorageType}>;
1030
+ @group(0) @binding(2) var<storage, read> bias: array<vec4<f32>>;
1031
+ @group(0) @binding(3) var<storage, read_write> outputData: array<${outputType}>;
1032
+ @group(0) @binding(4) var<uniform> params: Params;
1033
+
1034
+ var<workgroup> inputTile: array<${inputType}, ${gemmSharedSizes(gemm).input}>;
1035
+ var<workgroup> weightTile: array<${inputType}, ${gemmSharedSizes(gemm).weights}>;
1036
+
1037
+ @compute @workgroup_size(${workgroupX}, ${workgroupY}, 1)
1038
+ fn main(
1039
+ @builtin(local_invocation_id) localId: vec3<u32>,
1040
+ @builtin(workgroup_id) workgroupId: vec3<u32>
1041
+ ) {
1042
+ let spatialBase =
1043
+ workgroupId.x * ${tileM}u +
1044
+ localId.y * ${rowsPerThread}u;
1045
+ let outputBlock =
1046
+ workgroupId.y * ${tileNBlocks}u + localId.x;
1047
+ let spatialCount = params.outputWidth * params.outputHeight;
1048
+ var acc: array<vec4<f32>, ${rowsPerThread}>;
1049
+ if (outputBlock < ${outputBlocks}u) {
1050
+ for (var row = 0u; row < ${rowsPerThread}u; row++) {
1051
+ acc[row] = bias[outputBlock];
1052
+ }
1053
+ }
1054
+
1055
+ let localLinear =
1056
+ localId.y * ${workgroupX}u + localId.x;
1057
+ ${addressMode !== 'analytic' ? tiledAddressCacheCode(inputBlocks, false, gemm) : ''}
1058
+ let totalK = ${inputBlocks * 9}u;
1059
+ for (var kBase = 0u; kBase < totalK; kBase += ${tileKBlocks}u) {
1060
+ ${addressMode !== 'analytic' ? tiledIncrementalInputCode(inputType, `
1061
+ ${addressMode === 'base-offset' ? `let offset = (i32(kernelY) - 1) * i32(params.inputWidth) + i32(kernelX) - 1;
1062
+ let inputIndex = cachedBase0[load] + u32(offset * ${inputBlocks}) + inputBlock;` : `let inputIndex = (u32(inputY) * params.inputWidth + u32(inputX)) *
1063
+ ${inputBlocks}u + inputBlock;`}
1064
+ value = ${gemmLoadExpression('inputData[inputIndex]', packedInput)};
1065
+ `, gemm) : /* wgsl */ `
1066
+ for (
1067
+ var loadIndex = localLinear;
1068
+ loadIndex < ${inputTileValues}u;
1069
+ loadIndex += ${workgroupThreads}u
1070
+ ) {
1071
+ let tileSpatial = loadIndex / ${tileKBlocks}u;
1072
+ let tileK = loadIndex % ${tileKBlocks}u;
1073
+ let inputSpatialIndex =
1074
+ workgroupId.x * ${tileM}u + tileSpatial;
1075
+ let kIndex = kBase + tileK;
1076
+ var value = ${inputType}(0.0);
1077
+ if (inputSpatialIndex < spatialCount && kIndex < totalK) {
1078
+ let outputY = inputSpatialIndex / params.outputWidth;
1079
+ let outputX = inputSpatialIndex % params.outputWidth;
1080
+ let inputBlock = kIndex % ${inputBlocks}u;
1081
+ let kernelIndex = kIndex / ${inputBlocks}u;
1082
+ let kernelY = kernelIndex / 3u;
1083
+ let kernelX = kernelIndex % 3u;
1084
+ let inputY = i32(outputY) + i32(kernelY) - 1;
1085
+ let inputX = i32(outputX) + i32(kernelX) - 1;
1086
+ if (
1087
+ inputY >= 0 && inputY < i32(params.inputHeight) &&
1088
+ inputX >= 0 && inputX < i32(params.inputWidth)
1089
+ ) {
1090
+ let inputIndex =
1091
+ (u32(inputY) * params.inputWidth + u32(inputX)) *
1092
+ ${inputBlocks}u + inputBlock;
1093
+ value = ${gemmLoadExpression('inputData[inputIndex]', packedInput)};
1094
+ }
1095
+ }
1096
+ inputTile[${gemmSharedInputIndex('loadIndex', gemm)}] = value;
1097
+ }
1098
+ `}
1099
+
1100
+ for (
1101
+ var loadIndex = localLinear;
1102
+ loadIndex < ${weightTileValues}u;
1103
+ loadIndex += ${workgroupThreads}u
1104
+ ) {
1105
+ let tileK = loadIndex / ${tileNBlocks * 4}u;
1106
+ let outputRemainder = loadIndex % ${tileNBlocks * 4}u;
1107
+ let tileOutputBlock = outputRemainder / 4u;
1108
+ let outputLane = outputRemainder % 4u;
1109
+ let loadedOutputBlock =
1110
+ workgroupId.y * ${tileNBlocks}u + tileOutputBlock;
1111
+ let kIndex = kBase + tileK;
1112
+ var value = ${inputType}(0.0);
1113
+ if (loadedOutputBlock < ${outputBlocks}u && kIndex < totalK) {
1114
+ ${weightLayout === 'k-major' ? /* wgsl */ `
1115
+ let weightIndex = (kIndex * ${outputBlocks}u + loadedOutputBlock) * 4u + outputLane;
1116
+ ` : addressMode !== 'analytic' ? /* wgsl */ `
1117
+ let weightIndex = (loadedOutputBlock * ${inputBlocks * 9}u + kIndex) * 4u + outputLane;
1118
+ ` : /* wgsl */ `
1119
+ let inputBlock = kIndex % ${inputBlocks}u;
1120
+ let kernelIndex = kIndex / ${inputBlocks}u;
1121
+ let kernelY = kernelIndex / 3u;
1122
+ let kernelX = kernelIndex % 3u;
1123
+ let weightIndex =
1124
+ ((((loadedOutputBlock * 3u + kernelY) * 3u + kernelX) *
1125
+ ${inputBlocks}u + inputBlock) * 4u + outputLane);
1126
+ `}
1127
+ value = ${gemmLoadExpression('weights[weightIndex]', packedWeights)};
1128
+ }
1129
+ weightTile[${gemmSharedWeightIndex('loadIndex', gemm)}] = value;
1130
+ }
1131
+
1132
+ workgroupBarrier();
1133
+ ${tiledAccumulationCode(precision, gemm)}
1134
+ workgroupBarrier();
1135
+ ${addressMode !== 'analytic' ? tiledAddressAdvanceCode(inputBlocks, gemm) : ''}
1136
+ }
1137
+
1138
+ if (outputBlock < ${outputBlocks}u) {
1139
+ for (var row = 0u; row < ${rowsPerThread}u; row++) {
1140
+ let spatialIndex = spatialBase + row;
1141
+ if (spatialIndex < spatialCount) {
1142
+ let outputIndex = spatialIndex * ${outputBlocks}u + outputBlock;
1143
+ outputData[outputIndex] = ${stored.replaceAll('acc', 'acc[row]')};
1144
+ }
1145
+ }
1146
+ }
1147
+ }
1148
+ `;
1149
+ }
1150
+
1151
+ function createFusedConvPoolShader(
1152
+ precision: NativeUNetPrecision,
1153
+ activation: Conv2DNodeSpec['activation'],
1154
+ inputBlocks: number,
1155
+ outputBlocks: number
1156
+ ) {
1157
+ const valueType = storageVecType(precision);
1158
+ const activated = activationExpression('acc', activation);
1159
+ return /* wgsl */ `${shaderPreamble(precision)}
1160
+ struct Params {
1161
+ inputWidth: u32,
1162
+ inputHeight: u32,
1163
+ outputWidth: u32,
1164
+ outputHeight: u32,
1165
+ inputBlocks: u32,
1166
+ outputBlocks: u32,
1167
+ }
1168
+ @group(0) @binding(0) var<storage, read> inputData: array<${valueType}>;
1169
+ @group(0) @binding(1) var<storage, read> weights: array<${valueType}>;
1170
+ @group(0) @binding(2) var<storage, read> bias: array<vec4<f32>>;
1171
+ @group(0) @binding(3) var<storage, read_write> outputData: array<${valueType}>;
1172
+ @group(0) @binding(4) var<uniform> params: Params;
1173
+
1174
+ @compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE}, 1)
1175
+ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
1176
+ if (gid.x >= params.outputWidth || gid.y >= params.outputHeight || gid.z >= ${outputBlocks}u) {
1177
+ return;
1178
+ }
1179
+ var pooled = vec4<f32>(-3.402823466e+38);
1180
+ for (var py = 0u; py < 2u; py++) {
1181
+ let centerY = gid.y * 2u + py;
1182
+ if (centerY >= params.inputHeight) { continue; }
1183
+ for (var px = 0u; px < 2u; px++) {
1184
+ let centerX = gid.x * 2u + px;
1185
+ if (centerX >= params.inputWidth) { continue; }
1186
+ var acc = bias[gid.z];
1187
+ for (var ky = 0u; ky < 3u; ky++) {
1188
+ let inputY = i32(centerY) + i32(ky) - 1;
1189
+ if (inputY < 0 || inputY >= i32(params.inputHeight)) { continue; }
1190
+ for (var kx = 0u; kx < 3u; kx++) {
1191
+ let inputX = i32(centerX) + i32(kx) - 1;
1192
+ if (inputX < 0 || inputX >= i32(params.inputWidth)) { continue; }
1193
+ let pixelBase = (u32(inputY) * params.inputWidth + u32(inputX)) * ${inputBlocks}u;
1194
+ for (var inputBlock = 0u; inputBlock < ${inputBlocks}u; inputBlock++) {
1195
+ ${accumulationCode(
1196
+ 'inputData[pixelBase + inputBlock]',
1197
+ `((((gid.z * 3u + ky) * 3u + kx) * ${inputBlocks}u + inputBlock) * 4u)`,
1198
+ precision
1199
+ )}
1200
+ }
1201
+ }
1202
+ }
1203
+ pooled = max(pooled, ${activated});
1204
+ }
1205
+ }
1206
+ let outputIndex = (gid.y * params.outputWidth + gid.x) * ${outputBlocks}u + gid.z;
1207
+ outputData[outputIndex] = ${storeExpression('pooled', precision)};
1208
+ }
1209
+ `;
1210
+ }
1211
+
1212
+ function createMaxPoolShader(
1213
+ precision: NativeUNetPrecision,
1214
+ outputBlocks: number,
1215
+ coalesced: boolean
1216
+ ) {
1217
+ const valueType = storageVecType(precision);
1218
+ return /* wgsl */ `${shaderPreamble(precision)}
1219
+ struct Params {
1220
+ inputWidth: u32,
1221
+ inputHeight: u32,
1222
+ outputWidth: u32,
1223
+ outputHeight: u32,
1224
+ outputBlocks: u32,
1225
+ }
1226
+ @group(0) @binding(0) var<storage, read> inputData: array<${valueType}>;
1227
+ @group(0) @binding(1) var<storage, read_write> outputData: array<${valueType}>;
1228
+ @group(0) @binding(2) var<uniform> params: Params;
1229
+
1230
+ @compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE}, 1)
1231
+ fn main(
1232
+ @builtin(global_invocation_id) gid: vec3<u32>${coalesced ? ',\n @builtin(num_workgroups) groupCount: vec3<u32>' : ''}
1233
+ ) {
1234
+ ${coalesced ? `let linearX = gid.x + gid.z * groupCount.x * ${WORKGROUP_SIZE}u;
1235
+ let outputBlock = linearX % ${outputBlocks}u;
1236
+ let outputX = linearX / ${outputBlocks}u;` : `let outputBlock = gid.z;
1237
+ let outputX = gid.x;`}
1238
+ if (
1239
+ outputX >= params.outputWidth ||
1240
+ gid.y >= params.outputHeight ||
1241
+ outputBlock >= ${outputBlocks}u
1242
+ ) {
1243
+ return;
1244
+ }
1245
+ var pooled = vec4<f32>(-3.402823466e+38);
1246
+ for (var py = 0u; py < 2u; py++) {
1247
+ let inputY = gid.y * 2u + py;
1248
+ if (inputY >= params.inputHeight) { continue; }
1249
+ for (var px = 0u; px < 2u; px++) {
1250
+ let inputX = outputX * 2u + px;
1251
+ if (inputX >= params.inputWidth) { continue; }
1252
+ let inputIndex =
1253
+ (inputY * params.inputWidth + inputX) * ${outputBlocks}u + outputBlock;
1254
+ pooled = max(pooled, vec4<f32>(inputData[inputIndex]));
1255
+ }
1256
+ }
1257
+ let outputIndex =
1258
+ (gid.y * params.outputWidth + outputX) * ${outputBlocks}u + outputBlock;
1259
+ outputData[outputIndex] = ${storeExpression('pooled', precision)};
1260
+ }
1261
+ `;
1262
+ }
1263
+
1264
+ function createTiledDecoderShader(
1265
+ precision: NativeUNetPrecision,
1266
+ outputPrecision: NativeUNetPrecision,
1267
+ activation: Conv2DNodeSpec['activation'],
1268
+ sourceBlocks: readonly number[],
1269
+ upsampledSource: 0 | 1,
1270
+ outputBlocks: number,
1271
+ gemm: Readonly<Required<NativeUNetGemmOptions>>
1272
+ ) {
1273
+ const { workgroupX, workgroupY, rowsPerThread, tileM, tileNBlocks, tileKBlocks } = gemmTile(gemm);
1274
+ const { addressMode, weightLayout } = gemm;
1275
+ const valueType = storageVecType(precision);
1276
+ const outputType = storageVecType(outputPrecision);
1277
+ const packedInput = gemmPackedLoad(precision, gemm, false);
1278
+ const packedWeights = gemmPackedLoad(precision, gemm, true);
1279
+ const inputStorageType = packedInput ? 'vec2<u32>' : valueType;
1280
+ const weightStorageType = packedWeights ? 'vec2<u32>' : valueType;
1281
+ const inputBlocks = sourceBlocks[0] + sourceBlocks[1];
1282
+ const stored = storeExpression(
1283
+ activationExpression('acc[row]', activation),
1284
+ outputPrecision
1285
+ );
1286
+ const workgroupThreads =
1287
+ workgroupX * workgroupY;
1288
+ const inputTileValues = tileM * tileKBlocks;
1289
+ const weightTileValues =
1290
+ tileKBlocks * tileNBlocks * 4;
1291
+ const sourceRead = (source: 0 | 1, blockExpression: string) => {
1292
+ const sourceX =
1293
+ source === upsampledSource
1294
+ ? 'u32(inputX) / 2u'
1295
+ : 'u32(inputX)';
1296
+ const sourceY =
1297
+ source === upsampledSource
1298
+ ? 'u32(inputY) / 2u'
1299
+ : 'u32(inputY)';
1300
+ return /* wgsl */ `
1301
+ {
1302
+ let sourceBlock = ${blockExpression};
1303
+ ${addressMode === 'base-offset' ? `let dx = ${source === upsampledSource ? '(i32((cachedMask[load] >> 9u) & 1u) + i32(kernelX) - 1) >> 1u' : 'i32(kernelX) - 1'};
1304
+ let dy = ${source === upsampledSource ? '(i32((cachedMask[load] >> 10u) & 1u) + i32(kernelY) - 1) >> 1u' : 'i32(kernelY) - 1'};
1305
+ let offset = (dy * i32(params.source${source}Width) + dx) * ${sourceBlocks[source]};
1306
+ let sourceIndex = cachedBase${source}[load] + u32(offset) + sourceBlock;` : `let sourceX = ${sourceX};
1307
+ let sourceY = ${sourceY};
1308
+ let sourceIndex =
1309
+ (sourceY * params.source${source}Width + sourceX) *
1310
+ ${sourceBlocks[source]}u + sourceBlock;` }
1311
+ value = ${gemmLoadExpression(`input${source}[sourceIndex]`, packedInput)};
1312
+ }`;
1313
+ };
1314
+ return /* wgsl */ `${shaderPreamble(precision)}
1315
+ struct Params {
1316
+ outputWidth: u32,
1317
+ outputHeight: u32,
1318
+ outputBlocks: u32,
1319
+ inputBlocks: u32,
1320
+ source0Width: u32,
1321
+ source0Height: u32,
1322
+ source1Width: u32,
1323
+ source1Height: u32,
1324
+ }
1325
+ @group(0) @binding(0) var<storage, read> input0: array<${inputStorageType}>;
1326
+ @group(0) @binding(1) var<storage, read> input1: array<${inputStorageType}>;
1327
+ @group(0) @binding(2) var<storage, read> weights: array<${weightStorageType}>;
1328
+ @group(0) @binding(3) var<storage, read> bias: array<vec4<f32>>;
1329
+ @group(0) @binding(4) var<storage, read_write> outputData: array<${outputType}>;
1330
+ @group(0) @binding(5) var<uniform> params: Params;
1331
+
1332
+ var<workgroup> inputTile: array<${valueType}, ${gemmSharedSizes(gemm).input}>;
1333
+ var<workgroup> weightTile: array<${valueType}, ${gemmSharedSizes(gemm).weights}>;
1334
+
1335
+ @compute @workgroup_size(${workgroupX}, ${workgroupY}, 1)
1336
+ fn main(
1337
+ @builtin(local_invocation_id) localId: vec3<u32>,
1338
+ @builtin(workgroup_id) workgroupId: vec3<u32>
1339
+ ) {
1340
+ let spatialBase =
1341
+ workgroupId.x * ${tileM}u +
1342
+ localId.y * ${rowsPerThread}u;
1343
+ let outputBlock =
1344
+ workgroupId.y * ${tileNBlocks}u + localId.x;
1345
+ let spatialCount = params.outputWidth * params.outputHeight;
1346
+ var acc: array<vec4<f32>, ${rowsPerThread}>;
1347
+ if (outputBlock < ${outputBlocks}u) {
1348
+ for (var row = 0u; row < ${rowsPerThread}u; row++) {
1349
+ acc[row] = bias[outputBlock];
1350
+ }
1351
+ }
1352
+
1353
+ let localLinear =
1354
+ localId.y * ${workgroupX}u + localId.x;
1355
+ ${addressMode !== 'analytic' ? tiledAddressCacheCode(inputBlocks, true, gemm, { blocks: sourceBlocks, upsampled: upsampledSource }) : ''}
1356
+ let totalK = ${inputBlocks * 9}u;
1357
+ for (var kBase = 0u; kBase < totalK; kBase += ${tileKBlocks}u) {
1358
+ ${addressMode !== 'analytic' ? (gemm.decoderLoad === 'source-first' ? `
1359
+ if (channelBlock < ${sourceBlocks[0]}u) {
1360
+ ${tiledIncrementalInputCode(valueType, sourceRead(0, 'inputBlock'), gemm)}
1361
+ } else {
1362
+ ${tiledIncrementalInputCode(valueType, sourceRead(1, `inputBlock - ${sourceBlocks[0]}u`), gemm)}
1363
+ }
1364
+ ` : tiledIncrementalInputCode(valueType, `
1365
+ if (inputBlock < ${sourceBlocks[0]}u) {
1366
+ ${sourceRead(0, 'inputBlock')}
1367
+ } else {
1368
+ ${sourceRead(1, `inputBlock - ${sourceBlocks[0]}u`)}
1369
+ }
1370
+ `, gemm)) : /* wgsl */ `
1371
+ for (
1372
+ var loadIndex = localLinear;
1373
+ loadIndex < ${inputTileValues}u;
1374
+ loadIndex += ${workgroupThreads}u
1375
+ ) {
1376
+ let tileSpatial = loadIndex / ${tileKBlocks}u;
1377
+ let tileK = loadIndex % ${tileKBlocks}u;
1378
+ let outputSpatialIndex =
1379
+ workgroupId.x * ${tileM}u + tileSpatial;
1380
+ let kIndex = kBase + tileK;
1381
+ var value = ${valueType}(0.0);
1382
+ if (outputSpatialIndex < spatialCount && kIndex < totalK) {
1383
+ let outputY = outputSpatialIndex / params.outputWidth;
1384
+ let outputX = outputSpatialIndex % params.outputWidth;
1385
+ let inputBlock = kIndex % ${inputBlocks}u;
1386
+ let kernelIndex = kIndex / ${inputBlocks}u;
1387
+ let kernelY = kernelIndex / 3u;
1388
+ let kernelX = kernelIndex % 3u;
1389
+ let inputY = i32(outputY) + i32(kernelY) - 1;
1390
+ let inputX = i32(outputX) + i32(kernelX) - 1;
1391
+ if (
1392
+ inputY >= 0 && inputY < i32(params.outputHeight) &&
1393
+ inputX >= 0 && inputX < i32(params.outputWidth)
1394
+ ) {
1395
+ if (inputBlock < ${sourceBlocks[0]}u) {
1396
+ ${sourceRead(0, 'inputBlock')}
1397
+ } else {
1398
+ ${sourceRead(1, `inputBlock - ${sourceBlocks[0]}u`)}
1399
+ }
1400
+ }
1401
+ }
1402
+ inputTile[${gemmSharedInputIndex('loadIndex', gemm)}] = value;
1403
+ }
1404
+ `}
1405
+
1406
+ for (
1407
+ var loadIndex = localLinear;
1408
+ loadIndex < ${weightTileValues}u;
1409
+ loadIndex += ${workgroupThreads}u
1410
+ ) {
1411
+ let tileK = loadIndex / ${tileNBlocks * 4}u;
1412
+ let outputRemainder = loadIndex % ${tileNBlocks * 4}u;
1413
+ let tileOutputBlock = outputRemainder / 4u;
1414
+ let outputLane = outputRemainder % 4u;
1415
+ let loadedOutputBlock =
1416
+ workgroupId.y * ${tileNBlocks}u + tileOutputBlock;
1417
+ let kIndex = kBase + tileK;
1418
+ var value = ${valueType}(0.0);
1419
+ if (loadedOutputBlock < ${outputBlocks}u && kIndex < totalK) {
1420
+ ${weightLayout === 'k-major' ? /* wgsl */ `
1421
+ let weightIndex = (kIndex * ${outputBlocks}u + loadedOutputBlock) * 4u + outputLane;
1422
+ ` : addressMode !== 'analytic' ? /* wgsl */ `
1423
+ let weightIndex = (loadedOutputBlock * ${inputBlocks * 9}u + kIndex) * 4u + outputLane;
1424
+ ` : /* wgsl */ `
1425
+ let inputBlock = kIndex % ${inputBlocks}u;
1426
+ let kernelIndex = kIndex / ${inputBlocks}u;
1427
+ let kernelY = kernelIndex / 3u;
1428
+ let kernelX = kernelIndex % 3u;
1429
+ let weightIndex =
1430
+ ((((loadedOutputBlock * 3u + kernelY) * 3u + kernelX) *
1431
+ ${inputBlocks}u + inputBlock) * 4u + outputLane);
1432
+ `}
1433
+ value = ${gemmLoadExpression('weights[weightIndex]', packedWeights)};
1434
+ }
1435
+ weightTile[${gemmSharedWeightIndex('loadIndex', gemm)}] = value;
1436
+ }
1437
+
1438
+ workgroupBarrier();
1439
+ ${tiledAccumulationCode(precision, gemm)}
1440
+ workgroupBarrier();
1441
+ ${addressMode !== 'analytic' ? tiledAddressAdvanceCode(inputBlocks, gemm) : ''}
1442
+ }
1443
+
1444
+ if (outputBlock < ${outputBlocks}u) {
1445
+ for (var row = 0u; row < ${rowsPerThread}u; row++) {
1446
+ let spatialIndex = spatialBase + row;
1447
+ if (spatialIndex < spatialCount) {
1448
+ let outputIndex = spatialIndex * ${outputBlocks}u + outputBlock;
1449
+ outputData[outputIndex] = ${stored};
1450
+ }
1451
+ }
1452
+ }
1453
+ }
1454
+ `;
1455
+ }
1456
+
1457
+ /**
1458
+ * One workgroup owns one output phase, so its GEMM rows share the same four
1459
+ * combined upsampling taps. Skip inputs retain their original nine taps.
1460
+ * The shared tiles and short FP16 reduction groups match the regular GEMM;
1461
+ * only the decoder's mathematical decomposition and reduction order change.
1462
+ */
1463
+ function createTiledSubpixelDecoderShader(
1464
+ precision: NativeUNetPrecision,
1465
+ outputPrecision: NativeUNetPrecision,
1466
+ activation: Conv2DNodeSpec['activation'],
1467
+ sourceBlocks: readonly number[],
1468
+ upsampledSource: 0 | 1,
1469
+ outputBlocks: number,
1470
+ gemm: Readonly<Required<NativeUNetGemmOptions>>
1471
+ ) {
1472
+ const tile = gemmTile(gemm);
1473
+ const { workgroupX, workgroupY, rowsPerThread, tileM, tileNBlocks, tileKBlocks } = tile;
1474
+ const threads = workgroupX * workgroupY;
1475
+ const loads = tileM * tileKBlocks / threads;
1476
+ const valueType = storageVecType(precision);
1477
+ const packedInput = gemmPackedLoad(precision, gemm, false);
1478
+ const packedWeights = gemmPackedLoad(precision, gemm, true);
1479
+ const upBlocks = sourceBlocks[upsampledSource];
1480
+ const skipSource = (1 - upsampledSource) as 0 | 1;
1481
+ const skipBlocks = sourceBlocks[skipSource];
1482
+ const reduction = (source: 0 | 1, upsampled: boolean) => {
1483
+ const blocks = sourceBlocks[source];
1484
+ const taps = upsampled ? 4 : 9;
1485
+ const weightOffset = upsampled
1486
+ ? `phase * ${4 * upBlocks * outputBlocks * 4}u`
1487
+ : `${SUBPIXEL_MATRICES * upBlocks * outputBlocks * 4}u`;
1488
+ const offset = upsampled
1489
+ ? `(i32(tap / 2u) + i32(phaseY) - 1) * i32(params.source${source}Width) + i32(tap % 2u) + i32(phaseX) - 1`
1490
+ : `(i32(tap / 3u) - 1) * i32(params.source${source}Width) + i32(tap % 3u) - 1`;
1491
+ return /* wgsl */ `
1492
+ for (var kBase = 0u; kBase < ${taps * blocks}u; kBase += ${tileKBlocks}u) {
1493
+ let inputK = kBase + localLinear % ${tileKBlocks}u;
1494
+ let tap = inputK / ${blocks}u;
1495
+ let channel = inputK % ${blocks}u;
1496
+ for (var load = 0u; load < ${loads}u; load++) {
1497
+ let i = localLinear + load * ${threads}u;
1498
+ var value = ${valueType}(0.0);
1499
+ if (tap < ${taps}u && (mask${source}[load] & (1u << tap)) != 0u) {
1500
+ let offset = ${offset};
1501
+ let index = u32(base${source}[load] + offset) * ${blocks}u + channel;
1502
+ value = ${gemmLoadExpression(`input${source}[index]`, packedInput)};
1503
+ }
1504
+ inputTile[${gemmSharedInputIndex('i', gemm)}] = value;
1505
+ }
1506
+ for (var i = localLinear; i < ${tileKBlocks * tileNBlocks * 4}u; i += ${threads}u) {
1507
+ let k = kBase + i / ${tileNBlocks * 4}u;
1508
+ let outputBlock = workgroupId.y * ${tileNBlocks}u + (i % ${tileNBlocks * 4}u) / 4u;
1509
+ var value = ${valueType}(0.0);
1510
+ if (k < ${taps * blocks}u && outputBlock < ${outputBlocks}u) {
1511
+ let index = ${weightOffset} + (k * ${outputBlocks}u + outputBlock) * 4u + i % 4u;
1512
+ value = ${gemmLoadExpression('weights[index]', packedWeights)};
1513
+ }
1514
+ weightTile[${gemmSharedWeightIndex('i', gemm)}] = value;
1515
+ }
1516
+ workgroupBarrier();
1517
+ ${tiledAccumulationCode(precision, gemm)}
1518
+ workgroupBarrier();
1519
+ }`;
1520
+ };
1521
+ const cache = (source: 0 | 1, upsampled: boolean) => {
1522
+ const taps = upsampled ? 4 : 9;
1523
+ const side = upsampled ? 2 : 3;
1524
+ const x = upsampled ? 'x' : 'x * 2u + phaseX';
1525
+ const y = upsampled ? 'y' : 'y * 2u + phaseY';
1526
+ return /* wgsl */ `
1527
+ let x${source} = i32(${x});
1528
+ let y${source} = i32(${y});
1529
+ base${source}[load] = y${source} * i32(params.source${source}Width) + x${source};
1530
+ var bits${source} = 0u;
1531
+ for (var tap = 0u; tap < ${taps}u; tap++) {
1532
+ let sx = x${source} + i32(tap % ${side}u) ${upsampled ? '+ i32(phaseX)' : ''} - 1;
1533
+ let sy = y${source} + i32(tap / ${side}u) ${upsampled ? '+ i32(phaseY)' : ''} - 1;
1534
+ if (valid && sx >= 0 && sy >= 0 && sx < i32(params.source${source}Width) && sy < i32(params.source${source}Height)) {
1535
+ bits${source} |= 1u << tap;
1536
+ }
1537
+ }
1538
+ mask${source}[load] = bits${source};`;
1539
+ };
1540
+ return /* wgsl */ `${shaderPreamble(precision)}
1541
+ struct Params {
1542
+ outputWidth: u32, outputHeight: u32, outputBlocks: u32, inputBlocks: u32,
1543
+ source0Width: u32, source0Height: u32, source1Width: u32, source1Height: u32,
1544
+ }
1545
+ @group(0) @binding(0) var<storage, read> input0: array<${packedInput ? 'vec2<u32>' : valueType}>;
1546
+ @group(0) @binding(1) var<storage, read> input1: array<${packedInput ? 'vec2<u32>' : valueType}>;
1547
+ @group(0) @binding(2) var<storage, read> weights: array<${packedWeights ? 'vec2<u32>' : valueType}>;
1548
+ @group(0) @binding(3) var<storage, read> bias: array<vec4<f32>>;
1549
+ @group(0) @binding(4) var<storage, read_write> outputData: array<${storageVecType(outputPrecision)}>;
1550
+ @group(0) @binding(5) var<uniform> params: Params;
1551
+ var<workgroup> inputTile: array<${valueType}, ${gemmSharedSizes(gemm).input}>;
1552
+ var<workgroup> weightTile: array<${valueType}, ${gemmSharedSizes(gemm).weights}>;
1553
+
1554
+ @compute @workgroup_size(${workgroupX}, ${workgroupY}, 1)
1555
+ fn main(@builtin(local_invocation_id) localId: vec3<u32>, @builtin(workgroup_id) workgroupId: vec3<u32>) {
1556
+ let phase = workgroupId.z;
1557
+ let phaseX = phase & 1u;
1558
+ let phaseY = phase >> 1u;
1559
+ let phaseWidth = (params.outputWidth + 1u) / 2u;
1560
+ let localLinear = localId.y * ${workgroupX}u + localId.x;
1561
+ let outputBlock = workgroupId.y * ${tileNBlocks}u + localId.x;
1562
+ var acc: array<vec4<f32>, ${rowsPerThread}>;
1563
+ if (outputBlock < ${outputBlocks}u) {
1564
+ for (var row = 0u; row < ${rowsPerThread}u; row++) { acc[row] = bias[outputBlock]; }
1565
+ }
1566
+ var base0: array<i32, ${loads}>;
1567
+ var base1: array<i32, ${loads}>;
1568
+ var mask0: array<u32, ${loads}>;
1569
+ var mask1: array<u32, ${loads}>;
1570
+ for (var load = 0u; load < ${loads}u; load++) {
1571
+ let spatial = workgroupId.x * ${tileM}u + (localLinear + load * ${threads}u) / ${tileKBlocks}u;
1572
+ let x = spatial % phaseWidth;
1573
+ let y = spatial / phaseWidth;
1574
+ let valid = x * 2u + phaseX < params.outputWidth && y * 2u + phaseY < params.outputHeight;
1575
+ ${cache(upsampledSource, true)}
1576
+ ${cache(skipSource, false)}
1577
+ }
1578
+ ${reduction(upsampledSource, true)}
1579
+ ${reduction(skipSource, false)}
1580
+ if (outputBlock < ${outputBlocks}u) {
1581
+ for (var row = 0u; row < ${rowsPerThread}u; row++) {
1582
+ let spatial = workgroupId.x * ${tileM}u + localId.y * ${rowsPerThread}u + row;
1583
+ let x = (spatial % phaseWidth) * 2u + phaseX;
1584
+ let y = (spatial / phaseWidth) * 2u + phaseY;
1585
+ if (x < params.outputWidth && y < params.outputHeight) {
1586
+ outputData[(y * params.outputWidth + x) * ${outputBlocks}u + outputBlock] = ${storeExpression(activationExpression('acc[row]', activation), outputPrecision)};
1587
+ }
1588
+ }
1589
+ }
1590
+ }
1591
+ `;
1592
+ }
1593
+
1594
+ function createFusedDecoderShader(
1595
+ precision: NativeUNetPrecision,
1596
+ outputPrecision: NativeUNetPrecision,
1597
+ activation: Conv2DNodeSpec['activation'],
1598
+ sourceBlocks: readonly number[],
1599
+ upsampledSource: 0 | 1,
1600
+ outputBlocks: number
1601
+ ) {
1602
+ const valueType = storageVecType(precision);
1603
+ const outputType = storageVecType(outputPrecision);
1604
+ const inputBlocks = sourceBlocks[0] + sourceBlocks[1];
1605
+ const sourceCode = (source: 0 | 1, blockOffset: number) => {
1606
+ const isUpsampled = source === upsampledSource;
1607
+ return /* wgsl */ `
1608
+ {
1609
+ let sourceX = ${isUpsampled ? 'u32(inputX) / 2u' : 'u32(inputX)'};
1610
+ let sourceY = ${isUpsampled ? 'u32(inputY) / 2u' : 'u32(inputY)'};
1611
+ let sourcePixelBase = (sourceY * params.source${source}Width + sourceX) * ${sourceBlocks[source]}u;
1612
+ for (var sourceBlock = 0u; sourceBlock < ${sourceBlocks[source]}u; sourceBlock++) {
1613
+ let inputBlock = ${blockOffset}u + sourceBlock;
1614
+ ${accumulationCode(
1615
+ `input${source}[sourcePixelBase + sourceBlock]`,
1616
+ `((((gid.z * 3u + ky) * 3u + kx) * ${inputBlocks}u + inputBlock) * 4u)`,
1617
+ precision
1618
+ )}
1619
+ }
1620
+ }
1621
+ `;
1622
+ };
1623
+ const stored = storeExpression(
1624
+ activationExpression('acc', activation),
1625
+ outputPrecision
1626
+ );
1627
+ return /* wgsl */ `${shaderPreamble(precision)}
1628
+ struct Params {
1629
+ outputWidth: u32,
1630
+ outputHeight: u32,
1631
+ outputBlocks: u32,
1632
+ inputBlocks: u32,
1633
+ source0Width: u32,
1634
+ source0Height: u32,
1635
+ source1Width: u32,
1636
+ source1Height: u32,
1637
+ }
1638
+ @group(0) @binding(0) var<storage, read> input0: array<${valueType}>;
1639
+ @group(0) @binding(1) var<storage, read> input1: array<${valueType}>;
1640
+ @group(0) @binding(2) var<storage, read> weights: array<${valueType}>;
1641
+ @group(0) @binding(3) var<storage, read> bias: array<vec4<f32>>;
1642
+ @group(0) @binding(4) var<storage, read_write> outputData: array<${outputType}>;
1643
+ @group(0) @binding(5) var<uniform> params: Params;
1644
+
1645
+ @compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE}, 1)
1646
+ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
1647
+ if (gid.x >= params.outputWidth || gid.y >= params.outputHeight || gid.z >= ${outputBlocks}u) {
1648
+ return;
1649
+ }
1650
+ var acc = bias[gid.z];
1651
+ for (var ky = 0u; ky < 3u; ky++) {
1652
+ let inputY = i32(gid.y) + i32(ky) - 1;
1653
+ if (inputY < 0 || inputY >= i32(params.outputHeight)) { continue; }
1654
+ for (var kx = 0u; kx < 3u; kx++) {
1655
+ let inputX = i32(gid.x) + i32(kx) - 1;
1656
+ if (inputX < 0 || inputX >= i32(params.outputWidth)) { continue; }
1657
+ ${sourceCode(0, 0)}
1658
+ ${sourceCode(1, sourceBlocks[0])}
1659
+ }
1660
+ }
1661
+ let outputIndex = (gid.y * params.outputWidth + gid.x) * ${outputBlocks}u + gid.z;
1662
+ outputData[outputIndex] = ${stored};
1663
+ }
1664
+ `;
1665
+ }
1666
+
1667
+ function createSpatialDecoderShader(
1668
+ precision: NativeUNetPrecision,
1669
+ outputPrecision: NativeUNetPrecision,
1670
+ activation: Conv2DNodeSpec['activation'],
1671
+ sourceBlocks: readonly number[],
1672
+ upsampledSource: 0 | 1,
1673
+ outputBlocks: number
1674
+ ) {
1675
+ const valueType = storageVecType(precision);
1676
+ const outputType = storageVecType(outputPrecision);
1677
+ const inputBlocks = sourceBlocks[0] + sourceBlocks[1];
1678
+ const patchValues =
1679
+ SPATIAL_CONV_PATCH * SPATIAL_CONV_PATCH * inputBlocks;
1680
+ const workgroupThreads =
1681
+ SPATIAL_CONV_WORKGROUP * SPATIAL_CONV_WORKGROUP;
1682
+ const stored = storeExpression(
1683
+ activationExpression('acc', activation),
1684
+ outputPrecision
1685
+ );
1686
+ const sourceRead = (source: 0 | 1, blockExpression: string) => {
1687
+ const sourceX =
1688
+ source === upsampledSource ? 'u32(inputX) / 2u' : 'u32(inputX)';
1689
+ const sourceY =
1690
+ source === upsampledSource ? 'u32(inputY) / 2u' : 'u32(inputY)';
1691
+ return /* wgsl */ `
1692
+ let sourceBlock = ${blockExpression};
1693
+ let sourceIndex =
1694
+ (${sourceY} * params.source${source}Width + ${sourceX}) *
1695
+ ${sourceBlocks[source]}u + sourceBlock;
1696
+ value = input${source}[sourceIndex];`;
1697
+ };
1698
+
1699
+ return /* wgsl */ `${shaderPreamble(precision)}
1700
+ struct Params {
1701
+ outputWidth: u32,
1702
+ outputHeight: u32,
1703
+ outputBlocks: u32,
1704
+ inputBlocks: u32,
1705
+ source0Width: u32,
1706
+ source0Height: u32,
1707
+ source1Width: u32,
1708
+ source1Height: u32,
1709
+ }
1710
+ @group(0) @binding(0) var<storage, read> input0: array<${valueType}>;
1711
+ @group(0) @binding(1) var<storage, read> input1: array<${valueType}>;
1712
+ @group(0) @binding(2) var<storage, read> weights: array<${valueType}>;
1713
+ @group(0) @binding(3) var<storage, read> bias: array<vec4<f32>>;
1714
+ @group(0) @binding(4) var<storage, read_write> outputData: array<${outputType}>;
1715
+ @group(0) @binding(5) var<uniform> params: Params;
1716
+
1717
+ var<workgroup> inputPatch: array<${valueType}, ${patchValues}>;
1718
+
1719
+ @compute @workgroup_size(${SPATIAL_CONV_WORKGROUP}, ${SPATIAL_CONV_WORKGROUP}, 1)
1720
+ fn main(
1721
+ @builtin(local_invocation_id) localId: vec3<u32>,
1722
+ @builtin(workgroup_id) workgroupId: vec3<u32>
1723
+ ) {
1724
+ let localLinear =
1725
+ localId.y * ${SPATIAL_CONV_WORKGROUP}u + localId.x;
1726
+ for (
1727
+ var loadIndex = localLinear;
1728
+ loadIndex < ${patchValues}u;
1729
+ loadIndex += ${workgroupThreads}u
1730
+ ) {
1731
+ let patchPixel = loadIndex / ${inputBlocks}u;
1732
+ let inputBlock = loadIndex % ${inputBlocks}u;
1733
+ let patchX = patchPixel % ${SPATIAL_CONV_PATCH}u;
1734
+ let patchY = patchPixel / ${SPATIAL_CONV_PATCH}u;
1735
+ let inputX =
1736
+ i32(workgroupId.x * ${SPATIAL_CONV_WORKGROUP}u + patchX) - 1;
1737
+ let inputY =
1738
+ i32(workgroupId.y * ${SPATIAL_CONV_WORKGROUP}u + patchY) - 1;
1739
+ var value = ${valueType}(0.0);
1740
+ if (
1741
+ inputX >= 0 && inputX < i32(params.outputWidth) &&
1742
+ inputY >= 0 && inputY < i32(params.outputHeight)
1743
+ ) {
1744
+ if (inputBlock < ${sourceBlocks[0]}u) {
1745
+ ${sourceRead(0, 'inputBlock')}
1746
+ } else {
1747
+ ${sourceRead(1, `inputBlock - ${sourceBlocks[0]}u`)}
1748
+ }
1749
+ }
1750
+ inputPatch[loadIndex] = value;
1751
+ }
1752
+ workgroupBarrier();
1753
+
1754
+ let outputX =
1755
+ workgroupId.x * ${SPATIAL_CONV_WORKGROUP}u + localId.x;
1756
+ let outputY =
1757
+ workgroupId.y * ${SPATIAL_CONV_WORKGROUP}u + localId.y;
1758
+ let outputBlock = workgroupId.z;
1759
+ if (
1760
+ outputX >= params.outputWidth || outputY >= params.outputHeight ||
1761
+ outputBlock >= ${outputBlocks}u
1762
+ ) {
1763
+ return;
1764
+ }
1765
+
1766
+ var acc = bias[outputBlock];
1767
+ for (var ky = 0u; ky < 3u; ky++) {
1768
+ for (var kx = 0u; kx < 3u; kx++) {
1769
+ let patchBase =
1770
+ ((localId.y + ky) * ${SPATIAL_CONV_PATCH}u + localId.x + kx) *
1771
+ ${inputBlocks}u;
1772
+ for (var inputBlock = 0u; inputBlock < ${inputBlocks}u; inputBlock++) {
1773
+ ${accumulationCode(
1774
+ 'inputPatch[patchBase + inputBlock]',
1775
+ `((((outputBlock * 3u + ky) * 3u + kx) * ${inputBlocks}u + inputBlock) * 4u)`,
1776
+ precision
1777
+ )}
1778
+ }
1779
+ }
1780
+ }
1781
+ let outputIndex =
1782
+ (outputY * params.outputWidth + outputX) * ${outputBlocks}u + outputBlock;
1783
+ outputData[outputIndex] = ${stored};
1784
+ }
1785
+ `;
1786
+ }
1787
+
1788
+ export function createInputPackShader(
1789
+ precision: NativeUNetPrecision,
1790
+ sourceCount: number
1791
+ ) {
1792
+ const outputType = storageVecType(precision);
1793
+ const inputBindings = Array.from(
1794
+ { length: sourceCount },
1795
+ (_, index) =>
1796
+ `@group(0) @binding(${index}) var<storage, read> input${index}: array<vec4<f32>>;`
1797
+ ).join('\n');
1798
+ const readBranches = Array.from({ length: sourceCount }, (_, index) => {
1799
+ const firstChannel = index * 3;
1800
+ return `if (channel < ${firstChannel + 3}u) { return input${index}[pixel][channel - ${firstChannel}u]; }`;
1801
+ }).join('\n ');
1802
+ const outputBinding = sourceCount;
1803
+ const paramsBinding = sourceCount + 1;
1804
+ return /* wgsl */ `${shaderPreamble(precision)}
1805
+ struct Params {
1806
+ width: u32,
1807
+ height: u32,
1808
+ outputBlocks: u32,
1809
+ inputChannels: u32,
1810
+ }
1811
+ ${inputBindings}
1812
+ @group(0) @binding(${outputBinding}) var<storage, read_write> outputData: array<${outputType}>;
1813
+ @group(0) @binding(${paramsBinding}) var<uniform> params: Params;
1814
+
1815
+ fn readChannel(pixel: u32, channel: u32) -> f32 {
1816
+ ${readBranches}
1817
+ return 0.0;
1818
+ }
1819
+
1820
+ @compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE}, 1)
1821
+ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
1822
+ if (gid.x >= params.width || gid.y >= params.height || gid.z >= params.outputBlocks) {
1823
+ return;
1824
+ }
1825
+ let pixel = gid.y * params.width + gid.x;
1826
+ let firstChannel = gid.z * 4u;
1827
+ let value = vec4<f32>(
1828
+ readChannel(pixel, firstChannel),
1829
+ readChannel(pixel, firstChannel + 1u),
1830
+ readChannel(pixel, firstChannel + 2u),
1831
+ readChannel(pixel, firstChannel + 3u)
1832
+ );
1833
+ outputData[pixel * params.outputBlocks + gid.z] = ${storeExpression('value', precision)};
1834
+ }
1835
+ `;
1836
+ }
1837
+
1838
+ const SUBGROUP_MATRIX_FEATURE = 'chromium-experimental-subgroup-matrix';
1839
+ /** wgpu's feature name; native hosts report it through their device. */
1840
+ const COOPERATIVE_MATRIX_FEATURE = 'wgpu-cooperative-matrix';
1841
+ const SUBGROUP_MATRIX_TILE_WIDTH = 32;
1842
+ // Four subgroups own two output rows each, matching the 2x2 pooling windows.
1843
+ const SUBGROUP_MATRIX_TILE_HEIGHT = 8;
1844
+ const SUBGROUP_MATRIX_SUBGROUPS = 4;
1845
+ // Below this many workgroups a layer uses one output fragment per workgroup.
1846
+ const SUBGROUP_MATRIX_MIN_WORKGROUPS = 1024;
1847
+
1848
+ interface AdapterSubgroupMatrixInfo {
1849
+ subgroupMinSize?: number;
1850
+ subgroupMaxSize?: number;
1851
+ subgroupMatrixConfigs?: readonly {
1852
+ componentType: string;
1853
+ resultComponentType: string;
1854
+ M: number;
1855
+ N: number;
1856
+ K: number;
1857
+ }[];
1858
+ }
1859
+
1860
+ /**
1861
+ * Picks a square configuration, preferring f32 accumulation: mixed f16
1862
+ * inputs first, then f32 inputs (whose f16-exact values give the same
1863
+ * products), and f16 accumulation last.
1864
+ */
1865
+ export function selectSubgroupMatrixConfig(
1866
+ device: OIDNGPUDevice,
1867
+ accumulation: 'f32' | 'f16' = 'f32'
1868
+ ): NativeSubgroupMatrixConfig | undefined {
1869
+ const dialect = device.features.has(SUBGROUP_MATRIX_FEATURE)
1870
+ ? 'tint'
1871
+ : device.features.has(COOPERATIVE_MATRIX_FEATURE)
1872
+ ? 'naga'
1873
+ : undefined;
1874
+ if (!dialect || !device.features.has('subgroups')) return undefined;
1875
+ const info = (device as OIDNGPUDevice & { adapterInfo?: OIDNGPUAdapterInfo }).adapterInfo;
1876
+ const subgroupSize = info?.subgroupMaxSize ?? 0;
1877
+ if (!info || !subgroupSize || info.subgroupMinSize !== subgroupSize) return undefined;
1878
+ const preferences: readonly [string, string][] = accumulation === 'f16'
1879
+ ? [['f16', 'f16'], ['f16', 'f32'], ['f32', 'f32']]
1880
+ : [['f16', 'f32'], ['f32', 'f32'], ['f16', 'f16']];
1881
+ const shader16 = device.features.has('shader-f16');
1882
+ for (const [componentType, resultType] of preferences) {
1883
+ if (!shader16 && (componentType === 'f16' || resultType === 'f16')) continue;
1884
+ for (const size of [8, 16] as const) {
1885
+ const match = info.subgroupMatrixConfigs?.find((config) =>
1886
+ config.componentType === componentType &&
1887
+ config.resultComponentType === resultType &&
1888
+ config.M === size && config.N === size && config.K === size);
1889
+ if (match) {
1890
+ return {
1891
+ componentType: componentType as 'f16' | 'f32',
1892
+ resultType: resultType as 'f16' | 'f32',
1893
+ size,
1894
+ subgroupSize,
1895
+ dialect
1896
+ };
1897
+ }
1898
+ }
1899
+ }
1900
+ return undefined;
1901
+ }
1902
+
1903
+ function subgroupMatrixFragments(
1904
+ config: NativeSubgroupMatrixConfig,
1905
+ options: NativeSubgroupMatrixOptions
1906
+ ) {
1907
+ return options.outputFragments ?? 2;
1908
+ }
1909
+
1910
+ function subgroupMatrixSharedBytes(
1911
+ config: NativeSubgroupMatrixConfig,
1912
+ outputFragments: number,
1913
+ subPixel: boolean,
1914
+ doubleBuffer: boolean
1915
+ ) {
1916
+ const component = config.componentType === 'f16' ? 2 : 4;
1917
+ const result = config.resultType === 'f16' ? 2 : 4;
1918
+ const size = config.size;
1919
+ const halo = (SUBGROUP_MATRIX_TILE_HEIGHT + 2) * (SUBGROUP_MATRIX_TILE_WIDTH + 2) * size;
1920
+ const matrices = subPixel ? 16 : 9;
1921
+ return component * (doubleBuffer ? 2 : 1) * (halo + matrices * size * size * outputFragments) +
1922
+ result * SUBGROUP_MATRIX_SUBGROUPS * SUBGROUP_MATRIX_TILE_WIDTH * size;
1923
+ }
1924
+
1925
+ /** WGSL spelling of the subgroup-matrix operations for one configuration. */
1926
+ function subgroupMatrixDialect(config: NativeSubgroupMatrixConfig) {
1927
+ const size = config.size;
1928
+ const naga = config.dialect === 'naga';
1929
+ const matrixType = (scalar: string, tint: string, role: string) => naga
1930
+ ? `coop_mat${size}x${size}<${scalar}, ${role}>`
1931
+ : `subgroup_matrix_${tint}<${scalar}, ${size}, ${size}>`;
1932
+ const result = matrixType(config.resultType, 'result', 'C');
1933
+ return {
1934
+ left: matrixType(config.componentType, 'left', 'A'),
1935
+ right: matrixType(config.componentType, 'right', 'B'),
1936
+ result,
1937
+ load: (type: string, array: string, offset: string, stride: string) => naga
1938
+ ? `coopLoadT<${type}>(&${array}[${offset}], ${stride})`
1939
+ : `subgroupMatrixLoad<${type}, row_major>(&${array}, ${offset}, ${stride})`,
1940
+ store: (array: string, offset: string, value: string, stride: string) => naga
1941
+ ? `coopStoreT(${value}, &${array}[${offset}], ${stride});`
1942
+ : `subgroupMatrixStore<row_major>(&${array}, ${offset}, ${value}, ${stride});`,
1943
+ multiplyAdd: naga ? 'coopMultiplyAdd' : 'subgroupMatrixMultiplyAccumulate',
1944
+ declare: (name: string) => naga ? `var ${name}: ${result};` : `var ${name} = ${result}();`,
1945
+ // Naga provides subgroups without (and rejects) `enable subgroups`.
1946
+ extensions: naga
1947
+ ? 'enable wgpu_cooperative_matrix;'
1948
+ : `enable subgroups;
1949
+ enable chromium_experimental_subgroup_matrix;
1950
+ diagnostic(off, chromium_experimental.subgroup_matrix_uniformity);`
1951
+ };
1952
+ }
1953
+
1954
+ /** One workgroup-memory array filled per input-channel chunk. */
1955
+ interface SubgroupMatrixStage {
1956
+ array: string;
1957
+ /** vec4 values staged per chunk. */
1958
+ vectors: number;
1959
+ /** vec4 capacity of one buffer half (default `vectors`). */
1960
+ stride?: number;
1961
+ /** WGSL that may assign `value` from `i` (vec4 index) and `stageChunk`. */
1962
+ read: string;
1963
+ }
1964
+
1965
+ /**
1966
+ * Loop over input-channel chunks that stages `stages` and runs `body` on
1967
+ * them. The body addresses each array at `<array>Base`. Double buffering
1968
+ * prefetches the next chunk into registers while the body runs, then stores
1969
+ * it into the other half of each array: one barrier per chunk instead of two.
1970
+ */
1971
+ function subgroupMatrixChunkLoop(
1972
+ chunks: number,
1973
+ stages: readonly SubgroupMatrixStage[],
1974
+ body: string,
1975
+ threads: number,
1976
+ valueType: string,
1977
+ component: string,
1978
+ doubleBuffer: boolean
1979
+ ) {
1980
+ const storeValue = (stage: SubgroupMatrixStage, value: string, base: string) => `{
1981
+ let staged = vec4<${component}>(${value});
1982
+ let base = ${base} + i * 4u;
1983
+ ${stage.array}[base] = staged.x;
1984
+ ${stage.array}[base + 1u] = staged.y;
1985
+ ${stage.array}[base + 2u] = staged.z;
1986
+ ${stage.array}[base + 3u] = staged.w;
1987
+ }`;
1988
+ // Keep staging loops' trip counts uniform before the matrix operations.
1989
+ // A localIndex-dependent trip count produces nondeterministic results on
1990
+ // NVIDIA Vulkan with runtime checks disabled, despite the following barrier.
1991
+ const direct = (base: (stage: SubgroupMatrixStage) => string) => stages.map((stage) => `
1992
+ for (var batch = 0u; batch < ${Math.ceil(stage.vectors / threads)}u; batch++) {
1993
+ let i = localIndex + batch * ${threads}u;
1994
+ if (i < ${stage.vectors}u) {
1995
+ var value = ${valueType}(0.0);
1996
+ ${stage.read}
1997
+ ${storeValue(stage, 'value', base(stage))}
1998
+ }
1999
+ }`).join('');
2000
+ const bases = (buffer: string) => stages
2001
+ .map((stage) => `let ${stage.array}Base = ${buffer} * ${(stage.stride ?? stage.vectors) * 4}u;`)
2002
+ .join('\n ');
2003
+ if (!doubleBuffer || chunks < 2) {
2004
+ return /* wgsl */ `
2005
+ for (var chunk = 0u; chunk < ${chunks}u; chunk++) {
2006
+ let stageChunk = chunk;
2007
+ ${bases('0u')}
2008
+ ${direct((stage) => `${stage.array}Base`)}
2009
+ workgroupBarrier();
2010
+ ${body}
2011
+ workgroupBarrier();
2012
+ }`;
2013
+ }
2014
+ const registers = stages.map((stage) => Math.ceil(stage.vectors / threads));
2015
+ const prefetch = stages.map((stage, index) =>
2016
+ Array.from({ length: registers[index] }, (_, slot) => `
2017
+ {
2018
+ let i = localIndex + ${slot * threads}u;
2019
+ var value = ${valueType}(0.0);
2020
+ if (i < ${stage.vectors}u) {
2021
+ ${stage.read}
2022
+ }
2023
+ prefetch${index}_${slot} = value;
2024
+ }`).join('')).join('');
2025
+ const declarations = stages.map((stage, index) =>
2026
+ Array.from({ length: registers[index] }, (_, slot) =>
2027
+ `var prefetch${index}_${slot} = ${valueType}(0.0);`).join('\n ')).join('\n ');
2028
+ const storePrefetched = stages.map((stage, index) =>
2029
+ Array.from({ length: registers[index] }, (_, slot) => `
2030
+ {
2031
+ let i = localIndex + ${slot * threads}u;
2032
+ if (i < ${stage.vectors}u) ${storeValue(stage, `prefetch${index}_${slot}`, `next * ${(stage.stride ?? stage.vectors) * 4}u`)}
2033
+ }`).join('')).join('');
2034
+ return /* wgsl */ `
2035
+ {
2036
+ let stageChunk = 0u;
2037
+ ${direct(() => '0u')}
2038
+ }
2039
+ workgroupBarrier();
2040
+ for (var chunk = 0u; chunk < ${chunks}u; chunk++) {
2041
+ let buffer = chunk & 1u;
2042
+ let next = 1u - buffer;
2043
+ ${bases('buffer')}
2044
+ ${declarations}
2045
+ if (chunk + 1u < ${chunks}u) {
2046
+ let stageChunk = chunk + 1u;
2047
+ ${prefetch}
2048
+ }
2049
+ ${body}
2050
+ if (chunk + 1u < ${chunks}u) {
2051
+ ${storePrefetched}
2052
+ }
2053
+ workgroupBarrier();
2054
+ }`;
2055
+ }
2056
+
2057
+ /** Fixed native tap offsets preserve the chunk/tap reduction order. */
2058
+ function subgroupMatrixTapLoop(config: NativeSubgroupMatrixConfig, body: string) {
2059
+ // Explicit taps reduce native matrix latency on M4 Max. Keep the browser
2060
+ // shader's loop form and both paths' original accumulation order.
2061
+ return config.dialect === 'naga'
2062
+ ? Array.from({ length: 9 }, (_, tap) => `{ let tap = ${tap}u; ${body} }`).join('\n')
2063
+ : `for (var tap = 0u; tap < 9u; tap++) { ${body} }`;
2064
+ }
2065
+
2066
+ /**
2067
+ * 3x3 convolution as an implicit GEMM on subgroup matrices. Each workgroup
2068
+ * computes 8 rows x 32 pixels x (fragments x S) output channels. For every
2069
+ * chunk of S input channels it stages a zero-padded halo tile and the chunk's
2070
+ * weights in workgroup memory, so each A fragment is a contiguous run of S
2071
+ * pixels for one tap. A decoder reads its concatenated sources (one of them
2072
+ * nearest-upsampled) while staging, like the other fused decoder kernels.
2073
+ */
2074
+ function createSubgroupMatrixConvShader(
2075
+ precision: NativeUNetPrecision,
2076
+ outputPrecision: NativeUNetPrecision,
2077
+ activation: Conv2DNodeSpec['activation'],
2078
+ inputBlocks: number,
2079
+ outputBlocks: number,
2080
+ config: NativeSubgroupMatrixConfig,
2081
+ outputFragments: number,
2082
+ doubleBuffer: boolean,
2083
+ decoder?: { sourceBlocks: readonly number[]; upsampledSource: 0 | 1 },
2084
+ pool = false
2085
+ ) {
2086
+ const size = config.size;
2087
+ const component = config.componentType;
2088
+ const result = config.resultType;
2089
+ const tileWidth = SUBGROUP_MATRIX_TILE_WIDTH;
2090
+ const haloWidth = tileWidth + 2;
2091
+ const haloRows = SUBGROUP_MATRIX_TILE_HEIGHT + 2;
2092
+ const buffers = doubleBuffer ? 2 : 1;
2093
+ const chunkBlocks = size / 4;
2094
+ const chunks = Math.ceil(inputBlocks / chunkBlocks);
2095
+ const outputChannels = outputFragments * size;
2096
+ const pixelFragments = tileWidth / size;
2097
+ const threads = SUBGROUP_MATRIX_SUBGROUPS * config.subgroupSize;
2098
+ const valueType = storageVecType(precision);
2099
+ const outputType = storageVecType(outputPrecision);
2100
+ const { left, right, load, store, multiplyAdd, declare, extensions } = subgroupMatrixDialect(config);
2101
+ const accumulators: string[] = [];
2102
+ for (let row = 0; row < 2; row++) {
2103
+ for (let pixel = 0; pixel < pixelFragments; pixel++) {
2104
+ for (let fragment = 0; fragment < outputFragments; fragment++) {
2105
+ accumulators.push(`acc${row}_${pixel}_${fragment}`);
2106
+ }
2107
+ }
2108
+ }
2109
+ const declarations = accumulators.map(declare).join('\n ');
2110
+ const rightLoads = Array.from({ length: outputFragments }, (_, fragment) =>
2111
+ `let b${fragment} = ${load(right, 'weightTile', `weightTileBase + tap * ${size * outputChannels}u + ${fragment * size}u`, `${outputChannels}u`)};`
2112
+ ).join('\n ');
2113
+ const multiplies: string[] = [];
2114
+ for (let row = 0; row < 2; row++) {
2115
+ for (let pixel = 0; pixel < pixelFragments; pixel++) {
2116
+ multiplies.push(
2117
+ `{
2118
+ let a = ${load(left, 'inputTile', `inputTileBase + ((rowBase + ${row}u + ky) * ${haloWidth}u + ${pixel * size}u + kx) * ${size}u`, `${size}u`)};
2119
+ ${Array.from({ length: outputFragments }, (_, fragment) =>
2120
+ `acc${row}_${pixel}_${fragment} = ${multiplyAdd}(a, b${fragment}, acc${row}_${pixel}_${fragment});`
2121
+ ).join('\n ')}
2122
+ }`
2123
+ );
2124
+ }
2125
+ }
2126
+ const tapBody = `let ky = tap / 3u;
2127
+ let kx = tap % 3u;
2128
+ ${rightLoads}
2129
+ ${multiplies.join('\n ')}`;
2130
+ const taps = subgroupMatrixTapLoop(config, tapBody);
2131
+ const epilogues: string[] = [];
2132
+ if (pool) {
2133
+ // Bias, ReLU and FP16 conversion are monotone, so pooling before them
2134
+ // preserves the separate conv -> FP16 -> max-pool result for finite values.
2135
+ for (let fragment = 0; fragment < outputFragments; fragment++) {
2136
+ epilogues.push(`{
2137
+ var pooled = vec4<f32>(-3.402823e38);
2138
+ let pixel = lane / ${chunkBlocks}u;
2139
+ let outputBlock = blockBase + ${fragment * chunkBlocks}u + lane % ${chunkBlocks}u;
2140
+ ${Array.from({ length: 2 }, (_, row) => `
2141
+ ${Array.from({ length: pixelFragments }, (_, pixel) =>
2142
+ store('resultTile', `resultBase + ${pixel * size * size}u`, `acc${row}_${pixel}_${fragment}`, `${size}u`)
2143
+ ).join('\n ')}
2144
+ workgroupBarrier();
2145
+ {
2146
+ let base = resultBase + pixel * ${size * 2}u + (lane % ${chunkBlocks}u) * 4u;
2147
+ let first = vec4<f32>(resultTile[base], resultTile[base + 1u], resultTile[base + 2u], resultTile[base + 3u]);
2148
+ let second = vec4<f32>(resultTile[base + ${size}u], resultTile[base + ${size + 1}u], resultTile[base + ${size + 2}u], resultTile[base + ${size + 3}u]);
2149
+ if (tileY + rowBase + ${row}u < params.inputHeight) {
2150
+ pooled = max(pooled, first);
2151
+ if (tileX + pixel * 2u + 1u < params.inputWidth) { pooled = max(pooled, second); }
2152
+ }
2153
+ }
2154
+ workgroupBarrier();`).join('\n ')}
2155
+ let outputX = tileX / 2u + pixel;
2156
+ let outputY = (tileY + rowBase) / 2u;
2157
+ if (outputX < params.outputWidth && outputY < params.outputHeight && outputBlock < ${outputBlocks}u) {
2158
+ let acc = pooled + bias[outputBlock];
2159
+ outputData[(outputY * params.outputWidth + outputX) * ${outputBlocks}u + outputBlock] = ${storeExpression(activationExpression('acc', activation), outputPrecision)};
2160
+ }
2161
+ }`);
2162
+ }
2163
+ } else {
2164
+ for (let row = 0; row < 2; row++) {
2165
+ for (let fragment = 0; fragment < outputFragments; fragment++) {
2166
+ epilogues.push(`{
2167
+ ${Array.from({ length: pixelFragments }, (_, pixel) =>
2168
+ store('resultTile', `resultBase + ${pixel * size * size}u`, `acc${row}_${pixel}_${fragment}`, `${size}u`)
2169
+ ).join('\n ')}
2170
+ workgroupBarrier();
2171
+ let outputY = tileY + rowBase + ${row}u;
2172
+ for (var e = lane; e < ${tileWidth * chunkBlocks}u; e += ${config.subgroupSize}u) {
2173
+ let pixel = e / ${chunkBlocks}u;
2174
+ let outputX = tileX + pixel;
2175
+ let outputBlock = blockBase + ${fragment * chunkBlocks}u + e % ${chunkBlocks}u;
2176
+ if (outputX < params.outputWidth && outputY < params.outputHeight && outputBlock < ${outputBlocks}u) {
2177
+ let base = resultBase + pixel * ${size}u + (e % ${chunkBlocks}u) * 4u;
2178
+ let acc = vec4<f32>(f32(resultTile[base]), f32(resultTile[base + 1u]),
2179
+ f32(resultTile[base + 2u]), f32(resultTile[base + 3u])) + bias[outputBlock];
2180
+ outputData[(outputY * params.outputWidth + outputX) * ${outputBlocks}u + outputBlock] = ${storeExpression(activationExpression('acc', activation), outputPrecision)};
2181
+ }
2182
+ }
2183
+ workgroupBarrier();
2184
+ }`);
2185
+ }
2186
+ }
2187
+ }
2188
+ const sourceRead = decoder
2189
+ ? (() => {
2190
+ const [blocks0, blocks1] = decoder.sourceBlocks;
2191
+ const coordinates = (source: 0 | 1) => source === decoder.upsampledSource
2192
+ ? `min(u32(inputY) / 2u, params.source${source}Height - 1u) * params.source${source}Width + min(u32(inputX) / 2u, params.source${source}Width - 1u)`
2193
+ : `u32(inputY) * params.source${source}Width + u32(inputX)`;
2194
+ return /* wgsl */ `if (block < ${blocks0}u) {
2195
+ value = input0[(${coordinates(0)}) * ${blocks0}u + block];
2196
+ } else {
2197
+ value = input1[(${coordinates(1)}) * ${blocks1}u + block - ${blocks0}u];
2198
+ }`;
2199
+ })()
2200
+ : `value = inputData[(u32(inputY) * params.inputWidth + u32(inputX)) * ${inputBlocks}u + block];`;
2201
+ const params = decoder
2202
+ ? `struct Params {
2203
+ outputWidth: u32,
2204
+ outputHeight: u32,
2205
+ outputBlocks: u32,
2206
+ inputBlocks: u32,
2207
+ source0Width: u32,
2208
+ source0Height: u32,
2209
+ source1Width: u32,
2210
+ source1Height: u32,
2211
+ }
2212
+ @group(0) @binding(0) var<storage, read> input0: array<${valueType}>;
2213
+ @group(0) @binding(1) var<storage, read> input1: array<${valueType}>;
2214
+ @group(0) @binding(2) var<storage, read> weights: array<${valueType}>;
2215
+ @group(0) @binding(3) var<storage, read> bias: array<vec4<f32>>;
2216
+ @group(0) @binding(4) var<storage, read_write> outputData: array<${outputType}>;
2217
+ @group(0) @binding(5) var<uniform> params: Params;`
2218
+ : `struct Params {
2219
+ inputWidth: u32,
2220
+ inputHeight: u32,
2221
+ outputWidth: u32,
2222
+ outputHeight: u32,
2223
+ inputBlocks: u32,
2224
+ outputBlocks: u32,
2225
+ }
2226
+ @group(0) @binding(0) var<storage, read> inputData: array<${valueType}>;
2227
+ @group(0) @binding(1) var<storage, read> weights: array<${valueType}>;
2228
+ @group(0) @binding(2) var<storage, read> bias: array<vec4<f32>>;
2229
+ @group(0) @binding(3) var<storage, read_write> outputData: array<${outputType}>;
2230
+ @group(0) @binding(4) var<uniform> params: Params;`;
2231
+ const imageWidth = decoder ? 'params.outputWidth' : 'params.inputWidth';
2232
+ const imageHeight = decoder ? 'params.outputHeight' : 'params.inputHeight';
2233
+ const needsF16 = precision === 'fp16' || outputPrecision === 'fp16' ||
2234
+ component === 'f16' || result === 'f16';
2235
+ return /* wgsl */ `${needsF16 ? 'enable f16;\n' : ''}${extensions}
2236
+ ${params}
2237
+
2238
+ var<workgroup> inputTile: array<${component}, ${buffers * haloRows * haloWidth * size}>;
2239
+ var<workgroup> weightTile: array<${component}, ${buffers * 9 * size * outputChannels}>;
2240
+ var<workgroup> resultTile: array<${result}, ${SUBGROUP_MATRIX_SUBGROUPS * tileWidth * size}>;
2241
+
2242
+ @compute @workgroup_size(${threads})
2243
+ fn main(
2244
+ @builtin(local_invocation_index) localIndex: u32,
2245
+ @builtin(workgroup_id) workgroupId: vec3<u32>,
2246
+ @builtin(subgroup_id) subgroup: u32,
2247
+ @builtin(subgroup_invocation_id) lane: u32
2248
+ ) {
2249
+ let tileX = workgroupId.x * ${tileWidth}u;
2250
+ let tileY = workgroupId.y * ${SUBGROUP_MATRIX_TILE_HEIGHT}u;
2251
+ let blockBase = workgroupId.z * ${outputChannels / 4}u;
2252
+ let rowBase = subgroup * 2u;
2253
+ let resultBase = subgroup * ${tileWidth * size}u;
2254
+ ${declarations}
2255
+
2256
+ ${subgroupMatrixChunkLoop(chunks, [
2257
+ {
2258
+ array: 'inputTile',
2259
+ vectors: haloRows * haloWidth * chunkBlocks,
2260
+ read: /* wgsl */ `let pixel = i / ${chunkBlocks}u;
2261
+ let block = stageChunk * ${chunkBlocks}u + i % ${chunkBlocks}u;
2262
+ let inputX = i32(tileX + pixel % ${haloWidth}u) - 1;
2263
+ let inputY = i32(tileY + pixel / ${haloWidth}u) - 1;
2264
+ if (block < ${inputBlocks}u && inputX >= 0 && inputY >= 0 &&
2265
+ inputX < i32(${imageWidth}) && inputY < i32(${imageHeight})) {
2266
+ ${sourceRead}
2267
+ }`
2268
+ },
2269
+ {
2270
+ array: 'weightTile',
2271
+ vectors: 9 * size * outputChannels / 4,
2272
+ read: /* wgsl */ `let quad = i % ${outputChannels / 4}u;
2273
+ let row = i / ${outputChannels / 4}u;
2274
+ let tap = row / ${size}u;
2275
+ let inputChannel = stageChunk * ${size}u + row % ${size}u;
2276
+ let outputBlock = blockBase + quad;
2277
+ if (inputChannel < ${inputBlocks * 4}u && outputBlock < ${outputBlocks}u) {
2278
+ value = weights[((tap * ${inputBlocks}u + inputChannel / 4u) * ${outputBlocks}u + outputBlock) * 4u + inputChannel % 4u];
2279
+ }`
2280
+ }
2281
+ ], taps, threads, valueType, component, doubleBuffer)}
2282
+
2283
+ ${epilogues.join('\n ')}
2284
+ }
2285
+ `;
2286
+ }
2287
+
2288
+ // Sub-pixel decomposition of a 3x3 convolution of a 2x nearest-upsampled
2289
+ // source: along each axis, output phase p reads two source pixels at offsets
2290
+ // SUBPIXEL_OFFSETS[p] with the 3x3 weight rows SUBPIXEL_TAPS[p][d] summed.
2291
+ const SUBPIXEL_OFFSETS: readonly (readonly number[])[] = [[-1, 0], [0, 1]];
2292
+ const SUBPIXEL_TAPS: readonly (readonly (readonly number[])[])[] = [[[0], [1, 2]], [[0, 1], [2]]];
2293
+
2294
+ /** Upsampled-source weight matrices per chunk: 4 phases x 2x2 taps. */
2295
+ const SUBPIXEL_MATRICES = 16;
2296
+
2297
+ /**
2298
+ * Packs a fused upsample/concat decoder for `createSubgroupMatrixSubpixelShader`:
2299
+ * first the upsampled source's combined weights, vec4 index
2300
+ * (((phase * 4 + tap) * upBlocks + block) * outputBlocks + outputBlock) * 4 + lane
2301
+ * with phase = py * 2 + px and tap = dy * 2 + dx, then the other source's
2302
+ * 3x3 weights in the k-major layout. Combined weights are summed in f32 and
2303
+ * rounded once to the storage precision.
2304
+ */
2305
+ function packSubpixelDecoderTensors(
2306
+ device: OIDNGPUDevice,
2307
+ id: string,
2308
+ tensors: ValidatedConvTensor,
2309
+ precision: NativeUNetPrecision,
2310
+ sourceBlocks: readonly number[],
2311
+ upsampledSource: 0 | 1
2312
+ ): PackedConvBuffers {
2313
+ const outputBlocks = blocksForChannels(tensors.outputChannels);
2314
+ const upBlocks = sourceBlocks[upsampledSource];
2315
+ const skipBlocks = sourceBlocks[1 - upsampledSource];
2316
+ const upBase = upsampledSource === 0 ? 0 : sourceBlocks[0] * 4;
2317
+ const skipBase = upsampledSource === 0 ? sourceBlocks[0] * 4 : 0;
2318
+ const upValues = SUBPIXEL_MATRICES * upBlocks * outputBlocks * 16;
2319
+ const values = new Float32Array(upValues + 9 * skipBlocks * outputBlocks * 16);
2320
+ const source = tensorFloat32Values(tensors.weight);
2321
+ const weight = (outputChannel: number, inputChannel: number, y: number, x: number) =>
2322
+ outputChannel < tensors.outputChannels && inputChannel < tensors.inputChannels
2323
+ ? source[((outputChannel * tensors.inputChannels + inputChannel) * 3 + y) * 3 + x]
2324
+ : 0;
2325
+ for (let outputChannel = 0; outputChannel < outputBlocks * 4; outputChannel++) {
2326
+ const outputBlock = outputChannel >> 2;
2327
+ const outputLane = outputChannel & 3;
2328
+ for (let channel = 0; channel < upBlocks * 4; channel++) {
2329
+ for (let phase = 0; phase < 4; phase++) {
2330
+ for (let tap = 0; tap < 4; tap++) {
2331
+ let sum = 0;
2332
+ for (const y of SUBPIXEL_TAPS[phase >> 1][tap >> 1]) {
2333
+ for (const x of SUBPIXEL_TAPS[phase & 1][tap & 1]) {
2334
+ sum += weight(outputChannel, upBase + channel, y, x);
2335
+ }
2336
+ }
2337
+ const vector = ((phase * 4 + tap) * upBlocks + (channel >> 2)) * outputBlocks + outputBlock;
2338
+ values[vector * 16 + (channel & 3) * 4 + outputLane] = sum;
2339
+ }
2340
+ }
2341
+ }
2342
+ for (let channel = 0; channel < skipBlocks * 4; channel++) {
2343
+ for (let tap = 0; tap < 9; tap++) {
2344
+ const vector = (tap * skipBlocks + (channel >> 2)) * outputBlocks + outputBlock;
2345
+ values[upValues + vector * 16 + (channel & 3) * 4 + outputLane] =
2346
+ weight(outputChannel, skipBase + channel, (tap / 3) | 0, tap % 3);
2347
+ }
2348
+ }
2349
+ }
2350
+ let bytes: Uint8Array;
2351
+ if (precision === 'fp16') {
2352
+ const half = new Uint16Array(values.length);
2353
+ for (let index = 0; index < values.length; index++) half[index] = numberToHalfBits(values[index]);
2354
+ bytes = new Uint8Array(half.buffer);
2355
+ } else {
2356
+ bytes = new Uint8Array(values.buffer);
2357
+ }
2358
+ const packedBias = new Float32Array(outputBlocks * 4);
2359
+ packedBias.set(tensorFloat32Values(tensors.bias));
2360
+ const weights = createMappedBuffer(device, `oidn/${id}/weights/${precision}/subpixel`, bytes, BufferUsage.STORAGE);
2361
+ try {
2362
+ return {
2363
+ weights,
2364
+ weightLayout: 'k-major',
2365
+ bias: createMappedBuffer(device, `oidn/${id}/bias`, new Uint8Array(packedBias.buffer), BufferUsage.STORAGE)
2366
+ };
2367
+ } catch (error) {
2368
+ weights.destroy();
2369
+ throw error;
2370
+ }
2371
+ }
2372
+
2373
+ /**
2374
+ * Fused upsample/concat decoder convolution with the sub-pixel decomposition
2375
+ * (weights from `packSubpixelDecoderTensors`). Subgroup s computes output
2376
+ * phase (py, px) = (s >> 1, s & 1): rows py, py + 2, ... of the 8-row tile and
2377
+ * every other pixel from px, so each fragment holds S same-phase pixels and
2378
+ * each combined weight tile feeds all of the subgroup's fragments. The
2379
+ * upsampled source is staged at half resolution, where a fragment is S
2380
+ * contiguous source pixels; the other source keeps its 3x3 taps and loads
2381
+ * its fragments from the full-resolution halo with a two-pixel stride.
2382
+ */
2383
+ function createSubgroupMatrixSubpixelShader(
2384
+ precision: NativeUNetPrecision,
2385
+ outputPrecision: NativeUNetPrecision,
2386
+ activation: Conv2DNodeSpec['activation'],
2387
+ outputBlocks: number,
2388
+ config: NativeSubgroupMatrixConfig,
2389
+ outputFragments: number,
2390
+ sourceBlocks: readonly number[],
2391
+ upsampledSource: 0 | 1,
2392
+ doubleBuffer: boolean
2393
+ ) {
2394
+ const size = config.size;
2395
+ const buffers = doubleBuffer ? 2 : 1;
2396
+ const component = config.componentType;
2397
+ const result = config.resultType;
2398
+ const tileWidth = SUBGROUP_MATRIX_TILE_WIDTH;
2399
+ const tileHeight = SUBGROUP_MATRIX_TILE_HEIGHT;
2400
+ const haloWidth = tileWidth + 2;
2401
+ const haloRows = tileHeight + 2;
2402
+ const upWidth = tileWidth / 2 + 2;
2403
+ const upRows = tileHeight / 2 + 2;
2404
+ const chunkBlocks = size / 4;
2405
+ const upBlocks = sourceBlocks[upsampledSource];
2406
+ const skipBlocks = sourceBlocks[1 - upsampledSource];
2407
+ const upChunks = Math.ceil(upBlocks / chunkBlocks);
2408
+ const skipChunks = Math.ceil(skipBlocks / chunkBlocks);
2409
+ const outputChannels = outputFragments * size;
2410
+ const rows = tileHeight / 2;
2411
+ const segments = tileWidth / 2 / size;
2412
+ const threads = SUBGROUP_MATRIX_SUBGROUPS * config.subgroupSize;
2413
+ const valueType = storageVecType(precision);
2414
+ const outputType = storageVecType(outputPrecision);
2415
+ const { left, right, load, store, multiplyAdd, declare, extensions } = subgroupMatrixDialect(config);
2416
+ const accumulator = (row: number, segment: number, fragment: number) =>
2417
+ `acc${row}_${segment}_${fragment}`;
2418
+ const declarations: string[] = [];
2419
+ for (let row = 0; row < rows; row++) {
2420
+ for (let segment = 0; segment < segments; segment++) {
2421
+ for (let fragment = 0; fragment < outputFragments; fragment++) {
2422
+ declarations.push(declare(accumulator(row, segment, fragment)));
2423
+ }
2424
+ }
2425
+ }
2426
+ const multiplyRows = (aOffset: (row: number, segment: number) => string, stride: string) => {
2427
+ const lines: string[] = [];
2428
+ for (let row = 0; row < rows; row++) {
2429
+ for (let segment = 0; segment < segments; segment++) {
2430
+ lines.push(`{
2431
+ let a = ${load(left, 'inputTile', `inputTileBase + ${aOffset(row, segment)}`, stride)};
2432
+ ${Array.from({ length: outputFragments }, (_, fragment) =>
2433
+ `${accumulator(row, segment, fragment)} = ${multiplyAdd}(a, b${fragment}, ${accumulator(row, segment, fragment)});`
2434
+ ).join('\n ')}
2435
+ }`);
2436
+ }
2437
+ }
2438
+ return lines.join('\n ');
2439
+ };
2440
+ const rightLoads = (matrix: string) => Array.from({ length: outputFragments }, (_, fragment) =>
2441
+ `let b${fragment} = ${load(right, 'weightTile', `weightTileBase + ${matrix} * ${size * outputChannels}u + ${fragment * size}u`, `${outputChannels}u`)};`
2442
+ ).join('\n ');
2443
+ const sourceIndex = (source: 0 | 1, x: string, y: string) =>
2444
+ `(${y} * params.source${source}Width + ${x}) * ${sourceBlocks[source]}u`;
2445
+ // Each half of a double-buffered array must fit the larger of the two loops.
2446
+ const inputVectors = Math.max(haloRows * haloWidth, upRows * upWidth) * chunkBlocks;
2447
+ const weightVectors = SUBPIXEL_MATRICES * size * outputChannels / 4;
2448
+ const weightStage = (matrices: number, blocks: number, base: number): SubgroupMatrixStage => ({
2449
+ array: 'weightTile',
2450
+ vectors: matrices * size * outputChannels / 4,
2451
+ stride: weightVectors,
2452
+ read: /* wgsl */ `let quad = i % ${outputChannels / 4}u;
2453
+ let row = i / ${outputChannels / 4}u;
2454
+ let matrix = row / ${size}u;
2455
+ let inputChannel = stageChunk * ${size}u + row % ${size}u;
2456
+ let outputBlock = blockBase + quad;
2457
+ if (inputChannel < ${blocks * 4}u && outputBlock < ${outputBlocks}u) {
2458
+ value = weights[${base}u + ((matrix * ${blocks}u + inputChannel / 4u) * ${outputBlocks}u + outputBlock) * 4u + inputChannel % 4u];
2459
+ }`
2460
+ });
2461
+ const inputStage = (rowsStaged: number, width: number, read: string): SubgroupMatrixStage => ({
2462
+ array: 'inputTile',
2463
+ vectors: rowsStaged * width * chunkBlocks,
2464
+ stride: inputVectors,
2465
+ read: /* wgsl */ `let pixel = i / ${chunkBlocks}u;
2466
+ let block = stageChunk * ${chunkBlocks}u + i % ${chunkBlocks}u;
2467
+ ${read}`
2468
+ });
2469
+ const upRead = /* wgsl */ `let inputX = i32(tileX / 2u + pixel % ${upWidth}u) - 1;
2470
+ let inputY = i32(tileY / 2u + pixel / ${upWidth}u) - 1;
2471
+ if (block < ${upBlocks}u && inputX >= 0 && inputY >= 0 &&
2472
+ inputX < i32(params.source${upsampledSource}Width) && inputY < i32(params.source${upsampledSource}Height)) {
2473
+ value = input${upsampledSource}[${sourceIndex(upsampledSource, 'u32(inputX)', 'u32(inputY)')} + block];
2474
+ }`;
2475
+ const skipSource = (1 - upsampledSource) as 0 | 1;
2476
+ const skipRead = /* wgsl */ `let inputX = i32(tileX + pixel % ${haloWidth}u) - 1;
2477
+ let inputY = i32(tileY + pixel / ${haloWidth}u) - 1;
2478
+ if (block < ${skipBlocks}u && inputX >= 0 && inputY >= 0 &&
2479
+ inputX < i32(params.outputWidth) && inputY < i32(params.outputHeight)) {
2480
+ value = input${skipSource}[${sourceIndex(skipSource, 'u32(inputX)', 'u32(inputY)')} + block];
2481
+ }`;
2482
+ const upMultiplies: string[] = [];
2483
+ for (let tapY = 0; tapY < 2; tapY++) {
2484
+ for (let tapX = 0; tapX < 2; tapX++) {
2485
+ // Source offsets of this tap for the subgroup's phase, plus the halo.
2486
+ upMultiplies.push(`{
2487
+ let matrix = phase * 4u + ${tapY * 2 + tapX}u;
2488
+ let offsetY = select(${SUBPIXEL_OFFSETS[0][tapY] + 1}u, ${SUBPIXEL_OFFSETS[1][tapY] + 1}u, phaseY == 1u);
2489
+ let offsetX = select(${SUBPIXEL_OFFSETS[0][tapX] + 1}u, ${SUBPIXEL_OFFSETS[1][tapX] + 1}u, phaseX == 1u);
2490
+ ${rightLoads('matrix')}
2491
+ ${multiplyRows((row, segment) => `((${row}u + offsetY) * ${upWidth}u + ${segment * size}u + offsetX) * ${size}u`, `${size}u`)}
2492
+ }`);
2493
+ }
2494
+ }
2495
+ const skipTapBody = `let ky = tap / 3u;
2496
+ let kx = tap % 3u;
2497
+ ${rightLoads('tap')}
2498
+ ${multiplyRows((row, segment) => `((phaseY + ${row * 2}u + ky) * ${haloWidth}u + phaseX + kx + ${segment * size * 2}u) * ${size}u`, `${size * 2}u`)}`;
2499
+ const skipTaps = subgroupMatrixTapLoop(config, skipTapBody);
2500
+ const epilogues: string[] = [];
2501
+ for (let row = 0; row < rows; row++) {
2502
+ for (let fragment = 0; fragment < outputFragments; fragment++) {
2503
+ epilogues.push(`{
2504
+ ${Array.from({ length: segments }, (_, segment) =>
2505
+ store('resultTile', `resultBase + ${segment * size * size}u`, accumulator(row, segment, fragment), `${size}u`)
2506
+ ).join('\n ')}
2507
+ workgroupBarrier();
2508
+ let outputY = tileY + phaseY + ${row * 2}u;
2509
+ for (var e = lane; e < ${(tileWidth / 2) * chunkBlocks}u; e += ${config.subgroupSize}u) {
2510
+ let pixel = e / ${chunkBlocks}u;
2511
+ let outputX = tileX + phaseX + pixel * 2u;
2512
+ let outputBlock = blockBase + ${fragment * chunkBlocks}u + e % ${chunkBlocks}u;
2513
+ if (outputX < params.outputWidth && outputY < params.outputHeight && outputBlock < ${outputBlocks}u) {
2514
+ let base = resultBase + pixel * ${size}u + (e % ${chunkBlocks}u) * 4u;
2515
+ let acc = vec4<f32>(f32(resultTile[base]), f32(resultTile[base + 1u]),
2516
+ f32(resultTile[base + 2u]), f32(resultTile[base + 3u])) + bias[outputBlock];
2517
+ outputData[(outputY * params.outputWidth + outputX) * ${outputBlocks}u + outputBlock] = ${storeExpression(activationExpression('acc', activation), outputPrecision)};
2518
+ }
2519
+ }
2520
+ workgroupBarrier();
2521
+ }`);
2522
+ }
2523
+ }
2524
+ const needsF16 = precision === 'fp16' || outputPrecision === 'fp16' ||
2525
+ component === 'f16' || result === 'f16';
2526
+ return /* wgsl */ `${needsF16 ? 'enable f16;\n' : ''}${extensions}
2527
+ struct Params {
2528
+ outputWidth: u32,
2529
+ outputHeight: u32,
2530
+ outputBlocks: u32,
2531
+ inputBlocks: u32,
2532
+ source0Width: u32,
2533
+ source0Height: u32,
2534
+ source1Width: u32,
2535
+ source1Height: u32,
2536
+ }
2537
+ @group(0) @binding(0) var<storage, read> input0: array<${valueType}>;
2538
+ @group(0) @binding(1) var<storage, read> input1: array<${valueType}>;
2539
+ @group(0) @binding(2) var<storage, read> weights: array<${valueType}>;
2540
+ @group(0) @binding(3) var<storage, read> bias: array<vec4<f32>>;
2541
+ @group(0) @binding(4) var<storage, read_write> outputData: array<${outputType}>;
2542
+ @group(0) @binding(5) var<uniform> params: Params;
2543
+
2544
+ var<workgroup> inputTile: array<${component}, ${buffers * inputVectors * 4}>;
2545
+ var<workgroup> weightTile: array<${component}, ${buffers * weightVectors * 4}>;
2546
+ var<workgroup> resultTile: array<${result}, ${SUBGROUP_MATRIX_SUBGROUPS * (tileWidth / 2) * size}>;
2547
+
2548
+ @compute @workgroup_size(${threads})
2549
+ fn main(
2550
+ @builtin(local_invocation_index) localIndex: u32,
2551
+ @builtin(workgroup_id) workgroupId: vec3<u32>,
2552
+ @builtin(subgroup_id) subgroup: u32,
2553
+ @builtin(subgroup_invocation_id) lane: u32
2554
+ ) {
2555
+ let tileX = workgroupId.x * ${tileWidth}u;
2556
+ let tileY = workgroupId.y * ${tileHeight}u;
2557
+ let blockBase = workgroupId.z * ${outputChannels / 4}u;
2558
+ let phase = subgroup;
2559
+ let phaseY = subgroup >> 1u;
2560
+ let phaseX = subgroup & 1u;
2561
+ let resultBase = subgroup * ${(tileWidth / 2) * size}u;
2562
+ ${declarations.join('\n ')}
2563
+
2564
+ ${subgroupMatrixChunkLoop(upChunks, [inputStage(upRows, upWidth, upRead), weightStage(SUBPIXEL_MATRICES, upBlocks, 0)],
2565
+ upMultiplies.join('\n '), threads, valueType, component, doubleBuffer)}
2566
+
2567
+ ${subgroupMatrixChunkLoop(skipChunks, [
2568
+ inputStage(haloRows, haloWidth, skipRead),
2569
+ weightStage(9, skipBlocks, SUBPIXEL_MATRICES * upBlocks * outputBlocks * 4)
2570
+ ], skipTaps, threads, valueType, component, doubleBuffer)}
2571
+
2572
+ ${epilogues.join('\n ')}
2573
+ }
2574
+ `;
2575
+ }
2576
+
2577
+ function executableInputs(node: ExecutableModelNode): readonly string[] {
2578
+ if (node.op === 'concat') return node.inputs;
2579
+ if (node.op === 'fusedUpsampleConcatConv2d') {
2580
+ return node.inputs.map((input) => input.value);
2581
+ }
2582
+ return [node.input];
2583
+ }
2584
+
2585
+ export function resolveNativeUNetPrecision(
2586
+ device: OIDNGPUDevice,
2587
+ requested: NativeUNetPrecisionSetting = 'auto'
2588
+ ): NativeUNetPrecision {
2589
+ const hasShaderF16 = device.features.has('shader-f16');
2590
+ if (requested === 'fp16' && !hasShaderF16) {
2591
+ throw new Error(
2592
+ 'OIDN FP16 was requested but the OIDNGPUDevice does not have shader-f16 enabled'
2593
+ );
2594
+ }
2595
+ if (requested === 'auto') return hasShaderF16 ? 'fp16' : 'fp32';
2596
+ return requested;
2597
+ }
2598
+
2599
+ /**
2600
+ * Native, model-driven OIDN U-Net executor without asynchronous operations, so
2601
+ * that scriptc can compile it for native hosts. `NativeUNetExecutor` adds the
2602
+ * browser's asynchronous preparation, CPU readback and profiling.
2603
+ */
2604
+ export class NativeUNetCore {
2605
+ readonly usedKernels = new Set<NativeUNetKernel>();
2606
+
2607
+ /** Immutable packed weights and biases, for native resource sharing. */
2608
+ get weightBuffers(): OIDNGPUBuffer[] {
2609
+ return [...this._packedConvs.values()].flatMap(packed => [packed.weights, packed.bias]);
2610
+ }
2611
+
2612
+ readonly precision: NativeUNetPrecision;
2613
+ readonly kernelSetting: NativeUNetKernelSetting;
2614
+ readonly gemm: Readonly<Required<NativeUNetGemmOptions>>;
2615
+ readonly maxSpatialInputBlocks: number;
2616
+ readonly subgroupsAvailable: boolean;
2617
+ /** Selected configuration when the `subgroup-matrix` kernel can run. */
2618
+ readonly subgroupMatrix?: NativeSubgroupMatrixConfig;
2619
+ readonly subgroupMatrixOutputFragments: number;
2620
+ readonly subgroupMatrixSubPixel: boolean;
2621
+ readonly subgroupMatrixDoubleBuffer: boolean;
2622
+ private _subgroupMatrixFixedFragments: boolean;
2623
+
2624
+ protected _model: UNetModelGraph;
2625
+ private _gemmByOutputBlocks = new Map<number, Readonly<Required<NativeUNetGemmOptions>>>();
2626
+ private _packedConvs = new Map<string, PackedConvBuffers>();
2627
+ protected _pipelineCache: Map<string, OIDNGPUComputePipeline>;
2628
+ private _executionCache = new Map<string, CachedExecution>();
2629
+ protected _retiredExecutions = new Set<CachedExecution>();
2630
+ private _clock = 0;
2631
+ private _shapeCacheSize: number;
2632
+ private _profileNextExecution = false;
2633
+ protected _profileOperations = 0;
2634
+ protected _resources = new OIDNResourceTracker();
2635
+ protected _disposed = false;
2636
+
2637
+ constructor(
2638
+ protected _device: OIDNGPUDevice,
2639
+ model: ValidatedUNetModel,
2640
+ options: NativeUNetOptions = {}
2641
+ ) {
2642
+ this._pipelineCache = sharedPipelineCache(_device);
2643
+ this.precision = resolveNativeUNetPrecision(
2644
+ _device,
2645
+ options.precision ?? 'auto'
2646
+ );
2647
+ this.kernelSetting = options.kernel ?? 'auto';
2648
+ const workgroupSize = options.gemm?.workgroupSize ?? [8, 8];
2649
+ this.gemm = Object.freeze({
2650
+ tilePolicy: options.gemm?.tilePolicy ?? (options.gemm?.workgroupSize ? 'fixed' : 'output-aligned'),
2651
+ decoderLoad: options.gemm?.decoderLoad ?? 'per-load',
2652
+ decoderSubPixel: options.gemm?.decoderSubPixel ?? (this.precision === 'fp16'),
2653
+ unrollRows: options.gemm?.unrollRows ?? false,
2654
+ poolLayout: options.gemm?.poolLayout ?? 'channels',
2655
+ sharedLayout: options.gemm?.sharedLayout ?? (this.precision === 'fp16' ? 'padded-input' : 'padded'),
2656
+ accumulationOrder: options.gemm?.accumulationOrder ?? 'k-major',
2657
+ finalLayer: options.gemm?.finalLayer ?? 'shared-auto',
2658
+ loadMode: options.gemm?.loadMode ?? 'native',
2659
+ addressMode: options.gemm?.addressMode ?? 'incremental',
2660
+ weightLayout: options.gemm?.weightLayout ?? 'k-major',
2661
+ rowsPerThread: options.gemm?.rowsPerThread ?? 8,
2662
+ workgroupSize: Object.freeze([workgroupSize[0], workgroupSize[1]]) as NativeUNetGemmWorkgroup
2663
+ });
2664
+ if (!['fixed', 'output-aligned'].includes(this.gemm.tilePolicy)) {
2665
+ throw new Error(`Unsupported GEMM tile policy: ${this.gemm.tilePolicy}`);
2666
+ }
2667
+ if (!['per-load', 'source-first'].includes(this.gemm.decoderLoad)) {
2668
+ throw new Error(`Unsupported GEMM decoder load: ${this.gemm.decoderLoad}`);
2669
+ }
2670
+ if (!['spatial', 'channels'].includes(this.gemm.poolLayout)) {
2671
+ throw new Error(`Unsupported GEMM pool layout: ${this.gemm.poolLayout}`);
2672
+ }
2673
+ if (!['linear', 'padded', 'padded-input', 'padded-weights'].includes(this.gemm.sharedLayout)) {
2674
+ throw new Error(`Unsupported GEMM shared layout: ${this.gemm.sharedLayout}`);
2675
+ }
2676
+ if (!['k-major', 'row-major'].includes(this.gemm.accumulationOrder)) {
2677
+ throw new Error(`Unsupported GEMM accumulation order: ${this.gemm.accumulationOrder}`);
2678
+ }
2679
+ if (!['direct', 'shared-input', 'shared-input-weights', 'shared-auto'].includes(this.gemm.finalLayer)) {
2680
+ throw new Error(`Unsupported GEMM final layer: ${this.gemm.finalLayer}`);
2681
+ }
2682
+ if (!['native', 'packed-weights', 'packed-all'].includes(this.gemm.loadMode)) {
2683
+ throw new Error(`Unsupported GEMM load mode: ${this.gemm.loadMode}`);
2684
+ }
2685
+ if (!['analytic', 'incremental', 'base-offset'].includes(this.gemm.addressMode)) {
2686
+ throw new Error(`Unsupported GEMM address mode: ${this.gemm.addressMode}`);
2687
+ }
2688
+ if (!['output-major', 'k-major'].includes(this.gemm.weightLayout)) {
2689
+ throw new Error(`Unsupported GEMM weight layout: ${this.gemm.weightLayout}`);
2690
+ }
2691
+ const tile = gemmTile(this.gemm);
2692
+ const tileStorageBytes =
2693
+ (gemmSharedSizes(this.gemm).input + gemmSharedSizes(this.gemm).weights) *
2694
+ 4 * (this.precision === 'fp16' ? 2 : 4);
2695
+ if (
2696
+ ![2, 4, 8].includes(tile.rowsPerThread) ||
2697
+ ![4, 8, 16].includes(tile.workgroupX) ||
2698
+ ![4, 8].includes(tile.workgroupY) ||
2699
+ workgroupSize.length !== 2 ||
2700
+ tile.workgroupX > _device.limits.maxComputeWorkgroupSizeX ||
2701
+ tile.workgroupY > _device.limits.maxComputeWorkgroupSizeY ||
2702
+ tile.workgroupX * tile.workgroupY > _device.limits.maxComputeInvocationsPerWorkgroup ||
2703
+ tileStorageBytes > _device.limits.maxComputeWorkgroupStorageSize
2704
+ ) {
2705
+ throw new Error('Unsupported GEMM tile configuration for this OIDNGPUDevice');
2706
+ }
2707
+ this.subgroupsAvailable = _device.features.has(
2708
+ 'subgroups'
2709
+ );
2710
+ const matrixOptions = options.subgroupMatrix ?? {};
2711
+ const matrixConfig = this.kernelSetting === 'subgroup-matrix'
2712
+ ? selectSubgroupMatrixConfig(_device, matrixOptions.accumulation)
2713
+ : undefined;
2714
+ this.subgroupMatrixOutputFragments = matrixConfig
2715
+ ? subgroupMatrixFragments(matrixConfig, matrixOptions)
2716
+ : 0;
2717
+ this.subgroupMatrixSubPixel = matrixOptions.subPixel ?? true;
2718
+ this._subgroupMatrixFixedFragments = matrixOptions.outputFragments !== undefined;
2719
+ const fitsSharedMemory = (doubleBuffer: boolean) => matrixConfig !== undefined &&
2720
+ subgroupMatrixSharedBytes(
2721
+ matrixConfig, this.subgroupMatrixOutputFragments, this.subgroupMatrixSubPixel, doubleBuffer
2722
+ ) <= _device.limits.maxComputeWorkgroupStorageSize;
2723
+ // A 16x16 matrix with two fragments can exceed a browser's 32 KiB limit.
2724
+ // Keep hardware matrices available with one fragment before using GEMM.
2725
+ if (matrixConfig && matrixOptions.outputFragments === undefined && !fitsSharedMemory(false)) {
2726
+ this.subgroupMatrixOutputFragments = 1;
2727
+ }
2728
+ this.subgroupMatrixDoubleBuffer = (matrixOptions.doubleBuffer ?? false) && fitsSharedMemory(true);
2729
+ if (
2730
+ matrixConfig &&
2731
+ SUBGROUP_MATRIX_SUBGROUPS * matrixConfig.subgroupSize <= _device.limits.maxComputeInvocationsPerWorkgroup &&
2732
+ SUBGROUP_MATRIX_SUBGROUPS * matrixConfig.subgroupSize <= _device.limits.maxComputeWorkgroupSizeX &&
2733
+ fitsSharedMemory(this.subgroupMatrixDoubleBuffer)
2734
+ ) {
2735
+ this.subgroupMatrix = matrixConfig;
2736
+ }
2737
+ this.maxSpatialInputBlocks =
2738
+ this.precision === 'fp16' &&
2739
+ _device.limits.maxComputeInvocationsPerWorkgroup >=
2740
+ SPATIAL_CONV_WORKGROUP * SPATIAL_CONV_WORKGROUP &&
2741
+ _device.limits.maxComputeWorkgroupSizeX >= SPATIAL_CONV_WORKGROUP &&
2742
+ _device.limits.maxComputeWorkgroupSizeY >= SPATIAL_CONV_WORKGROUP
2743
+ ? Math.floor(
2744
+ _device.limits.maxComputeWorkgroupStorageSize /
2745
+ (SPATIAL_CONV_PATCH * SPATIAL_CONV_PATCH * 4 * 2)
2746
+ )
2747
+ : 0;
2748
+ this._shapeCacheSize = Math.max(1, options.shapeCacheSize ?? 2);
2749
+
2750
+ if (
2751
+ model.inputChannels % 3 !== 0 ||
2752
+ model.inputChannels < 3 ||
2753
+ model.inputChannels > 9
2754
+ ) {
2755
+ throw new Error(
2756
+ `Native OIDN expects 3, 6, or 9 input channels, got ${model.inputChannels}`
2757
+ );
2758
+ }
2759
+ this._model = {
2760
+ spec: model.spec,
2761
+ inputChannels: model.inputChannels,
2762
+ outputChannels: model.outputChannels,
2763
+ channelsByValue: new Map(model.channelsByValue),
2764
+ convChannels: new Map(model.convChannels)
2765
+ };
2766
+ const subPixelDecoders = new Map<string, { sourceBlocks: number[]; upsampledSource: 0 | 1 }>();
2767
+ for (const node of optimizeModelGraph(this._model, {
2768
+ fuseConvPool: this._matrixPool()
2769
+ }).nodes) {
2770
+ if (node.op === 'fusedUpsampleConcatConv2d') {
2771
+ const decoder = this._subPixelDecoder(node);
2772
+ if (decoder) subPixelDecoders.set(node.conv.id, decoder);
2773
+ }
2774
+ if (
2775
+ node.op !== 'conv2d' &&
2776
+ node.op !== 'maxPool2d' &&
2777
+ node.op !== 'fusedConvReluMaxPool2d' &&
2778
+ node.op !== 'fusedUpsampleConcatConv2d'
2779
+ ) {
2780
+ throw new Error(
2781
+ `Native OIDN descriptor ${model.spec.id} leaves unsupported ` +
2782
+ `${node.op} node ${node.id} after graph optimization`
2783
+ );
2784
+ }
2785
+ }
2786
+ try {
2787
+ for (const [id, tensors] of model.convTensors) {
2788
+ // Kernel selection is shape-independent. Pack only the active layout
2789
+ // for this executor, keeping direct/spatial/subgroup and final weights
2790
+ // in their original ABI without duplicating GPU allocations.
2791
+ const kernel = this._selectConvKernel(
2792
+ blocksForChannels(tensors.inputChannels), id === model.spec.output
2793
+ );
2794
+ const weightLayout = kernel === 'implicit-gemm'
2795
+ ? this.gemm.weightLayout
2796
+ : kernel === 'subgroup-matrix'
2797
+ ? 'k-major'
2798
+ : 'output-major';
2799
+ const decoder = subPixelDecoders.get(id);
2800
+ const packed = decoder
2801
+ ? packSubpixelDecoderTensors(
2802
+ _device, id, tensors, this.precision, decoder.sourceBlocks, decoder.upsampledSource
2803
+ )
2804
+ : packConvTensors(
2805
+ _device, id, tensors, this.precision, weightLayout
2806
+ );
2807
+ this._resources.track('gpu-buffer', packed.weights);
2808
+ this._resources.track('gpu-buffer', packed.bias);
2809
+ this._packedConvs.set(id, packed);
2810
+ }
2811
+ } catch (error) {
2812
+ for (const packed of this._packedConvs.values()) {
2813
+ this._releaseBuffer(packed.weights);
2814
+ this._releaseBuffer(packed.bias);
2815
+ }
2816
+ this._packedConvs.clear();
2817
+ throw error;
2818
+ }
2819
+ }
2820
+
2821
+ private _pipeline(key: string, code: string) {
2822
+ let pipeline = this._pipelineCache.get(key);
2823
+ if (!pipeline) {
2824
+ pipeline = this._device.createComputePipeline({
2825
+ label: `oidn/${key}`,
2826
+ layout: 'auto',
2827
+ compute: {
2828
+ module: this._device.createShaderModule({
2829
+ label: `oidn/${key}`,
2830
+ code
2831
+ }),
2832
+ entryPoint: 'main'
2833
+ }
2834
+ });
2835
+ this._pipelineCache.set(key, pipeline);
2836
+ }
2837
+ return pipeline;
2838
+ }
2839
+
2840
+
2841
+
2842
+ protected _nodePipelineSpec(
2843
+ node: ExecutableModelNode,
2844
+ isFinal: boolean,
2845
+ fragments = this.subgroupMatrixOutputFragments
2846
+ ): NativePipelineSpec {
2847
+ if (node.op === 'conv2d') {
2848
+ const outputPrecision = isFinal ? 'fp32' : this.precision;
2849
+ const inputBlocks = blocksForChannels(
2850
+ this._model.convChannels.get(node.id)!.inputChannels
2851
+ );
2852
+ const outputBlocks = blocksForChannels(
2853
+ this._model.convChannels.get(node.id)!.outputChannels
2854
+ );
2855
+ const gemm = this._gemmForOutput(outputBlocks);
2856
+ const kernel = this._selectConvKernel(inputBlocks, isFinal);
2857
+ const cacheFinalWeights = gemm.finalLayer === 'shared-input-weights' ||
2858
+ (gemm.finalLayer === 'shared-auto' &&
2859
+ finalRgbSharedMemoryBytes(this.precision, inputBlocks, true) <=
2860
+ this._device.limits.maxComputeWorkgroupStorageSize);
2861
+ if (
2862
+ kernel !== 'subgroup-matrix' &&
2863
+ isFinal && this._model.convChannels.get(node.id)!.outputChannels === 3 &&
2864
+ (this.kernelSetting === 'auto' || this.kernelSetting === 'implicit-gemm' ||
2865
+ this.kernelSetting === 'subgroup-matrix') &&
2866
+ gemm.finalLayer !== 'direct' &&
2867
+ this._device.limits.maxComputeWorkgroupSizeX >= 8 &&
2868
+ this._device.limits.maxComputeWorkgroupSizeY >= 8 &&
2869
+ this._device.limits.maxComputeInvocationsPerWorkgroup >= 64 &&
2870
+ finalRgbSharedMemoryBytes(this.precision, inputBlocks, cacheFinalWeights) <=
2871
+ this._device.limits.maxComputeWorkgroupStorageSize
2872
+ ) {
2873
+ return {
2874
+ key: `conv-final-rgb/${this.precision}/${node.activation}/in${inputBlocks}/weights-${cacheFinalWeights ? 'shared' : 'storage'}`,
2875
+ kernel: 'direct',
2876
+ code: createFinalRgbShader(this.precision, node.activation, inputBlocks, cacheFinalWeights)
2877
+ };
2878
+ }
2879
+ if (kernel === 'subgroup-matrix') {
2880
+ return this._subgroupMatrixSpec(
2881
+ `conv/${node.activation}/in${inputBlocks}/out${outputBlocks}`,
2882
+ outputPrecision,
2883
+ node.activation,
2884
+ inputBlocks,
2885
+ outputBlocks,
2886
+ fragments
2887
+ );
2888
+ }
2889
+ const key =
2890
+ `conv-${kernel}/${this.precision}/` +
2891
+ `${outputPrecision}/${node.activation}/` +
2892
+ `in${inputBlocks}/out${outputBlocks}` +
2893
+ (kernel === 'implicit-gemm'
2894
+ ? `/address-${gemm.addressMode}/weights-${gemm.weightLayout}-v1` +
2895
+ `/tile-${gemm.workgroupSize.join('x')}-r${gemm.rowsPerThread}/loads-${gemm.loadMode}/shared-${gemm.sharedLayout}/acc-${gemm.accumulationOrder}/unroll-${Number(gemm.unrollRows)}`
2896
+ : '');
2897
+ return {
2898
+ key,
2899
+ kernel,
2900
+ code: kernel === 'implicit-gemm'
2901
+ ? createTiledConvShader(
2902
+ this.precision,
2903
+ outputPrecision,
2904
+ node.activation,
2905
+ inputBlocks,
2906
+ outputBlocks,
2907
+ gemm
2908
+ )
2909
+ : kernel === 'spatial'
2910
+ ? createSpatialConvShader(
2911
+ this.precision,
2912
+ outputPrecision,
2913
+ node.activation,
2914
+ inputBlocks,
2915
+ outputBlocks
2916
+ )
2917
+ : kernel === 'subgroup'
2918
+ ? createSubgroupConvShader(
2919
+ this.precision,
2920
+ outputPrecision,
2921
+ node.activation,
2922
+ inputBlocks,
2923
+ outputBlocks
2924
+ )
2925
+ : createConvShader(
2926
+ this.precision,
2927
+ outputPrecision,
2928
+ node.activation,
2929
+ inputBlocks,
2930
+ outputBlocks
2931
+ )
2932
+ };
2933
+ }
2934
+ if (node.op === 'maxPool2d') {
2935
+ const outputBlocks = blocksForChannels(
2936
+ this._model.channelsByValue.get(node.id)!
2937
+ );
2938
+ const coalesced = this._coalescedPool();
2939
+ const key = `max-pool/${this.precision}/out${outputBlocks}/${coalesced ? 'channels' : 'spatial'}`;
2940
+ return {
2941
+ key,
2942
+ kernel: 'direct',
2943
+ code: createMaxPoolShader(this.precision, outputBlocks, coalesced)
2944
+ };
2945
+ }
2946
+ if (node.op === 'fusedConvReluMaxPool2d') {
2947
+ const inputBlocks = blocksForChannels(
2948
+ this._model.convChannels.get(node.conv.id)!.inputChannels
2949
+ );
2950
+ const outputBlocks = blocksForChannels(
2951
+ this._model.convChannels.get(node.conv.id)!.outputChannels
2952
+ );
2953
+ if (this._matrixPool()) {
2954
+ return this._subgroupMatrixSpec(
2955
+ `conv-pool/${node.conv.activation}/in${inputBlocks}/out${outputBlocks}`,
2956
+ this.precision, node.conv.activation, inputBlocks, outputBlocks, fragments, undefined, true
2957
+ );
2958
+ }
2959
+ const key =
2960
+ `conv-pool/${this.precision}/${node.conv.activation}/` +
2961
+ `in${inputBlocks}/out${outputBlocks}`;
2962
+ return {
2963
+ key,
2964
+ kernel: 'direct',
2965
+ code: createFusedConvPoolShader(
2966
+ this.precision,
2967
+ node.conv.activation,
2968
+ inputBlocks,
2969
+ outputBlocks
2970
+ )
2971
+ };
2972
+ }
2973
+ if (node.op === 'fusedUpsampleConcatConv2d') {
2974
+ if (node.inputs.length !== 2) {
2975
+ throw new Error(`Native fused decoder ${node.id} requires two inputs`);
2976
+ }
2977
+ const sourceBlocks = node.inputs.map((input) =>
2978
+ blocksForChannels(this._model.channelsByValue.get(input.value)!)
2979
+ );
2980
+ const upsampledSource = node.inputs.findIndex((input) => input.upsample);
2981
+ if (upsampledSource !== 0 && upsampledSource !== 1) {
2982
+ throw new Error(`Native fused decoder ${node.id} has no upsample input`);
2983
+ }
2984
+ const inputBlocks = sourceBlocks[0] + sourceBlocks[1];
2985
+ const outputPrecision = isFinal ? 'fp32' : this.precision;
2986
+ const selectedKernel = this._selectConvKernel(inputBlocks, isFinal);
2987
+ // The subgroup broadcast path currently targets the common standalone
2988
+ // convolution layout; fused decoder reads use the direct kernel.
2989
+ const kernel = selectedKernel === 'subgroup'
2990
+ ? 'direct'
2991
+ : selectedKernel;
2992
+ const outputBlocks = blocksForChannels(
2993
+ this._model.convChannels.get(node.conv.id)!.outputChannels
2994
+ );
2995
+ if (kernel === 'subgroup-matrix' && this._subPixelDecoder(node)) {
2996
+ const config = this.subgroupMatrix!;
2997
+ return {
2998
+ key: `subgroup-matrix-subpixel/${this.precision}/${outputPrecision}/` +
2999
+ `${config.dialect}/${config.componentType}-${config.resultType}-${config.size}-sg${config.subgroupSize}/` +
3000
+ `n${fragments}${this.subgroupMatrixDoubleBuffer ? '-db' : ''}/${node.conv.activation}/${sourceBlocks.join('+')}/out${outputBlocks}/up${upsampledSource}`,
3001
+ kernel: 'subgroup-matrix',
3002
+ code: createSubgroupMatrixSubpixelShader(
3003
+ this.precision,
3004
+ outputPrecision,
3005
+ node.conv.activation,
3006
+ outputBlocks,
3007
+ config,
3008
+ fragments,
3009
+ sourceBlocks,
3010
+ upsampledSource,
3011
+ this.subgroupMatrixDoubleBuffer
3012
+ )
3013
+ };
3014
+ }
3015
+ if (kernel === 'subgroup-matrix') {
3016
+ return this._subgroupMatrixSpec(
3017
+ `decoder/${node.conv.activation}/${sourceBlocks.join('+')}/out${outputBlocks}/up${upsampledSource}`,
3018
+ outputPrecision,
3019
+ node.conv.activation,
3020
+ inputBlocks,
3021
+ outputBlocks,
3022
+ fragments,
3023
+ { sourceBlocks, upsampledSource }
3024
+ );
3025
+ }
3026
+ const gemm = this._gemmForOutput(outputBlocks);
3027
+ const key =
3028
+ `decoder-${kernel}${kernel === 'implicit-gemm' && this._subPixelDecoder(node) ? '-subpixel' : ''}/${this.precision}/${outputPrecision}/` +
3029
+ `${node.conv.activation}/` +
3030
+ `${sourceBlocks.join('+')}/out${outputBlocks}/up${upsampledSource}` +
3031
+ (kernel === 'implicit-gemm'
3032
+ ? `/address-${gemm.addressMode}/weights-${gemm.weightLayout}-v1` +
3033
+ `/tile-${gemm.workgroupSize.join('x')}-r${gemm.rowsPerThread}/loads-${gemm.loadMode}/shared-${gemm.sharedLayout}/acc-${gemm.accumulationOrder}/unroll-${Number(gemm.unrollRows)}/decoder-${gemm.decoderLoad}`
3034
+ : '');
3035
+ return {
3036
+ key,
3037
+ kernel,
3038
+ code: kernel === 'implicit-gemm'
3039
+ ? (this._subPixelDecoder(node) ? createTiledSubpixelDecoderShader : createTiledDecoderShader)(
3040
+ this.precision,
3041
+ outputPrecision,
3042
+ node.conv.activation,
3043
+ sourceBlocks,
3044
+ upsampledSource,
3045
+ outputBlocks,
3046
+ gemm
3047
+ )
3048
+ : kernel === 'spatial'
3049
+ ? createSpatialDecoderShader(
3050
+ this.precision,
3051
+ outputPrecision,
3052
+ node.conv.activation,
3053
+ sourceBlocks,
3054
+ upsampledSource,
3055
+ outputBlocks
3056
+ )
3057
+ : createFusedDecoderShader(
3058
+ this.precision,
3059
+ outputPrecision,
3060
+ node.conv.activation,
3061
+ sourceBlocks,
3062
+ upsampledSource,
3063
+ outputBlocks
3064
+ )
3065
+ };
3066
+ }
3067
+ throw new Error(
3068
+ `Native OIDN does not implement unfused ${node.op} node ${node.id}`
3069
+ );
3070
+ }
3071
+
3072
+ /** The decoder's sources when it runs with the sub-pixel decomposition. */
3073
+ private _subPixelDecoder(node: FusedUpsampleConcatConvNodeSpec) {
3074
+ if (node.id === this._model.spec.output || node.inputs.length !== 2) {
3075
+ return undefined;
3076
+ }
3077
+ const kernel = this._selectConvKernel(
3078
+ blocksForChannels(this._model.convChannels.get(node.conv.id)!.inputChannels), false
3079
+ );
3080
+ if (!(kernel === 'subgroup-matrix' && this.subgroupMatrixSubPixel) &&
3081
+ !(kernel === 'implicit-gemm' && this.gemm.decoderSubPixel && this.gemm.weightLayout === 'k-major')) {
3082
+ return undefined;
3083
+ }
3084
+ const upsampledSource = node.inputs[0].upsample ? 0 : node.inputs[1].upsample ? 1 : -1;
3085
+ if (upsampledSource === -1) return undefined;
3086
+ return {
3087
+ sourceBlocks: node.inputs.map((input) =>
3088
+ blocksForChannels(this._model.channelsByValue.get(input.value)!)
3089
+ ),
3090
+ upsampledSource: upsampledSource as 0 | 1
3091
+ };
3092
+ }
3093
+
3094
+ private _subgroupMatrixSpec(
3095
+ key: string,
3096
+ outputPrecision: NativeUNetPrecision,
3097
+ activation: Conv2DNodeSpec['activation'],
3098
+ inputBlocks: number,
3099
+ outputBlocks: number,
3100
+ fragments: number,
3101
+ decoder?: { sourceBlocks: readonly number[]; upsampledSource: 0 | 1 },
3102
+ pool = false
3103
+ ): NativePipelineSpec {
3104
+ const config = this.subgroupMatrix!;
3105
+ return {
3106
+ key: `subgroup-matrix/${this.precision}/${outputPrecision}/` +
3107
+ `${config.dialect}/${config.componentType}-${config.resultType}-${config.size}-sg${config.subgroupSize}/` +
3108
+ `n${fragments}${this.subgroupMatrixDoubleBuffer ? '-db' : ''}/${key}`,
3109
+ kernel: 'subgroup-matrix',
3110
+ code: createSubgroupMatrixConvShader(
3111
+ this.precision,
3112
+ outputPrecision,
3113
+ activation,
3114
+ inputBlocks,
3115
+ outputBlocks,
3116
+ config,
3117
+ fragments,
3118
+ this.subgroupMatrixDoubleBuffer,
3119
+ decoder,
3120
+ pool
3121
+ )
3122
+ };
3123
+ }
3124
+
3125
+ /**
3126
+ * Output fragments per workgroup for a subgroup-matrix layer: the default
3127
+ * unless that leaves fewer than SUBGROUP_MATRIX_MIN_WORKGROUPS workgroups,
3128
+ * where one fragment keeps more of the GPU busy (low-resolution layers).
3129
+ * Never more fragments than the output channels fill (the final RGB layer).
3130
+ */
3131
+ private _subgroupMatrixFragmentsFor(shape: ModelValueShape) {
3132
+ const fragments = Math.min(
3133
+ this.subgroupMatrixOutputFragments,
3134
+ Math.ceil(blocksForChannels(shape.channels) * 4 / this.subgroupMatrix!.size)
3135
+ );
3136
+ if (this._subgroupMatrixFixedFragments || fragments === 1) return fragments;
3137
+ const workgroups = Math.ceil(shape.width / SUBGROUP_MATRIX_TILE_WIDTH) *
3138
+ Math.ceil(shape.height / SUBGROUP_MATRIX_TILE_HEIGHT) *
3139
+ Math.ceil(blocksForChannels(shape.channels) * 4 / (fragments * this.subgroupMatrix!.size));
3140
+ return workgroups < SUBGROUP_MATRIX_MIN_WORKGROUPS ? 1 : fragments;
3141
+ }
3142
+
3143
+ // Wider output tiles reuse each input load across twice as many channels.
3144
+ // Require full output blocks and keep the validated base tile as fallback.
3145
+ private _gemmForOutput(outputBlocks: number): Readonly<Required<NativeUNetGemmOptions>> {
3146
+ if (this.gemm.tilePolicy !== 'output-aligned' ||
3147
+ this.gemm.workgroupSize[0] !== 8 || outputBlocks <= 0 || outputBlocks % 16 !== 0) {
3148
+ return this.gemm;
3149
+ }
3150
+ const cached = this._gemmByOutputBlocks.get(outputBlocks);
3151
+ if (cached) return cached;
3152
+ const candidate = Object.freeze({
3153
+ ...this.gemm,
3154
+ workgroupSize: Object.freeze([16, this.gemm.workgroupSize[1]]) as NativeUNetGemmWorkgroup
3155
+ });
3156
+ const tile = gemmTile(candidate);
3157
+ const sizes = gemmSharedSizes(candidate);
3158
+ const limits = this._device.limits;
3159
+ const fits = tile.workgroupX <= limits.maxComputeWorkgroupSizeX &&
3160
+ tile.workgroupY <= limits.maxComputeWorkgroupSizeY &&
3161
+ tile.workgroupX * tile.workgroupY <= limits.maxComputeInvocationsPerWorkgroup &&
3162
+ (sizes.input + sizes.weights) * 4 * (this.precision === 'fp16' ? 2 : 4) <= limits.maxComputeWorkgroupStorageSize;
3163
+ const selected = fits ? candidate : this.gemm;
3164
+ this._gemmByOutputBlocks.set(outputBlocks, selected);
3165
+ return selected;
3166
+ }
3167
+
3168
+ // Keep fusion on the Large native 8x8 mixed-precision configuration measured
3169
+ // on Metal. Other matrix shapes and browser kernels retain separate pooling.
3170
+ private _matrixPool() {
3171
+ const config = this.subgroupMatrix;
3172
+ return this._model.spec.family === 'oidn-unet-large' && this.precision === 'fp16' &&
3173
+ config?.dialect === 'naga' && config.size === 8 && config.subgroupSize === 32 &&
3174
+ config.componentType === 'f16' && config.resultType === 'f32';
3175
+ }
3176
+
3177
+ private _coalescedPool() {
3178
+ return this.gemm.poolLayout === 'channels' &&
3179
+ (this.kernelSetting === 'auto' || this.kernelSetting === 'implicit-gemm' ||
3180
+ this.kernelSetting === 'subgroup-matrix');
3181
+ }
3182
+
3183
+ private _selectConvKernel(
3184
+ inputBlocks: number,
3185
+ isFinal: boolean
3186
+ ): NativeUNetKernel {
3187
+ const spatialFits =
3188
+ this.precision === 'fp16' &&
3189
+ inputBlocks <= this.maxSpatialInputBlocks;
3190
+ if (this.kernelSetting === 'direct') return 'direct';
3191
+ if (this.kernelSetting === 'spatial') {
3192
+ return spatialFits ? 'spatial' : 'direct';
3193
+ }
3194
+ if (this.kernelSetting === 'implicit-gemm') {
3195
+ return isFinal ? 'direct' : 'implicit-gemm';
3196
+ }
3197
+ if (this.kernelSetting === 'subgroup') {
3198
+ return this.subgroupsAvailable ? 'subgroup' : 'direct';
3199
+ }
3200
+ if (this.kernelSetting === 'subgroup-matrix') {
3201
+ // The 64-thread final RGB kernel is far below matrix throughput on
3202
+ // native M4 Max; Chrome keeps it until measured there.
3203
+ if (isFinal) return this.subgroupMatrix?.dialect === 'naga' ? 'subgroup-matrix' : 'direct';
3204
+ return this.subgroupMatrix ? 'subgroup-matrix' : 'implicit-gemm';
3205
+ }
3206
+ if (!isFinal) return 'implicit-gemm';
3207
+ return 'direct';
3208
+ }
3209
+
3210
+ private _nodePipeline(node: ExecutableModelNode, isFinal: boolean) {
3211
+ const { key, code } = this._nodePipelineSpec(node, isFinal);
3212
+ return this._pipeline(key, code);
3213
+ }
3214
+
3215
+ private _createExecution(width: number, height: number): CachedExecution {
3216
+ const fuseConvPool = this._matrixPool();
3217
+ const plan = planModelExecution(this._model, width, height, { fuseConvPool });
3218
+ const valueBuffers = new Map<string, OIDNGPUBuffer>();
3219
+ const slots: ActivationSlot[] = [];
3220
+ const plannedSlots: { capacity: number; lastUse: number; values: string[] }[] = [];
3221
+ const lastUses = new Map<string, number>();
3222
+ plan.nodes.forEach((node, index) => {
3223
+ for (const input of executableInputs(node)) lastUses.set(input, index);
3224
+ });
3225
+ lastUses.set(plan.spec.output, plan.nodes.length);
3226
+ const createdBuffers: OIDNGPUBuffer[] = [];
3227
+ const own = (buffer: OIDNGPUBuffer) => {
3228
+ createdBuffers.push(buffer);
3229
+ return this._resources.track('gpu-buffer', buffer);
3230
+ };
3231
+
3232
+ try {
3233
+ const allocate = (
3234
+ value: string,
3235
+ shape: ModelValueShape,
3236
+ bytesPerScalar: number,
3237
+ index: number
3238
+ ) => {
3239
+ const requiredSize = activationByteSize(shape, bytesPerScalar);
3240
+ const free = plannedSlots.filter((slot) => slot.lastUse < index);
3241
+ let slot = free
3242
+ .filter((candidate) => candidate.capacity >= requiredSize)
3243
+ .sort((a, b) => a.capacity - b.capacity)[0];
3244
+ // Plan lifetimes before creating GPU buffers. A retired small slot can
3245
+ // grow for the decoder after encoder pool fusion. Other paths keep
3246
+ // their previous slot selection.
3247
+ if (!slot && fuseConvPool) slot = free.sort((a, b) => b.capacity - a.capacity)[0];
3248
+ if (!slot) {
3249
+ slot = { capacity: 0, lastUse: -2, values: [] };
3250
+ plannedSlots.push(slot);
3251
+ }
3252
+ slot.capacity = Math.max(slot.capacity, requiredSize);
3253
+ slot.lastUse = lastUses.get(value) ?? -1;
3254
+ slot.values.push(value);
3255
+ };
3256
+
3257
+ allocate(
3258
+ plan.spec.input,
3259
+ plan.inputShape,
3260
+ this.precision === 'fp16' ? 2 : 4,
3261
+ -1
3262
+ );
3263
+ plan.plannedNodes.forEach(({ node, outputShape }, index) => {
3264
+ const isFinal = node.id === plan.spec.output;
3265
+ allocate(
3266
+ node.id,
3267
+ outputShape,
3268
+ isFinal || this.precision === 'fp32' ? 4 : 2,
3269
+ index
3270
+ );
3271
+ });
3272
+ plannedSlots.forEach((slot, index) => {
3273
+ const buffer = own(this._device.createBuffer({
3274
+ label: `oidn/activation/${width}x${height}/${index}`,
3275
+ size: roundUp(slot.capacity, 4),
3276
+ usage: BufferUsage.STORAGE | BufferUsage.COPY_SRC | BufferUsage.COPY_DST
3277
+ }));
3278
+ slots.push({ buffer, capacity: slot.capacity });
3279
+ for (const value of slot.values) valueBuffers.set(value, buffer);
3280
+ });
3281
+
3282
+ const inputSourceCount = this._model.inputChannels / 3;
3283
+ const inputKey = `pack/${this.precision}/${inputSourceCount}`;
3284
+ const inputPipeline = this._pipeline(
3285
+ inputKey,
3286
+ createInputPackShader(this.precision, inputSourceCount)
3287
+ );
3288
+ const nodePipelines: OIDNGPUComputePipeline[] = [];
3289
+ const nodeKernels: NativeUNetKernel[] = [];
3290
+ const nodeFragments: number[] = [];
3291
+ const nodeBindings: OIDNGPUBindGroup[] = [];
3292
+ const ownedBuffers: OIDNGPUBuffer[] = [];
3293
+
3294
+ plan.plannedNodes.forEach(({ node, outputShape }, index) => {
3295
+ const isFinal = node.id === plan.spec.output;
3296
+ const fragments = this.subgroupMatrix ? this._subgroupMatrixFragmentsFor(outputShape) : 0;
3297
+ const pipelineSpec = this._nodePipelineSpec(node, isFinal, fragments);
3298
+ if (pipelineSpec.kernel) this.usedKernels.add(pipelineSpec.kernel);
3299
+ const pipeline = this._pipeline(pipelineSpec.key, pipelineSpec.code);
3300
+ nodePipelines.push(pipeline);
3301
+ nodeKernels.push(pipelineSpec.kernel ?? 'direct');
3302
+ nodeFragments.push(pipelineSpec.kernel === 'subgroup-matrix' ? fragments : 0);
3303
+ const output = valueBuffers.get(node.id)!;
3304
+ let entries: OIDNGPUBindGroupEntry[];
3305
+ let uniformValues: number[];
3306
+ let convId: string;
3307
+
3308
+ if (node.op === 'conv2d') {
3309
+ const inputShape = plan.valueShapes.get(node.input)!;
3310
+ convId = node.id;
3311
+ uniformValues = [
3312
+ inputShape.width,
3313
+ inputShape.height,
3314
+ outputShape.width,
3315
+ outputShape.height,
3316
+ blocksForChannels(inputShape.channels),
3317
+ blocksForChannels(outputShape.channels)
3318
+ ];
3319
+ const packed = this._packedConvs.get(convId)!;
3320
+ entries = [
3321
+ { binding: 0, resource: { buffer: valueBuffers.get(node.input)! } },
3322
+ { binding: 1, resource: { buffer: packed.weights } },
3323
+ { binding: 2, resource: { buffer: packed.bias } },
3324
+ { binding: 3, resource: { buffer: output } }
3325
+ ];
3326
+ } else if (node.op === 'maxPool2d') {
3327
+ const inputShape = plan.valueShapes.get(node.input)!;
3328
+ uniformValues = [
3329
+ inputShape.width,
3330
+ inputShape.height,
3331
+ outputShape.width,
3332
+ outputShape.height,
3333
+ blocksForChannels(outputShape.channels)
3334
+ ];
3335
+ entries = [
3336
+ { binding: 0, resource: { buffer: valueBuffers.get(node.input)! } },
3337
+ { binding: 1, resource: { buffer: output } }
3338
+ ];
3339
+ } else if (node.op === 'fusedConvReluMaxPool2d') {
3340
+ const inputShape = plan.valueShapes.get(node.input)!;
3341
+ convId = node.conv.id;
3342
+ uniformValues = [
3343
+ inputShape.width,
3344
+ inputShape.height,
3345
+ outputShape.width,
3346
+ outputShape.height,
3347
+ blocksForChannels(inputShape.channels),
3348
+ blocksForChannels(outputShape.channels)
3349
+ ];
3350
+ const packed = this._packedConvs.get(convId)!;
3351
+ entries = [
3352
+ { binding: 0, resource: { buffer: valueBuffers.get(node.input)! } },
3353
+ { binding: 1, resource: { buffer: packed.weights } },
3354
+ { binding: 2, resource: { buffer: packed.bias } },
3355
+ { binding: 3, resource: { buffer: output } }
3356
+ ];
3357
+ } else if (node.op === 'fusedUpsampleConcatConv2d') {
3358
+ convId = node.conv.id;
3359
+ const firstShape = plan.valueShapes.get(node.inputs[0].value)!;
3360
+ const secondShape = plan.valueShapes.get(node.inputs[1].value)!;
3361
+ uniformValues = [
3362
+ outputShape.width,
3363
+ outputShape.height,
3364
+ blocksForChannels(outputShape.channels),
3365
+ blocksForChannels(this._model.convChannels.get(convId)!.inputChannels),
3366
+ firstShape.width,
3367
+ firstShape.height,
3368
+ secondShape.width,
3369
+ secondShape.height
3370
+ ];
3371
+ const packed = this._packedConvs.get(convId)!;
3372
+ entries = [
3373
+ {
3374
+ binding: 0,
3375
+ resource: { buffer: valueBuffers.get(node.inputs[0].value)! }
3376
+ },
3377
+ {
3378
+ binding: 1,
3379
+ resource: { buffer: valueBuffers.get(node.inputs[1].value)! }
3380
+ },
3381
+ { binding: 2, resource: { buffer: packed.weights } },
3382
+ { binding: 3, resource: { buffer: packed.bias } },
3383
+ { binding: 4, resource: { buffer: output } }
3384
+ ];
3385
+ } else {
3386
+ throw new Error(`Unexpected native node ${node.op}`);
3387
+ }
3388
+
3389
+ const uniform = own(
3390
+ createUniformBuffer(
3391
+ this._device,
3392
+ `oidn/${node.id}/params/${width}x${height}`,
3393
+ uniformValues
3394
+ )
3395
+ );
3396
+ ownedBuffers.push(uniform);
3397
+ entries.push({ binding: entries.length, resource: { buffer: uniform } });
3398
+ nodeBindings.push(
3399
+ this._device.createBindGroup({
3400
+ label: `oidn/${node.id}/bindings`,
3401
+ layout: pipeline.getBindGroupLayout(0),
3402
+ entries
3403
+ })
3404
+ );
3405
+ });
3406
+
3407
+ const inputUniform = own(
3408
+ createUniformBuffer(
3409
+ this._device,
3410
+ `oidn/input/params/${width}x${height}`,
3411
+ [
3412
+ width,
3413
+ height,
3414
+ blocksForChannels(this._model.inputChannels),
3415
+ this._model.inputChannels
3416
+ ]
3417
+ )
3418
+ );
3419
+ ownedBuffers.push(inputUniform);
3420
+
3421
+ return {
3422
+ plan,
3423
+ valueBuffers,
3424
+ slots,
3425
+ nodeBindings,
3426
+ nodePipelines,
3427
+ nodeKernels,
3428
+ nodeFragments,
3429
+ inputPipeline,
3430
+ inputUniform,
3431
+ ownedBuffers,
3432
+ lastUsed: ++this._clock
3433
+ };
3434
+ } catch (error) {
3435
+ for (const buffer of createdBuffers) this._releaseBuffer(buffer);
3436
+ throw error;
3437
+ }
3438
+ }
3439
+
3440
+ protected _execution(width: number, height: number) {
3441
+ if (this._disposed) throw new Error('Native OIDN executor is disposed');
3442
+ const key = `${width}x${height}`;
3443
+ let execution = this._executionCache.get(key);
3444
+ if (!execution) {
3445
+ execution = this._createExecution(width, height);
3446
+ this._executionCache.set(key, execution);
3447
+ if (this._executionCache.size > this._shapeCacheSize) {
3448
+ const oldest = [...this._executionCache.entries()]
3449
+ .filter(([candidateKey]) => candidateKey !== key)
3450
+ .sort((a, b) => a[1].lastUsed - b[1].lastUsed)[0];
3451
+ if (oldest) {
3452
+ this._executionCache.delete(oldest[0]);
3453
+ this._retireExecution(oldest[1]);
3454
+ }
3455
+ }
3456
+ }
3457
+ execution.lastUsed = ++this._clock;
3458
+ return execution;
3459
+ }
3460
+
3461
+ /**
3462
+ * Releases an evicted plan. The synchronous core destroys it at once; the
3463
+ * browser executor waits for the queue first.
3464
+ */
3465
+ protected _retireExecution(execution: CachedExecution) {
3466
+ this._destroyExecution(execution);
3467
+ }
3468
+
3469
+ /** Allocates shape-dependent execution resources before interactive use. */
3470
+ prewarm(shapes: readonly { width: number; height: number }[]) {
3471
+ for (const shape of shapes) {
3472
+ this._execution(shape.width, shape.height);
3473
+ }
3474
+ }
3475
+
3476
+ /** Captures per-pass GPU timestamps for the next execute call when supported. */
3477
+ profileNextExecution() {
3478
+ if (!this._device.features.has('timestamp-query')) return false;
3479
+ this._profileNextExecution = true;
3480
+ return true;
3481
+ }
3482
+
3483
+ /** Shape-owned packed input; external preprocessing must write all lanes. */
3484
+ packedInputBuffer(width: number, height: number) {
3485
+ const execution = this._execution(width, height);
3486
+ return execution.valueBuffers.get(execution.plan.spec.input)!;
3487
+ }
3488
+
3489
+ /** Executes after a preprocessing pass has filled packedInputBuffer. */
3490
+ executePacked(width: number, height: number) {
3491
+ return this._execute([], width, height, true);
3492
+ }
3493
+
3494
+ execute(inputBuffers: readonly OIDNGPUBuffer[], width: number, height: number) {
3495
+ return this._execute(inputBuffers, width, height, false);
3496
+ }
3497
+
3498
+ private _execute(inputBuffers: readonly OIDNGPUBuffer[], width: number, height: number, packedInput: boolean) {
3499
+ const sourceCount = this._model.inputChannels / 3;
3500
+ if (!packedInput && inputBuffers.length !== sourceCount) {
3501
+ throw new Error(
3502
+ `Native OIDN expected ${sourceCount} input buffers, got ${inputBuffers.length}`
3503
+ );
3504
+ }
3505
+ const execution = this._execution(width, height);
3506
+ const profileLabels = [
3507
+ ...(packedInput ? [] : ['input-pack']),
3508
+ ...execution.plan.nodes.map((node) => node.id)
3509
+ ];
3510
+ const shouldProfile =
3511
+ this._profileNextExecution &&
3512
+ this._device.features.has('timestamp-query');
3513
+ this._profileNextExecution = false;
3514
+ const queryCount = profileLabels.length * 2;
3515
+ const querySet = shouldProfile
3516
+ ? this._resources.track(
3517
+ 'gpu-query-set',
3518
+ this._device.createQuerySet({ type: 'timestamp', count: queryCount })
3519
+ )
3520
+ : undefined;
3521
+ const queryBufferSize = queryCount * 8;
3522
+ let queryResolveBuffer: OIDNGPUBuffer | undefined;
3523
+ let queryReadbackBuffer: OIDNGPUBuffer | undefined;
3524
+ try {
3525
+ queryResolveBuffer = shouldProfile
3526
+ ? this._resources.track(
3527
+ 'gpu-buffer',
3528
+ this._device.createBuffer({
3529
+ label: `oidn/profile/resolve/${width}x${height}`,
3530
+ size: queryBufferSize,
3531
+ usage: BufferUsage.QUERY_RESOLVE | BufferUsage.COPY_SRC
3532
+ })
3533
+ )
3534
+ : undefined;
3535
+ queryReadbackBuffer = shouldProfile
3536
+ ? this._resources.track(
3537
+ 'gpu-buffer',
3538
+ this._device.createBuffer({
3539
+ label: `oidn/profile/readback/${width}x${height}`,
3540
+ size: queryBufferSize,
3541
+ usage: BufferUsage.COPY_DST | BufferUsage.MAP_READ
3542
+ })
3543
+ )
3544
+ : undefined;
3545
+ } catch (error) {
3546
+ this._releaseBuffer(queryReadbackBuffer);
3547
+ this._releaseBuffer(queryResolveBuffer);
3548
+ this._releaseQuerySet(querySet);
3549
+ throw error;
3550
+ }
3551
+ const passDescriptor = (label: string, index: number) => ({
3552
+ label,
3553
+ ...(querySet
3554
+ ? {
3555
+ timestampWrites: {
3556
+ querySet,
3557
+ beginningOfPassWriteIndex: index * 2,
3558
+ endOfPassWriteIndex: index * 2 + 1
3559
+ }
3560
+ }
3561
+ : {})
3562
+ });
3563
+ try {
3564
+ const encoder = this._device.createCommandEncoder({
3565
+ label: `oidn/native/${width}x${height}`
3566
+ });
3567
+
3568
+ if (!packedInput) {
3569
+ const inputEntries: OIDNGPUBindGroupEntry[] = inputBuffers.map(
3570
+ (buffer, binding) => ({ binding, resource: { buffer } })
3571
+ );
3572
+ inputEntries.push({
3573
+ binding: sourceCount,
3574
+ resource: { buffer: execution.valueBuffers.get(execution.plan.spec.input)! }
3575
+ });
3576
+ inputEntries.push({
3577
+ binding: sourceCount + 1,
3578
+ resource: { buffer: execution.inputUniform }
3579
+ });
3580
+ const inputBindings = this._device.createBindGroup({
3581
+ label: 'oidn/input/bindings',
3582
+ layout: execution.inputPipeline.getBindGroupLayout(0),
3583
+ entries: inputEntries
3584
+ });
3585
+ const pass = encoder.beginComputePass(
3586
+ passDescriptor('oidn/input-pack', 0)
3587
+ );
3588
+ pass.setPipeline(execution.inputPipeline);
3589
+ pass.setBindGroup(0, inputBindings);
3590
+ pass.dispatchWorkgroups(
3591
+ Math.ceil(width / WORKGROUP_SIZE),
3592
+ Math.ceil(height / WORKGROUP_SIZE),
3593
+ blocksForChannels(this._model.inputChannels)
3594
+ );
3595
+ pass.end();
3596
+ }
3597
+
3598
+ execution.plan.plannedNodes.forEach(({ node, outputShape }, index) => {
3599
+ const pass = encoder.beginComputePass(
3600
+ passDescriptor(`oidn/${execution.plan.nodes[index].id}`, index + (packedInput ? 0 : 1))
3601
+ );
3602
+ pass.setPipeline(execution.nodePipelines[index]);
3603
+ pass.setBindGroup(0, execution.nodeBindings[index]);
3604
+ if (node.op === 'maxPool2d' && this._coalescedPool()) {
3605
+ const groupsX = Math.ceil(
3606
+ outputShape.width * blocksForChannels(outputShape.channels) / WORKGROUP_SIZE
3607
+ );
3608
+ const maxGroups = this._device.limits.maxComputeWorkgroupsPerDimension;
3609
+ // Wide channel rows continue in Z instead of exceeding the X limit.
3610
+ pass.dispatchWorkgroups(
3611
+ Math.min(groupsX, maxGroups),
3612
+ Math.ceil(outputShape.height / WORKGROUP_SIZE),
3613
+ Math.ceil(groupsX / maxGroups)
3614
+ );
3615
+ } else if (execution.nodeKernels[index] === 'subgroup-matrix') {
3616
+ pass.dispatchWorkgroups(
3617
+ Math.ceil(outputShape.width * (node.op === 'fusedConvReluMaxPool2d' ? 2 : 1) / SUBGROUP_MATRIX_TILE_WIDTH),
3618
+ Math.ceil(outputShape.height * (node.op === 'fusedConvReluMaxPool2d' ? 2 : 1) / SUBGROUP_MATRIX_TILE_HEIGHT),
3619
+ Math.ceil(
3620
+ blocksForChannels(outputShape.channels) /
3621
+ (execution.nodeFragments[index] * this.subgroupMatrix!.size / 4)
3622
+ )
3623
+ );
3624
+ } else if (execution.nodeKernels[index] === 'implicit-gemm') {
3625
+ const tile = gemmTile(this._gemmForOutput(blocksForChannels(outputShape.channels)));
3626
+ const subPixel = node.op === 'fusedUpsampleConcatConv2d' && this._subPixelDecoder(node) !== undefined;
3627
+ pass.dispatchWorkgroups(
3628
+ Math.ceil(
3629
+ (subPixel ? Math.ceil(outputShape.width / 2) * Math.ceil(outputShape.height / 2)
3630
+ : outputShape.width * outputShape.height) / tile.tileM
3631
+ ),
3632
+ Math.ceil(
3633
+ blocksForChannels(outputShape.channels) / tile.tileNBlocks
3634
+ ),
3635
+ subPixel ? 4 : 1
3636
+ );
3637
+ } else {
3638
+ pass.dispatchWorkgroups(
3639
+ Math.ceil(outputShape.width / WORKGROUP_SIZE),
3640
+ Math.ceil(outputShape.height / WORKGROUP_SIZE),
3641
+ blocksForChannels(outputShape.channels)
3642
+ );
3643
+ }
3644
+ pass.end();
3645
+ });
3646
+
3647
+ if (querySet) {
3648
+ encoder.resolveQuerySet(
3649
+ querySet,
3650
+ 0,
3651
+ queryCount,
3652
+ queryResolveBuffer!,
3653
+ 0
3654
+ );
3655
+ encoder.copyBufferToBuffer(
3656
+ queryResolveBuffer!,
3657
+ 0,
3658
+ queryReadbackBuffer!,
3659
+ 0,
3660
+ queryBufferSize
3661
+ );
3662
+ }
3663
+
3664
+ this._device.queue.submit([encoder.finish()]);
3665
+ } catch (error) {
3666
+ this._releaseBuffer(queryReadbackBuffer);
3667
+ this._releaseBuffer(queryResolveBuffer);
3668
+ this._releaseQuerySet(querySet);
3669
+ throw error;
3670
+ }
3671
+ if (querySet) {
3672
+ this._profileOperations++;
3673
+ this._readProfile(querySet, queryResolveBuffer!, queryReadbackBuffer!, profileLabels);
3674
+ }
3675
+ return execution.valueBuffers.get(execution.plan.spec.output)!;
3676
+ }
3677
+
3678
+ /**
3679
+ * Receives the timestamp queries of a profiled execution. The synchronous
3680
+ * core cannot read them back, so it releases them.
3681
+ */
3682
+ protected _readProfile(
3683
+ querySet: OIDNGPUQuerySet,
3684
+ resolveBuffer: OIDNGPUBuffer,
3685
+ readbackBuffer: OIDNGPUBuffer,
3686
+ labels: string[]
3687
+ ) {
3688
+ void labels;
3689
+ this._releaseQuerySet(querySet);
3690
+ this._releaseBuffer(resolveBuffer);
3691
+ this._releaseBuffer(readbackBuffer);
3692
+ this._profileOperations--;
3693
+ }
3694
+
3695
+ protected _releaseBuffer(buffer: OIDNGPUBuffer | undefined) {
3696
+ this._resources.release('gpu-buffer', buffer, () => buffer!.destroy());
3697
+ }
3698
+
3699
+ protected _releaseQuerySet(querySet: OIDNGPUQuerySet | undefined) {
3700
+ this._resources.release(
3701
+ 'gpu-query-set',
3702
+ querySet,
3703
+ () => querySet!.destroy()
3704
+ );
3705
+ }
3706
+
3707
+ protected _destroyExecution(execution: CachedExecution) {
3708
+ execution.slots.forEach((slot) => this._releaseBuffer(slot.buffer));
3709
+ execution.ownedBuffers.forEach((buffer) => this._releaseBuffer(buffer));
3710
+ }
3711
+
3712
+ getResourceInfo(): OIDNResourceSnapshot {
3713
+ return this._resources.snapshot(
3714
+ this._retiredExecutions.size + this._profileOperations
3715
+ );
3716
+ }
3717
+
3718
+ dispose() {
3719
+ if (this._disposed) return;
3720
+ this._disposed = true;
3721
+ for (const packed of this._packedConvs.values()) {
3722
+ this._releaseBuffer(packed.weights);
3723
+ this._releaseBuffer(packed.bias);
3724
+ }
3725
+ this._packedConvs.clear();
3726
+ for (const execution of this._executionCache.values()) {
3727
+ this._destroyExecution(execution);
3728
+ }
3729
+ this._executionCache.clear();
3730
+ for (const execution of this._retiredExecutions) {
3731
+ this._destroyExecution(execution);
3732
+ }
3733
+ this._retiredExecutions.clear();
3734
+ }
3735
+ }