AI Grimoire
Sheet
statusstandard
difficultyintermediate
timeO(1)
described2022
revised2w ago

Continuous Batching

Static batching makes every request in a batch wait for the longest one. Schedule at the granularity of a decode step instead, and a finished sequence frees its slot immediately.

[serving][latency]Current standard

Standing

Current standardPart of the default recipe. Present in most frontier models trained today, and the thing a new design departs from rather than argues for.

judged as of 2026-09 · what the labels mean

Theory

A batch of generation requests finishes at different times. Under request-level batching the whole batch is held until the longest completion is done, so a 20-token reply sharing a batch with a 2000-token one occupies its slot for the full 2000 steps.

U=iLiBmaxiLiU = \frac{\sum_i L_i}{B \cdot \max_i L_i}
eq. 1 — utilisation under static batching

Continuous batching schedules at each decode iteration instead. A sequence that emits EOS is evicted at the end of that step, a queued request is admitted in its place, and the batch composition changes continuously.

What it requires

The transformer itself is indifferent to this — attention over a batch of sequences at different lengths is the same operation — but three things must hold:

  • The KV cache must be allocated per sequence and freed independently, which is what paged attention provides.
  • Newly admitted requests need a prefill pass, which is compute-bound, while the in-flight sequences want a decode step, which is bandwidth-bound. Mixing them in one forward pass is the scheduling problem.
  • Admission must be bounded by free cache blocks, not by a batch-size constant, or the scheduler will admit a request it cannot finish.
irunningniblock  +  nnewblock    blockstotal\sum_{i \in \text{running}} \left\lceil \frac{n_i}{\text{block}} \right\rceil \;+\; \left\lceil \frac{n_{\text{new}}}{\text{block}} \right\rceil \;\le\; \text{blocks}_{\text{total}}
eq. 2 — the real admission constraint

When that constraint binds, something must be preempted. Recomputing the evicted sequence’s cache is usually cheaper than swapping it to host memory and back — prefill is fast, and PCIe is not.

Implementation

python · sketch of the scheduler loop
def serve(model, queue, cache, max_batch: int):
    running: list[Request] = []

    while queue or running:
        # admit what the free cache can actually hold
        while queue and len(running) < max_batch:
            if not cache.can_allocate(queue[0].prompt_len):
                break
            running.append(cache.admit(queue.pop(0)))

        # one decode step across every live sequence
        logits = model.step([r.next_token for r in running],
                            [r.block_table for r in running])

        for request, token in zip(running, logits.argmax(-1)):
            request.append(token)

        finished = [r for r in running if r.is_done()]
        for request in finished:
            cache.free(request)
            request.respond()
        running = [r for r in running if r not in finished]

Admitting purely first-come-first-served is simple and starves nobody, but it lets one long prompt block the queue; most production schedulers add a shortest-remaining-first bias for the prefill queue and accept the fairness cost. The can_allocate check must reserve for the expected output length, not just the prompt, or preemption becomes the normal case rather than the exception.

Static batching
1.0×
Continuous
up to 23×
Slot occupancy
~100%
reported throughput at fixed p50 latency, mixed-length workload

Related

References

[1]Yu et al. — Orca: A Distributed Serving System for Transformer-Based Generative Models (2022)OSDI 2022
[2]Kwon et al. — Efficient Memory Management for LLM Serving with PagedAttention (2023)arXiv:2309.06180
[3]Agrawal et al. — Taming Throughput-Latency Tradeoff with Sarathi-Serve (2024)arXiv:2403.02310