By Haimantika Mitra and Shaoni Mukherjee

Here is a thing that has happened to me more than once. I benchmark a model, the numbers look great, p50 latency is low, tokens per second is high. Then real traffic shows up and the p99 latency is suddenly 5 to 10 times worse than anything I measured. Same model and same GPU. So what happened? The benchmark was too clean.
Most load tests send requests at a steady pace, all about the same size. Real traffic does not do that. It comes in bursts, it mixes tiny prompts with huge ones, and it shares the GPU across a lot of users at once. Under that kind of load, one thing hurts your tail more than raw compute ever will: a long prefill blocking the decodes that are already running.
When a big prompt starts its prefill, the token generation for everyone else can get stuck waiting behind it. The GPU is stuck in a scheduling problem. So let’s walk through why this happens, why continuous batching does not fully fix it, and how chunked prefill and better scheduling get your tail back under control.
If you want the groundwork first, read the Continuous Batching Mechanics article that explains the scheduler that all of this sits on top of.
Continuous batching helped, but it did not finish the job!
If you serve LLMs at all, you have already moved beyond static request-level batching. With static (request-level) batching, the server groups a batch, runs it, and does not let anyone leave until the slowest request in the group is done. Fast requests just sit there waiting for slow ones. It wastes GPU time and it wrecks latency.
Continuous batching fixed a big chunk of that. Instead of locking in one group of requests for the whole run, the scheduler re-decides the batch before every step. Two things follow from that. A request that finishes leaves right away and frees its memory, instead of holding up everyone else until the batch drains. And a waiting request can be pulled in as soon as there is room, instead of sitting in a queue until the current batch is completely done. This step-by-step scheduling is the idea from the Orca paper (Yu et al., 2022) that vLLM and most modern servers use today.
It leaves one gap open, and that gap is where your tail lives. Prefill and decode are two very different kinds of work:
Continuous batching still tends to run a prefill as one big unit. So when a long prompt lands in the batch, that iteration takes a while, and every decode already in flight has to wait for it. Users who were happily streaming tokens suddenly hit a pause. That pause is your p99. Mixing prefill and decode badly is where tail latency comes from, and it is a scheduling problem.
This is not a small effect. The Sarathi-Serve team (Agrawal et al., OSDI 2024) measured that a single prefill sharing the batch can push inter-token latency up by as much as 28x versus a decode-only batch. That is why a benchmark can look fine on average and still fall apart at the tail.

PagedAttention is about KV cache memory. It is not a scheduler. The idea, from the vLLM paper (Kwon et al., 2023), is to treat the KV cache like virtual memory in an operating system. Instead of reserving one big block per sequence, it splits the cache into small fixed-size blocks that can sit anywhere in GPU memory. That cuts the space wasted on fragmentation, lets you keep more sequences in memory at once, and makes it cheap to share cache between sequences that share a prefix.
So where does latency come in? Only indirectly. Before PagedAttention, memory was usually the first limit you ran into on how many sequences you could hold in a batch, because each one had to over-reserve cache space for its worst-case length. Once memory stops being the thing that boxes you in, the scheduler has a lot more room to decide how to mix prefills and decodes. PagedAttention does not schedule anything. It just clears the runway so a good scheduler can do its job.
But clearing the memory constraint does not tell the scheduler how to fit a big prefill into the batch without stalling the decodes around it. That is the exact problem chunked prefill exists to solve.
Chunked prefill is the direct fix for the blocking problem. The idea is simple to say. Instead of running a long prefill all the way to the end in one iteration, you split it into smaller chunks of tokens. Then in each iteration you run one prefill chunk alongside the decodes that are already going. The decodes keep getting their turns, so the stream stays smooth, while the long prompt still makes steady progress in the background.
This is the “stall-free” scheduling idea from the Sarathi-Serve work (Agrawal et al.). In their tests, admitting prefills this way let a server handle up to 2.6x more traffic within the same latency target for Mistral-7B on a single A100, and up to 6.9x more for the much larger Falcon-180B spread across eight A100s, compared with Orca and vLLM.

The catch is chunk size, and this is where a lot of content stays too shallow. There is a real tradeoff:
In vLLM you mostly control this through the per-iteration token budget (max_num_batched_tokens) with chunked prefill turned on. That budget is basically your chunk size dial. It is also the exact thing the Sarathi-Serve authors profile per deployment rather than hardcode; their experiments use budgets like 512 and 2048 and tune to the latency target, which is a sensible range to start testing from.
Chunked prefill is not the only answer to the prefill-versus-decode clash. There are three common approaches, and it is worth knowing where chunked prefill sits among them. Prefill-first just pauses the decodes to run the whole prefill, which is simple but causes the exact hitch shown above. Chunked prefill slices and interleaves, and it has become the default in most modern engines because it smooths latency without extra hardware. Disaggregation goes further and runs prefill and decode on separate pools of GPUs so the two phases never compete at all, which pays off mainly at very large scale. If you are on a single node, chunked prefill is almost always the right tool.
One more thing that usually gets missed: chunked prefill and PagedAttention interact. Chunking changes the memory access pattern of prefill, and it changes how your cache blocks fill up over time. These two are almost always explained on their own, but in a real deployment they compound. If you tune your token budget without thinking about cache behavior, you are only seeing half the picture.
Chunked prefill decides how the work gets sliced. Scheduling policy decides whose work runs first. Both shape the tail, and the second one is usually left on its default.
Here are the common policies and what they trade:
The other half of scheduling is preemption, and this is where the tail really lives. When memory gets tight, the server has to kick out a sequence to keep going. vLLM can bring it back two ways: recompute its prefill from scratch later, or swap its KV cache out to CPU memory and back. Both are expensive. The key point is that they are rare. And rare, expensive events are exactly what set your p99. Your median request never gets preempted. The one that eats a huge penalty is the request sitting at your tail. So if you want to control p99, you control who gets preempted and how often. That is a scheduling decision.
Here is a rough guide to how the policies compare:
| Scheduling policy | Throughput | p50 latency | p99 latency | Best for |
|---|---|---|---|---|
| FCFS (default) | High | Good | Poor under bursts | Steady, uniform traffic |
| Priority-based | High | Good for high-priority | Good for high-priority, worse for the rest | Mixed tiers, interactive vs batch |
| SLA-aware | Slightly lower | Good | Best, if targets are set well | Production with real latency SLAs |
| Aggressive preemption / big batches | Highest | Good | Worst | Offline batch jobs where the tail does not matter |
One common reason production numbers differ from benchmark results is that the benchmark was far cleaner than the real workload. This is not just a hunch. When researchers characterized real LLM serving traffic in ServeGen, they found that request arrivals are usually bursty, with a coefficient of variation above 1, which is another way of saying a fixed-rate or simple Poisson generator does not describe them well. The BurstGPT dataset makes the same point at scale: it collected more than 10 million real request traces from Azure’s OpenAI services over 213 days and found sudden bursts in how many requests are running at once. A synthetic test usually sends requests at a fixed rate, with uniform prompt lengths, from one client. Real traffic has three habits that break that:
If you want your numbers to mean anything, test against traces that capture this, not against uniform load. Public datasets like BurstGPT and the Azure LLM inference traces give you real arrival patterns and real prompt and response length spreads. The test worth running is simple. Take one fixed config, run it under uniform load, then replay a real bursty trace through the same config. Report both. The published work already tells you what to expect: p50 barely moves while p99 climbs hard, because the tail is set by the bursts and the long prompts, not by the average request. That gap is what you should focus on. It is also the part almost no vendor benchmark shows you, which is exactly why publishing it earns trust.
Before you touch any config, work out which of the three problems you have. Look at your own traffic.
Are you prefill-bound? Your prompts are long compared to your outputs (RAG, document processing, long system prompts), and time to first token is your main complaint. Chunked prefill is your biggest lever here.
Are you decode-bound? Your outputs are long compared to your inputs (agents, long generations), and inter-token latency or throughput is the issue. Focus on batch size and memory headroom more than chunking.
Are you scheduling-bound? Your averages look fine but your tail gets worse under bursts. This is a policy and preemption problem.
Once you know which one you are, here are the first knobs to reach for, in order:
max_num_batched_tokens (your chunk size dial). If decodes still stall, lower it. If prefills feel too slow and decodes are fine, raise it. Move it in steps and measure against your trace, not against a single request.max_num_seqs to match the memory you actually have, so you preempt less often.A rough sense of when chunked prefill starts to matter: if your prompts are short (a few hundred tokens) and concurrency is low, it mostly adds overhead and you can skip it. As your prompts climb into the thousands of tokens, or concurrency rises to where prefills and decodes keep colliding, chunked prefill stops being optional and becomes the main thing keeping your tail flat. Do not chase magic numbers from a blog post, including this one. Measure your own traffic and let the numbers decide.
What causes p99 latency spikes in vLLM? Long prefills blocking decodes that are already running. When a big prompt starts processing, everyone else’s token generation waits for that iteration to finish. It is a scheduling problem, not a raw compute limit, and it shows up under the bursty, mixed traffic that synthetic benchmarks usually miss.
Is chunked prefill the same as continuous batching? No. Continuous batching schedules per iteration, so finished sequences leave and new ones join after each step. Chunked prefill goes further by splitting a long prefill into smaller pieces so it can run next to decodes instead of blocking them. You use both together.
Does PagedAttention reduce latency or just memory usage? Directly, it manages KV cache memory and cuts fragmentation. It does not schedule anything, so it does not fix latency on its own. It helps indirectly, because once memory is no longer the hard limit on batch size, the scheduler has more room to interleave prefill and decode well.
How do I choose a chunk size? Treat the per-iteration token budget (max_num_batched_tokens) as your dial. Too small keeps decodes snappy but slows prefill and adds overhead. Too large brings back the blocking you were trying to fix. Start with the default, then adjust in steps while testing against a realistic traffic trace.
Why do my benchmarks look fine but production does not? Your benchmark probably used evenly spaced, same-size requests. Production traffic is bursty, mixes short and long prompts, and shares the GPU across users. Re-run your benchmark against a real trace like the Azure LLM Inference Trace or BurstGPT and watch what happens to p99.
When should I not bother with chunked prefill? When your prompts are short and concurrency is low. In that case it mostly adds overhead. It earns its keep as prompts grow into the thousands of tokens and concurrency rises to where prefills and decodes keep colliding.
Tail latency is not a hardware problem you can buy your way out of. It is a scheduling problem. A long prefill blocks the decodes already in flight, preemption fires at the worst moment, and a benchmark built on uniform load hides all of it. Chunked prefill is the main lever for the first problem, a policy that fits your traffic handles the second, and testing against bursty traces is how you stop being surprised in production. Start by working out whether you are prefill-bound, decode-bound, or scheduling-bound, then change one knob at a time and measure the tail, not the median.
None of this needs fancy infrastructure. Chunked prefill, paged memory, and preemption are already built into vLLM and the other major engines, so most of the work is understanding what they do and tuning them to your traffic, not building anything from scratch. The teams that get predictable p99s are usually not the ones with the biggest GPUs. They are the ones who know which bound they are hitting, who test against traffic that looks like production instead of a clean loop, and who are honest about the tradeoffs when they share their numbers. And if a single node is no longer enough, the same ideas keep going: disaggregating prefill and decode onto separate pools is the next step up, and it builds on exactly the scheduling intuition covered here.
If you take one thing away, let it be this. The median is comfortable, but the tail is where your users actually live. Measure the tail, tune for it, and the rest of the stack gets much easier to reason about.
Thanks for learning with the DigitalOcean Community. Check out our offerings for compute, storage, networking, and managed databases.
A Developer Advocate by profession. I like to build with Cloud, GenAI and can build beautiful websites using JavaScript.
With a strong background in data science and over six years of experience, I am passionate about creating in-depth content on technologies. Currently focused on AI, machine learning, and GPU computing, working on topics ranging from deep learning frameworks to optimizing GPU-based workloads.
This textbox defaults to using Markdown to format your answer.
You can type !ref in this text area to quickly search our full set of tutorials, documentation & marketplace offerings and insert the link!
Get paid to write technical tutorials and select a tech-focused charity to receive a matching donation.
Full documentation for every DigitalOcean product.
The Wave has everything you need to know about building a business, from raising funding to marketing your product.
Scale up as you grow — whether you're running one virtual machine or ten thousand.

From GPU-powered inference and Kubernetes to managed databases and storage, get everything you need to build, scale, and deploy intelligent applications.
