# GPU.js — gpu.rocks, complete text Every page of gpu.rocks and its GPGPU course, concatenated. Individual pages are at the same URL with `.md` appended. Index: https://gpu.rocks/llms.txt --- # GPU.js — GPU accelerated JavaScript GPGPU operations using pure JavaScript. gpu.js compiles your JavaScript functions into shader code and runs them on your GPU — with a CPU fallback. ## Elsewhere on gpu.rocks - [Benchmark — GPU.js](https://gpu.rocks/benchmark.md) - [Installation — GPU.js](https://gpu.rocks/install.md) - [Examples — GPU.js](https://gpu.rocks/examples.md) - [Learn GPGPU — the interactive course](https://gpu.rocks/learn.md) --- Interactive version: https://gpu.rocks/ --- # Benchmark — GPU.js Thirty GPGPU workloads timed in your browser on every backend gpu.js can reach — WebGPU, WebGL2, WebGL, WebAssembly and CPU — against hand-written implementations with no gpu.js in them. Every answer is checked against a plain-JavaScript oracle before it is timed. ## Elsewhere on gpu.rocks - [GPU.js — GPU accelerated JavaScript](https://gpu.rocks/index.md) - [Installation — GPU.js](https://gpu.rocks/install.md) - [Examples — GPU.js](https://gpu.rocks/examples.md) - [Learn GPGPU — the interactive course](https://gpu.rocks/learn.md) --- Interactive version: https://gpu.rocks/benchmark --- # Installation — GPU.js Install GPU.js from npm, yarn, or a CDN script tag and write your first GPU-accelerated JavaScript kernel in the browser or Node.js. ## Elsewhere on gpu.rocks - [GPU.js — GPU accelerated JavaScript](https://gpu.rocks/index.md) - [Benchmark — GPU.js](https://gpu.rocks/benchmark.md) - [Examples — GPU.js](https://gpu.rocks/examples.md) - [Learn GPGPU — the interactive course](https://gpu.rocks/learn.md) --- Interactive version: https://gpu.rocks/install --- # Examples — GPU.js GPU.js examples: matrix multiplication and more GPGPU snippets you can read, run, and benchmark on your own graphics card. ## Elsewhere on gpu.rocks - [GPU.js — GPU accelerated JavaScript](https://gpu.rocks/index.md) - [Benchmark — GPU.js](https://gpu.rocks/benchmark.md) - [Installation — GPU.js](https://gpu.rocks/install.md) - [Learn GPGPU — the interactive course](https://gpu.rocks/learn.md) --- Interactive version: https://gpu.rocks/examples --- # Learn GPGPU in your browser — GPU.js Learn A free hands-on GPGPU course built on gpu.js: write real kernels in your browser, run them on your own GPU, and learn ideas that transfer to CUDA and WebGPU. ## GPGPU 101 From zero to your first thousand threads - [Hello, Kernel](https://gpu.rocks/learn/hello-kernel-f1399353.md) — What a kernel is, what a thread is, and why this.thread.x replaces your for-loop. (5 tasks) - [Data In, Data Out](https://gpu.rocks/learn/data-in-data-out-42b68d01.md) — Feeding arrays and images into kernels, shaping 1D/2D/3D output, and reading results back. (6 tasks) - [Pipelines & Textures](https://gpu.rocks/learn/pipelines-and-textures-9f4aeaa5.md) — Chaining kernels so data stays on the GPU — the single biggest real-world speedup. (5 tasks) - [Measuring Speed Honestly](https://gpu.rocks/learn/measuring-speed-honestly-b9188894.md) — Warm-up, transfer costs, and precision — when the GPU wins, and when the CPU quietly beats it. (4 tasks) ## Parallel Primitives The handful of patterns everything else is built from - [Thinking in Parallel](https://gpu.rocks/learn/thinking-in-parallel-c3876efb.md) — Map and gather patterns, why kernels write only their own cell, and how to design around it. (6 tasks) - [Reductions](https://gpu.rocks/learn/reductions-3dadc130.md) — Sum, min, max and mean over millions of values — the ladder pattern every platform uses. (6 tasks) - [Prefix Sums (Scan)](https://gpu.rocks/learn/prefix-sum-351cfa41.md) — Running totals in parallel — the doubling ladder, exclusive scans, and the offsets every variable-sized output depends on. (6 tasks) - [Stream Compaction](https://gpu.rocks/learn/stream-compaction-0aed2e43.md) — Filtering on a GPU: flag what survives, scan to find out where it lands, then gather it into a packed array. (5 tasks) - [Histograms & Binning](https://gpu.rocks/learn/histograms-and-binning-dfb254f4.md) — Counting values into bins with no atomics — the scatter that has to become a gather. (5 tasks) - [Top-K Selection](https://gpu.rocks/learn/top-k-selection-1ba56df3.md) — The ten largest of a million values: rank by counting, gather the winners, or bisect for a cutoff — and when each one wins. (5 tasks) - [Jump Flooding: Voronoi in log n Passes](https://gpu.rocks/learn/jump-flooding-a741a650.md) — A Voronoi diagram and a signed distance field in log₂(n) passes — more total work than the CPU algorithm, and faster anyway. (6 tasks) - [Bitonic Sort](https://gpu.rocks/learn/bitonic-sort-84e0728e.md) — More comparisons than quicksort, and far faster on a GPU — because the whole comparison schedule is fixed before the data arrives. (5 tasks) - [Radix Sort](https://gpu.rocks/learn/radix-sort-fd3ff796.md) — A histogram, a scan and a gather assembled into the sort production GPU libraries actually run. (6 tasks) ## Math & Simulation Heavy math, thousands of threads at once - [Matrix Multiply](https://gpu.rocks/learn/matrix-multiply-972e080b.md) — The canonical GPGPU workload: from naive triple loop to a kernel that scales. (5 tasks) - [Monte Carlo Methods](https://gpu.rocks/learn/monte-carlo-methods-9ea19810.md) — Estimate π, price an option, integrate the un-integrable — with a million random samples. (4 tasks) - [N-Body Gravity](https://gpu.rocks/learn/n-body-gravity-5de47751.md) — Every particle pulls on every other: an O(n²) problem the GPU eats for breakfast. (5 tasks) - [ODE Integrators](https://gpu.rocks/learn/ode-integrators-62f4a3ff.md) — Euler, midpoint, RK4 and velocity Verlet — measured against a closed form, one thread per trajectory. (6 tasks) - [Iterative Linear Solvers](https://gpu.rocks/learn/iterative-solvers-e73b8e1f.md) — Jacobi, Gauss-Seidel, and why colouring a grid like a chessboard turns a sequential algorithm parallel. (5 tasks) - [The Heat Equation & Stability](https://gpu.rocks/learn/heat-and-stability-514063bb.md) — Why a correct-looking simulation explodes — the step-size limit, and the implicit step that ignores it. (5 tasks) - [Gradient Descent](https://gpu.rocks/learn/gradient-descent-c94c3f22.md) — Fit a line by walking downhill — the gradient as a reduction, the learning rate as a stability limit, and 1,024 searches in one launch. (5 tasks) - [The Ising Model: Colour to Break the Race](https://gpu.rocks/learn/ising-model-1f12d841.md) — Metropolis on a lattice of spins, the race that makes an all-at-once update silently wrong, and the checkerboard that repairs it — ending in a temperature slider you can drag through a phase transition. (6 tasks) ## Computer Vision Teaching a GPU to look at pictures, not just draw them - [Colour Spaces](https://gpu.rocks/learn/colour-spaces-8d79c6af.md) — Leaving RGB: perceptual luminance, the hue wheel, and why a channel that wraps breaks ordinary arithmetic. (5 tasks) - [Convolution & Filters](https://gpu.rocks/learn/convolution-and-filters-66933805.md) — Sliding-window math on signals and images: blur, sharpen, edge detection. (5 tasks) - [Thresholding & Morphology](https://gpu.rocks/learn/thresholding-and-morphology-670eaafa.md) — Turning grey pixels into a clean binary mask: global and adaptive thresholds, then erosion and dilation as a neighbourhood min and max. (6 tasks) - [The Canny Edge Pipeline](https://gpu.rocks/learn/canny-edges-6901c51a.md) — The edge detector every vision library ships, one kernel per stage — blur, gradient, thinning, thresholds, hysteresis — then chained with pipeline: true. (6 tasks) - [Seam Carving: Content-Aware Resizing](https://gpu.rocks/learn/seam-carving-a23a0d9b.md) — Shrink a picture by deleting its most boring pixels — an energy map, a wavefront DP one launch per row, and a gather that reflows the image. (6 tasks) - [Template Matching](https://gpu.rocks/learn/template-matching-f57b4bed.md) — Finding a patch in a picture — and why a raw difference score is fooled by a light switch. (5 tasks) - [Optical Flow](https://gpu.rocks/learn/optical-flow-e85c6dfa.md) — Per-pixel motion between two frames: the aperture problem, a 2×2 least-squares solve per thread, and knowing when not to believe the answer. (5 tasks) - [Video Filters](https://gpu.rocks/learn/video-filters-4d39e404.md) — Sixteen milliseconds a frame, and state that survives between them: temporal filtering, motion masks and a background model. (6 tasks) ## Signal Processing Time in, frequency out — and the algorithm that made it practical - [Sampling & Aliasing](https://gpu.rocks/learn/sampling-and-aliasing-ad14836c.md) — One thread per sample: build a signal, watch a tone come back as the wrong one, and rebuild what fell between. (5 tasks) - [The DFT, Honestly](https://gpu.rocks/learn/the-dft-7b1e3f9b.md) — One thread per frequency bin, each summing over every sample — the honest O(n²) transform, complex arithmetic and all. (5 tasks) - [The FFT Butterfly](https://gpu.rocks/learn/fft-butterfly-d4375da7.md) — Split the sum by parity and the transform collapses from n² terms to log₂n passes of a two-line butterfly — the same multi-pass gather every ladder in this course uses. (6 tasks) - [Windowing & Spectral Leakage](https://gpu.rocks/learn/windowing-f563138d.md) — Why the same tone looks clean or filthy depending only on how many samples you took — and what a window costs to fix it. (5 tasks) - [Filtering in the Frequency Domain](https://gpu.rocks/learn/frequency-filtering-8c225e10.md) — Convolution becomes multiplication — the trade that makes the FFT worth its complexity, plus the ringing, the wrap-around and the cross terms it hides. (5 tasks) - [Spectrograms](https://gpu.rocks/learn/spectrograms-9ecd2295.md) — Slide a window along a signal and transform every slice — a picture of frequency over time, one thread per (frame, bin). (5 tasks) - [Autocorrelation & Pitch](https://gpu.rocks/learn/autocorrelation-b159433f.md) — Finding the note in a sound by asking how well it resembles itself, shifted — and the octave error that catches every naive detector once. (5 tasks) ## Computational Graphics Pictures computed, not drawn - [Pixels from Scratch](https://gpu.rocks/learn/pixels-from-scratch-d2869039.md) — Graphical kernels and this.color(): gradients, patterns and plots, one thread per pixel. (4 tasks) - [Escape-Time Fractals](https://gpu.rocks/learn/escape-time-fractals-0de4764c.md) — Mandelbrot and Julia sets with smooth coloring — infinite detail from a ten-line kernel. (5 tasks) - [Cellular Automata](https://gpu.rocks/learn/cellular-automata-407c2c34.md) — Conway's Life and friends: feed a kernel's output back in and watch worlds evolve. (5 tasks) - [Reaction–Diffusion](https://gpu.rocks/learn/reaction-diffusion-bc3d0b34.md) — Two chemicals, two equations, and suddenly: coral, fingerprints, leopard spots. (4 tasks) - [Hydraulic Erosion: Carving Terrain by Accumulation](https://gpu.rocks/learn/hydraulic-erosion-07165ca1.md) — Rain on a fractal heightmap, one gather at a time — until the noise grows rivers. (6 tasks) - [Ray-Marched Metaballs](https://gpu.rocks/learn/ray-marched-metaballs-8b1282bd.md) — Signed distance fields and soft shadows — a real-time 3D scene with no triangles at all. (6 tasks) - [Progressive Path Tracing: Noise Melting Into an Image](https://gpu.rocks/learn/path-tracing-c99efc67.md) — Every pixel fires its own random rays; a buffer that outlives the frame turns the static into a picture. (5 tasks) ## Others - [Wavefronts: Aligning DNA on the Diagonal](https://gpu.rocks/learn/sequence-alignment-a85ca6d9.md) — Smith-Waterman looks fatally serial — until you notice that every cell on an anti-diagonal is independent. (6 tasks) --- Interactive version: https://gpu.rocks/learn --- # Hello, Kernel *Module of the free GPU.js GPGPU course · 5 tasks* What a kernel is, what a thread is, and why this.thread.x replaces your for-loop. ## Tasks 1. [Your First Kernel](https://gpu.rocks/learn/hello-kernel-f1399353/1.md) 2. [Who Am I? this.thread.x](https://gpu.rocks/learn/hello-kernel-f1399353/2.md) 3. [From For-Loop to Formula](https://gpu.rocks/learn/hello-kernel-f1399353/3.md) 4. [A Second Dimension: this.thread.y](https://gpu.rocks/learn/hello-kernel-f1399353/4.md) 5. [Pass Something In](https://gpu.rocks/learn/hello-kernel-f1399353/5.md) --- Interactive version: https://gpu.rocks/learn/hello-kernel-f1399353 --- # Your First Kernel *Task 1 of 5 · [Hello, Kernel](https://gpu.rocks/learn/hello-kernel-f1399353.md) · GPU.js Learn* A **kernel** is an ordinary-looking JavaScript function with one twist: it doesn't run once. gpu.js compiles it and launches it **once per output cell**, all in parallel — each launch is called a **thread**. You never call the function in a loop; you tell the GPU how many cells you want, and it runs that many copies. That cell count is the `output` option: `output: [16]` means “give me 16 cells”, so 16 threads run and their 16 return values come back to you collected into one array. One habit to pick up right now, because it runs through the whole course: **calling a kernel is asynchronous**. The call hands you a promise while the GPU gets on with the work, so you write `await` in front of it and receive the finished result — `const result = await answer();`. Building the kernel with `createKernel` stays ordinary and synchronous; only the *call* is awaited. ## Figures - **one function, sixteen launches — the loop you never wrote** ## Goal **Goal:** finish the kernel so that **16 threads** each return the number `42` — your first parallel program. ## Requirements - Set `output` to `[16]` so 16 threads run - Return `42` from the kernel body - Call the kernel and log the result (already wired up) ## Hint 1 — where does the 16 go? `output` lives in the options object — the second argument to `createKernel`. It's an array because output can have more than one dimension (that's task 4). ## Hint 2 — the whole thing The whole call: ```js gpu.createKernel(function () { return 42; }, { output: [16], }) ``` And `await answer()` gives you an array of sixteen 42s. ## Same idea elsewhere Launching N copies of one function is *the* primitive of every GPU API: CUDA spells it `kernel<<>>()`, WebGPU calls it a compute `dispatch`, Metal dispatches threadgroups. gpu.js just hides the ceremony behind `output`. ## Starter code ```js // A kernel runs once per output cell — in parallel, not in a loop. const gpu = new GPU({ mode }); const answer = gpu.createKernel(function () { // TODO: every thread should return the same number: 42 return 0; }, { // TODO: give the kernel 16 output cells, not 1 output: [1], }); // Calling a kernel is asynchronous — await it to get the finished result. const result = await answer(); console.log(result); ``` --- Interactive version: https://gpu.rocks/learn/hello-kernel-f1399353/1 [Next task](https://gpu.rocks/learn/hello-kernel-f1399353/2.md) --- # Who Am I? this.thread.x *Task 2 of 5 · [Hello, Kernel](https://gpu.rocks/learn/hello-kernel-f1399353.md) · GPU.js Learn* Sixteen identical 42s prove the launch works, but parallel code is only useful if each thread can do something *different*. The trick: every thread knows which output cell it owns. That number is `this.thread.x` — 0 for the first cell, 1 for the next, up to `output − 1`. Same function, same arguments, different `this.thread.x` — that one number is the only thing telling the threads apart, and it's how each one finds its own work. ## Goal **Goal:** make each of the 32 threads return **its own index**, so the result counts `0, 1, 2, … 31`. ## Requirements - Keep `output: [32]` — 32 threads - Return `this.thread.x` from the kernel body - No loops, no counters — the index is handed to you ## Hint 1 — it’s already there You don't compute the index and you don't pass it in. Inside the kernel body, `this.thread.x` is simply available — gpu.js fills it in per thread. ## Hint 2 — the one-liner The entire kernel body: `return this.thread.x;` ## Same idea elsewhere Every platform hands threads this same self-identity, just under a different name: `threadIdx`/`blockIdx` in CUDA and ROCm/HIP, `global_invocation_id` in WebGPU's WGSL, `thread_position_in_grid` in Metal. ## Starter code ```js // Every thread runs the same body — this.thread.x is what differs. const gpu = new GPU({ mode }); const whoAmI = gpu.createKernel(function () { // TODO: return this thread's own index return 0; }, { output: [32] }); const result = await whoAmI(); console.log(result); ``` --- Interactive version: https://gpu.rocks/learn/hello-kernel-f1399353/2 [Previous task](https://gpu.rocks/learn/hello-kernel-f1399353/1.md) · [Next task](https://gpu.rocks/learn/hello-kernel-f1399353/3.md) --- # From For-Loop to Formula *Task 3 of 5 · [Hello, Kernel](https://gpu.rocks/learn/hello-kernel-f1399353.md) · GPU.js Learn* Here's the payoff of the thread index. On the CPU you'd sample a sine wave like this: ```js for (let i = 0; i < 64; i++) { wave[i] = Math.sin(i / 64 * 2 * Math.PI); } ``` On the GPU, the loop **disappears** — the 64 iterations become 64 threads, and the loop variable `i` becomes `this.thread.x`. The body of the loop is your kernel body, unchanged. (`Math.sin` and `Math.PI` work inside kernels, along with most of `Math`.) ## Figures - **same body, new address — the loop unrolls into threads** ## Goal **Goal:** sample one full sine cycle across 64 threads — thread `x` returns `Math.sin(x / 64 * 2 * Math.PI)`. ## Requirements - Keep `output: [64]` — one thread per sample - Use `this.thread.x` where the CPU loop used `i` - Return one sine sample per thread — the CPU loop body, unchanged except for the index ## Hint 1 — the translation rule Take the CPU loop body, delete the loop, and substitute `this.thread.x` for `i`. That mechanical rewrite is how most for-loops become kernels. ## Hint 2 — the body ```js return Math.sin(this.thread.x / 64 * 2 * Math.PI); ``` ## Same idea elsewhere This loop-body-becomes-kernel-body rewrite is called an *embarrassingly parallel map*, and it's the bread and butter of GPGPU: the same move turns a pixel loop into a Metal fragment shader, a physics update into a CUDA kernel, or an array transform into a WebGPU compute pass. ## Starter code ```js // The for-loop is gone — 64 threads each compute one sample. const gpu = new GPU({ mode }); // CPU version, for reference: // for (let i = 0; i < 64; i++) wave[i] = Math.sin(i / 64 * 2 * Math.PI); const wave = gpu.createKernel(function () { // TODO: one sample of a sine wave — i is this.thread.x now return 0; }, { output: [64] }); const samples = await wave(); console.log(samples); ``` --- Interactive version: https://gpu.rocks/learn/hello-kernel-f1399353/3 [Previous task](https://gpu.rocks/learn/hello-kernel-f1399353/2.md) · [Next task](https://gpu.rocks/learn/hello-kernel-f1399353/4.md) --- # A Second Dimension: this.thread.y *Task 4 of 5 · [Hello, Kernel](https://gpu.rocks/learn/hello-kernel-f1399353.md) · GPU.js Learn* Threads don't have to line up in a row. Give `output` two numbers — `output: [8, 8]` — and gpu.js launches an 8×8 **grid** of 64 threads. Each one now has two coordinates: `this.thread.x` is its column and `this.thread.y` is its row, and the result comes back as an array of rows you read as `result[y][x]`. To prove both coordinates are live, paint a classic: a checkerboard. A cell is “black” or “white” depending on whether `x + y` is even or odd — which is just `(x + y) % 2`. ## Figures - **two coordinates per thread — the grid is the picture** ## Goal **Goal:** launch an 8×8 grid where each cell holds `(x + y) % 2` — an alternating pattern of 0s and 1s. ## Requirements - Change `output` to a grid: `[8, 8]` - Use `this.thread.x` *and* `this.thread.y` - Return 0 or 1 in a checkerboard — the parity of the two coordinates added together ## Hint 1 — what changes with 2D? Two things: `output` gets a second number (`[width, height]`), and `this.thread.y` starts meaning something. Nothing else about the kernel changes. ## Hint 2 — the pattern ```js return (this.thread.x + this.thread.y) % 2; ``` Neighbours differ by one in `x` or `y`, so the parity flips checkerboard-style. ## Same idea elsewhere GPUs are built around 2D grids because images are 2D: ROCm and CUDA launch `dim3`-shaped blocks, WebGPU dispatches workgroups across x/y/z, and Metal's grids are up to three-dimensional. One thread per pixel — the idea **Data In, Data Out** runs with — starts exactly here. ## Starter code ```js // output: [width, height] launches a whole grid of threads. const gpu = new GPU({ mode }); const board = gpu.createKernel(function () { // TODO: return (x + y) % 2 using BOTH thread coordinates return this.thread.x % 2; }, { // TODO: make this an 8×8 grid, not an 8-cell line output: [8], }); const result = await board(); console.log(result); ``` --- Interactive version: https://gpu.rocks/learn/hello-kernel-f1399353/4 [Previous task](https://gpu.rocks/learn/hello-kernel-f1399353/3.md) · [Next task](https://gpu.rocks/learn/hello-kernel-f1399353/5.md) --- # Pass Something In *Task 5 of 5 · [Hello, Kernel](https://gpu.rocks/learn/hello-kernel-f1399353.md) · GPU.js Learn* So far every kernel has conjured its output from thread coordinates alone. Real kernels also take **arguments** — declare a parameter on the kernel function, pass a value when you call it, and every thread sees that same value. Combine it with `this.thread.x` and each thread computes something different from shared input. Here's the payoff: a compiled kernel is **reusable**. Build it once, call it with `2.5`, call it again with `0.5` — two parallel launches, zero recompiles. That build-once/call-many rhythm is how all real GPU code is structured. ## Goal **Goal:** make `ramp` return `scale * this.thread.x`, then call it twice — once with `2.5`, once with `0.5`. ## Requirements - Give the kernel function a `scale` parameter - Multiply the shared argument by this thread's index — shared argument × thread identity - Call the kernel twice with different scales (already wired up) ## Hint 1 — where arguments come from Kernel arguments are ordinary function parameters: `function (scale) { … }`, called as `await ramp(3)`. Every one of the 64 threads receives the same `3`. ## Hint 2 — the body `return scale * this.thread.x;` — the argument is shared, the index is per-thread, the product is different in every cell. ## Same idea elsewhere A value shared by all threads is a *uniform*: WebGPU binds it as a uniform buffer, CUDA and ROCm pass it as a kernel launch parameter, Metal hands it over with `setBytes`. And build-once/dispatch-many is universal too — shader and kernel compilation is expensive everywhere, so it's paid once up front. ## Starter code ```js // Arguments are shared by all threads; this.thread.x stays per-thread. const gpu = new GPU({ mode }); const ramp = gpu.createKernel(function (scale) { // TODO: scale this thread's index by the argument return this.thread.x; }, { output: [64] }); // One kernel, two launches — no recompilation between calls. console.log('scale 2.5:', await ramp(2.5)); console.log('scale 0.5:', await ramp(0.5)); ``` --- Interactive version: https://gpu.rocks/learn/hello-kernel-f1399353/5 [Previous task](https://gpu.rocks/learn/hello-kernel-f1399353/4.md) --- # Data In, Data Out *Module of the free GPU.js GPGPU course · 6 tasks* Feeding arrays and images into kernels, shaping 1D/2D/3D output, and reading results back. ## Tasks 1. [Pass an Array In](https://gpu.rocks/learn/data-in-data-out-42b68d01/1.md) 2. [Shape the Output: 2D](https://gpu.rocks/learn/data-in-data-out-42b68d01/2.md) 3. [Grayscale, the GPU way](https://gpu.rocks/learn/data-in-data-out-42b68d01/3.md) 4. [Read the Results Back](https://gpu.rocks/learn/data-in-data-out-42b68d01/4.md) 5. [Images Are Just Arrays](https://gpu.rocks/learn/data-in-data-out-42b68d01/5.md) 6. [Put It Together: Two Kernels](https://gpu.rocks/learn/data-in-data-out-42b68d01/6.md) --- Interactive version: https://gpu.rocks/learn/data-in-data-out-42b68d01 --- # Pass an Array In *Task 1 of 6 · [Data In, Data Out](https://gpu.rocks/learn/data-in-data-out-42b68d01.md) · GPU.js Learn* Kernels don't reach out and grab data — data is **handed to them** as arguments, and every thread sees the same arguments. What differs between threads is exactly one thing: `this.thread.x`, the index of the output cell this thread owns. Here `data` is a 64-number array. The kernel below runs 64 times — once per output cell — and each run should pick out *its own* element. ## Goal **Goal:** make the kernel return **double** the element of `data` that belongs to this thread. ## Requirements - Pass `data` into the kernel as an argument (already wired up) - Index it with `this.thread.x` — no loops over the array - Return the element multiplied by `2` ## Hint 1 — which element is mine? With `output: [64]` there are 64 threads, numbered `this.thread.x` = 0…63. Thread 7 should read `data[7]`. ## Hint 2 — the one-liner The whole kernel body is a single statement: `return data[this.thread.x] * 2;` ## Same idea elsewhere Arguments-in, index-by-thread-id is the universal GPGPU calling convention: CUDA kernels get device pointers plus `threadIdx`, WebGPU compute shaders get bound buffers plus `global_invocation_id`. Same shape, different spelling. ## Starter code ```js // A kernel runs once per output cell — 64 cells here, 64 threads. const gpu = new GPU({ mode }); const double = gpu.createKernel(function (data) { // TODO: return double the value that belongs to THIS thread. // Which element is yours? this.thread.x knows. return 0; }, { output: [64] }); const result = await double(data); console.log(result); ``` --- Interactive version: https://gpu.rocks/learn/data-in-data-out-42b68d01/1 [Next task](https://gpu.rocks/learn/data-in-data-out-42b68d01/2.md) --- # Shape the Output: 2D *Task 2 of 6 · [Data In, Data Out](https://gpu.rocks/learn/data-in-data-out-42b68d01.md) · GPU.js Learn* `output` is not just a size — it's a **shape**. `output: [16]` launches a line of 16 threads; `output: [16, 16]` launches a 16×16 *grid* of 256 threads, and each one gets two coordinates: `this.thread.x` (column) and `this.thread.y` (row). The result comes back with the same shape: a 2D kernel returns an array of rows, indexed `result[y][x]`. ## Figures - **output is a shape — [6] is a line, [4, 4] is rows of rows** ## Goal **Goal:** turn the kernel into a 16×16 grid that computes a multiplication table — cell `[y][x]` holds `(x + 1) * (y + 1)`. ## Requirements - Change `output` to a 16×16 grid: `[16, 16]` - Use both `this.thread.x` and `this.thread.y` - Return `(x + 1) * (y + 1)` so row 1 counts 1…16, row 2 counts 2…32, … ## Hint 1 — reading the shape `output: [width, height]` — x runs over `width`, y over `height`. The returned value lands in `result[y][x]`. ## Same idea elsewhere 2D and 3D launch grids are first-class everywhere: CUDA's `dim3` grid/block sizes, WebGPU's `workgroup_size` and dispatch dimensions. Choosing the launch shape to match the output shape is the same design move on every platform. ## Starter code ```js // output: [width, height] — gpu.js hands you a whole grid of threads. const gpu = new GPU({ mode }); const table = gpu.createKernel(function () { // TODO: use BOTH this.thread.x and this.thread.y // and return (x + 1) * (y + 1). return this.thread.x + 1; }, { // TODO: make this a 16×16 grid, not a 16-cell line output: [16], }); const result = await table(); console.log('rows:', result.length); console.log('row 0:', result[0]); ``` --- Interactive version: https://gpu.rocks/learn/data-in-data-out-42b68d01/2 [Previous task](https://gpu.rocks/learn/data-in-data-out-42b68d01/1.md) · [Next task](https://gpu.rocks/learn/data-in-data-out-42b68d01/3.md) --- # Grayscale, the GPU way *Task 3 of 6 · [Data In, Data Out](https://gpu.rocks/learn/data-in-data-out-42b68d01.md) · GPU.js Learn* On the CPU you'd loop over 262,144 pixels one by one. On the GPU, every pixel gets **its own thread** — the kernel body runs once per pixel, all at the same time. **Array layout in gpu.js** Image data comes in row-major: `image[y][x]` is the pixel in row *y*, column *x*, and each pixel is an `[r, g, b, a]` array with channels from 0 to 1. Mind the inversion that catches everyone — sizes are given width-first (`output: [width, height]`), but indexing runs row-first, so this thread's own pixel is `image[this.thread.y][this.thread.x]`. Swap those two and you read the transpose of your image. Three-dimensional data follows the same rule: `output: [w, h, d]` is indexed `[z][y][x]`. ## Figures - **one pixel in → one thread → one gray pixel out, for every pixel at once** ## Goal **Goal:** write a graphical kernel that converts `image` to grayscale using perceptual luminance. ## Requirements - Create the kernel with `graphical: true` and `output: [512, 512]` - Read the pixel for *this* thread from `image` - Weight the channels `0.299 R + 0.587 G + 0.114 B` - Write the result with `this.color()` ## Hint 1 — which pixel is mine? Inside a kernel, `this.thread.x` and `this.thread.y` tell you which output cell this thread owns. Use them to index into `image`. ## Hint 2 — reading a pixel `image[this.thread.y][this.thread.x]` gives you an `[r, g, b, a]` array with channels in the 0–1 range. ## Same idea elsewhere This is exactly a fragment shader in WebGPU/Metal, or a 2D thread block in CUDA and ROCm — one thread per output element. ## Starter code ```js // One thread per pixel. No loops over pixels — ever. const gpu = new GPU({ mode }); const grayscale = gpu.createKernel(function (image) { // TODO: read this thread's pixel from image, weight the channels // 0.299 R + 0.587 G + 0.114 B, and write it with this.color() this.color(1, 0, 1, 1); }, { output: [512, 512], graphical: true, }); await grayscale(inputImage); render(grayscale.canvas); ``` --- Interactive version: https://gpu.rocks/learn/data-in-data-out-42b68d01/3 [Previous task](https://gpu.rocks/learn/data-in-data-out-42b68d01/2.md) · [Next task](https://gpu.rocks/learn/data-in-data-out-42b68d01/4.md) --- # Read the Results Back *Task 4 of 6 · [Data In, Data Out](https://gpu.rocks/learn/data-in-data-out-42b68d01.md) · GPU.js Learn* A kernel's return value doesn't stay on the GPU — awaiting the call hands you the finished result as an ordinary (typed) array. From there it's plain JavaScript: loop over it, sum it, feed it to a chart, whatever you like. This round trip is the heartbeat of GPGPU: **upload → compute in parallel → read back**. Here the parallel part computes 128 squares; the read-back part totals them. ## Goal **Goal:** make the kernel return `x²` for each thread, then sum the returned array in plain JavaScript and log the total with `console.log`. ## Requirements - Kernel returns `this.thread.x * this.thread.x` for all 128 threads - Sum the returned `result` array in ordinary JavaScript — outside the kernel - Log the total (it should come to `690880`) ## Hint 1 — what comes back? With `output: [128]`, `await squares()` gives you a `Float32Array` of 128 numbers. It's indexable and loopable like any array. ## Hint 2 — the sum A plain `for` loop after the kernel call: ```js let total = 0; for (let i = 0; i < result.length; i++) { total += result[i]; } ``` ## Same idea elsewhere Read-back is never free: CUDA's `cudaMemcpy` device→host and WebGPU's `mapAsync` staging buffers exist for exactly this step — and minimizing round trips is rule one of real GPU performance (**Pipelines & Textures** makes a whole meal of it). ## Starter code ```js // Kernel output comes back to JavaScript as a typed array. const gpu = new GPU({ mode }); const squares = gpu.createKernel(function () { // TODO: return this thread's index, squared return this.thread.x; }, { output: [128] }); const result = await squares(); console.log(result); // TODO: total up `result` in plain JavaScript, then: // console.log('sum of squares:', total); ``` --- Interactive version: https://gpu.rocks/learn/data-in-data-out-42b68d01/4 [Previous task](https://gpu.rocks/learn/data-in-data-out-42b68d01/3.md) · [Next task](https://gpu.rocks/learn/data-in-data-out-42b68d01/5.md) --- # Images Are Just Arrays *Task 5 of 6 · [Data In, Data Out](https://gpu.rocks/learn/data-in-data-out-42b68d01.md) · GPU.js Learn* Task 3 painted pixels. But an image doesn't have to *stay* an image: in this course an image is a nested array — `photo[y][x]` is an `[r, g, b, a]` pixel with channels 0–1 — and a kernel can read it like any other array argument. Drop `graphical: true`, and the same per-pixel indexing produces **numbers** instead of colors: a measurement per pixel, ready for JavaScript. **Array layout in gpu.js** Image data comes in row-major: `image[y][x]` is the pixel in row *y*, column *x*, and each pixel is an `[r, g, b, a]` array with channels from 0 to 1. Mind the inversion that catches everyone — sizes are given width-first (`output: [width, height]`), but indexing runs row-first, so this thread's own pixel is `image[this.thread.y][this.thread.x]`. Swap those two and you read the transpose of your image. Three-dimensional data follows the same rule: `output: [w, h, d]` is indexed `[z][y][x]`. ## Figures - **drop graphical: true and a pixel is just four numbers** ## Goal **Goal:** compute a 64×64 brightness map of `photo` — each cell the average of that pixel's red, green and blue channels. ## Requirements - Keep the kernel numeric — no `graphical: true`, output `[64, 64]` - Read this thread's pixel: `photo[this.thread.y][this.thread.x]` - Return `(r + g + b) / 3` ## Hint 1 — same indexing as task 3 The pixel lookup is identical to the grayscale task — only the ending changes: `return` a number instead of calling `this.color()`. ## Hint 2 — the average ```js const pixel = photo[this.thread.y][this.thread.x]; return (pixel[0] + pixel[1] + pixel[2]) / 3; ``` ## Same idea elsewhere Treating an image as a data grid is how real pipelines work: computer-vision pre-processing, depth-map filtering, scientific imaging. In CUDA/WebGPU this is a compute pass sampling a texture and writing to a plain buffer. ## Starter code ```js // An image is a nested array: photo[y][x] → [r, g, b, a], all 0–1. const gpu = new GPU({ mode }); const brightness = gpu.createKernel(function (photo) { const pixel = photo[this.thread.y][this.thread.x]; // TODO: return the average of the red, green and blue channels return pixel[0]; }, { output: [64, 64] }); const map = await brightness(photo); console.log('top-left brightness:', map[0][0]); ``` --- Interactive version: https://gpu.rocks/learn/data-in-data-out-42b68d01/5 [Previous task](https://gpu.rocks/learn/data-in-data-out-42b68d01/4.md) · [Next task](https://gpu.rocks/learn/data-in-data-out-42b68d01/6.md) --- # Put It Together: Two Kernels *Task 6 of 6 · [Data In, Data Out](https://gpu.rocks/learn/data-in-data-out-42b68d01.md) · GPU.js Learn* Everything from this module in one pipeline. Kernel one reads the `photo` and produces a 64×64 **luminance map** — pure numbers. That result comes back to JavaScript, and you pass it straight into kernel two, a **graphical** kernel that paints the map as a grayscale picture. Array in → numbers out → array in again → pixels out. Data flowing *through* kernels is the whole game (and **Pipelines & Textures** shows how to keep that flow on the GPU). **Array layout in gpu.js** Image data comes in row-major: `image[y][x]` is the pixel in row *y*, column *x*, and each pixel is an `[r, g, b, a]` array with channels from 0 to 1. Mind the inversion that catches everyone — sizes are given width-first (`output: [width, height]`), but indexing runs row-first, so this thread's own pixel is `image[this.thread.y][this.thread.x]`. Swap those two and you read the transpose of your image. Three-dimensional data follows the same rule: `output: [w, h, d]` is indexed `[z][y][x]`. ## Goal **Goal:** finish both kernels — `luminance` returns `0.299r + 0.587g + 0.114b` per pixel, and `paint` renders the map as gray pixels with `this.color()`. ## Requirements - Numeric kernel: read `photo[this.thread.y][this.thread.x]`, return the weighted luminance - Graphical kernel: read this thread's value from `map` - Paint it gray: `this.color(l, l, l, 1)` - Feed the first kernel's result into the second (already wired up) ## Hint 1 — the luminance pass Same lookup as before, but return a number: ```js return 0.299 * pixel[0] + 0.587 * pixel[1] + 0.114 * pixel[2]; ``` ## Hint 2 — the paint pass `map` is a plain 2D array of numbers, so ```js const l = map[this.thread.y][this.thread.x]; this.color(l, l, l, 1); ``` ## Same idea elsewhere Multi-pass pipelines are the backbone of GPU work: render passes in graphics, kernel launch chains in CUDA, encoder passes in WebGPU. The handoff you just did through JavaScript is the slow version — pipelines (**Pipelines & Textures**) keep it on-device. ## Starter code ```js // Kernel 1 turns the photo into numbers. Kernel 2 turns numbers into pixels. const gpu = new GPU({ mode }); const luminance = gpu.createKernel(function (photo) { // TODO: return perceptual luminance — 0.299 R + 0.587 G + 0.114 B return 0; }, { output: [64, 64] }); const paint = gpu.createKernel(function (map) { // TODO: read this thread's value from map and paint it gray this.color(1, 0, 1, 1); }, { output: [64, 64], graphical: true }); const map = await luminance(photo); await paint(map); render(paint.canvas); ``` --- Interactive version: https://gpu.rocks/learn/data-in-data-out-42b68d01/6 [Previous task](https://gpu.rocks/learn/data-in-data-out-42b68d01/5.md) --- # Pipelines & Textures *Module of the free GPU.js GPGPU course · 5 tasks* Chaining kernels so data stays on the GPU — the single biggest real-world speedup. ## Tasks 1. [Flip On the Pipeline](https://gpu.rocks/learn/pipelines-and-textures-9f4aeaa5/1.md) 2. [Chain Kernels, Skip the Round Trip](https://gpu.rocks/learn/pipelines-and-textures-9f4aeaa5/2.md) 3. [toArray() Is a Tollbooth](https://gpu.rocks/learn/pipelines-and-textures-9f4aeaa5/3.md) 4. [Feedback Loops: immutable Textures](https://gpu.rocks/learn/pipelines-and-textures-9f4aeaa5/4.md) 5. [The Payoff: Photo to Screen, Zero Readbacks](https://gpu.rocks/learn/pipelines-and-textures-9f4aeaa5/5.md) --- Interactive version: https://gpu.rocks/learn/pipelines-and-textures-9f4aeaa5 --- # Flip On the Pipeline *Task 1 of 5 · [Pipelines & Textures](https://gpu.rocks/learn/pipelines-and-textures-9f4aeaa5.md) · GPU.js Learn* Until now, every kernel call ended the same way: the GPU finished computing, then the whole result was **downloaded back to JavaScript** as a typed array. That download is the expensive part — for a 512×512 grid it's a megabyte crossing the bus on every single call. `pipeline: true` changes the ending. The kernel still runs the same, but the result *stays in GPU memory*, and what you get back is a **texture** — a lightweight handle to data that never left the card. Log one and you'll see an object, not numbers. When you actually want the values, you ask for the download explicitly with `.toArray()` — and since the download is a real trip across the bus, it is asynchronous like the kernel call itself: `await result.toArray()`. One backend wrinkle to know: the CPU backend has no textures, so there a pipeline kernel hands back a plain array — which has no `.toArray` at all. Mode-safe code uses the same guard gpu.js uses internally, with the await in front of the call it guards: `result.toArray ? await result.toArray() : result`. (Awaiting a plain array is a no-op, so that one line is correct on every backend.) ## Goal **Goal:** make the `boost` kernel a pipeline kernel, then download its result explicitly and log the first sample. ## Requirements - Add `pipeline: true` to the kernel settings - Log the raw result — see what a texture looks like in the console - Download the values with `await .toArray()`, using the mode-safe guard - Log the first value as `console.log('first sample:', values[0])` ## Hint 1 — where does the flag go? `pipeline: true` sits in the settings object, right next to `output`. Nothing about the kernel function itself changes. ## Hint 2 — the mode-safe download ```js const values = result.toArray ? await result.toArray() : result; ``` On a GPU backend this awaits `toArray()`; on the CPU backend `result` is already an array and passes through untouched. ## Same idea elsewhere A gpu.js texture is the same idea as a `GPUBuffer` you never map in WebGPU, or device memory behind a pointer in CUDA and ROCm: the data has an address on the card, and JavaScript only holds the ticket stub. `.toArray()` is the explicit "map it back to the host" step. ## Starter code ```js // Run this as-is first: the kernel returns plain numbers, which means // every call ships the whole result back to JavaScript. Let's stop that. const gpu = new GPU({ mode }); const boost = gpu.createKernel(function (signal) { return Math.min(signal[this.thread.x] * 1.5, 1); }, { output: [256], // TODO: keep the result on the GPU }); const result = await boost(signal); console.log(result); // TODO: `result` is about to become a texture. Download the values // explicitly (mode-safe: result.toArray ? await result.toArray() : result) // and log the first one as: console.log('first sample:', values[0]); ``` --- Interactive version: https://gpu.rocks/learn/pipelines-and-textures-9f4aeaa5/1 [Next task](https://gpu.rocks/learn/pipelines-and-textures-9f4aeaa5/2.md) --- # Chain Kernels, Skip the Round Trip *Task 2 of 5 · [Pipelines & Textures](https://gpu.rocks/learn/pipelines-and-textures-9f4aeaa5.md) · GPU.js Learn* Here's the payoff of textures: a texture returned by one kernel can be passed **straight into the next kernel** as an argument. gpu.js binds the texture as the input — no download, no re-upload, no JavaScript in the middle. The data makes the whole trip without ever leaving the card. In **Data In, Data Out** you chained two kernels through JavaScript: the luminance map came back as arrays, then went up again for the second pass. Same chain below — except this time `luminance` is a pipeline kernel, and the second pass eats its texture directly. **Array layout in gpu.js** Image data comes in row-major: `image[y][x]` is the pixel in row *y*, column *x*, and each pixel is an `[r, g, b, a]` array with channels from 0 to 1. Mind the inversion that catches everyone — sizes are given width-first (`output: [width, height]`), but indexing runs row-first, so this thread's own pixel is `image[this.thread.y][this.thread.x]`. Swap those two and you read the transpose of your image. Three-dimensional data follows the same rule: `output: [w, h, d]` is indexed `[z][y][x]`. ## Figures - **kernel to kernel by texture — javascript never sees the middle** ## Goal **Goal:** finish the `contrast` kernel — stretch each luminance value around the midpoint with `(l − 0.5) × 2 + 0.5`, clamped to 0–1 — and keep the texture handoff intact. ## Requirements - Keep `luminance` a pipeline kernel — its result never touches JavaScript - Pass the returned texture directly into `contrast` (already wired up) - In `contrast`, return `(l - 0.5) * 2 + 0.5` clamped with `Math.min` / `Math.max` ## Hint 1 — textures index like arrays Inside `contrast`, the texture argument reads exactly like the 2D arrays you already know: `map[this.thread.y][this.thread.x]`. The kernel doesn't care where the data lives. ## Hint 2 — the clamp ```js return Math.min(Math.max((l - 0.5) * 2 + 0.5, 0), 1); ``` ## Same idea elsewhere Handing a texture from kernel to kernel is what CUDA does when consecutive launches read and write the same device pointers, and what a WebGPU compute pass does when one dispatch's storage buffer becomes the next dispatch's binding. On Metal it's two encoders sharing an `MTLBuffer`. Nobody copies to the CPU in between. ## Starter code ```js const gpu = new GPU({ mode }); // Pass 1 — luminance map, kept on the GPU as a texture. const luminance = gpu.createKernel(function (photo) { const pixel = photo[this.thread.y][this.thread.x]; return 0.299 * pixel[0] + 0.587 * pixel[1] + 0.114 * pixel[2]; }, { output: [64, 64], pipeline: true }); // Pass 2 — contrast stretch. Final stage, so it returns plain numbers. const contrast = gpu.createKernel(function (map) { const l = map[this.thread.y][this.thread.x]; // TODO: stretch around the midpoint — (l - 0.5) * 2 + 0.5 — // clamped to 0–1 with Math.min / Math.max return l; }, { output: [64, 64] }); const mapTexture = await luminance(photo); // a texture — still on the GPU const result = await contrast(mapTexture); // and straight back in it goes console.log('center cell:', result[32][32]); ``` --- Interactive version: https://gpu.rocks/learn/pipelines-and-textures-9f4aeaa5/2 [Previous task](https://gpu.rocks/learn/pipelines-and-textures-9f4aeaa5/1.md) · [Next task](https://gpu.rocks/learn/pipelines-and-textures-9f4aeaa5/3.md) --- # toArray() Is a Tollbooth *Task 3 of 5 · [Pipelines & Textures](https://gpu.rocks/learn/pipelines-and-textures-9f4aeaa5.md) · GPU.js Learn* Here's the mental model that makes GPU code fast: computation on the card is nearly free — it's the **transfers** that cost. Every kernel that is *not* `pipeline: true` ends with an implicit download, and passing that array to the next kernel triggers a re-upload. A three-stage chain without pipelines pays the toll **four times** for one result. The starter below is a fully working three-stage audio chain — normalize, gamma, smooth — and every hop goes through JavaScript. Your job isn't to fix the math. It's to fix the traffic: intermediates become pipeline kernels, and only the *final* stage returns plain numbers. The chain call itself shouldn't change by a single character. ## Goal **Goal:** refactor the chain so stages 1 and 2 keep their results on the GPU, the final stage returns numbers, and the output is bit-for-bit the same idea — just without the round trips. ## Requirements - Make `normalize` and `gamma` pipeline kernels - Leave `smooth` as a plain kernel — the one download you actually want - Do not change the chain: `await smooth(await gamma(await normalize(signal)))` stays as-is ## Hint 1 — where is the readback hiding? There's no `.toArray()` in the starter, but the readbacks are still there: a non-pipeline kernel's *awaited return value* is the readback. Count them: normalize downloads, gamma re-uploads and downloads, smooth re-uploads. ## Hint 2 — a two-line diff Add `pipeline: true` to the settings of `normalize` and `gamma`. That's the entire refactor — the chain line already does the right thing once textures flow through it. ## Same idea elsewhere Profile any real CUDA or ROCm app and the widest bars are often `cudaMemcpy` DtoH/HtoD, not kernels; in WebGPU the same toll is `mapAsync` plus staging-buffer copies. "Keep data resident, read back once at the end" is performance rule number one on every GPU platform. ## Starter code ```js const gpu = new GPU({ mode }); // Stage 1 — scale the raw 0–10 signal down to 0–1. const normalize = gpu.createKernel(function (signal) { return signal[this.thread.x] / 10; }, { output: [256] }); // TODO: this intermediate should stay on the GPU // Stage 2 — gamma curve to tame the loud parts. const gamma = gpu.createKernel(function (v) { return v[this.thread.x] * v[this.thread.x]; }, { output: [256] }); // TODO: so should this one // Stage 3 — 3-tap smoothing. Final stage: plain numbers out, on purpose. const smooth = gpu.createKernel(function (v) { let left = this.thread.x - 1; let right = this.thread.x + 1; if (left < 0) left = 0; if (right > 255) right = 255; return (v[left] + v[this.thread.x] + v[right]) / 3; }, { output: [256] }); // This chain is CORRECT — and slow. Each non-pipeline return is a full // GPU → JS download, and the next call re-uploads it. Four transfers. const out = await smooth(await gamma(await normalize(signal))); console.log('smoothed[0]:', out[0]); ``` --- Interactive version: https://gpu.rocks/learn/pipelines-and-textures-9f4aeaa5/3 [Previous task](https://gpu.rocks/learn/pipelines-and-textures-9f4aeaa5/2.md) · [Next task](https://gpu.rocks/learn/pipelines-and-textures-9f4aeaa5/4.md) --- # Feedback Loops: immutable Textures *Task 4 of 5 · [Pipelines & Textures](https://gpu.rocks/learn/pipelines-and-textures-9f4aeaa5.md) · GPU.js Learn* Simulations don't run once — they **step**: the output of step *n* is the input of step *n*+1. With pipelines that means feeding a kernel its own texture back. Try it naively and gpu.js stops you cold — the kernel would be reading the very storage it is writing to, and every backend refuses. WebGL puts it as *"Source and destination … are the same. Use immutable = true"*; WebGPU says the argument *"is this kernel's own output buffer"*. Same crime, and the same fix. `immutable: true` is the fix: each call renders to a *fresh* texture instead of recycling one, so last step's output is safe to read while this step writes. (In long-running sims you'd call `texture.delete()` on old steps to recycle their memory — at 128 cells here, we'll let them slide.) Below is a 1D heat field: 128 cells, all cold except one hot spike. One diffusion step moves each cell toward its neighbours. Twelve steps stay entirely on the GPU — one upload at the start, one download at the end. ## Figures - **immutable: true — a fresh texture per step makes feedback legal** ## Goal **Goal:** make the feedback loop legal — the `step` kernel needs `immutable: true` — and run 12 diffusion steps without the heat ever visiting JavaScript. ## Requirements - Add `immutable: true` to the `step` kernel (keep `pipeline: true`) - Keep the loop feeding `step`'s output straight back in — no readbacks inside it - After 12 steps, download once and log the peak at cell 64 ## Hint 1 — read the error message Run the starter as-is. The error names the crime: this kernel's input is its own output storage. On WebGL it names the sentence too — `immutable = true`; on WebGPU it only tells you the buffer is the kernel's own, and `immutable: true` is still the fix. Either way, the second step of the loop is where it fires: the first step reads `upload`'s texture, which is somebody else's. ## Hint 2 — why upload() exists The tiny `upload` kernel copies the seed array into a texture once, so `step` always sees texture inputs from its very first call. Keeping argument types stable means the kernel compiles exactly once. ## Hint 3 — the one-word diff In `step`'s settings: ```js { output: [128], pipeline: true, immutable: true } ``` The loop is already correct. ## Same idea elsewhere Every GPU API solves read-write hazards the same way gpu.js just made you do: ping-pong buffering. WebGPU compute passes swap two storage buffers each dispatch, CUDA stencil codes swap `in`/`out` device pointers, Metal simulations flip between two textures. `immutable: true` is ping-ponging with the bookkeeping done for you. ## Starter code ```js const gpu = new GPU({ mode }); // Upload pass — copies the seed array into a texture, once. const upload = gpu.createKernel(function (seed) { return seed[this.thread.x]; }, { output: [128], pipeline: true }); // One diffusion step: each cell relaxes toward its neighbours. // Edge cells hold their value. const step = gpu.createKernel(function (heat) { const x = this.thread.x; if (x === 0 || x === 127) { return heat[x]; } return 0.25 * heat[x - 1] + 0.5 * heat[x] + 0.25 * heat[x + 1]; }, { output: [128], pipeline: true, // TODO: this kernel reads its own previous output — run it and // let the error message tell you the missing setting. }); let state = await upload(field); for (let i = 0; i < 12; i++) { state = await step(state); // output straight back in — a feedback loop } const heat = state.toArray ? await state.toArray() : state; console.log('peak after 12 steps:', heat[64]); ``` --- Interactive version: https://gpu.rocks/learn/pipelines-and-textures-9f4aeaa5/4 [Previous task](https://gpu.rocks/learn/pipelines-and-textures-9f4aeaa5/3.md) · [Next task](https://gpu.rocks/learn/pipelines-and-textures-9f4aeaa5/5.md) --- # The Payoff: Photo to Screen, Zero Readbacks *Task 5 of 5 · [Pipelines & Textures](https://gpu.rocks/learn/pipelines-and-textures-9f4aeaa5.md) · GPU.js Learn* Time to cash in the whole module. In the finale of **Data In, Data Out**, a two-kernel chain hauled the luminance map down to JavaScript and back up again — two transfers it didn't need. This pipeline does more work with *fewer* transfers: photo → **luminance** → **3×3 blur** → **painted canvas**, and after the photo is uploaded, nothing comes back. The graphical kernel eats the blur texture and writes pixels; readbacks: zero. The missing piece is the blur. Each cell averages its 3×3 neighbourhood — two little loops over `dy`/`dx`, indices clamped to 0…63 so the edges don't read out of bounds. When it works, hit **Benchmark** and watch what keeping data on the card does to the gap. **Array layout in gpu.js** Image data comes in row-major: `image[y][x]` is the pixel in row *y*, column *x*, and each pixel is an `[r, g, b, a]` array with channels from 0 to 1. Mind the inversion that catches everyone — sizes are given width-first (`output: [width, height]`), but indexing runs row-first, so this thread's own pixel is `image[this.thread.y][this.thread.x]`. Swap those two and you read the transpose of your image. Three-dimensional data follows the same rule: `output: [w, h, d]` is indexed `[z][y][x]`. ## Goal **Goal:** implement the 3×3 box blur so the full three-pass pipeline — two texture passes and a graphical finale — runs with zero readbacks. ## Requirements - Blur: average the 3×3 neighbourhood, clamping indices to 0…63 at the edges - Both `luminance` and `blur` stay `pipeline: true` - The graphical pass is fed the blur *texture* — nothing is downloaded - Render the result with `render(paint.canvas)` ## Hint 1 — the neighbourhood loops Two nested loops with fixed bounds are fine in a kernel: `for (let dy = -1; dy <= 1; dy++)` and the same for `dx`. Accumulate into a `sum`, return `sum / 9`. ## Hint 2 — clamping the edges Compute `let yy = this.thread.y + dy;` then push it back in range: ```js if (yy < 0) yy = 0; if (yy > 63) yy = 63; ``` Same for `xx`. Corner cells just count some neighbours twice. ## Hint 3 — the whole body `let sum = 0;` then inside the loops `sum += map[yy][xx];` and finally `return sum / 9;` — the clamped `yy`/`xx` from hint 2 do the rest. ## Same idea elsewhere You just built what engine programmers call a render graph: named passes, explicit dependencies, all resources resident on the GPU — the architecture behind Frostbite's frame graph, CUDA Graphs' pre-recorded launch chains, and a Metal command buffer full of encoder passes. Real engines are this task with more boxes. ## Starter code ```js const gpu = new GPU({ mode }); // Pass 1 — luminance map. You've written this one twice already. const luminance = gpu.createKernel(function (photo) { const pixel = photo[this.thread.y][this.thread.x]; return 0.299 * pixel[0] + 0.587 * pixel[1] + 0.114 * pixel[2]; }, { output: [64, 64], pipeline: true }); // Pass 2 — 3×3 box blur. Currently a do-nothing passthrough. const blur = gpu.createKernel(function (map) { // TODO: average the 3×3 neighbourhood around this cell. // Clamp indices to 0…63 so edges don't read out of bounds. return map[this.thread.y][this.thread.x]; }, { output: [64, 64], pipeline: true }); // Pass 3 — paint the blurred map. Texture in, pixels out. const paint = gpu.createKernel(function (map) { const l = map[this.thread.y][this.thread.x]; this.color(l, l, l, 1); }, { output: [64, 64], graphical: true }); // The whole pipeline: after `photo` goes up, nothing comes back down. await paint(await blur(await luminance(photo))); render(paint.canvas); ``` --- Interactive version: https://gpu.rocks/learn/pipelines-and-textures-9f4aeaa5/5 [Previous task](https://gpu.rocks/learn/pipelines-and-textures-9f4aeaa5/4.md) --- # Measuring Speed Honestly *Module of the free GPU.js GPGPU course · 4 tasks* Warm-up, transfer costs, and precision — when the GPU wins, and when the CPU quietly beats it. ## Tasks 1. [The First Call Is a Lie](https://gpu.rocks/learn/measuring-speed-honestly-b9188894/1.md) 2. [Pay the Transfer Tax](https://gpu.rocks/learn/measuring-speed-honestly-b9188894/2.md) 3. [Two Machines, Two Answers](https://gpu.rocks/learn/measuring-speed-honestly-b9188894/3.md) 4. [When the CPU Wins](https://gpu.rocks/learn/measuring-speed-honestly-b9188894/4.md) --- Interactive version: https://gpu.rocks/learn/measuring-speed-honestly-b9188894 --- # The First Call Is a Lie *Task 1 of 4 · [Measuring Speed Honestly](https://gpu.rocks/learn/measuring-speed-honestly-b9188894.md) · GPU.js Learn* The first time you invoke a kernel, gpu.js does far more than run it: it **transpiles** your JavaScript function to shader code, hands it to the GPU driver to **compile and link**, allocates buffers — and *then* runs it. In `auto` mode there is more still: that first call is also where gpu.js asks the browser for a WebGPU adapter and rebuilds your kernel for it, so the backend swap happens inside the first `await` too. Every call after it skips straight to the run. So timing the first call measures the compiler, not your kernel. How wide the gap looks depends on the backend and on what the driver has already cached: on this page the first call typically costs a few times a warm one on WebGPU and ten times or more on WebGL, and on a big kernel with a cold shader cache it is wider still. What never changes is that it happens exactly once — which is why every honest benchmark **warms up first** and throws that first measurement away. ## Figures - **the first call buys the compiler — time the calls after it** ## Goal **Goal:** finish the kernel, then use `Date.now()` to time the *first* call and the *warmed-up* average separately — and log both. ## Requirements - Finish the kernel: return `Math.sin(x / 100) * 100` where `x` is this thread's index - Time the first call with `Date.now()` and log it: `first call: N ms` - Await 10 more calls in one timed block and log the average: `warm call: N ms` ## Hint 1 — the stopwatch pattern Snapshot the clock, do the work, subtract: ```js const t0 = Date.now(); // … the work … console.log('first call:', Date.now() - t0, 'ms'); ``` ## Hint 2 — averaging the warm calls One stopwatch around a loop of 10 calls, then divide: ```js t0 = Date.now(); for (let i = 0; i < 10; i++) await wave(); console.log('warm call:', (Date.now() - t0) / 10, 'ms'); ``` ## Same idea elsewhere Every platform has a version of this pause: CUDA JIT-compiles PTX at first launch (then caches it), WebGPU builds the shader in `createComputePipeline`, Metal compiles MSL when the pipeline state is created. Benchmarking guides on all of them open with the same rule — discard the first iteration. ## Starter code ```js // The first call compiles. The rest just run. Prove it. const gpu = new GPU({ mode }); const wave = gpu.createKernel(function () { // TODO: return Math.sin(x / 100) * 100, where x is this thread's index return 0; }, { output: [2048] }); // TODO: time the FIRST call with Date.now(): // const t0 = Date.now(); ...await wave()... // console.log('first call:', Date.now() - t0, 'ms'); const result = await wave(); // TODO: await wave() 10 more times inside one timed block, then log the // average as: console.log('warm call:', totalMs / 10, 'ms'); console.log('sample value:', result[100]); ``` --- Interactive version: https://gpu.rocks/learn/measuring-speed-honestly-b9188894/1 [Next task](https://gpu.rocks/learn/measuring-speed-honestly-b9188894/2.md) --- # Pay the Transfer Tax *Task 2 of 4 · [Measuring Speed Honestly](https://gpu.rocks/learn/measuring-speed-honestly-b9188894.md) · GPU.js Learn* A kernel call isn't just compute. Every invocation ships your input array from JavaScript to GPU memory, runs, then ships the result back. For a one-instruction kernel like `value + 1`, the arithmetic is nearly free — **the ride is the whole bill**. Below, the same trivial kernel runs on 1,024 values and on 65,536 values — 64× the data, one instruction per thread either way. Warm up first (task 1!), then measure, and read the two numbers together: 64× the payload does *not* cost 64× the time — on this page the big kernel usually lands under twice the small one — because most of a call is a **fixed toll** paid before any of your data moves. The part that does grow grows with **bytes moved**, not with arithmetic performed; the arithmetic here was free all along. ## Figures - **same +1 either way — the bill tracks bytes, not math** ## Goal **Goal:** finish the `+ 1` kernel and the `timeKernel` helper — warm up, then average 20 timed calls — and log the per-call cost for both payload sizes. ## Requirements - Kernel returns `data[this.thread.x] + 1` — one instruction, on purpose - In `timeKernel` (already `async` for you): `await` one *untimed* call to warm it up - Then time 20 awaited calls with `Date.now()` and return the average ms per call - Log both costs (the `small:`/`big:` lines are already wired up) ## Hint 1 — why warm up here too? `makePlusOne` builds *two separate kernels*, and each one compiles on its own first call. Without the warm-up, the big kernel's timing would include a compile — task 1's lie all over again. ## Hint 2 — the helper body ```js await kernel(arg); const t0 = Date.now(); for (let i = 0; i < 20; i++) await kernel(arg); return (Date.now() - t0) / 20; ``` ## Same idea elsewhere The bus is the bottleneck everywhere: `cudaMemcpy` across PCIe is the classic hot spot in CUDA and ROCm profiles, WebGPU makes you stage the copies explicitly with `writeBuffer` and `mapAsync`, and Apple's unified memory exists precisely to shrink this tax. Arithmetic is cheap; moving bytes is not. ## Starter code ```js // One-instruction kernel, two payload sizes. Cost tracks bytes, not math. const gpu = new GPU({ mode }); function makePlusOne(n) { return gpu.createKernel(function (data) { // TODO: return this thread's element, plus one return data[this.thread.x]; }, { output: [n] }); } const smallKernel = makePlusOne(1024); // small = 1,024 values const bigKernel = makePlusOne(65536); // big = 65,536 values async function timeKernel(kernel, arg) { // TODO: warm up with one untimed call (task 1!), // then time 20 calls and return the average ms per call return 0; } console.log('small:', await timeKernel(smallKernel, small), 'ms/call'); console.log('big:', await timeKernel(bigKernel, big), 'ms/call'); ``` --- Interactive version: https://gpu.rocks/learn/measuring-speed-honestly-b9188894/2 [Previous task](https://gpu.rocks/learn/measuring-speed-honestly-b9188894/1.md) · [Next task](https://gpu.rocks/learn/measuring-speed-honestly-b9188894/3.md) --- # Two Machines, Two Answers *Task 3 of 4 · [Measuring Speed Honestly](https://gpu.rocks/learn/measuring-speed-honestly-b9188894.md) · GPU.js Learn* JavaScript numbers are 64-bit floats — about 16 significant digits. GPU shaders compute in **32-bit floats** — about 7. Run the *same* arithmetic on both machines and the answers drift apart, a little more with every operation. The kernel below adds 1,000 fractions per thread; a plain JavaScript loop computes the identical sum in float64. The two results will disagree somewhere around the sixth decimal place — which means `===` is the wrong question. The right question is: **are they within a tolerance that matters for your problem?** ## Goal **Goal:** finish the kernel — each thread sums `1 / (k + this.thread.x)` for `k = 1…1000` — then fix the final comparison to use a tolerance instead of `===`. ## Requirements - Kernel: accumulate `1 / (k + this.thread.x)` over `k = 1…1000` in a loop - Keep the float64 reference sum for thread 0 (already wired up) - Log the verdict with a tolerance: `Math.abs(result[0] - ref) < 1e-3`, not `===` ## Hint 1 — loops inside kernels Fixed-bound loops are fine in kernel code: ```js for (let k = 1; k <= 1000; k++) { sum += 1 / (k + this.thread.x); } ``` ## Hint 2 — the tolerant verdict Replace the `===` comparison in the last line with `Math.abs(result[0] - ref) < 1e-3`. Exact equality across float32 and float64 is a coin you will almost never win. ## Same idea elsewhere float32-by-default is universal shader behavior — and production GPU code often trades away *more* precision on purpose: CUDA's `--use_fast_math`, TF32 on tensor cores, half-precision inference. That's why numerical toolkits ship `allclose`-style comparisons, and why this course's tests use `assertClose` instead of `==`. ## Starter code ```js // Same math, two machines: your GPU adds in float32, JavaScript in float64. const gpu = new GPU({ mode }); const partialSums = gpu.createKernel(function () { let sum = 0; // TODO: add up 1 / (k + this.thread.x) for k = 1 ... 1000 sum = 1 / (1 + this.thread.x); return sum; }, { output: [64] }); const result = await partialSums(); // The same sum for thread 0, computed in float64 JavaScript: let ref = 0; for (let k = 1; k <= 1000; k++) ref += 1 / k; console.log('kernel says:', result[0]); console.log('js says: ', ref); console.log('difference:', Math.abs(result[0] - ref)); // TODO: '===' is the wrong question — compare with a tolerance instead: console.log('close enough:', result[0] === ref); ``` --- Interactive version: https://gpu.rocks/learn/measuring-speed-honestly-b9188894/3 [Previous task](https://gpu.rocks/learn/measuring-speed-honestly-b9188894/2.md) · [Next task](https://gpu.rocks/learn/measuring-speed-honestly-b9188894/4.md) --- # When the CPU Wins *Task 4 of 4 · [Measuring Speed Honestly](https://gpu.rocks/learn/measuring-speed-honestly-b9188894.md) · GPU.js Learn* Sixteen numbers, doubled. The GPU *can* do it — but every kernel call pays a fixed toll before any math happens: dispatch through the graphics API, upload 16 values, read 16 back. A plain JavaScript loop finishes the whole job in nanoseconds, before the GPU has cleared its throat. This is the module's payoff — the full honest-measurement checklist in one run: **warm up first** (task 1), **remember the transfer toll** (task 2), **compare results with a tolerance** (task 3), and then **declare the true winner** — even when it isn't the GPU. Parallel hardware pays off on big workloads; on tiny ones, the honest answer is a for-loop. ## Goal **Goal:** double `tiny` both ways — kernel and plain loop — verify they agree within a tolerance, time both fairly, and log the winner. ## Requirements - Kernel returns `data[this.thread.x] * 2` for all 16 threads - Compare `fromKernel` to `fromLoop` element-wise with tolerance `1e-4` and log `match: true` - Time 200 warmed-up rounds of each contender and log both as `ms/round` - Log `winner:` with whichever contender was faster ## Hint 1 — the tolerant match Task 3's move, in a loop: start with `let allMatch = true;` and flip it to `false` whenever `Math.abs(fromKernel[i] - fromLoop[i]) > 1e-4`. ## Hint 2 — a fair fight The first `doubleTiny(tiny)` call already warmed the kernel up, so both timed loops measure steady state. Time 200 rounds of `doubleTiny(tiny)`, then 200 rounds of the JS loop, and divide each total by 200. ## Hint 3 — declaring the winner ```js console.log('winner:', kernelMs < loopMs ? 'gpu kernel' : 'plain js'); ``` On a job this small, expect the loop to take it. That's the honest answer. ## Same idea elsewhere Kernel-launch overhead runs to microseconds on CUDA and ROCm — thousands of CPU instructions' worth per launch. It's why serious frameworks batch and fuse tiny operations instead of dispatching them one at a time, and why "is this workload big enough?" is the first question asked in any GPU port. ## Starter code ```js // 16 numbers. The GPU CAN double them — but should it? const gpu = new GPU({ mode }); const doubleTiny = gpu.createKernel(function (data) { // TODO: return double this thread's element return data[this.thread.x]; }, { output: [16] }); const fromKernel = await doubleTiny(tiny); // also serves as the warm-up call // The same job, plain JavaScript: const fromLoop = new Array(16); for (let i = 0; i < 16; i++) fromLoop[i] = tiny[i] * 2; // TODO: compare fromKernel and fromLoop element-wise with tolerance 1e-4 // (task 3!) and log: console.log('match:', allMatch); // TODO: time 200 rounds of each contender with Date.now(), then log: // console.log('kernel: ', kernelMs, 'ms/round'); // console.log('plain js:', loopMs, 'ms/round'); // console.log('winner:', kernelMs < loopMs ? 'gpu kernel' : 'plain js'); ``` --- Interactive version: https://gpu.rocks/learn/measuring-speed-honestly-b9188894/4 [Previous task](https://gpu.rocks/learn/measuring-speed-honestly-b9188894/3.md) --- # Thinking in Parallel *Module of the free GPU.js GPGPU course · 6 tasks* Map and gather patterns, why kernels write only their own cell, and how to design around it. ## Tasks 1. [Map: One Thread, One Value](https://gpu.rocks/learn/thinking-in-parallel-c3876efb/1.md) 2. [Gather: Read Anywhere](https://gpu.rocks/learn/thinking-in-parallel-c3876efb/2.md) 3. [No Scatter Allowed](https://gpu.rocks/learn/thinking-in-parallel-c3876efb/3.md) 4. [Life on the Edge](https://gpu.rocks/learn/thinking-in-parallel-c3876efb/4.md) 5. [Smooth a Signal](https://gpu.rocks/learn/thinking-in-parallel-c3876efb/5.md) 6. [The Two-Pass Blur](https://gpu.rocks/learn/thinking-in-parallel-c3876efb/6.md) --- Interactive version: https://gpu.rocks/learn/thinking-in-parallel-c3876efb --- # Map: One Thread, One Value *Task 1 of 6 · [Thinking in Parallel](https://gpu.rocks/learn/thinking-in-parallel-c3876efb.md) · GPU.js Learn* Nearly every GPU program you'll ever write is built from a handful of patterns, and the first one has a name: **map**. Each output cell is a pure function of the input cell *at the same index* — no neighbors, no shared state, no "first do cell 3, then cell 4". That independence is exactly what lets the GPU run all the cells at once. Here `celsius` holds 64 temperature readings. Converting them to Fahrenheit is a textbook map: reading 7 becomes output 7, and nothing else matters to thread 7. ## Goal **Goal:** map every Celsius reading to Fahrenheit — `°F = °C × 9/5 + 32` — one thread per reading. ## Requirements - Read only *this thread's* element: `celsius[this.thread.x]` - Apply the formula `c * 9 / 5 + 32` - No loops over the array — the grid of threads *is* the loop ## Hint 1 — the shape of a map A map kernel touches exactly one input cell and one output cell, both at index `this.thread.x`. If you find yourself reading any other index, it's not a map any more. ## Hint 2 — the one-liner ```js return celsius[this.thread.x] * 9 / 5 + 32; ``` ## Same idea elsewhere Map is the hello-world of every GPU API: a CUDA grid where each thread transforms one element of a device array, a WebGPU compute shader dispatched once per buffer entry, a Metal compute encoder doing the same. If a problem is a pure map, it parallelizes for free. ## Starter code ```js // Map: output[i] depends ONLY on input[i]. One thread per reading. const gpu = new GPU({ mode }); const toFahrenheit = gpu.createKernel(function (celsius) { // TODO: convert THIS thread's reading — °F = °C × 9/5 + 32 return celsius[this.thread.x]; }, { output: [64] }); const result = await toFahrenheit(celsius); console.log('first reading:', celsius[0], '°C →', result[0], '°F'); ``` --- Interactive version: https://gpu.rocks/learn/thinking-in-parallel-c3876efb/1 [Next task](https://gpu.rocks/learn/thinking-in-parallel-c3876efb/2.md) --- # Gather: Read Anywhere *Task 2 of 6 · [Thinking in Parallel](https://gpu.rocks/learn/thinking-in-parallel-c3876efb.md) · GPU.js Learn* A map reads its own cell. A **gather** reads *any* cell — the thread computes **where to read from** using its own index. Reads are random-access and cheap; it's only *writes* that are pinned to your own cell (the next task is all about that). The cleanest possible gather: reverse an array. Thread 0 pulls the last element, thread 63 pulls the first — every thread reads exactly one cell, just not its own. The array length is wired in as `this.constants.n`, so the kernel doesn't hardcode 64. ## Goal **Goal:** make the kernel return the element from the *mirrored* position, so the output is `data` reversed. ## Requirements - Compute the read index from `this.thread.x` and `this.constants.n` - Thread `i` reads `data[n − 1 − i]` - No loops, no temporary arrays — one read per thread ## Hint 1 — mirror arithmetic The mirror of index `i` in an `n`-element array is `n − 1 − i`: index 0 ↔ index 63, index 1 ↔ index 62, … ## Hint 2 — the one-liner ```js return data[this.constants.n - 1 - this.thread.x]; ``` ## Same idea elsewhere Gather is why GPUs have texture units: shaders sample textures at arbitrary coordinates, CUDA routes scattered reads through `__ldg` and texture memory, WebGPU compute shaders index storage buffers freely. Hardware is built to make "read from anywhere" fast. ## Starter code ```js // A gather kernel computes WHERE to read from its own thread id. const gpu = new GPU({ mode }); const reverse = gpu.createKernel(function (data) { // TODO: read the element from the OTHER end of the array. // The array length is available as this.constants.n. return data[this.thread.x]; }, { output: [64], constants: { n: 64 }, }); const result = await reverse(data); console.log('first:', result[0], '(should be the old last:', data[63] + ')'); ``` --- Interactive version: https://gpu.rocks/learn/thinking-in-parallel-c3876efb/2 [Previous task](https://gpu.rocks/learn/thinking-in-parallel-c3876efb/1.md) · [Next task](https://gpu.rocks/learn/thinking-in-parallel-c3876efb/3.md) --- # No Scatter Allowed *Task 3 of 6 · [Thinking in Parallel](https://gpu.rocks/learn/thinking-in-parallel-c3876efb.md) · GPU.js Learn* Here's the rule that shapes gpu.js kernels (and any fragment shader): a thread can read anywhere but can only write **one place — its own cell**, via `return`. There is no `out[i + 1] = value` here, because 4096 simultaneous writers into shared cells would be chaos (who wins? in what order?). So the "push my value over there" plan — a **scatter** — must be turned inside out. Don't ask *"where does my value go?"*; ask *"whose value lands in **my** cell?"* — a gather. Try it on a rotation: every value moves one slot to the *right*, and the last wraps around to slot 0. ## Figures - **you can't push results to neighbours — pull what you need instead** ## Goal **Goal:** rotate `ring` one slot to the right by gathering: each thread pulls the value that belongs in its cell. ## Requirements - No writes to other cells — express the shift purely as a read - Thread `i` pulls from index `i − 1` - Thread 0 wraps around and pulls the *last* element ## Hint 1 — invert the direction If every value moves right by one, then the value in *my* cell came from my *left*: index `this.thread.x - 1`. The starter currently pulls from the right — that rotates the wrong way. ## Hint 2 — wrapping without an if Adding `n` before the modulo keeps the index positive: ```js (this.thread.x - 1 + this.constants.n) % this.constants.n ``` That turns `-1` into `63` and leaves 1…63 alone. ## Same idea elsewhere Compute APIs relax this ban: CUDA, WebGPU and ROCm threads *can* store to any buffer address (scatter), and neighbours in a block cooperate through workgroup memory. But two threads storing to the *same* address is still a data race, and the escape hatch — atomics like `atomicAdd` — serializes threads and costs dearly. That's why GPU folklore compresses this lesson into four words: *turn scatter into gather*. ## Starter code ```js // There is no out[i + 1] = value on a GPU. Threads only fill their OWN cell. const gpu = new GPU({ mode }); // Wanted: every value moves one slot RIGHT, the last wraps to slot 0. // You can't push your value right — so pull the right value in. const rotate = gpu.createKernel(function (ring) { // TODO: this pulls from the wrong side — it rotates LEFT. Fix the // gather so each thread pulls the value that belongs in its cell. return ring[(this.thread.x + 1) % this.constants.n]; }, { output: [64], constants: { n: 64 }, }); const result = await rotate(ring); console.log('ring[0] was', ring[0], '— it should now sit at result[1]:', result[1]); ``` --- Interactive version: https://gpu.rocks/learn/thinking-in-parallel-c3876efb/3 [Previous task](https://gpu.rocks/learn/thinking-in-parallel-c3876efb/2.md) · [Next task](https://gpu.rocks/learn/thinking-in-parallel-c3876efb/4.md) --- # Life on the Edge *Task 4 of 6 · [Thinking in Parallel](https://gpu.rocks/learn/thinking-in-parallel-c3876efb.md) · GPU.js Learn* The moment a gather reads a *neighbor*, the edges bite. Take the forward difference — `out[i] = signal[i+1] − signal[i]`, "how much does the signal jump here?". Thread 63 asks for `signal[64]`, which does not exist. What comes back is **whatever the backend decides** — and the three this course runs on decide three different things. Run the starter, then switch **Mode** and run it again: the CPU backend gives you `NaN`, WebGL gives you a garbage texel from elsewhere in the texture, and WebGPU quietly *clamps* the index and gives you `signal[63]` — a perfectly plausible number that you never asked for. That last one is the dangerous one: nothing looks wrong, so nothing gets fixed. Reading off the end isn't *wrong*, it is **undefined**: every platform is free to answer differently, and they do. So never rely on the read. Decide what the edge *means*, and write that down. The usual convention — the one every image filter uses — is **replicate**: the last cell repeats the last real difference, `signal[63] − signal[62]`. You get it by clamping the index you start *from*, so the pair you read is always a pair that exists. ## Figures - **signal[64] doesn't exist — clamp before you knock** ## Goal **Goal:** compute the forward difference with a clamped *base* index, so the last cell repeats the last real difference — `signal[63] − signal[62]` — instead of depending on what this backend happens to do with a read past the end. ## Requirements - Clamp the index you read *from*: `Math.min(this.thread.x, this.constants.n - 2)` - Interior cells still return `signal[i+1] − signal[i]` - The last cell repeats the one before it — never a value read past the end ## Hint 1 — which read is out of range Only thread 63 misbehaves: `this.thread.x + 1` is 64, one past the end. Every other thread's pair is fine, so the fix has to leave 0 … 62 exactly as they are and hand 63 a pair that exists. Careful which index you pin. Clamping the *neighbor* — `Math.min(this.thread.x + 1, n - 1)` — makes thread 63 read itself twice and return 0, which is the answer WebGPU was already inventing for you. Clamp the *base* instead. ## Hint 2 — the clamped base ```js const i = Math.min(this.thread.x, this.constants.n - 2); return signal[i + 1] - signal[i]; ``` For thread 63, `i` is 62, so the answer is `signal[63] - signal[62]` — the last real jump, repeated. Cells 62 and 63 come back holding the same number, which is exactly what "replicate" means. ## Same idea elsewhere Edge conventions are shipped as sampler settings on real hardware — `clamp-to-edge` address mode in WebGPU and Metal, `cudaAddressModeClamp` on CUDA texture objects, with `repeat` and `mirror` sitting beside them as the alternatives. Reading a raw buffer instead of a texture? Then you pick the convention by hand, exactly like here — and you *do* pick one, because an unguarded read past the end is undefined everywhere: CUDA will happily hand you another allocation's memory, and WGSL leaves out-of-range buffer access loose enough that two implementations can disagree. The three answers you just got from three backends are that fact, one level up. ## Starter code ```js // Forward difference: out[i] = signal[i + 1] - signal[i]. const gpu = new GPU({ mode }); const delta = gpu.createKernel(function (signal) { // TODO: thread 63 reads signal[64] — one past the end, and what comes // back is undefined: NaN on cpu, a garbage texel on webgl, a silently // clamped signal[63] on webgpu. Clamp the index you read FROM so the // last cell repeats the last real difference instead. return signal[this.thread.x + 1] - signal[this.thread.x]; }, { output: [64], constants: { n: 64 }, }); const result = await delta(signal); console.log('last two deltas (they should match):', result[62], result[63]); ``` --- Interactive version: https://gpu.rocks/learn/thinking-in-parallel-c3876efb/4 [Previous task](https://gpu.rocks/learn/thinking-in-parallel-c3876efb/3.md) · [Next task](https://gpu.rocks/learn/thinking-in-parallel-c3876efb/5.md) --- # Smooth a Signal *Task 5 of 6 · [Thinking in Parallel](https://gpu.rocks/learn/thinking-in-parallel-c3876efb.md) · GPU.js Learn* Time to combine everything: a **5-tap moving average**. Each output cell is the mean of `signal[x−2 … x+2]` — a gather over a small *window* of neighbors, with clamping where the window hangs off either end. This shape — loop over a fixed window, clamp, accumulate — is called a **stencil**, and it powers blurs, edge detectors, and physics simulations alike. Yes, a loop *inside* the kernel is fine: it's 5 iterations of private arithmetic per thread, not a loop over the data. 128 threads each averaging 5 numbers is still one parallel pass. ## Figures - **read five, write one — always your own cell** ## Goal **Goal:** each cell returns the average of the five values centered on it, with window indexes clamped to `0 … n−1`. ## Requirements - Loop over the window: `for (let d = 0; d < 5; d++)` with offset `d − 2` - Clamp every read with `Math.max(0, Math.min(n − 1, …))` - Return the sum divided by `5` ## Hint 1 — the window The five indexes are `this.thread.x + d - 2` for `d = 0…4`: two to the left, itself, two to the right. ## Hint 2 — clamp inside the loop Each iteration: ```js const j = Math.max(0, Math.min(this.constants.n - 1, this.thread.x + d - 2)); sum += signal[j]; ``` ## Hint 3 — sanity-check the edge Cell 0's clamped window reads indexes `0, 0, 0, 1, 2` — so `out[0]` should equal `(3·signal[0] + signal[1] + signal[2]) / 5`. ## Same idea elsewhere Windowed sums over neighbors are stencil computations — the bread and butter of scientific codes on CUDA and ROCm, where entire papers are devoted to tiling stencils into shared memory so the window reads come from fast on-chip storage instead of DRAM. ## Starter code ```js // A 5-tap stencil: mean of signal[x-2 ... x+2], edges clamped. const gpu = new GPU({ mode }); const smooth = gpu.createKernel(function (signal) { let sum = 0; for (let d = 0; d < 5; d++) { // TODO: read the window neighbor at offset d - 2, // clamped to 0 ... this.constants.n - 1 sum += signal[this.thread.x]; } return sum / 5; }, { output: [128], constants: { n: 128 }, }); const result = await smooth(signal); console.log('raw:', signal[64], '→ smoothed:', result[64]); ``` --- Interactive version: https://gpu.rocks/learn/thinking-in-parallel-c3876efb/5 [Previous task](https://gpu.rocks/learn/thinking-in-parallel-c3876efb/4.md) · [Next task](https://gpu.rocks/learn/thinking-in-parallel-c3876efb/6.md) --- # The Two-Pass Blur *Task 6 of 6 · [Thinking in Parallel](https://gpu.rocks/learn/thinking-in-parallel-c3876efb.md) · GPU.js Learn* The payoff. A 3×3 box blur of a 2D grid needs nine reads per cell — but the box blur is **separable**: blurring horizontally and then blurring that result vertically gives the *identical* answer with just three reads per cell per pass. Bigger blurs win bigger: a 9×9 blur drops from 81 reads to 18. This is also how you design around the no-communication rule at scale: since threads can't share work *within* a pass, you split the algorithm into passes — each pass a clean parallel gather, each handoff a finished grid. Kernel one blurs along `x`; its output feeds kernel two, which blurs along `y`. Both are 3-tap clamped stencils — task 5, twice, at right angles. ## Goal **Goal:** finish both kernels — `blurX` averages each cell with its left/right neighbors, `blurY` with its up/down neighbors — edges clamped, so the composition equals a full 3×3 box blur. ## Requirements - `blurX`: 3-tap average along the row — clamp `x + d − 1`, read `grid[this.thread.y][j]` - `blurY`: 3-tap average down the column — clamp `y + d − 1`, read `grid[j][this.thread.x]` - Both kernels divide their sum by `3` - Feed `blurX`'s output into `blurY` (already wired up) ## Hint 1 — task 5, rotated Each kernel is the moving-average pattern with a 3-wide window. The only new move: in 2D you clamp the coordinate along the blur axis and keep the other coordinate fixed. ## Hint 2 — the x pass ```js for (let d = 0; d < 3; d++) { const j = Math.max(0, Math.min(this.constants.n - 1, this.thread.x + d - 1)); sum += grid[this.thread.y][j]; } return sum / 3; ``` The y pass swaps which coordinate is clamped: `grid[j][this.thread.x]`. ## Same idea elsewhere Separable filtering is a classic GPU optimization you'll meet everywhere: game engines render Gaussian blurs as two fullscreen passes, WebGPU and Metal chain compute encoder passes the same way, and CUDA image pipelines launch one kernel per axis. Two cheap 1D passes beating one fat 2D pass — O(k) taps instead of O(k²) — never stops being true. ## Starter code ```js // Two passes at right angles = one 3×3 box blur, for 6 reads instead of 9. const gpu = new GPU({ mode }); const blurX = gpu.createKernel(function (grid) { // TODO: average grid[y][x-1], grid[y][x], grid[y][x+1] — clamp x return grid[this.thread.y][this.thread.x]; }, { output: [48, 48], constants: { n: 48 } }); const blurY = gpu.createKernel(function (grid) { // TODO: average grid[y-1][x], grid[y][x], grid[y+1][x] — clamp y return grid[this.thread.y][this.thread.x]; }, { output: [48, 48], constants: { n: 48 } }); const pass1 = await blurX(heightmap); const smooth = await blurY(pass1); console.log('corner before → after:', heightmap[0][0], '→', smooth[0][0]); ``` --- Interactive version: https://gpu.rocks/learn/thinking-in-parallel-c3876efb/6 [Previous task](https://gpu.rocks/learn/thinking-in-parallel-c3876efb/5.md) --- # Reductions *Module of the free GPU.js GPGPU course · 6 tasks* Sum, min, max and mean over millions of values — the ladder pattern every platform uses. ## Tasks 1. [The One-Thread Trap](https://gpu.rocks/learn/reductions-3dadc130/1.md) 2. [Partial Sums: Divide the Work](https://gpu.rocks/learn/reductions-3dadc130/2.md) 3. [One Rung of the Ladder](https://gpu.rocks/learn/reductions-3dadc130/3.md) 4. [Ride the Ladder Down](https://gpu.rocks/learn/reductions-3dadc130/4.md) 5. [Min and Max: Change the Operator](https://gpu.rocks/learn/reductions-3dadc130/5.md) 6. [Payoff: Mean and RMS, Fused](https://gpu.rocks/learn/reductions-3dadc130/6.md) --- Interactive version: https://gpu.rocks/learn/reductions-3dadc130 --- # The One-Thread Trap *Task 1 of 6 · [Reductions](https://gpu.rocks/learn/reductions-3dadc130.md) · GPU.js Learn* Meet the **reduction**: many values in, one value out — sum, min, max, mean. It's the awkward case in GPU land, because a kernel thread writes exactly *one* output cell. 4,096 inputs collapsing to 1 output means `output: [1]`… a single thread. You *can* do it — kernels may loop, as long as the bound is known at compile time, which is exactly what `this.constants` is for. But one thread grinding through 4,096 additions while thousands of its neighbours sit idle is the slowest possible way to use a GPU. Write it anyway: it's the baseline the rest of this module tears down. ## Goal **Goal:** make the single thread loop over all of `data` (bound: `this.constants.n`) and return the total. ## Requirements - Keep `output: [1]` — one thread owns the one output cell - Loop `for (let i = 0; i < this.constants.n; i++)` — in gpu.js's WebGL backend, loop bounds must be compile-time constants - Accumulate into a local `let sum` and return it ## Hint 1 — an accumulator Declare `let sum = 0;` before the loop, add to it inside the loop, and `return sum;` after. Plain JavaScript — the transpiler handles it. ## Hint 2 — the loop body One statement: `sum += data[i];` ## Same idea elsewhere This wall exists on every platform: a single CUDA thread summing a whole buffer is the textbook example of what *not* to do, and a naive WebGPU compute shader with one invocation hits it just the same. Everyone's escape route is the trick you build next — split the work, then combine. ## Starter code ```js // 4096 values, ONE output cell — so exactly one thread does everything. const gpu = new GPU({ mode }); const sumAll = gpu.createKernel(function (data) { // TODO: loop i from 0 to this.constants.n, accumulate data[i] // into a local sum, and return it. return 0; }, { output: [1], constants: { n: 4096 }, }); console.log('total:', (await sumAll(data))[0]); ``` --- Interactive version: https://gpu.rocks/learn/reductions-3dadc130/1 [Next task](https://gpu.rocks/learn/reductions-3dadc130/2.md) --- # Partial Sums: Divide the Work *Task 2 of 6 · [Reductions](https://gpu.rocks/learn/reductions-3dadc130.md) · GPU.js Learn* The fix: give *every* thread a slice. 64 threads, each summing 64 of the 4,096 values, produce 64 **partial sums** — and 64 leftover numbers are cheap to finish off in plain JavaScript. Watch the reading pattern, though. Thread `x` does *not* take a contiguous block; it reads `data[x]`, `data[x + 64]`, `data[x + 128]`, … — a **strided** walk. At every step of the loop, neighbouring threads touch neighbouring elements, which is exactly the access pattern GPU memory hardware is built to serve in one go. ## Figures - **thread x takes every 64th element — neighbours read neighbours at every step** ## Goal **Goal:** compute 64 strided partial sums on the GPU, then total the 64 partials in JavaScript and log the grand total. ## Requirements - Each of the 64 threads loops `this.constants.chunk` times - Strided reads: element `i` of thread `x` is `data[i * this.constants.threads + this.thread.x]` - Sum the 64 returned partials in plain JavaScript and `console.log` the total ## Hint 1 — which elements are mine? Thread `x` owns elements `x`, `x + 64`, `x + 128`, … so its `i`-th element sits at index `i * 64 + x`. ## Hint 2 — the loop body ```js sum += data[i * this.constants.threads + this.thread.x]; ``` ## Hint 3 — finishing in JS After `const partial = await partials(data);` a plain loop does it: ```js let total = 0; for (let i = 0; i < partial.length; i++) { total += partial[i]; } ``` ## Same idea elsewhere This is CUDA's *grid-stride loop*, almost line for line — every serious reduction in CUB and Thrust starts with per-thread partials accumulated in registers, and coalesced (strided-by-thread-count) reads are the whole reason for the pattern. WebGPU and Metal compute kernels stage the same partials into workgroup/threadgroup memory. ## Starter code ```js // 64 threads, 64 values each. Strided reads keep the memory hardware happy. const gpu = new GPU({ mode }); const partials = gpu.createKernel(function (data) { // TODO: loop this.constants.chunk times and accumulate this thread's // strided slice: data[i * this.constants.threads + this.thread.x] return 0; }, { output: [64], constants: { threads: 64, chunk: 64 }, }); const partial = await partials(data); console.log('partials:', partial.length); let total = 0; for (let i = 0; i < partial.length; i++) total += partial[i]; console.log('total:', total); ``` --- Interactive version: https://gpu.rocks/learn/reductions-3dadc130/2 [Previous task](https://gpu.rocks/learn/reductions-3dadc130/1.md) · [Next task](https://gpu.rocks/learn/reductions-3dadc130/3.md) --- # One Rung of the Ladder *Task 3 of 6 · [Reductions](https://gpu.rocks/learn/reductions-3dadc130.md) · GPU.js Learn* Sixty-four partials finished in JavaScript is fine. A million wouldn't be. To stay parallel all the way down, GPUs fold an array onto itself: add each element in the *top half* to its partner in the *bottom half*, and 512 values become 256 in a single parallel step. That's one rung of the **halving ladder** — every reduction library on every platform is built from this move. One kernel invocation = one rung. Each thread adds exactly one pair: `data[x] + data[x + half]`. And `half` comes for free — the fold distance is just the output length, `this.output.x`. ## Figures - **your partner lives one output-width away** ## Goal **Goal:** write the rung kernel — fold 512 values into 256 pair sums, preserving the total. ## Requirements - `output: [256]` — one thread per pair - Each thread adds its own element to its partner one output-width away - The fold preserves the total: the 256 outputs sum to the same value as the 512 inputs ## Hint 1 — how far away is my partner? With 512 inputs and 256 outputs, thread `x` pairs with element `x + 256` — and 256 is exactly `this.output.x`, the width of the output. ## Hint 2 — the one-liner ```js return data[this.thread.x] + data[this.thread.x + this.output.x]; ``` ## Same idea elsewhere The halving fold is the heart of every tree reduction: CUDA's classic shared-memory reduction halves its stride once per barrier, and WGSL subgroup ops or Metal's `simd_sum` are the same fold executed inside the hardware. One rung here equals one barrier-separated step there. ## Starter code ```js // Fold the top half onto the bottom half: 512 values in, 256 out. const gpu = new GPU({ mode }); const halve = gpu.createKernel(function (data) { // TODO: add this thread's element to its partner in the top half. // The fold distance is this.output.x. return data[this.thread.x]; }, { output: [256], }); const folded = await halve(data); console.log('folded length:', folded.length); console.log('first pair sum:', folded[0]); ``` --- Interactive version: https://gpu.rocks/learn/reductions-3dadc130/3 [Previous task](https://gpu.rocks/learn/reductions-3dadc130/2.md) · [Next task](https://gpu.rocks/learn/reductions-3dadc130/4.md) --- # Ride the Ladder Down *Task 4 of 6 · [Reductions](https://gpu.rocks/learn/reductions-3dadc130.md) · GPU.js Learn* Now ride it all the way: 1,024 → 512 → 256 → … → 1. Ten rungs and the array is a scalar. That means the *same* kernel has to run at a different size on every call — two options make that legal: `dynamicOutput: true` lets `setOutput()` shrink the thread grid between calls, and `dynamicArguments: true` lets the input shrink with it. The driving loop lives in JavaScript, but every rung of actual work stays parallel on the GPU: log₂(1024) = 10 launches instead of 1,023 serial additions. One real-world wrinkle, already wired into the driver: gpu.js locks an argument's *type* on the kernel's first call, so the ladder starts from a `Float32Array` — the same type every rung's output comes back as. ## Figures - **halve, halve, halve — the ladder every platform climbs** ## Goal **Goal:** reduce the 1,024 values of `data` to a single total by iterating the halving rung, and log the result. ## Requirements - Create the rung kernel with `dynamicOutput: true` and `dynamicArguments: true` - Fold pairs with `this.output.x`, exactly like the last task - Loop in JS: while `n > 1`, halve `n`, `setOutput([n])`, re-invoke - `console.log` the final scalar ## Hint 1 — resizing a kernel `halve.setOutput([n])` takes the new output shape as an array. Call it before each invocation, with `n` already halved. ## Hint 2 — the driver skeleton ```js let n = values.length; while (n > 1) { n = n / 2; // … } ``` — inside the loop, resize, re-invoke, and keep the returned array for the next rung. ## Hint 3 — the full driver ```js while (n > 1) { n = n / 2; halve.setOutput([n]); values = await halve(values); } ``` — then the answer is `values[0]`. ## Same idea elsewhere Multi-pass reduction is the production pattern everywhere: CUDA launches a shrinking sequence of grids (or grid-syncs with cooperative groups), WebGPU records repeated dispatches ping-ponging between two buffers, Metal encodes one compute pass per rung. The log₂(n) staircase is identical on all of them. ## Starter code ```js // Same rung as before — but dynamic, so it can shrink call by call. const gpu = new GPU({ mode }); const halve = gpu.createKernel(function (data) { // TODO: fold this thread's pair, exactly like the last task return data[this.thread.x]; }, { dynamicOutput: true, dynamicArguments: true, }); // Start from a Float32Array: gpu.js locks an argument's type on the first // call, and every rung's output comes back as a Float32Array. let values = Float32Array.from(data); let n = values.length; while (n > 1) { n = n / 2; halve.setOutput([n]); values = await halve(values); } console.log('total:', values[0]); ``` --- Interactive version: https://gpu.rocks/learn/reductions-3dadc130/4 [Previous task](https://gpu.rocks/learn/reductions-3dadc130/3.md) · [Next task](https://gpu.rocks/learn/reductions-3dadc130/5.md) --- # Min and Max: Change the Operator *Task 5 of 6 · [Reductions](https://gpu.rocks/learn/reductions-3dadc130.md) · GPU.js Learn* Here's the secret hiding inside the ladder: nothing about it is really about *addition*. Any operation that combines two values and doesn't care about order or grouping — associative and commutative — can ride the same ladder. Swap `+` for `Math.min` and the scalar at the bottom is the smallest value in the array. `Math.max` gives the largest. Two kernels, one driver. The structure doesn't change at all — only the fold rule. ## Goal **Goal:** find both the minimum and the maximum of `data` with two halving-ladder kernels, and log both. ## Requirements - `minStep` folds with `Math.min`, `maxStep` with `Math.max` - Both kernels use `dynamicOutput: true` and `dynamicArguments: true` - Ride each ladder down to a scalar and `console.log` both results ## Hint 1 — Math inside kernels `Math.min(a, b)` and `Math.max(a, b)` both work inside kernel functions. The fold becomes ```js Math.min(data[this.thread.x], data[this.thread.x + this.output.x]) ``` ## Hint 2 — one driver, two ladders Wrap last task's while-loop in a plain JS function that takes the kernel as a parameter — `await reduce(minStep, data)`, `await reduce(maxStep, data)` — instead of writing it twice. ## Same idea elsewhere Pluggable operators are why every library ships reduce as a higher-order function: `thrust::reduce` and ROCm's rocPRIM accept any binary op plus an identity value, Metal Performance Shaders sells min/max reductions pre-built, and WGSL's `subgroupMin`/`subgroupMax` are this exact ladder burned into silicon. ## Starter code ```js // Same ladder, new fold rule. Only the operator changes. const gpu = new GPU({ mode }); const minStep = gpu.createKernel(function (data) { // TODO: keep the SMALLER of the pair, not the sum return data[this.thread.x] + data[this.thread.x + this.output.x]; }, { dynamicOutput: true, dynamicArguments: true }); const maxStep = gpu.createKernel(function (data) { // TODO: keep the LARGER of the pair return data[this.thread.x] + data[this.thread.x + this.output.x]; }, { dynamicOutput: true, dynamicArguments: true }); async function reduce(step, values) { // Float32Array from the start — an argument's type is locked on first call. let v = Float32Array.from(values); let n = v.length; while (n > 1) { n = n / 2; step.setOutput([n]); v = await step(v); } return v[0]; } console.log('min:', await reduce(minStep, data)); console.log('max:', await reduce(maxStep, data)); ``` --- Interactive version: https://gpu.rocks/learn/reductions-3dadc130/5 [Previous task](https://gpu.rocks/learn/reductions-3dadc130/4.md) · [Next task](https://gpu.rocks/learn/reductions-3dadc130/6.md) --- # Payoff: Mean and RMS, Fused *Task 6 of 6 · [Reductions](https://gpu.rocks/learn/reductions-3dadc130.md) · GPU.js Learn* The payoff. Two statistics over 4,096 values: the **mean** (sum ÷ n) and the **RMS** — root-mean-square, √(sum of squares ÷ n) — the standard "how big is this signal" measure in audio and physics. RMS needs every value squared first. The rookie move is a separate squaring kernel — a whole extra pass over memory. The pro move is **fusion**: square each value in the same statement that reads it, inside the partial-sum kernel. Map and reduce, one pass over the data. Stack the whole module: strided partials (task 2) shrink 4,096 values to 64, then a single shared halving ladder (task 4) finishes *both* totals. ## Goal **Goal:** compute and log the mean and the RMS of `data` — two partial-sum kernels (one fused with squaring) plus one shared dynamic halving ladder. ## Requirements - `partialSums`: 64 strided partial sums of `data`, as in task 2 - `partialSquares`: same shape, but square each value *as it is read* — no separate squaring pass - One dynamic halving-ladder kernel rides both 64-value arrays down to scalars - `mean = total / 4096`, `rms = Math.sqrt(totalSq / 4096)` — log both ## Hint 1 — the fused body Read once, use twice: ```js const v = data[i * this.constants.threads + this.thread.x]; sum += v * v; ``` ## Hint 2 — one ladder, two rides The ladder kernel doesn't care what its 64 inputs mean. Wrap the driver loop in a function and call it once with each partials array. ## Hint 3 — the whole shape ```js const total = await ladder(await partialSums(data)); const totalSq = await ladder(await partialSquares(data)); ``` then divide, square-root, and log. ## Same idea elsewhere Fusing the map into the reduce is a marquee optimization on every platform: `thrust::transform_reduce` exists precisely for it, CUDA programmers hand-fuse to halve their memory traffic, and WebGPU/Metal kernels bake the transform into the accumulation loop. Memory bandwidth is the budget — fusion is the discount. ## Starter code ```js // Everything in one pipeline: partials → shared ladder → two statistics. const gpu = new GPU({ mode }); const partialSums = gpu.createKernel(function (data) { // TODO: strided partial sums, exactly like task 2 return 0; }, { output: [64], constants: { threads: 64, chunk: 64 } }); const partialSquares = gpu.createKernel(function (data) { // TODO: same walk, but square each value AS you read it (fusion!) return 0; }, { output: [64], constants: { threads: 64, chunk: 64 } }); // One rung, reused for both reductions. const halve = gpu.createKernel(function (data) { return data[this.thread.x] + data[this.thread.x + this.output.x]; }, { dynamicOutput: true, dynamicArguments: true }); async function ladder(values) { let v = values; let n = v.length; while (n > 1) { n = n / 2; halve.setOutput([n]); v = await halve(v); } return v[0]; } const total = await ladder(await partialSums(data)); const totalSq = await ladder(await partialSquares(data)); const mean = total / 4096; const rms = Math.sqrt(totalSq / 4096); console.log('mean:', mean); console.log('rms:', rms); ``` --- Interactive version: https://gpu.rocks/learn/reductions-3dadc130/6 [Previous task](https://gpu.rocks/learn/reductions-3dadc130/5.md) --- # Prefix Sums (Scan) *Module of the free GPU.js GPGPU course · 6 tasks* Running totals in parallel — the doubling ladder, exclusive scans, and the offsets every variable-sized output depends on. ## Tasks 1. [The Sum So Far](https://gpu.rocks/learn/prefix-sum-351cfa41/1.md) 2. [Everyone Sums Their Own Prefix](https://gpu.rocks/learn/prefix-sum-351cfa41/2.md) 3. [The Doubling Ladder](https://gpu.rocks/learn/prefix-sum-351cfa41/3.md) 4. [Inclusive, Exclusive, and Why It Matters](https://gpu.rocks/learn/prefix-sum-351cfa41/4.md) 5. [Work-Efficient: Upsweep, Downsweep](https://gpu.rocks/learn/prefix-sum-351cfa41/5.md) 6. [Payoff: Offsets Place the Data](https://gpu.rocks/learn/prefix-sum-351cfa41/6.md) --- Interactive version: https://gpu.rocks/learn/prefix-sum-351cfa41 --- # The Sum So Far *Task 1 of 6 · [Prefix Sums (Scan)](https://gpu.rocks/learn/prefix-sum-351cfa41.md) · GPU.js Learn* A **prefix sum** — a *scan* — is a running total. Give it `[3, 1, 4, 1]` and it answers `[3, 4, 8, 9]`: cell `i` holds everything from the start up to and including element `i`. A reduction collapses an array to a single number; a scan keeps *every* partial answer along the way, which turns out to be far more useful. In JavaScript it is two lines, and the shape of those two lines is the whole problem: ```js out[0] = x[0]; out[i] = out[i - 1] + x[i]; ``` Look at what cell `i` needs: not its neighbour's *input*, but its neighbour's **answer**. Every thread on a GPU starts at the same instant, so when thread 7 reaches for `out[6]` nobody has computed it yet — and nobody will, because thread 6 is waiting on thread 5. That is a serial dependency chain as long as the array, and it cannot be a kernel. Write it here in plain JavaScript first; the rest of this module is five ways around it. ## Goal **Goal:** fill `running` so that `running[i]` is the total rainfall of days `0 … i`, then log the array and the season total. ## Requirements - No kernel yet — plain JavaScript, so the dependency is impossible to miss - `running[0]` is just `rainfall[0]`; every later cell adds that day to the cell before it - `console.log` the whole `running` array, and the season total ## Hint 1 — seed the chain Cell 0 has nothing before it, so it is the only cell that does not read `running[i - 1]`. Set it first, then loop from `i = 1`. ## Hint 2 — the loop ```js running[0] = rainfall[0]; for (let i = 1; i < rainfall.length; i++) { running[i] = running[i - 1] + rainfall[i]; } ``` The season total is the last cell — an inclusive scan ends with the reduction already done. ## Same idea elsewhere Every serious GPU platform ships a scan primitive precisely because you cannot write one by accident: CUDA has `thrust::inclusive_scan` and CUB's `DeviceScan`, ROCm has rocPRIM's `inclusive_scan`, Metal Shading Language has `simd_prefix_inclusive_sum`, and WGSL's subgroup extension has `subgroupInclusiveAdd`. All of them exist to break the chain you are about to feel. ## Starter code ```js // No kernel here. Plain JavaScript, so the dependency is unmissable. const running = new Array(rainfall.length); // TODO: running[i] should be the total of rainfall[0 ... i]. // Right now every cell is just that day's rain — nothing accumulates. for (let i = 0; i < rainfall.length; i++) { running[i] = rainfall[i]; } console.log('daily :', rainfall); console.log('running:', running); console.log('season total:', running[running.length - 1]); ``` --- Interactive version: https://gpu.rocks/learn/prefix-sum-351cfa41/1 [Next task](https://gpu.rocks/learn/prefix-sum-351cfa41/2.md) --- # Everyone Sums Their Own Prefix *Task 2 of 6 · [Prefix Sums (Scan)](https://gpu.rocks/learn/prefix-sum-351cfa41.md) · GPU.js Learn* The way out of a dependency chain is to refuse to wait. Thread `i` does not ask thread `i − 1` for its answer — it computes its own from scratch, summing `values[0 … i]` itself. No thread needs anything but the original input, so all 1,024 of them run at once. Correct, embarrassingly parallel, and a **gather**: reads from anywhere, a write only to its own cell. And wasteful. Thread 1,023 does 1,024 additions, thread 512 does 513, and the whole thing costs about **n²/2 ≈ 524,000 additions** where the serial loop needed 1,023. That is the price of refusing to wait, and it is worth paying once: this is the honest baseline every cleverer scan has to beat, and the one you can put a stopwatch on. One wrinkle. You cannot write `for (let j = 0; j <= this.thread.x; j++)` — in gpu.js's WebGL backend a loop bound must be known when the shader is compiled, and `this.thread.x` is not. So loop over the whole array and *mask*: every thread walks all 1,024 elements and only counts the ones at or before its own index. ## Goal **Goal:** return the inclusive prefix sum of `values` — one thread per cell, each summing its own prefix — and log the grand total. ## Requirements - Loop `for (let j = 0; j < this.constants.n; j++)` — a compile-time bound - Add `data[j]` only while `j <= this.thread.x` - The last cell already holds the grand total — `console.log` it ## Hint 1 — which elements are mine? Thread 7 wants elements 0 through 7, its own included. Thread 0 wants only element 0. So the test inside the loop is `j <= this.thread.x` — with the equals sign, because the scan is *inclusive*. ## Hint 2 — the loop body ```js let sum = 0; for (let j = 0; j < this.constants.n; j++) { if (j <= this.thread.x) { sum += data[j]; } } return sum; ``` ## Same idea elsewhere The brute-force scan is not only a straw man — it is what you actually want at the very bottom of the hierarchy, where a handful of values already sit in registers and a smarter algorithm's bookkeeping costs more than the redundant adds. Above that size it loses badly, which is why CUB, rocPRIM and Thrust all switch strategy by scale instead of shipping one scan. ## Starter code ```js // 1024 threads, each summing its own prefix. Nobody waits for anybody. const gpu = new GPU({ mode }); const prefix = gpu.createKernel(function (data) { // TODO: accumulate data[j] for every j at or before this thread's index. // The loop bound has to be a compile-time constant, so walk the whole // array and mask with an if. return data[this.thread.x]; }, { output: [1024], constants: { n: 1024 }, }); const scan = await prefix(values); console.log('scan[0]:', scan[0], ' scan[1]:', scan[1], ' scan[2]:', scan[2]); // TODO: the last cell is already the grand total — log it. console.log('grand total:', 0); ``` --- Interactive version: https://gpu.rocks/learn/prefix-sum-351cfa41/2 [Previous task](https://gpu.rocks/learn/prefix-sum-351cfa41/1.md) · [Next task](https://gpu.rocks/learn/prefix-sum-351cfa41/3.md) --- # The Doubling Ladder *Task 3 of 6 · [Prefix Sums (Scan)](https://gpu.rocks/learn/prefix-sum-351cfa41.md) · GPU.js Learn* Half a million additions for a thousand-element scan is a lot. Here is the trick that gets it down to ten thousand: run **log₂(n) passes**, and on pass `d` have every cell add the value `2^d` places to its left. Stride 1, then 2, then 4, 8, … After pass `d` every cell holds the sum of the `2^(d+1)` elements ending at it, so ten passes over 1,024 cells leave each one holding its whole prefix. This is the **Hillis-Steele** scan — the same stride ladder the Reductions module climbs to collapse an array, run the other way: doubling instead of halving, and keeping every partial answer instead of only the last. One kernel, called ten times from a plain JavaScript loop with the stride as an *argument*. That multi-pass gather formulation is the point: gpu.js gives you no atomics and no shared memory, so a pass boundary is the only synchronisation there is — and it is the same shape as the barrier-separated steps a CUDA or WebGPU scan uses. It also hands you something for free. An in-place scan has a famous race: cell 7 reads cell 6 while cell 6 is busy overwriting itself, and back comes somebody's half-finished answer. Real GPU code prevents that with a barrier or a second buffer (*ping-pong* buffering). Here a kernel cannot write into the array it is reading — each pass **returns a new array** and the next pass consumes it, so the race is simply unavailable. As long as you really do feed each pass the previous pass's result. ## Figures - **1, 2, 4 — every pass reaches twice as far, and cell 7 collects the lot** ## Goal **Goal:** write the one-pass kernel, drive ten passes from JavaScript with the stride doubling each time, and log `scan[511]` and the grand total. ## Requirements - The kernel takes `(data, stride)` and returns `data[x] + data[x − stride]` - Threads below `stride` have no partner — they pass their own value through - Drive the passes from JS: `stride` = 1, 2, 4, … while `stride < 1024` - Each pass reads the array the *previous* pass returned ## Hint 1 — one pass Every cell wants the value `stride` places to its left — but cells `0 … stride − 1` have no such cell. They keep what they already have: ```js if (this.thread.x >= stride) { return data[this.thread.x] + data[this.thread.x - stride]; } return data[this.thread.x]; ``` ## Hint 2 — the driver Ten passes, and the stride *doubles* — `1, 2, 4, 8, …`, not `1, 2, 3`. The reassignment is what makes pass `d` read what pass `d − 1` returned: ```js for (let stride = 1; stride < N; stride *= 2) { v = await scanStep(v, stride); } ``` ## Hint 3 — why Float32Array gpu.js locks an argument's type on a kernel's first call, and every pass hands back a `Float32Array`. Start the ladder from one — `Float32Array.from(values)` — so pass 1 sees the same type as passes 2 … 10. ## Same idea elsewhere This exact ladder is burned into GPU silicon at warp scale. Metal's `simd_prefix_inclusive_sum`, WGSL's `subgroupInclusiveAdd` and the CUDA idiom built from `__shfl_up_sync` are all Hillis-Steele over 32 or 64 lanes, with a lane-id comparison playing the part of your `if (x >= stride)` guard. What you are writing by hand across kernel launches, the hardware does in five instructions inside a warp. ## Starter code ```js // One kernel, log2(1024) = 10 calls. The stride doubles every pass. const gpu = new GPU({ mode }); const N = 1024; const scanStep = gpu.createKernel(function (data, stride) { // TODO: add the value `stride` places to your left — if it exists. // Threads below `stride` have no partner and keep their own value. return data[this.thread.x]; }, { output: [N] }); // gpu.js locks an argument's type on the first call, so start from a // Float32Array — the same type every pass hands back. let v = Float32Array.from(values); // TODO: ten passes, stride 1, 2, 4, ... 512. Each pass must read the // array the PREVIOUS pass returned. v = await scanStep(v, 1); console.log('scan[511]:', v[511]); console.log('grand total:', v[N - 1]); ``` --- Interactive version: https://gpu.rocks/learn/prefix-sum-351cfa41/3 [Previous task](https://gpu.rocks/learn/prefix-sum-351cfa41/2.md) · [Next task](https://gpu.rocks/learn/prefix-sum-351cfa41/4.md) --- # Inclusive, Exclusive, and Why It Matters *Task 4 of 6 · [Prefix Sums (Scan)](https://gpu.rocks/learn/prefix-sum-351cfa41.md) · GPU.js Learn* Scans come in two flavours. The **inclusive** scan you just built answers *"everything up to and including me"*. The **exclusive** scan answers *"everything strictly before me"*: cell 0 is `0`, and every other cell is the inclusive scan shifted one place right. Exclusive is the one everything downstream actually wants, because it answers a different question — **where does my run of output start?** Here `counts` is a sign-up sheet: `counts[i]` people booked session `i`, and you are laying all of them out in one flat seating list. Session `i`'s block begins at `exclusive[i]`. The inclusive scan would tell you where that block *ends*, which is exactly one seat too late. Converting is a one-line gather: cell `i` reads `inclusive[i − 1]`, and cell 0 returns `0` because it has nothing before it. One wrinkle worth knowing — an exclusive scan *throws the grand total away*. Its last cell holds everything except the last element, so keep the total separately: `exclusive[n − 1] + counts[n − 1]`. ## Figures - **a zero goes in the front, the grand total drops off the back** ## Goal **Goal:** turn the prewired inclusive scan into the exclusive scan — the starting offset of every session — and log the total number of seats. ## Requirements - One kernel, taking the inclusive scan as its single argument - Cell 0 returns `0`; cell `i` returns `inclusive[i − 1]` - Log the grand total, which the exclusive scan on its own no longer knows ## Hint 1 — a shift is a gather "Move everything one cell right" is a scatter, and kernels cannot scatter. Ask the inverted question instead — *whose value lands in MY cell?* — and it is a one-line read from `this.thread.x - 1`. ## Hint 2 — the edge Thread 0 must not read `inclusive[-1]`: ```js if (this.thread.x === 0) { return 0; } return inclusive[this.thread.x - 1]; ``` ## Hint 3 — the total that got away `offsets[31]` is where the LAST session starts, so the seat count is `offsets[31] + counts[31]`. (The inclusive scan's last cell had it all along — that is the reduction hiding inside every scan.) ## Same idea elsewhere Exclusive is the library default for exactly this reason: `cub::DeviceScan::ExclusiveSum`, `thrust::exclusive_scan`, WGSL's `subgroupExclusiveAdd` and Metal's `simd_prefix_exclusive_sum` all answer "where does my output begin?". And they all share the same wrinkle — CUB hands the aggregate back through a separate output, because the exclusive scan itself cannot carry it. ## Starter code ```js // counts[i] people booked session i. Where does each session's block start? const gpu = new GPU({ mode }); const N = 32; // Last task's ladder, prewired: inclusive[i] = counts[0] + ... + counts[i]. const scanStep = gpu.createKernel(function (data, stride) { if (this.thread.x >= stride) { return data[this.thread.x] + data[this.thread.x - stride]; } return data[this.thread.x]; }, { output: [N] }); let v = Float32Array.from(counts); for (let stride = 1; stride < N; stride *= 2) { v = await scanStep(v, stride); } const inclusive = v; const toExclusive = gpu.createKernel(function (inclusive) { // TODO: cell i should hold the total of everything BEFORE session i. // Cell 0 has nothing before it. return inclusive[this.thread.x]; }, { output: [N] }); const offsets = await toExclusive(inclusive); console.log('counts [0..3]:', counts[0], counts[1], counts[2], counts[3]); console.log('offsets[0..3]:', offsets[0], offsets[1], offsets[2], offsets[3]); // TODO: the exclusive scan dropped the grand total. Log it. console.log('total seats:', 0); ``` --- Interactive version: https://gpu.rocks/learn/prefix-sum-351cfa41/4 [Previous task](https://gpu.rocks/learn/prefix-sum-351cfa41/3.md) · [Next task](https://gpu.rocks/learn/prefix-sum-351cfa41/5.md) --- # Work-Efficient: Upsweep, Downsweep *Task 5 of 6 · [Prefix Sums (Scan)](https://gpu.rocks/learn/prefix-sum-351cfa41.md) · GPU.js Learn* Hillis-Steele is fast but greedy: ten passes over 1,024 cells is about **n·log₂n ≈ 10,000 additions** where the serial loop needed 1,023. The **Blelloch** scan gets that down to roughly **2n**, in two halves of one balanced tree. *Upsweep* is a plain tree reduction — the one the Reductions module builds — done in place: at stride 1 every odd cell absorbs its left neighbour, at stride 2 every fourth cell absorbs the subtotal two places left, and so on. After log₂n passes the last cell holds the grand total and each "block top" holds its own block's subtotal — a whole tree of partial sums, stored in the array it came from. *Downsweep* then walks that tree back down: put `0` in the last cell, and at each level a node hands its value down to its left partner while keeping its own value plus that partner's old subtotal. What falls out is the **exclusive** scan. Be honest about the payoff. Blelloch does a fraction of the arithmetic — 2n against n·log₂n — but needs *twice* the kernel launches (21 here against 10), and near the root of the tree almost every thread is idle. At n = 1,024 the simpler ladder usually wins on the clock; work-efficiency only starts paying once the array is big enough that arithmetic, not launch overhead, is the bill. Press **Benchmark** and watch the better algorithm lose. ## Figures - **up the tree to build subtotals, down it again to hand them out** ## Goal **Goal:** write the two sweep kernels. The prewired driver runs upsweep up the tree, clears the last cell, and runs downsweep back down — producing the exclusive scan of `values`. ## Requirements - Upsweep: only the top cell of each `2·stride` block changes, to `data[i] + data[i − stride]` - Downsweep: the block top becomes `data[i] + data[i − stride]`, and its left partner takes over the block top's old value - Every other cell in both kernels passes its value straight through - Log `exclusive[512]` and the grand total ## Hint 1 — which cells are active? At stride `s` the blocks are `2s` wide, so their tops sit at indexes `2s − 1, 4s − 1, 6s − 1, …` — exactly the cells where `(i + 1) % (2 * stride) === 0`. The top's left partner is `stride` places earlier, so the partner's own test is `(i + 1 + stride) % (2 * stride) === 0`. ## Hint 2 — the upsweep body ```js const i = this.thread.x; if ((i + 1) % (2 * stride) === 0) { return data[i] + data[i - stride]; } return data[i]; ``` At stride 1 that is cells 1, 3, 5, …; at stride 2 it is cells 3, 7, 11, … — half as many workers each pass, which is where the n·log n turns into 2n. ## Hint 3 — the downsweep body Two active cases, and everybody else passes through: ```js const i = this.thread.x; const block = 2 * stride; if ((i + 1) % block === 0) { return data[i] + data[i - stride]; } if ((i + 1 + stride) % block === 0) { return data[i + stride]; } return data[i]; ``` The second case is the left partner taking over the block top's old value — which is why both swaps have to happen in the same pass, reading the same snapshot. ## Same idea elsewhere Blelloch's two sweeps are the textbook work-efficient scan, and they are what every GPU course draws on the board. Production libraries have moved past them: CUB's `DeviceScan` uses a single-pass *decoupled look-back*, where each block scans locally and then waits on its predecessors' aggregates, because on modern hardware the bill is memory traffic rather than additions — and two full sweeps means reading the array twice. Knowing why the elegant answer lost is the real lesson. ## Starter code ```js // Two sweeps of a balanced tree. ~2n additions instead of n·log2(n). const gpu = new GPU({ mode }); const N = 1024; // UPSWEEP — build the reduction tree in place. const upsweep = gpu.createKernel(function (data, stride) { const i = this.thread.x; // TODO: only the TOP cell of each 2*stride block works this pass — it // absorbs the subtotal `stride` places to its left. Everyone else // passes their value straight through. return data[i]; }, { output: [N] }); // DOWNSWEEP — walk the tree back down. const downsweep = gpu.createKernel(function (data, stride) { const i = this.thread.x; // TODO: two kinds of active cell this pass, everyone else passes through: // * the top of each 2*stride block keeps its own value PLUS its left // partner's old subtotal; // * that left partner takes over the block top's old value. return data[i]; }, { output: [N] }); // An exclusive scan starts from 0 at the root — prewired. const clearLast = gpu.createKernel(function (data) { if (this.thread.x === this.constants.n - 1) { return 0; } return data[this.thread.x]; }, { output: [N], constants: { n: N } }); let v = Float32Array.from(values); for (let stride = 1; stride < N; stride *= 2) { v = await upsweep(v, stride); } const total = v[N - 1]; // the upsweep already reduced the whole array v = await clearLast(v); for (let stride = N / 2; stride >= 1; stride /= 2) { v = await downsweep(v, stride); } console.log('exclusive[512]:', v[512]); console.log('grand total:', total); ``` --- Interactive version: https://gpu.rocks/learn/prefix-sum-351cfa41/5 [Previous task](https://gpu.rocks/learn/prefix-sum-351cfa41/4.md) · [Next task](https://gpu.rocks/learn/prefix-sum-351cfa41/6.md) --- # Payoff: Offsets Place the Data *Task 6 of 6 · [Prefix Sums (Scan)](https://gpu.rocks/learn/prefix-sum-351cfa41.md) · GPU.js Learn* Now the reason scan is the primitive everything else is built on. Each of the 32 sessions produces a *variable* number of output rows — `counts[i]` of them — and they all have to land in one flat 128-slot list, in order, with no gaps. The exclusive scan of `counts` is exactly the array of starting offsets, and it is prewired for you here out of tasks 3 and 4. On a CPU you would loop the sessions and *write* each block — a scatter, which kernels cannot do. So invert it, the way a gather always inverts a scatter: one thread per output **slot**, each asking *"which session owns me?"*. Slot `s` belongs to session `i` when `offsets[i] <= s < offsets[i] + counts[i]` — the session whose block has already started and has not yet run out. Six sessions here booked nobody; their blocks are empty, contain no slot at all, and drop out of the search on their own. An offset exists whether or not anything lands on it, which is exactly why the scan has to produce one for every session. Count, scan, place. That is stream compaction, run-length decoding, sparse-matrix assembly, and every "each thread emits a different number of results" problem on a GPU — all of them a scan wearing a hat. ## Goal **Goal:** fill 128 slots, each one returning the index of the session that owns it. ## Requirements - One thread per output slot — `output: [128]` - Search all 32 sessions with a compile-time loop bound (`this.constants.items`) - Slot `s` belongs to session `i` when `offsets[i] <= s` *and* `s < offsets[i] + counts[i]` ## Hint 1 — invert the question You cannot push a session's rows into the list. Ask the other question — *whose row lands in MY slot?* — and every slot searches the 32 sessions for the one whose block contains it. ## Hint 2 — mind the first seat Session `i` owns slot `offsets[i]` itself, so the lower test needs the equals sign: `offsets[i] <= slot`, not `<`. Get that wrong and every block's opening seat comes back ownerless. ## Hint 3 — the loop ```js const slot = this.thread.x; let found = 0; for (let i = 0; i < this.constants.items; i++) { if (offsets[i] <= slot && slot < offsets[i] + counts[i]) { found = i; } } return found; ``` ## Same idea elsewhere Count, scan, place is the standard three-kernel recipe for variable-sized output on every platform. `thrust::copy_if` and `cub::DeviceSelect::Flagged` are a scan of a 0/1 flag array with a gather bolted on; a WebGPU or Metal particle system whose sources each emit a different number of fragments uses the same scan to decide where each one writes; GPU sparse-matrix builders scan row lengths to get row pointers. Without a scan, none of it is parallel. ## Starter code ```js // 32 sessions, 128 seats, one flat list. Which session owns each seat? const gpu = new GPU({ mode }); const ITEMS = 32; const SLOTS = 128; // Tasks 3 and 4, prewired: counts -> inclusive scan -> starting offsets. const scanStep = gpu.createKernel(function (data, stride) { if (this.thread.x >= stride) { return data[this.thread.x] + data[this.thread.x - stride]; } return data[this.thread.x]; }, { output: [ITEMS] }); const toExclusive = gpu.createKernel(function (inclusive) { if (this.thread.x === 0) { return 0; } return inclusive[this.thread.x - 1]; }, { output: [ITEMS] }); let v = Float32Array.from(counts); for (let stride = 1; stride < ITEMS; stride *= 2) { v = await scanStep(v, stride); } const offsets = await toExclusive(v); // Your kernel: one thread per SLOT. const ownerOf = gpu.createKernel(function (offsets, counts) { // TODO: search the sessions for the one whose block contains this slot. // Session i owns slot s while offsets[i] <= s < offsets[i] + counts[i]. return 0; }, { output: [SLOTS], constants: { items: ITEMS } }); const owners = await ownerOf(offsets, counts); console.log('slots 0-9 belong to sessions:', owners[0], owners[1], owners[2], owners[3], owners[4], owners[5], owners[6], owners[7], owners[8], owners[9]); console.log('the last slot belongs to session:', owners[SLOTS - 1]); ``` --- Interactive version: https://gpu.rocks/learn/prefix-sum-351cfa41/6 [Previous task](https://gpu.rocks/learn/prefix-sum-351cfa41/5.md) --- # Stream Compaction *Module of the free GPU.js GPGPU course · 5 tasks* Filtering on a GPU: flag what survives, scan to find out where it lands, then gather it into a packed array. ## Tasks 1. [Filter Has No Kernel](https://gpu.rocks/learn/stream-compaction-0aed2e43/1.md) 2. [Where Do I Land?](https://gpu.rocks/learn/stream-compaction-0aed2e43/2.md) 3. [Turn the Scatter Around](https://gpu.rocks/learn/stream-compaction-0aed2e43/3.md) 4. [Find It in log n](https://gpu.rocks/learn/stream-compaction-0aed2e43/4.md) 5. [Payoff: How Many Survived](https://gpu.rocks/learn/stream-compaction-0aed2e43/5.md) --- Interactive version: https://gpu.rocks/learn/stream-compaction-0aed2e43 --- # Filter Has No Kernel *Task 1 of 5 · [Stream Compaction](https://gpu.rocks/learn/stream-compaction-0aed2e43.md) · GPU.js Learn* On a CPU, filtering is four words: `data.filter(v => v >= 50)`. Underneath it is a loop with a **moving write cursor** — every element that passes gets pushed at wherever the last one left off: ```js const kept = []; for (const v of data) { if (v >= 50) kept.push(v); // ← push() knows where the cursor is } ``` That cursor is the problem. Thread 7 can tell you instantly whether `data[7]` survives. It cannot tell you *where it goes*, because that depends on how many of elements 0…6 survived — six other threads' business, none of which thread 7 is allowed to ask about. An output position that depends on other threads is not something a single kernel can compute, which is why there is no `filter` kernel and never will be. So compaction gets built out of pieces, and the first piece is the one part that *is* perfectly independent: the **flag pass**. Turn the predicate into a mask of 1s and 0s — a plain map, one thread per element, nobody talking to anybody. Run it and look at what you get: the ones and zeros line up under the input, holes and all. Flags say *who* survives. They move nothing. ## Goal **Goal:** return `1` when this thread's sample is at or above `this.constants.threshold`, and `0` when it is not. ## Requirements - One thread per sample — `output: [64]`, no loops - Return exactly `1` or `0`, never the sample value - The predicate is *at or above*: a sample of exactly `50` survives ## Hint 1 — a predicate is just a map Read your own element and compare it — nothing else. The comparison `samples[this.thread.x] >= this.constants.threshold` is the whole decision; all that is left is turning it into a number. ## Hint 2 — the one-liner ```js return samples[this.thread.x] >= this.constants.threshold ? 1 : 0; ``` ## Same idea elsewhere Every compaction library on every platform starts here, and most of them let you hand the mask in yourself: CUB's `DeviceSelect::Flagged` takes a flags array beside the data, Thrust's `copy_if` takes the predicate and builds the same mask internally, and a WebGPU pipeline writes it to a storage buffer with one dispatch. The predicate pass is the cheap, embarrassingly parallel part — everything after it is the interesting problem. ## Starter code ```js // The flag pass: a map from "does this survive?" to 1 or 0. const gpu = new GPU({ mode }); const flag = gpu.createKernel(function (samples) { // TODO: return 1 when this thread's sample is at or above // this.constants.threshold, and 0 when it is not. return samples[this.thread.x]; }, { output: [64], constants: { threshold: 50 }, }); const flags = await flag(samples); console.log('samples:', samples.slice(0, 12).join(', ')); console.log('flags: ', Array.from(flags).slice(0, 12).join(', ')); console.log('same 64 slots, holes and all — a flag says WHO survives, not WHERE it goes.'); ``` --- Interactive version: https://gpu.rocks/learn/stream-compaction-0aed2e43/1 [Next task](https://gpu.rocks/learn/stream-compaction-0aed2e43/2.md) --- # Where Do I Land? *Task 2 of 5 · [Stream Compaction](https://gpu.rocks/learn/stream-compaction-0aed2e43.md) · GPU.js Learn* A survivor's output slot has a very short definition: **how many survivors are in front of me**. Element 9 with four survivors before it lands at index 4. That number, for every element at once, is the **exclusive prefix sum** — a *scan* — of the flags: cell `i` holds the total of flags `0 … i−1`, *not counting its own*. Scan is a whole subject of its own, and the Prefix Sums module derives the log-time ladder version properly. Sixty-four elements do not need it: every thread can simply walk the flags and count. That is `n` reads per thread — blunt, but perfectly parallel, and right now what matters is what the number *means*, not how fast you can get it. Exclusive, not inclusive. Count yourself and every survivor lands one slot too far, with the first one shoved off the front of the array. ## Figures - **flags say who survives; the scan under them says where each one lands** ## Goal **Goal:** for every index, return how many of the flags *strictly before* it are `1`. ## Requirements - Loop the full fixed range — `for (let i = 0; i < this.constants.n; i++)` - Add `flags[i]` only when `i` is strictly less than `this.thread.x` - Cell `0` is always `0`, whatever `flags[0]` says ## Hint 1 — "strictly before" The loop already visits every flag; it just needs to ignore the ones that are not in front of this thread. Guard the accumulation with `if (i < this.thread.x)` — note `<`, not `<=`. ## Hint 2 — the loop body ```js if (i < this.thread.x) { seen += flags[i]; } ``` ## Hint 3 — check it by hand For flags `[1, 0, 1, 1]` the destinations are `[0, 1, 1, 2]`: the survivor at index 0 goes to slot 0, the one at index 2 goes to slot 1, the one at index 3 goes to slot 2. Index 1 gets a number too — it just never uses it, because it does not survive. ## Same idea elsewhere Exclusive scan is one of the two or three primitives every GPU library is built on: `thrust::exclusive_scan`, CUB's `DeviceScan::ExclusiveSum`, rocPRIM's equivalent, and `subgroupExclusiveAdd` burned into WGSL and hardware. Blelloch's 1990 formulation gets it in O(n) work and O(log n) depth — the loop here is the honest, slow version of the same answer. ## Starter code ```js // The scan: every thread counts the survivors in front of it. const gpu = new GPU({ mode }); const destination = gpu.createKernel(function (flags) { let seen = 0; for (let i = 0; i < this.constants.n; i++) { // TODO: count only the flags STRICTLY BEFORE this thread's index. seen += flags[i]; } return seen; }, { output: [64], constants: { n: 64 }, }); const offsets = await destination(flags); console.log('flags: ', Array.from(flags).slice(0, 12).join(', ')); console.log('offsets: ', Array.from(offsets).slice(0, 12).join(', ')); ``` --- Interactive version: https://gpu.rocks/learn/stream-compaction-0aed2e43/2 [Previous task](https://gpu.rocks/learn/stream-compaction-0aed2e43/1.md) · [Next task](https://gpu.rocks/learn/stream-compaction-0aed2e43/3.md) --- # Turn the Scatter Around *Task 3 of 5 · [Stream Compaction](https://gpu.rocks/learn/stream-compaction-0aed2e43.md) · GPU.js Learn* You now have the two arrays a compaction needs: `flags`, and `offsets` — the exclusive scan that tells every survivor its slot. The obvious next line is the one you cannot write: ```js if (flags[i] === 1) out[offsets[i]] = samples[i]; // ✗ no scatter here ``` Thinking in Parallel spends a whole task on why: a thread writes one cell, its own, by returning a value. So turn the question inside out, exactly as it does. Not *"where does my value go?"* but *"whose value lands in **my** cell?"* — and output cell `j` can answer that itself. It goes looking for the index that is (a) a survivor and (b) carrying destination `j`. One thread, one pass over the flags, one value pulled home. The output array is still 64 long, because that is what a kernel launch gives you. Only the first few cells will hold survivors; the rest hold whatever each thread's search failed to find. That is fine, and normal — you just have to know where the real data stops, which is the last task of this module. ## Figures - **same arrows, opposite owner — and only one of the two is legal** ## Goal **Goal:** fill each output cell by searching for the element whose destination is this cell's index. ## Requirements - Loop over all `this.constants.n` elements — the only write is the `return` - Take `samples[i]` when `flags[i]` is `1` *and* `offsets[i]` is `this.thread.x` - The survivors come out packed, in input order, starting at cell `0` ## Hint 1 — ask the other question Thread `j` is not trying to place anything. It is trying to *find* something: the one index whose destination happens to be `j`. Keep a local `value`, overwrite it when the search hits, return it. ## Hint 2 — both halves of the condition Non-survivors have an `offsets` entry too — it just does not belong to them. So matching the offset alone is not enough; the flag has to be checked as well: ```js if (flags[i] === 1 && offsets[i] === this.thread.x) { value = samples[i]; } ``` ## Hint 3 — the whole body ```js let value = 0; for (let i = 0; i < this.constants.n; i++) { if (flags[i] === 1 && offsets[i] === this.thread.x) { value = samples[i]; } } return value; ``` ## Same idea elsewhere "Turn the scatter into a gather" is the phrase GPU folklore compresses this into, and it is exactly how the libraries do it: `thrust::copy_if` and CUB's `DeviceSelect` both run a flag pass, a scan, and then a data movement driven by the scan. Compute APIs *can* scatter — CUDA and WebGPU threads may store anywhere — but two threads aiming at one address is a race, and the fix (atomics) serialises them. Scan-then-gather costs no atomics at all. ## Starter code ```js // No scatter. Cell j goes looking for the element destined for j. const gpu = new GPU({ mode }); const compact = gpu.createKernel(function (samples, flags, offsets) { // TODO: search the whole array for the element whose destination is // this thread's index — a survivor (flags[i] === 1) whose offsets[i] // equals this.thread.x — and return its sample. return samples[this.thread.x]; }, { output: [64], constants: { n: 64 }, }); const packed = await compact(samples, flags, offsets); console.log('samples:', samples.slice(0, 12).join(', ')); console.log('packed: ', Array.from(packed).slice(0, 12).join(', ')); ``` --- Interactive version: https://gpu.rocks/learn/stream-compaction-0aed2e43/3 [Previous task](https://gpu.rocks/learn/stream-compaction-0aed2e43/2.md) · [Next task](https://gpu.rocks/learn/stream-compaction-0aed2e43/4.md) --- # Find It in log n *Task 4 of 5 · [Stream Compaction](https://gpu.rocks/learn/stream-compaction-0aed2e43.md) · GPU.js Learn* The search you just wrote reads all 64 flags per thread. Across 64 threads that is 4,096 reads to move 30-odd values — worse than the CPU's single pass. It works, and it is the right shape, but it throws away the one thing that makes the array searchable: `offsets` **never decreases**. Add the flag back to it and you get the **running count** — `offsets[i] + flags[i]`, how many survived up to and including `i`. It is non-decreasing too, and it steps up by exactly one at each survivor. So the element for output cell `j` is the *first index whose running count exceeds `j`*, and a sorted array is something you can binary-search: seven halvings settle 64 elements instead of 64 reads. This is a *lower bound* search — keep a window `[lo, hi)`, look at its midpoint, and throw away the half that cannot contain the answer. When the window is empty, `lo` is the index you wanted. ## Figures - **the running count only ever goes up, so you can halve your way to it** ## Goal **Goal:** replace the linear search with a binary search over the running count, and return the sample it lands on. ## Requirements - Keep a window `lo` … `hi`, starting at `0` and `this.constants.n` - Halve it `this.constants.steps` times, testing `offsets[mid] + flags[mid]` against `this.thread.x` - Return `samples[lo]` — clamped to the last index, because `lo` can finish at `n` ## Hint 1 — what you are searching for For output cell `j` you want the smallest index whose running count is **greater than** `j`. Greater than, not equal to: the running count reaches `j + 1` exactly at the survivor destined for slot `j`. ## Hint 2 — one halving If the midpoint's running count already exceeds `this.thread.x`, the answer is at `mid` or to its left, so `hi = mid`. Otherwise the answer is strictly to the right, so `lo = mid + 1`. Nothing else changes. ## Hint 3 — the whole body ```js let lo = 0; let hi = this.constants.n; for (let s = 0; s < this.constants.steps; s++) { if (lo < hi) { const mid = Math.floor((lo + hi) / 2); if (offsets[mid] + flags[mid] > this.thread.x) { hi = mid; } else { lo = mid + 1; } } } return samples[Math.min(lo, this.constants.n - 1)]; ``` The `if (lo < hi)` guard matters: the window can empty before the seventh halving, and a midpoint of an empty window is an out-of-bounds read. ## Same idea elsewhere Device-side binary search is a first-class primitive — `thrust::lower_bound`, CUB's `DeviceSelect` internals, and the merge-path partitioning that load-balances GPU merges and sparse-matrix products all lean on it. Production compactors often go one step further and build a *scatter-address table* instead: one pass writes each output slot's source index, a second gathers through it, trading a buffer for the search entirely. Same inversion, one more array. ## Starter code ```js // Same gather, log n reads: binary-search the running count. const gpu = new GPU({ mode }); const compact = gpu.createKernel(function (samples, flags, offsets) { let lo = 0; let hi = this.constants.n; for (let s = 0; s < this.constants.steps; s++) { if (lo < hi) { const mid = Math.floor((lo + hi) / 2); // TODO: compare the running count at mid — offsets[mid] + flags[mid] — // against this.thread.x, and throw away the half that cannot hold the // answer. One of the two branches has to move lo past mid. hi = mid; } } return samples[Math.min(lo, this.constants.n - 1)]; }, { output: [64], constants: { n: 64, steps: 7 }, }); const packed = await compact(samples, flags, offsets); console.log('samples:', samples.slice(0, 12).join(', ')); console.log('packed: ', Array.from(packed).slice(0, 12).join(', ')); ``` --- Interactive version: https://gpu.rocks/learn/stream-compaction-0aed2e43/4 [Previous task](https://gpu.rocks/learn/stream-compaction-0aed2e43/3.md) · [Next task](https://gpu.rocks/learn/stream-compaction-0aed2e43/5.md) --- # Payoff: How Many Survived *Task 5 of 5 · [Stream Compaction](https://gpu.rocks/learn/stream-compaction-0aed2e43.md) · GPU.js Learn* Three kernels, wired together: flags, then the scan, then the gather. All of it is below, finished — the pipeline is yours already. What is missing is the number that makes the result usable. The output is 64 cells long because the launch was 64 threads wide. Only the first **count** of them hold survivors; the rest hold whatever the search failed to find, and reading them as data is the classic way to ship a bug. So where does `count` come from? The end of the scan — but not from `offsets[63]` alone. An exclusive scan at the last index counts everyone *before* the last element, so the last element's own flag is still outstanding: ```js const count = offsets[n - 1] + flags[n - 1]; ``` Drop the `+ flags[n - 1]` and the pipeline quietly loses its final element, but only when that element happens to survive — which is exactly the kind of bug that passes every test you wrote by hand. And note what this number costs: it has to come back to JavaScript before anything can use it. That single readback is why compaction is the awkward step in an otherwise fully on-device pipeline. ## Goal **Goal:** compute the survivor count from the end of the scan, trim the packed output to it, and log both. ## Requirements - `count = offsets[63] + flags[63]` — the last offset *plus* the last flag - Trim the 64-cell output down to those `count` values - `console.log` the count on its own line, and the kept values as a list ## Hint 1 — the count lives at the end of the scan `offsets[63]` is "how many survived among elements 0…62". Element 63 is not in that total — its flag is. Add them. ## Hint 2 — trimming The kernel returns a `Float32Array`; turn it into a plain array and cut it at the count: ```js const kept = Array.from(packed).slice(0, count); ``` ## Hint 3 — check yourself `kept.length` should equal `count`, and every value in it should be at least 50. If the last one is missing, you dropped the `+ flags[63]`. ## Same idea elsewhere Every real compaction API hands the length back separately, and for the same reason: `thrust::copy_if` returns an end iterator, CUB's `DeviceSelect::Flagged` writes `d_num_selected_out` to device memory, and Vulkan/WebGPU pipelines that want to avoid the readback entirely feed that counter straight into an *indirect* dispatch or draw — the GPU deciding its own launch size from a number the CPU never sees. ## Starter code ```js // The finished pipeline: flags → scan → gather. Only the count is missing. const gpu = new GPU({ mode }); const flag = gpu.createKernel(function (samples) { return samples[this.thread.x] >= this.constants.threshold ? 1 : 0; }, { output: [64], constants: { threshold: 50 } }); const destination = gpu.createKernel(function (flags) { let seen = 0; for (let i = 0; i < this.constants.n; i++) { if (i < this.thread.x) { seen += flags[i]; } } return seen; }, { output: [64], constants: { n: 64 } }); // The linear search from task 3 — task 4's binary search drops straight in. const compact = gpu.createKernel(function (samples, flags, offsets) { let value = 0; for (let i = 0; i < this.constants.n; i++) { if (flags[i] === 1 && offsets[i] === this.thread.x) { value = samples[i]; } } return value; }, { output: [64], constants: { n: 64 } }); const flags = await flag(samples); const offsets = await destination(flags); const packed = await compact(samples, flags, offsets); // TODO: the scan stops one short — offsets[63] counts everyone BEFORE // element 63, so element 63's own flag is still missing from the total. const count = offsets[63]; // TODO: keep only the cells that actually hold survivors. const kept = Array.from(packed); console.log('survivors:', count); console.log('kept:', kept.join(', ')); ``` --- Interactive version: https://gpu.rocks/learn/stream-compaction-0aed2e43/5 [Previous task](https://gpu.rocks/learn/stream-compaction-0aed2e43/4.md) --- # Histograms & Binning *Module of the free GPU.js GPGPU course · 5 tasks* Counting values into bins with no atomics — the scatter that has to become a gather. ## Tasks 1. [The Increment That Vanishes](https://gpu.rocks/learn/histograms-and-binning-dfb254f4/1.md) 2. [One Thread Per Bin](https://gpu.rocks/learn/histograms-and-binning-dfb254f4/2.md) 3. [Where Does 7.35 Go?](https://gpu.rocks/learn/histograms-and-binning-dfb254f4/3.md) 4. [Partial Histograms, Then Merge](https://gpu.rocks/learn/histograms-and-binning-dfb254f4/4.md) 5. [Payoff: An Image's Tone Histogram](https://gpu.rocks/learn/histograms-and-binning-dfb254f4/5.md) --- Interactive version: https://gpu.rocks/learn/histograms-and-binning-dfb254f4 --- # The Increment That Vanishes *Task 1 of 5 · [Histograms & Binning](https://gpu.rocks/learn/histograms-and-binning-dfb254f4.md) · GPU.js Learn* On a CPU a histogram is three lines. Make an array of zeros, walk the data, add one to the bin each value belongs to. It is the friendliest loop in programming. ```js const bins = new Array(16).fill(0); for (let i = 0; i < data.length; i++) bins[data[i]]++; ``` Now run that loop on 4,096 threads at once. `bins[v]++` is not one operation, it is three — **read** bin *v*, **add** one, **write** bin *v* back. Two threads whose values land in the same bin both read 7, both compute 8, both write 8. Two increments went in; one came out. Nothing crashed and nothing warned — a count is just quietly too low, and differently too low every time you run it. This is not a gpu.js quirk. It is precisely why CUDA ships `atomicAdd`: the read-modify-write has to become indivisible, and making it indivisible means the colliding threads take turns. gpu.js hands you no atomics and no scatter at all — a thread writes one cell, its own — which forces the formulation that actually transfers: **invert the loop**. Stop asking "which bin does my value go to?" and start asking "which values belong to *my* bin?". Start with one bin. ## Figures - **nobody can increment your bin but you — so go and count it yourself** ## Goal **Goal:** make the single thread count how many of the 4,096 `codes` equal `this.constants.target`. ## Requirements - Keep `output: [1]` — one thread, one bin, one count - Loop `for (let i = 0; i < this.constants.n; i++)` over every code - Add **1** for each code equal to `this.constants.target` — the value itself is not what a histogram counts - Return the count; no shared array is touched anywhere ## Hint 1 — an accumulator, not an array The count lives in a local `let count = 0;` that only this thread can see. That is the whole reason there is nothing to race over: private variables cannot collide. ## Hint 2 — the loop body ```js if (codes[i] === this.constants.target) count++; ``` ## Same idea elsewhere Every compute API gives you the scatter this one withholds — and then charges for it. CUDA and HIP have `atomicAdd`, WGSL has `atomicAdd` on an `atomic` in a storage buffer, Metal has `atomic_fetch_add_explicit`. They are correct and they are not free: colliding threads serialize, and a histogram with one hot bin can reduce a whole warp to single file. The gather you are about to write is what the fast implementations fall back to when contention gets bad enough — which is why it is worth knowing even where atomics exist. ## Starter code ```js // One bin, one thread. Nothing is shared, so nothing can race. const gpu = new GPU({ mode }); const countBin = gpu.createKernel(function (codes) { // TODO: loop over all this.constants.n codes and count how many of // them equal this.constants.target. Add 1 per match — never the value. return 0; }, { output: [1], constants: { n: 4096, target: 5 }, }); console.log('codes equal to the target:', (await countBin(codes))[0]); ``` --- Interactive version: https://gpu.rocks/learn/histograms-and-binning-dfb254f4/1 [Next task](https://gpu.rocks/learn/histograms-and-binning-dfb254f4/2.md) --- # One Thread Per Bin *Task 2 of 5 · [Histograms & Binning](https://gpu.rocks/learn/histograms-and-binning-dfb254f4.md) · GPU.js Learn* Run the last task sixteen times over, once per bin, and you have the whole histogram. `output: [16]` launches sixteen threads; thread *x* owns bin *x*, scans the entire array, and counts the codes that belong to it. Nobody writes into anybody else's cell, so there is nothing left to race over. The scatter became a gather — the same move *Thinking in Parallel* makes, wearing its most useful disguise. Say the price out loud, because it is real: every one of the 16 threads reads all 4,096 codes, so this histogram costs **n × bins** reads where the CPU's cost **n**. You bought correctness with redundant work. On a GPU that is very often the right trade — the redundant reads run in parallel and hit cache, while the serialization an atomic costs does not parallelize at all — but it stops being the right trade as the bin count grows, and task 4 fixes the other end of it. One check catches almost every histogram bug ever written, so build the habit now: **the counts must sum to the number of inputs.** Every input belongs to exactly one bin, so 4,096 codes must produce counts totalling 4,096. Anything else means values are being dropped or double-counted, and the size of the gap usually tells you which. ## Goal **Goal:** produce all 16 counts in one kernel launch, then total them in plain JavaScript and log the total. ## Requirements - `output: [16]` — one thread per bin, no loop over the bins - Each thread scans all `this.constants.n` codes and counts only the ones equal to `this.thread.x` - Sum the 16 returned counts in ordinary JavaScript and `console.log` the total (it should come to `4096`) ## Hint 1 — which bin am I? `this.thread.x` is both this thread's output cell *and* the code it is counting. That coincidence is the entire kernel: thread 5 counts the 5s. ## Hint 2 — the loop body ```js if (codes[i] === this.thread.x) count++; ``` ## Hint 3 — the total A plain loop after the kernel call: ```js let total = 0; for (let b = 0; b < counts.length; b++) { total += counts[b]; } ``` If that is not 4096, stop and find out why before you trust a single bar. ## Same idea elsewhere "One thread per output bucket, each scanning the input" is the shape shaders used for histograms for years before compute shaders and atomics existed, and it is still what libraries fall back to when the bin count is small and contention would be brutal. The general lesson outlives the example: when a parallel algorithm wants to write where it cannot, re-derive it so each output owner reads what it needs. CUDA, WGSL and Metal all reward that reformulation even where they would have let you scatter. ## Starter code ```js // 16 threads, 16 bins. Thread x counts the codes equal to x. const gpu = new GPU({ mode }); const histogram = gpu.createKernel(function (codes) { let count = 0; for (let i = 0; i < this.constants.n; i++) { // TODO: only count this code when it belongs to THIS thread's bin. count++; } return count; }, { output: [16], constants: { n: 4096 }, }); const counts = await histogram(codes); console.log('counts:', counts); // TODO: total the 16 counts in plain JavaScript and log the total. // A correct histogram of 4096 codes sums to 4096 — anything else is a bug. ``` --- Interactive version: https://gpu.rocks/learn/histograms-and-binning-dfb254f4/2 [Previous task](https://gpu.rocks/learn/histograms-and-binning-dfb254f4/1.md) · [Next task](https://gpu.rocks/learn/histograms-and-binning-dfb254f4/3.md) --- # Where Does 7.35 Go? *Task 3 of 5 · [Histograms & Binning](https://gpu.rocks/learn/histograms-and-binning-dfb254f4.md) · GPU.js Learn* Real measurements are not tidy little category codes. `samples` holds 4,096 sensor readings spread over −32 … 32, and sixteen bins across that span makes each bin 4 units wide. Turning a reading into a bin index is one division and one floor: ```js // lo = -32, span = 64, bins = 16 const bin = Math.floor((v - lo) / span * bins); ``` Two details decide whether the histogram is right, and both of them are where real bugs live. First, a bin is **half-open**: bin 14 is `[24, 28)`, so 24 belongs to it and 28 belongs to bin 15. `Math.floor` gets that for free — which is exactly why the index is floored and not rounded. Second, the **top edge**. A reading exactly equal to the maximum maps to `(32 − −32) / 64 × 16 = 16` — bin 16, one past the last thread, owned by nobody. Four samples here sit exactly on it, and without a clamp all four silently stop existing: the counts come to 4,092 instead of 4,096. Clamp the index with `Math.min(bins − 1, …)` and they land in the last real bin, which is what closes that bin at the top. Every sample here is inside the range, so the clamp only ever has to catch the maximum. When data really *can* fall outside the range, clamping quietly piles the outliers into the end bins and the total will not say a word about it — so that becomes a decision to make on purpose: clamp them in, or drop them out. (And when the range comes from the data rather than from you, a min and a max reduction is where it comes from.) ## Figures - **bins are half-open, and the last one only closes because you clamped it** ## Goal **Goal:** histogram the 4,096 `samples` into 16 bins with a clamped index, so the counts total 4,096 — and log that total. ## Requirements - Map each sample with `(v − this.constants.lo) / this.constants.span * this.constants.bins`, floored - Clamp the index to `this.constants.bins - 1` so the maximum lands in the last bin instead of falling out - Count a sample only when its bin equals `this.thread.x` - `console.log` the total of the 16 counts — it must be `4096` ## Hint 1 — run it first The starter already computes an unclamped index and already totals the counts. Run it: the total comes out 4,092. Four readings went into a bin that does not exist. That gap is the whole task. ## Hint 2 — the clamp ```js const bin = Math.min(this.constants.bins - 1, Math.floor(raw)); ``` — and nothing else changes. ## Hint 3 — why floor and not round `Math.round` looks harmless and moves every reading that is more than half way through its bin into the next one — a histogram shifted by half a bin, with the right total. The total will not catch that one; only knowing the rule will. ## Same idea elsewhere Quantizing a continuous value into an integer index is everywhere in GPU work: picking a mip level, hashing a particle into a spatial grid cell, indexing a lookup table, choosing a colour ramp entry. Every platform ships the clamp as a primitive — `clamp()` in GLSL, WGSL and MSL, `__saturatef` and clamped texture address modes in CUDA — because the same off-by-one at the top edge has bitten everybody. NVIDIA's own histogram samples clamp for exactly this reason. ## Starter code ```js // 16 bins over -32 ... 32, so every bin is 4 units wide. const gpu = new GPU({ mode }); const histogram = gpu.createKernel(function (samples) { let count = 0; for (let i = 0; i < this.constants.n; i++) { const raw = (samples[i] - this.constants.lo) / this.constants.span * this.constants.bins; // TODO: floor alone sends a sample equal to the maximum to bin 16, // which no thread owns. Clamp the index to this.constants.bins - 1. const bin = Math.floor(raw); if (bin === this.thread.x) count++; } return count; }, { output: [16], constants: { n: 4096, bins: 16, lo: -32, span: 64 }, }); const counts = await histogram(samples); console.log('counts:', counts); let total = 0; for (let b = 0; b < counts.length; b++) total += counts[b]; console.log('total:', total); // must be 4096, and right now it is not ``` --- Interactive version: https://gpu.rocks/learn/histograms-and-binning-dfb254f4/3 [Previous task](https://gpu.rocks/learn/histograms-and-binning-dfb254f4/2.md) · [Next task](https://gpu.rocks/learn/histograms-and-binning-dfb254f4/4.md) --- # Partial Histograms, Then Merge *Task 4 of 5 · [Histograms & Binning](https://gpu.rocks/learn/histograms-and-binning-dfb254f4.md) · GPU.js Learn* Sixteen bins is sixteen threads. A GPU with thousands of cores just sat out that entire kernel — and each of those sixteen threads had to walk all 16,384 codes by itself. Few bins over lots of data is exactly where one-thread-per-bin runs out of parallelism. So cut the data into chunks and give every *(bin, chunk)* pair its own thread. Thirty-two chunks of 512 codes turns 16 threads into 16 × 32 = 512, each scanning 512 codes instead of 16,384. What comes back is a grid of **partial histograms**: one row per chunk, one column per bin. A second pass then adds up each bin's column. Mind the shape. `output: [bins, chunks]` is given width-first but indexed row-first, so the grid you get back is `partial[chunk][bin]` — swap those two and you read off the end of a row. Pass two sums a column of 32 numbers, which one loop handles comfortably; at 4,096 chunks you would ride the halving ladder from *Reductions* down instead, because that is the same reduction wearing a different hat. Keep watching the total — but do not over-trust it here. If every chunk reads chunk 0's codes, the counts are entirely wrong and still sum to 16,384. The total catches lost and duplicated inputs; it cannot catch inputs you counted the wrong number of times each. ## Figures - **one row per chunk, one column per bin, one reduction down each column** ## Goal **Goal:** build the 16 × 32 grid of partial histograms in one kernel, then merge it into 16 final counts in a second. ## Requirements - `partials`: `output: [16, 32]`, thread `(x = bin, y = chunk)` counts chunk *y*'s codes that equal *x* - Chunk *y* is contiguous: it starts at `this.thread.y * this.constants.chunkSize` - `merge`: `output: [16]`, thread *x* sums `partial[c][this.thread.x]` over all `this.constants.chunks` chunks - The merged counts sum to `16384` ## Hint 1 — where does my chunk start? Chunk *y* owns the 512 codes from `y * 512` to `y * 512 + 511`, so its *i*-th code is at `this.thread.y * this.constants.chunkSize + i`. The starter is missing that offset, which is why every chunk currently reports chunk 0's histogram. ## Hint 2 — the partials kernel ```js const code = codes[this.thread.y * this.constants.chunkSize + i]; if (code === this.thread.x) count++; ``` ## Hint 3 — the merge One thread per bin, walking down that bin's column of the grid: ```js let total = 0; for (let c = 0; c < this.constants.chunks; c++) { total += partial[c][this.thread.x]; } return total; ``` ## Same idea elsewhere This is what a production GPU histogram actually does, and the reason is the same one: parallelism. A CUDA kernel gives each *block* a private histogram in shared memory, so its `atomicAdd`s stay on-chip and only conflict within the block, then spends one global `atomicAdd` per bin to merge. WGSL does it with a `var` array of atomics and a single merge at the end; CUB and rocPRIM's `DeviceHistogram` are this structure, tuned. Private partials plus a merge pass is the pattern — gpu.js just makes you write the merge as an honest reduction instead of hiding it behind an atomic. ## Starter code ```js // Pass 1: one thread per (bin, chunk). Pass 2: merge each bin's column. const gpu = new GPU({ mode }); const partials = gpu.createKernel(function (codes) { let count = 0; for (let i = 0; i < this.constants.chunkSize; i++) { // TODO: every chunk is reading chunk 0 right now. Chunk this.thread.y // starts at this.thread.y * this.constants.chunkSize. if (codes[i] === this.thread.x) count++; } return count; }, { output: [16, 32], constants: { chunkSize: 512 }, }); const merge = gpu.createKernel(function (partial) { // TODO: add up all this.constants.chunks partial counts for THIS // thread's bin. The grid is indexed partial[chunk][bin]. return partial[0][this.thread.x]; }, { output: [16], constants: { chunks: 32 }, }); const grid = await partials(codes); const counts = await merge(grid); console.log('counts:', counts); ``` --- Interactive version: https://gpu.rocks/learn/histograms-and-binning-dfb254f4/4 [Previous task](https://gpu.rocks/learn/histograms-and-binning-dfb254f4/3.md) · [Next task](https://gpu.rocks/learn/histograms-and-binning-dfb254f4/5.md) --- # Payoff: An Image's Tone Histogram *Task 5 of 5 · [Histograms & Binning](https://gpu.rocks/learn/histograms-and-binning-dfb254f4.md) · GPU.js Learn* The payoff, and the histogram everybody has actually seen: an image's **tone histogram** — how many pixels are dark, how many mid, how many bright. Every photo editor draws one, because it tells you a shot is underexposed before your eyes do. Two kernels, and the reason for two is worth a sentence. Luminance is a per-pixel calculation and there are 4,096 pixels — but there are 32 bins, so a single histogram kernel would recompute every pixel's luminance *32 times over*, once per bin thread. Compute it once into a 64 × 64 map, then histogram the map. Map first, bin second; the map pass is 4,096 luminance evaluations instead of 131,072. Luminance runs 0 … 1, so 32 bins over that range is a bin every 0.03125 — the same clamped `floor` as task 3, with `lo = 0` and `span = 1` doing nothing visible. And the same smoke alarm: 4,096 pixels in, 4,096 counted out. **Array layout in gpu.js** Image data comes in row-major: `image[y][x]` is the pixel in row *y*, column *x*, and each pixel is an `[r, g, b, a]` array with channels from 0 to 1. Mind the inversion that catches everyone — sizes are given width-first (`output: [width, height]`), but indexing runs row-first, so this thread's own pixel is `image[this.thread.y][this.thread.x]`. Swap those two and you read the transpose of your image. Three-dimensional data follows the same rule: `output: [w, h, d]` is indexed `[z][y][x]`. ## Goal **Goal:** compute a 64 × 64 luminance map of `photo`, histogram it into 32 tone bins, and log the total. ## Requirements - `luminance`: `output: [64, 64]`, each cell `0.299r + 0.587g + 0.114b` of that pixel - `histogram`: `output: [32]`, each thread scans the whole map - Bin with the clamped index from task 3: `Math.min(bins - 1, Math.floor(l * bins))` - `console.log` the total of the 32 counts — it must be `4096` ## Hint 1 — the map pass Straight out of any grayscale kernel — read this thread's pixel and return a number instead of painting it: ```js const pixel = photo[this.thread.y][this.thread.x]; return 0.299 * pixel[0] + 0.587 * pixel[1] + 0.114 * pixel[2]; ``` ## Hint 2 — scanning a 2D map from a 1D kernel The histogram kernel has 32 threads and a 64 × 64 map, so each thread runs two nested loops over the map. Both bounds are constants, which is what the WebGL backend needs: ```js for (let y = 0; y < this.constants.size; y++) { for (let x = 0; x < this.constants.size; x++) { const bin = Math.min( this.constants.bins - 1, Math.floor(map[y][x] * this.constants.bins) ); if (bin === this.thread.x) count++; } } ``` ## Hint 3 — read the shape of the answer Once it runs, look at the counts: the first bins and the last bins are empty. This image never gets truly black or truly white — which is precisely the thing a tone histogram exists to tell you. ## Same idea elsewhere Tone histograms are load-bearing infrastructure, not a readout: auto-exposure, auto-contrast and histogram equalization all start here, and phone ISPs compute one in fixed function hardware on every frame. The two-pass shape generalizes past images — derive the quantity once into a buffer, then bin the buffer — and it is the same reason CUDA and WebGPU pipelines materialize an intermediate rather than recomputing inside an inner loop. Turning these counts into a cumulative curve (the next step of equalization) is a prefix sum, which is the one parallel primitive this module does not need. ## Starter code ```js // Map first (one luminance per pixel), bin second (one thread per bin). const gpu = new GPU({ mode }); const luminance = gpu.createKernel(function (photo) { const pixel = photo[this.thread.y][this.thread.x]; // TODO: return perceptual luminance — 0.299 R + 0.587 G + 0.114 B return pixel[0]; }, { output: [64, 64] }); const histogram = gpu.createKernel(function (map) { let count = 0; for (let y = 0; y < this.constants.size; y++) { for (let x = 0; x < this.constants.size; x++) { // TODO: bin map[y][x] into 0 ... bins - 1 with a clamped floor, // and count it only when that bin is this thread's own. count++; } } return count; }, { output: [32], constants: { size: 64, bins: 32 }, }); const map = await luminance(photo); const counts = await histogram(map); console.log('counts:', counts); // TODO: total the counts and log the total. 4096 pixels in, 4096 counted. ``` --- Interactive version: https://gpu.rocks/learn/histograms-and-binning-dfb254f4/5 [Previous task](https://gpu.rocks/learn/histograms-and-binning-dfb254f4/4.md) --- # Top-K Selection *Module of the free GPU.js GPGPU course · 5 tasks* The ten largest of a million values: rank by counting, gather the winners, or bisect for a cutoff — and when each one wins. ## Tasks 1. [Rank by Counting](https://gpu.rocks/learn/top-k-selection-1ba56df3/1.md) 2. [Gather the Winners](https://gpu.rocks/learn/top-k-selection-1ba56df3/2.md) 3. [The Brightest Pixels](https://gpu.rocks/learn/top-k-selection-1ba56df3/3.md) 4. [Find the Cutoff Instead](https://gpu.rocks/learn/top-k-selection-1ba56df3/4.md) 5. [Which One Wins?](https://gpu.rocks/learn/top-k-selection-1ba56df3/5.md) --- Interactive version: https://gpu.rocks/learn/top-k-selection-1ba56df3 --- # Rank by Counting *Task 1 of 5 · [Top-K Selection](https://gpu.rocks/learn/top-k-selection-1ba56df3.md) · GPU.js Learn* "Give me the ten largest of these four thousand scores." On a CPU you keep a heap of ten and walk the data once — and that plan does not port, because the heap's contents after element *i* depend on every element before it. Serial by construction. So ask a question every element can answer *alone*: **how many scores beat me?** That count is the element's **rank**, rank 0 means nothing beats it, and anything with a rank below `k` is in the top `k`. No sorting, no shared state, one thread per element — each of them reading the whole array, which makes this O(n²) work and gloriously parallel. Ties are where it bites. Two equal scores each counting the other come back with the *same* rank: two elements claim one slot, and the slot after it is claimed by nobody. The fix is a **tie-break on the index** — an element earlier in the array outranks you when the scores are equal, a later one does not. That turns the ranks into a permutation of 0…4095, exactly one element per slot. These scores repeat constantly, so you will feel it immediately. ## Figures - **a rank is a count, and a count is something every element can do alone** — Eight scores in a row. One of them, highlighted, counts the scores that outrank it: two strictly larger scores count, and an equal score at a lower index counts, while an equal score at a higher index does not. The total, three, is its rank and its output slot. ## Goal **Goal:** return, for each element, the number of scores that outrank it — strictly larger anywhere, or *equal at a lower index*. ## Requirements - One thread per score: `output: [4096]`, loop bound `this.constants.n` - A strictly larger score always counts - An equal score counts only when its index is below `this.thread.x` - The largest score must come back with rank `0` ## Hint 1 — one loop, two comparisons Split on the index, not on the value. For `j < this.thread.x` an equal score wins, so that side tests `>=`; for every other `j` an equal score loses, so that side tests `>`. ## Hint 2 — the loop body ```js const other = scores[j]; if (j < this.thread.x) { if (other >= mine) ahead++; } else if (other > mine) { ahead++; } ``` ## Hint 3 — checking yourself Every rank from 0 to 4095 should appear exactly *once*. If two elements share a rank, then somewhere a `>` is doing a `>=`'s job (or the other way round). ## Same idea elsewhere Counting ranks is how a GPU sorts small things — it is the first sort in every CUDA and WebGPU tutorial, and the reason CUB's `DeviceRadixSort` and bitonic networks exist is that O(n²) stops being free somewhere above a few thousand elements. The index tie-break is what makes such a sort *stable*, the same guarantee `thrust::stable_sort` and `std::stable_sort` sell. ## Starter code ```js // One thread per score. Each one asks: how many scores beat mine? const gpu = new GPU({ mode }); const rankScores = gpu.createKernel(function (scores) { const mine = scores[this.thread.x]; let ahead = 0; for (let j = 0; j < this.constants.n; j++) { // TODO: this counts every element. Count scores[j] only when it // outranks mine — strictly larger, or equal with j below // this.thread.x. ahead++; } return ahead; }, { output: [4096], constants: { n: 4096 }, }); const ranks = await rankScores(scores); console.log('rank of element 0:', ranks[0], '(its score is', scores[0] + ')'); ``` --- Interactive version: https://gpu.rocks/learn/top-k-selection-1ba56df3/1 [Next task](https://gpu.rocks/learn/top-k-selection-1ba56df3/2.md) --- # Gather the Winners *Task 2 of 5 · [Top-K Selection](https://gpu.rocks/learn/top-k-selection-1ba56df3.md) · GPU.js Learn* Ranks are the answer in an unusable shape: the ten you want are scattered somewhere among 4,096 slots. What you want is packed — `top[0]` the biggest score, `top[9]` the tenth biggest. The obvious move is a **scatter**: element `i` writes itself into `top[ranks[i]]`. Kernels cannot do that — a thread writes one cell, its own. So turn it inside out, the way every scatter gets turned inside out. Instead of "where does my value go?", output slot `j` asks **"who has rank `j`?"** and goes looking. Ten threads, each scanning 4,096 ranks: a gather. Exactly one element answers each slot — which is what last task's tie-break bought you. (Turning a rank array into a packed result is a pattern in its own right, and the Stream Compaction module develops it properly, with the prefix sum that makes it O(n) instead of O(k·n). You do not need that here: `k` is ten.) ## Goal **Goal:** fill ten output slots with the ten largest scores, largest first — slot `j` holds the score of the element whose rank is `j`. ## Requirements - `output: [10]` — one thread per result slot - Scan all `this.constants.n` ranks for the one equal to `this.thread.x` - Return that element's *score*, not its rank - `top[0]` is the largest score and `top[9]` the tenth largest ## Hint 1 — which element is mine? Thread `j` owns output slot `j`, and the element it wants is the one whose rank happens to be `j`. There is no way to know where that element sits, so look at all of them — a loop over the whole `ranks` array. ## Hint 2 — the scan ```js let best = 0; for (let i = 0; i < this.constants.n; i++) { if (ranks[i] === this.thread.x) best = scores[i]; } return best; ``` No `break` needed — exactly one `i` matches. ## Same idea elsewhere Gather-by-rank is the back half of every GPU sort: compute a destination for each element, then have each destination fetch its element — `thrust::gather`, `cub::DeviceRadixSort`'s final scatter pass, a WebGPU compute shader indexing a storage buffer. Production k-selection libraries (FAISS, RAFT's `select_k`) do exactly this once the candidates are down to a manageable few. ## Starter code ```js // Ranks in, a packed top-10 out. Slot j goes looking for rank j. const gpu = new GPU({ mode }); // Last task's kernel, unchanged. const rankScores = gpu.createKernel(function (scores) { const mine = scores[this.thread.x]; let ahead = 0; for (let j = 0; j < this.constants.n; j++) { const other = scores[j]; if (j < this.thread.x) { if (other >= mine) ahead++; } else if (other > mine) { ahead++; } } return ahead; }, { output: [4096], constants: { n: 4096 } }); const pickTop = gpu.createKernel(function (scores, ranks) { let best = 0; for (let i = 0; i < this.constants.n; i++) { // TODO: every slot is fetching rank 0. Slot this.thread.x wants // the element whose rank is this.thread.x. if (ranks[i] === 0) best = scores[i]; } return best; }, { output: [10], constants: { n: 4096 } }); const ranks = await rankScores(scores); const top = await pickTop(scores, ranks); console.log('top 10:', top); ``` --- Interactive version: https://gpu.rocks/learn/top-k-selection-1ba56df3/2 [Previous task](https://gpu.rocks/learn/top-k-selection-1ba56df3/1.md) · [Next task](https://gpu.rocks/learn/top-k-selection-1ba56df3/3.md) --- # The Brightest Pixels *Task 3 of 5 · [Top-K Selection](https://gpu.rocks/learn/top-k-selection-1ba56df3.md) · GPU.js Learn* The same two moves, one dimension up. `brightness` is a 64×64 grid — a sensor frame, a heat map, a saliency map — and the question is which eight cells are brightest. Every thread now scans the grid with two loops instead of one, and "earlier in the array" means earlier in **row-major order**: the flat index of cell `[y][x]` is `y * 64 + x`. (Mind the inversion that catches everyone: the launch shape is given width-first, `output: [64, 64]`, but indexing runs row-first, `grid[this.thread.y][this.thread.x]`.) The other change is what comes back. The eight brightest *values* are rarely what anyone wants — you want to know *where* they are. So the picker returns the flat index rather than the value, and JavaScript decodes it: `y = Math.floor(idx / 64)`, `x = idx % 64`. Carry the index and you can always look the value back up; carry the value and the location is gone for good. ## Goal **Goal:** rank all 4,096 cells, then return the **flat indices** of the eight brightest, brightest first. ## Requirements - The ranking kernel is 2D — `output: [64, 64]`, two loops over the whole grid - Tie-break on the flat index `y * 64 + x`: an earlier cell wins a tie - The picker returns the flat *index* of the cell whose rank is `this.thread.x` - The brightest cell's value, row and column are logged (already wired up) ## Hint 1 — the same rule, flattened Compute your own flat index once, before the loops: ```js const myIndex = this.thread.y * this.constants.size + this.thread.x; ``` Then compare each visited cell's flat index against it — that is exactly the `j < this.thread.x` test from task 1, in two dimensions. ## Hint 2 — the ranking body ```js const other = grid[y][x]; if (y * this.constants.size + x < myIndex) { if (other >= mine) ahead++; } else if (other > mine) { ahead++; } ``` ## Hint 3 — returning a location Track the coordinates as you scan and combine them at the end, so nothing has to be pulled apart again: ```js if (ranks[y][x] === this.thread.x) { foundY = y; foundX = x; } ``` then `return foundY * this.constants.size + foundX;` ## Same idea elsewhere Finding the brightest few cells of a grid is the last step of a stack of real pipelines: keypoint detection (SIFT/ORB pick local maxima of a response map), object detectors ranking anchor boxes before non-max suppression, astronomy source extraction. They all rank on the device and hand back *indices*, because the payload behind an index is usually far bigger than a float — the same reason CUDA's `cub::ArgMax` returns a `KeyValuePair` rather than a value. ## Starter code ```js // Top-8 over a grid — and what comes back is WHERE, not what. const gpu = new GPU({ mode }); const rankCells = gpu.createKernel(function (grid) { const mine = grid[this.thread.y][this.thread.x]; const myIndex = this.thread.y * this.constants.size + this.thread.x; let ahead = 0; for (let y = 0; y < this.constants.size; y++) { for (let x = 0; x < this.constants.size; x++) { // TODO: this counts every cell. Count grid[y][x] only when it // outranks mine — brighter anywhere, or equally bright at a // lower flat index than myIndex. ahead++; } } return ahead; }, { output: [64, 64], constants: { size: 64 } }); const pickBrightest = gpu.createKernel(function (ranks) { let foundY = 0; let foundX = 0; for (let y = 0; y < this.constants.size; y++) { for (let x = 0; x < this.constants.size; x++) { // TODO: every slot is fetching rank 0. Slot this.thread.x wants // the cell whose rank is this.thread.x. if (ranks[y][x] === 0) { foundY = y; foundX = x; } } } return foundY * this.constants.size + foundX; }, { output: [8], constants: { size: 64 } }); const ranks = await rankCells(brightness); const spots = await pickBrightest(ranks); // Flat index back to a location — this part is plain JavaScript. const row = Math.floor(spots[0] / 64); const col = spots[0] % 64; console.log('brightest:', brightness[row][col], 'at row', row, 'col', col); console.log('all eight (flat indices):', spots); ``` --- Interactive version: https://gpu.rocks/learn/top-k-selection-1ba56df3/3 [Previous task](https://gpu.rocks/learn/top-k-selection-1ba56df3/2.md) · [Next task](https://gpu.rocks/learn/top-k-selection-1ba56df3/4.md) --- # Find the Cutoff Instead *Task 4 of 5 · [Top-K Selection](https://gpu.rocks/learn/top-k-selection-1ba56df3.md) · GPU.js Learn* O(n²) is fine at four thousand and hopeless at four million — ranking a million scores against each other is 10¹² comparisons. Production top-k does something else entirely: it goes looking for a **threshold**. Find a value `t` that exactly `k` scores exceed, and the top `k` is simply "everything above `t`". Counting how many scores clear a given `t` is *one linear pass*, and the whole problem collapses into a handful of them. Finding `t` is a **bisection on the value axis**. Bracket it: below `lo` at least `k` scores pass, above `hi` fewer than `k` do. Guess the middle, count, and throw away the half that cannot contain the answer. Eighteen halvings later the bracket is narrower than the gap between two whole numbers, and `Math.floor(lo)` is the cutoff. Each count is 65,536 elements shared across 256 threads — the same strided walk a reduction uses, where neighbouring threads read neighbouring elements. One condition, and it is a real one: the `k`-th and (`k`+1)-th scores must *differ*. If they are equal — which is exactly what task 1's data looked like, where the 10th and 11th scores were both 993 — then no threshold on earth separates them and you are back to the index tie-break. These scores are finer-grained on purpose. ## Figures - **eighteen guesses, each one a single counting pass, instead of four billion comparisons** — A bisection on the value axis. Each step marks a guess in the middle of the live bracket and labels it with how many scores exceed it — 197, then 11, then 2. The half that cannot contain the cutoff is discarded each time, until the bracket is narrow enough that exactly ten scores clear it. ## Goal **Goal:** count in parallel, bisect in JavaScript, and log the cutoff that exactly 10 of the 65,536 scores clear. ## Requirements - The kernel counts a strided slice: element `i` of thread `x` is `values[i * 256 + x]`, and it is counted when it is *strictly above* `t` - Total the 256 partial counts in plain JavaScript - Bisect: `count >= k` raises `lo` to `mid`, otherwise `hi` comes down to it - Stop once `hi - lo` is 0.5 or less, then log `Math.floor(lo)` and how many scores clear it ## Hint 1 — the counting pass It is a strided partial sum with a comparison in front of it: ```js if (values[i * this.constants.threads + this.thread.x] > t) hits++; ``` Thread `x` walks `values[x]`, `values[x + 256]`, `values[x + 512]`, … so neighbouring threads touch neighbouring elements at every step. ## Hint 2 — which half survives Keep the invariant in your head: *at least `k` scores are above `lo`, fewer than `k` are above `hi`*. So if the middle still lets `k` or more through, the cutoff is at or above it — raise `lo`. If it lets fewer through, the middle is too high — lower `hi`. Note the `>=`: with `>` the bracket keeps a value that `k + 1` scores clear. ## Hint 3 — the whole driver ```js while (hi - lo > 0.5) { const mid = (lo + hi) / 2; if (total(await countAbove(scores, mid)) >= K) lo = mid; else hi = mid; } const cutoff = Math.floor(lo); ``` The scores are whole numbers, so once the bracket is narrower than 1 there is nothing left to resolve. ## Same idea elsewhere Narrowing a value range with counting passes instead of sorting is what real device-side k-selection does: RAFT/cuML's `select_k`, FAISS's GPU k-selection and CUB's radix-select all count elements into buckets and recurse into the bucket that contains the boundary — a radix bisection rather than a binary one, but the same idea, and the same reason. Sorting a million things to look at ten of them is a bad trade on every platform. ## Starter code ```js // A cutoff, not a ranking: 18 linear passes instead of 4 billion comparisons. const gpu = new GPU({ mode }); const K = 10; const countAbove = gpu.createKernel(function (values, t) { let hits = 0; for (let i = 0; i < this.constants.chunk; i++) { // TODO: count this thread's strided element when it is above t } return hits; }, { output: [256], constants: { threads: 256, chunk: 256 }, }); function total(partials) { let sum = 0; for (let i = 0; i < partials.length; i++) sum += partials[i]; return sum; } // Bracket the answer: everything is above lo, nothing is above hi. let lo = scores[0]; let hi = scores[0]; for (let i = 1; i < scores.length; i++) { if (scores[i] < lo) lo = scores[i]; if (scores[i] > hi) hi = scores[i]; } lo = lo - 1; // TODO: halve the bracket until it is narrower than 1, keeping the half // that can still contain the cutoff. const cutoff = Math.floor(lo); console.log('cutoff:', cutoff); console.log('above it:', total(await countAbove(scores, cutoff))); ``` --- Interactive version: https://gpu.rocks/learn/top-k-selection-1ba56df3/4 [Previous task](https://gpu.rocks/learn/top-k-selection-1ba56df3/3.md) · [Next task](https://gpu.rocks/learn/top-k-selection-1ba56df3/5.md) --- # Which One Wins? *Task 5 of 5 · [Top-K Selection](https://gpu.rocks/learn/top-k-selection-1ba56df3.md) · GPU.js Learn* Two formulations, one answer, and a price that depends on `n`. Both are wired up below, both report the same thing so the comparison is honest — the score at the boundary — and both run **twice**: once on 4,096 scores, once on 131,072. Rank-by-counting reads 4,096² ≈ 16.8 **million** values at the small size and 17.2 **billion** at the large one. The bisection reads 4,096 values eighteen times (about 74,000) and 131,072 eighteen times (about 2.4 million). Between the two sizes one of those grows 1,024×, the other 32×. So run it, and watch the winner change. At 4,096 the ranking pass *wins*, despite doing two hundred times the arithmetic: it is one dense, embarrassingly parallel launch, which is precisely what the hardware is for, while the bisection spends its life waiting for eighteen tiny kernels to come back — latency, not arithmetic. At 131,072 the arithmetic finally outgrows the latency and the order flips. On the machine this was written on (an M1 Max) the small size measures about **1 ms** for the ranking against **10 ms** for the bisection, and the large one about **48 ms** against **15 ms**: 32× the data costs the bisection five milliseconds, because eighteen round trips are eighteen round trips whatever they carry, and costs the ranking pass everything. The crossover sits near 65,000, where the two trade places from run to run — and it will not sit there on your hardware, which is the point. Every kernel gets one untimed warm-up call before the clock starts: a kernel's first launch compiles it, and a shader compiler inside the timer measures nothing you asked about. Even so, one `performance.now()` sample is a shape, not a benchmark. Read the four lines, then read them again with **Mode** switched from Auto to CPU — there the ranking pass loses at 4,096 already (about 50 ms against 0.2 ms), and at 131,072 it is not run at all, because a minute of single-threaded counting is the same lesson in a harsher form. **⏱ Benchmark** answers a different question, and on this task it answers it badly — which is worth seeing once. It runs the whole file twice, once per backend, and on the CPU backend the file skips the big ranking pass; so it times a smaller job on one side and reports something near **1×**. That number means "the CPU backend got out of the work", not "the GPU is not helping" — timing two things that are not the same thing is the oldest way to get a benchmark wrong, and it is exactly what the four lines above go out of their way to avoid. ## Goal **Goal:** write one `bisect()` driver and one `boundaryOf()` scan, use them at both sizes, and read off the four timings. ## Requirements - One `bisect(counter, values, k)` serving both the 4,096 and the 131,072 case - It brackets from the data, halves while `hi - lo > 0.5`, and returns `Math.floor(lo)` - One `boundaryOf(ranks, values)` for the ranking side: the score of the element whose rank is `K - 1`, over flat ranks so the same scan serves the grid - Every measurement the backend can afford runs and logs a time — four on the GPU, three on the CPU, where the 131,072-score ranking pass is reported instead of run (already written) ## Hint 1 — one driver, two counters `bisect` never mentions a size: it takes the counting kernel as an argument and gets its bracket from the values it was handed. That is why the same four lines serve 4,096 scores and 131,072. ## Hint 2 — the boundary from ranks The `K`-th largest score is the one whose rank is `K - 1`. One plain loop over the ranks finds it: ```js for (let i = 0; i < ranks.length; i++) { if (ranks[i] === K - 1) cut = values[i]; } ``` The big ranking pass hands back a 512 × 256 grid, so the driver flattens it first (`utils.flatten`) — flat rank `i` then belongs to `values[i]` in both cases. ## Hint 3 — reading the numbers The two cutoffs at a size will not be the same number, and they should not be: the ranking pass reports the 10th largest *score*, the bisection reports the largest whole number strictly below it. Both describe the same boundary — exactly ten scores are above the bisection's cutoff, and the tenth of them is the ranking pass's answer. Then compare the two *times* at 4,096 against the two at 131,072. The bisection barely notices the 32× more data; the ranking pass notices it 1,024 times over. ## Same idea elsewhere Picking a formulation by measurement rather than by asymptotics is the whole job. CUB ships several k-selection strategies and dispatches on size; cuDNN and cuBLAS carry multiple kernels per operation and choose at runtime; PyTorch's `topk` switches between a sorting path and a radix-select path on `k` and `n`. The crossovers are found the way you just found this one — by running both on both sides of it, warm, and reading the clock. ## Starter code ```js // Same question, two formulations, two sizes. Time them, then ⏱ Benchmark. const gpu = new GPU({ mode }); const K = 10; // --- approach A: rank everything (tasks 1-2), at both sizes const rankSmall = gpu.createKernel(function (values) { const mine = values[this.thread.x]; let ahead = 0; for (let j = 0; j < this.constants.n; j++) { const other = values[j]; if (j < this.thread.x) { if (other >= mine) ahead++; } else if (other > mine) { ahead++; } } return ahead; }, { output: [4096], constants: { n: 4096 } }); // The same pass on 131,072 scores. A launch that wide is a 2D texture // underneath whatever you call it, so this one says so — a 512 x 256 // grid, ranked by the flat index y * 512 + x, exactly like task 3. const rankBig = gpu.createKernel(function (values) { const me = this.thread.y * this.constants.width + this.thread.x; const mine = values[me]; let ahead = 0; for (let j = 0; j < this.constants.n; j++) { const other = values[j]; if (j < me) { if (other >= mine) ahead++; } else if (other > mine) { ahead++; } } return ahead; }, { output: [512, 256], constants: { n: 131072, width: 512 } }); // --- approach B: bisect for a cutoff (task 4), at both sizes const countSmall = gpu.createKernel(function (values, t) { let hits = 0; for (let i = 0; i < this.constants.chunk; i++) { if (values[i * this.constants.threads + this.thread.x] > t) hits++; } return hits; }, { output: [64], constants: { threads: 64, chunk: 64 } }); const countBig = gpu.createKernel(function (values, t) { let hits = 0; for (let i = 0; i < this.constants.chunk; i++) { if (values[i * this.constants.threads + this.thread.x] > t) hits++; } return hits; }, { output: [512], constants: { threads: 512, chunk: 256 } }); function total(partials) { let sum = 0; for (let i = 0; i < partials.length; i++) sum += partials[i]; return sum; } function bracket(values) { let lo = values[0]; let hi = values[0]; for (let i = 1; i < values.length; i++) { if (values[i] < lo) lo = values[i]; if (values[i] > hi) hi = values[i]; } return [lo - 1, hi]; } async function bisect(counter, values, k) { // TODO: bracket the values, halve while hi - lo > 0.5 keeping the half // that can still contain the cutoff, and return Math.floor(lo). Each // counting pass has to be awaited before its answer can be read. return 0; } function boundaryOf(ranks, values) { // TODO: the K-th largest score is the one whose rank is K - 1. Ranks // arrive flat, so this same scan serves both sizes. return 0; } // A kernel's first launch compiles it, and a shader compiler inside the // timer is not a measurement. One untimed warm-up call each. await rankSmall(scores); await bisect(countSmall, scores, K); // mode carries the gpu.js mode string — 'async' on the default Auto setting, // 'gpu' only when WebGL is picked by hand. The question here is "is this the // slow single-threaded backend?", so ask that. if (mode !== 'cpu') await rankBig(bigScores); await bisect(countBig, bigScores, K); let t0 = performance.now(); const smallByRank = boundaryOf(await rankSmall(scores), scores); const rankSmallMs = performance.now() - t0; t0 = performance.now(); const smallCut = await bisect(countSmall, scores, K); const bisectSmallMs = performance.now() - t0; console.log('rank 4096:', rankSmallMs.toFixed(1), 'ms - 10th largest score is', smallByRank); console.log('bisect 4096:', bisectSmallMs.toFixed(1), 'ms - cutoff', smallCut); // 131,072 x 131,072 = 17.2 billion comparisons. The GPU eats them in tens // of milliseconds; the CPU backend, one thread, would need about a minute, // so there this measurement is reported rather than run. if (mode !== 'cpu') { t0 = performance.now(); const bigByRank = boundaryOf(utils.flatten(await rankBig(bigScores)), bigScores); const rankBigMs = performance.now() - t0; console.log('rank 131072:', rankBigMs.toFixed(1), 'ms - 10th largest score is', bigByRank); } else { console.log('rank 131072: not run on the cpu backend - 17.2 billion comparisons, about a minute'); } t0 = performance.now(); const bigCut = await bisect(countBig, bigScores, K); const bisectBigMs = performance.now() - t0; console.log('bisect 131072:', bisectBigMs.toFixed(1), 'ms - cutoff', bigCut); ``` --- Interactive version: https://gpu.rocks/learn/top-k-selection-1ba56df3/5 [Previous task](https://gpu.rocks/learn/top-k-selection-1ba56df3/4.md) --- # Jump Flooding: Voronoi in log n Passes *Module of the free GPU.js GPGPU course · 6 tasks* A Voronoi diagram and a signed distance field in log₂(n) passes — more total work than the CPU algorithm, and faster anyway. ## Tasks 1. [What a Cell Has to Carry](https://gpu.rocks/learn/jump-flooding-a741a650/1.md) 2. [One Pass, One Stride](https://gpu.rocks/learn/jump-flooding-a741a650/2.md) 3. [The Halving Ladder](https://gpu.rocks/learn/jump-flooding-a741a650/3.md) 4. [From Seeds to Distances](https://gpu.rocks/learn/jump-flooding-a741a650/4.md) 5. [A Signed Distance Field From a Bitmap](https://gpu.rocks/learn/jump-flooding-a741a650/5.md) 6. [Payoff: Measure the Lie](https://gpu.rocks/learn/jump-flooding-a741a650/6.md) --- Interactive version: https://gpu.rocks/learn/jump-flooding-a741a650 --- # What a Cell Has to Carry *Task 1 of 6 · [Jump Flooding: Voronoi in log n Passes](https://gpu.rocks/learn/jump-flooding-a741a650.md) · GPU.js Learn* Scatter a handful of **seeds** over a grid and colour every cell by whichever seed is closest. That is a **Voronoi diagram**, and it is one of the most useful pictures in graphics: it is a distance field, a nearest-neighbour lookup, a watershed, a shatter pattern and a texture, depending on who is asking. A CPU builds one in *O(n)* in the number of pixels — Felzenszwalb's exact distance transform runs a couple of linear scans along every row, then the same down every column, and it is done. This module builds the same picture in *O(n log n)* and wins anyway, because each of those scans is a **chain**: the answer at column *j* is read off a running lower envelope that columns *0…j−1* built, so the exact algorithm offers one thread per row and nothing finer. Jump flooding hands all 16,384 cells to their own threads, seven times over. That is the whole reason this algorithm exists, and it is a different claim from "the GPU is faster": jump flooding does **more total work** than the algorithm it beats. Start where anyone would: ask each cell to check all 16 seeds. What matters is not the loop — it is **what the cell writes down**. Not the distance. The seed's *position*, because the next pass will have to measure that seed again from a different pixel. A gpu.js cell holds one number, so the pair is packed: `id = sy * 128 + sx`, and `-1` for "nothing yet". ## Goal **Goal:** make `nearest` return the **packed position** of the closest seed — `seedY[best] * n + seedX[best]` — rather than the distance the starter hands back. ## Requirements - Loop over all `this.constants.sites` seeds and keep the closest - Compare **squared** distances — no `Math.sqrt` in the loop - Return the winner packed as `seedY[best] * this.constants.n + seedX[best]` ## Hint 1 — remember the winner, not just its distance The starter already finds the smallest `bestD`. Add a second variable that remembers *which* seed produced it, and update both together. ```js if (d < bestD) { bestD = d; best = i; } ``` ## Hint 2 — packing the pair A cell holds one number and you need two. Rows first, exactly as in `grid[y][x]`: ```js return seedY[best] * this.constants.n + seedX[best]; ``` Unpacking it later is the same arithmetic backwards: `sy = Math.floor(id / n)`, then `sx = id - sy * n`. ## Same idea elsewhere Packing a payload into the value a thread can write is universal GPGPU housekeeping — a CUDA kernel stuffs an index and a key into one `uint64` so a single `atomicMin` carries both, and a WebGPU jump-flood pass stores its seed in an `rg32float` texture for the same reason. The lesson underneath is the one that transfers: a parallel algorithm's state has to be enough to *continue* from, not just enough to display. ## Starter code ```js // 16 seeds, 16,384 cells, one thread each. Brute force — for now. const gpu = new GPU({ mode }); const nearest = gpu.createKernel(function (seedX, seedY) { const x = this.thread.x; const y = this.thread.y; let bestD = this.constants.n * this.constants.n * 2; for (let i = 0; i < this.constants.sites; i++) { const dx = seedX[i] - x; const dy = seedY[i] - y; const d = dx * dx + dy * dy; if (d < bestD) { bestD = d; } } // TODO: a distance is a dead end — the next pass cannot re-measure from it. // Remember WHICH seed won, and return its packed position instead: // seedY[best] * this.constants.n + seedX[best] return Math.sqrt(bestD); }, { output: [128, 128], constants: { n: 128, sites: 16 } }); const paint = gpu.createKernel(function (grid) { const id = grid[this.thread.y][this.thread.x]; let r = 0.11; let g = 0.12; let b = 0.17; if (id >= 0) { const sy = Math.floor(id / this.constants.n); const sx = id - sy * this.constants.n; r = 0.22 + 0.68 * (sx / this.constants.n); g = 0.30 + 0.55 * (sy / this.constants.n); b = 0.88 - 0.6 * (sx / this.constants.n); } this.color(r, g, b, 1); }, { output: [128, 128], graphical: true, constants: { n: 128 } }); const cells = await nearest(seedX, seedY); await paint(cells); render(paint.canvas); console.log('cell (0, 0) carries', cells[0][0]); ``` --- Interactive version: https://gpu.rocks/learn/jump-flooding-a741a650/1 [Next task](https://gpu.rocks/learn/jump-flooding-a741a650/2.md) --- # One Pass, One Stride *Task 2 of 6 · [Jump Flooding: Voronoi in log n Passes](https://gpu.rocks/learn/jump-flooding-a741a650.md) · GPU.js Learn* Brute force cost 16 distance tests per cell, and it would cost 16,000 for 16,000 seeds. Jump flooding never looks at the seed list at all. It looks at **nine cells**: itself, and eight neighbours at offset `±k` — the corners and edges of a square of side `2k`. Each of those nine already carries a seed (or `-1`). Measure every carried seed *from here*, keep the nearest, write it down. That is the entire algorithm. Notice the shape of it: every thread **reads** nine cells and writes only its own. Nothing is ever pushed outwards to a neighbour. That is the course's gather formulation — the one gpu.js forces on you because it has no scatter — and jump flooding is the cleanest example of it there is, because the obvious way to describe the algorithm ("each seed spreads outwards") is scatter, and the way you actually write it is the exact inverse. Two traps live in that paragraph. The distance is measured from *this* pixel to the neighbour's seed, never from the neighbour to its own seed. And the neighbour at `dx = dy = 0` is *you*: keeping what you already had is one of the nine cases, not a special one. ## Figures - **nine cells, and one of them is you — which is how a cell keeps what it already had** — A lattice of grid cells with nine marked: the thread's own cell at the centre and eight neighbours k cells away in each direction, forming the corners and edge midpoints of a square of side 2k. ## Goal **Goal:** write `flood` — nine candidates at stride `k`, keep the one whose seed is nearest to this pixel — and run it once at `k = 64`. ## Requirements - Visit the nine offsets `dx, dy ∈ {−1, 0, 1}`, each scaled by `k` - Skip a candidate that falls off the grid, and one holding `-1` - Unpack each candidate's id and measure that seed from `this.thread.x/y` - Return the packed id of the nearest — or `-1` if no candidate had one ## Hint 1 — the nine candidates Two nested loops, each running −1, 0, 1. The offset is scaled by the stride: ```js const nx = x + dx * k; const ny = y + dy * k; ``` At `k = 64` that reaches 64 cells away in each direction; at `k = 1` it is the ordinary 3×3 neighbourhood. ## Hint 2 — unpacking a candidate `id = sy * n + sx` comes apart the way it went together: ```js const sy = Math.floor(id / this.constants.n); const sx = id - sy * this.constants.n; const d = (sx - x) * (sx - x) + (sy - y) * (sy - y); ``` `x` and `y` in that last line are *this thread's* coordinates — not `nx` and `ny`. ## Hint 3 — the guards Two of them, nested. First that the neighbour exists — `nx >= 0 && nx < this.constants.n` and the same for `ny` — and then that it carries something, `id >= 0`. Start `bestD` larger than any distance on the grid so the first real candidate always wins. ## Same idea elsewhere A fixed nine-tap stencil with a runtime stride is what every platform's jump-flood implementation looks like: a WebGPU compute pass sampling a seed texture at `±k`, a CUDA kernel doing the same over global memory, a Metal fragment shader with `k` as a push constant. Nothing about it needs atomics, shared memory or scatter — which is precisely why it ports to anything with a texture unit. ## Starter code ```js // The whole algorithm is nine reads. This is one pass of it. const gpu = new GPU({ mode }); const flood = gpu.createKernel(function (grid, k) { const x = this.thread.x; const y = this.thread.y; let best = -1; let bestD = this.constants.n * this.constants.n * 2; // TODO: loop dy and dx over -1, 0, 1. // nx = x + dx * k, ny = y + dy * k // skip it unless it is on the grid AND grid[ny][nx] >= 0 // unpack that id, measure the seed FROM THIS PIXEL, keep the nearest return best; }, { output: [128, 128], constants: { n: 128 } }); const paint = gpu.createKernel(function (grid) { const id = grid[this.thread.y][this.thread.x]; let r = 0.11; let g = 0.12; let b = 0.17; if (id >= 0) { const sy = Math.floor(id / this.constants.n); const sx = id - sy * this.constants.n; r = 0.22 + 0.68 * (sx / this.constants.n); g = 0.30 + 0.55 * (sy / this.constants.n); b = 0.88 - 0.6 * (sx / this.constants.n); } this.color(r, g, b, 1); }, { output: [128, 128], graphical: true, constants: { n: 128 } }); await paint(seedGrid); render(paint.canvas); const once = await flood(seedGrid, 64); await paint(once); render(paint.canvas); let filled = 0; for (let y = 0; y < 128; y++) { for (let x = 0; x < 128; x++) if (once[y][x] >= 0) filled++; } console.log('cells holding a seed: 16 ->', filled); ``` --- Interactive version: https://gpu.rocks/learn/jump-flooding-a741a650/2 [Previous task](https://gpu.rocks/learn/jump-flooding-a741a650/1.md) · [Next task](https://gpu.rocks/learn/jump-flooding-a741a650/3.md) --- # The Halving Ladder *Task 3 of 6 · [Jump Flooding: Voronoi in log n Passes](https://gpu.rocks/learn/jump-flooding-a741a650.md) · GPU.js Learn* One pass at `k = 64` moved 16 seeds into 64 cells. Useless on its own — and then you halve the stride and run it again. And again. `64, 32, 16, 8, 4, 2, 1`: seven passes on a 128-wide grid, `log₂(n)` of them, and the diagram is finished. Seven is enough because any distance up to 127 is a sum of those powers of two — `127 = 64 + 32 + 16 + 8 + 4 + 2 + 1`, and a shorter one simply drops the terms it does not need — so every seed has a route of jumps to every cell. (Having a route and arriving are not quite the same thing, which is what the last task is for.) It is the same halving ladder *Reductions* climbs, run backwards. The loop lives in JavaScript; the work stays on the GPU. Seven launches instead of a pair of scans, and each launch moves all 16,384 cells at once. Count the work honestly: *n log n* against the exact transform's *n*. Jump flooding loses that comparison and wins the race, because the exact transform spends its *n* walking chains — 128 cells deep along a row, then 128 deep down a column — while jump flooding spends its *n log n* as seven steps of 16,384 independent ones. Render inside the loop and you get the best view in this course: a frame scrubber you can drag, watching the diagram arrive in seven jumps — sparse dust, then blocks, then the boundaries snapping straight on the last pass. ## Figures - **105 = 64 + 32 + 8 + 1 — no distance on a 128-wide grid needs an eighth pass** — Seven stacked rows, one per pass, with the stride halving from 64 to 1. The bar reaches 64, then 96, stalls at 16, reaches 104 at stride 8, stalls at 4 and 2, and arrives at 105 on the final stride-1 pass. ## Goal **Goal:** drive the ladder — start `k` at 64, halve it to 1, feed each pass's output into the next, and `render()` every pass. ## Requirements - Loop `k = 64, 32, 16, 8, 4, 2, 1` — halve, never double - Each pass floods the **previous pass's output**, not `seedGrid` - `await` each pass before launching the next - Paint and `render()` inside the loop, and record `countFilled` ## Hint 1 — the loop Halving is just the update expression: ```js for (let k = 64; k >= 1; k = k / 2) { // … } ``` Seven iterations, and the last one is `k = 1`. ## Hint 2 — carrying the field forward `grid` has to be reassigned, or every pass re-floods the same sparse starting field: ```js grid = await flood(grid, k); ``` Never `Promise.all` here — pass *k* + 1 reads pass *k*'s output, so the ladder is sequential by construction. ## Hint 3 — the whole body ```js for (let k = 64; k >= 1; k = k / 2) { grid = await flood(grid, k); filled.push(countFilled(grid)); await paint(grid); render(paint.canvas); } ``` ## Same idea elsewhere Driving a shrinking sequence of dispatches from the host is the standard shape of every multi-pass GPU algorithm: CUDA launches a kernel per rung, WebGPU records repeated dispatches ping-ponging between two textures, Metal encodes one compute pass each. The stride schedule is data the host owns; the parallelism is what the device owns. Real implementations ping-pong between two buffers rather than reading back — here each pass already hands JavaScript a plain array, which is what makes `countFilled` and the per-pass render free. ## Starter code ```js // Seven passes. Halve the stride each time and the picture arrives. const gpu = new GPU({ mode }); const flood = gpu.createKernel(function (grid, k) { const x = this.thread.x; const y = this.thread.y; let best = -1; let bestD = this.constants.n * this.constants.n * 2; for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { const nx = x + dx * k; const ny = y + dy * k; if (nx >= 0 && nx < this.constants.n && ny >= 0 && ny < this.constants.n) { const id = grid[ny][nx]; if (id >= 0) { const sy = Math.floor(id / this.constants.n); const sx = id - sy * this.constants.n; const d = (sx - x) * (sx - x) + (sy - y) * (sy - y); if (d < bestD) { bestD = d; best = id; } } } } } return best; }, { output: [128, 128], constants: { n: 128 } }); const paint = gpu.createKernel(function (grid) { const id = grid[this.thread.y][this.thread.x]; let r = 0.11; let g = 0.12; let b = 0.17; if (id >= 0) { const sy = Math.floor(id / this.constants.n); const sx = id - sy * this.constants.n; r = 0.22 + 0.68 * (sx / this.constants.n); g = 0.30 + 0.55 * (sy / this.constants.n); b = 0.88 - 0.6 * (sx / this.constants.n); } this.color(r, g, b, 1); }, { output: [128, 128], graphical: true, constants: { n: 128 } }); function countFilled(grid) { let n = 0; for (let y = 0; y < 128; y++) { for (let x = 0; x < 128; x++) if (grid[y][x] >= 0) n++; } return n; } let grid = seedGrid; const filled = [countFilled(grid)]; await paint(grid); render(paint.canvas); // TODO: seven passes. Start k at 64 and HALVE it every time, flooding the // CURRENT grid — then record countFilled(grid), paint it and render() it, so // the console gives you a scrubber over the whole ladder. plot(filled, { title: 'cells holding a seed, pass by pass', log: true }); console.log('passes:', filled.length - 1, '- filled:', filled); ``` --- Interactive version: https://gpu.rocks/learn/jump-flooding-a741a650/3 [Previous task](https://gpu.rocks/learn/jump-flooding-a741a650/2.md) · [Next task](https://gpu.rocks/learn/jump-flooding-a741a650/4.md) --- # From Seeds to Distances *Task 4 of 6 · [Jump Flooding: Voronoi in log n Passes](https://gpu.rocks/learn/jump-flooding-a741a650.md) · GPU.js Learn* The finished field already *is* a distance field — you just have to ask it. Every cell knows where its nearest seed is, so the distance to that seed is one subtraction and one square root away, in a single extra pass with no memory of its own. This is where carrying the position rather than the distance pays off twice over. Had the cells accumulated distances, every pass would have compounded whatever rounding the last one introduced. Carrying coordinates means the distance is computed **once, at the end**, from two exact integers — so the field is as accurate as the seed assignment is, and no more approximate than that. ## Goal **Goal:** write `distance` — unpack each cell's seed and return the Euclidean distance from the cell to it. ## Requirements - One kernel, one argument: the finished `grid` of packed ids - Unpack with `Math.floor(id / n)` then `id − sy * n` - Return `Math.sqrt(…)` — this pass is where the square root belongs ## Hint 1 — the same unpack as the flood pass Nothing new: it is the two lines you already wrote inside the loop, applied once. ```js const sy = Math.floor(id / this.constants.n); const sx = id - sy * this.constants.n; ``` ## Hint 2 — the whole body ```js return Math.sqrt((sx - x) * (sx - x) + (sy - y) * (sy - y)); ``` A seed cell measures 0 from itself, which is exactly right — those are the black pinpricks in the rendered field. ## Same idea elsewhere Distance fields are the workhorse texture of real-time graphics: glyph rendering (Valve's signed-distance text), outlines and glows, soft particle collision, path planning and morphological dilation are all "threshold a distance field". Every one of them wants the field regenerated per frame from changing input, which is why this algorithm — not the asymptotically better sweep — is the one that ships. ## Starter code ```js // The seed field IS a distance field. One pass to read it out. const gpu = new GPU({ mode }); const flood = gpu.createKernel(function (grid, k) { const x = this.thread.x; const y = this.thread.y; let best = -1; let bestD = this.constants.n * this.constants.n * 2; for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { const nx = x + dx * k; const ny = y + dy * k; if (nx >= 0 && nx < this.constants.n && ny >= 0 && ny < this.constants.n) { const id = grid[ny][nx]; if (id >= 0) { const sy = Math.floor(id / this.constants.n); const sx = id - sy * this.constants.n; const d = (sx - x) * (sx - x) + (sy - y) * (sy - y); if (d < bestD) { bestD = d; best = id; } } } } } return best; }, { output: [128, 128], constants: { n: 128 } }); const distance = gpu.createKernel(function (grid) { const x = this.thread.x; const y = this.thread.y; const id = grid[y][x]; // TODO: unpack id into (sx, sy) and return the distance from (x, y) to it. return id; }, { output: [128, 128], constants: { n: 128 } }); const shade = gpu.createKernel(function (field) { const t = Math.min(1, field[this.thread.y][this.thread.x] / this.constants.scale); this.color(t, t, t, 1); }, { output: [128, 128], graphical: true, constants: { scale: 56 } }); let grid = seedGrid; for (let k = 64; k >= 1; k = k / 2) grid = await flood(grid, k); const field = await distance(grid); await shade(field); render(shade.canvas); plot(field[64], { title: 'distance to the nearest seed, along row 64' }); console.log('distance at (0, 0):', field[0][0]); ``` --- Interactive version: https://gpu.rocks/learn/jump-flooding-a741a650/4 [Previous task](https://gpu.rocks/learn/jump-flooding-a741a650/3.md) · [Next task](https://gpu.rocks/learn/jump-flooding-a741a650/5.md) --- # A Signed Distance Field From a Bitmap *Task 5 of 6 · [Jump Flooding: Voronoi in log n Passes](https://gpu.rocks/learn/jump-flooding-a741a650.md) · GPU.js Learn* Seeds do not have to be dots. Seed the flood with *every pixel inside a shape* and the finished field answers "how far is the nearest inside pixel?" — which is 0 inside and grows outside. Seed it with every pixel *outside* and you get the mirror image. Subtract one from the other and the result is a **signed distance field**: negative inside, zero on the boundary, positive outside. *Ray-Marched Metaballs* marches an SDF that is defined **analytically** — a sphere is `length(p) − r`, and the whole scene is a formula. This is the other half of that story: here you **manufacture** one from an arbitrary bitmap. Nothing about a five-pointed star wants to be a formula, and it does not have to be. Two ladders and a subtraction, and it is marchable, glowable, outlineable — exactly like the analytic kind. The bookkeeping is the interesting part. `dIn` is 0 for every inside pixel and positive outside; `dOut` is 0 for every outside pixel and positive inside. So `dIn − dOut` is signed automatically, with no test on the mask at all — one of the two terms is always zero. ## Figures - **one term is always zero, so the subtraction is the entire sign logic** — Three panels of the same star: the field flooded from the inside pixels (zero inside), minus the field flooded from the outside pixels (zero outside), equals a signed field that is negative inside and positive outside. ## Goal **Goal:** write `seedWhere(mask, want)` — seed the cells where the mask equals `want` — and `combine(dIn, dOut)`, which returns `dIn − dOut`. ## Requirements - `seedWhere` returns the packed id where `mask[y][x] === want`, else `-1` - Flood once with `want = 1` and once with `want = 0`, awaiting each ladder - `combine` returns `dIn − dOut` — negative inside, positive outside ## Hint 1 — seeding a region The same packed id as ever, gated on the mask: ```js let id = -1; if (mask[y][x] === want) id = y * this.constants.n + x; return id; ``` The `want` argument is what lets one kernel seed both sides. ## Hint 2 — two ladders, one driver `ladder()` takes any seeded field, so it runs twice unchanged: ```js const dIn = await distance(await ladder(await seedWhere(mask, 1))); const dOut = await distance(await ladder(await seedWhere(mask, 0))); ``` Fourteen passes in total, and every one of them awaited in order. ## Hint 3 — why no sign test is needed Inside a pixel of the shape, the nearest inside pixel is itself, so `dIn = 0` and the answer is `−dOut`. Outside, `dOut = 0` and the answer is `+dIn`. The subtraction is the whole sign logic. ## Same idea elsewhere Manufacturing an SDF from a raster is production practice: Valve's distance-field glyphs, Unity and Godot's SDF text, mesh voxelisation into a 3D distance field for collision and soft shadows — all of it is "flood a bitmap, subtract two fields". The measurement is a pixel-centre one, so this field is quantised to the raster it came from; the usual fix is to seed sub-pixel boundary positions rather than pixel centres, which changes the seeding and nothing else about the algorithm. ## Starter code ```js // Seed the inside. Seed the outside. Subtract. That is an SDF. const gpu = new GPU({ mode }); const flood = gpu.createKernel(function (grid, k) { const x = this.thread.x; const y = this.thread.y; let best = -1; let bestD = this.constants.n * this.constants.n * 2; for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { const nx = x + dx * k; const ny = y + dy * k; if (nx >= 0 && nx < this.constants.n && ny >= 0 && ny < this.constants.n) { const id = grid[ny][nx]; if (id >= 0) { const sy = Math.floor(id / this.constants.n); const sx = id - sy * this.constants.n; const d = (sx - x) * (sx - x) + (sy - y) * (sy - y); if (d < bestD) { bestD = d; best = id; } } } } } return best; }, { output: [128, 128], constants: { n: 128 } }); const distance = gpu.createKernel(function (grid) { const x = this.thread.x; const y = this.thread.y; const id = grid[y][x]; const sy = Math.floor(id / this.constants.n); const sx = id - sy * this.constants.n; return Math.sqrt((sx - x) * (sx - x) + (sy - y) * (sy - y)); }, { output: [128, 128], constants: { n: 128 } }); const seedWhere = gpu.createKernel(function (mask, want) { const x = this.thread.x; const y = this.thread.y; // TODO: return the packed id y * n + x where mask[y][x] === want, else -1. return -1; }, { output: [128, 128], constants: { n: 128 } }); const combine = gpu.createKernel(function (dIn, dOut) { // TODO: return dIn - dOut for this cell — negative inside, positive outside. return dIn[this.thread.y][this.thread.x]; }, { output: [128, 128] }); const paintSdf = gpu.createKernel(function (field) { const s = field[this.thread.y][this.thread.x]; const band = 0.55 + 0.45 * Math.cos(s * 0.9); const t = Math.min(1, Math.abs(s) / 26); let r = 0.22 + 0.72 * t * band; let g = 0.44 + 0.26 * t * band; let b = 0.24 + 0.16 * t * band; if (s < 0) { r = 0.14 + 0.20 * t * band; g = 0.42 + 0.30 * t * band; b = 0.55 + 0.44 * t * band; } this.color(r, g, b, 1); }, { output: [128, 128], graphical: true }); async function ladder(seeded) { let g = seeded; for (let k = 64; k >= 1; k = k / 2) g = await flood(g, k); return g; } const dIn = await distance(await ladder(await seedWhere(mask, 1))); const dOut = await distance(await ladder(await seedWhere(mask, 0))); const sdf = await combine(dIn, dOut); await paintSdf(sdf); render(paintSdf.canvas); console.log('sdf at the centre:', sdf[64][64], '- at a corner:', sdf[0][0]); ``` --- Interactive version: https://gpu.rocks/learn/jump-flooding-a741a650/5 [Previous task](https://gpu.rocks/learn/jump-flooding-a741a650/4.md) · [Next task](https://gpu.rocks/learn/jump-flooding-a741a650/6.md) --- # Payoff: Measure the Lie *Task 6 of 6 · [Jump Flooding: Voronoi in log n Passes](https://gpu.rocks/learn/jump-flooding-a741a650.md) · GPU.js Learn* Jump flooding is an **approximation**. It is not "exact but for rounding": there are seed layouts for which a cell's true nearest seed never reaches it. The route of halving jumps the ladder counted on always exists — but a cell part-way along it only forwards the seed it is holding at that moment, and it may be holding a different one that looked nearer when the pass ran. The chain breaks in the middle. Every honest description of this algorithm says so, and the way to believe it is to count. Careful about what "wrong" means, though. Two seeds can be exactly the same distance away, and then *both* answers are right — comparing the ids the two methods chose would report roughly twice as many failures as there are. A cell is wrong only when the seed it holds is **strictly farther** than the true nearest one. The layout here is 24 seeds chosen to make the flaw visible. It is not typical: across 200 random 24-seed layouts on this grid, 95 came out perfect and the average was 1.4 wrong cells out of 16,384 — 0.009%. This one manages 67. The standard patch, one extra pass at stride 1 ("JFA+1"), takes it to 55 — better, and still not exact. If you need exact, you need a different algorithm; what you get here is a fast answer with a bounded, measurable error, and for a glow or an outline or a shatter pattern that is the right trade. The `passes` dial stops the ladder short — one rung, or two, or all seven — and re-runs the whole program each time you move it. Wrong cells come out red, so dragging it puts the error curve and the picture side by side: the plot says how many, the diagram says *where*. Six passes out of seven is not "nearly right", it is **12,292 wrong cells out of 16,384**, because every jump left is a multiple of 2 and three quarters of the grid is an odd step away from every seed. The last rung is not a polish pass. It is the one that reaches the cells in between at all. ## Goal **Goal:** write `worse(jfa, truth)` — 1 where the flooded seed is strictly farther than the true nearest one, 0 otherwise — and `countOnes` to total it. ## Requirements - Unpack both ids and compare **squared** distances — whole numbers, so ties are exact - Equal distance is **not** an error: only strictly farther counts - A cell still holding `-1` counts as wrong - `countOnes` sums the field in plain JavaScript ## Hint 1 — two unpacks, one comparison Unpack `jfa[y][x]` and `truth[y][x]` the usual way, measure both seeds from this pixel, and compare the squared distances. Strictly greater: ```js let bad = 0; if (dJfa > dTruth) bad = 1; return bad; ``` ## Hint 2 — the unassigned case An id of `-1` unpacks to nonsense, so give it a distance larger than anything on the grid before the comparison — the same `n * n * 2` the flood pass starts from. ## Hint 3 — counting in JavaScript The field is 0s and 1s, so the count is the sum: ```js let n = 0; for (let y = 0; y < 128; y++) { for (let x = 0; x < 128; x++) n += grid[y][x]; } return n; ``` ## Same idea elsewhere "Asymptotically worse, measurably approximate, and shipped anyway" is a recurring GPU story — screen-space ambient occlusion approximates an integral nobody can afford, temporal upscalers approximate frames that were never rendered, and JFA approximates a transform that has an exact linear-time algorithm nobody can parallelise. What makes each of them defensible is exactly this task: somebody counted the error, published the number, and decided it was small enough. An approximation whose error you have not measured is not an engineering decision. ## Starter code ```js // How often is the fast answer the wrong answer? Count it. const gpu = new GPU({ mode }); const flood = gpu.createKernel(function (grid, k) { const x = this.thread.x; const y = this.thread.y; let best = -1; let bestD = this.constants.n * this.constants.n * 2; for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { const nx = x + dx * k; const ny = y + dy * k; if (nx >= 0 && nx < this.constants.n && ny >= 0 && ny < this.constants.n) { const id = grid[ny][nx]; if (id >= 0) { const sy = Math.floor(id / this.constants.n); const sx = id - sy * this.constants.n; const d = (sx - x) * (sx - x) + (sy - y) * (sy - y); if (d < bestD) { bestD = d; best = id; } } } } } return best; }, { output: [128, 128], constants: { n: 128 } }); const exact = gpu.createKernel(function (seedX, seedY) { const x = this.thread.x; const y = this.thread.y; let best = -1; let bestD = this.constants.n * this.constants.n * 2; for (let i = 0; i < this.constants.sites; i++) { const dx = seedX[i] - x; const dy = seedY[i] - y; const d = dx * dx + dy * dy; if (d < bestD) { bestD = d; best = seedY[i] * this.constants.n + seedX[i]; } } return best; }, { output: [128, 128], constants: { n: 128, sites: 24 } }); const worse = gpu.createKernel(function (jfa, truth) { const x = this.thread.x; const y = this.thread.y; // TODO: unpack both ids, measure both seeds from (x, y), and return 1 only // when the flooded one is STRICTLY farther. A tie is not an error. // An id of -1 is farther than anything: n * n * 2. return 0; }, { output: [128, 128], constants: { n: 128 } }); const paintErr = gpu.createKernel(function (grid, bad) { const id = grid[this.thread.y][this.thread.x]; const sy = Math.floor(id / this.constants.n); const sx = id - sy * this.constants.n; let r = 0.22 + 0.68 * (sx / this.constants.n); let g = 0.30 + 0.55 * (sy / this.constants.n); let b = 0.88 - 0.6 * (sx / this.constants.n); if (bad[this.thread.y][this.thread.x] > 0.5) { r = 1; g = 0.15; b = 0.2; } this.color(r, g, b, 1); }, { output: [128, 128], graphical: true, constants: { n: 128 } }); function countOnes(grid) { // TODO: total the 128x128 field of 0s and 1s and return the count. return 0; } // A dial, not a constant: slider() re-runs the whole program when you drag it, // so this is how far down the ladder gets to climb. Seven rungs — 64 down to 1 // — is the whole thing; stop earlier and the red is the error you did not pay // for, cell by cell. const passes = slider('passes', { min: 1, max: 7, value: 7, step: 1 }); const finest = Math.pow(2, 7 - passes); // 7 passes reach stride 1, 1 pass stops at 64 const truth = await exact(seedX, seedY); let grid = seedGrid; const wrong = [countOnes(await worse(grid, truth))]; for (let k = 64; k >= 1; k = k / 2) { if (k < finest) break; grid = await flood(grid, k); wrong.push(countOnes(await worse(grid, truth))); } plot(wrong, { title: 'cells not yet holding their nearest seed', log: true }); console.log('after', wrong.length - 1, 'passes:', wrong[wrong.length - 1], 'of 16384 cells are wrong'); const patched = await flood(grid, 1); console.log('after one extra stride-1 pass:', countOnes(await worse(patched, truth))); await paintErr(grid, await worse(grid, truth)); render(paintErr.canvas); ``` --- Interactive version: https://gpu.rocks/learn/jump-flooding-a741a650/6 [Previous task](https://gpu.rocks/learn/jump-flooding-a741a650/5.md) --- # Bitonic Sort *Module of the free GPU.js GPGPU course · 5 tasks* More comparisons than quicksort, and far faster on a GPU — because the whole comparison schedule is fixed before the data arrives. ## Tasks 1. [The Compare-Exchange, as a Gather](https://gpu.rocks/learn/bitonic-sort-84e0728e/1.md) 2. [Who Is My Partner?](https://gpu.rocks/learn/bitonic-sort-84e0728e/2.md) 3. [Which Way Does My Pair Sort?](https://gpu.rocks/learn/bitonic-sort-84e0728e/3.md) 4. [Drive the Whole Network](https://gpu.rocks/learn/bitonic-sort-84e0728e/4.md) 5. [Payoff: Sort a Real Array](https://gpu.rocks/learn/bitonic-sort-84e0728e/5.md) --- Interactive version: https://gpu.rocks/learn/bitonic-sort-84e0728e --- # The Compare-Exchange, as a Gather *Task 1 of 5 · [Bitonic Sort](https://gpu.rocks/learn/bitonic-sort-84e0728e.md) · GPU.js Learn* Quicksort is the wrong algorithm here, and not by a little. How deep it recurses depends on the data; its partition step writes elements to positions it only discovers as it goes; and neighbouring threads would take different branches on every comparison. Three separate ways to be slow. Sorting on a GPU is not a port of a CPU sort — it is a different algorithm, and this module builds the one GPUs actually use. It is made of a single move repeated: the **compare-exchange**. Take two positions, put the smaller value in one and the larger in the other. On a CPU you write that as a swap. You cannot here — a thread writes exactly one cell, its own. So both threads of a pair compute their own answer by reading *both* values: the one at the low index keeps the minimum, the one at the high index keeps the maximum. Same outcome, no thread ever touching another thread's cell. Sixteen values, eight pairs: 0 with 1, 2 with 3, and so on. Which of the two you are is just `this.thread.x % 2`. ## Figures - **nobody swaps — both threads look at both values and keep their own** ## Goal **Goal:** make each thread find its partner in the adjacent pair, read both values, and return the one it should end up holding — minimum at the even index, maximum at the odd one. ## Requirements - Work out your partner from `this.thread.x` alone — even indices pair upward, odd indices pair downward - Read *both* `data[i]` and `data[partner]` - Return `Math.min` at the even index and `Math.max` at the odd one — never the partner's value unconditionally ## Hint 1 — which half of the pair am I? `this.thread.x % 2` is 0 for the low member of a pair and 1 for the high one. Keep it in a variable — but as a *number*, not a comparison: gpu.js cannot store a boolean in a variable (it compiles in cpu mode and fails to compile in gpu mode), so write `const side = i % 2;` and test `side === 0` where you need it. ## Hint 2 — the partner Start from the downward step and correct it for the low member: ```js let partner = i - 1; if (side === 0) partner = i + 1; ``` ## Hint 3 — the ending Both values in hand, the choice is one line each way: ```js const me = data[i]; const other = data[partner]; if (side === 0) return Math.min(me, other); return Math.max(me, other); ``` ## Same idea elsewhere Compare-exchange is the primitive every sorting network is built from, and it is gather-shaped everywhere for the same reason: CUDA's `__shfl_xor_sync` hands a thread its partner's value so the thread can decide its own result, WGSL and Metal do the same through subgroup shuffles or threadgroup memory plus a barrier. Nobody writes to anybody else's slot. ## Starter code ```js // Eight pairs, sixteen threads. Each thread returns ITS OWN value. const gpu = new GPU({ mode }); const exchange = gpu.createKernel(function (data) { const i = this.thread.x; const side = i % 2; // 0 = low member of my pair, 1 = high // TODO: work out this thread's partner, read both values, and return // the one this thread should hold: min at the low index, max at the high. return data[i]; }, { output: [16] }); const result = await exchange(data); console.log(result); ``` --- Interactive version: https://gpu.rocks/learn/bitonic-sort-84e0728e/1 [Next task](https://gpu.rocks/learn/bitonic-sort-84e0728e/2.md) --- # Who Is My Partner? *Task 2 of 5 · [Bitonic Sort](https://gpu.rocks/learn/bitonic-sort-84e0728e.md) · GPU.js Learn* Adjacent pairs are only the first pass. The network also compares at distance 2, 4, 8, 16 — always a power of two, always the same pattern regardless of what the values are. The classic way to write it is one character long: ```js partner = i ^ stride; ``` XOR with a power of two flips exactly one bit of the index — bit `log₂(stride)`. If your bit is 0 you move forward across the gap; if it is 1 you move back. Do it twice and you are home, which is why the pairing is always mutual: no thread is anybody's partner twice, and nobody is left over. `^` does work in gpu.js — but it is worth knowing what you are buying. Both WebGL backends compile it to a helper function that walks up to 32 bits of both operands in a loop; the native GLSL integer operator is never emitted. One character of JavaScript, a 32-iteration loop in the shader. On CUDA or WebGPU, XOR is a single instruction. Here it is not, so this module spells the flip out in arithmetic instead — two operations, and it hands you something XOR hides: the *value* of the bit you are flipping. ```js const bit = Math.floor(i / stride) % 2; // 0 or 1 — my bit at log2(stride) partner = bit === 0 ? i + stride : i - stride; ``` Hold on to that `bit`. The next task needs it, and needs a second one just like it. ## Goal **Goal:** return the *partner index* for each of 16 threads at a given power-of-two `stride` — no data involved, pure index arithmetic. ## Requirements - The kernel takes `stride` as an argument and returns an index, not a value - Work for *any* power-of-two stride — 1, 2, 4 and 8 all have to come out right - Pairing must be mutual: the partner of your partner is you ## Hint 1 — which bit? At `stride = 4` the indices 0…7 split into 0–3 (bit clear, step forward) and 4–7 (bit set, step back). `Math.floor(i / 4) % 2` is exactly that split: 0, 0, 0, 0, 1, 1, 1, 1. ## Hint 2 — the whole kernel ```js const i = this.thread.x; const bit = Math.floor(i / stride) % 2; if (bit === 0) return i + stride; return i - stride; ``` `return i ^ stride;` gives the same answers, at the cost of that 32-iteration loop — and it will not help you with the next task. ## Same idea elsewhere The XOR partner is the canonical spelling of a sorting network everywhere: CUDA's `__shfl_xor_sync(mask, value, laneMask)` takes the lane XOR mask directly, and WGSL's `subgroupShuffleXor` is named after it. Both are one instruction on the hardware — worth remembering that the arithmetic form you write here is a gpu.js accommodation, not a universal truth. ## Starter code ```js // Pure index arithmetic: no data, no comparison. Just "who do I pair with?" const gpu = new GPU({ mode }); const partner = gpu.createKernel(function (stride) { const i = this.thread.x; // TODO: flip the bit that `stride` names. // Which bit is it? Math.floor(i / stride) % 2 tells you its value — // 0 means step forward by stride, 1 means step back. return i; }, { output: [16] }); console.log('stride 1:', await partner(1)); console.log('stride 4:', await partner(4)); ``` --- Interactive version: https://gpu.rocks/learn/bitonic-sort-84e0728e/2 [Previous task](https://gpu.rocks/learn/bitonic-sort-84e0728e/1.md) · [Next task](https://gpu.rocks/learn/bitonic-sort-84e0728e/3.md) --- # Which Way Does My Pair Sort? *Task 3 of 5 · [Bitonic Sort](https://gpu.rocks/learn/bitonic-sort-84e0728e.md) · GPU.js Learn* Every pass so far sorted every pair the same way. A bitonic network does not, and that is the whole trick. A sequence that rises and then falls is called **bitonic**, and a bitonic sequence is the one thing this network can merge into sorted order in log n passes. So the early passes exist to *build* bitonic runs: neighbouring blocks are deliberately sorted in opposite directions, so that gluing two of them together gives up then down. Which way your block goes is another bit of your index — the one named by `stage`, the size of the block currently being merged: ```js const dirBit = Math.floor(i / stage) % 2; // 0 → my block sorts ascending ``` So a thread now holds two bits. `strideBit` says whether it is the low or the high member of its pair; `dirBit` says which way its block is sorting. And the rule is as small as it could be: **keep the smaller value exactly when the two bits agree.** Low member of an ascending block, or high member of a descending one — either way, minimum. One detail makes that legal: `stride` is always smaller than `stage`, so flipping the stride bit never disturbs the direction bit. Both members of a pair read the same `dirBit` and agree about which way they are sorting — without exchanging a word. ## Figures - **neighbouring blocks sort opposite ways, and your index already knows which** ## Goal **Goal:** write one full bitonic pass over 8 values — the kernel takes `(data, stage, stride)` and returns each thread's new value. ## Requirements - Compute both bits: `strideBit` from `stride`, `dirBit` from `stage` - The partner still comes from `strideBit`, exactly as in the last task - Return `Math.min` when the two bits agree and `Math.max` when they differ ## Hint 1 — reading the rule off the table Four cases, and they collapse to one comparison: ```js low + ascending → min (0, 0) agree high + ascending → max (1, 0) differ low + descending → max (0, 1) differ high + descending → min (1, 1) agree ``` ## Hint 2 — the whole body ```js const i = this.thread.x; const strideBit = Math.floor(i / stride) % 2; const dirBit = Math.floor(i / stage) % 2; let partner = i - stride; if (strideBit === 0) partner = i + stride; const me = data[i]; const other = data[partner]; if (strideBit === dirBit) return Math.min(me, other); return Math.max(me, other); ``` ## Same idea elsewhere Every bitonic implementation on every platform carries this pair of bit tests — CUDA samples write `(i & k) == 0`, WGSL compute shaders write the same thing with `&`, and the arithmetic spelling here says exactly the same. What none of them need is communication: the direction is a property of your index, so a thread can work it out alone, which is what makes the whole network barrier-free within a pass. ## Starter code ```js // One pass of the network: (data, stage, stride) in, one value per thread out. const gpu = new GPU({ mode }); const pass = gpu.createKernel(function (data, stage, stride) { const i = this.thread.x; const strideBit = Math.floor(i / stride) % 2; // TODO: work out dirBit from `stage` the same way strideBit comes // from `stride`, find your partner, and keep the SMALLER value when // the two bits agree. let partner = i - stride; if (strideBit === 0) partner = i + stride; const me = data[i]; const other = data[partner]; if (strideBit === 0) return Math.min(me, other); return Math.max(me, other); }, { output: [8] }); // stage 2, stride 1: pairs (0,1) and (4,5) sort up, (2,3) and (6,7) sort down. console.log(await pass(data, 2, 1)); ``` --- Interactive version: https://gpu.rocks/learn/bitonic-sort-84e0728e/3 [Previous task](https://gpu.rocks/learn/bitonic-sort-84e0728e/2.md) · [Next task](https://gpu.rocks/learn/bitonic-sort-84e0728e/4.md) --- # Drive the Whole Network *Task 4 of 5 · [Bitonic Sort](https://gpu.rocks/learn/bitonic-sort-84e0728e.md) · GPU.js Learn* One pass is one kernel launch. The network is two plain JavaScript loops around it — stages doubling outward, and within each stage strides halving down to 1. Here is the thing worth noticing, though: those loops never look at the data. So don't interleave them with it. Build the entire **schedule** first, as a list of `[stage, stride]` pairs, and print it: ```js const schedule = []; for (let stage = 2; stage <= n; stage *= 2) { for (let stride = stage / 2; stride >= 1; stride /= 2) { schedule.push([stage, stride]); } } ``` All 36 pairs for n = 256, complete, before a single value has been read. Then run them. That is the property this whole module is about: every thread derives its partner and its direction from its own index, so nothing waits on a comparison, no warp diverges, and nobody has to be told anything. A quicksort cannot do this — you do not know its second partition until you have done the first. The bill comes due in comparisons. Bitonic sort does O(n log²n) of them where quicksort does O(n log n): 36 passes × 128 pairs = 4,608 compare-exchanges for 256 values, against roughly 2,000. More than twice the work — in 36 sequential steps, with everything inside a step happening at once. That trade, a predictable structure bought with extra work, is the most transferable idea in this course. ## Figures - **six passes, twenty-four comparators, and not one of them depends on a value** ## Goal **Goal:** build and print the whole 36-pass schedule before touching the data, run it to sort 256 values, and log the smallest and largest of the result. ## Requirements - `schedule` holds the `[stage, stride]` pairs: stages doubling from 2 up to and *including* `n`, strides halving from `stage / 2` down to 1 - Build it without reading `data` — the schedule is complete before the first kernel call - `console.log` the pass count and the schedule itself (already wired up) - `console.log` the smallest and largest values of the sorted result ## Hint 1 — the two loops Outer loop doubles, inner loop halves, and both bounds are inclusive at the far end: `stage <= n`, `stride >= 1`. The body is one line — `schedule.push([stage, stride]);` ## Hint 2 — the last stage is the one that sorts Stopping at `stage < n` costs exactly one merge, and that merge is the one that turns a bitonic sequence into a sorted array. The result looks plausible — it rises, then falls — and it is wrong. ## Hint 3 — the first few pairs A correct schedule starts ```js [[2,1], [4,2], [4,1], [8,4], [8,2], [8,1], [16,8], …] ``` — stride 2 *before* stride 1 inside stage 4, not after. ## Same idea elsewhere A host-side loop issuing one kernel launch per pass is exactly how bitonic sort ships in practice: CUDA samples launch `bitonicSortShared` once per (stage, stride), WebGPU records one dispatch per pass into a command encoder, and Metal encodes one compute pass each. The launches are the synchronisation — everything within a pass is independent, and the boundary between passes is the only barrier anyone needs. ## Starter code ```js // The schedule first, the data second. One kernel launch per pass. const gpu = new GPU({ mode }); const n = 256; const pass = gpu.createKernel(function (data, stage, stride) { const i = this.thread.x; const strideBit = Math.floor(i / stride) % 2; const dirBit = Math.floor(i / stage) % 2; let partner = i - stride; if (strideBit === 0) partner = i + stride; const me = data[i]; const other = data[partner]; if (strideBit === dirBit) return Math.min(me, other); return Math.max(me, other); }, { output: [n] }); const schedule = []; // TODO: fill `schedule` with every [stage, stride] pair — stages doubling // 2 → n, and within each stage strides halving stage / 2 → 1. // Notice that nothing in here can look at `data`. That is the point. console.log('passes:', schedule.length); console.log('schedule:', JSON.stringify(schedule)); // Float32Array from the start: gpu.js locks an argument's type on the first // call, and every pass hands back a Float32Array. let values = Float32Array.from(data); for (let i = 0; i < schedule.length; i++) { values = await pass(values, schedule[i][0], schedule[i][1]); } console.log('smallest:', values[0]); console.log('largest:', values[n - 1]); ``` --- Interactive version: https://gpu.rocks/learn/bitonic-sort-84e0728e/4 [Previous task](https://gpu.rocks/learn/bitonic-sort-84e0728e/3.md) · [Next task](https://gpu.rocks/learn/bitonic-sort-84e0728e/5.md) --- # Payoff: Sort a Real Array *Task 5 of 5 · [Bitonic Sort](https://gpu.rocks/learn/bitonic-sort-84e0728e.md) · GPU.js Learn* One constraint has been quietly true all along: **n must be a power of two**. Every thread's partner is its index with one bit flipped, and that only lands inside the array when the array fills the whole index space. Give the network 100 values and threads near the top reach for elements that do not exist — no error, no warning, just a result that is quietly wrong in a way that is very hard to see. The fix is the one every real implementation uses: **pad up to the next power of two** with a sentinel that is guaranteed to sort to the end, run the network on the padded array, then slice the padding off. 100 values become 128, sorted, and the last 28 slots come back full of sentinel. Padding costs a little wasted work and buys you an algorithm with no special cases at all. +Infinity is the textbook sentinel. This task uses a large finite one instead, because a padded array has to survive a round trip through a float texture, and a finite value always does. Anything comfortably above your data's maximum works. ## Goal **Goal:** sort all 100 of `values` by padding up to a power of two, running the network, and dropping the padding — then log the smallest and largest of the *real* values. ## Requirements - Choose `size` = the next power of two at or above `values.length`, and create the kernel with `output: [size]` - Pad with `PAD` up to `size`, run the full stage/stride schedule, then take the first `values.length` results - `console.log` the smallest and the largest of the sorted *real* values — not of the padded array ## Hint 1 — the next power of two Double until you clear the length: ```js let size = 1; while (size < values.length) size *= 2; ``` For 100 values that lands on 128. ## Hint 2 — padding and un-padding Pad before, slice after: ```js const padded = values.slice(); while (padded.length < size) padded.push(PAD); // … run the network … const sorted = Array.from(result).slice(0, values.length); ``` The sentinels all sort to the end, so the real values keep the front of the array in exactly the right order. ## Hint 3 — reading the answer `result[size - 1]` is a sentinel, not your largest value. The largest real value is `sorted[values.length - 1]`, after the slice. ## Same idea elsewhere Power-of-two padding is what every production sorter does with this network: CUDA's bitonic samples require it outright and pad in the host code, and library sorts (CUB, rocPRIM, WebGPU's community sort implementations) hide the same padding inside a friendlier signature. The general-case handling never lives in the kernel — it lives in the few lines around it, exactly where you just put it. ## Starter code ```js // 100 values. The network needs a power of two — so give it one. const gpu = new GPU({ mode }); // TODO: the next power of two at or above values.length (100 → 128). const size = values.length; const pass = gpu.createKernel(function (data, stage, stride) { const i = this.thread.x; const strideBit = Math.floor(i / stride) % 2; const dirBit = Math.floor(i / stage) % 2; let partner = i - stride; if (strideBit === 0) partner = i + stride; const me = data[i]; const other = data[partner]; if (strideBit === dirBit) return Math.min(me, other); return Math.max(me, other); }, { output: [size] }); // TODO: pad `values` up to `size` with PAD before sorting. const padded = values.slice(); let result = Float32Array.from(padded); for (let stage = 2; stage <= size; stage *= 2) { for (let stride = stage / 2; stride >= 1; stride /= 2) { result = await pass(result, stage, stride); } } // TODO: drop the padding before reading the answer. const sorted = Array.from(result); console.log('smallest:', sorted[0]); console.log('largest:', sorted[sorted.length - 1]); const reference = values.slice().sort((a, b) => a - b); console.log('matches Array.prototype.sort:', sorted.length === reference.length && sorted.every((v, i) => Math.abs(v - reference[i]) < 1e-3)); ``` --- Interactive version: https://gpu.rocks/learn/bitonic-sort-84e0728e/5 [Previous task](https://gpu.rocks/learn/bitonic-sort-84e0728e/4.md) --- # Radix Sort *Module of the free GPU.js GPGPU course · 6 tasks* A histogram, a scan and a gather assembled into the sort production GPU libraries actually run. ## Tasks 1. [Sort by One Digit](https://gpu.rocks/learn/radix-sort-fd3ff796/1.md) 2. [One Bit at a Time](https://gpu.rocks/learn/radix-sort-fd3ff796/2.md) 3. [Widen the Radix](https://gpu.rocks/learn/radix-sort-fd3ff796/3.md) 4. [Whose Value Lands Here?](https://gpu.rocks/learn/radix-sort-fd3ff796/4.md) 5. [The Whole Sort](https://gpu.rocks/learn/radix-sort-fd3ff796/5.md) 6. [Keys That Aren't Plain Integers](https://gpu.rocks/learn/radix-sort-fd3ff796/6.md) --- Interactive version: https://gpu.rocks/learn/radix-sort-fd3ff796 --- # Sort by One Digit *Task 1 of 6 · [Radix Sort](https://gpu.rocks/learn/radix-sort-fd3ff796.md) · GPU.js Learn* Radix sort never compares two keys. It sorts by **one digit at a time**, starting with the least significant, and after enough passes the array is sorted — which reads like a card trick until you watch it happen: ```js start by ones by tens 34 21 13 21 13 21 ← tie 13 34 27 ← tie 27 27 34 ``` The tens pass never looks at the ones digit. All it knows is that 21 and 27 both have a 2 — and the only reason 21 still comes out first is that the pass is **stable**: it leaves equal digits in the order it found them, and the ones pass had already put 21 first. Break that and the earlier pass's work is destroyed. An unstable tens pass may emit `13, 27, 21, 34`: perfectly ordered by tens digit, and not sorted. So each pass has to answer one question per element: *how many elements belong in front of me?* Everything with a smaller digit, plus everything with the same digit that started earlier. That second clause **is** stability. ## Figures - **equal digits keep the order they arrived in — cross those arrows and the previous pass was wasted** ## Goal **Goal:** for every element of `digits`, return the index it lands on in a stable one-digit pass. ## Requirements - Loop over all `this.constants.n` digits — one pass over the array per thread - Count every digit strictly smaller than yours - Break ties by original position: count an equal digit only when its index is before `this.thread.x` - Return the count — that count *is* the destination ## Hint 1 — two counts, one loop Walk every `j` from 0 to `n − 1` and ask two questions about `digits[j]`: is it smaller than mine? and if it is *equal* to mine, did it start before me? Either one puts that element in front of you. ## Hint 2 — the tie-break ```js const other = digits[j]; if (other < mine) { before++; } else if (other === mine && j < this.thread.x) { before++; } ``` The `j < this.thread.x` is the entire stability guarantee. Turn it round and the pass still sorts by digit — and still destroys everything the previous pass did. ## Same idea elsewhere Every production GPU radix sort is a *stable* sort, and not by accident: NVIDIA's CUB ranks each key inside its digit with `BlockRadixRank`, AMD's rocPRIM and Metal's sort primitives do the same. Stability is what makes multi-pass radix sorting work at all, and it is also what lets you sort key–value pairs, or sort by one field and then another, and trust the result. ## Starter code ```js // A stable pass answers one question per element: // how many elements belong in front of me? const gpu = new GPU({ mode }); const destination = gpu.createKernel(function (digits) { const mine = digits[this.thread.x]; let before = 0; for (let j = 0; j < this.constants.n; j++) { // TODO: count the digits that belong in front of this one — // everything smaller, plus the EQUAL digits that started earlier. if (digits[j] < mine) { before++; } } return before; }, { output: [16], constants: { n: 16 }, }); const dest = await destination(digits); console.log('digits: ', digits.join(' ')); console.log('destinations:', Array.from(dest).join(' ')); ``` --- Interactive version: https://gpu.rocks/learn/radix-sort-fd3ff796/1 [Next task](https://gpu.rocks/learn/radix-sort-fd3ff796/2.md) --- # One Bit at a Time *Task 2 of 6 · [Radix Sort](https://gpu.rocks/learn/radix-sort-fd3ff796.md) · GPU.js Learn* Take the narrowest radix there is: **2**. One bit per pass, two buckets, and the whole bucket table collapses to a single number — how many zeros there are. Zeros go to the front in the order they appeared; ones go behind them, also in order. So element `i`'s destination is either *how many zeros are before me*, or *every zero, plus how many ones are before me*. That count of preceding flags is a running total over a 0/1 array — the same shape stream compaction uses to close its gaps, except here neither half gets thrown away. The zero total is 32 numbers coming back to JavaScript, which is cheap to finish there. Moving the data is one line of ordinary JavaScript: `out[dest[i]] = keys[i]`. Enjoy it while it lasts. That line is a **scatter**, and it is the one thing a kernel cannot do — task 4 is about turning it inside out. ## Goal **Goal:** split `keys` by their low bit — even keys first, odd keys behind them, each half keeping its original order — and log the result. ## Requirements - `lowBit` returns `keys[this.thread.x] % 2` — a 0/1 flag per key - Count the zeros in plain JavaScript and pass the total into the second kernel - A zero's destination is how many zeros came before it; a one's is `zeros` plus how many ones came before it - `console.log` the reordered array (the starter's last line already does) ## Hint 1 — count your own kind Both halves need the same thing: how many *earlier* elements share your flag. One loop does it for either flag — ```js if (bits[j] === mine && j < this.thread.x) before++; ``` — and then only the starting point differs. ## Hint 2 — the two starting points The zero bucket starts at slot 0. The one bucket starts right after every zero, at slot `zeros`: ```js if (mine === 0) return before; return zeros + before; ``` ## Same idea elsewhere The one-bit split is where GPU radix sorting started — Satish, Harris and Garland's manycore sorting paper builds an entire sort from it, one bit at a time, with a prefix sum over the flag array supplying every destination. Modern hardware does the counting in a single instruction: CUDA's `__ballot_sync` + `__popc` and WGSL's `subgroupBallot` give a warp its flag ranks for free. ## Starter code ```js // Radix 2: two buckets, and the whole bucket table is one number. const gpu = new GPU({ mode }); const lowBit = gpu.createKernel(function (keys) { // TODO: return this key's low bit — 0 for even, 1 for odd return 0; }, { output: [32] }); const destination = gpu.createKernel(function (bits, zeros) { const mine = bits[this.thread.x]; let before = 0; for (let j = 0; j < this.constants.n; j++) { // TODO: count the EARLIER elements carrying the same flag before += 0; } // TODO: zeros start at slot 0; ones start after every zero return before; }, { output: [32], constants: { n: 32 }, }); const bits = await lowBit(keys); let zeros = 0; for (let i = 0; i < bits.length; i++) { if (bits[i] === 0) zeros++; } console.log('zeros:', zeros); const dest = await destination(bits, zeros); // A scatter — fine in JavaScript, impossible inside a kernel. Task 4 fixes it. const out = new Array(32); for (let i = 0; i < 32; i++) out[dest[i]] = keys[i]; console.log('after the pass:', out.join(' ')); ``` --- Interactive version: https://gpu.rocks/learn/radix-sort-fd3ff796/2 [Previous task](https://gpu.rocks/learn/radix-sort-fd3ff796/1.md) · [Next task](https://gpu.rocks/learn/radix-sort-fd3ff796/3.md) --- # Widen the Radix *Task 3 of 6 · [Radix Sort](https://gpu.rocks/learn/radix-sort-fd3ff796.md) · GPU.js Learn* One bit per pass means 32 passes for a 32-bit key. Four bits per pass means **16 buckets** and eight passes — the same total work rearranged, with far fewer round trips. That is the real engineering trade in radix sorting, and every library picks a number here (4 and 8 bits are the usual answers). The price is that the bucket table stops being a single number. You need a **histogram** — how many keys carry each of the 16 digits — and then a running total across the buckets to turn those counts into **starting offsets**: bucket `b` begins after every key whose digit is smaller than `b`. Both of those are primitives in their own right (and each has a module of its own); at 16 buckets they are small enough to write out in a loop. Note the word *smaller*. The scan is **exclusive**: bucket 0 starts at slot 0, and bucket `b`'s offset stops at `b − 1`. Include your own count and every bucket starts one whole bucket too far along. ## Figures - **count, scan, and every key knows its slot without comparing itself to anything (four buckets here, sixteen in the code)** ## Goal **Goal:** write both kernels — `histogram` counts the keys in each of the 16 digit buckets at a given `place`, and `offsets` turns those counts into starting slots with an exclusive scan. ## Requirements - The digit of a key at `place` is `Math.floor(key / place) % this.constants.radix` - `histogram`: 16 threads, thread `b` counts the keys whose digit is `b` - `offsets`: thread `b` totals `counts[0 … b−1]` — exclusive, so `offsets[0]` is `0` ## Hint 1 — extracting a digit `place` selects which digit you want: `1` for the ones digit, `16` for the sixteens, `256` for the next. Divide it away, then take what is left modulo the radix: ```js const d = Math.floor(keys[i] / place) % this.constants.radix; ``` Skip the `% 16` and `d` is the whole quotient, not a digit. ## Hint 2 — the histogram is a gather, not a scatter You cannot walk the keys and bump a counter — that is 64 threads fighting over 16 cells. Invert it: each of the 16 threads owns one bucket and walks the whole key array counting its own digit. `if (d === this.thread.x) count++;` ## Hint 3 — exclusive means stop early ```js for (let b = 0; b < this.constants.radix; b++) { if (b < this.thread.x) start += counts[b]; } ``` Sixteen values is far too few to be worth a clever scan; the point is the `b < this.thread.x`. ## Same idea elsewhere Count, scan, scatter is the skeleton of every real GPU radix sort: CUB and rocPRIM histogram each tile of keys locally, scan the per-tile histograms into global digit offsets, then move the keys. Choosing the radix is a genuine tuning knob — wider digits mean fewer passes over memory but a bigger bucket table to keep on chip, which is why 4 and 8 bits win in practice and 16 does not. ## Starter code ```js // 16 buckets now, so the bucket table needs counting and scanning. const gpu = new GPU({ mode }); const histogram = gpu.createKernel(function (keys, place) { let count = 0; for (let i = 0; i < this.constants.n; i++) { // TODO: this key's digit at `place`, then count it if it is MY bucket const d = 0; if (d === this.thread.x) { count++; } } return count; }, { output: [16], constants: { n: 64, radix: 16 }, }); const offsets = gpu.createKernel(function (counts) { let start = 0; for (let b = 0; b < this.constants.radix; b++) { // TODO: add the buckets STRICTLY BEFORE this one start += 0; } return start; }, { output: [16], constants: { radix: 16 }, }); const onesCounts = await histogram(keys, 1); console.log('ones-digit counts: ', Array.from(onesCounts).join(' ')); console.log('ones-digit offsets:', Array.from(await offsets(onesCounts)).join(' ')); const sixteensCounts = await histogram(keys, 16); console.log('16s-digit counts: ', Array.from(sixteensCounts).join(' ')); console.log('16s-digit offsets: ', Array.from(await offsets(sixteensCounts)).join(' ')); ``` --- Interactive version: https://gpu.rocks/learn/radix-sort-fd3ff796/3 [Previous task](https://gpu.rocks/learn/radix-sort-fd3ff796/2.md) · [Next task](https://gpu.rocks/learn/radix-sort-fd3ff796/4.md) --- # Whose Value Lands Here? *Task 4 of 6 · [Radix Sort](https://gpu.rocks/learn/radix-sort-fd3ff796.md) · GPU.js Learn* Everything so far produced a **plan**: for each element, the slot it belongs in. Executing the plan is the one move a kernel does not have. `out[destinations[i]] = keys[i]` is a **scatter** — a thread writing somewhere other than its own cell — and gpu.js has no such thing (Thinking in Parallel makes a whole module of why). So turn the question round, exactly as you would anywhere else on a GPU. Instead of *"where does my value go?"*, output slot `x` asks *"which element wants me?"* — sweep the destinations, find the one that equals `x`, and take that element's key. Every thread reads the whole plan and writes one cell. It looks wasteful and it is completely parallel, which on a GPU is the trade you take. ## Goal **Goal:** apply the permutation with a gather — output slot `x` holds the key of the element whose destination is `x`. ## Requirements - No writes anywhere but your own cell — the answer is a `return` - Sweep all `this.constants.n` destinations looking for `this.thread.x` - Return that element's *key*, not its index ## Hint 1 — which comparison? `destinations[i]` is where element `i` is *going*. Your cell is `this.thread.x`. So the element you want is the one where those two are equal — never `keys[destinations[this.thread.x]]`, which applies the permutation backwards. ## Hint 2 — the sweep ```js let value = 0; for (let i = 0; i < this.constants.n; i++) { if (destinations[i] === this.thread.x) { value = keys[i]; } } return value; ``` ## Same idea elsewhere Compute APIs do let you scatter — CUDA and WebGPU threads can store to any buffer address — and a production radix sort uses that: it writes keys straight to their computed offsets, which is why it also needs atomics and shared memory to arrange those offsets safely. Where you have no scatter, the inversion here is the standard replacement, and it is the same move a fragment shader has made since the beginning: every output pixel pulls what it needs. ## Starter code ```js // The plan is done. Now move the data — without a scatter. const gpu = new GPU({ mode }); const gather = gpu.createKernel(function (keys, destinations) { // TODO: find the element whose destination is THIS cell, // and return its key. return keys[this.thread.x]; }, { output: [64], constants: { n: 64 }, }); const sorted = await gather(keys, destinations); console.log('before:', keys.slice(0, 8).join(' '), '…'); console.log('after: ', Array.from(sorted).slice(0, 8).join(' '), '…'); ``` --- Interactive version: https://gpu.rocks/learn/radix-sort-fd3ff796/4 [Previous task](https://gpu.rocks/learn/radix-sort-fd3ff796/3.md) · [Next task](https://gpu.rocks/learn/radix-sort-fd3ff796/5.md) --- # The Whole Sort *Task 5 of 6 · [Radix Sort](https://gpu.rocks/learn/radix-sort-fd3ff796.md) · GPU.js Learn* Assemble it. 1,024 keys, all below 4,096 — three hex digits, so **three passes**. Each pass is the four kernels you have already written: histogram the digit, scan the counts into starting offsets, compute every element's destination, gather. The gathered array is the next pass's input. Only one piece is left: the destination rule at radix 16. It is task 1's stable rank with a bucket offset in front of it — `starts[digit]` puts you at the head of your bucket, and counting the earlier elements that share your digit places you inside it. Stability is still the whole game, and now you can see why: the last pass sorts by the *most* significant digit, and everything the first two passes achieved survives only inside its ties. Two things the driver must get right, both of them silent when they are wrong: the passes go **least significant digit first**, and the histogram and offsets are recomputed *every* pass — each one looks at a different digit of a differently ordered array. ## Figures - **low digit first, every pass stable — shown in base 10; the code counts in base 16** ## Goal **Goal:** finish the `destinations` kernel and drive three passes over `keys`, then log the smallest, middle and largest of the result. ## Requirements - `destinations` returns `starts[digit] + ` how many earlier elements share that digit - Three passes with `place` = `1`, then `16`, then `256` - Recompute the histogram and the offsets inside the loop — once per pass - `console.log` the sorted array's first, middle and last values ## Hint 1 — the destination rule Two halves. `starts[mine]` is where your bucket begins; the loop counts your rank inside it, exactly as in task 1 but restricted to your own digit: ```js if (d === mine && j < this.thread.x) rank++; ``` ## Hint 2 — the driver ```js for (let place = 1; place <= 256; place *= 16) { const counts = await histogram(values, place); const starts = await offsets(counts); const dest = await destinations(values, place, starts); values = await gather(values, dest); } ``` Three iterations, and every line of it inside the loop. ## Hint 3 — why low digit first Each pass makes its own digit the primary sort key and demotes everything the previous passes did to a tie-break. So the digit you want to dominate — the most significant one — has to be sorted *last*. Run the passes the other way and the array comes out ordered by its ones digit. ## Same idea elsewhere This is the shape of the real thing. `cub::DeviceRadixSort`, `thrust::sort` on integers, rocPRIM, and the Vulkan/WebGPU sort libraries all run this loop: per-pass digit histogram, scan to global offsets, stable scatter, repeat for as many digits as the key has. They beat this version on the two lines you did not write — the rank inside a bucket comes from a parallel scan instead of an O(n) sweep, and the move is a scatter into shared memory rather than a search — but the algorithm on the page is the algorithm they run. ## Starter code ```js // Four kernels, three passes. Only the destination rule is missing. const gpu = new GPU({ mode }); const histogram = gpu.createKernel(function (keys, place) { let count = 0; for (let i = 0; i < this.constants.n; i++) { const d = Math.floor(keys[i] / place) % this.constants.radix; if (d === this.thread.x) { count++; } } return count; }, { output: [16], constants: { n: 1024, radix: 16 } }); const offsets = gpu.createKernel(function (counts) { let start = 0; for (let b = 0; b < this.constants.radix; b++) { if (b < this.thread.x) { start += counts[b]; } } return start; }, { output: [16], constants: { radix: 16 } }); const destinations = gpu.createKernel(function (keys, place, starts) { // TODO: your digit is Math.floor(keys[this.thread.x] / place) % this.constants.radix. // Return starts[digit], plus how many EARLIER elements carry the same digit. return 0; }, { output: [1024], constants: { n: 1024, radix: 16 } }); const gather = gpu.createKernel(function (keys, dest) { let value = 0; for (let i = 0; i < this.constants.n; i++) { if (dest[i] === this.thread.x) { value = keys[i]; } } return value; }, { output: [1024], constants: { n: 1024 } }); // gpu.js locks an argument's type on a kernel's first call, and every kernel // here is fed another kernel's output — so the chain starts as a Float32Array. let values = Float32Array.from(keys); // TODO: three passes, least significant digit first — place = 1, then 16, // then 256. Each pass: counts → starts → destinations → gather, and the // gathered array becomes the next pass's input. console.log('smallest:', values[0], '| middle:', values[512], '| largest:', values[1023]); ``` --- Interactive version: https://gpu.rocks/learn/radix-sort-fd3ff796/5 [Previous task](https://gpu.rocks/learn/radix-sort-fd3ff796/4.md) · [Next task](https://gpu.rocks/learn/radix-sort-fd3ff796/6.md) --- # Keys That Aren't Plain Integers *Task 6 of 6 · [Radix Sort](https://gpu.rocks/learn/radix-sort-fd3ff796.md) · GPU.js Learn* The sort has a requirement it never had to say out loud: the key must be a **non-negative integer**, because `Math.floor(key / place) % 16` is only a digit for those. Hand it `−5` and the "digit" is `−5`, `starts[−5]` is off the front of the bucket table, and the pass returns junk. Signed integers have a clean fix that costs one map each way: **bias** them. Add 2,048 and the range −2048…2047 becomes 0…4095 — same order, all non-negative. Sort, then subtract the 2,048 back off. Production libraries call this step encoding the key, and the rule is the only one that matters: any order-preserving, invertible map into the unsigned integers makes radix sort work on your type. Floats are the same idea and a harder map — and this is where gpu.js stops. A float's ordering *is* its bit pattern's ordering, for positives; IEEE-754 negatives carry a sign bit on top and sort backwards under an unsigned comparison, so real implementations reinterpret the 32 bits and flip them (`x ^ 0x80000000` for a positive, `~x` for a negative) before sorting and flip back after. gpu.js does have `&`, `|`, `^`, `<<` and `>>` inside kernels, but the WebGL backend emulates them with GLSL integer loops and they part company with JavaScript the moment an operand goes negative (`-8 & 15` is 8 in JavaScript and on the CPU backend, and 0 on WebGL). More to the point, there is no way to *see* a float's bits at all: GLSL ES 1.00 has no `floatBitsToInt` and gpu.js exposes none, so `key & 15` truncates the value to an integer first — `3.5 & 15` is 3, the number's integer part, never its bit pattern. This course therefore sorts non-negative integer keys, and signed ones through the bias below; a CUDA or WebGPU implementation runs the same six kernels with a bit-flipping encoder in front. ## Goal **Goal:** sort `readings`, which run from −2048 to 2047, by biasing them into non-negative integers, sorting, and taking the bias back off. ## Requirements - `encode` adds `this.constants.bias` to every reading - `decode` subtracts it again - Run the given `radixSort` on the *encoded* values, and decode the result - `console.log` the sorted readings' smallest and largest values ## Hint 1 — the two maps Both kernels are one-line maps over their own cell — one adds `this.constants.bias`, the other subtracts it. Nothing about the sort changes. ## Hint 2 — the wiring ```js const sorted = await decode(await radixSort(await encode(readings))); ``` Encode on the way in, decode on the way out. Miss the decode and every value comes back 2,048 too high; miss the encode and the negative keys index off the front of the bucket table. ## Same idea elsewhere Every serious sorting library has this seam. CUB twiddles a key's bits in and out around the sort so that floats, signed integers and custom types all reduce to unsigned digits; rocPRIM and Thrust do the same, and newer CUB versions let you hand it a *decomposer* for your own struct. The sort never changes — only the map into unsigned integers does. ## Starter code ```js // The sort below is finished. It only accepts non-negative integer keys. const gpu = new GPU({ mode }); const encode = gpu.createKernel(function (v) { // TODO: shift every reading up so the smallest one becomes 0 return v[this.thread.x]; }, { output: [256], constants: { bias: 2048 } }); const decode = gpu.createKernel(function (v) { // TODO: undo the shift return v[this.thread.x]; }, { output: [256], constants: { bias: 2048 } }); const histogram = gpu.createKernel(function (keys, place) { let count = 0; for (let i = 0; i < this.constants.n; i++) { const d = Math.floor(keys[i] / place) % this.constants.radix; if (d === this.thread.x) { count++; } } return count; }, { output: [16], constants: { n: 256, radix: 16 } }); const offsets = gpu.createKernel(function (counts) { let start = 0; for (let b = 0; b < this.constants.radix; b++) { if (b < this.thread.x) { start += counts[b]; } } return start; }, { output: [16], constants: { radix: 16 } }); const destinations = gpu.createKernel(function (keys, place, starts) { const mine = Math.floor(keys[this.thread.x] / place) % this.constants.radix; let rank = 0; for (let j = 0; j < this.constants.n; j++) { const d = Math.floor(keys[j] / place) % this.constants.radix; if (d === mine && j < this.thread.x) { rank++; } } return starts[mine] + rank; }, { output: [256], constants: { n: 256, radix: 16 } }); const gather = gpu.createKernel(function (keys, dest) { let value = 0; for (let i = 0; i < this.constants.n; i++) { if (dest[i] === this.thread.x) { value = keys[i]; } } return value; }, { output: [256], constants: { n: 256 } }); async function radixSort(values) { // gpu.js locks an argument's type on a kernel's first call, and every pass // feeds one kernel's output into the next — so the chain starts as a // Float32Array whatever it was handed. Each stage is awaited before the // next reads it: the passes are a chain, not a set. let v = Float32Array.from(values); for (let place = 1; place <= 256; place *= 16) { const counts = await histogram(v, place); const starts = await offsets(counts); const dest = await destinations(v, place, starts); v = await gather(v, dest); } return v; } // TODO: bias the readings on the way in, and take the bias off on the way out. const sorted = await radixSort(readings); console.log('smallest:', sorted[0], '| largest:', sorted[255]); ``` --- Interactive version: https://gpu.rocks/learn/radix-sort-fd3ff796/6 [Previous task](https://gpu.rocks/learn/radix-sort-fd3ff796/5.md) --- # Matrix Multiply *Module of the free GPU.js GPGPU course · 5 tasks* The canonical GPGPU workload: from naive triple loop to a kernel that scales. ## Tasks 1. [One Cell, One Dot Product](https://gpu.rocks/learn/matrix-multiply-972e080b/1.md) 2. [The Full Grid: Matrix × Matrix](https://gpu.rocks/learn/matrix-multiply-972e080b/2.md) 3. [Rectangular: Three Different Sizes](https://gpu.rocks/learn/matrix-multiply-972e080b/3.md) 4. [Transpose: Swap the Axes](https://gpu.rocks/learn/matrix-multiply-972e080b/4.md) 5. [One Kernel, Any Size](https://gpu.rocks/learn/matrix-multiply-972e080b/5.md) --- Interactive version: https://gpu.rocks/learn/matrix-multiply-972e080b --- # One Cell, One Dot Product *Task 1 of 5 · [Matrix Multiply](https://gpu.rocks/learn/matrix-multiply-972e080b.md) · GPU.js Learn* Matrix multiply is the workload GPUs were born for, and every cell of the result is the same small machine: a **dot product**. Multiply matching elements of two vectors, add the products up, one number comes out. Get one cell right before launching a grid of them. Notice what is parallel and what is not. The loop over `k` runs *sequentially inside one thread* — GPUs don't parallelize the sum, they parallelize the thousands of *independent* sums a full matrix needs. This task needs exactly one, so the launch is a single thread: `output: [1]`. ## Goal **Goal:** make the kernel return the dot product of the 16-vectors `a` and `b` — one output cell holding `a[0]·b[0] + a[1]·b[1] + … + a[15]·b[15]`. ## Requirements - Change `output` to a single cell: `[1]` - Loop `k` from 0 to 15 *inside* the kernel — statically bounded loops are allowed - Accumulate `a[k] * b[k]` into a running sum and return it ## Hint 1 — a loop? inside a kernel? Yes — as long as the bound is a compile-time constant: `for (let k = 0; k < 16; k++) { … }`. The loop belongs to one thread; the parallelism (next task) comes from launching many threads that each own a loop. ## Hint 2 — the whole body ```js let sum = 0; for (let k = 0; k < 16; k++) { sum += a[k] * b[k]; } return sum; ``` — and `output: [1]` so only one thread runs it. ## Same idea elsewhere Every GPU linear-algebra library — cuBLAS on CUDA, rocBLAS on ROCm, Metal Performance Shaders — bottoms out in this exact shape: one output element, one multiply-accumulate loop. All their sophistication goes into feeding that loop faster. ## Starter code ```js // A dot product folds two 16-vectors into ONE number. const gpu = new GPU({ mode }); const dot = gpu.createKernel(function (a, b) { // TODO: one thread owns the whole sum. Loop k = 0..15, // multiply matching elements, add them up, return the total. return a[this.thread.x] * b[this.thread.x]; }, { // TODO: how many output cells does a dot product have? output: [16], }); console.log(await dot(a, b)); ``` --- Interactive version: https://gpu.rocks/learn/matrix-multiply-972e080b/1 [Next task](https://gpu.rocks/learn/matrix-multiply-972e080b/2.md) --- # The Full Grid: Matrix × Matrix *Task 2 of 5 · [Matrix Multiply](https://gpu.rocks/learn/matrix-multiply-972e080b.md) · GPU.js Learn* On the CPU, `C = A × B` is the classic triple loop: over rows, over columns, over `k`. On the GPU the outer two loops **vanish into the launch** — `output: [16, 16]` starts 256 threads, one per cell of `C`, and only the innermost loop survives inside the kernel. Cell `C[y][x]` is the dot product of **row y of A** with **column x of B**: walk `k` across the row `a[y][k]` and down the column `b[k][x]`. Same loop as task 1 — now every thread aims it at its own row/column pair. ## Figures - **row y across, column x down — 256 threads, each owning one dot product** ## Goal **Goal:** compute the 16×16 product `matA × matB` — each thread returns the dot product of its row of `a` with its column of `b`. ## Requirements - Keep `output: [16, 16]` — one thread per cell of C - Loop `k` over the 16 shared elements - Accumulate `a[this.thread.y][k] * b[k][this.thread.x]` and return the sum ## Hint 1 — row and column `this.thread.y` picks the row of `a`, `this.thread.x` picks the column of `b`, and `k` is the only index that moves during the loop. ## Hint 2 — the inner loop ```js let sum = 0; for (let k = 0; k < 16; k++) { sum += a[this.thread.y][k] * b[k][this.thread.x]; } return sum; ``` ## Same idea elsewhere This one-thread-per-output-cell matmul is the "naive kernel" every WebGPU and CUDA tutorial starts from — and the baseline that tiled, shared-memory versions are measured against. The structure you just wrote is their starting point too. ## Starter code ```js // output: [16, 16] launches 256 threads — one per cell of C. const gpu = new GPU({ mode }); const multiply = gpu.createKernel(function (a, b) { // TODO: this is the ELEMENTWISE product — one term, no loop. // C[y][x] needs the whole dot product: loop k over the 16 // shared elements, walking a's row and b's column. return a[this.thread.y][this.thread.x] * b[this.thread.y][this.thread.x]; }, { output: [16, 16] }); const c = await multiply(matA, matB); console.log('C[0][0] =', c[0][0]); ``` --- Interactive version: https://gpu.rocks/learn/matrix-multiply-972e080b/2 [Previous task](https://gpu.rocks/learn/matrix-multiply-972e080b/1.md) · [Next task](https://gpu.rocks/learn/matrix-multiply-972e080b/3.md) --- # Rectangular: Three Different Sizes *Task 3 of 5 · [Matrix Multiply](https://gpu.rocks/learn/matrix-multiply-972e080b.md) · GPU.js Learn* Square matrices hide a trap: every dimension is 16, so any loop bound "works". Real matmuls are rectangular — here `rectA` is 8×32 (8 rows, 32 columns) and `rectB` is 32×12, so the product is **8×12**. Suddenly there are three different sizes and each belongs somewhere specific. Two of them shape the launch: `output: [width, height]` = [columns of B, rows of A] = `[12, 8]` — already set up below. The third, 32, is the **shared dimension**: A's columns must equal B's rows, and that's the only dimension the loop is allowed to run over. ## Figures - **8 and 12 shape the launch; 32 is the loop’s whole world** ## Goal **Goal:** compute the 8×12 product `rectA × rectB` — fix the inner loop so it covers the full shared dimension of 32. ## Requirements - Keep `output: [12, 8]` — columns of B across, rows of A down - Loop `k` over the *shared* dimension: all 32 of it - Sum `a[this.thread.y][k] * b[k][this.thread.x]` as before ## Hint 1 — which size does the loop get? The loop walks *across* a row of A (32 long) and *down* a column of B (also 32 long — that's why the shapes are compatible). Neither 8 nor 12 appears in the loop at all. ## Hint 2 — the fix The starter loop stops at 12 — it sums only the first 12 of 32 terms. Change the bound: `for (let k = 0; k < 32; k++)`. ## Same idea elsewhere BLAS calls this M, N, K — `sgemm(M, N, K, …)` in cuBLAS and rocBLAS keeps the three sizes as separate parameters for exactly this reason. Mixing them up is the classic GEMM bug on every platform, not just here. ## Starter code ```js // (8×32) times (32×12) → 8×12. Three sizes, three different jobs. const gpu = new GPU({ mode }); const multiply = gpu.createKernel(function (a, b) { let sum = 0; // TODO: this loop stops too early — it covers 12 of the 32 // shared elements. Which of the three sizes does the loop own? for (let k = 0; k < 12; k++) { sum += a[this.thread.y][k] * b[k][this.thread.x]; } return sum; }, { // [width, height] = [columns of B, rows of A] output: [12, 8], }); const c = await multiply(rectA, rectB); console.log('rows:', c.length, 'cols:', c[0].length); ``` --- Interactive version: https://gpu.rocks/learn/matrix-multiply-972e080b/3 [Previous task](https://gpu.rocks/learn/matrix-multiply-972e080b/2.md) · [Next task](https://gpu.rocks/learn/matrix-multiply-972e080b/4.md) --- # Transpose: Swap the Axes *Task 4 of 5 · [Matrix Multiply](https://gpu.rocks/learn/matrix-multiply-972e080b.md) · GPU.js Learn* Look back at the matmul loop: `b[k][x]` walks *down a column* — each step jumps a whole row of memory. GPUs hate that; neighbouring threads reading neighbouring addresses is where their bandwidth comes from. The standard fix is to **transpose** B first, turning column walks into row walks. A transpose kernel is one line of insight: the thread that owns output cell `[y][x]` reads input cell `[x][y]`. With a rectangular 24×40 input the flip is visible in the shapes too — the result is 40×24, so `output: [24, 40]`. ## Goal **Goal:** transpose the 24×40 matrix `matWide` — output cell `[y][x]` holds `matWide[x][y]`, giving a 40×24 result. ## Requirements - Keep `output: [24, 40]` — the transposed width and height - Each thread reads exactly one input cell: indices *swapped* - No loops — a transpose moves data, it computes nothing ## Hint 1 — who reads what The thread writing output cell `[y][x]` must read the input cell whose row and column are swapped. Both `this.thread.x` and `this.thread.y` appear — just not in their usual seats. ## Hint 2 — the one-liner `return m[this.thread.x][this.thread.y];` ## Same idea elsewhere Memory-coalescing is why cuBLAS and rocBLAS pick a different tiled kernel for each setting of GEMM's `transA/transB` flags — whichever layout you pass, threads must still read side by side — and why Metal and WebGPU matmul kernels pre-stage tiles in threadgroup memory. Reordering data for coalesced access is half of GPU performance work. ## Starter code ```js // The thread for output [y][x] reads input... where? const gpu = new GPU({ mode }); const transpose = gpu.createKernel(function (m) { // TODO: return the input cell with row and column swapped. return 0; }, { // input is 24 rows × 40 cols → output is 40 rows × 24 cols output: [24, 40], }); const t = await transpose(matWide); console.log('rows:', t.length, 'cols:', t[0].length); ``` --- Interactive version: https://gpu.rocks/learn/matrix-multiply-972e080b/4 [Previous task](https://gpu.rocks/learn/matrix-multiply-972e080b/3.md) · [Next task](https://gpu.rocks/learn/matrix-multiply-972e080b/5.md) --- # One Kernel, Any Size *Task 5 of 5 · [Matrix Multiply](https://gpu.rocks/learn/matrix-multiply-972e080b.md) · GPU.js Learn* Every kernel so far had its size welded on: `output: [16, 16]`, loop to 16. Real code multiplies whatever matrices show up. gpu.js has three switches for that: `dynamicOutput: true` lets you call `kernel.setOutput([n, n])` before each run, `dynamicArguments: true` lets the input arrays change size between calls, and `loopMaxIterations` raises the safety cap so the loop bound can be a *runtime argument* instead of a constant. Pass the size in as a plain number, loop `k < size`, and one kernel object serves an 8×8 and a 48×48 multiply back to back. This is the payoff of the module: the naive triple loop from task 2, now packaged as a function that scales. ## Goal **Goal:** make `multiply(a, b)` work for any square size up to 64 using a *single* kernel — verify it on the 8×8 and 48×48 pairs provided. ## Requirements - Kernel options: `dynamicOutput`, `dynamicArguments`, and `loopMaxIterations: 64` - Take `size` as a third kernel argument and loop `k < size` - In `multiply`, call `matmul.setOutput([n, n])` before `await`-ing the kernel - Exactly one `createKernel` call serves both sizes ## Hint 1 — why the cap? On the GPU backend a loop bound that isn't a compile-time constant becomes ```js for (i = 0; i < LOOP_MAX; i++) { if (!(i < size)) break; // … } ``` in the shader — `loopMaxIterations` *is* that LOOP_MAX. Set it to the largest size you'll ever pass: 64 here. ## Hint 2 — sizing per call Inside `multiply` — which is `async`, because a kernel call is awaited: ```js const n = a.length; matmul.setOutput([n, n]); return await matmul(a, b, n); ``` — set the launch shape first, then invoke with the size as the last argument. Its callers then `await multiply(…)` in turn. ## Hint 3 — the kernel ```js function (a, b, size) { let sum = 0; for (let k = 0; k < size; k++) { sum += a[this.thread.y][k] * b[k][this.thread.x]; } return sum; } ``` with options ```js { dynamicOutput: true, dynamicArguments: true, loopMaxIterations: 64, } ``` ## Same idea elsewhere Shipping one kernel that covers a size range is standard practice everywhere: CUDA kernels take M, N, K as launch parameters and pick grid dimensions at call time, WebGPU dispatches a runtime-computed number of workgroups, and Metal binds sizes through a constant buffer. Compile once, launch at any size — exactly what you just built. ## Starter code ```js // One kernel, any size — no rebuilding between calls. const gpu = new GPU({ mode }); // TODO: this kernel is welded to 8×8. Free it: dynamicOutput, // dynamicArguments, loopMaxIterations: 64, and a size argument. const matmul = gpu.createKernel(function (a, b) { let sum = 0; for (let k = 0; k < 8; k++) { sum += a[this.thread.y][k] * b[k][this.thread.x]; } return sum; }, { output: [8, 8] }); async function multiply(a, b) { const n = a.length; // TODO: point the kernel at an n×n launch before invoking, // and pass n in so the loop knows where to stop. return await matmul(a, b); } console.log('8×8 C[0][0] =', (await multiply(smallA, smallB))[0][0]); console.log('48×48 C[0][0] =', (await multiply(bigA, bigB))[0][0]); ``` --- Interactive version: https://gpu.rocks/learn/matrix-multiply-972e080b/5 [Previous task](https://gpu.rocks/learn/matrix-multiply-972e080b/4.md) --- # Monte Carlo Methods *Module of the free GPU.js GPGPU course · 4 tasks* Estimate π, price an option, integrate the un-integrable — with a million random samples. ## Tasks 1. [Darts at a Quarter Circle](https://gpu.rocks/learn/monte-carlo-methods-9ea19810/1.md) 2. [Reduce 65,536 Hits to π](https://gpu.rocks/learn/monte-carlo-methods-9ea19810/2.md) 3. [Integrate the Un-integrable](https://gpu.rocks/learn/monte-carlo-methods-9ea19810/3.md) 4. [Price an Option](https://gpu.rocks/learn/monte-carlo-methods-9ea19810/4.md) --- Interactive version: https://gpu.rocks/learn/monte-carlo-methods-9ea19810 --- # Darts at a Quarter Circle *Task 1 of 4 · [Monte Carlo Methods](https://gpu.rocks/learn/monte-carlo-methods-9ea19810.md) · GPU.js Learn* Monte Carlo is statistics as a weapon: throw random darts at a square, and the *fraction* that lands inside the quarter circle inscribed in it approaches its area — π/4. No geometry beyond the Pythagorean check `x² + y² ≤ 1`. The method is embarrassingly parallel: every dart is judged independently, so every dart gets its own thread. One rule, though — the randomness is made **outside** the kernel. `xs` and `ys` hold 4,096 seeded dart positions; the kernel's job is only the verdict. Deterministic data in, deterministic verdicts out — that's what makes GPU Monte Carlo debuggable. ## Figures - **throw darts, count hits — the circle’s area falls out of the ratio** ## Goal **Goal:** make each thread return `1` if its dart `(xs[x], ys[x])` lands inside the unit quarter circle, else `0`. ## Requirements - Read this thread's dart: `xs[this.thread.x]` and `ys[this.thread.x]` - Inside means `x² + y² ≤ 1` — no `Math.sqrt` needed - Return exactly `1` or `0`, nothing in between ## Hint 1 — skip the square root The dart is inside when its distance to the origin is ≤ 1 — and distances compare the same way squared: `x * x + y * y <= 1` is the whole test. ## Hint 2 — the verdict ```js if (x * x + y * y <= 1) { return 1; } return 0; ``` — a branch is fine in a kernel as long as every path returns. ## Same idea elsewhere Real GPU Monte Carlo keeps the random numbers on-device — CUDA ships cuRAND, and WebGPU/Metal compute shaders run counter-based generators like Philox per thread — but the shape is exactly this: one thread, one sample, one verdict. ## Starter code ```js // 4,096 seeded darts. One thread judges one dart. const gpu = new GPU({ mode }); const inside = gpu.createKernel(function (xs, ys) { const x = xs[this.thread.x]; const y = ys[this.thread.x]; // TODO: return 1 if this dart lands inside the unit quarter // circle (x² + y² ≤ 1), otherwise 0. return 0; }, { output: [4096] }); const hits = await inside(xs, ys); let count = 0; for (let i = 0; i < hits.length; i++) count += hits[i]; console.log(count, 'of 4096 darts hit — π ≈', (4 * count) / 4096); ``` --- Interactive version: https://gpu.rocks/learn/monte-carlo-methods-9ea19810/1 [Next task](https://gpu.rocks/learn/monte-carlo-methods-9ea19810/2.md) --- # Reduce 65,536 Hits to π *Task 2 of 4 · [Monte Carlo Methods](https://gpu.rocks/learn/monte-carlo-methods-9ea19810.md) · GPU.js Learn* Last task summed the verdicts with a JavaScript loop — fine for 4,096 darts, wasteful for 65,536 and absurd for a billion. The GPU answer is a **parallel reduction**: don't ship every verdict home, ship *partial sums*. A second kernel with 256 threads gives each thread its own 256-verdict slice to total, collapsing 65,536 numbers to 256 in one launch. Thread `t` owns the slice starting at `t * 256` — a statically bounded `for` loop walks it. JavaScript then folds the 256 partials into the final count, and `4 × hits / 65536` is your π. ## Goal **Goal:** complete the `partialSums` kernel so each of its 256 threads returns the sum of its own 256-element slice of `hits`, then log the π estimate. ## Requirements - Kernel 1 (`inside`) is last task's dart test — leave it as is - In `partialSums`, thread `x` starts at `this.thread.x * 256` - Loop `i = 0…255` and accumulate `hits[base + i]` - Total the 256 partials in JavaScript and log `4 * total / 65536` ## Hint 1 — who sums what Thread 0 sums `hits[0…255]`, thread 1 sums `hits[256…511]`, and so on. The starting offset is `this.thread.x * 256`. ## Hint 2 — the loop ```js const base = this.thread.x * 256; let sum = 0; for (let i = 0; i < 256; i++) { sum += hits[base + i]; } return sum; ``` The bound is a literal, so gpu.js can unroll it safely. ## Same idea elsewhere Reduction is *the* fundamental pattern of GPU computing — CUDA has warp shuffles and the CUB library for it, Metal has SIMD-group reductions, WebGPU builds them from workgroup shared memory. Chunked partial sums like yours are always the first rung. ## Starter code ```js // 65,536 darts. Kernel 1 judges them; kernel 2 sums them — in parallel. const gpu = new GPU({ mode }); const inside = gpu.createKernel(function (xs, ys) { const x = xs[this.thread.x]; const y = ys[this.thread.x]; if (x * x + y * y <= 1) { return 1; } return 0; }, { output: [65536] }); const partialSums = gpu.createKernel(function (hits) { // TODO: sum THIS thread's 256-element slice of hits. // Slice start: this.thread.x * 256. return hits[this.thread.x]; }, { output: [256] }); const hits = await inside(xs, ys); const partials = await partialSums(hits); let total = 0; for (let i = 0; i < partials.length; i++) total += partials[i]; console.log('π ≈', (4 * total) / 65536); ``` --- Interactive version: https://gpu.rocks/learn/monte-carlo-methods-9ea19810/2 [Previous task](https://gpu.rocks/learn/monte-carlo-methods-9ea19810/1.md) · [Next task](https://gpu.rocks/learn/monte-carlo-methods-9ea19810/3.md) --- # Integrate the Un-integrable *Task 3 of 4 · [Monte Carlo Methods](https://gpu.rocks/learn/monte-carlo-methods-9ea19810.md) · GPU.js Learn* `e^(−x²)` — the bell curve — famously has **no elementary antiderivative**. No substitution, no parts, no closed form. Monte Carlo doesn't care: for uniform samples on [0, 1], the *average* of `f(x)` converges to `∫₀¹ f(x) dx`. Sampling beats symbolic calculus. And here's the efficiency move over last task: instead of one kernel to evaluate and another to reduce, **fuse them**. Each of 256 threads walks its own 64-sample slice, evaluating `e^(−x²)` and accumulating in one pass — 16,384 evaluations, one launch, 256 numbers back. ## Figures - **no antiderivative, no problem — average enough heights and it’s the area** ## Goal **Goal:** make each thread return the sum of `e^(−x²)` over its 64-sample slice of `samples`, so the logged mean lands on `≈ 0.7468`. ## Requirements - Thread `x` owns the slice starting at `this.thread.x * 64` - Evaluate `Math.exp(-x * x)` for each sample — inside the loop, inside the kernel - Return the slice sum; JavaScript divides the grand total by 16384 ## Hint 1 — mean value, not area sampling No darts this time: the estimator is just the average height of the curve, `(1/N) Σ f(xᵢ)`, times the interval width (here 1). You only need `f`, not a hit test. ## Hint 2 — one line changes The loop skeleton is last task's reduction. Swap what you accumulate: ```js const x = xs[base + i]; sum += Math.exp(-x * x); ``` ## Same idea elsewhere Fusing the map into the reduction halves the memory traffic — the same reasoning behind kernel fusion in CUDA and ROCm, and behind doing per-workgroup sums in a single WebGPU compute pass instead of two. Bandwidth, not arithmetic, is usually the bill. ## Starter code ```js // ∫₀¹ e^(−x²) dx has no closed form. Estimate it: average f over // 16,384 seeded samples — 256 threads × 64 samples each, fused map+reduce. const gpu = new GPU({ mode }); const partials = gpu.createKernel(function (xs) { const base = this.thread.x * 64; let sum = 0; for (let i = 0; i < 64; i++) { const x = xs[base + i]; // TODO: accumulate f(x) = e^(−x²) — not x itself. sum += x; } return sum; }, { output: [256] }); const sums = await partials(samples); let total = 0; for (let i = 0; i < sums.length; i++) total += sums[i]; console.log('∫₀¹ e^(−x²) dx ≈', total / 16384, '(truth ≈ 0.746824)'); ``` --- Interactive version: https://gpu.rocks/learn/monte-carlo-methods-9ea19810/3 [Previous task](https://gpu.rocks/learn/monte-carlo-methods-9ea19810/2.md) · [Next task](https://gpu.rocks/learn/monte-carlo-methods-9ea19810/4.md) --- # Price an Option *Task 4 of 4 · [Monte Carlo Methods](https://gpu.rocks/learn/monte-carlo-methods-9ea19810.md) · GPU.js Learn* The payoff. A **European call option** is the right to buy a stock at a fixed strike price K on a future date — worth `max(S_T − K, 0)` when the stock finishes at `S_T`, and its fair price today is the *discounted expected payoff*. Expectations are integrals, and you just learned to integrate by sampling. Each thread simulates one possible market: under the standard log-normal model, a pre-drawn normal shock `z` gives `S_T = S0 · e^(drift + volT · z)`. Your kernel turns 16,384 shocks into 16,384 payoffs; JavaScript averages and discounts. Stock at 100, strike 105, one year out — the Black–Scholes formula says the answer is ≈ 7.13. Your simulation should agree. ## Goal **Goal:** complete the payoff kernel — simulate this thread's final stock price and return the option payoff `max(S_T − strike, 0)`. ## Requirements - Simulate the final price: `s0 * Math.exp(drift + volT * z)` (already wired) - Return the call payoff: `Math.max(st - strike, 0)` — an option never goes negative - Average the payoffs and discount by `Math.exp(-RATE * T)` in JavaScript ## Hint 1 — why the max? If the stock ends below the strike you simply don't exercise — the option expires worthless, payoff 0, never negative. Forgetting the `max` drags the average down by every losing path (the price comes out near −1.9 instead of ≈ 7.1). ## Hint 2 — the kernel body `return Math.max(st - strike, 0);` — `Math.max` works inside kernels, and beats an `if` here. ## Same idea elsewhere This is production reality: quant desks run exactly this workload on CUDA and ROCm — millions of simulated paths per pricing call, one thread per path, then a reduction — because exotic options have no closed form at all. You now hold the whole recipe. ## Starter code ```js // Fair price = discounted average payoff over simulated futures. // Stock at 100, strike 105, 3% rate, 20% volatility, 1 year to expiry. const S0 = 100, STRIKE = 105, RATE = 0.03, SIGMA = 0.2, T = 1; const gpu = new GPU({ mode }); const payoff = gpu.createKernel(function (normals, s0, strike, drift, volT) { const z = normals[this.thread.x]; const st = s0 * Math.exp(drift + volT * z); // this thread's final stock price // TODO: return the call payoff — st minus strike, but never below zero. return st - strike; }, { output: [16384] }); const payoffs = await payoff(normals, S0, STRIKE, (RATE - SIGMA * SIGMA / 2) * T, SIGMA * Math.sqrt(T)); let sum = 0; for (let i = 0; i < payoffs.length; i++) sum += payoffs[i]; const price = Math.exp(-RATE * T) * (sum / payoffs.length); console.log('Monte Carlo price:', price, '— Black–Scholes says ≈ 7.13'); ``` --- Interactive version: https://gpu.rocks/learn/monte-carlo-methods-9ea19810/4 [Previous task](https://gpu.rocks/learn/monte-carlo-methods-9ea19810/3.md) --- # N-Body Gravity *Module of the free GPU.js GPGPU course · 5 tasks* Every particle pulls on every other: an O(n²) problem the GPU eats for breakfast. ## Tasks 1. [The Pull of One Star](https://gpu.rocks/learn/n-body-gravity-5de47751/1.md) 2. [Every Body Pulls on Every Body](https://gpu.rocks/learn/n-body-gravity-5de47751/2.md) 3. [Softening the Singularity](https://gpu.rocks/learn/n-body-gravity-5de47751/3.md) 4. [One Tick of the Clock](https://gpu.rocks/learn/n-body-gravity-5de47751/4.md) 5. [Put It Together: 128 Bodies](https://gpu.rocks/learn/n-body-gravity-5de47751/5.md) --- Interactive version: https://gpu.rocks/learn/n-body-gravity-5de47751 --- # The Pull of One Star *Task 1 of 5 · [N-Body Gravity](https://gpu.rocks/learn/n-body-gravity-5de47751.md) · GPU.js Learn* Newton, in one line: the gravitational pull between two bodies is `G · m₁ · m₂ / r²`. Divide out the mass being pulled and you get its **acceleration** — `a = G · M / r²` — which only depends on the *other* body. In this course `G = 1` (astrophysicists rescale units to do exactly this, so you're in good company). Here 64 bodies drift around one star. Each thread owns one body — its position is `posX[this.thread.x]`, `posY[this.thread.x]` — and answers a single question: *how hard does the star pull on me?* No loops yet; that's next. ## Goal **Goal:** make the kernel return the strength of the star's pull on this thread's body: `starMass / r²`. ## Requirements - Use the `dx`, `dy` offsets to the star (already wired up) - Compute the squared distance: `r² = dx·dx + dy·dy` - Return `starMass / r²` — inverse-square, with `G = 1` ## Hint 1 — no square root needed The law wants `r²`, and `dx*dx + dy*dy` *is* `r²`. Taking `Math.sqrt` just to square it again is the most popular way to waste GPU cycles. ## Hint 2 — the one-liner `return starMass / (dx * dx + dy * dy);` ## Same idea elsewhere One-thread-per-body is the opening move of GPU physics everywhere: the CUDA SDK's classic `nbody` sample assigns body *i* to thread *i* exactly like this, and its HIP port runs the identical mapping on ROCm. ## Starter code ```js // 64 bodies, one star. Each thread owns one body and asks: // how hard does the star pull on ME? const gpu = new GPU({ mode }); const pull = gpu.createKernel(function (posX, posY, starX, starY, starMass) { const dx = starX - posX[this.thread.x]; const dy = starY - posY[this.thread.x]; // TODO: inverse-square law — return starMass / r², // where r² = dx·dx + dy·dy. (G = 1 here.) return 0; }, { output: [64] }); const strength = await pull(posX, posY, 0, 0, 100); console.log('pull on body 0:', strength[0]); ``` --- Interactive version: https://gpu.rocks/learn/n-body-gravity-5de47751/1 [Next task](https://gpu.rocks/learn/n-body-gravity-5de47751/2.md) --- # Every Body Pulls on Every Body *Task 2 of 5 · [N-Body Gravity](https://gpu.rocks/learn/n-body-gravity-5de47751.md) · GPU.js Learn* Real gravity has no star at the center — **every body pulls on every other**. For 64 bodies that's 64 × 63 interactions; for a million, half a trillion. On the GPU the shape is beautiful: the *outer* loop over bodies becomes 64 parallel threads, and each thread keeps a small *inner* loop over the other 63. O(n²) work, O(n) time per thread, all at once. One wrinkle: pulls are **vectors** now, not strengths. The unit direction from you to body *j* is `(dx / r, dy / r)`, and the strength is `mass[j] / r²` — multiply them and the x-component of each contribution is `mass[j] · dx / r³`. This kernel sums just the x-components; skip yourself, or you'll divide by zero. ## Figures - **sixty-three pulls per body, summed in one thread — n² work, n time** ## Goal **Goal:** complete the inner loop so each thread returns the net x-acceleration on its body: the sum of `mass[j] · dx / r³` over every other body. ## Requirements - Loop `j` over all `this.constants.n` bodies - Skip yourself — the `j !== this.thread.x` guard is already there - Accumulate `mass[j] * dx / (r² · r)` into `ax` ## Hint 1 — where does r³ come from? Direction `dx / r` times strength `1 / r²` is `dx / r³`. With `r2 = dx*dx + dy*dy` in hand, that's `r2 * Math.sqrt(r2)` — one square root per pair. ## Hint 2 — the loop body ```js const dx = posX[j] - myX; const dy = posY[j] - myY; const r2 = dx * dx + dy * dy; ax += mass[j] * dx / (r2 * Math.sqrt(r2)); ``` ## Same idea elsewhere This loop-inside-a-thread is the canonical O(n²) GPU pattern. Fast CUDA and ROCm n-body codes keep exactly this loop but *tile* it: a thread block stages a chunk of bodies in shared memory so all threads reuse the loads — WebGPU's `var` and Metal's threadgroup memory exist for the same trick. ## Starter code ```js // Newton, vectorised: this thread's body feels EVERY other body. // The inner loop is O(n) — but all 64 of them run at once. const gpu = new GPU({ mode }); const accelX = gpu.createKernel(function (posX, posY, mass) { const myX = posX[this.thread.x]; const myY = posY[this.thread.x]; let ax = 0; for (let j = 0; j < this.constants.n; j++) { if (j !== this.thread.x) { // TODO: dx, dy → r² → accumulate mass[j] * dx / r³ // (dx / r is the direction, 1 / r² is the strength.) ax += 0; } } return ax; }, { output: [64], constants: { n: 64 } }); const ax = await accelX(posX, posY, mass); console.log('net x-pull on body 0:', ax[0]); ``` --- Interactive version: https://gpu.rocks/learn/n-body-gravity-5de47751/2 [Previous task](https://gpu.rocks/learn/n-body-gravity-5de47751/1.md) · [Next task](https://gpu.rocks/learn/n-body-gravity-5de47751/3.md) --- # Softening the Singularity *Task 3 of 5 · [N-Body Gravity](https://gpu.rocks/learn/n-body-gravity-5de47751.md) · GPU.js Learn* Two of this task's bodies sit `0.001` apart. Plug that into `1 / r²` and their mutual pull is about a *million* — one tick of the clock later they're flung out of the galaxy. That's not physics; it's what happens when a point-mass model meets a finite time step. The standard fix is **Plummer softening**: replace `r²` with `r² + ε²`. Far away, `ε` changes nothing; up close, the force flattens out instead of diverging. Bonus: the `j !== i` self-check becomes dead weight — your own term has `dx = dy = 0`, so it contributes exactly zero. Drop the branch; GPUs run happiest when every thread takes the same path. ## Figures - **close encounters flatten out instead of blowing up** ## Goal **Goal:** soften the kernel — use `r² + soft²`, drop the self-check, and return the full `[ax, ay]` pair. ## Requirements - Squared distance becomes `dx·dx + dy·dy + soft·soft` - Remove the `j !== this.thread.x` guard — the self term is now zero - Accumulate *both* components and return `[ax, ay]` ## Hint 1 — why the guard can go For `j === i`: `dx` and `dy` are 0, so the contribution is `0 · something`. With `soft² > 0` the denominator is never zero, so that something is a plain finite number. ## Hint 2 — share the weight Compute `const w = mass[j] / (r2 * Math.sqrt(r2));` once, then `ax += dx * w; ay += dy * w;` — one denominator, two components. ## Same idea elsewhere Softening appears verbatim in production astrophysics codes (GADGET, Bonsai) on CUDA and ROCm clusters. It's also a lesson in GPU numerics generally: shader float math never throws — a divide-by-zero silently mints `Infinity` and then `NaN`s spread through every sum they touch, on Metal and WebGPU alike. ## Starter code ```js // Bodies 0 and 1 sit 0.001 apart. Unsoftened, their mutual pull // is ~a million — one bad pair and the whole simulation explodes. const gpu = new GPU({ mode }); const accel = gpu.createKernel(function (posX, posY, mass, soft) { const myX = posX[this.thread.x]; const myY = posY[this.thread.x]; let ax = 0; let ay = 0; for (let j = 0; j < this.constants.n; j++) { if (j !== this.thread.x) { const dx = posX[j] - myX; const dy = posY[j] - myY; // TODO: soften — add soft·soft to r² so close encounters stay // finite. Then the j !== i guard above is dead weight: delete it. const r2 = dx * dx + dy * dy; const w = mass[j] / (r2 * Math.sqrt(r2)); ax += dx * w; ay += dy * w; } } return [ax, ay]; }, { output: [64], constants: { n: 64 } }); const acc = await accel(posX, posY, mass, 0.1); console.log('acceleration on body 0:', acc[0][0], acc[0][1]); ``` --- Interactive version: https://gpu.rocks/learn/n-body-gravity-5de47751/3 [Previous task](https://gpu.rocks/learn/n-body-gravity-5de47751/2.md) · [Next task](https://gpu.rocks/learn/n-body-gravity-5de47751/4.md) --- # One Tick of the Clock *Task 4 of 5 · [N-Body Gravity](https://gpu.rocks/learn/n-body-gravity-5de47751.md) · GPU.js Learn* Accelerations are just numbers until an integrator turns them into motion. The simplest scheme that doesn't wreck orbits is **semi-implicit Euler**: update the velocity *first*, then move the body with the *new* velocity — `v′ = v + a·dt`, then `x′ = x + v′·dt`. Do it in the other order (plain Euler) and orbits visibly spiral outward, gaining energy from nowhere. Both updates are embarrassingly parallel — body *i* never looks at body *j* — so they're two tiny kernels. Between them, the `[vx, vy]` pairs come back to JavaScript and get unpacked into plain arrays for the next kernel. Clunky? Yes. Instructive? Also yes — and **Pipelines & Textures** shows how to skip the round trip. ## Goal **Goal:** finish both kernels — `stepVel` returns `[v + a·dt]` per component, `stepPos` returns `[x + v·dt]` — and feed the position step the *new* velocities. ## Requirements - `stepVel` returns `[vx + ax·dt, vy + ay·dt]` for its body - `stepPos` returns `[x + vx·dt, y + vy·dt]` for its body - The position step must receive the *updated* velocities (semi-implicit, already wired up) ## Hint 1 — the same index four times Everything in both kernels is indexed by `this.thread.x`: this body's velocity, this body's acceleration, this body's position. ## Hint 2 — the velocity kernel ```js return [velX[this.thread.x] + accX[this.thread.x] * dt, velY[this.thread.x] + accY[this.thread.x] * dt]; ``` — the position kernel is the same shape with `pos` and `vel`. ## Same idea elsewhere Splitting an integrator into per-buffer passes is exactly how GPU engines ship it: WebGPU dispatches one compute pass per update with position/velocity buffers ping-ponging between bind groups, and Metal encodes the same thing as back-to-back compute command encoders. The math stays this small; the choreography is the product. ## Starter code ```js // Numbers → motion. Semi-implicit Euler: update velocity FIRST, // then move with the NEW velocity — it keeps orbits stable. const gpu = new GPU({ mode }); const stepVel = gpu.createKernel(function (velX, velY, accX, accY, dt) { // TODO: return [new vx, new vy] — old velocity plus acceleration · dt return [velX[this.thread.x], velY[this.thread.x]]; }, { output: [64] }); const stepPos = gpu.createKernel(function (posX, posY, velX, velY, dt) { // TODO: return [new x, new y] — old position plus velocity · dt return [posX[this.thread.x], posY[this.thread.x]]; }, { output: [64] }); const DT = 0.01; const newVel = await stepVel(velX, velY, accX, accY, DT); // unpack the [vx, vy] pairs so the position kernel gets plain arrays const newVelX = []; const newVelY = []; for (let i = 0; i < 64; i++) { newVelX.push(newVel[i][0]); newVelY.push(newVel[i][1]); } const newPos = await stepPos(posX, posY, newVelX, newVelY, DT); console.log('body 0 moved to', newPos[0][0], newPos[0][1]); ``` --- Interactive version: https://gpu.rocks/learn/n-body-gravity-5de47751/4 [Previous task](https://gpu.rocks/learn/n-body-gravity-5de47751/3.md) · [Next task](https://gpu.rocks/learn/n-body-gravity-5de47751/5.md) --- # Put It Together: 128 Bodies *Task 5 of 5 · [N-Body Gravity](https://gpu.rocks/learn/n-body-gravity-5de47751.md) · GPU.js Learn* Everything from this module, running as one machine. The three kernels below are your last three tasks — softened O(n²) acceleration, the velocity tick, the position tick. What's missing is the **conductor**: a JavaScript loop that runs ten full ticks, feeding each kernel's output into the next and carrying the new state into the next tick. Notice who does what: JavaScript never touches a single interaction — it just passes arrays around. The GPU grinds through 128 × 128 = 16,384 interactions per tick, 163,840 across the run. Swap 128 for 100,000 and this exact structure is a galaxy simulator; the loop you're about to write wouldn't change by a character. ## Goal **Goal:** write the simulation loop — ten ticks of `accel → stepVel → stepPos`, carrying the new arrays forward each time. ## Requirements - Each tick: accelerations first — `await accel(px, py, mass, SOFT)` - Unpack the pairs, then `await stepVel(vx, vy, ax, ay, DT)`, then `await stepPos` with the *new* velocities — awaited one after another, because each reads the one before it - Reassign `px, py, vx, vy` so the next tick starts from the new state - Run exactly `STEPS` ticks, then log body 0's final position ## Hint 1 — the shape of one tick Inside the loop: `await accel`, unpack its pairs into `ax, ay` arrays (the `unpack` helper is right there), `await stepVel`, unpack, `await stepPos`, unpack. One at a time — the next call needs the previous one's numbers. ## Hint 2 — carrying the state End every tick by overwriting the state: ```js vx = newVx; vy = newVy; px = newPx; py = newPy; ``` — next tick's `accel` must see the moved bodies, or time never advances. ## Hint 3 — the whole loop ```js for (let step = 0; step < STEPS; step++) { const [ax, ay] = unpack(await accel(px, py, mass, SOFT)); const [nvx, nvy] = unpack(await stepVel(vx, vy, ax, ay, DT)); const [npx, npy] = unpack(await stepPos(px, py, nvx, nvy, DT)); px = npx; py = npy; vx = nvx; vy = nvy; } ``` ## Same idea elsewhere A host loop launching device kernels in sequence is the universal skeleton of GPU simulation: CUDA streams queueing kernel after kernel per timestep, WebGPU building one command encoder per frame, Metal committing a command buffer per tick. Production codes differ mainly in never reading the arrays back between passes — that's what the textures in **Pipelines & Textures** are for. ## Starter code ```js // Three kernels from the last three tasks — and a conductor's podium. const gpu = new GPU({ mode }); const N = 128; const DT = 0.01; const SOFT = 0.1; const STEPS = 10; const accel = gpu.createKernel(function (posX, posY, mass, soft) { const myX = posX[this.thread.x]; const myY = posY[this.thread.x]; let ax = 0; let ay = 0; for (let j = 0; j < this.constants.n; j++) { const dx = posX[j] - myX; const dy = posY[j] - myY; const r2 = dx * dx + dy * dy + soft * soft; const w = mass[j] / (r2 * Math.sqrt(r2)); ax += dx * w; ay += dy * w; } return [ax, ay]; }, { output: [N], constants: { n: N } }); const stepVel = gpu.createKernel(function (velX, velY, accX, accY, dt) { return [velX[this.thread.x] + accX[this.thread.x] * dt, velY[this.thread.x] + accY[this.thread.x] * dt]; }, { output: [N] }); const stepPos = gpu.createKernel(function (posX, posY, velX, velY, dt) { return [posX[this.thread.x] + velX[this.thread.x] * dt, posY[this.thread.x] + velY[this.thread.x] * dt]; }, { output: [N] }); // [x, y] pairs → two plain arrays function unpack(pairs) { const xs = []; const ys = []; for (let i = 0; i < pairs.length; i++) { xs.push(pairs[i][0]); ys.push(pairs[i][1]); } return [xs, ys]; } let px = posX; let py = posY; let vx = velX; let vy = velY; for (let step = 0; step < STEPS; step++) { // TODO — one full tick. Every kernel call is awaited, and in this // order: each stage reads the stage before it. // 1. pairs = await accel(px, py, mass, SOFT), unpack into ax, ay // 2. await stepVel with DT → unpack into the NEW vx, vy // 3. await stepPos with the NEW velocities → unpack into the new px, py // 4. reassign px, py, vx, vy for the next tick } console.log('after', STEPS, 'ticks, body 0 is at', px[0], py[0]); ``` --- Interactive version: https://gpu.rocks/learn/n-body-gravity-5de47751/5 [Previous task](https://gpu.rocks/learn/n-body-gravity-5de47751/4.md) --- # ODE Integrators *Module of the free GPU.js GPGPU course · 6 tasks* Euler, midpoint, RK4 and velocity Verlet — measured against a closed form, one thread per trajectory. ## Tasks 1. [One Thread, One Whole Trajectory](https://gpu.rocks/learn/ode-integrators-62f4a3ff/1.md) 2. [Halve the Step, Halve the Error](https://gpu.rocks/learn/ode-integrators-62f4a3ff/2.md) 3. [Look Before You Leap](https://gpu.rocks/learn/ode-integrators-62f4a3ff/3.md) 4. [Four Slopes, Weighted](https://gpu.rocks/learn/ode-integrators-62f4a3ff/4.md) 5. [Velocity Verlet: the Symplectic Step](https://gpu.rocks/learn/ode-integrators-62f4a3ff/5.md) 6. [Payoff: RK4 Drifts, Verlet Does Not](https://gpu.rocks/learn/ode-integrators-62f4a3ff/6.md) --- Interactive version: https://gpu.rocks/learn/ode-integrators-62f4a3ff --- # One Thread, One Whole Trajectory *Task 1 of 6 · [ODE Integrators](https://gpu.rocks/learn/ode-integrators-62f4a3ff.md) · GPU.js Learn* A mass on a spring, in one line: the acceleration always points back at the origin, `a = −x`. That is the entire physics of this module. It is deliberately the dullest force there is, because the subject here is the **clock**, not the force — N-Body Gravity owns interesting forces. What the spring buys is that we already know the answer, exactly and forever: ```js x(t) = x₀·cos t + v₀·sin t E = (x² + v²) / 2, constant ``` So every number a solver produces can be subtracted from the truth, and "how wrong is this?" stops being a feeling. And the GPU shape flips. N-Body's tick loop runs in JavaScript, one launch per step, because every body needs every other body's *new* position before the next tick — that is a barrier. These trajectories never speak to each other, so the loop moves **inside** the kernel: one launch, one thread, one entire trajectory, a thousand at a time. ## Figures - **the clock moved inside the kernel — one launch, one thread, one whole trajectory** ## Goal **Goal:** fill in one explicit-Euler step, so each thread integrates its own oscillator for 200 steps and returns the final `x`. ## Requirements - The time loop stays *inside* the kernel — `this.constants.steps` iterations, one thread per trajectory - Explicit Euler commits to the start of the step: take `a = −x` first - Advance `x` by the step-start `v · dt`, and `v` by `a · dt` - Return this thread's final `x` ## Hint 1 — what one step is Three lines. The acceleration first, because `x` is about to change under it: ```js const a = -x; x = x + v * dt; v = v + a * dt; ``` ## Hint 2 — why that order Explicit Euler uses *only* step-start values. Writing `v` first and then moving `x` with the **new** `v` is a different method — semi-implicit Euler, the one N-Body Gravity steps with. It is better, and it is task 5's business. Here we want the naive one, because its badness is the lesson. ## Hint 3 — no arrays inside the loop `x` and `v` are plain `let` locals. The arrays are read once, before the loop, to start this thread off — after that the whole trajectory lives in two registers. ## Same idea elsewhere One thread per independent problem is how GPUs are pointed at ODEs everywhere: CUDA and HIP ensemble solvers give each thread one particle's trajectory, WebGPU compute does the same for particle systems, and the "batched" families in cuBLAS and cuSOLVER exist because thousands of small independent problems are the shape this hardware likes best. The kernel is the solver; the launch is the ensemble. ## Starter code ```js // One thread owns one WHOLE trajectory: the time loop is inside the kernel. const gpu = new GPU({ mode }); const STEPS = 200; const DT = 0.025; // 200 × 0.025 = t = 5 const T_END = STEPS * DT; const trajectory = gpu.createKernel(function (startX, startV, dt) { let x = startX[this.thread.x]; let v = startV[this.thread.x]; for (let s = 0; s < this.constants.steps; s++) { // TODO: one explicit-Euler step. // a = -x first, then x moves by v * dt and v changes by a * dt — // both using the values from the START of the step. } return x; }, { output: [1024], constants: { steps: STEPS }, }); const finalX = await trajectory(startX, startV, DT); console.log('trajectory 0 ended at', finalX[0]); console.log('the exact answer is ', startX[0] * Math.cos(T_END) + startV[0] * Math.sin(T_END)); ``` --- Interactive version: https://gpu.rocks/learn/ode-integrators-62f4a3ff/1 [Next task](https://gpu.rocks/learn/ode-integrators-62f4a3ff/2.md) --- # Halve the Step, Halve the Error *Task 2 of 6 · [ODE Integrators](https://gpu.rocks/learn/ode-integrators-62f4a3ff.md) · GPU.js Learn* Euler is wrong. The useful question is *how* wrong, and what more steps buy you. Explicit Euler is a **first-order** method: its error at a fixed end time is proportional to `dt`. Halve the step, halve the error. Pay twice, get twice — which is a terrible exchange rate, and you can only find that out by measuring it. Measuring needs the same trajectories run at several step sizes, which is what the second output axis is for. `output: [256, 4]` launches a grid: `this.thread.x` picks the trajectory, `this.thread.y` picks the **refinement level** — 50, 100, 200 or 400 steps, every level covering the same `t = 0…5`, so `dt` is `5 / n`. One launch, the whole convergence study. Notice what that does to the loop: the trip count now differs from thread to thread. gpu.js compiles a non-constant bound as a fixed `loopMaxIterations` loop with an early exit, so the 50-step threads still march through all 400 iterations alongside their neighbours. That is not a gpu.js quirk — lockstep hardware behaves that way everywhere. ## Goal **Goal:** return each thread's absolute error against the closed form, then average each row in JavaScript and log what each halving of `dt` bought. ## Requirements - Take the step count from the level: `levelSteps[this.thread.y]`, and `dt = this.constants.tEnd / n` - Integrate `n` explicit-Euler steps, exactly as in the last task - Return the *absolute* error against `startX·cos(tEnd) + startV·sin(tEnd)` - In JavaScript: average each row, then `console.log` the three ratios `means[level − 1] / means[level]` ## Hint 1 — which step size is mine? The row index *is* the level: ```js const n = levelSteps[this.thread.y]; const dt = this.constants.tEnd / n; ``` Every row ends at the same time; only the number of steps it took to get there differs. ## Hint 2 — the truth to subtract `Math.cos` and `Math.sin` both work inside kernels: ```js const exact = startX[this.thread.x] * Math.cos(this.constants.tEnd) + startV[this.thread.x] * Math.sin(this.constants.tEnd); return Math.abs(x - exact); ``` `Math.abs` matters: without it half the trajectories report a positive error and half a negative one, and the row average cancels to nearly nothing. ## Hint 3 — the JavaScript half A 2D kernel returns rows, so `errors[level]` is a whole row of trajectory errors: ```js const means = []; for (let level = 0; level < errors.length; level++) { let total = 0; for (let i = 0; i < errors[level].length; i++) total += errors[level][i]; means.push(total / errors[level].length); } for (let level = 1; level < means.length; level++) { console.log((means[level - 1] / means[level]).toFixed(3)); } ``` ## Same idea elsewhere Running an entire convergence study as one dispatch is the GPU-native form of a parameter sweep, and 2D launch grids are how every platform spells it — CUDA's `dim3` grid, WebGPU's dispatch dimensions, Metal's threadgroup grid. The divergence lesson travels too: a warp, a wavefront or a subgroup runs its loop until the *last* lane is finished, so a per-thread trip count is a hint about work, never a promise about time. ## Starter code ```js // x = trajectory, y = refinement level. 256 oscillators × 4 step sizes, // every level covering the same t = 0…5. One launch, one whole study. const gpu = new GPU({ mode }); const errorOf = gpu.createKernel(function (startX, startV, levelSteps) { // TODO: this row's step count and step size const n = 1; const dt = 1; let x = startX[this.thread.x]; let v = startV[this.thread.x]; for (let s = 0; s < n; s++) { const a = -x; x = x + v * dt; v = v + a * dt; } // TODO: the exact answer is startX·cos(tEnd) + startV·sin(tEnd). // Return how far this trajectory ended up from it — an absolute distance. return x; }, { output: [256, 4], constants: { tEnd: 5 }, loopMaxIterations: 400, // the largest level; a bigger one would be truncated }); const errors = await errorOf(startX, startV, levelSteps); console.log('levels:', errors.length, '× trajectories:', errors[0].length); // TODO: average each row into means[], log each one, then log the ratios // means[level - 1] / means[level] — that number is the method's order. ``` --- Interactive version: https://gpu.rocks/learn/ode-integrators-62f4a3ff/2 [Previous task](https://gpu.rocks/learn/ode-integrators-62f4a3ff/1.md) · [Next task](https://gpu.rocks/learn/ode-integrators-62f4a3ff/3.md) --- # Look Before You Leap *Task 3 of 6 · [ODE Integrators](https://gpu.rocks/learn/ode-integrators-62f4a3ff.md) · GPU.js Learn* Euler's mistake is committing. It reads the slope at the start of the step and then pretends that slope holds all the way across. The **midpoint method** (RK2) makes the obvious repair: take a trial *half*-step, read the slope *there*, throw the trial away, and take the real full step using that better slope. Two force evaluations per step instead of one, and the error goes from first order to **second**: halve `dt` and the error drops by *four*. Same instrument as the last task; only the number at the bottom changes. The step sizes are coarser here — 25 to 200 steps, not 50 to 400 — and that is worth knowing rather than glossing. A convergence study only reads true inside a window. Too coarse and the leading error term is not yet dominant; too fine and the error sinks into the rounding noise of the float32 the WebGL backend computes in, and the ratio starts reporting arithmetic instead of mathematics. ## Figures - **the trial half-step is thrown away; only the slope it found is kept** ## Goal **Goal:** replace the Euler step with a midpoint step, and watch the ratio go from 2 to 4. ## Requirements - Trial half-step from the start: `midX = x + v·(dt/2)` and `midV = v + a·(dt/2)`, with `a = −x` - Take the real step with the *midpoint* slopes: `x += midV·dt` and `v += (−midX)·dt` - Both updates use midpoint values — leaving either one on the start-of-step slope drops you back to first order ## Hint 1 — what the trial step is for Nothing about `midX` and `midV` survives the step. They exist only to answer one question — *what is the slope halfway across?* — and the answer is `midV` for the position and `−midX` for the velocity. ## Hint 2 — which slope goes where The state is `(x, v)` and its derivative is `(v, −x)`. So the midpoint *velocity* drives the position, and the midpoint *position* drives the velocity. Crossing those over is the single easiest way to get this wrong. ## Hint 3 — the whole step ```js const half = dt / 2; const a = -x; const midX = x + v * half; const midV = v + a * half; x = x + midV * dt; v = v + -midX * dt; ``` ## Same idea elsewhere A multi-stage integrator has to hold several copies of the state at once, and on a GPU that is register pressure — the resource that decides how many threads a streaming multiprocessor can keep in flight. CUDA calls it occupancy, WebGPU and Metal have the same constraint under different names. It is a real reason production codes sometimes prefer a cheap scheme with a smaller step to an elegant one with a bigger footprint. ## Starter code ```js // The same instrument, a better step. 256 oscillators × 4 step sizes. const gpu = new GPU({ mode }); const errorOf = gpu.createKernel(function (startX, startV, levelSteps) { const n = levelSteps[this.thread.y]; const dt = this.constants.tEnd / n; let x = startX[this.thread.x]; let v = startV[this.thread.x]; for (let s = 0; s < n; s++) { // TODO: one MIDPOINT step. // half-step to (midX, midV), read the slope there, // then take the full step with THAT slope. const a = -x; x = x + v * dt; v = v + a * dt; } const exact = startX[this.thread.x] * Math.cos(this.constants.tEnd) + startV[this.thread.x] * Math.sin(this.constants.tEnd); return Math.abs(x - exact); }, { output: [256, 4], constants: { tEnd: 5 }, loopMaxIterations: 200, }); const errors = await errorOf(startX, startV, levelSteps); // average the trajectories at each step size, then see what each halving bought const means = []; for (let level = 0; level < errors.length; level++) { let total = 0; for (let i = 0; i < errors[level].length; i++) total += errors[level][i]; means.push(total / errors[level].length); console.log(levelSteps[level] + ' steps: mean error ' + means[level].toFixed(6)); } for (let level = 1; level < means.length; level++) { console.log('halving dt divided the error by ' + (means[level - 1] / means[level]).toFixed(3)); } ``` --- Interactive version: https://gpu.rocks/learn/ode-integrators-62f4a3ff/3 [Previous task](https://gpu.rocks/learn/ode-integrators-62f4a3ff/2.md) · [Next task](https://gpu.rocks/learn/ode-integrators-62f4a3ff/4.md) --- # Four Slopes, Weighted *Task 4 of 6 · [ODE Integrators](https://gpu.rocks/learn/ode-integrators-62f4a3ff.md) · GPU.js Learn* Classical RK4 samples the slope four times per step — once at the start, twice at the middle (the second one using the first one's estimate), once at the end — and combines them `(k₁ + 2·k₂ + 2·k₃ + k₄) / 6`. Four evaluations, **fourth** order: halving `dt` divides the error by *sixteen*. Which is why this task's step sizes look absurd. The coarsest level crosses `t = 0…5` in **five** steps — a full radian each — and it is still more accurate than 400 Euler steps. Run RK4 at Euler's finest setting instead and its error would be around 10⁻⁹, roughly a thousand times below what float32 can resolve next to a value of order 1; the study would measure rounding, not convergence. Three levels is what fits between "not yet asymptotic" and "already noise" — take one more halving and the ratio stops being a clean 16. ## Goal **Goal:** write the RK4 step and watch the ratio jump to ≈16. ## Requirements - Four slope pairs: `k1` at the start, `k2` and `k3` at the midpoint, `k4` at the end - `k2` and `k3` step out by `dt/2`; `k4` by the full `dt` - Each probe builds on the *previous* one — `k3` from `k2`, `k4` from `k3` - Combine with weights `1, 2, 2, 1` over `6` ## Hint 1 — the derivative of the state The state is `(x, v)`, so every slope is a pair: the derivative of `x` is `v`, and the derivative of `v` is `−x`. That gives `k1x = v` and `k1v = −x`, and every later probe is the same rule evaluated at a shifted state. ## Hint 2 — the middle two `k2` is the slope half a step along `k1`; `k3` is the slope half a step along `k2`: ```js const k2x = v + k1v * half; const k2v = -(x + k1x * half); const k3x = v + k2v * half; const k3v = -(x + k2x * half); ``` Building `k3` from `k1` again is the classic slip, and it costs you two whole orders. ## Hint 3 — the combination ```js x = x + (dt / 6) * (k1x + 2 * k2x + 2 * k3x + k4x); v = v + (dt / 6) * (k1v + 2 * k2v + 2 * k3v + k4v); ``` The middle pair carries twice the weight, and the six is what the weights sum to. ## Same idea elsewhere RK4 trades memory traffic for arithmetic: four evaluations of the derivative per step, all of them on values already sitting in registers. That is exactly the bargain GPUs reward — arithmetic is nearly free, and the force evaluation in a real simulation is usually memory-bound, so fewer, bigger, better steps often beat more cheap ones. It is the same arithmetic-intensity argument that drives kernel fusion in CUDA, WebGPU and Metal. ## Starter code ```js // Only THREE levels here, and they are brutally coarse: 5, 10, 20 steps // to cross t = 0…5. Fourth order needs big steps to stay visible above float32. const gpu = new GPU({ mode }); const errorOf = gpu.createKernel(function (startX, startV, levelSteps) { const n = levelSteps[this.thread.y]; const dt = this.constants.tEnd / n; let x = startX[this.thread.x]; let v = startV[this.thread.x]; for (let s = 0; s < n; s++) { const half = dt / 2; // TODO: four slope pairs — k1 at the start, k2 and k3 at the midpoint // (each from the previous one), k4 at the end — then combine them // with weights 1, 2, 2, 1 over 6. const k1x = v; const k1v = -x; x = x + k1x * dt; v = v + k1v * dt; } const exact = startX[this.thread.x] * Math.cos(this.constants.tEnd) + startV[this.thread.x] * Math.sin(this.constants.tEnd); return Math.abs(x - exact); }, { output: [256, 3], constants: { tEnd: 5 }, loopMaxIterations: 20, }); const errors = await errorOf(startX, startV, levelSteps); // average the trajectories at each step size, then see what each halving bought const means = []; for (let level = 0; level < errors.length; level++) { let total = 0; for (let i = 0; i < errors[level].length; i++) total += errors[level][i]; means.push(total / errors[level].length); console.log(levelSteps[level] + ' steps: mean error ' + means[level].toFixed(6)); } for (let level = 1; level < means.length; level++) { console.log('halving dt divided the error by ' + (means[level - 1] / means[level]).toFixed(3)); } ``` --- Interactive version: https://gpu.rocks/learn/ode-integrators-62f4a3ff/4 [Previous task](https://gpu.rocks/learn/ode-integrators-62f4a3ff/3.md) · [Next task](https://gpu.rocks/learn/ode-integrators-62f4a3ff/5.md) --- # Velocity Verlet: the Symplectic Step *Task 5 of 6 · [ODE Integrators](https://gpu.rocks/learn/ode-integrators-62f4a3ff.md) · GPU.js Learn* **Velocity Verlet** reads like Euler with one extra term. Move the position with a quadratic instead of a straight line, then update the velocity using the *average* of the accelerations at the two ends of the step: ```js x ← x + v·dt + ½·a·dt² v ← v + ½·(a + a_new)·dt where a_new = −x_new ``` It is second order — the same exponent midpoint gave you, so the instrument you have been using *cannot tell them apart* by exponent alone. (It is about four times more accurate than midpoint at the same step size, and a real implementation carries `a_new` into the next step as its `a`, so it costs one force evaluation per step against midpoint's two. Here `a = −x` is free, so the code below just recomputes it.) What it has that midpoint does not is invisible in a single step and decisive over a million. The Verlet step is an exactly area-preserving map of the `(x, v)` plane — **symplectic** — and for this oscillator it conserves `(1 − dt²/4)·x² + v²` exactly, forever, in a way no accumulation of steps can erode. Measure the order here. The next task is where that sentence starts to matter. ## Goal **Goal:** write the velocity Verlet step. The ratio should be ≈4 again — and the errors themselves about four times smaller than midpoint's. ## Requirements - Position first, with the quadratic term: `x += v·dt + 0.5·a·dt·dt` where `a = −x` at the start of the step - Then recompute the acceleration at the NEW position: `aNext = −x` - Velocity from the average of the two: `v += 0.5·(a + aNext)·dt` ## Hint 1 — order of operations `a` is captured before `x` moves; `aNext` is read after. The velocity update needs both, so it has to come last. ## Hint 2 — the extra term `0.5 * a * dt * dt` is the constant-acceleration formula from first-year mechanics. Dropping it leaves a scheme that still looks plausible and is back to first order. ## Hint 3 — the whole step ```js const a = -x; x = x + v * dt + 0.5 * a * dt * dt; const aNext = -x; v = v + 0.5 * (a + aNext) * dt; ``` ## Same idea elsewhere Velocity Verlet, not RK4, is what every production molecular-dynamics code on a GPU actually ships — GROMACS, LAMMPS, HOOMD-blue and OpenMM all step with Verlet or leapfrog on CUDA, HIP and Metal. It is not because they cannot afford RK4's four evaluations. It is because a run of a hundred million steps is judged on whether the energy stayed put, and that is a property of the *shape* of the update, not of its order. ## Starter code ```js // Same instrument again. Same order as midpoint — and something else. const gpu = new GPU({ mode }); const errorOf = gpu.createKernel(function (startX, startV, levelSteps) { const n = levelSteps[this.thread.y]; const dt = this.constants.tEnd / n; let x = startX[this.thread.x]; let v = startV[this.thread.x]; for (let s = 0; s < n; s++) { // TODO: one velocity-Verlet step. // x moves with v·dt AND the ½·a·dt² term, // then v updates on the AVERAGE of a and the new a. const a = -x; x = x + v * dt; v = v + a * dt; } const exact = startX[this.thread.x] * Math.cos(this.constants.tEnd) + startV[this.thread.x] * Math.sin(this.constants.tEnd); return Math.abs(x - exact); }, { output: [256, 4], constants: { tEnd: 5 }, loopMaxIterations: 200, }); const errors = await errorOf(startX, startV, levelSteps); // average the trajectories at each step size, then see what each halving bought const means = []; for (let level = 0; level < errors.length; level++) { let total = 0; for (let i = 0; i < errors[level].length; i++) total += errors[level][i]; means.push(total / errors[level].length); console.log(levelSteps[level] + ' steps: mean error ' + means[level].toFixed(6)); } for (let level = 1; level < means.length; level++) { console.log('halving dt divided the error by ' + (means[level - 1] / means[level]).toFixed(3)); } ``` --- Interactive version: https://gpu.rocks/learn/ode-integrators-62f4a3ff/5 [Previous task](https://gpu.rocks/learn/ode-integrators-62f4a3ff/4.md) · [Next task](https://gpu.rocks/learn/ode-integrators-62f4a3ff/6.md) --- # Payoff: RK4 Drifts, Verlet Does Not *Task 6 of 6 · [ODE Integrators](https://gpu.rocks/learn/ode-integrators-62f4a3ff.md) · GPU.js Learn* Eighty-one orbits at `dt = 0.5`, every trajectory released from `x = 0` with `v = 1`. That start makes the bookkeeping free: `E₀ = ½`, so the energy as a multiple of its starting value is exactly `x² + v²`. The launch is the interesting part. Thread *x* integrates `this.thread.x + 1` steps — one thread takes a single step, the last takes all 1,024, and between them the 1,024 threads trace the **whole energy history** of the run. Every prefix computed independently, from scratch, at the same time. Nothing is shared, so nothing has to be sequenced. RK4 is the most accurate thing you have written: after eighty-one orbits its phase is 0.24 radians behind the truth, where Verlet's has slipped nearly a whole orbit. Watch what its energy does anyway — and remember that midpoint, second order and perfectly respectable, ends this same run with about eight million times the energy it started with. ## Figures - **the accurate method leaks; the symplectic one ripples and stays** ## Goal **Goal:** finish the velocity-Verlet energy kernel, then log each method's final energy and the smallest value Verlet ever reaches. ## Requirements - Thread *x* runs `this.thread.x + 1` steps — the loop bound is the thread index - Start every trajectory at `x = 0`, `v = 1` and step with velocity Verlet - Return `x * x + v * v` — the energy as a multiple of where it started - `console.log` both final energies and the minimum of the Verlet curve ## Hint 1 — the trip count `for (let s = 0; s < this.thread.x + 1; s++)`. gpu.js compiles a bound it cannot fold at build time into a `loopMaxIterations` loop with an early exit, which is why the kernel declares `loopMaxIterations: 1024`. ## Hint 2 — why x² + v² is the energy ratio `E = (x² + v²)/2` and every trajectory starts at `(0, 1)`, so `E₀ = ½` and `E/E₀ = x² + v²`. A value of 1 means the energy is exactly where it began. ## Hint 3 — what to look for in JavaScript Scan for the smallest value on each curve, and read the last one: ```js let low = Infinity; for (let i = 0; i < verletCurve.length; i++) { if (verletCurve[i] < low) low = verletCurve[i]; } ``` RK4's minimum *is* its final value — it never goes back up. ## Same idea elsewhere Two lessons travel. The launch shape — a thread per prefix, wildly unequal work, the wavefront running until its slowest lane finishes — is the load-imbalance problem every CUDA, ROCm, WebGPU and Metal programmer eventually has to lay out differently. And the result is why orbital-mechanics and molecular-dynamics codes on those platforms pick symplectic integrators: over a long run you are not choosing the method with the smallest error per step, you are choosing the one whose error does not have a direction. ## Starter code ```js // 1,024 steps of dt = 0.5 — about eighty-one orbits. // Thread x integrates x + 1 of them, so the 1,024 threads together // trace the whole energy history, every prefix computed from scratch. const gpu = new GPU({ mode }); const DT = 0.5; // RK4, exactly as you wrote it two tasks ago, reporting energy instead of error. const rk4Energy = gpu.createKernel(function (dt) { let x = 0; let v = 1; for (let s = 0; s < this.thread.x + 1; s++) { const half = dt / 2; const k1x = v; const k1v = -x; const k2x = v + k1v * half; const k2v = -(x + k1x * half); const k3x = v + k2v * half; const k3v = -(x + k2x * half); const k4x = v + k3v * dt; const k4v = -(x + k3x * dt); x = x + (dt / 6) * (k1x + 2 * k2x + 2 * k3x + k4x); v = v + (dt / 6) * (k1v + 2 * k2v + 2 * k3v + k4v); } return x * x + v * v; }, { output: [1024], loopMaxIterations: 1024 }); const verletEnergy = gpu.createKernel(function (dt) { let x = 0; let v = 1; // TODO: run this.thread.x + 1 velocity-Verlet steps and return // the energy as a multiple of its starting value: x * x + v * v. return 1; }, { output: [1024], loopMaxIterations: 1024 }); const rk4Curve = await rk4Energy(DT); const verletCurve = await verletEnergy(DT); // TODO: log both final energies, and the smallest value the Verlet curve reaches. ``` --- Interactive version: https://gpu.rocks/learn/ode-integrators-62f4a3ff/6 [Previous task](https://gpu.rocks/learn/ode-integrators-62f4a3ff/5.md) --- # Iterative Linear Solvers *Module of the free GPU.js GPGPU course · 5 tasks* Jacobi, Gauss-Seidel, and why colouring a grid like a chessboard turns a sequential algorithm parallel. ## Tasks 1. [One Sweep of Jacobi](https://gpu.rocks/learn/iterative-solvers-e73b8e1f/1.md) 2. [Watch the Residual Fall](https://gpu.rocks/learn/iterative-solvers-e73b8e1f/2.md) 3. [Colour the Board](https://gpu.rocks/learn/iterative-solvers-e73b8e1f/3.md) 4. [Two Halves Make a Sweep](https://gpu.rocks/learn/iterative-solvers-e73b8e1f/4.md) 5. [The Race](https://gpu.rocks/learn/iterative-solvers-e73b8e1f/5.md) --- Interactive version: https://gpu.rocks/learn/iterative-solvers-e73b8e1f --- # One Sweep of Jacobi *Task 1 of 5 · [Iterative Linear Solvers](https://gpu.rocks/learn/iterative-solvers-e73b8e1f.md) · GPU.js Learn* A square metal plate, its edges clamped at fixed temperatures. What does the inside settle to? Not "what happens next" — the answer once nothing happens any more. That steady state solves `∇²u = 0`, and the 5-point stencil turns it into one tiny equation per interior cell: **every cell equals the average of its four neighbours**. Nine hundred equations, nine hundred unknowns, all tangled together. Nobody inverts that matrix. You guess, and improve the guess. **Jacobi's method** is that made literal: set every interior cell to the average of its neighbours *as they were before this sweep*, and repeat. Because every cell reads the previous iterate and nothing else, all 1,024 threads are independent — it is the pure *gather* Thinking in Parallel calls the shape that always parallelises, and one whole sweep is one kernel call. The edges never move: they are the known values that pin the answer down. (Reaction–Diffusion steps this same stencil *forward in time*; here we are solving for the state where time has stopped.) ## Figures - **everyone reads yesterday — which is exactly why everyone can go at once** ## Goal **Goal:** finish the sweep kernel — interior cells return the average of their four neighbours in `u`, boundary cells return their own value unchanged. ## Requirements - Boundary cells — `x` or `y` equal to `0` or `size − 1` — return `u[y][x]` untouched - Interior cells return `(left + right + up + down) / 4`: four neighbours, no centre - Read only `u` — no cell may see a value written during this sweep ## Hint 1 — the edges come first Guard the boundary before you do any arithmetic, so the neighbour reads below can never leave the grid: ```js if (x === 0 || y === 0 || x === this.constants.size - 1 || y === this.constants.size - 1) { return u[y][x]; } ``` ## Hint 2 — the four neighbours Only ever vary *one* coordinate at a time: `u[y][x - 1]` and `u[y][x + 1]` along the row, `u[y - 1][x]` and `u[y + 1][x]` down the column. The centre `u[y][x]` is not part of a Jacobi average. ## Hint 3 — the whole return ```js return (u[y][x - 1] + u[y][x + 1] + u[y - 1][x] + u[y + 1][x]) / 4; ``` ## Same idea elsewhere Jacobi is the starting point of every multigrid solver on every platform — a CUDA or WGSL version of this kernel is line-for-line the same gather, with a buffer swap where your JavaScript assignment is. It is also why "matrix-free" is a phrase: nobody stores the 900×900 matrix this stencil stands for, because the kernel *is* the matrix. ## Starter code ```js // One Jacobi sweep: every interior cell becomes the average of its four // neighbours, read from the grid as it was BEFORE this sweep. const gpu = new GPU({ mode }); const sweep = gpu.createKernel(function (u) { const x = this.thread.x; const y = this.thread.y; // TODO 1: the boundary is held fixed — return u[y][x] for any cell whose // x or y is 0 or this.constants.size - 1. // TODO 2: every other cell returns the average of its four neighbours: // u[y][x - 1], u[y][x + 1], u[y - 1][x], u[y + 1][x]. return u[y][x]; }, { output: [32, 32], constants: { size: 32 } }); const next = await sweep(guess); console.log('centre before:', guess[16][16], '→ after:', next[16][16]); ``` --- Interactive version: https://gpu.rocks/learn/iterative-solvers-e73b8e1f/1 [Next task](https://gpu.rocks/learn/iterative-solvers-e73b8e1f/2.md) --- # Watch the Residual Fall *Task 2 of 5 · [Iterative Linear Solvers](https://gpu.rocks/learn/iterative-solvers-e73b8e1f.md) · GPU.js Learn* Iterating is easy; knowing when to stop is the skill. The honest measure is the **residual**: take the current guess, put it back into the equation, and see how badly it is violated. For "every cell equals the average of its four neighbours" that is the 5-point Laplacian — `left + right + up + down − 4·centre` — the same stencil Reaction–Diffusion uses to diffuse, borrowed here as a scorecard. Zero where the equation holds, large where it does not. Boundary cells have no equation to violate: they are given, not solved. Their residual is `0` by definition rather than by accident, and saying so in the kernel keeps the edge from polluting the score forever. A grid of numbers is not a progress report, so collapse it to one: the root-mean-square over the grid. Totalling a grid *on* the GPU is the halving ladder Reductions builds; at 1,024 cells the read-back is cheaper than the ladder, so this sum happens in plain JavaScript. ## Goal **Goal:** complete the `residual` kernel and the `rmsOf` helper. The sweep loop is already wired and will print the residual every 10 sweeps — you should watch it fall by a factor of about 36. ## Requirements - The kernel returns `0` for boundary cells - Interior cells return `left + right + up + down − 4·centre` — a sum, with no division - `rmsOf(grid)` returns `Math.sqrt(sum of every cell squared / (32 × 32))` ## Hint 1 — the kernel is the stencil, unaveraged Same five reads as the Jacobi sweep, assembled differently: the update divides the neighbour sum by 4, the residual subtracts `4 × centre` from it. ```js return u[y][x - 1] + u[y][x + 1] + u[y - 1][x] + u[y + 1][x] - 4 * u[y][x]; ``` ## Hint 2 — the reduction is ordinary JavaScript `residual(u)` hands back a plain 2D array of numbers, so: ```js let sum = 0; for (let y = 0; y < 32; y++) { for (let x = 0; x < 32; x++) sum += grid[y][x] * grid[y][x]; } return Math.sqrt(sum / (32 * 32)); ``` Square, mean, root — in that order. Divide by every cell in the grid, not just the interior ones. ## Same idea elsewhere Every production solver stops on a residual, not on a sweep count, and every one of them argues about how often to measure it: the norm needs a reduction across the whole device and then a read-back to the host, which is a synchronisation point in CUDA, WebGPU and MPI alike. Checking every iteration can cost more than the iterations do — the usual answer is exactly what this task does, sample it every few sweeps. ## Starter code ```js // How wrong is the current guess? Plug it back into the equation. const gpu = new GPU({ mode }); const SWEEPS = 60; const sweep = gpu.createKernel(function (u) { const x = this.thread.x; const y = this.thread.y; if (x === 0 || y === 0 || x === this.constants.size - 1 || y === this.constants.size - 1) { return u[y][x]; } return (u[y][x - 1] + u[y][x + 1] + u[y - 1][x] + u[y + 1][x]) / 4; }, { output: [32, 32], constants: { size: 32 } }); const residual = gpu.createKernel(function (u) { const x = this.thread.x; const y = this.thread.y; // TODO 1: a boundary cell has no equation to violate — return 0. // TODO 2: every other cell returns // left + right + up + down - 4 * centre. return 1; }, { output: [32, 32], constants: { size: 32 } }); function rmsOf(grid) { // TODO 3: square every cell, take the mean over all 32 × 32 of them, // then the square root. return 0; } let u = plate; const curve = []; for (let k = 0; k <= SWEEPS; k++) { curve.push(rmsOf(await residual(u))); if (k % 10 === 0) console.log('sweep', k, '— RMS residual', rmsOf(await residual(u))); u = await sweep(u); } // A residual that falls 36x is a straight-ish line on a LOG axis and an // uninformative hook on a linear one. That is the whole reason for the option. plot({ residual: curve }, { title: 'RMS residual per sweep', log: true }); ``` --- Interactive version: https://gpu.rocks/learn/iterative-solvers-e73b8e1f/2 [Previous task](https://gpu.rocks/learn/iterative-solvers-e73b8e1f/1.md) · [Next task](https://gpu.rocks/learn/iterative-solvers-e73b8e1f/3.md) --- # Colour the Board *Task 3 of 5 · [Iterative Linear Solvers](https://gpu.rocks/learn/iterative-solvers-e73b8e1f.md) · GPU.js Learn* Jacobi throws information away. Halfway through a sweep plenty of neighbours already have better values, and Jacobi ignores every one of them because it reads only the old grid. **Gauss-Seidel** is the fix a human would reach for: walk the cells in order and always use the newest value available. It converges about twice as fast — and it is *sequential by construction*. Cell 500 cannot start until cell 499 has finished. There is no thread ordering on a GPU and no way to make one thread wait for another, so the textbook algorithm is simply not on the menu. The fix is a chessboard. The 5-point stencil only ever reads the four direct neighbours, and on a chessboard every direct neighbour of a red square is black. So call a cell **red** when `(x + y)` is even and **black** when it is odd, and update all the reds at once: no red cell reads another red cell, so there is nothing left to order. Then update all the blacks, reading the reds that were just written — which is exactly the "use the newest value" that made Gauss-Seidel fast. One sequential pass becomes two data-parallel half-sweeps. This task is the red half. Every thread still writes only its own cell, so black cells are not "skipped" — they gather *themselves*, unchanged, ready for the half-sweep that is about to need them exactly as they are. ## Figures - **a red cell's four neighbours are all black, so no red waits on a red** ## Goal **Goal:** write the red half-sweep — cells with `(x + y) % 2 === 0` take their neighbours' average, and every other cell (black cells and the whole boundary) comes through untouched. ## Requirements - Boundary cells return `u[y][x]` - Keep the parity in a *number*: `const parity = (x + y) % 2;` — a boolean in a kernel variable does not compile on WebGL - Cells with parity `1` (black) return `u[y][x]` unchanged - Cells with parity `0` (red) return `(left + right + up + down) / 4` ## Hint 1 — the trap this task is built around The natural spelling is a boolean, and it is the one thing gpu.js cannot do: ```js const isRed = (x + y) % 2 === 0; // throws on WebGL ``` The GL backend has no way to store a `bool` in a kernel variable, so it fails at shader-compile time with *cannot convert from 'bool' to 'lowp float'* — and the CPU backend runs it happily, which is how this reaches production. Keep the number: ```js const parity = (x + y) % 2; // 0 or 1 if (parity !== 0) return u[y][x]; ``` ## Hint 2 — three exits, one average The kernel is a stack of guards: boundary first, then the wrong colour, then the arithmetic. Whichever way a thread leaves, it writes exactly one cell — its own. ## Hint 3 — the whole body ```js if (x === 0 || y === 0 || x === this.constants.size - 1 || y === this.constants.size - 1) { return u[y][x]; } const parity = (x + y) % 2; if (parity !== 0) return u[y][x]; return (u[y][x - 1] + u[y][x + 1] + u[y - 1][x] + u[y + 1][x]) / 4; ``` ## Same idea elsewhere Red-black ordering — and its multi-colour generalisation — is the standard way to put a Gauss-Seidel or SOR smoother on a GPU: CUDA and WGSL do exactly this, one dispatch per colour, and unstructured meshes get their colours from a graph-colouring pass first. The idea generalises past solvers: a colour is simply a set of updates guaranteed not to depend on each other, which is the same permission slip a wavefront or a task-graph level hands out. ## Starter code ```js // The red half-sweep: update the cells where (x + y) is even, // and pass everything else through untouched. const gpu = new GPU({ mode }); const red = gpu.createKernel(function (u) { const x = this.thread.x; const y = this.thread.y; if (x === 0 || y === 0 || x === this.constants.size - 1 || y === this.constants.size - 1) { return u[y][x]; } // TODO 1: compute the parity as a NUMBER — const parity = (x + y) % 2; // (a boolean in a kernel variable will not compile on WebGL) // TODO 2: parity 1 is black — return u[y][x] unchanged. // TODO 3: parity 0 is red — return the four neighbours' average. return u[y][x]; }, { output: [32, 32], constants: { size: 32 } }); const afterRed = await red(guess); console.log('a red cell [16][16]:', guess[16][16], '→', afterRed[16][16]); console.log('a black cell [16][17]:', guess[16][17], '→', afterRed[16][17]); ``` --- Interactive version: https://gpu.rocks/learn/iterative-solvers-e73b8e1f/3 [Previous task](https://gpu.rocks/learn/iterative-solvers-e73b8e1f/2.md) · [Next task](https://gpu.rocks/learn/iterative-solvers-e73b8e1f/4.md) --- # Two Halves Make a Sweep *Task 4 of 5 · [Iterative Linear Solvers](https://gpu.rocks/learn/iterative-solvers-e73b8e1f.md) · GPU.js Learn* The red half alone is not a sweep — half the grid has not been touched. The black half is the same kernel with its parity test flipped, and the ordering that matters is in the *chaining*: `black(red(u))`. The black cells read the grid the red half produced, so they see this sweep's reds, not last sweep's. That single fact is all that separates Gauss-Seidel from Jacobi. Write `black(u)` instead and both halves read the same old grid. The code still runs, the answer still looks plausible, and you have written Jacobi with an extra kernel launch — which is the most expensive way to be wrong in this module, because nothing about the output shouts. ## Goal **Goal:** write the black half-sweep and chain the two halves into one full red-black Gauss-Seidel sweep. ## Requirements - The black kernel is the red kernel with its parity test flipped: cells with `(x + y) % 2 === 1` update, everything else passes through - Create the red kernel first and the black kernel second - One full sweep is `await black(await red(u))` — the black half reads what the red half wrote - The boundary is untouched by both halves ## Hint 1 — the black kernel Copy the red kernel and change one character: ```js const parity = (x + y) % 2; if (parity !== 1) return u[y][x]; ``` Still a number, never a boolean — the WebGL backend rejects `const isBlack = …` exactly as it rejects `isRed`. ## Hint 2 — the chain is the lesson ```js const afterRed = await red(guess); const afterBoth = await black(afterRed); // NOT black(guess) ``` Or, in one line: `await black(await red(guess))` — the inner `await` is not optional, because an un-awaited kernel call hands the next kernel a Promise instead of a grid. ## Same idea elsewhere Two dispatches with a dependency between them is the ordinary shape of GPU work: WebGPU inserts a barrier between compute passes, CUDA orders them on a stream, Vulkan wants an explicit pipeline barrier. What you cannot do — on any of them — is order threads *inside* one dispatch, which is exactly why the sequential Gauss-Seidel had to be split into two of them in the first place. ## Starter code ```js // One full red-black sweep = the red half, then the black half // reading what the red half just wrote. const gpu = new GPU({ mode }); const red = gpu.createKernel(function (u) { const x = this.thread.x; const y = this.thread.y; if (x === 0 || y === 0 || x === this.constants.size - 1 || y === this.constants.size - 1) { return u[y][x]; } // parity as a NUMBER — gpu.js cannot keep a boolean in a kernel variable const parity = (x + y) % 2; if (parity !== 0) return u[y][x]; return (u[y][x - 1] + u[y][x + 1] + u[y - 1][x] + u[y + 1][x]) / 4; }, { output: [32, 32], constants: { size: 32 } }); const black = gpu.createKernel(function (u) { const x = this.thread.x; const y = this.thread.y; if (x === 0 || y === 0 || x === this.constants.size - 1 || y === this.constants.size - 1) { return u[y][x]; } // TODO 1: same shape as the red kernel, with the parity test flipped — // cells where (x + y) % 2 is 1 take the neighbours' average. return u[y][x]; }, { output: [32, 32], constants: { size: 32 } }); const afterRed = await red(guess); // TODO 2: finish the sweep. The black half must read afterRed, not guess. const afterSweep = afterRed; console.log('a black cell [16][17]:', guess[16][17], '→', afterSweep[16][17]); ``` --- Interactive version: https://gpu.rocks/learn/iterative-solvers-e73b8e1f/4 [Previous task](https://gpu.rocks/learn/iterative-solvers-e73b8e1f/3.md) · [Next task](https://gpu.rocks/learn/iterative-solvers-e73b8e1f/5.md) --- # The Race *Task 5 of 5 · [Iterative Linear Solvers](https://gpu.rocks/learn/iterative-solvers-e73b8e1f.md) · GPU.js Learn* Everything is wired: the Jacobi sweep, both halves of the red-black sweep, and the residual. Same plate, same starting guess of zero, same finish line — an RMS residual below `0.0005`. Count sweeps. "The same work per sweep" is the claim worth being careful about. One Jacobi sweep updates all 900 interior cells once. One red-black sweep updates all 900 once too, in two halves — the same averages, split across two kernel launches instead of one, with half the threads in each launch copying themselves. Sweeps are what we are comparing; the extra launch is what it costs. (A production kernel launches only the cells of one colour and pays nothing for the copies.) The residual is sampled every 5 sweeps rather than every sweep: the read-back is the expensive part of this loop, and five sweeps barely move the number. ## Figures - **same finish line, same work per sweep — 170 sweeps against 275** ## Goal **Goal:** fill in the red-black loop, and read the two sweep counts off the console — Jacobi should need about 275 sweeps and red-black about 170. ## Requirements - Drive red-black with the same `sweepsToTolerance` helper the Jacobi baseline uses - One red-black sweep is `black(red(u))` — both halves, in that order - Count sweeps, not half-sweeps ## Hint 1 — the helper already does the counting `sweepsToTolerance` takes one argument: a function that turns a grid into the next grid. Because a kernel call is awaited, that function is `async` — Jacobi's is `async u => await sweep(u)`, and the helper awaits whatever it returns. Red-black's is one sweep — both halves — expressed the same way. ## Hint 2 — the one-liner ```js const redBlackSweeps = await sweepsToTolerance( async u => await black(await red(u)) ); ``` Both halves inside one call, so the helper counts a full sweep each time it runs it. Both `await`s matter: the outer one hands the helper a grid, and the inner one is what makes the black half read the red half's output instead of a Promise. ## Same idea elsewhere The shape of this measurement is the one that transfers, more than the numbers: an iterative solver is judged on iterations-to-tolerance, and a GPU implementation is judged on that *times* the cost of an iteration. Red-black buys fewer sweeps for one extra dispatch, which is a trade you make on nearly every platform; the same ledger decides whether SOR's relaxation factor, a Chebyshev acceleration or a full multigrid V-cycle is worth its complexity. Multigrid is where this ends up — and its inner smoother is the red-black sweep you just wrote. ## Starter code ```js // Both solvers, one finish line. Jacobi's loop is done — write red-black's. const gpu = new GPU({ mode }); const sweep = gpu.createKernel(function (u) { const x = this.thread.x; const y = this.thread.y; if (x === 0 || y === 0 || x === this.constants.size - 1 || y === this.constants.size - 1) { return u[y][x]; } return (u[y][x - 1] + u[y][x + 1] + u[y - 1][x] + u[y + 1][x]) / 4; }, { output: [32, 32], constants: { size: 32 } }); const red = gpu.createKernel(function (u) { const x = this.thread.x; const y = this.thread.y; if (x === 0 || y === 0 || x === this.constants.size - 1 || y === this.constants.size - 1) { return u[y][x]; } // parity as a NUMBER — gpu.js cannot keep a boolean in a kernel variable const parity = (x + y) % 2; if (parity !== 0) return u[y][x]; return (u[y][x - 1] + u[y][x + 1] + u[y - 1][x] + u[y + 1][x]) / 4; }, { output: [32, 32], constants: { size: 32 } }); const black = gpu.createKernel(function (u) { const x = this.thread.x; const y = this.thread.y; if (x === 0 || y === 0 || x === this.constants.size - 1 || y === this.constants.size - 1) { return u[y][x]; } // parity as a NUMBER — gpu.js cannot keep a boolean in a kernel variable const parity = (x + y) % 2; if (parity !== 1) return u[y][x]; return (u[y][x - 1] + u[y][x + 1] + u[y - 1][x] + u[y + 1][x]) / 4; }, { output: [32, 32], constants: { size: 32 } }); const residual = gpu.createKernel(function (u) { const x = this.thread.x; const y = this.thread.y; if (x === 0 || y === 0 || x === this.constants.size - 1 || y === this.constants.size - 1) { return 0; } return u[y][x - 1] + u[y][x + 1] + u[y - 1][x] + u[y + 1][x] - 4 * u[y][x]; }, { output: [32, 32], constants: { size: 32 } }); function rmsOf(grid) { let sum = 0; for (let y = 0; y < 32; y++) { for (let x = 0; x < 32; x++) sum += grid[y][x] * grid[y][x]; } return Math.sqrt(sum / (32 * 32)); } const TOL = 0.0005; const CHECK_EVERY = 5; const MAX_SWEEPS = 400; // Sweeps until the RMS residual drops below TOL, checking every 5 sweeps. // `step` turns one grid into the next. async function sweepsToTolerance(step) { let u = plate; let sweeps = 0; while (sweeps < MAX_SWEEPS) { if (rmsOf(await residual(u)) < TOL) break; for (let i = 0; i < CHECK_EVERY; i++) { u = await step(u); sweeps++; } } return sweeps; } const jacobiSweeps = await sweepsToTolerance(async u => await sweep(u)); console.log('jacobi: converged in', jacobiSweeps, 'sweeps'); // TODO: one red-black sweep is the red half followed by the black half, // and the black half has to read what the red half just wrote. // Await both halves — an un-awaited call hands black a Promise. const redBlackSweeps = await sweepsToTolerance(async u => u); console.log('red-black: converged in', redBlackSweeps, 'sweeps'); ``` --- Interactive version: https://gpu.rocks/learn/iterative-solvers-e73b8e1f/5 [Previous task](https://gpu.rocks/learn/iterative-solvers-e73b8e1f/4.md) --- # The Heat Equation & Stability *Module of the free GPU.js GPGPU course · 5 tasks* Why a correct-looking simulation explodes — the step-size limit, and the implicit step that ignores it. ## Tasks 1. [One Explicit Step](https://gpu.rocks/learn/heat-and-stability-514063bb/1.md) 2. [Cross the Line](https://gpu.rocks/learn/heat-and-stability-514063bb/2.md) 3. [Sixteen Step Sizes at Once](https://gpu.rocks/learn/heat-and-stability-514063bb/3.md) 4. [Solve, Don’t Step](https://gpu.rocks/learn/heat-and-stability-514063bb/4.md) 5. [Stable Is Not Accurate](https://gpu.rocks/learn/heat-and-stability-514063bb/5.md) --- Interactive version: https://gpu.rocks/learn/heat-and-stability-514063bb --- # One Explicit Step *Task 1 of 5 · [The Heat Equation & Stability](https://gpu.rocks/learn/heat-and-stability-514063bb.md) · GPU.js Learn* The heat equation is the simplest interesting PDE there is: `∂u/∂t = D·∇²u`. Every point drifts toward the average of what surrounds it, at a rate set by the diffusivity `D`. Discretise the right-hand side with the 5-point stencil — `left + right + up + down − 4·centre`, divided by `dx²`, which Reaction–Diffusion derives in full — take a plain forward-Euler step in time, and the whole solver is one line: ```js u' = u + α·(left + right + up + down − 4u) with α = D·dt/dx² ``` That single dimensionless number `α`, the **diffusion number**, is what this module is about. Collect the terms and the step turns out not to be an addition at all. It is an *average*: ```js u' = (1 − 4α)·u + α·left + α·right + α·up + α·down ``` Five weights that add to exactly one. Notice the shape of that centre weight, `1 − 4α` — the next task is about what happens when it goes negative. ## Figures - **five weights that add to one — while the middle one is positive** ## Goal **Goal:** compute the diffusion number `α` from `D`, `dt` and `dx`, and return one explicit step of `field`. ## Requirements - Compute `ALPHA = D * dt / (dx * dx)` in JavaScript — it reaches the kernel as a constant - The kernel returns this cell’s new value: the old one plus `α` times the Laplacian - The stencil and its wrap-around edges are already written — the world is a torus ## Hint 1 — dx is squared `α = D·dt/dx²` — the cell spacing appears *squared*, because a second derivative is a difference of differences. With `D = 8`, `dt = 0.2` and `dx = 4` that comes to `0.1`. ## Hint 2 — the whole return `lap` and `c` are already in scope, so the body is one line: ```js return c + this.constants.alpha * lap; ``` ## Same idea elsewhere This three-line update is, almost character for character, the innermost loop of every explicit finite-difference solver on every platform: a CUDA kernel with one thread per cell, a WGSL compute shader reading and writing a storage texture, a Metal kernel tiling the grid into threadgroups. What differs between them is memory layout and how the halo is exchanged — never the arithmetic. ## Starter code ```js // One forward-Euler step of the heat equation on a 48×48 torus. const gpu = new GPU({ mode }); const D = 8; // diffusivity const dx = 4; // cell spacing const dt = 0.2; // time step // TODO: the diffusion number, alpha = D * dt / dx² const ALPHA = 0; const step = gpu.createKernel(function (u) { const x = this.thread.x; const y = this.thread.y; // the 5-point stencil, wrapped at the edges (a torus, as in Reaction–Diffusion) let xl = x - 1; if (xl < 0) xl = this.constants.size - 1; let xr = x + 1; if (xr > this.constants.size - 1) xr = 0; let yd = y - 1; if (yd < 0) yd = this.constants.size - 1; let yu = y + 1; if (yu > this.constants.size - 1) yu = 0; const c = u[y][x]; const lap = u[y][xl] + u[y][xr] + u[yd][x] + u[yu][x] - 4 * c; // TODO: return the new value — the old one plus alpha times the Laplacian return c; }, { output: [48, 48], constants: { size: 48, alpha: ALPHA }, }); const next = await step(field); console.log('alpha:', ALPHA, ' centre weight 1 - 4*alpha:', 1 - 4 * ALPHA); console.log('a hot cell on the block edge, was 1, is now:', next[24][20]); ``` --- Interactive version: https://gpu.rocks/learn/heat-and-stability-514063bb/1 [Next task](https://gpu.rocks/learn/heat-and-stability-514063bb/2.md) --- # Cross the Line *Task 2 of 5 · [The Heat Equation & Stability](https://gpu.rocks/learn/heat-and-stability-514063bb.md) · GPU.js Learn* Nothing about the kernel you just wrote is wrong. Hand it a step size that is slightly too big and it will still compute exactly what you asked for — and the field will be at 10²⁴ in eighty steps. Look again at that centre weight, `1 − 4α`. At `α = 0.1` it is `+0.6`, the new value really is an average of five old ones, and an average can never leave the range of the things it averaged. At `α = 0.4` it is `−0.6`, and "average" has become a lie: a cell that sits above its neighbours is now pushed *further* above them every step. In `dims` dimensions the centre weight is `1 − 2·dims·α`, so the tipping point is `α = 1/(2·dims)` — which, written in the quantities you actually control, is the limit every explicit solver lives under: ```js dt ≤ dx² / (2 · D · dims) dims = 2 on a 2D grid ``` Past it, the fastest pattern the grid can hold — a checkerboard of alternating hot and cold cells — is multiplied by `|1 − 8α|` every step instead of damped. At `α = 0.4` that is **2.2× per step**, and there is always some checkerboard in there, if only from rounding. Ten steps: ×2,700. Eighty steps: the run below. ## Figures - **the fastest pattern the grid can hold, flipping and growing, every step** ## Goal **Goal:** work out `dtMax`, then run the same eighty-step simulation twice — once safely inside the limit, once past it — and watch the console. The stepping loop is written for you: N-Body already made a meal of *how* to advance a simulation, and the only thing that matters here is how far each step goes. ## Requirements - Compute `dtMax = dx * dx / (2 * D * DIMS)` — it is `0.5` for this grid - Call `run` once at `0.4 * dtMax` and once at `1.6 * dtMax` - Leave `STEPS` at 80 — the trace prints the hottest cell every 20 steps ## Hint 1 — where the numbers come from Each of the `2 · dims` neighbours takes an `α`-sized bite out of the centre, so the centre keeps `1 − 2·dims·α`. Set that to zero, substitute `α = D·dt/dx²`, and solve for `dt`. ## Hint 2 — the two runs ```js const dtMax = dx * dx / (2 * D * DIMS); await run('SAFE', 0.4 * dtMax); await run('PAST THE LINE', 1.6 * dtMax); // One trace is a flat line hugging the bottom; the other walks off the top. // No linear axis can hold both, which is what the log option is for. plot(traces, { title: 'hottest |u| per step', log: true }); ``` ## Same idea elsewhere Every production explicit solver computes this number and refuses to exceed it: CFL conditions in fluid codes, the diffusion-number check in a thermal simulation, the substepping loop in a cloth or fluid solver on the GPU. It is also why GPU simulations so often become *launch-bound* — halving `dx` to sharpen a picture quarters the legal `dt`, so the same second of simulated time costs four times as many kernel launches. ## Starter code ```js // Two runs of the same simulation. Only the step size differs. const gpu = new GPU({ mode }); const D = 8; // diffusivity const dx = 4; // cell spacing const DIMS = 2; // a 2D grid: four neighbours const STEPS = 80; // TODO: the explicit stability limit — dt ≤ dx² / (2 · D · dims) const dtMax = 0; // max |u| over the field, written so a NaN cannot hide: every comparison with // NaN is false, so `if (a > m)` would skip it. `!(a <= m)` is true for NaN. function hottest(u) { let m = 0; for (let y = 0; y < u.length; y++) { for (let x = 0; x < u[y].length; x++) { const a = Math.abs(u[y][x]); if (!(a <= m)) m = a; } } return m; } const traces = {}; async function run(label, dt) { const alpha = D * dt / (dx * dx); const step = gpu.createKernel(function (u) { const x = this.thread.x; const y = this.thread.y; let xl = x - 1; if (xl < 0) xl = this.constants.size - 1; let xr = x + 1; if (xr > this.constants.size - 1) xr = 0; let yd = y - 1; if (yd < 0) yd = this.constants.size - 1; let yu = y + 1; if (yu > this.constants.size - 1) yu = 0; const c = u[y][x]; const lap = u[y][xl] + u[y][xr] + u[yd][x] + u[yu][x] - 4 * c; return c + this.constants.alpha * lap; }, { output: [48, 48], constants: { size: 48, alpha } }); console.log(label, '— dt =', dt, ' alpha =', alpha, ' centre weight =', 1 - 4 * alpha); let u = seed; const trace = []; for (let i = 1; i <= STEPS; i++) { u = await step(u); trace.push(hottest(u)); if (i % 20 === 0) console.log(' step', i, '→ hottest |u| =', hottest(u)); } traces[label] = trace; return u; } console.log('stability limit: dt <=', dtMax); // TODO: run twice — at 0.4 * dtMax, then at 1.6 * dtMax. // run() is async now (it awaits the kernel), so await each call. // Then draw both traces together with // plot(traces, { title: 'hottest |u| per step', log: true }) // — a LOG axis, because one of them is about to leave the other behind // by twenty orders of magnitude. ``` --- Interactive version: https://gpu.rocks/learn/heat-and-stability-514063bb/2 [Previous task](https://gpu.rocks/learn/heat-and-stability-514063bb/1.md) · [Next task](https://gpu.rocks/learn/heat-and-stability-514063bb/3.md) --- # Sixteen Step Sizes at Once *Task 3 of 5 · [The Heat Equation & Stability](https://gpu.rocks/learn/heat-and-stability-514063bb.md) · GPU.js Learn* You have been told where the line is. Now measure it — and measure it the way a GPU makes cheap: not by running sixteen simulations one after another, but by running **all sixteen in the same kernel launch**. The grid below is 64 columns by 16 rows, and each row is its own universe: a 64-cell ring, seeded with one hot cell, stepped with *its own* `dts[row]`. Nothing couples the rows, so the Laplacian here is the 1D one, `left + right − 2·centre`, along `x` only. Reach for the familiar 5-point stencil and row 3's instability leaks into row 4. Rings are one-dimensional, so `dims = 1` and the limit moves: `dt ≤ dx²/(2·D·1) = 1`, twice what it was on the 2D grid. That factor is not decoration — it is the number of neighbours taking a bite out of the centre. After 90 steps the answer is unmissable: the stable rows have flattened to a few hundredths, and the row on the other side of the line is at 10³. ## Goal **Goal:** step all sixteen rings 90 times, then report the largest `dt` whose row is still under 1 — and compare it with `dx²/(2·D·1)`. ## Requirements - The kernel forms *this row’s* diffusion number from `dts[this.thread.y]` - The Laplacian runs along `x` only: `left + right − 2·centre` — rows must not read each other - After 90 steps, find each row’s largest `|u|`; a row survived if that is below 1 - Log the largest surviving `dt` on a line that says `measured`, and the predicted limit beside it ## Hint 1 — which dt is mine? `this.thread.y` is the row, so `dts[this.thread.y]` is this ring's step size. Turn it into a diffusion number the same way as before: ```js const a = this.constants.diff * dts[r] / (this.constants.dx * this.constants.dx); ``` ## Hint 2 — one dimension, two neighbours `return u[r][x] + a * (u[r][xl] + u[r][xr] - 2 * u[r][x]);` — note the `2`, not `4`. Only the row index `r` never varies. ## Hint 3 — scanning the rows afterwards Plain JavaScript on the finished grid: ```js for (let r = 0; r < ROWS; r++) { let m = 0; for (let x = 0; x < CELLS; x++) { const a = Math.abs(u[r][x]); if (!(a <= m)) m = a; // so a NaN cannot hide } if (m < 1 && dts[r] > measured) { measured = dts[r]; } } ``` ## Same idea elsewhere Sweeping a parameter by giving it an axis of the launch grid is the GPU's answer to "try them all": CUDA codes run a batch of independent problems as extra blocks, WebGPU dispatches a third workgroup dimension over configurations, and every autotuner on every platform is this shape. It is also how a solver picks its own step size in production — run the candidate, look at what came back, and back off. ## Starter code ```js // Sixteen simulations in one grid: row r is a 64-cell ring with its own dt. const gpu = new GPU({ mode }); const D = 8; const dx = 4; const CELLS = 64; const ROWS = 16; const STEPS = 90; const step = gpu.createKernel(function (u, dts) { const x = this.thread.x; const r = this.thread.y; let xl = x - 1; if (xl < 0) xl = this.constants.cells - 1; let xr = x + 1; if (xr > this.constants.cells - 1) xr = 0; // TODO: this row's diffusion number, then ONE 1D step of ring r: // a = diff * dts[r] / (dx * dx) (both live in this.constants) // u[r][x] + a * (left + right - 2 * centre) return u[r][x]; }, { output: [64, 16], constants: { cells: 64, diff: 8, dx: 4 }, }); let u = seed; for (let i = 0; i < STEPS; i++) u = await step(u, dts); // TODO: for each row print the largest |u| left, and keep the largest dt // whose row stayed below 1. let measured = 0; console.log('measured limit: dt <=', measured); console.log('predicted: dt <=', dx * dx / (2 * D * 1)); ``` --- Interactive version: https://gpu.rocks/learn/heat-and-stability-514063bb/3 [Previous task](https://gpu.rocks/learn/heat-and-stability-514063bb/2.md) · [Next task](https://gpu.rocks/learn/heat-and-stability-514063bb/4.md) --- # Solve, Don’t Step *Task 4 of 5 · [The Heat Equation & Stability](https://gpu.rocks/learn/heat-and-stability-514063bb.md) · GPU.js Learn* The whole problem is that the explicit step evaluates the Laplacian on the field it is leaving. Evaluate it on the field it is *arriving* at instead — that is backward Euler — and the step size limit vanishes entirely. Unconditionally stable, at any `dt`, forever. The catch is visible the moment you write it down. The unknown is on both sides: ```js u' = u + α·∇²u' ⟺ (1 + 4α)·u' − α·(neighbours of u') = u ``` That is not a formula you evaluate, it is a **linear system** — one equation per cell, 1,024 of them on this grid, all coupled. Solve it the way GPUs like: rearrange each equation for its own cell, then iterate. ```js u'[c] = ( u[c] + α·(neighbours of u') ) / (1 + 4α) ``` Every cell reads only the *previous* iterate, so all 1,024 can be computed at once — that is a **Jacobi sweep**, and the Iterative Solvers module takes it much further (red-black ordering, residuals, why it beats Gauss–Seidel on a GPU). Here 25 sweeps is plenty, because the `1` in `1 + 4α` makes this system diagonally dominant and easy. One thing must not slip: `u` on the right is the *old time level* and never changes during the solve. Only the guess moves. ## Figures - **one word changes — 'new' — and the step becomes a system of equations** ## Goal **Goal:** write the Jacobi sweep, then iterate it 25 times to take a single implicit step at `dt = 2` — four times the explicit limit. ## Requirements - The sweep returns `(uOld[y][x] + α · (four neighbours of *guess*)) / (1 + 4α)` - The centre term comes from `uOld`; only the four neighbours come from `guess` - Iterate `SWEEPS` times, passing the *same* `seed` as `uOld` every time ## Hint 1 — where the division comes from Collect the unknown cell on the left of `u' = u + α·(l + r + up + dn − 4u')`: the `−4α·u'` moves over as `+4α·u'`, giving `(1 + 4α)·u' = u + α·(l + r + up + dn)`. Divide. ## Hint 2 — the sweep body ```js const neighbours = guess[y][xl] + guess[y][xr] + guess[yd][x] + guess[yu][x]; return (uOld[y][x] + this.constants.alpha * neighbours) / (1 + 4 * this.constants.alpha); ``` ## Hint 3 — why the starter’s loop is wrong `sweep(guess, guess)` replaces the right-hand side with the current iterate every sweep, which throws away the one piece of information that makes this a *time step*. It still converges — to `∇²u = 0`, a flat field. The fix is one word: `guess = await sweep(seed, guess);` ## Same idea elsewhere "The implicit step is a linear solve" is the fork in the road for every production simulator: implicit thermal and structural codes hand `(I − αL)` to a Krylov solver with a preconditioner, and GPU fluid solvers run exactly this Jacobi (or a multigrid V-cycle) for the pressure projection every frame. cuSPARSE, rocSPARSE and every WebGPU fluid demo you have seen are all standing on this one rearrangement. ## Starter code ```js // Backward Euler: the new field appears on BOTH sides of the equation. // Solve it with Jacobi sweeps — every cell reads the previous iterate. const gpu = new GPU({ mode }); const D = 8; const dx = 4; const dt = 2; // 4× the explicit limit of 0.5 const ALPHA = D * dt / (dx * dx); // = 1 const SWEEPS = 25; const sweep = gpu.createKernel(function (uOld, guess) { const x = this.thread.x; const y = this.thread.y; let xl = x - 1; if (xl < 0) xl = this.constants.size - 1; let xr = x + 1; if (xr > this.constants.size - 1) xr = 0; let yd = y - 1; if (yd < 0) yd = this.constants.size - 1; let yu = y + 1; if (yu > this.constants.size - 1) yu = 0; // TODO: one Jacobi sweep — // (uOld[y][x] + alpha * (the four neighbours of GUESS)) / (1 + 4 * alpha) return guess[y][x]; }, { output: [32, 32], constants: { size: 32, alpha: ALPHA }, }); function hottest(u) { let m = 0; for (let y = 0; y < u.length; y++) { for (let x = 0; x < u[y].length; x++) { const a = Math.abs(u[y][x]); if (!(a <= m)) m = a; } } return m; } function total(u) { let s = 0; for (let y = 0; y < u.length; y++) for (let x = 0; x < u[y].length; x++) s += u[y][x]; return s; } let guess = seed; for (let k = 0; k < SWEEPS; k++) { // TODO: the right-hand side is the OLD field and never changes during a // solve. This passes the current iterate instead, which throws the time // step away and converges to a flat field. guess = await sweep(guess, guess); } console.log('hottest after one implicit step:', hottest(guess)); console.log('total heat (unchanged at 36):', total(guess)); ``` --- Interactive version: https://gpu.rocks/learn/heat-and-stability-514063bb/4 [Previous task](https://gpu.rocks/learn/heat-and-stability-514063bb/3.md) · [Next task](https://gpu.rocks/learn/heat-and-stability-514063bb/5.md) --- # Stable Is Not Accurate *Task 5 of 5 · [The Heat Equation & Stability](https://gpu.rocks/learn/heat-and-stability-514063bb.md) · GPU.js Learn* Time to put the two schemes on the same clock. Both runs below finish at `T = 16`: the explicit one in small steps of `0.2`, the implicit one in steps of `2` — ten times larger, and four times past the explicit limit. A third run takes the explicit scheme at the implicit step size, for the pleasure of watching it fail in eight steps. The two survivors will not agree. Backward Euler is *first-order* accurate, just like forward Euler, so a ten-times-larger step carries a ten-times-larger error — it damps sharp features harder than the real equation does. Expect the two fields to differ by a few percent of the peak. That is the honest trade, and it is worth stating plainly: **unconditional stability is not accuracy**. What implicit stepping buys you is the right to choose `dt` for the accuracy you need, rather than having it dictated by the smallest cell in your mesh. Nor is it free. Each implicit step here costs 25 sweeps, so the coarse run makes `8 × 25 = 200` kernel launches against the fine run's 80 — implicit *loses* the launch count at this size. It wins when the explicit limit gets brutal: refine `dx` by 10× and the explicit run needs 100× the steps, while the implicit one needs the same eight and a slightly harder solve. ## Goal **Goal:** finish the Jacobi sweep with `α` arriving as an argument, work out how many steps of each size reach `T`, and run all three. ## Requirements - The sweep is the one from the last task, but `alpha` is a kernel *argument*, not a constant - `smallSteps` and `bigSteps` are the counts that reach `T = 16` at `dt = 0.2` and `dt = 2` - Run all three: explicit at the small step, implicit at the big one, explicit at the big one - Log the hottest cell of each, and the largest gap between the two survivors ## Hint 1 — alpha as an argument Only the spelling changes: a plain `alpha` where the constant used to be, and the caller passes it. Everything else is last task's body: ```js return (uOld[y][x] + alpha * neighbours) / (1 + 4 * alpha); ``` ## Hint 2 — how many steps? Steps × step size = elapsed time, so it is `T / dt`: `16 / 0.2 = 80` and `16 / 2 = 8`. ## Same idea elsewhere Choosing a scheme by what limits it — accuracy or stability — is the daily work of numerical simulation everywhere: stiff chemistry and implicit thermal solvers pay for a linear solve per step because the explicit alternative would need millions of them, while explicit codes dominate wave propagation and particle work, where the stability step is close to the accuracy step anyway. On a GPU the arithmetic is nearly free, so the calculus is really about launches and memory traffic per unit of simulated time. ## Starter code ```js // Same physics, same finish time, two step sizes. const gpu = new GPU({ mode }); const D = 8; const dx = 4; const T = 16; // finish time const DT_SMALL = 0.2; // 0.4× the explicit limit (0.5) const DT_BIG = 2; // 4× the explicit limit const SWEEPS = 25; const explicitStep = gpu.createKernel(function (u, alpha) { const x = this.thread.x; const y = this.thread.y; let xl = x - 1; if (xl < 0) xl = this.constants.size - 1; let xr = x + 1; if (xr > this.constants.size - 1) xr = 0; let yd = y - 1; if (yd < 0) yd = this.constants.size - 1; let yu = y + 1; if (yu > this.constants.size - 1) yu = 0; const c = u[y][x]; return c + alpha * (u[y][xl] + u[y][xr] + u[yd][x] + u[yu][x] - 4 * c); }, { output: [32, 32], constants: { size: 32 } }); const sweep = gpu.createKernel(function (uOld, guess, alpha) { const x = this.thread.x; const y = this.thread.y; let xl = x - 1; if (xl < 0) xl = this.constants.size - 1; let xr = x + 1; if (xr > this.constants.size - 1) xr = 0; let yd = y - 1; if (yd < 0) yd = this.constants.size - 1; let yu = y + 1; if (yu > this.constants.size - 1) yu = 0; // TODO: last task's sweep, with alpha coming in as an argument return guess[y][x]; }, { output: [32, 32], constants: { size: 32 } }); function hottest(u) { let m = 0; for (let y = 0; y < u.length; y++) { for (let x = 0; x < u[y].length; x++) { const a = Math.abs(u[y][x]); if (!(a <= m)) m = a; } } return m; } function gap(a, b) { let m = 0; for (let y = 0; y < a.length; y++) { for (let x = 0; x < a[y].length; x++) { const d = Math.abs(a[y][x] - b[y][x]); if (!(d <= m)) m = d; } } return m; } async function runExplicit(dt, steps) { const alpha = D * dt / (dx * dx); let u = seed; for (let i = 0; i < steps; i++) u = await explicitStep(u, alpha); return u; } async function runImplicit(dt, steps) { const alpha = D * dt / (dx * dx); let u = seed; for (let i = 0; i < steps; i++) { let guess = u; for (let k = 0; k < SWEEPS; k++) guess = await sweep(u, guess, alpha); u = guess; } return u; } // TODO: how many steps of each size land exactly on time T? const smallSteps = 0; const bigSteps = 0; const fine = await runExplicit(DT_SMALL, smallSteps); const coarse = await runImplicit(DT_BIG, bigSteps); const doomed = await runExplicit(DT_BIG, bigSteps); console.log('explicit, dt =', DT_SMALL, 'x', smallSteps, 'steps → hottest', hottest(fine)); console.log('implicit, dt =', DT_BIG, 'x', bigSteps, 'steps → hottest', hottest(coarse)); console.log('explicit at dt =', DT_BIG, '→ hottest', hottest(doomed)); console.log('largest gap between the two survivors:', gap(fine, coarse)); ``` --- Interactive version: https://gpu.rocks/learn/heat-and-stability-514063bb/5 [Previous task](https://gpu.rocks/learn/heat-and-stability-514063bb/4.md) --- # Gradient Descent *Module of the free GPU.js GPGPU course · 5 tasks* Fit a line by walking downhill — the gradient as a reduction, the learning rate as a stability limit, and 1,024 searches in one launch. ## Tasks 1. [The Loss You Can Check](https://gpu.rocks/learn/gradient-descent-c94c3f22/1.md) 2. [The Gradient Is a Reduction](https://gpu.rocks/learn/gradient-descent-c94c3f22/2.md) 3. [Take the Step](https://gpu.rocks/learn/gradient-descent-c94c3f22/3.md) 4. [How Big a Step? Ask 256 at Once](https://gpu.rocks/learn/gradient-descent-c94c3f22/4.md) 5. [A Thousand Starts, Three Answers](https://gpu.rocks/learn/gradient-descent-c94c3f22/5.md) --- Interactive version: https://gpu.rocks/learn/gradient-descent-c94c3f22 --- # The Loss You Can Check *Task 1 of 5 · [Gradient Descent](https://gpu.rocks/learn/gradient-descent-c94c3f22.md) · GPU.js Learn* Fitting is optimisation. Pick a model — here the straight line `y = m·x + c` — pick a single number that says how badly it fits, then go looking for the parameters that make that number small. The number is the **loss**; for least squares it is the mean squared residual. Gradient descent has a reputation as the engine inside model training, but underneath it is nothing more than a numerical method for walking downhill, and that is all this module asks of it. Look at what the loss actually *is*: ```js L(m, c) = (1/n) · Σ (m·xᵢ + c − yᵢ)² ``` A sum over every data point, divided by n — a **reduction**, the same many-in-one-out shape the Reductions module builds its halving ladder for. So the first kernel here is a partial-sum kernel with the squaring fused into the read: 64 threads, 64 points each, one pass over memory. These 4,096 points were built so that every claim in this module is checkable to the last digit. The best-fitting line is exactly `y = 3x + 4`, the lowest reachable loss is exactly `0.5`, and the whole surface is `L(m, c) = (m − 3)² + (c − 4)² + 0.5`. From `m = c = 0`, then, the loss must read `25.5`. ## Goal **Goal:** finish the partial-sum kernel so it accumulates **squared** residuals, then divide the grand total by 4,096 and log the loss at `m = 0, c = 0` — it should be `25.5`. ## Requirements - Each of the 64 threads walks its strided slice: `i * this.constants.threads + this.thread.x` - The residual of point `at` is `m * xs[at] + c - ys[at]` - Accumulate its *square* — squaring happens as the value is read, not in a second pass - Divide the total of the 64 partials by 4096 before logging ## Hint 1 — read once, square immediately Name the residual, then square the name — no second pass over the data: ```js const r = m * xs[at] + c - ys[at]; sum += r * r; ``` ## Hint 2 — mean, not total The 64 partials add up to `Σ r²` over all 4,096 points. The loss is the *mean* squared residual, so the last step is `total / 4096`. Forget it and you get 104,448 instead of 25.5. ## Same idea elsewhere Every training loop on every platform begins with exactly this: a per-example loss, summed and averaged. CUB and Thrust reduce it with a tree, a WGSL compute shader does it with workgroup shared memory and subgroup adds. Fusing the square into the read rather than running a separate squaring pass is `thrust::transform_reduce` in one line, and it halves the memory traffic. ## Starter code ```js // 4,096 points, 64 threads, 64 points each — strided, so neighbouring // threads read neighbouring points at every step of the loop. const gpu = new GPU({ mode }); const lossPartials = gpu.createKernel(function (xs, ys, m, c) { let sum = 0; for (let i = 0; i < this.constants.chunk; i++) { const at = i * this.constants.threads + this.thread.x; // TODO: the residual of point `at` is m * xs[at] + c - ys[at]. // Accumulate its SQUARE into sum. sum += 0; } return sum; }, { output: [64], constants: { threads: 64, chunk: 64 }, }); const partials = await lossPartials(xs, ys, 0, 0); let total = 0; for (let i = 0; i < partials.length; i++) total += partials[i]; // TODO: the loss is the MEAN squared residual — divide by 4096. console.log('loss at m = 0, c = 0:', total); ``` --- Interactive version: https://gpu.rocks/learn/gradient-descent-c94c3f22/1 [Next task](https://gpu.rocks/learn/gradient-descent-c94c3f22/2.md) --- # The Gradient Is a Reduction *Task 2 of 5 · [Gradient Descent](https://gpu.rocks/learn/gradient-descent-c94c3f22.md) · GPU.js Learn* To walk downhill you need the direction of steepest *ascent* — the **gradient** — and then you go the other way. Differentiating the loss is two lines of calculus, and for a model this small there is no reason to reach for anything cleverer than writing them out: ```js rᵢ = m·xᵢ + c − yᵢ ∂L/∂m = (2/n) · Σ rᵢ · xᵢ ∂L/∂c = (2/n) · Σ rᵢ ``` Two sums, over the same residuals, in the same order. There is no reason to walk the data twice: one kernel computes both, holding each residual in a register and pushing it into two accumulators. What comes back is a 2D output — `output: [64, 2]`, 64 threads wide and 2 rows tall — where **row 0** holds the `Σ r·x` partials and **row 1** the `Σ r` partials. Notice where the branch that picks the row goes: *after* the loop, never inside it. Every thread then runs the identical straight-line body and only the last statement differs. At `m = c = 0` the gradient must come out `(−6, −8)`, because on this data `∇L = (2(m − 3), 2(c − 4))`. ## Figures - **read each residual once, spend it twice** ## Goal **Goal:** fill in the two accumulators and the row selection, then assemble the gradient in JavaScript and log it — `(−6, −8)` at `m = 0, c = 0`. ## Requirements - `sm` accumulates `r * xs[at]`, `sc` accumulates `r` - Row 0 of the output returns `sm`, row 1 returns `sc` — branch on `this.thread.y` *after* the loop - Total each row in JavaScript and multiply by `2 / 4096` - Log both components ## Hint 1 — one residual, two homes Read the residual once and use it twice, exactly as the fused loss kernel reused it: ```js const r = m * xs[at] + c - ys[at]; sm += r * xs[at]; sc += r; ``` ## Hint 2 — which row am I? `this.thread.y` is 0 for the first row and 1 for the second. Put the choice after the loop so the loop body stays identical for every thread: ```js if (this.thread.y === 0) { return sm; } return sc; ``` ## Hint 3 — the 2/n out front A 2D result is indexed `rows[y][x]`, so `rows[0]` is the 64 slope partials and `rows[1]` the 64 intercept partials. Both totals then need the same scaling: `(2 * total) / 4096`. ## Same idea elsewhere Accumulating several statistics in one pass over the data is standard practice everywhere: a CUDA kernel keeps both partials in registers across a single grid-stride loop, CUB instantiates one `BlockReduce` per accumulator but reads the tile once, and WGSL does the same with two workgroup arrays. The 2D output is just gpu.js's spelling of *one launch, two results*. ## Starter code ```js // One walk over the data, two sums out of it. // Row 0 → the Σ r·x partials, row 1 → the Σ r partials. const gpu = new GPU({ mode }); const gradPartials = gpu.createKernel(function (xs, ys, m, c) { let sm = 0; let sc = 0; for (let i = 0; i < this.constants.chunk; i++) { const at = i * this.constants.threads + this.thread.x; const r = m * xs[at] + c - ys[at]; // TODO: sm accumulates the residual weighted by x, sc the residual itself. sm += 0; sc += 0; } // TODO: row 0 of the output is sm, row 1 is sc. Branch on this.thread.y. return sm; }, { output: [64, 2], constants: { threads: 64, chunk: 64 }, }); const rows = await gradPartials(xs, ys, 0, 0); let sumMx = 0; let sumC = 0; for (let i = 0; i < 64; i++) { sumMx += rows[0][i]; sumC += rows[1][i]; } // TODO: both components carry a factor of 2/n. n is 4096. const gm = sumMx; const gc = sumC; console.log('gradient at m = 0, c = 0:', gm, gc); ``` --- Interactive version: https://gpu.rocks/learn/gradient-descent-c94c3f22/2 [Previous task](https://gpu.rocks/learn/gradient-descent-c94c3f22/1.md) · [Next task](https://gpu.rocks/learn/gradient-descent-c94c3f22/3.md) --- # Take the Step *Task 3 of 5 · [Gradient Descent](https://gpu.rocks/learn/gradient-descent-c94c3f22.md) · GPU.js Learn* The whole algorithm, now: stand somewhere, ask which way is up, move the other way, repeat. ```js m ← m − η · ∂L/∂m c ← c − η · ∂L/∂c ``` η is the **learning rate** — how far you move per step. Both kernels below are already written (task 1's loss, task 2's gradient), and the driving loop lives in JavaScript, exactly as the halving ladder's driver does: the arithmetic that touches all 4,096 points stays on the GPU, and the two numbers that say where you are stay on the host. Sixty steps at `η = 0.1` from `(0, 0)` land on `(3, 4)` to five decimal places, and the loss falls `25.5 → 0.79 → 0.503 → 0.5` along the way. Watch where it stops: **0.5**, not zero. The scatter in this data is real, and no straight line can explain it away — the optimum is where the loss stops falling, not where it vanishes. ## Goal **Goal:** write the two lines that move `m` and `c` one step downhill, and land on `y = 3x + 4` with a final loss of `0.5`. ## Requirements - Step *against* the gradient — subtract, do not add - Scale each step by `rate`: `m = m - rate * g[0]` - `gradientAt()` is async — `await` it, and use the averaged gradient it returns, not a raw sum - The logged final slope and intercept should read `3` and `4` ## Hint 1 — which way is downhill? The gradient points in the direction the loss *increases* fastest. At `(0, 0)` it is `(−6, −8)`, so downhill is `(+6, +8)`, and `m − 0.1·(−6) = 0.6` is the first step. That minus sign is the whole difference between fitting and exploding. ## Hint 2 — the two lines ```js m = m - rate * g[0]; c = c - rate * g[1]; ``` Both parameters move on the *same* gradient — the one measured before either of them changed. ## Same idea elsewhere Host-driven, device-computed is how a real optimiser runs: a Python training loop issues CUDA kernels one step at a time, a WebGPU trainer records one dispatch per step. Parameters are small and data is not, so the parameters live where the control flow is — and the 60 round trips you just paid are exactly the cost real frameworks fight by fusing whole steps into one launch. ## Starter code ```js // Both kernels are already written. The algorithm is yours. const gpu = new GPU({ mode }); const gradPartials = gpu.createKernel(function (xs, ys, m, c) { let sm = 0; let sc = 0; for (let i = 0; i < this.constants.chunk; i++) { const at = i * this.constants.threads + this.thread.x; const r = m * xs[at] + c - ys[at]; sm += r * xs[at]; sc += r; } if (this.thread.y === 0) { return sm; } return sc; }, { output: [64, 2], constants: { threads: 64, chunk: 64 } }); const lossPartials = gpu.createKernel(function (xs, ys, m, c) { let sum = 0; for (let i = 0; i < this.constants.chunk; i++) { const at = i * this.constants.threads + this.thread.x; const r = m * xs[at] + c - ys[at]; sum += r * r; } return sum; }, { output: [64], constants: { threads: 64, chunk: 64 } }); async function gradientAt(m, c) { const rows = await gradPartials(xs, ys, m, c); let sumMx = 0; let sumC = 0; for (let i = 0; i < 64; i++) { sumMx += rows[0][i]; sumC += rows[1][i]; } return [(2 * sumMx) / 4096, (2 * sumC) / 4096]; } async function lossAt(m, c) { const partials = await lossPartials(xs, ys, m, c); let total = 0; for (let i = 0; i < 64; i++) total += partials[i]; return total / 4096; } const rate = 0.1; let m = 0; let c = 0; const curve = []; for (let step = 1; step <= 60; step++) { const g = await gradientAt(m, c); // TODO: move m and c one step DOWNHILL. // g[0] is dL/dm and g[1] is dL/dc — both point UPHILL. curve.push(await lossAt(m, c)); if (step % 10 === 0) console.log('step', step, '· loss', await lossAt(m, c)); } console.log('fitted slope:', m); console.log('fitted intercept:', c); console.log('final loss:', await lossAt(m, c)); // The shape is the lesson: a steep drop, then a floor. Watch WHERE it flattens. plot({ loss: curve }, { title: 'loss per step' }); ``` --- Interactive version: https://gpu.rocks/learn/gradient-descent-c94c3f22/3 [Previous task](https://gpu.rocks/learn/gradient-descent-c94c3f22/2.md) · [Next task](https://gpu.rocks/learn/gradient-descent-c94c3f22/4.md) --- # How Big a Step? Ask 256 at Once *Task 4 of 5 · [Gradient Descent](https://gpu.rocks/learn/gradient-descent-c94c3f22.md) · GPU.js Learn* Where did `η = 0.1` come from? Nowhere. It was a guess, and guesses are where gradient descent goes wrong: too small and you never arrive, too large and you do not merely overshoot — you **diverge**, because the step lands you further from the minimum than you started and the next one is longer still. There is an exact limit. One step turns the error `e = θ − θ*` into `(I − η·H)·e`, where `H` is the matrix of second derivatives, so the walk shrinks the error only while `η < 2 / λ_max`. On this data `H = 2I`, which makes `λ_max = 2` and the limit exactly `η < 1` — and `η = 1/2` the rate that lands on the answer in a single step. (A step size with a hard ceiling is not a quirk of optimisation. Explicit time-stepping of a diffusion has one too, for the same eigenvalue reason.) Believing that is optional; measuring it costs one launch. Give each of 256 threads its own learning rate, let each run a complete 80-step descent on its own copy of `m` and `c`, and read all 256 final losses back at once. This is the axis flip that makes hyperparameter search a GPU problem: earlier tasks were parallel *over data points*, and here the data is small (64 points, the identical loss surface) while the *runs* are many — so the threads go one per run, and each one walks its own slice of the data by itself. ## Figures - **too short to arrive, exactly right, or thrown out of the bowl** ## Goal **Goal:** give each thread its own learning rate and its own descent, and return the final loss — so that rates `1/8` and `1/2` come back at `0.5`, rate `1` sits at `25.5` forever, and rate `3/2` is off the scale. ## Requirements - Each thread reads exactly one rate: `rates[this.thread.x]` - The step is scaled by that rate *and* by `this.constants.gradScale` (the 2/n) - Return the mean squared residual after the last step, not `m` or `c` - Log the losses at rates `1/8`, `1/2`, `1` and `3/2` ## Hint 1 — one thread, one rate This is the same "which element is mine?" question every kernel asks, and the answer is the same: `const rate = rates[this.thread.x];`. Leave it as `rates[0]` and all 256 threads run the identical experiment. ## Hint 2 — the step ```js m = m - rate * this.constants.gradScale * gm; c = c - rate * this.constants.gradScale * gc; ``` `gradScale` is `2 / 64`, precomputed in JavaScript — two integer constants would divide as integers in the shader and give you zero. ## Hint 3 — reading the result The final losses tell a story in three parts: a band in the middle that reached `0.5`, a few rates at the bottom still crawling towards it, and everything past `η = 1` heading for infinity. The one at exactly `η = 1` is the strangest: it neither converges nor explodes, because `|1 − η·λ| = 1` means the error flips sign and keeps its size. ## Same idea elsewhere Sweeping hyperparameters one per thread is the small, cheap version of what a tuner does with whole models across a cluster — and when the per-run state fits in registers there is no reason to leave the GPU between runs at all. The design decision is which axis to parallelise: over data when the data is big, over runs when the runs are many. Same kernel language, opposite layout. ## Starter code ```js // 256 threads. 256 learning rates. One complete descent each. // The dataset is 64 points from the same line — the same loss surface, // L(m, c) = (m − 3)² + (c − 4)² + 0.5, at a size that fits in a loop. const gpu = new GPU({ mode }); const sweep = gpu.createKernel(function (xs, ys, rates) { // TODO: this thread owns exactly ONE of the 256 learning rates. const rate = rates[0]; let m = 0; let c = 0; for (let s = 0; s < this.constants.steps; s++) { let gm = 0; let gc = 0; for (let i = 0; i < this.constants.points; i++) { const r = m * xs[i] + c - ys[i]; gm += r * xs[i]; gc += r; } // TODO: the step is missing this thread's rate. m = m - this.constants.gradScale * gm; c = c - this.constants.gradScale * gc; } let loss = 0; for (let i = 0; i < this.constants.points; i++) { const r = m * xs[i] + c - ys[i]; loss += r * r; } return loss * this.constants.invPoints; }, { output: [256], constants: { points: 64, steps: 80, gradScale: 2 / 64, invPoints: 1 / 64 }, }); const finalLoss = await sweep(xs, ys, rates); let converged = 0; for (let k = 0; k < finalLoss.length; k++) { if (finalLoss[k] < 1) converged++; } console.log('rate 1/8 → loss', finalLoss[15]); console.log('rate 1/2 → loss', finalLoss[63]); console.log('rate 1 → loss', finalLoss[127]); console.log('rate 3/2 → loss', finalLoss[191]); console.log('rates that got under loss 1:', converged, 'of 256'); ``` --- Interactive version: https://gpu.rocks/learn/gradient-descent-c94c3f22/4 [Previous task](https://gpu.rocks/learn/gradient-descent-c94c3f22/3.md) · [Next task](https://gpu.rocks/learn/gradient-descent-c94c3f22/5.md) --- # A Thousand Starts, Three Answers *Task 5 of 5 · [Gradient Descent](https://gpu.rocks/learn/gradient-descent-c94c3f22.md) · GPU.js Learn* Everything so far worked because the loss was a bowl: one minimum, and every road leads to it. Most surfaces are not bowls. Here is one that is not — a single parameter `w`, and a slope written out by hand, the way every gradient in this module has been: ```js f'(w) = w⁵ − 5w³ + 4w = w · (w² − 1) · (w² − 4) ``` Five roots: `−2, −1, 0, 1, 2`. Three are valleys (`−2`, `0`, `+2`) and two are ridges (`±1`). Gradient descent can see none of this. It reads the slope under its feet and steps, so *where it ends up is decided entirely by where it began* — and the borders between the three answers sit exactly at `w = ±1`. Which is a question with 1,024 answers and 1,024 threads to answer it: one start each, 200 steps each, one launch. The step size is fixed at `0.02`, well under the `2/24 ≈ 0.083` the curvature at `w = ±2` allows. Watch what comes back: `starts[300] = −1.0352` finishes at `−2` and `starts[320] = −0.9375` finishes at `0` — two runs a tenth of a unit apart, ending two whole units apart. ## Figures - **the answer you get is the valley you started in** ## Goal **Goal:** give each thread its own starting point, step it 200 times against the slope, and return where it stopped — `308` threads should land near `−2`, `409` near `0` and `307` near `+2`. ## Requirements - Each thread reads its own start: `starts[this.thread.x]` - The slope is `w⁵ − 5w³ + 4w` — write it out with plain multiplications - Step against it: `w = w - this.constants.rate * slope` - Count how many threads finished in each of the three valleys and log the three counts ## Hint 1 — the slope, spelled out No `Math.pow` needed, and no autodiff either — the derivative of a polynomial is a polynomial: ```js const slope = w * w * w * w * w - 5 * w * w * w + 4 * w; ``` ## Hint 2 — the update is the same one Identical to the line fit, with one parameter instead of two: `w = w - this.constants.rate * slope;`. That is the entire algorithm; the surface changed, not the method. ## Hint 3 — counting the basins Every thread finishes within a rounding error of `−2`, `0` or `+2`, so a two-way split on the returned value is enough: ```js if (ends[k] < -1) left++; else if (ends[k] < 1) middle++; else right++; ``` ## Same idea elsewhere Random-restart optimisation is a working tool, not a curiosity — basin hopping, multi-start least squares, ensembles of annealing runs all do this, and every one of them is embarrassingly parallel, which is why CUDA and Metal implementations launch thousands of restarts at a time. The GPU does not make any single walk faster; it makes a thousand of them fit in one launch. ## Starter code ```js // f'(w) = w⁵ − 5w³ + 4w. Three valleys at −2, 0, +2; // two ridges at ±1. 1,024 starts, one per thread. const gpu = new GPU({ mode }); const walk = gpu.createKernel(function (starts) { // TODO: this thread owns ONE starting point. let w = starts[0]; for (let s = 0; s < this.constants.steps; s++) { // TODO: the slope of the surface at w is w⁵ − 5w³ + 4w. const slope = 0; w = w - this.constants.rate * slope; } return w; }, { output: [1024], constants: { steps: 200, rate: 0.02 }, }); const ends = await walk(starts); let left = 0; let middle = 0; let right = 0; for (let k = 0; k < ends.length; k++) { if (ends[k] < -1) left++; else if (ends[k] < 1) middle++; else right++; } console.log('landed near -2:', left, '| near 0:', middle, '| near +2:', right); console.log('start', starts[300], '→', ends[300]); console.log('start', starts[320], '→', ends[320]); ``` --- Interactive version: https://gpu.rocks/learn/gradient-descent-c94c3f22/5 [Previous task](https://gpu.rocks/learn/gradient-descent-c94c3f22/4.md) --- # The Ising Model: Colour to Break the Race *Module of the free GPU.js GPGPU course · 6 tasks* Metropolis on a lattice of spins, the race that makes an all-at-once update silently wrong, and the checkerboard that repairs it — ending in a temperature slider you can drag through a phase transition. ## Tasks 1. [What a Flip Would Cost](https://gpu.rocks/learn/ising-model-1f12d841/1.md) 2. [Randomness Without a Random Number Generator](https://gpu.rocks/learn/ising-model-1f12d841/2.md) 3. [Everyone at Once Is Wrong](https://gpu.rocks/learn/ising-model-1f12d841/3.md) 4. [Colour the Lattice](https://gpu.rocks/learn/ising-model-1f12d841/4.md) 5. [Two Halves Make a Sweep](https://gpu.rocks/learn/ising-model-1f12d841/5.md) 6. [Drag the Temperature Across Tc](https://gpu.rocks/learn/ising-model-1f12d841/6.md) --- Interactive version: https://gpu.rocks/learn/ising-model-1f12d841 --- # What a Flip Would Cost *Task 1 of 6 · [The Ising Model: Colour to Break the Race](https://gpu.rocks/learn/ising-model-1f12d841.md) · GPU.js Learn* A magnet, stripped to almost nothing: a grid of arrows, each one either **up** (`+1`) or **down** (`−1`), and a single rule — neighbours would rather agree. Ernst Ising's 1925 model is that sentence and no more, and it is still the standard proving ground for phase transitions, because it is simple enough to be solved exactly and rich enough to have one. Agreement is written as energy. Every neighbouring pair contributes `−s·s'`: `−1` when the two agree, `+1` when they do not, so the lattice's total energy falls as more spins line up. Now ask what one spin flipping would do. Its four bonds all reverse, so the change is ```js ΔE = 2 · s · (up + down + left + right) ``` and because every spin and every neighbour is `±1`, that has exactly five possible values: `−8` when all four neighbours disagree with you (flipping is a bargain), through `0`, up to `+8` when all four agree (flipping is the most expensive move on the board). This lattice is a **torus** — the right edge is glued to the left, the top to the bottom — so there are no boundary cells to special-case, and every one of the 16,384 threads does the identical four reads. Pure gather, the shape Thinking in Parallel calls the one that always parallelises. ## Figures - **five neighbourhoods, five prices — and only two of them are ever a gamble** — Five spin neighbourhoods side by side, with four, three, two, one and zero neighbours agreeing with the centre spin, and the resulting flip costs +8, +4, 0, -4 and -8. ## Goal **Goal:** finish the kernel so cell `[y][x]` holds `2 · s[y][x] · (sum of its four neighbours)`, with the neighbour indices wrapping round the lattice. ## Requirements - Sum the four axis neighbours with wrap-around — no diagonals, no boundary special case - Wrap by adding the size first: `(x + n - 1) % n`, never `(x - 1) % n` - Return `2 * s[y][x] * sum` ## Hint 1 — where the 2 comes from This spin's share of the energy is `−s · (neighbour sum)`. Flip it and the share becomes `+s · (neighbour sum)`. The difference between those two is `2 · s · (neighbour sum)` — the bonds do not just vanish, they reverse, so the change is twice the share, not once. ## Hint 2 — the wrap that bites `(x - 1) % n` is `-1` at `x = 0`, in JavaScript and in gpu.js's GLSL alike — both keep the sign of the left operand. Reading `s[y][-1]` gives you `undefined` on the CPU backend and a zero out of nowhere on WebGL. Add the size before you subtract: ```js const left = s[y][(x + n - 1) % n]; const right = s[y][(x + 1) % n]; const up = s[(y + n - 1) % n][x]; const down = s[(y + 1) % n][x]; ``` ## Hint 3 — the whole body ```js const sum = s[y][(x + 1) % n] + s[y][(x + n - 1) % n] + s[(y + 1) % n][x] + s[(y + n - 1) % n][x]; return 2 * s[y][x] * sum; ``` ## Same idea elsewhere A four-neighbour gather with periodic wrap is one of the most-written kernels there is — CUDA and WGSL spell it the same way, and a graphics API gives you the wrap for free by setting a texture's address mode to `repeat` instead of doing modular arithmetic at all. The five-value structure matters too: a stencil whose result comes from a tiny discrete set is exactly the case where production Ising codes drop the exponential and look the answer up in a five-entry table. ## Starter code ```js // One thread per spin. Each one works out what flipping ITSELF would cost. const gpu = new GPU({ mode }); const flipCost = gpu.createKernel(function (s) { const x = this.thread.x; const y = this.thread.y; const n = this.constants.size; // TODO 1: sum the four neighbours, wrapping round the torus: // s[y][(x + 1) % n], s[y][(x + n - 1) % n], // s[(y + 1) % n][x], s[(y + n - 1) % n][x] // TODO 2: return 2 * (this spin) * (that sum). return 0; }, { output: [128, 128], constants: { size: 128 } }); const cost = await flipCost(lattice); console.log('the spin at [0][0] is', lattice[0][0], '— flipping it would cost', cost[0][0]); console.log('row 0, first ten costs:', Array.from(cost[0]).slice(0, 10)); ``` --- Interactive version: https://gpu.rocks/learn/ising-model-1f12d841/1 [Next task](https://gpu.rocks/learn/ising-model-1f12d841/2.md) --- # Randomness Without a Random Number Generator *Task 2 of 6 · [The Ising Model: Colour to Break the Race](https://gpu.rocks/learn/ising-model-1f12d841.md) · GPU.js Learn* The rule that makes this a *model of temperature* rather than a downhill slide needs a coin toss per spin per sweep. On a CPU you reach for `Math.random()` without thinking. Think about it here: an ordinary generator is a **stream** — one hidden state, advanced once per call, so call number 900 depends on call number 899. That is a sequential dependency, and it is the same one Thinking in Parallel rules out. There is no ordering between 16,384 threads and nothing they can share. gpu.js is blunt about it too: the WebGPU backend *refuses* `Math.random` at compile time, and on WebGL a kernel that calls it is no longer reproducible. So stop asking for a stream and ask for a **function**: `u = hash(x, y, seed)`. Same thread, same sweep, same number — every time, on every backend. Different thread, unrelated number. No state, no ordering, and a run you can replay exactly, which is what makes a stochastic simulation debuggable at all. This is not a compromise for gpu.js's benefit; it is what production GPU Monte Carlo does. A hash has one job: make the output look nothing like the input. Three moves do it. The *fold* multiplies `x`, `y` and `seed` by unrelated odd constants and adds them, which spreads the three inputs across sixteen bits. The *squaring* step is the only nonlinear one, and without it a hash is just an affine map: neighbouring cells would come out a fixed distance apart, which is not randomness, it is a ramp. The *byte swap* moves the high eight bits down where the next multiply can reach them. Every intermediate stays under `2²⁴` — the largest integer a 32-bit float holds exactly — so WebGPU, WebGL and the CPU compute the identical value and nothing about your run depends on which one you got. ## Goal **Goal:** fill a 128×128 grid with `hash(x, y, seed)` in `[0, 1)`, using no `Math.random` anywhere. ## Requirements - Fold `x`, `y` and `seed` into one value below `65536` - Mix it twice: square a low slice, swap the two bytes, one multiply-and-add round - Return the mixed value divided by `65536` so it lands in `[0, 1)` - No `Math.random` — the same seed must give the same field every run ## Hint 1 — the fold, and why the seed belongs in it Three odd constants, one modulo: ```js let h = (x * 1103 + y * 2749 + seed * 3571) % 65536; ``` Leave `seed` out and the field is the same 16,384 numbers on every sweep — the simulation would take one step and then repeat it forever. The seed is what makes this a *sequence* of fields rather than one field. ## Hint 2 — the two mixing moves Squaring is the nonlinear part. `h % 2048` keeps the value small enough that `q * q` stays under `2²⁴` and the arithmetic is still exact: ```js let q = h % 2048; h = (h + q * q) % 65536; ``` The swap exchanges the high and low bytes. Note which side gets the multiply — `h % 256` is the LOW byte, so it is the one that has to move UP: ```js h = (h % 256) * 256 + Math.floor(h / 256); ``` ## Hint 3 — the whole body ```js let h = (x * 1103 + y * 2749 + seed * 3571) % 65536; let q = h % 2048; h = (h + q * q) % 65536; h = (h % 256) * 256 + Math.floor(h / 256); h = (h * 253 + 30011) % 65536; q = h % 2048; h = (h + q * q) % 65536; return h / 65536; ``` Two rounds, not one — and the reason is not where you would look for it. Inside a single field one round is already fine: neighbouring cells measure `0.021` against the two-round hash's `0.032`, which is the same noise. The damage is *between consecutive seeds*. One squaring plus one multiply is still so nearly affine that `hash(x, y, k)` and `hash(x+1, y+1, k+1)` correlate at `0.30`, against `0.007`–`0.021` with two rounds — and that is precisely the pair the next task puts side by side, a red cell drawing at seed `2k` and the black neighbour that reads it drawing at `2k + 1`. ## Same idea elsewhere Counter-based randomness — hash a coordinate instead of advancing a stream — is the standard on every parallel platform: Random123 / Philox in CUDA, `curand`'s counter-based generators, and essentially every shader that needs noise. The reason is the same everywhere: a stream forces an order on things that have none, while a hash gives every thread an independent draw for free and makes the whole run reproducible. Keeping the arithmetic inside the exactly-representable integer range is the other half of the trick, and it is why real GPU hashes are written in integer types rather than floats. ## Starter code ```js // 16,384 threads, 16,384 random numbers, and no random number generator. const gpu = new GPU({ mode }); const noise = gpu.createKernel(function (seed) { const x = this.thread.x; const y = this.thread.y; // TODO 1: fold x, y and seed into one value below 65536. // TODO 2: mix it twice — square a low slice, swap the two bytes, // one multiply-and-add round, then square again. // TODO 3: return the mixed value / 65536. return 0; }, { output: [128, 128] }); const field = await noise(1); // A histogram of all 16,384 values, in plain JavaScript. const bins = new Array(32).fill(0); let total = 0; for (let y = 0; y < 128; y++) { for (let x = 0; x < 128; x++) { bins[Math.min(31, Math.floor(field[y][x] * 32))]++; total += field[y][x]; } } console.log('mean of the field:', total / (128 * 128)); console.log('counts per bin:', bins); plot({ 'cells per bin': bins }, { title: 'hash output, 32 equal bins of [0, 1)' }); ``` --- Interactive version: https://gpu.rocks/learn/ising-model-1f12d841/2 [Previous task](https://gpu.rocks/learn/ising-model-1f12d841/1.md) · [Next task](https://gpu.rocks/learn/ising-model-1f12d841/3.md) --- # Everyone at Once Is Wrong *Task 3 of 6 · [The Ising Model: Colour to Break the Race](https://gpu.rocks/learn/ising-model-1f12d841.md) · GPU.js Learn* Now the dynamics. The **Metropolis** rule is two lines: work out what flipping this spin would cost; if the cost is zero or negative, flip it; if it is positive, flip it anyway with probability `exp(−ΔE / T)`. That exponential is the whole of temperature — near `T = 0` an expensive flip essentially never happens and the lattice freezes into agreement; at large `T` almost everything is accepted and the lattice is noise. You have both halves already: task 1's cost and task 2's `u`. The obvious GPU move is to do all 16,384 at once, and it looks watertight. Every thread reads the old lattice and writes only its own cell, so there is no memory race — nothing is overwritten while something else is reading it. The race is in the *physics*. Each spin computed its cost on the assumption that its neighbours would hold still, and they did not. Two neighbours that agree share one bond; each one separately prices the cost of breaking it; both flip; the bond is not broken at all, and both of them paid for a change that never happened. Don't take it on trust — measure it. The energy per spin is `−½ · mean(s · neighbour sum)`, the same five reads as task 1 assembled differently, with the `½` because each bond is counted once from each end. It runs from `−2` (perfectly aligned) through `0` (random) to `+2` (a perfect checkerboard, every neighbour disagreeing). Metropolis at `T = 1.5` should walk downhill from a random start. Run it all-at-once and watch which way it actually goes. ## Figures - **nobody overwrote anybody. the arithmetic was still describing a lattice that had already moved** — Two neighbouring up spins sharing a satisfied bond. Each thread prices flipping itself assuming the other holds still, both accept, and after both flip the bond is satisfied again — so the cost they each paid was never incurred. ## Goal **Goal:** write the `bondEnergy` kernel and the `meanOf` helper, then read the energy the prewired all-at-once loop prints. ## Requirements - The kernel returns `-0.5 * s[y][x] * (neighbour sum)`, wrapping as in task 1 - `meanOf(grid)` averages all 128 × 128 cells - Leave the prewired `naiveSweep` kernel and its 30-sweep loop as they are ## Hint 1 — why the ½ The bond between two neighbours belongs to both of them. If every cell claimed the full `−s · (neighbour sum)`, summing over the lattice would count each bond twice, and an aligned lattice would report `−4` per spin instead of `−2`. Halving each cell's claim fixes it exactly. ## Hint 2 — the same reads as task 1 Identical neighbour sum, different assembly: the flip cost multiplies it by `2 · s`, the energy by `−½ · s`. ```js return -0.5 * s[y][x] * sum; ``` ## Hint 3 — the mean `bondEnergy(s)` hands back an ordinary 2D grid of numbers, so this is plain JavaScript: ```js let total = 0; for (let y = 0; y < 128; y++) { for (let x = 0; x < 128; x++) total += grid[y][x]; } return total / (128 * 128); ``` Totalling a grid *on* the GPU is the halving ladder Reductions builds; at 16,384 cells the read-back is cheaper than the ladder, so this one stays in JavaScript. ## Same idea elsewhere The failure you are about to watch is not a gpu.js quirk, it is what "synchronous Metropolis" does on any platform: the update rule was derived for one spin moving against a fixed background, and running it in parallel silently changes the algorithm into a different, wrong one. The general lesson is worth more than the physics — a parallel version of a sequential algorithm is a *different algorithm* until you have proved otherwise, and "no thread writes another thread's memory" proves nothing about whether the maths still holds. ## Starter code ```js // Metropolis, applied to every spin at once. Watch the energy. const gpu = new GPU({ mode }); const naiveSweep = gpu.createKernel(function (s, temperature, seed) { const x = this.thread.x; const y = this.thread.y; const n = this.constants.size; const spin = s[y][x]; const sum = s[y][(x + 1) % n] + s[y][(x + n - 1) % n] + s[(y + 1) % n][x] + s[(y + n - 1) % n][x]; const dE = 2 * spin * sum; // a random number for THIS thread, this sweep — no shared state, no ordering let h = (x * 1103 + y * 2749 + seed * 3571) % 65536; let q = h % 2048; h = (h + q * q) % 65536; h = (h % 256) * 256 + Math.floor(h / 256); h = (h * 253 + 30011) % 65536; q = h % 2048; h = (h + q * q) % 65536; const u = h / 65536; if (dE <= 0) return -spin; if (u < Math.exp(-dE / temperature)) return -spin; return spin; }, { output: [128, 128], constants: { size: 128 } }); const bondEnergy = gpu.createKernel(function (s) { const x = this.thread.x; const y = this.thread.y; const n = this.constants.size; // TODO 1: same four wrapped neighbours as task 1, summed. // TODO 2: return this cell's share of the energy: -0.5 * s[y][x] * sum. return 0; }, { output: [128, 128], constants: { size: 128 } }); function meanOf(grid) { // TODO 3: average all 128 x 128 cells of grid. return 0; } let s = lattice; console.log('energy per spin at the start:', meanOf(await bondEnergy(s))); const trace = []; for (let k = 0; k < 30; k++) { s = await naiveSweep(s, 1.5, k); trace.push(meanOf(await bondEnergy(s))); } console.log('energy per spin, sweep by sweep:', trace); console.log('after 30 all-at-once sweeps: E =', trace[29]); plot({ 'energy per spin': trace }, { title: 'all-at-once Metropolis at T = 1.5' }); ``` --- Interactive version: https://gpu.rocks/learn/ising-model-1f12d841/3 [Previous task](https://gpu.rocks/learn/ising-model-1f12d841/2.md) · [Next task](https://gpu.rocks/learn/ising-model-1f12d841/4.md) --- # Colour the Lattice *Task 4 of 6 · [The Ising Model: Colour to Break the Race](https://gpu.rocks/learn/ising-model-1f12d841.md) · GPU.js Learn* The cure is a chessboard, and if you have been through **Iterative Linear Solvers** you have already met it: red-black Gauss-Seidel colours a grid in exactly this way, for exactly this reason. One idea in two costumes. There it rescues a solver that is sequential by construction; here it repairs a Monte Carlo update that is silently wrong when it is parallel. Both times the argument is one sentence — *the stencil reads only the four direct neighbours, and on a chessboard every direct neighbour of a red square is black*. So call a cell **red** when `(x + y)` is even and **black** when it is odd, and update all 8,192 reds at once. No red cell reads another red cell, so nothing a red thread looked at can move while it is deciding: the `ΔE` it computed is exact, not an estimate, and the flip it accepts is the flip Metropolis meant. Then do the blacks, reading the reds that were just written. Two data-parallel half-sweeps, and the race is gone — not mitigated, gone. Black cells are not "skipped" in the red half. Every thread still writes its own cell; a black thread writes back the value it already had, ready for the half-sweep that is about to need it exactly as it is. ## Goal **Goal:** write the red half-sweep — cells with `(x + y) % 2 === 0` take the Metropolis decision, every other cell comes through untouched. ## Requirements - Keep the parity in a *number*: `const parity = (x + y) % 2;` — a boolean in a kernel variable does not compile on WebGL - Cells with parity `1` (black) return their spin unchanged - Red cells with `dE ≤ 0` flip unconditionally - Red cells with `dE > 0` flip when `u < Math.exp(-dE / temperature)` ## Hint 1 — the trap this task is built around The natural spelling is a boolean, and it is the one thing gpu.js cannot do: ```js const isRed = (x + y) % 2 === 0; // throws on WebGL ``` The GL backend has no way to store a `bool` in a kernel variable, so it fails at shader-compile time with *cannot convert from 'bool' to 'lowp float'* — while the CPU backend runs it happily, which is how this reaches production. Keep the number: ```js const parity = (x + y) % 2; // 0 or 1 if (parity !== 0) return spin; ``` ## Hint 2 — the Metropolis decision Two exits, in this order: ```js if (dE <= 0) return -spin; if (u < Math.exp(-dE / temperature)) return -spin; return spin; ``` The first line is technically implied by the second — `u` is always below 1 and `exp(−dE/T)` is at least 1 whenever `dE ≤ 0`, so the test would pass anyway. Write it out regardless: it is the rule, and it says that a flip which lowers the energy is never a gamble. ## Hint 3 — the shape of the body The parity guard comes first, so a black thread never computes a neighbour sum it is not going to use: ```js const spin = s[y][x]; const parity = (x + y) % 2; if (parity !== 0) return spin; const sum = s[y][(x + 1) % n] + s[y][(x + n - 1) % n] + s[(y + 1) % n][x] + s[(y + n - 1) % n][x]; const dE = 2 * spin * sum; ``` Then the hash (already written for you) and the two exits above. ## Same idea elsewhere Red-black ordering, and its multi-colour generalisation, is the standard way to put a Gauss-Seidel smoother, a lattice Monte Carlo or a physics solver's constraint pass on a GPU: one dispatch per colour, and an unstructured mesh gets its colours from a graph-colouring pass first. The idea is bigger than any of them — a colour is simply a set of updates guaranteed not to depend on each other, which is the same permission slip a wavefront's anti-diagonal or a task graph's level hands out. ## Starter code ```js // Half the lattice at a time. Reds first — and no red reads a red. const gpu = new GPU({ mode }); const red = gpu.createKernel(function (s, temperature, seed) { const x = this.thread.x; const y = this.thread.y; const n = this.constants.size; const spin = s[y][x]; // TODO 1: the parity, as a NUMBER — const parity = (x + y) % 2; // (a boolean in a kernel variable will not compile on WebGL) // TODO 2: parity 1 is black — return spin unchanged. // TODO 3: sum the four wrapped neighbours and form dE = 2 * spin * sum. // a random number for THIS thread, this sweep — no shared state, no ordering let h = (x * 1103 + y * 2749 + seed * 3571) % 65536; let q = h % 2048; h = (h + q * q) % 65536; h = (h % 256) * 256 + Math.floor(h / 256); h = (h * 253 + 30011) % 65536; q = h % 2048; h = (h + q * q) % 65536; const u = h / 65536; // TODO 4: dE <= 0 flips unconditionally; otherwise flip when u < exp(-dE / temperature). return spin; }, { output: [128, 128], constants: { size: 128 } }); const after = await red(lattice, 1.5, 0); let movedRed = 0; let movedBlack = 0; for (let y = 0; y < 128; y++) { for (let x = 0; x < 128; x++) { if (after[y][x] === lattice[y][x]) continue; if ((x + y) % 2 === 0) movedRed++; else movedBlack++; } } console.log('red cells that flipped:', movedRed, 'of 8192'); console.log('black cells that flipped:', movedBlack, '(must be 0)'); ``` --- Interactive version: https://gpu.rocks/learn/ising-model-1f12d841/4 [Previous task](https://gpu.rocks/learn/ising-model-1f12d841/3.md) · [Next task](https://gpu.rocks/learn/ising-model-1f12d841/5.md) --- # Two Halves Make a Sweep *Task 5 of 6 · [The Ising Model: Colour to Break the Race](https://gpu.rocks/learn/ising-model-1f12d841.md) · GPU.js Learn* The red half is not a sweep — half the lattice has not been offered a move. The black half is the same kernel with its parity test flipped, and the ordering that matters is in the **chaining**: `black(red(s))`. The black cells read the lattice the red half produced, so their `ΔE` accounts for the reds that just moved. That is not an optimisation, it is the correctness argument: a black cell's four neighbours are all red, and the reds have finished. Give the two halves different seeds too — `2k` and `2k + 1` — or every black cell would draw the same number its red neighbours just used. Then run it. Same starting lattice as task 3, same temperature, same thirty sweeps, and the energy that climbed from `−0.01` to `+1.30` now falls to about `−1.84`, sweep after sweep — 26 of the 29 steps go down, and the three that do not tick back up by less than `0.004`. That wobble is the temperature doing its job: `T = 1.5` is cold, not zero, so a handful of uphill moves are accepted every sweep and the energy is allowed to breathe. Nothing about the physics changed and nothing got slower: the same 16,384 spins are offered the same moves. All that changed is *which of them are allowed to move at the same time*. ## Goal **Goal:** write the black half-sweep and chain the two halves into one full sweep, `black(red(s))`. ## Requirements - The black kernel is the red kernel with its parity test flipped — parity `1` moves, everything else passes through - Create the red kernel first and the black kernel second - One full sweep is `await black(await red(s, T, 2k), T, 2k + 1)` — the black half reads what the red half wrote ## Hint 1 — the black kernel Copy the red kernel and change one digit: ```js const parity = (x + y) % 2; if (parity !== 1) return spin; ``` Still a number, never a boolean — the WebGL backend rejects `const isBlack = …` exactly as it rejects `isRed`. ## Hint 2 — the chain is the lesson ```js const afterRed = await red(s, temperature, k * 2); s = await black(afterRed, temperature, k * 2 + 1); // NOT black(s, …) ``` Or in one line, `await black(await red(s, T, 2 * k), T, 2 * k + 1)`. The inner `await` is not decoration: an un-awaited kernel call hands the black half a Promise instead of a lattice. ## Same idea elsewhere Two dispatches with a dependency between them is the ordinary shape of GPU work — WebGPU puts a barrier between compute passes, CUDA orders them on a stream, Vulkan wants an explicit pipeline barrier. What no platform will do is order threads *inside* one dispatch, which is exactly why the update had to be split in two. Production lattice Monte Carlo goes one step further and launches only the cells of one colour, so the half that would copy itself is never scheduled at all; the trade is a strided memory access pattern against half the threads, and which wins is a benchmark, not an argument. ## Starter code ```js // One full sweep = the reds, then the blacks reading what the reds just wrote. const gpu = new GPU({ mode }); const red = gpu.createKernel(function (s, temperature, seed) { const x = this.thread.x; const y = this.thread.y; const n = this.constants.size; const spin = s[y][x]; // parity as a NUMBER — gpu.js cannot keep a boolean in a kernel variable const parity = (x + y) % 2; if (parity !== 0) return spin; const sum = s[y][(x + 1) % n] + s[y][(x + n - 1) % n] + s[(y + 1) % n][x] + s[(y + n - 1) % n][x]; const dE = 2 * spin * sum; // a random number for THIS thread, this sweep — no shared state, no ordering let h = (x * 1103 + y * 2749 + seed * 3571) % 65536; let q = h % 2048; h = (h + q * q) % 65536; h = (h % 256) * 256 + Math.floor(h / 256); h = (h * 253 + 30011) % 65536; q = h % 2048; h = (h + q * q) % 65536; const u = h / 65536; if (dE <= 0) return -spin; if (u < Math.exp(-dE / temperature)) return -spin; return spin; }, { output: [128, 128], constants: { size: 128 } }); const black = gpu.createKernel(function (s, temperature, seed) { const x = this.thread.x; const y = this.thread.y; const n = this.constants.size; const spin = s[y][x]; // TODO 1: same body as the red kernel, with the parity test flipped — // the cells where (x + y) % 2 is 1 take the Metropolis decision. return spin; }, { output: [128, 128], constants: { size: 128 } }); const bondEnergy = gpu.createKernel(function (s) { const x = this.thread.x; const y = this.thread.y; const n = this.constants.size; const sum = s[y][(x + 1) % n] + s[y][(x + n - 1) % n] + s[(y + 1) % n][x] + s[(y + n - 1) % n][x]; return -0.5 * s[y][x] * sum; }, { output: [128, 128], constants: { size: 128 } }); function meanOf(grid) { let total = 0; for (let y = 0; y < 128; y++) { for (let x = 0; x < 128; x++) total += grid[y][x]; } return total / (128 * 128); } let s = lattice; const trace = []; for (let k = 0; k < 30; k++) { const afterRed = await red(s, 1.5, k * 2); // TODO 2: finish the sweep. The black half must read afterRed, not s. s = afterRed; trace.push(meanOf(await bondEnergy(s))); } console.log('energy per spin, sweep by sweep:', trace); console.log('after 30 red-black sweeps: E =', trace[29]); plot({ 'energy per spin': trace }, { title: 'checkerboard Metropolis at T = 1.5' }); ``` --- Interactive version: https://gpu.rocks/learn/ising-model-1f12d841/5 [Previous task](https://gpu.rocks/learn/ising-model-1f12d841/4.md) · [Next task](https://gpu.rocks/learn/ising-model-1f12d841/6.md) --- # Drag the Temperature Across Tc *Task 6 of 6 · [The Ising Model: Colour to Break the Race](https://gpu.rocks/learn/ising-model-1f12d841.md) · GPU.js Learn* Everything is wired. The payoff is one number you can move with your finger. Start from the coldest configuration there is — every spin up — and run 150 coloured sweeps at a temperature you choose. Below the **critical temperature** the lattice keeps its order: thermal noise chews holes in it, but the holes heal and the magnetisation `m`, the average spin, sits stubbornly near `±1`. Above it the order does not survive at all — the lattice dissolves into salt and pepper and `m` falls to zero. In between there is no gentle slope; the whole thing turns over inside a few tenths of a degree. Onsager solved this exactly in 1944 and the answer is `Tc = 2 / ln(1 + √2) ≈ 2.269`, in units of `J / k_B`. Two honest caveats, because a lattice of 16,384 spins is not infinite and 150 sweeps is not forever. The crossover you can see sits a little above `2.269` — finite lattices round a transition off, and a cold start clings to its order — and right at `Tc` the model slows to a crawl, which is not a bug in the simulation but the defining symptom of a critical point. Drag slowly through `2.3`–`2.5` and watch the domains grow to the size of the whole box just before they let go. ## Figures - **the exact answer drops off a cliff at 2.269; 16,384 spins in 150 sweeps take the corner wide** — Magnetisation against temperature, with the exact Onsager curve dropping vertically to zero at 2.269 and the measured 128 by 128 curve hanging on until about 2.4 before collapsing. ## Goal **Goal:** declare the temperature slider, paint the lattice, and render a frame every ten sweeps so the run becomes something you can scrub through. ## Requirements - Declare the control: `slider('temperature', { min: 1.5, max: 3.5, value: 2.25, step: 0.05 })` - Paint up spins `this.color(0.97, 0.58, 0.26, 1)` and down spins `this.color(0.09, 0.14, 0.28, 1)` - Every tenth sweep, `await paint(s)` and then `render(paint.canvas)` - Leave the prewired sweep loop and `plot()` call alone ## Hint 1 — the slider is the program `slider()` returns the value this run is using and puts the control under the console; moving it re-runs the whole program from the top. That is the entire model — your code is a pure function of its controls, so there is no event loop to write. ```js const temperature = slider('temperature', { min: 1.5, max: 3.5, value: 2.25, step: 0.05 }); ``` ## Hint 2 — three renders make a scrubber Consecutive `render()` calls collapse into a frame strip with a slider under it, so rendering inside the loop costs you nothing and gives you the whole history: ```js if (k % 10 === 0) { await paint(s); render(paint.canvas); } ``` Keep the two lines adjacent and do not `console.log` between them — a log line in the middle breaks the run of frames into separate images. ## Same idea elsewhere A control that re-runs the whole computation is the interaction model of every GPU toy worth playing with, and it works for the same reason here as in a shader: the frame is cheap enough that recomputing it beats maintaining incremental state. The physics travels further than the code. Order parameters, critical exponents and finite-size scaling are the vocabulary of everything from magnets to percolation thresholds to the training dynamics of large models, and the Ising lattice is where all of it was worked out first. ## Starter code ```js // The whole model, with a dial on it. Drag the temperature past 2.269. const gpu = new GPU({ mode }); // TODO 1: declare the control. slider() returns the value THIS run is using. const temperature = 2.25; const red = gpu.createKernel(function (s, temperature, seed) { const x = this.thread.x; const y = this.thread.y; const n = this.constants.size; const spin = s[y][x]; // parity as a NUMBER — gpu.js cannot keep a boolean in a kernel variable const parity = (x + y) % 2; if (parity !== 0) return spin; const sum = s[y][(x + 1) % n] + s[y][(x + n - 1) % n] + s[(y + 1) % n][x] + s[(y + n - 1) % n][x]; const dE = 2 * spin * sum; // a random number for THIS thread, this sweep — no shared state, no ordering let h = (x * 1103 + y * 2749 + seed * 3571) % 65536; let q = h % 2048; h = (h + q * q) % 65536; h = (h % 256) * 256 + Math.floor(h / 256); h = (h * 253 + 30011) % 65536; q = h % 2048; h = (h + q * q) % 65536; const u = h / 65536; if (dE <= 0) return -spin; if (u < Math.exp(-dE / temperature)) return -spin; return spin; }, { output: [128, 128], constants: { size: 128 } }); const black = gpu.createKernel(function (s, temperature, seed) { const x = this.thread.x; const y = this.thread.y; const n = this.constants.size; const spin = s[y][x]; // parity as a NUMBER — gpu.js cannot keep a boolean in a kernel variable const parity = (x + y) % 2; if (parity !== 1) return spin; const sum = s[y][(x + 1) % n] + s[y][(x + n - 1) % n] + s[(y + 1) % n][x] + s[(y + n - 1) % n][x]; const dE = 2 * spin * sum; // a random number for THIS thread, this sweep — no shared state, no ordering let h = (x * 1103 + y * 2749 + seed * 3571) % 65536; let q = h % 2048; h = (h + q * q) % 65536; h = (h % 256) * 256 + Math.floor(h / 256); h = (h * 253 + 30011) % 65536; q = h % 2048; h = (h + q * q) % 65536; const u = h / 65536; if (dE <= 0) return -spin; if (u < Math.exp(-dE / temperature)) return -spin; return spin; }, { output: [128, 128], constants: { size: 128 } }); const paint = gpu.createKernel(function (s) { const spin = s[this.thread.y][this.thread.x]; // TODO 2: up spins this.color(0.97, 0.58, 0.26, 1), // down spins this.color(0.09, 0.14, 0.28, 1). this.color(1, 0, 1, 1); }, { output: [128, 128], graphical: true }); function meanOf(grid) { let total = 0; for (let y = 0; y < 128; y++) { for (let x = 0; x < 128; x++) total += grid[y][x]; } return total / (128 * 128); } // The coldest possible start: every spin up. 150 sweeps at your temperature. let s = alignedLattice; const trace = []; for (let k = 0; k < 150; k++) { s = await black(await red(s, temperature, k * 2), temperature, k * 2 + 1); trace.push(meanOf(s)); // TODO 3: every tenth sweep, await paint(s) and then render(paint.canvas). } console.log('temperature', temperature, '— Tc is 2.269'); console.log('magnetisation after 150 sweeps:', trace[149]); plot({ magnetisation: trace }, { title: 'magnetisation per sweep' }); ``` --- Interactive version: https://gpu.rocks/learn/ising-model-1f12d841/6 [Previous task](https://gpu.rocks/learn/ising-model-1f12d841/5.md) --- # Colour Spaces *Module of the free GPU.js GPGPU course · 5 tasks* Leaving RGB: perceptual luminance, the hue wheel, and why a channel that wraps breaks ordinary arithmetic. ## Tasks 1. [Three Greys, One Pixel](https://gpu.rocks/learn/colour-spaces-8d79c6af/1.md) 2. [Hue, Saturation, Value](https://gpu.rocks/learn/colour-spaces-8d79c6af/2.md) 3. [The Midpoint of 350° and 10°](https://gpu.rocks/learn/colour-spaces-8d79c6af/3.md) 4. [Select by Colour](https://gpu.rocks/learn/colour-spaces-8d79c6af/4.md) 5. [Payoff: What Colour Is This Picture?](https://gpu.rocks/learn/colour-spaces-8d79c6af/5.md) --- Interactive version: https://gpu.rocks/learn/colour-spaces-8d79c6af --- # Three Greys, One Pixel *Task 1 of 5 · [Colour Spaces](https://gpu.rocks/learn/colour-spaces-8d79c6af.md) · GPU.js Learn* Nearly every vision algorithm's first move is to leave RGB, because RGB tangles together the two things you usually want to reason about separately: what colour something is, and how bright it is. Brightness is the easier half, and the one most often got wrong. There are three answers to "how bright is this pixel", and they do not agree. The channel average `(r + g + b) / 3` is what everyone writes first, and it is simply false — the eye is roughly five times more sensitive to green than to blue, so a pure green and a pure blue that "average" the same are nowhere near equally bright. Weighting the channels fixes that: `0.299r + 0.587g + 0.114b` is Rec. 601 **luma**, the recipe Data In, Data Out had you write. But luma weights the numbers *as stored*, and sRGB channels are **gamma-encoded**: 0.5 in a PNG is not half the light of 1.0, it is about 21% of it. Relative luminance undoes that encoding first and then weights the actual light. It is the number a photometer would agree with, and the one every contrast-ratio rule is built on. ```js t = (c + 0.055) / 1.055 c <= 0.04045 linear = c / 12.92 otherwise linear = Math.pow(t, 2.4) Y = 0.2126*R + 0.7152*G + 0.0722*B ``` **Array layout in gpu.js** Image data comes in row-major: `image[y][x]` is the pixel in row *y*, column *x*, and each pixel is an `[r, g, b, a]` array with channels from 0 to 1. Mind the inversion that catches everyone — sizes are given width-first (`output: [width, height]`), but indexing runs row-first, so this thread's own pixel is `image[this.thread.y][this.thread.x]`. Swap those two and you read the transpose of your image. Three-dimensional data follows the same rule: `output: [w, h, d]` is indexed `[z][y][x]`. ## Goal **Goal:** compute a 64 × 64 relative-luminance map of `photo`, and log that map's value for the one pixel the starter already prints two other ways. ## Requirements - Keep the kernel numeric — `output: [64, 64]`, one thread per pixel - Linearise *each* of r, g and b with the sRGB transfer function above, before any weighting - Weight the linear channels `0.2126 R + 0.7152 G + 0.0722 B` - `console.log` the relative luminance of `photo[1][35]` beside the two greys already printed ## Hint 1 — one channel at a time The transfer function is a two-case branch, and it is the same branch three times over. Pull the channels into `let` variables so you can rewrite them in place: ```js let r = pixel[0]; if (r <= 0.04045) { r = r / 12.92; } else { r = Math.pow((r + 0.055) / 1.055, 2.4); } ``` ## Hint 2 — then the weights Once `r`, `g` and `b` hold linear light, the last line is just the weighted sum: ```js return 0.2126 * r + 0.7152 * g + 0.0722 * b; ``` Note that these are *not* the 0.299 / 0.587 / 0.114 of the earlier module. Those weights belong to gamma-encoded channels; these belong to linear ones. Mixing the two pairs up is the classic version of this bug. ## Hint 3 — reading the answer `map[1][35]` is the pixel the starter prints. Expect all three numbers to differ, and the linearised one to be much the smallest: most of what looks like brightness in an sRGB file is the encoding, not the light. ## Same idea elsewhere Every graphics API knows about this and will do it for you if you ask: an `rgba8unorm-srgb` texture in WebGPU, `GL_SRGB8_ALPHA8` in OpenGL and `MTLPixelFormatRGBA8Unorm_sRGB` in Metal all linearise on read and re-encode on write, in fixed-function hardware, for free. Blending or filtering in gamma space because you forgot to ask is one of the oldest bugs in rendering — it is why badly-resized images get darker, and why naive alpha compositing leaves dark fringes. ## Starter code ```js // One thread per pixel: a pure map, no neighbours involved. const gpu = new GPU({ mode }); const relativeLuminance = gpu.createKernel(function (photo) { const pixel = photo[this.thread.y][this.thread.x]; // TODO: undo the sRGB gamma encoding on each channel FIRST, // then weight the linear channels 0.2126 / 0.7152 / 0.0722. return 0.2126 * pixel[0] + 0.7152 * pixel[1] + 0.0722 * pixel[2]; }, { output: [64, 64] }); const map = await relativeLuminance(photo); // The same pixel, three ways. Two of them are done for you, on the host — // photo.at(x, y) is the host-side view of photo[y][x]. const p = photo.at(35, 1); console.log('channel average: ', (p[0] + p[1] + p[2]) / 3); console.log('Rec. 601 luma: ', 0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]); // TODO: log the relative luminance of that same pixel, out of your map. ``` --- Interactive version: https://gpu.rocks/learn/colour-spaces-8d79c6af/1 [Next task](https://gpu.rocks/learn/colour-spaces-8d79c6af/2.md) --- # Hue, Saturation, Value *Task 2 of 5 · [Colour Spaces](https://gpu.rocks/learn/colour-spaces-8d79c6af.md) · GPU.js Learn* RGB says how much of each light to mix. It does not say what colour something *is*. **HSV** does, by splitting the question three ways: hue (which colour), saturation (how far from grey), value (how bright). Two of the three take one line each. The third is where the interesting code lives. Start from the largest and smallest channel. `V = max`. The gap between them is the **chroma**, `C = max − min` — how far this pixel is from grey — and saturation is that gap as a fraction of the value, `C / V`. Hue is an *angle*: red at 0°, green at 120°, blue at 240°, round to red again at 360°. Which channel is the max picks a 60° wedge of that wheel, and the other two channels say where you sit inside it. When the chroma is zero there is no wedge at all: a grey pixel has no hue. Not "hue 0" — 0 is red. No hue, and it needs a value that is not an angle, which here is `-1`. ```js V = max(r, g, b) C = V - min(r, g, b) S = C / V V is r H = 60 * ((g - b) / C) + 360 when that comes out negative V is g H = 60 * ((b - r) / C + 2) V is b H = 60 * ((r - g) / C + 4) ``` **Array layout in gpu.js** Image data comes in row-major: `image[y][x]` is the pixel in row *y*, column *x*, and each pixel is an `[r, g, b, a]` array with channels from 0 to 1. Mind the inversion that catches everyone — sizes are given width-first (`output: [width, height]`), but indexing runs row-first, so this thread's own pixel is `image[this.thread.y][this.thread.x]`. Swap those two and you read the transpose of your image. Three-dimensional data follows the same rule: `output: [w, h, d]` is indexed `[z][y][x]`. ## Figures - **grey lives in the hole in the middle, where none of the three formulas apply** ## Goal **Goal:** finish the two kernels — `hue`, in degrees, with `-1` where there is no hue, and `saturation`. The graphical kernel below is already written and will paint whatever hue channel you produce. ## Requirements - `hue`: return `-1` when the chroma is `0`, and otherwise the angle in degrees - The red wedge is the one that can come out negative — add `360` to bring it back onto the wheel - `saturation`: return `(max − min) / max`, and `0` when `max` is `0` rather than dividing by it - Leave `paintHue` alone — it renders your hue channel at full strength so you can see it ## Hint 1 — the three quantities first Every branch below is written in terms of the same three numbers, so name them once at the top of the kernel: ```js const v = Math.max(pixel[0], Math.max(pixel[1], pixel[2])); const m = Math.min(pixel[0], Math.min(pixel[1], pixel[2])); const c = v - m; ``` ## Hint 2 — which wedge am I in? `v` is one of the three channels exactly — `Math.max` returns one of its arguments, it does not compute a new number — so you can compare against it directly: ```js if (c === 0) { return -1; } if (v === pixel[0]) { const h = 60 * ((pixel[1] - pixel[2]) / c); if (h < 0) { return h + 360; } return h; } if (v === pixel[1]) { return 60 * ((pixel[2] - pixel[0]) / c + 2); } return 60 * ((pixel[0] - pixel[1]) / c + 4); ``` ## Hint 3 — the two zeros Both kernels have a divide-by-nothing case and they are not the same case. Hue divides by the *chroma*, which is zero for any grey. Saturation divides by the *value*, which is zero only for black. Test before you divide in both, or those pixels come back `NaN` and quietly poison everything downstream. ## Same idea elsewhere This is `cvtColor(src, dst, COLOR_BGR2HSV)`, and on a GPU it is exactly what you just wrote: per-pixel, no communication, embarrassingly parallel. Watch the shape of it, though — the wedge is chosen by a branch, and threads in the same warp (CUDA) or subgroup (WebGPU/Metal) execute in lockstep, so a tile containing several wedges pays for every branch it contains rather than just its own. Branch *divergence* is the cost model here, and it is why production colour-conversion shaders are often written branch-free with `step()` and `mix()` instead. ## Starter code ```js // One thread per pixel. Three quantities, two kernels, one branchy angle. const gpu = new GPU({ mode }); const hue = gpu.createKernel(function (photo) { const pixel = photo[this.thread.y][this.thread.x]; const v = Math.max(pixel[0], Math.max(pixel[1], pixel[2])); const m = Math.min(pixel[0], Math.min(pixel[1], pixel[2])); const c = v - m; // TODO: -1 when there is no chroma at all; otherwise the angle in degrees. // Which channel equals v picks the wedge. return 0; }, { output: [64, 64] }); const saturation = gpu.createKernel(function (photo) { const pixel = photo[this.thread.y][this.thread.x]; const v = Math.max(pixel[0], Math.max(pixel[1], pixel[2])); const m = Math.min(pixel[0], Math.min(pixel[1], pixel[2])); // TODO: (v - m) / v, but guard the black pixel where v is 0. return v - m; }, { output: [64, 64] }); // Already written: paints your hue channel at full saturation and value, so // the wheel is all you see. Pixels with no hue come out flat grey. const paintHue = gpu.createKernel(function (h) { const angle = h[this.thread.y][this.thread.x]; let r = 0.24; let g = 0.24; let b = 0.28; if (angle >= 0 && angle < 60) { r = 1; g = angle / 60; b = 0; } else if (angle >= 60 && angle < 120) { r = (120 - angle) / 60; g = 1; b = 0; } else if (angle >= 120 && angle < 180) { r = 0; g = 1; b = (angle - 120) / 60; } else if (angle >= 180 && angle < 240) { r = 0; g = (240 - angle) / 60; b = 1; } else if (angle >= 240 && angle < 300) { r = (angle - 240) / 60; g = 0; b = 1; } else if (angle >= 300) { r = 1; g = 0; b = (360 - angle) / 60; } this.color(r, g, b, 1); }, { output: [64, 64], graphical: true }); const hues = await hue(chart); const sats = await saturation(chart); console.log('top-left swatch is pure red: hue', hues[0][0], ' saturation', sats[0][0]); console.log('the grey row has no hue: hue', hues[28][4], ' saturation', sats[28][4]); await paintHue(hues); render(paintHue.canvas); ``` --- Interactive version: https://gpu.rocks/learn/colour-spaces-8d79c6af/2 [Previous task](https://gpu.rocks/learn/colour-spaces-8d79c6af/1.md) · [Next task](https://gpu.rocks/learn/colour-spaces-8d79c6af/3.md) --- # The Midpoint of 350° and 10° *Task 3 of 5 · [Colour Spaces](https://gpu.rocks/learn/colour-spaces-8d79c6af.md) · GPU.js Learn* Hue is an angle, and angles wrap. That one fact quietly breaks arithmetic you have been doing safely your entire career. Take two readings of the same pixel, from two frames: 350° and 10°. Both are red. They are 20° apart on the wheel. Average them the obvious way and you get 180° — cyan. Not a slightly-off red: the *opposite colour*, out of two inputs that were nearly identical. Every mean, every interpolation, every blur that touches a hue channel has this hole in it. The repair is to stop pretending the number line has no seam. Take `b − a`, fold it onto the short way round by adding or subtracting 360 until it lands in −180 … 180, walk half of it from `a`, and fold the answer back into 0 … 360. Four lines, no cleverness — just refusing to subtract two angles as if they were distances. ## Figures - **far apart on a line, neighbours on a wheel — and only one of those is true** ## Goal **Goal:** return the midpoint of `hueA[i]` and `hueB[i]` the short way round, as an angle in `0 … 360`. ## Requirements - One thread per pair — `output: [64]`, indexed with `this.thread.x` - Fold `b − a` into `−180 … 180` *before* you halve it - Fold the answer back: every output must land in `0 … 360`, none negative and none `360` or more - The first pair, `350` and `10`, must come out at `0` — not `180` ## Hint 1 — the short way round The difference between two angles is only ever at most 180°. If the plain subtraction gives you more than that, you went the long way round: ```js let d = b[this.thread.x] - a[this.thread.x]; if (d > 180) { d = d - 360; } if (d < -180) { d = d + 360; } ``` ## Hint 2 — half a step, then home Walk half of that difference from `a`, then bring the result back onto the wheel. One check each way is enough, because `a` is already in range and you moved it by at most 90°: ```js let m = a[this.thread.x] + d / 2; if (m < 0) { m = m + 360; } if (m >= 360) { m = m - 360; } return m; ``` ## Hint 3 — check the first four The first eight pairs of the input straddle the seam deliberately. `(350, 10)` and `(10, 350)` must both give `0`, `(358.5, 4.5)` gives `1.5`, and `(340, 20)` gives `0` as well. If those four come out near 180, the fold is missing. ## Same idea elsewhere Wrapping quantities are everywhere in vision and nowhere in your standard library: gradient orientation in HOG and SIFT, optical-flow direction, the phase channel of an FFT, compass bearings, the time of day. The standard fix for averaging *many* of them is prettier than this one and just as parallel — turn each angle into a unit vector, sum the vectors (which is a plain reduction), and take `atan2` of the total. That is what circular statistics libraries do, what CUDA and WGSL kernels do, and it removes the branches entirely, which on a GPU is worth having. ## Starter code ```js // 64 pairs of angles, one thread each. Angles are not numbers on a line. const gpu = new GPU({ mode }); const midpoint = gpu.createKernel(function (a, b) { // TODO: the midpoint of two angles, taken the SHORT way round, // folded back into 0 ... 360. return (a[this.thread.x] + b[this.thread.x]) / 2; }, { output: [64] }); const mid = await midpoint(hueA, hueB); for (let i = 0; i < 4; i++) { console.log(hueA[i] + '° and ' + hueB[i] + '° → ' + mid[i] + '°'); } ``` --- Interactive version: https://gpu.rocks/learn/colour-spaces-8d79c6af/3 [Previous task](https://gpu.rocks/learn/colour-spaces-8d79c6af/2.md) · [Next task](https://gpu.rocks/learn/colour-spaces-8d79c6af/4.md) --- # Select by Colour *Task 4 of 5 · [Colour Spaces](https://gpu.rocks/learn/colour-spaces-8d79c6af.md) · GPU.js Learn* Here is the whole module's argument, in one exercise. You want every pixel of the red ball in `frame` — all of it, top to bottom, shadow included. In RGB that is a threshold on numbers that move when the light moves. "Red" comes out as something like `r > 0.5 and r − g > 0.3`, and it works beautifully on the lit top of the ball. Turn the light down and every one of those numbers falls with it, until the test stops being true — for a pixel that is exactly as red as it ever was. That kernel is written for you below, so you can watch it happen. In HSV, brightness lives in V and nowhere else. Multiply a pixel's r, g and b by the same factor and H and S do not move at all. So the test becomes "hue near red, saturated enough", and the shadow costs you nothing. One wrinkle, and it is task 3's wrinkle: red sits at 0°, so a 15° wedge around it runs from 345° up over the seam to 15°. `h > 345 && h < 15` is true for no angle whatsoever. Measure the distance *round the wheel* instead. **Array layout in gpu.js** Image data comes in row-major: `image[y][x]` is the pixel in row *y*, column *x*, and each pixel is an `[r, g, b, a]` array with channels from 0 to 1. Mind the inversion that catches everyone — sizes are given width-first (`output: [width, height]`), but indexing runs row-first, so this thread's own pixel is `image[this.thread.y][this.thread.x]`. Swap those two and you read the transpose of your image. Three-dimensional data follows the same rule: `output: [w, h, d]` is indexed `[z][y][x]`. ## Figures - **turn the light down and RGB loses the colour; HSV only loses the V** ## Goal **Goal:** finish `hsvMask` — `1` for pixels within `this.constants.tol` degrees of `this.constants.target` on the wheel and at least `this.constants.minSat` saturated, `0` for everything else — and log how many pixels each mask found. ## Requirements - Fold `h − target` into `−180 … 180` before comparing — the wedge straddles 0° - Require `s >= this.constants.minSat`: a pixel with no hue reports `-1`, which is one degree from red, and the saturation floor is what keeps it out - Return exactly `1` or `0`, nothing in between - `console.log` both mask counts — the HSV one should find the whole ball, the RGB one only its lit half ## Hint 1 — distance round the wheel Exactly the fold from task 3, then drop the sign: ```js let d = h - this.constants.target; if (d > 180) { d = d - 360; } if (d < -180) { d = d + 360; } if (d < 0) { d = -d; } ``` Now `d` is a distance in degrees, 0 … 180, and it does not care where the seam is. ## Hint 2 — the test itself Two conditions, and both matter: ```js if (d <= this.constants.tol && s >= this.constants.minSat) { return 1; } return 0; ``` ## Hint 3 — reading the counts Total each mask with a plain nested loop in JavaScript after the kernels have run. The HSV count should be comfortably the larger — and the gap between them is the part of the ball that RGB gave up on because a lamp was dimmer there. ## Same idea elsewhere This is chroma keying, and the reason a green screen is *green*: it is the channel a sensor samples most finely, and it is nowhere near skin. Real compositors go further into spaces built for exactly this — YCbCr, or CIE L*a*b* — where the two chromaticity axes are perpendicular to lightness by construction, so a key becomes a distance in a plane rather than a wedge with a seam in it. On the GPU it stays what you just wrote: one thread per pixel, no communication, and the mask is a texture the next pass reads. ## Starter code ```js // The same intent — "that's red" — expressed in two colour spaces. const gpu = new GPU({ mode }); const hue = gpu.createKernel(function (photo) { const pixel = photo[this.thread.y][this.thread.x]; const v = Math.max(pixel[0], Math.max(pixel[1], pixel[2])); const m = Math.min(pixel[0], Math.min(pixel[1], pixel[2])); const c = v - m; if (c === 0) { return -1; } if (v === pixel[0]) { const h = 60 * ((pixel[1] - pixel[2]) / c); if (h < 0) { return h + 360; } return h; } if (v === pixel[1]) { return 60 * ((pixel[2] - pixel[0]) / c + 2); } return 60 * ((pixel[0] - pixel[1]) / c + 4); }, { output: [64, 64] }); // "Red" in RGB: bright, and much redder than it is green or blue. const rgbMask = gpu.createKernel(function (photo) { const pixel = photo[this.thread.y][this.thread.x]; if (pixel[0] > 0.5 && pixel[0] - pixel[1] > 0.3 && pixel[0] - pixel[2] > 0.3) { return 1; } return 0; }, { output: [64, 64] }); const hsvMask = gpu.createKernel(function (photo, hueMap) { const pixel = photo[this.thread.y][this.thread.x]; const v = Math.max(pixel[0], Math.max(pixel[1], pixel[2])); const m = Math.min(pixel[0], Math.min(pixel[1], pixel[2])); let s = 0; if (v > 0) { s = (v - m) / v; } const h = hueMap[this.thread.y][this.thread.x]; // TODO: 1 when h is within this.constants.tol degrees of // this.constants.target ON THE WHEEL, and s clears this.constants.minSat. // The red wedge runs from 345 up over the seam to 15 ... doesn't it? if (h > 345 && h < 15) { return 1; } return 0; }, { output: [64, 64], constants: { target: 0, tol: 15, minSat: 0.35 }, }); const hues = await hue(frame); const inRgb = await rgbMask(frame); const inHsv = await hsvMask(frame, hues); // TODO: total both masks and log the two counts. ``` --- Interactive version: https://gpu.rocks/learn/colour-spaces-8d79c6af/4 [Previous task](https://gpu.rocks/learn/colour-spaces-8d79c6af/3.md) · [Next task](https://gpu.rocks/learn/colour-spaces-8d79c6af/5.md) --- # Payoff: What Colour Is This Picture? *Task 5 of 5 · [Colour Spaces](https://gpu.rocks/learn/colour-spaces-8d79c6af.md) · GPU.js Learn* The payoff, and a question a person can answer in a glance: what colour is this picture, mostly? Cut the wheel into 12 bins of 30°, count how many pixels fall in each, and read off the fullest one. The counting is a histogram, and the one-thread-per-bin shape it has to take when you have no atomics is exactly what Histograms & Binning derives — so that kernel comes ready made below, along with the two you wrote in task 2. What is left for you is the part that is about colour: turning each pixel into a bin number, and refusing to answer for the pixels that have no colour to report. That refusal is the difference between an answer and a rumour. The stones along the bottom of this picture are grey to within a rounding error, and the direction of a rounding error is still a perfectly valid-looking angle. Bin them and they smear a plausible-looking 96 pixels of nonsense across the whole wheel. Drop anything below a saturation floor and the histogram only counts pixels that actually have a hue — which is why its counts come to fewer than 4,096, on purpose. **Array layout in gpu.js** Image data comes in row-major: `image[y][x]` is the pixel in row *y*, column *x*, and each pixel is an `[r, g, b, a]` array with channels from 0 to 1. Mind the inversion that catches everyone — sizes are given width-first (`output: [width, height]`), but indexing runs row-first, so this thread's own pixel is `image[this.thread.y][this.thread.x]`. Swap those two and you read the transpose of your image. Three-dimensional data follows the same rule: `output: [w, h, d]` is indexed `[z][y][x]`. ## Goal **Goal:** finish `hueBin` — the bin index for each pixel, or `-1` for a pixel with no usable hue — then find the fullest bin in JavaScript and log it. ## Requirements - Return `-1` when the saturation is below `this.constants.floor` - Otherwise `Math.floor(h / this.constants.width)`, clamped to `this.constants.bins - 1` - Find the fullest bin in plain JavaScript and `console.log` its index - Check the counts: they should total `4000`, not 4,096 — the 96 stones are excluded on purpose ## Hint 1 — the floor first The saturation test comes before anything else, because a pixel that fails it has no angle worth binning: ```js if (sat[this.thread.y][this.thread.x] < this.constants.floor) { return -1; } ``` ## Hint 2 — the bin 30° per bin, so the index is the hue divided by the width and floored. The clamp is the same one Histograms & Binning needed: a hue of exactly 360 would otherwise land in bin 12, which no thread owns. ```js const h = hue[this.thread.y][this.thread.x]; return Math.min(this.constants.bins - 1, Math.floor(h / this.constants.width)); ``` ## Hint 3 — reading the answer The fullest bin is a plain loop over 12 numbers — not worth a kernel. Bin *b* covers `b * 30` to `(b + 1) * 30` degrees, so printing that range alongside the index tells you what colour the picture actually is. ## Same idea elsewhere Hue histograms are the backbone of colour-based tracking: the CAMShift tracker that ships with OpenCV builds one over a target region and then back-projects it into each new frame, precisely because hue survives the target walking through a shadow. The two-pass shape — derive a per-pixel quantity into a map, then bin the map — is the same one every GPU histogram uses, on every platform, and for the same reason: binning has to read the data many times, so you want it reading something cheap. ## Starter code ```js // Map first (a bin per pixel), bin second (a thread per bin). const gpu = new GPU({ mode }); // The two kernels from task 2, unchanged. const hue = gpu.createKernel(function (photo) { const pixel = photo[this.thread.y][this.thread.x]; const v = Math.max(pixel[0], Math.max(pixel[1], pixel[2])); const m = Math.min(pixel[0], Math.min(pixel[1], pixel[2])); const c = v - m; if (c === 0) { return -1; } if (v === pixel[0]) { const h = 60 * ((pixel[1] - pixel[2]) / c); if (h < 0) { return h + 360; } return h; } if (v === pixel[1]) { return 60 * ((pixel[2] - pixel[0]) / c + 2); } return 60 * ((pixel[0] - pixel[1]) / c + 4); }, { output: [64, 64] }); const saturation = gpu.createKernel(function (photo) { const pixel = photo[this.thread.y][this.thread.x]; const v = Math.max(pixel[0], Math.max(pixel[1], pixel[2])); const m = Math.min(pixel[0], Math.min(pixel[1], pixel[2])); if (v === 0) { return 0; } return (v - m) / v; }, { output: [64, 64] }); const hueBin = gpu.createKernel(function (hue, sat) { // TODO: -1 when this pixel's saturation is below this.constants.floor, // otherwise its bin: the hue divided by this.constants.width, floored, // and clamped to this.constants.bins - 1. return 0; }, { output: [64, 64], constants: { bins: 12, width: 30, floor: 0.15 }, }); // One thread per bin, each scanning the whole map — the shape a GPU histogram // has to take when nobody can increment anybody else's counter. const histogram = gpu.createKernel(function (bins) { let count = 0; for (let y = 0; y < this.constants.size; y++) { for (let x = 0; x < this.constants.size; x++) { if (bins[y][x] === this.thread.x) { count++; } } } return count; }, { output: [12], constants: { size: 64 } }); const counts = await histogram(await hueBin(await hue(photo), await saturation(photo))); console.log('counts:', counts); // TODO: find the fullest bin and log it. Bin b covers b * 30 ... (b + 1) * 30 degrees. ``` --- Interactive version: https://gpu.rocks/learn/colour-spaces-8d79c6af/5 [Previous task](https://gpu.rocks/learn/colour-spaces-8d79c6af/4.md) --- # Convolution & Filters *Module of the free GPU.js GPGPU course · 5 tasks* Sliding-window math on signals and images: blur, sharpen, edge detection. ## Tasks 1. [Slide a Window: 1D Convolution](https://gpu.rocks/learn/convolution-and-filters-66933805/1.md) 2. [Any Filter, One Kernel](https://gpu.rocks/learn/convolution-and-filters-66933805/2.md) 3. [Box Blur: the Window Goes 2D](https://gpu.rocks/learn/convolution-and-filters-66933805/3.md) 4. [Sharpen: Negative Weights](https://gpu.rocks/learn/convolution-and-filters-66933805/4.md) 5. [Sobel Edge Detection](https://gpu.rocks/learn/convolution-and-filters-66933805/5.md) --- Interactive version: https://gpu.rocks/learn/convolution-and-filters-66933805 --- # Slide a Window: 1D Convolution *Task 1 of 5 · [Convolution & Filters](https://gpu.rocks/learn/convolution-and-filters-66933805.md) · GPU.js Learn* A **convolution** slides a small window of weights along a signal: each output sample is a weighted average of the input around it. With weights `[0.25, 0.5, 0.25]` the window *smooths* — every sample leans toward its neighbors and jitter cancels out. On the GPU nothing actually slides. Every output sample gets its own thread, and each thread reads its *own* three inputs, all at the same time. The only wrinkle is the ends: sample 0 has no left neighbor, so we **clamp** — reuse the nearest in-bounds sample instead of reading past the edge. ## Figures - **nothing slides — thread x just reads its own three samples** ## Goal **Goal:** smooth the 128-sample `signal` — each output is `0.25·left + 0.5·center + 0.25·right`, with indexes clamped at both ends. ## Requirements - Read this thread's neighbors: `signal[x - 1]` and `signal[x + 1]` - Clamp the indexes — below `0` becomes `0`, above `127` becomes `127` - Return `0.25·left + 0.5·center + 0.25·right` ## Hint 1 — nothing slides Thread `x` only ever touches `signal[x - 1]`, `signal[x]` and `signal[x + 1]`. Three reads, one weighted sum, done — the "sliding" is 128 threads doing this at once. ## Hint 2 — clamping with an if ```js let left = x - 1; if (left < 0) left = 0; ``` and the mirror image for `right` against `127`. Plain `if` statements work fine inside kernels. ## Hint 3 — the whole body ```js let left = x - 1; if (left < 0) left = 0; let right = x + 1; if (right > 127) right = 127; return 0.25 * signal[left] + 0.5 * signal[x] + 0.25 * signal[right]; ``` ## Same idea elsewhere Neighborhood reads like this are called *stencil* patterns in CUDA and ROCm — the classic optimization is staging the window in shared memory. A WebGPU compute shader does the same thing with neighboring buffer reads inside a workgroup. ## Starter code ```js // Convolution: each output sample is a weighted average of its neighborhood. const gpu = new GPU({ mode }); const smooth = gpu.createKernel(function (signal) { const x = this.thread.x; // TODO: return 0.25 * left + 0.5 * center + 0.25 * right, // clamping the neighbor indexes so x = 0 and x = 127 stay in bounds. return signal[x]; }, { output: [128] }); const result = await smooth(signal); console.log('before:', signal[63], ' after:', result[63]); ``` --- Interactive version: https://gpu.rocks/learn/convolution-and-filters-66933805/1 [Next task](https://gpu.rocks/learn/convolution-and-filters-66933805/2.md) --- # Any Filter, One Kernel *Task 2 of 5 · [Convolution & Filters](https://gpu.rocks/learn/convolution-and-filters-66933805.md) · GPU.js Learn* Hardcoded weights mean writing a new kernel for every filter. The fix: pass the `filter` in as an ordinary array argument and loop over its taps. But a GPU loop wants bounds it can see *at compile time* — and that is exactly what `this.constants` is for: values baked into the kernel when it compiles, perfectly legal as loop bounds. This kernel is built with `constants: { size: 5, radius: 2 }`. Tap `i` of the filter lines up with input sample `x + i - radius` — clamp that index like before and accumulate `filter[i] * signal[tap]`. ## Goal **Goal:** finish the generic convolution — loop over `this.constants.size` taps, clamp each tap index, and return the accumulated weighted sum. One kernel, any 5-tap filter. ## Requirements - Loop `for (let i = 0; i < this.constants.size; i++)` — a constant is a legal bound - Tap index: `x + i - this.constants.radius`, clamped to `0…127` - Accumulate `filter[i] * signal[tap]` into `sum` and return it ## Hint 1 — why constants? Kernel arguments change per call; constants are frozen into the compiled kernel. That is why `this.constants.size` can bound a loop when a plain argument could not. ## Hint 2 — the loop body ```js let tap = x + i - this.constants.radius; if (tap < 0) tap = 0; if (tap > 127) tap = 127; sum += filter[i] * signal[tap]; ``` ## Same idea elsewhere Baked-in constants are a first-class idea everywhere: WGSL has pipeline-overridable constants, CUDA kernels take template parameters and `__constant__` memory, Metal has function constants — all so the compiler knows your loop bounds and can unroll the filter loop. ## Starter code ```js // One kernel, any 5-tap filter: weights come in as data, size as constants. const gpu = new GPU({ mode }); const convolve = gpu.createKernel(function (signal, filter) { const x = this.thread.x; let sum = 0; // TODO: loop i from 0 to this.constants.size, // tap index = x + i - this.constants.radius (clamped to 0…127), // accumulate filter[i] * signal[tap]. return sum; }, { output: [128], constants: { size: 5, radius: 2 }, }); const gauss = [0.06, 0.24, 0.4, 0.24, 0.06]; const result = await convolve(signal, gauss); console.log('smoothed sample 64:', result[64]); ``` --- Interactive version: https://gpu.rocks/learn/convolution-and-filters-66933805/2 [Previous task](https://gpu.rocks/learn/convolution-and-filters-66933805/1.md) · [Next task](https://gpu.rocks/learn/convolution-and-filters-66933805/3.md) --- # Box Blur: the Window Goes 2D *Task 3 of 5 · [Convolution & Filters](https://gpu.rocks/learn/convolution-and-filters-66933805.md) · GPU.js Learn* Take the sliding window into two dimensions and you have image filtering. A **3×3 box blur** is the simplest case: every output pixel is the plain average of the 3×3 patch centered on it — nine reads, per color channel, per pixel. 131,072 threads each do their nine reads at once. Same edge problem, now on four sides: clamp *both* coordinates into `0…this.constants.last` before indexing. Average red, green and blue separately and hand the result to `this.color()`. **Array layout in gpu.js** Image data comes in row-major: `image[y][x]` is the pixel in row *y*, column *x*, and each pixel is an `[r, g, b, a]` array with channels from 0 to 1. Mind the inversion that catches everyone — sizes are given width-first (`output: [width, height]`), but indexing runs row-first, so this thread's own pixel is `image[this.thread.y][this.thread.x]`. Swap those two and you read the transpose of your image. Three-dimensional data follows the same rule: `output: [w, h, d]` is indexed `[z][y][x]`. ## Figures - **nine reads and an average; off the edge, the border pixel answers twice** ## Goal **Goal:** blur `inputImage` with a 3×3 box filter — each painted pixel is the average of its 3×3 neighborhood, edges clamped. ## Requirements - Loop over the 3×3 neighborhood (a double `for` loop over `dy`, `dx`) - Clamp both sample coordinates to `0…this.constants.last` - Accumulate red, green and blue separately, then paint `this.color(r/9, g/9, b/9, 1)` ## Hint 1 — the neighborhood loop `for (let dy = 0; dy < 3; dy++)` nested with `dx`, and the sample position is `this.thread.y + dy - 1`, `this.thread.x + dx - 1` — the `- 1` centers the window on this thread's pixel. ## Hint 2 — clamp, then read ```js let sy = this.thread.y + dy - 1; if (sy < 0) sy = 0; if (sy > this.constants.last) sy = this.constants.last; ``` — same for `sx` — then `const pixel = image[sy][sx];` and add `pixel[0]`, `pixel[1]`, `pixel[2]` into three running sums. ## Hint 3 — the finish After the loops: `this.color(r / 9, g / 9, b / 9, 1);` — nine samples went in, so divide by nine on the way out. ## Same idea elsewhere Blur passes ship in every production toolkit — Metal Performance Shaders' `MPSImageBox`, NVIDIA's NPP filtering routines, WebGPU post-processing chains. The fast ones exploit that a box blur is *separable*: a horizontal pass then a vertical pass — six reads per pixel instead of nine. ## Starter code ```js // Nine reads per pixel, averaged per channel. 131,072 threads at once. const gpu = new GPU({ mode }); const blur = gpu.createKernel(function (image) { // TODO: average the 3×3 neighborhood around this pixel. // Clamp sample coordinates to 0…this.constants.last on both axes. const pixel = image[this.thread.y][this.thread.x]; this.color(pixel[0], pixel[1], pixel[2], 1); }, { output: [128, 128], graphical: true, constants: { last: 127 }, }); await blur(inputImage); render(blur.canvas); ``` --- Interactive version: https://gpu.rocks/learn/convolution-and-filters-66933805/3 [Previous task](https://gpu.rocks/learn/convolution-and-filters-66933805/2.md) · [Next task](https://gpu.rocks/learn/convolution-and-filters-66933805/4.md) --- # Sharpen: Negative Weights *Task 4 of 5 · [Convolution & Filters](https://gpu.rocks/learn/convolution-and-filters-66933805.md) · GPU.js Learn* Filters are not all averages. Give the window **negative weights** and it starts measuring *differences*. The classic sharpen filter is a cross: `5` at the center, `−1` at each direct neighbor. Where the image is flat, the terms cancel to exactly the original value; where it changes, the difference gets amplified — edges pop. Sharpened values can overshoot right out of the 0–1 range, so this task computes on a numeric **luminance map** (`gray[y][x]`, one number per pixel) and returns raw numbers you can inspect — no color clamping hiding the math. **Array layout in gpu.js** Image data comes in row-major: `image[y][x]` is the pixel in row *y*, column *x*, and each pixel is an `[r, g, b, a]` array with channels from 0 to 1. Mind the inversion that catches everyone — sizes are given width-first (`output: [width, height]`), but indexing runs row-first, so this thread's own pixel is `image[this.thread.y][this.thread.x]`. Swap those two and you read the transpose of your image. Three-dimensional data follows the same rule: `output: [w, h, d]` is indexed `[z][y][x]`. ## Goal **Goal:** sharpen the 96×96 `gray` map — each cell becomes `5·center − left − right − up − down`, with neighbor indexes clamped. ## Requirements - Clamp all four neighbor indexes to `0…this.constants.last` - Return `5 * gray[y][x]` minus the four clamped neighbor samples - Keep the kernel numeric — no `graphical: true`, values may leave 0–1 ## Hint 1 — why 5 and −1? The weights sum to 1, so flat regions pass through unchanged: `5c − 4c = c`. Everything the filter adds comes purely from center-vs-neighbor *differences*. ## Hint 2 — four clamps, one return ```js let left = x - 1; if (left < 0) left = 0; ``` — repeat for `right`, `up`, `down` against `this.constants.last`, then a single return with the five terms: ```js return 5 * gray[y][x] - gray[y][left] - gray[y][right] - gray[up][x] - gray[down][x]; ``` ## Same idea elsewhere A convolution with learned weights is a CNN layer — cuDNN (CUDA) and MIOpen (ROCm) are entire libraries for running this exact multiply-accumulate window fast. Your sharpen filter is the same arithmetic with the weights picked by hand instead of by gradient descent. ## Starter code ```js // Sharpen = identity + edge boost: 5×center − the 4 direct neighbors. const gpu = new GPU({ mode }); const sharpen = gpu.createKernel(function (gray) { const x = this.thread.x; const y = this.thread.y; // TODO: clamp left/right/up/down to 0…this.constants.last, then // return 5 * center − left − right − up − down. return gray[y][x]; }, { output: [96, 96], constants: { last: 95 }, }); const result = await sharpen(gray); console.log('center before:', gray[48][48], ' after:', result[48][48]); ``` --- Interactive version: https://gpu.rocks/learn/convolution-and-filters-66933805/4 [Previous task](https://gpu.rocks/learn/convolution-and-filters-66933805/3.md) · [Next task](https://gpu.rocks/learn/convolution-and-filters-66933805/5.md) --- # Sobel Edge Detection *Task 5 of 5 · [Convolution & Filters](https://gpu.rocks/learn/convolution-and-filters-66933805.md) · GPU.js Learn* The payoff: run **two convolutions at once**. Sobel's `Gx` filter responds to horizontal change, `Gy` to vertical change, and the length of that gradient vector — `√(gx² + gy²)` — is how *edge-like* the pixel is, whatever the edge's direction. This is a two-kernel pipeline like the finale of **Data In, Data Out**: a numeric pass turns the image into a luminance map (written for you), then the Sobel pass reads each map cell's eight neighbors, applies both weight grids, and paints the magnitude. Border pixels have no full neighborhood, so the starter already paints them black — your work lives in the `else` branch. **Array layout in gpu.js** Image data comes in row-major: `image[y][x]` is the pixel in row *y*, column *x*, and each pixel is an `[r, g, b, a]` array with channels from 0 to 1. Mind the inversion that catches everyone — sizes are given width-first (`output: [width, height]`), but indexing runs row-first, so this thread's own pixel is `image[this.thread.y][this.thread.x]`. Swap those two and you read the transpose of your image. Three-dimensional data follows the same rule: `output: [w, h, d]` is indexed `[z][y][x]`. ## Goal **Goal:** finish the Sobel kernel — read the 3×3 neighborhood of `gray`, compute `gx` and `gy` with the weights shown in the starter, and paint `Math.sqrt(gx * gx + gy * gy)` as a gray value. ## Requirements - Read the eight neighbors of `gray[y][x]` (no clamping needed — the border branch already ran) - Apply both weight grids: `gx` from the right column minus the left, `gy` from the bottom row minus the top - Paint the magnitude `Math.sqrt(gx * gx + gy * gy)` as gray via `this.color(m, m, m, 1)` ## Hint 1 — name the neighborhood Pull the nine cells into locals first — `const tl = gray[y - 1][x - 1];` through `const br = gray[y + 1][x + 1];` — then the two weighted sums are easy to read off the grids. ## Hint 2 — the two sums ```js const gx = (tr + 2 * mr + br) - (tl + 2 * ml + bl); ``` — right column minus left column, middle counted double. `gy` is the same with rows: `(bl + 2 * bm + br) - (tl + 2 * tm + tr)`. ## Hint 3 — the finish ```js const m = Math.sqrt(gx * gx + gy * gy); this.color(m, m, m, 1); ``` — flat areas give 0 (black), sharp edges overshoot 1 and clamp to white. ## Same idea elsewhere Sobel is the hello-world of GPU vision: it opens the OpenCL and CUDA imaging tutorials, camera ISPs run it in silicon, and edge maps feed feature detectors everywhere. Fusing two directional filters into one pass is exactly how you would write it in WGSL or Metal, too. ## Starter code ```js // Two directional convolutions, one kernel, magnitude out. const gpu = new GPU({ mode }); // Pass 1 — luminance map (Data In, Data Out déjà vu; already done for you). const luminance = gpu.createKernel(function (image) { const pixel = image[this.thread.y][this.thread.x]; return 0.299 * pixel[0] + 0.587 * pixel[1] + 0.114 * pixel[2]; }, { output: [128, 128] }); // Pass 2 — Sobel. Gx and Gy weigh the same 3×3 neighborhood: // // Gx Gy // -1 0 +1 -1 -2 -1 // -2 0 +2 0 0 0 // -1 0 +1 +1 +2 +1 // const sobel = gpu.createKernel(function (gray) { const x = this.thread.x; const y = this.thread.y; if (x === 0 || y === 0 || x === this.constants.last || y === this.constants.last) { this.color(0, 0, 0, 1); // border: no full neighborhood — paint it black } else { // TODO: read the 8 neighbors, compute gx and gy with the grids above, // then paint the magnitude Math.sqrt(gx * gx + gy * gy). const l = gray[y][x]; this.color(l, l, l, 1); } }, { output: [128, 128], graphical: true, constants: { last: 127 }, }); const grayMap = await luminance(inputImage); await sobel(grayMap); render(sobel.canvas); ``` --- Interactive version: https://gpu.rocks/learn/convolution-and-filters-66933805/5 [Previous task](https://gpu.rocks/learn/convolution-and-filters-66933805/4.md) --- # Thresholding & Morphology *Module of the free GPU.js GPGPU course · 6 tasks* Turning grey pixels into a clean binary mask: global and adaptive thresholds, then erosion and dilation as a neighbourhood min and max. ## Tasks 1. [One Number for the Whole Image](https://gpu.rocks/learn/thresholding-and-morphology-670eaafa/1.md) 2. [Let the Histogram Pick the Number](https://gpu.rocks/learn/thresholding-and-morphology-670eaafa/2.md) 3. [A Threshold Per Neighbourhood](https://gpu.rocks/learn/thresholding-and-morphology-670eaafa/3.md) 4. [Erode and Dilate: the Sweep, With Min and Max](https://gpu.rocks/learn/thresholding-and-morphology-670eaafa/4.md) 5. [Opening and Closing: Order Is the Answer](https://gpu.rocks/learn/thresholding-and-morphology-670eaafa/5.md) 6. [Payoff: Clean the Mask, Count What Is Left](https://gpu.rocks/learn/thresholding-and-morphology-670eaafa/6.md) --- Interactive version: https://gpu.rocks/learn/thresholding-and-morphology-670eaafa --- # One Number for the Whole Image *Task 1 of 6 · [Thresholding & Morphology](https://gpu.rocks/learn/thresholding-and-morphology-670eaafa.md) · GPU.js Learn* Everything downstream — counting, measuring, tracking — wants a **binary mask**: one bit per pixel, foreground or background. The cheapest way to make one is a **threshold**. Pick a number; call every pixel brighter than it foreground. Per pixel, no neighbours, no order, nothing shared: the friendliest shape a kernel can have, and exactly the pure map "Thinking in Parallel" calls the easy case. One thread, one pixel, one comparison. It is also where real images bite back. `photo` is lit unevenly — bright at the top-left corner, fading away to the bottom-right — with small bright marks scattered over the whole frame. Run the starter and read the ASCII dump it prints: one corner comes back solid, the opposite corner comes back empty, and only a diagonal band across the middle finds the marks at all. **Array layout in gpu.js** Image data comes in row-major: `image[y][x]` is the pixel in row *y*, column *x*, and each pixel is an `[r, g, b, a]` array with channels from 0 to 1. Mind the inversion that catches everyone — sizes are given width-first (`output: [width, height]`), but indexing runs row-first, so this thread's own pixel is `image[this.thread.y][this.thread.x]`. Swap those two and you read the transpose of your image. Three-dimensional data follows the same rule: `output: [w, h, d]` is indexed `[z][y][x]`. ## Figures - **one number cannot serve both ends of a lit scene; a number per neighbourhood can** ## Goal **Goal:** return a 128×128 mask — `1` where this pixel's *luminance* is above `this.constants.t`, `0` everywhere else. ## Requirements - Read this thread's pixel: `photo[this.thread.y][this.thread.x]` - Threshold the **luminance** `0.299r + 0.587g + 0.114b`, not a single channel - Return exactly `1` or `0` — brighter than `this.constants.t` is foreground ## Hint 1 — luminance first, comparison second Two steps, both of which you have written before: reduce the pixel to one number, then compare that number. The image is warm-toned, so red and luminance are genuinely different pictures — thresholding `pixel[0]` gives a mask that is wrong by a couple of lighting bands. ## Hint 2 — returning a bit A kernel returns a number, so the "bit" is the number `1` or the number `0`: ```js if (lum > this.constants.t) return 1; return 0; ``` ## Hint 3 — the whole body ```js const p = photo[this.thread.y][this.thread.x]; const lum = 0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]; if (lum > this.constants.t) return 1; return 0; ``` ## Same idea elsewhere A threshold is one `step()` in GLSL/WGSL, one predicated store in CUDA, and a single fused op in every imaging library — it is so cheap that camera ISPs do it in silicon. Which is exactly why the interesting question is never how to compare, but what to compare against. ## Starter code ```js // One thread, one pixel, one comparison. No neighbours needed. const gpu = new GPU({ mode }); const threshold = gpu.createKernel(function (photo) { // TODO: reduce this thread's pixel to its luminance // (0.299 R + 0.587 G + 0.114 B), then return 1 when that is above // this.constants.t and 0 when it is not. return 0; }, { output: [128, 128], constants: { t: 0.49 }, }); const mask = await threshold(photo); // A look at the result: every 4th pixel, '#' where the mask says foreground. for (let y = 0; y < 128; y += 4) { let line = ''; for (let x = 0; x < 128; x += 4) line += mask[y][x] > 0.5 ? '#' : '.'; console.log(line); } // The same story in numbers: two opposite corners of the frame. let lit = 0; let dark = 0; for (let y = 0; y < 32; y++) { for (let x = 0; x < 32; x++) { lit += mask[y][x]; dark += mask[y + 96][x + 96]; } } console.log('top-left 32x32 foreground:', lit, 'of 1024'); console.log('bottom-right 32x32 foreground:', dark, 'of 1024'); ``` --- Interactive version: https://gpu.rocks/learn/thresholding-and-morphology-670eaafa/1 [Next task](https://gpu.rocks/learn/thresholding-and-morphology-670eaafa/2.md) --- # Let the Histogram Pick the Number *Task 2 of 6 · [Thresholding & Morphology](https://gpu.rocks/learn/thresholding-and-morphology-670eaafa.md) · GPU.js Learn* Picking `0.49` by hand was a cheat. **Otsu's method** reads the number off the data instead: try every cut, keep the one whose two sides are furthest apart. "Furthest apart" has a precise meaning — the **between-class variance**. Cut the tone histogram at bin `t`; let `p0` and `p1` be the fraction of pixels on each side and `mu0`, `mu1` their mean bin numbers. Then ```js score(t) = p0 * p1 * (mu0 - mu1) * (mu0 - mu1) ``` and the winner is the `t` that maximises it. `tones` is the 256-bin tone histogram of an *evenly* lit scene — the same one-thread-per-bin build Histograms & Binning finishes on, handed over here rather than counted again. The parallel shape is the good part: 256 candidate thresholds, 256 threads, each sweeping all 256 bins for itself. 65,536 reads that happen at once, and then a single tiny argmax over the answers. (Task 3 is the reminder that Otsu picks the best possible single number, and that on a badly lit frame the best possible single number is still not good enough.) ## Goal **Goal:** one thread per candidate threshold — return the between-class variance of the cut at `t = this.thread.x`, with class 0 being bins `0…t` **inclusive**. ## Requirements - Output `[256]`: one thread per candidate threshold, `t = this.thread.x` - Sweep all `this.constants.bins` bins, accumulating each class's count and its count-weighted bin sum - Class 0 is bins `0…t` **inclusive** — a bin equal to `t` belongs below the cut - Return `p0 * p1 * (mu0 - mu1)²`, or `0` when either class is empty ## Hint 1 — one thread, one candidate Thread `t` owns exactly one question: *what if I cut here?* It reads the whole histogram to answer it, which is fine — 256 reads is nothing, and all 256 threads are doing it at the same time. ## Hint 2 — four running totals One pass, four accumulators: the count and the bin-weighted sum on each side. ```js for (let i = 0; i < this.constants.bins; i++) { if (i <= t) { w0 += tones[i]; s0 += i * tones[i]; } else { w1 += tones[i]; s1 += i * tones[i]; } } ``` The class means are then `s0 / w0` and `s1 / w1`. ## Hint 3 — the empty class At `t = 0` class 0 may hold no pixels at all, and `s0 / w0` is then `0 / 0` — a NaN that poisons the whole comparison. Guard it: a cut with an empty side separates nothing, so its score is `0`. ```js if (w0 === 0 || w1 === 0) return 0; ``` ## Same idea elsewhere This is the classic "try every candidate in parallel, reduce afterwards" shape: one CUDA thread per hypothesis, one WGSL invocation per bin, one Metal thread per candidate. OpenCV's `THRESH_OTSU` runs the same arithmetic serially over 256 bins because on a CPU that is already free — on a GPU it is free *and* it fuses into whatever pass produced the histogram. ## Starter code ```js // 256 candidate thresholds, 256 threads, one histogram sweep each. const gpu = new GPU({ mode }); const between = gpu.createKernel(function (tones) { const t = this.thread.x; let w0 = 0; let s0 = 0; let w1 = 0; let s1 = 0; // TODO: sweep all this.constants.bins bins. Bins 0…t (inclusive) go into // w0/s0, the rest into w1/s1. Then return p0 * p1 * (mu0 - mu1)², // and 0 if either class turned out to be empty. return 0; }, { output: [256], constants: { bins: 256 }, }); const scores = await between(tones); // The argmax is one tiny reduction — plain JavaScript is the right tool here. let best = 0; for (let t = 1; t < 256; t++) { if (scores[t] > scores[best]) best = t; } console.log('Otsu threshold: bin', best); console.log('as a grey level:', (best / 255).toFixed(3)); ``` --- Interactive version: https://gpu.rocks/learn/thresholding-and-morphology-670eaafa/2 [Previous task](https://gpu.rocks/learn/thresholding-and-morphology-670eaafa/1.md) · [Next task](https://gpu.rocks/learn/thresholding-and-morphology-670eaafa/3.md) --- # A Threshold Per Neighbourhood *Task 3 of 6 · [Thresholding & Morphology](https://gpu.rocks/learn/thresholding-and-morphology-670eaafa.md) · GPU.js Learn* Task 1's failure was not bad luck, and no cleverer *single* number fixes it: Otsu would pick the best one that exists and the lit corner would still saturate. The premise is what is wrong. One number cannot describe an image whose brightness changes across the frame. So stop asking for one. **Adaptive thresholding** compares every pixel against the mean of *its own* neighbourhood — a 9×9 box average, which is the clamped sweep the box blur in Convolution & Filters already makes — plus a small bias `c`. A pixel is foreground when it is at least `c` brighter than its surroundings. That is a statement about local contrast, and it says nothing whatever about the lamp. The window size is the one real choice. It has to be comfortably bigger than the things you are hunting, or the mean drowns in them and a mark declares itself average; and comfortably smaller than the lighting changes, or it stops tracking them and you are back to task 1. Here the marks are 5 pixels across and the light drifts over tens of pixels, so 9×9 sits nicely in between. `gray` is the same scene's luminance, one number per pixel. A luminance pass produces it in a real pipeline — the finale of **Data In, Data Out** is exactly that pass — and it is handed over here so the sweep is the only thing you write. ## Goal **Goal:** return `1` where `gray[y][x]` exceeds the mean of its clamped 9×9 neighbourhood by more than `this.constants.c`, and `0` everywhere else. ## Requirements - Sum the `this.constants.win` × `this.constants.win` neighbourhood, both coordinates clamped to `0…this.constants.last` - Centre the window: sample `this.thread.y + dy - this.constants.radius`, likewise for x - Divide by `this.constants.area` to get the mean - Return `1` when this pixel is above `mean + this.constants.c`, otherwise `0` ## Hint 1 — it is a box blur that ends in a question The loop is the one from the 3×3 box blur, widened to 9×9 and reading a single number per cell instead of three channels. The only new line is the last one: instead of painting the mean, compare against it. ## Hint 2 — the clamped sample ```js let sy = this.thread.y + dy - this.constants.radius; if (sy < 0) sy = 0; if (sy > this.constants.last) sy = this.constants.last; ``` — the same four lines for `sx`, then `sum += gray[sy][sx];`. ## Hint 3 — the finish ```js const mean = sum / this.constants.area; if (gray[this.thread.y][this.thread.x] > mean + this.constants.c) return 1; return 0; ``` The bias goes on the *mean*, raising the bar. Subtract it instead and flat ground starts reporting itself as foreground. ## Same idea elsewhere Every vision toolkit ships this: OpenCV's `adaptiveThreshold`, Sauvola and Niblack binarisation in document scanning, and the local-contrast test at the front of most feature detectors. On a GPU the box average is separable and can be done in two passes, or in one with a summed-area table — the same trick that makes real-time adaptive thresholding cheap on a phone. ## Starter code ```js // A threshold per pixel: the box-blur sweep, ending in a comparison. const gpu = new GPU({ mode }); const adaptive = gpu.createKernel(function (gray) { let sum = 0; // TODO: sum the 9×9 neighbourhood centred on this thread, clamping both // coordinates to 0…this.constants.last. Then return 1 when this pixel is // more than this.constants.c above the mean, and 0 when it is not. return 0; }, { output: [128, 128], constants: { last: 127, win: 9, radius: 4, area: 81, c: 0.03 }, }); const mask = await adaptive(gray); // A look at the result: every 4th pixel, '#' where the mask says foreground. for (let y = 0; y < 128; y += 4) { let line = ''; for (let x = 0; x < 128; x += 4) line += mask[y][x] > 0.5 ? '#' : '.'; console.log(line); } let lit = 0; let dark = 0; for (let y = 0; y < 32; y++) { for (let x = 0; x < 32; x++) { lit += mask[y][x]; dark += mask[y + 96][x + 96]; } } console.log('top-left 32x32 foreground:', lit, 'of 1024'); console.log('bottom-right 32x32 foreground:', dark, 'of 1024'); ``` --- Interactive version: https://gpu.rocks/learn/thresholding-and-morphology-670eaafa/3 [Previous task](https://gpu.rocks/learn/thresholding-and-morphology-670eaafa/2.md) · [Next task](https://gpu.rocks/learn/thresholding-and-morphology-670eaafa/4.md) --- # Erode and Dilate: the Sweep, With Min and Max *Task 4 of 6 · [Thresholding & Morphology](https://gpu.rocks/learn/thresholding-and-morphology-670eaafa.md) · GPU.js Learn* A fresh mask is never clean. Stray single pixels where a highlight caught the sensor; single missing pixels where a shape had a dark fleck. **Morphology** is the repair kit, and it is built out of two operations you have, in a real sense, already written. In Convolution & Filters the 3×3 window read nine samples, multiplied them by nine weights and added them up. Keep the window, keep the nine reads, keep the clamped edges — and replace the weighted sum with a **minimum**. That is **erosion**: a pixel survives only if *every* one of its neighbours is foreground, so shapes lose a one-pixel rind and lone specks vanish. Replace it with a **maximum** and you have **dilation**: a pixel lights up if *any* neighbour does, so shapes gain a rind and small holes close over. Same access pattern, different reduction operator. That is worth saying out loud, because it generalises: a neighbourhood sweep is a *shape*, and what you do with the nine values you gathered is a separate decision. Sum them and you have a filter; take their extreme and you have morphology. The window has a name — the **structuring element** — and a 3×3 square is the plainest one there is. **Edges:** this module *clamps*, so a sample that falls off the frame reuses the nearest in-bounds cell, exactly as the box blur did. Treating out-of-bounds as background is just as defensible, and it is a different answer: a shape lying flush against the frame erodes away along that edge instead of surviving it. Four of the shapes in `mask` run to the frame, so the tests can tell which rule you picked. ## Figures - **gather the same nine samples, then decide what to do with them** ## Goal **Goal:** two kernels over the same clamped 3×3 sweep — an **eroder** that returns the smallest sample in the window, then a **dilator** that returns the largest. ## Requirements - Create the eroder *first* and the dilator *second* — the tests read them in that order - Sweep the 3×3 neighbourhood with both coordinates clamped to `0…this.constants.last` - Erosion keeps the minimum (start at `1`, `Math.min`); dilation keeps the maximum (start at `0`, `Math.max`) - Nothing else changes — a min or max of 1s and 0s is still exactly 1 or 0 ## Hint 1 — the same nine reads Copy the box blur's double loop verbatim, clamps and all. Replace the three channel sums with one accumulator, and replace `+=` with `Math.min` or `Math.max`. ## Hint 2 — the accumulator Start the minimum at the largest value a mask can hold and the maximum at the smallest, so the first sample always wins: ```js let lo = 1; // … inside the loops … lo = Math.min(lo, mask[sy][sx]); ``` and the mirror image — `let hi = 0;` with `Math.max` — for dilation. ## Hint 3 — which way round? Say it as a sentence. Erosion: "I stay foreground only if *all* of my neighbours are" — that is an AND over the window, and the AND of 1s and 0s is their minimum. Dilation: "I become foreground if *any* neighbour is" — an OR, which is their maximum. If your shapes are growing when you asked them to shrink, these two are the wrong way round. ## Same idea elsewhere Morphology is a first-class citizen everywhere: NVIDIA's NPP has `nppiErode`/`nppiDilate`, Metal Performance Shaders has `MPSImageAreaMin` and `MPSImageAreaMax`, and every WGSL post-process chain grows one eventually. The optimisation is the same as for a box blur — a rectangular structuring element is separable, so an *n*×*n* erosion is a horizontal pass followed by a vertical one. ## Starter code ```js // One sweep, two reduction operators. const gpu = new GPU({ mode }); const erode = gpu.createKernel(function (mask) { let lo = 1; // TODO: sweep the 3×3 neighbourhood with both coordinates clamped to // 0…this.constants.last, and keep the SMALLEST sample you saw. return lo; }, { output: [128, 128], constants: { last: 127 }, }); const dilate = gpu.createKernel(function (mask) { let hi = 0; // TODO: the same sweep, keeping the LARGEST sample. return hi; }, { output: [128, 128], constants: { last: 127 }, }); // Plain JavaScript: how many cells of a mask are foreground. function count(grid) { let n = 0; for (let y = 0; y < 128; y++) { for (let x = 0; x < 128; x++) n += grid[y][x]; } return n; } console.log('mask :', count(mask)); console.log('eroded :', count(await erode(mask))); console.log('dilated :', count(await dilate(mask))); ``` --- Interactive version: https://gpu.rocks/learn/thresholding-and-morphology-670eaafa/4 [Previous task](https://gpu.rocks/learn/thresholding-and-morphology-670eaafa/3.md) · [Next task](https://gpu.rocks/learn/thresholding-and-morphology-670eaafa/5.md) --- # Opening and Closing: Order Is the Answer *Task 5 of 6 · [Thresholding & Morphology](https://gpu.rocks/learn/thresholding-and-morphology-670eaafa.md) · GPU.js Learn* Erosion on its own is a blunt instrument: it kills the specks and takes a rind off everything else. Dilation on its own is the same mistake in reverse. Run them back to back and the size change cancels while the repair survives — and which repair you get depends entirely on which one goes first. **Opening** is erode *then* dilate. The erosion wipes anything thinner than the structuring element, the dilation grows the survivors back to size: small bright specks are gone for good and everything else ends up roughly where it started. **Closing** is dilate *then* erode: the dilation swallows small dark holes, the erosion pulls the outlines back in, so pinholes fill and the specks stay exactly where they were. They are not inverses and they are not interchangeable. Opening removes; closing fills. Ask for one and write the other and you get precisely the opposite of what you wanted — which is the single most reliable way to lose an afternoon to morphology. Both kernels are given below, so this task is about the plumbing: chain them, and chain them twice. Two erosions followed by two dilations is an opening with a radius-2 element — it clears out the 3×3 clumps that a single pass is too gentle to touch. ## Figures - **run them the other way round and you repair the other defect** ## Goal **Goal:** build an opening, a closing and a two-pass opening from the given kernels, and report what each one changed with the exact labels the starter uses. ## Requirements - Opening is `await dilate(await erode(mask))`; closing is `await erode(await dilate(mask))` - The two-pass opening runs both erosions before either dilation - Write the `removed` kernel: `1` where `before` is foreground and `after` is not - Log the three counts with the labels already in the starter ## Hint 1 — chaining kernels A kernel's result is an ordinary 2D array, so it goes straight back into another kernel: `await dilate(await erode(mask))` is the whole opening. Every pass is a separate launch, which is exactly how a real pipeline does it (and Pipelines & Textures shows how to keep the intermediate on the GPU). ## Hint 2 — which is which Read the name outwards. An *opening* opens gaps up: it must start by shrinking, so erosion goes first. A *closing* closes gaps: it starts by growing. If your "opening" is filling holes instead of clearing specks, you have written a closing. ## Hint 3 — the difference kernel ```js const removed = gpu.createKernel(function (before, after) { if (before[this.thread.y][this.thread.x] > after[this.thread.y][this.thread.x]) return 1; return 0; }, { output: [128, 128] }); ``` Feed it `(mask, opened)` to see what the opening threw away, and `(closed, mask)` to see what the closing filled in. ## Same idea elsewhere Opening and closing are the standard pre-processing pair in OpenCV (`MORPH_OPEN`, `MORPH_CLOSE`) and in every medical- and satellite-imaging toolchain. On a GPU each is a fixed chain of launches with no readback in between — the ping-pong between two buffers that WebGPU and CUDA pipelines are built around. ## Starter code ```js // Two orders, two completely different repairs. const gpu = new GPU({ mode }); // Given: the two sweeps from the previous task, unchanged. const erode = gpu.createKernel(function (mask) { let lo = 1; for (let dy = 0; dy < 3; dy++) { for (let dx = 0; dx < 3; dx++) { let sy = this.thread.y + dy - 1; let sx = this.thread.x + dx - 1; if (sy < 0) sy = 0; if (sy > this.constants.last) sy = this.constants.last; if (sx < 0) sx = 0; if (sx > this.constants.last) sx = this.constants.last; lo = Math.min(lo, mask[sy][sx]); } } return lo; }, { output: [128, 128], constants: { last: 127 } }); const dilate = gpu.createKernel(function (mask) { let hi = 0; for (let dy = 0; dy < 3; dy++) { for (let dx = 0; dx < 3; dx++) { let sy = this.thread.y + dy - 1; let sx = this.thread.x + dx - 1; if (sy < 0) sy = 0; if (sy > this.constants.last) sy = this.constants.last; if (sx < 0) sx = 0; if (sx > this.constants.last) sx = this.constants.last; hi = Math.max(hi, mask[sy][sx]); } } return hi; }, { output: [128, 128], constants: { last: 127 } }); const removed = gpu.createKernel(function (before, after) { // TODO: 1 where before is foreground and after is not; 0 otherwise. return 0; }, { output: [128, 128] }); // Plain JavaScript: how many cells of a mask are foreground. function count(grid) { let n = 0; for (let y = 0; y < 128; y++) { for (let x = 0; x < 128; x++) n += grid[y][x]; } return n; } // TODO: opening is erode then dilate; closing is dilate then erode; // the two-pass opening erodes twice before dilating twice. const opened = mask; const closed = mask; const openedTwice = mask; console.log('opening removed:', count(await removed(mask, opened))); console.log('closing added:', count(await removed(closed, mask))); console.log('two passes removed:', count(await removed(mask, openedTwice))); ``` --- Interactive version: https://gpu.rocks/learn/thresholding-and-morphology-670eaafa/5 [Previous task](https://gpu.rocks/learn/thresholding-and-morphology-670eaafa/4.md) · [Next task](https://gpu.rocks/learn/thresholding-and-morphology-670eaafa/6.md) --- # Payoff: Clean the Mask, Count What Is Left *Task 6 of 6 · [Thresholding & Morphology](https://gpu.rocks/learn/thresholding-and-morphology-670eaafa.md) · GPU.js Learn* The whole module in one run. `noisy` is a mask straight off a threshold: sixteen solid rectangles and a confetti of stray one- and two-pixel specks. Open it once to clear the confetti, then count what survived. Counting connected blobs sounds like it needs a real labelling algorithm — and in general it does. But every shape here is an axis-aligned rectangle, and a rectangle has exactly one **top-left corner**: a foreground pixel whose neighbour above and whose neighbour to the left are both background. So count corners and you have counted shapes, with a per-pixel predicate and a sum — the same map-then-reduce shape Reductions is built on. Be straight about the caveat. A U-shaped blob has two top-left corners and this would count it twice. The trick is exact for *this* mask, not for all masks; real connected-component labelling is a different and much heavier algorithm. One more border note. This mask has a clear frame — nothing touches the edge — so a clamped read of a missing neighbour lands on background either way and the count comes out exact. Had a shape run to the edge, the clamped read would have returned the shape itself and that corner would have gone uncounted: one more place where the border rule is a decision, not a detail. ## Goal **Goal:** open `noisy` once, write a `corners` kernel that marks each rectangle's top-left pixel, and log the blob count before and after the cleanup. ## Requirements - Clean the mask with one opening: erode, then dilate - `corners` returns `1` only for a foreground pixel whose neighbour above *and* neighbour to the left are background - Clamp both neighbour indexes — a negative index reads outside the mask - Sum the corner grid in JavaScript and log both counts with the starter's labels ## Hint 1 — three conditions A cell is a corner when all three hold: it is foreground, the cell above is not, and the cell to its left is not. Any one of them failing means 0 — which reads nicely as three early returns. ## Hint 2 — clamping just the two you need Only the low side can go out of bounds here, so two clamps are enough: ```js let up = this.thread.y - 1; if (up < 0) up = 0; let left = this.thread.x - 1; if (left < 0) left = 0; ``` ## Hint 3 — the whole body ```js const y = this.thread.y; const x = this.thread.x; if (mask[y][x] < 0.5) return 0; let up = y - 1; if (up < 0) up = 0; let left = x - 1; if (left < 0) left = 0; if (mask[up][x] > 0.5) return 0; if (mask[y][left] > 0.5) return 0; return 1; ``` ## Same idea elsewhere Cleaning a mask and then reducing it to a handful of numbers is what a vision pipeline actually does — the mask is never the product. The corner predicate is a *stencil* in CUDA/ROCm terms and the sum is a standard reduction, so on any platform this is one filter pass feeding one reduction: precisely the two primitives this course keeps coming back to. ## Starter code ```js // Threshold, clean, count. The whole module in one run. const gpu = new GPU({ mode }); // Given: the two sweeps from the previous task, unchanged. const erode = gpu.createKernel(function (mask) { let lo = 1; for (let dy = 0; dy < 3; dy++) { for (let dx = 0; dx < 3; dx++) { let sy = this.thread.y + dy - 1; let sx = this.thread.x + dx - 1; if (sy < 0) sy = 0; if (sy > this.constants.last) sy = this.constants.last; if (sx < 0) sx = 0; if (sx > this.constants.last) sx = this.constants.last; lo = Math.min(lo, mask[sy][sx]); } } return lo; }, { output: [128, 128], constants: { last: 127 } }); const dilate = gpu.createKernel(function (mask) { let hi = 0; for (let dy = 0; dy < 3; dy++) { for (let dx = 0; dx < 3; dx++) { let sy = this.thread.y + dy - 1; let sx = this.thread.x + dx - 1; if (sy < 0) sy = 0; if (sy > this.constants.last) sy = this.constants.last; if (sx < 0) sx = 0; if (sx > this.constants.last) sx = this.constants.last; hi = Math.max(hi, mask[sy][sx]); } } return hi; }, { output: [128, 128], constants: { last: 127 } }); const corners = gpu.createKernel(function (mask) { // TODO: return 1 only when this pixel is foreground AND the pixels above it // and to its left are both background. Clamp both neighbour indexes. return 0; }, { output: [128, 128], constants: { last: 127 }, }); // Plain JavaScript: how many cells of a mask are foreground. function count(grid) { let n = 0; for (let y = 0; y < 128; y++) { for (let x = 0; x < 128; x++) n += grid[y][x]; } return n; } // TODO: one opening — erode first, then dilate. const clean = noisy; console.log('blobs before cleaning:', count(await corners(noisy))); console.log('blobs after cleaning:', count(await corners(clean))); ``` --- Interactive version: https://gpu.rocks/learn/thresholding-and-morphology-670eaafa/6 [Previous task](https://gpu.rocks/learn/thresholding-and-morphology-670eaafa/5.md) --- # The Canny Edge Pipeline *Module of the free GPU.js GPGPU course · 6 tasks* The edge detector every vision library ships, one kernel per stage — blur, gradient, thinning, thresholds, hysteresis — then chained with pipeline: true. ## Tasks 1. [Blur First: a Separable Gaussian](https://gpu.rocks/learn/canny-edges-6901c51a/1.md) 2. [Magnitude, and the Angle Nobody Mentions](https://gpu.rocks/learn/canny-edges-6901c51a/2.md) 3. [Non-Maximum Suppression](https://gpu.rocks/learn/canny-edges-6901c51a/3.md) 4. [Strong, Weak, Gone](https://gpu.rocks/learn/canny-edges-6901c51a/4.md) 5. [Hysteresis: Run It Until Nothing Changes](https://gpu.rocks/learn/canny-edges-6901c51a/5.md) 6. [Payoff: Five Stages, Zero Round Trips](https://gpu.rocks/learn/canny-edges-6901c51a/6.md) --- Interactive version: https://gpu.rocks/learn/canny-edges-6901c51a --- # Blur First: a Separable Gaussian *Task 1 of 6 · [The Canny Edge Pipeline](https://gpu.rocks/learn/canny-edges-6901c51a.md) · GPU.js Learn* Canny's first move looks like vandalism: before you go looking for edges, you **throw detail away**. The reason is that every later stage is built on a derivative, and the derivative of noise is enormous. A pixel that wobbles by ±0.12 against its neighbours has no visible brightness to speak of — but a difference operator reads that wobble at full strength, because a difference is exactly what it is looking for. The numbers on this task's own picture: run the rest of this module on `gray` unsmoothed and you get **596 edge pixels, 154 of them in flat background** — pure noise, promoted to structure. Smooth it first and the same pipeline reports **299 edge pixels and not one spurious**. That is what the blur buys. Convolution & Filters already taught the sliding window, the box blur, clamped edges, and the fact that a box blur is *separable*. Both facts come due here. A Gaussian beats a box for this job because it has no corners: a box filter's response oscillates as the window slides, so it manufactures small ridges of its own — precisely the thing stage 3 is about to hunt for. And a Gaussian is separable too, so a 5×5 window is **two 5-tap passes, not one 25-tap pass**: 10 reads per pixel instead of 25. **Array layout in gpu.js** Image data comes in row-major: `image[y][x]` is the pixel in row *y*, column *x*, and each pixel is an `[r, g, b, a]` array with channels from 0 to 1. Mind the inversion that catches everyone — sizes are given width-first (`output: [width, height]`), but indexing runs row-first, so this thread's own pixel is `image[this.thread.y][this.thread.x]`. Swap those two and you read the transpose of your image. Three-dimensional data follows the same rule: `output: [w, h, d]` is indexed `[z][y][x]`. ## Figures - **five kernels in a row — you build them left to right, then chain them in task 6** — The five stages of Canny as a strip of pictures: noisy photo, blurred photo, a thick gradient ridge, that ridge thinned to one pixel, the thin line broken into strong and weak segments, and one unbroken edge after hysteresis. ## Goal **Goal:** finish the vertical half of the blur. `blurX` is written for you; write `blurY` so the pair applies the weights `[1, 4, 6, 4, 1] / 16` along *each* axis, indexes clamped at the edges. ## Requirements - Weight five samples `1, 4, 6, 4, 1` and divide the total by `16` - `blurY` walks **rows** — offset `this.thread.y`, not `this.thread.x` - Clamp every sample index into `0…this.constants.last` ## Hint 1 — the same filter, turned ninety degrees `blurX` holds `y` still and moves `x`. `blurY` does the mirror image: hold `x` still, move `y`. Copying the body is fine — copying its *axis* is the mistake the tests are watching for. ## Hint 2 — the clamps ```js let y0 = y - 2; if (y0 < 0) y0 = 0; let y4 = y + 2; if (y4 > this.constants.last) y4 = this.constants.last; ``` — and the same for `y1` and `y3` at distance 1. ## Hint 3 — the whole return ```js return (map[y0][x] + 4 * map[y1][x] + 6 * map[y][x] + 4 * map[y3][x] + map[y4][x]) / 16; ``` The weights sum to 16, so the divide is what keeps a flat area flat. ## Same idea elsewhere Separability is not a gpu.js trick — it is why production blurs are fast everywhere. Metal Performance Shaders' `MPSImageGaussianBlur` and NVIDIA NPP's `nppiFilterGaussBorder` both decompose internally; a WebGPU post-processing chain does horizontal-then-vertical into a ping-pong pair of textures. The saving grows with the kernel: a 15×15 Gaussian is 225 taps as one pass and 30 as two. ## Starter code ```js // Stage 1 of Canny: smooth, so the derivative that follows is a // derivative of the picture and not of the noise. const gpu = new GPU({ mode }); // Pass 1 — horizontal. Weights 1, 4, 6, 4, 1 over five columns. const blurX = gpu.createKernel(function (gray) { const x = this.thread.x; const y = this.thread.y; let x0 = x - 2; if (x0 < 0) x0 = 0; let x1 = x - 1; if (x1 < 0) x1 = 0; let x3 = x + 1; if (x3 > this.constants.last) x3 = this.constants.last; let x4 = x + 2; if (x4 > this.constants.last) x4 = this.constants.last; return (gray[y][x0] + 4 * gray[y][x1] + 6 * gray[y][x] + 4 * gray[y][x3] + gray[y][x4]) / 16; }, { output: [64, 64], constants: { last: 63 }, }); // Pass 2 — vertical. Same weights, other axis. const blurY = gpu.createKernel(function (map) { const x = this.thread.x; const y = this.thread.y; // TODO: the same five weighted samples, walking DOWN the column: // rows y-2, y-1, y, y+1, y+2, each index clamped to 0…this.constants.last. return map[y][x]; }, { output: [64, 64], constants: { last: 63 }, }); const smooth = await blurY(await blurX(gray)); console.log('noisy background pixel:', gray[4][40], ' smoothed:', smooth[4][40]); ``` --- Interactive version: https://gpu.rocks/learn/canny-edges-6901c51a/1 [Next task](https://gpu.rocks/learn/canny-edges-6901c51a/2.md) --- # Magnitude, and the Angle Nobody Mentions *Task 2 of 6 · [The Canny Edge Pipeline](https://gpu.rocks/learn/canny-edges-6901c51a.md) · GPU.js Learn* The Sobel pass you wrote in Convolution & Filters answered one question: *how strong* is the change here, `√(gx² + gy²)`. Canny needs a second answer from the same eight reads, and it is the one that usually gets skipped: *which way* does the change point. `Math.atan2(gy, gx)` — and the next stage is built entirely on it. Get the angle wrong and non-maximum suppression compares the wrong two neighbours, silently, on every pixel. Two things about `atan2` worth saying out loud. It takes the **vertical component first**: `Math.atan2(gy, gx)`, not the other way round — swap them and every angle is reflected about 45°. And it returns **radians** in −π…π, which is why the result can be negative: a gradient pointing up-and-right and one pointing down-and-left are 180° apart and describe the same edge. Stage 3 is where that gets sorted out. `gray` here is already smoothed — it is what stage 1 hands over. Both kernels read the same 3×3 neighbourhood; the starter has pulled the nine cells into locals for you. **Array layout in gpu.js** Image data comes in row-major: `image[y][x]` is the pixel in row *y*, column *x*, and each pixel is an `[r, g, b, a]` array with channels from 0 to 1. Mind the inversion that catches everyone — sizes are given width-first (`output: [width, height]`), but indexing runs row-first, so this thread's own pixel is `image[this.thread.y][this.thread.x]`. Swap those two and you read the transpose of your image. Three-dimensional data follows the same rule: `output: [w, h, d]` is indexed `[z][y][x]`. ## Goal **Goal:** compute `gx` and `gy` from the Sobel grids in both kernels, then return the gradient's **length** from `magnitude` and its **angle in radians** from `direction`. ## Requirements - `gx` is the right column minus the left, middle row counted double; `gy` is the bottom row minus the top - `magnitude` returns `Math.sqrt(gx * gx + gy * gy)` — the length, not its square - `direction` returns `Math.atan2(gy, gx)` — vertical component first, in radians - Border pixels have no full neighbourhood: both kernels already return `0` there ## Hint 1 — the two grids Same pair Convolution & Filters used: ```js const gx = (tr + 2 * mr + br) - (tl + 2 * ml + bl); const gy = (bl + 2 * bm + br) - (tl + 2 * tm + tr); ``` `gy` is bottom minus top because `y` runs *down* the image. Flip that sign and the magnitude will not notice — it squares everything — but every angle will. ## Hint 2 — the two returns ```js return Math.sqrt(gx * gx + gy * gy); // magnitude return Math.atan2(gy, gx); // direction, radians ``` Leaving the `Math.sqrt` off is tempting — comparisons on squares sort the same way — but every threshold in the rest of this module is calibrated against a length, and squaring bends the scale. ## Same idea elsewhere `atan2` is a hardware instruction's worth of work on every GPU: CUDA has `atan2f` (and `__fdividef` for the cheap path), WGSL and Metal both spell it `atan2`, and gpu.js compiles `Math.atan2` straight to GLSL's `atan(y, x)`. OpenCV's `cv::Canny` famously avoids it altogether — it compares `|gy|` against `tan(22.5°)·|gx|` with integer arithmetic — which is the same quantisation you are about to write, with the trigonometry folded away. ## Starter code ```js // Stage 2 of Canny: two answers from one 3x3 neighbourhood. // // Gx Gy // -1 0 +1 -1 -2 -1 // -2 0 +2 0 0 0 // -1 0 +1 +1 +2 +1 // const gpu = new GPU({ mode }); const magnitude = gpu.createKernel(function (gray) { const x = this.thread.x; const y = this.thread.y; if (x === 0 || y === 0 || x === this.constants.last || y === this.constants.last) { return 0; } const tl = gray[y - 1][x - 1]; const tm = gray[y - 1][x]; const tr = gray[y - 1][x + 1]; const ml = gray[y][x - 1]; const mr = gray[y][x + 1]; const bl = gray[y + 1][x - 1]; const bm = gray[y + 1][x]; const br = gray[y + 1][x + 1]; // TODO: gx and gy from the grids above, then return the gradient's LENGTH. return 0; }, { output: [64, 64], constants: { last: 63 }, }); const direction = gpu.createKernel(function (gray) { const x = this.thread.x; const y = this.thread.y; if (x === 0 || y === 0 || x === this.constants.last || y === this.constants.last) { return 0; } const tl = gray[y - 1][x - 1]; const tm = gray[y - 1][x]; const tr = gray[y - 1][x + 1]; const ml = gray[y][x - 1]; const mr = gray[y][x + 1]; const bl = gray[y + 1][x - 1]; const bm = gray[y + 1][x]; const br = gray[y + 1][x + 1]; // TODO: the same gx and gy, then return the gradient's ANGLE in radians. return 0; }, { output: [64, 64], constants: { last: 63 }, }); const mag = await magnitude(gray); const dir = await direction(gray); console.log('on a vertical edge — magnitude:', mag[20][8], ' angle:', dir[20][8]); console.log('on a horizontal edge — magnitude:', mag[8][20], ' angle:', dir[8][20]); ``` --- Interactive version: https://gpu.rocks/learn/canny-edges-6901c51a/2 [Previous task](https://gpu.rocks/learn/canny-edges-6901c51a/1.md) · [Next task](https://gpu.rocks/learn/canny-edges-6901c51a/3.md) --- # Non-Maximum Suppression *Task 3 of 6 · [The Canny Edge Pipeline](https://gpu.rocks/learn/canny-edges-6901c51a.md) · GPU.js Learn* Stage 2 leaves edges several pixels thick: a gradient does not switch on at one column, it ramps across the whole slope. Canny's third stage is what makes the output an **edge map** rather than a heat map — and it is the stage everyone gets wrong. The rule: a pixel survives only if it is a local maximum **along its own gradient direction**. Not along the edge — *across* it. The gradient points the way the brightness climbs, which is perpendicular to the edge itself, and walking one step each way along that direction is walking off the ridge on both sides. If the pixel is the top of that little ridge, it stays; if either neighbour is above it, it is on the slope, and it goes to zero. Two steps, then. **Quantise** the angle to one of four axes — the only neighbours you have are the eight around you, so the gradient's direction can only be answered to 45° — and then **compare** against the two neighbours on that axis. Quantising has one trap in it: `atan2` returns −π…π, but an axis has no sense of forwards. −45° and +135° are the same axis, so an angle below zero has to be wrapped up by 180° first. Skip the wrap and one of your four buckets is never selected at all — on this task's own map, that is 570 of the 1,049 gradient pixels quietly landing in the wrong bucket. **Array layout in gpu.js** Image data comes in row-major: `image[y][x]` is the pixel in row *y*, column *x*, and each pixel is an `[r, g, b, a]` array with channels from 0 to 1. Mind the inversion that catches everyone — sizes are given width-first (`output: [width, height]`), but indexing runs row-first, so this thread's own pixel is `image[this.thread.y][this.thread.x]`. Swap those two and you read the transpose of your image. Three-dimensional data follows the same rule: `output: [w, h, d]` is indexed `[z][y][x]`. ## Figures - **the two neighbours that matter are across the edge, not along it** — A patch of gradient magnitudes holding a three-pixel-wide ridge. The pixel under test is compared with the two neighbours lying along its gradient direction, which crosses the ridge; the edge itself runs at right angles to that. On the right, the three-wide band becomes a one-pixel line. ## Goal **Goal:** keep `mag[y][x]` when it is at least as large as both of its neighbours along the quantised gradient direction, and return `0` otherwise. ## Requirements - Wrap a negative angle by `+ Math.PI` before quantising — 180° and 0° are the same axis - Quantise into four buckets at 22.5°, 67.5°, 112.5°: an `(ax, ay)` step of `(1,0)`, `(1,1)`, `(0,1)` or `(-1,1)` - Compare against `mag[y + ay][x + ax]` and `mag[y - ay][x - ax]` — the GRADIENT axis, not the edge - Survivors keep their magnitude; everything else is `0`, borders included ## Hint 1 — which neighbours belong to which bucket Take the angle to degrees after wrapping, so it lies in 0…180, and read off the axis: ```js 0° ± 22.5 → (ax, ay) = ( 1, 0) left ↔ right 45° ± 22.5 → (ax, ay) = ( 1, 1) ↖ ↘ 90° ± 22.5 → (ax, ay) = ( 0, 1) up ↕ down 135° ± 22.5 → (ax, ay) = (-1, 1) ↗ ↙ ``` Start with `(1, 0)` and let the last bucket fall out of the `else`: 0° and 180° share it. ## Hint 2 — the shape of the body ```js let a = dir[y][x]; if (a < 0) a += Math.PI; const deg = a * 180 / Math.PI; let ax = 1; let ay = 0; if (deg >= 22.5 && deg < 67.5) { ax = 1; ay = 1; } else if (deg >= 67.5 && deg < 112.5) { ax = 0; ay = 1; } else if (deg >= 112.5 && deg < 157.5) { ax = -1; ay = 1; } ``` ## Hint 3 — the comparison ```js const m = mag[y][x]; if (m >= mag[y + ay][x + ax] && m >= mag[y - ay][x - ax]) { return m; } return 0; ``` `>=`, not `>`: on a perfectly symmetric edge the two middle pixels tie, and `>` would erase both and leave a hole where the edge was. The border check has already returned, so these indexes are in bounds. ## Same idea elsewhere The name is borrowed all over vision: object detectors run "NMS" over overlapping boxes with exactly this argument — keep the local maximum, drop everything it explains. On the GPU the pattern is a pure gather, one thread per pixel with no coordination, which is why NVIDIA's VPI, OpenCV's `cudaimgproc` and every WebGPU implementation fuse it into a single compute pass. The awkward part on real hardware is the branch: four buckets means four different neighbour pairs, and a warp whose threads disagree runs all four paths — which is why some implementations interpolate along the true angle instead of quantising, trading arithmetic for branch uniformity. ## Starter code ```js // Stage 3 of Canny: thin the ridges down to one pixel. const gpu = new GPU({ mode }); const suppress = gpu.createKernel(function (mag, dir) { const x = this.thread.x; const y = this.thread.y; if (x === 0 || y === 0 || x === this.constants.last || y === this.constants.last) { return 0; } // TODO 1: wrap dir[y][x] up by Math.PI when it is negative, and turn it // into degrees so it lies in 0…180. // TODO 2: pick the (ax, ay) step for its bucket — (1,0), (1,1), (0,1), (-1,1). // TODO 3: keep mag[y][x] only if it is >= BOTH mag[y + ay][x + ax] // and mag[y - ay][x - ax]. Otherwise return 0. return mag[y][x]; }, { output: [64, 64], constants: { last: 63 }, }); const thin = await suppress(mag, dir); let before = 0; let after = 0; for (let y = 0; y < 64; y++) { for (let x = 0; x < 64; x++) { if (mag[y][x] > 0) before++; if (thin[y][x] > 0) after++; } } console.log('pixels with a gradient:', before, ' still standing after suppression:', after); ``` --- Interactive version: https://gpu.rocks/learn/canny-edges-6901c51a/3 [Previous task](https://gpu.rocks/learn/canny-edges-6901c51a/2.md) · [Next task](https://gpu.rocks/learn/canny-edges-6901c51a/4.md) --- # Strong, Weak, Gone *Task 4 of 6 · [The Canny Edge Pipeline](https://gpu.rocks/learn/canny-edges-6901c51a.md) · GPU.js Learn* One threshold forces a bad choice. Set it high and long edges break into dashes wherever the contrast dips; set it low and the picture fills with noise. Canny's answer is to refuse to choose: use **two** thresholds and admit that the middle band is undecided. Above `high` a pixel is **strong** — it is an edge, no argument. Below `low` it is **gone**. Between them it is **weak**: it might be the faint continuation of a real edge, or it might be nothing, and this stage deliberately does not decide. It just labels. The next stage decides, and it decides by asking who the pixel's neighbours are. The labels are numbers, because a kernel returns a number: `1` for strong, `0.5` for weak, `0` for gone. Order matters — test `high` first. Written the other way round, `low` catches everything and the strong branch is the only one that ever fires, which turns three classes back into one. ## Goal **Goal:** classify every cell of `thin` into `1` (at or above `this.constants.high`), `0.5` (at or above `this.constants.low`) or `0`. ## Requirements - Compare against `this.constants.high` *first*, then `this.constants.low` - Return exactly `1`, `0.5` or `0` — the next stage tests for those values - Both comparisons are `>=`, so a pixel exactly on a threshold takes the higher class ## Hint 1 — three lines ```js const m = thin[this.thread.y][this.thread.x]; if (m >= this.constants.high) { return 1; } ``` …then the same shape for `low` returning `0.5`, and a bare `return 0;` at the end. ## Hint 2 — why 0.5 and not 2 The value has to survive a float texture and a `>` comparison in the next kernel, so the three labels want to be far apart and exactly representable. `0`, `0.5` and `1` are all exact in binary floating point, and the propagation kernel can then test `> 0.75` for "strong" and `< 0.25` for "gone" without ever comparing floats for equality. ## Same idea elsewhere This stage is the most boring kernel in the module and the most universally fast one: a pure elementwise map, one read and one write per thread, no neighbours, no coordination — the shape a GPU is happiest with. In CUDA it is a `thrust::transform`, in WebGPU a one-line compute shader, and in a fused production Canny it does not exist as a separate pass at all: the comparison gets folded into the tail of the suppression kernel, because the memory traffic of a whole extra pass costs more than the arithmetic it saves. ## Starter code ```js // Stage 4 of Canny: three classes, two thresholds, no decisions. const gpu = new GPU({ mode }); const classify = gpu.createKernel(function (thin) { const m = thin[this.thread.y][this.thread.x]; // TODO: 1 when m is at or above this.constants.high, // 0.5 when it is at or above this.constants.low, // 0 otherwise. Mind which one you test first. return m; }, { output: [64, 64], constants: { low: 0.3, high: 0.7 }, }); const labels = await classify(thin); let strong = 0; let weak = 0; for (let y = 0; y < 64; y++) { for (let x = 0; x < 64; x++) { if (labels[y][x] === 1) strong++; if (labels[y][x] === 0.5) weak++; } } console.log('strong:', strong, ' weak:', weak, ' gone:', 64 * 64 - strong - weak); ``` --- Interactive version: https://gpu.rocks/learn/canny-edges-6901c51a/4 [Previous task](https://gpu.rocks/learn/canny-edges-6901c51a/3.md) · [Next task](https://gpu.rocks/learn/canny-edges-6901c51a/5.md) --- # Hysteresis: Run It Until Nothing Changes *Task 5 of 6 · [The Canny Edge Pipeline](https://gpu.rocks/learn/canny-edges-6901c51a.md) · GPU.js Learn* Stage 4 left a pile of undecided pixels. Hysteresis decides them with one rule: a weak pixel lives if it is **connected to a strong one** — touching it, or touching something that is. That "or" is the whole problem. Connectivity is *transitive*, and a GPU kernel can only see one step out. So you run the kernel again. One pass promotes every weak pixel that touches a strong one; the second pass promotes the ones that touch those; a chain of length *n* takes *n* passes to light up end to end. On this task's map that is **28 passes** — and you cannot know that in advance. The propagation is done when a pass changes nothing, which you can only find out by reading the result back and looking. Here that readback is free, because these kernels are not pipelined yet and every pass comes home to JavaScript anyway. Task 6 is where that stops being true, and where the honest cost of "iterate until stable" shows up. Worth knowing: plenty of real-time implementations do not iterate at all. They run **one** pass — a weak pixel survives if any of its eight neighbours is strong — and ship it. It under-connects long faint chains, and for a 60 fps video filter that is a bargain: a fixed, known cost per frame instead of a data-dependent loop nobody can budget for. ## Figures - **the front moves one pixel per pass, and nobody knows how many passes that is** — A strong pixel beside a chain of weak ones. Each pass promotes exactly one more weak cell, so the strong front advances one pixel at a time until the chain is complete. A separate group of weak pixels touching nothing strong is never promoted, and is dropped. ## Goal **Goal:** write the propagation kernel, then run it in a loop until a pass changes nothing, logging how many passes that took. ## Requirements - A strong cell (`> 0.75`) stays `1`; a gone cell (`< 0.25`) stays `0` - A weak cell becomes `1` if any of its **8** neighbours is strong, else stays `0.5` - Loop until `unchanged(next, state)`, then log `console.log('settled after', passes, 'passes')` - Count every call to `grow`, including the last one — the one that told you to stop ## Hint 1 — no early return inside the loop Scan the 3×3 neighbourhood and set a flag rather than returning from inside the loops — it compiles the same on every backend and reads better: ```js let strongNear = 0; for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { // clamp sy, sx into 0…this.constants.last, then: if (state[sy][sx] > 0.75) { strongNear = 1; } } } ``` The centre cell is included in that scan, and it is harmless: this branch only runs when the centre is weak, so it can never mark itself. ## Hint 2 — the loop ```js let state = classified; let passes = 0; for (let i = 0; i < 40; i++) { const next = await grow(state); passes++; state = next; if (unchanged(next, state)) break; } ``` — except that assignment above happens too early to compare anything. Take `next`, count it, compare it against the *previous* `state`, and only then replace it. ## Hint 3 — why 40 The `for` is a safety rail, not the plan: the `break` is what actually stops the loop, and 40 is simply more passes than a 64×64 map could ever need. Leaving a bound on a loop you expect to break out of is cheap insurance against a kernel that never settles. ## Same idea elsewhere "Iterate a local rule until the global answer stops changing" is label propagation, and it is how connected components are computed on GPUs everywhere — CUDA's `cuGraph`, ROCm's rocPRIM-based labelers, every union-find-on-GPU paper. The expensive part is always the same: the termination test. CUDA can keep a device-side "changed" flag and read back four bytes per iteration; WebGPU can write it to a storage buffer and feed it to an indirect dispatch. gpu.js has neither, so the choice is stark — pay a full readback per pass to ask, or pick a fixed count and accept whatever it gets you. Task 6 picks the second. ## Starter code ```js // Stage 5 of Canny: a weak edge lives if it is connected to a strong one. const gpu = new GPU({ mode }); // One propagation pass. const grow = gpu.createKernel(function (state) { const x = this.thread.x; const y = this.thread.y; const v = state[y][x]; if (v > 0.75) { return 1; // already strong } if (v < 0.25) { return 0; // already gone } // TODO: this cell is weak. Scan its 8 neighbours (clamp sy and sx into // 0…this.constants.last); return 1 if any of them is strong, else 0.5. return 0.5; }, { output: [64, 64], constants: { last: 63 }, }); // Weak pixels that never found a strong friend do not make the cut. const finish = gpu.createKernel(function (state) { if (state[this.thread.y][this.thread.x] > 0.75) { return 1; } return 0; }, { output: [64, 64] }); // Plain JavaScript: did this pass change anything at all? function unchanged(a, b) { for (let y = 0; y < a.length; y++) { for (let x = 0; x < a[y].length; x++) { if (a[y][x] !== b[y][x]) return false; } } return true; } // TODO: one pass is not hysteresis. A weak pixel three steps from a strong one // needs three passes to hear about it — keep going until a pass changes nothing. let state = await grow(classified); const passes = 1; console.log('settled after', passes, 'passes'); const edges = await finish(state); let count = 0; for (let y = 0; y < 64; y++) { for (let x = 0; x < 64; x++) count += edges[y][x]; } console.log('edge pixels:', count); ``` --- Interactive version: https://gpu.rocks/learn/canny-edges-6901c51a/5 [Previous task](https://gpu.rocks/learn/canny-edges-6901c51a/4.md) · [Next task](https://gpu.rocks/learn/canny-edges-6901c51a/6.md) --- # Payoff: Five Stages, Zero Round Trips *Task 6 of 6 · [The Canny Edge Pipeline](https://gpu.rocks/learn/canny-edges-6901c51a.md) · GPU.js Learn* Everything you have written, in one chain, on a real 384×384 photo: **luminance → blurx → blury → magnitude + direction → suppression → threshold → hysteresis → edges**. Nine kernel objects, and with the 64 hysteresis passes, **72 launches** per image. That launch count is the point. Without `pipeline: true`, every one of those stages ends with a full download to JavaScript and the next one begins with a full upload: 384×384 floats, 576 KB, crossing the bus twice per stage — **144 transfers** and 81 MB of traffic to produce one edge map. With pipelines it is two: the photo goes up, the edge map comes down, and the 71 intermediates never leave the card. Pipelines & Textures taught the mechanism on a three-stage chain; this is the chain long enough to make the arithmetic obvious. And at this size the stopwatch finally agrees with the arithmetic. Press **Run**: the console reports the whole thing — nine kernels compiled, 72 launches, one edge map counted — in about **45 ms** on the laptop GPU these notes were measured on. Now delete the eight `pipeline: true` flags, so that every stage hands its result back to JavaScript and the next one uploads it again, and run it once more: about **120 ms**. Same kernels, same arithmetic, same 72 launches — the extra 75 ms is bus traffic and nothing else. (Both figures carry roughly 35 ms of one-time shader compilation. Time the chain on its own, without that, and on the WebGL backend it is **10 ms pipelined against 70–100 ms round-tripping** depending on the machine — seven to ten times either way.) Eight deletions and two clicks: run that experiment rather than take this paragraph's word for it. One note on the console while you do: on this task *auto* reports *WebGL* rather than its usual mix, because the comparison only means anything if both runs use the same backend — strip the pipelining and the stages start handing back plain arrays, which WebGPU would happily take over, and you would be measuring two changes at once instead of one. **Benchmark** agrees from the other direction, reporting the GPU **7–8× faster** than the CPU backend here — roughly 2 ms against 15 ms. Know what that button does before you quote it, though: it replays each of the nine kernels *once* with the arguments it last received, so your 64-pass hysteresis loop collapses into a single call, and it drains the pipeline once at the end rather than after every stage. It times one pass of the chain, not the whole of it — which is why its milliseconds and your console's are different sizes. One honest footnote, because none of that holds at every size. Shrink the photo to 96×96 and the same chain measures 3.2 ms on the GPU against 2.0 ms on the CPU — the CPU wins outright, because 24 launches over 9,216 threads is nowhere near enough work per launch to pay for the driver overhead of making them. The transfer arithmetic is just as true down there; it simply has nothing to show for itself. Launch overhead swamping small work is a real effect, and Measuring Speed Honestly makes a whole meal of it — it is just not the ending this particular chain deserves. The hysteresis loop changes shape here, and honestly so. In task 5 you looped until a pass changed nothing — which you could only know by reading the state back and comparing it. On a pipeline that readback is the very thing you are trying to avoid, so this version runs a **fixed 64 passes** and never asks. This photo settles after 59; the last five do nothing, and you pay for them anyway. A fixed count has to cover the worst photo you will be handed rather than this one — the second photo the tests use needs 56. That is the deal. **Array layout in gpu.js** Image data comes in row-major: `image[y][x]` is the pixel in row *y*, column *x*, and each pixel is an `[r, g, b, a]` array with channels from 0 to 1. Mind the inversion that catches everyone — sizes are given width-first (`output: [width, height]`), but indexing runs row-first, so this thread's own pixel is `image[this.thread.y][this.thread.x]`. Swap those two and you read the transpose of your image. Three-dimensional data follows the same rule: `output: [w, h, d]` is indexed `[z][y][x]`. ## Goal **Goal:** make every stage but the last a pipeline kernel, give the hysteresis kernel `immutable: true` so it can eat its own output, and wire the nine stages into a chain that runs `grow` 64 times. ## Requirements - Add `pipeline: true` to all eight intermediate kernels; leave `finish` plain — its return *is* the one readback you want - Add `immutable: true` to `grow`, which reads the texture it is writing - Feed `magnitude` and `direction` the **smoothed** map, not the raw luminance - Run `grow` exactly `PASSES` times, then log `console.log('edge pixels:', count)` ## Hint 1 — the chain, stage by stage ```js const gray = await luminance(photo); const smooth = await blurY(await blurX(gray)); const thin = await suppress(await magnitude(smooth), await direction(smooth)); let state = await classify(thin); for (let i = 0; i < PASSES; i++) { state = await grow(state); } const edges = await finish(state); ``` Both gradient kernels read *smooth*. Handing them `gray` instead is the starter's first deliberate mistake, and it puts the noise straight back in. ## Hint 2 — which flags, where `pipeline: true` on `luminance`, `blurX`, `blurY`, `magnitude`, `direction`, `suppress`, `classify` and `grow`. Additionally `immutable: true` on `grow` — without it gpu.js refuses the feedback loop with *"Source and destination … are the same"*, because a recycled output texture is the same storage the kernel is reading. ## Hint 3 — reading the answer back `finish` stays a plain kernel, so its result is already a normal 2D array — no `.toArray()` needed. Count the ones with an ordinary JavaScript double loop and log the total. ## Same idea elsewhere A named chain of passes with explicit dependencies and every intermediate resident on the device is what engine programmers call a render graph, or a frame graph: Frostbite's, Unreal's, and — in compute form — CUDA Graphs, where an entire launch chain is recorded once and replayed with a single API call precisely because 72 individual launches carry 72 lots of driver overhead. WebGPU encodes the same idea into one command buffer. The lesson does not change with the spelling: a pipeline is fast when the data never comes home. ## Starter code ```js // The whole detector. Nine kernels; the data should touch JavaScript twice. const gpu = new GPU({ mode }); const PASSES = 64; // TODO: every kernel below except `finish` wants pipeline: true, // and `grow` additionally wants immutable: true. const luminance = gpu.createKernel(function (image) { const p = image[this.thread.y][this.thread.x]; return 0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]; }, { output: [384, 384] }); const blurX = gpu.createKernel(function (gray) { const x = this.thread.x; const y = this.thread.y; let x0 = x - 2; if (x0 < 0) x0 = 0; let x1 = x - 1; if (x1 < 0) x1 = 0; let x3 = x + 1; if (x3 > this.constants.last) x3 = this.constants.last; let x4 = x + 2; if (x4 > this.constants.last) x4 = this.constants.last; return (gray[y][x0] + 4 * gray[y][x1] + 6 * gray[y][x] + 4 * gray[y][x3] + gray[y][x4]) / 16; }, { output: [384, 384], constants: { last: 383 } }); const blurY = gpu.createKernel(function (map) { const x = this.thread.x; const y = this.thread.y; let y0 = y - 2; if (y0 < 0) y0 = 0; let y1 = y - 1; if (y1 < 0) y1 = 0; let y3 = y + 1; if (y3 > this.constants.last) y3 = this.constants.last; let y4 = y + 2; if (y4 > this.constants.last) y4 = this.constants.last; return (map[y0][x] + 4 * map[y1][x] + 6 * map[y][x] + 4 * map[y3][x] + map[y4][x]) / 16; }, { output: [384, 384], constants: { last: 383 } }); const magnitude = gpu.createKernel(function (gray) { const x = this.thread.x; const y = this.thread.y; if (x === 0 || y === 0 || x === this.constants.last || y === this.constants.last) { return 0; } const gx = (gray[y - 1][x + 1] + 2 * gray[y][x + 1] + gray[y + 1][x + 1]) - (gray[y - 1][x - 1] + 2 * gray[y][x - 1] + gray[y + 1][x - 1]); const gy = (gray[y + 1][x - 1] + 2 * gray[y + 1][x] + gray[y + 1][x + 1]) - (gray[y - 1][x - 1] + 2 * gray[y - 1][x] + gray[y - 1][x + 1]); return Math.sqrt(gx * gx + gy * gy); }, { output: [384, 384], constants: { last: 383 } }); const direction = gpu.createKernel(function (gray) { const x = this.thread.x; const y = this.thread.y; if (x === 0 || y === 0 || x === this.constants.last || y === this.constants.last) { return 0; } const gx = (gray[y - 1][x + 1] + 2 * gray[y][x + 1] + gray[y + 1][x + 1]) - (gray[y - 1][x - 1] + 2 * gray[y][x - 1] + gray[y + 1][x - 1]); const gy = (gray[y + 1][x - 1] + 2 * gray[y + 1][x] + gray[y + 1][x + 1]) - (gray[y - 1][x - 1] + 2 * gray[y - 1][x] + gray[y - 1][x + 1]); return Math.atan2(gy, gx); }, { output: [384, 384], constants: { last: 383 } }); const suppress = gpu.createKernel(function (mag, dir) { const x = this.thread.x; const y = this.thread.y; if (x === 0 || y === 0 || x === this.constants.last || y === this.constants.last) { return 0; } let a = dir[y][x]; if (a < 0) a += Math.PI; const deg = a * 180 / Math.PI; let ax = 1; let ay = 0; if (deg >= 22.5 && deg < 67.5) { ax = 1; ay = 1; } else if (deg >= 67.5 && deg < 112.5) { ax = 0; ay = 1; } else if (deg >= 112.5 && deg < 157.5) { ax = -1; ay = 1; } const m = mag[y][x]; if (m >= mag[y + ay][x + ax] && m >= mag[y - ay][x - ax]) { return m; } return 0; }, { output: [384, 384], constants: { last: 383 } }); const classify = gpu.createKernel(function (thin) { const m = thin[this.thread.y][this.thread.x]; if (m >= this.constants.high) { return 1; } if (m >= this.constants.low) { return 0.5; } return 0; }, { output: [384, 384], constants: { low: 0.3, high: 0.7 } }); const grow = gpu.createKernel(function (state) { const x = this.thread.x; const y = this.thread.y; const v = state[y][x]; if (v > 0.75) { return 1; } if (v < 0.25) { return 0; } let strongNear = 0; for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { let sy = y + dy; let sx = x + dx; if (sy < 0) sy = 0; if (sy > this.constants.last) sy = this.constants.last; if (sx < 0) sx = 0; if (sx > this.constants.last) sx = this.constants.last; if (state[sy][sx] > 0.75) { strongNear = 1; } } } if (strongNear === 1) { return 1; } return 0.5; }, { output: [384, 384], constants: { last: 383 } }); // The one kernel that stays plain: its return IS the readback. const finish = gpu.createKernel(function (state) { if (state[this.thread.y][this.thread.x] > 0.75) { return 1; } return 0; }, { output: [384, 384] }); // TODO: the chain. Two mistakes are already in it — the gradient stages are // reading the UNSMOOTHED luminance, and the hysteresis runs exactly once. const gray = await luminance(photo); const smooth = await blurY(await blurX(gray)); const thin = await suppress(await magnitude(gray), await direction(gray)); let state = await classify(thin); state = await grow(state); const edges = await finish(state); let count = 0; for (let y = 0; y < 384; y++) { for (let x = 0; x < 384; x++) count += edges[y][x]; } console.log('edge pixels:', count); ``` --- Interactive version: https://gpu.rocks/learn/canny-edges-6901c51a/6 [Previous task](https://gpu.rocks/learn/canny-edges-6901c51a/5.md) --- # Seam Carving: Content-Aware Resizing *Module of the free GPU.js GPGPU course · 6 tasks* Shrink a picture by deleting its most boring pixels — an energy map, a wavefront DP one launch per row, and a gather that reflows the image. ## Tasks 1. [What Can We Afford to Lose?](https://gpu.rocks/learn/seam-carving-a23a0d9b/1.md) 2. [The Cheapest Path Down: One Launch per Row](https://gpu.rocks/learn/seam-carving-a23a0d9b/2.md) 3. [Reading the Seam Back Out](https://gpu.rocks/learn/seam-carving-a23a0d9b/3.md) 4. [Take It Out, Let the Picture Close Up](https://gpu.rocks/learn/seam-carving-a23a0d9b/4.md) 5. [Payoff: Thirty-Two Seams](https://gpu.rocks/learn/seam-carving-a23a0d9b/5.md) 6. [What It Does Badly, and the Fix Everybody Ships](https://gpu.rocks/learn/seam-carving-a23a0d9b/6.md) --- Interactive version: https://gpu.rocks/learn/seam-carving-a23a0d9b --- # What Can We Afford to Lose? *Task 1 of 6 · [Seam Carving: Content-Aware Resizing](https://gpu.rocks/learn/seam-carving-a23a0d9b.md) · GPU.js Learn* To make a picture narrower you can squash it, crop it — or delete the pixels nobody would miss. Seam carving does the third: it removes a **seam**, a connected path of one pixel per row, threaded through the least interesting part of the picture, and does it once per column you want to lose. Everything then slides across to close the gap, so the interesting parts keep their proportions and the boring parts get squeezed out. "Interesting" needs a number, and the usual one is the **gradient magnitude**: flat regions score near zero, edges score high. That is exactly the Sobel pass *Convolution & Filters* already derives — the two weight grids are in the starter and we are not deriving them again. What changes here is two small things, and both matter later: The energy must be defined **at the border**. A seam is allowed to run straight down the edge of the picture, so column 0 needs a price like every other column — clamp the neighbour coordinates instead of painting the border black. And the kernel must work at **any width**, because after the first seam comes out the picture is 127 columns wide, then 126… so the size comes from `this.output.x` and `this.output.y`, never from a constant. **Array layout in gpu.js** Image data comes in row-major: `image[y][x]` is the pixel in row *y*, column *x*, and each pixel is an `[r, g, b, a]` array with channels from 0 to 1. Mind the inversion that catches everyone — sizes are given width-first (`output: [width, height]`), but indexing runs row-first, so this thread's own pixel is `image[this.thread.y][this.thread.x]`. Swap those two and you read the transpose of your image. Three-dimensional data follows the same rule: `output: [w, h, d]` is indexed `[z][y][x]`. ## Goal **Goal:** finish `energy` so that cell `[y][x]` holds `Math.sqrt(gx * gx + gy * gy)` for the 3×3 neighbourhood of `gray`, with every neighbour coordinate clamped to the picture. ## Requirements - Clamp all four neighbour coordinates — `Math.max(x - 1, 0)` and `Math.min(x + 1, this.output.x - 1)`, the same on `y` — so the border has an energy rather than a hole - Compute `gx` (right column minus left) and `gy` (bottom row minus top) with the two Sobel grids in the starter - Return the magnitude `Math.sqrt(gx * gx + gy * gy)` - Keep `dynamicOutput: true` and `dynamicArguments: true` — the same kernel runs at every width the picture passes through ## Hint 1 — the clamp Four one-liners, and they are the only thing standing between you and a NaN in the first and last row and column: ```js const xm = Math.max(x - 1, 0); const xp = Math.min(x + 1, this.output.x - 1); ``` — and the same pair on `y`, against `this.output.y - 1`. A pixel on the edge now simply sees itself twice, which is what "replicate the border" means. ## Hint 2 — the two sums Read them straight off the grids in the starter — right column minus left, middle row counted double: ```js const gx = (gray[ym][xp] + 2 * gray[y][xp] + gray[yp][xp]) - (gray[ym][xm] + 2 * gray[y][xm] + gray[yp][xm]); ``` `gy` is the same move on rows: bottom row minus top row, middle column counted double. ## Hint 3 — why not a constant `this.output.x` is the width of *this* launch, not of the original picture. Hard-code `127` and the kernel is right exactly once — the first time — and then quietly clamps to a column that no longer exists. ## Same idea elsewhere Every content-aware tool starts by building a cost field and only then decides what to do with it. NVIDIA's NPP and OpenCV's CUDA module both ship Sobel as a primitive; a video encoder builds the same gradient field to decide which macroblocks deserve bits; and "saliency map first, decision second" is the shape of seam carving, content-aware fill and adaptive sampling alike. ## Starter code ```js // Energy: how much does this pixel's neighbourhood change? Flat = cheap. const gpu = new GPU({ mode }); // The picture arrives as ImageData. One pass turns it into luminance // (module "Data In, Data Out" writes this one) — already done for you. const luminance = gpu.createKernel(function (image) { const p = image[this.thread.y][this.thread.x]; return 0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]; }, { output: [128, 72] }); // Sobel's two weight grids, exactly as Convolution & Filters derives them: // // Gx Gy // -1 0 +1 -1 -2 -1 // -2 0 +2 0 0 0 // -1 0 +1 +1 +2 +1 // const energy = gpu.createKernel(function (gray) { const x = this.thread.x; const y = this.thread.y; // TODO 1: clamp the four neighbour coordinates to the picture. The width // and height of THIS launch are this.output.x and this.output.y. const xm = x; const xp = x; const ym = y; const yp = y; // TODO 2: gx = right column - left column, gy = bottom row - top row, // then return Math.sqrt(gx * gx + gy * gy). return gray[y][x]; }, { output: [128, 72], dynamicOutput: true, // the picture narrows every time a seam comes out dynamicArguments: true, }); // A look at what we built: bright where the picture is busy. const paint = gpu.createKernel(function (e) { // thread.y 0 is the BOTTOM row of a canvas, so read the rows in reverse const v = e[this.output.y - 1 - this.thread.y][this.thread.x]; this.color(v, v, v, 1); }, { output: [128, 72], graphical: true }); const gray = await luminance(photo); const map = await energy(gray); await paint(map); render(paint.canvas); // A logged numeric array draws its own sparkline: this is one row of energy, // left to right — flat corridor, texture, two poles, and the face. console.log('energy across row 20:', map[20]); ``` --- Interactive version: https://gpu.rocks/learn/seam-carving-a23a0d9b/1 [Next task](https://gpu.rocks/learn/seam-carving-a23a0d9b/2.md) --- # The Cheapest Path Down: One Launch per Row *Task 2 of 6 · [Seam Carving: Content-Aware Resizing](https://gpu.rocks/learn/seam-carving-a23a0d9b.md) · GPU.js Learn* A seam is a path: one pixel per row, and from row to row it may step at most one column left or right. The cheapest such path is a two-line dynamic program. Let `M[y][x]` be the price of the cheapest seam that *ends* at pixel `(x, y)`: ```js M[0][x] = e[0][x] M[y][x] = e[y][x] + min( M[y-1][x-1], M[y-1][x], M[y-1][x+1] ) ``` Look at what that recurrence does and does not say. Cell `[y][x]` depends on row `y - 1` and on nothing else in its own row — so **every cell of a row is independent of every other cell of that row**, while the rows themselves are strictly ordered. That is the whole trick: one kernel launch per row, 128 threads wide and 71 launches deep — row 0 is free, because nothing is above it. Parallel across, sequential down. (It is the same wavefront *Wavefronts: Aligning DNA on the Diagonal* finds along an anti-diagonal — there the independent set has to be dug out; here the rows hand it to you.) One wrinkle worth knowing: each launch's output is the next launch's input, so the kernel must hand back a *fresh* buffer every call rather than recycling one. `immutable: true` is that promise, and every ping-ponging kernel in this course carries it. ## Figures - **a whole row at once, because nothing in it looks sideways** — Two rows of a cumulative cost map. Three neighbouring cells in the finished row above are marked as the only cells the highlighted cell below can have come from. Every cell of the lower row is computed at the same time; the rows go one after another. ## Goal **Goal:** write `step` — one row of the recurrence — and drive it down the picture, one awaited launch per row. ## Requirements - Cell `x` takes the smallest of `prev[x - 1]`, `prev[x]` and `prev[x + 1]`, skipping the ones that fall off the ends - Add this row's own energy: `eRow[x] + best` - Drive it in JavaScript: one `await step(...)` per row, in order — row *y* needs row *y - 1*'s answer, so never fire them together - `plot` the finished bottom row so the cost across the picture is visible ## Hint 1 — guarding the two ends Column 0 has no `x - 1` and the last column has no `x + 1`. Start from the cell directly above — which always exists — and only fold in the diagonals when they do: ```js let best = prev[x]; if (x > 0) best = Math.min(best, prev[x - 1]); if (x + 1 < this.output.x) best = Math.min(best, prev[x + 1]); ``` ## Hint 2 — the driver Row 0 is free: nothing is above it, so its cumulative cost *is* its energy. After that it is one launch per row: ```js for (let y = 1; y < energy.length; y++) { rows.push(await step(energy[y], rows[y - 1])); } ``` Await each one before launching the next. Fire them all at once and every row after the first reads a promise instead of a row. ## Same idea elsewhere "Find the axis along which the cells are independent, then launch once per step along the other one" is the whole wavefront family: CUDA's dynamic-programming samples, the banded Smith–Waterman kernels in bioinformatics, and WebGPU compute passes separated by a barrier all look like this. The launch count is the depth of the dependency chain, and that is the number you optimise. ## Starter code ```js // One launch per row. Across a row, nothing depends on anything. const gpu = new GPU({ mode }); const step = gpu.createKernel(function (eRow, prev) { const x = this.thread.x; // TODO 1: the cheapest of the three cells above — prev[x - 1], prev[x], // prev[x + 1] — with the two edge columns guarded. let best = prev[x]; // TODO 2: add this row's own energy, eRow[x], and return it. return best; }, { output: [128], immutable: true, // each call's output is the next call's input dynamicOutput: true, dynamicArguments: true, }); // Row 0 has nothing above it, so its cumulative cost is its energy. Start // from a Float32Array: gpu.js locks an argument's type on the first call, // and that is what every launch hands back. const rows = [Float32Array.from(energy[0])]; // TODO 3: one awaited launch per remaining row. // for (let y = 1; y < energy.length; y++) { … } const bottom = rows[rows.length - 1]; console.log('cheapest seam costs', Math.min(...bottom), '· dearest', Math.max(...bottom)); plot(bottom, { title: 'cumulative cost of the bottom row', xLabel: 'column' }); ``` --- Interactive version: https://gpu.rocks/learn/seam-carving-a23a0d9b/2 [Previous task](https://gpu.rocks/learn/seam-carving-a23a0d9b/1.md) · [Next task](https://gpu.rocks/learn/seam-carving-a23a0d9b/3.md) --- # Reading the Seam Back Out *Task 3 of 6 · [Seam Carving: Content-Aware Resizing](https://gpu.rocks/learn/seam-carving-a23a0d9b.md) · GPU.js Learn* The cost map now knows the price of every seam: `cost[71][x]` is what the cheapest seam ending at column `x` costs. What it does not contain is the seam. To get that you start at the cheapest cell of the bottom row and walk *upwards*, at each step moving to the cheapest of the (at most) three cells you could have come from. Here is the part worth saying out loud: **this walk does not go on the GPU.** It is 72 steps, each of which reads three numbers and picks one, and each step needs the answer to the one before it. A kernel launch costs more than the whole walk does. Knowing which part of an algorithm to leave on the host is not a compromise — it is the skill. (The same is true of the traceback in *Wavefronts: Aligning DNA on the Diagonal*: the expensive half is parallel, the cheap half is a loop.) The picture also has flat regions, and flat regions have ties. There is usually more than one cheapest seam; any of them is a correct answer, and the tests below check that the seam you produce is *optimal*, not that it is one particular optimum. ## Figures - **the cost map holds the price; the path has to be walked back out of it** — A six by four grid of cumulative costs. The cheapest cell of the bottom row, holding eight, is the start; from it a path is traced upwards, each step taking the cheapest of the three cells above, until it reaches the top row. ## Goal **Goal:** write `backtrack(cost)` so it returns one column index per row, top to bottom, tracing the cheapest seam — then `plot` it. ## Requirements - Start at the column of the smallest value in the LAST row of `cost` - Walking up, from column `x` in row `y + 1` the seam can only have come from `x - 1`, `x` or `x + 1` in row `y` — take the cheapest that exists - Return an array of `cost.length` column indices, one per row - `plot(seam, …)` — it draws the path, and it is how the tests read your answer ## Hint 1 — where the walk starts The bottom row holds the finished prices, so the cheapest seam is the one that ends at its smallest entry: ```js let x = 0; for (let i = 1; i < w; i++) if (cost[h - 1][i] < cost[h - 1][x]) x = i; ``` ## Hint 2 — the step upwards Exactly the mirror of the recurrence that built the map — the same window of three, the same two guards: ```js let best = x; if (x > 0 && cost[y][x - 1] < cost[y][best]) best = x - 1; if (x + 1 < w && cost[y][x + 1] < cost[y][best]) best = x + 1; x = best; ``` Only cells within one column of where you already are — that is what makes the result a connected seam rather than 72 unrelated minima. ## Hint 3 — direction The loop runs `for (let y = h - 2; y >= 0; y--)`. Walking the other way looks plausible and is wrong: the cumulative map was *built* downwards, so only the bottom row holds finished prices. Row 0's numbers are raw energies. ## Same idea elsewhere Every dynamic program ends this way: a parallel fill and a serial traceback. CUDA DP kernels return the score matrix and walk it on the host; production Smith–Waterman implementations do the same, and even hand back only the score when the alignment is not needed. The lesson generalises past DP — if a step is O(n) with a serial dependency and the fill was O(n²) in parallel, the launch overhead alone decides where it belongs. ## Starter code ```js // The cost map knows the price. The seam has to be read back out of it — // on the host, because 72 dependent steps is not a job for a kernel. function backtrack(cost) { const h = cost.length; const w = cost[0].length; const seam = new Array(h).fill(0); // TODO 1: find the column of the cheapest cell in the LAST row of cost, // and record it as this seam's bottom end. let x = 0; seam[h - 1] = x; // TODO 2: walk upwards, row by row. From column x you could only have come // from x - 1, x or x + 1 in the row above — take the cheapest of // those that exist, and record it in seam[y]. return seam; } const seam = backtrack(cost); // The plot IS the seam: column against row, wandering down the picture. plot(seam, { title: 'the cheapest seam: column by row', xLabel: 'row' }); console.log('ends at column', seam[seam.length - 1], '· total energy', cost[cost.length - 1][seam[seam.length - 1]]); ``` --- Interactive version: https://gpu.rocks/learn/seam-carving-a23a0d9b/3 [Previous task](https://gpu.rocks/learn/seam-carving-a23a0d9b/2.md) · [Next task](https://gpu.rocks/learn/seam-carving-a23a0d9b/4.md) --- # Take It Out, Let the Picture Close Up *Task 4 of 6 · [Seam Carving: Content-Aware Resizing](https://gpu.rocks/learn/seam-carving-a23a0d9b.md) · GPU.js Learn* Deleting the seam is a splice on a CPU: for each row, remove one element and let the rest shuffle down. On a GPU there is no splice. A thread writes its own output cell and nothing else — it cannot push its neighbour along, which is the "no scatter" rule *Thinking in Parallel* makes a whole module of. So turn the question round, which is what a gather always is. The output is one column narrower than the input. The thread that owns output cell `(x, y)` asks: which input pixel belongs *here*? Everything left of the seam has not moved. Everything from the seam rightwards has slid one column left, so it comes from `x + 1`. One `if`, no shuffling, and every row does its own thing at the same time even though every row's seam is at a different column. The output being narrower than the input is the reason for `dynamicOutput` — `carve.setOutput([w - 1, 72])` before each call, and the same kernel keeps working all the way down. ## Figures - **nobody is pushed aside — every output pixel reaches for its own source** — Eight input pixels above seven output pixels. Input three is the seam and is removed. Outputs zero to two read straight down; outputs three to six read diagonally from inputs four to seven, one column further right. ## Goal **Goal:** write `carve` so its output is `plane` with the seam pixel removed from every row, and everything to its right pulled one column left. ## Requirements - Read the seam position for THIS row: `seam[this.thread.y]` — every row removes a different column - Output cell `x` comes from `plane[y][x]` when `x < seam[y]`, and from `plane[y][x + 1]` otherwise - Keep the output one column narrower than the input ## Hint 1 — which input pixel is mine? Say the seam is at column 3 in this row. Output cells 0, 1, 2 are input cells 0, 1, 2 — nothing moved. Output cell 3 is input cell **4**: input cell 3 was the seam and is gone. Output cell 4 is input 5, and so on. ## Hint 2 — the whole kernel ```js const x = this.thread.x; const y = this.thread.y; if (x < seam[y]) return plane[y][x]; return plane[y][x + 1]; ``` Note the strict `<`. With `<=` the seam pixel survives and its right-hand neighbour is deleted instead — the picture still narrows by one, so the shapes all check out and the wrong pixel is gone. ## Same idea elsewhere Compaction by gather is the standard GPU answer to "remove some elements": a thread computes where its data comes from rather than where it goes, because destinations collide and sources never do. *Stream Compaction* builds the general version with a prefix sum; here the geometry hands you the offset for free — it is 0 or 1, decided by one comparison. CUDA's `thrust::remove_if` and WebGPU compaction passes are the same shape underneath. ## Starter code ```js // No splice on a GPU. Ask where each output pixel COMES FROM. const gpu = new GPU({ mode }); const carve = gpu.createKernel(function (plane, seam) { const x = this.thread.x; const y = this.thread.y; // TODO: which input pixel belongs in output cell (x, y)? Everything left // of this row's seam has not moved; everything from the seam rightwards // came from one column further right. return plane[y][x]; }, { output: [127, 72], immutable: true, dynamicOutput: true, dynamicArguments: true, }); const narrower = await carve(plane, seam); console.log(plane[0].length, 'columns in ·', narrower[0].length, 'columns out'); console.log('row 0 before:', plane[0]); console.log('row 0 after: ', narrower[0]); ``` --- Interactive version: https://gpu.rocks/learn/seam-carving-a23a0d9b/4 [Previous task](https://gpu.rocks/learn/seam-carving-a23a0d9b/3.md) · [Next task](https://gpu.rocks/learn/seam-carving-a23a0d9b/5.md) --- # Payoff: Thirty-Two Seams *Task 5 of 6 · [Seam Carving: Content-Aware Resizing](https://gpu.rocks/learn/seam-carving-a23a0d9b.md) · GPU.js Learn* One seam is a curiosity. Thirty-two is a resize. And the loop has a catch that matters: once a seam is gone the picture is a *different picture*, so the energy map and the cost map both have to be built again from the carved luminance — not from the original. Energy → cost → seam → carve, and round again. Everything you wrote is here already. What is left is the orchestration, and the order is the whole thing: each stage awaits the one before it, and inside the cost map the rows must go one at a time, because row *y* reads row *y - 1*'s answer. Three colour planes and the luminance plane all reflow with the same seam — the carve kernel has no idea what it is carving, which is why one kernel does all four. Two console tricks pay for themselves here. `render()` once per removal collapses into a **frame scrubber** you can drag back and forth — that is where you actually see the picture reflow. And plotting each seam's energy against its removal number gives you the curve that tells you when to stop: the cheap seams go first, and when the curve knees upwards the picture has run out of things it can afford to lose. The slider under the console re-runs the whole program, so you can carve less and compare. **Array layout in gpu.js** Image data comes in row-major: `image[y][x]` is the pixel in row *y*, column *x*, and each pixel is an `[r, g, b, a]` array with channels from 0 to 1. Mind the inversion that catches everyone — sizes are given width-first (`output: [width, height]`), but indexing runs row-first, so this thread's own pixel is `image[this.thread.y][this.thread.x]`. Swap those two and you read the transpose of your image. Three-dimensional data follows the same rule: `output: [w, h, d]` is indexed `[z][y][x]`. ## Goal **Goal:** fill in `carveOnce` — energy, cumulative cost, backtrack, then carve every plane with that one seam — and return the new planes together with the seam's price. ## Requirements - Build the energy map from the CURRENT luminance plane (`planes[3]`) at the current width — `energy.setOutput([w, 72])` - Build the cumulative rows with one awaited `step` launch per row, in order - `backtrack` the rows to a seam, and take its price from the bottom row - Carve **every** plane with that seam at `[w - 1, 72]`, and return the new planes plus the cost ## Hint 1 — the current width Nothing needs to be tracked by hand: the planes know how wide they are. ```js const w = planes[0][0].length; energy.setOutput([w, 72]); step.setOutput([w]); carve.setOutput([w - 1, 72]); ``` ## Hint 2 — the cost map, again Exactly the driver from the cumulative-cost task, over the energy map you just built: ```js const rows = [Float32Array.from(e[0])]; for (let y = 1; y < 72; y++) rows.push(await step(e[y], rows[y - 1])); ``` ## Hint 3 — carving four planes with one seam A `for` loop, not `.map()` — a callback cannot hold an `await`, and every one of these is a kernel call: ```js const next = []; for (let i = 0; i < planes.length; i++) { next.push(await carve(planes[i], seam)); } return { planes: next, cost: rows[71][seam[71]] }; ``` ## Same idea elsewhere A per-frame chain of dependent passes with a host-side loop around it is what a real pipeline looks like on every platform: CUDA streams a sequence of launches, WebGPU records passes into a command encoder, Metal encodes one compute pass per stage. And the cost of this particular shape is visible in the numbers — 71 cost-map launches per removal, well over two thousand for the whole run, each one tiny. When a wavefront gets slow it is almost never the arithmetic; it is the launch count. ## Starter code ```js // Thirty-two removals, and the picture reflows around what is left. const gpu = new GPU({ mode }); // Drag me: the program is a pure function of its controls, so moving this // re-runs the whole carve. const seams = slider('seams to remove', { min: 20, max: 32, value: 32, step: 1 }); // ImageData in, one numeric plane out: 0/1/2 are r/g/b, 3 is the luminance // the energy map is built from. Four planes that all have to reflow together. const channel = gpu.createKernel(function (image, c) { const p = image[this.thread.y][this.thread.x]; if (c === 0) return p[0]; if (c === 1) return p[1]; if (c === 2) return p[2]; return 0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]; }, { output: [128, 72] }); // Task 1: the Sobel magnitude, border-clamped, width from this.output.x. const energy = gpu.createKernel(function (gray) { const x = this.thread.x; const y = this.thread.y; const xm = Math.max(x - 1, 0); const xp = Math.min(x + 1, this.output.x - 1); const ym = Math.max(y - 1, 0); const yp = Math.min(y + 1, this.output.y - 1); const gx = (gray[ym][xp] + 2 * gray[y][xp] + gray[yp][xp]) - (gray[ym][xm] + 2 * gray[y][xm] + gray[yp][xm]); const gy = (gray[yp][xm] + 2 * gray[yp][x] + gray[yp][xp]) - (gray[ym][xm] + 2 * gray[ym][x] + gray[ym][xp]); return Math.sqrt(gx * gx + gy * gy); }, { output: [128, 72], dynamicOutput: true, dynamicArguments: true }); // Task 2: one row of the cumulative map. One launch per row. const step = gpu.createKernel(function (eRow, prev) { const x = this.thread.x; let best = prev[x]; if (x > 0) best = Math.min(best, prev[x - 1]); if (x + 1 < this.output.x) best = Math.min(best, prev[x + 1]); return eRow[x] + best; }, { output: [128], immutable: true, dynamicOutput: true, dynamicArguments: true }); // Task 4: remove the seam by gathering — one column narrower. const carve = gpu.createKernel(function (plane, seam) { const x = this.thread.x; const y = this.thread.y; if (x < seam[y]) return plane[y][x]; return plane[y][x + 1]; }, { output: [127, 72], immutable: true, dynamicOutput: true, dynamicArguments: true }); // The canvas never shrinks — the PICTURE does. Everything from column w // rightwards is painted as empty frame, so the narrowing is visible. const paint = gpu.createKernel(function (r, g, b, w) { const x = this.thread.x; // this.color() paints from the bottom up — thread.y 0 is the BOTTOM row of // the canvas, on every backend — so read the rows in reverse to put row 0 // back at the top of the picture where it belongs. const y = this.output.y - 1 - this.thread.y; if (x < w) { this.color(r[y][x], g[y][x], b[y][x], 1); } else { this.color(0.09, 0.10, 0.12, 1); } }, { output: [128, 72], graphical: true, dynamicArguments: true }); // Task 3: 72 sequential steps, three numbers each. It stays on the host. function backtrack(cost) { const h = cost.length; const w = cost[0].length; const seam = new Array(h); let x = 0; for (let i = 1; i < w; i++) if (cost[h - 1][i] < cost[h - 1][x]) x = i; seam[h - 1] = x; for (let y = h - 2; y >= 0; y--) { let best = x; if (x > 0 && cost[y][x - 1] < cost[y][best]) best = x - 1; if (x + 1 < w && cost[y][x + 1] < cost[y][best]) best = x + 1; x = best; seam[y] = x; } return seam; } // One removal: energy → cumulative cost → seam → carve every plane. async function carveOnce(planes) { const w = planes[0][0].length; // TODO 1: the energy map of the CURRENT luminance plane, planes[3], at // width w. (energy.setOutput([w, 72]) first.) // TODO 2: the cumulative rows — one awaited step launch per row, in order. // TODO 3: backtrack to a seam, and read its price off the bottom row. // TODO 4: carve every plane in `planes` with that seam, at [w - 1, 72]. return { planes, cost: 0 }; } // r, g, b and the luminance the energy is built from: four planes that all // have to reflow together. let planes = []; for (let c = 0; c < 4; c++) planes.push(await channel(photo, c)); const costs = []; await paint(planes[0], planes[1], planes[2], planes[0][0].length); render(paint.canvas); for (let k = 0; k < seams; k++) { const out = await carveOnce(planes); planes = out.planes; costs.push(out.cost); // one render() per removal — consecutive ones become a frame scrubber await paint(planes[0], planes[1], planes[2], planes[0][0].length); render(paint.canvas); } console.log('carved down to', planes[0][0].length, 'columns'); plot(costs, { title: 'energy of each seam removed', xLabel: 'removal' }); ``` --- Interactive version: https://gpu.rocks/learn/seam-carving-a23a0d9b/5 [Previous task](https://gpu.rocks/learn/seam-carving-a23a0d9b/4.md) · [Next task](https://gpu.rocks/learn/seam-carving-a23a0d9b/6.md) --- # What It Does Badly, and the Fix Everybody Ships *Task 6 of 6 · [Seam Carving: Content-Aware Resizing](https://gpu.rocks/learn/seam-carving-a23a0d9b.md) · GPU.js Learn* Drag the last task's scrubber slowly and watch the face. The eyes creep together, the head goes oval, and by the end it is a different person: every row of it wide enough to matter loses exactly seven columns, so its widest row comes back thirty columns instead of thirty-seven — and not the same seven in every row (twenty-eight different columns of the face are cut somewhere), so its features stop lining up vertically. That skew is the straight-line artefact seam carving is famous for, showing up here as a warp. Seam carving has no idea what a face is. It knows that skin is smooth and that smooth is cheap, so once the sky is used up the face is the next best bargain in the picture. The two poles, meanwhile, come through perfectly straight — every seam in this run happened to pass entirely to one side of them. That is luck, not a property, and it is the uncomfortable part: which structures survive depends on where the cheap material happens to be, and you cannot read it off the picture beforehand. This is why nothing ships it unattended. Every product that offers content-aware resize offers a brush next to it, and the brush paints a **mask**: a region whose energy gets a large constant added, so every seam routes around it. Twenty is plenty here — the dearest seam this picture has anywhere in the run prices at under nineteen, and the ones actually taken top out at fourteen, so a single protected pixel already prices a seam out of the neighbourhood. One trap comes free with the idea, and it is the reason the mask is carried in `planes` with everything else: the mask lives in *image* space. Carve the picture without carving the mask and the protection slides off the thing it was protecting, one column at a time. ## Goal **Goal:** write `maskedEnergy`, then measure the result — count the protected pixels that survived, and plot the protected run's costs against the unprotected ones. ## Requirements - `maskedEnergy` returns the Sobel magnitude *plus* `this.constants.penalty * mask[y][x]` - Count the 1s left in the carved mask (`planes[4]`) after the run and `console.log` it — it should equal what you started with - Plot both curves together: `plot({ ... })` with `unmaskedCosts` and your own `costs` ## Hint 1 — the penalty term The Sobel part is untouched; the mask is one more term on the end: ```js return Math.sqrt(gx * gx + gy * gy) + this.constants.penalty * mask[y][x]; ``` Because the mask is 0 outside the protected region, this costs the rest of the picture exactly nothing. ## Hint 2 — counting what survived `planes[4]` is the mask after the same 32 carves as the picture, so it is a plain 2D array one column narrower per removal: ```js let left = 0; for (let y = 0; y < 72; y++) { for (let x = 0; x < planes[4][y].length; x++) left += planes[4][y][x]; } ``` If that number has dropped, a seam went through the face. ## Hint 3 — two series in one chart `plot` takes an object of named series and draws them on the same axes: ```js plot({ 'no mask': unmaskedCosts, 'face protected': costs }, { title: 'what protection costs', xLabel: 'removal' }); ``` ## Same idea elsewhere Weighted energy is how this is done everywhere — Photoshop's content-aware scale takes a protect/remove mask, and the same "add a large constant to the cost field" move drives graph-cut segmentation, path planning around obstacles, and every route planner that has ever been told to avoid motorways. The deeper lesson is the honest one: an algorithm that optimises a proxy will happily destroy whatever the proxy does not measure, and the fix is always to put the missing knowledge into the objective rather than to hope. ## Starter code ```js // The same carve, with a brush stroke over the face. const gpu = new GPU({ mode }); // ImageData in, one numeric plane out: 0/1/2 are r/g/b, 3 is the luminance // the energy map is built from. Four planes that all have to reflow together. const channel = gpu.createKernel(function (image, c) { const p = image[this.thread.y][this.thread.x]; if (c === 0) return p[0]; if (c === 1) return p[1]; if (c === 2) return p[2]; return 0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]; }, { output: [128, 72] }); // The energy map, plus one term. Everything else is task 1's kernel. const maskedEnergy = gpu.createKernel(function (gray, mask) { const x = this.thread.x; const y = this.thread.y; const xm = Math.max(x - 1, 0); const xp = Math.min(x + 1, this.output.x - 1); const ym = Math.max(y - 1, 0); const yp = Math.min(y + 1, this.output.y - 1); const gx = (gray[ym][xp] + 2 * gray[y][xp] + gray[yp][xp]) - (gray[ym][xm] + 2 * gray[y][xm] + gray[yp][xm]); const gy = (gray[yp][xm] + 2 * gray[yp][x] + gray[yp][xp]) - (gray[ym][xm] + 2 * gray[ym][x] + gray[ym][xp]); // TODO 1: add this.constants.penalty * mask[y][x] to the magnitude, so a // seam that clips the protected region prices itself out. return Math.sqrt(gx * gx + gy * gy); }, { output: [128, 72], constants: { penalty: 20 }, dynamicOutput: true, dynamicArguments: true, }); // Task 2: one row of the cumulative map. One launch per row. const step = gpu.createKernel(function (eRow, prev) { const x = this.thread.x; let best = prev[x]; if (x > 0) best = Math.min(best, prev[x - 1]); if (x + 1 < this.output.x) best = Math.min(best, prev[x + 1]); return eRow[x] + best; }, { output: [128], immutable: true, dynamicOutput: true, dynamicArguments: true }); // Task 4: remove the seam by gathering — one column narrower. const carve = gpu.createKernel(function (plane, seam) { const x = this.thread.x; const y = this.thread.y; if (x < seam[y]) return plane[y][x]; return plane[y][x + 1]; }, { output: [127, 72], immutable: true, dynamicOutput: true, dynamicArguments: true }); // The canvas never shrinks — the PICTURE does. Everything from column w // rightwards is painted as empty frame, so the narrowing is visible. const paint = gpu.createKernel(function (r, g, b, w) { const x = this.thread.x; // this.color() paints from the bottom up — thread.y 0 is the BOTTOM row of // the canvas, on every backend — so read the rows in reverse to put row 0 // back at the top of the picture where it belongs. const y = this.output.y - 1 - this.thread.y; if (x < w) { this.color(r[y][x], g[y][x], b[y][x], 1); } else { this.color(0.09, 0.10, 0.12, 1); } }, { output: [128, 72], graphical: true, dynamicArguments: true }); // Task 3: 72 sequential steps, three numbers each. It stays on the host. function backtrack(cost) { const h = cost.length; const w = cost[0].length; const seam = new Array(h); let x = 0; for (let i = 1; i < w; i++) if (cost[h - 1][i] < cost[h - 1][x]) x = i; seam[h - 1] = x; for (let y = h - 2; y >= 0; y--) { let best = x; if (x > 0 && cost[y][x - 1] < cost[y][best]) best = x - 1; if (x + 1 < w && cost[y][x + 1] < cost[y][best]) best = x + 1; x = best; seam[y] = x; } return seam; } async function carveOnce(planes) { const w = planes[0][0].length; maskedEnergy.setOutput([w, 72]); const e = await maskedEnergy(planes[3], planes[4]); step.setOutput([w]); const rows = [Float32Array.from(e[0])]; for (let y = 1; y < 72; y++) rows.push(await step(e[y], rows[y - 1])); const seam = backtrack(rows); const cost = rows[71][seam[71]]; carve.setOutput([w - 1, 72]); const next = []; for (let i = 0; i < planes.length; i++) next.push(await carve(planes[i], seam)); return { planes: next, cost }; } // Five planes now: r, g, b, luminance — and the mask, which lives in image // space and so has to reflow with everything else. let planes = []; for (let c = 0; c < 4; c++) planes.push(await channel(photo, c)); planes.push(faceMask); let protectedBefore = 0; for (let y = 0; y < 72; y++) for (let x = 0; x < 128; x++) protectedBefore += faceMask[y][x]; const costs = []; await paint(planes[0], planes[1], planes[2], planes[0][0].length); render(paint.canvas); for (let k = 0; k < 32; k++) { const out = await carveOnce(planes); planes = out.planes; costs.push(out.cost); await paint(planes[0], planes[1], planes[2], planes[0][0].length); render(paint.canvas); } console.log('carved down to', planes[0][0].length, 'columns'); console.log('protected pixels before:', protectedBefore); // TODO 2: count the 1s left in the carved mask, planes[4], and log it. // TODO 3: plot unmaskedCosts and costs on the same axes. ``` --- Interactive version: https://gpu.rocks/learn/seam-carving-a23a0d9b/6 [Previous task](https://gpu.rocks/learn/seam-carving-a23a0d9b/5.md) --- # Template Matching *Module of the free GPU.js GPGPU course · 5 tasks* Finding a patch in a picture — and why a raw difference score is fooled by a light switch. ## Tasks 1. [Score Every Position at Once](https://gpu.rocks/learn/template-matching-f57b4bed/1.md) 2. [The Score That Lies](https://gpu.rocks/learn/template-matching-f57b4bed/2.md) 3. [Normalize It](https://gpu.rocks/learn/template-matching-f57b4bed/3.md) 4. [Hoist What Never Changes](https://gpu.rocks/learn/template-matching-f57b4bed/4.md) 5. [Payoff: Present or Absent?](https://gpu.rocks/learn/template-matching-f57b4bed/5.md) --- Interactive version: https://gpu.rocks/learn/template-matching-f57b4bed --- # Score Every Position at Once *Task 1 of 5 · [Template Matching](https://gpu.rocks/learn/template-matching-f57b4bed.md) · GPU.js Learn* Template matching asks the simplest question in object finding: **where in this picture is that patch?** Slide the patch over every position it could occupy, score how well it fits at each one, and keep the best. That sounds like a loop, and on a GPU it is the exact opposite of a loop — every candidate position is scored from data alone, with no reference to any other position. One thread per position, all 7,921 of them at once. The obvious score is the **sum of squared differences**. Line the 8×8 template up with its top-left corner at (x, y), subtract it from the scene pixel by pixel, square each difference so a positive cannot cancel a negative, and add them up. Zero is a perfect match; bigger is worse. ```js d = scene[y + j][x + i] − patch[j][i] SSD = sum of d² over the 8×8 window ``` One thing to settle before you write a line: **the score map is smaller than the scene**. A window whose corner sits at column 89 would need columns 89…96, and this scene stops at 95. The last legal corner is 88, so there are 96 − 8 + 1 = **89** positions along each axis, and the map is 89×89. `scene` here is a luminance map — one number per pixel, the kind a grayscale pass hands you — but it is indexed like any other image. **Array layout in gpu.js** Image data comes in row-major: `image[y][x]` is the pixel in row *y*, column *x*, and each pixel is an `[r, g, b, a]` array with channels from 0 to 1. Mind the inversion that catches everyone — sizes are given width-first (`output: [width, height]`), but indexing runs row-first, so this thread's own pixel is `image[this.thread.y][this.thread.x]`. Swap those two and you read the transpose of your image. Three-dimensional data follows the same rule: `output: [w, h, d]` is indexed `[z][y][x]`. ## Figures - **one thread per position — and a map that comes out smaller than the scene** ## Goal **Goal:** build the 89×89 SSD score map for `patch` over `scene`, and log the position of the best match. ## Requirements - Set `output` to the number of candidate positions — `96 − 8 + 1` per axis, not 96 - Sum over the whole template with a double loop bounded by `this.constants.size` - Square every difference: `const d = …; sum += d * d;` - `console.log` the position `bestMatch()` returns ## Hint 1 — which window is mine? Thread (x, y) owns the window whose *top-left corner* is at `scene[y][x]`. Its pixels are `scene[y + j][x + i]` for `j` and `i` from 0 to 7 — and those same `j`, `i` index the template as `patch[j][i]`. No clamping is needed anywhere: the output shape already guarantees every read is in bounds. ## Hint 2 — the loop body ```js const d = scene[y + j][x + i] - patch[j][i]; sum += d * d; ``` — two statements, inside two nested `for` loops that both run to `this.constants.size`. ## Hint 3 — the whole kernel ```js const x = this.thread.x; const y = this.thread.y; let sum = 0; for (let j = 0; j < this.constants.size; j++) { for (let i = 0; i < this.constants.size; i++) { const d = scene[y + j][x + i] - patch[j][i]; sum += d * d; } } return sum; ``` — and `output: [89, 89]`. ## Same idea elsewhere This is OpenCV's `matchTemplate` and NVIDIA NPP's `nppiSQRDistanceNorm`, and it is one of the friendliest workloads a GPU ever sees: no communication between threads, no atomics, perfectly regular reads, and neighbouring threads reading overlapping windows straight out of cache. A WGSL compute shader or a CUDA 2D block does it with the same two nested loops. ## Starter code ```js // One thread per candidate position. 89 × 89 = 7,921 of them. const gpu = new GPU({ mode }); const ssd = gpu.createKernel(function (scene, patch) { const x = this.thread.x; const y = this.thread.y; // TODO: sum (scene[y + j][x + i] - patch[j][i])² over the whole // this.constants.size × this.constants.size template. return 0; }, { // TODO: 88 is wrong. How many top-left corners actually fit? output: [88, 88], constants: { size: 8 }, }); // Scanning 7,921 scores in JavaScript is not the lesson here — Reductions and // Top-K Selection do exactly this on the GPU, in parallel, and properly. function bestMatch(map) { let best = Infinity; let bx = 0; let by = 0; for (let y = 0; y < map.length; y++) { for (let x = 0; x < map[y].length; x++) { if (map[y][x] < best) { best = map[y][x]; bx = x; by = y; } } } return { x: bx, y: by, score: best }; } const map = await ssd(scene, patch); const hit = bestMatch(map); console.log('best match at x =', hit.x, ' y =', hit.y, ' score =', hit.score); ``` --- Interactive version: https://gpu.rocks/learn/template-matching-f57b4bed/1 [Next task](https://gpu.rocks/learn/template-matching-f57b4bed/2.md) --- # The Score That Lies *Task 2 of 5 · [Template Matching](https://gpu.rocks/learn/template-matching-f57b4bed.md) · GPU.js Learn* Now break it. `brightScene` is the same scene photographed in brighter light: every value 0.28 higher, nothing moved, nothing changed shape. The patch is still exactly where it was. Run the same kernel over it — the kernel is not what is wrong here — and the best match walks off to a completely different place. Here is why, in one line of algebra. Add δ to every scene value and the score at a window becomes ```js SSD(w + δ, t) = SSD(w, t) + 2δ · sum(wᵢ − tᵢ) + n · δ² ``` At the true match the pixels agree, so `sum(wᵢ − tᵢ)` is zero and there is nothing to offset the last term: a *perfect* match now scores `64 × 0.28² = 5.02`. Meanwhile any window that is **darker** than the template has a negative `sum(wᵢ − tᵢ)`, and the middle term pays it a discount. Somewhere in this scene sits a patch that is dark and matches badly; brighten the picture and its discount beats a perfect match outright. That is the whole lesson of this module, and it is not really about vision. SSD is not a measure of similarity — it is a measure of **distance in absolute value**, and every camera, every light, every exposure, every gain setting moves absolute values around. A score that cannot tell "brighter" from "different" will confidently point at the wrong thing. ## Figures - **the same two windows, before and after somebody turned the lights up** ## Goal **Goal:** score both scenes with the same SSD kernel and show the damage — log where each one thinks the patch is, and the two bright-scene scores that explain it. ## Requirements - Score `scene` and `brightScene` with the same kernel - `console.log` the best position on each map — they disagree - From the bright map, `console.log` the score at the true position and the score the winner got — the winner's is smaller ## Hint 1 — nothing about the kernel changes Same kernel, called twice. `brightScene` has exactly the same shape as `scene`, so the second call costs you one line. ## Hint 2 — reading a known cell The map is indexed `map[y][x]`, so the score the bright map gives the true position is `brightMap[TRUE_Y][TRUE_X]`. Compare it against `bestMatch(brightMap).score`. ## Same idea elsewhere Every practitioner meets this wall. It is why OpenCV ships `TM_CCOEFF_NORMED` alongside `TM_SQDIFF`, why stereo matchers use census transforms or rank filters instead of raw differences, and why "we normalised the inputs and the model started working" is the most common debugging story in machine learning. A raw difference is a distance in whatever units the sensor happened to produce. ## Starter code ```js // Same kernel, two scenes. The kernel is not what is wrong here. const gpu = new GPU({ mode }); const ssd = gpu.createKernel(function (scene, patch) { const x = this.thread.x; const y = this.thread.y; let sum = 0; for (let j = 0; j < this.constants.size; j++) { for (let i = 0; i < this.constants.size; i++) { const d = scene[y + j][x + i] - patch[j][i]; sum += d * d; } } return sum; }, { output: [89, 89], constants: { size: 8 }, }); function bestMatch(map) { let best = Infinity; let bx = 0; let by = 0; for (let y = 0; y < map.length; y++) { for (let x = 0; x < map[y].length; x++) { if (map[y][x] < best) { best = map[y][x]; bx = x; by = y; } } } return { x: bx, y: by, score: best }; } // Where the patch really is — task 1 found it. const TRUE_X = 58; const TRUE_Y = 21; const plainMap = await ssd(scene, patch); const brightMap = await ssd(brightScene, patch); // TODO: log the best position on each map. // TODO: log brightMap's score at the true position, and the score its winner // got. The winner's is smaller — that is the failure, in two numbers. ``` --- Interactive version: https://gpu.rocks/learn/template-matching-f57b4bed/2 [Previous task](https://gpu.rocks/learn/template-matching-f57b4bed/1.md) · [Next task](https://gpu.rocks/learn/template-matching-f57b4bed/3.md) --- # Normalize It *Task 3 of 5 · [Template Matching](https://gpu.rocks/learn/template-matching-f57b4bed.md) · GPU.js Learn* The fix is to stop comparing brightness and start comparing *shape*. Subtract each window's own mean, subtract the template's mean, and divide by how much each of them varies. What survives is **normalized cross-correlation**: ```js cov = sum( (wᵢ − w̄) · (tᵢ − t̄) ) varW = sum( (wᵢ − w̄)² ) varT = sum( (tᵢ − t̄)² ) NCC = cov / sqrt(varW · varT) ``` Subtracting the means removes anything *added* to the light; dividing by the spreads removes anything the light was *multiplied* by. The result is bounded: `+1` is a perfect match, `0` is no relationship at all, and `−1` is a perfect *anti*-match — the same shape with its lights and darks swapped. That bound is a gift, because a score that leaves −1…1 is proof the arithmetic is wrong. Written that way it looks like three passes over the window: one to find the means, one for the spreads, one for the product. It is not. Every one of those three quantities is a sum over the same 64 pixels the thread is already reading, and two schoolbook identities turn all three into plain running totals: ```js cov = sumWT − sumW · sumT / n varW = sumW2 − sumW · sumW / n varT = sumT2 − sumT · sumT / n ``` So the thread keeps five accumulators — `sumW`, `sumW2`, `sumT`, `sumT2`, `sumWT` — fills them in one pass, and assembles the score after the loop. Five running totals, one divide, no mean subtracted from anything explicitly. (A window with no variation at all would put a zero in that denominator; production code adds a tiny epsilon for it. Nothing in this scene is flat, so the plain formula is safe here.) ## Figures - **same scene, same patch, two scores — only one of them is looking at the shape** ## Goal **Goal:** build the 89×89 NCC map over `brightScene` and log the winning position and its score. The match snaps back to where the patch really is. ## Requirements - Accumulate `sumW`, `sumW2`, `sumT`, `sumT2` and `sumWT` in one pass over the window - Assemble `cov`, `varW` and `varT` with the identities above - Return `cov / Math.sqrt(varW * varT)` — a square root of the product, not the product - NCC is a similarity, so `bestMatch()` has to keep the **largest** score ## Hint 1 — five accumulators, one loop Declare all five before the loops and add to each one inside: ```js const w = scene[y + j][x + i]; const t = patch[j][i]; sumW += w; sumW2 += w * w; sumT += t; sumT2 += t * t; sumWT += w * t; ``` ## Hint 2 — assembling the score ```js const n = this.constants.count; const cov = sumWT - (sumW * sumT) / n; const varW = sumW2 - (sumW * sumW) / n; const varT = sumT2 - (sumT * sumT) / n; return cov / Math.sqrt(varW * varT); ``` — note that `varW` and `varT` here are the sums of squared deviations, not the sums divided by `n`. Dividing both by `n` would cancel out of the ratio anyway, so there is no point paying for it. ## Hint 3 — the other half of the change Task 1's `bestMatch` kept the smallest score, because SSD was a distance. NCC is a similarity: `if (map[y][x] > best)`, starting from `-Infinity`. Leave it as a minimum and this map will hand you its most spectacularly wrong position instead of its right one. ## Same idea elsewhere Normalising before you compare is one of the most portable ideas in computing. It is `TM_CCOEFF_NORMED` in OpenCV and `nppiCrossCorrValid_NormLevel` in CUDA's NPP; it is cosine similarity over centred vectors in every retrieval system; it is the Pearson correlation in statistics; and it is exactly what a batch-norm or layer-norm layer does inside a neural network, for exactly the same reason — so that what comes next responds to structure instead of to scale. ## Starter code ```js // Same 7,921 threads. A score that brightness cannot move. const gpu = new GPU({ mode }); const ncc = gpu.createKernel(function (scene, patch) { const x = this.thread.x; const y = this.thread.y; let sumW = 0; let sumW2 = 0; let sumT = 0; let sumT2 = 0; let sumWT = 0; // TODO: one pass over the 8×8 window, filling all five accumulators. // TODO: assemble cov, varW and varT with the two identities, then // return cov / Math.sqrt(varW * varT). return 0; }, { output: [89, 89], constants: { size: 8, count: 64 }, }); function bestMatch(map) { let best = map[0][0]; let bx = 0; let by = 0; for (let y = 0; y < map.length; y++) { for (let x = 0; x < map[y].length; x++) { // TODO: NCC is a similarity — keep the LARGER score, not the smaller. if (map[y][x] < best) { best = map[y][x]; bx = x; by = y; } } } return { x: bx, y: by, score: best }; } const map = await ncc(brightScene, patch); const hit = bestMatch(map); console.log('best match at x =', hit.x, ' y =', hit.y, ' score =', hit.score); ``` --- Interactive version: https://gpu.rocks/learn/template-matching-f57b4bed/3 [Previous task](https://gpu.rocks/learn/template-matching-f57b4bed/2.md) · [Next task](https://gpu.rocks/learn/template-matching-f57b4bed/4.md) --- # Hoist What Never Changes *Task 4 of 5 · [Template Matching](https://gpu.rocks/learn/template-matching-f57b4bed.md) · GPU.js Learn* Look again at what those 7,921 threads just did. Two of the five running totals — `sumT` and `sumT2` — depend only on the template. Every thread walked the same 64 template values, arrived at the same two numbers, used them once and threw them away. That is 7,920 calculations too many. So do it once, in JavaScript, before the kernel runs — and do it in the shape the kernel actually wants: the template with its mean already subtracted, plus the length of that centred template. ```js patchMean = (sum of patch) / 64 patchCentered[j][i] = patch[j][i] − patchMean patchNorm = sqrt(sum of patchCentered²) ``` That simplifies the numerator too. Once the centred values sum to zero, `sum((wᵢ − w̄) · cᵢ)` equals `sum(wᵢ · cᵢ)` — the window's own mean cancels itself out and never has to be subtracted from anything. The thread drops from five accumulators to three and from two square roots to one: ```js varW = sumW2 − sumW · sumW / n NCC = sumWC / ( sqrt(varW) · patchNorm ) ``` Press **Benchmark** before and after and watch the difference. Hoisting loop-invariant work out of a loop is the oldest optimisation there is; what makes it worth a task is that a GPU multiplies the saving by the thread count, so the same three lines buy far more here than they would in a `for` loop. There is a bigger version of this idea that this module deliberately does *not* build. `sumW` and `sumW2` can also be precomputed — for the entire scene, once — as **integral images** (summed-area tables): a table where each cell holds the sum of everything above and to the left of it, so any rectangle's total costs four lookups and three subtractions no matter how large the rectangle is. That is genuinely how large-template matching is done at scale. It is also a two-dimensional prefix sum, which is a module of its own — Prefix Sums (Scan) builds the one-dimensional version — and at 8×8 those four lookups would replace 64 reads this thread is making anyway. The win arrives when the template is 64×64, and so does the module. ## Goal **Goal:** compute the template's statistics once in JavaScript, pass them in, and get the same NCC map from a kernel that does strictly less work per thread. ## Requirements - Compute `patchMean`, `patchCentered` and `patchNorm` in plain JavaScript, outside the kernel - The kernel takes exactly three arguments — `(scene, centered, norm)` - Keep three accumulators: `sumW`, `sumW2` and `sumWC` - Same answer as before — log the winning position and its score ## Hint 1 — centring the template Two passes over 64 values, in ordinary JavaScript: ```js let sum = 0; for (let j = 0; j < 8; j++) { for (let i = 0; i < 8; i++) sum += patch[j][i]; } const patchMean = sum / 64; ``` then build `patchCentered` as `patch[j][i] - patchMean`, accumulating the squares into `patchNorm` as you go — and take the square root at the end. ## Hint 2 — the shorter loop body ```js const w = scene[y + j][x + i]; sumW += w; sumW2 += w * w; sumWC += w * centered[j][i]; ``` — no `sumT`, no `sumT2`, and nothing to subtract from `sumWC`. ## Hint 3 — the return ```js const varW = sumW2 - (sumW * sumW) / this.constants.count; return sumWC / (Math.sqrt(varW) * norm); ``` — `norm` is a plain number argument; gpu.js is perfectly happy passing scalars alongside arrays. ## Same idea elsewhere Every mature matcher does this. OpenCV precomputes the template's sum and sum-of-squares once inside `matchTemplate`; cuDNN and MIOpen hoist per-filter constants out of every convolution launch; CUDA programmers park exactly this kind of small, read-only, uniformly-accessed data in `__constant__` memory, and WGSL puts it in a uniform buffer. The rule is the same everywhere: anything that does not vary with the thread index does not belong inside the thread. ## Starter code ```js // The template's statistics are the same at all 7,921 positions. // Compute them once, here, and hand the kernel the finished numbers. const gpu = new GPU({ mode }); // TODO: the template's mean; the template with that mean subtracted; // and the length of the centred template. const patchMean = 0; const patchCentered = patch; const patchNorm = 1; const ncc = gpu.createKernel(function (scene, centered, norm) { const x = this.thread.x; const y = this.thread.y; let sumW = 0; let sumW2 = 0; let sumWC = 0; // TODO: one pass over the window — the window's sum, its sum of squares, // and its dot product with centered[j][i]. const varW = sumW2 - (sumW * sumW) / this.constants.count; return sumWC / (Math.sqrt(varW) * norm); }, { output: [89, 89], constants: { size: 8, count: 64 }, }); function bestMatch(map) { let best = -Infinity; let bx = 0; let by = 0; for (let y = 0; y < map.length; y++) { for (let x = 0; x < map[y].length; x++) { if (map[y][x] > best) { best = map[y][x]; bx = x; by = y; } } } return { x: bx, y: by, score: best }; } const hit = bestMatch(await ncc(brightScene, patchCentered, patchNorm)); console.log('best match at x =', hit.x, ' y =', hit.y, ' score =', hit.score); ``` --- Interactive version: https://gpu.rocks/learn/template-matching-f57b4bed/4 [Previous task](https://gpu.rocks/learn/template-matching-f57b4bed/3.md) · [Next task](https://gpu.rocks/learn/template-matching-f57b4bed/5.md) --- # Payoff: Present or Absent? *Task 5 of 5 · [Template Matching](https://gpu.rocks/learn/template-matching-f57b4bed.md) · GPU.js Learn* Everything so far, pointed at a real question. `brightScene` hides an 8×8 patch; `patch` is that patch. `rotatedPatch` is the same eight by eight values turned a quarter turn — identical mean, identical spread, identical histogram, and **nowhere in the scene**. Find the one; refuse the other. The kernel is finished (it is task 4's, unchanged). What is left is the part that catches people twice: reading an answer off a score map. **Take the maximum.** NCC is a similarity, so its best is its largest. SSD was a distance, so its best was its smallest. The convention is inverted between the two measures, and reaching for the wrong one does not give you a slightly worse answer — it gives you the map's most emphatically *wrong* position. **The coordinates are the window's top-left corner.** Cell (x, y) scored the window that starts at (x, y) and runs 8 pixels right and down. That corner is the answer. The centre is `(x + 4, y + 4)` if that is what you want — just be sure you know which one you are reporting, because the score map is 89 wide where the scene is 96, and quietly mixing the two coordinate systems is how a detector ends up drawing boxes in the wrong place. And then the honest part. A search over 7,921 positions *always* returns a winner — the best score is a best score whether or not anything is there. What turns matching into **detection** is a **threshold**: a line below which "the best I found" means "nothing". Here the patch that is present scores 1.000 and the one that is absent tops out near 0.47, so 0.9 separates them with room to spare. That number is not universal — it depends on the noise, on the template, and on how much deformation you are willing to forgive — and calibrating it against data whose answers you already know is most of the work in building a real detector. ## Goal **Goal:** report where `patch` is, report that `rotatedPatch` is not there, and let `THRESHOLD` be what decides. ## Requirements - Score both templates against `brightScene` with the same kernel - Finish `bestMatch`: scan for the **largest** score and return the window's top-left corner - `console.log` the position found for `patch`, and the best score each template managed - For each template, `console.log` whether its best score clears `THRESHOLD` — one `true`, one `false` ## Hint 1 — the scan Start from `-Infinity` and keep the larger: ```js let best = -Infinity; let bx = 0; let by = 0; for (let y = 0; y < map.length; y++) { for (let x = 0; x < map[y].length; x++) { if (map[y][x] > best) { best = map[y][x]; bx = x; by = y; } } } ``` `bx` and `by` are already the corner — no offset to add. ## Hint 2 — the verdict `prepare()` hands the kernel what task 4 built, so each report is three lines: ```js const map = await ncc(brightScene, t.centered, t.norm); const hit = bestMatch(map); console.log(label, hit.x, hit.y, hit.score, hit.score >= THRESHOLD); ``` ## Same idea elsewhere Thresholding a similarity map is the last mile of nearly every classical detector — Viola-Jones cascades, ORB and SIFT keypoint matching with Lowe's ratio test, stereo correspondence rejecting low-confidence disparities — and it survives into modern ones as the confidence score on every bounding box a neural network emits. The score tells you which position is most like the template; only a threshold tells you whether the template is there at all. ## Starter code ```js // The finished matcher. Two templates: one is in the scene, one is not. const gpu = new GPU({ mode }); const THRESHOLD = 0.9; const ncc = gpu.createKernel(function (scene, centered, norm) { const x = this.thread.x; const y = this.thread.y; let sumW = 0; let sumW2 = 0; let sumWC = 0; for (let j = 0; j < this.constants.size; j++) { for (let i = 0; i < this.constants.size; i++) { const w = scene[y + j][x + i]; sumW += w; sumW2 += w * w; sumWC += w * centered[j][i]; } } const varW = sumW2 - (sumW * sumW) / this.constants.count; return sumWC / (Math.sqrt(varW) * norm); }, { output: [89, 89], constants: { size: 8, count: 64 }, }); // Task 4, packaged: any template in, the two numbers the kernel wants out. function prepare(template) { let sum = 0; for (let j = 0; j < 8; j++) { for (let i = 0; i < 8; i++) sum += template[j][i]; } const mean = sum / 64; const centered = []; let normSq = 0; for (let j = 0; j < 8; j++) { const row = []; for (let i = 0; i < 8; i++) { const c = template[j][i] - mean; row.push(c); normSq += c * c; } centered.push(row); } return { centered: centered, norm: Math.sqrt(normSq) }; } function bestMatch(map) { // TODO: scan the whole map for its LARGEST score, and return the (x, y) // it came from — that (x, y) is the window's top-left corner. return { x: 0, y: 0, score: map[0][0] }; } async function report(label, template) { const t = prepare(template); const hit = bestMatch(await ncc(brightScene, t.centered, t.norm)); // TODO: log the label, the position, the score, and whether the score // clears THRESHOLD. } await report('patch: ', patch); await report('rotatedPatch: ', rotatedPatch); ``` --- Interactive version: https://gpu.rocks/learn/template-matching-f57b4bed/5 [Previous task](https://gpu.rocks/learn/template-matching-f57b4bed/4.md) --- # Optical Flow *Module of the free GPU.js GPGPU course · 5 tasks* Per-pixel motion between two frames: the aperture problem, a 2×2 least-squares solve per thread, and knowing when not to believe the answer. ## Tasks 1. [One Equation, Two Unknowns](https://gpu.rocks/learn/optical-flow-e85c6dfa/1.md) 2. [The Aperture Problem](https://gpu.rocks/learn/optical-flow-e85c6dfa/2.md) 3. [Lucas–Kanade: Buy a Second Equation](https://gpu.rocks/learn/optical-flow-e85c6dfa/3.md) 4. [Which Answers to Believe](https://gpu.rocks/learn/optical-flow-e85c6dfa/4.md) 5. [Paint the Flow Field](https://gpu.rocks/learn/optical-flow-e85c6dfa/5.md) --- Interactive version: https://gpu.rocks/learn/optical-flow-e85c6dfa --- # One Equation, Two Unknowns *Task 1 of 5 · [Optical Flow](https://gpu.rocks/learn/optical-flow-e85c6dfa.md) · GPU.js Learn* Optical flow asks a simple-sounding question: for every pixel of frame 1, where did it go in frame 2? The only assumption anyone can make is **brightness constancy** — a moving point keeps its intensity, it just shows up somewhere else. Write that down and expand it to first order and you get one equation per pixel: ```js Ix·u + Iy·v + It = 0 ``` `Ix` and `Iy` are the spatial gradients — the same central differences the Sobel pass in Convolution & Filters is built from — and `It` is how much this pixel's intensity changed between the frames. `u` and `v` are what you want, and there is only one equation for the two of them; every method in this module is a different way of buying a second. One subtlety first, though: all three derivatives have to describe the *same instant*, the moment halfway between the frames. `It` naturally sits there, so the spatial gradients are measured on the **average of the two frames**. Take them from frame 1 alone and the answers scatter — on these frames the typical error goes from about 0.03 pixels to about 0.19. The frames arrive as task inputs rather than from a camera, and that is a wall rather than a shortcut: your code runs inside a Web Worker, which has no `navigator.mediaDevices`, no `getUserMedia` and no `