Skip to content

refactor: batch-native engine step API with BatchScheduler on top #2172

Closed

Description

@inureyes

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, :874 and :1448.
  • src/server/batch/scheduler/prefill.rs:649, :837, :856, :1008, :1027 and :1248.
  • src/server/batch/scheduler/prompt_cache.rs:982 and :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:

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_state and restore_sequence_state, generate.rs:788-843).
  • Constraints: the scheduler's lookahead pipeline (decode_tick.rs:563-798, maybe_prime_lookahead through pipelined_steady_decode) must keep its overlap, so step needs a split submit and collect form, or a lookahead variant. That form must also carry the speculative-append contract above: the prime forward runs inside DecodeLookaheadAppendScope, and a teardown unwinds up to DECODE_LOOKAHEAD_MAX_SPECULATIVE_APPENDS (2) appends per row, through the pool trim or rewind_decode_appends for a model-owned family. No per-step allocation proportional to batch size beyond what exists today.
  • Error handling: EngineError per sequence becomes FinishReason::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 a Length or Stop recorded on the same tick so the desynchronized state is not donated (perf(scheduler): restore Gemma 3 decode lookahead via ring rewind #2182).

Acceptance Criteria

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

  1. added
    type:refactorCode restructuring without changing functionality
    status:backlogIn the backlog, not yet ready
    area:coremlxcel-core: MLX FFI, primitives, KV cache, layers
    area:inferenceGeneration, sampling, decoding (incl. speculative, DRY)
    area:architectureArchitecture and code structure changes
    on Oct 6, 2026
  2. inureyes commented on Oct 7, 2026

    @inureyes
    MemberAuthor

    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:

    • Gemma 3 (src/models/gemma3.rs, still reads is_paged_backed()), Llama 4, and the model_owned adapter (src/models/model_owned.rs, two is_paged_backed() reads) move onto attend / attend_batched and stop branching on storage.
    • qwen3_5, qwen3_moe and muse_glimmer still take a DecodeBatchContext argument that they ignore; drop it when the batched entry is reshaped for Engine::step.

    Acceptance: rg -n 'is_paged_backed\(\)|is_paged_decode\(\)' src/models returns nothing, and the Gemma 3 decode-undo log (#2182) keeps logging every speculative write (the rotating cache's update path stays the write path).

  3. added and removed
    status:backlogIn the backlog, not yet ready
    on Oct 7, 2026
  4. inureyes commented on Oct 7, 2026

    @inureyes
    MemberAuthor

    Decision on the "LanguageModel::forward delegates 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::step is 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 provided forward_batched already does at b == 1 and what the measured B=1 throughput rests on. LanguageModel::forward stays the required method. Inverting it (a provided forward delegating 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 its b == 1 delegate to the single-row forward.

  5. added a commit that references this issue on Oct 12, 2026
    1a0ff24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:architectureArchitecture and code structure changesarea:coremlxcel-core: MLX FFI, primitives, KV cache, layersarea:inferenceGeneration, sampling, decoding (incl. speculative, DRY)priority:mediumMedium prioritystatus:doneCompletedtype:refactorCode restructuring without changing functionality

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions