Repository navigation
refactor: batch-native engine step API with BatchScheduler on top #2172
Description
Part of #2166. Phase 4b. Depends on #2171 (Phase 4a, KV storage), #2168 (Phase 1, finish step) and #2169 (Phase 2, sampling step), and through 4a on #2170 (Phase 3, prefill plan). Phase 5 builds on this.
Problem / Background
BatchScheduler mixes two jobs: deciding what runs (admission, tick policy, preemption, prompt cache, handoff), and running it (model forwards, sampling, finishing). Because the execution half lives in the scheduler, the CLI cannot reuse it without inheriting the scheduler. The single-sequence LanguageModel::forward is also a separate path from forward_batched_with_context, so "B=1" on the server and "single sequence" on the CLI are different code. llama.cpp has one batch-native entry, llama_decode(llama_batch), where a single sequence is B=1. This phase gives mlxcel the equivalent.
Current Behavior
The scheduler calls model forwards directly:
src/server/batch/scheduler/decode_tick.rs:682,:686,:874and:1448.src/server/batch/scheduler/prefill.rs:649,:837,:856,:1008,:1027and:1248.src/server/batch/scheduler/prompt_cache.rs:982and:1160.
It calls samplers directly at six sites in decode_tick.rs and three in prefill.rs. The single-sequence and batched forwards are separate LanguageModel methods (src/lib/mlxcel-core/src/generate.rs:481 and :1011-1051).
The decode lookahead pipeline also reaches model state directly. lookahead_params (decode_tick.rs:372) admits a model-owned family only when LanguageModel::supports_decode_lookahead_rewind() is true (Gemma 3 since #2182). The prime forward in lookahead_forward (:669) runs inside a DecodeLookaheadAppendScope (:677) so a rewinding model logs the rows its speculative writes overwrite. Teardown in apply_lookahead_trim (:464-512) trims pool caches and, for a model-owned family, calls LanguageModel::rewind_decode_appends(seq_id, n). A failed rewind finishes that row with an error through fail_desynchronized_sequence (:518-526), and the callers drop it from the synchronous re-dispatch (without_failed, :529).
Proposed Solution
Add an engine module in the core, src/lib/mlxcel-core/src/engine/. Use the names ADR 0007 (#2167) fixes. If the ADR leaves names open, use these:
pub struct Engine<M: LanguageModel> { /* model, KV pool, per-sequence RowSampler and finish state */ }
impl<M: LanguageModel> Engine<M> {
pub fn open(&mut self, req: SequenceParams) -> Result<SeqHandle, EngineError>;
pub fn prefill(&mut self, seq: SeqHandle, piece: &PrefillPiece) -> Result<PrefillOutcome, EngineError>;
pub fn step(&mut self, batch: &[SeqHandle]) -> Result<Vec<StepOutcome>, EngineError>;
pub fn close(&mut self, seq: SeqHandle);
}
pub struct StepOutcome { pub seq: SeqHandle, pub token: i32, pub finish: Option<FinishCause> }step composes the contracts from the earlier phases, restated here so this issue stands alone:
- Forward: through the Phase 4a KV abstraction (refactor: one KV storage abstraction with dense and paged backends #2171).
KVCache::attend(&mut self, q, k, v, scale, mask)and its batched counterpart choose dense SDPA or paged kernels inside the cache. Models do not branch on storage. - Sampling: through the Phase 2
RowSampler(refactor: one per-row sampling step and sampler-state lifecycle #2169). It ownsSamplerStatecreation (needs_token_history() || needs_sampler_feedback_state()), the fix(generate): penalties in CxxGenerator see a history one token stale #2090 history-ordering invariant, the structured-output mask hook, the thinking-budget override (returning(sampled, final)), andcompose_token_bias. Itsfused_eligibleselects the fused[B, vocab]chain. - Finishing: through the Phase 1
finish_step(FinishInput, &mut dyn FinishHooks) -> Option<FinishCause>(refactor: one post-sample finish step for every CLI and server decode site #2168).FinishCauseisEos | StopSequence | StructuredStop | Length | ContextExhausted | RepetitionLoop, applied in the order EOS, push, stop string, generation bound, structured stop,max_tokens, context bound, repetition loop, cache-clear cadence. - Prefill: through the Phase 3
PrefillPlan(refactor: one prefill planner and one pad-trim hook for CLI and server #2170). The scheduler passes onePrefillPiece { range, padded_len }at a time so chunked prefill still interleaves with decode, andtrim_state(seq: Option<SequenceId>, excess)runs after padding.
A single sequence is a batch of one. LanguageModel::forward becomes a provided method that delegates to the batched entry with one row, so no family keeps a separate single-row decode path.
BatchScheduler is rebuilt on Engine. These stay in the scheduler: admission, tick policy, preemption, mixed prefill/decode steps (ADR 0005), prompt-cache lookup, donate and adopt, disaggregated handoff, and the batched speculative generators. These come from the engine: model execution, sampling and finishing. The legacy --no-batch worker (src/server/model_worker.rs:1103-1115) is the same scheduler at max_batch_size=1, so it is covered automatically.
Scope
In scope: the engine module, delegating the single-row forward, rebuilding the scheduler's execution half, the legacy worker, and tests.
Out of scope: the CLI (Phase 5), retiring CxxGenerator (Phase 6), and changing scheduler policy.
Implementation Notes
- Reuse: the existing
CachePool, the speculative round loops and the prompt-cache code. Wrap them, do not reimplement them. The engine exposes the hooks the prompt cache needs (snapshot_sequence_stateandrestore_sequence_state,generate.rs:788-843). - Constraints: the scheduler's lookahead pipeline (
decode_tick.rs:563-798,maybe_prime_lookaheadthroughpipelined_steady_decode) must keep its overlap, sostepneeds a split submit and collect form, or a lookahead variant. That form must also carry the speculative-append contract above: the prime forward runs insideDecodeLookaheadAppendScope, and a teardown unwinds up toDECODE_LOOKAHEAD_MAX_SPECULATIVE_APPENDS(2) appends per row, through the pool trim orrewind_decode_appendsfor a model-owned family. No per-step allocation proportional to batch size beyond what exists today. - Error handling:
EngineErrorper sequence becomesFinishReason::Error(msg)for that row only, matching today's fix(server): catch MLX C++ aborts at the decode FFI boundary so a backend throw fails the request, not the process #822 behavior. One failed row never aborts the batch. A failed model-owned rewind is the same kind of row-only error, and it overrides aLengthorStoprecorded on the same tick so the desynchronized state is not donated (perf(scheduler): restore Gemma 3 decode lookahead via ring rewind #2182).
Acceptance Criteria
-
rg -n '\.forward[a-z_]*\(|sample_token_[a-z_]*\(' src/server/batch/scheduler --glob '!*_tests.rs'returns nothing, so the scheduler no longer calls model forwards or samplers directly (PR refactor: batch-native engine step API with BatchScheduler on top #2217; the grep's only hits areplan.forwarded_len(), aPrefillPlanaccessor) -
LanguageModel::forwarddelegates to the batched entry. A test asserts that single-rowforwardand one-rowforward_batched_with_contextgive bitwise-identical logits on a real checkpoint (PR refactor: batch-native engine step API with BatchScheduler on top #2217: implemented at the engine instead of inverting the trait, see ADR 0009 and the comment below;src/models/single_row_batch_parity_tests.rspins the bitwise equality for Qwen3 and Llama 3) - The server tests and the continuous-batching benches are green (PR refactor: batch-native engine step API with BatchScheduler on top #2217: scheduler test modules green on GB10; benches not run, per the epic's end-of-run measurement rule)
- Under
MLXCEL_SDPA_DETERMINISTIC=1, the Phase 0 harness shows server B=1 dense identical to a directEngineB=1 run (PR refactor: batch-native engine step API with BatchScheduler on top #2217:d:enginearm, identical on Qwen3-1.7B and Llama-3.2-1B for both cases) - Batched and single-stream serving throughput stay within the Phase 0 threshold
- The engine is the execution path the server actually runs, with no parallel unused module (PR refactor: batch-native engine step API with BatchScheduler on top #2217)
- Phase 4a deferral (comment below): Gemma 3, Llama 4 and
model_ownedmove ontoattend/attend_batchedsorg -n 'is_paged_backed\(\)|is_paged_decode\(\)' src/modelsreturns nothing, with the Gemma 3 decode-undo log kept onRotatingKVCache::update(PR refactor: batch-native engine step API with BatchScheduler on top #2217:KvAttention,RotatingKVCache::attendandChunkedKVCache::attendwrite throughupdate_and_fetch,cache::attend_batched_rowsreplaces themodel_ownedhelpers; recorded in ADR 0009) - Phase 4a deferral (comment below): qwen3_5, qwen3_moe and muse_glimmer drop the ignored
DecodeBatchContextargument (PR refactor: batch-native engine step API with BatchScheduler on top #2217:DecodeBatchContextand the coreDecodeStorageBackendare removed, the trait entry isforward_batched_with_ids)
Verification
make verify # macOS gate
make verify-fmt verify-test-cuda # Linux/CUDA gate
cargo test --profile test-fast --features cuda -p mlxcel-core engine
cargo test --profile test-fast --features cuda --lib server::batch
MLXCEL_SDPA_DETERMINISTIC=1 make <Phase 0 parity target> MODEL=<Qwen3-1.7B-4bit path>References verified against main at fc21784 (2026-10-07).
Activity
inureyes commentedon Oct 7, 2026
Scope addition from Phase 4a (#2171, PR #2210, ADR 0008).
Phase 4a moved the dense-versus-paged attention choice into the cache (
KVCache::attend,cache::attend_batched,MlaLatentCache::attend) for Qwen3, Llama 3, Helium and the DeepSeek absorbed decode. ADR 0008 defers the remaining families to this phase, so they are in scope here:src/models/gemma3.rs, still readsis_paged_backed()), Llama 4, and themodel_ownedadapter (src/models/model_owned.rs, twois_paged_backed()reads) move ontoattend/attend_batchedand stop branching on storage.DecodeBatchContextargument that they ignore; drop it when the batched entry is reshaped forEngine::step.Acceptance:
rg -n 'is_paged_backed\(\)|is_paged_decode\(\)' src/modelsreturns nothing, and the Gemma 3 decode-undo log (#2182) keeps logging every speculative write (the rotating cache'supdatepath stays the write path).refactor: move KV attention dispatch into the cache (#2210)
refactor(engine): sample and finish decode rows inside Engine::step
test(engine): pin batch-of-one parity and add the direct engine parit…
inureyes commentedon Oct 7, 2026
Decision on the "
LanguageModel::forwarddelegates to the batched entry" criterion (PR #2217, ADR 0009).The batch-of-one rule is implemented at the engine, not by inverting the trait.
Engine::stepis the only decode entry for every row count: the scheduler's synchronous single and batched paths both call it, and inside it B=1 runs the family's single-row forward (forward_with_sequence_id), which is what the providedforward_batchedalready does atb == 1and what the measured B=1 throughput rests on.LanguageModel::forwardstays the required method. Inverting it (a providedforwarddelegating to a required batched entry) is a mechanical change across 224 implementations, about 40 of which override a batched entry, and no caller other than the engine chooses between the two forwards, so the inversion would add churn without removing a code path.The equivalence the criterion asks for is pinned where it can break:
src/models/single_row_batch_parity_tests.rs(ignored, real checkpoint,MLXCEL_SDPA_DETERMINISTIC=1) runs the single-row forward and the one-row batched forward of Qwen3 and Llama 3, the locally available families that override the batched entry, and requires byte-identical logits at each of four decode steps. Both pass on the GB10 host (models/mlx/qwen3-1.7b-4bit,models/mlx/llama-3.2-1b-instruct-4bit). A family whose one-row batched path ever differs makes itsb == 1delegate to the single-row forward.refactor(models): attend through the cache in Gemma 3 and Llama 4
fix(engine): keep the per-row chain for a batch of one
test(models): pin the Int8 and Turbo4Delegated attend routes, add rea…
refactor(engine): share the piece input builder and tidy the review LOWs
fix(engine): count a fused-draw eval failure once
test(engine): pin the fused draw's short readback as a shared BatchEval
docs: align ADR 0009 and the scheduler comments with the final engine
refactor: batch-native engine step API with BatchScheduler on top (la…