Systems · Deep Dive

Making LLMs Fast: A Visual Guide to Modern Inference Engines

A playable walk through the ideas that took GPU utilization from ~20% to ~96% — padding, continuous batching, paged memory — and the specialized silicon now chasing the same goal from the other side.

14 min read · Serving · KV Cache · PagedAttention · LPUs
Credit & origin
I came across this article by Mishra on a genuinely fascinating topic — the evolution of LLM inference engines — but found it scarce on the details I actually wanted to understand. So I went deeper, worked through the underlying papers, and rebuilt the explanation from the ground up. I used Claude to create the animations and graphics you'll find throughout.

Get LLM serving wrong — set it up the naïve, straightforward way — and an expensive GPU capable of trillions of operations per second will sit idle 60–80% of the time. The bottleneck is rarely raw compute; it's scheduling and memory. Closing that gap is the entire job of a modern inference engine, and this article is about the ideas that did it.

To understand why, you need one idea about how these models generate text. An LLM produces output autoregressively: one token at a time, where each new token requires a full forward pass that looks back at every previous token. That "looking back" is expensive — and avoiding it is where our whole story begins.

First, the thing everything else is about: the KV cache

Inside every attention layer, each token is turned into three vectors: a Query, a Key, and a Value. To generate the next token, the model takes the current token's Query and compares it against the Keys of all previous tokens, then blends their Values accordingly. That is attention in one sentence.

Here's the catch. To produce token #101 you need the Keys and Values of tokens #1–100; for token #102, tokens #1–101. Recomputing them from scratch every step would be painfully slow, so the model stores the Key and Value vectors for every token it has already seen and reuses them. That store is the KV cache — a growing list of (Key, Value) pairs, one entry per token.

Figure 1 · During decoding, the KV cache grows by one entry per generated token

Step 1 — generate "cat"
The cat
KV cache
K,V K,V
Step 2 — generate "sat"
The cat sat
KV cache
K,V K,V K,V
Step 3 — generate "on"
The cat sat on
KV cache
K,V K,V K,V K,V
New (K,V) added this step Cached (K,V) reused every step
Each new token adds exactly one Key/Value entry to the cache; every future token reuses all of them. A 2,000-token response caches 2,000 entries per layer, across dozens of layers — this cache is fast, but it is big, and how you store it is the whole game.
💡 Good to know — the two phases of generation

Filling that cache reveals something useful: every request runs in two very different phases. Think of it like answering a question. First you read the whole question — you can take it in at a glance, all the words at once. Then you write your answer — necessarily one word after another, each word depending on the ones before. LLMs work exactly the same way.

Prefillcompute-heavy · happens once
Thecatsat on
↑ all prompt tokens processed in one parallel pass
K,VK,VK,V added to the cache all at once
The GPU crunches the entire prompt together, so its compute units are fully loaded. Fast, and it happens just once — producing the first output token. The generated K,V pairs are added to the cache and stored so they need not be computed again. Prefill runs only once, right at the start.
Decodememory-bound · repeats per token
step 1onthe+1
step 2themat+1
step 3mat.+1
Now the model generates one token at a time, calculating a single KV entry per step and appending it to the existing cache (exactly what Figure 1 above shows). Each step barely uses the compute units — it's mostly waiting on memory — and this phase runs for as many steps as the answer is long.

Why this matters for the rest of the article: decode is the long, unpredictable part. Nobody knows in advance whether a request will decode 5 tokens or 1,500 — and that single fact is the root of nearly every problem ahead. It's why short requests get stuck behind long ones, why memory can't be sized correctly up front, and why keeping the GPU busy is so hard. Almost everything that follows is really about making the decode phase efficient.

Now the connection that ties this whole article together. You want to serve as many users at the same time as possible — not just to keep users happy, but also to make sure your GPU is utilized efficiently. But keep in mind that every request drags along its own KV cache. So a GPU serving lots of people has two things it must never waste: its compute (the slots doing the actual token-generation work) and its memory (the KV caches those slots occupy). The rest of the story is about squeezing more out of both — the first two eras keep the compute busy, and the third finally makes the memory dense.

The story in four moves
Act I — Padding & static batching: square everything off so the GPU can chew a batch. Simple, wasteful.
Act II — ORCA / continuous batching: schedule per token, not per request. Kills idle compute, but memory is still wasteful.
Act III — vLLM / PagedAttention: treat the KV cache like a computer's virtual memory. Waste collapses to near zero.
Additional note — Hardware (Groq): if moving data is the problem, put the whole model on the chip.
Beyond our scope: newer techniques squeeze even more out of the prefill/decode split — chunked prefill (slicing a long prompt so prefill and decode work can be interleaved) and prefill–decode disaggregation (running the two phases on separate pools of GPUs). Both are production-tuning topics we won't cover here; see the further-reading link if you want to go deeper.

Act I — Padding and Static Batching

Serve requests one at a time and the GPU sits badly underused — like a bank with a long line but only one counter open, the other tellers just looking at their phones and taking a break. Batching is the fix: open all the counters at once. Instead of pushing one request through the model and then the next, you run a whole group of them together in a single pass. GPUs are built for exactly this — doing the same operation on many things at once — so serving many requests together is what keeps all those idle tellers working.

Where the intuition comes from: batching images to train a CNN

If you've ever trained a CNN, you know the shape of this problem. You never feed images one at a time; you stack a mini-batch into a single 4-D tensor of shape [batch size, color, height, width] and push it through in one pass. But there's a precondition everyone hits on day one: every image must have the same dimensions. So you resize or pad each one to a common height and width before stacking — compute spent on pixels that carry no real signal.

LLM batching inherits the same constraint, just along the sequence axis. To stack requests into one tensor of shape [batch size, sequence length, hidden size], every sequence must be the same length. Real prompts aren't. So we do the CNN thing: pad the short prompts with filler tokens up to the length of the longest — and the GPU dutifully burns compute on that filler.

Figure 2 · Padding a batch to the longest sequence

Real token (useful work) Padding (wasted compute)
26useful cells
18padding cells
41%batch wasted
Just like resizing images to a common height and width before stacking a CNN mini-batch, requests are squared off to the longest sequence (R3, at 11 tokens). Everything past a real prompt is filler the GPU still pays for — here, 41% of the batch is wasted.

Then the real waste begins: waiting for the slowest

Padding the input is only half the problem. The bigger issue is the output. In static batching, the batch is fixed for its whole lifetime: you admit N requests, run them together, and return results only when all of them finish. But output lengths are wildly unpredictable — one user wants a yes/no answer (5 tokens), another a full code review (1,500 tokens).

So the short requests finish early… then just sit there, their slots occupied and doing nothing, until the single longest request grinds to a halt. Your dashboard says "GPU busy, batch of 8." In reality it's been processing a batch of 1 for most of that window.

Figure 3 · Static batching — the whole batch runs at the speed of its slowest member

Press play. Watch short requests finish, then idle while R2 keeps generating.
Actively generating Finished but stuck (idle, wasted)
0decode step
4slots doing work
0slots wasted
Requests R1, R3, R4 finish quickly but cannot leave. They hold GPU memory and slots hostage until R2's long generation completes. New queued requests cannot enter.
✗ Why padding & static batching hit a wall

The problem is structural, not a tuning knob: the batch is frozen for its whole lifetime. What if we could change its membership while it runs?


Act II — ORCA and Continuous Batching

In 2022, the ORCA paper (Yu et al., OSDI '22) answered that question with a simple reframing. The old world scheduled at the granularity of a request. ORCA schedules at the granularity of an iteration — a single token-generation step. This is iteration-level scheduling, better known today as continuous batching.

The intuition: a bus that never stops moving

Static batching is a tour bus that waits until every seat is full, drives the whole route, and won't let anyone off until the final stop — even the passenger who only needed the first block. Continuous batching is a city bus: at every stop, whoever has arrived gets off and whoever is waiting gets on. Always full, always moving, nobody held up by the longest rider.

Concretely, at every decode step the scheduler:

1. Generates one token for every active request  →  2. Evicts any request that just hit its stop token  →  3. Pulls waiting requests from the queue into the freed slots  →  4. Repeats.

The batch's composition now changes every step. A finished 5-token request returns to the user immediately instead of being trapped behind a 1,500-token neighbor.

Figure 4 · Continuous batching — a freed slot is refilled on the very next step

Step 5 — R4 hits its stop token
slot 1R1 · 5/12
slot 2R2 · 5/20
slot 3R3 · 5/9
slot 4R4 · 5/5 ✓ done
Queue:R5R6R7
All four slots busy. R4 just produced its final token — its slot is about to open.
Step 6 — R4 leaves, slot 4 frees
slot 1R1 · 6/12
slot 2R2 · 6/20
slot 3R3 · 6/9
slot 4— free —
Queue:R5R6R7
R4 is returned to the user immediately. Slot 4 sits empty for just this one moment.
Step 7 — R5 swaps in
slot 1R1 · 7/12
slot 2R2 · 7/20
slot 3R3 · 7/9
slot 4R5 · 1/7 ← queue
Queue:R5R6R7
On the very next step, R5 is pulled from the queue into the freed slot. No idle gap.
Generating Just swapped in from queue Momentarily free Waiting in queue
Follow the arrows. The moment R4 finishes (step 5), its slot opens (step 6) and a queued request, R5, takes its place on the very next step (step 7) — no waiting for the slowest member, no draining batch. GPU utilization stays near 100%.

The catch nobody mentions: you just broke your nice rectangular batch

Continuous batching sounds clean until you try to run one forward pass on it. Recall Act I: batching worked because every sequence was the same length, so they stacked into one neat tensor. But continuous batching deliberately does the opposite — at any step the batch is a ragged mix of lengths:

Request A is 3 tokens into its answer. Request B just got admitted and is doing a 5-token prefill. Request C is 200 tokens deep. Same batch, same forward pass, three different lengths.

You can't pad this back into a rectangle — that revives the padding tax you paid ORCA to remove. So how do you batch a batch that isn't a rectangle? ORCA's second key idea: selective batching.

The insight: some operations don't care about sequence length, and one does

Sort a transformer layer's operations by one question: does this operation mix information across different token positions?

OperationLooks across positions?Batchable?
Linear / QKV projectionNo — same weights applied to each token row independently✅ Yes
LayerNormNo — normalizes each token's own vector✅ Yes
GeLU / MLPNo — pure per-token function✅ Yes
Residual AddNo — element-wise per token✅ Yes
AttentionYes — each token must look at its own sequence's past KV cache❌ No

Almost every operation is token-independent: it applies the same weight matrix to each token, one row at a time, never looking left or right. For those, sequence boundaries don't matter — so you throw away the [request, length] structure, dump all tokens from all requests into one tall matrix of shape [total tokens, hidden size], and run a single big matrix multiply.

What's a "matrix multiply" here (GEMM)? The heavy lifting inside a neural network is multiplying big grids of numbers — a matrix × matrix operation engineers call a GEMM (GEneral Matrix Multiply), the one thing GPUs do blazingly fast. And one big GEMM is far more efficient than many small ones, so a batch of 3 + 5 + 200 tokens becomes a single 208-row GEMM — no padding, maximum efficiency.

Attention is the one exception — it's exactly the operation where a token gathers from every previous token in its own sequence: A's tokens attend to A's cache, not B's or C's. Since each request has a different number of past tokens, their Key/Value tensors have different shapes and can't fold into one batched multiply. So attention — and only attention — is peeled off and run per request.

That's the whole trick. Batch the token-independent operations across the flattened batch; split out only attention, run it per sequence, then merge back. You keep one giant efficient GEMM for the vast majority of the work and pay the per-request cost only where you truly must.

Figure 5 · Selective batching, step by step (batch: A=3, B=5, C=2 tokens)

The 10 tokens flatten into a single matrix for the token-independent ops (one efficient GEMM, no padding), split into three independent attention computations, then merge and continue. Only attention ever "sees" the request boundaries.

ORCA showed a 36.9× throughput improvement over previous inference techniques at the same latency — a jump big enough that continuous batching is now the default in essentially every serious serving stack.

✗ Where continuous batching still bleeds — memory

ORCA kept the compute full. But it exposed the other resource: memory. To swap requests in and out, the server still had to reserve a block of GPU memory for each request, sized to the longest it might possibly get.

Why the maximum? The server can't know in advance how long a request will run — the model decides when to stop, token by token. So to avoid running out of room mid-generation (which would crash the request), it books the worst case up front. That reservation is dead weight: it's off-limits to everyone else whether or not it's actually filled.

The measurements were brutal: 60–80% of KV-cache memory wasted. And since each concurrent request needs its own cache, memory is the real cap on how many you can batch. We needed a smarter way to store the cache.


Act III — vLLM and PagedAttention

In 2023, the vLLM project (Kwon et al., SOSP '23) landed the final blow, by borrowing an idea operating systems solved decades ago: virtual memory with paging.

The intuition: stop reserving a whole parking row per car

The old KV cache is a parking lot where every car must reserve a run of spaces as long as the longest car ever recorded, all side-by-side. Compacts waste most of their reservation and the lot fills with awkward gaps. PagedAttention rips out that rule: it chops the lot into small, uniform blocks (16 tokens each) and lets a car's KV cache occupy any free blocks, anywhere, not necessarily adjacent. A small block table (like an index) tracks which blocks belong to which sequence and in what order.

The sequence still sees a clean, continuous run of tokens — the "virtual" view. Physically, its blocks are scattered wherever there was room, handed out on demand one at a time as the sequence grows.

Figure 6 · Same three sequences, same memory pool — reserved vs. paged

Without PagedAttentionEach request reserves a contiguous block sized for the longest it might get (here, 8).
A
B
C
9 of 24 blocks used · 63% reserved but empty
The empty tail of each reservation is locked to its owner. A fourth request, R4, is rejected — "out of memory" — even though 15 blocks sit unused.
With PagedAttentionSame pool, chopped into 16-token blocks, handed out on demand — anywhere free.
9 of 24 blocks used · 0% wasted
Each sequence's blocks can be scattered anywhere (a block table tracks the order). The 15 free blocks are genuinely available — so R4, R5, R6… keep getting admitted until the pool is nearly full.
Sequence A Sequence B Sequence C Reserved but empty (wasted) Free & available
Both panels hold the same nine blocks of real data in the same 24-block pool. On the left, over-reservation locks 63% of memory as dead space and turns requests away. On the right, PagedAttention allocates only what's used — so the freed space is real, and far more sequences fit. This is how utilization jumps from ~20–40% to roughly 96%.

Because allocation is now fine-grained and on-demand, vLLM packs far more sequences into the same memory — utilization jumps from ~20–40% to about 96%. A bonus falls out for free: sequences that share a prefix (say, the same system prompt) can point to the same physical blocks instead of duplicating them — the basis of prefix caching.

The winning combination
vLLM didn't replace continuous batching — it completed it. PagedAttention fixes the memory waste; continuous batching fixes the compute waste. Together they keep the GPU both full of requests and full of useful memory at every step — which is why vLLM reported 2–4× the throughput of the strong continuous-batching systems before it.

Additional Note — When Software Isn't Enough: Hardware-Assisted Inference

Notice what every problem so far shared. Padding, idle slots, fragmentation — all symptoms of one physical fact: a GPU keeps the model's data in a large pool of memory that sits next to the chip, not on it. Every step, the chip must fetch weights and the growing KV cache out of that separate memory before it can compute the next token. The fetch is slow relative to the math, so the compute units spend much of their time simply waiting for data. Padding, ORCA, and PagedAttention all make that waiting hurt less. But what if you removed it entirely?

That's the bet behind Groq's LPU (Language Processing Unit), a chip designed specifically for running models, not training them.

Two ideas, one goal

1. Keep the data on the chip. Instead of fetching the model from nearby memory every token, the LPU holds its working set in fast memory built into the chip, so the dominant cost of decoding — waiting for weights — largely disappears. 2. Plan everything ahead of time. Where a GPU decides what to run next on the fly (causing unpredictable slow-downs), the LPU works out the exact schedule in advance, so every response takes a consistent, predictable amount of time.

The payoff is remarkable single-stream speed — public benchmarks put popular models in the hundreds of tokens per second, several times a comparable GPU endpoint, with very little delay before the first word. It's a different goal than vLLM's: vLLM maximizes total throughput across many users on one GPU; the LPU minimizes latency for the single response in front of you.

✗ The trade-offs of going custom

Hardware and software aren't rivals here — they're complementary answers to the same problem. Most of the world still serves on GPUs with vLLM; specialized chips like the LPU carve out the latency-critical frontier.


The takeaway

The story of LLM inference engines isn't really about faster math. Every era ran the same transformer. What changed was an insight about the two scarce resources — the chip's compute and its memory — and, in the end, about the cost of moving data.

Padding taught us to batch, then trapped us behind the slowest request. ORCA freed the scheduler to think per-token, and used selective batching to keep the math efficient on ragged batches — but ran into the memory wall. vLLM borrowed a decades-old operating-systems trick and turned the KV cache into flexible, on-demand memory. And Groq asked whether we should keep working around the cost of moving data, or just build a chip that keeps the data close. The lesson worth keeping: when the hardware isn't the bottleneck, the win is in how you schedule work and lay out memory — and when even that isn't enough, you change the hardware.

Next frontiers on this same foundation: Chunked prefill Prefix caching Speculative decoding Disaggregated prefill/decode SGLang TPUs / Cerebras

Written to actually explain the mechanics
A ground-up rewrite with playable diagrams for the KV cache, padding, continuous & selective batching, paged memory, and on-chip inference. Sparked by Mishra's original article; animations and graphics built with Claude. If a section didn't earn its picture, it was cut.

Sources & further reading