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.
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.
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.
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.
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.
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.
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.
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 file | Compiler (static) | Allocated per-thread at compile time; no tags, no eviction. GEMM accumulators live here for the whole K-loop. | 256 KB/SM | 256 KB/SM | 256 KB/SM | 256 KB/SM |
| Shared memory | Programmer (explicit) | Scratchpad: data moved in by cp.async (Ampere) or the TMA engine (Hopper); deterministic reuse, double-buffered pipelines. | 228 KB/SM max | 228 KB/SM max | 164 KB/SM max | 100 KB/SM max |
| L1 cache | Hardware | Same 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 cache | Hardware + hints | Device-wide, write-back, the coherence point; persistence windows and eviction hints since Ampere. Cross-CTA reuse emerges here. | 50 MB | 60 MB | 40 MB | 96 MB |
| HBM | Driver / allocator | The 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/s | 141 GB · 4.8 TB/s | 80 GB · 2.04 TB/s | 48 GB GDDR6 · 0.86 TB/s |
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 GEMM | sm90_xmma_gemm_bf16* (H100/H200) / ampere_bf16_s16816gemm_*, cutlass::Kernel2 (A100/L40S) | 128 | weights + activations |
| Attention (prefill / decode) | flash_fwd_kernel, flash_fwd_splitkv_kernel (FA2) / FA3 device_kernel<FlashAttnFwdSm90> | 32 | Q, K, V, O + KV cache |
| Fused Add + RMSNorm | fused_add_rms_norm_kernel | 64 | activations ×2 (in-place) |
| RoPE | rotary_embedding_kernel | 32 | Q/K in-place + cos-sin table |
| SiLU × Mul | act_and_mul_kernel<silu> | 32 | 2×14336 wide read → 14336 write |
| KV-cache write | reshape_and_cache_flash_kernel | 32 | K,V scatter into paged blocks |
| LM head | GEMM (M≥64) / gemv2T_kernel (Hopper, M=1) / CUDA-core gemv (L40S, M=1) | 1 | 4096×128256 weight stream |
| Embedding | vectorized_gather_kernel | 1 | row gather from vocab table |
| Sampling (greedy) | reduce_kernel<ArgMaxOps> | 1 | logits [bs, 128256] fp32 read |
| Epilogue reduce (decode GEMMs, arch-dependent) | splitKreduce_kernel | 0–32 | partial-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.
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 / write | dram__bytes_read/write.sum | bytes that actually crossed the HBM bus |
| L2 → SM | l1tex__m_xbar2l1tex_read_bytes.sum | bytes the L2 crossbar returned into SMs — catches both LSU loads and TMA |
| SMEM traffic | l1tex__data_pipe_lsu_wavefronts_mem_shared_* | shared-memory access wavefronts (counts, not bytes) |
| SMEM → RF | smsp__inst_executed_op_ldsm.sum | ldmatrix instructions feeding Tensor-Core fragments (Ampere path) |
| Async copies | sm__sass_inst_executed_op_ldgsts.sum | cp.async instructions (GMEM→SMEM bypassing RF) |
| TC duty | sm__pipe_tensor*_cycles_active | % of cycles the Tensor pipe was busy |
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.
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
Write path — epilogue
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_m2048 | t (µs) | DRAM rd meas / min (MB) | DRAM wr meas / min (MB) | amp | L2→SM | service | TC | cp.async | ldmatrix | tile |
|---|---|---|---|---|---|---|---|---|---|---|
| H100 | 180.2 | 124.7 / 67.1 | 21.4 / 25.2 | 1.58 | 1.61 GB | 12.9× | 95.5% | 768 | 0 | 128×128×64 wgmma |
| H200 | 191.4 | 113.9 / 67.1 | 20.9 / 25.2 | 1.46 | 1.61 GB | 14.1× | 95.5% | 768 | 0 | 128×128×64 wgmma |
| A100-80 | 541.2 | 123.7 / 67.1 | 23.7 / 25.2 | 1.60 | 1.21 GB | 9.8× | 91.8% | 2.36 M | 6.32 M | 256×128 s16816 |
| L40S | 770.6 | 84.0 / 67.1 | 1.7 / 25.2 | 0.93 | 1.21 GB | 14.4× | 96.2% | 2.36 M | 6.32 M | 256×128 s16816 |
| theoretical capability, shrinking step by step | factor applied | TF/s |
|---|---|---|
| spec sheet: 132 SM × 4096 FLOP/clk × 1.830 GHz | — | 989.43 |
| same silicon at the ncu-pinned clock (1.3044 GHz) | × 0.7128 | 705.27 |
| only the 112 SMs that received a cluster | × 112/132 | 598.41 |
| only their busy windows (post-launch, pre-exit) | × 0.9488 | 567.80 |
| only cycles where the tensor pipe is occupied | × 0.9549 | 542.20 |
| measured: 103,079,215,104 FLOP ÷ 190.112 µs | delivery = 1.0000 | 542.20 |
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.
HBM → L2 read pressure over kernel time (model anchored to the measured total)
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.
| M | grid | sweeps S | DRAM rd meas (MB) | compulsory (MB) | re-reads meas (MB) | model (S−1)·e·W |
|---|---|---|---|---|---|---|
| 512 | 28×4 | 1 | 55.1 | 54.5 | 0.6 | 0 |
| 1024 | 28×4 | 2 | 92.3 | 58.7 | 33.6 | 18.4 |
| 1536 | 60×2 | 3 | 74.3 | 62.9 | 11.4 | 36.7 |
| 2048 | 28×4 | 4 | 114.1 | 67.1 | 47.0 | 55.1 |
| 3072 | 120×1 | 6 | 149.6 | 75.5 | 74.1 | 91.8 |
| 4096 | 120×1 | 8 | 201.8 | 83.9 | 118.0 | 128.6 |
| 6144 | 14×8 | 12 | 300.7 | 100.7 | 200.0 | 202.0 |
| 8192 | 30×4 | 16 | 408.8 | 117.4 | 291.4 | 275.5 |
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 cell | weights | H100 t / BW | H200 t / BW | A100 t / BW | L40S t / BW | amp | dispatched kernel |
|---|---|---|---|---|---|---|---|
| o_proj (4096×4096) | 33.6 MB | 21.9 µs · 1.63 TB/s | 20.1 µs · 1.71 TB/s | 31.1 µs · 1.16 TB/s | 55.6 µs · 0.72 TB/s | 1.02–1.07 · L40S 1.20 | Hopper: real gemv2T_kernel (TC 0%) · A100: s16816gemm (TC 64%) · L40S: CUDA-core gemv |
| gate_up (4096×28672) | 235 MB | 86.4 µs · 2.75 TB/s | 63.9 µs · 3.72 TB/s | 158.9 µs · 1.50 TB/s | 325.9 µs · 0.82 TB/s | 1.01 · L40S 1.14 | xmma 64×128 / s16816gemm 64×64 / gemv (L40S) |
| down (14336×4096) | 117 MB | 47.4 µs · 2.54 TB/s | 37.6 µs · 3.20 TB/s | 85.1 µs · 1.41 TB/s | 173.9 µs · 0.78 TB/s | 1.02 · L40S 1.16 | xmma 64×64 / s16816gemm 64×64 / gemv (L40S) |
| lm_head (4096×128256) | 1051 MB | 344.0 µs · 3.06 TB/s | 250.0 µs · 4.22 TB/s | 610.0 µs · 1.73 TB/s | 1475.8 µs · 0.84 TB/s | 1.00 · L40S 1.17 | xmma 64×128 / s16816gemm 64×64 / gemv (L40S) |
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.
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
Write path — output O
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.
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.
| cell | t (µs) | DRAM rd | theoretical min | amp | L2 hit | TC | achieved BW | kernels |
|---|---|---|---|---|---|---|---|---|
| A100 · bs=1, ctx 2048 | 35.9 | 8.74 MB | 8.40 MB | 1.04 | 10.7% | 18% | 243 GB/s (12%) | splitkv + combine (FA2) |
| A100 · bs=64, ctx 2048 | 373.7 | 537.6 MB | 537.9 MB | 1.00 | 24.1% | 47% | 1.45 TB/s (71%) | splitkv (FA2) |
| H100 · bs=1, ctx 2048 | 23.1 | 8.63 MB | 8.40 MB | 1.03 | 14.5% | 9% | 374 GB/s (11%) | FA3 device_kernel |
| H100 · bs=64, ctx 2048 | 195.1 | 537.6 MB | 537.9 MB | 1.01 | 1.9% | 25% | 2.77 TB/s (83%) | FA3 device_kernel |
| H200 · bs=1, ctx 2048 | 22.4 | 8.63 MB | 8.40 MB | 1.03 | 8.9% | 8% | 386 GB/s (8%) | FA3 device_kernel |
| H200 · bs=64, ctx 2048 | 149.4 | 537.6 MB | 537.9 MB | 1.01 | 1.2% | 37% | 3.62 TB/s (75%) | FA3 device_kernel |
| L40S · bs=1, ctx 2048 | 36.8 | 9.98 MB | 8.40 MB | 1.19 | 12.1% | 20% | 271 GB/s (31%) | splitkv + combine (FA2) |
| L40S · bs=64, ctx 2048 | 772.1 | 619.9 MB | 537.9 MB | 1.16 | 0.3% | 32% | 809 GB/s (94%) | splitkv (FA2) |
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=2048 | logical writes | H100 DRAM wr | H200 DRAM wr | A100 DRAM wr | L40S DRAM wr | absorbed (H100) |
|---|---|---|---|---|---|---|
| fused_add_rms_norm | 33.6 MB | 11.7 MB | 7.2 MB | 16.5 MB | 0.04 MB | 65% |
| rotary_embedding | 21.0 MB | 1.96 MB | 0.68 MB | 5.1 MB | ~0 MB | 91% |
| reshape_and_cache (KV write) | 8.4 MB | ~0 MB | ~0 MB | 0.02 MB | ~0 MB | ~100% |
| embedding gather | 16.8 MB | 1.87 MB | 0.89 MB | 3.8 MB | ~0 MB | 89% |
| silu_and_mul (working set 176 MB > L2) | 58.7 MB | 41.7 MB | 45.6 MB | 50.0 MB | 27.6 MB | 29% |
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.
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.
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:
| GPU | decode moved | decode min | excess | prefill moved | prefill min | excess |
|---|---|---|---|---|---|---|
| H100 | 15.64 GB | 15.29 GB | +2.3% | 48.1 GB | 37.3 GB | +29% |
| H200 | 15.59 GB | 15.29 GB | +2.0% | 44.2 GB | 37.3 GB | +18% |
| A100-80 | 15.63 GB | 15.29 GB | +2.2% | 52.5 GB | 37.3 GB | +41% |
| L40S | 17.60 GB | 15.29 GB | +15.1% | 40.3 GB | 37.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.
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 |
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.
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