Task 6 of 6

The Two-Pass Blur

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

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

All tasks in Thinking in Parallel

  1. Map: One Thread, One Value
  2. Gather: Read Anywhere
  3. No Scatter Allowed
  4. Life on the Edge
  5. Smooth a Signal
  6. The Two-Pass Blur

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