From Transformer to Production Server
agent37.club
14 GB model. 8 GB GPU. First wall.
50 tok/s single user. 10 concurrent → 30-second latency. Second wall.
The gap between "I downloaded a model" and "production inference service" is enormous.
mini-sglang: ~5,000 lines of Python. Small enough to understand, large enough to be useful.
Chapter 1
Attention, FFN, and the shapes that matter
Attention(Q, K, V) =
softmax(QKT / √dk) ⋅ V
| Tensor | Shape |
|---|---|
| Input x | (batch, seq_len, hidden_dim) |
| Q, K, V | (batch, seq_len, head_dim) |
| QKT scores | (batch, seq_len, seq_len) |
| Output | (batch, seq_len, head_dim) |
def attention(q, k, v): d_k = q.shape[-1] scores = torch.matmul(q, k.transpose(-2, -1)) scores = scores / math.sqrt(d_k) weights = torch.softmax(scores, dim=-1) return torch.matmul(weights, v)
| Method | KV Heads | Cache (32K ctx) |
|---|---|---|
| Full MHA | 32 | 42 GB |
| GQA | 8 | 5.2 GB |
| MQA | 1 | 0.65 GB |
“That’s the difference between fitting on one GPU or needing two.”
# SwiGLU forward pass def forward(self, x): gate = F.silu(self.w1(x)) # silu = x * sigmoid(x) up = self.w3(x) return self.w2(gate * up) # Shapes for 7B model: # x: (batch, seq, 4096) # w1(x): (batch, seq, 11008) gate projection # w3(x): (batch, seq, 11008) up projection # w2(...): (batch, seq, 4096) down projection
SiLU = x · sigmoid(x), also called Swish. Smooth, non-monotonic activation.
| Component | Params | Share |
|---|---|---|
| Attention (GQA) | ~38M | 22% |
| FFN (SwiGLU) | ~135M | 78% |
| Total per layer | ~173M | 100% |
FFN has ~3.5x the parameters of attention.
Most people think attention is where the action is — but the FFN is where the knowledge lives.
32-layer model: 4.3B parameters in FFN alone
| Component | Shape / Count | Params |
|---|---|---|
| Embedding | 151936 × 4096 | 622M |
| Q proj (×32 layers) | 4096 × 4096 | 16.7M |
| K proj (GQA, 8 heads) | 4096 × 1024 | 4.2M |
| V proj (GQA, 8 heads) | 4096 × 1024 | 4.2M |
| O proj | 4096 × 4096 | 16.7M |
| FFN w1 (gate) | 4096 × 11008 | 45M |
| FFN w3 (up) | 4096 × 11008 | 45M |
| FFN w2 (down) | 11008 × 4096 | 45M |
| LM Head | 4096 × 151936 | 622M |
total parameters
at fp16 (2 bytes per param)
Embedding → 32 × [RMSNorm → Attention → + → RMSNorm → FFN → +] → Final Norm → LM Head
Chapter 2
Prefill, decode, and the KV cache
# The naive generation loop def generate(model, prompt_ids, max_tokens): input_ids = prompt_ids.clone() for _ in range(max_tokens): logits = model(input_ids) # re-run ENTIRE sequence next_tok = torch.argmax(logits[:, -1, :], dim=-1) input_ids = torch.cat([input_ids, next_tok.unsqueeze(-1)], dim=1) return input_ids
Naive cost: Token 1 processes p tokens. Token n processes p+n-1 tokens.
Total: np + n(n-1)/2 = O(n²) forward passes. “This is terrible.”
THE fundamental insight of LLM inference
| Tokens | All prompt tokens at once |
| Parallelism | Full — all tokens known |
| Operation | GEMM (matrix × matrix) |
| Bound by | Compute |
| Intensity | High — tensor cores stay busy |
Builds the initial KV cache.
| Tokens | 1 new token per step |
| Parallelism | Minimal |
| Operation | GEMV (matrix × vector) |
| Bound by | Memory bandwidth |
| Intensity | Low — GPU starving for data |
Appends one K,V entry per step.
Every decode step recomputes K,V for all previous tokens.
O(n²) per token generated
Store K,V once computed. Only compute for the new token.
O(n) per token generated
Tradeoff: Exchange O(n²) redundant computation for O(n) memory usage. K,V at position i only depend on input at position i — once computed, they never change.
Per Token
Per Sequence
Total (batch)
of 80 GB H100 VRAM
Chapter 3
GEMM, GEMV, and the roofline model
C = A @ B (M,K) × (K,N)
| FLOPs | 2 × M × N × K |
| Memory | 3n² elements (square case) |
| Intensity | (2/3)n FLOPs/element |
| n=4096 | ~1350 FLOPs/byte |
| Status | COMPUTE-BOUND |
y = A @ x (M,K) × (K,)
| FLOPs | 2 × M × K |
| Memory | M×K + K + M elements |
| Intensity | ~0.5 FLOPs/byte |
| vs Ridge | 1200× below ridge point |
| Status | MEMORY-BOUND |
This single distinction — GEMM vs GEMV — explains why prefill is fast and decode is slow. The entire performance story follows from here.
TFLOPS
fp16 tensor core (sparse)
TB/s
HBM3 memory bandwidth
FLOPs/byte
ridge point (2000 / 3.35)
The ridge point is the dividing line. Below ~597 FLOPs/byte, your operation is memory-bound. Above it, compute-bound. Single-token decode sits at 0.5 FLOPs/byte — 1200× below the ridge.
| GEMM Benchmark | Achieved TFLOPS |
|---|---|
| 2048 × 2048 | 617 |
| 4096 × 4096 | 709 |
| 8192 × 8192 | 686 |
Memory wins over compute. The GPU is starving.
“The GPU loads the entire 13 GB model from memory for each token, but only does one row of computation. The GPU is a supercar stuck in a parking lot.”
| Batch | TFLOPS | Tokens/sec | Efficiency | |
|---|---|---|---|---|
| 1 | 3.4 | 10,000 | 0.17% | |
| 8 | 27.0 | 80,000 | 1.35% | |
| 32 | 107.0 | 316,000 | 5.4% | |
| 128 | 430.0 | 1,270,000 | 21.5% | |
| 512 | 1,720.0 | 5,100,000 | 86.0% |
You need batch ≈ 600 to saturate an H100. This is why production serving systems need continuous batching, complex scheduling, and memory management — maintaining large effective batch sizes is the entire game.
| Batch | Latency/tok | Throughput | Vibe |
|---|---|---|---|
| 1 | 0.1 ms | 10K tok/s | Fast for one user |
| 32 | 0.3 ms | 316K tok/s | Good balance |
| 128 | 0.8 ms | 1.27M tok/s | Production sweet spot |
| 512 | 1.3 ms | 5.1M tok/s | Max throughput |
The fundamental tension: Each user wants low latency. The system wants high throughput. Production serving is the art of balancing both.
Chapter 4
Kernels, memory hierarchy, and profiling
H100: 132 SMs, each with CUDA cores + Tensor cores
| Level | Bandwidth | Size (H100) |
|---|---|---|
| Registers | ~20 TB/s | 256 KB/SM |
| Shared Mem | ~15 TB/s | 228 KB/SM |
| L2 Cache | ~8 TB/s | 50 MB |
| HBM (Global) | 3.35 TB/s | 80 GB |
Classic optimization: load from HBM to shared memory once, access it many times.
// CUDA vector addition kernel __global__ void vector_add( float* a, float* b, float* c, int n) { int idx = blockIdx.x * blockDim.x + threadIdx.x; if (idx < n) { c[idx] = a[idx] + b[idx]; } } // Launch configuration int block_size = 256; int num_blocks = (n + 255) / 256; vector_add<<<num_blocks, block_size>>>( d_a, d_b, d_c, n);
idx = blockIdx.x × blockDim.x + threadIdx.x
| Concept | Meaning |
|---|---|
| __global__ | Callable from CPU, runs on GPU |
| blockIdx.x | Which block am I in? |
| blockDim.x | Threads per block (256) |
| threadIdx.x | My index within the block |
| if (idx < n) | Bounds check for edge blocks |
| <<<B, T>>> | Launch config: blocks, threads |
Block size 256 = 8 warps of 32 threads. (n + 255) / 256 = ceiling division for full coverage.
# Profile your inference script $ nsys profile -o output python inference.py $ nsys stats output.nsys-rep # Label regions with NVTX from torch.cuda import nvtx with nvtx.range("prefill"): output = model(input_ids) with nvtx.range("decode"): for i in range(100): output = model(token)
“Profile before optimizing. Your intuition is probably wrong.”
Optimizing the 8% RMSNorm? Waste of effort.
Optimizing the 68% GEMM + Attention? That moves the needle.
Watch for: Gaps between kernels (launch overhead), cudaMemcpy stalls, sequential kernels that could be parallel.
Coalescing, shared memory, tensor cores, and Triton
Making every transistor count
1 transaction (128 bytes)
Multiple transactions (wastes half)
Measured Bandwidth (H100 SXM)
Just reading every other element drops bandwidth by 77%. The hardware is incredibly sensitive to access patterns.
Bandwidth Comparison
4.5x faster bandwidth, 10-15x lower latency
Tiled Reduction Pattern
__global__ void sum_reduce( float* data, float* result, int n) { __shared__ float smem[256]; int idx = blockIdx.x * blockDim.x + threadIdx.x; smem[threadIdx.x] = (idx < n) ? data[idx] : 0.0f; __syncthreads(); for (int s = blockDim.x/2; s > 0; s >>= 1) { if (threadIdx.x < s) smem[threadIdx.x] += smem[threadIdx.x+s]; __syncthreads(); } if (threadIdx.x == 0) atomicAdd(result, smem[0]); }
128 FLOPs per cycle per core
30x faster for matrix math
33% performance drop from a single extra element!
Takeaway: Pad your matrices to multiples of 16. Always.
A 10-line Triton kernel gets 83% of cuBLAS. Decades of hardware evolution accessible through simple Python.
@triton.jit def matmul_kernel( a_ptr, b_ptr, c_ptr, M, N, K, BLOCK_M: tl.constexpr, BLOCK_N: tl.constexpr, BLOCK_K: tl.constexpr): pid_m = tl.program_id(0) pid_n = tl.program_id(1) offs_m = pid_m*BLOCK_M + tl.arange(0, BLOCK_M) offs_n = pid_n*BLOCK_N + tl.arange(0, BLOCK_N) acc = tl.zeros((BLOCK_M, BLOCK_N), dtype=tl.float32) for k in range(0, K, BLOCK_K): a = tl.load(a_ptr + ...) b = tl.load(b_ptr + ...) acc += tl.dot(a, b) # Tensor cores! tl.store(c_ptr + ..., acc)
The Attention Bottleneck, Solved
The N2 matrix never needs to exist
per attention head at seq_len=4K
40962 x 2 bytes
for all attention matrices
32 heads x 32 layers
Arithmetic intensity: only 32 FLOPs/byte vs H100 ridge point of 600 FLOPs/byte. That is 18.75x below the compute ceiling.
Attention Matrix Size Scaling
GPU SRAM per SM: only ~200 KB
Standard Softmax (3 passes):
Online Softmax (1 pass):
def online_softmax(x): m, d = float('-inf'), 0.0 for xi in x: m_new = max(m, xi) # Correction factor! d = d * math.exp(m - m_new) \ + math.exp(xi - m_new) m = m_new return [math.exp(xi-m)/d for xi in x]
Step-Through
Q rows x K columns (click Next to process)
SRAM Budget Per Tile
28 KB per tile in SRAM vs 32 MB for the full attention matrix. ~1,000x reduction.
(B=64, d=128, fp16)
| Metric | Standard | FlashAttention |
|---|---|---|
| Memory | O(N2) | O(N) |
| Kernel Speed | 1x | 2-4x faster |
| BERT-large E2E | baseline | 15% faster |
| Max Seq Length | ~8K (OOM) | 65K+ |
15% E2E seems modest because attention is a fraction of total compute. The kernel-level speedup (2-4x) is what matters.
FlashInfer: Inference-Specialized
| Batch | SDPA | FlashInfer | Speedup |
|---|---|---|---|
| B=1 | 0.02ms | 0.02ms | 1x |
| B=8 | 0.11ms | 0.03ms | 3.7x |
| B=32 | 0.41ms | 0.10ms | 4.1x |
GQA: 32 heads / 8 KV heads, seq=2048
The Serving Revolution
Just check after every iteration
useful tokens
wasted (padding)
efficiency
Nearly half your GPU is doing nothing useful. At $2-3/hr for an H100, that is $1-1.50/hr burned.
Static: wait for all to finish
Continuous: check every step
GPU Utilization
Orca vs FasterTransformer on GPT-3 175B. Not a marginal optimization — a paradigm shift.
OSDI 2022 — Yu et al.
Scheduler Strategies
| Strategy | Tradeoff |
|---|---|
| FCFS | Fair but not optimal — long requests block short ones |
| SJF | Min avg latency — requires length estimation |
| Priority | Premium vs free tier, deadline-aware |
Swap KV cache to CPU when a high-priority request arrives. Resume later without recomputing.
Savings by Workload
def match_prefix(self, tokens): node, matched = self.root, 0 for token in tokens: if token in node.children: node = node.children[token] matched += 1 else: break return node, matched
Naive: Contiguous Allocation
Fragmented! New sequence cannot use scattered free blocks.
Paged: Fixed-Size Blocks
No fragmentation! Block table maps logical → physical.
Block Table
| Sequence | Logical | Physical |
|---|---|---|
| Seq A | 0, 1 | 0, 2 |
| Seq B | 0, 1, 2 | 1, 4, 6 |
| Seq C | 0, 1 | 3, 7 |
| Seq D | 0 | 5 |
Just like OS virtual memory: logical-to-physical mapping, page tables, CoW. Same idea, new hardware.
Production refinements for serving at scale
Chunked prefill, mixed batches, CUDA graphs, overlap
Monolithic Prefill
Chunked Prefill (512-token chunks)
monolithic decode delay
chunked max delay
in worst-case decode latency. 32K prompt / 512 tokens per chunk = 64 iterations.
| Prefill | Decode | |
|---|---|---|
| Tokens | 512 per request | 1 per request |
| Operation | GEMM | GEMV |
| Bottleneck | Compute-bound | Memory-bound |
Naive Padding Waste
Smart: Split Attention, Share FFN
Overhead as % of Total Time
Pre-capture graphs for: 1, 2, 4, 8, 16, 32. Pad to nearest captured size if needed.
Without CUDA Graph
With CUDA Graph
g = torch.cuda.CUDAGraph() with torch.cuda.graph(g): output = model.decode_step( input, kv_cache) # Inference loop for token in range(max_tokens): g.replay() # single launch!
Sequential: GPU idles during scheduling
Throughput loss: 2/(10+2) = 17%
Overlapped: CPU and GPU work in parallel
No idle gaps! Two threads with lock + join synchronization.
Each layer builds on the one below. Ch 5 teaches HOW to make kernels fast. Ch 6 applies that to attention. Ch 7 moves to system-level scheduling. Ch 8 adds production refinements.
| Metric | Value |
|---|---|
| H100 HBM bandwidth | 3.35 TB/s |
| H100 fp16 tensor compute | ~2000 TFLOPS |
| Ridge point (memory-bound vs compute-bound) | ~600 FLOPs/byte |
| GEMV arithmetic intensity (decode) | 0.5 FLOPs/byte |
| Decode latency (7B, batch=1) | ~5 ms |
| KV cache per token (7B, GQA) | 128 KB |
| Shared memory bandwidth | ~15 TB/s (4.5x HBM) |
| Static batching efficiency | ~56% |
| Continuous batching utilization | 95%+ |
| FlashAttention memory complexity | O(N) vs O(N2) |
| Term | Definition |
|---|---|
| Coalescing | Combining contiguous memory accesses into fewer transactions |
| Bank Conflict | Two threads hitting the same shared memory bank → serialization |
| Tensor Core | 4x4 matrix multiply-accumulate hardware unit |
| Roofline | Performance vs arithmetic intensity — shows memory/compute ceilings |
| Triton | Python-based GPU kernel language, block programming model |
| Online Softmax | Single-pass softmax with running max correction |
| FlashAttention | Tiled attention that never materializes N2 matrix |
| Term | Definition |
|---|---|
| FlashInfer | Inference-specialized attention (variable lengths, paged KV) |
| Continuous Batching | Add/remove requests after every iteration |
| PagedAttention | Block-based KV cache allocation — no fragmentation |
| Radix Cache | Prefix tree for reusing KV cache across shared prefixes |
| Chunked Prefill | Process long prompts in pieces to avoid blocking decode |
| Mixed Batch | Batch containing both prefill and decode tokens |
| CUDA Graph | Pre-recorded GPU operations replayed with single launch |
More Parameters, Same Compute
Chapter 9
30B total params
3B active per token
8 experts, top_k=2 — "A3B" = Active 3B
# Router selects 2 of 8 experts router_logits = Linear(x) # 4096 → 8 weights, indices = topk(logits, 2) output = 0.7 * expert_3(x) + 0.3 * expert_5(x)
| Dense 70B | MoE 30B-A3B | DeepSeek-V3 | |
|---|---|---|---|
| Total params | 70B | 30B | 671B |
| Active per token | 70B (ALL) | ~3B | 37B |
| Memory (fp16) | 140 GB | 60 GB | ~1.3 TB |
| Compute/token | 100% | ~10% | ~5.5% |
18x cheaper to train. MoE + FP8 = radical efficiency.
GPU VRAM is a cache over all experts. With 8 experts and room for 4, LRU eviction decides which stay. Same concept as CPU caches applied to neural networks.
If all tokens route to 2 of 8 experts, 6 sit idle — 75% wasted capacity. Training uses auxiliary losses; inference lives with what the model learned.
Must fit all experts in memory even though only k are active per token. MoE 30B needs 60 GB for weights but only 6 GB are "hot" each step.
Distribute experts across GPUs. Token routing crosses GPU boundaries — requires all-to-all communication. DeepSeek-V3 uses 256 experts across many GPUs.
Sort tokens by expert, process contiguous chunks. Batch 1: 0.25 tok/expert (terrible). Batch 256: 64 tok/expert (efficient).
From GPU Kernel to HTTP Response
Chapter 10
One uvicorn worker. The GPU is the bottleneck, not the CPU.
Async event loop handles thousands of concurrent connections on a single thread.
def _engine_loop(self): while self.running: batch = self.scheduler.get_batch() if batch is None: time.sleep(0.001) continue outputs = self.model.forward(batch) for req_id, token in outputs: self.emit_token(req_id, token)
async def get_tokens(self, req_id): queue = self.queues[req_id] while True: token = await queue.get() if token is None: break yield token
loop.call_soon_threadsafe(queue.put_nowait, token)
THE most important line in the entire server. Bridges two worlds.
// Server-Sent Events over HTTP data: {"choices": [{"delta": {"content": "Hello"}}]} data: {"choices": [{"delta": {"content": " world"}}]} data: {"choices": [{"delta": {}, "finish_reason": "stop"}]} data: [DONE]
SSE over HTTP — no WebSockets needed. Works everywhere, reconnects gracefully.
| Metric | p50 | p99 |
|---|---|---|
| Throughput | 4,000-6,000 tok/s | |
| TTFT | 30-60 ms | 100-250 ms |
| ITL | 5-10 ms | 15-25 ms |
p99 vs mean: Mean TTFT of 30ms sounds great. But if p99 is 500ms, 1 in 100 users waits half a second.
Percentiles are the metrics that matter for user experience.
# Small model proposes 4 tokens for _ in range(4): token = draft_model.decode_step() speculated.append(token)
Draft model is small & fast.
4 decode steps, cheap memory reads.
# Target model verifies ALL 4 at once # Same cost as 1 decode step! logits = target_model.forward(speculated) # Rejection sampling: exact distribution ratio = target_p / draft_p accept if random() < ratio
Verification is like prefill —
multiple tokens, one memory read.
Correct guesses save everything.
4 tokens for the cost of ~1
Wrong guesses cost nothing.
Just discard and sample normally
Expected speedup: 2-3x
Output distribution is EXACT
What has changed since the book was written
2026 Update
740 TFLOPS on H100 (75% util)
1.2 PFLOPS with FP8 path
| H100 | B200 | GB200 NVL72 | |
|---|---|---|---|
| Inference vs H200 | baseline | 4x | 30x |
| Native FP4 | No | NVFP4 | NVFP4 |
| Cost/M tokens | $1.56 | — | $0.10 |
| FP4 PFLOPS | N/A | 10 | — |
Native FP4 + FP6 support. 4x over MI300X. A credible alternative for memory-bound decode.
Key shift: torch.compile replacing hand-written kernels (vLLM V1). Radix-tree KV reuse as a new caching paradigm (SGLang). Datacenter orchestration layer above engines (Dynamo).
2-6x speedup
Production-ready. Supported in vLLM + SGLang.
Open-source EAGLE-3 draft models for targets from 8B to 1T parameters. Up to 4x speedup with SGLang.
2.8x on H100, 2.3x on Apple Silicon
RNN draft model + dynamic tree attention
Performance is fragile and variable. At higher batch sizes, verification overhead negates gains. Best for latency-sensitive interactive scenarios: chat, code completion, real-time assistants.
| GPU utilization | 90-95% |
| Bottleneck | Compute-bound |
| Ops/byte | 200-400 |
| Parallelism | Tensor Parallelism |
KV cache transfer via NVLink / RDMA
| GPU utilization | 20-40% |
| Bottleneck | Memory-bound |
| Ops/byte | 60-80 |
| Parallelism | Data Parallelism |
Prefill and decode have radically different compute profiles. Different hardware for different regimes.
This follows directly from the roofline. Prefill sits on the compute-bound side; decode sits on the memory-bound side. Different hardware for different regimes.
Attention
FlashAttention-3
740 TFLOPS (FP16)
Speculation
EAGLE-3 via SpecBundle
2-6x latency reduction
Precision
NVFP4 native on Blackwell
3.5x memory reduction
One model, everything follows. Every optimization in this book is explained by understanding where you sit on the roofline and moving to a better spot.
The Physics of LLM Inference — Elliot Arledge
agent37.club