Search Tech Journey

Find topics, journeys and posts

back to blog
mladvanced 120m read

DL S070 · Continuous Batching — The Idea That Made LLM Serving Cheap

Why static batching leaves 80% of your GPU idle, how Orca / vLLM's continuous batching fixes it, and the Little's Law math to size your fleet correctly.

🧠SoftwareM11 · Efficient inference + serving· Session 070 of 130 120 min

🎯 Understand static vs dynamic vs continuous batching, and use Little's Law to size an LLM serving fleet from scratch.

Series: Deep Learning & LLMs From Scratch — 80 sessions · Session 70 / 80 · Module M11 · ~2 hours

The story

Serving LLMs used to be embarrassing. In 2022, a well-tuned Triton server running GPT-J-6B on an A100 got maybe 100 tokens/sec of aggregate throughput. The GPU was 5% utilized. Everyone knew batching helped, but batching in the old (BERT-era) sense — pad all requests to the longest length, run them together — was a disaster for generative models because different requests generate different numbers of tokens.

Then Orca (OSDI 2022) shipped iteration-level scheduling, later called continuous batching. vLLM took it mainstream in 2023. Suddenly the same A100 was doing 3000+ tokens/sec aggregate. 30× throughput improvement, same hardware. No fancy quantization, no distillation, just a better scheduling algorithm.

Today: what static/dynamic batching are and why they suck for LLMs, how continuous batching solves it, the interaction with the KV cache (S066), and Little's Law — the single most useful equation for capacity planning that no one teaches you.

An analogy: the airport security line

Static batching is a TSA agent who insists on processing exactly 8 passengers at a time. If passenger 3 needs an extra bag search (500-token completion), everyone else stands there watching, even though 7 of them are ready to move on. When they finally all clear, the agent takes the next 8. In between batches, the metal detector sits idle.

Continuous batching is the airport you actually want. As soon as any passenger clears the checkpoint, the next one in the queue steps in. The agent is always processing exactly 8 people; the specific 8 change every second. Nobody waits for anyone else's baggage search. Throughput is bounded by the checkpoint's actual capacity, not by the slowest passenger.

On a GPU, the "checkpoint" is a single forward pass. Each pass produces one new token for every request currently in the batch. Between passes, the scheduler swaps requests in and out. It's boring, mechanical, and it was the single biggest inference speedup of 2023.

A carousel that keeps spinning as riders hop on and off
🌍 Real world
💻 Code world
You will be able to
  • Draw the timeline diagram showing why static batching wastes GPU time on ragged requests.
  • Describe continuous batching as: 'schedule at the *iteration* level, not the request level'.
  • State Little's Law and use it to predict throughput given concurrency and per-token latency.
  • Distinguish prefill-priority from decode-priority schedulers and pick one for your SLA.
  • Predict when chunked prefill helps and when it hurts.
  • Read a vLLM/TGI throughput chart and know what knobs to turn.

Prerequisites

  • Session 066 — batch size is limited by KV cache; this session builds on that.
  • Session 069 — its interaction with batching is subtle.


1 · Why static batching fails

Old-school (BERT-era) batching: collect BB requests, pad them to the max length, run one forward pass, return results. Works great when all requests have similar lengths and produce fixed outputs (e.g., classification).

LLM generation breaks the assumptions. Requests arrive at different times, prompts have wildly different lengths, and — critically — completion lengths are unknown up front and can differ by 100×. If request A completes in 20 tokens and request B needs 500, static batching pins B behind an idle slot for A once A finishes but the batch pass hasn't. The GPU is busy but 15 of 16 slots are computing padding.

Timeline sketch:

Static batch (4 requests, max length 500):req A: [==========] (done at t=20)req B: [=================================================================] (t=500)req C: [============================================] (t=200)req D: [====================================================] (t=250) during this whole time, A's slot computes nothing useful C and D also idle after finishingEffective utilization (20 + 500 + 200 + 250) / (4 × 500) = 48%

Dynamic batching (Triton-era): admit requests up to a batch-window deadline, then run. Same problem once started.


2 · Continuous batching (iteration-level scheduling)

Orca's insight: decouple the batch from the request. Every decode iteration, the scheduler decides which requests to include. When a request finishes, it drops out and a new request slots in — mid-batch, no waiting for the whole batch to finish.

Continuous batch (max concurrency 4):t=0: [A B C D] all 4 decode step 1t=1: [A B C D] all 4 decode step 2...t=20: A finishes. Scheduler admits E.t=21: [E B C D] decode step (E's first, others' 22nd)...t=200: C finishes. Admit F....

The GPU is always running a full-width forward pass, mixing decode steps for old requests with prefill for new ones. Utilization approaches 100%.

The magic ingredient: the forward pass doesn't care whether tokens are "old decode" or "new prefill" or which request they belong to. It just processes a big batched tensor. As long as attention masks correctly isolate each request's context, you can mix any set of live requests every iteration.

Selective batching

Two operations behave differently across positions in the batch:

  • Attention — request-local; the queries in request A shouldn't see request B's keys. Handled with a block-diagonal mask, or with FlashAttention's varlen (variable-length) support, which packs sequences end-to-end and marks boundaries via cu_seqlens arrays.
  • Everything else (LayerNorm, feed-forward, LM head, embeddings) — position-independent. Just concat all tokens and run once.

Orca calls this selective batching: batch what you can, isolate what you must.

Try itFeel the static-vs-continuous gap by simulating 16 requests with variable output lengths.

Write two Python functions: static_batch(requests) waits until all 16 finish before returning; continuous_batch(requests) swaps in a new request the moment one finishes. Give each request a random output length between 20 and 500 tokens, and assume 30 ms per decode iteration. Simulate 64 total requests. Log total wall-clock and average per-request latency. You should see continuous batching cut total time by ~2× and cut p95 latency by 5×. That gap widens the more skewed your length distribution is.

💡 Hint · You don't need a GPU — a Python simulator with sleep is fine to compare wall-clock.

3 · Interaction with the KV cache

Each active request holds a KV cache proportional to its context length (S066). Continuous batching lets you juggle 100+ concurrent requests — but each new one costs KV memory. The scheduler must know:

  • Current KV memory budget.
  • Per-request KV footprint (grows as they decode).
  • Whether admitting request EE will OOM.

Without smart memory management, you're stuck preallocating max_seq per slot (huge waste). PagedAttention (S071) solves this by chunking KV into fixed-size pages that can be freed and reallocated as requests come and go. Continuous batching and PagedAttention are two halves of the same design.


4 · Little's Law — the capacity-planning equation

Every queueing-theory course teaches this and every ML engineer eventually rediscovers it:

L=λWL = \lambda \cdot W

  • LL = average number of requests in the system (concurrency).
  • λ\lambda = arrival rate (requests/sec).
  • WW = average time each request spends in the system (latency, sec).

For LLM decode specifically, phrased in tokens: throughput (tok/s)=concurrency×per-request tokens/sec\text{throughput (tok/s)} = \text{concurrency} \times \text{per-request tokens/sec}

Example. You want 500 requests/min = 8.3 req/s. Each request generates on average 300 tokens at 40 tok/s per stream → W=7.5W = 7.5 s. Concurrency needed: L=8.3×7.563L = 8.3 \times 7.5 \approx 63. You need a serving stack that comfortably runs batch-size 63 for your model on your GPU. From S066 you already know how much KV memory that is; from that you know how many GPUs you need.

Napkin sizing template

    5 · Prefill vs decode scheduling

    The scheduler faces an ongoing tension. Prefill (a new request's prompt) is compute-bound and dominates the pass duration; decode (existing requests) is memory-bound and cheap per step. Mix badly and you get:

    • Prefill starvation: new requests wait forever behind long decodes. Terrible p99 latency.
    • Decode starvation: existing streams stutter every time a big prompt arrives. Terrible TPS variance.

    Strategies:

    • First-come, first-served (FCFS) prefill-priority. Simple, hurts existing streams when a big prompt hits.
    • Decode-priority. Existing streams smooth; time-to-first-token (TTFT) is unbounded.
    • Chunked prefill (Sarathi, DistServe). Split a big prompt into ~2k-token chunks and interleave prefill chunks with decode steps in the same iteration. Bounds both TTFT and inter-token latency (ITL). Now the default in vLLM and TensorRT-LLM.

    Chunked prefill in one iteration:

    iteration i: [ req A decode 1 tok | req B decode 1 tok | req C prefill chunk 1 (2048 tok) ]
    iteration i+1: [ A dec | B dec | C prefill chunk 2 ]
    ...

    The forward is one big variable-length pass over ~2050 tokens. Attention masks (varlen) keep each request's context isolated.


    6 · Prefix caching — the batching-adjacent freebie

    Two users hit your system with the same system prompt (typical for a product). Without help, you re-prefill those tokens for every user. With prefix caching (vLLM --enable-prefix-caching), the KV cache for shared prefixes is retained and reused across requests. TTFT drops to near-zero for shared prefixes.

    It's not batching per se, but every serious continuous-batching system ships it because the memory manager already tracks KV pages.


    7 · A minimal continuous-batching loop (educational)

    Real vLLM/TGI code is thousands of lines. The core idea in pseudocode:

    import heapq
     
    class Scheduler:
        def __init__(self, kv_budget_tokens, max_batch):
            self.running = []          # list of Request currently decoding
            self.waiting = []          # priority queue of Request awaiting admission
            self.kv_used = 0
            self.kv_budget = kv_budget_tokens
            self.max_batch = max_batch
     
        def admit(self):
            while self.waiting and len(self.running) < self.max_batch:
                req = self.waiting[0]
                if self.kv_used + req.prompt_len > self.kv_budget:
                    break                       # not enough KV memory
                heapq.heappop(self.waiting)
                self.running.append(req)
                self.kv_used += req.prompt_len
     
        def step(self, model):
            # 1) admit new requests if room
            self.admit()
            # 2) build batched input: last token of each running request + any prefill chunks
            tokens, cu_seqlens, kv_ptrs = self._pack_batch()
            # 3) one forward pass
            logits = model.forward_varlen(tokens, cu_seqlens, kv_ptrs)
            # 4) sample next token per request, append to KV cache
            new_toks = sample(logits)
            for req, tok in zip(self.running, new_toks):
                req.tokens.append(tok)
                self.kv_used += 1
            # 5) evict finished requests
            for req in list(self.running):
                if req.done():
                    self.running.remove(req)
                    self.kv_used -= len(req.tokens)  # free KV pages
                    req.callback(req.tokens)

    Missing (deliberately) for clarity: preemption, paged KV, chunked prefill, speculative integration, priority. All of these live in Scheduler.step in real systems.


    8 · Pitfalls

    War story Choosing max-batch too high on a small GPU

    You set max_num_seqs=256 on an A10. vLLM cheerfully admits requests, and around request 5 KV memory tips over. vLLM will preempt (evict) the request holding the fewest tokens, restarting it later. Latency spikes look mysterious until you find "preempted" in the logs. Fix: set max_num_seqs conservatively; let PagedAttention manage the ceiling.

    War story TTFT vs TPS trade

    Decode-priority scheduling gets you great tokens-per-second for existing users and terrible time-to-first-token for new ones. Chunked prefill (--enable-chunked-prefill) is almost always what you want; the ~5% throughput cost buys huge TTFT stability.

    War story Confusing throughput and per-stream latency

    "3000 tok/s" on a benchmark is aggregate. Per-user it might be 30 tok/s across 100 concurrent streams. Product teams sometimes assume "3000 tok/s" means their one user's stream is that fast. It isn't. Always report both aggregate throughput and per-stream latency.

    War story Speculative decoding in a continuous-batching server

    Speculation (S069) helps when memory-bound. In continuous batching with high concurrency, decode is already being amortized across many requests — batching is doing the same job as speculation. Turning both on can hurt. vLLM defaults to disabling speculation above a concurrency threshold; trust that.

    War story vLLM V1's unified scheduler surprise (2025)

    The vLLM V1 rearchitecture (Jan 2025) merged prefill and decode into one scheduler with a single per-request token budget. If you carry over V0-era config flags (e.g. --max-num-batched-tokens 2048), you can accidentally under-provision because V1's budget accounting is different. Symptom: throughput drops 30–40% after upgrading. Fix: remove tuning flags and let V1's defaults take over, then re-tune from scratch.

    War story Chunked prefill + short prompts = no-op

    Chunked prefill only kicks in when a single request's prompt exceeds long_prefill_token_threshold (default 2048 in vLLM V1). If your workload is chat with 400-token prompts, the chunking never fires and you get plain FCFS. Not wrong, just don't expect the paper's numbers. For code assistants (10k-token contexts) chunked prefill is a genuine 3× TTFT improvement.


    10 · The 2024–2025 batching landscape

    Continuous batching in 2022 was one idea. In 2025 it's a stack of interlocking optimizations. Here's what's new since Orca.

    10.1 Disaggregated prefill (Splitwise / DistServe)

    Patel et al., Splitwise (ISCA 2024) and Zhong et al., DistServe (OSDI 2024) noticed: prefill and decode have opposite hardware preferences. Prefill wants raw FLOPs (H100); decode wants HBM bandwidth (A100 is nearly as good, cheaper). Running both on the same GPU means one is always the wrong hardware for the current work.

    Disaggregated serving: dedicate some GPUs to prefill, others to decode. Ship the KV cache between them over NVLink/RDMA when prefill finishes. Result: 2–4× tokens/dollar improvement at high traffic. Meta, Microsoft, and Anthropic all reportedly run some variant of this in production in 2025.

    10.2 vLLM V1's unified scheduler

    The January 2025 vLLM v1 release merged prefill and decode paths. Every request has a single "tokens I need to process this step" number, and the scheduler picks a batch that fits the token budget. Prefills, decodes, chunked prefills, and speculative-verifications are all the same abstraction. Cleaner code, ~1.5× throughput on mixed workloads.

    10.3 SGLang's RadixAttention

    Zheng et al., SGLang (2024) store KV caches in a radix tree keyed by token-prefix. Two requests sharing a system prompt physically share KV pages via copy-on-write. On agent workloads (few-shot prompts, react/CoT scaffolds), 5× throughput uplift vs vLLM without prefix caching, and 30% vs vLLM with prefix caching (SGLang's tree structure catches partial-prefix matches vLLM misses).

    10.4 The current default recipe (mid-2025)

    For an English-code chat workload on 8×H100:

    1. vLLM v1 (or SGLang if you're heavy on shared prefixes).
    2. fp8 weights + fp8 KV cache.
    3. --enable-chunked-prefill on (V1 default).
    4. --enable-prefix-caching on (V1 default).
    5. EAGLE-3 speculator if latency-critical, off if throughput-critical.
    6. Tensor parallel = 2 for 70B, 4 for 405B fp8, 8 for larger.

    That stack routinely hits 8000–12000 tok/s aggregate on one 8×H100 node with Llama-3-70B at reasonable p99.

    Further reading — the modern serving stack:


    9 · Mermaid: the scheduler loop


    Recall

    1. One-sentence definition of continuous batching? Schedule at the iteration (per-token) level rather than the request level: any request can join or leave the batch at any decoding step.

    2. What is Little's Law and how do you apply it to sizing an LLM fleet? L=λWL = \lambda W. Concurrency needed = QPS × avg time-in-system. From your target throughput and target latency, you compute required concurrency; that plus KV-cache size tells you GPU count.

    3. What is chunked prefill and why does it help? Splitting a big prompt into ~2k-token chunks so prefill for a new request can interleave with ongoing decode iterations, bounding both TTFT and inter-token latency.

    4. Why must attention be handled specially in a mixed batch but LayerNorm doesn't need to be? Attention is request-local (queries only attend to their own request's keys), so you need masking or varlen support. LayerNorm/MLP are position-independent; concatenating all tokens and running once is correct.

    5. Two-user aggregate throughput = 200 tok/s. What's per-user tok/s? 100 tok/s each. Divide aggregate by concurrency. Don't confuse the two on product dashboards.

    Stretch: you have SLA: p99 TTFT ≤ 300 ms, per-stream ≥ 30 tok/s. Your prompts average 1500 tokens, completions 400. Model runs prefill at 5000 tok/s and decode at 40 tok/s per stream on a single A100 at concurrency 32. Can one A100 hit the SLA at 10 QPS? Show the arithmetic.

    In your own words: continuous batching wins because __________________________.

    Spaced review: S066 KV cache (the memory constraint), S069 speculative decoding (interacts).

    Next session (S071): we open the hood on vLLM and TGI. PagedAttention (the missing piece), block manager, scheduler internals.

    Bring back tomorrow:

    • Iteration-level vs request-level scheduling.
    • Little's Law: L=λWL = \lambda W.
    • Chunked prefill as the default answer to TTFT-vs-TPS.
    Common misconception
    ✗ What most people think

    "Continuous batching is just dynamic batching with a shorter timeout. It groups requests that arrive close together — so under low traffic it does nothing, and under high traffic it's the same idea with better tuning."

    ✓ What is actually true

    Static and dynamic batching operate at request granularity: a batch is formed, runs to completion, and only then does the next form. Continuous batching operates at iteration granularity — the scheduler re-forms the batch before every single decode step, so a finished sequence leaves immediately and a newly arrived one joins on the next step. There is no timeout to tune because there is no waiting for a batch to close. The gain is not better grouping; it is the elimination of the idle slots that a request-granular batch necessarily carries.

    Why the myth is so sticky

    The myth is compelling because request-level batching is the pattern that works everywhere else: database writes, HTTP calls, GPU inference for classifiers, Spark micro-batches. In all of those, items in a batch take roughly the same time, so waiting for the batch to finish wastes almost nothing and a timeout genuinely is the only knob. Generative decoding breaks the shared premise: output lengths in one batch can differ by two orders of magnitude, so with request-granular batching the whole batch runs at the pace of its longest member and most of its slots compute padding. You are not wrong about batching in general — you are applying a heuristic whose precondition, roughly equal item cost, silently fails here.

    Prove it to yourself

    Quantify the waste your intuition is missing, using your own traffic:

    import random
    # replace with real output lengths from your logs
    lens = [random.choice([20, 40, 60, 900]) for _ in range(512)]
    B = 32
    useful = sum(lens)
    static = 0
    for i in range(0, len(lens), B):
        chunk = lens[i:i+B]
        static += max(chunk) * len(chunk)   # every slot runs to the longest
    print('useful token-steps :', useful)
    print('static-batch steps :', static)
    print('wasted fraction    :', round(1 - useful/static, 3))

    Skew the length distribution the way your real traffic is skewed and watch the wasted fraction dominate. That fraction is what iteration-level scheduling recovers, and no timeout value recovers any of it.

    From first principles
    Start with the question

    Why can a new request join a batch mid-flight at all? Every other batched system requires a fixed batch shape for the duration of the operation.

    1. 1
      One decode step computes, for each sequence, a single new token from that sequence's own state — its KV cache — plus the shared weights.
      forced by · the transformer's only cross-token coupling is attention, and attention for sequence i reads only sequence i's cache
    2. 2
      So sequences in a batch are mathematically independent. The batch dimension exists solely to amortise the weight read, not because the sequences interact.
      forced by · a batched matmul is a stack of independent matmuls sharing one operand, which is exactly the shape of "same weights, different states"
    3. 3
      Therefore the set of sequences in a step is an arbitrary choice made fresh each step, provided each member brings a valid cache and a current position.
      forced by · nothing in the step's computation depends on which sequences were present in the previous step
    4. 4
      The only real constraints are physical: total KV memory must fit, and a joining request must first be prefilled, which is a compute-heavy operation that competes with the decode step for the same GPU.
      forced by · prefill processes an entire prompt at once and is compute-bound, whereas decode is bandwidth-bound — mixing them means one delays the other
    5. 5
      Hence the scheduler's job reduces to an admission decision per iteration: which waiting request can I prefill without blowing the memory budget or stalling the sequences already decoding.
      forced by · the math imposes no batch identity, so the entire problem is resource arbitration between prefill and decode
    ⇒ Therefore

    Therefore "continuous" batching is not a clever algorithm on top of batching — it is what you get when you stop imposing a constraint the mathematics never required. The apparent rigidity of a batch was an artifact of the serving loop, not of the model.

    And note the prediction. First: because a joining request must be prefilled and prefill is compute-bound, admitting a long prompt should visibly stall the decode of everyone already in flight — so you should be able to see inter-token latency spike for existing streams exactly when a large prompt is admitted. That is the phenomenon that chunked prefill exists to smooth, and you can measure it by correlating per-stream inter-token latency against admission events. Second: since sequences are independent and only memory couples them, throughput should rise with concurrency until KV memory is exhausted and then fall off sharply rather than gracefully — the system is capacity-limited, not contention-limited. Third: time-to-first-token and inter-token latency should trade against each other under any admission policy, because both consume the same GPU; a policy that improves both simultaneously is either exploiting slack you were wasting or measuring something wrong.

    Mental modelA revolving door, not a bus

    Static batching is a bus: everyone boards, the doors close, and nobody gets on or off until the route is done. If one passenger has a long route, everyone else sits in their seat doing nothing until the bus finishes.

    Continuous batching is a revolving door into a room where, once a second, everyone inside takes one step. Finish and you leave immediately; arrive and you enter on the next tick. Nobody waits for anybody else's route. The room has a fixed capacity — that capacity is your KV memory, and it is the only thing that makes anyone queue.

    • The scheduling unit is one iteration, not one request. Every reasoning error about this system comes from thinking in requests.
    • Sequences in a batch are mathematically independent; the batch dimension exists only to amortise the weight read.
    • The binding constraint is KV memory, so the effective concurrency limit is a memory number, not a thread-count number.
    • Prefill is compute-bound and decode is bandwidth-bound; they compete, and every admission policy is a position on that TTFT-versus-ITL trade.
    • Preemption exists because admission is a gamble on output length you do not know. Eviction and recompute are the escape hatch when the gamble loses.
    🔔 Fires when you see

    Fire this the moment you see: throughput far below what the GPU should deliver while utilisation looks moderate · inter-token latency that stutters for existing streams when new traffic arrives · a max-batch-size configured as a constant when the real limit is memory-dependent · a serving stack with a batch timeout knob for generative traffic · tail latency dominated by a few very long generations · someone proposing to cap output length purely to make batching work.

    The tradeoff

    Requests are waiting and sequences are decoding. How aggressively should the scheduler admit new work into the running batch?

    Prefill-priority — admit waiting requests as soon as memory allows
    + you gain the best time-to-first-token and the shortest queues, because nothing waits behind a decode step that could have been shared; it also keeps the GPU's compute units busy, which decode alone leaves idle
    − you pay each admission injects a compute-bound prefill into a bandwidth-bound loop, so every existing stream stutters — users mid-response see their tokens pause; and aggressive admission raises the odds of running out of KV memory later and having to preempt someone
    pick when the product is interactive and users judge responsiveness by how fast text starts appearing — chat interfaces, autocomplete, anything where a slow start reads as broken
    Decode-priority — finish in-flight sequences before admitting anything new
    + you gain smooth, predictable inter-token latency for every active stream and near-zero preemption risk, since memory pressure only ever decreases; it also makes tail latency far easier to reason about and to guarantee
    − you pay queued requests wait behind generations whose length nobody knows, so TTFT becomes both long and unpredictable under load, and the GPU's compute capacity sits idle during pure-decode steps
    pick when the workload is offline or batch — evaluation runs, bulk generation, document processing — where nobody is watching a cursor and total wall-clock is the only metric
    Chunked prefill — split long prompts and interleave slices with decode steps
    + you gain admits promptly while bounding the disruption per step, because a prompt slice sized to the step budget cannot stall decode by more than that budget; it fills otherwise-idle compute with useful prefill work and gives the smoothest combined behaviour of the three
    − you pay a more complex scheduler with a chunk-size parameter that genuinely needs tuning per model and hardware; too small and prefill loses its efficiency advantage from processing many positions at once, too large and you are back to stalling
    pick when you serve mixed traffic where prompt lengths vary widely and both TTFT and inter-token latency appear in your SLOs — this is the general-purpose answer once prompts are long enough to be disruptive
    What a senior engineer actually does

    For interactive serving, chunked prefill with a chunk size tuned by measurement is the default worth defending, because it is the only option that does not force you to sacrifice one of the two latencies users actually feel. For offline work, decode-priority is simpler and strictly better, and adopting an interactive scheduler for batch jobs is a common waste of complexity.

    The thing to instrument regardless of policy is preemption. Admission is fundamentally a bet on unknown output length, and preemption is what happens when the bet loses — a sequence gets evicted and its cache recomputed, which is invisible in throughput averages but shows up brutally in tail latency. A rising preemption rate means you are over-admitting, and it is a far more actionable signal than a p99 number that tells you something is wrong without telling you what. Track it alongside KV memory utilisation, and treat those two together as the health metric of the scheduler.


    Quick recall · click to reveal
    ★ = stretch question

    Previous: ← DL S069 · Next: DL S071 →