Skip to content

feat: add ADR 0007, CLI/server parity harness and B=1 decode bench - #2194

Merged
inureyes merged 6 commits into
mainfrom
feature/issue-2167-parity-harness-adr
Oct 7, 2026
Merged

inureyes merged 6 commits into
mainfrom
feature/issue-2167-parity-harness-adr

Conversation

@inureyes

@inureyes inureyes commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

Summary

Phase 0 of epic #2166. ADR 0007 records the batch-native engine design and the CLI-vs-server defaults table, two tools measure where the two decode paths stand today, and a baseline run on GB10 fills the rows the ADR decides by measurement. No decode behavior changes.

What is in it

  • ADR 0007 (docs/adr/0007-unified-batch-native-engine.md, indexed in docs/adr/README.md):
  • make engine-parity (mlxcel-engine-parity) runs one prompt and SamplingConfig through CxxGenerator and through the in-process server scheduler at B=1, dense and paged.
    • It also runs a prompt-cache miss vs hit, recording each side's prefill partition.
    • Per pair it prints identical or the first divergent index and both token ids, plus the paged kernel launch count on the paged row.
    • --expect-identical exits 1 on divergence, and the tool refuses to run without MLXCEL_SDPA_DETERMINISTIC=1.
  • make bench-engine (mlxcel-bench-engine) times bench_decode's CxxGenerator call next to the server worker driven in-process.
    • HTTP and tokenization are outside the timed region.
    • It runs at 256 and 8192-token prompts, with --prefill-chunk (both paths) and --decode-storage (server).
    • scripts/engine_bench_rounds.py runs interleaved rounds with a rotating order, null arms, a quiet-host gate and a per-run timeout.
  • src/server/engine_probe builds the server the way start_server does. The prompt-cache store setup moved verbatim into resolve_prompt_cache_store so both share it, and the long-prompt corpus moved out of bench_decode so both benches synthesize the same prompt.

Baseline (GB10, docs/benchmark_results/unified-engine-baseline-gb10-2026-10-07.md)

  • Server single-stream decode today is 8 to 23 percent below the CLI (Qwen3-1.7B 0.77x at 256 tokens, 0.92x at 8192; Llama-3.2-1B 0.90x at both). Almost all of it is paged storage at B=1: with dense storage the server path is within 1.5 percent of the CLI.
  • Prefill chunk: 512 raised TTFT at 8192 tokens by 8.4 to 29.4 percent against 2048, on both paths and both models. Every null range was within ±1.9 percent, and decode did not move.
  • Storage: paged decode at B=1 was 6.5 to 23.0 percent slower than dense in every cell, with the paged kernel confirmed running. Every null range was within ±1.1 percent.
  • Parity: under MLXCEL_SDPA_DETERMINISTIC=1, CLI, server-dense and server-paged are identical over 400 tokens for both models, greedy and seeded with penalties and DRY. Only the prompt-cache hit diverges: on Qwen3 greedy at token 0, and seeded at token 11 on Qwen3 and token 25 on Llama. That divergence is refactor: one prefill planner and one pad-trim hook for CLI and server #2170's first target.

Changes during review

  • Paged attention reporting: a "paged" B=1 run could actually be dense attention for families that own their KV state. The tools now report paged kernel launches and flag a paged row that ran dense.
  • CUDA graph setup: the in-process server now applies the same main-thread CUDA-graph disable start_server applies.
  • Rounds script: arm order now rotates. A per-run timeout and a reserved -null suffix guard were added.
  • Prompt validation: too-short prompts are rejected up front.
  • Tests: unit tests added for report formatting, argument validation and the rounds script.

Test plan

  • cargo test --profile test-fast --features cuda --lib server::engine_probe (14). Bin tests: engine_parity 18, bench_engine 11. tests/test_engine_bench_rounds.py 12.
  • cargo clippy --profile test-fast --features cuda --lib --tests --bins -- -D warnings and cargo fmt --check
  • Real checkpoints, functional:
    • make engine-parity on qwen3-0.6b, qwen3-1.7b, llama-3.2-1b and gemma-3-1b (the last confirming the dense-attention annotation).
    • The bench and the rounds script exercised end to end.
  • Baseline and A/B measurements run once on a quiet host under gpu-lock, raw records committed under docs/benchmark_results/data/unified-engine-baseline-gb10-2026-10-07/

Closes #2167

Phase 0 of epic #2166. Before the CLI and server decode paths merge into one engine, record the target design, the divergence we start from, and the single-stream throughput to protect.

- ADR 0007 names the engine API (Engine, KvStore with dense and paged storage, PrefillPlan, RowSampler, FinishStep), maps phases #2168 to #2176 onto it, supersedes ADR 0004's KV/scheduler deferral, defines parity on ROCm and Metal, and holds the CLI-vs-server defaults table. Semantic rows are decided with reasons; the prefill chunk and single-sequence storage rows and the regression threshold list candidates and the deciding measurement, marked TBD for the measured baseline.
- mlxcel-engine-parity (make engine-parity) runs one prompt and SamplingConfig through CxxGenerator and the in-process server scheduler at B=1, dense and paged, plus a prompt-cache miss vs hit, and prints the first divergent token per pair; --expect-identical exits 1 on divergence.
- mlxcel-bench-engine (make bench-engine) times bench_decode's CxxGenerator call next to the server worker driven in-process, with --prefill-chunk and --decode-storage knobs; scripts/engine_bench_rounds.py runs interleaved A/B rounds with a null arm.
- src/server/engine_probe builds the server exactly as start_server does; the prompt-cache store setup is extracted into resolve_prompt_cache_store for that, and the long-prompt corpus moves out of bench_decode so both benches share it.

No decode behavior changes. Verified on qwen3-0.6b-4bit, llama-3.2-1b-instruct-4bit and qwen3.5-0.8b-4bit (functional runs only); engine_probe and server::startup unit tests, clippy and fmt pass.

Refs #2167
@inureyes inureyes added status:review Under review type:test Test related changes priority:high High priority area:architecture Architecture and code structure changes area:benchmark Benchmark harness and performance measurement (bench_*.sh, /update-benchmarks) labels Oct 7, 2026
The parity harness and the B=1 bench labelled a server run "Paged" whenever the scheduler resolved paged storage. Model-owned KV families (Gemma 3, Llama 4, Qwen 3.5) resolve to paged storage but decode a lone sequence through their dense caches, with only the block table paged, so their (c) row silently compared dense attention. On gemma-3-1b-it-4bit the paged run launched zero paged kernels; on qwen3-0.6b-4bit it launched 476.

ServerEngineRun now carries paged_decode_launches, the delta of mlxcel_core's process-wide paged decode counters across the request. The harness appends it to the (c) row and marks a zero as dense attention; the bench records it per server row and warns when a paged row ran dense.

ServerEngine::start also runs the main-thread CUDA graph hoist start_server runs (Gemma 4, #688). The rounds script rotates the arm order per round so a drift across a round does not land on one arm. ADR 0007 states the paged-kernel caveat, the full auto-to-paged condition and the per-row seed scope (first token only) as the code has them.

Validated with clippy and engine_probe tests (12) on the cuda feature, and functional runs of the harness on qwen3-0.6b-4bit and gemma-3-1b-it-4bit and the rounds script on the server path.

Refs #2167
@inureyes

inureyes commented Oct 7, 2026

Copy link
Copy Markdown
Member Author

Implementation Review Summary

Intent

Phase 0 of #2166: ADR 0007 with the CLI-vs-server defaults table, a CLI/server parity harness and a B=1 decode bench that drive the real CxxGenerator and BatchScheduler, with no decode behavior change.

Findings Addressed

  • A paged (c) run could not be told apart from dense attention. Model-owned KV families (Gemma 3, Llama 4, Qwen 3.5) resolve to paged storage but decode a lone sequence through their dense caches. On gemma-3-1b-it-4bit the "Paged" row launched zero paged kernels. The run now records paged_decode_launches from mlxcel_core::cache::paged_batch_decode_stats, the harness prints it on the (c) row, and the bench writes it per server row (f2d1f79) (MEDIUM)
  • ServerEngine::start skipped the main-thread CUDA graph hoist start_server runs for Gemma 4 (bug(gemma4/cuda): decode collapses to <pad> after 1-3 tokens (single-query maskless attention + quantized GEMV); mlx_lm decodes coherently #688) (f2d1f79) (MEDIUM)
  • engine_bench_rounds.py ran the arms in the same order every round. The order now rotates per round (f2d1f79) (MEDIUM)
  • ADR 0007 corrections: the auto-to-paged condition, the paged-kernel caveat for the storage A/B, and seed scope (per-row reseed happens at the first token only) (f2d1f79) (LOW)

Remaining Items

  • --expect-identical exits 1 both on divergence and on errors, including the missing MLXCEL_SDPA_DETERMINISTIC refusal, so a gate cannot tell the two apart (LOW). Left as is because the ADR documents exit 1.
  • The prefill partition on the cache rows is predicted from the scheduler's own rule (history_boundary_len), not observed (LOW).

Verification

  • All stated requirements implemented
  • No placeholder/mock code remaining
  • Integrated into project code flow
  • Project conventions followed
  • Existing modules reused where applicable
  • No unintended structural changes
  • Tests pass

@inureyes

inureyes commented Oct 7, 2026

Copy link
Copy Markdown
Member Author

Security and performance review

No CRITICAL, HIGH or MEDIUM findings, so nothing was committed.

  • resolve_prompt_cache_store is a verbatim move: same position in start_server (after the router early return, before ModelProvider creation), same capacity defaults, hybrid-SSM APC rule and metrics wiring. No validation, auth or limit step is skipped.
  • Visibility widening is crate-internal (pub(super) for warmup_model and resolve_prompt_cache_store, pub(crate) for history_boundary_len). engine_probe is referenced only by the two new bins and bench_decode; no route, router or startup path reaches it.
  • set_var("MLXCEL_PREFILL_CHUNK") in bench_engine runs before initialize_runtime and before any thread exists, so it is sound. ServerEngine::start calls maybe_disable_cuda_graphs_for_model_for_path on the calling thread, the same posture start_server has.
  • No production hot path changed. bench_decode's prompt synthesis and context cap are behavior-identical after the move.
  • engine_bench_rounds.py builds argv lists (no shell); arm flags go through shlex.split. Every input is operator-supplied.

LOW, left open:

  • src/bin/bench_engine.rs:128: with --path both --prefill-chunk N the env var also reaches the in-process server worker, where the Gemma 4 MTP target prefill reads it (only under speculative serving). --path server does not set it, so those two arms can differ for MTP models.
  • engine_bench_rounds.py: an arm the user names X-null collides with the generated null arm for X, and subprocess.run has no timeout, so a hung bench blocks the round.
  • engine_parity.rs: an empty or one-token prompt (--no-chat-template -p "") gives a zero-length prime or CLI prompt. The run errors out instead of reporting it.

Checks run: cargo check and clippy -D warnings (--lib --tests --bins, cuda, test-fast) are clean, and cargo test --lib server::engine_probe passes 12 of 12. No GPU runs.

engine_bench_rounds.py ran every benchmark process with no time limit, so a hung model load or decode stalled a round indefinitely. Each process now runs under --run-timeout seconds (default 3600, 0 disables) and a timeout fails the round with the captured output and a pointer to the flag.

A user arm named like a generated null arm (for example dense-null next to dense) shared its records and summary row with it and corrupted the noise floor, so arm names ending in -null are now rejected before any output file is opened.

Adds tests/test_engine_bench_rounds.py covering arm name checks, timeout, failure and missing-record handling, and a rotating-schedule run against a stand-in binary. docs/benchmarks.md and ADR 0007 mention the new flag and the reserved suffix.

Validation:
- python3 -m unittest tests/test_engine_bench_rounds.py: 12 tests pass

Refs #2167
mlxcel-engine-parity built a zero-length priming request for the prompt-cache hit case when the rendered prompt was a single token, and failed deep inside the scheduler. An empty --prompt is now rejected before any model loads, and a rendered prompt shorter than two tokens (one when --no-prompt-cache-case is given) is rejected right after tokenization with the reason and the remedy.

The pure logic in the new binaries is now unit tested: argument defaults and parsing, case construction, the --expect-identical determinism check and exit gate, divergence rows including n/a pairs and token-0 divergence, the text report and JSON output, benchmark measurement records, the side-by-side summary, CSV header and quoting, and the MLXCEL_PREFILL_CHUNK conflict check (which also rejects --prefill-chunk 0). The report and summary printers now build their lines in a function the tests call.

Validation:
- cargo fmt --all -- --check: clean
- cargo clippy --profile test-fast --features cuda --lib --tests --bins -- -D warnings: clean
- cargo test --profile test-fast --features cuda --lib server::engine_probe: 14 pass
- cargo test --profile test-fast --features cuda --bin mlxcel-engine-parity: 18 pass
- cargo test --profile test-fast --features cuda --bin mlxcel-bench-engine: 11 pass

Refs #2167
The baseline run on GB10 (5 interleaved rounds, null arms, quiet-host gate) fills the rows ADR 0007 decides by measurement:

- Prefill chunk: 2048. At 8192 tokens a 512 chunk raised TTFT by 8 to 29 percent on both paths and both models; decode did not move.
- Single-sequence KV storage: dense. Paged decode at B=1 was 6 to 23 percent slower in every cell, with the paged kernel confirmed running; paged stays the batched backend.
- Regression threshold: 1.0 percent, from the widest CLI null-arm decode delta (0.92 percent).

The baseline also shows today's server single-stream decode 8 to 23 percent below the CLI, almost all of it from paged storage at B=1, and the parity harness finds the CLI, server-dense and server-paged paths identical under MLXCEL_SDPA_DETERMINISTIC=1 for Qwen3-1.7B and Llama-3.2-1B over 400 tokens, with only the prompt-cache hit diverging.
@inureyes inureyes added status:done Completed and removed status:review Under review labels Oct 7, 2026
@inureyes
inureyes merged commit 4c44e31 into main Oct 7, 2026
24 of 26 checks passed
@inureyes
inureyes deleted the feature/issue-2167-parity-harness-adr branch October 7, 2026 05:17
inureyes added a commit that referenced this pull request Oct 7, 2026
The test file added by #2195 (`src/execution/memory_estimate_inflight_tests.rs`) lacks the project license header, so the `license headers` job fails on `main` (run 37569698228) and on every PR CI run merged with it, for example #2194.

This inserts the standard header with `python3 scripts/insert_apache_header.py`; `--check` now passes. No code change.

The other failure on recent `main` runs, `WebUI installed artifact`, is unrelated: the GB10 runner could not obtain the host `gpu-lock` within 600 seconds because development benchmarks on that host held it (`issue-2190-bench-31b-b2`, `issue-2191-throughput`).
inureyes added a commit that referenced this pull request Oct 8, 2026
## Summary

End-of-run measurement for epic #2166 (ADR 0007's regression threshold and the speed questions the phases left open), on GB10 against two release builds: the pre-epic `CxxGenerator` build (4c44e31, PR #2194) and the final engine build (30ceea2, PR #2225). Adds `docs/benchmark_results/unified-engine-final-gb10-2026-10-08.md`, its raw records and drivers under `docs/benchmark_results/data/unified-engine-final-gb10-2026-10-08/`, and an index entry in `docs/benchmarks.md`. No code changes.

## Results

- `mlxcel generate` (the engine's `DirectEngine` client) decodes within the 1.0 percent threshold in every cell: +0.82 and +0.31 percent on Qwen3-1.7B (256 and 8192-token prompts), +0.51 and +0.19 percent on Llama-3.2-1B. TTFT at 256 tokens is about 1 ms higher (+4.4 percent unresolved on Qwen3, +9.0 percent on Llama).
- The server engine at B=1 with dense storage, the path `mlxcel run` and chat take since #2173, is 1.14 to 2.05 percent below the pre-epic CLI and misses the threshold in three of four cells. Phase 0 measured the same scheduler overhead before the epic, but `run` used `CxxGenerator` then, so `run` users see a 1 to 2 percent decode regression.
- The server's default single-stream configuration (paged at `--parallel 4`) decodes as before; its 8192-token TTFT dropped 19 percent (Qwen3) and 12 percent (Llama) with the 2048-token chunk.
- Batched serving: unchanged on Qwen3 defaults; Llama with Turbo4 KV is 16 and 18 percent higher at concurrency 4 and 8.
- Prefill chunk under live decode (four streams, one 8192-token admission, Llama): 2048 gives about 2.0 s admitted TTFT and 128 ms live-stream p95 ITL; 512 gives about 3.9 s and 56 ms. A policy trade, not a speed win.
- Gemma 3 paged vs dense under concurrency: dense 1 to 2 percent higher with overlapping ranges, unresolved; default unchanged.

## Test plan

- [x] Single-stream: `scripts/engine_bench_rounds.py`, 5 interleaved rounds with a null arm and the quiet-host gate, both builds through `pick.sh`, under the host `gpu-lock` with no other GPU work
- [x] Serving: `scripts/bench_serving_concurrency.py` and `scripts/bench_mixed_step_admission.py`, 3 alternating rounds per arm

Part of #2166
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:architecture Architecture and code structure changes area:benchmark Benchmark harness and performance measurement (bench_*.sh, /update-benchmarks) priority:high High priority status:done Completed type:test Test related changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

test: ADR 0007 plus CLI/server parity and B=1 decode benchmark harness

1 participant