EXPERIMENT DEEPDIVE #1

GPU Datapath: Where Every Byte Moves
in One LLM Inference Step

This deepdive profiles every kernel class that vLLM dispatches for Llama-3.1-8B — GEMMs, FlashAttention, RMSNorm, RoPE, KV-cache writes, sampling — with NVIDIA Nsight Compute on real datacenter GPUs, and reconstructs the complete byte ledger of one inference step: how much data leaves HBM, how the L2 and shared memory multiply it, and what finally supplies the Tensor Cores.

Llama-3.1-8B · BF16 H100 · H200 · A100-80GB · L40S Nsight Compute · 39-cell sweep vLLM kernel set · PACE SLURM

Five Things the Byte Ledger Says

Performance folklore about LLM inference is full of plausible claims that are rarely checked against hardware counters: fusion saves bandwidth, caching helps decode, attention is the bottleneck. Measuring the actual byte traffic at every level of the memory hierarchy lets each claim be confirmed, quantified, or retired. The five findings below are the ones that change how a serving system should be tuned.

1 · Decode is a weight-streaming machine — and it is already near-optimal

Summing all 394 kernels of one batch-1 decode step on H100 — no cell missing: 15.6 GB moves through HBM versus a 15.3 GB theoretical minimum, only 2.3% excess (H200 agrees at +2.0%, A100 at +2.2%; L40S is the one outlier at +15%, because its CUDA-core M=1 GEMM path overfetches weights by 13–20%). Each step reads essentially the whole 16 GB model once. There is almost nothing for caching to save; the levers are batching, quantization, and speculative decoding.

2 · Prefill GEMMs: L2 multiplies bandwidth 10–14×

The QKV projection at 2048 tokens reads 114 MB from DRAM on H200 but delivers 1.61 GB from L2 into the SMs — a 14.1× service ratio (12.9× on H100, 9.8× on A100, 14.4× on L40S). Co-scheduled tiles sharing weight panels hit in L2; that is what lets Tensor Cores run at 95% duty.

3 · FlashAttention's O(s²) matrix truly never touches HBM

Prefill attention over 2048 tokens moves only 42.7 MB of DRAM traffic — almost exactly Q+K+V+O — while the quadratic score matrix (hundreds of MB logically) lives entirely in shared memory and registers. The counters confirm the algorithm's central claim: DRAM amplification is 1.02.

4 · Decode attention has zero data reuse — by construction

Paged decode attention at batch 64 / context 2048 on A100 reads 537.55 MB from DRAM against a 537.92 MB minimum — amplification exactly 1.00, L2 service ratio 1.0, L2 hit rate 24% — and H100/H200 match it at 1.01. Every KV byte is consumed once and never again (L40S alone overfetches 16%, its recurring Ada quirk). Decode attention is a pure DRAM bandwidth race.

5 · The write-back L2 quietly absorbs elementwise traffic — fusion folklore needs a footnote

RMSNorm, RoPE, embedding and KV-cache writes at 2048 tokens show DRAM write traffic far below their logical writes: RoPE wrote 0.68 MB to DRAM on H200 against 21.0 MB of logical output (97% absorbed); the KV-cache write kernel reached ~0 MB. Their working sets fit in the 40–60 MB L2, so dirty lines wait there for the next kernel to consume. On L40S, whose L2 is 96 MB, absorption is near-total for every light op. The exception proves the rule: SiLU×Mul's 176 MB working set overflows the HBM GPUs' L2 — absorption drops to 29% on H100, 22% on H200 and 15% on A100 — yet reaches 53% on L40S. On big-L2 GPUs, kernel fusion's main win over these chains is launch overhead and locality — not the HBM round-trips the textbook story promises.

Status: the sweep is complete on all four GPUs: H100, H200, A100-80GB and L40S each report all 39 cells. Every cross-GPU claim on this page uses only cells measured on that GPU, and every number traces to the per-cell ledger behind this page (the harness, ledger JSONs, cuBLASLt dispatch logs and Nsight Compute reports live together in the research repository).

The Hierarchy a Byte Must Climb

Every operand of every kernel travels the same physical ladder: HBM holds the model, the device-wide L2 caches it, each SM's unified SRAM splits into a hardware-managed L1 and an explicitly-managed shared memory, the register file feeds the compute pipes, and Tensor Cores consume operands from registers (Ampere) or directly from shared memory (Hopper's wgmma). What distinguishes the levels is not just speed — it is who manages them.

Level Managed by How H100 H200 A100-80GB L40S
Register fileCompiler (static)Allocated per-thread at compile time; no tags, no eviction. GEMM accumulators live here for the whole K-loop.256 KB/SM256 KB/SM256 KB/SM256 KB/SM
Shared memoryProgrammer (explicit)Scratchpad: data moved in by cp.async (Ampere) or the TMA engine (Hopper); deterministic reuse, double-buffered pipelines.228 KB/SM max228 KB/SM max164 KB/SM max100 KB/SM max
L1 cacheHardwareSame SRAM as SMEM; caches global loads; not coherent across SMs.256 KB/SM (unified)256 KB/SM (unified)192 KB/SM (unified)128 KB/SM (unified)
L2 cacheHardware + hintsDevice-wide, write-back, the coherence point; persistence windows and eviction hints since Ampere. Cross-CTA reuse emerges here.50 MB60 MB40 MB96 MB
HBMDriver / allocatorThe only level large enough for weights and KV cache; every byte read here is paid at full latency and energy.80 GB · 3.35 TB/s141 GB · 4.8 TB/s80 GB · 2.04 TB/s48 GB GDDR6 · 0.86 TB/s
Reading the ledger: for each kernel we report DRAM read/write bytes, L2→SM bytes (the xbar return path), shared-memory access counts, Tensor-Core duty, and an amplification factor = measured DRAM bytes ÷ the algorithm's minimum (each input read once, each output written once). Amplification ≈ 1 means the kernel is as lean as physics allows; > 1 means re-reads; < 1 means the write-back L2 absorbed writes during the kernel.

One Step = 394 Kernel Launches

Tracing a vLLM engine step for Llama-3.1-8B (32 layers) shows a fixed structural inventory: 12 kernels per layer plus ~10 framework kernels — 394 launches per step. Every one of them falls into one of the ten classes below; together the classes cover 100% of the step's GPU time.

Class Dispatched CUDA kernel (measured name) Calls/step What moves
QKV / O / Gate-Up / Down GEMMsm90_xmma_gemm_bf16* (H100/H200) / ampere_bf16_s16816gemm_*, cutlass::Kernel2 (A100/L40S)128weights + activations
Attention (prefill / decode)flash_fwd_kernel, flash_fwd_splitkv_kernel (FA2) / FA3 device_kernel<FlashAttnFwdSm90>32Q, K, V, O + KV cache
Fused Add + RMSNormfused_add_rms_norm_kernel64activations ×2 (in-place)
RoPErotary_embedding_kernel32Q/K in-place + cos-sin table
SiLU × Mulact_and_mul_kernel<silu>322×14336 wide read → 14336 write
KV-cache writereshape_and_cache_flash_kernel32K,V scatter into paged blocks
LM headGEMM (M≥64) / gemv2T_kernel (Hopper, M=1) / CUDA-core gemv (L40S, M=1)14096×128256 weight stream
Embeddingvectorized_gather_kernel1row gather from vocab table
Sampling (greedy)reduce_kernel<ArgMaxOps>1logits [bs, 128256] fp32 read
Epilogue reduce (decode GEMMs, arch-dependent)splitKreduce_kernel0–32partial-sum round trip

The sweep resolved the last, arch-dependent class: the separate splitKreduce_kernel appeared only on A100 and L40S, and only for the down projection (K=14336, the deepest reduction) at 64–256 tokens — exactly where split-K theory predicts it, a deep dot-product with too few output tiles to fill the GPU. Hopper never dispatched it at any measured shape: its persistent segment_k schedules fold the reduction into the main kernel.

Measuring the Real Kernels, One at a Time

Attaching Nsight Compute to a live multi-process vLLM server is slow and fragile, so we use sentinel microbenches that dispatch the exact same kernels: GEMMs go through torch.matmul (the cuBLASLt path vLLM uses, with CUBLASLT_LOG_LEVEL=5 recording the dispatched tile and algorithm as evidence), attention calls vllm.vllm_flash_attn.flash_attn_varlen_func (FA3 on Hopper, FA2 elsewhere; decode uses the paged block_table path with shuffled pages), and the light ops call the registered vLLM CUDA ops directly through torch.ops._C. Each cell wraps a single call in cudaProfilerStart/Stop so ncu profiles exactly one launch after 10 warmups, with clocks pinned to base.

# one cell of the 39-cell sweep: GEMM/light ops at {1, 64, 256, 2048} tokens; attention at {1024, 2048, 4096} + paged decode
ncu --clock-control base --replay-mode kernel --profile-from-start off \
    -k 'regex:gemm|cutlass|Kernel2|wgmma|ampere|sm80|sm90|xmma|gemv' --launch-count 2 \
    --metrics dram__bytes_read.sum,dram__bytes_write.sum,lts__t_sector_hit_rate.pct,\
l1tex__m_xbar2l1tex_read_bytes.sum,l1tex__data_pipe_lsu_wavefronts_mem_shared_op_ld.sum,\
smsp__inst_executed_op_ldsm.sum,sm__sass_inst_executed_op_ldgsts.sum,... \
    --csv --page raw \
    python experiments/ncu/profile_datapath.py --op gemm --proj qkv --m 2048

The two flags doing the real work are -k, a regex over demangled kernel base names that selects which launch to profile, and --launch-count 2, which also captures the secondary kernel some cells dispatch (the split-K reduce after decode GEMMs, the combine pass after split-KV attention). Each requested counter then maps onto one column of the ledger:

Ledger column ncu counter Meaning
HBM read / writedram__bytes_read/write.sumbytes that actually crossed the HBM bus
L2 → SMl1tex__m_xbar2l1tex_read_bytes.sumbytes the L2 crossbar returned into SMs — catches both LSU loads and TMA
SMEM trafficl1tex__data_pipe_lsu_wavefronts_mem_shared_*shared-memory access wavefronts (counts, not bytes)
SMEM → RFsmsp__inst_executed_op_ldsm.sumldmatrix instructions feeding Tensor-Core fragments (Ampere path)
Async copiessm__sass_inst_executed_op_ldgsts.sumcp.async instructions (GMEM→SMEM bypassing RF)
TC dutysm__pipe_tensor*_cycles_active% of cycles the Tensor pipe was busy
Honesty box: microbenches isolate kernels, so inter-kernel L2 effects (a consumer reading the producer's dirty lines) are inferred from per-kernel absorption, not measured end-to-end. Clocks are pinned to base for counter stability, so absolute times are conservative relative to boost-clock serving. SMEM wavefronts are access counts, not bytes — we never convert them.

Six Datapaths, Measured

The ten kernel classes of the inventory collapse into six distinct movement patterns, and each pattern uses the hierarchy differently: some multiply bandwidth through reuse, some stream through it once, and some never leave the L2 at all. This section walks each pattern with its measured byte counts, beginning with the most reuse-rich kernel and ending with the most latency-bound one.

A · Prefill GEMM — the reuse cathedral

The QKV projection at M=2048 tokens (4096×6144 weight) is the canonical compute-bound kernel. Watch the byte counts grow as data climbs toward the SMs on H200:

Read path — operand supply

HBM
weights + acts
67 MB unique
114 MBmiss fill
L2
92.9% of bytes served
panels shared across CTAs
1.61 GBTMA · 14.1×
SMEM
226 KB/CTA
multi-stage pipeline
operandswgmma reads SMEM
Tensor Core
95.5% duty
539 TF/s

Write path — epilogue

RF accumulator
FP32 · resident all K-loop
25.2 MBepilogue → BF16
L2
write-back
dirty lines park here
20.9 MBsank to DRAM (83%)
HBM
C write

H200, qkv_m2048: amplification 1.46 (114+21 MB moved vs the 92.3 MB theoretical min). Read supply 1.61 GB = 768 tiles × 2.1 MB panel fetches — every DRAM-delivered byte served ~14× (24× per unique byte: weight rows read by 16 CTA rows, activation columns by 48), 92.9% answered by L2 (the 61.8% sector hit rate ncu reports counts accesses, not bytes). Writes land in write-back L2 first; 20.9 of 25.2 MB sank to DRAM before kernel end. Grid 112 persistent CTAs, 0.85 waves, accumulators pinned in registers for the whole K-loop.

The same logical GEMM walks a different micro-datapath on each architecture, and the instruction counters prove it. On H200 the kernel issued only 768 cp.async and zero ldmatrix instructions — operands ride the TMA engine into shared memory and wgmma consumes them there directly, skipping the register file on the way in. On A100 the same cell issued 2.36 M cp.async and 6.32 M ldmatrix instructions: bulk copies into SMEM, then explicit fragment loads into registers for mma.sync. Two generations, one algorithm, two physically different byte routes.

qkv_m2048t (µs)DRAM rd
meas / min (MB)
DRAM wr
meas / min (MB)
ampL2→SMserviceTCcp.asyncldmatrixtile
H100180.2124.7 / 67.121.4 / 25.21.581.61 GB12.9×95.5%7680128×128×64 wgmma
H200191.4113.9 / 67.120.9 / 25.21.461.61 GB14.1×95.5%7680128×128×64 wgmma
A100-80541.2123.7 / 67.123.7 / 25.21.601.21 GB9.8×91.8%2.36 M6.32 M256×128 s16816
L40S770.684.0 / 67.11.7 / 25.20.931.21 GB14.4×96.2%2.36 M6.32 M256×128 s16816
Takeaway: the 1.3–1.9× HBM amplification on the HBM GPUs is the cost of tiling: each weight panel is re-fetched by multiple CTA rows whose combined footprint exceeds L2. On L40S the same kernel shows amplification 0.93 — its 96 MB L2 holds the whole ~92 MB working set, so the re-reads never reach DRAM and 93% of the output write is still parked in L2 as dirty lines when the kernel ends (1.7 of 25.2 MB written back). A ratio below 1.0 means deferred writes the next kernel can consume straight from L2, not negative traffic. QKV is the representative cell; across all four projections the amplification spans 1.31 (down, H200) to 1.92 (o_proj, H100) on the HBM GPUs, while on L40S only the K-deep down projection — whose panel set exceeds even 96 MB — still amplifies at 1.79. This amplification is also the single largest source of removable traffic in the whole inference step (see the rollup below).
Duty ≠ percent-of-peak: the TC node carries two numbers answering different questions: 95.5% is pipe duty — the fraction of cycles the tensor pipe is occupied on SMs that have work — while the achieved rate sits near 55% of the 989 TF/s spec. The spec itself is constructed: 132 SMs × 4096 FLOP/cycle (4 Tensor Cores × 512 BF16 MACs × 2) × 1.830 GHz — the whitepaper's reference clock, not the 1.98 GHz maximum boost. Re-reading the same cell from the fully-instrumented Nsight report (190.112 µs; every factor below comes from that one file at full counter precision, nothing rounded into place), the gap factors exactly:
$$\frac{542.20\ \mathrm{TF/s}}{989.43\ \mathrm{TF/s}} \;=\; \underbrace{\frac{1.3044\ \mathrm{GHz}}{1.8300\ \mathrm{GHz}}}_{\substack{\eta_{\mathrm{clock}}\,=\,0.7128\\[2pt] \text{ncu-pinned / spec ref}}} \times \underbrace{\frac{112}{132}}_{\substack{\eta_{\mathrm{cluster}}\,=\,0.8485\\[2pt] \text{cluster quantization}}} \times \underbrace{\vphantom{\frac{1}{1}}0.9488}_{\substack{\eta_{\mathrm{active}}\\[2pt] \text{SM busy fraction}}} \times \underbrace{\vphantom{\frac{1}{1}}0.9549}_{\substack{\eta_{\mathrm{duty}}\\[2pt] \text{TC pipe duty}}} \times \underbrace{\vphantom{\frac{1}{1}}1.0000}_{\substack{\eta_{\mathrm{delivery}}\\[2pt] \text{FLOP/clk when busy}}}$$
theoretical capability, shrinking step by stepfactor appliedTF/s
spec sheet: 132 SM × 4096 FLOP/clk × 1.830 GHz989.43
same silicon at the ncu-pinned clock (1.3044 GHz)× 0.7128705.27
only the 112 SMs that received a cluster× 112/132598.41
only their busy windows (post-launch, pre-exit)× 0.9488567.80
only cycles where the tensor pipe is occupied× 0.9549542.20
measured: 103,079,215,104 FLOP ÷ 190.112 µsdelivery = 1.0000542.20
The identity closes to six decimal places at full counter precision — 0.712809 × 0.848485 × 0.948836 × 0.954922 × 1.000000 = 0.547995 = 542.2026/989.4298 (the 4-decimal factors displayed above reproduce it to four decimals) — and the delivery term is the punchline: whenever the wgmma pipe is busy it delivers the full 4096 FLOP per cycle — the math units lose nothing. Every loss is idleness at some granularity: the pinned measurement clock; 20 SMs excluded by 4×4×1 cluster quantization (cuBLASLt log); 5.1% of working-SM cycles spent before launch or after early exit, of which the ⌈768/112⌉ = 7-round tile tail accounts for 2.0 points and launch/exit skew the rest; and 4.5% of active cycles where the pipe waits at tile boundaries. (The flow diagram quotes 539 TF/s from the 39-cell sweep run, 191.4 µs; run-to-run spread between the two captures is ±0.7%.)

Anatomy of the 113.9 MB, sweep by sweep

The persistent grid (28×4 = 112 CTAs) walks the 16×48 tile grid four rows at a time — four sweeps for M=2048, each touching all 48 weight panels once. Sweep 1 starts with a cold L2: every panel fetch is compulsory (W 50.3 MB as each column's cluster first touches it, plus X rows 0–3, 4.2 MB). But the kernel's full footprint (W+X+C ≈ 92 MB) far exceeds the 60 MB L2, and all 48 weight panels are re-touched every sweep: as each sweep's later fetches and dirty output compete for the same cache sets, LRU evicts the earliest weight panels before sweep 2's CTAs (rows 4–7) reuse them — so those panels miss and re-read from HBM. Measured: about 31% of W (15.7 MB) per sweep boundary.

$$\mathrm{rd}(M) \;=\; \underbrace{W}_{50.3\ \mathrm{MB}} \;+\; \underbrace{X(M)}_{M\cdot 4096\cdot 2\mathrm{B}} \;+\; \underbrace{(S\!-\!1)\, e\, W}_{S\,=\,M/512},\qquad \mathrm{rd}(2048) = 50.3 + 16.8 + 3 \times 0.311 \times 50.3 = 114.1\ \mathrm{MB}$$

HBM → L2 read pressure over kernel time (model anchored to the measured total)

1.15 TB/s 0.42 TB/s
sweep 1 · 0–47.5 µs
compulsory
W 50.3 + X 4.2 MB
sweep 2
re-fetch 15.7 + X 4.2
sweep 3
re-fetch 15.7 + X 4.2
sweep 4 · →190 µs
re-fetch 15.7 + X 4.2

Integral = 54.5 + 3 × 19.86 = 114.1 MB — exactly the measured DRAM reads. Peak pressure is 24% of H200's 4.8 TB/s, which is why the re-reads hide under compute (TC duty 95.5%): the cost is energy and power budget, not kernel time.

The model has exactly one fitted constant — the per-sweep eviction fraction e — and a dedicated 9-point M-sweep on H200 pins it: every M from 256 to 8192 dispatches the same 128×128×64 kernel, and the measured re-reads grow linearly with the sweep count as predicted (e = 0.365, R² = 0.978). The scatter around the line tracks cuBLASLt's launch-shape choice — the heuristic picks anything from 28×4 to 60×2, 120×1 and 14×8 across this sweep, and each shape walks the tiles in a different order with slightly different locality.

Mgridsweeps SDRAM rd meas (MB)compulsory (MB)re-reads meas (MB)model (S−1)·e·W
51228×4155.154.50.60
102428×4292.358.733.618.4
153660×2374.362.911.436.7
204828×44114.167.147.055.1
3072120×16149.675.574.191.8
4096120×18201.883.9118.0128.6
614414×812300.7100.7200.0202.0
819230×416408.8117.4291.4275.5
Method note: ncu's PM Sampling did capture this kernel at 1.5 µs resolution, but the 2024.3 CLI and Python APIs cannot export the time series (UI-only), so the pressure timeline above is the sweep model anchored at the measured aggregate — every plateau height is fixed by the validated formula, none is free.

B · Decode GEMM & LM head — the weight stream

At M=1 (one new token), every projection degenerates to reading the entire weight matrix once to produce a vector. Amplification collapses to 1.00–1.07 across all four projections and the LM head on H100, H200 and A100 (L40S sits apart at 1.13–1.20 — see the callout below): there is no reuse to exploit, because there is only one row of activations. The LM head is the heaviest single stream — 1.05 GB per step, 250 µs on H200 at 4.2 TB/s (88% of peak bandwidth).

M=1 cellweightsH100 t / BWH200 t / BWA100 t / BWL40S t / BWampdispatched kernel
o_proj (4096×4096)33.6 MB21.9 µs · 1.63 TB/s20.1 µs · 1.71 TB/s31.1 µs · 1.16 TB/s55.6 µs · 0.72 TB/s1.02–1.07 · L40S 1.20Hopper: real gemv2T_kernel (TC 0%) · A100: s16816gemm (TC 64%) · L40S: CUDA-core gemv
gate_up (4096×28672)235 MB86.4 µs · 2.75 TB/s63.9 µs · 3.72 TB/s158.9 µs · 1.50 TB/s325.9 µs · 0.82 TB/s1.01 · L40S 1.14xmma 64×128 / s16816gemm 64×64 / gemv (L40S)
down (14336×4096)117 MB47.4 µs · 2.54 TB/s37.6 µs · 3.20 TB/s85.1 µs · 1.41 TB/s173.9 µs · 0.78 TB/s1.02 · L40S 1.16xmma 64×64 / s16816gemm 64×64 / gemv (L40S)
lm_head (4096×128256)1051 MB344.0 µs · 3.06 TB/s250.0 µs · 4.22 TB/s610.0 µs · 1.73 TB/s1475.8 µs · 0.84 TB/s1.00 · L40S 1.17xmma 64×128 / s16816gemm 64×64 / gemv (L40S)
A dispatch curiosity: for the small square o_proj at M=1, cuBLAS on H200 picks an honest gemv CUDA-core kernel (Tensor-Core duty 0.03%), while A100 still runs a Tensor-Core GEMM padded to a 64-wide tile at 64% duty. Both saturate bandwidth — at M=1 the math units are irrelevant; only the stream rate matters. L40S goes further: every M=1 GEMM dispatches a CUDA-core gemv path that overfetches the weight matrix by 13–20% (amplification 1.13–1.20, L2 hit <5%), which is why its decode step is the only one with double-digit excess traffic.

C · FlashAttention prefill — the quadratic that stays on-chip

Attention is the one operator whose intermediate state grows quadratically with sequence length, so its datapath is decided by a single question: where does the score matrix live? FlashAttention's answer — in shared memory and registers, tile by tile, never materialized — can be verified directly from the byte counters of the A100 cell at 2048 tokens:

Read path — Q/K/V tile supply

HBM
Q,K,V
25.2 MB unique
26 MBmiss fill
L2
91.5% of bytes served
K/V tiles re-served
307 MBcp.async · 11.8×
SMEM
64 KB/CTA
11.5 M accesses
S, P
never leave
ldmatrix 2.79 M
TC + softmax
62% duty
online rescale in RF

Write path — output O

RF accumulator
O · rescaled per tile
16.8 MBepilogue store
L2
write-back
16.7 MBsank to DRAM (99%)
HBM
O write

A100-80GB, attnp_s2048 (FA2, causal, GQA 32:8): DRAM total 42.7 MB vs 41.9 MB minimum — amplification 1.02. Read supply 307 MB = 11.8× re-read of Q/K/V tiles, 91.5% answered by L2; the s² score matrix lives entirely in SMEM and registers. A100's 40 MB L2 evicts the output promptly, so 99% of O lands in DRAM within the kernel. At s=4096 the service ratio rises to 21.9× (1.15 GB served from 52.6 MB of DRAM reads).

A naive attention would materialize the 2048×2048 score matrix per head — hundreds of megabytes — write it to HBM after QKᵀ, read it back for softmax, and read it again for PV. The ledger shows none of that: DRAM traffic is Q+K+V+O and nothing else. The quadratic intermediate is born in Tensor-Core accumulators, renormalized in registers by online softmax, and dies there. This single design decision is why the measured 42.7 MB replaces what would otherwise be >1 GB of HBM traffic.

FA3 on Hopper sharpens the same picture. The H100 cell at s=2048 finishes in 87.7 µs — 3.9× faster than A100's FA2 at 342.6 µs — with DRAM reads of 25.25 MB, matching the 25.17 MB logical Q+K+V to within 0.3%. Its DRAM amplification even drops to 0.66, because 85% of the output write is still sitting in L2 when the kernel ends. H200 runs the same cell in 91.5 µs, and L40S's FA2 needs 366 µs at 78% Tensor-Core duty. The TMA + warpgroup pipeline does not change what moves; it changes how little time the movement takes.

D · Paged decode attention — the zero-reuse stream

Decode attention reads a paged KV cache through a block table (we shuffle the pages to reproduce vLLM's scattered layout) with one query token per sequence. At batch 64, context 2048 on A100: 537.55 MB read against a 537.92 MB minimum. Amplification 1.00. L2 service ratio 1.0. The split-KV kernel parallelizes along the context dimension to fill SMs, and the page-granular (16-token) reads remain fully coalesced — scattering pages costs nothing measurable at this granularity. There is simply no locality here to mine: each step touches every cached KV byte exactly once.

cellt (µs)DRAM rdtheoretical minampL2 hitTCachieved BWkernels
A100 · bs=1, ctx 204835.98.74 MB8.40 MB1.0410.7%18%243 GB/s (12%)splitkv + combine (FA2)
A100 · bs=64, ctx 2048373.7537.6 MB537.9 MB1.0024.1%47%1.45 TB/s (71%)splitkv (FA2)
H100 · bs=1, ctx 204823.18.63 MB8.40 MB1.0314.5%9%374 GB/s (11%)FA3 device_kernel
H100 · bs=64, ctx 2048195.1537.6 MB537.9 MB1.011.9%25%2.77 TB/s (83%)FA3 device_kernel
H200 · bs=1, ctx 204822.48.63 MB8.40 MB1.038.9%8%386 GB/s (8%)FA3 device_kernel
H200 · bs=64, ctx 2048149.4537.6 MB537.9 MB1.011.2%37%3.62 TB/s (75%)FA3 device_kernel
L40S · bs=1, ctx 204836.89.98 MB8.40 MB1.1912.1%20%271 GB/s (31%)splitkv + combine (FA2)
L40S · bs=64, ctx 2048772.1619.9 MB537.9 MB1.160.3%32%809 GB/s (94%)splitkv (FA2)
Why bs=1 underuses bandwidth: a single 2048-token context yields only 8.4 MB of work — the split-KV grid (128 CTAs, 0.59 waves) cannot cover memory latency across 108 SMs, so the stream runs at 243 GB/s (12% of peak). At bs=64 the same kernels reach 71% of peak on A100, 83% on H100, 75% on H200 — and L40S saturates its GDDR6 at 94% — with Hopper L2 hit rates collapsing to 1.2–1.9%: a textbook stream. L40S again pays the Ada overfetch (amplification 1.16–1.19, mirroring its M=1 GEMMs). Decode attention efficiency is a batching problem, not a kernel problem.

E · RMSNorm, RoPE, embedding — the L2 residents

The per-layer elementwise kernels read and write activations whose entire working set (16–50 MB at T=2048) fits inside L2. The ledger makes their datapath unmistakable: Tensor Cores 0%, shared memory essentially unused, and — the striking part — DRAM writes far below logical writes, because the write-back L2 holds the dirty lines for the next consumer.

T=2048logical writesH100 DRAM wrH200 DRAM wrA100 DRAM wrL40S DRAM wrabsorbed (H100)
fused_add_rms_norm33.6 MB11.7 MB7.2 MB16.5 MB0.04 MB65%
rotary_embedding21.0 MB1.96 MB0.68 MB5.1 MB~0 MB91%
reshape_and_cache (KV write)8.4 MB~0 MB~0 MB0.02 MB~0 MB~100%
embedding gather16.8 MB1.87 MB0.89 MB3.8 MB~0 MB89%
silu_and_mul (working set 176 MB > L2)58.7 MB41.7 MB45.6 MB50.0 MB27.6 MB29%

At decode (T=1) the same kernels become almost weightless — RMSNorm launches a single thread block (one CTA on a 132-SM GPU, 4.3 µs) and the whole op is launch-latency, not bytes. Thirty-two layers of these per step is why kernel-launch overhead, not arithmetic, dominates the light-op budget during decode.

F · Greedy sampling — one block, one scan

The final argmax over the 128256-entry logits row reads 0.51 MB of fp32 and runs as a single-block reduction — pure latency, 0.01% Tensor-Core duty, one CTA: 45.2 µs on H100, 46.1 on H200, 59.8 on L40S and 69.3 on A100, none of them above 13 GB/s. At batch 64 the same kernel reads 32.9 MB at real bandwidth (740 GB/s on H200, 742 on H100, 628 on L40S, 481 on A100). Like everything else at batch 1, it is bound by being small, not by being slow.

Adding Up a Whole Step

Multiplying each measured cell by its structural call count (32 layers; 64 RMSNorms; one LM head, embedding and argmax) reconstructs the full step's HBM budget. The four numbers below pair each phase's measured traffic with its theoretical minimum — the gap between them is the entire optimization opportunity:

15.6 GB
decode step (bs=1), H100, all 39 cells — vs 15.3 GB minimum
+2.3%
decode excess traffic — already near-optimal
48.1 GB
prefill step (T=2048), H100 — vs 37.3 GB minimum
+29%
prefill excess — dominated by GEMM tile re-reads
GPUdecode moveddecode minexcessprefill movedprefill minexcess
H10015.64 GB15.29 GB+2.3%48.1 GB37.3 GB+29%
H20015.59 GB15.29 GB+2.0%44.2 GB37.3 GB+18%
A100-8015.63 GB15.29 GB+2.2%52.5 GB37.3 GB+41%
L40S17.60 GB15.29 GB+15.1%40.3 GB37.3 GB+8.0%

The decode floor is easy to sanity-check: 15.6 GB at H100's 3.35 TB/s is 4.7 ms of pure streaming; the measured kernel-time sum is 7.7 ms (base clock, no inter-kernel gaps counted), so the byte ledger explains the majority of decode latency from first principles. The complete H200 ledger gives the same shape — 15.59 GB against a 15.29 GB minimum, a 3.2 ms floor against 6.3 ms of kernel time — as do A100 (7.7 ms floor vs 13.0 ms) and L40S, where the floor explains fully 86% of measured kernel time (20.4 vs 23.8 ms). Prefill is different: H100's excess 10.7 GB comes almost entirely from the four projection GEMMs' amplification (×32 layers), the one place where a better tiling/persistent-kernel strategy has real bytes to recover.

Where the Removable Bytes Are

An optimization is only as large as the traffic it can actually remove, so the right way to rank candidates is to subtract each kernel's theoretical minimum from its measured bytes and see what remains. Applying that subtraction across the whole step ledger produces the table below — and it disagrees with intuition in instructive ways.

# Opportunity Evidence from the ledger Honest ceiling
1 Prefill GEMM tile re-reads projection amplification spans roughly 1.3–1.9 at T=2048 on the HBM GPUs; the H100 prefill step moves 48.1 GB against a 37.3 GB minimum — 10.7 GB of net excess, 29% above minimum eliminating all of it would cut ~22% of prefill HBM traffic on H100 (10.7 of 48.1 GB; 15% on H200, 29% on A100) — L2-aware rasterization, larger/persistent tiles, Stream-K. L40S proves the ceiling is real: its 96 MB L2 absorbs the re-reads entirely (QKV amplification 0.93)
2 LM head quantization 1.05 GB/step — the single largest stream in decode (7% of step bytes) halve it with FP8/INT8 lm_head; exact 525 MB/step saving, no architecture change
3 Decode batching (the only effective decode dial) the GEMM weight stream is batch-invariant (every M=1 vs M=64 GEMM cell reads the same weight bytes); decode-attention bandwidth rises from 243 GB/s at bs=1 to 1.45 TB/s at bs=64 (A100) 64× tokens for 2.1× whole-step bytes on H100 (15.6 → 33.1 GB, summing the same ledger's M=64 cells) — a 30× per-token reduction. The weight stream itself is batch-invariant; what grows is KV attention and activations
4 Fusing elementwise chains the write-back L2 already absorbs 51–100% of non-SiLU light-op writes at T=2048 across GPUs (65–100% on Hopper; SiLU overflows at 15–53%); at T=1 each op is a ~4–6 µs launch the win is ~160 launches/step of overhead, not HBM bytes — fuse for latency, not bandwidth
5 Decode attention / KV layout amplification already 1.00 on the HBM GPUs, page scatter free at 16-token granularity (L40S's 1.16 is Ada read overfetch, not a layout problem) nothing left at this layer — gains must come from KV quantization, GQA ratio, or sparsity
The meta-finding: most folklore optimization targets (fusion for bandwidth, KV layout, smarter caching of weights in decode) attack traffic that the measurements show is already minimal or already absorbed by L2. The bytes that are genuinely removable hide in exactly two places: prefill GEMM amplification and operand precision. Everything else is a latency or parallelism problem wearing a bandwidth costume.

Reproducing the Measurements

Every number on this page comes from a 39-cell sweep that runs unattended as a single SLURM job per GPU and finishes in about seven minutes. The harness is three small files — a microbench multiplexer, a parser, and the sweep template — and the same recipe ports to any CUDA GPU with profiling-counter permission. Each run also archives its evidence: per-cell wide-CSV counters, the cuBLASLt dispatch logs, and three full Nsight Compute reports whose Memory Workload Analysis charts draw the same flows this page tabulates.

Source Code Reading

  • experiments/ncu/profile_datapath.pythe 10-op microbench multiplexer; check how each builder reproduces vLLM's exact dispatch (paged block_table, torch.ops._C light ops, cuBLASLt logging)
  • experiments/ncu/parse_datapath.pywide-CSV → per-level byte ledger; the amplification and service-ratio derivations live here
  • pace/_ncu_datapath_template.shthe 39-cell SLURM sweep: metric wish-list intersection, kernel-name regexes (note the demangled base names: cutlass::Kernel2 matches as 'Kernel2', FA3 as 'device_kernel'), smoke validation

The fastest way to explore a single kernel is to capture one cell with the full section set and open the report in the Nsight Compute UI, whose Memory Workload Analysis chart draws the same per-level byte flow this page tabulates. The sanity-run line first confirms the op dispatches correctly before paying the profiling overhead:

# profile one op locally (any CUDA GPU with ncu counter permission)
python experiments/ncu/profile_datapath.py --op attn_decode --m 64 --ctx 2048   # sanity-run without ncu
ncu --clock-control base --profile-from-start off \
    -k 'regex:flash|fmha|attn|device_kernel' --launch-count 2 --set full -o attnd_b64 \
    python experiments/ncu/profile_datapath.py --op attn_decode --m 64 --ctx 2048
# then open attnd_b64.ncu-rep in the Nsight Compute UI and read the Memory Workload Analysis chart
Three traps this harness already absorbs: (1) ncu's -k matches the demangled base name — FA3's kernel is literally named device_kernel and cutlass M=1 GEMMs are Kernel2; (2) torch.ops._C.silu_and_mul only exists after import vllm._custom_ops loads the extension; (3) on Hopper, LSU-pipe global-load counters miss TMA traffic — use l1tex__m_xbar2l1tex_read_bytes for the true L2→SM volume.

Written Analysis (1-2 pages)

  • The decode rollup shows about 2% excess HBM traffic while prefill shows about 29%. Explain why the same GEMM kernels produce such different amplification at M=1 versus M=2048, using the tile-reuse argument.
  • RoPE shows DRAM-write absorption of 97% on H200 but only 76% on A100, with identical kernels. Identify the hardware difference that explains the gap, then verify the explanation against the near-total absorption the L40S column shows, and derive the working-set-to-L2 ratio at which absorption should start to fall.
  • Using the measured 1.05 GB lm_head stream and the target GPU's bandwidth, compute the latency floor of the lm_head at batch 1, 8, and 64, and determine at which batch size it stops being bandwidth-bound.
  • The H200 GEMM issued 768 cp.async instructions; the A100 issued 2.36 million for the same matrix. Trace both datapaths from L2 to the Tensor Core and explain what TMA replaces.