Task 6 of 6

Payoff: Five Stages, Zero Round Trips

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: 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

Hint 1 — the chain, stage by stage
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.

All tasks in The Canny Edge Pipeline

  1. Blur First: a Separable Gaussian
  2. Magnitude, and the Angle Nobody Mentions
  3. Non-Maximum Suppression
  4. Strong, Weak, Gone
  5. Hysteresis: Run It Until Nothing Changes
  6. Payoff: Five Stages, Zero Round Trips

This page is an interactive exercise — the editor, the GPU runner and your saved progress need JavaScript. The text above is the full brief.