6.2.13 · D3GPU Architecture

Worked examples — CUDA programming model basics

4,273 words19 min readBack to topic

This page is the hands-on companion to CUDA programming model basics. The parent note built the machinery: blocks map to SMs, threads carry a global index, and memory has speed tiers. Here we use that machinery on every awkward case a real problem can throw at you.

Before anything, let us re-anchor the one formula we will lean on the whole page. Picture a long line of numbered lockers — that is your array. We chop the line into equal blocks (fixed-size chunks). Inside each block, threads are numbered from upward.

Figure — CUDA programming model basics

Read the figure first. The horizontal strip is your array; each little box is one thread's element, and the number inside each box is that thread's globalIdx. The cyan boxes are ordinary threads; the single amber box (rightmost, index ) is the thread we track in Example 1. Above the boxes, the white brackets group boxes into blocks — three brackets labelled block 0, block 1, block 2. Below each box the small white t0 … t3 tags are the seat number inside that block (threadIdx.x). Notice the pattern: seats restart at at every new bracket, while the big number keeps climbing — that climb is the global index.

Every symbol above now has a picture. Two more pieces of notation appear repeatedly below, so we define them now, before first use:

Now the matrix.

The scenario matrix

Every CUDA-indexing problem you will meet is one of these cells. The examples below are labelled with the cell they cover, and together they cover all of them.

Cell What makes it tricky Covered by
A. Clean divide is an exact multiple of block size — no leftovers Ex 1
B. Ragged tail not divisible — extra idle threads appear Ex 2
C. Degenerate: tiny smaller than one block, or , or Ex 3
D. Reverse map Given a globalIdx, find which block & seat produced it Ex 4
E. 2-D grid Image / matrix — two index axes, row-major flattening Ex 5
F. Memory-cost word problem Should data live in registers, shared, or global? Ex 6
G. Launch-config sizing Choose block size; count idle threads and wasted warp lanes Ex 7
H. Exam twist: stride loop More elements than threads — one thread handles many Ex 8
I. 3-D grid Volumes / batches — three index axes, depth·height·width flatten Ex 9

one

two

three

need the index

have index find block

yes clean

no ragged

tiny or zero

choosing block size

fewer threads than data

where to store data

How many dimensions?

Do you know the index or need it?

Cell E - Ex 5

Cell I - Ex 9

Is N divisible by block size?

Cell D - Ex 4

Cell A - Ex 1

Cell B - Ex 2

Cell C - Ex 3

Cell G - Ex 7

Cell H - Ex 8

Cell F - Ex 6


Cell A — the clean divide

Forecast: guess the index before reading on. (Hint: last block, last seat — should be the very last element.)

  1. Read the two pieces. blockIdx.x = 2, blockDim.x = 4, threadIdx.x = 3. Why this step? The formula needs exactly these three numbers; gather them first so nothing is guessed later.
  2. Threads before my block: . Why this step? Two whole chunks of seats sit ahead of me; I must skip all . (In the figure, that is the two full brackets block 0 and block 1.)
  3. Add my seat: . Why this step? Seat inside my chunk lands me past my chunk's front door — the amber box in the figure.
  4. Boundary check if (idx < N): is ? Yes. Why this step? Even in a clean divide we still test — good habit, and it protects the ragged cases later.

Answer: globalIdx , valid.

Verify: Total threads , exactly . The indices produced are — every element hit once, none twice, none skipped. is the largest, so the last thread hits the last element. Consistent.


Cell B — the ragged tail

Forecast: will come out even? No — so expect leftover idle threads. Guess how many.

  1. Ceiling division. We need at least enough threads for all elements: Why this step? blocks give threads; blocks give only , which would leave elements untouched. We must round up — that is exactly what the ceiling bracket (defined above) does.
  2. Why the trick? Adding threadsPerBlock − 1 before integer-dividing forces a round-up without floating-point math. Why this step? Integer division truncates down; the offset pushes any non-zero remainder over the next boundary. It answers the question "did anything spill past the last full block?"
  3. Total threads launched: . Why this step? Blocks come in fixed sizes — you cannot launch a partial block, so you always over-provision.
  4. Idle threads: . These have idx . Why this step? This is exactly why if (idx < N) exists — without it, threads read past the array end. See the mistakes callout in the parent note.

Answer: 4 blocks, 1024 threads launched, 24 idle.

Verify: ✓ and ✗, so is the smallest valid count. Idle , and . ✓


Cell C — degenerate & edge inputs

Forecast: is the sneaky one — how many blocks should you launch for nothing?

  1. (a) : block. Why? Even one element needs one block to hold it; you cannot launch a fraction of a block. Threads launched , idle . Only thread passes idx < 1.
  2. (b) : blocks. Via the trick: (integer division). Why? Nothing to do → the ceiling formula returns blocks.
  3. (c) : block. Trick: . Why? Exactly one full block; the offset does not falsely bump us to because . This confirms the ceiling trick never over-rounds on exact multiples.

Answer: (a) 1 block, 255 idle; (b) 0 blocks (guard the launch), 0 idle; (c) 1 block, 0 idle.

Verify: (a) ✓. (b) ✓. (c) ✓, and the trick gives , not , so no phantom extra block.


Cell D — the reverse map

Forecast: which block? Roughly , so block maybe. Let's be exact.

  1. Recover the block by integer division (the floor bracket defined above — round down): Why this step? The formula was with . Dividing by and dropping the remainder strips off the seat and leaves the block — it inverts the multiplication.
  2. Recover the seat with the remainder (modulo): Why this step? The remainder is exactly "how far past the block's front door" — the seat we skipped over in step 1.

Answer: block 3, seat 45.

Verify: Rebuild the index forward: ✓. And , a legal seat. Both directions agree — division and modulo are the clean inverse of the index formula.


Cell E — the 2-D grid

Images and matrices need two axes. Picture a grid laid over a photo: threadIdx.x/blockIdx.x count columns (across), threadIdx.y/blockIdx.y count rows (down).

Figure — CUDA programming model basics

Read the figure first. The big square is a corner of the image. The faint white lines chop it into blocks (four across, four down). The cyan-shaded square is the one block at blockIdx = (2, 3) — its columns run (that is to ) and its rows run ( onward). Inside it, the amber square marker is our target pixel: seat (5, 7) inside that block lands at column , row . The amber label spells out both little index sums. Notice rows increase downward (image convention) — the y-axis is flipped for exactly that reason.

Forecast: row uses the .y numbers, column uses the .x numbers. Guess before computing.

  1. Column (x-axis): Why this step? Same 1-D logic as Example 1, applied along the horizontal axis only.
  2. Row (y-axis): Why this step? The vertical axis is a second, independent copy of the same formula.
  3. Flatten to 1-D (row-major): Why this step? Memory is a straight line; a full row of pixels sits before each new row. Multiplying row × width skips all complete rows above, then + col walks across the current one. This is exactly the 1-D idea nested inside itself.

Answer: pixel (row 55, col 37), flat index 28197.

Verify: Bounds: ✓, ✓. Flatten range check: max legal flat index is , and ✓. Re-derive col from flat: ✓; row ✓.


Cell F — the memory-cost word problem

Forecast: re-reading from far-away global memory 8 times sounds wasteful. Guess the cycle counts.

  1. All-global cost: cycles. Why this step? If nothing is cached, every one of the 8 reads pays the full off-chip trip. See Memory Hierarchy for why global is ~ cycles (off-chip, ~cm of wire + controller).
  2. Shared-memory cost: one global load , then shared reads . Total cycles. Why this step? Pay the expensive trip once to stage the data on-chip, then re-read cheaply. Shared memory lives inside the SM.
  3. Speedup: . Why this step? This ratio is the reason shared-memory tiling exists — reuse amortises the one expensive load.

Answer: 2400 vs 340 cycles → ≈7.06× faster with shared memory.

Verify: Break-even reuse count solves . So any element read twice or more already favours shared memory — and we read it times, comfortably past break-even. ✓


Cell G — launch-config sizing

Forecast: divides evenly — tempting! But is even allowed (max is ), and is it a multiple of ? Guess where the hidden waste is.

  1. Option A — 256: . Threads . Idle (grid-level) . Why this step? threads is whole warps — no partial warp. Wasted warp lanes per block , so total wasted lanes .
  2. Option B — 1000: , so it is legal. exactly. Threads . Idle (grid-level) . Why this step? An exact divide means zero leftover threads at the grid level — no ragged tail. So far it looks perfect.
  3. Quantify the warp-level waste for Option B. Warps per block warps. Those warps physically occupy lanes, but only threads are real. Wasted lanes per block . Why this step? The hardware always schedules a full warp of lanes; the last warp of each block runs with live threads and dead lanes. Those dead lanes still consume a scheduling slot — pure waste.
  4. Total wasted lanes across the grid (Option B): wasted lanes. Why this step? Multiply per-block waste by the block count to see the full picture. Option A wastes idle threads total; Option B wastes warp lanes total — over more waste, hidden behind its "zero idle threads" headline.

Answer: A: 3907 blocks, 1 000 192 threads, 192 idle, 0 wasted lanes. B: 1000 blocks, 1 000 000 threads, 0 grid-idle, but 24 wasted lanes/block = 24 000 total — the worse choice.

Verify: A: and ✓ (smallest count); waste ✓. B: exactly ✓, ✓ (legal); warps/block , waste/block ✓, total ✓.


Cell H — the exam twist (grid-stride loop)

Sometimes you launch fewer threads than elements on purpose (e.g. to fit a specific GPU). Each thread then loops, jumping forward by the total number of threads each time. That jump is the stride.

Figure — CUDA programming model basics

Read the figure first. The strip is a -element array, boxes numbered . Each box carries a small white t0…t7 tag showing which of the threads owns it — you can see the tags repeat because every thread jumps ahead by the stride of . The boxes owned by thread 3 are drawn in bright amber; the two curved amber arrows trace its journey . After , the next jump would land on , which is off the strip — so thread 3 stops. That is the loop condition i < N doing its job.

Forecast: thread 3 starts at element 3 and jumps by 8. Guess its list before computing.

  1. Compute the stride. . Why this step? The stride must equal the total thread count so the 8 threads tile the array without overlap — like 8 people counting off "1-8, 9-16, ..." repeatedly.
  2. March thread 3. Start ; then . Stop, because . Why this step? The loop condition i < N cleanly halts each thread when it runs off the end — this replaces the single if (idx < N) check with a loop that self-limits.
  3. Collect the survivors. The values that passed i < 20 are . So thread 3 handles — three elements from one thread. Why this step? This is the whole point of the grid-stride loop: threads cover elements by each doing a few passes.

Answer: thread 3 → elements 3, 11, 19.

Verify: Coverage count: elements (first pass, all 8 threads), (second pass), (third pass, only threads reach). Total ✓, every index hit exactly once (no two threads share a start, and the stride equals the thread count so passes never collide). ✓


Cell I — the 3-D grid

Volumes (medical scans, physics grids) and batched tensors need a third axis: blockIdx.z / threadIdx.z count depth (which slice). It is the same one-axis formula, now stamped out three times, then flattened with a nested version of the row-major trick.

Forecast: three independent sums, then one three-term flatten. Guess first — it uses the .z numbers.

  1. x-coordinate: . Why this step? Horizontal axis, one copy of the base formula.
  2. y-coordinate: . Why this step? Vertical axis, independent second copy.
  3. z-coordinate (depth): . Why this step? Depth axis, independent third copy — this is the new piece 3-D adds.
  4. Flatten depth-major with : Why this step? Skip whole slices, then whole rows, then across.

Answer: , flat index 124236.

Verify: Bounds: all ✓. Max legal flat index , and ✓. Unflatten: ✓; ✓; ✓.


Recall Self-test

Clean vs ragged: what does the +threadsPerBlock-1 trick achieve? ::: It forces integer division to round up (ceiling ) so we never launch too few blocks, without floating-point math. Reverse map: given globalIdx and blockDim, how do you recover blockIdx and threadIdx? ::: blockIdx globalIdx / blockDim (floor / integer divide), threadIdx globalIdx mod blockDim. 2-D flatten: why row × width + col? ::: Memory is 1-D; each full row of width pixels sits before the next, so row × width skips complete rows and + col walks the current one. 3-D flatten: what is the extra term and why? ::: — it skips whole 2-D slices before applying the 2-D formula. What is a warp and why 32? ::: A warp is a hardware bundle of 32 threads run in lockstep; 32 is a fixed NVIDIA hardware width (one instruction feeds 32 lanes). Shared vs global break-even: from how many reuses does shared memory win? ::: From 2 reuses ( gives ). Why prefer 256 over 1000 threads/block even though 1000 divides evenly? ::: is a multiple of the warp size (0 wasted lanes); leaves dead lanes per block ( across the grid).

See also: GPU Architecture Overview · Parallel Algorithm Design · OpenCL vs CUDA · Deep Learning Frameworks