Blog / Engineering

How Paged Attention and Continuous Batching Actually Work

How Paged Attention and Continuous Batching Actually Work

Every serious inference engine built since 2023, vLLM, SGLang, TensorRT-LLM, and our own runtime included, rests on the same two ideas: paged attention and continuous batching. They are usually name-dropped and rarely explained mechanically, which is a shame, because neither is hard once you see what problem it solves, and both are the reason one GPU today serves traffic that used to take three.

This article explains how they actually work: the block tables and the indexing formula behind paged attention, and the scheduling loop behind continuous batching. It draws on the vLLM V1 architecture, following Aleksa Gordić's excellent code-level breakdown and the original papers, with the memory arithmetic worked out on a concrete model

TL;DR

  • The KV cache is dynamic memory: it grows one token at a time to an unknown final length. Contiguous allocators handled that by reserving the maximum, wasting most of it.
  • Paged attention is virtual memory for the KV cache: fixed 16-token blocks, a per-request block table, and one indexing formula. Worst-case waste drops from most of the reservation to 15 tokens per sequence.
  • Continuous batching schedules per step, not per batch: when a request finishes, its slot is refilled at the very next iteration instead of waiting for the longest request in the batch.
  • The two are one system: batching keeps the GPU busy only if memory can be granted and reclaimed at token granularity, which is exactly what paging provides.
  • The reason it all matters is the roofline: below roughly 200 concurrent sequences, an 8B decode step on an H200 costs the same 3.3 ms whether it serves 1 token or 200. Empty batch slots are pure waste.

The problem: KV cache is dynamic memory

During generation the model caches a key and a value vector for every token it has seen, per layer (the mechanics are in our KV cache explainer, and the sizing maths in the VRAM guide). For a Llama-3.1-8B-class model, 32 layers, 8 KV heads, head dimension 128, bf16, that costs:

The awkward part is not the size, it is the shape of the demand. The cache grows by one token per step, to a final length nobody knows in advance, and it must be freed the instant the request finishes. That is heap allocation, happening thousands of times per second, inside GPU memory.

Pre-2023 engines dodged the problem by reserving one contiguous slab per request, sized for the maximum the request might reach. The PagedAttention authors measured what that costs: in existing systems, only 20 to 40% of KV memory held actual token state. The rest was reservation, padding, and fragmentation. Here is that failure on our 8B model, for a request that ends up 200 tokens long against a 4,096-token maximum:

0 128 256 384 512 KV memory reserved for one 200-token request (MiB) Contiguous (reserve max_model_len) 512 MiB reserved, 95% wasted Paged (13 blocks of 16 tokens) 26 MiB reserved, 4% wasted actually used (25 MiB) reserved but wasted
One 200-token request on an 8B model. The contiguous allocator reserves for the 4,096-token maximum and wastes 95% of half a gigabyte. Paging allocates 13 blocks as the request grows and wastes at most one partial block.

Wasted KV memory is not a rounding error. It is lost concurrency: every wasted gigabyte is a batch slot some other user could have occupied.

Paged attention: virtual memory for the KV cache

The fix is the one operating systems shipped in the 1960s: stop requiring contiguity. Chop KV memory into fixed-size blocks of 16 tokens, keep a pool of free blocks, and give each request a block table, a small array mapping its logical positions to whichever physical blocks it happened to be granted. Blocks of one sequence can be scattered anywhere in VRAM.

At startup the engine profiles a forward pass, sees what VRAM remains after weights and activations, and carves all of it into this pool, often hundreds of thousands of blocks held in a free list. Allocation is popping from that list; freeing is pushing back onto it. Both are O(1), which is what lets the scheduler do them every single step.

The entire trick then compresses into one line of indexing. When the attention kernel needs the KV entry for token position of a request, it computes:

Divide to find which logical block the token lives in, look up the physical block the table assigned, multiply out, add the offset. The kernel gathers KV entries through this mapping instead of assuming they sit side by side. That gather is the only price of paging, and fused kernels hide it well.

Allocation now tracks actual length instead of feared length. A request that has produced tokens holds blocks, so the waste is bounded by what fits in one partial block:

per sequence, versus hundreds of megabytes under reservation. That is the whole mechanism. Two consequences fall out of it almost for free. First, sharing: because the table is indirection, two requests with the same prefix can point at the same physical blocks with a reference count, which is what prefix caching is (block hashes decide when it is safe, as covered in the token cost guide's caching section). Second, preemption: under memory pressure the engine can evict a low-priority request by returning its blocks to the pool and recomputing later, because giving memory back is just as O(1) as taking it.

Continuous batching: schedule steps, not batches

Now the second idea. Batching exists because of the decode roofline: a decode step must stream all 16 GB of the model's weights from HBM regardless of how many sequences it serves, so the step costs about the same until there is enough arithmetic to hide the streaming:

0 20k 40k 60k decode tok/s 0 128 256 384 512 sequences decoding together (batch size) B_sat ≈ 206: kernels turn compute-bound ← bandwidth-bound: the step streams 16 GB of weights whether it decodes 1 token or 200 62k tok/s
Decode throughput against batch size for an 8B bf16 model on an H200, from the step-time formula above. Up to about 206 concurrent sequences, adding another sequence is nearly free, because the step was going to spend 3.33 ms streaming weights anyway.

So throughput lives and dies by how full the batch is. The old approach, static batching, formed a batch, ran it to completion, then formed the next one. But generation lengths are wildly uneven, so the batch runs at the pace of its longest member while finished slots sit idle:

Static batching: idle slots until the longest request finishes (47% utilised) slot 0 r1: 256 steps slot 1 r2: 32 steps slot 2 r3: 128 steps slot 3 r4: 64 steps 0 64 128 192 256 Continuous batching: freed slots refill from the waiting queue (~100% utilised) slot 0 r1: 256 steps slot 1 r2: 32 steps r5 starts next step slot 2 r3: 128 steps r6 starts next step slot 3 r4: 64 steps r7 starts next step 0 64 128 192 256 decode step decode step
Four requests with 256, 32, 128 and 64 decode steps. Static batching keeps slots idle until the longest request finishes, 47% utilisation over the window, and every newly arrived request waits for the whole batch. Continuous batching refills each freed slot from the waiting queue at the next step.

Continuous batching, introduced by Orca and at the heart of vLLM's engine loop, moves the scheduling decision from the batch to the iteration. Every step, the scheduler rebuilds the working set: it walks the running queue granting each decode its next token's worth of blocks, then admits prefills from the waiting queue until a per-step token budget is spent. A request that finishes this step returns its blocks to the pool this step; a request that arrived a millisecond ago can be computing in the very next one.

The part that makes this cheap is how the forward pass is shaped. The batch is not a padded matrix of sequences. Every scheduled token, from every request, prefill and decode alike, is flattened into one long sequence, and attention metadata tells the kernel where each request's tokens begin, end, and are allowed to look. No right-padding, no wasted lanes, and mixed prefill/decode steps come free. Chunked prefill is the same budget logic applied once more: a very long prompt is fed in slices so it cannot monopolise a step while everyone else's latency spikes.

And here is why the two ideas are really one system: iteration-level scheduling only works if memory can be granted and reclaimed at iteration granularity. A contiguous allocator cannot admit a request whose reservation does not fit, even when plenty of scattered free memory exists. The block pool can. Paging is what makes continuous batching's promise, "any free slot refills immediately", actually true.

One step, end to end

Putting it together, here is what one iteration of the engine loop does:

  • Schedule: for each running request, compute tokens needed this step and call allocate_slots, popping blocks from the pool. Admit waiting prefills while the token budget lasts. If the pool runs dry, preempt the lowest-priority request and return its blocks.
  • Forward pass: flatten every scheduled token into the super-sequence, gather and write KV through the slot-mapping formula, sample one token per sequence at its final position.
  • Postprocess: append sampled tokens, check stop conditions, and for every finished request push its blocks straight back onto the free list, where the next step's scheduling can hand them to someone else.

That loop, run thousands of times a second, is the entire runtime behaviour of a modern engine. Everything else, prefix caching, speculative decoding, guided decoding, disaggregated prefill, plugs into one of those three stages.

What this means when you serve a model

Three practical consequences follow directly from the mechanics.

Your capacity is the block pool. After weights and overhead, whatever VRAM remains becomes KV blocks, and concurrency times context length must fit inside it. That is the token-capacity arithmetic we derived in the VRAM guide, and the LLM VRAM Calculator computes it per model.

Latency spikes usually mean pool pressure, not compute. When the free list empties, the engine preempts and recomputes, which shows up as sudden inter-token stalls. The fix is a smaller advertised context, a quantized KV cache, or fewer wasted prompt tokens, which is why trimming inputs with Token Counter or Web to Markdown translates directly into steadier latency.

Engines differ above this layer, not below it. Paging and continuous batching are table stakes; the differences that show up in benchmarks come from scheduling policy, kernel quality, and speculative execution. That is the layer Netra competes at: our runtime serves up to 4x more output per second than vLLM on the same GPU and the same weights, and we deploy it on infrastructure you own.

FAQ

Why 16 tokens per block? It is a fragmentation-versus-overhead dial. Smaller blocks waste less in the final partial block but mean longer block tables and more gather indirection; larger blocks reverse the trade. 16 is vLLM's default, and other engines pick nearby powers of two.

Does paging make attention slower? The kernel pays one extra indirection per block through the table. In fused implementations the cost is small, and it buys back far more in concurrency than it spends in gather latency.

Is continuous batching why my per-request latency varies? Partly. Your tokens share each step with everyone else's, so inter-token latency rises with load. Engines bound this with the per-step token budget and chunked prefill so one long prompt cannot starve the rest.

Do these ideas apply to linear-attention models? The batching logic does; the paging story changes, because models like Qwen's GDN layers keep a fixed-size state instead of a growing cache. We covered what replaces the problem in the GDN kernel deep dive.

Where does prefix caching fit? It is a consequence of paging: blocks are content-hashed, and a new request whose prompt hashes to already-resident blocks simply points at them instead of recomputing. Shared system prompts are the big winner.

Free tools from Netra

All of these run in your browser. No signup, and your data is not uploaded to us.

References