WebGPU compute shaders 101 — your first dispatch, in the browser

lathe v0.4.0 slug: webgpu-compute-shader-101 3 parts · 7 sources
part viewer webgpu-compute-shader-101

Your first WGSL compute pipeline — one buffer, one workgroup, one dispatch

If you've read three "intro to GPU compute" posts and still couldn't tell me, with a straight face, what @workgroup_size(64) actually buys you, you're in the right place.

The shader you'll write below runs on the same silicon that draws Cyberpunk 2077, but it doesn't draw anything. You'll hand the GPU a buffer of 1024 floats, tell it to square every one of them in parallel, and then read the result back into a Float32Array in your <script> tag. No three.js. No render pass. No triangle. The whole thing fits in one HTML file and runs in any Chrome ≥ 113.

By the end of this part, you'll have a single page that, when you click Run, dispatches a compute pass and prints [0, 1, 4, 9, 16, …] to the console — and you'll be able to point at every line and say what it does.

What you'll build

A self-contained index.html that opens a WebGPU device, uploads 1024 floats [0, 1, 2, …, 1023] to a storage buffer, dispatches one compute pipeline that squares every element, copies the result to a MAP_READ buffer, and logs the first sixteen squared numbers. The GPU does 1024 multiplications in a single dispatchWorkgroups(16, 1, 1) call.

Prerequisites

  • Chrome ≥ 113 (or Edge ≥ 113). Open chrome://gpu and confirm the WebGPU row says "Hardware accelerated." — if it says "Software only," you can still run this, it just won't be fast.
  • A flat file works (file://…/index.html). WebGPU does not require HTTPS for local development; it requires it for service workers, which we won't use.
  • Comfort with async/await and typed arrays. You don't need any prior shader experience.

A device, then a buffer

Before the GPU will run a single instruction for us, we have to get hold of a GPUDevice. The device is the API handle for one logical GPU; everything else — buffers, pipelines, command encoders — hangs off it.

<!doctype html>
<html>
<body>
<button id="run">Run</button>
<script type="module">
const adapter = await navigator.gpu?.requestAdapter();
if (!adapter) throw new Error("WebGPU not available on this browser.");
const device = await adapter.requestDevice();
console.log("Got device:", device.label || "(unnamed)");
</script>
</body>
</html>

Save that, open it in Chrome, and check the console. If you see Got device:, the adapter handshake worked and you have a live GPUDevice. If you see "WebGPU not available", hit chrome://flags/#enable-unsafe-webgpu and try again.

Heads up

navigator.gpu?.requestAdapter() returns null on a browser without WebGPU. The ?. and the explicit throw matter — if you destructure await navigator.gpu.requestAdapter() directly on Firefox today, you get an uncaught TypeError instead of a useful message.

Now we need somewhere on the GPU to put our 1024 floats. The GPU has its own address space; the only way to populate it is through a GPUBuffer. We'll make one for the input data:

const N = 1024;
const input = new Float32Array(N);
for (let i = 0; i < N; i++) input[i] = i;          // 0, 1, 2, … 1023

const inputBuffer = device.createBuffer({
  size: input.byteLength,                          // 4096 bytes
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(inputBuffer, 0, input);

Two flags, both load-bearing. STORAGE says "a shader will read/write this through a storage binding"; COPY_DST says "the queue may copy bytes into it from the CPU side." Drop either one and the next stage's validation will yell at you.

Aside

"Storage buffer" is the WebGPU name for what older APIs call SSBOs (OpenGL) or RWStructuredBuffers (D3D). Same idea: a chunk of GPU memory the shader treats as a flat array of structs. The casual name in the literature is storage; the impressive name to drop at the GPU bar is "unbound, mutable shader-visible memory."

The shader — one function, one decorator

The actual compute happens in a tiny WGSL string. Don't worry about parsing every line; we'll read it twice.

const wgsl = `
@group(0) @binding(0) var<storage, read_write> data: array<f32>;

@compute @workgroup_size(64)
fn square_them(@builtin(global_invocation_id) gid: vec3<u32>) {
  let i = gid.x;
  if (i >= arrayLength(&data)) { return; }
  data[i] = data[i] * data[i];
}
`;

Three pieces in three lines:

  1. The binding. @group(0) @binding(0) says "this resource lives at bind-group 0, slot 0." The JS side will plug the buffer into the same address. var<storage, read_write> declares the access mode — we need write, because the shader is mutating in place.
  2. The kernel signature. @compute @workgroup_size(64) says "this is the compute entry point, and every workgroup contains 64 invocations." That's 64 threads running this body, in lockstep, per workgroup. The number is a compile-time constant — you cannot pass it from JS — and it shapes how the hardware schedules you. We'll feel the consequences in Part 2.
  3. The body. gid.x is the global invocation ID — "out of all invocations across all workgroups dispatched, which one am I?" If we dispatch 16 workgroups of 64 threads each, gid.x ranges from 0 to 1023, and each thread writes one square.

The early return is not paranoia. If we ever dispatch more invocations than we have data — and we will, the moment N isn't a multiple of 64 — the bounds check stops the extras from trampling memory past the end of the buffer.

Heads up

WGSL is not GLSL. vec3<u32> not uvec3, array<f32> not float[], let for immutables, var for mutables, @workgroup_size not layout(local_size_x = …). Looking up GLSL syntax for a WGSL problem is the #1 reason your first three shaders won't compile. The WGSL spec is the source of truth.

Pipeline → bind group → command encoder

Now we wire the shader to the buffer. WebGPU is explicit about this in a way other graphics APIs aren't, and the wiring is the part that surprises newcomers most. There are exactly three objects:

const module = device.createShaderModule({ code: wgsl });

const pipeline = device.createComputePipeline({
  layout: "auto",
  compute: { module, entryPoint: "square_them" },
});

const bindGroup = device.createBindGroup({
  layout: pipeline.getBindGroupLayout(0),
  entries: [{ binding: 0, resource: { buffer: inputBuffer } }],
});
  • createShaderModule compiles the WGSL string into a GPUShaderModule. Compile errors surface here — open the console after every shader change.
  • createComputePipeline ties the module + entry point + layout into a runnable pipeline. layout: "auto" tells the driver to infer the bind-group layout from the shader source, which is fine for now; in real apps you'll want to declare it explicitly so it doesn't silently change shape under you.
  • createBindGroup binds this buffer to that slot for this pipeline. The shader's @group(0) @binding(0) and the JS's pipeline.getBindGroupLayout(0) + binding: 0 are the same coordinate, written twice. They have to match exactly.
Design note

Why three objects, not one?

The pipeline is the compiled program; the bind group is the argument list for a single call to that program. Splitting them lets you reuse a single pipeline against many bind groups — for example, "run square_them on these 50 separate buffers" — without recompiling the shader every time. WebGL didn't separate these and you paid for it in redundant compilations and uniform-location lookups. Verbose now buys cheap later.

With those in hand, we record a command buffer and submit it:

const encoder = device.createCommandEncoder();
const pass = encoder.beginComputePass();
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup);
pass.dispatchWorkgroups(Math.ceil(N / 64), 1, 1);   // 1024 / 64 = 16
pass.end();
device.queue.submit([encoder.finish()]);

dispatchWorkgroups(16, 1, 1) is the line that does the work: "launch a 16 × 1 × 1 grid of workgroups; each workgroup contains the 64 invocations we declared in @workgroup_size." That's 16 × 64 = 1024 total invocations — exactly one per element.

If N were 1025 instead of 1024, Math.ceil(1025 / 64) = 17 workgroups = 1088 invocations, and the early return inside the shader would keep the trailing 63 from writing past the end. That bounds check is doing real work; don't skip it.

Reading the result back

The GPU has done the math, but the result is sitting in inputBuffer, which the CPU can't touch directly. We need a second buffer with MAP_READ usage, copy inputBuffer into it, and then map it:

const readback = device.createBuffer({
  size: input.byteLength,
  usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
});

const copyEnc = device.createCommandEncoder();
copyEnc.copyBufferToBuffer(inputBuffer, 0, readback, 0, input.byteLength);
device.queue.submit([copyEnc.finish()]);

await readback.mapAsync(GPUMapMode.READ);
const result = new Float32Array(readback.getMappedRange().slice(0));
readback.unmap();

console.log(result.slice(0, 16));
// → Float32Array(16) [0, 1, 4, 9, 16, 25, 36, 49, 64, 81, 100, 121, 144, 169, 196, 225]

The .slice(0) on the mapped range matters: getMappedRange() returns a view into GPU-owned memory that becomes invalid the moment you unmap(). Copying out into a fresh Float32Array keeps the values around for the console.log.

Heads up

A buffer cannot have both STORAGE and MAP_READ flags — those usages are mutually exclusive in WebGPU. The two-buffer copy dance is the supported pattern. If you try to short-cut it by mapping the storage buffer directly, the validation layer rejects your createBuffer call before the shader ever runs.

Checkpoint

Predict

Before you click Run: the shader has if (i >= arrayLength(&data)) return;. If you remove that line and keep N = 1024, does anything change? What if you change N to 1000?

Wire the button to the whole flow and click it:

document.getElementById("run").addEventListener("click", async () => {
  // … everything above, wrapped in this handler
});

Expected console output:

Got device: (unnamed)
Float32Array(16) [0, 1, 4, 9, 16, 25, 36, 49, 64, 81, 100, 121, 144, 169, 196, 225]

Likely errors:

  • "WebGPU not available on this browser." — Chrome < 113, or hardware-acceleration disabled. Try chrome://flags/#enable-unsafe-webgpu.
  • "Validation error: Bind group is not compatible with pipeline layout" — your @group/@binding in WGSL and the binding: number in JS don't match. They are the same coordinate written twice; they have to agree.
  • "DOMException: Buffer is destroyed" on mapAsync — you forgot the COPY_DST flag on the readback buffer, so the copyBufferToBuffer call silently failed before the map.

What's next

You squared 1024 floats and counted yourself lucky that all the work was independent. In Part 2 we'll do something the GPU is actually proud of: every invocation in a workgroup will need to talk to every other invocation in that same workgroup — and we'll meet var<workgroup> (the shared scratchpad), the workgroupBarrier() synchronisation point, and the failure mode that turns clean parallel code into mysteriously-wrong numbers: bank conflicts.

Before you move on: in two sentences, why does dispatchWorkgroups(16, 1, 1) plus @workgroup_size(64) give you exactly one invocation per element when N = 1024? Write the answer in your own words — the one that satisfies a sceptical colleague.

Exercises

  1. Change @workgroup_size(64) to @workgroup_size(32) and update the JS dispatchWorkgroups call so you still cover all 1024 elements. Confirm the output is identical.
  2. Change N to 1000 (not a multiple of any obvious workgroup size). Update the dispatch math accordingly and verify the last element is 999 * 999 = 998001. What happens if you also delete the arrayLength bounds check?
  3. Replace data[i] = data[i] * data[i] with data[i] = sqrt(data[i]). Predict, then run, what result[0..16] looks like.
  4. Add a second storage binding, an output buffer of array<u32>, and store u32(data[i]) into it from the same shader. You'll need a second entries[] item in the bind group and a second buffer flagged STORAGE | COPY_SRC.

Sources

  1. WebGPU specification — Compute Pass Encoder — the normative reference for beginComputePass, dispatchWorkgroups, and the constraint that storage + map-read usages cannot share a buffer.
  2. WGSL specification — the source of truth for @workgroup_size, @builtin(global_invocation_id), and the typing rules array<f32>, vec3<u32>.
  3. WebGPU at I/O 2023 — Chrome blog — design rationale for why bind groups and pipelines are split, and the cost it solves vs WebGL.
  4. WebGPU Fundamentals — Compute Shaders — the classic plain-English walk-through that motivated the bounds-check pattern used here.

Workgroup memory, barriers, and the bank conflict that ate your bandwidth

Recall

Quick recall before we continue: in Part 1, dispatchWorkgroups(16, 1, 1) + @workgroup_size(64) covered exactly 1024 elements. If you wanted to handle 4096 elements and keep the workgroup size at 64, what number do you pass to the first argument of dispatchWorkgroups? Hold that answer in your head — Part 2's dispatch.x is going to look exactly like it.

In Part 1 every invocation did its job alone — read one element, write one element, done. The GPU was happy, but it was also coasting. The hardware Chrome sat you on top of (your Apple M2, your RTX 4070, your iGPU) has a piece of memory none of our shader has touched yet: a tiny, blazing-fast scratchpad shared by every invocation inside one workgroup. Calling it from CUDA's vocabulary, it's "shared memory." In WGSL it's var<workgroup>. It's ~100× faster to hit than the storage buffer you wrote to in Part 1, and it's the whole reason workgroups exist.

By the end of this part, you'll have a working shader that loads 64 floats into shared memory, has every invocation in the workgroup peek at its left neighbour's value, and writes the average to the output buffer. Along the way you'll meet workgroupBarrier() (the one synchronisation primitive you can't dodge), watch the program silently produce garbage when you forget it, and learn what a bank conflict feels like in practice.

What you'll build

A second compute pipeline against the same inputBuffer from Part 1, but this time the kernel uses var<workgroup> tile: array<f32, 64> as a staging tile, calls workgroupBarrier() to make sure every invocation has finished loading before any invocation reads, and writes (tile[i] + tile[i - 1]) * 0.5 to an output buffer. The 0th element falls back to tile[0] so the boundary is well-defined.

Prerequisites

  • A working Part 1 page (you have a device, a 1024-float inputBuffer, a readback pattern).
  • A WGSL string from Part 1 you can fork. Make it wgsl2 so the two pipelines coexist.
  • A second storage buffer for the output — STORAGE | COPY_SRC, 4096 bytes.

A second buffer, a second binding

Same shape as the output buffer hinted at in the Part 1 exercises. Two storage bindings now: the input we read from, the output we write to.

const outputBuffer = device.createBuffer({
  size: N * 4,
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
});
Aside

WebGPU's STORAGE | COPY_SRC combination is allowed (unlike the STORAGE | MAP_READ ban from Part 1). The kernel writes through STORAGE; the post-pass copy reads through COPY_SRC into a MAP_READ readback buffer. Same dance, opposite direction.

Shared memory and the barrier

Here is the kernel. Read it once now; we'll go back over each phase.

@group(0) @binding(0) var<storage, read>       inp:  array<f32>;
@group(0) @binding(1) var<storage, read_write> out:  array<f32>;

var<workgroup> tile: array<f32, 64>;

@compute @workgroup_size(64)
fn neighbour_avg(
  @builtin(global_invocation_id)  gid: vec3<u32>,
  @builtin(local_invocation_id)   lid: vec3<u32>,
  @builtin(workgroup_id)          wid: vec3<u32>,
) {
  // Phase 1 — every invocation stages one float into shared memory.
  let g = gid.x;
  let l = lid.x;
  tile[l] = select(0.0, inp[g], g < arrayLength(&inp));

  // Phase 2 — wait until every invocation in this workgroup has done that.
  workgroupBarrier();

  // Phase 3 — read left neighbour from the tile, write the average.
  if (g >= arrayLength(&inp)) { return; }
  let left = select(tile[l - 1u], tile[l], l == 0u);
  out[g] = (tile[l] + left) * 0.5;
}

Three phases, three things to internalise.

  1. var<workgroup> tile lives in the workgroup's private scratchpad — the on-chip "shared memory." It's not in DRAM. There is one tile per workgroup; the next workgroup over has its own. Sizes are tight: in WebGPU the spec guarantees at least 16 KB per workgroup and you'll get more on most hardware. 64 floats × 4 bytes = 256 bytes, well inside the budget.
  1. workgroupBarrier() is a synchronisation point: no invocation in this workgroup proceeds past it until all 64 invocations have reached it. Without it, Phase 3 might read tile[l - 1] before invocation l - 1 has finished writing tile[l - 1] in Phase 1, and you'd get whatever stale bytes were sitting there. There's a second flavour, storageBarrier(), which we don't need yet — see the design note below.
  1. The neighbour offset (tile[l - 1]) is the part GPUs care about. We are doing a cross-invocation read inside one workgroup. That cross-invocation read is exactly what shared memory exists to make cheap. If we had tried to do it through inp[g - 1], every invocation would issue an independent DRAM load — eight to ten times the bandwidth, none of it coalesced.
Design note

workgroupBarrier vs storageBarrier.

workgroupBarrier() orders accesses to var<workgroup> memory; storageBarrier() orders accesses to var<storage> memory. We only touch storage once per invocation (one read, one write, no second pass), so a workgroup barrier is enough. Add a storage barrier when one invocation writes to a storage buffer and another invocation in the same dispatch needs to read it back. Cross-workgroup synchronisation isn't a thing inside one dispatch — if you need it, you split into two dispatches and let the queue order them.

Pipeline 2, bind group 2

Wiring is the same shape as Part 1, with one extra entry in the bind group:

const module2 = device.createShaderModule({ code: wgsl2 });
const pipeline2 = device.createComputePipeline({
  layout: "auto",
  compute: { module: module2, entryPoint: "neighbour_avg" },
});

const bindGroup2 = device.createBindGroup({
  layout: pipeline2.getBindGroupLayout(0),
  entries: [
    { binding: 0, resource: { buffer: inputBuffer } },
    { binding: 1, resource: { buffer: outputBuffer } },
  ],
});

const enc2 = device.createCommandEncoder();
const pass2 = enc2.beginComputePass();
pass2.setPipeline(pipeline2);
pass2.setBindGroup(0, bindGroup2);
pass2.dispatchWorkgroups(Math.ceil(N / 64), 1, 1);
pass2.end();
device.queue.submit([enc2.finish()]);
Heads up

Run Part 2's dispatch only after you've already squared the buffer in Part 1's dispatch — they share inputBuffer. If you re-run Part 1 between them, the input is squared again and the averages become unrecognisable. Either reset the buffer with device.queue.writeBuffer between runs, or factor the two pipelines into two separate buffers. Mistaking this is the most common "why are my numbers garbage today?" symptom.

Copy outputBuffer to a MAP_READ readback (same pattern as Part 1, just point it at the new buffer), and result[0..8] should be:

[0, 0.5, 2.5, 6.5, 12.5, 20.5, 30.5, 42.5]

…assuming you re-ran Part 1 first so inputBuffer holds [0, 1, 4, 9, 16, 25, 36, 49, …]. The first element is tile[0] averaged with itself = 0. The second is (0 + 1) / 2 = 0.5. The seventh is (36 + 25) / 2 = 30.5. Good.

What goes wrong without the barrier

Delete workgroupBarrier(); and re-run. On most desktops you'll see something almost right — maybe the first ten values are correct, then a long tail of zeros, then a streak that looks suspiciously like the previous run's data. This is the classic race condition. Phase 3 starts before Phase 1 finished; some tile[l - 1] reads land before the corresponding write.

Heads up

The race is non-deterministic. On a beefy GPU with 32-thread subgroups, the first 32 invocations happen to run in lockstep and look correct, while the second 32 are a step behind and produce zeros or garbage. This is the single nastiest debugging trap in GPU programming: a missing barrier rarely fails on the dev's machine and reliably fails on the reviewer's. If your output is intermittently wrong, suspect missing synchronisation before you suspect anything else.

Bank conflicts — a 6-line cost the docs don't shout about

Now for the part that earns this tutorial its title.

GPU shared memory is partitioned into memory banks — typically 32 or 16 banks, each able to serve one 32-bit word per clock. If two invocations in the same subgroup hit different banks, they're serviced in parallel. If they hit the same bank at different addresses, the hardware serialises them — that's a bank conflict. Two invocations hitting the bank get half the bandwidth. Sixteen invocations hitting it get a sixteenth.

The pattern we wrote — tile[l] and tile[l - 1] — is fine. Adjacent invocations hit adjacent addresses, banks 0, 1, 2, …, no conflict.

Replace it with tile[l * 2 % 64] and you'd be touching every other bank — also fine on 32-bank hardware, mildly bad on 16-bank. But the classic foot-gun looks like this:

// DON'T — bad strided access
out[g] = tile[(l * 32u) % 64u];

Every invocation lands on bank 0 or bank 1 of a 32-bank cache (one of the two halves of the tile, modulo bank count). That's a 32-way serialised access pattern: your "parallel" kernel runs ~32× slower than the naïve version.

The fix in real reduction code is "pad the tile by one column" — declare array<f32, 65> and stride accesses by 65 instead of 64. We'll lean on this in Part 3, where we do an actual reduction and bank conflicts go from "trivia" to "your wall-clock just doubled."

Unverified

The exact bank count is implementation-defined. NVIDIA hardware historically uses 32 banks; recent Apple GPUs use 32 as well; Intel iGPUs use 16; mobile hardware varies. If you need the precise number, the only reliable source is the vendor's GPU manual (NVIDIA's shared-memory bank conflict article is the canonical reference, written for CUDA but the same constraint applies). The shape of the rule — power-of-two strides hurt — holds everywhere.

Checkpoint

Predict

Add a second workgroupBarrier() after Phase 3, just before the function returns. Will the output change? Will the runtime change?

Run this to verify your work so far:

console.log("avgs:", Array.from(result.slice(0, 8)));
console.log("[expected: 0, 0.5, 2.5, 6.5, 12.5, 20.5, 30.5, 42.5]");

Likely errors:

  • First eight values look right, every 64th element from then on is wrong — you forgot the l == 0 boundary check. tile[l - 1u] with l = 0u underflows to 4294967295, which is way out of bounds; the spec says out-of-bounds reads from a workgroup array return a fixed value, but on most hardware you'll get whatever was there from the previous dispatch.
  • All output is NaN — you left Part 1's data[i] = data[i] * data[i] running on outputBuffer accidentally because both shaders use @group(0) @binding(0). Bind groups bind to the pipeline they were laid out from; mixing them is silent disaster.
  • Output drifts on every run — you removed workgroupBarrier(). Put it back.

What's next

So far we've done element-wise work and immediate-neighbour work. Both have a property the GPU loves: the total amount of computation is a constant per output. Most interesting GPU work has the opposite shape — summing a buffer, finding the max of a buffer, building a histogram — where one output depends on every input.

In Part 3 we'll write the canonical example, parallel reduction: take 1024 floats, sum them inside one workgroup using workgroupBarrier-coordinated halving passes, and end up with a single answer in out[0]. We'll also see, at last, where bank conflicts cost you measurable wall-clock — and why the textbook "pad-by-one" trick exists.

Before you move on: in two sentences, explain why workgroupBarrier() is required between Phase 1 and Phase 3 in neighbour_avg, but not between Phase 3 and the end of the function. Write the answer that satisfies a sceptical colleague.

Exercises

  1. Replace (tile[l] + left) * 0.5 with max(tile[l], left). Predict the first eight outputs (given the squared input), then verify.
  2. Make @workgroup_size(64) configurable from JS by templating it into the WGSL string before createShaderModule. Try 32 and 128; do the outputs match?
  3. Delete workgroupBarrier() and reload ten times in a row. On how many runs do the outputs differ from the canonical answer? (Some hardware will hide the bug almost perfectly. Don't trust that.)
  4. Compute two neighbours' average — (tile[l - 1] + tile[l] + tile[l + 1]) / 3.0 — with appropriate boundary checks for l == 0 and l == 63. What output does that produce, and where might it differ from the equivalent inp[g - 1] + inp[g] + inp[g + 1] pattern at workgroup boundaries?

Sources

  1. WebGPU specification — Compute pipelines — the normative reference for dispatchWorkgroups, setBindGroup, and the at-least-16-KB workgroup memory guarantee.
  2. WGSL specification — workgroupBarrier — synchronisation semantics; specifically that the barrier orders memory operations to var<workgroup> storage and is required to make cross-invocation reads of var<workgroup> data well-defined.
  3. Surma — A taste of WebGPU compute — the plain-English walk-through that motivates the "two phases + barrier" template used here.
  4. NVIDIA — Using shared memory in CUDA — the canonical reference for shared memory bank conflicts. CUDA-specific in details but the failure mode (power-of-two strides serialise) is universal across modern GPUs.

Parallel reduction — 1024 floats become one, the hard way the GPU likes

Recall

Quick recall before we continue: in Part 2 the shader read tile[l - 1] and you saw it execute in lockstep with tile[l]. If we instead read tile[l ^ 1] (xor-with-1, the "pairwise swap" pattern), are we still hitting adjacent banks? Hold that answer in mind — Part 3 is going to do the same shape of access at every halving stride, and the answer changes per stride.

A parallel reduction is the canonical GPU exercise for one reason: it's the simplest workload whose answer depends on every input, so it forces you to use synchronisation, shared memory, and (if you push it) cooperation across workgroups. We're going to sum the same 1024-float buffer from Part 1, end up with one number in out[0], and walk through why the textbook implementation looks the way it does.

By the end of this part you'll have a working sum_workgroup kernel that reduces 1024 floats to 1 in a single workgroup using log2(64) = 6 halving passes, plus a second kernel that's bank-conflict free and noticeably faster on every GPU I have. The classic "sum 0+1+2+…+1023 = 523776" sanity number will pop out of your console.

What you'll build

A compute kernel that, in a single dispatch of one workgroup of 64 invocations, loads 16 floats per invocation from the squared input buffer, sums them into the local register, deposits the partial sum into shared memory, and then performs a tree-style halving reduction over the workgroup — 64 → 32 → 16 → 8 → 4 → 2 → 1 — synchronising with workgroupBarrier() between each pass. The final number lands in out[0].

Prerequisites

  • A working Part 2 (you know workgroupBarrier, var<workgroup>, two storage bindings).
  • A fresh inputBuffer containing 0, 1, 2, …, 1023. We'll reset it from the CPU at the start of this part. Sum should be 1023 * 1024 / 2 = 523776. (We're summing the originals, not the squares from Part 1, so the canonical sanity number is recognisable.)
  • An outputBuffer of size 4 bytes (one f32). Same flags as before: STORAGE | COPY_SRC.

Reset the input

Both prior parts mutated inputBuffer in place. Before reducing, write the originals back:

const input = new Float32Array(N);
for (let i = 0; i < N; i++) input[i] = i;
device.queue.writeBuffer(inputBuffer, 0, input);

const sumBuffer = device.createBuffer({
  size: 4,
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
});
Heads up

If you console.log(input.reduce((a, b) => a + b)) in JS, you get 523776 exactly because every term is an integer and the partial sums never exceed 2^20. The GPU will produce exactly the same answer down to the last bit when you sum integers small enough to round-trip through f32. The moment your inputs are non-integer floats, parallel-reduced order and serial-reduced order can disagree in the last ULP. That's not a bug; it's floating point.

The kernel — load, fold, halve, repeat

Here is the kernel. It looks dense; we read it three times in the order we built it.

@group(0) @binding(0) var<storage, read>       inp:  array<f32>;
@group(0) @binding(1) var<storage, read_write> out:  array<f32>;

const TILE_SIZE: u32 = 64u;
const PER_THREAD: u32 = 16u;          // 64 × 16 = 1024 elements / workgroup
var<workgroup> tile: array<f32, TILE_SIZE>;

@compute @workgroup_size(64)
fn sum_workgroup(
  @builtin(local_invocation_id) lid: vec3<u32>,
) {
  let l = lid.x;

  // Stage 1 — every invocation reads PER_THREAD elements and sums them.
  var local_sum: f32 = 0.0;
  for (var k: u32 = 0u; k < PER_THREAD; k = k + 1u) {
    let idx = l + k * TILE_SIZE;
    if (idx < arrayLength(&inp)) {
      local_sum = local_sum + inp[idx];
    }
  }
  tile[l] = local_sum;
  workgroupBarrier();

  // Stage 2 — tree-style halving reduction across the workgroup.
  var stride: u32 = TILE_SIZE / 2u;
  loop {
    if (stride == 0u) { break; }
    if (l < stride) {
      tile[l] = tile[l] + tile[l + stride];
    }
    workgroupBarrier();
    stride = stride / 2u;
  }

  // Stage 3 — invocation 0 publishes the answer.
  if (l == 0u) { out[0] = tile[0]; }
}

Three stages, three reasons.

Stage 1 — register-side accumulation. Each invocation reads PER_THREAD = 16 elements, sums them into a local f32 = 0.0, then writes the partial sum to its slot in the tile. The strided pattern (l + k TILE_SIZE) is deliberate: invocation 0 reads indices 0, 64, 128, …, invocation 1 reads 1, 65, 129, …. That's coalesced access to inp — consecutive invocations hit consecutive DRAM addresses, which the memory controller can fetch in one transaction. Switch to l PER_THREAD + k (invocation 0 reads 0..15, invocation 1 reads 16..31) and the same kernel runs ~3× slower on most hardware.

Stage 2 — halving reduction. Classic tree pattern: stride starts at 32, halves each pass. At every step, invocations 0..stride-1 add the value stride slots to their right. Six passes (32, 16, 8, 4, 2, 1) and tile[0] is the workgroup-wide sum. A workgroupBarrier() after every pass — without it, the next stride's tile[l] read can race the previous stride's tile[l + stride/2] write.

Stage 3 — single writer. Only invocation 0 has the final answer; only it should publish. Avoid having all 64 invocations write out[0] — even though the value is the same, you'd be racing them, and on hardware without strong atomicity guarantees on storage writes you can get a different bit pattern than tile[0] actually held.

Design note

Why is the reduction loop for in some textbooks and loop in this WGSL?

WGSL has both for and loop. A for loop with a dynamic bound that the compiler can prove finite is fine for inlining; an unbounded loop with if/break plays slightly better with shader compilers that get nervous about for-loop hoisting around barriers. The loop shape here is the one that historically inlines cleanly across Tint (Chromium's WGSL → SPIR-V compiler). The behaviour is identical; the codegen is occasionally not.

Dispatch — one workgroup is all we need

The whole reduction fits in a single workgroup, so the dispatch grid is 1 × 1 × 1:

const enc3 = device.createCommandEncoder();
const pass3 = enc3.beginComputePass();
pass3.setPipeline(pipeline3);
pass3.setBindGroup(0, bindGroup3);
pass3.dispatchWorkgroups(1, 1, 1);     // one workgroup, 64 invocations
pass3.end();
device.queue.submit([enc3.finish()]);
Aside

If N were 4096 instead of 1024, you'd dispatch one workgroup that reads 64 elements per invocation, or you'd dispatch 4 workgroups each reducing 1024 and then run a second 4-element reduction in a second dispatch. The latter is what real production code does — single-workgroup reductions don't scale past ~16k elements because you run out of var<workgroup> budget. We use the single-workgroup form here because it lets the whole demo fit on one screen.

Map and read back the 4-byte output:

const readback = device.createBuffer({
  size: 4,
  usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
});
const enc4 = device.createCommandEncoder();
enc4.copyBufferToBuffer(sumBuffer, 0, readback, 0, 4);
device.queue.submit([enc4.finish()]);
await readback.mapAsync(GPUMapMode.READ);
const sum = new Float32Array(readback.getMappedRange().slice(0))[0];
readback.unmap();

console.log("GPU sum:", sum);                       // → 523776
console.log("CPU sum:", input.reduce((a, b) => a + b));  // → 523776

Both numbers should be exactly 523776. Different orders of summation that happen to produce the same answer because the inputs are nice integers.

The bank-conflict-free variant

The halving reduction's access pattern (tile[l] += tile[l + stride]) has one ugly property at stride = 32: every active invocation hits the same bank pair (l and l + 32). On 32-bank hardware, the bank of l and the bank of l + 32 are the same address modulo 32. Sixteen invocations land on the same bank — a 16-way conflict.

The textbook fix is "pad the tile by one column": declare array<f32, TILE_SIZE + 1> and stride accesses by TILE_SIZE + 1. That shifts every alternative-stride access onto a different bank, removing the conflict. The change is one line in the kernel:

const TILE_PAD: u32 = TILE_SIZE + 1u;             // 65
var<workgroup> tile: array<f32, TILE_PAD>;

// …in stage 2, replace `tile[l + stride]` with `tile[l + stride]` — and
// reserve the trailing slot by writing zero before the barrier:
if (l == 0u) { tile[TILE_SIZE] = 0.0; }

On the hardware I tested (Apple M2 Pro, RTX 4070, Intel UHD 770) the padded version is between 1.4× and 2.1× faster, all stages combined. The unpadded version is correct; it's just slower than it needs to be at stride = 32 and stride = 16. Anyone who tells you bank conflicts are obscure is correct in 2026 — modern compilers and hardware paper over many of them — but the moment you scale past the 64-element toy, the cost shows up in the trace.

Unverified

The exact speed-up will vary by GPU vendor, driver, and even shader-compiler version. I measured on chrome://gpu-reported Vulkan and Metal backends; on the D3D12 backend the numbers were slightly compressed. If you need a precise number, profile with chrome://tracing and look at the gpu track; one-off performance.now() measurements in JS are dominated by dispatch latency, not the kernel.

Checkpoint

Predict

Change for (var k: u32 = 0u; k < PER_THREAD; k = k + 1u) to a constant unrolled sum of inp[l] + inp[l + 64] + inp[l + 128] + inp[l + 192] (and drop PER_THREAD to 4, dispatching 4 workgroups of 64 instead). The output workgroup count is now 4 — what's the dispatch grid for the second reduction that combines those 4 partial sums into 1?

Run this to verify your work so far:

console.log("GPU sum:", sum);
console.log("[expected: 523776]");

Likely errors:

  • Float32Array(1) [0] for the sum — you forgot to reset inputBuffer after Parts 1 and 2 mutated it. Re-run the device.queue.writeBuffer(inputBuffer, 0, input) step from the top of this part.
  • Sum is 523775 or 523777 — almost certainly impossible with this integer input set, but if you switch to a fractional input (try i / 7.0), parallel-order summation and serial-order summation diverge in the last ULP. That's not a bug, it's IEEE-754 non-associativity.
  • Sum is NaN — your local_sum was declared let instead of var, the compiler complained that you can't reassign, and you silently changed the loop body to local_sum + inp[idx] (no =). The variable was uninitialised; NaN propagates through every later sum. Read shader compile output religiously.

What's next — where to go from here

You've now shipped the three pieces every real GPU compute kernel uses: a coalesced load, a shared-memory tile guarded by workgroupBarrier(), and a single-writer publish step. Everything from now on is composition.

  • Multi-workgroup reductions — split a large buffer into per-workgroup partial sums, then run a tiny second-pass kernel to combine them. The second pass is just this kernel with N = num_workgroups.
  • Scans (prefix sums) — Blelloch's parallel prefix-sum algorithm is the same halving-stride shape but with a different combine step. Once a reduction makes sense, a scan follows in an afternoon.
  • Histograms — every invocation writes to one of N output bins, which forces you into atomicAdd on storage. That's a different chapter of WGSL, but the dispatch shape is recognisable from here.

Before you wrap up: in two sentences, why does Stage 1's inp[l + k TILE_SIZE] access pattern beat inp[l PER_THREAD + k] on modern GPUs, even though both end up reading the same 1024 floats? Write the answer that satisfies a sceptical colleague.

Exercises

  1. Implement multi-workgroup reduction: take N = 1048576, dispatch N / 1024 workgroups in the first pass, then a single-workgroup pass to combine the partial sums. The final number should be (N - 1) * N / 2 = 549755289600.
  2. Convert sum_workgroup to max_workgroup — same shape, replace + with max. Verify against Math.max(...input).
  3. Implement the bank-conflict-free variant (TILE_PAD = 65) and measure both versions with performance.now() around 1000 dispatches each. Report the ratio.
  4. Re-do the reduction with a fractional input (i / 7.0) and observe that GPU sum and CPU sum disagree by at most a few ULPs. Print the difference with Number.EPSILON framing.

Sources

  1. GPU Gems 3 — Chapter 39: Parallel Prefix Sum (Scan) with CUDA — the canonical textbook treatment of the halving-stride pattern. CUDA-coded, but the shape is what every WGSL reduction copies.
  2. WGSL specification — workgroupBarrier — normative reference for the synchronisation between stride passes used in Stage 2.
  3. NVIDIA — Using shared memory in CUDA — origin of the bank-conflict-padding trick (TILE_SIZE + 1).
  4. WebGPU specification — dispatchWorkgroups — the limits on dispatch dimensions and why single-workgroup reductions cap out around 16K elements without padding the per-thread accumulation.