Part of #623. Can proceed in parallel with Phase 3; rebase measurements on the post-#625 baseline.
Context
The CLI generation loop overlaps graph construction with GPU execution: it builds step n+1's graph (reshape -> forward -> fused sample) before reading back step n's token, schedules it with ffi::async_eval_pair, and only then syncs the current token with item_i32 (src/lib/mlxcel-core/src/generate.rs:1157-1260; first token at :1246; MLXCEL_FORCE_SYNC opt-out at :1227). A dedicated thread-local generation stream supports the overlap (src/lib/mlxcel-core/src/streams.rs, installed at generate.rs:1059).
The server scheduler has none of this. Both decode paths synchronize every step:
decode_single_step (src/server/batch/scheduler.rs:4699): forward at :4724, then synchronous eval + item_i32 at :4778-4779.
execute_batched_decode (scheduler.rs:4300): forward at :4363, fused batch sample at :4384-4388, then a host copy of the [B] token ids.
On CUDA, where kernel-launch and graph-build latency is higher than Metal, this makes server decode structurally slower than CLI decode for identical models. Overhead-bound models (<= 2B: 67-75% of roofline) lose the most; the effect also scales the whole batched path since one tick's build time is serialized against the GPU.
Scope
Bring lookahead pipelining to the scheduler's decode, for both B=1 and batched decode, without breaking continuous-batching semantics (admission, completion, chunked-prefill interleave, cancellation).
Implementation plan
- Mechanics (mirror the CLI loop): after sampling step n's token array, immediately build step n+1's graph feeding the token array back as the next
[B,1] input WITHOUT reading it to host, schedule with async_eval (async_eval_pair when logprobs are also needed), and only then extract step n's ids to host for stop checks and streaming. The sampled-token MLX array is a valid device-side input; the CLI loop already relies on this.
- Batch-topology invalidation: lookahead is only valid while batch membership, masks, and per-sequence sampler state are unchanged. Invalidate (discard the prebuilt step and fall back to a synchronous step) when the next tick's
decide_action is not Decode with the identical id set: admission of a queued request (scheduler.rs:2616-2621), any sequence finishing (EOS/stop/length), chunked-prefill interleave (:2593-2601), preemption, or --max-kv-size trimming that mutates cache shape. Simplest correct policy: compute the lookahead only when the queue is empty and no sequence is within 1 token of a stop condition that is knowable host-side; otherwise run synchronously. Measure how often lookahead engages (add a counter to BatchObservability).
- Stop-condition subtlety: stop-string and EOS checks need host tokens, and a lookahead step may compute one token past a stop. That is the same speculative-waste tradeoff the CLI loop accepts (it trims after the fact); the scheduler must additionally make sure the extra step does not corrupt per-sequence KV: one extra decode step appends one KV position, so on stop, trim the cache by the overshoot (
KVCache::trim, src/lib/mlxcel-core/src/cache.rs:2112) before the slot is reused or donated to the prompt cache.
- Sampler-state ordering: penalties/DRY state updates (
SamplerState, src/lib/mlxcel-core/src/sampling.rs:273-287) must see token n before sampling token n+1. The CLI loop already threads this; reuse the same structure rather than reimplementing.
- Keep
MLXCEL_FORCE_SYNC=1 as the global kill switch (already respected in the CLI loop; honor it here too).
- Cancellation: the lazy disconnect check in
finalize_completed (scheduler.rs:4886-4924) is unaffected, but verify a cancelled sequence with a prebuilt lookahead step aborts cleanly.
Acceptance criteria
Validation
cargo build --release --features cuda
./target/release/mlxcel-bench-decode --model ./models/qwen2.5-0.5b-bf16 --max-tokens 512 # CLI reference
./target/release/mlxcel-server --model ./models/qwen2.5-0.5b-bf16 & # server side
python3 scripts/bench_serving_concurrency.py --concurrency 1
MLXCEL_FORCE_SYNC=1 python3 scripts/bench_serving_concurrency.py --concurrency 1 # equivalence check
References
- CLI pipelined loop:
src/lib/mlxcel-core/src/generate.rs:1131,1157-1260.
- Bridge:
async_eval/async_eval_all/async_eval_pair (src/lib/mlxcel-core/src/lib.rs:1719-1793).
- Scheduler decode:
src/server/batch/scheduler.rs:4259-4400,4699-4790; tick policy :2585-2622.
Part of #623. Can proceed in parallel with Phase 3; rebase measurements on the post-#625 baseline.
Context
The CLI generation loop overlaps graph construction with GPU execution: it builds step n+1's graph (reshape -> forward -> fused sample) before reading back step n's token, schedules it with
ffi::async_eval_pair, and only then syncs the current token withitem_i32(src/lib/mlxcel-core/src/generate.rs:1157-1260; first token at:1246;MLXCEL_FORCE_SYNCopt-out at:1227). A dedicated thread-local generation stream supports the overlap (src/lib/mlxcel-core/src/streams.rs, installed atgenerate.rs:1059).The server scheduler has none of this. Both decode paths synchronize every step:
decode_single_step(src/server/batch/scheduler.rs:4699): forward at:4724, then synchronouseval+item_i32at:4778-4779.execute_batched_decode(scheduler.rs:4300): forward at:4363, fused batch sample at:4384-4388, then a host copy of the[B]token ids.On CUDA, where kernel-launch and graph-build latency is higher than Metal, this makes server decode structurally slower than CLI decode for identical models. Overhead-bound models (<= 2B: 67-75% of roofline) lose the most; the effect also scales the whole batched path since one tick's build time is serialized against the GPU.
Scope
Bring lookahead pipelining to the scheduler's decode, for both B=1 and batched decode, without breaking continuous-batching semantics (admission, completion, chunked-prefill interleave, cancellation).
Implementation plan
[B,1]input WITHOUT reading it to host, schedule withasync_eval(async_eval_pairwhen logprobs are also needed), and only then extract step n's ids to host for stop checks and streaming. The sampled-token MLX array is a valid device-side input; the CLI loop already relies on this.decide_actionis notDecodewith the identical id set: admission of a queued request (scheduler.rs:2616-2621), any sequence finishing (EOS/stop/length), chunked-prefill interleave (:2593-2601), preemption, or--max-kv-sizetrimming that mutates cache shape. Simplest correct policy: compute the lookahead only when the queue is empty and no sequence is within 1 token of a stop condition that is knowable host-side; otherwise run synchronously. Measure how often lookahead engages (add a counter toBatchObservability).KVCache::trim,src/lib/mlxcel-core/src/cache.rs:2112) before the slot is reused or donated to the prompt cache.SamplerState,src/lib/mlxcel-core/src/sampling.rs:273-287) must see token n before sampling token n+1. The CLI loop already threads this; reuse the same structure rather than reimplementing.MLXCEL_FORCE_SYNC=1as the global kill switch (already respected in the CLI loop; honor it here too).finalize_completed(scheduler.rs:4886-4924) is unaffected, but verify a cancelled sequence with a prebuilt lookahead step aborts cleanly.Acceptance criteria
mlxcel-bench-decodeas the CLI reference).MLXCEL_FORCE_SYNC=1) including stop-string cases that trigger mid-stream and multi-sequence runs with staggered completions.cargo test --features cudapasses; scheduler unit tests extended for the invalidation cases (admission mid-lookahead, stop mid-lookahead, preemption).Validation
References
src/lib/mlxcel-core/src/generate.rs:1131,1157-1260.async_eval/async_eval_all/async_eval_pair(src/lib/mlxcel-core/src/lib.rs:1719-1793).src/server/batch/scheduler.rs:4259-4400,4699-4790; tick policy:2585-2622.