Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Computer Graphics (rasterisation, ray tracing, WebGPU)  Module 9 – GPU programming with WebGPU and WGSL

How GPUs work: SIMT, memory and tiling

How GPUs run threads in lockstep groups, why a split branch costs both sides, how many groups hide a memory read, and what tile-based GPUs make cheap.

  • Intermediate
  • 25 minutes
  • Examples run with Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Explain how a GPU runs a group of threads in lockstep and what a branch that splits the group costs
  • Estimate how many groups a core needs in flight to hide a memory read
  • Classify a pass as limited by arithmetic or by memory bandwidth from its operations and bytes
  • Contrast immediate-mode and tile-based GPUs and choose load and store operations for each attachment

Before you start

What you’ll learn · Before you start

What you will learn

  • Explain how a GPU runs a group of threads in lockstep and what a branch that splits the group costs
  • Estimate how many groups a core needs in flight to hide a memory read
  • Classify a pass as limited by arithmetic or by memory bandwidth from its operations and bytes
  • Contrast immediate-mode and tile-based GPUs and choose load and store operations for each attachment

Before you start

On this page

A 1920 × 1080 frame has 2,073,600 pixels, and at 60 frames a second a GPU has 16.7 ms to shade every one of them, often several times over once passes and overlapping triangles are counted. It manages by running the same small program on thousands of pixels at once, in groups that move in step, and by switching to another group whenever one has to wait for memory.

The sections below run a model of one such group through a branch that splits it, measure how many groups a core needs to hide a slow read, decide whether a pass is limited by arithmetic or by memory bandwidth, and follow one pass through a tile-based GPU, where the load and store operations of a render pass decide most of its memory traffic. Every number comes from a program on this page.

One instruction drives a whole group of threads

A fragment shader runs once for every pixel a triangle covers: 2,073,600 times for a full-screen pass at 1920 × 1080. A CPU core fetches, decodes and schedules the instructions of each thread on its own. A GPU does that bookkeeping once for a whole group of threads and then executes the instruction on all of them at the same moment, each on its own data. NVIDIA’s CUDA Programming Guide calls such a group a warp, 32 threads that move through the program in lockstep, and the model single instruction, multiple threads: SIMT (single instruction, multiple threads): Running one instruction at a time for a whole group of threads in lockstep, each thread on its own data. Threads may still take different paths through a branch, at the cost of running every path.. WGSL, the shading language of WebGPU, calls the group a subgroup: WGSL's name for a group of invocations that a GPU runs together in lockstep, what NVIDIA calls a warp. Its size is a power of two from 4 to 128 and depends on the device.. Its size is a power of two from 4 to 128 and depends on the device, and WebGPU reports the range as the adapter’s subgroupMinSize and subgroupMaxSize.

The figure shows one group of eight lane: One thread's place in a group that runs in lockstep: the part of the hardware that runs that thread's copy of each instruction, on that thread's own data., the size this lesson’s model uses so that every lane fits on one line. The unit fetches and decodes load x once, and each lane receives its own value:

An instruction unit above eight lanes: it fetches load x once, and each lane holds its own x, from 3 in lane 0 to 2 in lane 7.One instruction, eight lanesInstruction unitfetches and decodes load x once3091427314856627lane number; each box is that lane’s own x1 fetch and decode, 8 results

One fetch and decode serve every lane of the group

Text description of the diagram

At the top, one box is the instruction unit. It fetches and decodes the instruction load x a single time.

Eight thin lines fan out from it to a row of eight boxes, the lanes of one group, numbered 0 to 7 from the left. Each lane box holds that lane's own value of x, the same values as run 2 of the lesson's model: 3, 9, 4, 7, 1, 8, 6 and 2.

The note at the bottom says that one fetch and decode gives eight results: the same instruction runs on eight values at once.

Sharing the fetch and the decode is what lets a GPU spend its chip on arithmetic: a group of 32 does 32 additions for the bookkeeping of one, and a full-screen pass at 1920 × 1080 needs only 2,073,600 ÷ 32 = 64,800 groups. Fatahalian and Houston’s survey of GPU design puts this idea next to the one in the section after next, many groups kept in flight. The price of sharing is a rule: every lane of a group runs the same instruction at the same time, so what one lane needs, the whole group waits for.

A branch that splits a group runs both sides

Give the eight lanes the values of the figure, 3, 9, 4, 7, 1, 8, 6 and 2, and the shader if (x > 4) { light } else { shadow }, where lighting takes four instructions and shadow three. Lanes 1, 3, 5 and 6 hold values above 4 and the other four do not, so no single path suits the group. It runs both sides, one after the other, with an execution mask: One bit per lane of a group that says whether the lane runs the current instruction. Masked lanes keep their values and do nothing while the group runs a path they did not take. that switches off the lanes not on the side being run: NVIDIA’s guide says the threads that do not follow a branch are masked off while the others execute. Step through it:

Interactive · step by step One group of eight lanes meets a branch Step through the shader of run 2. Watch the mask decide which lanes compute, and count the instructions the group issues.
One group of eight lanes meets a branch, step 6 of 6. Showing Instruction unit, Mask (########), Lane 0: x = 3 (stored), Lane 1: x = 9 (stored), Lane 2: x = 4 (stored), Lane 3: x = 7 (stored), Lane 4: x = 1 (stored), Lane 5: x = 8 (stored), Lane 6: x = 6 (stored), Lane 7: x = 2 (stored). In focus: Instruction unit.Instruction unitfetches once for allMask########Lane 0: x = 3storedLane 1: x = 9storedLane 2: x = 4storedLane 3: x = 7storedLane 4: x = 1storedLane 5: x = 8storedLane 6: x = 6storedLane 7: x = 2stored10 issued for 7 lit or 6 dark

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. One fetch, eight lanes. The unit fetches load x once and all eight lanes run it, each on its own data. Every bit of the mask is set, so every lane does useful work.
  2. The test splits the group. All eight lanes run the test x > 4. Lanes 1, 3, 5 and 6 hold 9, 7, 8 and 6, so they answer true; lanes 0, 2, 4 and 7 answer false.
  3. The lit side runs first. The unit issues the four light instructions with the mask .#.#.##. set. Lanes 1, 3, 5 and 6 compute; the other four lanes sit idle for all four instructions.
  4. Then the shadow side. Next come the three shadow instructions with the mask inverted. Now the lit lanes wait. The branch has cost the group 4 + 3 = 7 instructions, while no lane needed more than 4.
  5. Back together. After the branch the two paths meet again and the mask is full. Store colour runs on all eight lanes.
  6. The bill. The group issued 10 instructions and used 52 of its 80 lane slots, 65 %. Had every lane been lit, as in run 1, it would have issued 7 and used all 56.

The program behind the figure is a model of one group of eight lanes. It runs the shader with every lane lit, then with the split of the figure, and then a loop in which each lane marches until it hits a surface, one lane needing 9 steps where the others need 2 or 3:

One group of eight lanes: a branch, a split branch and an uneven loop JavaScript · simt/simt.mjs
// A model of one SIMT group of 8 lanes. The group fetches each instruction once and runs it on every lane whose
// mask bit is set; masked lanes ('.') sit idle for that instruction. Each issued instruction costs one step of the
// group, whatever the number of active lanes. The shader is the lesson's:
//   load x; if (x > 4) { light 1..4 } else { shadow 1..3 }; store colour
// and a loop that marches each lane until it hits a surface.
const LANES = 8;

function machine() {
  let issued = 0;
  let used = 0;
  const lines = [];
  return {
    issue(name, mask) {
      const active = mask.filter(Boolean).length;
      if (active === 0) return; // no lane needs it: the group skips it
      issued += 1;
      used += active;
      const lanes = mask.map((on) => (on ? '#' : '.')).join('');
      lines.push(`  ${String(issued).padStart(2)}  ${name.padEnd(13)} ${lanes}  ${active}`);
    },
    report() {
      const slots = issued * LANES;
      const pct = ((100 * used) / slots).toFixed(1);
      return [...lines, `  issued ${issued}, lane slots used ${used} of ${slots}: ${pct} %`].join('\n');
    },
  };
}

function shade(xs) {
  const m = machine();
  const all = xs.map(() => true);
  const lit = xs.map((x) => x > 4);
  const dark = lit.map((t) => !t);
  m.issue('load x', all);
  m.issue('test x > 4', all);
  for (let i = 1; i <= 4; i++) m.issue(`light ${i}`, lit);
  for (let i = 1; i <= 3; i++) m.issue(`shadow ${i}`, dark);
  m.issue('store colour', all);
  return m.report();
}

function march(steps) {
  const m = machine();
  const done = steps.map(() => 0);
  for (let it = 1; done.some((d, lane) => d < steps[lane]); it++) {
    const active = steps.map((s, lane) => done[lane] < s);
    m.issue(`step ${it}`, active);
    m.issue(`test hit ${it}`, active);
    active.forEach((on, lane) => { if (on) done[lane] += 1; });
  }
  return m.report();
}

const runs = [
  ['Run 1: every lane is lit', [9, 7, 8, 6, 5, 12, 10, 11]],
  ['Run 2: the test splits the group', [3, 9, 4, 7, 1, 8, 6, 2]],
];
for (const [title, xs] of runs) {
  console.log(`${title} (x = ${xs.join(' ')})`);
  console.log(`  #   instruction   lanes     active`);
  console.log(shade(xs));
  console.log('');
}

const steps = [2, 2, 3, 2, 2, 2, 2, 9];
console.log(`Run 3: a loop, lanes need ${steps.join(' ')} steps`);
console.log(`  #   instruction   lanes     active`);
console.log(march(steps));

Output

Run 1: every lane is lit (x = 9 7 8 6 5 12 10 11)
  #   instruction   lanes     active
   1  load x        ########  8
   2  test x > 4    ########  8
   3  light 1       ########  8
   4  light 2       ########  8
   5  light 3       ########  8
   6  light 4       ########  8
   7  store colour  ########  8
  issued 7, lane slots used 56 of 56: 100.0 %

Run 2: the test splits the group (x = 3 9 4 7 1 8 6 2)
  #   instruction   lanes     active
   1  load x        ########  8
   2  test x > 4    ########  8
   3  light 1       .#.#.##.  4
   4  light 2       .#.#.##.  4
   5  light 3       .#.#.##.  4
   6  light 4       .#.#.##.  4
   7  shadow 1      #.#.#..#  4
   8  shadow 2      #.#.#..#  4
   9  shadow 3      #.#.#..#  4
  10  store colour  ########  8
  issued 10, lane slots used 52 of 80: 65.0 %

Run 3: a loop, lanes need 2 2 3 2 2 2 2 9 steps
  #   instruction   lanes     active
   1  step 1        ########  8
   2  test hit 1    ########  8
   3  step 2        ########  8
   4  test hit 2    ########  8
   5  step 3        ..#....#  2
   6  test hit 3    ..#....#  2
   7  step 4        .......#  1
   8  test hit 4    .......#  1
   9  step 5        .......#  1
  10  test hit 5    .......#  1
  11  step 6        .......#  1
  12  test hit 6    .......#  1
  13  step 7        .......#  1
  14  test hit 7    .......#  1
  15  step 8        .......#  1
  16  test hit 8    .......#  1
  17  step 9        .......#  1
  18  test hit 9    .......#  1
  issued 18, lane slots used 48 of 144: 33.3 %

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node simt.mjs

With every lane lit, the group issued 7 instructions and every lane slot did work. The split cost 10 instructions, 4 lit and 3 dark, and 28 of its 80 lane slots idled: 65.0 % were used. The loop is worse. The group keeps issuing until its slowest lane finishes, so it ran 18 instructions with only 33.3 % of the lane slots busy: one lane’s 9 steps cost the whole group 9 steps. This is divergence: Lanes of one group taking different paths through a branch or a loop. The group then runs every path any lane takes, one after the other, with the other lanes masked.. Press Edit, make every value of run 2 larger than 4, and run it again: the group then issues only the lit side. Across a screen, what matters is whether the pixels that share a group agree. A shadow with one straight edge splits only the groups along that edge, while scattered shadow pixels split nearly every group.

Key idea

The cost of a branch is decided per group, not per pixel: a group runs every path that any of its lanes takes, with the other lanes masked, and a loop runs until its slowest lane is done.

The slip that follows from forgetting it is easy to make when reading shader code line by line:

Only the pixels that take a branch pay for it

On a GPU the group pays for every path any of its lanes takes. A branch on a value every lane of the group shares, such as a material flag, costs only the side taken; a branch on a value that changes from pixel to pixel, such as a noise texture, often costs both sides, and a loop costs its slowest lane’s trip count.

Checkpoint

Check: One instruction for a group of lanes · Branches and loops that split a group

  1. Question 1 of 3 A group of 32 lanes runs one add instruction. How many times does the GPU fetch and decode that instruction?

    Choose one answer.

    Show the answer: Question 1 of 3

    Answer: Once, for the whole group

    One fetch and decode serve every lane of the group. A full-screen pass at 1920 × 1080 is 2,073,600 pixels, so in groups of 32 it needs only 64,800 instruction streams, one per group.

  2. Question 2 of 3 A group of eight lanes runs if (x > 4) { light } else { shadow }, with light taking 4 instructions and shadow 3, plus one load, one test and one store. Only lane 7 has x at most 4. How many instructions does the group issue?

    Type a number.

    A hint

    Count every side that at least one lane takes, whatever the number of lanes on it.

    Show the answer: Question 2 of 3

    Answer: 10 instructions

    Seven lanes are lit and one is dark, so both sides are taken: 1 load + 1 test + 4 light + 3 shadow + 1 store = 10, the same as run 2 of the model, where four lanes took each side. Only a group whose lanes all agree skips a side.

  3. Question 3 of 3 In run 3 of the model, seven lanes need 2 or 3 steps of a loop and lane 7 needs 9. Each step is two instructions. Why did the group issue 18 instructions?

    Choose one answer.

    Show the answer: Question 3 of 3

    Answer: The group repeats the loop until its slowest lane is done, with finished lanes masked

    A loop diverges like a branch: the group keeps issuing while any lane still loops. Lane 7's 9 iterations cost the whole group 9 iterations, 18 instructions, while the useful work was 24 lane-steps of two instructions.

Many groups in flight hide the wait for memory

Now give the shader a texture read. Suppose, as this lesson’s model does, that a group runs 20 instructions of arithmetic and then reads a texel that arrives 400 cycles later. A core that ran only that group would issue for 20 cycles and wait for 400, busy 4.8 % of the time. A GPU core keeps many groups resident instead, and whenever the group it is running has to wait, it issues from another group that is ready. NVIDIA’s Best Practices Guide calls executing other warps while one is stalled the only way to hide latencies. Step through the first 420 cycles of a core with two groups:

Interactive · message by message Two groups take turns while memory answers Step through the first 420 cycles of a core with two groups. Watch when it issues and when every group waits.
Two groups take turns while memory answers, step 7 of 7. Messages so far: Group A to Memory: read texel; Group B to Memory: read texel; Memory to Group A: texel.Group Aissuing, cycles 420-439Group Bwaiting until 440Memoryanswers in 400 cyclesread texelread texeltexel21 groups hide a 400-cycle read

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. A computes. The core issues group A's 20 instructions of arithmetic, one a cycle, in cycles 0 to 19. Then A needs a texel from memory.
  2. A waits for its read. The read leaves at cycle 20, and its answer arrives 400 cycles later, at cycle 420. A can do nothing until then.
  3. B fills the gap. The core does not wait with A. Group B is ready, so cycles 20 to 39 go to B's arithmetic.
  4. B waits too. At cycle 40 group B sends its own read, which comes back at cycle 440.
  5. Nobody is ready. Both groups now wait, so the core issues nothing from cycle 40 to 419. It worked in 40 of the first 420 cycles, 9.5 %, the share the model measures for two groups.
  6. A's texel arrives. At cycle 420 A's texel is back, and A starts its next round of 20 instructions.
  7. Enough groups. Each group issues during 20 of every 420 cycles, so (20 + 400) / 20 = 21 groups keep the core issuing every cycle. More groups in flight only wait their turn.

Two groups only double the busy share, to 9.5 %. The program runs the same core with more groups, and then with 40 instructions of arithmetic per read:

How many groups keep a core busy through a 400-cycle read JavaScript · latency/latency.mjs
// A model of one GPU core that can issue one instruction a cycle. Every group runs the same loop: WORK instructions
// of arithmetic, then a read from memory whose answer arrives LATENCY cycles later; the group waits for it, then
// starts the next round. The core keeps issuing from one group until that group waits, then moves on to the next
// group that is ready. The numbers are the model's: change WORK, LATENCY and the group counts, and run it again.
const LATENCY = 400;
const CYCLES = 42000;

function run(groups, work) {
  const left = Array(groups).fill(work); // instructions left before the group's next read
  const readyAt = Array(groups).fill(0); // the cycle from which the group may issue again
  let current = 0;
  let busy = 0;
  for (let t = 0; t < CYCLES; t++) {
    let g = -1;
    for (let k = 0; k < groups; k++) {
      const candidate = (current + k) % groups;
      if (readyAt[candidate] <= t) {
        g = candidate;
        break;
      }
    }
    if (g < 0) continue; // every group is waiting for memory: the core idles
    current = g;
    busy += 1;
    left[g] -= 1;
    if (left[g] === 0) {
      left[g] = work;
      readyAt[g] = t + 1 + LATENCY; // the read leaves now; its data is back LATENCY cycles later
      current = (g + 1) % groups;
    }
  }
  return busy / CYCLES;
}

for (const work of [20, 40]) {
  const needed = Math.ceil((work + LATENCY) / work);
  console.log(`${work} instructions per read, memory answers in ${LATENCY} cycles`);
  console.log(`  groups needed to keep issuing: (${work} + ${LATENCY}) / ${work} = ${needed}`);
  console.log('  groups  core busy');
  for (const groups of [...new Set([1, 2, 4, 8, 16, needed, 32])].sort((a, b) => a - b)) {
    console.log(`  ${String(groups).padStart(6)}  ${(100 * run(groups, work)).toFixed(1).padStart(6)} %`);
  }
  console.log('');
}

Output

20 instructions per read, memory answers in 400 cycles
  groups needed to keep issuing: (20 + 400) / 20 = 21
  groups  core busy
       1     4.8 %
       2     9.5 %
       4    19.0 %
       8    38.1 %
      16    76.2 %
      21   100.0 %
      32   100.0 %

40 instructions per read, memory answers in 400 cycles
  groups needed to keep issuing: (40 + 400) / 40 = 11
  groups  core busy
       1     9.1 %
       2    18.3 %
       4    36.6 %
       8    72.9 %
      11   100.0 %
      16   100.0 %
      32   100.0 %

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node latency.mjs

The busy share grows in step with the groups, 20 cycles of issue for each 420-cycle round, until (20 + 400) ÷ 20 = 21 groups keep the core issuing on every cycle; groups beyond that only wait their turn. Doubling the arithmetic per read to 40 instructions cuts the groups needed to 11, so work between reads hides latency too. This is latency hiding: Keeping a core busy while some groups wait for memory, by issuing instructions from other resident groups that are ready to run., and on a real GPU it has a limit: a core can hold only so many groups, because each one keeps its own registers. The guide notes that blocks which use many registers leave room for fewer blocks on a core. The share of its maximum that a core actually holds is its occupancy: The number of groups resident on a GPU core as a share of the most it can hold. The registers and workgroup memory each group uses limit it.. Low occupancy leaves memory latency uncovered, but once the core issues on every cycle, more occupancy brings nothing, as the 32-group rows show.

Checkpoint

Check: Hiding memory latency with many groups

  1. Question 1 of 2 In the lesson's model, every group runs 20 instructions and then waits 300 cycles for a read. How many groups must a core hold to issue on every cycle?

    Type a number.

    A hint

    One round of a group lasts 20 + 300 cycles, and it issues during 20 of them.

    Show the answer: Question 1 of 2

    Answer: 16 groups

    (20 + 300) ÷ 20 = 16. While one group waits its 300 cycles, the other 15 issue 20 instructions each, exactly the 300 cycles it is away, plus its own 20.

  2. Question 2 of 2 A shader's reads take 400 cycles and the core can hold only 12 groups of it, because each group uses many registers. Which change keeps the core busiest?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Do more arithmetic between reads, for example 40 instructions per read instead of 20

    Latency is hidden by work in flight, so it can come from more groups or from more work per group. With 12 groups resident, 20 instructions per read cover 12 × 20 = 240 of the 420 cycles of a round; 40 instructions per read keep the core busy every cycle.

Operations per byte decide what limits a pass

Hiding latency keeps the core issuing, but every read still moves bytes, and memory delivers only so many a second: its memory bandwidth: The bytes a second a processor can move to and from its memory, such as 100 GB a second.. The bytes add up quickly. A 1920 × 1080 target in rgba16float, 8 bytes a pixel, is 16,588,800 bytes, so a frame that touches it 5 times at 60 frames a second moves about 4.98 GB a second for that one target.

Whether a pass finishes in time depends on two numbers, the operations it does and the bytes it moves. Their ratio is its arithmetic intensity: The operations a computation does per byte it moves to or from memory. Compared with a processor's balance point, it says whether memory or arithmetic limits the computation., in operations per byte. The roofline model: A bound on how fast a computation can run: the lower of the processor's peak rate of operations and its memory bandwidth times the computation's arithmetic intensity. of Williams, Waterman and Patterson turns it into a bound: a pass runs no faster than the processor’s peak rate of operations, and no faster than bandwidth times intensity, whichever is lower. The two limits meet at the balance point: The arithmetic intensity at which memory and arithmetic take equally long on a processor: its peak operations a second divided by its bytes a second of bandwidth., peak rate ÷ bandwidth. The program works out the bytes of six render targets, then tests four passes on an invented GPU with 2,000 billion operations and 100 GB a second, a balance point of 20 operations per byte:

Bytes of render targets, and four passes on an invented GPU JavaScript · bandwidth/bandwidth.mjs
// Bandwidth arithmetic for render targets, then the roofline test for four passes on an invented GPU.
// MB and GB here are decimal: 1 MB = 1,000,000 bytes, 1 GB = 1,000,000,000 bytes.
const BYTES_PER_PIXEL = { rgba8unorm: 4, rgba16float: 8, rgba32float: 16 };
const fmt = (x, digits) => {
  const [whole, part] = x.toFixed(digits).split('.');
  const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
  return part ? `${grouped}.${part}` : grouped;
};

console.log('One render target, and a frame that touches it 5 times at 60 frames a second');
console.log('  size         format          one target   per second');
for (const [w, h] of [[1920, 1080], [3840, 2160]]) {
  for (const format of ['rgba8unorm', 'rgba16float', 'rgba32float']) {
    const bytes = w * h * BYTES_PER_PIXEL[format];
    const perSecond = bytes * 5 * 60;
    console.log(`  ${`${w} x ${h}`.padEnd(12)} ${format.padEnd(15)} ${fmt(bytes / 1e6, 1).padStart(6)} MB  ${fmt(perSecond / 1e9, 2).padStart(6)} GB/s`);
  }
}

// An invented GPU: 2,000 billion operations a second and 100 GB/s of memory bandwidth.
const GPU = { opsPerSecond: 2000e9, bytesPerSecond: 100e9 };
const balance = GPU.opsPerSecond / GPU.bytesPerSecond;
const PIXELS = 1920 * 1080;
const passes = [
  { name: 'tone map', items: PIXELS, ops: 12, bytes: 8 + 4 }, // reads rgba16float, writes rgba8unorm
  { name: 'blur, 9 taps', items: PIXELS, ops: 60, bytes: 4 + 4 }, // neighbours come from the cache
  { name: 'procedural sky', items: PIXELS, ops: 400, bytes: 4 }, // computes every pixel, reads nothing
  { name: 'particle update', items: 1_000_000, ops: 30, bytes: 32 + 32 }, // reads and writes 32 bytes each
];

console.log('');
console.log(`Invented GPU: ${fmt(GPU.opsPerSecond / 1e9, 0)} billion operations/s, ${fmt(GPU.bytesPerSecond / 1e9, 0)} GB/s`);
console.log(`  balance point: ${fmt(GPU.opsPerSecond / 1e9, 0)} / ${fmt(GPU.bytesPerSecond / 1e9, 0)} = ${balance} operations per byte`);
console.log('  pass             ops/byte  limited by  time     of peak');
for (const p of passes) {
  const intensity = p.ops / p.bytes;
  const opsTime = (p.items * p.ops) / GPU.opsPerSecond;
  const bytesTime = (p.items * p.bytes) / GPU.bytesPerSecond;
  const limit = bytesTime > opsTime ? 'memory' : 'compute';
  const seconds = Math.max(opsTime, bytesTime);
  const share = (100 * (p.items * p.ops)) / seconds / GPU.opsPerSecond;
  console.log(`  ${p.name.padEnd(16)} ${fmt(intensity, 2).padStart(8)}  ${limit.padEnd(10)}  ${fmt(seconds * 1000, 3)} ms  ${fmt(share, 0).padStart(4)} %`);
}

Output

One render target, and a frame that touches it 5 times at 60 frames a second
  size         format          one target   per second
  1920 x 1080  rgba8unorm         8.3 MB    2.49 GB/s
  1920 x 1080  rgba16float       16.6 MB    4.98 GB/s
  1920 x 1080  rgba32float       33.2 MB    9.95 GB/s
  3840 x 2160  rgba8unorm        33.2 MB    9.95 GB/s
  3840 x 2160  rgba16float       66.4 MB   19.91 GB/s
  3840 x 2160  rgba32float      132.7 MB   39.81 GB/s

Invented GPU: 2,000 billion operations/s, 100 GB/s
  balance point: 2,000 / 100 = 20 operations per byte
  pass             ops/byte  limited by  time     of peak
  tone map             1.00  memory      0.249 ms     5 %
  blur, 9 taps         7.50  memory      0.166 ms    38 %
  procedural sky     100.00  compute     0.415 ms   100 %
  particle update      0.47  memory      0.640 ms     2 %

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node bandwidth.mjs

The figure puts the four passes on that GPU’s roofline. Passes on the rising line are limited by memory, and only the flat part belongs to arithmetic:

A roofline on log scales: a rising line, 100 GB/s times intensity, meets a flat limit of 2,000 billion operations a second at 20.What limits a pass on the invented GPUbillion operations a second (log scale)0.11101001000101001k10kbalance point: 20compute limit 2,000memory limit:100 GB/s x intensityparticles 0.47tone map 1blur 7.5sky 100operations per byte (log scale)

The roofline of the invented GPU, with the four passes of the example

Text description of the diagram

Both axes are logarithmic. The horizontal axis is the intensity of a pass, operations per byte, from 0.1 to 1,000. The vertical axis is the rate of work, from 10 to 10,000 billion operations a second.

A thick line gives the most work the invented GPU can do at each intensity. From the left it rises in step with intensity: 100 GB a second of memory times the operations done per byte. At an intensity of 20, marked by a dashed line and labelled the balance point, it reaches 2,000 billion operations a second and stays flat from there: the compute limit.

Four passes sit on the line:

  1. Particles, at 0.47 operations per byte: 47 billion operations a second, on the rising part.
  2. Tone map, at 1: 100 billion a second, on the rising part.
  3. Blur, at 7.5: 750 billion a second, still on the rising part.
  4. Sky, at 100: on the flat part, at the full 2,000 billion a second.

The three passes on the rising part are limited by memory; only the sky is limited by compute.

The tone map does 12 operations per pixel and moves 12 bytes, an intensity of 1, so it can use only 5 % of the GPU’s arithmetic and its 0.249 ms are memory time. Even the 9-tap blur, at 7.5, stays below the balance point. Only the procedural sky, 400 operations for the 4 bytes it writes, reaches the flat part and uses the whole 2,000. For a pass below the balance point faster arithmetic changes nothing; fewer bytes do, through a smaller format, fewer passes over the same target, or two passes fused into one.

Find your device's balance point

Divide the peak operation rate on your GPU’s data sheet by its memory bandwidth. A pass whose operations per byte fall below that number is waiting for memory, however much arithmetic the GPU has to spare.

Data Storage Converter Turn the bytes of a render target into MB, MiB and GB, the way the bandwidth and traffic figures on this page count them.

Checkpoint

Check: Arithmetic intensity and the roofline

  1. Question 1 of 2 On the invented GPU (2,000 billion operations and 100 GB a second), a pass does 30 operations for every 4 bytes it moves. What limits it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Memory bandwidth, because 7.5 operations per byte is below the balance point of 20

    Intensity 30 ÷ 4 = 7.5 operations per byte, the same as the lesson's blur. Below 20 the rising line of the roofline applies: 7.5 × 100 GB/s = 750 billion operations a second, 38 % of peak.

  2. Question 2 of 2 A frame touches a 3840 × 2160 rgba8unorm target 5 times, at 60 frames a second. How many GB a second is that (1 GB = 1,000,000,000 bytes)? Give two decimals.

    Type a number.

    A hint

    Bytes of one target × touches × frames a second; rgba8unorm is 4 bytes a pixel.

    Show the answer: Question 2 of 2

    Answer: 9.95 GB/s (anything from 9.94 to 9.96 counts)

    3840 × 2160 × 4 = 33,177,600 bytes, then × 5 × 60 = 9,953,280,000 bytes a second, about 9.95 GB/s, the same as the example's row for that target.

Tile-based GPUs keep each tile’s pixels on chip

A GPU can draw a pass in two orders. An immediate-mode rendering: Rendering that shades triangles in the order they are drawn, reading and writing each fragment's colour and depth in memory, helped by caches. GPU shades triangles in the order they arrive and reads and writes the colour and depth of each fragment in memory, helped by caches. A tile-based rendering: Rendering that first sorts a pass's triangles by the screen tiles they touch, then shades one tile at a time in on-chip tile memory and writes each finished tile out once. GPU first sorts the pass’s triangles by the screen tiles they touch. It then renders one tile at a time in tile memory: Fast memory on the GPU chip that holds one tile's colour, depth and other attachments while a tile-based GPU renders that tile. on the chip and writes each finished tile out once. Apple’s GPUs work this way, and Apple’s guide describes tile memory as fast, temporary storage on the GPU itself, with much higher bandwidth and lower latency than device memory, and much less energy per access. Every triangle that overlaps a tile, and every depth test and blend on it, then costs no memory traffic. Step through one pass:

Interactive · step by step One render pass on a tile-based GPU Step through the pass from binning to the last tile. Watch which arrows touch device memory.
One render pass on a tile-based GPU, step 6 of 6. Showing Vertex stage, Tile lists, Tile memory (next tile), Fragment stage, Device memory (complete colour), Screen.Vertex stageevery triangleTile listsin device memoryTile memorynext tileFragment stageone tile at a timeDevice memorycomplete colourScreenshows the colourfinished frame

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Binning. First the GPU runs the vertex stage for every triangle of the pass and writes, for each tile, the list of triangles that touch it.
  2. The first tile is cleared. Then the pass runs tile by tile. With loadOp clear the tile's colour and depth start at their clear values, set on chip. With loadOp load the old pixels would come in over the dashed arrow, a read of memory.
  3. Shading stays on chip. The fragment stage shades only the triangles on this tile's list. Depth tests and blending read and write tile memory, however often triangles overlap there.
  4. Colour is stored, depth dropped. When the tile is done, storeOp store writes its colour to device memory, once. Depth has storeOp discard, so it is dropped on chip; nothing reads it after the pass.
  5. Every other tile. The next tile clears, shades and stores the same way, and so does every tile after it. Device memory sees one write of each tile's colour and nothing else.
  6. The pass is done. After the last tile the colour attachment is complete in memory and can go to the screen. The pass read no attachment and wrote one copy of the colour, 548.8 MiB a second at 2220 x 1080 and 60 frames.

What still reaches memory is decided at the two ends of each tile: whether the tile starts from the attachment’s old contents, read from memory, and whether its result is written back. The figure’s pass cleared both attachments and kept only the colour, so its whole attachment traffic was one write of the colour attachment, however many triangles overlapped.

Load and store operations set the traffic of a pass

WebGPU states those two ends for each attachment of a render pass. The loadOp: What a render pass does with an attachment at its start: clear sets it to a clear value, load keeps the contents it held, which a tile-based GPU must read from memory. is clear, which starts the attachment at a clear value, or load, which keeps what it held. The storeOp: What a render pass does with an attachment at its end: store writes the result to memory, discard drops it and leaves the attachment's contents undefined. is store, which keeps the result, or discard, which drops it; WebGPU then treats the attachment as cleared to zero, so a later read finds zeros, never leftover data. MDN recommends clear wherever the old contents do not matter, for better performance on some devices such as phones. The program counts the traffic of four choices for a colour and a depth attachment at 2220 × 1080, 4 bytes a pixel and 60 frames a second, the frame of the Khronos Group’s sample on render pass attachments:

Attachment traffic of one pass, from its load and store operations JavaScript · tiles/tile_traffic.mjs
// Memory traffic of one render pass on a tile-based GPU, from its load and store operations alone. A tile starts in
// on-chip memory: loadOp 'load' reads the attachment's old pixels from memory, 'clear' fills the tile on chip.
// When the tile is done, storeOp 'store' writes it to memory, 'discard' throws it away. Everything drawn in between
// stays on chip. The frame is the size the Khronos render-pass sample uses: 2220 x 1080, 4 bytes a pixel, 60 a second.
const W = 2220;
const H = 1080;
const BYTES = 4;
const FPS = 60;
const MiB = 1024 * 1024;

const attachment = W * H * BYTES; // one full read or write of one attachment
const perSecond = (bytes) => ((bytes * FPS) / MiB).toFixed(1);
const grouped = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');

function traffic(colour, depth) {
  let reads = 0;
  let writes = 0;
  for (const [loadOp, storeOp] of [colour, depth]) {
    if (loadOp === 'load') reads += attachment;
    if (storeOp === 'store') writes += attachment;
  }
  return { reads, writes };
}

console.log(`Frame ${W} x ${H}, ${BYTES} bytes a pixel per attachment, ${FPS} frames a second`);
console.log(`One full read or write of one attachment: ${grouped(attachment)} bytes, ${perSecond(attachment)} MiB/s`);
console.log('');
console.log('  colour        depth           reads MiB/s  writes MiB/s');
const cases = [
  [['load', 'store'], ['load', 'store']],
  [['clear', 'store'], ['load', 'store']],
  [['load', 'store'], ['clear', 'discard']],
  [['clear', 'store'], ['clear', 'discard']],
];
for (const [colour, depth] of cases) {
  const t = traffic(colour, depth);
  console.log(`  ${colour.join(', ').padEnd(13)} ${depth.join(', ').padEnd(15)} ${perSecond(t.reads).padStart(11)}  ${perSecond(t.writes).padStart(12)}`);
}
console.log('');
console.log(`A depth attachment that is cleared and discarded never leaves the chip:`);
console.log(`its ${grouped(attachment)} bytes need no memory if the GPU keeps it in tile memory.`);

Output

Frame 2220 x 1080, 4 bytes a pixel per attachment, 60 frames a second
One full read or write of one attachment: 9,590,400 bytes, 548.8 MiB/s

  colour        depth           reads MiB/s  writes MiB/s
  load, store   load, store          1097.5        1097.5
  clear, store  load, store           548.8        1097.5
  load, store   clear, discard        548.8         548.8
  clear, store  clear, discard          0.0         548.8

A depth attachment that is cleared and discarded never leaves the chip:
its 9,590,400 bytes need no memory if the GPU keeps it in tile memory.

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node tile_traffic.mjs

One full read or write of one attachment costs 548.8 MiB a second. Loading and storing both attachments moves 1,097.5 MiB a second each way; clearing the colour and clearing and discarding the depth removes three of those four transfers and leaves one write. The Khronos sample’s own measurements show the same effect: clearing instead of loading cut the bytes read by 600.2 MiB a second, and not storing depth cut the bytes written by 554.8 MiB a second, close to the model’s 548.8. Choose the pair for an attachment of your own:

Interactive · answer and compare Which load and store operations fit this attachment? Answer two questions about one attachment of your pass. The table rules out the pairs that waste a read or a write, or lose data you need.
Must this pass start from what the attachment held before it?
Will anything read the attachment after this pass, such as the screen, a copy or a later pass?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

The four loadOp and storeOp pairs of one attachment, against their memory traffic on a tile-based GPU
loadOp and storeOp Reads from memory Writes to memory Old contents Contents after the pass
load, store Poor the whole attachment, every frame: 548.8 MiB/s here Poor every tile, every frame: 548.8 MiB/s here Good kept: draws add to them Good kept in memory for later passes
load, discard Poor the whole attachment: 548.8 MiB/s here Good none: the tiles are dropped on chip Good kept: draws add to them Poor lost: it then reads as all zeros
clear, store Good none: each tile starts at the clear value on chip Poor every tile: 548.8 MiB/s here Mixed replaced by the clear value Good kept in memory for later passes
clear, discard Good none: the tile starts on chip Good none: the tile never leaves the chip Mixed replaced by the clear value Poor lost: only this pass can use it
How the answers decide
  • “Must this pass start from what the attachment held before it?” No, the pass draws it from scratch: rules out load, store and load, discard. Loading reads the whole attachment from memory only for the pass to draw over it: 548.8 MiB a second at 2220 x 1080 and 60 frames. Clear starts each tile on chip.
  • “Must this pass start from what the attachment held before it?” Yes, the pass adds to an earlier one: rules out clear, store and clear, discard. Clearing throws away the contents this pass must build on, such as the colour an earlier pass drew.
  • “Will anything read the attachment after this pass, such as the screen, a copy or a later pass?” Yes, it is read later: rules out load, discard and clear, discard. Discard drops the result, and WebGPU then treats the attachment as cleared to zero, so the screen, a copy or a later pass would read zeros, not the frame.
  • “Will anything read the attachment after this pass, such as the screen, a copy or a later pass?” No, only this pass uses it: rules out load, store and clear, store. Storing writes every tile back to memory for nobody. A depth buffer used only inside this pass is the usual case: discard it.
  • “Must this pass start from what the attachment held before it?” No, the pass draws it from scratch and “Will anything read the attachment after this pass, such as the screen, a copy or a later pass?” No, only this pass uses it: adds a caveat to clear, discard. Such an attachment never needs memory at all. Create its texture with the TRANSIENT_ATTACHMENT usage, which allows exactly clear and discard, and a tile-based GPU may keep it on chip.

A depth buffer that one pass uses and nothing reads afterwards is the usual case for clear and discard. WebGPU’s TRANSIENT_ATTACHMENT texture usage tells the GPU exactly that: such a texture may be used only with clear and discard, and Chrome’s notes say it can then stay in on-chip memory, possibly without any video memory at all. On any GPU these operations say what a pass needs, and an implementation can skip only the work it is told is unneeded.

Key idea

On a tile-based GPU what a pass draws stays on chip, and its attachment traffic is what its load and store operations ask for: load only the contents you build on, and store only what something reads later.

Checkpoint

Check: Tile-based and immediate-mode GPUs · Load and store operations

  1. Question 1 of 2 Three transparent triangles cover the same tile and each is blended over the one below. On a tile-based GPU, how often does that tile's colour travel to or from device memory during the pass?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: At most once each way, at the tile's start and end, however many triangles overlap

    The tile is shaded completely in tile memory before it is written out. Overlap costs shading time, not memory traffic: the load and store at the two ends of the tile are the only transfers.

  2. Question 2 of 2 A pass draws the whole scene with a depth buffer that nothing reads after the pass. Which depth operations suit it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: depthLoadOp clear and depthStoreOp discard

    Clear and discard keep the depth buffer on chip from start to end. A texture used that way may also take the TRANSIENT_ATTACHMENT usage, so a tile-based GPU need not give it any memory.

Practise: what limits a pass

The exercise turns the lesson’s arithmetic into three functions: the bytes a second of a render target, what limits a pass on a given GPU, and the groups needed to hide a read. Its sample tests use the passes and the model above:

Exercise · Easy · JavaScript

What limits a pass, and how many groups hide a read

Write three functions in limits.mjs, the arithmetic of this lesson:

  • trafficPerSecond(target): the bytes a second that a frame moves when it touches a render target touches times, fps frames a second. target is { width, height, bytesPerPixel, touches, fps }.
  • limitedBy(work, gpu): what limits a pass. work is { ops, bytes }, the operations it does and the bytes it moves to and from memory in total; gpu is { opsPerSecond, bytesPerSecond }. Return { intensity, limit, seconds }: intensity is operations per byte (Infinity when bytes is 0), limit is 'memory' when moving the bytes takes longer than doing the operations and 'compute' otherwise (a tie is 'compute'), and seconds is the longer of the two times.
  • groupsToHide(work, latency): how many groups a core needs in flight to keep issuing, when each group issues work instructions and then waits latency cycles for a read: one round of a group lasts work + latency cycles, of which it issues during work.

The starter gives answers that look plausible and are wrong. The sample tests use the lesson's passes on its invented GPU (2,000 billion operations and 100 GB a second) and the latency model's 400-cycle reads.

Starter code · limits.mjs

/** Bytes a second when a frame touches a width x height render target `touches` times, `fps` frames a second. */
export function trafficPerSecond({ width, height, bytesPerPixel, touches, fps }) {
  return width * height * bytesPerPixel * fps; // forgets the touches
}

/** What limits a pass: { intensity, limit: 'memory' | 'compute', seconds }. */
export function limitedBy({ ops, bytes }, { opsPerSecond, bytesPerSecond }) {
  const intensity = ops / bytes;
  const limit = intensity < 1 ? 'memory' : 'compute'; // compares with 1, not with the GPU's balance point
  const seconds = ops / opsPerSecond + bytes / bytesPerSecond; // adds the two times
  return { intensity, limit, seconds };
}

/** Groups needed in flight so that a core never waits, when each issues `work` and then waits `latency` cycles. */
export function groupsToHide(work, latency) {
  return Math.floor(latency / work); // rounds down and forgets the group's own work
}
The sample tests · limits.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { groupsToHide, limitedBy, trafficPerSecond } from './limits.mjs';

const GPU = { opsPerSecond: 2000e9, bytesPerSecond: 100e9 };
const PIXELS = 1920 * 1080;
const round = (x) => Math.round(x * 1e12) / 1e12;

test('traffic: 1920 x 1080 rgba16float touched 5 times at 60 frames a second', () => {
  assert.equal(trafficPerSecond({ width: 1920, height: 1080, bytesPerPixel: 8, touches: 5, fps: 60 }), 4_976_640_000);
  assert.equal(trafficPerSecond({ width: 2220, height: 1080, bytesPerPixel: 4, touches: 1, fps: 60 }), 575_424_000);
});

test('the tone map pass is limited by memory', () => {
  const r = limitedBy({ ops: PIXELS * 12, bytes: PIXELS * 12 }, GPU);
  assert.equal(r.intensity, 1);
  assert.equal(r.limit, 'memory');
  assert.equal(round(r.seconds), round((PIXELS * 12) / 100e9), 'the bytes take 0.249 ms; the operations 0.012 ms');
});

test('the procedural sky is limited by compute', () => {
  const r = limitedBy({ ops: PIXELS * 400, bytes: PIXELS * 4 }, GPU);
  assert.equal(r.intensity, 100);
  assert.equal(r.limit, 'compute');
  assert.equal(round(r.seconds), round((PIXELS * 400) / 2000e9));
});

test('a blur at 7.5 operations a byte is still limited by memory, below the balance point of 20', () => {
  const r = limitedBy({ ops: PIXELS * 60, bytes: PIXELS * 8 }, GPU);
  assert.equal(r.limit, 'memory');
  assert.equal(r.intensity, 7.5);
});

test('no bytes at all, and a tie at the balance point', () => {
  const none = limitedBy({ ops: 1e9, bytes: 0 }, GPU);
  assert.equal(none.intensity, Infinity);
  assert.equal(none.limit, 'compute');
  assert.equal(round(none.seconds), 0.0005);
  const tie = limitedBy({ ops: 2000e6, bytes: 100e6 }, GPU);
  assert.equal(tie.intensity, 20);
  assert.equal(tie.limit, 'compute', 'equal times are a tie, and a tie is compute');
  assert.equal(round(tie.seconds), 0.001);
});

test('groups needed to hide a read', () => {
  assert.equal(groupsToHide(20, 400), 21);
  assert.equal(groupsToHide(40, 400), 11);
  assert.equal(groupsToHide(30, 400), 15, '430 / 30 is 14.33: round up');
  assert.equal(groupsToHide(400, 400), 2);
  assert.equal(groupsToHide(5, 0), 1, 'with no wait, one group keeps the core busy');
});
A hint

Compute both times before deciding anything: ops / gpu.opsPerSecond and bytes / gpu.bytesPerSecond. The larger one is the pass's time, and it names the limit. For the groups, ask how many rounds of work + latency cycles must overlap so that some group is always issuing, and round up: a fraction of a group cannot issue.

The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Then practise the whole lesson: the questions start with the ideas you know least, and each wrong answer explains what it misses.

Practice that adapts to you

Practise this lesson

13 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 13 A full-screen pass at 1280 × 720 runs one fragment shader invocation per pixel, in groups of 32. How many groups is that?

    Type a number.

    A hint

    Count the pixels first.

    Show the answer: Question 1 of 13

    Answer: 28800 groups

    1280 × 720 = 921,600 pixels, and 921,600 ÷ 32 = 28,800 groups, each with one fetch and decode per instruction.

  2. Question 2 of 13 What do the lanes of a SIMT group share, and what does each keep to itself?

    Choose one answer.

    Show the answer: Question 2 of 13

    Answer: They share the instruction being run; each keeps its own data, such as its own x

    SIMT means a single instruction for many threads: the group shares the fetch and decode, and each lane applies the instruction to its own values.

  3. Question 3 of 13 A shader branches twice: once on a material flag that is the same for the whole draw, and once on a noise value that changes from pixel to pixel. Which branch is more likely to cost both of its sides?

    Choose one answer.

    Show the answer: Question 3 of 13

    Answer: The branch on the noise value

    Divergence depends on whether the lanes of a group agree. A value shared by the whole draw cannot split a group; a value that changes from pixel to pixel splits most of them.

  4. Question 4 of 13 A group of 32 lanes runs a branch whose expensive side takes 40 instructions and whose cheap side takes 2. One lane takes the cheap side. How many lane slots does the branch use in all (issued instructions × 32)?

    Type a number.

    A hint

    Both sides are issued, each for all 32 lane slots.

    Show the answer: Question 4 of 13

    Answer: 1344 lane slots

    The group issues 40 + 2 = 42 instructions for the branch, and each occupies all 32 lane slots: 42 × 32 = 1,344. Of those, 31 × 40 + 1 × 2 = 1,242 do work, so the lone cheap lane costs little; the expensive side is the bill.

  5. Question 5 of 13 What limits how many groups a GPU core can hold at once?

    Choose one answer.

    Show the answer: Question 5 of 13

    Answer: The registers and other on-chip resources each group needs

    Occupancy is the share of its maximum that a core holds. Each resident group keeps its registers on the core, so heavy shaders fit fewer groups and hide less latency.

  6. Question 6 of 13 In the latency model, reads take 600 cycles and each group runs 25 instructions between reads. How many groups keep the core issuing on every cycle?

    Type a number.

    A hint

    A round of one group lasts 25 + 600 cycles.

    Show the answer: Question 6 of 13

    Answer: 25 groups

    (25 + 600) ÷ 25 = 25 groups. Any fewer leave cycles in which every group is waiting for memory.

  7. Question 7 of 13 A GPU does 8,000 billion operations a second and its memory delivers 400 GB a second. What is its balance point, in operations per byte?

    Type a number.

    A hint

    Peak rate ÷ bandwidth.

    Show the answer: Question 7 of 13

    Answer: 20 operations per byte

    8,000 × 10⁹ ÷ 400 × 10⁹ = 20 operations per byte. Passes below 20 are limited by memory, above it by arithmetic.

  8. Question 8 of 13 What is a pass's arithmetic intensity?

    Choose one answer.

    Show the answer: Question 8 of 13

    Answer: The operations it does per byte it moves to and from memory

    Intensity = operations ÷ bytes. The tone map's is 1, the blur's 7.5 and the procedural sky's 100.

  9. Question 9 of 13 The tone map pass of the lesson, intensity 1 on a GPU whose balance point is 20, takes too long. Which change makes it faster?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: Read the input from a smaller format, or fuse the pass into the one before it

    A pass below the balance point is limited by the bytes it moves. Only fewer bytes, or more operations per byte, move it up the rising line of the roofline.

  10. Question 10 of 13 What does a tile-based GPU do before it shades any pixel of a pass?

    Choose one answer.

    Show the answer: Question 10 of 13

    Answer: It runs the vertex stage for all triangles and sorts them into lists per tile

    A tile-based GPU bins the pass's triangles by tile, then renders each tile completely in tile memory and writes it out once.

  11. Question 11 of 13 Why does a busy pass with much overdraw cost a tile-based GPU less memory traffic than an immediate-mode one?

    Choose one answer.

    Show the answer: Question 11 of 13

    Answer: Depth tests and blending for overlapping triangles happen in tile memory, not in device memory

    The tile's colour and depth live on chip while it is shaded, so overdraw multiplies on-chip work, not traffic to device memory.

  12. Question 12 of 13 A pass draws the scene into a colour attachment, and a later bloom pass samples that colour. Which load and store operations does the scene pass's colour attachment need?

    Choose one answer.

    Show the answer: Question 12 of 13

    Answer: loadOp clear and storeOp store

    Ask two questions per attachment: does this pass build on the old contents, and does anything read the result later? Here the answers are no and yes: clear and store.

  13. Question 13 of 13 A depth texture is created with the TRANSIENT_ATTACHMENT usage. Which render pass operations may it use?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: Only loadOp clear and storeOp discard

    WebGPU allows only clear and discard for a TRANSIENT_ATTACHMENT texture, and in return a tile-based GPU may keep it entirely in on-chip memory.

Interview questions

Warm-up (fresher to 2-5 years): What is warp divergence, and why does it cost time?

A GPU runs threads in groups, warps of 32 on NVIDIA hardware, that execute one instruction at a time in lockstep. When the threads of a group reach a branch and some take one side while the rest take the other, the group cannot run both sides at once. It runs one side with the threads that took the other side masked off, then the other side with the first set masked, and the threads join again after the branch. That is divergence. The group’s time is the sum of both sides, so a branch that a few scattered pixels take can make many groups pay for the expensive path. Loops diverge too: a group keeps running until its slowest thread finishes. Branches on values every thread of a group shares, such as a material flag, do not diverge.

Two questions carry these ideas into a shader that slows down and a phone’s frame budget. Each row links to the question’s first step and its follow-ups on the module’s page:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A fragment shader takes an expensive path for pixels in shadow and a cheap one elsewhere. Screens with scattered shadow run almost twice as slow as screens with one large shadow of the same area. Explain why, and how you would restructure the shader.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A phone game renders at 2220 × 1080 and 60 frames a second, with a colour and a depth attachment of 4 bytes a pixel, and every pass loads and stores both. Estimate the attachment traffic of one pass, and say what you would change on a tile-based GPU and how much it saves.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A GPU fetches and decodes each instruction once for a whole group of lanes, 32 threads in an NVIDIA warp and a power of two from 4 to 128 in a WGSL subgroup, and runs it on every lane at the same moment.
  • When the lanes of a group take different paths, the group runs every path any lane takes, with the others masked: the model’s split branch used 65.0 % of its lane slots, and its uneven loop 33.3 %.
  • A core hides memory latency by issuing from other resident groups; (work + latency) ÷ work groups, 21 in the model, keep it issuing on every cycle, and registers per group limit how many it can hold.
  • Arithmetic intensity, operations per byte, decides what limits a pass: below the balance point, peak rate ÷ bandwidth, a pass waits for memory and only fewer bytes make it faster.
  • A tile-based GPU keeps each tile’s colour and depth on chip while it shades, so a pass’s attachment traffic is what its loadOp and storeOp ask for: clear what is redrawn, and discard what nobody reads.

Terms in this lesson

arithmetic intensity
The operations a computation does per byte it moves to or from memory. Compared with a processor's balance point, it says whether memory or arithmetic limits the computation.
balance point
The arithmetic intensity at which memory and arithmetic take equally long on a processor: its peak operations a second divided by its bytes a second of bandwidth.
divergence
Lanes of one group taking different paths through a branch or a loop. The group then runs every path any lane takes, one after the other, with the other lanes masked.
execution mask
One bit per lane of a group that says whether the lane runs the current instruction. Masked lanes keep their values and do nothing while the group runs a path they did not take.
immediate-mode rendering
Rendering that shades triangles in the order they are drawn, reading and writing each fragment's colour and depth in memory, helped by caches.
lane
One thread's place in a group that runs in lockstep: the part of the hardware that runs that thread's copy of each instruction, on that thread's own data.
latency hiding
Keeping a core busy while some groups wait for memory, by issuing instructions from other resident groups that are ready to run.
loadOp
What a render pass does with an attachment at its start: clear sets it to a clear value, load keeps the contents it held, which a tile-based GPU must read from memory.
memory bandwidth
The bytes a second a processor can move to and from its memory, such as 100 GB a second.
occupancy
The number of groups resident on a GPU core as a share of the most it can hold. The registers and workgroup memory each group uses limit it.
roofline model
A bound on how fast a computation can run: the lower of the processor's peak rate of operations and its memory bandwidth times the computation's arithmetic intensity.
SIMT (single instruction, multiple threads)
Running one instruction at a time for a whole group of threads in lockstep, each thread on its own data. Threads may still take different paths through a branch, at the cost of running every path.
storeOp
What a render pass does with an attachment at its end: store writes the result to memory, discard drops it and leaves the attachment's contents undefined.
subgroup
WGSL's name for a group of invocations that a GPU runs together in lockstep, what NVIDIA calls a warp. Its size is a power of two from 4 to 128 and depends on the device.
tile memory
Fast memory on the GPU chip that holds one tile's colour, depth and other attachments while a tile-based GPU renders that tile.
tile-based rendering
Rendering that first sorts a pass's triangles by the screen tiles they touch, then shades one tile at a time in on-chip tile memory and writes each finished tile out once.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.