NVIDIA GPU Architectures Series — Presentation 10

Software Stack & Performance — From CUDA to LLM Throughput

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.

CUDAcuBLAScuDNN CUTLASSNCCLTensorRT-LLM Transformer EngineTriton Flash-AttnPerformance Model
Driver → Runtime → cuBLAS → cuDNN → NCCL → Frameworks → TRT-LLM → Perf
00

Topics We'll Cover

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.

01

The CUDA Stack — Layered Cake

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.

User code — PyTorch / JAX / vLLM / TRT-LLM model.generate(), torch.compile(), te.fp8_autocast() High-level libraries cuBLASLt · cuDNN · NCCL · Transformer Engine · Flash-Attn · CUTLASS templates CUDA Runtime API (libcudart) cudaMalloc · cudaMemcpy · cudaLaunchKernel · cudaStreamCreate CUDA Driver API (libcuda) cuCtxCreate · cuModuleLoad · cuLaunchKernel · cuStreamCreate NVIDIA Driver — kernel module + GSP firmware nvidia.ko · nvidia-uvm.ko · GSP RM offload · doorbells, page tables, MMIO Hardware — GPU SMs · tensor cores · HBM/GDDR · NVLink · copy engines · PCIe

CUDA versions vs minimum hardware

ArchitectureCompute capabilityMin CUDA toolkitWhy
Ampere (A100, 30xx, A40, A6000)8.0 / 8.6CUDA 11.0+Third-gen tensor cores, BF16, async copy.
Ada Lovelace (40xx, L4, L40S)8.9CUDA 11.8+FP8 tensor cores (all Ada); SM89 PTX target.
Hopper (H100, H200, GH200)9.0CUDA 11.8+ (12.x recommended)WGMMA, TMA, thread-block clusters, FP8.
Blackwell (B100, B200, GB200, 50xx)10.0 / 12.0CUDA 12.8+5th-gen tensor cores, NVFP4 / MX-FP4, TMEM.
Why each layer exists

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.

02

cuBLAS / cuBLASLt — Matrix Multiplication

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.

cuBLAS — classic BLAS

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.

cuBLASLt — "Lt" = light-weight

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.

FP8 matmul on Hopper, with FP32 accumulator

cublasLtMatmul — A=FP8, B=FP8, C=BF16, accum=FP32 (sketch)
// 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);
The workspace and the algo cache

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.

Where cuBLASLt earns its keep

03

cuDNN — Deep Neural Networks

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 familyWhat cuDNN providesLLM relevance
ConvolutionForward, 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 supportCritical. cuDNN 9 is now the preferred path for transformer attention on Hopper/Blackwell.
NormalizationLayerNorm, RMSNorm, batchnorm, groupnorm — all with fused affine + epilogueRMSNorm is in every modern LLM block.
ActivationGELU, SiLU, ReLU, softmax (with online streaming variants)SiLU/GELU in the MLP, online-softmax in attention.
Graph APIBuild a DAG of fused ops, let cuDNN pick a single kernelHow modern frameworks register custom fused blocks.

cuDNN 9's flash-attention-style attention

cuDNN frontend — fused FP16 attention (sketch)
# 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)
Why frameworks moved to cuDNN attention

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.

04

CUTLASS — Templates for Custom Matmul

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.

When canned cuBLAS isn't right

3.x core
Layout Tile Copy Mma Pipeline Epilogue
Hopper
WGMMA atoms TMA loaders Cluster launch Async barrier
Blackwell
NVFP4 / MX-FP4 atoms TMEM accumulators 5th-gen MMA UMMA
CUTLASS 3.x — declaring a Hopper FP8 GEMM (sketch)
// 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;
Version pin

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.

05

Transformer Engine — Auto FP8/FP4

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.

What it does

Replaces nn.Linear and the attention block with versions that:

  • Track activation amax over a sliding window per tensor.
  • Compute per-tensor scaling factors so values fit in FP8 dynamic range.
  • Fuse cast-to-FP8 + GEMM + cast-back-to-BF16 in a single cuBLASLt call.
  • Emit FP8 weight tensors at save time so checkpoints round-trip.

Hopper vs Blackwell scaling

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.

The Python integration

PyTorch — replacing Linear with TE Linear
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)
Why bother — the numbers

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.

06

NCCL — Collective Comms

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".

Collectives that matter for transformers

OpUsed byBytes moved per call
ncclAllReduceTP attention + MLP output sums; DP gradient sync2 × (1 - 1/N) × tensor_bytes
ncclAllGatherFSDP weight gather; sequence-parallel input gather(N - 1) / N × tensor_bytes per rank
ncclReduceScatterFSDP gradient shard; sequence-parallel output reduce(N - 1) / N × tensor_bytes per rank
ncclSend / ncclRecvPipeline-parallel stage handoff; expert-parallel routingtensor_bytes (pairwise)

Algorithms NCCL picks between

Ring

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.

Tree

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.

NVLS (NVLink-Sharp)

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.

Critical environment knobs

NCCL knobs that change throughput by 2×
# 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
The real-world failure

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.

07

Triton — Python-First Kernel DSL

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.

What it gives you

  • Block-level loops over tensor tiles, not threads.
  • Automatic shared-memory staging for loads.
  • Automatic vectorisation and instruction scheduling.
  • JIT compilation per shape, with a kernel cache.
  • Targets Hopper WGMMA and Blackwell tensor cores via the Triton compiler backends.

Where you'll meet it

  • torch.compile generates Triton kernels for fused operators.
  • FlashAttention 2/3 reference implementations.
  • Mamba / SSM selective-scan kernels.
  • vLLM uses Triton for paged-attention helpers and quantization kernels.
  • Most cutting-edge research kernels ship as Triton first, CUDA later (or never).

A Triton matmul, end to end

Triton FP16 matmul — ~30 lines, runs on any modern arch
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
Triton vs CUTLASS

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.

08

TensorRT-LLM + vLLM + SGLang — The Inference Layer

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-LLMvLLMSGLang
OriginNVIDIA, closed-ishBerkeley + communityLMSYS + community
Build modelOffline-compiled engine per (model, GPU, precision)JIT graph at load timeJIT graph at load time
Cold startslow minutes; engine build is heavyfast secondsfast seconds
KV-cache schemePaged + in-flight batchingPagedAttention + continuous batchingRadixAttention — prefix-shared KV
PrecisionsFP16, BF16, FP8 (Hopper+), NVFP4 / MX-FP4 (Blackwell), INT4 AWQFP16, BF16, FP8, NVFP4 / MX-FP4, AWQ, GPTQFP16, BF16, FP8, AWQ
Custom kernelsNVIDIA-tuned CUTLASS, pluginsCUDA + Triton + Flash-AttnCUDA + Triton, plus structured-output pipeline
Throughputpeak on supported configsStrong, very close to TRT-LLMStrong, especially with shared prefixes
Ease of useheavy per-engine buildlight install + servelight
Niche strengthStatic deployments, FP8/FP4 perfGeneral-purpose, broad model coverageStructured output, agent workflows, big-prefix RAG

What TensorRT-LLM's engine actually contains

Picking the engine

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.

09

Performance Model — Decode-Bound vs Prefill-Bound

An LLM serving workload has two distinct phases, with different bottlenecks. Get this right and the rest of capacity planning falls into place.

Decode — bandwidth-bound

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.

Prefill — FLOPS-bound

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.

The KV-cache cost

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

ModelLayers × kv_heads × head_dimFP16 KV / tokenFP8 KV / token
Llama-3 8B32 × 8 × 128~131 KB~66 KB
Llama-3 70B80 × 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
Why this matters at 8K context

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.

10

Tensor Parallel Scaling — How Architectures Differ

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.

ConfigurationLink8-GPU TP scaling factorWhy
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 GBPCIe 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 GBPCIe 5 x16 (no NVLink)~1.7–1.9×PCIe 5 helps; there is no NVLink bridge on this card.
72× B200 in NVL72 rackNVLink 5 fabric, full mesh~60–65× (model-dependent)Whole rack acts as one GPU domain; NVLS reductions in switch silicon.

The compute / bandwidth ratio

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.

Practical implication

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.

11

Interactive: End-to-End LLM Performance Model

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.

Weight bytes
—
Fits VRAM?
—
Decode tok/s (b=1)
—
Throughput tok/s
—
Prefill tok/s
—
KV @ batch×seq
—
How to read this

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.