/ concept-collection / turing-surface
Sign in
concept-collection / turing-surface
887 lines · 30.5 KBBlameHistoryRaw
1/**
2 * Statement list -> a replayable sequence of GPU operations.
3 *
4 * Everything expensive happens once, here: pipeline compilation, buffer
5 * allocation, bind-group construction. Because numbl fixes every type and
6 * shape at lowering time, the resulting op sequence is fully static — so
7 * `encodeStep` is pure synchronous command recording, with no allocation, no
8 * pipeline lookup and no readback. That is what lets the whole timestep be
9 * encoded into one submit and keeps the CPU out of the loop.
10 */
11import { isMultiElement, scalarDouble } from 'numbl-src/numbl-core/jit/lowering/types.ts';
12import type {
13 Assign,
14 For,
15 IRExpr,
16 IRStmt,
17 MultiAssignCall,
18} from 'numbl-src/numbl-core/jit/lowering/ir.ts';
19import type { NumericType, Type } from 'numbl-src/numbl-core/jit/lowering/types.ts';
20import { ShtPlan, type ShtBinding, type ShtBatchBinding, type ShtDphigBinding } from '../sht/sht.ts';
21import { DerivPlan, type DerivBinding } from '../sht/deriv.ts';
22import type { CompiledFunction } from './compile.ts';
23import { EXTERNAL_OPS } from './externals.ts';
24import {
25 buildKernel,
26 UnsupportedOnGpu,
27 WORKGROUP_SIZE,
28 type KernelInputs,
29} from './wgsl.ts';
31const isNumeric = (t: Type): t is NumericType => t.kind === 'Numeric';
32const isTensor = (t: Type): boolean => isNumeric(t) && isMultiElement(t);
33const numel = (t: NumericType): number => (t.shape ?? []).reduce((a, b) => a * b, 1);
35/**
36 * The compile-time value of a scalar expression, if it has one. A literal
37 * carries its own; a variable carries one when it was bound to a `const` (the
38 * host's fixed scalars) or computed from constants, because numbl propagates
39 * `exact` through the type lattice.
40 */
41const exactValue = (e: IRExpr): number | undefined => {
42 if (isNumeric(e.ty) && typeof e.ty.exact === 'number') return e.ty.exact;
43 return e.kind === 'NumLit' ? e.value : undefined;
44};
46/** Cap on the iterations a `for` may unroll to. Each one is real GPU work —
47 * its own pipelines at compile time and its own dispatches per step — so a
48 * runaway bound should be a clear error rather than a hang. */
49const MAX_UNROLL = 64;
51interface Slot {
52 buffer: GPUBuffer;
53 count: number;
56const makeBuffer = (device: GPUDevice, label: string, count: number): GPUBuffer =>
57 device.createBuffer({
58 label,
59 size: 4 * count,
60 usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
61 });
63/**
64 * Buffers for host-bound variables, shared across plans.
65 *
66 * A model is two programs — `init` and `step` — compiled separately but
67 * operating on the same state. `U` in the step must be the very buffer `init`
68 * wrote, so the buffers for host bindings live here rather than inside either
69 * plan.
70 */
71export class HostBuffers {
72 #device: GPUDevice;
73 #slots = new Map<string, Slot>();
75 constructor(device: GPUDevice) {
76 this.#device = device;
77 }
79 ensure(name: string, count: number): Slot {
80 const existing = this.#slots.get(name);
81 if (existing) {
82 if (existing.count !== count) {
83 throw new UnsupportedOnGpu(
84 `'${name}' is ${existing.count} elements in one program and ` +
85 `${count} in another`,
86 );
87 }
88 return existing;
89 }
90 const slot = { buffer: makeBuffer(this.#device, `mgpu-${name}`, count), count };
91 this.#slots.set(name, slot);
92 return slot;
93 }
95 get(name: string): Slot | undefined {
96 return this.#slots.get(name);
97 }
99 /** Upload initial data for a host binding. */
100 upload(name: string, data: Float32Array): void {
101 const slot = this.#slots.get(name);
102 if (!slot) throw new Error(`upload: no buffer named '${name}'`);
103 if (data.length !== slot.count) {
104 throw new Error(
105 `upload '${name}': expected ${slot.count} elements, got ${data.length}`,
106 );
107 }
108 this.#device.queue.writeBuffer(slot.buffer, 0, data as Float32Array<ArrayBuffer>);
109 }
111 destroy(): void {
112 for (const s of this.#slots.values()) s.buffer.destroy();
113 this.#slots.clear();
114 }
117type Op =
118 | {
119 kind: 'kernel';
120 pipeline: GPUComputePipeline;
121 bindGroup: GPUBindGroup;
122 count: number;
123 label: string;
124 /** Set when the kernel had to write to scratch because its output
125 * aliases one of its inputs; copied back after the dispatch. */
126 copyBack?: { from: GPUBuffer; to: GPUBuffer; bytes: number };
127 }
128 | { kind: 'synth' | 'analys'; binding: ShtBinding; label: string }
129 | { kind: 'synth-batch' | 'analys-batch'; binding: ShtBatchBinding; labels: string[] }
130 | { kind: 'dtheta' | 'dphi'; binding: DerivBinding; label: string }
131 | { kind: 'dthetac' | 'dphic'; bindGroup: GPUBindGroup; label: string }
132 | { kind: 'dphig'; binding: ShtDphigBinding; label: string }
133 | { kind: 'copy'; from: GPUBuffer; to: GPUBuffer; bytes: number; label: string };
135/**
136 * A transform op as planned, before bindings exist: `in`/`out` are the
137 * caller-side buffers (spectral in / grid out for synth, the reverse for
138 * analys). Kept unbound until every statement is planned so that adjacent
139 * independent transforms of the same kind can be grouped into one batched
140 * dispatch (ShtPlan.createSynthBatchBinding) — the Legendre recurrence is
141 * the expensive shared part, and a batch walks it once for all lanes.
142 */
143interface PendingSht {
144 pending: true;
145 kind: 'synth' | 'analys';
146 in: GPUBuffer;
147 out: GPUBuffer;
148 label: string;
151type Planned = Op | PendingSht;
153const isPending = (op: Planned): op is PendingSht => 'pending' in op;
155/**
156 * Group maximal runs of adjacent same-kind transforms into batches of the
157 * widest compiled lane count, and create all bindings. Only literal
158 * adjacency in the op sequence is batched — no reordering — so the models
159 * are written to keep batchable transforms consecutive (see the solve loops
160 * in models/*.m). Batching changes dispatch shape only: per-lane arithmetic
161 * is identical to the scalar kernels', so results do not depend on batchK.
162 */
163function materializeTransforms(planned: Planned[], sht: ShtPlan): Op[] {
164 /** Lanes must not collide: distinct outputs, and no lane reading another's
165 * output (repeated read-only inputs would be harmless, but WebGPU also
166 * forbids aliasing a writable binding, so outputs are the hard rule). */
167 const disjoint = (members: PendingSht[]): boolean => {
168 const outs = new Set<GPUBuffer>();
169 for (const m of members) {
170 if (outs.has(m.out)) return false;
171 outs.add(m.out);
172 }
173 return members.every((m) => !outs.has(m.in));
174 };
175 const bind = (m: PendingSht): Op =>
176 m.kind === 'synth'
177 ? { kind: 'synth', binding: sht.createSynthBinding(m.in, m.out), label: m.label }
178 : { kind: 'analys', binding: sht.createAnalysBinding(m.in, m.out), label: m.label };
179 const bindBatch = (members: PendingSht[]): Op =>
180 members[0].kind === 'synth'
181 ? {
182 kind: 'synth-batch',
183 binding: sht.createSynthBatchBinding(
184 members.map((m) => ({ qlmIn: m.in, spatOut: m.out })),
185 ),
186 labels: members.map((m) => m.label),
187 }
188 : {
189 kind: 'analys-batch',
190 binding: sht.createAnalysBatchBinding(
191 members.map((m) => ({ spatIn: m.in, qlmOut: m.out })),
192 ),
193 labels: members.map((m) => m.label),
194 };
196 const out: Op[] = [];
197 let i = 0;
198 while (i < planned.length) {
199 const op = planned[i];
200 if (!isPending(op)) {
201 out.push(op);
202 i++;
203 continue;
204 }
205 let j = i;
206 while (j < planned.length) {
207 const p = planned[j];
208 if (!isPending(p) || p.kind !== op.kind) break;
209 j++;
210 }
211 const run = planned.slice(i, j) as PendingSht[];
212 let s = 0;
213 while (s < run.length) {
214 let take = 1;
215 for (const K of [4, 2]) {
216 if (K > sht.batchK || s + K > run.length) continue;
217 if (disjoint(run.slice(s, s + K))) {
218 take = K;
219 break;
220 }
221 }
222 out.push(take === 1 ? bind(run[s]) : bindBatch(run.slice(s, s + take)));
223 s += take;
224 }
225 i = j;
226 }
227 return out;
230export interface PlanSpec {
231 /** The specialized function this plan executes. */
232 fn: CompiledFunction;
233 /** Output index -> host binding name to copy the result into after the run,
234 * so the next call reads it (the new spectral state feeds the old). */
235 feedback: (string | null)[];
238/**
239 * Bind group layout for a kernel: the output at 0, `inputs` read-only storage
240 * buffers after it, then the params buffer.
241 *
242 * Declared explicitly rather than with `layout: 'auto'`, because an auto layout
243 * only contains the bindings the shader actually references — so a kernel that
244 * happens to use no parameters (`uuv = u .* u .* v`) would drop the params
245 * binding and no longer match the bind group. An explicit layout may carry
246 * bindings the shader ignores.
247 */
248function kernelLayout(device: GPUDevice, inputs: number): GPUBindGroupLayout {
249 const readOnly = (binding: number): GPUBindGroupLayoutEntry => ({
250 binding,
251 visibility: GPUShaderStage.COMPUTE,
252 buffer: { type: 'read-only-storage' },
253 });
254 return device.createBindGroupLayout({
255 entries: [
256 {
257 binding: 0,
258 visibility: GPUShaderStage.COMPUTE,
259 buffer: { type: 'storage' },
260 },
261 ...Array.from({ length: inputs }, (_, i) => readOnly(i + 1)),
262 readOnly(inputs + 1),
263 ],
264 });
267async function makePipeline(
268 device: GPUDevice,
269 code: string,
270 label: string,
271 bindGroupLayout: GPUBindGroupLayout,
272): Promise<GPUComputePipeline> {
273 device.pushErrorScope('validation');
274 const module = device.createShaderModule({ code, label });
275 const info = await module.getCompilationInfo();
276 const errors = info.messages.filter((m) => m.type === 'error');
277 if (errors.length) {
278 throw new UnsupportedOnGpu(
279 `generated WGSL failed to compile for '${label}':\n` +
280 errors.map((e) => ` ${e.lineNum}:${e.linePos} ${e.message}`).join('\n') +
281 `\n--- shader ---\n${code}`,
282 );
283 }
284 const pipeline = await device.createComputePipelineAsync({
285 layout: device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] }),
286 compute: { module, entryPoint: 'main' },
287 label,
288 });
289 const err = await device.popErrorScope();
290 if (err) throw new UnsupportedOnGpu(`pipeline '${label}': ${err.message}`);
291 return pipeline;
294/** A compiled .m step, ready to run on the GPU. */
295export class ModelPlan {
296 /** Scalar parameter names, in the order the params buffer expects them. */
297 readonly paramNames: string[];
299 #device: GPUDevice;
300 #sht: ShtPlan;
301 #deriv?: DerivPlan;
302 #ops: Op[];
303 #owned: GPUBuffer[];
304 #paramBuf: GPUBuffer;
305 #paramData: Float32Array;
306 /** Public name -> buffer, for uploading initial state and reading results. */
307 #byName: Map<string, Slot>;
309 private constructor(init: {
310 device: GPUDevice;
311 sht: ShtPlan;
312 deriv?: DerivPlan;
313 ops: Op[];
314 byName: Map<string, Slot>;
315 owned: GPUBuffer[];
316 paramBuf: GPUBuffer;
317 paramData: Float32Array;
318 paramNames: string[];
319 }) {
320 this.#device = init.device;
321 this.#sht = init.sht;
322 this.#deriv = init.deriv;
323 this.#ops = init.ops;
324 this.#byName = init.byName;
325 this.#owned = init.owned;
326 this.#paramBuf = init.paramBuf;
327 this.#paramData = init.paramData;
328 this.paramNames = init.paramNames;
329 }
331 static async create(
332 device: GPUDevice,
333 sht: ShtPlan,
334 spec: PlanSpec,
335 host: HostBuffers,
336 /** Computes dtheta/dphi — only needed if the .m calls them. */
337 deriv?: DerivPlan,
338 ): Promise<ModelPlan> {
339 const { fn } = spec;
341 const slots = new Map<string, Slot>();
342 const byName = new Map<string, Slot>();
343 const owned: GPUBuffer[] = [];
344 /** Scalars the .m computes from its parameters, by cName. */
345 const derivedScalars = new Map<string, { name: string; expr: IRExpr }>();
347 const alloc = (label: string, count: number): Slot => {
348 const buffer = makeBuffer(device, label, count);
349 owned.push(buffer);
350 return { buffer, count };
351 };
353 // Arguments, bound by what the function's signature declares. Array
354 // arguments come from the shared pool, so a value one function returns is
355 // the same buffer the next one reads. Scalar parameters share one small
356 // storage buffer, in signature order.
357 const paramNames: string[] = [];
358 const paramSlots = new Map<string, number>();
359 for (const p of fn.params) {
360 if (p.binding.kind === 'tensor') {
361 const count = p.binding.shape.reduce((x, y) => x * y, 1);
362 const slot = host.ensure(p.name, count);
363 slots.set(p.cName, slot);
364 byName.set(p.name, slot);
365 } else if (p.binding.kind === 'param') {
366 paramSlots.set(p.cName, paramNames.length);
367 paramNames.push(p.name);
368 }
369 // `const` arguments are exact in the IR and fold into the kernels.
370 }
371 const paramData = new Float32Array(Math.max(1, paramNames.length));
372 const paramBuf = device.createBuffer({
373 label: 'mgpu-params',
374 size: 4 * paramData.length,
375 usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
376 });
378 const planned: Planned[] = [];
379 for (const stmt of fn.body) {
380 await planStatement(stmt);
381 }
383 // Feed declared outputs back into the argument buffers they replace.
384 fn.outputs.forEach((out, i) => {
385 const to = spec.feedback[i];
386 if (!to) return;
387 const src = slots.get(out.cName);
388 const dst = host.get(to);
389 if (!src) {
390 throw new UnsupportedOnGpu(
391 `'${fn.name}' declares the output '${out.name}' but never assigns it`,
392 );
393 }
394 if (!dst) throw new UnsupportedOnGpu(`'${to}' is not a host binding`);
395 if (src.count !== dst.count) {
396 throw new UnsupportedOnGpu(
397 `'${out.name}' (${src.count} elements) cannot feed ` +
398 `'${to}' (${dst.count})`,
399 );
400 }
401 planned.push({
402 kind: 'copy',
403 from: src.buffer,
404 to: dst.buffer,
405 bytes: 4 * src.count,
406 label: `${out.name} -> ${to}`,
407 });
408 });
410 // Group adjacent independent transforms into batched dispatches and
411 // create every binding.
412 const ops = materializeTransforms(planned, sht);
414 return new ModelPlan({
415 device, sht, deriv, ops, byName, owned, paramBuf, paramData, paramNames,
416 });
418 async function planStatement(stmt: IRStmt): Promise<void> {
419 if (stmt.kind === 'ReturnFromFunction') return; // nothing follows it
420 if (stmt.kind === 'For') return planFor(stmt);
421 if (stmt.kind === 'MultiAssignCall') return planMultiTransform(stmt);
422 if (stmt.kind !== 'Assign') {
423 throw new UnsupportedOnGpu(
424 `a model function body may only contain assignments ` +
425 `(found '${stmt.kind}')`,
426 stmt.span,
427 );
428 }
429 if (!isNumeric(stmt.ty)) {
430 throw new UnsupportedOnGpu(
431 `'${stmt.name}' is not a numeric value`,
432 stmt.span,
433 );
434 }
435 if (!isTensor(stmt.ty)) {
436 // A scalar the model derives from its parameters (`us = a + b`). It
437 // gets no buffer and no dispatch: the kernels that read it bind it as
438 // a `let` in their prologue.
439 derivedScalars.set(stmt.cName, { name: stmt.name, expr: stmt.expr });
440 return;
441 }
442 const count = numel(stmt.ty);
444 // Reuse the destination buffer across steps: the same cName always maps
445 // to the same buffer, so a step allocates nothing.
446 let dest = slots.get(stmt.cName);
447 if (!dest) {
448 dest = alloc(`mgpu-${stmt.name}`, count);
449 slots.set(stmt.cName, dest);
450 } else if (dest.count !== count) {
451 throw new UnsupportedOnGpu(
452 `'${stmt.name}' changes size between assignments`,
453 stmt.span,
454 );
455 }
456 byName.set(stmt.name, dest);
458 const ext = externalCall(stmt);
459 if (ext) {
460 const argSlot = slots.get(ext.argCName);
461 if (!argSlot) {
462 throw new UnsupportedOnGpu(
463 `'${ext.name}' reads '${ext.argName}', which has no buffer`,
464 stmt.span,
465 );
466 }
467 const label = `${stmt.name} = ${ext.name}(${ext.argName})`;
468 if (ext.name === 'dphig') {
469 // Grid -> grid, staged through the plan's fm scratch; safe even
470 // in place, so no aliasing guard is needed.
471 planned.push({
472 kind: 'dphig',
473 binding: sht.createDphigBinding(argSlot.buffer, dest.buffer),
474 label,
475 });
476 return;
477 }
478 if (ext.name === 'synth' || ext.name === 'analys') {
479 // Left unbound until materializeTransforms has grouped adjacent
480 // independent transforms into batched dispatches.
481 planned.push({
482 pending: true,
483 kind: ext.name,
484 in: argSlot.buffer,
485 out: dest.buffer,
486 label,
487 });
488 } else if (
489 ext.name === 'dtheta' || ext.name === 'dphi' ||
490 ext.name === 'dthetac' || ext.name === 'dphic'
491 ) {
492 if (!deriv) {
493 throw new UnsupportedOnGpu(
494 `'${ext.name}' needs the surface's derivative transforms, ` +
495 `which this plan was not given`,
496 stmt.span,
497 );
498 }
499 if (ext.name === 'dthetac' || ext.name === 'dphic') {
500 // Coefficient-space shuffles read at l+-1 (dthetac) or in place
501 // (dphic) and cannot alias their output: WebGPU forbids one buffer
502 // being readable and writable storage in the same dispatch, and
503 // there is no scratch-copy fallback here — refuse rather than
504 // silently reroute.
505 if (argSlot.buffer === dest.buffer) {
506 throw new UnsupportedOnGpu(
507 `'${stmt.name} = ${ext.name}(${ext.argName})' reads and ` +
508 `writes the same buffer; assign to a new name instead`,
509 stmt.span,
510 );
511 }
512 planned.push({
513 kind: ext.name,
514 bindGroup:
515 ext.name === 'dthetac'
516 ? deriv.createDthetacBinding(argSlot.buffer, dest.buffer)
517 : deriv.createDphicBinding(argSlot.buffer, dest.buffer),
518 label,
519 });
520 return;
521 }
522 planned.push(
523 ext.name === 'dtheta'
524 ? { kind: 'dtheta', binding: deriv.createDthetaBinding(argSlot.buffer, dest.buffer), label }
525 : { kind: 'dphi', binding: deriv.createDphiBinding(argSlot.buffer, dest.buffer), label },
526 );
527 } else {
528 throw new UnsupportedOnGpu(`unknown external op '${ext.name}'`, stmt.span);
529 }
530 return;
531 }
533 // Element-wise kernel. Collect the distinct tensor operands and give
534 // them dense binding slots.
535 const tensors = new Map<string, number>();
536 collectTensorVars(stmt.expr, (cName) => {
537 if (!tensors.has(cName)) tensors.set(cName, tensors.size);
538 });
540 const label = `${stmt.name} = <${count} elements, element-wise>`;
541 const kernel = buildKernel(
542 stmt,
543 {
544 tensors,
545 params: paramSlots,
546 scalars: derivedScalars,
547 } satisfies KernelInputs,
548 count,
549 label,
550 );
552 const bindGroupLayout = kernelLayout(device, tensors.size);
553 const pipeline = await makePipeline(device, kernel.code, label, bindGroupLayout);
555 // WebGPU forbids aliasing a writable storage binding with another
556 // binding in the same group, so an in-place update (`u = u + 1`) writes
557 // to scratch and copies back. Element-wise kernels only ever touch
558 // their own index, so the copy is the only cost.
559 const aliased = tensors.has(stmt.cName);
560 const target = aliased ? alloc(`mgpu-${stmt.name}-scratch`, count) : dest;
562 const entries: GPUBindGroupEntry[] = [
563 { binding: 0, resource: { buffer: target.buffer } },
564 ];
565 for (const [cName, i] of tensors) {
566 const s = slots.get(cName);
567 if (!s) {
568 throw new UnsupportedOnGpu(
569 `'${stmt.name}' reads a value with no buffer`,
570 stmt.span,
571 );
572 }
573 entries.push({ binding: i + 1, resource: { buffer: s.buffer } });
574 }
575 entries.push({ binding: tensors.size + 1, resource: { buffer: paramBuf } });
577 planned.push({
578 kind: 'kernel',
579 pipeline,
580 bindGroup: device.createBindGroup({
581 layout: bindGroupLayout,
582 entries,
583 }),
584 count,
585 label,
586 copyBack: aliased
587 ? { from: target.buffer, to: dest.buffer, bytes: 4 * count }
588 : undefined,
589 });
590 }
592 /**
593 * `[a, b] = synth(x, y)` / `[a, b] = analys(x, y)`: an explicitly grouped
594 * transform — output k is the transform of argument k. The group is
595 * planned as consecutive pending transforms, which materializeTransforms
596 * then chunks into whatever batched dispatch widths the device supports
597 * (one x4 batch, two x2, or scalars with SHT_BATCH=0) — the syntax
598 * promises grouping intent, never a lane width, so the same source
599 * compiles everywhere.
600 */
601 function planMultiTransform(stmt: MultiAssignCall): void {
602 if (stmt.name !== 'synth' && stmt.name !== 'analys') {
603 throw new UnsupportedOnGpu(
604 `'${stmt.name}' does not return multiple values here — only the ` +
605 `transforms ('synth', 'analys') support [a, b] = op(x, y) grouping`,
606 stmt.span,
607 );
608 }
609 const kind = stmt.name;
610 for (let i = 0; i < stmt.outputs.length; i++) {
611 const slot = stmt.outputs[i];
612 const arg = stmt.args[i];
613 if (!slot.binding) {
614 throw new UnsupportedOnGpu(
615 `every output of '${kind}' must be bound to a name — output ` +
616 `${i + 1} is dropped, but each input costs a transform`,
617 stmt.span,
618 );
619 }
620 if (!arg || arg.kind !== 'Var') {
621 throw new UnsupportedOnGpu(
622 `'${kind}' must be applied to variables (argument ${i + 1})`,
623 stmt.span,
624 );
625 }
626 const argSlot = slots.get(arg.cName);
627 if (!argSlot) {
628 throw new UnsupportedOnGpu(
629 `'${kind}' reads '${arg.name}', which has no buffer`,
630 stmt.span,
631 );
632 }
633 if (!isNumeric(slot.ty) || !isTensor(slot.ty)) {
634 throw new UnsupportedOnGpu(
635 `'${slot.binding.name}' is not a numeric array`,
636 stmt.span,
637 );
638 }
639 const count = numel(slot.ty);
640 let dest = slots.get(slot.binding.cName);
641 if (!dest) {
642 dest = alloc(`mgpu-${slot.binding.name}`, count);
643 slots.set(slot.binding.cName, dest);
644 } else if (dest.count !== count) {
645 throw new UnsupportedOnGpu(
646 `'${slot.binding.name}' changes size between assignments`,
647 stmt.span,
648 );
649 }
650 byName.set(slot.binding.name, dest);
651 planned.push({
652 pending: true,
653 kind,
654 in: argSlot.buffer,
655 out: dest.buffer,
656 label: `${slot.binding.name} = ${kind}(${arg.name})`,
657 });
658 }
659 }
661 /**
662 * Unroll a counted loop into the op sequence.
663 *
664 * A plan is a fixed list of GPU operations with no branching, which is what
665 * makes a timestep pure command recording. A `for` with compile-time-known
666 * bounds still fits that: it is the same body planned once per iteration.
667 * Nothing else changes — numbl gives a variable one cName for every
668 * assignment to it, so the buffer an iteration writes is the buffer the
669 * next one reads, which is exactly a loop-carried value.
670 *
671 * The loop variable gets no buffer either: it is bound as a derived scalar
672 * to this iteration's literal value, so a kernel that reads `k` folds the
673 * number in. The binding is overwritten per iteration, before that
674 * iteration's body is planned and its WGSL emitted.
675 */
676 async function planFor(stmt: For): Promise<void> {
677 const from = exactValue(stmt.start);
678 const to = exactValue(stmt.end);
679 if (from === undefined || to === undefined) {
680 throw new UnsupportedOnGpu(
681 `a 'for' loop is unrolled into the op sequence, so its bounds must ` +
682 `be known when the model is compiled — ` +
683 `${from === undefined ? 'the start' : 'the end'} of this one is a ` +
684 `runtime value. Use a whole number, or a count the app supplies ` +
685 `as a fixed argument (changing it recompiles).`,
686 stmt.span,
687 );
688 }
689 const trips = Math.floor((to - from) / stmt.step) + 1;
690 if (!Number.isFinite(trips)) {
691 throw new UnsupportedOnGpu(`'for ${stmt.varName}' has no finite length`, stmt.span);
692 }
693 if (trips > MAX_UNROLL) {
694 throw new UnsupportedOnGpu(
695 `'for ${stmt.varName}' would unroll to ${trips} iterations, over the ` +
696 `limit of ${MAX_UNROLL}. Every iteration is separate GPU work, so a ` +
697 `long loop compiles slowly and runs no faster than writing it out.`,
698 stmt.span,
699 );
700 }
701 for (let i = 0; i < trips; i++) {
702 const value = from + i * stmt.step;
703 derivedScalars.set(stmt.cVar, {
704 name: stmt.varName,
705 expr: {
706 kind: 'NumLit',
707 value,
708 ty: scalarDouble(
709 value > 0 ? 'positive' : value < 0 ? 'negative' : 'zero',
710 value,
711 ),
712 span: stmt.span,
713 },
714 });
715 for (const s of stmt.body) await planStatement(s);
716 }
717 }
718 }
720 /** Upload parameter values, in `paramNames` order. Cheap — call freely. */
721 setParams(values: Record<string, number>): void {
722 this.paramNames.forEach((name, i) => {
723 const v = values[name];
724 this.#paramData[i] = Number.isFinite(v) ? v : 0;
725 });
726 this.#device.queue.writeBuffer(
727 this.#paramBuf,
728 0,
729 this.#paramData as Float32Array<ArrayBuffer>,
730 );
731 }
733 /** Buffer holding the named value, or undefined if the .m never binds it. */
734 buffer(name: string): GPUBuffer | undefined {
735 return this.#byName.get(name)?.buffer;
736 }
738 elementCount(name: string): number | undefined {
739 return this.#byName.get(name)?.count;
740 }
742 /**
743 * Record `steps` timesteps. Synchronous: no awaits, no readback. All of the
744 * ops share one compute pass, which WebGPU executes in submission order
745 * with a barrier between dispatches.
746 */
747 encodeSteps(encoder: GPUCommandEncoder, steps: number): void {
748 for (let s = 0; s < steps; s++) {
749 let pass: GPUComputePassEncoder | null = null;
750 const inPass = (): GPUComputePassEncoder => {
751 if (!pass) pass = encoder.beginComputePass({ label: 'mgpu-step' });
752 return pass;
753 };
754 const endPass = (): void => {
755 if (pass) {
756 pass.end();
757 pass = null;
758 }
759 };
760 for (const op of this.#ops) {
761 switch (op.kind) {
762 case 'kernel': {
763 const p = inPass();
764 p.setPipeline(op.pipeline);
765 p.setBindGroup(0, op.bindGroup);
766 p.dispatchWorkgroups(Math.ceil(op.count / WORKGROUP_SIZE));
767 if (op.copyBack) {
768 endPass();
769 encoder.copyBufferToBuffer(
770 op.copyBack.from, 0, op.copyBack.to, 0, op.copyBack.bytes,
771 );
772 }
773 break;
774 }
775 case 'synth':
776 this.#shtInto(inPass(), op);
777 break;
778 case 'analys':
779 this.#shtInto(inPass(), op);
780 break;
781 case 'dtheta':
782 this.#derivInto(inPass(), op);
783 break;
784 case 'dphi':
785 this.#derivInto(inPass(), op);
786 break;
787 case 'dthetac':
788 this.#deriv!.encodeDthetacInto(inPass(), op.bindGroup);
789 break;
790 case 'dphic':
791 this.#deriv!.encodeDphicInto(inPass(), op.bindGroup);
792 break;
793 case 'dphig':
794 this.#sht.encodeDphigInto(inPass(), op.binding);
795 break;
796 case 'synth-batch':
797 this.#sht.encodeSynthBatchInto(inPass(), op.binding);
798 break;
799 case 'analys-batch':
800 this.#sht.encodeAnalysBatchInto(inPass(), op.binding);
801 break;
802 case 'copy':
803 endPass();
804 encoder.copyBufferToBuffer(op.from, 0, op.to, 0, op.bytes);
805 break;
806 }
807 }
808 endPass();
809 }
810 }
812 #shtInto(pass: GPUComputePassEncoder, op: Op & { kind: 'synth' | 'analys' }): void {
813 if (op.kind === 'synth') this.#sht.encodeSynthInto(pass, op.binding);
814 else this.#sht.encodeAnalysInto(pass, op.binding);
815 }
817 #derivInto(pass: GPUComputePassEncoder, op: Op & { kind: 'dtheta' | 'dphi' }): void {
818 // planStatement already refused to plan a dtheta/dphi op without a
819 // DerivPlan, so #deriv is guaranteed set whenever an op of this kind exists.
820 if (op.kind === 'dtheta') this.#deriv!.encodeDthetaInto(pass, op.binding);
821 else this.#deriv!.encodeDphiInto(pass, op.binding);
822 }
824 /**
825 * Human-readable op sequence — what the .m actually compiled to. Batched
826 * transforms list one line per lane, annotated: the line count equals the
827 * logical op count regardless of the device's batch width, so op-count
828 * assertions in the tests are batch-invariant.
829 */
830 describe(): string[] {
831 return this.#ops.flatMap((op) => {
832 if ('labels' in op) {
833 const kind = op.kind === 'synth-batch' ? 'synth' : 'analys';
834 return op.labels.map(
835 (label, i) =>
836 `${kind.padEnd(7)} ${label} [batch lane ${i + 1}/${op.binding.size}]`,
837 );
838 }
839 return [`${op.kind.padEnd(7)} ${op.label}`];
840 });
841 }
843 destroy(): void {
844 for (const b of this.#owned) b.destroy();
845 this.#paramBuf.destroy();
846 this.#owned.length = 0;
847 }
850/** `x = synth(y)` / `x = analys(y)` -> the call's name and argument. */
851function externalCall(
852 stmt: Assign,
853): { name: string; argCName: string; argName: string } | null {
854 const e = stmt.expr;
855 if (e.kind !== 'Call' || !EXTERNAL_OPS.has(e.name)) return null;
856 if (e.args.length !== 1 || e.args[0].kind !== 'Var') {
857 throw new UnsupportedOnGpu(
858 `'${e.name}' must be applied to a single variable`,
859 stmt.span,
860 );
861 }
862 const arg = e.args[0];
863 return { name: e.name, argCName: arg.cName, argName: arg.name };
866function collectTensorVars(e: IRExpr, visit: (cName: string) => void): void {
867 const walk = (x: IRExpr): void => {
868 switch (x.kind) {
869 case 'Var':
870 if (isTensor(x.ty)) visit(x.cName);
871 return;
872 case 'Binary':
873 walk(x.left);
874 walk(x.right);
875 return;
876 case 'Unary':
877 walk(x.operand);
878 return;
879 case 'Call':
880 x.args.forEach(walk);
881 return;
882 default:
883 return;
884 }
885 };
886 walk(e);
moveopenescclose