n4nAI

How continuous batching improves GPU utilization

Continuous batching keeps GPUs saturated by interleaving prefill and decode, but it introduces scheduler complexity and memory pressure that static batching avoids.

n4n Team4 min read922 words

Audio narration

Coming soon — every post will get a voice note here.

Static batching wastes GPU cycles. When a request finishes early, its slot sits idle until the entire batch completes. Continuous batching gpu utilization solves this by evicting finished sequences and admitting new ones on every iteration, keeping the device saturated. The thesis is straightforward: continuous batching improves throughput by 2-4x over static batching for typical LLM workloads, but it shifts the bottleneck from compute to scheduler overhead and KV cache management. If you’re serving LLMs at scale, you need to understand exactly where those new bottlenecks live.

The problem with static batching

Static batching pads every sequence to the same length and processes them in lockstep. A batch of 32 requests with max length 2048 tokens means every forward pass computes 32 × 2048 positions, even if 30 of those requests finished at 500 tokens. The GPU computes on padding tokens that contribute nothing to throughput.

# Static batching: wasted compute on padding
batch_size = 32
max_len = 2048
# Actual work: sum of real lengths
# Wasted work: batch_size * max_len - sum(real_lengths)

The utilization curve looks like a sawtooth. As requests complete, active token count drops but the kernel launch configuration stays fixed. You’re launching a 32×2048 GEMM when only 8×500 tokens need computation. The arithmetic intensity collapses.

How continuous batching works

Continuous batching treats the batch as a fluid. On each iteration, the scheduler:

  1. Removes sequences that hit EOS or max length
  2. Appends new requests from the queue
  3. Runs a single forward pass on the concatenated token stream
  4. Returns logits for every active position
# Conceptual continuous batching loop
active_seqs = []  # list of (seq_id, token_ids, kv_cache_ptrs)
waiting_queue = deque()

while active_seqs or waiting_queue:
    # Admit new requests up to memory budget
    while waiting_queue and can_admit(waiting_queue[0]):
        seq = waiting_queue.popleft()
        active_seqs.append(init_sequence(seq))
    
    # Single forward pass on all active tokens
    logits = model.forward(concat_tokens(active_seqs))
    
    # Sample next tokens, update KV caches
    for i, seq in enumerate(active_seqs):
        next_token = sample(logits[seq.offset : seq.offset + 1])
        seq.append(next_token)
        if seq.is_finished():
            evict(seq)

The kernel sees a single 1D tensor of shape [total_active_tokens, hidden_dim] instead of [batch, seq_len, hidden_dim]. This is the key: the GEMM dimensions stay large even as individual sequences come and go.

Memory pressure and KV cache dynamics

Continuous batching gpu utilization lives or dies by KV cache management. Each active sequence holds 2 * num_layers * num_heads * head_dim * seq_len bytes of KV cache. With 32-layer models and 4096 context, that’s ~1.5 GB per sequence at FP16. You cannot admit unlimited requests.

The scheduler must track per-sequence cache allocation and support:

  • Variable-length sequences: KV cache is a jagged 2D array, not a dense tensor
  • Cache eviction: Free blocks when sequences finish
  • Fragmentation: Non-contiguous free blocks after many evictions
# Block-based KV cache manager (simplified)
class KVCacheManager:
    def __init__(self, num_blocks, block_size, num_layers, num_heads, head_dim):
        self.free_blocks = list(range(num_blocks))
        self.allocated = {}  # seq_id -> list[block_ids]
        self.block_size = block_size
    
    def allocate(self, seq_id, num_tokens):
        needed = (num_tokens + self.block_size - 1) // self.block_size
        if len(self.free_blocks) < needed:
            return None  # OOM
        blocks = self.free_blocks[:needed]
        self.free_blocks = self.free_blocks[needed:]
        self.allocated[seq_id] = blocks
        return blocks
    
    def free(self, seq_id):
        self.free_blocks.extend(self.allocated.pop(seq_id))

Real systems like vLLM use PagedAttention with 16-32 token blocks. This reduces fragmentation but adds indirection: the attention kernel must gather KV blocks via an indirection table. That gather costs cycles. At high batch sizes, the indirection overhead becomes measurable.

Scheduling policies that matter

Admission policy determines whether you maximize throughput or minimize latency. Three common policies:

First-come-first-served (FCFS) admits requests in arrival order. Simple, fair, but head-of-line blocking hurts tail latency when a long request occupies cache blocks.

Shortest-job-first (SJF) prioritizes requests with fewer remaining tokens. Improves average latency but starves long requests. Requires estimating remaining length — often unknown for open-ended generation.

Chunked prefill splits long prefill across multiple iterations. Instead of admitting a 4096-token prefill that monopolizes the GPU for 200ms, you process 512 tokens per iteration, interleaving with decode work from other requests.

# Chunked prefill admission
def can_admit(req, max_tokens_per_iter=512):
    if req.is_prefill:
        return estimate_prefill_chunks(req) * max_tokens_per_iter <= budget
    return req.remaining_tokens <= budget

Chunked prefill is the practical sweet spot. It prevents a single massive prefill from starving decode work, which is where continuous batching gpu utilization shines — decode is memory-bound and benefits most from large batch sizes.

Tradeoffs you actually face

Continuous batching is not free. Here are the real costs:

Scheduler overhead: Every iteration runs Python (or C++) logic to manage queues, allocate cache blocks, and construct the attention mask. At 1000+ tokens/sec, this overhead competes with kernel launch latency. vLLM moves the scheduler to a dedicated thread; TensorRT-LLM uses a C++ executor. You need the scheduler faster than your slowest kernel.

Variable-length attention: The attention mask is no longer a clean triangular matrix. You need either a block-sparse kernel (complex) or a padded mask with explicit sequence boundaries (wastes compute on cross-sequence attention). FlashAttention-2 supports variable-length sequences via cu_seqlens, but the kernel launch config changes every iteration.

Cache fragmentation: Even with paged allocation, long-running sequences create “islands” of allocated blocks. A new 2048-token request may fail admission despite 50% free cache because no contiguous run of blocks exists. Compaction (copying KV cache) costs bandwidth and stalls the GPU.

Priority inversion: A high-priority short request waits behind a low-priority long prefill. Solutions exist — preemption, priority queues — but add complexity. Most production systems accept FCFS with chunked prefill as “good enough.”

Observability gaps: Static batching gives you clean per-request latency percentiles. Continuous batching interleaves work; a request’s latency depends on what else was in the batch at each step. You need per-iteration logging to debug tail latency.

When static batching still wins

Continuous batching gpu utilization assumes variable-length generation. If your workload is fixed-length — classification, embedding, reranking — static batching is simpler and faster. No scheduler, no fragmentation, no variable-length kernels. The padding waste is bounded and predictable.

Similarly, if you serve a single model with batch sizes that saturate the GPU (e.g., 128+ concurrent requests of similar length), static batching utilization approaches 90%+. The marginal gain from continuous batching may not justify the operational complexity.

Decisive takeaway

Adopt continuous batching for any open-ended generation workload with variable output lengths. The throughput gains are real and compound at scale. But treat the scheduler and KV cache manager as critical infrastructure — not glue code. Invest in a block-based cache allocator, chunked prefill, and a scheduler thread that never blocks the GPU. If you’re building this yourself, start with vLLM’s architecture as a reference; if you’re buying, verify the vendor’s scheduler handles fragmentation and priority inversion without manual tuning. The GPU is the expensive part; the scheduler’s job is to keep it fed.

Tagscontinuous-batchinggpu-utilizationllm-servingthroughput

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All batching & continuous batching posts →