# 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)
