↩ Contents Illustrated GPU Kernels for ML · Part 2
Part 2 of 9 · Foundations

The Memory Hierarchy

Five tiers, four orders of magnitude of bandwidth, and two access rules — coalescing and bank conflicts — that between them explain most of the performance you are missing. This is the part with the swizzle diagram.

Follows Part 1.  ·  Everything here is hardware behaviour you cannot change; the kernel techniques in Parts 4–7 are all negotiations with it.

A kernel is a plan for moving data. The arithmetic is almost incidental — an H100 can issue 989 TFLOP/s of BF16 matmul but only read 3.35 TB/s from memory, so for every byte you fetch you had better find 295 floating-point operations to do with it. Everything in this part is about where a byte can live, what it costs to get it, and the two ways you can accidentally throw away 8× or 32× of your bandwidth without any warning from the compiler.

2.1 The map

Five tiers, from the fastest thing on the chip to the slowest. The numbers are for one H100 SXM5.

The memory hierarchy of one H100, drawn with bandwidth on a log scale. Note two things: the aggregate register file is the same order of magnitude as the whole L2 cache (33.8 MB against 50 MB), and there is roughly a 30× bandwidth gap between registers and HBM. The register figure is an estimate — NVIDIA does not publish register-file bandwidth — but its order is not in doubt. Every optimization in Parts 4–7 is an attempt to move work up this diagram.
TierCapacity (H100)LatencyAggregate BWManaged by
Registers256 KB/SM · 33.8 MB total~4 cyc~100 TB/s*Compiler. You influence it with __launch_bounds__ and by how much work each thread does.
Shared memoryup to 228 KB/SM · 30 MB total29 cyc33.5 TB/sYou. Explicitly allocated, explicitly indexed, explicitly synchronized.
L1 cache256 KB/SM minus the SMEM carveout41 cyc~33 TB/sHardware, with cache-policy hints.
L2 cache50 MB, two partitions263 cyc~7.8 TB/s*Hardware, plus an explicit persistence window.
HBM380 GB479 cyc3.35 TB/sHardware. Your only lever is how much you touch.

* L2 bandwidth is an estimate, not an NVIDIA specification: Luo et al., Table V measure 3,942.4 B/clock aggregate on H800; applying an H100 1.98 GHz clock gives about 7.8 TB/s. HBM bandwidth is the published NVIDIA H100 figure.

The ratio that drives everything

Shared memory delivers 128 bytes per clock per SM — 32 banks × 4 B — and that number has not changed since Maxwell. Aggregate, that is 33.5 TB/s on an H100 against 3.35 TB/s of HBM: exactly 10×. On a B200 it is 37.5 vs 7.7 TB/s, only 4.9×. The SMEM:HBM ratio is shrinking every generation, which is why Hopper and Blackwell feed the tensor cores directly from shared memory and why Blackwell added a separate 256 KB tensor-memory scratchpad. Part 6.

2.2 Global memory: sectors, cache lines, and coalescing

Threads do not read bytes from DRAM. The memory system reads sectors, and a sector is 32 bytes, naturally aligned. Four sectors make a 128-byte cache line. The Programming Guide states the rule directly:

CUDA C++ Programming Guide

“Global memory is accessed via 32-byte memory transactions. When a CUDA thread requests a word of data from global memory, the relevant warp coalesces the memory requests from all the threads in that warp into the number of memory transactions necessary to satisfy the request… For perfectly coalesced access to 4-byte data elements, 4 global memory transactions will be required. In the worst case, 32 global memory transactions may be required to satisfy the addresses requested by a single load instruction from a single warp.”

So the question for every load instruction is: how many distinct 32-byte sectors do the 32 lanes of this warp touch? The best case is four — 32 lanes × 4 bytes = 128 contiguous bytes. The worst case is thirty-two, and then you are using one eighth of your bandwidth to move data you will throw away.

interactive One warp issuing a 4-byte load per lane. The top strip is the address space in 32-byte sectors; the bottom grid is the 32 lanes. Change the stride and the starting offset and watch the sector count — and therefore the wasted bandwidth — move.

Three cases are worth internalising. Write the byte address of lane l as base + 4·l and count the sectors it crosses:

  • Stride 1, aligned — lane l reads byte 4·l, so the warp spans bytes 0–127: sectors 0, 1, 2, 3. Four sectors, 100% efficiency. This is what y[i] = a*x[i] + y[i] gives you when i = blockIdx.x*blockDim.x + threadIdx.x.
  • Stride 1, misaligned by 4 bytes — lane l reads byte 4·l + 4; the span 4–131 now touches sectors 0 through 4. Five sectors, 80% efficiency. Costs you one extra sector per warp; often partially recovered from L2 because the neighbouring warp already pulled that line.
  • Stride 8 floats — lane l reads byte 32·l, and since one sector is exactly 32 bytes every lane opens a fresh one: 32 sectors, 12.5% efficiency. Any larger stride behaves the same. This is the column-major access pattern, and it is the single most common reason a "simple" kernel runs 8× slower than it should.

2.2.1 Why the block shape decides this

Recall from Part 1 that warps are cut along the linear thread index, so threadIdx.x varies fastest within a warp. That makes the following two kernels — identical in every other respect — differ by 8×:

// dim3 block(32, 8);  A is row-major, N columns.

// COALESCED: within a warp, tid.y is fixed and tid.x runs 0..31,
// so the 32 lanes read 32 consecutive floats = 4 sectors.
int row = blockIdx.y * 8 + threadIdx.y;
int colM = blockIdx.x * 32 + threadIdx.x;
float v = A[row * N + colM];

// UNCOALESCED: swap the roles. Now the 32 lanes of a warp read
// addresses N*4 bytes apart -> 32 separate sectors.
float w = A[colM * N + row];
# Triton makes the same distinction, just with block pointers.
offs_m = pid_m * BLOCK_M + tl.arange(0, BLOCK_M)
offs_n = pid_n * BLOCK_N + tl.arange(0, BLOCK_N)

# Coalesced: the LAST axis of the offset tensor is contiguous in memory.
a = tl.load(A + offs_m[:, None] * stride_am + offs_n[None, :] * stride_an)

# If stride_an != 1 you have a strided load. Triton will still
# emit it, and it will still cost you sectors.
Same data, same instruction count, 8× the DRAM traffic.
The compiler will not warn you

Uncoalesced access is not an error, a warning, or even unusual-looking code. It is only visible in a profiler, as l1tex__t_sectors_per_request being 32 instead of 4, or in Nsight's "Uncoalesced Global Access" rule. Part 9 covers reading these.

2.3 Vectorized loads, and bytes in flight

A lane can load 1, 2, 4, 8 or 16 bytes in a single instruction, provided the address is naturally aligned to that size. Using the wide forms does two things at once: it cuts the instruction count by 4×, and — more importantly — it quadruples the bytes each thread has in flight.

That second effect is Little's Law again. From Part 1: under the stated latency estimate, an H100 needs ~915 kB (893 KiB) of memory traffic in flight to saturate HBM, which is 3.4 bytes per thread at full occupancy. A B200 needs more than 15. A kernel where each thread has one 4-byte load outstanding is structurally incapable of saturating a B200's memory system, no matter how good its access pattern is.

// 1 instruction, 16 bytes per lane, 128 bytes per QUARTER-warp.
// Requires x to be 16-byte aligned and n divisible by 4.
const float4* x4 = reinterpret_cast<const float4*>(x);
float4 v = x4[i];                 // compiles to LDG.E.128
v.x *= a; v.y *= a; v.z *= a; v.w *= a;

// cudaMalloc always returns >=256-byte-aligned pointers, so the base
// is fine; it is your OFFSETS that break alignment.
The same trick appears in Triton as tl.max_contiguous/tl.multiple_of hints, and automatically when your block sizes are powers of two.

One warp issuing LDG.E.128 requests 512 bytes — sixteen sectors, four cache lines — in one instruction. Same efficiency and payload as four separate warp-wide 4-byte coalesced load instructions, one quarter of the instructions, four times the memory-level parallelism per thread. This is why many high-performance kernels use float4 or int4 as their unit of global traffic when alignment and layout permit it.

2.4 The caches

2.4.1 L1 and shared memory are the same silicon

L1 and shared memory have been one physical array since Volta, split at kernel launch between hardware-managed cache and programmer-managed scratchpad. The array has grown every generation — 96 KB on Volta, 192 KB on A100, 256 KB per SM on Hopper and Blackwell (128 KB on consumer parts) — and so has the scratchpad's maximum share: 164 KB of shared memory per SM on A100, 228 KB on H100 and B200. Selecting the 228 KB carveout leaves 28 KB of L1; because CUDA reserves 1 KB per block, one block can request at most 227 KB of it. That is a real trade, and for a well-tiled kernel it is usually the right one — you are replacing a cache that guesses with a scratchpad that knows.

// Static __shared__ arrays are capped at 48 KB. Above that: dynamic + opt-in.
cudaFuncSetAttribute(kernel, cudaFuncAttributeMaxDynamicSharedMemorySize, 227*1024);

// The carveout is a HINT; percentages round UP to a supported capacity.
cudaFuncSetAttribute(kernel, cudaFuncAttributePreferredSharedMemoryCarveout, 100);
Supported SMEM capacities per SM on Hopper (Programming Guide, compute capability 9.0): 0, 8, 16, 32, 64, 100, 132, 164, 196, 228 KB. The per-block opt-in maximum is one step below the per-SM figure: 227 KB on H100 and B200, 163 KB on A100, 99 KB on consumer parts (compute capability 8.6/8.9) — a kernel that asks Hopper for 227 KB will not launch on an Ada card.

2.4.2 L2 is big, shared, and not uniform

40 MB on an A100, 50 MB on an H100, ~126 MB on a B200 — large enough that for many LLM decode steps the entire activation working set is L2-resident, and only the weights come from HBM. But L2 is physically split into two partitions, each closer to half the GPCs. Crossing that boundary costs measurably more latency and bandwidth, and on a B200 the split is also a die boundary: measured 21 TB/s within a partition against 16.8 TB/s across it.

NVIDIA publishes the 126 MB GB200 L2 capacity in its Blackwell Tuning Guide. The 21/16.8 TB/s partition-bandwidth figures are separate original Chips and Cheese measurements, not NVIDIA specifications.

This is why tile scheduling order matters. A GEMM that walks output tiles in row-major order streams a fresh row of B through L2 for every tile; one that walks them in small 2D groups reuses what is already there. Part 5 measures the difference.

You also get an explicit lever: a persistence window that marks a range of global memory as preferentially retained in L2.

// Set aside part of L2 for persisting accesses (max 75% of L2 on A100/H100).
cudaDeviceSetLimit(cudaLimitPersistingL2CacheSize, size);

cudaStreamAttrValue attr;
attr.accessPolicyWindow.base_ptr  = kv_cache;      // hot, reused every step
attr.accessPolicyWindow.num_bytes = bytes;
attr.accessPolicyWindow.hitRatio  = 0.6f;         // <1.0 avoids LRU thrash
attr.accessPolicyWindow.hitProp   = cudaAccessPropertyPersisting;
attr.accessPolicyWindow.missProp  = cudaAccessPropertyStreaming;
cudaStreamSetAttribute(stream, cudaStreamAttributeAccessPolicyWindow, &attr);
Compute capability 8.0+. Disabled entirely under MIG. A hitRatio below 1.0 makes the hardware mark only that fraction of the window as persisting, which is what you want when several kernels share L2.

At the instruction level, PTX exposes cache operators for the same idea — ld.global.cg to skip L1 entirely, .cs ("streaming", evict-first) for data you will touch once, .L1::evict_last for data you want kept. In CUDA C++ these surface as __ldcg, __ldcs, __stwt, and cuda::annotated_ptr.

A widely repeated error

__ldg() does not bypass L1 on Volta and later. It marks a load as read-only, which lets the compiler drop aliasing conservatism — the same effect you get by declaring pointers const __restrict__. The intrinsic that actually bypasses L1 is __ldcg() (PTX .cg). The Guide still describes __ldg as a load through the “read-only data cache”; that cache has been the same silicon as L1 since Volta.

2.5 Shared memory and its 32 banks

Shared memory is a scratchpad: no tags, no eviction, no misses. It is organised as 32 banks, each 4 bytes wide, each serving one 32-bit word per clock. Successive 32-bit words live in successive banks, so:

bank(address) = (address / 4) mod 32

A warp-wide access is conflict-free if the 32 lanes hit 32 different banks — or the same word, which is broadcast for free. Anything else serialises:

CUDA C++ Programming Guide

“If multiple addresses of a memory request map to the same memory bank, the accesses are serialized. The hardware splits a memory request that has bank conflicts into as many separate conflict-free requests as necessary, decreasing the effective bandwidth by a factor equal to the number of separate memory requests… for read accesses, the word is broadcast to the requesting threads.”

So an N-way conflict costs exactly N×. The pathological case is 32-way, and you hit it with the most natural code imaginable.

interactive A 32×32 float tile in shared memory, and what happens when a warp reads a row versus a column. The bank map on the right shows which of the 32 banks each lane lands on; stacked cells are a conflict. Try the padded and swizzled layouts — both fix the column read, but only one of them survives contact with tensor cores.

2.5.1 Why the column access is 32-way

With __shared__ float tile[32][32], element tile[r][c] lives at word index 32r + c, so its bank is (32r + c) mod 32 = c. The row index has no effect on the bank. Now run the two natural warp accesses lane by lane, say for row/column 7:

row 7:   lane l reads word 32·7 + l  →  bank l  — 32 distinct banks, one cycle
col 7:   lane l reads word 32·l + 7  →  bank 7  — one bank, 32 cycles

A warp reading a column asks one bank for 32 different words, and gets them one per cycle.

2.5.2 Padding: the cheap fix that breaks later

Declare tile[32][33] instead. Now tile[r][c] is at word 33r + c, bank (33r + c) mod 32 = (r + c) mod 32. Reading column c gives banks c, c+1, c+2, … — all 32 distinct. Conflict gone, for the price of 3.1% of your shared memory.

This one-float pad is right for the hand-written FP32 access above. It does not compose with the standard tensor-core layouts: a 33-float row is 132 bytes, so every row after the first loses the 16-byte alignment expected by ldmatrix, wgmma descriptors and TMA layouts. Padding is not categorically forbidden if the padded stride preserves every required alignment, but the naive +1 fix does not. Which brings us to the usual alternative.

2.6 Swizzling: the fix that keeps its alignment

A swizzle permutes addresses within a power-of-two block instead of expanding the block. The trick is to XOR the 16-byte-chunk index with the row index: for any fixed row, c ⊕ r is a permutation of that row's chunks — apply it twice and you are back where you started — so every element still has exactly one home and the array stays dense. And because the permutation moves whole 16-byte chunks and never touches the bits inside a chunk, 16-byte alignment survives.

CUTLASS expresses the whole family with three integers:

Swizzle<B, M, S>:   phys = off XOR ((off & yyy_msk) >> S)   where  yyy_msk = ((1<<B)−1) << (M + S)

M is how many low bits to leave alone, B is how many bits participate, S is the shift. Every swizzle mode that TMA and wgmma accept uses M = 4 (the low 16 bytes are never permuted) and S = 3 (the pattern repeats every 8 rows):

ModeSwizzle<B,M,S>AtomEffect on the 16 B chunk index
NONE / interleavedSwizzle<0,4,3>8 × 16 Bidentity
32 BSwizzle<1,4,3>8 × 32 Bchunk ^= (row >> 2), 2 chunks/row
64 BSwizzle<2,4,3>8 × 64 Bchunk ^= (row >> 1), 4 chunks/row
128 BSwizzle<3,4,3>8 × 128 Bchunk ^= row, 8 chunks/row

The 128 B mode is the one you want by default: its atom is 8 rows × 128 bytes, which matches both a full cache line on the global side and a full conflict-free warp access on the shared side. In C it is one line:

offset ^= ((offset >> 3) & (0b111 << 4));

The Swizzle<B, M, S> parameterisation is CUTLASS’s (cute::Swizzle); the same three non-trivial patterns are the CU_TENSOR_MAP_SWIZZLE_32B/64B/128B values accepted by cuTensorMapEncodeTiled.

The figure below is the important one in this part. It shows a 128-byte-swizzled 8×8 grid of 16-byte chunks, and what happens when a tensor-core load asks for the same logical chunk from all 8 rows — which is exactly what ldmatrix.m8n8 does.

interactive XOR swizzle, at 16-byte-chunk granularity. Each cell is one 16 B chunk; the number is the logical chunk index and the colour is the bank group it physically occupies. Switch modes to see the permutation. Then pick a logical column and watch what an 8-row tensor-core load actually touches: eight different bank groups instead of one.
Why this is the layout tensor cores demand

ldmatrix.m8n8.b16 loads eight rows of sixteen bytes — and the 8×16 B no-swizzle atom is exactly one such operand. Row-major, those eight addresses are 128 B apart, so all eight land in the same four banks: an 8-way conflict on every single operand load. Swizzled by 128 B, chunk c of row r lives at physical chunk c ⊕ r, so the eight rows land in eight distinct bank groups. Conflict-free, still 16 B aligned, still dense. Part 6 does the full derivation.

One constraint to know before you meet it: with 128 B swizzle the innermost TMA box dimension must be ≤ 128 bytes, which for BF16 means at most 64 elements. A BLOCK_K = 128 BF16 K-major tile therefore needs a split or reoriented box, a different contiguous dimension, or no swizzle. A smaller non-zero swizzle does not help: the Driver API limits tighten to 64 and 32 bytes for the 64 B and 32 B modes. Violating this constraint is a common cause of CUDA_ERROR_INVALID_VALUE out of cuTensorMapEncodeTiled.

2.7 Getting data in: from LDG to cp.async to TMA

Historically, staging a tile from global to shared memory meant a round trip through registers: LDG into a register, STS out to shared. That burns registers, burns issue slots, and forces the thread to wait.

Ampere added cp.async, which copies global → shared without touching the register file, asynchronously, with group-based completion tracking. Hopper added TMA, which goes further: a single thread describes a multidimensional tile copy, and dedicated hardware generates all the addresses, handles the out-of-bounds edges, applies the swizzle, and signals a barrier when the bytes have landed.

Three generations of "get a tile into shared memory". Registers held per thread and instructions issued both collapse; the address arithmetic that Part 1 showed running at only 64 INT32 ops/clock/SM moves off the critical path entirely.
// Each thread copies 16 B global -> shared, bypassing registers.
unsigned smem_addr = __cvta_generic_to_shared(&smem[stage][tid*4]);
asm volatile("cp.async.cg.shared.global [%0], [%1], 16;\n"
             :: "r"(smem_addr), "l"(&gmem[idx]));

asm volatile("cp.async.commit_group;\n");       // close this stage
asm volatile("cp.async.wait_group 2;\n");       // keep 2 stages in flight
__syncthreads();
// One thread issues the whole tile copy; hardware does the rest.
if (threadIdx.x == 0) {
    mbarrier_init(&bar[stage], 1);
    mbarrier_expect_tx(&bar[stage], TILE_BYTES);      // byte-counting barrier
    cp_async_bulk_tensor_2d(smem[stage], tensor_map, k_off, m_off, &bar[stage]);
}
// Everyone waits on the phase bit; no per-thread address math at all.
mbarrier_wait_parity(&bar[stage], phase);
Sketches, not compilable code — Part 6 gives the real thing. The point is the shape: cp.async is per-thread and counts groups; TMA is per-tile and counts bytes.

Both exist to enable the same structure: a multi-stage software pipeline, where the copy for tile i+2 is in flight while the math for tile i is running. This is the single most important structural idea in modern GEMM and attention kernels, and it is why shared memory is allocated as a circular buffer of 3–6 stages rather than one tile.

interactive A multi-stage pipeline. With one stage, compute and copy strictly alternate and the SM idles for the whole memory latency. Add stages and the copies slide under the math. For one block on Hopper, CUDA's reservation makes the user budget: stages × tile bytes ≤ 227 KB.

2.8 Clusters: shared memory that spans SMs

Hopper added a level between block and grid. A thread block cluster is up to 8 blocks (16 non-portably) guaranteed to be co-scheduled on one GPC, and — the actual point — every block in a cluster can load, store and do atomics directly on the shared memory of its peers, over a dedicated SM-to-SM network. This is distributed shared memory (DSMEM).

Measured on an H800: SM-to-SM latency 180 cycles, a 32% reduction versus going through L2, and 3.27 TB/s of throughput at cluster size 2. A size-8 cluster on an H100 gives a kernel 1.8 MB of single-hop, software-managed memory.

Source: Luo et al., Benchmarking and Dissecting the NVIDIA Hopper GPU Architecture, §III-D3; measured on an H800 PCIe.

The practical payoff for GEMM is TMA multicast: one block issues a TMA load and the hardware delivers the same tile into the shared memory of every block in the cluster. Two SMs cooperating on one B-tile load halves the L2 traffic for that operand — worth 704 → 734 TFLOP/s in one benchmark in this H100 GEMM worklog. Treat that delta as configuration-specific, not a universal multicast gain.

__global__ void __cluster_dims__(2, 1, 1) kernel(
    const float* input, float* output) {
    namespace cg = cooperative_groups;
    cg::cluster_group cluster = cg::this_cluster();

    extern __shared__ float smem[];
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    smem[threadIdx.x] = input[i];

    cluster.sync();                     // every peer exists and its SMEM is initialized
    float* peer = cluster.map_shared_rank(smem, cluster.block_rank() ^ 1);
    float v = peer[threadIdx.x];        // ~180 cycles, no L2 round trip
    output[i] = v;
    cluster.sync();                     // no block exits while a peer still reads its SMEM
}
Both barriers are part of the contract: the first makes every peer and its initialized SMEM available; the second satisfies CUDA's requirement that all distributed-shared-memory accesses finish before any owning block exits.
Where a cluster sits. Blocks in a cluster stay inside one GPC and reach each other's shared memory over the SM-to-SM network; blocks in different clusters can only communicate through L2 and global memory, at roughly 1.5× the latency and with no ordering guarantees.

2.9 The checklist

Everything above compresses into six questions to ask of any kernel:

  1. Does a warp's global load span 128 contiguous bytes? If not, count the sectors. Fix the block shape or transpose the data before you fix anything else.
  2. Are you loading 16 bytes per thread per instruction? If you are loading 4, you have a quarter of the memory-level parallelism you could have, and on Blackwell that is disqualifying.
  3. Does a warp's shared-memory access hit 32 distinct banks? If it reads a column of a power-of-two-strided array, it does not.
  4. If tensor cores are involved, does the shared layout satisfy their alignment? The naive one-element padding fix breaks the standard ldmatrix, wgmma and TMA layouts; a compatible swizzle usually avoids both the conflict and the extra stride.
  5. Is there anything in flight while you compute? A single-stage loop pays full memory latency on every iteration.
  6. Are you re-reading from HBM what L2 or SMEM already has? That is a scheduling-order question, not a bandwidth question — Part 5.

Next: how to tell, before writing a line of code, whether a kernel can be fast — and which of the two ceilings it will hit.