You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
serve: Qwen3.5 on CUDA never batches concurrent requests (m_formed=0 at c=4/8/16) #5279
apr serve on a Qwen3.5 model with CUDA never batches concurrent requests. N concurrent chat requests run as N separate decodes, and the batch scheduler never forms a batch with m > 1. Other architectures on the same CUDA serve path do form batches: the perf041 witness on qwen2.5-coder formed m=16 at c=16 (#4888).
This is a throughput parity gap whatever #4971 (V3) decides. Concurrent serving of the 0.71 flagship model gets no batching.
Evidence (2026-10-14 08:33–08:36Z, NVIDIA GB10)
apr 0.71.2 (d97936f47), built from main d97936f470 with --features cuda; binary sha256 7043e8370bc8a890ef157cef4195b6c473050193ae5fa56365e0afde35194dd4.
apr serve run Qwen3.5-4B-Q4_K_M.gguf --gpu-layers all --context-length 4096, CUDA_BATCH_WINDOW_MS=200. The log reads Backend: GPU (CUDA, NVIDIA GB10 …) [qwen35 hybrid forward, #3090], resolved 32/32.
apr test llm parity --ladder 1,4,8,16 exited 8 (UNMEASURABLE): m_formed=0 in every band; c=4/8/16 report "no batch with m>1 formed in this band's window".
The server log has one [qwen35] batched prefill line per request (rows of one prompt) and no Batch m=N done line.
apr test llm parity --ladder 1,4,8,16 against apr serve with CUDA on Qwen3.5-4B records m_formed == c in every band. For c > 1 that means the server log carries Batch m=c done for each band.
Falsifier
The same run against a build that serves Qwen3.5 one request at a time reports m_formed < c and exits non-zero. The witness on main d97936f470 above is that build.
Not measured
The RTX 4090 leg has not been run. It uses the same backend and code, so I expect the same result.
Per-request decode at c=4 vs c=1 is unmeasured (no rate is recorded without a formed band).
Startup returns early.crates/apr-cli/src/commands/serve/handler_gpu_completion.rs:405-406 checks hybrid_forward_handles(arch) and then calls start_qwen35_server. That happens before build_serve_model, so no OwnedQuantizedModelCuda is built and spawn_cuda_batch_scheduler (crates/aprender-serve/src/api/cuda_batch_scheduler.rs:204, typed to the dense model) is never spawned.
One mutex serializes requests.crates/aprender-serve/src/api/mod_app_state_qwen35.rs:9-10 holds the session as Mutex<Qwen35Session> ("One request at a time: the hybrid is a single-stream model"). It also sets cuda_batch_tx: None. qwen35_chat_backend.rs takes session.lock() for a whole turn, so concurrent requests queue on the lock.
No batched decode exists.Qwen35CudaState (gguf/cuda/forward_qwen35_cuda.rs:99-118) holds state for one sequence. Decode runs through forward_single (:1726) one token at a time, and the decode CUDA graph is also single-sequence. The DeltaNet kernels in cuda/executor/gdn_ops.rs have no sequence dimension. "Batched prefill" (#3596, #4228) means the prompt rows of one sequence.
What done needs
An m-sequence forward: tokens[m], states[m], positions[m].
Q4K GEMM, not GEMV, for the projections and the FFN.
DeltaNet conv, gate and delta-rule kernels that take a sequence dimension, with per-sequence conv and SSM state.
Attention KV caches per sequence, each with its own length.
A scheduler for the hybrid model. The existing one assumes the dense BatchedDecodeState.
The early return and the mutex replaced, with per-slot prefix reuse and checkpoints.
A contract that batched output matches single-sequence output token for token, like the parity gates in continuous-batching-v1.
Size: L. A round-robin over N single-sequence states would print m=N but would not batch, so it does not meet the done-when.
Phase plan (read on main d97936f). One PR per phase, each with its own falsifier
What already holds
State is separate from weights. Per-sequence state is Qwen35CudaState (forward_qwen35_cuda.rs:102-119), and the weights live in Qwen35CudaModel (:254-264). forward_single, prefill and the checkpoint calls all take the state from outside. What is single-row is the shared scratch (:182-269).
The decode graph is keyed by the state's buffer addresses (forward_qwen35_cuda_graph.rs:64). So the batch path stays eager at first.
Problem: no existing batched matmul is bitwise equal to the single-sequence path.
The prefill row helper qwen35_project_rows uses cuBLAS F16, so it is not equal to gemv_dispatch.
The dense batched Q4K/Q6K GEMVs are switched off (USE_BATCHED_GEMV = false, layers/batched_qkv.rs:22).
dp4a quantizes the activations.
Phases
P0 — Scheduler over a trait.cuda_batch_scheduler becomes generic over a BatchEngine trait, with seven methods: single request, setup+prefill, decode step, add slot, recycle slot, cleanup, reset. The dense impl is plain delegation. Test: a mock engine forms Batch m=c for c ∈ {1,4,8,16}.
P1 — Looped multi-slot step on Qwen35CudaModel.forward_batch_greedy(tokens, states, positions), with a looped forward per slot. Test: greedy streams are bitwise equal to forward_single, for m=1..4 with ragged prompts.
P2 — Qwen3.5 engine plus serve wiring.
Removes the Mutex<Qwen35Session> and the startup early return. m=1 keeps the session turn (prefix reuse and logprobs unchanged); m>1 uses a pool of per-slot states.
Tests: c=4 batched output equals serial output, and an apr-cli smoke test asserts Batch m=N.
After P2, m_formed == c, but decode is still serial. The S7 decode-timing rule will likely still reject the witness.
P3 — Row-twin GEMV kernels (Q4K, Q6K, Q8_0, F32). Each weight block is loaded once and dotted against m rows, with the same accumulation order per row as the single-row kernel. Test: bitwise equal to gemv_dispatch for m ∈ {1,2,4,8,16}. This is the risky phase: the same idea was switched off once before (realizr#220).
P4 — Batched matmuls. Every projection, the FFN and lm_head run at m rows through P3, plus batched_gpu_argmax. GDN and attention still loop per slot over row views. Test: P4 steps are bitwise equal to P1 steps. The first phase that should satisfy the V3 timing rule.
P5 — GDN conv1d and the delta rule gain a slot dimension. grid.y = m, with a device u64[m] array of per-layer state pointers. Test: bitwise equal to the per-slot result.
P6 — Decode attention (head_dim 256, output gate) gains a slot dimension. Pointer arrays plus seq_lens[m]. Test: ragged lengths {1, 17, 300, 4095} equal the per-slot result.
P7 — Per-slot prefix reuse. Longest-prefix admission. Test: shared-prefix prefill rows are skipped and the output is identical.
P8 (optional) — Batched CUDA graph keyed by the slot-pointer arrays. Test: graph output equals eager output.
Fallback if P3 cannot reach bitwise equality: the parity criterion drops to the prefill tests' standard: argmax equal and cosine at or above the budget (prefill_tests.rs:101).
merged into #5312 (master ticket for epic #3994; triage rule 7b, 2026-10-14). It is a checklist row there; reopen this issue when work on it starts or its PR opens.
Activity
noahgift commentedon Oct 14, 2026
Where concurrency is lost (read on main d97936f)
Why no batch forms
crates/apr-cli/src/commands/serve/handler_gpu_completion.rs:405-406checkshybrid_forward_handles(arch)and then callsstart_qwen35_server. That happens beforebuild_serve_model, so noOwnedQuantizedModelCudais built andspawn_cuda_batch_scheduler(crates/aprender-serve/src/api/cuda_batch_scheduler.rs:204, typed to the dense model) is never spawned.crates/aprender-serve/src/api/mod_app_state_qwen35.rs:9-10holds the session asMutex<Qwen35Session>("One request at a time: the hybrid is a single-stream model"). It also setscuda_batch_tx: None.qwen35_chat_backend.rstakessession.lock()for a whole turn, so concurrent requests queue on the lock.No batched decode exists.
Qwen35CudaState(gguf/cuda/forward_qwen35_cuda.rs:99-118) holds state for one sequence. Decode runs throughforward_single(:1726) one token at a time, and the decode CUDA graph is also single-sequence. The DeltaNet kernels incuda/executor/gdn_ops.rshave no sequence dimension. "Batched prefill" (#3596, #4228) means the prompt rows of one sequence.What done needs
tokens[m],states[m],positions[m].BatchedDecodeState.continuous-batching-v1.Size: L. A round-robin over N single-sequence states would print
m=Nbut would not batch, so it does not meet the done-when.noahgift commentedon Oct 14, 2026
Phase plan (read on main d97936f). One PR per phase, each with its own falsifier
What already holds
Qwen35CudaState(forward_qwen35_cuda.rs:102-119), and the weights live inQwen35CudaModel(:254-264).forward_single,prefilland the checkpoint calls all take the state from outside. What is single-row is the shared scratch (:182-269).forward_qwen35_cuda_graph.rs:64). So the batch path stays eager at first.qwen35_project_rowsuses cuBLAS F16, so it is not equal togemv_dispatch.USE_BATCHED_GEMV = false,layers/batched_qkv.rs:22).Phases
cuda_batch_schedulerbecomes generic over aBatchEnginetrait, with seven methods: single request, setup+prefill, decode step, add slot, recycle slot, cleanup, reset. The dense impl is plain delegation. Test: a mock engine formsBatch m=cfor c ∈ {1,4,8,16}.Qwen35CudaModel.forward_batch_greedy(tokens, states, positions), with a looped forward per slot. Test: greedy streams are bitwise equal toforward_single, for m=1..4 with ragged prompts.Mutex<Qwen35Session>and the startup early return. m=1 keeps the session turn (prefix reuse and logprobs unchanged); m>1 uses a pool of per-slot states.Batch m=N.gemv_dispatchfor m ∈ {1,2,4,8,16}. This is the risky phase: the same idea was switched off once before (realizr#220).lm_headrun at m rows through P3, plusbatched_gpu_argmax. GDN and attention still loop per slot over row views. Test: P4 steps are bitwise equal to P1 steps. The first phase that should satisfy the V3 timing rule.u64[m]array of per-layer state pointers. Test: bitwise equal to the per-slot result.seq_lens[m]. Test: ragged lengths {1, 17, 300, 4095} equal the per-slot result.Fallback if P3 cannot reach bitwise equality: the parity criterion drops to the prefill tests' standard: argmax equal and cosine at or above the budget (
prefill_tests.rs:101).noahgift commentedon Oct 14, 2026
merged into #5312 (master ticket for epic #3994; triage rule 7b, 2026-10-14). It is a checklist row there; reopen this issue when work on it starts or its PR opens.