mlxcel-server (and mlxcel serve) runs a continuous-batching scheduler: it
admits concurrent requests into a running batch, interleaves prefill and decode,
and streams each request's tokens as they are produced. This document covers the
batching scheduler, how it uses the paged KV cache, and the disaggregated
serving roles that split prefill and decode across processes.
The scheduler keeps up to --parallel sequences active at once. Each step it
either admits and prefills queued prompts (chunked at --prefill-chunk-size so a
long prompt does not stall decode) or advances the active batch by one decode
token, then streams the new tokens out. Relevant flags:
| Flag | Default | Purpose |
|---|---|---|
--parallel N |
4 | Maximum active (in-flight) sequences; caps the concurrent decode batch. |
--max-batch-size N |
(= --parallel) |
Maximum sequences decoded together in one batched step. |
--max-batch-prefill N |
4 | Requests batched into one prefill forward pass (families that support it). |
--max-batch-prefill-tokens N |
(derived) | Padded-token budget bounding one batched prefill's transient memory. Unset derives 2 * max_batch_prefill * prefill_chunk_size; 0 disables the cap. |
--max-queue-depth N |
32 | Maximum queued (not yet admitted) requests. |
--prefill-chunk-size N |
512 | Token chunk size for prefill; bounds prefill's effect on decode latency. |
--prefill-grant-interval N |
16 | Decode ticks a parked chunked prefill yields before it is granted one; bounds an admitted long prompt's time to first token. 0 disables the grant (unbounded wait). |
--enable-preemption |
off | Allow evicting a lower-priority sequence to admit a waiting one. |
--no-batch |
off | Disable batching and serve sequentially (the legacy single worker). |
--no-prompt-cache |
off | Disable the prompt-prefix KV cache (it is on by default). |
The shipped defaults are tuned for multi-client serving. Batched decode reads each weight once per step regardless of how many sequences share the step, so aggregate throughput scales with concurrency until the batch hits the compute roofline, while per-request decode rate falls proportionally. The defaults:
--parallel 4: admit a decode batch of up to 4. The worker clamps this to 1 for families that cannot batch (SSM / hybrid / mixed-cache, i.e. any model wheresupports_batching()is false), so the default is safe for every architecture. When an explicit split configuration is clamped this way, the worker restores the whole configured context as the one effective request window and publishes that post-load value to/props,/slots, and/v1/models.supports_batching()being true does not by itself mean a family runs one forward per tick: only families that overrideforward_batched()(Llama 3, Llama 4, Qwen 3, Qwen 3.5, Gemma 3, Helium, Muse Glimmer, and Qwen3-MoE since #1616) do. Every other batching family runs theLanguageModeldefault, which calls the single-sequenceforward()once per row and evaluates the B graphs together, so its aggregate scaling comes only from overlapping independent single-token graphs. The 2026-09-04 M1 Ultra profile indocs/benchmark_results/moe-batched-decode-m1ultra-2026-09-04.mdmeasured that fallback onqwen3-30b-a3b-4bitbefore the override landed.--max-batch-prefill 4: batch up to 4 pending prompts into one prefill pass. Only families that opt intosupports_batched_prefill()(Llama 3, Qwen 3, Qwen 3.5, and aliases such as Qwen 2.5) use it; others fall back to sequential prefill automatically.- Prompt-prefix cache on, bounded to a 2 GiB KV budget (
--prompt-cache-*to retune). A repeated shared prefix (for example a long system prompt) is prefilled once and reused.
Escape hatches restore the previous single-client behavior: --parallel 1
(single decode slot), --no-batch (legacy sequential worker, no scheduler),
--max-batch-prefill 1 (sequential prefill), and --no-prompt-cache.
The shipped continuous-batching defaults are a serving-throughput contract, not
a token-exactness contract against mlxcel generate for every model at
temperature 0. Front-end prompt rendering is shared, but the server can still
use scheduler-owned cache allocation, paged storage, prompt-cache adoption,
chunked prefill, and batched decode paths that the CLI loop does not use. If a
reproduction or oracle comparison needs the CLI-shaped single-request path, use
--no-batch for the legacy worker or --max-batch-size 1 to keep the scheduler
while making --decode-storage-backend auto resolve to dense storage. These are
diagnostic/oracle controls that narrow the server path toward the CLI; they do
not turn every model family into an unmeasured token-exactness guarantee. To
isolate only the decode storage backend while preserving the default admission
width, prefer --decode-storage-backend dense; that is the first bisect knob
when a --max-batch-size 1 run matches the CLI but the default server does not.
Backend note (CUDA / Blackwell, e.g. GB10): batched decode used to be a throughput wash on CUDA because the
M*B < 8quantized matmul fell back to per-row qmv, re-reading the weights once per sequence (aggregate flat at ~45-50 tok/s across B=1/2/4 on llama-3.1-8b-4bit). The multirow qmv path (issue #725,MLXCEL_QMV_MULTIROW, on by default) amortizes the weight reads across the batch, so aggregate decode now scales with concurrency on CUDA too: 40.4 / 68.5 / 74.2 tok/s at 1/2/4 clients on llama-3.1-8b-4bit on GB10 (vs 40.5 / 49.8 / 49.6 with the kill switch off). Scaling stays sublinear versus Metal because attention KV reads still grow with B; the small-Mqmm_sm80tile shape (M*B >= 8) remains upstream MLX territory. For the same reason, keep--parallelat 7 or below on CUDA (both server binaries also accept--n-parallel): the multirow window covers 2-7 rows, and a full batch of 8+ decode rows crosses into the under-tiledqmm_sm80shape, which measures worse than per-row decode until the upstream small-M tile lands. The shipped default of 4 sits comfortably inside the window. Seedocs/benchmark_results/qmv-multirow-gb10-2026-07-11.md.--max-batch-prefill(large-M, tile-tuned,docs/benchmark_results/qmm-sm121-tile-tuning-gb10-2026-07-10.md) and the prompt cache remain independent wins on CUDA.
Memory sizing: the KV footprint grows with the active batch, so budget for up to
--parallel concurrent sequences' KV. With auto --parallel -1, mlxcel
matches b10621's unified default: four slots are exposed, every slot can use
the whole --ctx-size window, and the scheduler enforces that all live
prompt+generated tokens together stay within that shared budget. With an
explicit positive --parallel (or explicit --max-batch-size) the context is
split across the active slots (ctx_size / parallel per slot) unless
--kv-unified is also set; --no-kv-unified forces split windows. Leave
--ctx-size at 0 to use the model default. Admission control
(--kv-cache-budget, --max-kv-size) still sheds load under pressure, so a
large --parallel degrades to queueing rather than OOM.
Measured scaling (Apple M1 Ultra, Metal; meta-llama-3.1-8b-instruct-4bit,
512-token prompt, 128 new tokens; scripts/bench_serving_concurrency.py).
The baseline column is the previous default --parallel 1. The batched column
was measured with --max-batch-prefill 4 and a batch ceiling high enough to
expose each concurrency level (--parallel 8), so the 8-client row reflects a
--parallel 8 server; the shipped default is --parallel 4, whose batch caps
at 4 (the 4-client row is the default at saturation, and 8 clients would run 4
batched plus 4 queued). Aggregate is tokens/sec summed across concurrent
clients; TTFT is time-to-first-token:
| clients | aggregate tok/s (-p1) |
aggregate tok/s (batched) | TTFT mean ms (-p1) |
TTFT mean ms (batched) |
|---|---|---|---|---|
| 1 | 56.6 | 56.8 | 889 | 783 |
| 2 | 71.5 | 99.9 | 1150 | 114 |
| 4 | 65.6 | 107.9 | 3257 | 189 |
| 8 | 62.8 | 105.1 (-p8) |
7514 | 348 (-p8) |
At 4 concurrent clients the default delivers 1.90x the single-client aggregate throughput and cuts mean TTFT under load ~17x (3257 ms to 189 ms), while single-client throughput is unchanged. On a higher-bandwidth decode target (for example GB10) the weights-read amortization headroom is larger; that number is pending a CUDA measurement session.
The batched-decode default is paired with an auto paged KV budget
(--kv-cache-budget auto, the default): the #122 block-budget admission bounds
KV for the concurrent batch and returns backpressure instead of letting four
full-context sequences run into an OOM abort. On the dense decode backend the
budget is inert. Disable the guard with --kv-cache-budget none. Memory-
constrained hosts can also lower --parallel or cap --ctx-size (see the
context-sizing note in environment-variables.md).
The flag table above says --prefill-chunk-size "bounds prefill's effect on
decode latency". That is true, and it is worth stating what the tick policy
actually does, because the shape is not what a chunk size usually implies.
A prompt longer than --prefill-chunk-size is admitted, runs chunk 0, and is
then parked. From that point the tick policy resolves in favour of decode for as
long as any sequence is active. Before issue #1011 it resolved that way
forever: the policy is a pure function of scheduler state, and running a decode
changes none of the state it reads, so the parked prompt made no progress at all
until the last decoding sequence finished. Measured on an M1 Ultra, the prefill
held at chunk 1 of 19 for 20 s while decode advanced 162 to 344 steps. With
requests arriving continuously that wait has no ceiling, so neither does the
admitted request's time to first token.
--prefill-grant-interval N (default 16) bounds it. After N consecutive
decode ticks the next tick is GRANTED to the parked prefill, which runs one
chunk and resets the count. So:
- The bound. The parked prompt advances at least one chunk per
N + 1ticks, so aC-chunk prompt reaches its first token withinC * (N + 1)ticks of admission, however long the batch keeps decoding. - The price. Over one grant cycle the decoding streams get
Ntokens perN * D + Pof wall clock, for a decode stepDand a chunk forwardP, so their mean inter-token latency during the admission window isD + P / N. Their p95 is a different shape: one gap inN + 1carries the chunk, so the chunk shows up in p95 wheneverN < 19and hides above p95 otherwise. The hiccup is the same size either way; only its frequency changes, so tuningNto keep a percentile clean is not the same as making the streams faster.
N is therefore the TTFT-versus-ITL dial, on the same frontier
--prefill-chunk-size moves from the other direction: a smaller chunk makes
each hiccup smaller, a smaller interval makes them more frequent and the
admitted request faster. --prefill-grant-interval 0 disables the grant and
restores the pre-#1011 arbitration, unbounded wait included. Measured frontier:
docs/benchmark_results/prefill-fairness-m1ultra-2026-08-03.md.
Two further things worth knowing when reading latency numbers:
- With the batch already at
--parallel, a queued request is not admitted at all, so it contributes no prefill and none of the above arises. The scenario "a long prompt prefills while others decode" needs--parallelstrictly above the number of active streams. - At most one chunked prefill is ever parked. The scheduler holds it in a single slot and the chunked branch short-circuits above the admission branch, so while one prompt is parked no further request is admitted whatever the batch's occupancy. Shortening the parked prompt's stay therefore also shortens the head-of-line wait of everything queued behind it.
Issue #908 analysed the same branch and decided against fixing it with a fused
ragged forward; see
ADR 0005. The experimental
MLXCEL_MIXED_STEP=1 remains as the extreme end of the frontier: every tick
advances both workloads, which minimizes the parked prompt's TTFT and maximizes
what the decoding streams pay. It is a measurement instrument, not a supported
operator knob. scripts/bench_mixed_step_admission.py drives the scenario and
attributes each run through mlxcel_batch_mixed_steps_total (the #908
prototype) and mlxcel_batch_prefill_grants_total (the #1011 grant);
scripts/bench/starvation_probe.sh samples the same counters over time.
The --kv-cache-budget guard above bounds steady-state KV, not the transient a
batched prefill allocates while it runs. When two or more cold prompts share one
padded batched prefill, the path pads every row to the window's longest prompt
L and materializes a stacked [B, L, L] FP32 attention mask plus a
[B, L, hidden] forward. Left uncapped, B long prompts arriving together
allocate an O(B*L^2) mask that ignores --prefill-chunk-size: four 8k prompts
build a [4, 8192, 8192] FP32 mask, about 1 GiB, a spike far above the working
set of the sequential chunked prefill those prompts take when batched prefill is
off. On the serving path an allocation failure at this eval is now caught at the
fallible try_eval FFI boundary and fails just the cohort's requests instead of
aborting the whole server (issue #822); a persistent run of consecutive eval
failures across cohorts still trips a shutdown guard, so the transient itself
remains an availability edge the KV budget does not model.
--max-batch-prefill-tokens N caps the drained batched window by total padded
tokens (rows * L). Draining stops before a row that would push rows * L past
N; the remaining rows stay queued and prefill on later ticks (short ones
re-batch, long ones take the chunked single-sequence path). A head prompt too
long to join a two-row batch (2 * head_len > N) skips the batched path
entirely and prefills chunked. The bound follows from the token budget: a cohort
of B >= 2 rows padded to L costs B*L <= N tokens, and since L <= (B*L)/2
the mask stays within N^2 / 2 elements, i.e. ~N^2 bytes at FP16 and
~2*N^2 at FP32.
The default budget is derived, not fixed: 2 * max_batch_prefill * prefill_chunk_size
(the shipped 2 * 4 * 512 = 4096; the 2x headroom absorbs the padding slop of
real chunk-sized prompts, whose chat template pushes them slightly over
prefill_chunk_size), so a full batch of chunk-sized prompts stays
eligible for batching while a window of longer prompts spills to the chunked
path. At the default the FP32 mask is bounded to 2 * 4096^2 bytes, about 34 MiB,
negligible beside model activation memory. 0 (the flag, or
MLXCEL_MAX_BATCH_PREFILL_TOKENS=0) disables the cap for the pre-#715 unbounded
behavior. The flag takes precedence over MLXCEL_MAX_BATCH_PREFILL_TOKENS, which
takes precedence over the derived default (see
environment-variables.md).
The analytic prediction for four concurrent 8k-token prompts on
meta-llama-3.1-8b-instruct-4bit (Apple M1 Ultra, Metal):
| config | prefill mask window | mask transient (analytic) | path |
|---|---|---|---|
uncapped (--max-batch-prefill-tokens 0) |
[4, 8192, 8192] FP32 |
4 * 8192^2 * 4 B = 1024 MiB |
single unchunked batched forward |
| default cap (4096) | four [≤512, 8192] chunk masks |
512 * 8192 * 4 B = 16 MiB (one at a time) |
8k prompts spill to the chunked single-sequence path |
The empirical A/B (server phys-footprint peak: RSS does not capture MLX Metal
buffers on Apple Silicon, so use /usr/bin/footprint -p <pid> for
phys_footprint_peak) is pending a measurement session. Reproduce with a server
started at --max-batch-prefill-tokens 0 versus the default, driven by four
concurrent 8k-token requests (scripts/bench_serving_concurrency.py --concurrency 4 --prompt-tokens 8192, --max-tokens small to isolate prefill).
Short-prompt concurrency (the #714 default motivation) is unaffected by
construction: at --prompt-tokens 512 --concurrency 4 all four rows cost
4 * 512 = 2048 padded tokens, half the default budget, so they batch with
headroom even when the chat template pushes each prompt slightly past 512; the
2 * head_len <= budget admission holds (2 * 512 <= 4096). TTFT and aggregate
throughput match the #714 table above (empirical re-run pending the same
session).
Decode state and the cross-request prompt-prefix cache share one refcounted,
copy-on-write block pool (the default for batch-capable pool-backed families;
--decode-storage-backend dense opts out). Concurrent requests that share a
prompt prefix store that prefix's KV once and skip re-prefilling it: adoption
is non-consuming (clone-and-pin), so one stored prefix serves any number of
simultaneous borrowers, and Automatic Prefix Caching (on by default, disable
with --apc-enabled=false) lets requests that diverge after a shared prefix
reuse the common part. The mechanism, the measured memory and prefill-token
savings, the decode throughput, and --kv-cache-budget are documented in
turbo-kv-cache.md. The paged backend
is covered by tests/paged_scheduler_parity.rs for the scheduler-shaped
qwen3/llama3 Fp16 dense-natural-cache path at B=1/B=2, and by a CUDA qwen3 arm
that requires greedy-token equality with bounded logit RMS when TF32 is
disabled. Do not generalize that evidence to VLM front ends, model-owned caches,
Turbo/quantized-KV modes, or all CUDA reduction geometries.
Since v0.4.4 a batched paged decode step runs as one fused attention launch
over the whole batch (issue #899) rather than as a per-sequence loop that
copied each sequence's visible KV out of the pool before attending. The kernel
reads the pool's scattered pages in place through a CSR page table and splits
the KV range across thread blocks, so its parallelism grows with context
instead of saturating. Measured against the previous path on an Apple M1 Ultra:
1.4x to 3.1x at batch 4 across 1K to 32K of context, and 1.3x to 1.5x for a
single sequence at 16K and 32K
(docs/benchmark_results/paged-decode-v2-m1ultra-2026-07-31.md).
What it does not serve falls back to the previous gather-then-SDPA path, per launch, under the same parity limits described above:
| case | why |
|---|---|
| a lone request under 4096 visible KV tokens | measured slower there (0.91x at batch 1 with 1K of context) |
| a multi-request launch under 512 visible tokens per request | below the measured evidence, and gather is trivially cheap at that size |
| a layer whose pool rows span more than one slab | the kernel reads one contiguous buffer per side |
| multi-token steps: batched prefill, speculative / MTP verify | the kernel serves a single query token per sequence |
| logit-softcap families, explicit attention masks | no soft-cap or mask term in the kernel |
| Int8 / Turbo KV modes | those sequences keep dense caches and are never pool-backed |
The floor is two-regime because the measured loss is a property of batch 1, not of total work: at 1024 tokens of context per request the kernel runs 0.91x at batch 1 but 1.41x at batch 4 and 1.47x at batch 8, since a batched launch spreads the same chunk count over more requests and amortizes the merge pass.
The server announces the first occurrence of each distinct dispatch outcome at
info, with no RUST_LOG needed:
INFO ...: paged decode v2: fused v2 launch (batch 4, 3828 visible KV tokens, 16 chunks, merge on)
INFO ...: paged decode v2: gather: the layer spans 3 K / 3 V pool slabs and the fused kernels read one buffer per side (slab_blocks=32); raise --ctx-size or MLXCEL_PAGED_SLAB_BLOCKS so a layer's rows fit one slab
INFO ...: paged decode v2: gather: 1024 visible KV tokens across 1 request(s) is below the 4096-token dispatch floor
INFO ...: paged decode v2: gather: pinned by MLXCEL_PAGED_ATTENTION_NATIVE
INFO ...: paged decode v2: fused v2 cascade launch (batch 4, 4 of them sharing 256 pages / 8192 KV tokens read once; 8 shared-span chunks, 4 suffix chunks)
A run that never prints fused v2 launch never used the fused kernel, whatever
its throughput looks like. Check for that line before drawing any conclusion
from a before/after comparison.
Two knobs, neither normally needed:
MLXCEL_PAGED_ATTENTION_NATIVE=0is the kill switch. It restores the pre-#899 gather behaviour end to end, for bisecting a suspected kernel regression.=1forces the fused path for every servable shape, bypassing the token floor; that is the supported way to benchmark the declined corner. The floors themselves move withMLXCEL_PAGED_V2_MIN_KV_TOKENS(lone request, default 4096) andMLXCEL_PAGED_V2_MIN_KV_TOKENS_PER_REQUEST(multi-request, default 512).MLXCEL_CASCADE_ATTENTION=1enables two-level cascade decode (issue #903): a whole-page prompt prefix shared by several sequences in the batch is attended once for the subgroup and merged into each member's per-request suffix state, instead of being re-read once per sequence. Off by default, pending a serving measurement. Its own outcome line says when it ran, and/metricscarriesmlxcel_paged_decode_launches_total{path="cascade"}alongside the flat and gather counts. See Cascade attention.MLXCEL_PAGED_SLAB_BLOCKSoverrides the pool's slab size in blocks. The server derives it from--ctx-sizeand--parallel, clamped by the KV budget, and logs the resolved value at startup (Paged KV slab size: N blocks per layer). A layer's first write allocates its whole slab, so this front-loads exactly the KV bytes the startup memory estimate already reports for that batch and context. Serving contexts longer than--ctx-sizeimplies makes layers outgrow the slab and silently returns them to the gather path; raise--ctx-sizeor set this variable if the startup line looks too small.0pins the historical 32-block default.
Recurrent and hybrid SSM / linear-attention families cannot safely reuse
arbitrary KV blocks, so they keep the hybrid-SSM/APC exclusion. Families that
opt into supports_snapshot_reuse() instead use a separate exact-prefix
snapshot bucket: on a healthy finish the scheduler copies the model-owned
state, and on the next turn it restores that state only when the stored token
vector is a whole prefix of the incoming request in the same session. The
unmatched suffix is still prefilled normally, with no recurrent state
truncation or cross-session sharing. The supported snapshot families are
Mamba, Mamba2, Jamba, Nemotron-H, Qwen 3.5 / 3.6 text, MoE, and VLM wrappers,
and Gemma 4 text, VLM, and Unified wrappers.
For multimodal servers, --enable-vlm-prefix-cache opts image requests into
prompt-prefix reuse across same-session follow-up turns with the same image.
The default stays off for VLM requests, and text-only prompt-cache behavior is
unchanged.
Supplying a drafter checkpoint (--draft-model on mlxcel serve,
--model-draft on mlxcel-server; the spellings are cross-aliased) turns on
server-side speculative decoding. The kind is auto-detected from the drafter's
config.json (--draft-kind mtp|dflash overrides), and the draft block size
K defaults per kind (4 for MTP, 16 for DFlash; --draft-block-size).
Whether the singleton (B=1) speculative burst actually runs is decided by an
adaptive policy (issues #333/#736), not by the flag alone. The first 4
qualifying requests of each (target, drafter, hardware, K) pairing are
profiled with speculation forced on; each profiled burst also runs 2
classic-step probe rounds (drafterless single-token verifies that emit real
greedy tokens), so the policy can compare the measured speculative round cost
against the measured classic step time and settle to Enable, Decline, or the
static per-hardware default when the estimate is ambiguous. The verdict is
persisted under ${MLXCEL_CACHE_DIR:-$HOME/.cache/mlxcel}/mtp-policy/ and
logged at settle time (adaptive MTP policy: settled verdict ... est_speedup=... [measured]), so a restart skips profiling. Changing K re-profiles. Because
the decision is measured, the same binary enables a pairing where the
speculative round pays for itself (Gemma 4 12B + assistant: 1.90x to 3.14x on
M5 Max depending on how predictable the output is,
~1.5x on GB10 with the multirow qmv verify) and declines it where it does not
(the same pairing on M1 Ultra, or on CUDA with MLXCEL_QMV_MULTIROW=0).
GET /v1/internal/mtp-policy reports the current pairing's state through a
versioned, supported interface: whether the verdict has settled and which way,
how many qualifying samples a profiling window still needs, and why no policy
is running when there is none. A host application should read it there rather
than parsing the hint files, which are a private on-disk format. See
Adaptive MTP policy API.
Scheduling: for non-batchable targets (the 12B Unified family) an enabled B=1
burst equals their inherent single-slot serialization. For batch-capable
targets, the B=1 MTP arm is served tick-cooperatively by default (issue #734):
the request runs one speculative round per scheduler tick, alternating with
the classic decode/prefill actions, so concurrent classic-decode rows advance
between rounds instead of stalling for the whole request. This bounds the
head-of-line stall a speculative request imposes on concurrent rows to about
one round; the scheduler logs it as the max single-tick burst_wall_ms at
finalize (on the validation pairing this dropped a multi-second whole-burst
stall to about one round of steady-state occupancy, roughly 23x faster
time-to-first-token for the concurrent classic rows). The interleaving is not
free: on the validation pairing the strict alternation cost roughly 27% of
the combined two-request aggregate decode throughput, so
MLXCEL_MTP_TICK_SLICE=0 restores the legacy run-to-completion burst for
deployments that serve speculative requests without concurrent classic traffic
and want raw speculative throughput. DFlash B=1 and every B>1 batched burst
still run to completion inside one tick regardless of the flag. Concurrent
speculative requests that share a prompt length, max_tokens, and sampling
config can instead run as one B>1 batched burst, but that path is experimental
and stays behind MLXCEL_ENABLE_MTP_BATCH (plus
MLXCEL_ENABLE_MTP_BATCH_RAGGED for mixed prompt lengths).
A tick-cooperative slice spans the request's whole generation, so the single
speculative slot would otherwise be held for that entire time. Since issue
#746 the slot rotates across concurrent speculative requests instead of
serving only the first one: while a slice is in flight, up to 2 further
tick-slice-eligible requests are parked in a grant backlog (any beyond that
cap fall back to classic decode, the pre-#746 behavior), and once the active
request has run MLXCEL_MTP_SLICE_GRANT_ROUNDS slices (default 8, slice 0
included) with the backlog non-empty, it is parked at the next round boundary
and the slot is granted to the next request, priority lane first and FIFO
within a lane, with an anti-starvation floor: an entry passed over by 2 grant
decisions becomes overdue and must be granted next regardless of lane, so a
sustained stream of higher-priority arrivals (priorities are client-assignable
via the X-Priority header) can delay a lower-priority request only by a
bounded number of grants, never stall its stream outright. The backlog cap
applies at admission; a park transiently holds cap + 1 backlog entries until
the next promotion, so total live speculative sessions (the active request
plus parked and waiting ones, each holding its per-sequence KV) peak at 3.
A parked request resumes from its saved session state on its
next grant, with the worker's one drafter handle handed over through the same
return/take plumbing the end of a request uses (safe because the MTP
assistant drafter's reset is a no-op and every round re-arms the drafter from
the session's own stored verify output), so the token streams are
byte-identical to uncontended runs. The budget binds only under contention:
a single speculative request never rotates and behaves exactly as under
#734. MLXCEL_MTP_SLICE_GRANT_ROUNDS=0 disables rotation and restores the
whole-generation hold, including the classic fallback for every concurrent
speculative request.
Operator controls, all env-level (see
environment-variables.md): MLXCEL_ENABLE_MTP_B1
pins the B=1 decision in either direction and suppresses profiling,
MLXCEL_MTP_ADAPTIVE=0 disables the adaptive policy in favor of the static
per-hardware gates, MLXCEL_MTP_SLICE_GRANT_ROUNDS sizes (or disables) the
speculative-slot grant rotation, and MLXCEL_QMV_MULTIROW=0 restores the
stock per-row CUDA quantized-matmul kernel under the verify.
On CUDA, the same draft/verify shape diversity (multiplied by batch size and
sequence-length bucket) is also what drives MLX's own captured-graph-cache
LRU hardest; mlxcel raises MLX_CUDA_GRAPH_CACHE_SIZE to 2000 at startup by
default so a long-lived server does not hit MLX's fatal Cache thrashing
abort at its own 400 default (issue #818, MLX_CUDA_GRAPH_CACHE_SIZE in
environment-variables.md).
Prefill is compute-bound and decode is memory-bound, so a deployment can run them
on separate processes (or machines) with a router in front. --node-role
selects the role:
prefill: runs prompt prefill, samples the first token, and hands the sequence's KV cache off to a decode node over TCP.decode: receives the KV handoff, continues autoregressive decode, and returns the continuation.router: a model-free HTTP front (it loads only a tokenizer and chat template, not model weights). It tokenizes the client request, routes it to a prefill node, and merges the prefill node's first token with the decode node's continuation into one SSE stream back to the client.- absent (hybrid): single-node serving, byte-identical to a server started with no distributed flags.
Request flow (topology A): client HTTP request -> router -> prefill node (TCP) -> decode node (TCP) -> router merges the two halves -> client SSE.
Networking flags:
| Flag | Role | Purpose |
|---|---|---|
--serving-bind <addr> |
prefill, decode, router | This node's own role-transport listener (host:port). |
--decode-peers <addr,...> |
prefill, router | On a router, the decode pool it balances; on a prefill node, the static fallback used only when the router does not pick a decode target for a request. |
--prefill-peers <addr,...> |
decode, router | On a router, the prefill pool it balances; on a decode node, the prefill node(s) it accepts handoffs from. |
With more than one prefill or decode node, the router balances both pools
(issue #201). For each request it picks a prefill node and, independently, a
decode node, both round-robin across the nodes the registry currently considers
online (the router has no live per-node load telemetry, so round-robin is the
strategy that actually spreads load). The chosen decode node travels to the
prefill node in the request frame's decode_target field, so the prefill node
hands the KV cache to the router-balanced decode node rather than to its own
--decode-peers config. The field is optional on the wire: a frame without it
(an older router) leaves the prefill node on its config fallback, and an older
prefill node ignores it and uses config, so mixed-version pools keep working.
As opt-in defense-in-depth (issue #389), a prefill node can validate the
router-chosen decode_target against an allowlist before connecting. The
allowlist source is the dedicated MLXCEL_DECODE_ALLOWLIST environment variable,
a comma-separated list of numeric IP:port values (parsed as SocketAddr) set to the full pool of router-selectable decode nodes (the shared cluster config); hostname:port entries are not resolved and are skipped with a warning. It is independent of --decode-peers,
which stays the static handoff fallback only, so enabling the allowlist does not
constrain router balancing. When MLXCEL_DECODE_ALLOWLIST is unset the prefill
stays permissive and logs a warning rather than rejecting, so balancing is never
silently broken; see docs/distributed.md for the security
rationale.
The router tracks node health and fails over without wedging:
- A transport error when sending a request to a prefill node marks that node unreachable, re-routes its in-flight requests, and retries the request on a healthy node. When no prefill node is left, the request fails cleanly with an error rather than hanging.
- A background health monitor TCP-probes every peer's serving address on an interval. A node that stops accepting connections is marked unreachable (so routing skips it, including a dead decode node the router never sends to directly), and a node that starts accepting again is restored to online.
- Admission control runs before every dispatch: when the prefill queue is full or no prefill node is available, the router returns HTTP 503 instead of attempting a dispatch that cannot succeed.
GET /router/stats reports the per-node dispatch counts for both pools, the
registered nodes with their current health status, and the routing-metrics
snapshot, so an operator can confirm the spread and see which nodes are down.
The handoff transfers the paged block contents (not just metadata) over the
transport, so the decode node reconstructs the exact KV the prefill node built;
see src/distributed/kv_cache_serde/. The disaggregated output is byte-identical
to a single-node run, verified end to end by tests/disaggregated_router_e2e.rs
(three real processes) and at the cache level by tests/paged_handoff_parity.rs.
Implementation entry points: src/server/router_front.rs (the router front),
src/distributed/disaggregated/{coordinator,handoff_impl,serving_protocol}.rs
(the role loops and wire protocol), and src/server/batch/ (the scheduler
serving-role entries).
# Decode node: receives KV handoffs on :8304.
mlxcel-server -m models/<ckpt> --port 8302 \
--parallel 2 --max-batch-size 2 --decode-storage-backend paged \
--node-role decode --serving-bind 127.0.0.1:8304
# Prefill node: prefills, hands off to the decode node.
mlxcel-server -m models/<ckpt> --port 8301 \
--parallel 2 --max-batch-size 2 --decode-storage-backend paged \
--node-role prefill --serving-bind 127.0.0.1:8305 \
--decode-peers 127.0.0.1:8304
# Router: model-free HTTP front on :8300.
mlxcel-server -m models/<ckpt> --port 8300 \
--node-role router --serving-bind 127.0.0.1:8306 \
--prefill-peers 127.0.0.1:8305 --decode-peers 127.0.0.1:8304
# Client talks only to the router.
curl http://127.0.0.1:8300/v1/chat/completions \
-H 'content-type: application/json' \
-d '{"model":"m","messages":[{"role":"user","content":"What is 2 + 2?"}],"stream":true,"temperature":0}'- Pool-backed Fp16 families only (the dense-natural backends such as qwen3 and llama3). Model-owned-state families and recurrent/hybrid SSM models are excluded from the paged handoff; the exact-prefix snapshot cache described above is a single-node prompt-cache optimization and is not serialized across disaggregated prefill/decode roles.
- Text-only. The router serves
POST /v1/chat/completionsandPOST /v1/completions; multimodal requests are rejected. - On
/v1/completions, three option groups are rejected with HTTP 400 because the disaggregated wire protocol does not carry the data they require:logprobs,response_format(structured output), and explicit reasoning/thinking budgets. Requests without those fields work normally. - A forced
tool_choice("required"or a named function, #1319) is accepted on/v1/chat/completionsrather than rejected the wayresponse_formatis: the router still narrows the declared tools and injects the same prompt instruction the single-node path uses, but the disaggregated wire protocol cannot carry a compiled grammar constraint to the decode node, so the call is enforced through the prompt only and is logged once atwarn. Combining a forcedtool_choicewithresponse_formaton the same request still 400s, matching single-node. stopsequences are not forwarded to the worker nodes by the router. This is a pre-existing limitation shared with the chat path.completion_tokensin the usage block uses the worker's authoritative generated-token count carried over the disaggregated wire protocol (issue #387), so it is exact for both byte-level BPE tokenizers (Qwen, Llama) and byte-fallback tokenizers (Gemma<0xXX>byte sequences), andfinish_reasonmatches single-node. A node that predates the wire field (a mixed-version cluster) falls back to counting emitted detokenized text pieces, which can under-count for byte-fallback tokenizers and flipfinish_reasonbetween"length"and"stop"./v1/chat/completionsreports the sameusageshape as/v1/completions(prompt_tokens,completion_tokens,total_tokens): the non-streaming response always carries it, and the streaming response sends it as a trailing chunk with emptychoiceswhen the request setsstream_options.include_usage, matching single-node (issue #398).- The router stream filter suppresses model-specific structural markers (
<think>, tool-call delimiters) and routes thinking content toreasoning_contentplus the default identicalreasoningalias, matching the single-node Chat Completions path;--reasoning-alias-field nonesuppresses the duplicate. Tool-call parsing is not yet supported on the router path: onlycontentand the configured reasoning fields are emitted. - Co-locating the roles on one machine adds transport hops without the scaling benefit of separate prefill and decode hardware, so a single-machine setup is for validation. The throughput case for disaggregation is separate prefill and decode pools under load.