Skip to content
Phuc's Blog
Go back

GPU Compute Architecture

Edit page
📌Series:Parallel Programming
Part 3 of 4
View all articles in this series

This blog gives you a clear and simple overview of how a GPU executes code, how threads run under the hood, and how to optimize your CUDA programs.

GPU Compute Architecture

Table of contents

Open Table of contents

1. Overview of GPU Architecture

Overview of a modern architecture of GPU Figure 1: Architectural overview of a modern GPU hierarchy.

Note
  • A GPU contains multiple SMs (Streaming Multiprocessors).
  • Each SM contains multiple SPs (Streaming Processors, commonly called CUDA Cores).

Hardware vs. Software Mapping

Hardware vs Software Figure 2: Mapping between software (CUDA) and physical hardware.

CUDA virtualizes physical hardware to make parallel programming simple and scalable:

Note
  • Block →\rightarrow Virtual SM
  • Thread →\rightarrow Virtual SP (CUDA Core)

Block Scheduling & Queue

Block queue Figure 3: Hardware Block scheduler dispatching Blocks from the queue into available SMs.

Important

Blocks execute in arbitrary order — they can run concurrently, sequentially, or interleaved across any SM.


Blocks Must Be Independent 🛑

To run on any GPU (from a laptop chip to a datacenter cluster), blocks must never depend on each other:

// ❌ INCORRECT: Busy-waiting across blocks causes deadlocks!
__global__ void bad_interblock(volatile int* flag, int* data) {
    if (blockIdx.x == 0 && threadIdx.x == 0) {
        while (*flag == 0) { } // Block 0 waits for Block 1
        data[0] = data[1];
    }
    if (blockIdx.x == 1 && threadIdx.x == 0) {
        data[1] = 42;
        *flag = 1;             // Block 1 might never be scheduled!
    }
}
Tip

How to communicate safely across blocks:

  1. Kernel boundaries: Split work into separate kernel launches. The end of a kernel acts as a global barrier.
  2. Atomic operations: Use atomic functions (atomicAdd, etc.) for race-free updates without waiting.

Cooperation Inside a Block 🤝

While different blocks are independent, threads inside the same block can share data and synchronize:

  1. Shared Memory (__shared__): Fast on-chip memory shared by all threads in that block.
  2. Barrier Synchronization (__syncthreads()):
    • Every thread in the block pauses at the barrier.
    • Execution resumes only after all threads in the block arrive.
    • Ensures memory writes made before the barrier are visible to all threads after it.

Barrier synchronization in time Figure 4: Barrier synchronization (__syncthreads) coordinating threads over time.

Barrier Pitfalls to Avoid ⚠️

1. Divergent Barriers

__syncthreads() must be reached by all threads in a block. Never put it inside a branch where threads split paths:

// ❌ WRONG: Threads split paths -> undefined behavior or hang
if (threadIdx.x % 2 == 0) {
    __syncthreads();
} else {
    __syncthreads();
}

// ✔️ CORRECT: Condition is identical for all threads in the block
if (blockIdx.x % 2 == 0) {
    __syncthreads(); // Safe: every thread in the block takes this path
}
2. Early Return

Calling return early strands the remaining threads waiting at __syncthreads():

// ❌ WRONG: Out-of-bounds threads exit early, stranding others
if (t >= count) return;
float v = a[start + count - 1 - t];
__syncthreads();
a[start + t] = v;

// ✔️ CORRECT: Guard only the memory work, not the barrier
float v = 0.0f;
if (t < count) v = a[start + count - 1 - t];
__syncthreads();                             // ALL threads arrive
if (t < count) a[start + t] = v;

2. Warps and SIMT Execution

What is a Warp? ⚡

In CUDA code, we write kernels as if each thread runs independently. But hardware executes threads in bundles: the GPU groups 32 threads into a unit called a Warp.

Tip

Put simply: 1 Warp = 32 threads running together.

Warp Hardware Execution Model Figure 5: Logical Thread Blocks partitioned into 32-thread hardware Warps executed by an SM.


How is a Block Divided into Warps? 🧩

Regardless of whether your block is 1D, 2D, or 3D, the hardware flattens threads into a 1D list and chunks them into groups of 32:

Warp m→Threads [32m…32m+31]\text{Warp } m \rightarrow \text{Threads } [32m \dots 32m + 31] Index=threadIdx.y×blockDim.x+threadIdx.x\text{Index} = \text{threadIdx.y} \times \text{blockDim.x} + \text{threadIdx.x} Index=threadIdx.z×(blockDim.x×blockDim.y)+threadIdx.y×blockDim.x+threadIdx.x\text{Index} = \text{threadIdx.z} \times (\text{blockDim.x} \times \text{blockDim.y}) + \text{threadIdx.y} \times \text{blockDim.x} + \text{threadIdx.x}

Practice Quiz: Warp Thread Mapping 🧠

Question 1: Block (16, 16)

Which (x,y)(x, y) threads form Warp 3?

Reveal Solution Total threads=16×16=256 threads (8 warps)\text{Total threads} = 16 \times 16 = 256 \text{ threads } (8 \text{ warps})

Warp 3 covers linear thread indices 96 to 127.

Each row has blockDim.x=16\text{blockDim.x} = 16 threads:

  • Threads 96…11196 \dots 111: row y=96/16=6y = 96 / 16 = 6 (x∈[0,15]x \in [0, 15]).
  • Threads 112…127112 \dots 127: row y=112/16=7y = 112 / 16 = 7 (x∈[0,15]x \in [0, 15]).

Answer: Warp 3 contains all threads from rows 6 and 7 (threadIdx.y∈{6,7}\text{threadIdx.y} \in \{6, 7\} for all threadIdx.x∈[0,15]\text{threadIdx.x} \in [0, 15]).

Question 2: Block (8, 8)

Which threads form Warp 1?

Reveal Solution Total threads=8×8=64 threads (2 warps)\text{Total threads} = 8 \times 8 = 64 \text{ threads } (2 \text{ warps})

Warp 1 covers linear thread indices 32 to 63.

With blockDim.x=8\text{blockDim.x} = 8:

  • y=32/8=4y = 32 / 8 = 4 through y=63/8=7y = 63 / 8 = 7.

Answer: Warp 1 contains rows 4, 5, 6, and 7 (threadIdx.y∈[4,7]\text{threadIdx.y} \in [4, 7] for all threadIdx.x∈[0,7]\text{threadIdx.x} \in [0, 7]).

Question 3: Block (4, 4, 4)

How many warps are created, and which zz values are in Warp 0?

Reveal Solution Total threads=4×4×4=64 threads →2 warps\text{Total threads} = 4 \times 4 \times 4 = 64 \text{ threads } \rightarrow 2 \text{ warps}

Each zz-slice contains 4×4=164 \times 4 = 16 threads:

  • z=0z = 0 covers threads 0…150 \dots 15.
  • z=1z = 1 covers threads 16…3116 \dots 31.

Answer: 2 warps. Warp 0 covers all threads in slices z=0z = 0 and z=1z = 1.

Question 4: Block (30, 2)

How many warps are created, and which threads are in Warp 1?

Reveal Solution Total threads=30×2=60 threads→2 warps\text{Total threads} = 30 \times 2 = 60 \text{ threads} \rightarrow 2 \text{ warps}

Warp 1 covers linear indices 32 to 59 (plus 4 inactive padding slots):

  • Index 32: y=32/30=1y = 32 / 30 = 1, x=32(mod30)=2x = 32 \pmod{30} = 2.
  • Index 59: y=59/30=1y = 59 / 30 = 1, x=59(mod30)=29x = 59 \pmod{30} = 29.

Answer: 2 warps. Warp 1 contains threads with threadIdx.y=1\text{threadIdx.y} = 1 and threadIdx.x∈[2,29]\text{threadIdx.x} \in [2, 29] (with 4 inactive padded threads).


What is Warp Divergence? ⚠️

Because all 32 threads in a warp share one instruction unit, what happens when an if / else condition evaluates to true for some threads and false for others?

This is called Warp Divergence (or Branch Divergence).

Warp Divergence Example Figure 6: Warp Divergence executing branching paths serially with inactive threads masked out.

Hardware Mechanism: Predicated Execution

The hardware cannot split one warp into two to run different branches simultaneously:

  1. Serial Execution: The warp executes the if path and else path one after another.
  2. Masking: During the if path, threads that took else are disabled (masked off). During the else path, threads that took if are disabled.
Warning

The output remains correct, but execution takes twice as long because the paths run serially!

How to Avoid Warp Divergence

Align branch conditions to multiples of 32 threads so all threads in a warp take the same branch together:

// ❌ HIGH DIVERGENCE: Adjacent threads alternate true/false
if (i % 2 == 0) {
    C[i] = A[i] * B[i];
} else {
    C[i] = A[i] / B[i];
}

// ✔️ NO DIVERGENCE: Threads are grouped by warps (multiples of 32)
// Warp 0 (i = 0..31):   (i / 32) is even -> all 32 threads multiply
// Warp 1 (i = 32..63):  (i / 32) is odd  -> all 32 threads divide
if ((i / 32) % 2 == 0) {
    C[i] = A[i] * B[i];
} else {
    C[i] = A[i] / B[i];
}
Tip

Branching on blockIdx never causes divergence because all threads in the same block share the same blockIdx.


3. Latency Hiding & Warp Scheduling 🔄

Why does an SM hold up to 64 warps (2,048 threads) when it only has physical cores to execute 1 to 4 instructions per cycle?

The reason is Latency Hiding:

If an SM held only as many threads as it had physical cores, the cores would sit idle for hundreds of cycles waiting for memory data. By keeping dozens of warps resident on the SM, the scheduler can immediately swap to another ready warp whenever a warp waits on memory.


The 4 Warp States 🚥

Inside each SM, the Warp Scheduler categorizes resident warps into four states:

Warp States Transition Flow Figure 7: Hardware transition flow across Active, Stalled, Eligible, and Selected warp states.

Warp StateMeaning
Active (Resident)Assigned to the SM with allocated registers (from block start to finish).
StalledWaiting on a dependency (e.g. DRAM data, barrier synchronization).
EligibleReady to execute right now (operands ready, execution unit available).
SelectedPicked by the scheduler to issue its instruction in the current clock cycle.

At every clock cycle, the scheduler checks which warps are Eligible, picks one or more (Selected), and sends their instructions to the cores.


Warp Scheduling Flow: Ready, Executing & Suspended 🔄

In practice, warps continuously cycle through a three-stage execution loop:

Warp Scheduling and Execution Lifecycle Figure 8: Warp scheduling cycle: Ready Warps, Executing Warp, and Suspended Warps waiting for memory requests.

  1. Ready Warps (Eligible Pool): Warps that have all data ready and wait to be picked.
  2. Executing Warp (Selected): The scheduler selects a Ready Warp and issues its instruction to the execution cores.
  3. Suspended Warps (Memory Request):
    • When an executing warp issues a memory request to off-chip DRAM, it moves into Suspended Warps.
    • The scheduler does not wait—it immediately picks another warp from Ready Warps.
  4. Return to Ready: When the memory data returns from DRAM to the SM, the warp moves back into Ready Warps.

Zero-Overhead Context Switching ⚡

On CPUs, thread switching requires saving registers to RAM and restoring them, wasting thousands of cycles.

On GPUs, all registers for all active warps live directly in the SM’s massive on-chip Register File. Switching warps costs zero clock cycles—the scheduler simply points to a different warp in the next clock cycle:

[Cycle 1] ──► Warp 8 is ready  ──► Dispatch Instruction (Memory Load)
[Cycle 2] ──► Warp 8 is WAITING for DRAM (Suspended)!
              Scheduler instantly switches with 0 penalty:
              ──► Warp 2 is ready (Eligible) ──► Dispatch Instruction (Math ALU)
[Cycle 3] ──► Warp 14 is ready (Eligible)    ──► Dispatch Instruction
  ...
[Cycle N] ──► Warp 8's DRAM data arrived!
              ──► Warp 8 returns to Ready pool ──► Resumes execution

Warp Scheduler Hardware Execution Timeline Figure 9: Warp Scheduler and Instruction Dispatch Units cycling through active Warps across time.


4. Hardware Resources & GPU Occupancy 📊

Occupancy measures how full the SM’s warp capacity is:

Occupancy=Active Warps Resident on SMMaximum Warps Supported by SM Hardware\text{Occupancy} = \frac{\text{Active Warps Resident on SM}}{\text{Maximum Warps Supported by SM Hardware}}

Resource Partitioning (The Limiting Bottlenecks)

Every resident block and warp needs a portion of the SM’s fixed hardware resources:

Resource Partitioning Figure 10: Dynamic hardware resource partitioning across resident blocks and warps in an SM.

Hardware ResourceLimit per SM (NVIDIA A100)Limit per SM (Tesla T4)
Max Block Slots32 blocks16 blocks
Max Warp / Thread Slots64 warps (2,048 threads)32 warps (1,024 threads)
32-bit Registers65,536 registers65,536 registers
Shared MemoryUp to 164 KBUp to 64 KB
Important

The Bottleneck Rule: The number of blocks that can reside on an SM is capped by whichever of these four resources runs out first.


Key Resource Pitfalls to Avoid ⚠️

  1. Block Size Too Small (e.g. 32 threads/block):
    • On an A100 (max 32 blocks/SM), choosing 32 threads/block fits:
32 blocks×32 threads=1,024 threads32 \text{ blocks} \times 32 \text{ threads} = 1,024 \text{ threads}
  1. The “Register Cliff” Effect:
    • On an A100 with 512 threads per block:
      • At 31 registers/thread: 4 blocks fit (2,0482,048 threads) →\rightarrow 100% Occupancy.
      • Increasing by just 2 registers to 33 registers/thread: each block takes 33×512=16,89633 \times 512 = 16,896 registers. The 65,536 registers only fit 3 blocks (50,68850,688 registers). The 4th block cannot fit →\rightarrow Occupancy drops sharply from 100% to 75%!

Practical Rules for Block Size Selection 🎯


5. Tools Cheatsheet: Inspecting & Profiling 🛠️

Here is a quick summary of the essential command-line tools and compiler flags for inspecting resources, occupancy, and branch divergence:

1. Compiler Flags (nvcc)

# 1. Print register, shared memory, and spill stats during compilation
nvcc -O3 -arch=sm_75 -Xptxas -v kernel.cu

# 2. Limit maximum registers per thread across the entire file
nvcc -maxrregcount=32 kernel.cu

# 3. Compile for profiling without aggressive optimizations (if needed for line debugging)
nvcc -G -arch=sm_75 kernel.cu

2. Kernel Launch Bounds Attribute

// Restrict kernel to at most 256 threads/block and guarantee at least 4 blocks fit per SM
__global__ void __launch_bounds__(256, 4) myKernel(...) {
    // ...
}

3. Profiling with NVIDIA Nsight Compute (ncu)

# 1. Measure achieved occupancy and compare with theoretical occupancy
ncu --section Occupancy ./my_app

# 2. Profile scheduler and warp stall states
ncu --section SchedulerStats --section WarpStateStats ./my_app

# 3. Check warp branch divergence (total divergent branch targets)
ncu --metrics smsp__sass_branch_targets_threads_divergent.sum ./my_app

# 4. Check warp execution efficiency (ratio of 32 active threads per executed instruction)
ncu --metrics smsp__thread_inst_executed_per_inst_executed.ratio ./my_app
Warning

Watch for Register Spilling: If spill stores or spill loads appear in the nvcc -Xptxas -v output, registers overflowed into slow off-chip DRAM (Local Memory). Never blindly restrict -maxrregcount without verifying spills remain 0!


6. Summary & Conclusion 🎯

To bring everything together, let’s revisit the four fundamental questions from the lecture:

PartCore QuestionSimple Answer
Part 1: From Grid to HardwareWhere do my blocks actually run?Thread blocks are distributed from a hardware Block Queue onto physical SMs. Multiple blocks can reside on the same SM concurrently, and they execute in completely arbitrary, independent order.
Part 2: Warps and SIMTHow do 32 threads share one instruction?The hardware groups threads into Warps (32 threads). A single instruction unit decodes and broadcasts one instruction to all 32 threads at once (SIMT). If threads branch (if/else), paths run serially with inactive lanes masked out.
Part 3: Latency HidingWhy does an SM hold far more threads than it has cores?Memory access (DRAM) takes 400–800 cycles, while math takes ~4 cycles. The SM holds many resident warps in on-chip registers so the scheduler can swap to a ready warp in 0 cycles whenever another warp is waiting for memory.
Part 4: Resources and OccupancyHow do I choose a block size?Pick a multiple of 32, start with 128–256 threads, and ensure at least 2 blocks fit per SM. Check register usage with nvcc -Xptxas -v to prevent register cliffs and memory spills, then verify with ncu --section Occupancy.

Edit page
Share this post:

Previous Post
Linear Regression from Scratch with PyTorch
Next Post
Self-Attention Mechanism

Comments