Repository navigation
perf: offer prompt lookup on the server without slowing plain decode #2255
Description
Part of #2166.
Depends on #2229 (it reuses that issue's per-row submit/draw entries only if convenient; the shared verify round below is independent).
Split out of #2229, whose Part 2 this was. The maintainer asked that offering prompt lookup on the server must not slow plain decode, so this issue carries speed gates next to the correctness criteria. Implement everything in one PR, run the local gate once at the end, and keep pushes to a minimum.
Problem / Background
PromptLookupDrafter (src/lib/mlxcel-core/src/speculative/prompt_lookup_drafter.rs, DrafterKind::PromptLookup at src/lib/mlxcel-core/src/drafter/mod.rs:152) runs only in mlxcel generate --prompt-lookup (run_prompt_lookup, src/commands/generate.rs:2024, over DirectEngine::generate_with_drafter at src/lib/mlxcel-core/src/engine/speculative.rs:145, which uses Engine::verify / Engine::commit_appends at speculative.rs:53 and :73). The server cannot offer it: SpeculativeDispatch::resolve (src/server/speculative_dispatch.rs:223) returns Disabled when no --draft-model is set (:227), before it reads --draft-kind, and with a draft model it rejects prompt-lookup as unrecognised (:247). The burst arms in src/server/batch/speculative_burst.rs are per-family adapters around MtpGenerator / DFlashGenerator, and is_kind_specific (speculative_dispatch.rs:399) knows only MTP and DFlash.
Current Behavior
Speculative dispatch and the lookahead pipeline are mutually exclusive today. BatchScheduler::lookahead_params (src/server/batch/scheduler/decode_tick.rs:330) returns None whenever should_dispatch_speculative() (src/server/batch/scheduler/config.rs:623, which is is_kind_specific()), so a naive prompt-lookup arm that set that flag would make every plain token synchronous. The retired CLI loop measured the lookahead overlap at about 12 percent of decode time. DirectEngine already solves this for the CLI: pipelined_plain_round (src/lib/mlxcel-core/src/engine/speculative_plain.rs:45) pipelines one-token rounds once Drafter::pipelines_plain_rounds (drafter/mod.rs:1001, prompt-lookup impl at prompt_lookup_drafter.rs:198) says a proposal is unlikely, and when a lookup finds a proposal while a step is in flight it submits no next forward, reads the in-flight token, calls retract_draft (prompt_lookup_drafter.rs:202), and verifies synchronously on the next round, so the switch costs no forward.
Proposed Solution
- Flag:
mlxcel-serverandmlxcel serveaccept--draft-kind prompt-lookupwith no--draft-model(a draft model with it isSpeculativeDispatchError::InvalidKind: "prompt-lookup takes no draft model").--draft-block-size NsetsPromptLookupConfig::max_draft; every other field isPromptLookupConfig::default()(src/lib/mlxcel-core/src/speculative/prompt_lookup.rs:131). AddSpeculativeDispatch::PromptLookup { config: PromptLookupConfig }, check it before thedraft_model_pathearly return, listprompt-lookupin the unrecognised-kind message, and printspeculative=prompt-lookup (max_draft=N, policy=P)fromsummary()(speculative_dispatch.rs:318).is_kind_specific()stays false forPromptLookup, soshould_dispatch_speculative()stays false and the burst path is never entered; the scheduler checks a separateprompt_lookup_config()accessor instead.mlxcel generatekeeps--prompt-lookup; its--draft-kindrejection message (src/cli/speculative_args.rs:177) is unchanged. - Shared round: factor the verify round out of
DirectEngine::speculate(speculative.rs:213) into oneEngine-level function that both callers use: given a sequence'sStepRow, its current token and a proposal, runEngine::verify, sample or argmax every position through the per-row chain in order,commit_appendsthe accepted prefix plus the target's token,unwind_appendsthe rest, and return the emitted tokens with theirRowOutcomes and the accepted count. Also factor the eligibility rule (SpeculativeRunError::NotTrimmable,SamplerFeedbackState,NeedsHiddenStates, plusprompt_lookup_unsupported_reasonatprompt_lookup.rs:243) into one predicate the scheduler calls per sequence. - Scheduler arm: at most one verify round per scheduler tick per prompt-lookup sequence, not a run-to-completion burst. At prefill completion an eligible sequence (text-only, no structured output, no logprobs, no mirostat / adaptive-p / extended chain, the shared eligibility predicate true) gets a
PromptLookupDrafteron itsSequenceInfo, bound and primed withprefill_from_target_hidden(prompt_lookup_drafter.rs:129). On each decode tick a row whose drafter proposes nothing, or whose verify round the gate below declines, joins the regularEngine::stepbatch; a row with an accepted-by-gate proposal (capped so it never passesmax_tokens) runs the shared round on its own, its tokens go through the same finish step and streaming as every other row, andaccept_verified_tokensreports the outcome. An ineligible request decodes plainly with adebuglog naming the reason, asshould_burst_for_sequence(speculative_burst.rs:398) does. - Lookahead stays on for proposal-free ticks (replaces perf: pipeline penalty and DRY requests in the engine's B=1 client #2229's "keep the pipeline off under speculative dispatch"). Mirror
pipelined_plain_roundat batch level inrun_decode_tick(decode_tick.rs:284) andpipelined_steady_decode(:581): (1) while a lookahead step is in flight, before submitting the step n+1 prime, ask every gated-in prompt-lookup row's drafter for a proposal on its committed tokens; (2) if none proposes, prime and pipeline exactly as today; (3) if any proposes, submit no prime, read and commit step n,retract_draftthose proposals, and run the next tick synchronously: proposing rows run the shared verify round, every other row takes the synchronous batched step; (4) re-prime (maybe_prime_lookahead,:478) only once no row proposes and every prompt-lookup row'spipelines_plain_rounds()is true. Where a prime has already been submitted when a proposal appears (admission, completion), tear it down withdiscard_lookahead(:456) /apply_lookahead_trim(:400) and the existinglookahead_teardown_positionsrules.lookahead_paramsdrops its blanket speculative check only forPromptLookup; MTP and DFlash keep it. - Verify gate (verify rounds must not lower batched throughput): each proposing row runs its own verify forward, so with many concurrent rows a tick would cost one forward per proposing row plus the batched decode. A row runs a verify round only when (a) its drafter's governor grants a non-zero budget (
DraftGovernor::budget,prompt_lookup.rs:354, already 0 when the Graded or Gated policy sees recent acceptance below its cutoff), and (b) the tick's active decode batch is at most a new server option--prompt-lookup-max-batch N, whose default is the largest batch at which measurement (c) below shows no aggregate loss. A row declined by (b) does not calldraft_blockthat tick (or retracts the call), so the governor does not record a miss, and its drafter keeps observing committed tokens so it resumes when the batch shrinks. - Paged storage: a multi-token verify forward cannot take the paged single-token kernel and goes through the pool intercept and gather (
docs/adr/0001-paged-attention-gather-vs-fused-kernel.mdmeasured batch-4 gather at 2 to 3 times contiguous SDPA cost past 4096 tokens). Measure verify cost on paged storage at 8192 tokens (measurement (d)). If it erases the gain, skip verify rounds for paged sequences past a measured context length (a named constant next to the gate, with the measurement cited in its doc comment); if it does not, document in the module docs that no limit applies and why. - Rejected: a run-to-completion burst like the DFlash arm. It holds the worker for the whole request and stalls every concurrent row (the head-of-line block perf(server): tick-cooperative speculative decoding to remove the burst head-of-line block #734 removed for MTP). The engine entries work on the scheduler's own pooled sequence, so the round's state (the drafter and its counters) lives on
SequenceInfoacross ticks. - Rejected: batching several prompt-lookup verify rounds into one forward (out of scope here; the verify gate limits the per-row cost instead).
Scope
In scope: src/server/speculative_dispatch.rs (+ tests), the server CLI args for --prompt-lookup-max-batch, src/server/batch/scheduler/ (decode tick, lookahead gate, SequenceInfo), the shared verify round and eligibility predicate in src/lib/mlxcel-core/src/engine/speculative.rs, scripts/bench_serving_concurrency.py (a --prompt-style plain|copy option: copy wraps the synthetic paragraph from build_prompt as "Repeat the following paragraph exactly: ..."), results under docs/benchmark_results/.
Out of scope: llama-server's --spec-type ngram-* spellings (still rejected by src/cli/spec_compat_args.rs); batched verify forwards; vision and structured-output requests (they decode plainly).
Implementation Notes
- Reuse: the verify round and acceptance accounting of
speculate, moved rather than copied, somlxcel generate --prompt-lookupand the server run the same code;pipelined_plain_round's switch rule; the scheduler'sstep_rows::step_row,apply_row_outcomes,apply_lookahead_trimanddiscard_lookahead;PromptLookupStats(prompt_lookup.rs:575) for the reported counts. - Edge cases: a proposal that would pass
max_tokensis truncated (asspeculatedoes); a token inside an accepted block that hits EOS, a stop string or the context bound ends the row there and unwinds the positions after it; a request cancelled mid-round is finalized after the round; a--max-kv-sizetrim runs before the verify forward as before a step; a request admitted while a lookahead is in flight follows the existing admission teardown before its first lookup. - Error handling: a verify forward error fails only that row (
FinishReason::Error, fix(server): catch MLX C++ aborts at the decode FFI boundary so a backend throw fails the request, not the process #822) and unwinds its appends; a failed lookahead teardown followsfail_desynchronized_sequenceas today. - Greedy exactness on real checkpoints: a multi-token verify forward can round differently from the one-token step at a near-tie (the prompt-lookup module's "Greedy exactness" section); the real-checkpoint equality check runs under
MLXCEL_SDPA_DETERMINISTIC=1on a prompt whose reply copies the prompt text, and a mismatch must be shown to be such a near-tie (logit gap printed) or it is a defect.
Acceptance Criteria
-
SpeculativeDispatch::resolvetests (src/server/speculative_dispatch_tests.rs) for--draft-kind prompt-lookupalone (resolves toPromptLookup,should_dispatch_speculative()false), with a draft model (error), and with--draft-block-size 4(max_draft == 4). - Scheduler test on a stub model whose reply repeats the prompt: a prompt-lookup sequence emits the same tokens as plain decode, accepted proposals are counted, and a concurrent plain request keeps getting a token every tick while the prompt-lookup request decodes.
- Scheduler test: with the flag on and no row proposing,
lookahead_paramsreturnsSomeand the tick is pipelined (fails on current main, where speculative dispatch disables it); with a proposing row the next tick is synchronous and no prime was submitted for it. - Scheduler test: with an active batch above
--prompt-lookup-max-batch, no verify round runs and the drafter's miss count is unchanged. - Real checkpoint:
mlxcel-server -m models/mlx/qwen3-1.7b-4bit --draft-kind prompt-lookupreturns the same greedy text as the same server without the flag for a copy-heavy prompt, underMLXCEL_SDPA_DETERMINISTIC=1, withdraft_n_accepted > 0anddraft_kind == "prompt-lookup"in the/completionresponse's speculative timings (SpeculativeTimings,src/server/types/native_completion.rs:66). -
mlxcel generate --prompt-lookupproduces the same output as before the refactor (speculative_plain_tests.rsand the prompt-lookup tests pass unchanged). - Speed, measured once at the end of the implementation run on a quiet host by the orchestrator (together with perf: pipeline penalty and DRY requests in the engine's B=1 client #2229's measurement), 3 to 5 interleaved rounds, Qwen3-1.7B 4-bit: (a) plain chat prompt with little copying, B=1, flag on vs off: decode no worse than -1.0 percent; (b) copy-heavy prompt, B=1: decode speedup and accepted tokens per verify reported; (c)
bench_serving_concurrency.pyat concurrency 4 and 8, flag on vs off,--prompt-style plainand--prompt-style copy: aggregate tok/s with the flag no worse than without beyond the round spread, and the chosen--prompt-lookup-max-batchdefault justified by these numbers; (d) 8192-token prompt on paged storage (--decode-storage-backend paged): verify cost reported and whether the paged limit engages. Results committed underdocs/benchmark_results/. - Integrated: the flag works through
mlxcel-serverandmlxcel serveend to end, and--helplistsprompt-lookupand--prompt-lookup-max-batch.
Verification
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --features cuda -- -D warnings
gpu-lock run --tag test -- cargo test --profile test-fast --features cuda -p mlxcel-core --lib engine:: -- --test-threads=1
gpu-lock run --tag test -- cargo test --profile test-fast --features cuda --lib -- --test-threads=1 server::speculative_dispatch server::batch
gpu-lock run --tag verify -- make verify-test-cuda
cargo build --release --features cuda --bin mlxcel --bin mlxcel-server
export MLXCEL_SDPA_DETERMINISTIC=1
./target/release/mlxcel-server -m models/mlx/qwen3-1.7b-4bit --draft-kind prompt-lookup --port 18080 &
./target/release/mlxcel-server -m models/mlx/qwen3-1.7b-4bit --port 18081 &
# POST the copy-heavy prompt with temperature 0 to /completion on both ports, diff the content, check timings.draft_n_accepted > 0
# Measurement, once, at the end, on a quiet host, by the orchestrator
gpu-lock run --tag serve-bench -- python3 scripts/bench_serving_concurrency.py --port 18080 --concurrency 4,8 --max-tokens 256 --prompt-style copy
gpu-lock run --tag serve-bench -- python3 scripts/bench_serving_concurrency.py --port 18081 --concurrency 4,8 --max-tokens 256 --prompt-style copy
# repeat with --prompt-style plain, B=1 (--concurrency 1), and --prompt-tokens 8192 against servers started with --decode-storage-backend paged; interleave on/off roundsThe diff must print nothing, and each speed criterion must hold across the interleaved rounds.
Activity
update: pipeline penalty and DRY requests in the engine's B=1 client (#…
update: run prompt-lookup verify rounds in the batch scheduler
docs: document server prompt lookup and add a copy prompt style
update: warm server verify widths and build the n-gram index lazily
fix: cap prompt-lookup proposals at the context bound
test: cover EOS and cancel in prompt-lookup rounds, mark provisional …
update: set prompt-lookup defaults from the GB10 measurement (#2265)
update: offer prompt lookup on the server without slowing plain decode (