<<<CUDA C++ Coursegrid · block · warp · lane
Stage 2 · Execution model and memory04 / 15about 24 min

Warp and SM: the GPU's real unit of execution

The SIMT model, warp divergence, latency hiding, and why the number 32 shows up everywhere

After this lesson you can
  • Understand that a warp, not a thread, is the smallest unit the hardware schedules
  • See the cost of warp divergence, and rewrite code to avoid it
  • Know how a GPU hides memory latency: extra resident warps, not a bigger cache
  • Tell when to use __syncthreads() versus __syncwarp()

In the programming model you write threads, but the hardware does not schedule one thread at a time. 32 consecutive threads are bound into a warp, share one program counter, and execute the same instruction in lockstep. That is NVIDIA's SIMT (Single Instruction, Multiple Thread). Understanding warps is the line between writing CUDA that is correct and writing CUDA that is fast.

┌──────────────────────── SM ─────────────────────────┐
│  Warp Scheduler ×4   each cycle picks a ready warp  │
│         │                                           │
│  ┌──────┴──────┬───────────┬──────────┐             │
│  │ CUDA Core   │  LD/ST    │  SFU     │  Tensor     │
│  │  ×128       │  units    │          │  Core ×4    │
│  └─────────────┴───────────┴──────────┴────────────┘│
│                                                     │
│  Register File   256 KB (SM-wide, split per thread) │
│  Shared Memory / L1   up to 164 KB (configurable)   │
└─────────────────────────────────────────────────────┘
              │
        ┌─────┴─────┐
        │  L2 Cache │  chip-wide, tens of MB
        └─────┬─────┘
              │
        ┌─────┴─────┐
        │   HBM     │  device memory, tens of GB, 1-3 TB/s
        └───────────┘
Simplified SM layout (Ampere-class)

How a block actually runs

  1. 1.The scheduler assigns a whole block atomically to one SM. A block is never split across two SMs.
  2. 2.The SM slices the block into warps by linear tid. blockDim = 256 is 8 warps.
  3. 3.If blockDim is not a multiple of 32, the last warp is filled out but some threads stay inactive. Those slots waste issue bandwidth.
  4. 4.Each cycle, the warp scheduler picks a ready warp among those resident on the SM and issues an instruction. When one warp is waiting on memory, the scheduler switches to another. The switch costs nothing, because every warp's registers stay physically allocated.

Warp divergence: what an if really costs

A warp has one program counter. When threads in a warp take different sides of an if, the hardware cannot run both paths at once. It serializes them: first the then path (threads that took else are masked off and idle), then the else path (the mask flips). The two paths add, and that sum is warp divergence.

The worst kind of branch
1__global__ void bad(float* x) {2    int i = blockIdx.x * blockDim.x + threadIdx.x;3 4    if (i % 2 == 0) {         // half the warp each way: 100% divergence5        x[i] = expensiveA(x[i]);6    } else {7        x[i] = expensiveB(x[i]);8    }9    // Wall time ≈ expensiveA + expensiveB, not one or the other10}
Align the branch at warp granularity: no divergence
1__global__ void good(float* x) {2    int i = blockIdx.x * blockDim.x + threadIdx.x;3 4    if ((i / warpSize) % 2 == 0) {   // every thread in the warp takes the same path5        x[i] = expensiveA(x[i]);6    } else {7        x[i] = expensiveB(x[i]);8    }9    // Each warp runs one path. Cost falls back to a true either-or10}

Synchronization: __syncthreads and __syncwarp

Block-wide barrier
1__global__ void stencil(const float* in, float* out, int n) {2    __shared__ float tile[BLOCK + 2 * RADIUS];3 4    int gid = blockIdx.x * blockDim.x + threadIdx.x;5    int lid = threadIdx.x + RADIUS;6 7    tile[lid] = in[gid];                       // each thread writes its own cell8    if (threadIdx.x < RADIUS) {                // the first few threads also copy the halo9        tile[lid - RADIUS]     = in[gid - RADIUS];10        tile[lid + blockDim.x] = in[gid + blockDim.x];11    }12 13    __syncthreads();     // barrier: nobody reads until the whole tile is written14 15    float sum = 0.0f;16    for (int d = -RADIUS; d <= RADIUS; ++d) sum += tile[lid + d];17    out[gid] = sum;18}

Starting with Volta (sm_70), NVIDIA added Independent Thread Scheduling: each thread in a warp has its own program counter, and after divergence they are no longer guaranteed to reconverge on their own. Consequence: old code that assumed "threads in a warp are implicitly in sync, so no barrier is needed" (especially warp-level reductions) is wrong after Volta. Every intra-warp data exchange now needs an explicit masked sync.

The correct form after Volta
1// Wrong: Kepler-era pattern. After Volta this is no longer guaranteed2volatile float* v = sdata;3if (tid < 32) { v[tid] += v[tid + 32]; v[tid] += v[tid + 16]; /* ... */ }4 5// Correct: explicit sync6if (tid < 32) {7    float val = sdata[tid] + sdata[tid + 32];8    for (int offset = 16; offset > 0; offset >>= 1) {9        val += __shfl_down_sync(0xffffffff, val, offset);10    }11    if (tid == 0) out[blockIdx.x] = val;12}

Self-check

Answer in your head first, then open the solution. If you cannot, reread that section.