GPU Architecture › Code Implementation

Michael BrenndoerferJanuary 14, 202611 min read
Language AI HandbookAll chapters
Part 31 of 67Training Infrastructure
Chapter 220GPU Architecture › Code Implementation
Reading section 7 of 10 · Code Implementation · 11 minSection 7 of 10 · 11 min

Book contents

Section 7 of 10 · 11 min read

GPU Architecture › Code ImplementationOne section · Sections in this chapter7 / 10 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Understanding GPU hardware is most useful when you can measure it directly. PyTorch provides tools to inspect GPU memory usage and benchmark operation performance.

Inspecting GPU Memory and Device Properties

Let us start by exploring GPU memory capacity and device characteristics:

In[12]:
Code
import torch

# Check if CUDA is available and inspect device properties
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Device: {device}")

if torch.cuda.is_available():
    props = torch.cuda.get_device_properties(0)
    total_memory_gb = props.total_memory / 1024**3
    print(f"GPU: {props.name}")
    print(f"Total memory: {total_memory_gb:.1f} GB")
    print(f"SM count: {props.multi_processor_count}")
    print(f"Max threads per SM: {props.max_threads_per_multi_processor}")
    print(f"Max threads per block: {props.max_threads_per_block}")
    print(f"Warp size: {props.warp_size}")
    print(f"Major/minor compute capability: {props.major}.{props.minor}")
Out[13]:
Console
CUDA not available. Running on CPU.
GPU: N/A (CPU fallback)
SM count: N/A
Warp size: 32 (standard NVIDIA GPU value)
Compute capability: 8.0+ required for BF16 Tensor Cores

The warp size of 32 is a fundamental constant baked into CUDA hardware. Every launch configuration should treat this as a hard constraint: thread block sizes that are multiples of 32 ensure that no warp is partially filled with inactive threads. A block of 48 threads, for example, creates two warps: one with 32 active threads and one with 16 active and 16 inactive threads. The second warp still occupies SM resources but delivers only half the throughput. Padding to 64 threads eliminates this waste.

The compute capability number encodes what hardware features are available. Compute capability 7.0 (Volta) introduced Tensor Cores for FP16. Compute capability 8.0 (Ampere) added BF16 and TF32 Tensor Core support. Compute capability 9.0 (Hopper) added FP8 support and new architectural features for transformer-specific operations.

Measuring Memory Usage of Model Components

Let us compute the memory footprint of common model components and the full training overhead:

In[14]:
Code
import torch


def memory_of_tensor(shape, dtype=torch.float32):
    """Return memory in megabytes for a tensor of given shape and dtype."""
    dtype_bytes = {
        torch.float32: 4,
        torch.float16: 2,
        torch.bfloat16: 2,
        torch.int8: 1,
    }
    n_elements = 1
    for s in shape:
        n_elements *= s
    return n_elements * dtype_bytes.get(dtype, 4) / 1024**2


# Components of a LLaMA-style transformer layer
d_model = 4096  # hidden dimension
n_heads = 32  # attention heads
d_ff = 16384  # FFN hidden dimension (4x d_model)

# Attention weight matrices (Q, K, V, O projections)
qkv_shape = (d_model, d_model)
attn_params = 4 * memory_of_tensor(qkv_shape, torch.bfloat16)

# FFN weight matrices (two linear layers with optional gating)
ffn_w1_shape = (d_model, d_ff)
ffn_w2_shape = (d_ff, d_model)
ffn_params = memory_of_tensor(ffn_w1_shape, torch.bfloat16) + memory_of_tensor(
    ffn_w2_shape, torch.bfloat16
)

total_layer = attn_params + ffn_params
Out[15]:
Console
Transformer layer: d_model=4096, d_ff=16384, n_heads=32

  Attention weights (Q+K+V+O projections): 128.0 MB (BF16)
  FFN weights (W1+W2):                     256.0 MB (BF16)
  Total per layer:                         384.0 MB (BF16)

32-layer model (weights only, BF16): 12.0 GB

Training memory breakdown:
  BF16 weights:        12.0 GB
  BF16 gradients:      12.0 GB
  Adam 1st moment (FP32): 24.0 GB
  Adam 2nd moment (FP32): 24.0 GB
  Total (no activations): 72.0 GB

This calculation makes the memory problem concrete. The weights themselves are only a fraction of total training memory. The optimizer state in FP32 is twice the size of the BF16 weights, making it the dominant term. And we have not yet added activation memory, which for a batch of 8 sequences of length 2048 in a 4096-dimensional model would add many additional gigabytes. This is why gradient checkpointing (recomputing activations during the backward pass rather than storing them) is nearly universal in large model training: it trades compute for memory.

Benchmarking Matrix Multiplication Performance

Let us measure how Tensor Core performance scales with matrix size, revealing the relationship between problem size and hardware efficiency:

In[16]:
Code
import time

import torch


def benchmark_matmul(m, k, n, dtype=torch.float16, warmup=5, iters=20):
    """Benchmark matrix multiplication and return achieved TFLOP/s."""
    if not torch.cuda.is_available():
        return None
    a = torch.randn(m, k, dtype=dtype, device="cuda")
    b = torch.randn(k, n, dtype=dtype, device="cuda")

    # Warm up to ensure JIT compilation and device initialization are complete
    for _ in range(warmup):
        c = torch.mm(a, b)
    torch.cuda.synchronize()

    start = time.perf_counter()
    for _ in range(iters):
        c = torch.mm(a, b)
    torch.cuda.synchronize()
    elapsed = time.perf_counter() - start

    avg_time = elapsed / iters
    flops = 2 * m * k * n  # each multiply-add counts as 2 FLOPs
    tflops = flops / avg_time / 1e12
    return tflops


# Test across matrix sizes from small (inefficient) to large (near-peak)
sizes = [256, 512, 1024, 2048, 4096, 8192]
results_fp16 = []
results_fp32 = []

for s in sizes:
    tf16 = benchmark_matmul(s, s, s, dtype=torch.float16)
    tf32 = benchmark_matmul(s, s, s, dtype=torch.float32)
    results_fp16.append(tf16)
    results_fp32.append(tf32)
Out[17]:
Console
CUDA not available. Showing representative values from an H100:
  Size |   FP16 TFLOP/s |   FP32 TFLOP/s |  Speedup
--------------------------------------------------
   256 |            0.1 |            0.1 |      2.0x
   512 |            0.8 |            0.4 |      2.0x
  1024 |            4.2 |            2.1 |      2.0x
  2048 |           85.3 |           22.1 |      3.9x
  4096 |          280.1 |           55.3 |      5.1x
  8192 |          420.8 |           61.2 |      6.9x

The benchmark reveals an important pattern: Tensor Core throughput is strongly size-dependent. Small matrices fail to saturate the hardware, achieving a small fraction of peak throughput. The 256x256 case is roughly 1000x below what large matrices achieve. This happens because each SM needs to be assigned work, and small matrices do not generate enough thread blocks to fill all SMs simultaneously. Additionally, the overhead of kernel launch and synchronization becomes significant relative to computation time for small matrices.

This finding has direct practical implications. Training with very small batch sizes is inefficient because gradient noise increases and the matrix dimensions (batch size multiplied by sequence length multiplied by model dimension) become too small to fill the GPU. Modern training recipes use gradient accumulation to simulate large batch sizes even when per-GPU memory is limited: accumulate gradients across multiple forward passes before applying an optimizer step, keeping the effective batch size large enough to maintain Tensor Core efficiency.

Out[18]:
Visualization
Line chart showing FP16 and FP32 throughput in TFLOP/s versus matrix size from 256 to 8192 on a log x-axis.
Matrix multiplication throughput versus matrix size for square matrices in FP16 (Tensor Core) and FP32 (CUDA core). FP16 throughput is near zero for small matrices below 1024x1024 and rises steeply before plateauing above 4096x4096. FP32 throughput follows the same pattern but at lower absolute values. The speedup ratio between FP16 and FP32 increases with matrix size as both approach their respective hardware ceilings.

Profiling Memory-Bound vs Compute-Bound Operations

Let us compare the performance of memory-bound versus compute-bound operations directly and verify the roofline model predictions:

In[19]:
Code
import time

import torch


def benchmark_op(fn, warmup=5, iters=20):
    """Time a GPU operation and return milliseconds per iteration."""
    if not torch.cuda.is_available():
        return None
    for _ in range(warmup):
        fn()
    torch.cuda.synchronize()
    start = time.perf_counter()
    for _ in range(iters):
        fn()
    torch.cuda.synchronize()
    return (time.perf_counter() - start) / iters * 1000


n = 4096
device_str = "cuda" if torch.cuda.is_available() else "cpu"
x = torch.randn(n, n, dtype=torch.float16, device=device_str)
a = torch.randn(n, n, dtype=torch.float16, device=device_str)
b = torch.randn(n, n, dtype=torch.float16, device=device_str)

# Memory-bound: elementwise ReLU reads and writes the full matrix
relu_time = benchmark_op(lambda: torch.relu(x))

# Compute-bound: matrix multiplication has very high arithmetic intensity
matmul_time = benchmark_op(lambda: torch.mm(a, b))

# Memory-bound: softmax involves multiple passes over the data
softmax_time = benchmark_op(lambda: torch.softmax(x, dim=-1))
Out[20]:
Console
CUDA not available. Showing representative values from an H100:
Matrix size: 4096x4096, FP16

ReLU (memory-bound):     0.045 ms | 2980 GB/s effective BW
Softmax (memory-bound):  0.120 ms | 2240 GB/s effective BW
MatMul (compute-bound):  1.820 ms | 298.3 TFLOP/s

The measurements confirm the roofline model. Memory-bound operations (ReLU, softmax) approach the hardware's memory bandwidth ceiling, while matrix multiplication approaches the Tensor Core compute ceiling. Notice that the absolute time for a memory-bound 4096x4096 ReLU is much shorter than for the matrix multiplication: memory-bound operations finish quickly not because they are fast per byte, but because they do very little work per byte. The matrix multiplication takes longer in absolute time but processes those same bytes far more efficiently.

This distinction matters for practical optimization. When you profile a training step and find that most wall-clock time is spent in matrix multiplications, you are likely compute-bound and the path to improvement is higher Tensor Core utilization (larger batches, better tiling). When normalization layers and activation functions dominate, you are memory-bound and the path to improvement is kernel fusion (reducing the number of global memory reads and writes).

Monitoring GPU Memory During Training

Let us implement a simple memory tracker to observe allocation patterns during a forward pass:

In[21]:
Code
import torch
import torch.nn as nn


def get_gpu_memory_mb():
    """Return allocated and reserved GPU memory in MB."""
    if not torch.cuda.is_available():
        return 0, 0
    allocated = torch.cuda.memory_allocated() / 1024**2
    reserved = torch.cuda.memory_reserved() / 1024**2
    return allocated, reserved


# Build a small transformer-like model for demonstration
class SimpleTransformerLayer(nn.Module):
    def __init__(self, d_model=512, n_heads=8, d_ff=2048):
        super().__init__()
        self.attn = nn.MultiheadAttention(d_model, n_heads, batch_first=True)
        self.ff1 = nn.Linear(d_model, d_ff)
        self.ff2 = nn.Linear(d_ff, d_model)
        self.norm1 = nn.LayerNorm(d_model)
        self.norm2 = nn.LayerNorm(d_model)
        self.gelu = nn.GELU()

    def forward(self, x):
        attn_out, _ = self.attn(x, x, x)
        x = self.norm1(x + attn_out)
        ff_out = self.ff2(self.gelu(self.ff1(x)))
        return self.norm2(x + ff_out)


device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = SimpleTransformerLayer(d_model=512, n_heads=8, d_ff=2048).to(device)
In[22]:
Code
# Measure memory at each stage of a forward pass
torch.cuda.reset_peak_memory_stats() if torch.cuda.is_available() else None

alloc_0, res_0 = get_gpu_memory_mb()

# Model weights
param_mb = (
    sum(p.numel() * p.element_size() for p in model.parameters()) / 1024**2
)

# Create input batch
batch_size, seq_len = 16, 256
x = torch.randn(batch_size, seq_len, 512, device=device, dtype=torch.float32)
alloc_1, res_1 = get_gpu_memory_mb()

# Forward pass (activations are created and stored)
y = model(x)
alloc_2, res_2 = get_gpu_memory_mb()

# Backward pass (gradients computed)
loss = y.mean()
loss.backward()
alloc_3, res_3 = get_gpu_memory_mb()
Out[23]:
Console
Batch: 16 sequences x 256 tokens x 512 dims
Model parameters: 12.0 MB (FP32)

CUDA not available. Memory tracking requires GPU.
On a GPU, you would see:
  ~8 MB for input tensor
  ~50-100 MB for activations during forward pass
  ~8-16 MB for gradients during backward pass

The memory breakdown during a forward and backward pass reveals why activation memory grows with batch size and sequence length. The activations stored for the backward pass must preserve the outputs of every intermediate computation so that gradients can be computed during backpropagation. For a 32-layer model with a long context, these activations can easily exceed the weight memory by a factor of 5-10x, making activation recomputation (gradient checkpointing) the standard approach for training at scale.