Task 6 of 6
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.
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].
immutable: true so it can eat its own output, and wire the
nine stages into a chain that runs grow 64 times.pipeline: true to all eight intermediate kernels; leave finish plain — its return is the one readback you wantimmutable: true to grow, which reads the texture it is writingmagnitude and direction the smoothed map, not the raw luminancegrow exactly PASSES times, then log console.log('edge pixels:', count)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.
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.
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.
This page is an interactive exercise — the editor, the GPU runner and your saved progress need JavaScript. The text above is the full brief.