Skip to content

cuda: opt-in exact concurrency, byte-identical output regardless of neighbours or parks - #194

Draft
danielhanchen wants to merge 47 commits into
feat/server-side-preemptionfrom
feat/exact-concurrency
Draft

cuda: opt-in exact concurrency, byte-identical output regardless of neighbours or parks#194
danielhanchen wants to merge 47 commits into
feat/server-side-preemptionfrom
feat/exact-concurrency

Conversation

@danielhanchen

@danielhanchen danielhanchen commented Sep 5, 2026

Copy link
Copy Markdown
Member

Stacked on #184; the commits after it are new. Opt-in, default off.

Summary

LLAMA_EXACT_CONCURRENCY=1 makes a sequence's generated tokens byte-identical whether it decodes alone, next to other sequences in one unified KV cache, or across a park and restore that moved its cells. Today's output under concurrency differs from a solo run of the same prompt even with no preemption, for two reasons this branch removes: kernels change algorithm with batch width, and a sequence's attention rounds differently depending on where its cells sit next to its neighbours.

Policy

  • GGML_CUDA_BATCH_INVARIANT: a CUDA row's result does not depend on how many other columns share the launch. The dispatcher pins the single-column configuration of MUL_MAT (MMVF against cuBLAS on F32, MMVQ, MMQ) and FLASH_ATTN_EXT (vector against MMA kernel, parallel_blocks), bounded by GGML_CUDA_BATCH_INVARIANT_MAX_COLS.
  • Page-aligned placement: the unified pool allocates 256-cell pages to one (sequence, position / 256) pair, so a page never holds two sequences and a cell's offset is its position modulo 256. A per-query I32 page table is passed to attention as source 5 and the vector kernel walks the sequence's pages in logical order, one query per block, no split-K combine. Pages come from anywhere in the pool, so sharing and park and restore are unchanged; a restore takes fresh pages. Page ownership is maintained as cells are assigned and freed rather than rebuilt per ubatch.
  • One sequence per prompt ubatch, in every memory type: a sequence's prompt tokens never share a ubatch with another sequence's prompt tokens, so its prefill matmul shapes equal the solo run's. Without this the gated delta net recurrent state diverges before the first attention layer. Plain decode stays batched, and decoders are not split apart when a prompt arrives beside them.
  • The column bound is derived, not guessed: exact mode bounds the policy at n_parallel * (1 + n_draft) (16 for the default four slots with MTP drafts), so a speculative verify batch is covered by construction. An explicit GGML_CUDA_BATCH_INVARIANT_MAX_COLS below the derived bound is refused at start rather than silently narrowing the guarantee.
  • Mixture of experts: under the knob every MUL_MAT_ID token is computed with the single-token configuration, in one launch that puts the tokens on the MMVQ kernel's sample axis and reads the expert index per sample, so each (token, expert slot) block runs the single-token instructions on the same data. The fused top-k routing kernel is left unfused, since it is admitted only at one row and would otherwise route a solo decode with a different algorithm than a concurrent one.
  • The column bound is reported by every context at creation (n_seq_max times the per-sequence width, never lowered), so a program using the library directly is covered without knowing there is a bound; a caller building wider steps reports the width itself through llama_set_exact_decode_width(). What a context reports is its sequence count; the width is recomputed from the widest count seen whenever the per-sequence token figure changes, so a context created under a narrower figure keeps a bound that covers it. Both setters return false and change nothing when an explicit GGML_CUDA_BATCH_INVARIANT_MAX_COLS cannot cover the width, since that bound wins in the backend; the context constructor and the server setup treat that as an error. Reports are serialised and the backend keeps the widest figure whatever order they arrive in; the per-sequence token figure is never lowered either, so a narrower context set up later cannot turn an existing context's verify steps into prompts. The three figures change under one lock, and the setup runs at the front of the init result's constructor, before any context exists.
  • What the mode cannot do, it refuses by name instead of computing something inexact: placement is enabled on CUDA only; any attention head width other than 256, and V-less layouts, refuse the mode at load with the layer and widths named; a token carrying several sequence ids fails the batch instead of reaching the placement; cross-sequence copies, shared cells, context shift, position division and whole-context state loading log an error and are ignored, and a hybrid memory refuses before touching either half; n > 1 completions are refused with a 400 rather than aborting in the cache. A context created non-causal over a KV cache is refused at creation rather than asserting on its first graph. A model that soft-caps its attention logits is refused at load too, since the paged kernel has no soft-capped variant.
  • Recurrent and hybrid memories keep the equal-count grouping of split_equal on whenever the mode is on, not only while a prompt is in the batch: a three-token verify step placed beside a two-token one as two now and one later would be reduced in chunks the solo run never had. The plain KV cache keeps prompt-only isolation, since its kernels are column invariant. An isolated ubatch takes only sets that finish in it, so the expansion never cuts them part way either.
  • Prompts are isolated by width, not by having more than one token: llama_set_exact_decode_tokens() carries one plus the draft length, so a speculative verify batch stays grouped with the other slots' and is not run once per sequence, while a prompt still gets a ubatch of its own.
  • llama_memory_alloc_granularity() tells the server how many cells one allocation takes (256 under exact mode, 1 otherwise), so the preemption planner from server: preempt a slot instead of ending every conversation when the KV pool fills #184 counts pages instead of tokens when deciding whether a sequence fits. The planner charges every slot's tail page in full, reserves a page only when a step crosses a page boundary, and rounds the margin; at a granularity of 1 every figure is the parent's, held by static_assert. Without it the planner saw room in tokens that find_slot could not find in pages, never parked anybody, and the retry ladder ended every request with Context size has been exceeded.

Results

The shape that used to fail, four chats of 937 prompt plus 2048 generated tokens at -c 8192 with MTP drafts, three rounds each, on one binary with the planner switched between token and page arithmetic through the test knob:

planner in tokens planner in pages exact mode off
completions 0 of 4, 0 of 4, 1 of 4 4 of 4 in every round 4 of 4 in every round
parks / restores none 3 / 3, 4 / 4, 4 / 4 3 / 3, 2 / 2, 2 / 2
Context size has been exceeded 12 0 0
P0 byte-identical to its solo run no result yes, yes, yes no, no, no

Qwen3.5-4B, --parallel 4 --kv-unified -c 8192 --flash-attn on, seed 0, temperature 0, top_k 1, 512 generated tokens, P0 against its own solo run, three rounds per cell, independently rebuilt and reproduced:

speculation forced parks every 64 tokens rounds
off no identical, identical, identical
off yes, 98 parks and 98 restores identical, identical, identical
draft-mtp, n-max 2 no identical, identical, identical
draft-mtp, n-max 2 yes, 98 parks and 98 restores identical, identical, identical
eight requests through four slots, P0 fifth and sixth no identical in every round
-c 16384 no identical, identical, identical

Mode off on the same binary diverges at token 118, so the harness sees. The same three cells (spec off, spec off with 98 parks, MTP) were re-run after the last commit against the strictest reference, an unparked non-speculative solo run, and read identical in every round.

Qwen3.6-35B-A3B (mixture of experts, Unsloth dynamic quant), same settings, three rounds per cell:

speculation forced parks every 64 tokens rounds
off no identical, identical, identical
off yes, 98 parks and 98 restores identical, identical, identical
draft-mtp, n-max 2 no identical, identical, identical

The three solo references are byte-identical to each other, and 512 lockstep decode steps of one sequence alone against inside a four-wide batch never differ in a single logit, on either model. At the library level a probe over all 1831 graph nodes of a decode step finds 0 differing for a sequence alone against inside a four-sequence batch, and 0 for a parked and restored sequence.

Exactness

Every solo reference across spec off, MTP, forced parks and 16384 context is byte-identical to every other, and every concurrent run matches them. Exact mode's output differs from mode-off output at one near-tied greedy step, as any bit-level change to a canonical computation would; the claim is neighbour independence, not equality with the unpinned kernels.

Cost when it does not fire

None. Off by default: no page tables, no reserved pages, no extra kernels, no ubatch restriction. The op tests and the four-chat and solo rates with the mode off reproduce the parent commit's.

Cost when it fires

Three interleaved pairs per row on one GPU, medians, ratios to the mode off:

workload exact mode on
solo decode about 9 percent slower on the dense 4B (the paged vector attention); the 35B solo figure of 15.6 percent predates the one-launch expert path and was not re-measured
solo prompt processing 1.03x at 1000 prompt tokens, 1.05x at 2000, 1.17x at 3500
four chats, 937-token prompts plus 1536 generated at -c 8192, pool full and preemption running aggregate decode 0.915 of off (137.9 against 150.8 tok/s)
the same four chats at -c 65536, no preemption 0.901
four chats, 937-token prompts plus 2048 generated at -c 8192, speculation off, three interleaved pairs 0.91 (135.6 against 149.2 tok/s), every run 4 of 4 with parks and zero context errors
the same with two MTP drafts 0.74 (108.1 against 146.5 tok/s); before c6c3cb6 the splitter isolated each slot's verify batch as a prompt and it was 0.43
35B four-chat aggregate decode at the mode-off rate within the noise of a shared GPU, with and without two MTP drafts (411 to 498 against 401 to 480 tok/s; 341 to 375 against 197 to 361 with drafts); the earlier 18.3 percent was the per-token re-entry of the expert projections

Of the 8.5 percent four-chat loss on the 4B, the kernel policy on its own (GGML_CUDA_BATCH_INVARIANT=2 with no paged pool) costs more than the whole mode, 0.791 of off, because the paged pool lets attention use the single-column kernels without the split-K combine.

Prompt serialisation, the one structural cost of the ubatch rule, was measured directly. Four prompts of P tokens arriving at once on the 4B, default n_batch 2048 / n_ubatch 512:

P ubatches off / on max time to first token on/off aggregate prefill on/off
128 1 / 4 1.43x 0.70x
300 3 / 4 1.14x 0.87x
512 4 / 4 0.75x 1.34x
1000 8 / 8 0.86x 1.16x
2000 16 / 16 0.89x 1.13x
3500 28 / 28 0.96x 1.04x

The cost is per extra ubatch, not per prompt token: a prompt of at least n_ubatch tokens fills a ubatch by itself, so above that the two modes need the same ubatch count and exact mode is not slower to first token. With three chats mid-decode and a 3500-token prompt arriving, the decoders emit the same number of tokens during the prefill in both modes, so decoders are not split apart; the stall is longer only by the prefill's own slowdown.

Tests

tools/server/tests/unit/test_preempt.py 14 passed, including the page-planner case and two prompts within a page of the pool size. test-backend-ops -b CUDA0 -o MUL_MAT_ID,MUL_MAT,FLASH_ATTN_EXT,SET_ROWS 5234 of 5234 with the mode on and off; MUL_MAT_ID alone 919 of 919 with the knob on and off, including new gate, up and down cases over Q4_K, Q5_K, Q6_K, Q8_0 and F16 at 1 to 17 tokens. New op cases: nonadjacent pages in reversed order, partial tail, query widths 1, 4 and 12; shared-weight projections F32, Q4_K, Q6_K, Q8_0 at widths 1, 17 and 307 with sequence axes 2 and 3. Server-level matrix above. The evidence harness refuses to certify a run that completed fewer rounds than requested.

Limitations

  • CUDA, 256-dimensional K and V heads, causal flash attention, no soft cap, no sliding window. Other backends and multimodal inputs are not validated; every other backend that implements FLASH_ATTN_EXT (CANN, Metal, OpenCL, SYCL, Vulkan, WebGPU, ExecuTorch, Hexagon, OpenVINO, RPC) refuses the op when the page table is present, and those rejections are compile-time only here.
  • Cross-sequence prefix copies, tokens shared by several sequence ids, context shift, position division, whole-context state loading and n > 1 are refused in exact mode, and a refusal leaves the cache as it was: a whole-context restore is refused before a byte is read, so llama_state_set_data returns 0 with every sequence intact. Per-sequence park and restore is supported and tested.
  • Each active sequence tail can hold up to 255 unused cells, so a pool holds fewer resident sequences than the token count suggests; the planner accounts for this in pages. The planner charges every slot's tail page in full, reserves a page only when a step crosses a page boundary, and rounds the margin; at a granularity of 1 every figure is the parent's, held by static_assert. Without it the planner saw room in tokens that find_slot could not find in pages, never parked anybody, and the retry ladder ended every request with Context size has been exceeded.
  • The head-width refusal is exercised: the 8-wide test model fails to load under the mode with the widths in the message. The V-less refusal is a code-path argument; no model on this host uses that layout.
  • Bursts of prompts shorter than n_ubatch pay up to 1.4x on time to first token at four chats; the crossover moves with -ub.
  • Under the knob only quantized expert matrices take the one-launch path; other expert types are still recomputed one token at a time. The sorted per-expert fallback is untested here, since neither model reaches it.
  • Two models (one dense hybrid, one mixture of experts), one GPU, four and eight sequences, two context sizes measured.

Follow-up commits after review

A whole-context restore under the mode was refused inside the cache's metadata reader, after which the generic restore path cleared the live cache; it is refused at the top of the context's and the cache's readers now, before anything is read, and a probe on the 4B shows llama_state_set_data returning 0 with the sequence still at its position and decoding afterwards. The context reported its sequence count at the front of its constructor, so a construction that failed later (an unsupported cache layout, say) left a width behind that no context needed; the count is checked against the explicit bound early and reported at the end. DSpark runs on the DFlash implementation and turns causal attention off on its draft, so it is refused alongside DFlash. The decode width is computed in 64 bits and refused above INT32_MAX. The five backends above that still claimed the paged op refuse it. The probe's PROBE_A_PERM keeps prompt 0 on sequence 0, which its second phase compares against. One more from the next pass: the cache accepted a layer on any CUDA, ROCm or MUSA device by the registry name alone, and a build without the flash attention kernels, or a device and head shape they do not cover, would have had the scheduler hand the paged op to the CPU, which accepts the page table as the reference for the backend test and ignores it. The constructor now builds the attention op the way the graph does, page table attached, at the widths of a decode step, a verify step and a prompt chunk, and asks the device; a refusal is a load error naming the layer and the K/V types.

…atch

The number of tokens in a batch selects the matmul implementation, the flash
attention kernel, and inside several of them how the K loop or the KV cache is
divided between threads and blocks. All of those change the order in which the
partial products of one destination element are summed, so a request decoding
next to three others produces different bits than the same request decoding
alone, even at temperature 0.

GGML_CUDA_BATCH_INVARIANT=1 computes every destination column, and attends every
query row, with the configuration a batch of one would use. =2 does the same but
only where the batch-of-one configuration actually differs, which leaves the
quantized projections batched because MMVQ already uses the same nwarps for one
to four columns.

Flash attention additionally pins the vector kernel, pins the split over the KV
cache to one block per tile, and scans the mask for the sequence's own extent,
so neither the query count nor the length of a shared KV cache selects the
algorithm.

Measured on a B200 with Qwen3.5-4B-UD-Q4_K_XL: with the KV cache state held
equal, the 1831 node decode graph goes from 1190 nodes whose sequence-0 row
differs between a one token and a four token ubatch to 0, and 256 greedy decode
steps that diverged at step 125 become identical.
…MAX_COLS

Splitting a prompt-sized batch costs far more than splitting a decode-sized one:
a 273 token prefill becomes 273 single column matmuls and 273 single row
attention launches per layer, which took prompt processing from 2731 to 225
tok/s on a B200 while four-chat decode only lost 7 percent.

GGML_CUDA_BATCH_INVARIANT_MAX_COLS caps the width the split applies to, 0 keeps
the previous unbounded behaviour. At 8 it covers every decode batch the server
can form and leaves prefill alone, which restores prompt processing to 2696
tok/s and four-chat wall throughput to 127.2 against 136.4 unpatched.

The bound gives up invariance for the prompt phase, so it is opt-in rather than
the default.
…ENCY

The recurrent half of a hybrid model is not invariant to the shape of the ubatch. With the
attention half made exact, a prompt processed in ubatches it shares with other sequences'
prompt tokens still leaves a different gated delta net state than the same prompt processed
alone: the first node to show it is the layer 1 recurrent state, 2.2e5 of 5.2e5 elements,
max 7.3e-4, and over a 512 token generation it flips a token at step 79.

split_equal grows an optional cap on the number of sequence sets per ubatch. The hybrid
memory passes 1 when the mode is on and some sequence contributes more than one token to
the batch, which is the prompt phase. A plain decode step, one token per sequence, is
already exact under the gather and stays batched, so the cost falls on prompt processing
only.
@danielhanchen

Copy link
Copy Markdown
Member Author

Status on a mixture-of-experts model, Qwen3.6-35B-A3B, same harness, same settings, seed 0, temperature 0, 512 tokens.

The mode does not hold there yet. Mode on diverges from the solo run at token 54 in every round, with and without 98 forced parks (mode off diverges at 47). The node probe over one decode step, sequence 0 alone against with three neighbours, is byte-identical for nodes 0 to 3249, which covers every layer through 33 and layer 34's routing, gate and up projections, and first differs at node 3250, ffn_moe_down-34, a MUL_MAT_ID, by one ulp of f32. The unbounded column policy gives the same node, so the cause is routing, not the bound: the per-expert matmul's column count is the number of tokens routed to that expert in the ubatch, which depends on the neighbours.

Cost on that model is the same as on the 4B, about 9 percent solo decode, within 1 percent on prompt, aggregate and wall.

A follow-up commit extending the batch-invariant policy to MUL_MAT_ID is in progress; until it lands the mode is exact for dense and hybrid recurrent models only, and this note will be folded into the Limitations.

A mixture-of-experts decode groups the ubatch's tokens by the expert they routed
to, so the column count of an expert's matmul, the rows the per-expert copy
gathers and the width the activations are quantized at all depend on what the
other tokens in the ubatch picked. The quantized path makes it visible: at one
token per ubatch MUL_MAT_ID runs mul_mat_vec_q at ncols_dst 1, four warps
dividing the K loop and a shared memory reduction across them, and at more than
one token it runs the dedicated MoE kernel, one warp per token with a warp only
reduction. Whether those two agree bit for bit depends on the quantization type
and on K. Measured on a B200 they agree for Q4_K and Q5_K up to K 2048 and
disagree for Q6_K and Q8_0 from K 512, which is why on
Qwen3.6-35B-A3B-UD-Q4_K_XL the Q4_K gate and up projections of every layer
matched and the three Q6_K down projections, layers 34, 38 and 39, did not.

Under GGML_CUDA_BATCH_INVARIANT the node is now computed one token at a time.
Each call then sees the shapes a batch of one has, whatever the neighbours routed
to, which covers the ids variants of MMVQ, MMQ and MMF and the sorted per expert
fallback with one change. GGML_CUDA_BATCH_INVARIANT_MAX_COLS bounds it the way it
bounds the MUL_MAT column split. The CUDA graph fallback check is evaluated
against the single-token path as well, since that is what a split node runs.

On the 35B the sequence-0 slice of one decode step goes from 195 of 3727 nodes
differing between a one token and a four token ubatch to 0, and a standalone
MUL_MAT_ID probe over Q4_K, Q5_K, Q6_K, Q8_0, Q4_0, Q3_K, Q2_K, MXFP4, F16 and
BF16 at K 512, 2048 and 4096 goes to 0 at every token count up to 8.
ggml_cuda_check_fusion_memory_ranges accepts the top-k MoE subgraph through an
explicit ggml_nrows(node) == 1 exception, which skips the aliasing test on the
grounds that each row is read entirely before it is written. With more than one
token the generic overlap test runs instead and refuses. So a request decoding
alone computes its routing weights with the fused warp local top-k kernel and the
same request decoding next to three others computes them with the softmax,
argsort, get_rows, sum, clamp and divide chain. Two algorithms for one set of
routing weights is exactly the batch dependence this knob removes, and the same
reason mul_mat plus GLU fusion is already off here.

A node probe cannot see this, which is worth recording: registering an eval
callback disables fusion, so both sides of the comparison take the unfused chain
and agree. It only appears when the two are compared without one. On
Qwen3.6-35B-A3B, with every node of one decode step already byte-identical,
sequence 0's logits differed in 248319 of 248320 entries from the first decode
step, by up to 2.6e-1, and the greedy stream flipped a token at step 47. The dense
4B is unaffected either way, and disabling every CUDA fusion removes it, which is
what named the fused op.

With the routing left unfused under the knob, 512 decode steps of sequence 0
alone against sequence 0 next to three neighbours are byte-identical on the 35B,
and every cell of the server matrix reads identical.
@danielhanchen

Copy link
Copy Markdown
Member Author

Two more commits and the mode now holds on the mixture-of-experts model as well. Qwen3.6-35B-A3B, same harness, seed 0, temperature 0, 512 tokens, P0 against its solo run, three rounds per cell: speculation off identical, speculation off with 98 forced parks identical, draft-mtp with two drafts identical, and the three solo references are byte-identical to each other. At the library level 512 greedy decode steps of sequence 0 alone against inside a four-wide batch never differ in a single logit, on the 35B and on the 4B.

What the earlier divergence was, corrected. The three differing MUL_MAT_ID nodes were exactly the three Q6_K down projections of this dynamic quant (layers 34, 38, 39); every Q4_K and Q5_K expert matmul already agreed. At one token the quantized MoE path runs the four-warp mul_mat_vec_q kernel and at more than one token a dedicated one-warp-per-token MoE kernel, and whether the two reduce in the same order depends on the quantization type and K. A standalone probe over ten types and three K values shows which cells agree. It was not routing collisions: with no neighbour routed to any expert the token uses, the Q6_K shape still differs by the same amount.

4814a2685 gives every MUL_MAT_ID token the single-token configuration under the knob, by re-entering the op one token at a time, bounded by MAX_COLS like the MUL_MAT split. New op cases cover gate, up and down shapes over Q4_K, Q5_K, Q6_K, Q8_0 and F16 at 1 to 17 tokens.

A second defect only became visible once that was fixed, and no node probe could have found it: registering an eval callback disables CUDA op fusion, so both sides of every probe took the unfused path. The fused top-k routing kernel is only admitted at one row (its memory-range exception is sound only there), so a request decoding alone computed its routing weights with the fused kernel and the same request next to three others computed them with the unfused chain. 7b0d7edb9 leaves the top-k routing unfused under the knob, the same treatment the branch already gives the mul_mat plus GLU fusion.

Cost on the 35B, five interleaved pairs on an uncontended GPU: solo decode 198 to 167 tok/s (15.6 percent, of which about two thirds is the unfused routing, which also applies to a solo decode), four-chat aggregate 440 to 360 (18.3 percent, the four-way split of 123 expert matmuls per step), prompt within 3 percent. On the dense 4B nothing changed. test-backend-ops 919 of 919 for MUL_MAT_ID on and off, 5234 of 5234 across the four touched ops on and off.

Limitations added: MAX_COLS=8 does not cover a speculative verify batch of up to 12 tokens by construction (the MTP cell read identical anyway, an observation rather than a guarantee; MAX_COLS=16 would cover it), and the sorted per-expert fallback is now entered per token and untested here because this model never reaches it.

…y batch

Exact mode still defaults the column policy to unbounded, so every measurement
that recovered the prefill cost had to set GGML_CUDA_BATCH_INVARIANT_MAX_COLS by
hand, and the value everything was measured at, 8, does not cover a speculative
verify ubatch by construction: with --parallel 4 and --spec-type draft-mtp
--spec-draft-n-max 2 a verify ubatch holds one accepted token plus two drafts per
slot, up to 12 columns, and above the bound neither the MUL_MAT column split nor
the MUL_MAT_ID per token split fires.

Default the bound to 16 in exact mode instead, which covers four slots at up to
three tokens each. An explicitly set GGML_CUDA_BATCH_INVARIANT_MAX_COLS still
wins, in either mode, so a deployment with more slots or a wider draft can raise
it.

Measured on one B200, Qwen3.6-35B-A3B-UD-Q4_K_XL and Qwen3.5-4B-UD-Q4_K_XL,
--parallel 4 --kv-unified -c 8192 --flash-attn on -ngl 99 --seed 0, greedy
sampling, 512 predicted tokens, P0 solo twice then P0 concurrent with P1 to P3,
three rounds per cell. Every P0 completion was byte identical to its solo
reference and every solo repeat matched:

  35B, draft-mtp n-max 2, MAX_COLS=16                       identical x3
  35B, draft-mtp n-max 2, MAX_COLS=16, PREEMPT_EVERY=64     identical x3, 98/98 parks
  35B, draft-mtp n-max 4, MAX_COLS=8  (verify up to 20)     identical x3
  35B, draft-mtp n-max 4, MAX_COLS=32                       identical x3
  4B,  draft-mtp n-max 2, MAX_COLS=16, PREEMPT_EVERY=64     identical x3, 98/98 parks
  35B, draft-mtp n-max 2, new default, no env var           identical x3
  35B, spec off, new default, no env var                    identical x3

The 35B MTP cell at 16 reproduces the acceptance counters of the same cell at 8
exactly, 4091 of 6103 draft tokens, so raising the bound does not perturb the
generation. Cost, three interleaved MAX_COLS=8 against MAX_COLS=16 pairs on the
35B with speculation on, medians: solo decode 25.42 against 25.37 tok/s, four
chat aggregate decode 54.55 against 54.23 tok/s, four chat wall 19.98 against
20.15 s. Four interleaved pairs with speculation off, where a decode ubatch is
four columns wide and both bounds must behave identically, medians 31.39 against
31.40 tok/s solo and 111.41 against 111.41 tok/s aggregate. All within the run to
run spread.

test-backend-ops test -b CUDA0 -o MUL_MAT_ID,MUL_MAT under
LLAMA_EXACT_CONCURRENCY=1 GGML_CUDA_BATCH_INVARIANT=2 MAX_COLS=16:
2136/2136 passed, 2/2 backends.
@danielhanchen

Copy link
Copy Markdown
Member Author

One more commit, 65860ea38: exact mode now defaults the column bound to 16 instead of unbounded, which covers four slots with up to three tokens each (a speculative verify batch with two drafts). An explicit GGML_CUDA_BATCH_INVARIANT_MAX_COLS still wins in either mode.

Measured on the 35B, mode on, P0 against its solo run, three rounds per cell: draft-mtp with two drafts identical at 16, the same with 98 forced parks identical, and as a stress a four-draft verify batch of up to 20 columns identical both at a bound of 8 (left unsplit) and at 32 (split). On the 4B the forced-park MTP cell is identical at 16. Cost of 16 against 8, interleaved pairs: within 0.6 percent on every metric with speculation on and flat with it off, inside the run-to-run spread. test-backend-ops for MUL_MAT_ID and MUL_MAT at the new bound: 2136 of 2136.

The default is not self-adjusting: more than four slots, or more than two drafts, exceeds 16 and the bound has to be raised by hand, since the CUDA backend has no view of --parallel or --spec-draft-n-max. Both stress cells were identical either way, so no shape measured here showed the verify batch to matter; the commit closes the hole in the argument rather than an observed divergence.

The mode was gated on unified && offload && !v_trans && n_swa == 0 and never on
where the attention layers actually run. Only the CUDA FLASH_ATTN_EXT reads
src[5]; the CPU, Metal, Vulkan, SYCL, OpenCL and CANN kernels ignore it. So with
-ngl 0, a partial -ngl, or a non-CUDA GPU the pool was still paged and the page
table was still attached, but attention traversed physical cell order: the output
stayed correct and neighbour and relocation independence were silently lost while
the mode reported itself as on.

Check the placement where the cache is built instead. Every KV layer must be
offloaded and its device must belong to the CUDA family backend, which is also
built as ROCm and MUSA and carries the same paged kernel; anything else fails the
load naming the layer and the backend it landed on. As a second line, the
FLASH_ATTN_EXT of every backend that would ignore the page table now refuses an
op with src[5] set, so a scheduler decision made after the load cannot route it
somewhere that walks the pool in physical order. The CPU is deliberately left
accepting it and says so in place: it is the reference test-backend-ops compares
the paged CUDA kernel against, and that test builds a mask which selects exactly
the listed cells.

The four remaining preconditions were one bare GGML_ASSERT each, so a quantized
KV cache, an SWA model, a transposed V cache or a -c that is not a multiple of
256 aborted at model load without naming which one failed. Each now logs what it
needs and which flag sets it, and the load returns an error the way every other
KV cache failure does. The context size check also moved after the shared-source
override, so it tests the size the cache is actually built at.

  -ngl 10 with LLAMA_EXACT_CONCURRENCY=1:
  llama_kv_cache: LLAMA_EXACT_CONCURRENCY is set but layer 0 keeps its KV cache
  on CPU, which has no paged attention: every layer must be offloaded to the CUDA
  backend (pass -ngl to offload all layers and do not pass --no-kv-offload)
get_can_shift() still returned true under exact_pages, so --context-shift and
--cache-reuse N passed every startup capability gate and then aborted the whole
process on the first seq_add with a nonzero shift. Both are opt-in flags, so the
default was safe, but llama-server accepted them silently and died on the first
request that needed them. The server already disables both at load for a cache
that cannot shift, with a warning, so returning false there reuses that path:

  srv    load_model: ctx_shift is not supported by this context, it will be disabled

The remaining aborts are reachable the same way, from one request parameter or
one API call, and abort() is not an acceptable answer to either in a network
server. seq_cp between two different sequences, a nonzero seq_add and a seq_div
now log which transformation was refused and on which sequence and return without
touching the cells, and the whole-context branch of state_read_meta() logs and
returns false the way the two failure paths next to it already do, so
llama_state_load_file() reports a recoverable error through an API that is
designed for one instead of killing the process.

The page invariant is protected exactly as before: none of these paths can now
run and leave a cell outside the page its position belongs to.
Two things were wrong with the one-sequence prompt ubatch rule.

It only existed in the hybrid memory. A dense transformer with a unified cache
takes split_simple, which packs every sequence's prompt tokens into one ubatch,
so its prefill matmuls ran at a width the solo run never sees and, with the
column policy bounded, produced K and V the solo run never produces. Exact mode
was therefore not exact by construction on dense models, which is most of what it
will be pointed at. A pure recurrent model still called the three-argument
split_equal for the same reason the hybrid one no longer does. Both now take the
rule: llama_kv_cache::init_batch keeps split_simple for a plain decode step and
switches to the sequence-set split when a prompt is present, and
llama_memory_recurrent::init_batch passes the flag through.

The rule itself then serialized more than it had to. has_multi_token_seq()
scanned the whole original batch and ignored used[], so it stayed true after the
prompt had been consumed, and the n_seqs_max cap it fed capped every sequence set
including one-token decode sets. One prompt chunk plus three decodes therefore
became four single-sequence ubatches and the three chats decoded one at a time
for the whole prefill, which contradicts the comment saying a plain decode step
stays batched. The predicate now skips used[] tokens, and the cap became an
isolate_multi_token_seqs flag: a sequence set with more than one token left to
place takes a ubatch of its own, sets with one token left keep grouping. One
prompt next to three decodes now costs one extra ubatch, not three.

The KV cache constructor also read getenv("LLAMA_EXACT_CONCURRENCY") directly
while llama_exact_concurrency() and ggml_cuda_exact_concurrency() each cache the
first value they see, so a process that created one context with the knob unset
and then set it got a paged cache on top of a dispatcher still in default mode.
It now reads the same cached value as the other two.
The fixed default of 16 silently turns exact mode off above 16 columns: MUL_MAT,
MUL_MAT_ID and the per-row attention split all fall back to the neighbour
dependent batched path. --parallel 6 --spec-draft-n-max 2 gives 18 columns and
--parallel 8 gives 24; both are ordinary server configurations and neither said
anything. The source comment documented the cliff, nothing at runtime did.

The bound only ever had to cover the widest ubatch a decode step can build, since
a prompt ubatch holds one sequence and gets its exactness from that. So let the
caller report that width. ggml_backend_cuda_set_exact_decode_width(), reachable
directly or through ggml_backend_reg_get_proc_address(), takes one column per slot
times one plus the draft length, and exact mode defaults the bound to it. common
computes it from n_parallel and the speculative type and reports it before the
warmup, which is the first graph any of these tools computes, and refuses at
startup an explicitly set GGML_CUDA_BATCH_INVARIANT_MAX_COLS that is smaller:

  GGML_CUDA_BATCH_INVARIANT_MAX_COLS is 8 but LLAMA_EXACT_CONCURRENCY needs at
  least 12 to cover a decode step of 4 slots, above which a matmul is left batched
  and its rows depend on the other rows in the ubatch. Raise it to 12, set it to 0
  for no bound, or unset it to let it default to 12.

--parallel 4 with speculation off now defaults to 4 rather than 16 and with
--spec-type draft-mtp --spec-draft-n-max 2 to 12 rather than 16, which is the same
guarantee over a narrower range of shapes: a decode ubatch of that model cannot be
wider than that, and everything above it is a prompt.

When nothing reported a width, the default stays 16 and the dispatcher warns once
per process the first time a MUL_MAT or MUL_MAT_ID above the bound is left
unsplit, naming both numbers. It deliberately stays quiet once a width is known,
because then the only batches above the bound are prompt ubatches and warning on
those would fire on every prefill for a case that is working as intended.
Page tables are wired into llm_graph_input_attn_kv only. llm_graph_input_attn_k
has no self_pages member and its build_attn calls build_attn_mha without a pages
argument, as do the DeepSeek sparse and sliding window variants. A model on one of
those layouts still got its cells placed in pages by the allocator and then
attended in physical order after a park and a restore, so the mode reported itself
as on and lost the one invariant it exists for. That is the same silent failure
the CUDA placement gate was added to stop, so answer it the same way: log which
layout it is and fail the context.

Rejecting is the smaller correct change of the two. Wiring self_pages into
llm_graph_input_attn_k is four lines and looks tempting, but it fixes one of the
four V-less input classes and DeepSeek 3.2 uses two of them: its sparse layers
build their mask from a top-k selection and would stay unpaged, leaving the model
half paged, which is worse than refused. None of these architectures was measured
here, and the paged kernel also requires 256-dimensional K and V heads, which none
of them was checked against.

Reaches the user through the path llama_init_from_model already has for a context
that cannot be built:

  llm_graph_reject_exact_concurrency: LLAMA_EXACT_CONCURRENCY is set, but this
  model uses the V-less KV (attn_k) attention layout, which carries no page table
  and would attend in physical cell order
  llama_init_from_model: failed to initialize the context: exact concurrency:
  unsupported attention layout
run_concurrent lost worker exceptions. A Python thread exception only prints a
traceback, join() returns, and the partial dict was returned, so a round where
P1 to P3 failed and P0 succeeded still classified P0 as identical and aggregated
throughput over whichever requests happened to survive. The evidence harness could
certify a solo run as a clean four-way concurrency result. Exceptions are now
collected under a lock and raised after join, the barrier is aborted so the other
workers do not block on one that will never fill, and every expected name has to
be present before the result is returned. bench.py imports the same helper, so its
throughput number is covered too.

Server.__enter__ raised after Popen had already started the server, and Python
does not call __exit__ when __enter__ raises, so a server that started but never
reported healthy kept the GPU, the port and the log handle. The health wait is now
wrapped and tears the server down before re-raising. __exit__ also waits after the
SIGKILL path instead of leaving a zombie, and says in place that it is POSIX only.

The run record and the server log header captured only variables starting with
GGML plus CUDA_VISIBLE_DEVICES, so an inherited LLAMA_EXACT_CONCURRENCY was
invisible in both and a run intended as the mode-off reference could silently have
been an exact-mode run while the JSON said "env": {}. That is the baseline the
whole divergence claim rests on. Both now record the environment the server
actually inherited, from an explicit allowlist that includes
LLAMA_EXACT_CONCURRENCY, GGML_CUDA_BATCH_INVARIANT,
GGML_CUDA_BATCH_INVARIANT_MAX_COLS, LLAMA_SERVER_PREEMPT_EVERY and
CUDA_VISIBLE_DEVICES, along with the resolved model path and the full server
command line. What the run asked for is kept separately as env_requested.

UNSLOTH_WORKSPACE was read at import, so both tools raised KeyError before argparse
ran and even --help failed. The model path is resolved when the server arguments
are built and raises a named RuntimeError.

The README still said the mode forces GGML_CUDA_BATCH_INVARIANT=2 with no column
limit including during prefill, which stopped being true two commits before this
branch. It now states the bound, where its default comes from, that an explicit
value below the decode width is refused at startup, and the load-time refusals for
placement and the V-less layouts.
danielhanchen added a commit to danielhanchen/unsloth-staging-2 that referenced this pull request Sep 6, 2026
…ve to

A llama-server built with LLAMA_EXACT_CONCURRENCY set (unslothai/llama.cpp#194)
gives a chat the same generated tokens whether it decodes alone, beside three
other chats in one unified KV cache, or across a park and restore that moved its
cells. There is no command-line flag for it, no --help entry and nothing in
/props: the whole interface is an environment variable. So the only way to ask
for it today is to set a llama.cpp variable on the Studio process, which is not
discoverable, is not per load, and hands the mode to every child whether or not
that load wanted it.

UNSLOTH_LLAMA_EXACT_CONCURRENCY=auto|off|on is the switch, shaped like
UNSLOTH_LLAMA_PREEMPT_MODE next to it, with the same three values also on the
load request (exact_concurrency) and in a stored setting the GUI can write
through GET/PUT /api/settings/exact-concurrency. Highest wins: the environment,
then the request, then the store. Default off, because the mode costs about 9
percent of solo decode on a dense model and more on a mixture of experts, and
buys nothing for a chat that never shares its cache. An inherited
LLAMA_EXACT_CONCURRENCY is read as the default rather than ignored, so whoever
set it before this existed keeps it; an explicit off takes it back out of the
child environment, since off is the one answer that has to be obeyed exactly.

The launch sets the variable on the child rather than relying on inheritance,
and squares Studio's own line with the mode: --parallel 1 skips --kv-unified,
which the paged KV pool needs whether or not anything else is decoding, so exact
mode adds it. Studio never emits --cache-reuse and already emits
--no-context-shift; a contradiction in the user's extra args (--cache-reuse,
--context-shift, --no-kv-offload, a quantized KV cache, flash attention off) is
named in a load warning instead of arriving as an error about a launch line the
user did not compose.

Detection is by trying, because there is nothing to ask. Under auto, a child
that dies naming the mode is relaunched once without the variable, ahead of
every other rung in the ladder since the server said which of its own
requirements it could not meet; the attempt bound goes from three to four so the
ROCm correction and the fit recovery do not lose their slot to it. Under on the
refusal is the answer, and it is classified into a message that names the
setting and what the mode requires. The --flash-attn off respawn drops the
variable under auto for the same reason. What is reported is read off the argv
and environment that actually launched, so a respawn that took away flash
attention or the unified cache reads as unavailable rather than still claiming
the guarantee.

The load and status responses gain exact_concurrency (on/off/unavailable) and
requested_exact_concurrency, the duplicate-load check treats a different setting
as a reload since the mode cannot change without a new child, the preemption
snapshot carries it, and the "llama preemption armed" line gains exact=. The
one thing none of this can do is tell a build that predates #194, which ignores
the variable and starts perfectly, from one that granted the mode: that reports
on, and the tests record it as the limit it is.

103 new tests over the resolution order, the child environment, the launch line,
the refusal detection and the auto fallback, the reported state and the armed
line; 2278 pass across the preemption, start-failure and load-mode suites.
find_slot() and set_input_pages() each rebuilt the (sequence, logical page) to
physical page map from a scan of every live cell into an std::map, so the paged
allocator did O(cells log pages) work twice per ubatch, twice per decode step,
and the CPU cost grew with the size of the pool rather than with the number of
pages in it.

Keep the ownership in a flat vector with one entry per physical page, claimed in
apply_ubatch() as cells are placed and marked dirty by the paths that remove them,
seq_rm(), seq_keep() and clear(). prepare() snapshots it alongside the cells so
that undoing a speculative placement puts back what the allocator knew rather than
forcing a rebuild. Both readers now build their lookup from one entry per page:
32 entries at -c 8192 and 256 at -c 65536, against 8192 and 65536 cells. find_slot
also stops allocating a cells-sized bitmap per call. Nothing about the placement
policy changes, and the derived-from-live-cells rebuild is still there and still
authoritative: LLAMA_KV_CACHE_DEBUG=1 runs it on every read and asserts that the
maintained ownership says exactly what the cells say.

Four chats, 937 token prompts, 1536 tokens each, ignore_eos, speculation off,
--parallel 4 --kv-unified --flash-attn on -ngl 99, three interleaved pairs on one
B200, medians of four-chat aggregate decode tok/s:

  -c 8192    off 151.03   exact 136.70   0.905      before
  -c 8192    off 150.83   exact 137.94   0.915      after
  -c 65536   off 158.34   exact 136.29   0.861      before
  -c 65536   off 159.48   exact 143.62   0.901      after

So at 32 pages it is worth about a point, and at 256 pages it is worth four, which
is what a cost that followed the cell count and now follows the page count should
look like. With LLAMA_KV_CACHE_DEBUG=1 and LLAMA_SERVER_PREEMPT_EVERY=32, which
parks and restores every slot every 32 tokens and so exercises every path that
marks the ownership dirty, no assert fires and P0 stays byte identical to its solo
reference.
…he cache

A review of #194 pointed at the new GGML_ASSERT in llama_kv_cache::seq_cp, and it
is right. Reproduced on this branch with the 4B on one B200:

  LLAMA_EXACT_CONCURRENCY=1, POST /completion {"n": 2}
    -> llama-kv-cache.cpp:458: GGML_ASSERT(!exact_pages || seq_id_src == seq_id_dst)
       failed, through server_context_impl::decode -> common_memory::seq_cp
    -> process aborted, the next request gets connection refused

With the mode off the same request is served normally, so this is reachable by any
client of an exact-mode server and takes every other request on the machine with
it. Two changes:

The server refuses the request. n_cmpl > 1 works by copying the parent's cells to
a second sequence id, and exact mode gives a page to one sequence, so there is
nowhere for that copy to land. Rejecting it where the task is built turns it into
a 400 with a reason. The check reads LLAMA_EXACT_CONCURRENCY from the environment,
the same way the KV cache, the batch splitter and the CUDA backend each do,
because the answer is needed before a context exists and the mode has no other
representation.

The cache stops aborting. seq_cp, seq_add and seq_div log an error and return
instead of asserting, so a caller this branch does not know about degrades to a
refused operation rather than killing the server. The guards also move below the
shared-cells early return, which the asserts sat above: a draft cache forwards
these calls and copies nothing of its own, and it should not be judged by a rule
about cells it does not own.

After: the n=2 request returns 400 on both /completion and /v1/completions, the
server stays up, and a following ordinary request returns 200.
Under exact concurrency the KV cache hands out 256-cell pages, one to each
(sequence, position / 256) pair, so a sequence can hold up to 255 cells that
nobody else can be given. A preemption planner that counts tokens does not see
those cells: it believes there is room, never parks anybody, and the pool fills
until every request ends in the old context error.

llama_memory_i gains alloc_granularity(), defaulting to 1 so no module that
allocates a cell per token changes; the KV cache returns its page size under
exact mode and the hybrid memory forwards to its attention half.
llama_memory_alloc_granularity() exposes it. The server side of this, rounding
its planner figures by that value, follows separately.
@danielhanchen

Copy link
Copy Markdown
Member Author

Measured the prompt serialisation limitation, since the description names it as the one known cost of the isolation rule.

Setup: Qwen3.5-4B UD-Q4_K_XL, --parallel 4 --kv-unified -c 16384, default n_batch 2048 / n_ubatch 512, temperature 0, exact off and on interleaved per repeat with a fresh server per cell, prompts submitted as disjoint token windows so no prefix is shared.

Four prompts of P tokens arriving at once, median of 3 repeats:

P ubatches off / on max TTFT on/off aggregate prefill tok/s on/off
128 1 / 4 1.43x 0.70x
300 3 / 4 1.14x 0.87x
512 4 / 4 0.75x 1.34x
1000 8 / 8 0.86x 1.16x
2000 16 / 16 0.89x 1.13x
3500 28 / 28 0.96x 1.04x

The cost is per extra ubatch, not per prompt token. A prompt of at least n_ubatch tokens fills a ubatch on its own, so packing it with a neighbour buys nothing and the two modes need the same ubatch count. Below that, four 128-token prompts cost four ubatches instead of one, about 59 ms each here. The token-proportional cost of the mode itself is visible in the solo column: 1.03x at 1000, 1.05x at 2000 and 1.17x at 3500 prompt tokens on prefill.

Mixed case, three chats mid-decode when a 3500-token prompt arrives: the decoders emit the same 5, 6, 6 tokens during the prefill in both modes, so decoders are not split apart, and the longer stall (p50 gap 151 ms off, 184 ms on) is entirely the slower prefill.

The GPU was shared with another job, so the absolute numbers are not clean; the paired ratios are. The crossover is n_ubatch, so -ub moves it. I will fold this into the description.

Exact mode and #184's preemption planner cannot both be right about how full the
pool is, and on this branch they are not. Reproduced with the 4B on one B200,
LLAMA_EXACT_CONCURRENCY=1, four chats with 1000-token prompts generating 2048
tokens each at --parallel 4 --kv-unified -c 8192 --spec-type draft-mtp
--spec-draft-n-max 2, no forced-park knob, three rounds:

  0 of 4, 0 of 4 and 1 of 4 completions, 12 "Context size has been exceeded",
  0 parks and 0 restores.

Nothing was ever parked. preempt_kv_used(), preempt_n_need() and
preempt_kv_reserve() count tokens, and exact mode's allocator hands out 256-cell
pages, one page to one (sequence, position / 256) pair. Four sequences can
therefore be holding up to 1020 cells that no other sequence can be given, and the
planner, seeing room in tokens that find_slot cannot find in pages, never reaches
the threshold that would park anybody. The retry ladder then halves n_batch to 1
and ends every request, which is the pre-#184 behaviour that preemption exists to
remove.

Ask the memory how it allocates instead of assuming. The server reads
llama_memory_alloc_granularity() once at load and rounds:

  preempt_kv_used()    charges every slot's tail page in full, because a page
                       belongs to one sequence however little of it is used
  preempt_n_need()     rounds what a resume must be given, since a restore takes
                       fresh pages
  preempt_kv_reserve() reserves the cells the next step ADDS rather than its
                       tokens, because on a rounded used figure a step is free
                       until it crosses a page boundary and costs a whole page
                       when it does, and that crossing is the only moment the pool
                       can run out
  preempt_n_margin()   rounds the spare cells up to a page, since a margin of
                       eight is no margin at all where a step can cost 256

With a granularity of 1 every one of these is the arithmetic it was, which is
pinned by static assertions on the two rounding helpers rather than left to be
read: at 1 they are the identity, so nothing changes with the mode off.

After, same configuration and three rounds: 4 of 4 completions each round, 3, 4
and 4 parks and the same number of restores, no context errors, and P0
byte-identical to its solo run in all three. The same run with exact mode off is
also 4 of 4 with 3, 2 and 2 parks, and its planner figures are still the token
counts they always were.

LLAMA_SERVER_PREEMPT_GRANULARITY overrides the figure the memory reports. It is a
test knob, next to LLAMA_SERVER_PREEMPT_EVERY: the paged attention kernel needs a
head size of 256, which the harness model does not have, so this is the only way
to reach the paged arithmetic from tools/server/tests. The new test drives two
slots over a 256-cell pool at a granularity of 64 and asserts every figure the
planner logs is a whole number of blocks. Counting tokens the same run logs
kv 119/256 and wanted 249; counting cells it logs kv 64/256 and wanted 256.
@danielhanchen

Copy link
Copy Markdown
Member Author

Nine more commits, and the review items are closed. 3cc003acf refuses the mode at load when any KV layer is not on the CUDA backend, and names every other precondition and the flag that sets it instead of asserting; 66583931d turns the cache transformations the paged pool cannot do into logged refusals, so --context-shift and --cache-reuse disable themselves through the paths that already exist; b81d8e701 makes the prompt isolation rule the same in every memory type and applies it only to sequences that still have more than one unplaced token; c7027d60d derives the column bound from the decode width the process will actually build, refuses a smaller explicit bound at startup, and warns once when a matmul is left batched above it; 9cd122243 refuses the V-less attention layouts at context creation rather than leaving a model half paged; b5e9ebdf2 stops the evidence harness from reporting a run whose workers failed as a clean four-way result; and 712bee75c maintains page ownership in a flat per-page vector instead of rebuilding it from every live cell twice per ubatch.

The 37 to 42 percent concurrency cost measured on 65860ea38 is gone, and the page bookkeeping is not where it was. Full pool, four chats, 937-token prompts, ignore_eos, speculation off, three interleaved pairs, medians: with 1536 tokens each at -c 8192 exact mode is now 0.915 of mode-off four-chat aggregate, and at -c 65536 it is 0.901. Splitting the mode in half says where the rest is: the CUDA batch-invariant kernel policy alone, with no paged pool, no page table and no ubatch rule, is 0.791, so the paged allocator is net negative cost on this workload and all of the remaining loss is the column policy, the per-row flash attention and the per-token MUL_MAT_ID, which are the price of the guarantee. What recovered the 37 percent was b81d8e701 and c7027d60d: one prompt was serialising every concurrent decode for the whole prefill, and the column bound was a fixed 16 rather than the 4 a decode step can actually reach with speculation off. Item 7 is worth about a point at 32 pages and four at 256, which is the shape a cost that used to follow the cell count and now follows the page count should have.

a2f9c081f closes the n > 1 abort. LLAMA_EXACT_CONCURRENCY=1 with POST /completion {"n": 2} reached GGML_ASSERT(!exact_pages || seq_id_src == seq_id_dst) in llama_kv_cache::seq_cp and killed the process, so any client of an exact-mode server could take every other request on the machine down with it. n_cmpl > 1 copies the parent's cells to a second sequence id and a page belongs to one sequence, so the server refuses it with a 400 where the task is built, and seq_cp, seq_add and seq_div log and return instead of asserting. The guards sit below the shared-cells early return, so a draft cache that forwards these calls and owns no cells of its own is not judged by a rule about cells.

The last two commits are the deferred Medium row of the review, the one that said exact mode and #184's preemption planner cannot both be right about how full the pool is. They are not. Four chats with 1000-token prompts generating 2048 tokens each at --parallel 4 --kv-unified -c 8192 --spec-type draft-mtp --spec-draft-n-max 2, no forced-park knob, three rounds on the 4B: 0 of 4, 0 of 4 and 1 of 4 completions, 12 Context size has been exceeded, and zero parks. Nothing was ever parked. preempt_kv_used(), preempt_n_need() and preempt_kv_reserve() count tokens while the allocator hands out 256-cell pages, one page to one (sequence, position / 256) pair, so four sequences can hold up to 1020 cells nobody else can be given; the planner saw room that find_slot could not find and never reached the threshold that would park anybody, and the retry ladder halved n_batch to 1 and ended every request.

72aca44b5 adds llama_memory_i::alloc_granularity(), non-pure and defaulting to 1 so no module that allocates a cell per token needs a change, returning the page size from llama_kv_cache under the mode and forwarded by the hybrid memory to its attention half. da8556d31 is the server half: it reads the figure once at load and rounds, charging every slot's tail page in full in preempt_kv_used(), rounding what a resume must be given in preempt_n_need(), reserving in preempt_kv_reserve() the cells the next step adds rather than its tokens, since on a rounded used figure a step is free until it crosses a page boundary and costs a whole page when it does, and rounding the spare margin up to a page, because eight cells is no margin where a step can cost 256. At a granularity of 1 every one of these is the arithmetic it was, pinned by static assertions on the two rounding helpers rather than left to be read.

After, same configuration, three rounds:

round 0 round 1 round 2
completions 4 of 4 4 of 4 4 of 4
parks / restores 3 / 3 4 / 4 4 / 4
context errors 0 0 0
P0 identical to its solo run yes yes yes

Exact mode off on the same binary is 4 of 4 in all three rounds with 3, 2 and 2 parks, and its planner still logs the token figures it always did (kv 6159/8192 (wanted 8196)), against whole pages with the mode on (kv 6144/8192 (wanted 8192)). P0 inside the batch is not identical to its solo run with the mode off, in all three rounds, which is the premise of this PR and shows the check would see its absence.

Server harness tools/server/tests/unit/test_preempt.py: 7 passed, the 6 that were there plus one for the page case. It needs a knob. The paged attention kernel requires a head size of 256 and the harness model has 8, so LLAMA_SERVER_PREEMPT_GRANULARITY injects the figure the memory would report, next to the existing LLAMA_SERVER_PREEMPT_EVERY; what is under test is the server's arithmetic, which is the same at 64 as at 256. The test drives two slots over a 256-cell pool and asserts every figure the planner logs is a whole number of blocks: counting tokens the same run logs kv 119/256 and wanted 249, counting cells it logs kv 64/256 and wanted 256.

Two things this does not answer. The live cell above is not a cost measurement: the three configurations ran back to back rather than interleaved on a GPU shared with another tenant, and mode-off aggregate swung from 94 to 151 tok/s across its own three rounds, so the only cost figures worth quoting are the interleaved ones above. And, found while checking whether the harness model could reach the page case: on a model whose head size the CUDA paged kernel does not support, ggml_backend_cuda_device_supports_op refuses FLASH_ATTN_EXT outright, the scheduler places it on the CPU, and the CPU deliberately ignores the page table, so the mode reports itself as on while attending in physical cell order. The load-time gate checks where the KV cache lives, not whether the backend that has it will take the paged op. Not fixed in these commits.

Under LLAMA_EXACT_CONCURRENCY a decode step with speculative drafts cost
about twice what the same step cost with the mode off, and the mode itself
was not where the time went.

The batch splitter isolated any sequence set with more than one token left
to place, on the reasoning that such a set is a prompt whose prefill would
otherwise share a ubatch with other sequences. A speculative verify batch
is such a set too: with two MTP drafts every slot brings three tokens, so
each slot's verify step became a ubatch of its own and every decode step ran
the whole graph once per slot.

The splitter now isolates by width. llama_set_exact_decode_tokens() tells
the library how many tokens one sequence contributes to a decode step, one
plus the draft length, and only a set with more tokens than that left to
place is a prompt. The server already derives the CUDA column bound from
the same figure, so a grouped verify batch is at most that wide and the
column policy keeps every column at its batch-of-one arithmetic. The
default of 1 is the previous behaviour.

On the CUDA side the column policy recomputed a batch above the bound one
column at a time, reading the weights once per column. A column's result
depends on the implementation and, for MMVQ, on the warp count of the
launch, and neither depends on the other columns in the launch, so the
split now runs in the widest slices whose configuration matches a batch of
one: a twelve-column verify batch is three MMVQ launches on a table whose
configuration holds up to four columns, rather than twelve. Mode 1 of
GGML_CUDA_BATCH_INVARIANT still recomputes one column at a time.

Qwen3.5-4B, four chats with two MTP drafts each, exact mode on: byte
identical to the solo run in every round, with and without a forced park
every 64 tokens, at about twice the previous aggregate rate.
Qwen3.6-35B-A3B, the same cell: identical in every round at 92 to 96 tok/s
against 55 before. MUL_MAT op tests 1217 of 1217 under the mode; server
preemption tests 7 of 7.
@danielhanchen

Copy link
Copy Markdown
Member Author

Pushed c6c3cb6, which takes the speculative case from 0.43 to 0.74 of the mode-off rate.

The 0.45 measured earlier was not the column policy. Slicing the column split into batch-of-one-equivalent slices (four columns on this GPU's MMVQ table, so three launches for a twelve-column batch instead of twelve) changed nothing on its own, because no matmul was ever twelve wide: the batch splitter isolated every sequence set with more than one token left to place as a prompt, and a slot's three-token verify batch is such a set, so with two MTP drafts each decode step ran the whole graph once per slot.

The splitter now isolates by width. llama_set_exact_decode_tokens() carries one plus the draft length, set by common_exact_concurrency_init from the same figure that derives the CUDA column bound, so a grouped verify batch is never wider than the bound and every column of it stays at its batch-of-one arithmetic. The default of 1 is the previous behaviour. The sliced column split is what keeps the now twelve-wide matmuls at three weight reads rather than twelve.

Exactness, three rounds per cell, P0 against its own solo run, all byte-identical:

model cell four-chat aggregate tok/s before
4B two MTP drafts 98 to 101 50
4B two MTP drafts, forced park every 64 (98 parks) 84 to 86 46 to 51
4B speculation off, forced park every 64 103 to 121
35B MoE two MTP drafts 92 to 96 55
35B MoE two MTP drafts, forced park every 64 77 to 78

Full pool, four chats of 937 prompt plus 2048 generated tokens at -c 8192, two MTP drafts, interleaved pairs on one GPU, all 4 of 4 with parks and zero context errors:

exact on, this commit exact on, before exact off
aggregate tok/s (median of two clean pairs) 108.1 62.9 146.5
ratio to off 0.74 0.43 1.00

Speculation off, the same shape: 0.92, unchanged from before the commit. test-backend-ops -o MUL_MAT 1217 of 1217 under the mode; test_preempt.py 7 passed.

The MUL_MAT_ID split stays per token: the multi-token MoE path is a different kernel whose reduction order differs from the single-token one for some quantization types, so a wider slice would not be exact there.

…in one launch

Under the batch-invariant policy a MUL_MAT_ID with several tokens was
recomputed one token at a time, re-entering the op once per token, so a
decode step of four slots with two MTP drafts each cost twelve serial
expert launches per projection.

The single-column MMVQ kernel already carries a sample axis. With ids, a
sample is now a token: the wrapper launches the ncols_dst = 1 configuration
once with the tokens on the z axis, y advancing by a token per sample and
the expert index read per sample, so every (token, expert slot) block runs
exactly the instructions the token alone would run, on the same data, with
the same warp count, row split and K loop. Only the block indices differ.
The stock single-token launch has one sample and is unchanged, as is every
launch without the knob.

A quantized expert matrix takes this path for any token count under the
knob; other types still go one token at a time.

Single-op probe, token 0's slice against its own single-token run, knob on,
Q4_K, Q5_K, Q6_K and Q8_0 at K 2048 in both the gate-up and the down
layout and Q6_K at K 512 (the shape whose multi-token kernel differed),
2 to 12 tokens: 0 differing elements in every one of 99 comparisons.
@danielhanchen

Copy link
Copy Markdown
Member Author

Pushed 379ca5d, which removes the per-token cost of the mode on mixture-of-experts models.

Under the knob a MUL_MAT_ID with several tokens was recomputed one token at a time, re-entering the op once per token, so a verify step of four slots with two MTP drafts each was twelve serial launches per expert projection. The single-column MMVQ kernel already carries a sample axis, and with ids a sample is now a token: the wrapper launches the ncols_dst = 1 configuration once with the tokens on the z axis, y advancing by one token per sample and the expert index read per sample. Every (token, expert slot) block therefore executes exactly the instructions the token alone would, on the same data, with the same warp count, row split and K loop; only block indices differ. The stock single-token launch has one sample and is unchanged, as is every launch without the knob. Quantized expert matrices take this path for any token count under the knob; other types still go one token at a time.

Single-op probe, token 0's slice for 2 to 12 tokens against its own single-token run, knob on, neighbours routed to token 0's experts: Q4_K, Q5_K, Q6_K and Q8_0 at K 2048 in both the gate-up and the down layout, and Q6_K at K 512 (the shape whose multi-token kernel differed before the knob): 0 differing elements in all 99 comparisons.

Qwen3.6-35B-A3B, four chats, P0 against its own solo run, three rounds per cell, exact on and off interleaved on a shared GPU:

cell P0 identical four-chat aggregate tok/s
off, two MTP drafts diverges at token 68, 103, 68 361, 197, 296
on, two MTP drafts yes, yes, yes 375, 368, 341
off, speculation off diverges at 118 480, 423, 401
on, speculation off yes, yes, yes 411, 498, 497
on, two MTP drafts, forced park every 64 (98 parks) yes, yes, yes
on, speculation off, forced park every 64 yes, yes, yes

Exact mode on the 35B now runs at the mode-off rate within the noise of the shared GPU, with and without speculation; the per-token re-entry had left it at about 0.7 with two drafts. test-backend-ops -o MUL_MAT_ID 919 of 919 with the knob on and off.

@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Sep 6, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Sep 6, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Sep 6, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Sep 6, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Sep 6, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Sep 6, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Sep 6, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Sep 6, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Sep 6, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Sep 6, 2026
chatgpt-codex-connector[bot]

This comment was marked as resolved.

… for the token figure, the sequence count and the width

common_init_from_params() checked the mode after the context existed, so a
caller that skipped common_params_parse() could be handed a live context
under a bound the setup had just refused. The check moved to the front of
the init result's constructor, ahead of the fitting contexts and the model
load; on failure nothing is loaded.

The token figure, the widest sequence count and the width moved as three
separate atomics, so a context reporting its count while the figure changed
could leave the backend with a width that covered neither. One recursive
lock now spans every transition.
…o HEAD

# Conflicts:
#	tools/server/server-context.cpp
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Sep 6, 2026
…it; one bound check at context creation

Under isolation the equal-count guard chose which sets join a ubatch, but
the expansion could still cut them all part way when the sets together
exceeded n_ubatch, the chunking the guard exists to prevent. A set that
would not finish in the ubatch waits for the next one.

The context constructor checked the explicit column bound itself and then
again through the report; the report's refusal is the error now.
…e cache, publish the width once construction succeeds, refuse the page table on every backend that ignores it

A whole-context restore under LLAMA_EXACT_CONCURRENCY was refused inside
state_read_meta(), after which the generic restore path cleared the live
cache. It is refused at the top of state_read_data() and
llama_kv_cache::state_read() now, before a byte is read, so
llama_state_set_data() returns 0 and the sequences are untouched.

The context reported its sequence count at the front of the constructor,
so a construction that failed later on left a width behind that no
context needed. The count is checked against the explicit column bound
early and reported at the end, once nothing can fail any more.

WebGPU, ExecuTorch, Hexagon, OpenVINO and RPC advertised FLASH_ATTN_EXT
with the page table in src[5] that only the CUDA kernels read; they
refuse it like CANN, Metal, OpenCL, SYCL and Vulkan already did.

DSpark runs on the DFlash implementation and turns causal attention off
on its draft context, so it is refused alongside DFlash. The decode width
is computed in 64 bits and refused above INT32_MAX instead of wrapping.
The probe's PROBE_A_PERM keeps prompt 0 on sequence 0, which phase B
compares against.
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Sep 6, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Sep 6, 2026
chatgpt-codex-connector[bot]

This comment was marked as resolved.

…ion, not only which backend it is

The KV cache accepted a layer on any CUDA, ROCm or MUSA device by the
registry name alone. A build without the flash attention kernels, or a
device and head shape they do not cover, would then have the scheduler
hand the paged op to the CPU, which accepts the page table as the
reference for test-backend-ops and ignores it, and the mode would report
itself on while attending in physical order. The constructor now builds
the attention op the way the graph does, page table attached, at the
widths of a decode step, a verify step and a prompt chunk, and asks the
device; a refusal is a load error naming the layer and the types.
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Sep 7, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Sep 7, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Sep 7, 2026
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Another round soon, please!

Reviewed commit: 918a8bf4f0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment-only pass over the PR's diff: collapse the long explanations to one or two lines each and drop the ones the code already says.
…o HEAD

# Conflicts:
#	tools/server/server-context.cpp
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant