WgpuCompute.ts

src/wgpu/WgpuCompute.ts · raw · all examples · site home

Exports: ComputeIdsDispatchSizerequireWebGPUWgpuComputeWgpuComputeBatch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
import type { IndirectStorageBufferAttribute, WebGPURenderer } from "three/webgpu";
import { isWebGPUBackend } from "../gpu/backend";
import { TSL, type TslNode } from "../gpu/tsl";
import { SCAN_WORKGROUP, scanTier, threadRank, workgroupExclusiveScan } from "./workgroupScan";

const { Fn, globalId, instanceIndex, localId, numWorkgroups, workgroupId } = TSL;

// Compute builtins handed to a WgpuCompute body
export interface ComputeIds {
  globalId: TslNode;
  localId: TslNode;
  workgroupId: TslNode;
  numWorkgroups: TslNode;
  // Linear thread index: globalId.x, continued over the y rows three
  // splits large 1D dispatches into
  index: TslNode;
}

// Threads (a number), workgroups per dimension, or workgroup counts the GPU
// wrote to an indirect buffer (WgpuBuffer with `indirect`, 3 x uint32)
export type DispatchSize = number | [number, number?, number?] | IndirectStorageBufferAttribute;

export function requireWebGPU(renderer: WebGPURenderer, what: string) {
  if (!isWebGPUBackend(renderer)) {
    throw new Error(`${what} needs the WebGPU backend`);
  }
}

// A compute kernel built once from a TSL body and dispatched with a size per
// call (WebGPU backend only). Bodies bounds-check themselves: a thread count
// is rounded up to whole workgroups, and three adds no `index >= count`
// return.
export class WgpuCompute {
  // Workgroup-wide scans for kernel bodies (workgroupScan.ts): threads per
  // scan workgroup, the tier a device supports ("subgroup" or "portable"),
  // each thread's rank in scan order, and an exclusive prefix sum of one
  // uint per thread plus the total
  static readonly SCAN_WORKGROUP = SCAN_WORKGROUP;
  static readonly scanTier = scanTier;
  static readonly threadRank = threadRank;
  static readonly workgroupExclusiveScan = workgroupExclusiveScan;

  readonly workgroupSize: [number, number, number];
  private computeNode: TslNode;

  constructor({ workgroupSize = [64], body, label }: {
    workgroupSize?: [number] | [number, number] | [number, number, number];
    body: (ids: ComputeIds) => void;
    // Node name, shown by GPU profilers
    label?: string;
  }) {
    this.workgroupSize = [workgroupSize[0], workgroupSize[1] ?? 1, workgroupSize[2] ?? 1];
    this.computeNode = Fn(() => {
      body({ globalId, localId, workgroupId, numWorkgroups, index: instanceIndex });
    })().compute([1, 1, 1], this.workgroupSize);

    if (label) {
      this.computeNode.name = label;
    }
  }

  // A number is a thread count (three divides by the workgroup size); an
  // array is the workgroup count per dimension
  dispatch(renderer: WebGPURenderer, size: DispatchSize) {
    requireWebGPU(renderer, "WgpuCompute");
    if (typeof size === "number") {
      if (size > 0) {
        renderer.compute(this.computeNode, size);
      }
    } else {
      renderer.compute(this.computeNode, dispatchArgument(size));
    }
  }

  // The compute node with its dispatch size set, for WgpuComputeBatch
  prepare(size: DispatchSize): TslNode {
    this.computeNode.dispatchSize = typeof size === "number" ? size : dispatchArgument(size);
    return this.computeNode;
  }

  dispose() {
    this.computeNode.dispose?.();
  }
}

function dispatchArgument(size: Exclude<DispatchSize, number>) {
  return Array.isArray(size) ? [size[0], size[1] ?? 1, size[2] ?? 1] : size;
}

// Dispatches recorded into as few compute passes as possible: submit() hands
// them to renderer.compute() as one list, which three records into one pass
// in order (WebGPU makes each dispatch's storage writes visible to the next).
// A node has one dispatch size per pass, so a kernel queued again with a
// different size starts a new pass. Uniforms hold one value per pass too:
// set them before queueing and do not change them until submit. `split`
// dispatches one by one instead, so GPU profilers time each kernel.
export class WgpuComputeBatch {
  readonly renderer: WebGPURenderer;
  private queue: { kernel: WgpuCompute; size: DispatchSize }[] = [];

  constructor(renderer: WebGPURenderer) {
    requireWebGPU(renderer, "WgpuComputeBatch");
    this.renderer = renderer;
  }

  add(kernel: WgpuCompute, size: DispatchSize): this {
    const empty = typeof size === "number" ? size <= 0 : Array.isArray(size) && size[0] <= 0;
    if (!empty) {
      this.queue.push({ kernel, size });
    }
    return this;
  }

  submit({ split = false }: { split?: boolean } = {}) {
    const { renderer, queue } = this;
    this.queue = [];

    if (split) {
      for (const { kernel, size } of queue) {
        kernel.dispatch(renderer, size);
      }
      return;
    }

    let pass: { kernel: WgpuCompute; size: DispatchSize }[] = [];
    const sizes = new Map<WgpuCompute, string>();
    const flush = () => {
      if (pass.length > 0) {
        renderer.compute(pass.map(({ kernel, size }) => kernel.prepare(size)));
      }
      pass = [];
      sizes.clear();
    };

    for (const entry of queue) {
      const key = String(entry.size);
      const previous = sizes.get(entry.kernel);
      if (previous !== undefined && previous !== key) {
        flush();
      }
      sizes.set(entry.kernel, key);
      pass.push(entry);
    }
    flush();
  }
}