←Home KnowML
TL;DR

Every optimization on this page is either fighting a compute limit or a memory-bandwidth limit — figure out which one you're up against before reaching for a fix. Training-time efficiency is about splitting a model and its optimizer state across many GPUs (data, tensor, pipeline, expert parallelism, and ZeRO/FSDP sharding) so you can fit and train something too big for one device.

Inference-time efficiency is almost entirely about the KV cache and memory bandwidth: PagedAttention manages that cache without fragmentation, continuous batching keeps the GPU fed instead of idle, FlashAttention removes wasted memory traffic inside attention itself, and speculative decoding trades a cheap extra model for fewer expensive sequential steps.

One sentence for an interview: LLM inference is usually memory-bandwidth-bound and rarely compute-bound, and almost every serving optimization aims to move fewer bytes per useful token generated.

01Intuition

You are always waiting on one of two things: the GPU's math units, or data moving through memory. Knowing which one tells you which fix helps.

A GPU has two separate hard limits:

  • Compute throughput. How many floating-point operations it can execute per second.
  • Memory bandwidth. How many bytes per second it can move between HBM (the GPU's main memory) and the chip's actual arithmetic units.

Every piece of work you hand the GPU is bound by whichever limit it hits first. That gives you two regimes:

  • Compute-bound. The data arrives long before the arithmetic on it finishes. More bandwidth wouldn't help. You need faster or fewer FLOPs.
  • Memory-bound. The arithmetic finishes almost instantly, then the chip sits idle waiting for the next chunk of data. More compute units wouldn't help at all. You need to move less data, or move it faster.

The number that tells you which regime you're in is arithmetic intensity: how many FLOPs of work you get per byte of data moved. Lots of math per byte fetched means compute-bound. A little math per byte means memory-bound.

Training a large model with big batched matrix multiplications tends to sit on the compute-bound side. You reuse the same weights across many examples in the batch, so each byte you load does a lot of work.

Why generating one token is the worst case

Autoregressive LLM inference, generating one token at a time, is the opposite extreme. Each forward step drags the entire set of model weights, and the growing KV cache, through memory. It computes output for often just a single new token per sequence.

The result is huge byte traffic with a comparatively tiny amount of arithmetic per byte.

That's why LLM inference is usually memory-bandwidth-bound and rarely compute-bound, and it's the single most important systems fact for anyone building or interviewing on LLM serving.

It also explains why batching helps so much. If you can process 32 requests' worth of tokens against the same loaded weights instead of one, you pay the same memory cost to move those weights once but get 32× the useful compute out of it. That pushes you back toward the compute-bound side, where the GPU is earning its keep.

Making a model smaller often means storing each weight in fewer bits. Here a million random weights are squeezed from 32 bits to 8 and the damage is measured.

Try it Store a million weights in 8 bits instead of 32
import numpy as np

rng = np.random.default_rng(0)
w = rng.normal(0, 0.02, 1_000_000).astype(np.float32)   # fake weights

scale = np.abs(w).max() / 127                 # largest weight maps to 127
q = np.round(w / scale).astype(np.int8)       # 1 byte per weight
back = q.astype(np.float32) * scale

print(f"float32: {w.nbytes / 1e6:.1f} MB   int8: {q.nbytes / 1e6:.1f} MB")
print(f"mean absolute error per weight: {np.abs(w - back).mean():.6f}")
print(f"typical weight size:            {np.abs(w).mean():.6f}")
Memory drops from 4.0 MB to 1.0 MB, and the average weight changes by 0.000186. The typical weight has a size of about 0.016, so the rounding error is around 1% of it. These are neat random numbers, and real trained weights are less well-behaved, so treat this as a best case.

02Timeline

Before

Early transformer serving used full-precision forward passes, the full attention matrix materialized in memory and one fixed batch at a time, which became wasteful and then impossible as context lengths and models grew.

→
Innovation

FlashAttention (2022) computed the same attention without writing the huge intermediate matrix to slow memory, PagedAttention (vLLM, 2023) managed the KV cache with OS-style virtual paging, and continuous batching slotted requests in and out at every generation step.

→
After

Modern stacks add quantization, speculative decoding and MoE-aware routing, and the field increasingly optimizes cost per token, since a model that is 2× faster but not cheaper per useful token does not change the economics.

03Architecture: the modern serving stack, end to end

None of the pieces below are exotic on their own. The insight is how they stack. Follow one request through:

  • It gets folded into a running batch instead of waiting in a queue.
  • Its KV cache lives in non-contiguous pages instead of one reserved block.
  • Its attention computation avoids ever writing the full score matrix to slow memory.
  • Optionally, a small model proposes tokens that the big model verifies in bulk rather than generating them one at a time.
LLM serving pipeline — a request's path through a modern stack
Incoming requests Continuous batching scheduler Paged KV cache block table FlashAttention kernel SRAM-tiled → Speculative decode draft → verify (optional) requests join/leave the batch every step, not in lockstep fixed-size blocks, no contiguous reservation recompute, don't store — stay in fast on-chip memory
Each stage attacks a different bottleneck: the scheduler attacks GPU idle time, paged KV cache attacks memory fragmentation, FlashAttention attacks wasted memory traffic inside attention, and speculative decoding (dashed, optional) attacks the sequential-step count itself. They compose — production stacks run all four together.

04Training-time efficiency: splitting the model across devices

A frontier model's parameters, gradients, and optimizer state don't fit on one GPU. Even if they did, training on one GPU would take years.

The four strategies below answer different questions about what to split. Real training runs at scale use several simultaneously, because each one alone runs into a wall.

  • Data parallelism is the simplest. Replicate the entire model on every GPU, split the training batch across them, run forward and backward independently, then average the gradients before the optimizer step.

    It scales beautifully as long as the model itself fits in one GPU's memory. The communication cost is just one gradient sync per step. It does nothing to help when the model is too big for a single device, which is the situation for every modern frontier LLM.

  • Tensor parallelism splits individual matrix multiplications across devices. Split a weight matrix by columns, and each GPU computes part of the output, exchanging partial results mid-layer.

    You need this when a single layer's weights are too large for one GPU, full stop. Data parallelism offers no way around it. The cost: GPUs now communicate within every single layer, which demands very fast interconnects. Devices doing tensor parallelism are usually on the same physical node.

  • Pipeline parallelism splits layers across devices instead. GPU 1 holds layers 1–8, GPU 2 holds layers 9–16, and activations flow through like an assembly line.

    It needs far less bandwidth than tensor parallelism, since only activations cross stage boundaries. That makes it viable over slower links between nodes. The catch is the pipeline bubble: early stages sit idle waiting for the first microbatch to reach later stages, and later stages sit idle waiting for the first to start. You need many microbatches in flight to keep the idle fraction small.

  • Expert parallelism is specific to mixture-of-experts models. Different experts, meaning separate feed-forward sub-networks, live on different devices. A router sends each token to only the handful of experts it needs, over the network, mid-forward-pass.

    This scales total parameter count far beyond what any single accelerator could hold, while keeping compute per token roughly fixed. The extra capacity costs communication and leaves compute cost alone.

Why combine them. At real scale, no single strategy is sufficient on its own. A frontier training run typically uses tensor parallelism within a node, pipeline parallelism across nodes, and data parallelism across many copies of that whole pipeline. Expert parallelism sometimes layers on top for MoE architectures.

Each strategy solves a different constraint. Stacking them is how you turn a few thousand GPUs into one coherent training job.

05ZeRO / FSDP: sharding what data parallelism used to duplicate

Plain data parallelism has a wasteful property. Every single replica holds a full copy of three things: the model's parameters, its gradients, and its optimizer state. For Adam, that optimizer state is itself roughly twice the size of the parameters, since it stores momentum and variance terms for every weight.

For a large model, that redundancy alone can make data parallelism impossible before you even get to compute.

ZeRO (Zero Redundancy Optimizer), and its widely used implementation in PyTorch's FSDP (Fully Sharded Data Parallel), attacks exactly that redundancy. Instead of every device holding a full copy, each of those three things is sharded: split into pieces across the data-parallel devices.

Each device permanently holds only its own shard. When a layer's full parameters are needed for a forward or backward computation, the devices briefly gather the missing pieces from each other, use them, then discard them again rather than keeping a persistent full copy.

What this buys you is significant. You can train models far larger than any single GPU's memory could hold, using ordinary data-parallel-style training, without hand-designing a tensor-parallel or pipeline-parallel split of the architecture.

It doesn't replace tensor or pipeline parallelism at the largest scales, and you still combine them, but it removes a huge amount of unnecessary memory duplication that would otherwise force you into more complex parallelism sooner than necessary. It's usually the first lever pulled before reaching for more architecturally invasive splitting strategies.

06Mixed precision: doing arithmetic in fewer bits

Numbers in deep learning don't need to be stored or computed at the same precision throughout. Using fewer bits is a direct, compounding win. Less memory to store and move, which matters enormously given the memory-bandwidth story above. And faster arithmetic, since modern GPUs execute lower-precision matrix multiplications at higher throughput than full FP32.

  • FP32 (32-bit float) is the traditional default. High precision, high range, expensive in both memory and compute.
  • FP16 (16-bit float) halves both. But its exponent range is narrow, so very large or very small values, common in gradients, can overflow or underflow to zero during training. That historically required loss scaling: multiply the loss by a constant before backprop, then divide it back out, keeping small gradient values representable.
  • BF16 (bfloat16) also uses 16 bits but allocates them differently. It keeps FP32's wide exponent range and sacrifices mantissa precision instead. That wider range is why BF16 tends to be numerically stable for training without needing loss scaling at all. It can represent the same enormous dynamic range as FP32, just less precisely, and range turns out to matter far more than precision for training stability.
  • FP8 pushes further still. Mostly used for inference, and increasingly for training the least precision-sensitive parts of the computation. More precision traded for more speed and memory savings.

In practice the precision is mixed and not uniform. Keep a master copy of the weights in FP32, or at least higher precision, for the optimizer's accumulation step where small updates need to register. Run the forward and backward passes themselves in FP16 or BF16.

You get the speed and memory benefit of low precision where it barely costs you anything, in the bulk of the matrix multiplications. And you protect the one part of training that is sensitive to precision loss: the accumulation of many small weight updates over time.

07Inference-time efficiency: where most interview questions land

Training happens once, while inference happens continuously, at whatever your product's traffic is, for the entire lifetime of the model in production. That's exactly why the serving stack gets so much systems attention, and why it's the part of this page you should know coldest.

KV cache growth (the direct callback to page 08)

Page 08 already covered the mechanism. During autoregressive generation, you cache every past token's key and value vectors so you don't recompute them from scratch at every new step.

The systems consequence is what matters here. Cache size grows linearly with (tokens generated) × (layers) × (heads) × (head dimension), per request. For long conversations or long contexts this routinely becomes the dominant consumer of GPU memory during serving, often larger than the model's own weights.

Every technique below responds to the fact that the cache is large, grows unpredictably per request, and has to be managed without wasting the GPU memory it lives in.

Try it Size the KV cache from the formula and find where it overtakes the weights
# Llama-3-8B: 32 layers, 8 KV heads after GQA, head dim 128, fp16.
L, KVH, HD, BY = 32, 8, 128, 2
W_GB = 8.03e9 * BY / 1e9              # fp16 weights: 16.1 GB, and fixed

kv_tok = L * KVH * HD * 2 * BY        # x2 because K and V are both cached
print("KV per token per sequence:", kv_tok / 1024, "KB")
print("weights", round(W_GB, 1), "GB on an 80 GB card\n")

print(f"{'ctx':>7} {'batch 1':>9} {'batch 32':>10}")
for ctx in (2048, 8192, 32768, 131072):
    one = kv_tok * ctx / 1e9
    b32 = one * 32
    tag = ""
    if b32 > W_GB:
        tag = "1 seq > weights" if one > W_GB else "b32 > weights"
    if b32 > 80 - W_GB:
        tag += "  OOM"
    print(f"{ctx:>7} {one:8.1f}G {b32:9.1f}G  {tag}")

mha = L * 32 * HD * 2 * BY * 32768 / 1e9   # the same model without GQA
print(f"\n32k ctx, 1 seq:  GQA {kv_tok*32768/1e9:.1f} GB"
      f"   MHA {mha:.1f} GB")
The crossover arrives earlier than it feels like it should. At 128.0 KB per token per sequence, 32 concurrent requests at an 8k context need 34.4 GB of cache — more than double the 16.1 GB of weights all 32 are sharing. At 32k it is 137.4 GB and the card is long gone. A single 131,072-token sequence carries 17.2 GB by itself, more than the whole model. That is what the last line is about: the same 32k sequence costs 17.2 GB under plain multi-head attention and 4.3 GB with the 8 KV heads of GQA. Grouped-query attention is a 4x cut to the thing that decides how many users fit on the box, and is more than a modelling nicety.

PagedAttention

Naive KV cache implementations reserve one contiguous block of memory per request, sized for the maximum sequence length it might reach. Most requests never get that long, so most of that reserved memory sits wasted. Worse: because it's reserved contiguously, that wasted memory can't even be reused for a different request in the meantime.

This is the fragmentation problem operating systems solved decades ago with virtual memory paging. PagedAttention, introduced with vLLM, applies the same idea to KV caches:

  • Memory is divided into fixed-size blocks, or pages.
  • A request's cache lives across possibly non-contiguous blocks.
  • A block table tracks which physical blocks belong to which logical positions in each sequence.

That eliminates fragmentation and lets memory be allocated exactly as needed rather than reserved upfront. As a bonus, it makes sharing cache blocks across requests cheap, which is useful when many requests share a common prefix like a system prompt.

Continuous batching

Static batching waits for an entire fixed-size batch to finish generating before starting the next one. But requests finish at wildly different lengths: a one-sentence answer versus a long explanation. So the GPU spends much of its time processing a batch that's mostly finished requests waiting on one straggler, wasting the capacity freed up by everything that already completed.

Continuous batching, also called in-flight batching, removes that lockstep constraint. At every generation step, finished requests are evicted and new ones from the queue slot in immediately. Batch composition changes continuously rather than being fixed for a whole batch's lifetime.

This keeps the GPU close to fully utilized almost all the time, instead of oscillating between busy and mostly-idle. It's one of the largest practical throughput wins in modern serving, precisely because the earlier intuition holds: more real work per byte of weights moved is what pushes you toward compute-bound instead of memory-bound.

FlashAttention

Also introduced in page 08, and worth restating precisely here since this is where it belongs in depth.

FlashAttention computes the exact same attention output as the standard formula. It changes nothing about the math. What it changes is the memory-access pattern.

Standard implementations compute the full $n \times n$ attention score matrix and write it out to HBM, the GPU's relatively slow main memory, before applying softmax and multiplying by V.

FlashAttention instead processes attention in small tiles that fit in SRAM, the GPU's much faster and much smaller on-chip memory. During the backward pass it recomputes what it needs on the fly rather than storing the large intermediate matrix at all.

It's IO-aware exact attention: the same result with dramatically less data shuffled between fast and slow memory. Given that memory bandwidth is usually the actual bottleneck, that translates directly into real wall-clock speedups without any approximation.

Speculative decoding

Autoregressive generation is inherently sequential. Token $t{+}1$ needs token $t$ to exist first, so you can't parallelize across the generation steps of a single sequence the way you can across a batch.

Speculative decoding works around this indirectly:

  • A small, cheap draft model proposes several tokens in a row.
  • The large target model checks all of them in a single parallel forward pass. Verification parallelizes even though generation doesn't.
  • It accepts the prefix of proposed tokens matching what it would have generated anyway, then rejects and corrects from the first mismatch onward.
Why this is not an approximation

The output distribution is provably identical to running the large model alone. This isn't a quality-for-speed trade. It's a mathematically exact sampling procedure.

It just happens to be faster when the draft model's guesses are frequently right, because you get multiple tokens' worth of large-model-verified output for close to the cost of one large-model forward pass.

08The one number that decides everything

$$\text{Arithmetic Intensity} = \frac{\text{FLOPs}}{\text{Bytes moved}}$$
  • FLOPs total floating-point operations the computation performs. The numerator: how much math you're asking the chip to do.
  • Bytes moved total bytes read from and written to memory to perform that computation. The denominator: how much data has to physically travel.
  • Threshold every GPU has a ridge point, its peak compute throughput divided by its peak memory bandwidth, in FLOPs/byte. Above that ridge point you're compute-bound: the chip's math units are the limiting factor. Below it you're memory-bound: the chip finishes the math and then waits on data.
  • Practical read operations with a lot of reuse per byte loaded, like the big batched matrix multiplications in most of training, sit above the ridge point. Operations that load a lot of data to do comparatively little math with it, like single-token autoregressive decoding steps, sit well below it. That's the formal version of "LLM inference is memory-bound."
The roofline — one chart that tells you which resource you are wasting
arithmetic intensity — FLOPs per byte moved (log) achieved FLOP/s (log) slope = peak memory bandwidth peak compute — tensor cores saturated nothing can run above this line decode, batch 1 one token per weight load decode, batch 32 same weights, 32× the useful work prefill / training thousands of tokens per weight load batching MEMORY-BOUND math units idle, waiting on data COMPUTE-BOUND bandwidth to spare, math is the limit ▲ ridge
Both axes are logarithmic, and the two straight lines come off a datasheet: the diagonal is peak memory bandwidth, the flat ceiling is peak compute. Where they meet is the ridge point, and which side of it your operation falls on is what "memory-bound" means. Note carefully what batching does. It does not lift decode off the roof — it slides it along the roof toward the ridge. That is why the win is real but bounded: once you reach the ridge, more batching buys nothing, and prefill is over on the right because it has high intensity inherently, not because someone batched it harder.
Try it Count the bytes and the FLOPs in one decode step, at batch 1 and batch 256
# Llama-3-8B shapes, fp16 weights, decoding on one H100.
P = 8.03e9                      # parameters
L, KVH, HD = 32, 8, 128         # layers, KV heads (GQA), head dim
CTX = 4096                      # tokens already sitting in the cache

w_bytes = P * 2                 # every weight is read on every step
kv_tok = L * KVH * HD * 2 * 2   # K and V, fp16, per token, per seq
ridge = 989e12 / 3.35e12        # peak FLOP/s divided by peak bytes/s

print("weights", w_bytes / 1e9, "GB   KV per token", kv_tok / 1024, "KB")
print("ridge point", round(ridge, 1), "FLOPs/byte\n")

for B in (1, 8, 32, 256):
    moved = w_bytes + kv_tok * CTX * B   # weights plus the whole cache
    flops = 2 * P * B                    # 2 FLOPs per param per token
    ai = flops / moved
    side = "compute" if ai > ridge else "MEMORY"
    print(f"batch {B:4d}  {moved/1e9:6.1f} GB moved  AI {ai:6.2f}"
          f"  {side}-bound")

pre = 2 * P * 2048 / (w_bytes + kv_tok * 2048)   # a 2048-token prefill
print(f"\nprefill 2048   AI {pre:6.2f}  compute-bound")
Batching slides you along the roof, then stops helping. Batch 1 gets 0.97 FLOPs per byte. Batch 32 gets 15.46. Batch 256 gets 26.78 — eight times the batch bought 1.7x the intensity. The reason is the middle column: the weights are a fixed 16.06 GB, but the KV cache is 128.0 KB per token per sequence, so beyond batch 8 you are mostly paying to re-read cache, and cache bytes scale with the batch exactly like the useful work does. Every decode row is still MEMORY-bound against an H100 ridge of 295.2. The last line is the same weights on the same chip doing one pass over 2048 prompt tokens — 2014.33, the only number here that clears the ridge.

Keep this conceptual and don't memorize exact ridge-point numbers for specific chips. An interviewer is checking whether you understand that this ratio, and neither raw FLOPs nor raw bandwidth in isolation, determines your bottleneck.

09Why memory bandwidth, not FLOPs, dominates LLM inference

Modern GPUs are built with tensor cores: specialized hardware units purpose-built for exactly one operation. A multiply-accumulate on small matrix tiles, extremely fast, in parallel, in a single instruction.

Most deep learning compute is matrix multiplication, which is why they exist. Every linear layer, every attention projection, every feed-forward block reduces to it. Building dedicated silicon for that one operation buys enormous throughput gains over general-purpose arithmetic units.

But a tensor core can only run as fast as data arrives at it. That constraint bites during generation.

During training, or during the prefill step of inference where the initial prompt is processed before generation starts, you run the full sequence through the model at once. A large batch of tokens gets multiplied against the same loaded weights, so each weight you fetch gets reused across many tokens' worth of arithmetic. High arithmetic intensity, comfortably compute-bound.

During autoregressive decoding, the picture inverts. Generating tokens one at a time, one per sequence per step, each forward pass pulls the entire set of model weights through memory. Those weights can be tens or hundreds of gigabytes, and the growing KV cache comes along too. All of that, to compute output for typically just one new token per sequence.

You pay the full memory cost of moving those weights and get only a sliver of arithmetic per byte moved. That's low arithmetic intensity, and it is why single-request autoregressive generation is memory-bandwidth-bound and not compute-bound. The tensor cores finish their multiply-accumulate work almost instantly, then sit idle waiting for the next chunk of weights to arrive from HBM.

That is why batching multiple requests together helps so dramatically. Batching reuses the same loaded weights across many requests' tokens in one pass. That raises the useful compute extracted per byte moved, pushing the operation back toward the compute-bound side of the ridge point, where the expensive tensor cores are earning their keep instead of idling.

10Tradeoffs, and what breaks

Data parallelTensor parallelPipeline parallelExpert parallel
What's splitThe batch, across full model replicasIndividual weight matrices, within a layerLayers, across devicesExperts (MoE sub-networks), across devices
Communication patternOne gradient all-reduce per stepFrequent, mid-layer, needs fast interconnectActivations at stage boundaries onlyToken routing to remote experts, mid-forward
When you need itModel fits on one GPU, you want more throughputA single layer's weights don't fit on one GPUMany layers, moderate cross-node bandwidthMoE architecture, want capacity without proportional compute
Main costFull parameter/optimizer redundancy per replica (unless sharded via ZeRO/FSDP)Needs very fast, usually intra-node, linksPipeline bubble — idle stages without enough microbatches in flightRouting imbalance can stall some experts while others queue up

Three failure modes worth naming explicitly, because they show up in real deployments and in interview follow-ups:

  • Quantization accuracy loss compounds. Drop weight precision, then also use speculative decoding, then also run a distilled model. Each is individually reasonable. Together they can stack into a noticeably degraded model, if nobody tracks the combined effect as well as each optimization in isolation.
  • MoE routing imbalance is a hard serving problem. If the router sends a disproportionate share of tokens to a handful of popular experts, those devices become bottlenecks while others sit underused. The expert-parallel overhead of shipping tokens to remote experts can dominate when routing isn't reasonably balanced.
  • Speculative decoding can backfire. If the draft model's proposals are frequently wrong, you pay for its forward passes and get little acceptance benefit back. It helps exactly when draft and target agree often, and can slow things down when they don't.

11Compression: quantization, distillation, pruning

Quantization reduces the numeric precision used to store and compute with weights, and sometimes activations. Represent weights in int8 or int4 instead of FP16. Two ways to get there:

  • Post-training quantization (PTQ) takes an already-trained model and quantizes it afterward. A calibration step runs a small representative dataset through the model to pick good scaling factors for mapping continuous weight values into the reduced integer range.
  • Quantization-aware training (QAT) instead simulates quantization during training itself, so weights adapt to tolerate the reduced precision. Generally more accurate at very low bit-widths, at the cost of needing to retrain or fine-tune rather than just post-process a finished checkpoint.
Why int4 weights barely hurt an LLM

Weight-only int8/int4 quantization works surprisingly well for LLM inference specifically, and the reason follows from everything above.

LLM inference is memory-bandwidth-bound, so shrinking the weights shrinks exactly the thing you're bottlenecked on: bytes moved through memory. Leaving activations at higher precision keeps the actual arithmetic reasonably accurate.

You get most of the speed and memory benefit without touching the more precision-sensitive parts of the computation.

Try it Quantize a weight matrix to int8 and watch one outlier row wreck the rest
import torch
torch.manual_seed(0)

W = torch.randn(512, 512) * 0.02  # a plausible weight matrix
W[7] *= 40                        # one outlier channel -- real LLMs
                                  # have them, and they set the scale

def fake_quant(w, per_channel):
    if per_channel:
        amax = w.abs().amax(dim=1, keepdim=True)  # one scale per row
    else:
        amax = w.abs().max()                      # one scale for all
    s = amax / 127                                # symmetric int8
    q = torch.clamp((w / s).round(), -127, 127).to(torch.int8)
    return q, q.float() * s                       # dequantised back

for name, pc in (("per-tensor", False), ("per-channel", True)):
    q, deq = fake_quant(W, pc)
    err = (W - deq).abs()
    codes = q[0].unique().numel()   # distinct levels a normal row uses
    print(f"{name:12s} q {tuple(q.shape)} {q.dtype}")
    print(f"  max err {err.max():.5f}  mean err {err.mean():.6f}"
          f"  row 0 uses {codes:3d}/255 codes")

print("fp16", W.numel() * 2 / 1e6, "MB  ->  int8", W.numel() / 1e6, "MB")
One row ruins it for everyone. Row 7 is 40x larger than the rest, and under a single per-tensor scale it alone sets the step size — so row 0, a completely ordinary row, lands on only 8 of the 255 available int8 codes. Give every row its own scale and row 0 uses 132, and mean absolute error drops from 0.005321 to 0.000139: 38x better for the cost of one extra float per row. Max error barely moves (0.01066 to 0.01065) because it belongs to the outlier row itself, which is poorly served either way — which is why you look at the mean here, not the max. Both cost the same 0.262144 MB, half of fp16.

Distillation trains a smaller "student" model to match the behavior of a larger "teacher", typically on the teacher's output distribution rather than only hard labels. The result is a smaller, cheaper model, not a compressed version of the same one.

Pruning and sparsity remove weights, or entire structural components like attention heads and neurons, that contribute little to the output. Two flavours:

  • Unstructured sparsity removes individual scattered weights. Hard to accelerate on standard hardware without specialized kernels.
  • Structured sparsity removes aligned blocks. Easier to get speedups from.

These three techniques complement each other and don't compete. A real deployment might run a distilled model that's also quantized and has some structural sparsity applied, stacking savings from three different angles at once.

12MoE serving, edge inference, and deployment tooling

Serving a mixture-of-experts model adds two problems that dense models don't have:

  • Routing imbalance. Some experts get far more tokens than others. The router's choices are learned and not guaranteed to be even, so this is a pure load-balancing problem. An overloaded expert becomes a serving-time bottleneck no matter how fast the underlying hardware is.
  • Expert-parallel communication overhead. When experts live on different devices, routed tokens physically travel over the network to reach their assigned expert, and results travel back. A dense model, with everything colocated, doesn't pay that cost.

Both are why MoE serving infrastructure is meaningfully more complex than dense-model serving, even though MoE's whole appeal is cheaper compute per token at a given parameter count.

Edge and mobile inference operates under a different constraint entirely. Not "how do I maximize throughput across a data-center's worth of GPUs" but "how do I fit and run anything useful inside a phone's tight memory, power, and thermal budget."

That usually means small models to begin with, aggressive quantization (often int4 or lower), and hardware-specific compiled kernels rather than a general-purpose serving framework. The entire optimization target shifts from throughput-at-scale to fitting inside a hard, fixed resource ceiling.

ONNX and TensorRT-style deployment tools sit at a different layer again: graph compilation and kernel fusion.

A trained model is a graph of many small operations. A matrix multiply, then an activation function, then a normalization, and so on. Naive execution runs each as a separate kernel launch, each with its own overhead and its own round trip to memory for intermediate results.

Compiling the graph lets the tool fuse adjacent operations into single kernels, skipping the memory round trip between them, and pick optimized kernel implementations for the specific target hardware. Similar in spirit to what FlashAttention does for attention, but applied generally across an entire model's computation graph.

13Profiling: find the bottleneck before you fix it

The first diagnostic step, always, before applying any optimization from this page: figure out whether the workload is compute-bound or memory-bound.

Profiling tools report GPU utilization, memory bandwidth utilization, and time spent in each kernel. Read the result two ways:

  • Compute utilization high, bandwidth low. You're compute-bound. Look at reducing FLOPs (smaller model, fewer layers, algorithmic changes) or using faster numeric formats.
  • Bandwidth near its ceiling, compute units idle. You're memory-bound. Look at reducing bytes moved (quantization, better batching, KV cache management) and leave alone the arithmetic that was never the bottleneck.

Applying a compute-side fix to a memory-bound problem, or the reverse, is a common and completely avoidable mistake. Profile first, optimize second.

14Build this

The roofline turns "memory-bound" from a phrase you repeat into a place on a graph. Your GPU has one, and you can measure it yourself in an afternoon.

Project Measure your GPU's roofline with one matmul ~3 hours · PyTorch + one NVIDIA GPU

Benchmark a single operation, $y = Wx$, across a sweep of batch sizes. That is the whole experiment: one frozen weight matrix, one growing batch, and the arithmetic intensity from section 08 worked out by hand from the shapes.

As the batch grows, the same loaded weights do more useful work per byte fetched. The operation walks out of one regime and into the other, and you get to watch it. This one needs a GPU. A free Colab T4 is enough.

  1. Fix $W$ at something like 4096×4096 in FP16. Sweep the batch size $B$ over powers of two, from 1 up to whatever still fits in memory.
  2. Time each size with torch.cuda.Event. Warm up first, average over many repeats, and call torch.cuda.synchronize() before you trust any timing.
  3. Compute both quantities by hand from the shapes and skip the profiler. FLOPs is $2 \cdot 4096 \cdot 4096 \cdot B$. Bytes is the weight matrix plus the input and output activations, 2 bytes apiece. Divide one by the other.
  4. Plot achieved FLOP/s against arithmetic intensity, both axes logarithmic. Then draw two straight lines from your GPU's datasheet: a diagonal at its peak memory bandwidth, and a flat ceiling at its peak FP16 throughput.
  5. Now change one thing. Store $W$ in int8, rerun the identical sweep, recompute intensity at 1 byte per weight, and plot the new points on the same axes.
You'll know it worked when your measured points trace both roofs. At small $B$ they climb the bandwidth diagonal. At large $B$ they flatten out underneath the compute ceiling. The bend between the two is your GPU's ridge point, sitting where your own two datasheet lines cross. Throughput keeps rising with batch size until batching stops helping. That is the knee, and you found it by measurement rather than by argument.
What the change teaches. Halving the bytes per weight does not change the FLOP count at all. It slides every point to the right along the intensity axis. Watch which of them move. The small-batch points are pinned to the diagonal, so sliding right walks them up the bandwidth roof. The large-batch points were already pressed against the compute ceiling, so they have nowhere to go. That is the "why int4 weights barely hurt an LLM" callout from section 11, drawn rather than argued. It is also section 13's advice in one picture: which fix helps depends entirely on which side of the knee you started.

Where this runs in production

Say you're standing up serving infrastructure for a chat product. Three decisions, in order:

  • The KV cache budget per GPU sets your concurrency ceiling. Work out your model's per-token cache size: layers × heads × head_dim × 2 for K and V × precision in bytes.

    Multiply by your target max context length and expected concurrent conversations. That's how much GPU memory is spoken for by cache alone, before you've served a single request beyond what fits.

  • Continuous batching determines how efficiently you use that concurrency. With static batching you leave a large fraction of GPU time on the table waiting for stragglers. With continuous batching you keep the batch close to full almost all the time.
  • PagedAttention lets you hit the budget. No memory wasted on over-reserved, under-used contiguous blocks, and cheap cache sharing for a common system prompt across many concurrent users.

Together these determine realized throughput: tokens served per second per GPU. Combine that with your GPU's hourly cost and you get cost-per-token.

That's the number that decides whether the product is economically viable at the traffic you expect. Not whether any single request feels fast in isolation.

15Interview questions

BeginnerIs LLM inference compute-bound or memory-bound, and why does that matter?

Autoregressive decoding is usually memory-bandwidth-bound, not compute-bound. Each generation step moves the entire set of model weights (and the growing KV cache) through memory to compute output for typically just one new token per sequence — a lot of bytes moved for comparatively little arithmetic, i.e. low arithmetic intensity, which lands you below the GPU's compute/bandwidth ridge point. It matters because it tells you which class of optimization actually helps: reducing bytes moved (quantization, better cache management, batching) helps a memory-bound workload; reducing FLOPs alone does not, since the tensor cores were already sitting idle waiting on data, not on math.

BeginnerWhat is arithmetic intensity, in plain terms?

FLOPs of computation divided by bytes of data moved to support that computation. High arithmetic intensity means you're doing a lot of math per byte fetched, which tends to be compute-bound. Low arithmetic intensity means you're fetching a lot of data to do comparatively little math with it, which tends to be memory-bound. It's the single number that tells you which of the GPU's two hard limits — compute throughput or memory bandwidth — you're actually up against for a given operation.

IntermediateExplain PagedAttention and why it matters for serving.

Naive KV cache allocation reserves one contiguous block per request sized for the worst-case sequence length, which wastes memory (most requests don't reach that length) and fragments memory (that reserved space can't be reused for other requests in the meantime). PagedAttention, from the vLLM paper, borrows the OS virtual-memory idea of paging: the cache is divided into fixed-size blocks, a request's cache can live across non-contiguous blocks, and a block table tracks the mapping. This eliminates fragmentation, lets memory be allocated on demand rather than reserved upfront, and enables efficient sharing of cache blocks across requests — e.g. a shared system prompt — which directly increases how many concurrent requests you can serve out of a fixed pool of GPU memory.

IntermediateWhy does continuous batching improve GPU utilization over static batching?

Static batching waits for every request in a fixed batch to finish generating before starting the next batch, but requests finish at very different lengths — so for much of the batch's lifetime, the GPU is doing useful work for only the few requests still generating while capacity freed up by finished requests sits unused. Continuous batching evicts finished requests and admits new ones from the queue at every generation step instead of at fixed batch boundaries, keeping the batch close to full essentially continuously. Since memory-bound decode steps benefit hugely from batching (more useful compute extracted per byte of weights moved), keeping the batch full translates directly into much higher realized throughput.

IntermediateWhy does speculative decoding not change the output distribution, even though it's using a different, smaller model to help?

The draft model only proposes candidate tokens; it never gets to unilaterally decide what's actually output. The large target model verifies every proposed token in one parallel forward pass and only accepts a proposed token if it matches (via an exact, correct acceptance/rejection procedure) what the target model itself would have sampled. Anything the target model wouldn't have produced gets rejected and regenerated from the target model directly. The math guarantees the final sampled sequence has exactly the same distribution as running the target model alone — the draft model only affects how many large-model forward passes you need, not what gets generated.

DeepDesign a cost-efficient serving system for a high-traffic chat product. What do you actually decide, in order?

Start by profiling to confirm you're memory-bound at your expected sequence lengths and batch sizes — that dictates the rest of the priorities. Pick a quantization level (often int8 weights, sometimes int4) that shrinks memory traffic without unacceptable quality loss, calibrated on representative data. Use PagedAttention-style KV cache management sized against your target max context and expected concurrency, so memory isn't wasted on over-reserved blocks. Run continuous batching so the GPU stays close to fully utilized instead of oscillating with request completions. Use FlashAttention-class kernels so attention itself isn't burning extra memory bandwidth beyond what's structurally necessary. Consider speculative decoding if you have a cheap, reasonably accurate draft model available and users are latency-sensitive. Then measure realized tokens/second/GPU under realistic traffic, multiply by GPU hourly cost, and that gives cost-per-token — the number to actually optimize against, since it captures throughput, latency, and hardware cost together rather than any one of those in isolation.

DeepYou quantize a model, add speculative decoding, and serve a distilled version of it — and quality drops more than any single change would predict. What's going on, and how do you debug it?

Each optimization was validated in isolation, but their errors can compound rather than simply add: a distilled model already approximates the teacher's behavior, quantizing it further compounds that approximation, and if the draft model used for speculative decoding was itself calibrated against the original full-precision model, its acceptance behavior may now be miscalibrated against the new quantized-distilled target, changing acceptance rates and effectively changing what gets generated in edge cases. Debug by isolating variables — evaluate the distilled model alone, then distilled+quantized, then the full stack with speculative decoding, at each step measuring quality on the same eval set, to find where the compounding actually happens rather than assuming it's evenly distributed across all three changes.

16Go deeper

●Now write it yourself

Reading the derivation and being able to produce it are different skills. These are Deep-ML problems that exercise what this page covers — each one is checked against real test cases, not multiple choice.

Matched to this page from Deep-ML's catalogue of 1,380 problems. More at deep-ml.com, and Where to practise covers the other platforms and what each one trains.

Other techniques for this problem

A scoped slice of the full Technique Map — every technique this page covers, grouped by what it solves.

My Notes — 23 Efficient AI & Systems

Free notes

Highlights on this page