Skip to content

epic: one batch-native engine for CLI and server decode #2166

Closed
9 of 9 issues completed
Closed

Description

@inureyes

Problem / Background

The CLI and the server share no decode loop. mlxcel generate, mlxcel run and the chat REPL decode through CxxGenerator (src/lib/mlxcel-core/src/generate.rs:1914, :2233, :2416, :2683, reached through MlxInferenceSession in src/lib/mlxcel-core/src/session.rs:47-60). mlxcel-server, mlxcel serve and --no-batch decode through BatchScheduler (src/server/model_worker.rs:1103-1115: the legacy worker is the scheduler at max_batch_size=1). ADR 0004 and src/backend/mod.rs:37-43 record this split as deferred work.

What is shared today: the single-row model forward, the per-token sampler (sample_token_optimized[_with_state]), build_sampling_config (src/execution/sampling.rs:87), EOS merging, the streaming detokenizer, and the MTP/DFlash round loops.

What is implemented twice:

  • The post-sample finish step (EOS, history push, stop strings, generation and context bounds, max_tokens, loop detection, cache-clear cadence): five server copies and four CLI loops.
  • The per-row sampling step, the SamplerState lifecycle, and the token-history ordering invariant.
  • Prefill chunking, padding and trim: chunk 2048 on the CLI (generate.rs:282) vs 512 on the server (src/server/config.rs:1172), and two trim hooks (trim_internal_caches at generate.rs:652, trim_sequence_state at generate.rs:675).
  • Sampling-default resolution, token-bias composition, KV-mode application, reasoning splitting, and DFlash target setup.
  • Attention kernels: the CLI's dense cache runs MLX fused SDPA, while pool-backed server decode runs mlxcel's paged kernels (src/models/qwen3.rs:354 branches on cache.is_paged_backed()).

Consequences seen so far:

Goal

Follow the llama.cpp design (maintainer decision, 2026-10-07). Build one batch-native engine in the core: a single sequence is a batch of one, one KV abstraction has dense and paged storage behind it, and one implementation does sampling and finishing. The server runs on that engine. mlxcel run and chat become clients of the in-process server engine, and a thin raw-completion generate (the llama-completion analog) also runs on the engine.

Reference design, verified on llama.cpp HEAD 65840ed (2026-10-06):

  • The core forward is batch-native: llama_decode(llama_batch) with multi-sequence KV, where a single sequence is B=1.
  • Sampling with history lives in one sampler object (common_sampler_* over libllama's llama_sampler chain), and every tool uses it.
  • llama-cli is a thin client of llama-server: tools/cli/CMakeLists.txt links llama-server-impl, tools/cli/cli-server.h runs llama_server() in a thread on a random local port, and tools/cli/cli-context.cpp posts to /v1/chat/completions over SSE.
  • History: cli: new CLI experience ggml-org/llama.cpp#17824 (2025-12) moved the CLI onto the server context, and the old loop became llama-completion for raw use. cli : move to HTTP-based implementation ggml-org/llama.cpp#24948 (2026-07) made the CLI an HTTP client, with --server-base for a remote server.

Rejected alternative: routing the CLI through today's scheduler as-is. The CLI would only inherit the server path's different numerics (paged kernels, 512-token chunks) and the scheduler's per-step overhead. Unifying the engine first is what makes "same input, same output" hold.

Program invariants:

  • Once Phase 5 lands, greedy output for the same request under MLXCEL_SDPA_DETERMINISTIC=1 is identical across the CLI and the server engine (B=1, dense and paged).
  • No phase may regress single-stream decode throughput beyond the threshold Phase 0 sets.
  • Every phase ends integrated into the real code paths. No phase may leave a standalone module unused.

Out of scope: new model families, kernel tuning, the router, OpenXLA/IREE.

Sub-issues

The ### Phase N headings below are execution waves for /epic-impl, which accepts only positive integers. Each item names its program phase (0, 1, 2, 3, 4a, 4b, 5, 6), plus a final ROCm verification phase. Program Phases 1, 2 and 3 run in parallel after Phase 0. Phase 4a follows Phase 3. Phase 4b needs 4a, 1 and 2. Phase 5 follows 4b, and Phase 6 follows 5. The final ROCm verification (wave 7) follows Phase 6 and runs on the gfx1151 ROCm host, because the epic is implemented on a non-ROCm machine.

Phase 1

Phase 2

Phase 3

Phase 4

Phase 5

Phase 6

Phase 7

ROCm constraints

Not verified on the implementing machine. The epic is implemented on a non-ROCm host, so ROCm-only tests (files under #![cfg(feature = "rocm")], and tests that skip when the backend has no kernel port) do not run there. All ROCm verification is deferred to #2192.

Behavior to preserve. Phases 2, 4a, 4b and 0 must not drop these while moving code (#2192 lists the tests that pin each):

Sequencing with in-flight ROCm work.

ROCm parity definition. ADR 0007 (Phase 0) should state what the parity invariant means on ROCm: MLXCEL_SDPA_DETERMINISTIC is read only by the CUDA overlay (src/lib/mlx-cpp/patches/mlx/backend/cuda/scaled_dot_product_attention.cpp), and ROCm dense decode (MLX SDPA) and paged decode (HIP paged ports) differ in reduction order. #2192 measures it.

Acceptance Criteria

  • All sub-issues completed and merged
  • The Phase 0 parity harness shows greedy output identical across mlxcel generate, mlxcel run and the server (B=1 dense and paged) under MLXCEL_SDPA_DETERMINISTIC=1
  • Single-stream decode stays within the Phase 0 threshold of the pre-epic CxxGenerator baseline, at short and long context
  • docs/CONTINUOUS_BATCHING.md and the CLI docs describe a single engine, and the token-exactness disclaimer is replaced
  • test(rocm): verify the unified engine on ROCm after the batch-native engine epic #2192 ROCm verification passes (make verify-rocm green on the final main, ROCm parity recorded)

Technical Considerations

ADR 0007 (Phase 0) supersedes ADR 0004's deferral of the KV, batching and scheduler abstraction, and it names the engine API. The decision table on CLI vs server defaults in ADR 0007 must be confirmed by the maintainer before Phase 5 relies on it. Per project policy, verify each phase on a real checkpoint and take performance measurements once, at the end of a phase, on a quiet host.

Two mechanisms already on main constrain the phases. Model-owned families can rewind decode appends (LanguageModel::supports_decode_lookahead_rewind and rewind_decode_appends, src/lib/mlxcel-core/src/generate.rs:698 and :712, backed by the RotatingKVCache undo log in src/lib/mlxcel-core/src/cache/decode_undo.rs, #2182), which is what keeps Gemma 3 on the scheduler's lookahead pipeline; Phases 1, 3, 4a, 4b and 6 must preserve it. With the prompt cache on, classic serving splits a chat prefill at the history boundary (capture_history_boundary_snapshot, src/server/batch/scheduler/prompt_cache.rs:1088, #1143), and the B=1 Gemma 4 MTP burst mirrors that split through history_boundary_split (:1212, #2185) because the partition alone changes greedy output. The #2185 parity harness (docs/benchmark_results/data/gemma4-mtp-cuda-gb10-2026-10-07/harness/) is prior art for Phase 0, and the split is a lead for Phase 3's prompt-cache divergence.

References verified against main at fc21784 (2026-10-07).

Activity

  1. added
    type:refactorCode restructuring without changing functionality
    area:architectureArchitecture and code structure changes
    area:inferenceGeneration, sampling, decoding (incl. speculative, DRY)
    on Oct 6, 2026
  2. 9 remaining items

  3. inureyes commented on Oct 8, 2026

    @inureyes
    MemberAuthor

    Epic progress: Phases 0 to 6 merged, ROCm verification (#2192) pending

    Gate on final main (30ceea22, GB10)

    • Contract targets: make verify-versions verify-kernel-dtype-keys verify-kernel-port-dispatch verify-llama-compat pass.
    • make verify-test-cuda: 12156 passed, 2 failed, 388 ignored.
      • The two failures are ssm_update_parity_tests::ssm_update_kernel_matches_graph_step_bf16 and ssm_update_kernel_keys_on_a_log_dtype.
      • They fail identically on the pre-epic build 4c44e317, and the epic touched neither the test nor the kernel. This is a pre-existing failure, not a regression.

    Parity

    Under MLXCEL_SDPA_DETERMINISTIC=1, greedy and seeded output on Qwen3-1.7B, Llama-3.2-1B and Gemma-3-1B (4-bit) is identical across:

    • mlxcel generate
    • mlxcel run -p
    • the server at B=1, dense and paged
    • the direct engine

    The only divergences are the documented prompt-cache miss-versus-hit rows.

    Performance against the pre-epic CxxGenerator (threshold 1.0 percent)

    • mlxcel generate passes in every cell: +0.19 to +0.82 percent decode.
    • The server engine at B=1 with dense storage misses in all four cells: -1.14 to -2.05 percent. Since refactor: route mlxcel run and chat through the in-process server engine #2173, this is the path mlxcel run and chat take. Phase 0 measured the same scheduler overhead before the epic, but run used CxxGenerator then.
    • Batched serving: unchanged on Qwen3 defaults; Turbo4 KV is 16 to 18 percent higher at concurrency 4 and 8.
    • Prefill chunk 2048 under live decode: halves an admitted 8192-token request's TTFT (about 2.0 s vs 3.9 s) and raises live streams' p95 ITL from 56 ms to 128 ms. This is a policy trade, not a speed win.

    Open follow-ups

    • Bring mlxcel run / chat B=1 decode within the threshold. This means reducing the scheduler's per-tick overhead at one slot.
    • Prefill chunk policy under live decode: for example, 2048 when no other sequence is decoding and 512 when one is.
    • Pipelining for requests with penalties, DRY or feedback samplers in DirectEngine. These currently run synchronously.
    • A server option for the prompt-lookup drafter.
    • Remove the reference-only dense-compat paged kernels.
    • Decide by measurement whether Gemma 3 and Llama 4 should still report supports_paged_decode_backend(). The concurrency measurement was unresolved.
    • The pre-existing CUDA ssm_update_parity_tests bf16 failures.
  4. added and removed on Oct 8, 2026
  5. inureyes commented on Oct 8, 2026

    @inureyes
    MemberAuthor

    Follow-ups from the end-of-run measurement (PR #2226, docs/benchmark_results/unified-engine-final-gb10-2026-10-08.md):

  6. inureyes commented on Oct 10, 2026

    @inureyes
    MemberAuthor

    Split #2229 in two. #2229 now covers only pipelining penalty and DRY requests in DirectEngine (the B=1 client). Server-side prompt lookup moved to #2255, which adds speed gates: lookahead pipelining stays on for ticks with no proposal, verify rounds are gated by batch size and drafter acceptance, paged long-context verify cost is measured, and plain decode with the flag on must stay within -1.0 percent. Both issues' measurements run once at the end of the implementation run, on a quiet host.

  7. inureyes commented on Oct 10, 2026

    @inureyes
    MemberAuthor

    Epic implementation complete

    Follow-ups filed from the end-of-run measurement and reviews, outside this epic:

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:inferenceGeneration, sampling, decoding (incl. speculative, DRY)priority:highHigh 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