@nadrif/thread 0.1.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.
package/src/gpu/gpu.js ADDED
@@ -0,0 +1,1199 @@
1
+ /**
2
+ * @file WebGPU compute shader integration for the thread module.
3
+ *
4
+ * `GPUCompute` wraps the WebGPU API to provide a high-level interface
5
+ * for running compute shaders. It manages device acquisition, buffer
6
+ * lifecycle, pipeline creation, and data transfer automatically.
7
+ *
8
+ * @module gpu
9
+ */
10
+
11
+ import { Metrics } from '../metrix.js';
12
+ import { GPUComputeError } from '../error.js';
13
+ import { buildShader, BUILT_IN_OPS, SPECIAL_OPS } from './shaders.js';
14
+ import {
15
+ OUTPUT_TYPES, resolveType, box, boxAll, isOldRunFormat,
16
+ transpileExpression, transpileDefineBody, jsToWgsl, getParamNames,
17
+ } from './helpers.js';
18
+ import { PipelineChain, DataPipelineChain } from './chains.js';
19
+ import { runSpecial } from './special.js';
20
+ import { gpuEnv, requestGPUAdapter } from './env.js';
21
+
22
+ // Re-export everything from sub-modules for backward compatibility
23
+ export { OUTPUT_TYPES, resolveType, box, boxAll, isOldRunFormat,
24
+ transpileExpression, transpileDefineBody, jsToWgsl, getParamNames } from './helpers.js';
25
+ export { PipelineChain, DataPipelineChain } from './chains.js';
26
+ export { runSpecial, runMatmul, buildMatmulShader, runReduce, getReduceBody,
27
+ runHistogram, runArgMaxMin, runScan } from './special.js';
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // GPUCompute class
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /**
34
+ * WebGPU compute shader executor.
35
+ *
36
+ * Manages the full GPU lifecycle: device, pipelines, buffers, and
37
+ * dispatch. Designed to integrate with the thread module's metrics
38
+ * and error patterns.
39
+ */
40
+ export class GPUCompute {
41
+ // -----------------------------------------------------------------------
42
+ // Constructor
43
+ // -----------------------------------------------------------------------
44
+
45
+ /**
46
+ * Create a GPU compute instance.
47
+ *
48
+ * Device acquisition is **lazy** — it happens on the first call to
49
+ * {@link GPUCompute.compute} (or eagerly via {@link GPUCompute.init}).
50
+ *
51
+ * @param {import('../types.js').GPUComputeOptions} options
52
+ * @throws {TypeError} If `options.shader` is missing or not a string.
53
+ */
54
+ constructor(options = {}) {
55
+ if (options.shader && typeof options.shader !== 'string') {
56
+ throw new TypeError('options.shader must be a string');
57
+ }
58
+
59
+ /** @type {string|null} Default WGSL compute shader source. */
60
+ this._shader = options.shader || null;
61
+
62
+ /** @type {number} Workgroup size (must match @workgroup_size in shader). */
63
+ this._workgroupSize = options.workgroupSize || 256;
64
+
65
+ /** @type {number} Maximum buffer size in bytes (default 256 MB). */
66
+ this._maxBufferSize = options.maxBufferSize || 256 * 1024 * 1024;
67
+
68
+ /** @type {string} Shader entry point function name. */
69
+ this._entryPoint = options.entryPoint || 'main';
70
+
71
+ /** @type {Object} GPU request adapter options. */
72
+ this._adapterOptions = options.adapterOptions || {};
73
+
74
+ /** @type {'low-power'|'high-performance'|undefined} GPU power preference. */
75
+ this._powerPreference = options.powerPreference || 'high-performance';
76
+
77
+ // Internal state
78
+ this._device = null;
79
+ this._bufferPool = new Map();
80
+ this._isInitialised = false;
81
+ this._initPromise = null;
82
+ this._metrics = new Metrics();
83
+ this._bytesTransferred = 0;
84
+ this._dispatchCount = 0;
85
+
86
+ /** @type {Function | null} CPU fallback function. */
87
+ this._cpuFallback = options.cpuFallback || null;
88
+
89
+ /** @type {boolean} Whether WebGPU is available. */
90
+ this._available = gpuEnv.sync;
91
+
92
+ /** @type {'idle'|'running'|'error'|'unavailable'} Current status. */
93
+ this._status = this._available ? 'idle' : 'unavailable';
94
+
95
+ /**
96
+ * Pipeline registry: name -> { shaderSource, pipeline, entryPoint }.
97
+ * @type {Map<string, { shader: string, pipeline: any|null, entryPoint: string }>}
98
+ */
99
+ this._pipelines = new Map();
100
+
101
+ /** @type {string} Name of the currently active pipeline. */
102
+ this._activePipeline = 'default';
103
+
104
+ // Register the default shader if provided
105
+ if (options.shader) {
106
+ this._pipelines.set('default', {
107
+ shader: options.shader,
108
+ pipeline: null,
109
+ entryPoint: this._entryPoint,
110
+ });
111
+ }
112
+ }
113
+
114
+ // -----------------------------------------------------------------------
115
+ // Initialisation
116
+ // -----------------------------------------------------------------------
117
+
118
+ /**
119
+ * Initialise the GPU device and compile all registered shaders.
120
+ *
121
+ * @returns {Promise<void>}
122
+ * @throws {GPUComputeError} If WebGPU is unavailable, adapter request
123
+ * fails, or shader compilation fails.
124
+ */
125
+ async init() {
126
+ if (this._isInitialised) return;
127
+ if (this._initPromise) return this._initPromise;
128
+
129
+ this._initPromise = this._doInit();
130
+ await this._initPromise;
131
+ }
132
+
133
+ /** @private */
134
+ async _doInit() {
135
+ // Re-check availability asynchronously (some runtimes need async check)
136
+ if (!this._available) {
137
+ const asyncAvailable = await gpuEnv.available;
138
+ if (!asyncAvailable) {
139
+ this._status = 'unavailable';
140
+ throw new GPUComputeError(
141
+ 'WebGPU is not available in this environment. ' +
142
+ 'Ensure your runtime supports WebGPU (browser, Node.js with bindings, or Deno).',
143
+ );
144
+ }
145
+ this._available = true;
146
+ }
147
+
148
+ let adapter;
149
+ try {
150
+ adapter = await requestGPUAdapter({
151
+ powerPreference: this._powerPreference,
152
+ ...this._adapterOptions,
153
+ });
154
+ } catch (err) {
155
+ this._status = 'error';
156
+ throw new GPUComputeError('Failed to request GPU adapter', err);
157
+ }
158
+
159
+ if (!adapter) {
160
+ this._status = 'error';
161
+ throw new GPUComputeError(
162
+ 'Failed to obtain a GPU adapter. Your hardware may not support WebGPU.',
163
+ );
164
+ }
165
+
166
+ this._device = await adapter.requestDevice();
167
+ this._device.lost.then((info) => {
168
+ this._status = 'unavailable';
169
+ this._device = null;
170
+ this._pipelines.forEach((p) => { p.pipeline = null; });
171
+ this._isInitialised = false;
172
+ });
173
+
174
+ // Compile all registered shaders
175
+ for (const [name, entry] of this._pipelines) {
176
+ entry.pipeline = await this._compileShader(entry.shader, entry.entryPoint, name);
177
+ }
178
+
179
+ this._isInitialised = true;
180
+ this._status = 'idle';
181
+ }
182
+
183
+ /**
184
+ * Compile a single shader and create its pipeline.
185
+ *
186
+ * @param {string} shaderSource - WGSL source code.
187
+ * @param {string} entryPoint - Entry point function name.
188
+ * @param {string} pipelineName - Name for error messages.
189
+ * @returns {Promise<GPUComputePipeline>}
190
+ * @private
191
+ */
192
+ async _compileShader(shaderSource, entryPoint, pipelineName) {
193
+ const shaderModule = this._device.createShaderModule({ code: shaderSource });
194
+
195
+ // getCompilationInfo() is not implemented in all runtimes (e.g. bun-webgpu).
196
+ // Wrap in try/catch — shader errors will surface at pipeline creation time.
197
+ try {
198
+ const compilationInfo = await shaderModule.getCompilationInfo();
199
+ const errors = compilationInfo.messages.filter((m) => m.type === 'error');
200
+ if (errors.length > 0) {
201
+ const details = errors.map((e) => ` line ${e.lineNum}: ${e.message}`).join('\n');
202
+ throw new GPUComputeError(
203
+ `Shader "${pipelineName}" compilation failed:\n${details}`,
204
+ );
205
+ }
206
+ } catch (err) {
207
+ if (err instanceof GPUComputeError) throw err;
208
+ // Runtime doesn't support getCompilationInfo() — continue
209
+ }
210
+
211
+ return this._device.createComputePipeline({
212
+ layout: 'auto',
213
+ compute: {
214
+ module: shaderModule,
215
+ entryPoint,
216
+ },
217
+ });
218
+ }
219
+
220
+ // -----------------------------------------------------------------------
221
+ // Pipeline management
222
+ // -----------------------------------------------------------------------
223
+
224
+ /**
225
+ * Register a named shader for later use.
226
+ *
227
+ * @param {string} name - Unique pipeline name (e.g. `'multiply'`).
228
+ * @param {string} shaderSource - WGSL shader source code.
229
+ * @param {Object} [opts={}]
230
+ * @param {string} [opts.entryPoint='main'] - Entry point function name.
231
+ * @returns {Promise<void>}
232
+ */
233
+ async addShader(name, shaderSource, opts = {}) {
234
+ if (typeof name !== 'string' || !name) {
235
+ throw new TypeError('Pipeline name must be a non-empty string');
236
+ }
237
+ if (typeof shaderSource !== 'string' || !shaderSource) {
238
+ throw new TypeError('Shader source must be a non-empty string');
239
+ }
240
+ if (this._pipelines.has(name)) {
241
+ throw new Error(`Pipeline "${name}" already exists. Use setActive() to switch.`);
242
+ }
243
+
244
+ const entryPoint = opts.entryPoint || 'main';
245
+ this._pipelines.set(name, { shader: shaderSource, pipeline: null, entryPoint });
246
+
247
+ if (this._isInitialised && this._device) {
248
+ const entry = this._pipelines.get(name);
249
+ entry.pipeline = await this._compileShader(shaderSource, entryPoint, name);
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Remove a registered shader.
255
+ *
256
+ * @param {string} name - Pipeline name to remove.
257
+ */
258
+ removeShader(name) {
259
+ if (name === 'default') throw new Error('Cannot remove the default pipeline');
260
+ if (!this._pipelines.has(name)) {
261
+ throw new Error(`Pipeline "${name}" does not exist`);
262
+ }
263
+ this._pipelines.delete(name);
264
+ if (this._activePipeline === name) this._activePipeline = 'default';
265
+ }
266
+
267
+ /**
268
+ * Switch the active pipeline for subsequent `compute()` calls.
269
+ *
270
+ * @param {string} name - Pipeline name (must be registered).
271
+ */
272
+ setActive(name) {
273
+ if (!this._pipelines.has(name)) {
274
+ throw new Error(`Pipeline "${name}" does not exist`);
275
+ }
276
+ this._activePipeline = name;
277
+ }
278
+
279
+ /** @type {string} Name of the currently active pipeline. */
280
+ get activePipeline() {
281
+ return this._activePipeline;
282
+ }
283
+
284
+ /** @type {string[]} List of all registered pipeline names. */
285
+ get pipelines() {
286
+ return [...this._pipelines.keys()];
287
+ }
288
+
289
+ // -----------------------------------------------------------------------
290
+ // define() — register ops from declarations (NO WGSL needed)
291
+ // -----------------------------------------------------------------------
292
+
293
+ /**
294
+ * Register a compute operation from a simple declaration or JS function.
295
+ *
296
+ * @param {string} name - Operation name.
297
+ * @param {import('../types.js').OpDeclaration|Function} declarationOrFn
298
+ */
299
+ define(name, declarationOrFn) {
300
+ if (typeof name !== 'string' || !name) {
301
+ throw new TypeError('Op name must be a non-empty string');
302
+ }
303
+
304
+ // --- Function overload: define(name, (data, { alpha }) => expr) ---
305
+ if (typeof declarationOrFn === 'function') {
306
+ const fn = declarationOrFn;
307
+ const paramNames = getParamNames(fn);
308
+
309
+ const inputName = paramNames[0] || 'data';
310
+ let uniformNames = [];
311
+ if (paramNames.length > 1) {
312
+ const src = fn.toString();
313
+ const secondParamMatch = src.match(/\([^)]*,\s*\(?(\{[^}]+\})/);
314
+ if (secondParamMatch) {
315
+ uniformNames = secondParamMatch[1]
316
+ .replace(/[{}]/g, '')
317
+ .split(',')
318
+ .map(s => s.trim().split(':')[0].trim())
319
+ .filter(Boolean);
320
+ }
321
+ }
322
+
323
+ const wgslBody = transpileDefineBody(fn, inputName);
324
+
325
+ const cpuFn = async (input) => {
326
+ const data = input.inputs[inputName];
327
+ const count = data.byteLength / (data.BYTES_PER_ELEMENT || 4);
328
+ const result = new Float32Array(count);
329
+ const uniformObj = {};
330
+ for (const uName of uniformNames) {
331
+ uniformObj[uName] = input.uniforms?.[uName]?.[0] ?? 0;
332
+ }
333
+ for (let i = 0; i < count; i++) {
334
+ const proxy = { value: data[i], index: i, i };
335
+ result[i] = fn(proxy, uniformObj);
336
+ }
337
+ return { result };
338
+ };
339
+
340
+ const decl = {
341
+ inputs: [inputName],
342
+ outputs: ['result'],
343
+ uniforms: uniformNames,
344
+ body: `result[i] = ${wgslBody};`,
345
+ fn: cpuFn,
346
+ };
347
+
348
+ return this.define(name, decl);
349
+ }
350
+
351
+ // --- Object overload: define(name, { inputs, body, ... }) ---
352
+ const declaration = declarationOrFn;
353
+ if (!declaration || typeof declaration.body !== 'string') {
354
+ throw new TypeError('Op declaration requires a body string or function');
355
+ }
356
+
357
+ const {
358
+ inputs = [],
359
+ outputs = ['result'],
360
+ uniforms = [],
361
+ body,
362
+ type = 'f32',
363
+ fn = null,
364
+ workgroupSize,
365
+ } = declaration;
366
+
367
+ const wgsl = buildShader({
368
+ inputs,
369
+ outputs,
370
+ uniforms,
371
+ body,
372
+ type,
373
+ workgroupSize: workgroupSize || this._workgroupSize,
374
+ });
375
+
376
+ if (!this._ops) this._ops = new Map();
377
+ this._ops.set(name, {
378
+ inputs,
379
+ outputs,
380
+ uniforms,
381
+ type,
382
+ fn,
383
+ _body: body,
384
+ workgroupSize: workgroupSize || this._workgroupSize,
385
+ });
386
+
387
+ this._pipelines.set(name, {
388
+ shader: wgsl,
389
+ pipeline: null,
390
+ entryPoint: 'main',
391
+ });
392
+ }
393
+
394
+ /**
395
+ * Register a built-in operation by name.
396
+ *
397
+ * @param {string} name - Built-in op name (e.g. `'multiply'`, `'sqrt'`).
398
+ * @param {Object} [opts={}]
399
+ */
400
+ defineBuiltin(name, opts = {}) {
401
+ const op = BUILT_IN_OPS[name];
402
+ if (!op) {
403
+ throw new Error(`Unknown built-in op: "${name}". Available: ${Object.keys(BUILT_IN_OPS).join(', ')}`);
404
+ }
405
+ this.define(name, { ...op, ...opts });
406
+ }
407
+
408
+ /**
409
+ * Register all built-in operations at once.
410
+ *
411
+ * @param {Object} [opts={}]
412
+ * @returns {string[]} Names of registered ops.
413
+ */
414
+ defineAllBuiltins(opts = {}) {
415
+ const names = [];
416
+ for (const name of Object.keys(BUILT_IN_OPS)) {
417
+ this.defineBuiltin(name, opts);
418
+ names.push(name);
419
+ }
420
+ return names;
421
+ }
422
+
423
+ /** @type {string[]} All defined operation names (built-in + custom). */
424
+ get ops() {
425
+ return this._ops ? [...this._ops.keys()] : [];
426
+ }
427
+
428
+ // -----------------------------------------------------------------------
429
+ // run() — high-level entry point (NO WGSL needed)
430
+ // -----------------------------------------------------------------------
431
+
432
+ /**
433
+ * Run a named operation.
434
+ *
435
+ * @param {string} name - Operation name.
436
+ * @param {Object} inputsOrOpts
437
+ * @param {Object} [uniformsOrOpts={}]
438
+ * @param {Object} [opts={}]
439
+ * @returns {Promise<Object<string, TypedArray>>}
440
+ */
441
+ async run(name, inputsOrOpts = {}, uniformsOrOpts = {}, opts = {}) {
442
+ let input, signal;
443
+ if (isOldRunFormat(inputsOrOpts)) {
444
+ input = inputsOrOpts;
445
+ signal = input.signal;
446
+ } else {
447
+ input = {
448
+ inputs: boxAll(inputsOrOpts),
449
+ uniforms: boxAll(uniformsOrOpts),
450
+ signal: opts.signal,
451
+ outputType: opts.outputType,
452
+ workgroups: opts.workgroups,
453
+ };
454
+ signal = opts.signal;
455
+ }
456
+
457
+ if (signal?.aborted) {
458
+ throw new GPUComputeError('Operation cancelled', signal.reason ?? new DOMException('Aborted', 'AbortError'));
459
+ }
460
+
461
+ const ops = this._ops || new Map();
462
+ let opDef = ops.get(name);
463
+ let builtinDef = BUILT_IN_OPS[name];
464
+ let specialDef = SPECIAL_OPS[name];
465
+
466
+ if (!opDef && builtinDef) {
467
+ this.defineBuiltin(name);
468
+ opDef = ops.get(name);
469
+ }
470
+
471
+ if (specialDef && !opDef) {
472
+ try {
473
+ return await runSpecial(this, name, specialDef, input);
474
+ } catch (err) {
475
+ this._status = 'idle';
476
+ if (err instanceof GPUComputeError) throw err;
477
+ throw new GPUComputeError(`Special op "${name}" failed: ${err.message}`, err);
478
+ }
479
+ }
480
+
481
+ if (!opDef) {
482
+ throw new Error(
483
+ `Unknown operation: "${name}". ` +
484
+ `Use gpu.define() or gpu.defineBuiltin() first, or pick from: ` +
485
+ `[${[...ops.keys(), ...Object.keys(BUILT_IN_OPS), ...Object.keys(SPECIAL_OPS)].join(', ')}]`,
486
+ );
487
+ }
488
+
489
+ const {
490
+ inputs: inputNames = [],
491
+ outputs: outputNames = ['result'],
492
+ uniforms: uniformNames = [],
493
+ type = 'f32',
494
+ fn,
495
+ } = opDef;
496
+
497
+ let { outputs: outputSpec } = input;
498
+ if (!outputSpec) {
499
+ const firstInputName = inputNames[0];
500
+ const firstInput = input.inputs?.[firstInputName];
501
+ if (firstInput) {
502
+ const count = firstInput.byteLength / (firstInput.BYTES_PER_ELEMENT || 4);
503
+ outputSpec = {};
504
+ for (const outName of outputNames) outputSpec[outName] = count;
505
+ } else {
506
+ throw new GPUComputeError(
507
+ `Cannot auto-size outputs: no input named "${firstInputName}" found`,
508
+ );
509
+ }
510
+ }
511
+
512
+ const computeInput = {
513
+ inputs: boxAll(input.inputs || {}),
514
+ uniforms: boxAll(input.uniforms || {}),
515
+ outputBuffers: outputSpec,
516
+ outputType: input.outputType || type,
517
+ workgroups: input.workgroups ?? null,
518
+ signal: input.signal,
519
+ };
520
+
521
+ this.setActive(name);
522
+
523
+ if (!this._available && fn) {
524
+ return fn(computeInput);
525
+ }
526
+
527
+ // If we have a CPU fallback, try GPU first and fall back on error
528
+ // (handles runtimes where adapter exists but dispatch fails, e.g. Dawn/DXC issues)
529
+ if (fn) {
530
+ try {
531
+ return await this.compute(computeInput);
532
+ } catch (err) {
533
+ this._status = 'idle';
534
+ console.warn(`[GPUCompute] GPU dispatch failed for "${name}", using CPU fallback: ${err.message}`);
535
+ return fn(computeInput);
536
+ }
537
+ }
538
+
539
+ return this.compute(computeInput);
540
+ }
541
+
542
+ // -----------------------------------------------------------------------
543
+ // autoTune() — find optimal workgroup size
544
+ // -----------------------------------------------------------------------
545
+
546
+ /**
547
+ * Benchmark different workgroup sizes and return the fastest.
548
+ *
549
+ * @param {string} name - Operation name (must be defined first).
550
+ * @param {Object} input - Sample input for benchmarking.
551
+ * @param {Object} [opts={}]
552
+ * @param {number} [opts.iterations=10] - Number of iterations per size.
553
+ * @param {number[]} [opts.sizes=[32, 64, 128, 256, 512]] - Workgroup sizes to test.
554
+ * @returns {Promise<{ optimal: number, results: Array<{ size: number, avgMs: number }> }>}
555
+ */
556
+ async autoTune(name, input, opts = {}) {
557
+ const { iterations = 10, sizes = [32, 64, 128, 256, 512] } = opts;
558
+ const opDef = this._ops?.get(name);
559
+ if (!opDef) throw new Error(`Unknown operation: "${name}". Define it first with gpu.define().`);
560
+
561
+ const results = [];
562
+ for (const size of sizes) {
563
+ const wgsl = buildShader({
564
+ inputs: opDef.inputs,
565
+ outputs: opDef.outputs,
566
+ uniforms: opDef.uniforms,
567
+ body: opDef._body,
568
+ type: opDef.type,
569
+ workgroupSize: size,
570
+ });
571
+
572
+ const tempName = `_tune_${name}_${size}`;
573
+ this._pipelines.set(tempName, { shader: wgsl, pipeline: null, entryPoint: 'main' });
574
+ if (this._isInitialised && this._device) {
575
+ const entry = this._pipelines.get(tempName);
576
+ entry.pipeline = await this._compileShader(wgsl, 'main', tempName);
577
+ }
578
+
579
+ const times = [];
580
+ for (let i = 0; i < iterations; i++) {
581
+ const start = performance.now();
582
+ this.setActive(tempName);
583
+ await this.run(name, input);
584
+ times.push(performance.now() - start);
585
+ }
586
+
587
+ const avgMs = times.reduce((a, b) => a + b, 0) / times.length;
588
+ results.push({ size, avgMs });
589
+
590
+ // Cleanup temp pipeline
591
+ this._pipelines.delete(tempName);
592
+ }
593
+
594
+ results.sort((a, b) => a.avgMs - b.avgMs);
595
+ return { optimal: results[0].size, results };
596
+ }
597
+
598
+ // -----------------------------------------------------------------------
599
+ // profile() — timing breakdown for pipelines
600
+ // -----------------------------------------------------------------------
601
+
602
+ /**
603
+ * Time a sequence of named operations and return per-step breakdowns.
604
+ *
605
+ * @param {Array<{ name: string, input: Object }>} steps
606
+ * @returns {Promise<{ results: Object, steps: Array<{ name: string, ms: number }>, totalMs: number }>}
607
+ */
608
+ async profile(steps) {
609
+ if (!Array.isArray(steps) || steps.length === 0) {
610
+ throw new TypeError('steps must be a non-empty array');
611
+ }
612
+
613
+ const stepResults = [];
614
+ let carriedOutputs = {};
615
+ const startTime = performance.now();
616
+
617
+ for (let i = 0; i < steps.length; i++) {
618
+ const { name, input } = steps[i];
619
+
620
+ // Merge carried outputs from previous step
621
+ const mergedInput = {
622
+ ...input,
623
+ inputs: { ...carriedOutputs, ...(input.inputs || {}) },
624
+ };
625
+
626
+ const start = performance.now();
627
+ const result = await this.run(name, mergedInput);
628
+ const ms = performance.now() - start;
629
+
630
+ stepResults.push({ name, ms });
631
+ carriedOutputs = result;
632
+ }
633
+
634
+ return {
635
+ results: carriedOutputs,
636
+ steps: stepResults,
637
+ totalMs: performance.now() - startTime,
638
+ };
639
+ }
640
+
641
+ // -----------------------------------------------------------------------
642
+ // runMany() — run different ops in parallel
643
+ // -----------------------------------------------------------------------
644
+
645
+ /**
646
+ * Run multiple named operations concurrently or sequentially.
647
+ *
648
+ * Accepts either format:
649
+ * - `runMany([{ name, input }, ...], { onProgress })` — array of step specs
650
+ * - `runMany({ op1: input1, op2: input2 })` — object form (keys are op names)
651
+ *
652
+ * @param {Array|Object} tasks
653
+ * @param {Object} [opts={}]
654
+ * @returns {Promise<Array<Object<string, TypedArray>>>}
655
+ */
656
+ async runMany(tasks, opts = {}) {
657
+ if (typeof tasks === 'string' || !tasks) {
658
+ throw new TypeError('tasks must be an array or object');
659
+ }
660
+
661
+ if (Array.isArray(tasks)) {
662
+ const { onProgress } = opts;
663
+ const results = [];
664
+ for (let i = 0; i < tasks.length; i++) {
665
+ const t = tasks[i];
666
+ results.push(await this.run(t.name, t.input));
667
+ if (onProgress) onProgress(i + 1, tasks.length);
668
+ }
669
+ return results;
670
+ }
671
+
672
+ // Object form: { opName: input, ... }
673
+ const entries = Object.entries(tasks);
674
+ const results = await Promise.all(
675
+ entries.map(([name, input]) => this.run(name, input)),
676
+ );
677
+ return results;
678
+ }
679
+
680
+ // -----------------------------------------------------------------------
681
+ // map() — parallel element-wise transform
682
+ // -----------------------------------------------------------------------
683
+
684
+ /**
685
+ * Apply a function to every element in parallel on the GPU.
686
+ *
687
+ * @param {TypedArray} data - Input data.
688
+ * @param {string|Function} body - WGSL expression or JS arrow function.
689
+ * @param {Object} [opts={}]
690
+ * @returns {Promise<TypedArray>} Transformed data.
691
+ */
692
+ async map(data, body, opts = {}) {
693
+ if (opts.signal?.aborted) {
694
+ throw new GPUComputeError('Operation cancelled', opts.signal.reason ?? new DOMException('Aborted', 'AbortError'));
695
+ }
696
+
697
+ const { outputType = 'f32', signal } = opts;
698
+ const count = data.byteLength / (data.BYTES_PER_ELEMENT || 4);
699
+ const isFn = typeof body === 'function';
700
+
701
+ const wgslBody = isFn ? jsToWgsl(body) : body;
702
+ const inputNames = isFn ? getParamNames(body) : ['x_in'];
703
+ const opName = `_map_${wgslBody.replace(/\W+/g, '_').slice(0, 32)}`;
704
+
705
+ const inputMap = {};
706
+ inputMap[inputNames[0]] = data;
707
+
708
+ const cpuFn = isFn ? async (input) => {
709
+ const src = input.inputs[inputNames[0]];
710
+ const result = new Float32Array(count);
711
+ for (let i = 0; i < count; i++) result[i] = body(src[i]);
712
+ return { result };
713
+ } : null;
714
+
715
+ this.define(opName, {
716
+ inputs: inputNames.length === 1 ? [inputNames[0]] : inputNames,
717
+ outputs: ['result'],
718
+ body: `result[i] = ${wgslBody};`,
719
+ type: outputType,
720
+ fn: cpuFn,
721
+ });
722
+
723
+ const result = await this.run(opName, {
724
+ inputs: inputMap,
725
+ outputs: { result: count },
726
+ outputType,
727
+ signal,
728
+ });
729
+
730
+ return result.result;
731
+ }
732
+
733
+ // -----------------------------------------------------------------------
734
+ // pipe() — fluent pipeline chaining API
735
+ // -----------------------------------------------------------------------
736
+
737
+ /**
738
+ * Start a fluent pipeline chain.
739
+ *
740
+ * Overloads:
741
+ * - `pipe()` — empty chain
742
+ * - `pipe('op', inputs, uniforms)` — chain starting with a named op
743
+ * - `pipe(fn)` — chain starting with a JS function
744
+ * - `pipe(data, count)` — data-first chain with Proxy-based named methods
745
+ *
746
+ * @returns {PipelineChain|DataPipelineChain}
747
+ */
748
+ pipe(nameOrFn, countOrInput, uniforms, opts) {
749
+ // --- New: pipe(data, count) → Proxy chain with named methods ---
750
+ if (nameOrFn instanceof Float32Array || nameOrFn instanceof Int32Array || nameOrFn instanceof Uint32Array) {
751
+ const data = nameOrFn;
752
+ const count = typeof countOrInput === 'number' ? countOrInput
753
+ : data.byteLength / (data.BYTES_PER_ELEMENT || 4);
754
+ const chain = new DataPipelineChain(this, data, count);
755
+
756
+ return new Proxy(chain, {
757
+ get(target, prop, receiver) {
758
+ if (prop === 'result' || prop === 'add' || prop === 'length' || prop === '_steps' || prop === '_gpu' || prop === '_data' || prop === '_count') {
759
+ return Reflect.get(target, prop, receiver);
760
+ }
761
+ if (typeof prop === 'string' && target._gpu._ops?.has(prop)) {
762
+ return (uniformsOrOpts) => {
763
+ const boxedUniforms = boxAll(uniformsOrOpts || {});
764
+ target.add(prop, { inputs: {}, uniforms: boxedUniforms });
765
+ return receiver;
766
+ };
767
+ }
768
+ return Reflect.get(target, prop, receiver);
769
+ },
770
+ });
771
+ }
772
+
773
+ // --- Existing: pipe() / pipe('op', ...) / pipe(fn) ---
774
+ const chain = new PipelineChain(this);
775
+ if (nameOrFn) {
776
+ if (typeof nameOrFn === 'function') {
777
+ chain.add(nameOrFn);
778
+ } else if (typeof nameOrFn === 'string') {
779
+ chain.add(nameOrFn, { inputs: boxAll(countOrInput || {}), uniforms: boxAll(uniforms || {}), ...opts });
780
+ } else if (typeof nameOrFn === 'object' && nameOrFn.name) {
781
+ chain.add(nameOrFn.name, nameOrFn);
782
+ }
783
+ }
784
+ return chain;
785
+ }
786
+
787
+ // -----------------------------------------------------------------------
788
+ // runBatch — high-level batch execution
789
+ // -----------------------------------------------------------------------
790
+
791
+ /**
792
+ * Run the same operation on many input sets.
793
+ *
794
+ * @param {string} name - Operation name.
795
+ * @param {Array} inputs - Array of input specs or data arrays.
796
+ * @param {Object} [opts={}]
797
+ * @returns {Promise<Array<Object<string, TypedArray>>>}
798
+ */
799
+ async runBatch(name, inputs, opts = {}) {
800
+ if (!Array.isArray(inputs)) throw new TypeError('inputs must be an array');
801
+
802
+ // Flat format: runBatch('op', [data1, data2, ...], { uniforms })
803
+ if (inputs.length > 0 && (ArrayBuffer.isView(inputs[0]) || Array.isArray(inputs[0]))) {
804
+ const { onProgress, uniforms = {} } = opts;
805
+ const results = [];
806
+ for (let i = 0; i < inputs.length; i++) {
807
+ const data = inputs[i];
808
+ const firstKey = 'data';
809
+ const result = await this.run(name, {
810
+ inputs: { [firstKey]: box(data) },
811
+ uniforms: boxAll(uniforms),
812
+ });
813
+ results.push(result);
814
+ if (onProgress) onProgress(i + 1, inputs.length);
815
+ }
816
+ return results;
817
+ }
818
+
819
+ // Old format: runBatch('op', [{ inputs, uniforms, outputs }, ...])
820
+ const { onProgress } = opts;
821
+ const results = [];
822
+ for (let i = 0; i < inputs.length; i++) {
823
+ const result = await this.run(name, inputs[i]);
824
+ results.push(result);
825
+ if (onProgress) onProgress(i + 1, inputs.length);
826
+ }
827
+ return results;
828
+ }
829
+
830
+ // -----------------------------------------------------------------------
831
+ // Buffer management
832
+ // -----------------------------------------------------------------------
833
+
834
+ /** @private */
835
+ _acquireBuffer(byteLength, usage) {
836
+ const aligned = Math.ceil(Math.max(byteLength, 4) / 64) * 64;
837
+ const key = `${aligned}_${usage}`;
838
+ const pool = this._bufferPool.get(key);
839
+ if (pool && pool.length > 0) return pool.pop();
840
+ return this._device.createBuffer({
841
+ size: aligned,
842
+ usage,
843
+ mappedAtCreation: true,
844
+ });
845
+ }
846
+
847
+ /** @private */
848
+ _releaseBuffer(buffer, byteLength, usage) {
849
+ const aligned = Math.ceil(Math.max(byteLength, 4) / 64) * 64;
850
+ const key = `${aligned}_${usage}`;
851
+ if (!this._bufferPool.has(key)) this._bufferPool.set(key, []);
852
+ const pool = this._bufferPool.get(key);
853
+ if (pool.length < 32) pool.push(buffer);
854
+ }
855
+
856
+ /** @private */
857
+ _writeBuffer(buffer, data) {
858
+ const byteLength = data.byteLength;
859
+ const srcData = data.buffer.slice(data.byteOffset, data.byteOffset + byteLength);
860
+ this._device.queue.writeBuffer(buffer, 0, srcData, 0, byteLength);
861
+ this._bytesTransferred += byteLength;
862
+ }
863
+
864
+ /** @private */
865
+ async _readBuffer(buffer, byteLength, TypedArrayConstructor) {
866
+ const readBuf = this._acquireBuffer(
867
+ byteLength,
868
+ GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
869
+ );
870
+
871
+ const encoder = this._device.createCommandEncoder();
872
+ encoder.copyBufferToBuffer(buffer, 0, readBuf, 0, byteLength);
873
+ this._device.queue.submit([encoder.finish()]);
874
+
875
+ await readBuf.mapAsync(GPUMapMode.READ);
876
+ const arrayBuffer = readBuf.getMappedRange();
877
+ const result = new TypedArrayConstructor(arrayBuffer.slice(0, byteLength));
878
+ readBuf.unmap();
879
+ this._releaseBuffer(readBuf, byteLength, GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ);
880
+ this._bytesTransferred += byteLength;
881
+
882
+ return result;
883
+ }
884
+
885
+ /** @private */
886
+ _buildBindGroup({ inputs, uniforms, outputBuffers, outputType }) {
887
+ const entries = [];
888
+ const toRelease = [];
889
+ const outputInfo = {};
890
+ let bindingIndex = 0;
891
+
892
+ for (const [name, data] of Object.entries(inputs)) {
893
+ const byteLength = data.byteLength;
894
+ const buf = this._acquireBuffer(byteLength, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
895
+ this._writeBuffer(buf, data);
896
+ entries.push({ binding: bindingIndex, resource: { buffer: buf } });
897
+ toRelease.push({ buffer: buf, byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
898
+ bindingIndex++;
899
+ }
900
+
901
+ for (const [name, data] of Object.entries(uniforms)) {
902
+ const byteLength = data.byteLength;
903
+ const buf = this._acquireBuffer(byteLength, GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);
904
+ this._writeBuffer(buf, data);
905
+ entries.push({ binding: bindingIndex, resource: { buffer: buf } });
906
+ toRelease.push({ buffer: buf, byteLength, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
907
+ bindingIndex++;
908
+ }
909
+
910
+ const TypeConstructor = resolveType(outputType);
911
+ const bytesPerElement = TypeConstructor.BYTES_PER_ELEMENT || 4;
912
+ for (const [name, elements] of Object.entries(outputBuffers)) {
913
+ const byteLength = elements * bytesPerElement;
914
+ const buf = this._acquireBuffer(byteLength, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC);
915
+ entries.push({ binding: bindingIndex, resource: { buffer: buf } });
916
+ outputInfo[name] = { buffer: buf, byteLength, Constructor: TypeConstructor };
917
+ toRelease.push({ buffer: buf, byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC });
918
+ bindingIndex++;
919
+ }
920
+
921
+ return { entries, outputInfo, toRelease };
922
+ }
923
+
924
+ /** @private */
925
+ _releaseAll(toRelease) {
926
+ for (const { buffer, byteLength, usage } of toRelease) {
927
+ this._releaseBuffer(buffer, byteLength, usage);
928
+ }
929
+ }
930
+
931
+ // -----------------------------------------------------------------------
932
+ // Core compute
933
+ // -----------------------------------------------------------------------
934
+
935
+ /**
936
+ * Execute a compute pass on the GPU.
937
+ *
938
+ * @param {import('../types.js').GPUComputeInput} input
939
+ * @returns {Promise<Object<string, TypedArray>>}
940
+ */
941
+ async compute(input) {
942
+ if (!this._isInitialised) await this.init();
943
+
944
+ const {
945
+ inputs = {},
946
+ uniforms = {},
947
+ outputBuffers = {},
948
+ outputType = 'f32',
949
+ workgroups = null,
950
+ bindingStart = 0,
951
+ } = input;
952
+
953
+ this._status = 'running';
954
+ const startTime = performance.now();
955
+
956
+ try {
957
+ const { entries, outputInfo, toRelease } = this._buildBindGroup({
958
+ inputs, uniforms, outputBuffers, outputType,
959
+ });
960
+
961
+ if (bindingStart > 0) {
962
+ for (const entry of entries) entry.binding += bindingStart;
963
+ }
964
+
965
+ const pipeline = this._pipelines.get(this._activePipeline);
966
+ if (!pipeline || !pipeline.pipeline) {
967
+ throw new GPUComputeError(`Pipeline "${this._activePipeline}" is not compiled`);
968
+ }
969
+
970
+ const bindGroup = this._device.createBindGroup({
971
+ layout: pipeline.pipeline.getBindGroupLayout(0),
972
+ entries,
973
+ });
974
+
975
+ const maxElements = Math.max(
976
+ ...Object.values(inputs).map((d) => d.byteLength / (d.BYTES_PER_ELEMENT || 4)),
977
+ ...Object.values(outputBuffers).map((e) => e),
978
+ 1,
979
+ );
980
+ const wg = workgroups != null ? workgroups : Math.ceil(maxElements / this._workgroupSize);
981
+
982
+ const encoder = this._device.createCommandEncoder();
983
+ const pass = encoder.beginComputePass();
984
+ pass.setPipeline(pipeline.pipeline);
985
+ pass.setBindGroup(0, bindGroup);
986
+ pass.dispatchWorkgroups(wg, 1, 1);
987
+ pass.end();
988
+ this._device.queue.submit([encoder.finish()]);
989
+
990
+ const results = {};
991
+ for (const [name, info] of Object.entries(outputInfo)) {
992
+ results[name] = await this._readBuffer(info.buffer, info.byteLength, info.Constructor);
993
+ }
994
+
995
+ this._releaseAll(toRelease);
996
+
997
+ const duration = performance.now() - startTime;
998
+ this._metrics.record(duration, true);
999
+ this._dispatchCount++;
1000
+ this._status = 'idle';
1001
+ return results;
1002
+ } catch (err) {
1003
+ this._status = 'error';
1004
+ const duration = performance.now() - startTime;
1005
+ this._metrics.record(duration, false);
1006
+ if (err instanceof GPUComputeError) throw err;
1007
+ throw new GPUComputeError(`Compute dispatch failed: ${err.message}`, err);
1008
+ }
1009
+ }
1010
+
1011
+ // -----------------------------------------------------------------------
1012
+ // computeMany / computeSequential / computeWithFallback
1013
+ // -----------------------------------------------------------------------
1014
+
1015
+ /** @param {import('../types.js').GPUComputeInput[]} batches */
1016
+ async computeMany(batches, opts = {}) {
1017
+ if (!Array.isArray(batches)) throw new TypeError('batches must be an array');
1018
+ const { onProgress = null } = opts;
1019
+ const run = this._cpuFallback
1020
+ ? (b) => this.computeWithFallback(b)
1021
+ : (b) => this.compute(b);
1022
+ const results = [];
1023
+ for (let i = 0; i < batches.length; i++) {
1024
+ results.push(await run(batches[i]));
1025
+ if (onProgress) onProgress(i + 1, batches.length);
1026
+ }
1027
+ return results;
1028
+ }
1029
+
1030
+ /** @param {Array} steps */
1031
+ async computeSequential(steps) {
1032
+ if (!Array.isArray(steps) || steps.length === 0) {
1033
+ throw new TypeError('steps must be a non-empty array');
1034
+ }
1035
+
1036
+ const run = this._cpuFallback
1037
+ ? (s) => this.computeWithFallback(s)
1038
+ : (s) => this.compute(s);
1039
+ let carriedOutputs = {};
1040
+
1041
+ for (let i = 0; i < steps.length; i++) {
1042
+ const step = steps[i];
1043
+ if (step.pipeline) this.setActive(step.pipeline);
1044
+ const mergedInputs = { ...carriedOutputs, ...(step.inputs || {}) };
1045
+ const result = await run({
1046
+ inputs: mergedInputs,
1047
+ uniforms: step.uniforms || {},
1048
+ outputBuffers: step.outputBuffers,
1049
+ outputType: step.outputType || 'f32',
1050
+ bindingStart: 0,
1051
+ });
1052
+ carriedOutputs = result;
1053
+ }
1054
+
1055
+ return carriedOutputs;
1056
+ }
1057
+
1058
+ /** @param {import('../types.js').GPUComputeInput} input */
1059
+ async computeWithFallback(input, fallbackOverride = null) {
1060
+ if (!this._available) {
1061
+ const fb = fallbackOverride || this._cpuFallback;
1062
+ if (fb) return fb(input);
1063
+ throw new GPUComputeError('WebGPU unavailable and no cpuFallback provided');
1064
+ }
1065
+
1066
+ try {
1067
+ return await this.compute(input);
1068
+ } catch (err) {
1069
+ const fb = fallbackOverride || this._cpuFallback;
1070
+ if (fb) {
1071
+ console.warn('[GPUCompute] GPU failed, using CPU fallback:', err.message);
1072
+ return fb(input);
1073
+ }
1074
+ throw err;
1075
+ }
1076
+ }
1077
+
1078
+ // -----------------------------------------------------------------------
1079
+ // Lifecycle
1080
+ // -----------------------------------------------------------------------
1081
+
1082
+ destroy() {
1083
+ for (const [, pool] of this._bufferPool) {
1084
+ for (const buf of pool) {
1085
+ try { buf.destroy(); } catch (_) { /* already destroyed */ }
1086
+ }
1087
+ }
1088
+ this._bufferPool.clear();
1089
+ if (this._device) {
1090
+ this._device.destroy();
1091
+ this._device = null;
1092
+ }
1093
+ this._pipelines.forEach((p) => { p.pipeline = null; });
1094
+ this._isInitialised = false;
1095
+ this._status = 'unavailable';
1096
+ }
1097
+
1098
+ resetMetrics() {
1099
+ this._metrics.reset();
1100
+ this._bytesTransferred = 0;
1101
+ this._dispatchCount = 0;
1102
+ }
1103
+
1104
+ // -----------------------------------------------------------------------
1105
+ // Inspect
1106
+ // -----------------------------------------------------------------------
1107
+
1108
+ inspect() {
1109
+ let poolEntries = 0;
1110
+ for (const [, pool] of this._bufferPool) poolEntries += pool.length;
1111
+
1112
+ return {
1113
+ status: this._status,
1114
+ available: this._available,
1115
+ ready: this._isInitialised,
1116
+ activePipeline: this._activePipeline,
1117
+ pipelines: this.pipelines,
1118
+ ops: this.ops,
1119
+ metrics: this._metrics.snapshot(),
1120
+ dispatchCount: this._dispatchCount,
1121
+ bytesTransferred: this._bytesTransferred,
1122
+ bufferPoolEntries: poolEntries,
1123
+ workgroupSize: this._workgroupSize,
1124
+ maxBufferSize: this._maxBufferSize,
1125
+ powerPreference: this._powerPreference,
1126
+ };
1127
+ }
1128
+
1129
+ // -----------------------------------------------------------------------
1130
+ // Getters
1131
+ // -----------------------------------------------------------------------
1132
+
1133
+ /** @type {boolean} */
1134
+ get available() { return this._available; }
1135
+
1136
+ /** @type {boolean} */
1137
+ get ready() { return this._isInitialised; }
1138
+
1139
+ /** @type {'idle'|'running'|'error'|'unavailable'} */
1140
+ get status() { return this._status; }
1141
+
1142
+ /** @type {number} */
1143
+ get dispatchCount() { return this._dispatchCount; }
1144
+
1145
+ /** @type {number} */
1146
+ get bytesTransferred() { return this._bytesTransferred; }
1147
+
1148
+ /** @type {import('../types.js').MetricsSnapshot} */
1149
+ get metrics() { return this._metrics.snapshot(); }
1150
+ }
1151
+
1152
+ // ---------------------------------------------------------------------------
1153
+ // Factory functions
1154
+ // ---------------------------------------------------------------------------
1155
+
1156
+ /**
1157
+ * Create a GPUCompute instance.
1158
+ *
1159
+ * @param {import('../types.js').GPUComputeOptions} options
1160
+ * @returns {GPUCompute}
1161
+ */
1162
+ export function createGPUCompute(options) {
1163
+ return new GPUCompute(options);
1164
+ }
1165
+
1166
+ /**
1167
+ * Create a GPUCompute with a pre-configured CPU fallback.
1168
+ *
1169
+ * @param {string} shader - WGSL shader source.
1170
+ * @param {Function} cpuFunction
1171
+ * @param {Object} [options={}]
1172
+ * @returns {GPUCompute}
1173
+ */
1174
+ export function createGPUWithFallback(shader, cpuFunction, options = {}) {
1175
+ return new GPUCompute({ ...options, shader, cpuFallback: cpuFunction });
1176
+ }
1177
+
1178
+ /**
1179
+ * Helper to create an output spec object.
1180
+ *
1181
+ * @param {string} name - Output buffer name.
1182
+ * @param {number} elements - Number of elements.
1183
+ * @param {string|typeof TypedArray} [type='f32']
1184
+ * @returns {{ outputBuffers: Object<string, number>, outputType: string|typeof TypedArray }}
1185
+ */
1186
+ export function outputSpec(name, elements, type = 'f32') {
1187
+ return { outputBuffers: { [name]: elements }, outputType: type };
1188
+ }
1189
+
1190
+ /**
1191
+ * Helper to create a uniform spec object.
1192
+ *
1193
+ * @param {string} name - Uniform binding name.
1194
+ * @param {TypedArray} data - Uniform data.
1195
+ * @returns {{ uniforms: Object<string, TypedArray> }}
1196
+ */
1197
+ export function uniform(name, data) {
1198
+ return { uniforms: { [name]: data } };
1199
+ }