Skip to content

update: offer prompt lookup on the server without slowing plain decode - #2262

Merged
inureyes merged 9 commits into
mainfrom
update/issue-2255-server-prompt-lookup
Oct 10, 2026
Merged

inureyes merged 9 commits into
mainfrom
update/issue-2255-server-prompt-lookup

Conversation

@inureyes

@inureyes inureyes commented Oct 10, 2026 •

Copy link
Copy Markdown
Member

Summary

mlxcel serve / mlxcel-server --draft-kind prompt-lookup (no draft model) run prompt-lookup verify rounds per sequence inside the batch scheduler, at most one per tick, while ticks on which no row proposes stay on the lookahead pipeline exactly as without the flag.

  • Dispatch: SpeculativeDispatch::PromptLookup { config, max_batch } resolves before the draft-model early return; a draft model with it is InvalidKind; --draft-block-size sets max_draft; --spec-type none drops it. is_kind_specific() stays false, so the burst path is never entered and the scheduler reads prompt_lookup_config(). New --prompt-lookup-max-batch on both server binaries, classified read-only in the runtime settings schema. mlxcel generate --prompt-lookup and its --draft-kind rejection are unchanged.
  • Shared round: the verify round moved out of DirectEngine::speculate into Engine::verify_round (engine/verify_round.rs), with Engine::verify_rounds_unsupported as the one eligibility rule and Engine::warm_up_verify_widths as the one warmup; the CLI and the scheduler both run them. ADR 0009 amended.
  • Scheduler (scheduler/prompt_lookup.rs): drafter on SequenceInfo, primed at prefill completion; proposing rows verify alone, the rest take the batched step. Switch rule: ask before the step n+1 prime; a proposal commits step n (through try_eval) with no prime, retracts the asks and makes the next tick synchronous; re-prime only when nobody proposed and every gated-in drafter pipelines_plain_rounds(). The module docs state invariants I1 to I8 (append ownership, admission, cancellation, errors), and each scheduler test pins one.
  • Gate: a row verifies only while its governor grants a budget, the active batch is at most --prompt-lookup-max-batch, no unified KV budget is set, and the proposal fits under max_tokens and the context-bound stop. A gated-out row calls no draft_block, so its governor records no miss.
  • Warmup: the first eligible prefill (normally the startup warmup request) runs one verify forward per block width and unwinds it, as the CLI does.
  • Ineligible requests (multimodal, structured output, logprobs, feedback or extended sampler chains, state a trim cannot roll back such as Gemma 3's sliding-window cache) decode plainly with a debug log naming why.
  • scripts/bench_serving_concurrency.py --prompt-style plain|copy.

Provisional values for the end-of-run measurement

  • DEFAULT_PROMPT_LOOKUP_MAX_BATCH = 2 (server/speculative_dispatch.rs): one extra forward per proposing row at most doubles a step at B <= 2. Measurement (c) sets the final value.
  • PROMPT_LOOKUP_PAGED_CONTEXT_LIMIT = None (scheduler/prompt_lookup.rs), no limit, pending measurement (d).

Changes during review

  • Review (2cd467a): server-side verify-width warmup (early requests paid each width's first-use kernel cost mid-decode); the n-gram index is built at the first lookup, so rows the gate declines no longer pay it at prefill.
  • Security (a64431e): proposals capped at the context bound (a prompt ending just under --max-kv-size could make a verify forward append up to 63 positions the stop then discarded); the same bound in the warmup; an unchecked slice in the ask path replaced, since a scheduler panic stops all serving.
  • Finalizer (953b03b): scheduler tests for a stop token inside an accepted block and a cancel during a round.
  • Local CUDA gate (9f5b5a8): prompt_lookup_max_batch classified in the runtime settings schema (server_config_schema_classifies_all_112_fields).

Test plan

All on GB10 under gpu-lock, --profile test-fast --features cuda, each CUDA test with -- --test-threads=1.

  • New: speculative_dispatch_tests (5), engine::verify_round_tests (3), scheduler_prompt_lookup_tests (11, a cycle-model dense Llama, KV offset checked against committed tokens after every tick; proposal_free_ticks_stay_pipelined fails on main)
  • fmt; clippy -D warnings (root and mlxcel-core); mlxcel-core engine::, speculative::, drafter::, session, sampling, generate::; root server::batch, server::speculative_dispatch, server::in_process, server::runtime_settings, engine_probe, backend::, cli_input, commands::, mlxcel-server bin; cli_help_consistency, dead_doc_pointers, model_loader_stdout_guard
  • make verify-test-cuda: 13,868 passed; the only failures are the pre-existing SSM bf16 parity tests (chore: post-epic cleanup of compat paged kernels, the model-owned paged flag, and the CUDA SSM bf16 parity failures #2230) and the schema test fixed in 9f5b5a8
  • mlxcel generate --prompt-lookup on Llama-3.2-1B, greedy, 160 tokens: identical to the build before this PR
  • Server pairs, release build, MLXCEL_SDPA_DETERMINISTIC=1, /completion greedy, flag on vs off: Qwen3-1.7B at 140 tokens (auto, dense, paged) and Llama-3.2-1B at 160 tokens (auto, paged) identical for copy and plain prompts, concurrent copy + plain equal their solo outputs, draft_kind prompt-lookup with draft_n_accepted > 0; mlxcel serve identical; Gemma-3-1B identical with prompt lookup declined (non-trimmable sliding-window cache, as on the CLI). At 160 tokens the Qwen3 copy reply diverges once at about token 150 on an exact tie (top two logprobs both -1.625 on the flag-off server)
  • mlxcel-engine-parity -n 400 on Qwen3-1.7B and Llama-3.2-1B: unchanged from update: pipeline penalty and DRY requests in the engine's B=1 client #2256
  • Speed (a) to (d): measured after merge together with perf: pipeline penalty and DRY requests in the engine's B=1 client #2229; results and the final constants land in a follow-up docs PR

Closes #2255

The server could not offer prompt lookup: SpeculativeDispatch::resolve returned Disabled before reading --draft-kind when no drafter checkpoint was set. This is stage A of #2255.

- SpeculativeDispatch::PromptLookup { config, max_batch } resolves before the draft-model early return; a draft model with it is InvalidKind, --draft-block-size sets max_draft, and is_kind_specific() stays false so the burst path is never entered. prompt_lookup_config() is the scheduler's accessor.
- New --prompt-lookup-max-batch on mlxcel serve and mlxcel-server (provisional default 2, DEFAULT_PROMPT_LOOKUP_MAX_BATCH), plumbed into ServerConfig; --spec-type none also drops prompt-lookup; --draft-kind help lists prompt-lookup.
- The verify round moves out of DirectEngine::speculate into Engine::verify_round (engine/verify_round.rs), with Engine::verify_rounds_unsupported as the one eligibility rule; mlxcel generate --prompt-lookup now runs through both.

Validated: mlxcel-core engine::, speculative::, drafter:: and root server::speculative_dispatch tests pass; clippy clean.

Refs #2255
@inureyes inureyes added status:in-progress Currently being worked on type:performance Performance improvements priority:medium Medium priority area:core mlxcel-core: MLX FFI, primitives, KV cache, layers area:inference Generation, sampling, decoding (incl. speculative, DRY) labels Oct 10, 2026
Stages B and C of #2255. Under --draft-kind prompt-lookup an eligible sequence gets a PromptLookupDrafter on its SequenceInfo at prefill completion. Each decode tick asks the gated-in rows for a proposal on their committed tokens: a proposing row runs one Engine::verify_round on its own, the rest take the regular batched step, so concurrent rows keep getting a token every tick. Ineligible requests (multimodal, structured output, logprobs, feedback or extended sampler chains, models a trim cannot roll back) decode plainly with a debug log.

Proposal-free ticks stay on the lookahead pipeline: proposals are asked for before the step n+1 prime; a proposal while a step is in flight commits that step with no prime, retracts the asks and makes the next tick synchronous; a prime after a synchronous tick waits until nobody proposed and every gated-in drafter pipelines plain rounds. Verify rounds run only while the active batch is at most --prompt-lookup-max-batch and no unified KV budget is set; gated-out rows call no draft_block, so no governor miss is recorded. The invariants (append ownership, admission, cancellation, errors) are in the scheduler::prompt_lookup module docs, and each new test pins one. Finished requests report prompt-lookup acceptance counters.

Validated: scheduler_prompt_lookup_tests (cycle-model Llama: plain-equal output, per-tick progress of a concurrent row, pipelined proposal-free ticks, synchronous switch with no prime, max-batch gate, admission teardown), engine verify_round_tests, server::batch and mlxcel-core engine/speculative/drafter suites; clippy clean.

Refs #2255
Stage D of #2255. scripts/bench_serving_concurrency.py gains --prompt-style plain|copy (copy wraps the synthetic paragraph as "Repeat the following paragraph exactly: ...") for the flag-on versus flag-off concurrency measurement. CONTINUOUS_BATCHING.md documents --draft-kind prompt-lookup and --prompt-lookup-max-batch on the servers, speculative-acceptance.md and llama-server-compat.md list the new path and its timings, ADR 0009 records Engine::verify_round and Engine::verify_rounds_unsupported, and CHANGELOG Unreleased gains the entry.

Refs #2255
@inureyes
inureyes marked this pull request as ready for review October 10, 2026 15:24
@inureyes inureyes added status:review Under review and removed status:in-progress Currently being worked on labels Oct 10, 2026
The server never ran the verify-width warmup that mlxcel generate --prompt-lookup runs, so the first requests at each block width paid that width's first-use kernel cost mid-decode (on GB10 more than the rest of a short reply). The loop moves from DirectEngine::warm_up_verify_widths to Engine::warm_up_verify_widths, which unwinds a width's appends before returning an error; the CLI delegates to it, and the scheduler runs it once on the first eligible prefill, before a finish at prefill returns, so the one-token startup warmup request covers it.

PromptLookupDrafter no longer builds its n-gram index at prefill; the first draft_block builds it, as it already extended it. A row that --prompt-lookup-max-batch keeps out of verify rounds no longer pays an index build over its whole prompt at prefill completion. Proposals are unchanged.

Tests: the scheduler warms once (also for a request that finishes at prefill) and leaves the KV offset at the prompt; the drafter builds no index at prefill and proposes what find_draft finds. mlxcel-core engine::, speculative::, drafter::; root server::batch, server::speculative_dispatch, generate commands, cli_help_consistency; clippy -D warnings and fmt pass.

Refs #2255
@inureyes

Copy link
Copy Markdown
Member Author

Implementation Review Summary

Intent

Offer prompt lookup on mlxcel serve / mlxcel-server without slowing plain decode: proposal-free ticks stay pipelined, and verify rounds are gated by the governor and --prompt-lookup-max-batch.

Findings Addressed

  • The server never warmed the verify widths the CLI warms, so early requests paid first-use kernel costs mid-decode. The warmup loop is now Engine::warm_up_verify_widths; the CLI delegates to it, and the scheduler runs it once on the first eligible prefill, which the startup warmup request covers (MEDIUM, 2cd467a)
  • Rows the max-batch gate keeps out still built the full n-gram index at prefill. The index is now built on the first lookup (LOW, 2cd467a)

Remaining Items

  • Rule 4 (stay synchronous until every gated-in drafter has made three plain asks) is kept. It is in the issue, lookahead is per tick at any B, and dropping it trades three synchronous ticks for a commit-without-prime tick plus a delayed proposal. Measurements (a) and (c) should confirm it (INFO)
  • Rule 3 also retracts the empty asks of non-proposing rows, so stats.rounds undercounts. This matches the CLI and is not reported to clients (LOW, cosmetic)
  • Proposals are capped by max_tokens only, not by the context bound. Same as the CLI (LOW)
  • No scheduler-level test for a verify-round eval failure, a cancel mid-round, or EOS inside an accepted block (LOW)

Verification

  • All stated requirements implemented (the speed measurements remain with the orchestrator)
  • No placeholder/mock code remaining (paged limit is an intentionally provisional None)
  • Integrated into project code flow
  • Project conventions followed
  • Existing modules reused where applicable
  • No unintended structural changes
  • Tests pass: mlxcel-core engine::, speculative::, drafter::; root server::batch, server::speculative_dispatch, commands::generate, cli_help_consistency; clippy and fmt. proposal_free_ticks_stay_pipelined fails when lookahead_params is forced back to main's behavior

A proposal was capped by max_tokens only. With --max-kv-size and context shifting off, a client prompt that ends just under the bound and copies itself made the verify forward append up to max_draft - 1 positions past the KV window the plain step that ends the row would hold; the bound then finished the row and those positions were forwarded only to be discarded. ContextBound::verify_room now gives the room left before the context-bound stop, and proposal_budget takes the smaller of it and the max_tokens room, so every forwarded position is used and the forward never exceeds the plain path's peak. The verify-width warmup also skips a prefill without room for the widest block and leaves the warmup to a later one.

ask_prompt_lookup sliced generated_tokens[observed..] unchecked; a row whose drafter ever ran ahead of its tokens would panic the scheduler thread. It now drops the drafter and decodes the row plainly.

Tests: proposals_stop_at_the_context_bound, a_drafter_ahead_of_its_row_decodes_plainly. server::batch 524 pass, clippy and fmt clean.

Refs #2255
@inureyes

Copy link
Copy Markdown
Member Author

Security and performance review (fixes in a64431e).

Fixed:

  • LOW: proposals were capped by max_tokens only. With --max-kv-size and context shift off, a self-copying prompt ending just under the bound made the verify forward append up to max_draft - 1 positions past the plain path's KV peak, all discarded when the bound finished the row. ContextBound::verify_room now caps the budget so the last forwarded position is at most the finishing token. The verify-width warmup skips a prefill without room for the widest block. Dense caches have no fixed capacity and the paged budget was already checked, so this was wasted compute and transient growth, not an overflow.
  • LOW (hardening): ask_prompt_lookup sliced generated_tokens[observed..] unchecked. Not reachable today, but the scheduler has no catch_unwind, so a future break would stop serving. A drafter that runs ahead of its row is now dropped, and the row decodes plainly.

Open:

  • MEDIUM: on paged storage, verify forwards for long copy-heavy prompts take the gather path. PROMPT_LOOKUP_PAGED_CONTEXT_LIMIT stays None until measurement (d) decides. It is bounded by the max-batch gate (default 2) and the governor.
  • LOW: the lazily built n-gram index costs about 120 B per prompt token (about 15 MB at 128K) plus a one-time build on the scheduler thread. It uses SipHash keys and is small next to the KV for that context.

Verified, no change: every new device read goes through try_eval (#822), failed rounds unwind and fail only their own row, request-controlled edge values are safe (empty prompt rejected, length 1, max_tokens 0 or 1), the request speculative.* keys stay inert so clients cannot enable this per request, and drafters see only their own request's tokens.

server::batch 524 pass, clippy and fmt clean.

…values

Add two scheduler-level tests on the stub cycle model. An EOS inside an accepted verify block ends the row at the stop token, stores nothing after it, and leaves the KV at the prompt plus the stored tokens, matching the plain decode with the same stop id. A cancel set before a synchronous verify tick lets the round run to its end, and finalize_completed releases the row after the tick with the I1 state intact. The cycle model has no EOS, so the stop id rides in the request's SamplingConfig. A verify-eval failure test is not added: the harness has no way to make a device read throw without a new production seam, and the engine-level paths are covered in verify_round_tests.

State in the I1 invariant that a stop token inside the block is not stored. Mark DEFAULT_PROMPT_LOOKUP_MAX_BATCH and PROMPT_LOOKUP_PAGED_CONTEXT_LIMIT as provisional pending the end-of-run #2255 measurement, and add the default (2, provisional) to the --prompt-lookup-max-batch help text on mlxcel serve and mlxcel-server.

Validation:
- cargo test --profile test-fast --features cuda --lib scheduler_prompt_lookup_tests: 11 passed
- cargo test --test cli_help_consistency --test dead_doc_pointers: 27 and 2 passed
- cargo clippy --lib --tests --bins --examples -D warnings, cargo fmt --check, insert_apache_header.py --check: clean

Refs #2255
The new ServerConfig field broke server_config_schema_classifies_all_111_fields. It is read-only at runtime (the worker reads it when it builds its scheduler, like the other speculative knobs) and reported as an integer.
@inureyes inureyes added status:done Completed and removed status:review Under review labels Oct 10, 2026
@inureyes
inureyes merged commit 2807850 into main Oct 10, 2026
26 of 27 checks passed
@inureyes
inureyes deleted the update/issue-2255-server-prompt-lookup branch October 10, 2026 17:13
inureyes added a commit that referenced this pull request Oct 10, 2026
## Summary

End-of-run measurement for #2229 (PR #2256) and #2255 (PR #2262) on GB10, and the two values #2255 left provisional, now set from it. Report: `docs/benchmark_results/server-prompt-lookup-gb10-2026-10-11.md`; drivers, raw output and analysis script under `docs/benchmark_results/data/server-prompt-lookup-gb10-2026-10-11/`.

- `PROMPT_LOOKUP_PAGED_CONTEXT_LIMIT`: `None` to `Some(4096)`. A paged sequence stops running verify rounds past 4096 tokens of context and decodes plainly.
- `DEFAULT_PROMPT_LOOKUP_MAX_BATCH`: stays 2; the doc comment and the `--prompt-lookup-max-batch` help now cite the measurement instead of calling it provisional.
- `docs/CONTINUOUS_BATCHING.md` and the CHANGELOG entry state the measured gains and the cost on low-copy chat, and point to #2264.

## Results

**#2229, penalty requests on the CLI path** (`--repetition-penalty 1.1`, 5 rounds with a null arm): against main before #2229 the engine client now decodes 23 % (Qwen3-1.7B, 256 tokens), 16 % (Qwen3, 8192), 31 % (Llama-3.2-1B, 256) and 23 % (Llama, 8192) faster, every round above every round. Against the pre-epic `CxxGenerator` (`4c44e317`) it is equal on Qwen3 at 256 tokens (inside the null range) and 1.5 to 2.7 % faster in the other three cells.

**#2255, server prompt lookup** (Qwen3-1.7B, 256 tokens):

| Criterion | Result |
|---|---|
| (a) plain chat, B=1, flag on vs off, no worse than -1.0 % | **Not met.** -3.49 % (-3.73..-3.22) over 8 chat-template questions x 5 rounds, 25 to 45 % of proposed tokens accepted. Tracked in #2264. |
| (b) copy-heavy, B=1 | +53 to +67 %, 4.2 tokens per verify |
| (c) concurrency 4 and 8, default gate | on matches off in every workload; a gate of 8 loses 13 % on the chat-template copy workload at concurrency 4, so the default stays 2 |
| (d) paged, long prompts | on / off ratio for copy and open question: 2.69x / 1.27x at 1024, 2.79x / 1.64x at 2048, 1.42x / 1.12x at 4096, 1.15x / 0.62x at 8192; dense at 8192: 2.10x / 0.99x |

The flag is opt-in, so decode without it is unchanged.

## Test plan

- [x] clippy `--profile test-fast --features cuda --lib --tests --bins -D warnings`
- [x] `--lib server::batch` (526), `server::speculative_dispatch` (17), `--test cli_help_consistency` (27), `--test dead_doc_pointers`, Apache header check, all under `gpu-lock` with `--test-threads=1`
- [ ] `scripts/ci/check_binary_assets.py` fails on main's two undeclared tarballs from #2250 (`docs/benchmark_results/data/rocm-unified-engine-gfx1151-2026-10-08/`); this PR adds no binary file

Refs #2229, #2255, #2264
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core mlxcel-core: MLX FFI, primitives, KV cache, layers area:inference Generation, sampling, decoding (incl. speculative, DRY) priority:medium Medium priority status:done Completed type:performance Performance improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf: offer prompt lookup on the server without slowing plain decode

1 participant