Hardware does nothing without the software stack that exposes it. Walk through CUDA's layers — driver, runtime, libraries, frameworks — meet cuBLAS, cuDNN, CUTLASS, NCCL, Triton, Transformer Engine, TensorRT-LLM — and finish with a performance model that turns a GPU's spec sheet into actual LLM tok/s.
The capstone of the architecture series. Up to this point we have inspected the silicon — SMs, tensor cores, memory hierarchy, NVLink, every architecture from Ampere to Blackwell. None of that runs an LLM by itself. The software stack is what turns a transistor count into a tok/s number.
Every call from model.generate(...) down to a tensor-core instruction passes through six layers. Knowing where each layer sits is the difference between debugging a NCCL timeout and hunting a kernel-mode firmware bug.
| Architecture | Compute capability | Min CUDA toolkit | Why |
|---|---|---|---|
| Ampere (A100, 30xx, A40, A6000) | 8.0 / 8.6 | CUDA 11.0+ | Third-gen tensor cores, BF16, async copy. |
| Ada Lovelace (40xx, L4, L40S) | 8.9 | CUDA 11.8+ | FP8 tensor cores (all Ada); SM89 PTX target. |
| Hopper (H100, H200, GH200) | 9.0 | CUDA 11.8+ (12.x recommended) | WGMMA, TMA, thread-block clusters, FP8. |
| Blackwell (B100, B200, GB200, 50xx) | 10.0 / 12.0 | CUDA 12.8+ | 5th-gen tensor cores, NVFP4 / MX-FP4, TMEM. |
The split is deliberate. Runtime is the convenience layer most developers use; Driver API is what JIT compilers and engines like TRT-LLM actually call so they can manage modules and contexts explicitly. Underneath, the kernel module is no longer a giant blob — since Open GPU Kernel Modules in 2022, the resource manager runs as GSP firmware on a small RISC-V on the GPU itself, and the host driver mostly marshals messages to it.
The whole transformer is, fundamentally, a stack of matrix multiplications interleaved with elementwise ops. Whatever framework you use, almost every FLOP eventually lands in cuBLAS or its successor cuBLASLt.
The original NVIDIA BLAS implementation. Provides cublasSgemm, cublasHgemm, cublasGemmEx, cublasGemmStridedBatchedEx and so on. Static algorithm selection per (precision, transpose) pair. Still used everywhere, but the ergonomics are showing their age.
Newer API with problem descriptors and algorithm heuristics. The library inspects matrix shapes, layouts, alignment and bias/activation epilogues, then selects the best kernel from its catalogue. Required for FP8 (Hopper+) and FP4 microscaling — both NVFP4 (16-element blocks, E4M3 scale) and MX-FP4 (32-element blocks, E8M0 scale) on Blackwell. PyTorch's torch.matmul dispatches here for big shapes.
// Descriptors set: A=E4M3 row, B=E4M3 col, scaleType=FP32, accumType=FP32, output=BF16
cublasLtMatmulDesc_t op;
cublasLtMatmulDescCreate(&op, CUBLAS_COMPUTE_32F, CUDA_R_32F);
cublasLtMatmulDescSetAttribute(op, CUBLASLT_MATMUL_DESC_A_SCALE_POINTER, &a_scale, sizeof(void*));
cublasLtMatmulDescSetAttribute(op, CUBLASLT_MATMUL_DESC_B_SCALE_POINTER, &b_scale, sizeof(void*));
// Heuristic asks the library what kernel + workspace fit this problem
cublasLtMatmulHeuristicResult_t heur[8];
int n;
cublasLtMatmulAlgoGetHeuristic(handle, op, ad, bd, cd, dd, pref, 8, heur, &n);
// Three-line execution: A * B + bias, with chosen algo and workspace
cublasLtMatmul(handle, op,
&alpha, A, ad, B, bd, &beta, C, cd, D, dd,
&heur[0].algo, workspace, ws_bytes, stream);
Every cuBLASLt matmul wants a workspace — scratch HBM for tiling and split-K accumulators. PyTorch defaults to 4–32 MiB; bigger workspaces let the library pick faster kernels. The algo cache stores the heuristic decision per problem shape so the second call is essentially free; warm-up matters — the first batch of a new shape is always slower.
cuDNN is the layer above cuBLAS that knows what a neural network is rather than just a matrix. Convolutions, attention, normalization, activations, pooling, RNN cells — the abstraction is "an op fused into a graph", not "a single GEMM".
| Op family | What cuDNN provides | LLM relevance |
|---|---|---|
| Convolution | Forward, backward-data, backward-weights; multiple algorithms (implicit GEMM, Winograd, FFT) | Marginal — LLMs are mostly attention + MLP. Vision encoders still rely on it. |
| Attention (frontend) | Fused multi-head attention, flash-attention-style kernels, grouped-query and MQA support | Critical. cuDNN 9 is now the preferred path for transformer attention on Hopper/Blackwell. |
| Normalization | LayerNorm, RMSNorm, batchnorm, groupnorm — all with fused affine + epilogue | RMSNorm is in every modern LLM block. |
| Activation | GELU, SiLU, ReLU, softmax (with online streaming variants) | SiLU/GELU in the MLP, online-softmax in attention. |
| Graph API | Build a DAG of fused ops, let cuDNN pick a single kernel | How modern frameworks register custom fused blocks. |
# Python via cudnn-frontend bindings — simplified
import cudnn
graph = cudnn.pygraph(io_data_type=cudnn.data_type.HALF)
q = graph.tensor_like(Q) # [B, H, S, D]
k = graph.tensor_like(K)
v = graph.tensor_like(V)
o, stats = graph.sdpa(
name="sdpa", q=q, k=k, v=v,
is_inference=True,
attn_scale=1.0/math.sqrt(D),
use_causal_mask=True,
use_alibi=False,
)
o.set_output(True).set_dim([B, H, S, D])
graph.build([cudnn.heur_mode.A]) # picks best kernel for shape
graph.execute({q: Q, k: K, v: V, o: O}, workspace)
Before cuDNN 9, the universe of fused-attention kernels was a maze of community CUDA — flash-attn-2, xFormers, custom Triton, every one with its own quirks. cuDNN 9 centralised it: one frontend, one kernel catalogue, hardware-vendor-supported, with FP8 paths on Hopper and NVFP4 / MX-FP4 paths on Blackwell. PyTorch's F.scaled_dot_product_attention now prefers cuDNN's implementation when available.
cuBLAS is a black box; you call it, it returns a result. CUTLASS is the opposite: a header-only C++ template library that exposes every level of the matmul hierarchy — tile shape, warp tile, instruction tile, pipeline depth, epilogue — for you to compose. NVIDIA writes its own kernels with it, and so does every research lab.
// Pick the architecture-specific atoms
using ArchTag = cutlass::arch::Sm90;
using ElementA = cutlass::float_e4m3_t; // FP8 E4M3
using ElementB = cutlass::float_e4m3_t;
using ElementAcc = float; // FP32 accumulate
using ElementC = cutlass::bfloat16_t; // BF16 output
using TileShape = Shape<_128, _128, _64>; // CTA-level tile
using ClusterShape = Shape<_2, _1, _1>; // thread-block cluster
// Compose mainloop + epilogue
using Mainloop = cutlass::gemm::collective::CollectiveBuilder<
ArchTag, OperatorClass, ElementA, LayoutA, AlignA,
ElementB, LayoutB, AlignB, ElementAcc,
TileShape, ClusterShape,
cutlass::gemm::collective::StageCountAuto,
cutlass::gemm::KernelTmaWarpSpecializedPingpong >::CollectiveOp;
CUTLASS 3.x is the rewrite around CuTe (the new layout algebra) and is required for Hopper WGMMA-aware kernels. CUTLASS 3.8+ introduced first-class NVFP4 / MX-FP4 atoms for Blackwell. If you are studying flash-attention-3, DeepSeek's grouped GEMM, or NVIDIA's MoE kernels, you are reading CUTLASS 3.x.
Decks 03 and 07 covered the silicon: per-tensor FP8 scales on Hopper, per-microblock FP4 (NVFP4 and MX-FP4) on Blackwell. Deck 09 covered the second-gen Transformer Engine on Blackwell. Transformer Engine the library is the piece that drives the silicon.
Replaces nn.Linear and the attention block with versions that:
Hopper TE: per-tensor scale. One scaling factor per (weight, activation) tensor. Good enough for most LLMs.
Blackwell TE2: per-microblock FP4. Two formats supported in hardware — NVFP4 (16-element blocks, E4M3 scale + per-tensor FP32 scale; default in TE / TRT-LLM) and the open OCP MX-FP4 (32-element blocks, E8M0 scale). Quality is materially better than per-tensor scaling, allowing FP4 weights for inference.
Both expose the same Python API; the engine picks the right kernel.
import transformer_engine.pytorch as te
from transformer_engine.common import recipe
# A transformer block, with TE-aware Linear and LayerNorm
class Block(nn.Module):
def __init__(self, d, hidden):
super().__init__()
self.norm = te.RMSNorm(d)
self.fc1 = te.Linear(d, hidden) # <-- not torch.nn.Linear
self.fc2 = te.Linear(hidden, d)
# Per-tensor scaling recipe (Hopper); MX recipe on Blackwell
fp8_recipe = recipe.DelayedScaling(
margin=0, fp8_format=recipe.Format.HYBRID,
amax_history_len=16, amax_compute_algo="max")
# Forward inside the autocast block runs FP8 in tensor cores
with te.fp8_autocast(enabled=True, fp8_recipe=fp8_recipe):
y = block(x)
On H100, switching a 70B fine-tune from BF16 to FP8 via Transformer Engine roughly halves step time and memory pressure with quality essentially unchanged. On B200 the second-gen engine extends the same trick to FP4 inference, doubling throughput again over H100 FP8 for the right workloads. None of that is automatic from the framework alone — the layer-replacement and the autocast block are how you opt in.
Once a model crosses one GPU, every step gathers, all-reduces, or reduce-scatters tensors across ranks. NCCL (NVIDIA Collective Communications Library) is the implementation. It is in every distributed framework: PyTorch DDP, FSDP, vLLM TP, Megatron, JAX. Bad NCCL configuration is the most common cause of "8 GPUs do less than 4".
| Op | Used by | Bytes moved per call |
|---|---|---|
ncclAllReduce | TP attention + MLP output sums; DP gradient sync | 2 × (1 - 1/N) × tensor_bytes |
ncclAllGather | FSDP weight gather; sequence-parallel input gather | (N - 1) / N × tensor_bytes per rank |
ncclReduceScatter | FSDP gradient shard; sequence-parallel output reduce | (N - 1) / N × tensor_bytes per rank |
ncclSend / ncclRecv | Pipeline-parallel stage handoff; expert-parallel routing | tensor_bytes (pairwise) |
Each rank sends to its right neighbour, receives from its left, in 2(N−1) steps. Bandwidth-optimal on big tensors. Default for large all-reduces over NVSwitch or fast PCIe.
Two trees overlaid — reduce up one, broadcast down the other. Latency-optimal on small messages and across deep network hops. Used for cross-node IB.
Hardware-accelerated reduction inside the NVSwitch. Bytes only cross the switch once and are reduced in flight. Massive win on NVL72 and HGX H100/H200/B200 boxes.
# Pick the algorithm explicitly — default heuristics are usually right but not always
export NCCL_ALGO=Ring # or Tree, or NVLS, or NVLSTree
export NCCL_PROTO=Simple # Simple | LL | LL128
# Enable in-network reductions on NVSwitch (Hopper/Blackwell HGX, NVL72)
export NCCL_NVLS_ENABLE=1
# Diagnostics
export NCCL_DEBUG=INFO
export NCCL_TOPO_DUMP_FILE=/tmp/topo.xml
# Turn off PXN if you are debugging weird stalls; turn it back on for perf
export NCCL_PXN_DISABLE=0
# Force IB over a particular HCA, exclude management interfaces
export NCCL_IB_HCA=mlx5_0,mlx5_1
export NCCL_SOCKET_IFNAME=^lo,docker0
A common pattern: an 8×H100 node with NVSwitch is configured but NCCL_NVLS_ENABLE was left at 0. TP-8 throughput halves silently. Or: a Kubernetes pod has NCCL_SOCKET_IFNAME picking a CNI overlay interface, and the all-reduce traverses the host network instead of NVLink. Always print the topology with NCCL_DEBUG=INFO and verify the chosen algo and protocol on first run.
Until Triton, writing a custom CUDA kernel meant C++, CUTLASS templates, and very specific knowledge of warp scheduling. Triton (originally OpenAI) provides a Python-like language where you express tile-level ops on tensors, and a compiler handles vectorisation, shared-memory tiling, and warp-level scheduling automatically.
torch.compile generates Triton kernels for fused operators.import torch, triton
import triton.language as tl
@triton.jit
def matmul_kernel(A, B, C, M, N, K,
sa0, sa1, sb0, sb1, sc0, sc1,
BM: tl.constexpr, BN: tl.constexpr, BK: tl.constexpr):
pid_m = tl.program_id(0)
pid_n = tl.program_id(1)
rm = pid_m * BM + tl.arange(0, BM)
rn = pid_n * BN + tl.arange(0, BN)
rk = tl.arange(0, BK)
a_ptr = A + rm[:, None] * sa0 + rk[None, :] * sa1
b_ptr = B + rk[:, None] * sb0 + rn[None, :] * sb1
acc = tl.zeros([BM, BN], dtype=tl.float32)
for k in range(0, K, BK):
a = tl.load(a_ptr, mask=(rm[:, None] < M) & (rk[None, :] + k < K))
b = tl.load(b_ptr, mask=(rk[:, None] + k < K) & (rn[None, :] < N))
acc += tl.dot(a, b) # maps to WMMA / WGMMA / Blackwell MMA
a_ptr += BK * sa1
b_ptr += BK * sb0
c_ptr = C + rm[:, None] * sc0 + rn[None, :] * sc1
tl.store(c_ptr, acc.to(tl.float16),
mask=(rm[:, None] < M) & (rn[None, :] < N))
def matmul(a, b, BM=128, BN=128, BK=32):
M, K = a.shape; _, N = b.shape
c = torch.empty((M, N), device=a.device, dtype=torch.float16)
grid = (triton.cdiv(M, BM), triton.cdiv(N, BN))
matmul_kernel[grid](a, b, c, M, N, K,
a.stride(0), a.stride(1),
b.stride(0), b.stride(1),
c.stride(0), c.stride(1),
BM=BM, BN=BN, BK=BK)
return c
Roughly: CUTLASS is what you reach for when you want to wring the last 5% out of a kernel and you know exactly which tensor-core instruction you want. Triton is what you reach for when you want a kernel that is 90% of optimal in 30 lines and runs on the next architecture without rewriting. Modern frameworks contain both; torch.compile emits Triton for fused ops and falls back to cuBLAS/CUTLASS for big GEMMs.
Above all the kernels sits the inference engine — the thing that schedules requests, manages KV cache, batches prompts, and turns a model checkpoint into an HTTP-served chat completion. Three engines dominate in 2026.
| TensorRT-LLM | vLLM | SGLang | |
|---|---|---|---|
| Origin | NVIDIA, closed-ish | Berkeley + community | LMSYS + community |
| Build model | Offline-compiled engine per (model, GPU, precision) | JIT graph at load time | JIT graph at load time |
| Cold start | slow minutes; engine build is heavy | fast seconds | fast seconds |
| KV-cache scheme | Paged + in-flight batching | PagedAttention + continuous batching | RadixAttention — prefix-shared KV |
| Precisions | FP16, BF16, FP8 (Hopper+), NVFP4 / MX-FP4 (Blackwell), INT4 AWQ | FP16, BF16, FP8, NVFP4 / MX-FP4, AWQ, GPTQ | FP16, BF16, FP8, AWQ |
| Custom kernels | NVIDIA-tuned CUTLASS, plugins | CUDA + Triton + Flash-Attn | CUDA + Triton, plus structured-output pipeline |
| Throughput | peak on supported configs | Strong, very close to TRT-LLM | Strong, especially with shared prefixes |
| Ease of use | heavy per-engine build | light install + serve | light |
| Niche strength | Static deployments, FP8/FP4 perf | General-purpose, broad model coverage | Structured output, agent workflows, big-prefix RAG |
If you are deploying a fixed model on H100 / B200 with a known traffic profile and care about peak tok/s/$ — TensorRT-LLM. If you are switching models often, supporting many quantization formats, or running on a heterogeneous fleet — vLLM. If your traffic is dominated by long shared prefixes (RAG, agent loops) — SGLang's RadixAttention can win by a lot. Most teams end up running two of the three side-by-side.
An LLM serving workload has two distinct phases, with different bottlenecks. Get this right and the rest of capacity planning falls into place.
One token at a time, batch usually small. Each token reads all of the model's weights once from HBM into the SMs, multiplies by a vector, writes a single new KV entry, and is done.
Tensor cores are largely idle — the matmul is matrix × vector, not matrix × matrix. The GPU spends almost all of its time waiting on HBM.
tok/s ≈ HBM_BW / weight_bytes
For 70B FP8 (70 GB) on H200 (4.8 TB/s): ~68 tok/s ceiling per stream.
Whole prompt processed at once. Now the matmul is matrix (S tokens) × matrix (weights). Tensor cores do real work; HBM bandwidth is plentiful relative to compute.
Almost all time is in the big GEMMs of the MLP and attention output projections.
prefill_tps ≈ TC_FLOPS / (2 × num_params)
The 2× comes from one multiply + one add per parameter per token in the dominant matmul.
Every new decode token writes a fresh row into the K and V caches:
kv_per_token = 2 × num_layers × num_kv_heads × head_dim × bytes
| Model | Layers × kv_heads × head_dim | FP16 KV / token | FP8 KV / token |
|---|---|---|---|
| Llama-3 8B | 32 × 8 × 128 | ~131 KB | ~66 KB |
| Llama-3 70B | 80 × 8 × 128 | ~328 KB | ~164 KB |
| Llama-3 405B (GQA) | 126 × 8 × 128 | ~516 KB | ~258 KB |
| Mistral 24B (GQA) | 40 × 8 × 128 | ~164 KB | ~82 KB |
An 8K-context, batch-32 deployment of Llama-3 70B with FP8 KV consumes about 40 GB of HBM just for KV cache, on top of the 70 GB of weights. That fits on H200 (141 GB) but not H100 (80 GB). Long contexts and large batches push you towards H200 / B200 / RTX PRO 6000 Blackwell long before the model itself does.
Tensor parallel splits each weight matrix across N GPUs and inserts an all-reduce after every attention output and every MLP output. Whether N× throughput is realised depends entirely on how fast that all-reduce is relative to the GEMM it follows.
| Configuration | Link | 8-GPU TP scaling factor | Why |
|---|---|---|---|
| 8× A100 80 GB SXM (DGX A100) | NVSwitch, NVLink 3 (600 GB/s) | ~7.0× | Bandwidth ample; BF16 only, so compute is the slow part too. |
| 8× H100 80 GB SXM (DGX H100) | NVSwitch, NVLink 4 (900 GB/s) | ~7.6× | NVSwitch + NVLS reduce. FP8 doubles compute, but NVLink doubles too — ratios stay healthy. |
| 8× B200 192 GB (HGX B200, NVL4 domain) | NVSwitch, NVLink 5 (1.8 TB/s) | ~7.8× | NVLink 5 keeps up with FP8/FP4 compute. NVLS in-network reduce. |
| 2× RTX 4090 24 GB | PCIe 4 x16 (~32 GB/s) | ~1.4× | No NVLink. Each all-reduce stalls the pipeline; barely a win over single-GPU. |
| 8× RTX 4090 24 GB (consumer rig) | PCIe 4 x16, no switch | ~3–4× | PCIe trees, P2P often hampered by IOMMU; collective cost dominates. |
| 2× RTX PRO 6000 Blackwell 96 GB | PCIe 5 x16 (no NVLink) | ~1.7–1.9× | PCIe 5 helps; there is no NVLink bridge on this card. |
| 72× B200 in NVL72 rack | NVLink 5 fabric, full mesh | ~60–65× (model-dependent) | Whole rack acts as one GPU domain; NVLS reductions in switch silicon. |
The shape of TP scaling is not "NVLink = good, PCIe = bad" in isolation. It is the ratio of (GEMM time) to (all-reduce time). FP8 / FP4 halve or quarter the GEMM time without changing the all-reduce. So as you adopt lower precisions, the relative cost of comms goes up, and the gap between an NVLink box and a PCIe box widens. Hopper and Blackwell only really shine when the link beneath them is fast enough to feed the new tensor cores.
An 8×4090 PCIe rig is not "75% of an 8×A100". For TP serving it is closer to 40–50% of an 8×A100 because the all-reduce dominates. The right comparison for a consumer-card rig is not "smaller TP", it is "more independent DP replicas with a router in front" — which gives you near-linear throughput for free.
Combine everything from the earlier slides into a single calculator. Pick a model, a precision, a GPU, a batch size and a sequence length, and get a quick verdict on whether you are decode-bound, prefill-bound, or out of VRAM.
The single-stream decode tok/s is a hard physical ceiling — HBM_BW / weight_bytes. The throughput number assumes you are batching well; in reality scheduler overhead, attention costs, and KV pressure pull it 20–40% lower. The prefill number is the rate at which prompt tokens get turned into KV entries on the first forward pass — it is what determines TTFT (time-to-first-token) for long prompts.