Continuous batching in transformers¶
Scope: serving and batch-generating with transformers' own continuous batching engine (model.generate_batch, ContinuousBatchingManager, transformers serve --continuous-batching), covering the paged KV cache and its two reserved blocks, the fifo and prefill_first schedulers, the safety_margin admission reserve, the offload-then-soft-reset response to KV pressure, the decode fast path and its silent fallback, and the asynchronous CUDA-streams loop. For the engine-agnostic scheduler theory see continuous batching internals; for running a transformers model inside vLLM instead, see the vLLM Transformers modeling backend.
Code on this page comes in two kinds. The three numpy blocks are executed: they run on a stock
python3with numpy, assert every number quoted around them, and include boundary and must-raise cases. Thetransformerssnippets need a GPU and the library, are not executed here, and are labelled as reference templates pinned totransformers==5.13.1. Symbols and defaults were read offmainon 2026-07-13; re-verify against your installed version, because several of these defaults have moved. One exception: thetransformers servedeployment material under "How to run it in production" was executed for real on 2026-07-17 againsttransformers==5.14.1on a CPU-only host, and every block there is labelled executed or template individually.
What it is¶
transformers ships a continuous batching engine inside the library itself, in src/transformers/generation/continuous_batching/. It is not a wrapper around vLLM. It is a paged-KV-cache serving loop with its own scheduler, block allocator, prefix cache, CUDA-graph capture, and CPU-offload tier, reachable from any causal LM through the ContinuousMixin methods.
The public surface is small. ContinuousMixin exposes three entry points that form a strict hierarchy:
| Entry point | What it does | Use it for |
|---|---|---|
model.generate_batch(inputs, ...) |
Blocks until every prompt is done, returns dict[str, GenerationOutput] |
Offline batch generation, evals, RL rollouts |
model.continuous_batching_context_manager(...) |
Yields a started manager, stops (and destroys) it on exit | Scoped streaming or interactive use |
model.init_continuous_batching(...) |
Returns a ContinuousBatchingManager you start and stop yourself |
Long-lived servers |
generate_batch wraps the context manager, which wraps init_continuous_batching. The manager runs a background thread; requests are submitted with add_request / add_requests, cancelled with cancel_request, and collected with get_result, by iterating the manager, or through request_id_iter for token-by-token streaming. There is no request() method, a plausible-looking name that does not exist.
The package's __all__ is exactly ContinuousBatchingManager, ContinuousMixin, FIFOScheduler, PagedAttentionCache, PrefillFirstScheduler, RequestState, RequestStatus, Scheduler. Everything else is configured through ContinuousBatchingConfig.
Continuous batching requires a paged attention backend. If a model is loaded with a plain implementation, the manager rewrites the implementation string to paged|<impl> when it starts. Supported combinations are paged|flash_attention_2, paged|sdpa, and paged|eager.
Why use it¶
Continuous batching changes request admission and retirement at iteration boundaries. Ragged or paged attention is a separate optimization that removes rectangular padding inside each iteration; the two are usually deployed together. The padding arithmetic is worth deriving because the canonical explainer for this engine misstates its growth rate.
When a fresh n-token prompt joins B-1 requests that are decoding one token each, a padded (rectangular) batch must pad every short row out to n. The Hugging Face post derives the exact cost, (n-1)(B-1) wasted slots, and then says the cost "grows quadratically with both batch size and prompt length". It does not. (n-1)(B-1) is linear in each variable separately and bilinear jointly: doubling the batch size roughly doubles the waste, it does not quadruple it. The distinction matters because it changes what you tune. The block below reproduces the post's own 693-slot example and then proves the growth is linear, by showing the second difference in each variable is exactly zero.
# Runnable on system python3 (numpy). Core claim: padded (dynamic) batching wastes
# (n-1)(B-1) token slots when one prompt of n tokens joins B-1 decoding requests, and that
# waste is LINEAR in each variable, not quadratic. Ragged batching wastes nothing.
import numpy as np
def padded_slots(batch: list[int]) -> int:
"""A padded batch is a rectangle: rows x longest row."""
assert batch and min(batch) > 0, "every row needs at least one token"
return len(batch) * max(batch)
def useful_slots(batch: list[int]) -> int:
"""Ragged/packed batching stores exactly the real tokens: no rectangle, no padding."""
return sum(batch)
def padding_waste(B: int, n: int) -> int:
"""Waste when a fresh n-token prefill joins B-1 decodes (1 token each)."""
assert B >= 1 and n >= 1
batch = [n] + [1] * (B - 1)
return padded_slots(batch) - useful_slots(batch)
# 1. The blog's own example, reproduced exactly: B=8, n=100 -> 99 * 7 = 693 wasted slots.
assert padding_waste(8, 100) == 693
assert padding_waste(8, 100) == (100 - 1) * (8 - 1)
# 2. The closed form holds for every (B, n), against the brute-force rectangle.
for B in range(1, 40):
for n in range(1, 40):
assert padding_waste(B, n) == (n - 1) * (B - 1)
# 3. Ragged batching (what continuous batching actually does) wastes zero slots, by
# construction: there is no rectangle to pad into.
assert useful_slots([100] + [1] * 7) == 107
assert padded_slots([100] + [1] * 7) == 800 # 8 rows x 100 wide
assert 800 - 107 == 693 # the same 693, seen as a rectangle
# 4. NOT quadratic. Double B alone -> waste grows ~2x (not 4x). Double n alone -> ~2x.
# A function quadratic in B would give exactly 4x.
r_B = padding_waste(16, 100) / padding_waste(8, 100)
r_n = padding_waste(8, 200) / padding_waste(8, 100)
assert 2.0 <= r_B < 2.5, r_B # 15/7 = 2.142..., nowhere near 4
assert 2.0 <= r_n < 2.5, r_n # 199/99 = 2.010...
r_both = padding_waste(16, 200) / padding_waste(8, 100)
assert 4.0 <= r_both < 4.5, r_both # bilinear: doubling BOTH gives ~4x
# 5. Exact proof of linearity: the second difference of a linear function is exactly 0.
# (For a quadratic in B, the second difference would be a non-zero constant.)
second_diff_B = [padding_waste(B + 2, 100) - 2 * padding_waste(B + 1, 100) + padding_waste(B, 100)
for B in range(1, 20)]
second_diff_n = [padding_waste(8, n + 2) - 2 * padding_waste(8, n + 1) + padding_waste(8, n)
for n in range(1, 20)]
assert set(second_diff_B) == {0}, second_diff_B
assert set(second_diff_n) == {0}, second_diff_n
# The MIXED second difference is the non-zero one: that is what "bilinear" means.
mixed = (padding_waste(9, 101) - padding_waste(9, 100)
- padding_waste(8, 101) + padding_waste(8, 100))
assert mixed == 1, mixed
# 6. Boundaries: a batch of one, or a one-token prompt, wastes nothing. And a zero-length
# row is not a batch: it must raise rather than silently pad against a phantom row.
assert padding_waste(1, 100) == 0 # nothing to pad against
assert padding_waste(8, 1) == 0 # prompt already one token wide
raised = False
try:
padded_slots([100, 0, 1])
except AssertionError:
raised = True
assert raised, "a zero-length row must raise, not be padded silently"
print("padding waste OK")
print(f" B=8, n=100 -> {padding_waste(8, 100)} wasted slots ({padded_slots([100] + [1]*7)} padded"
f" vs {useful_slots([100] + [1]*7)} real)")
print(f" double B -> x{r_B:.3f} (quadratic in B would be x4.000)")
print(f" double n -> x{r_n:.3f} (quadratic in n would be x4.000)")
print(f" double both -> x{r_both:.3f} (bilinear: this is the ~4x)")
print(f" 2nd difference in B = {set(second_diff_B)}, in n = {set(second_diff_n)}, mixed = {mixed}")
Executed output:
padding waste OK
B=8, n=100 -> 693 wasted slots (800 padded vs 107 real)
double B -> x2.143 (quadratic in B would be x4.000)
double n -> x2.010 (quadratic in n would be x4.000)
double both -> x4.307 (bilinear: this is the ~4x)
2nd difference in B = {0}, in n = {0}, mixed = 1
Ragged batching removes 693 slots of rectangular padding in this example. CUDA graphs do not inherently require every row to be padded to the model's maximum context: production engines capture shape buckets and pass sequence lengths or block tables to paged kernels. Continuous batching's defining gain is iteration-level admission and retirement; ragged or paged attention removes the per-step padding quantified here.
Beyond throughput, the reasons to use this engine rather than a dedicated one are specific:
- One library for training, evaluation, rollout, and serving. RL rollouts want
return_logprobs=Truefrom the same model definition used to train. Running rollouts on a separate engine introduces train/serve skew that surfaces as a misbehaving reward curve, not a crash. - Day-zero coverage. A model merged into
transformersis servable with continuous batching immediately, with no port to a serving engine. - A short path from research code to a throughput-shaped loop.
generate_batchis a drop-in for a paddedgenerateloop over a list of prompts.
When to use it (and when not)¶
| Situation | Verdict |
|---|---|
Offline batch generation, evals, RL rollouts on a model already in transformers |
Use it |
| Rollouts that need per-token logprobs from the training model definition | Use it (return_logprobs=True) |
| A model with no vLLM or SGLang implementation yet | Use it, or use the vLLM Transformers backend |
| A latency-critical, high-QPS production endpoint | Prefer a dedicated engine; see below |
| You need disaggregated prefill/decode, speculative decoding, or structured output | Not available here; use vLLM or SGLang |
| Sliding-window model with heavy prefix reuse | Works, but prefix caching and the decode fast path are both disabled automatically; expect no reuse and varlen decode |
The honest comparison against vLLM and SGLang is architectural, not numerical. There is no citable, reproducible throughput ratio between transformers continuous batching and vLLM: published third-party claims range from "84% of vLLM" to multiples in either direction, none with a method you can re-run. This page therefore states no such ratio, and neither should a capacity plan built on it. What can be compared is the shape of the two engines: transformers exposes an explicit admission reserve (safety_margin) that vLLM does not, prefers CPU swap over recompute under KV pressure when it is configured to, and lacks disaggregation and speculative decoding entirely. Benchmark your own workload before choosing.
Architecture¶
The manager owns a background thread. The scheduler builds one batch per step under three budgets, the paged cache hands out blocks, and the offloading manager is the pressure-release valve.
flowchart TD
A["add_request / add_requests"] --> Q["waiting queue (PENDING)"]
Q --> S{"scheduler: fifo or prefill_first"}
S -->|"below reserve: hold additional prefills<br/>(allow one if the step would be empty)"| Q
S -->|"admit under token + cache + request budgets"| P["PREFILLING (chunked)"]
P --> D["DECODING (1 token / step)"]
D --> D
D -->|"stop condition"| F["FINISHED: free blocks"]
S --> C["PagedAttentionCache: num_blocks + 2"]
C -->|"block table"| K{"batch is decode-only?"}
K -->|"yes, and FA2/FA3 on CUDA"| FP["decode fast path: flash_attn_with_kvcache"]
K -->|"no, or unsupported backend"| VL["varlen path: flash_attn_varlen_func"]
D -->|"cannot allocate next block"| O["offloading manager"]
O -->|"cpu_offload_space > 0 and it fits"| CPU["pinned CPU swap, resume later"]
O -->|"otherwise"| SR["soft reset: append generated tokens to prompt, re-queue"]
CPU --> Q
SR --> Q
The paged cache, its two reserved blocks, and the block-size trade¶
The cache has a three-level hierarchy. A page is the key (or value) state for one token in one layer. A block is block_size pages and is the unit of allocation. Cache tensors are the physical support, one per layer in a layer group.
The allocated tensor is num_blocks + 2 blocks long. The two extra blocks are a padding zone no block manager ever hands out: the first holds the read-trash index (where padding tokens read from) and, one slot later, the sliding-window sentinel index; the second is the write-trash block (where padding tokens can safely write).
Three constants, taken as constants: _min_block_size = 4, so block_size below 4 raises a ValueError; the block_size default is 256; and the reserved zone is exactly 2 blocks. Resist the upstream doc's causal story for the first one. The architecture doc says the two-block reservation "requires block_size to be at least 4", but that does not follow from the code: the sentinel sits one slot after the read trash, so the bookkeeping needs only block_size >= 2, and the source comment on the sentinel line says as much (# since block size >= 4 >= 2, this is safe), treating 4 as a given rather than as the thing being derived. The one rationale the source does give is for the default, not the minimum: # Default used to be 32, now it's 256 to be compatible with flash_with_kvcache. So the minimum of 4 is a constant with no stated derivation, and the default of 256 exists for the decode kernel. Trust the constants, not the prose around them.
The block below builds that layout, checks the boundary arithmetic, proves block_size < 4 raises, and prices the block-size trade-off. It also corrects a second upstream doc claim: the docs say small blocks "spend a large fraction of memory on this fixed overhead", but the reserved zone is two blocks, not a fixed number of bytes, so its cost shrinks as block_size shrinks. The real cost of a small block is the per-request block table, which grows.
# Runnable on system python3 (numpy). The transformers paged cache: a page is the K (or V)
# state of ONE token in ONE layer; a block is block_size pages and is the allocation unit;
# the cache allocates num_blocks + 2 blocks, the last two being a padding/sentinel zone no
# block manager ever hands out. block_size < 4 is rejected.
import numpy as np
MIN_BLOCK_SIZE = 4 # PagedAttentionCache._min_block_size
RESERVED_BLOCKS = 2 # read-trash + sentinel, and write-trash
def make_cache(num_blocks: int, block_size: int, kv_heads: int, head_dim: int) -> np.ndarray:
"""Physical cache tensor for one layer: [(num_blocks + 2) * block_size, kv_heads, head_dim]."""
if block_size < MIN_BLOCK_SIZE:
raise ValueError(f"Block size must be at least {MIN_BLOCK_SIZE}, but got {block_size}")
return np.zeros(((num_blocks + RESERVED_BLOCKS) * block_size, kv_heads, head_dim), dtype=np.float16)
def blocks_for(seq_len: int, block_size: int) -> int:
"""Blocks a sequence occupies: ceil-div. The last block is usually partly filled."""
assert seq_len >= 0 and block_size >= MIN_BLOCK_SIZE
return (seq_len + block_size - 1) // block_size
def internal_waste(seq_len: int, block_size: int) -> int:
"""Token slots paid for but unused, i.e. the tail of the last block."""
return blocks_for(seq_len, block_size) * block_size - seq_len
BS = 256 # ContinuousBatchingConfig.block_size default
cache = make_cache(num_blocks=64, block_size=BS, kv_heads=8, head_dim=128)
# 1. The reserved zone is real memory: the tensor is 2 blocks longer than the allocatable pool.
assert cache.shape[0] == (64 + 2) * BS
read_trash, sentinel, write_trash = 64 * BS, 64 * BS + 1, (64 + 1) * BS
assert sentinel == read_trash + 1 # both live in the first reserved block
assert write_trash == read_trash + BS # the second reserved block
assert max(read_trash, sentinel, write_trash) < cache.shape[0] # in bounds, never allocated
# 2. Exact-multiple boundary: a full block wastes nothing, one token more costs a whole block.
assert blocks_for(BS, BS) == 1 and internal_waste(BS, BS) == 0
assert blocks_for(BS + 1, BS) == 2 and internal_waste(BS + 1, BS) == BS - 1
assert blocks_for(0, BS) == 0
# 3. Internal fragmentation is bounded by one block per sequence, never more.
lengths = [1, 255, 256, 257, 512, 1000, 4096, 8191]
assert all(internal_waste(L, BS) < BS for L in lengths)
assert max(internal_waste(L, BS) for L in lengths) == 255 # worst case: block_size - 1
# 4. Adversarial: block_size below the minimum must RAISE. _min_block_size = 4 is a bare
# constant: the sentinel needs only block_size >= 2 (the source comment on that line reads
# "since block size >= 4 >= 2, this is safe", i.e. it ASSUMES 4, it does not derive it), and
# the 256 default is attributed to flash_with_kvcache compatibility, not to the reserved
# zone. The upstream doc's "the reservation requires block_size >= 4" is not in the code.
for bad in (0, 1, 2, 3):
raised = False
try:
make_cache(num_blocks=8, block_size=bad, kv_heads=8, head_dim=128)
except ValueError:
raised = True
assert raised, f"block_size={bad} must raise"
make_cache(num_blocks=8, block_size=MIN_BLOCK_SIZE, kv_heads=8, head_dim=128) # 4 is allowed
# 5. The block-size tradeoff, priced. For a FIXED KV budget, the reserved zone costs
# 2 * block_size pages, so it gets CHEAPER as blocks shrink, while the per-request block
# table (max_blocks_per_request entries) gets more expensive. The upstream doc says small
# blocks "spend a large fraction of memory on this fixed overhead"; the reserved zone is
# two BLOCKS, not a fixed number of bytes, so as written that is backwards.
PAGE_BYTES = 2 * 8 * 128 * 2 # K + V, kv_heads * head_dim * fp16
BUDGET = 4 * 1024**3 # 4 GiB of KV per layer
rows = []
for bs in (4, 16, 64, 256, 1024):
num_blocks = BUDGET // (bs * PAGE_BYTES) - RESERVED_BLOCKS
reserved_frac = RESERVED_BLOCKS / (num_blocks + RESERVED_BLOCKS)
table_entries = blocks_for(8192, bs) # block-table rows for an 8k sequence
frag = max(internal_waste(L, bs) for L in range(1, 2 * bs + 1)) # attained worst case
assert frag == bs - 1 # the bound is tight, not just an upper bound
rows.append((bs, num_blocks, reserved_frac, table_entries, frag))
# Reserved-zone fraction SHRINKS as block_size shrinks; block table and fragmentation GROW.
assert [r[2] for r in rows] == sorted(r[2] for r in rows), "reserved fraction must rise with block_size"
assert rows[0][2] < rows[-1][2]
assert rows[0][3] > rows[-1][3], "small blocks need a bigger block table"
assert rows[0][4] < rows[-1][4], "small blocks waste less on the last-block tail"
# 6. The default block table addresses exactly 8192 tokens. FALLBACK_DEFAULTS sets
# max_blocks_per_request = 32 when no workload hint is given, and block_size is 256:
# 32 * 256 = 8192. A request longer than that needs a hint (max_prompt_length +
# max_generated_length) or an explicit max_blocks_per_request, or the block table is
# too small to address its KV.
FALLBACK_MAX_BLOCKS_PER_REQUEST = 32
assert FALLBACK_MAX_BLOCKS_PER_REQUEST * BS == 8192
assert blocks_for(8192, BS) == FALLBACK_MAX_BLOCKS_PER_REQUEST # exactly fits
assert blocks_for(8193, BS) > FALLBACK_MAX_BLOCKS_PER_REQUEST # one token over: does not
# What a hint would give instead, per resolve_using_hints: ceil(max_seq / block_size) + 1,
# rounded up to an even number of blocks.
def blocks_from_hint(max_prompt: int, max_generated: int, block_size: int) -> int:
n = blocks_for(max_prompt + max_generated, block_size) + 1
return n + (n % 2)
assert blocks_from_hint(30_000, 2_000, BS) == 126 # 32k-token request: 126 blocks, not 32
assert blocks_from_hint(4_000, 500, BS) == 20 # ceil(4500/256)=18, +1=19, rounded to 20
print("paged block allocator OK")
print(f" cache rows = (num_blocks + {RESERVED_BLOCKS}) * block_size = (64 + 2) * {BS} = {cache.shape[0]}")
print(f" read_trash={read_trash} sentinel={sentinel} write_trash={write_trash} (never allocated)")
print(f" internal fragmentation <= block_size - 1 = {BS - 1} tokens per sequence")
print()
print(" block_size | num_blocks | reserved % of pool | block-table rows (8k seq) | worst tail waste")
for bs, nb, rf, te, fr in rows:
print(f" {bs:>10} | {nb:>10} | {rf * 100:>17.4f}% | {te:>25} | {fr:>16}")
Executed output:
paged block allocator OK
cache rows = (num_blocks + 2) * block_size = (64 + 2) * 256 = 16896
read_trash=16384 sentinel=16385 write_trash=16640 (never allocated)
internal fragmentation <= block_size - 1 = 255 tokens per sequence
block_size | num_blocks | reserved % of pool | block-table rows (8k seq) | worst tail waste
4 | 262142 | 0.0008% | 2048 | 3
16 | 65534 | 0.0031% | 512 | 15
64 | 16382 | 0.0122% | 128 | 63
256 | 4094 | 0.0488% | 32 | 255
1024 | 1022 | 0.1953% | 8 | 1023
Two operational facts fall out. Internal fragmentation is at most block_size - 1 tokens per sequence, so at the default 256 a workload of many short prompts pays up to 255 wasted token slots each; that is the case where a smaller block earns its keep. And the default block table addresses exactly 32 x 256 = 8192 tokens: a request longer than 8192 tokens needs either a workload hint (max_prompt_length plus max_generated_length) or an explicit max_blocks_per_request, or its block table cannot address its own KV.
Keep block_size at 256 unless measurement says otherwise. FlashAttention's paged KV-cache path requires a page block size that is a multiple of 256; the default is the smallest compatible value, not the only valid value.
How to use it¶
The blocking path. This is a reference template: it needs a GPU and transformers==5.13.1, and was not executed for this page.
# Reference template (needs GPU + transformers==5.13.1). Not executed here.
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from transformers.generation import ContinuousBatchingConfig, GenerationConfig
model = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen3-4B",
attn_implementation="paged|flash_attention_2", # paged| is added automatically if omitted
device_map="cuda",
dtype=torch.bfloat16,
)
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen3-4B")
inputs = [tokenizer.encode(p) for p in ["Whats up?", "Name a cat breed."]]
cb_config = ContinuousBatchingConfig(
block_size=256, # KV block size in tokens; must be >= 4
max_batch_tokens=8192, # per-forward-pass query-token budget
scheduler_type="fifo", # or "prefill_first"
safety_margin=0.15, # admission reserve; 0.15 is the fifo default
)
generation_config = GenerationConfig(max_new_tokens=64, eos_token_id=tokenizer.eos_token_id)
outputs = model.generate_batch(
inputs=inputs,
generation_config=generation_config,
continuous_batching_config=cb_config,
)
for request_id, output in outputs.items():
print(request_id, tokenizer.decode(output.generated_tokens, skip_special_tokens=True))
The serving path is an OpenAI-compatible HTTP endpoint built on the same manager:
# Reference template. transformers serve, continuous batching on the SDPA paged backend.
transformers serve \
--continuous-batching \
--attn-implementation "sdpa"
# flash_attention_2 instead of sdpa is what unlocks the decode fast path on CUDA.
transformers serve \
--continuous-batching \
--attn-implementation "flash_attention_2"
This invocation shape is verified against 5.14.1 in the production section below, together with the exact environment lock, a real smoke-test transcript, and the endpoints the server actually exposes.
The config that matters¶
ContinuousBatchingConfig defaults, read off main on 2026-07-13. Most are sentinels resolved at startup from free VRAM and workload hints, so the effective value is not the literal default.
| Field | Default | Resolves to |
|---|---|---|
block_size |
256 |
Literal. Below 4 raises ValueError |
num_blocks |
None |
Solved from free VRAM after weights load |
max_batch_tokens |
None |
8192, clamped by VRAM, floor 256 |
max_memory_percent |
None |
0.9, or 0.8 when logits processors are active |
max_requests_per_batch |
None |
Number of prompts submitted; fallback 1024; then capped by max_batch_tokens and num_blocks |
max_blocks_per_request |
None |
From workload hints, else 32. 0 disables the decode fast path, and it is forced to 0 on any sliding-window model |
allow_block_sharing |
True |
Prefix caching. Forced off unless every layer is full-attention |
use_async_batching |
None |
On iff CUDA graphs are on and no attention mask is needed |
use_cuda_graph |
None |
On iff no attention mask is needed (i.e. FlashAttention) |
default_compile_level |
0 |
0 none, 1 default+dynamic, 2 max-autotune-no-cudagraphs+dynamic, 3 same, dynamic=False |
scheduler_type |
"fifo" |
SCHEDULER_MAPPING = {"fifo": FIFOScheduler, "prefill_first": PrefillFirstScheduler} |
safety_margin |
None |
0.15 under fifo, 0.0 under prefill_first |
cpu_offload_space |
0.0 |
GiB of pinned CPU swap. 0.0 disables the CPU tier |
cpu_offload_space_safety_threshold |
0.8 |
Clamps the swap pool to 80% of available RAM (needs psutil) |
return_logprobs |
False |
Per-token logprobs in the output |
per_request_processors |
False |
Per-request temperature / top_k / top_p |
max_queue_size |
0 |
Unlimited |
use_default_compile_configs (deprecated in 5.11) and max_cached_graphs (deprecated in 5.13) still exist and log warnings.
How to develop with it¶
The manager is the API you build a server on. Requests are submitted and collected independently of the generation loop.
# Reference template (needs GPU + transformers==5.13.1). Not executed here.
from transformers.generation.continuous_batching import RequestStatus
# Scoped: started on enter, stopped (and destroyed) on exit.
with model.continuous_batching_context_manager(generation_config=generation_config) as manager:
manager.add_request(input_ids=tokenizer.encode("Write a history of QM."),
request_id="long", max_new_tokens=512)
manager.add_request(input_ids=tokenizer.encode("What's up?"),
request_id="short", max_new_tokens=32, streaming=True)
# Token-by-token for one request.
for chunk in manager.request_id_iter(request_id="short"):
print(tokenizer.decode(chunk.generated_tokens[-1:]), end="", flush=True)
if chunk.status == RequestStatus.FINISHED:
break
# Or drain everything as it completes.
for result in manager:
print(result.request_id, tokenizer.decode(result.generated_tokens))
Four points that are easy to get wrong:
add_requestssorts its inputs to maximise prefix-cache hits when block sharing is on. Submitting one at a time withadd_requestforfeits that.get_result(request_id=...)is not a lookup. It pops the next result from the output queue; if it does not match the requested id, the result is re-queued and the call returnsNone. Iterate, or register a handler withregister_result_handler, rather than polling a specific id in a loop.stop()is graceful,stop(hard_stop=True)is not. The graceful path drains queued and active requests; the hard path fails them with aRuntimeError. Afterstop()the manager can be restarted withstart().destroy()releases distributed resources and is terminal.- Per-request sampling needs
per_request_processors=True, and the correspondingGenerationConfigfield must be non-default (for exampletemperatureset to something other thanNoneor1), or the processor is never built and the per-request value is silently ignored.
For RL rollouts, return_logprobs=True returns the log-softmax of the processed logits alongside the tokens, which is what removes the need to re-score rollouts on a second engine. See rollout redundancy for the surrounding cost model.
How to maintain it¶
- Pin
transformersexactly. This subsystem is moving fast:block_sizewas 32 before it became 256,max_cached_graphswas deprecated in 5.13,use_default_compile_configsin 5.11. Treat a minor bump as a behaviour change and re-readContinuousBatchingConfig. - Assert the decode fast path is live, do not assume it.
max_blocks_per_requestis silently set to0by two independent code paths. First,ensure_decode_fast_path_is_availablezeroes it whenever the attention backend cannot provideflash_attn_with_kvcache, and logs a warning only if you setmax_blocks_per_requestexplicitly, so setting it explicitly is the cheapest way to make that failure loud. Second, and later,continuous_api.pyzeroes it unconditionally when the cache reportsnum_sliding_attention_groups > 0. That one runs after the availability check, so it fires no warning at all, not even the explicit-set one. Read the resolved config back after startup instead of relying on the log. - Re-check
allow_block_sharingafter a model swap. Prefix caching is force-disabled for any model with a sliding-window layer group (use_prefix_sharing = allow_block_sharing and group_types == ["full_attention"]). A model change from full-attention to hybrid attention therefore costs both prefix reuse and the decode fast path, and neither loss is announced. - Size the block table for the longest real request. The 32-block fallback covers 8192 tokens. Pass workload hints or set
max_blocks_per_requestwhen requests are longer. - Watch the prefix cache under memory pressure. Cached complete blocks count as free: when uninitialized blocks run out, the manager lazily demotes the most recently initialized block back to uninitialized and drops its hash. This is a best-effort cache, not a reservation, and its eviction order is the opposite of vLLM's LRU (KV cache management).
How to run it in production¶
Schedulers and the admission reserve¶
Two schedulers ship. FIFOScheduler (default) fills the batch in arrival order with active decodes first, then active prefills, then new waiting requests, which prioritises finishing in-flight work. PrefillFirstScheduler completes split (chunked) prefills before resuming decode, which reduces fragmentation on long-prompt workloads.
The knob that has no direct equivalent in vLLM or SGLang is safety_margin. Once free blocks fall below safety_margin * num_blocks, the scheduler holds additional prefills while continuing active work. A liveness exception admits the first prefill when the current step would otherwise schedule no request. That is an explicit admission reserve (inference QoS and admission control), and it defaults to 0.15 under fifo and 0.0 under prefill_first.
It is worth understanding exactly what the reserve does and does not buy, because it is a reserve against transients, not a capacity substitute. The block below models the pool under two workloads.
# Runnable on system python3 (numpy). safety_margin is an ADMISSION RESERVE: when
# free_blocks < safety_margin * num_blocks this simplified model holds new PREFILLS but
# keeps DECODING what is already in flight. The production scheduler also admits one prefill
# when a step would otherwise be empty, a liveness exception not exercised by this workload. Without it, admissions drive the pool to zero and
# a decode that needs its next block is evicted (offloaded to CPU, or soft reset: generated
# tokens are appended to the prompt, blocks are freed, the request is re-queued).
import numpy as np
BLOCK_SIZE = 256
NUM_BLOCKS = 64
def blocks_for(tokens: int) -> int:
return (tokens + BLOCK_SIZE - 1) // BLOCK_SIZE
class Pool:
"""Simplified block pool for the admission-reserve mechanism."""
def __init__(self, num_blocks: int, safety_margin: float):
if not 0.0 <= safety_margin <= 1.0:
raise ValueError(f"Got safety_margin = {safety_margin} but expected a value in [0, 1]")
self.num_blocks = num_blocks
self.free = num_blocks
self.margin = safety_margin
self.evictions = 0
self.admitted = 0
self.first_eviction: int | None = None
@property
def reserve(self) -> int:
"""Smallest free-block count that still admits a prefill: ceil(margin * num_blocks)."""
return int(np.ceil(self.margin * self.num_blocks))
def may_admit(self, need: int) -> bool:
"""Prefills are held back below the reserve. Decodes are not: see the loop below."""
return self.free >= need and self.free >= self.margin * self.num_blocks
def simulate(margin: float, arrival_every: int, steps: int, prompt: int, gen: int) -> Pool:
"""Each request prefills `prompt` tokens, decodes `gen` more, takes another block every
time it crosses a block boundary, and frees everything when it finishes. Deterministic."""
pool = Pool(NUM_BLOCKS, margin)
live: list[list[int]] = [] # [tokens, blocks_held]
for step in range(steps):
for req in live: # 1. decodes grow first (FIFO priority)
req[0] += 1
if blocks_for(req[0]) > req[1]: # crossed a boundary: one more block
if pool.free == 0: # pool empty: EVICT this request
pool.evictions += 1
if pool.first_eviction is None:
pool.first_eviction = step
pool.free += req[1]
req[0], req[1] = -1, 0 # re-queued, its KV is gone
continue
pool.free -= 1
req[1] += 1
elif req[0] >= prompt + gen: # 2. finished: return its blocks
pool.free += req[1]
req[0], req[1] = -1, 0
live = [r for r in live if r[0] >= 0]
need = blocks_for(prompt) # 3. only then admit a new prefill
if step % arrival_every == 0 and pool.may_admit(need):
pool.free -= need
pool.admitted += 1
live.append([prompt, need])
return pool
# Growth demand: blocks a request still needs AFTER admission, i.e. what the reserve must cover.
def growth_blocks(prompt: int, gen: int) -> int:
return blocks_for(prompt + gen) - blocks_for(prompt)
SHORT = dict(arrival_every=5, steps=600, prompt=200, gen=200) # 1 block of growth per request
LONG = dict(arrival_every=5, steps=600, prompt=200, gen=800) # 3 blocks of growth per request
# 1. Short generations: the reserve prevents eviction outright. Zero margin does not.
s0, s15 = simulate(0.00, **SHORT), simulate(0.15, **SHORT)
assert (s0.admitted, s0.evictions) == (120, 9), (s0.admitted, s0.evictions)
assert (s15.admitted, s15.evictions) == (96, 0), (s15.admitted, s15.evictions)
assert s15.first_eviction is None
# 2. It is not free: the reserve gives up 24 admissions to buy those 9 avoided evictions.
assert s0.admitted - s15.admitted == 24
# 3. Long generations: the reserve REDUCES and DELAYS eviction but does not abolish it.
# A margin is a buffer against transients, not a substitute for capacity.
l0, l15 = simulate(0.00, **LONG), simulate(0.15, **LONG)
assert (l0.evictions, l15.evictions) == (59, 13), (l0.evictions, l15.evictions)
assert l15.evictions < l0.evictions
assert l15.first_eviction > l0.first_eviction # eviction arrives later, still arrives
# 4. WHY, quantified. The reserve must cover the growth still owed to in-flight decodes.
# reserve = ceil(0.15 * 64) = 10 blocks. A short request owes 1 more block, so the reserve
# covers 10 of them concurrently. A long request owes 3, so it covers only 3, and the sim
# keeps far more than 3 in flight: the reserve is structurally too small for that workload.
reserve = Pool(NUM_BLOCKS, 0.15).reserve
assert reserve == 10 # ceil(0.15 * 64) = ceil(9.6)
assert growth_blocks(200, 200) == 1 and growth_blocks(200, 800) == 3
assert reserve // growth_blocks(200, 200) == 10 # 10 concurrent short decodes covered
assert reserve // growth_blocks(200, 800) == 3 # only 3 concurrent long decodes covered
# 5. The reserve gates PREFILLS only. One block under the margin, a new prefill is refused
# while an in-flight decode still gets its block. That asymmetry is the whole mechanism.
p = Pool(NUM_BLOCKS, 0.15)
p.free = reserve
assert p.may_admit(1) is True # exactly at the reserve: admissible
p.free -= 1
assert p.may_admit(1) is False # below it: prefill held back
assert p.free > 0 # ...but blocks remain for decodes
# 6. Boundary: margin = 1.0 reserves the whole pool, so a prefill is admitted ONLY into a
# completely idle pool. Concurrency collapses to one request. A total reserve is
# serialization, not safety.
full = simulate(1.00, **SHORT)
assert full.evictions == 0 and full.admitted <= 3 # 600 steps, one request at a time
q = Pool(NUM_BLOCKS, 1.0)
assert q.may_admit(1) is True # pool completely free
q.free -= 1
assert q.may_admit(1) is False # a single block in use blocks admission
# 7. Adversarial: out-of-range margins must raise, matching Scheduler.__init__.
for bad in (-0.01, 1.01, 2.0):
raised = False
try:
Pool(NUM_BLOCKS, bad)
except ValueError:
raised = True
assert raised, f"safety_margin={bad} must raise"
print("safety_margin admission reserve OK")
print(f" pool = {NUM_BLOCKS} blocks x {BLOCK_SIZE} tokens, reserve at 0.15 = {reserve} blocks")
print()
print(" workload | margin | admitted | evictions | 1st eviction")
for tag, cfg, pl in (("short gen (owes 1 block) ", SHORT, s0), ("short gen (owes 1 block) ", SHORT, s15),
("long gen (owes 3 blocks)", LONG, l0), ("long gen (owes 3 blocks)", LONG, l15)):
ev = pl.first_eviction if pl.first_eviction is not None else "never"
print(f" {tag} | {pl.margin:>6.2f} | {pl.admitted:>8} | {pl.evictions:>9} | {ev:>12}")
print()
print(f" short: reserve ({reserve}) covers {reserve // 1} concurrent decodes -> 0 evictions")
print(f" long: reserve ({reserve}) covers only {reserve // 3} -> eviction is delayed"
f" ({l0.first_eviction} -> {l15.first_eviction}), not avoided ({l0.evictions} -> {l15.evictions})")
Executed output:
safety_margin admission reserve OK
pool = 64 blocks x 256 tokens, reserve at 0.15 = 10 blocks
workload | margin | admitted | evictions | 1st eviction
short gen (owes 1 block) | 0.00 | 120 | 9 | 187
short gen (owes 1 block) | 0.15 | 96 | 0 | never
long gen (owes 3 blocks) | 0.00 | 83 | 59 | 187
long gen (owes 3 blocks) | 0.15 | 33 | 13 | 212
short: reserve (10) covers 10 concurrent decodes -> 0 evictions
long: reserve (10) covers only 3 -> eviction is delayed (187 -> 212), not avoided (59 -> 13)
The operating rule: the reserve only holds while it covers the block growth still owed to the requests already in flight. At 64 blocks and a 0.15 margin the reserve is 10 blocks, which covers ten short decodes but only three long ones. On long-generation workloads a bigger safety_margin buys a later and smaller eviction storm, not a cure; that needs capacity, a CPU offload tier, or fewer concurrent requests.
The response to KV pressure, and how it differs from vLLM¶
When an in-flight decode cannot allocate its next block, transformers does not immediately drop work. The offloading manager takes victims from the requests the scheduler reports as starved, newest first (so the batch just scheduled keeps its cache), never evicting the last remaining active request, and it evicts only as many as the demand requires. Each victim then takes one of two paths:
- CPU offload. If
cpu_offload_space > 0.0and the victim fits in the pinned pool, its KV blocks are copied to host RAM in a single batched transfer and copied back when GPU space frees up. The request resumes without recomputing its prompt or its generated tokens. - Soft reset. Otherwise, the request's generated tokens are appended to its prompt, its blocks are freed, and it is re-queued with the same
request_id. On resume it re-prefills its (now longer) prompt. This is recomputation.
This differs from vLLM only when CPU offload is enabled: vLLM V1 uses RECOMPUTE, and its CPU-swap path was removed as dead code (KV cache management, continuous batching internals). The transformers default is cpu_offload_space=0.0, so its pressure response also uses recomputation, although the engines' scheduling and victim-selection policies still differ. The swap tier is opt-in, and it is the thing to turn on when soft resets are costing measurable prefill.
Async batching: the one published benchmark, read carefully¶
By default the loop is synchronous: the CPU prepares a batch, the GPU computes it, the CPU reads the results back, and neither ever works while the other does. Async batching (use_async_batching) overlaps them using three CUDA streams and three events, so the CPU can prepare batch N+1 while the GPU computes batch N. The source symbols are h2d_stream, d2h_stream, compute_stream and h2d_over, compute_over, d2h_over, all in continuous_batching/input_outputs.py; the blog calls the events h2d_done / compute_done / d2h_done, which is a name that appears nowhere in the code, so grep for the _over forms. Two mechanisms make it safe:
- Dual buffer slots (A/B). The CPU cannot write batch N+1's inputs into buffers the GPU is still reading, so the engine alternates between two sets of I/O tensors. This doubles the RAM and VRAM for input and output tensors, which is the real cost of the feature. It is cheapest under FlashAttention, which needs no attention mask (by far the largest input tensor).
- Two CUDA graphs, one memory pool. A graph is captured against fixed addresses, so slot A's graph cannot replay on slot B's buffers. Both graphs allocate from a shared pool, so total VRAM is capped at the max across graphs rather than their sum. The constraint (two graphs in one pool must never run concurrently) is satisfied because batch N finishes before N+1 starts.
- Carry-over. Batch N+1's inputs are built before batch N has produced its tokens, so the missing token is a placeholder
0, and a carry-over mask (holding a destination index, or-1for "do not carry") adds the real token in before the forward pass. The four carry-over ops are cheap enough to be captured inside the CUDA graph.
Async batching requires CUDA graphs, because graph replay is what provides the stable tensor addresses that stream overlap needs.
The published result, and every caveat that belongs with it:
| Metric | Sync | Async |
|---|---|---|
| Total generation time | 300.6 s | 234.5 s |
| GPU active fraction of wall clock | 76.0% | 99.4% |
Conditions, quoted in full because they are all that is given: "8K tokens, batch size 32, 8B model". Read this as follows:
- Hardware is never stated and the model is never named. The H200 in the post is an unrelated pricing aside, not the benchmark machine. Any table reproducing this must carry "hardware not disclosed".
- "99.4% GPU utilization" is GPU busy-time from a span trace. It is not SM occupancy and not MFU. Call it the GPU active fraction of wall clock and nothing stronger; it says the GPU had work queued, not that the work was efficient.
- The "22% speedup" is a time reduction, not a throughput gain. 300.6 s to 234.5 s is a 22.0% reduction in time, which is a 28.2% throughput increase (1.28x). Quoting the 22% next to another engine's tokens-per-second figure compares two different quantities.
- No repeat count or variance is disclosed. The post reports one synchronous result and one asynchronous result, with no tokens per second, TTFT, inter-token latency, percentiles, or comparison against another engine.
The mechanism generalises even though the number does not: the win exists only while the CPU finishes preparing batch N+1 before the GPU finishes computing batch N. On a small model, a fast GPU, or a batch small enough that the forward pass is short, the CPU becomes the critical path again and the overlap buys nothing while still costing double the I/O memory. See CUDA streams and concurrency.
The serve CLI, executed: environment lock, invocation, smoke test¶
Everything in this subsection and the three that follow was executed on 2026-07-17 against transformers==5.14.1 (the latest stable release at the time; the internals above were read off main on 2026-07-13, pinned as 5.13.1) on a CPU-only Linux host with Python 3.12.3, serving HuggingFaceTB/SmolLM2-135M-Instruct. Executed here: the install, the server start, the health and models probes, non-streaming and streaming chat completions, the request-id echo, the revision-pin acceptance and rejection paths, the /metrics 404, the flag-surface capture, a SIGTERM shutdown, and (in a separate pass, same day) the smoke.sh script referenced by the systemd unit below, including its success path and two failure paths. Not executed here: any GPU path (CUDA graphs, FlashAttention, the decode fast path), any load or soak test, the CB-worker-death 503, systemctl-managed start and restart, and the reverse-proxy, canary, and authentication material, which remain labelled as templates where they appear. Latencies quoted below are from this one host and carry no generality.
The environment lock that produced every transcript below:
# Executed 2026-07-17. Python 3.12.3, fresh venv.
pip install torch==2.13.0+cpu --index-url https://download.pytorch.org/whl/cpu
pip install "transformers[serving]==5.14.1"
pip install requests==2.34.2 # workaround for a real packaging gap, see below
# Key resolved pins: fastapi==0.139.2, uvicorn==0.51.0, pydantic==2.13.4,
# tokenizers==0.22.2, huggingface_hub==1.23.0, safetensors==0.8.0,
# accelerate==1.14.0, openai==2.45.0, numpy==2.5.1.
The third line works around a packaging defect in 5.14.1, confirmed from the wheel metadata: the serving extra declares openai, pydantic, uvicorn, fastapi, starlette, rich, torch, and accelerate, but not requests, while the CLI entry point (transformers/cli/transformers.py) unconditionally imports transformers/cli/chat.py, which imports requests at module level. On a clean transformers[serving] install, transformers serve exits at startup; captured verbatim:
File ".../transformers/cli/transformers.py", line 19, in <module>
from transformers.cli.chat import Chat
File ".../transformers/cli/chat.py", line 26, in <module>
import requests
ModuleNotFoundError: No module named 'requests'
The server code also moved between the two pins: in 5.14.1 it lives in transformers/cli/serve.py plus the transformers/cli/serving/ package (server.py, model_manager.py, utils.py, and one handler module per endpoint family), not in a commands/ module as in older releases. Every source claim below cites the installed 5.14.1 package.
The verified invocation. The positional argument is force_model: it preloads the model at startup, disables the idle unload timer, and rejects requests for any other model. Pinning it to an exact commit is the revision half of the rollback story below.
# Executed 2026-07-17 on CPU. On a GPU host: drop --device cpu and prefer
# --attn-implementation flash_attention_2 (unlocks the decode fast path).
transformers serve \
'HuggingFaceTB/SmolLM2-135M-Instruct@12fd25f77366fa6b3b4b768ec3050bf629380bac' \
--continuous-batching \
--attn-implementation sdpa \
--device cpu \
--port 8901 \
--log-level info
The smoke test, real request and real response, with the model field omitted (allowed when force_model is set; the pin is substituted server-side):
$ curl -s http://localhost:8901/health
{"status":"ok"}
$ curl -s http://localhost:8901/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"messages": [{"role": "user", "content": "Say pong."}], "max_tokens": 6}'
{"id":"507077a8-0587-47d4-8c88-35ba897244bc","choices":[{"finish_reason":"length","index":0,"message":{"content":"Pong is a game that","role":"assistant"}}],"created":1784249975,"model":"HuggingFaceTB/SmolLM2-135M-Instruct@12fd25f77366fa6b3b4b768ec3050bf629380bac","object":"chat.completion","usage":{"completion_tokens":6,"prompt_tokens":34,"total_tokens":40}}
Facts the transcript establishes, plus three more from the same session:
- The response
modelfield echoes the exact commit pin. A deploy pipeline should assert on that string; it is the cheapest end-to-end proof that the intended revision is serving. - The server log prints
[Request received] Model: ..., CB: Trueper request, which is the confirmation that continuous batching, not sequentialgenerate, handled it. - Streaming works over SSE (executed):
"stream": trueyieldschat.completion.chunkdeltas; in the captured stream eachdata:line carried one token-sized text chunk. - The
x-request-idrequest header is echoed as a response header and becomes the completionid(executed withx-request-id: smoke-warm-001; the response carried"id":"smoke-warm-001"). This is the only built-in request-tracing hook; the middleware is incli/serving/server.py. - The first request is the slow one: the CB manager initializes lazily on first use (
init_cbincli/serving/utils.py). On this host the cold first request (16 tokens) took 45.9 s and a warm 8-token request took 16.6 s. The numbers are CPU noise; the lazy initialization is not. - The full route surface, read from the live server's
/openapi.json(executed):POST /v1/chat/completions,POST /v1/completions,POST /v1/responses,POST /v1/audio/transcriptions,POST /load_model,POST /reset,GET /v1/models,GET /health.
Readiness and health¶
GET /health exists in 5.14.1 and is CB-aware. It returns 200 {"status":"ok"} normally and 503 {"status":"unhealthy","reason":"cb_worker_dead"} once the continuous-batching background worker has died, by checking ContinuousBatchingManager.fatal_error through is_cb_alive() (cli/serving/server.py and cli/serving/utils.py, read from the installed package; the 503 path was not exercised here because the CLI offers no way to kill the worker on demand). A request submitted after the worker dies fails fast as the same 503 through the CBWorkerDeadError exception handler instead of queueing into a void.
/health is liveness, not readiness. is_cb_alive() deliberately returns True while CB is not yet initialized, and initialization happens on the first generation request; without force_model, model weights also load lazily per request and unload after --model-timeout seconds idle (default 300; force_model sets it to -1, never unload). Executed here: /health returned 200 before any request had initialized CB. So a 200 means the process is up and the CB worker has not died. It does not mean a model is resident or that the first token will arrive quickly. Gate readiness on a real one-token completion; the systemd template below does exactly that.
Metrics, authentication, supervision¶
Metrics: none. GET /metrics returns 404 (executed), and the serving package defines no counters or histograms. The only per-request signals are the uvicorn access log and the x-request-id echo. RED metrics have to come from a reverse proxy in front.
Authentication: none. The full 5.14.1 flag surface, captured from transformers serve --help: the force_model positional, --continuous-batching, --attn-implementation, --compile, --quantization, --reasoning, --chat-template-kwargs, --device, --dtype, --trust-remote-code, --model-timeout, --cb-block-size, --cb-num-blocks, --cb-max-batch-tokens, --cb-max-memory-percent, --cb-use-cuda-graph, --host, --port, --enable-cors, --log-level, --default-seed. No token, no API key, no TLS. Two routes make exposure worse than it sounds: POST /reset unloads every model and POST /load_model loads arbitrary ones, both unauthenticated; and GET /v1/models scans the local Hugging Face cache and lists every generative model on the machine (executed: it returned six entries while one model was being served). The default bind of localhost:8000 is the right one; keep it and put a proxy in front for anything beyond loopback.
# Reference template, not executed. Pinned against transformers==5.14.1 behaviour.
# Supplies what the CLI lacks: TLS, bearer auth, admin-route denial, a concurrency
# cap (the server itself never sheds load), and access-log-based RED metrics.
upstream tcb { server 127.0.0.1:8901; }
limit_conn_zone $server_name zone=tcb_conn:1m;
server {
listen 443 ssl;
# ssl_certificate / ssl_certificate_key per your PKI
location = /reset { return 403; }
location = /load_model { return 403; }
location / {
if ($http_authorization != "Bearer REPLACE_WITH_SECRET") { return 401; }
limit_conn tcb_conn 32; # hard concurrency cap; excess connections get 503
proxy_buffering off; # required for SSE streaming
proxy_read_timeout 120s; # streamed completions are long-lived
proxy_pass http://tcb;
}
}
Supervision is external. transformers serve runs a single in-process uvicorn server (uvicorn.Config(app, host=host, port=port, log_level="info") in cli/serve.py; there is no workers option), so restarts belong to systemd or an equivalent. SIGTERM is clean (executed): the log shows Shutting down, Waiting for application shutdown., Application shutdown complete., Finished server process, and the FastAPI lifespan hook unloads models on the way out.
# Reference template, not executed. /etc/systemd/system/transformers-serve.service
# The venv holds the exact pins from the environment lock above.
[Unit]
Description=transformers serve (continuous batching), pinned deployment
After=network-online.target
[Service]
User=svc-inference
ExecStart=/opt/tcb/venv/bin/transformers serve \
'HuggingFaceTB/SmolLM2-135M-Instruct@12fd25f77366fa6b3b4b768ec3050bf629380bac' \
--continuous-batching --attn-implementation flash_attention_2 \
--host 127.0.0.1 --port 8901 --log-level info
# Readiness is a real completion, not /health (see above): the script posts a
# one-token chat completion and asserts the response model string equals the pin.
ExecStartPost=/opt/tcb/smoke.sh
Restart=always
RestartSec=5
TimeoutStopSec=30 # SIGTERM shutdown is clean; give model unload time to finish
[Install]
WantedBy=multi-user.target
smoke.sh: the readiness script the unit calls, executed¶
The unit above names /opt/tcb/smoke.sh in ExecStartPost but a prior pass of this page never supplied it. Here is the real content, and it was executed against the real server from the section above, not written as a template.
#!/usr/bin/env bash
# /opt/tcb/smoke.sh
# Readiness gate for the transformers-serve continuous-batching deployment.
# Two checks, both against the real running server:
# 1. GET /health returns 200 with {"status":"ok"}.
# 2. A real one-token-budget chat completion succeeds, and its echoed
# "model" field equals the exact revision pin the server was started
# with. This is the readiness proof the ExecStartPost comment above
# promises: /health alone only proves the process is up, not that a
# model is loaded or that the CB worker can serve tokens.
# Exit 0 on success. Nonzero (1-5) on the first failure, with a reason on stderr.
set -u
HOST="${TCB_HOST:-127.0.0.1}"
PORT="${TCB_PORT:-8901}"
MODEL_PIN="${TCB_MODEL_PIN:-HuggingFaceTB/SmolLM2-135M-Instruct@12fd25f77366fa6b3b4b768ec3050bf629380bac}"
PYTHON="${TCB_PYTHON:-/opt/tcb/venv/bin/python3}"
BASE="http://${HOST}:${PORT}"
# The first request after startup initializes the CB manager lazily and is
# slow (documented above: 45.9s cold on this host). The default covers that;
# override TCB_TIMEOUT for a faster or slower host.
TIMEOUT="${TCB_TIMEOUT:-90}"
health_body="$(curl -sf -m "${TIMEOUT}" "${BASE}/health")" || {
echo "smoke.sh: FAIL health check unreachable at ${BASE}/health" >&2
exit 1
}
case "${health_body}" in
*'"status":"ok"'*) ;;
*)
echo "smoke.sh: FAIL health body not ok: ${health_body}" >&2
exit 1
;;
esac
completion_body="$(curl -sf -m "${TIMEOUT}" "${BASE}/v1/chat/completions" \
-H 'Content-Type: application/json' \
-d '{"messages": [{"role": "user", "content": "Say pong."}], "max_tokens": 6}')" || {
echo "smoke.sh: FAIL completion request failed" >&2
exit 2
}
echo "${completion_body}" | "${PYTHON}" -c "
import json, sys
pin = sys.argv[1]
try:
data = json.load(sys.stdin)
except json.JSONDecodeError as exc:
print(f'smoke.sh: FAIL completion response is not JSON: {exc}', file=sys.stderr)
sys.exit(3)
model = data.get('model')
if model != pin:
print(f'smoke.sh: FAIL model field {model!r} != pin {pin!r}', file=sys.stderr)
sys.exit(4)
choices = data.get('choices') or []
content = (choices[0].get('message') or {}).get('content') if choices else None
if not content:
print('smoke.sh: FAIL completion has no content', file=sys.stderr)
sys.exit(5)
" "${MODEL_PIN}" || exit $?
echo "smoke.sh: OK health 200 + completion served from ${MODEL_PIN}"
exit 0
Save it to /opt/tcb/smoke.sh and chmod +x it. Its defaults (127.0.0.1:8901, and the same model@revision string) match the ExecStart line above with no flags needed, and TCB_PYTHON defaults to the same venv ExecStart uses, so under the real /opt/tcb layout it needs no environment overrides at all.
Executed here (2026-07-17), against the server started earlier in this section, from a fresh scratch venv holding the exact pins from the environment lock (TCB_PYTHON is overridden below only because this reproduction venv is not literally at /opt/tcb; the resolved transitive pins also drifted slightly since the original install in this section, huggingface_hub==1.24.0 and openai==2.46.0 this time versus 1.23.0 / 2.45.0 quoted above, with no effect on behavior):
$ TCB_PYTHON="$(pwd)/venv/bin/python3" ./smoke.sh
smoke.sh: OK health 200 + completion served from HuggingFaceTB/SmolLM2-135M-Instruct@12fd25f77366fa6b3b4b768ec3050bf629380bac
$ echo $?
0
Two failure paths, executed to confirm the exit-code contract ExecStartPost depends on. A health check against an unreachable port fails closed, and a completion whose echoed model does not match the pin fails closed, both nonzero rather than silently passing:
$ TCB_PORT=8999 TCB_TIMEOUT=3 ./smoke.sh; echo "exit=$?"
smoke.sh: FAIL health check unreachable at http://127.0.0.1:8999/health
exit=1
$ TCB_PYTHON="$(pwd)/venv/bin/python3" TCB_MODEL_PIN="HuggingFaceTB/SmolLM2-135M-Instruct@deadbeef" ./smoke.sh; echo "exit=$?"
smoke.sh: FAIL model field 'HuggingFaceTB/SmolLM2-135M-Instruct@12fd25f77366fa6b3b4b768ec3050bf629380bac' != pin 'HuggingFaceTB/SmolLM2-135M-Instruct@deadbeef'
exit=4
What this closes: the ExecStartPost line now has a real script behind it, and the readiness semantics its comment promises (a real completion, not /health alone) are executed and verified end to end, success and failure, on this host. What remains unexecuted: systemctl start itself was not run, so the unit's process supervision (Restart=always, RestartSec=5) was not exercised, only the script it calls; and the reverse-proxy authentication layer and the canary traffic shift below remain templates, as does anything that needs a GPU.
Overload policy¶
What exists and what does not, read from the installed 5.14.1 source:
- Under
transformers serve, the admission queue is unbounded. The manager builds it asqueue.Queue(maxsize=max_queue_size)with the default 0, unlimited (generation/continuous_batching/continuous_api.py; default ingeneration/configuration_utils.py). The CLI's--cb-*flags map onlyblock_size,num_blocks,max_batch_tokens,max_memory_percent, anduse_cuda_graphintoContinuousBatchingConfig(cli/serve.py), somax_queue_size,scheduler_type,safety_margin, andcpu_offload_spaceare unreachable from the CLI. Under sustained overload the server sheds nothing: the queue grows, TTFT grows with it, and no request is ever answered 429 or 503. - A bounded queue blocks, then raises. If you embed the manager yourself and set
max_queue_size > 0,add_requestcallsinput_queue.put(state, block=True, timeout=10): a full queue stalls the submitting thread for up to 10 seconds and then raisesqueue.Full. In the serve handlers that would surface as an unhandled 500, not a 429 (not executed here; read from the source). There is no native rate limiting on either path. - Admission control therefore lives in front. The proxy concurrency cap in the template above is the overload policy. Size it from the KV arithmetic earlier on this page: the
safety_marginanalysis governs what happens to requests after admission; the proxy cap decides how many are admitted at all.
Canary and rollback by pinned revisions¶
The mechanics below are executed; the procedure is a labelled template.
model@revisionis native.process_model_namecanonicalizes bare names to@main(cli/serving/model_manager.py), and the transcript above shows a completion served from an exact commit pin and echoed back in the responsemodelfield.- The pin is enforced as an exact string. With
force_modelset, a request whosemodelfield differs from the literal pin is rejected with HTTP 400 (executed):{"detail":"Server is pinned to 'HuggingFaceTB/SmolLM2-135M-Instruct@12fd25f77366fa6b3b4b768ec3050bf629380bac'; requested 'HuggingFaceTB/SmolLM2-135M-Instruct'."}. The comparison happens before canonicalization, so a revision-pinned server rejects even the bare form of its own model name. Clients must omitmodelentirely or send the identicalmodel@revisionstring; plan for that before pinning, because most OpenAI SDKs sendmodelon every call. - Canary procedure (reference template, not executed). A deployment is the pair (package pins, model pin): the environment lock plus the
force_modelstring. To canary: start a second instance on another port with the new pair; run the smoke gate against it, including the assertion that the responsemodelequals the new pin; shift a traffic fraction to it at the proxy; judge on proxy-side latency and error rate, because the server exports no metrics; then either move the remaining traffic or point it all back. Rollback is a redeploy of the previous pair. No server-side state mutates across versions, and the HF cache stores revisions side by side, so a rollback on the same host is a restart against already-cached weights, not a re-download.
Failure modes¶
| Symptom | Cause | Action |
|---|---|---|
| Decode much slower than expected, no error or warning | The decode fast path was silently disabled: flash_attn_with_kvcache is unavailable for this backend/device, so ensure_decode_fast_path_is_available set max_blocks_per_request to 0 and every batch takes the varlen path |
Set max_blocks_per_request explicitly, which turns the silent fallback into a logged warning. The fast path needs FA2/FA3 on CUDA, or FA2 on XPU |
Decode slow on a sliding-window model, and setting max_blocks_per_request explicitly produces no warning either |
A second, independent kill switch: after the cache is built, continuous_api.py zeroes max_blocks_per_request outright when num_sliding_attention_groups > 0 (a TODO in the source). It runs after ensure_decode_fast_path_is_available, so the explicit-set warning never fires |
Assert on the resolved config after startup rather than trusting a warning. A sliding-window model loses the fast path even on FA2/CUDA; there is no config that turns it back on |
| No prefix-cache reuse on a model with a shared system prompt | The model has a sliding-window layer group, so use_prefix_sharing is forced off regardless of allow_block_sharing |
Expect no reuse; do not spend memory chasing it. A sliding-window model loses the decode fast path as well (row above), so it is two losses, not one. Route to an engine with a different cache design if either matters |
ValueError: Block size must be at least 4 |
block_size below the implementation minimum |
Use 256 (the default) unless a measurement justifies otherwise |
| Requests longer than 8192 tokens misbehave on the fast path | The default block table is 32 blocks x 256 tokens = 8192 | Pass workload hints (max_prompt_length, max_generated_length) or set max_blocks_per_request |
| Throughput collapses under load, prefill time balloons | Victims are being soft reset (recomputed) because cpu_offload_space=0.0 |
Raise capacity, lower concurrency, raise safety_margin, or enable the CPU swap tier |
A bigger safety_margin does not stop eviction |
The reserve does not cover the block growth still owed to in-flight decodes (validated above) | Long generations need capacity or offload, not a bigger reserve |
| OOM shortly after enabling async batching | Dual I/O slots double the input/output tensor memory | Disable use_async_batching, or lower max_batch_tokens / max_requests_per_batch |
| OOM during sampling on a large-vocabulary model | The logits tensor scales with batch tokens x vocab and is cast to fp32 | Lower max_requests_per_batch; note max_memory_percent already drops 0.9 to 0.8 when logits processors are active |
Per-request temperature silently ignored |
per_request_processors=False, or the GenerationConfig field is at its default so the processor was never built |
Set per_request_processors=True and a non-default value in GenerationConfig |
get_result(request_id=...) returns None for a live request |
It pops the queue head and re-queues on mismatch; it is not a lookup | Iterate the manager, use request_id_iter, or register_result_handler |
| Tensor-parallel startup error about KV heads | TP size must divide num_key_value_heads |
Choose --nproc-per-node accordingly |
transformers serve exits at startup with ModuleNotFoundError: No module named 'requests' |
Packaging gap in 5.14.1: the serving extra does not declare requests, but the CLI entry point imports it via cli/chat.py (reproduced 2026-07-17) |
pip install requests alongside transformers[serving] |
| Every request to a revision-pinned server returns 400 "Server is pinned to ..." | The force_model check is an exact string comparison, done before @main canonicalization, so even the bare form of the pinned model name is rejected (reproduced 2026-07-17) |
Have clients omit the model field or send the exact model@revision string |
/health returns 200 but the first completion is slow or errors |
/health only asserts the CB worker has not died; the model and the CB manager initialize lazily on the first request |
Gate readiness on a real one-token completion, never on /health alone |
Open questions and validation¶
- Compile and continuous batching. The
transformers servedocs state flatly that "Compile is incompatible with continuous batching", whileContinuousBatchingConfigexposesdefault_compile_level1 to 3 with separatevarlen_compile_configanddecode_compile_config, andresolve_compile_configsclearly builds them. These cannot both be complete descriptions. The likely reading is that the serve CLI's--compileflag drives a different code path from the config's compile levels, but that is inference, not a verified fact. Do not rely on either statement; test compile on your own model and measure. - Upstream doc drift, verified 2026-07-13. The async blog says "the general entry point for continuous batching is
continuous_batching.py"; no such file exists (it is the packagecontinuous_batching/, withcontinuous_api.pyas the entry point). Thecontinuous_api.pymodule docstring points atContinuousBatchingConfig.resolve_sentinel_values(), a method that does not exist (defaults resolve ininitialization.py). Bothcontinuous_batching.mdandcontinuous_batching_architecture.mdclaim the first blog post covers "performance benchmark numbers"; it contains none.continuous_batching_architecture.mdsays async batching "uses two I/O buffer pairs and two CUDA streams"; the code creates three (h2d_stream,d2h_stream,compute_stream,input_outputs.py), and the async blog agrees it is three, so the architecture doc is the stale one. The blog's event names (h2d_done,compute_done,d2h_done) are also not the code's (h2d_over,compute_over,d2h_over). - Errors in the first blog post, not propagated here. It calls the
(n-1)(B-1)padding cost "quadratic" (it is linear in each variable, refuted in the executed block above); its KV example ("16 KB in memory") is per token per layer, so the per-token total for Llama-2-7B across 32 layers is 512 KB, and the "16 KB per token" reading is wrong; and it motivates chunked prefill as a memory constraint, when the production reason is head-of-line blocking and decode starvation (Sarathi-Serve), which continuous batching internals already states correctly. - Prefix-cache eviction order is LIFO, not LRU. The manager demotes the most recently initialized block first. This is what the architecture doc says and what
BlockManagerdoes, but the consequence (a hot, long-lived shared prefix is evicted later than a recently added one, which is desirable, while recently built prefixes are the first to go) is not discussed upstream and is not benchmarked. Measure hit rate before relying on it. - No transformers-versus-vLLM throughput number is citable. None is published under a reproducible method. Any capacity decision between the two engines needs a local A/B on real traffic.
References¶
- Hugging Face, Continuous batching (docs):
generate_batch,ContinuousBatchingManager,ContinuousBatchingConfigfields and defaults, the paged-backend table, tensor parallelism, sliding-window behaviour. - Hugging Face, Continuous batching architecture (docs): request lifecycle, chunked prefill, FIFO and PrefillFirst schedulers, admission and
safety_margin, prefix caching, lazy eviction, soft reset, CUDA graphs, async batching, offloading. - Hugging Face, Paged attention (docs): the varlen path (
flash_attn_varlen_func) and the decode fast path (flash_attn_with_kvcache), block tables,cache_seqlens. - Hugging Face, transformers source:
generation/continuous_batching/:cache.py(_min_block_size = 4, thenum_blocks + 2reserved zone,_default_max_batch_tokens = 8192,_min_max_batch_tokens = 256),scheduler.py(SCHEDULER_MAPPING, safety-margin defaults 0.15 / 0.0),initialization.py(FALLBACK_DEFAULTS, decode-fast-path availability, CUDA-graph and async auto-detection),offloading_manager.py(newest-first, demand-driven offload),cache_manager.py(content-hashed, ref-counted, lazily evicted blocks). - Hugging Face, Server optimizations (
transformers serve):--continuous-batching,--attn-implementation, and the "Compile is incompatible with continuous batching" note. transformers==5.14.1installed package, read and executed against on 2026-07-17:cli/serve.py(full flag surface, single in-process uvicorn server,--cb-*toContinuousBatchingConfigmapping),cli/serving/server.py(route table,/healthand its 503cb_worker_deadpath,x-request-idmiddleware,CBWorkerDeadErrorhandler),cli/serving/utils.py(is_cb_alive,fatal_error, lazyinit_cb, the pin-mismatch 400),cli/serving/model_manager.py(model@revisioncanonicalization, idle unload,force_modelpreload),generation/continuous_batching/continuous_api.py(unbounded input queue,put(block=True, timeout=10)backpressure).- Rémi Ouazan Reboul, Arthur Zucker, Luc Georges, Continuous batching from first principles, Hugging Face, 2025-11-25. Pedagogical only: no API, no code, no benchmark numbers. Source of the
(n-1)(B-1)derivation and the 693-token example, and of the three errors listed under Open questions. - Rémi Ouazan Reboul, Pedro Cuenca, Aritra Roy Gosthipaty, Unlocking asynchronicity in continuous batching, Hugging Face, 2026-05-14. Source of the CUDA-streams design and the reported comparison (300.6 s to 234.5 s; 76.0% to 99.4% GPU active fraction; "8K tokens, batch size 32, 8B model"; hardware, model name, repeat count, and variance not disclosed).
- Yu, Jeong, Kim, Kim, Chun, Orca: A Distributed Serving System for Transformer-Based Generative Models, OSDI '22: iteration-level scheduling and selective batching, the origin of continuous batching.
Related: Continuous batching internals · vLLM Transformers modeling backend · KV cache management · Inference QoS and admission control · Inference serving and optimization · Serving open-weight models · CUDA streams and concurrency · Rollout redundancy and cascade attention · SLOs: inference serving · Glossary