Problem / Background
Prompt lookup (mlxcel generate --prompt-lookup, PR #2074) speeds up replies that copy their prompt but still costs throughput on replies with little to copy. The PR measured break-even (1.01x) on free writing on an M4 Pro; on GB10 the same kind of reply runs below plain decoding.
Measured 2026-10-02 on GB10 (CUDA), release build of the PR #2074 head after its hardening commit (a1fa9e7); greedy, whole-call tok/s including prefill, median of 3 runs, run-to-run spread mostly under 2%. Speedup over plain decoding:
| Model |
edit_fn |
edit_json |
summary |
write |
story (800 tok) |
| Qwen3-1.7B-4bit |
1.10x |
1.37x |
0.79x |
1.06x |
0.84x |
| Qwen3-4B-4bit |
1.21x |
1.55x |
1.32x |
0.93x |
0.87x |
| Qwen3-8B-4bit |
1.36x |
1.81x |
1.50x |
0.97x |
0.90x |
| Llama-3.1-8B-Instruct-4bit |
1.51x |
1.94x |
0.92x |
0.96x |
0.92x |
Current Behavior
The losing rows share few drafted rounds, almost all rejected (story: acceptance 0.07 to 0.18, tokens per forward 1.02 to 1.06; 35 to 73 drafted rounds of 371 to 748). Each drafted round pays three costs in PromptLookupGenerator::generate (src/lib/mlxcel-core/src/speculative/prompt_lookup.rs):
- A verify block wider than one token, run synchronously: the host reads the argmax before building the next round (
prompt_lookup.rs:761-831).
- A broken pipeline: when a proposal appears while a plain step is in flight, the loop submits nothing new, reads the in-flight token synchronously, emits it, and looks the proposal up again next round (
prompt_lookup.rs:724-746).
- A slow restart:
plain_streak resets to 0, and pipelining resumes only once it exceeds PLAIN_ROUNDS_BEFORE_PIPELINE (2), so two synchronous plain rounds follow every drafted round (prompt_lookup.rs:254, prompt_lookup.rs:747-759).
On GB10 a forced-synchronous plain step costs 17% on Qwen3-1.7B (MLXCEL_FORCE_SYNC=1: 180 to 150 tok/s), and the 1.7B story works out to about 2.4 plain steps lost per drafted round. The DraftGovernor constants (MISSES_BEFORE_COOLDOWN 3, cooldown MIN_COOLDOWN 4 doubling to MAX_COOLDOWN 32, budget 2 * ema + 2; prompt_lookup.rs:240-322) and the pipelining threshold were chosen from a 36-point grid on an M4 Pro, where verify and sync are relatively cheaper.
Proposed Solution
Candidate directions, each to be measured on both Metal and CUDA before any default changes:
- Keep the pipeline running when a proposal appears while a step is in flight: verify from the in-flight token instead of draining it.
- Require a minimum proposal length or recent acceptance before breaking the pipeline.
- Backend-specific governor thresholds derived from a measured verify-to-step cost ratio.
Decision criterion: ship the direction (or combination) that meets every acceptance criterion below on both backends; if more than one does, prefer the smaller change to the round loop.
Scope
In scope: the decode round loop and DraftGovernor in src/lib/mlxcel-core/src/speculative/prompt_lookup.rs, its tests in prompt_lookup_tests.rs, and the benchmark harness and results under docs/benchmark_results/.
Out of scope: first-use kernel costs per verify width, which made short replies much worse (0.65x) and were fixed in PR #2074 by warming every verify width before the timed run (warm_up_verify_widths, prompt_lookup.rs:544). Server-side prompt lookup is also out of scope.
Implementation Notes
- Reuse: the existing
in_flight / async_eval pipelining in the same loop and the PromptLookupStats counters; keep MLXCEL_FORCE_SYNC as the kill switch (prompt_lookup.rs:696).
- Constraints: cache trim accounting (
trim_caches with draft.len() - accepted) must stay exact if a verify starts from an in-flight token; samplers that need the emitted history (needs_history) stay synchronous.
- Edge cases: a proposal on the last allowed token (
remaining <= 1), EOS read from the in-flight token, and --prompt-lookup-no-adaptive.
Acceptance Criteria
Verification
make release-cuda # GB10; `make release` on Apple Silicon
make test-fast-cuda FILTER="-p mlxcel-core --lib prompt_lookup"
./target/release/mlxcel generate --model <model> --temp 0 -n 400 -p "<prompt>"
./target/release/mlxcel generate --model <model> --temp 0 -n 400 -p "<prompt>" --prompt-lookup
Run the five prompts on all four models, median of 3, and diff the greedy outputs against the pre-change prompt-lookup outputs.
Refs #2074
Problem / Background
Prompt lookup (
mlxcel generate --prompt-lookup, PR #2074) speeds up replies that copy their prompt but still costs throughput on replies with little to copy. The PR measured break-even (1.01x) on free writing on an M4 Pro; on GB10 the same kind of reply runs below plain decoding.Measured 2026-10-02 on GB10 (CUDA), release build of the PR #2074 head after its hardening commit (a1fa9e7); greedy, whole-call tok/s including prefill, median of 3 runs, run-to-run spread mostly under 2%. Speedup over plain decoding:
Current Behavior
The losing rows share few drafted rounds, almost all rejected (story: acceptance 0.07 to 0.18, tokens per forward 1.02 to 1.06; 35 to 73 drafted rounds of 371 to 748). Each drafted round pays three costs in
PromptLookupGenerator::generate(src/lib/mlxcel-core/src/speculative/prompt_lookup.rs):prompt_lookup.rs:761-831).prompt_lookup.rs:724-746).plain_streakresets to 0, and pipelining resumes only once it exceedsPLAIN_ROUNDS_BEFORE_PIPELINE(2), so two synchronous plain rounds follow every drafted round (prompt_lookup.rs:254,prompt_lookup.rs:747-759).On GB10 a forced-synchronous plain step costs 17% on Qwen3-1.7B (
MLXCEL_FORCE_SYNC=1: 180 to 150 tok/s), and the 1.7B story works out to about 2.4 plain steps lost per drafted round. TheDraftGovernorconstants (MISSES_BEFORE_COOLDOWN3, cooldownMIN_COOLDOWN4 doubling toMAX_COOLDOWN32, budget2 * ema + 2;prompt_lookup.rs:240-322) and the pipelining threshold were chosen from a 36-point grid on an M4 Pro, where verify and sync are relatively cheaper.Proposed Solution
Candidate directions, each to be measured on both Metal and CUDA before any default changes:
Decision criterion: ship the direction (or combination) that meets every acceptance criterion below on both backends; if more than one does, prefer the smaller change to the round loop.
Scope
In scope: the decode round loop and
DraftGovernorinsrc/lib/mlxcel-core/src/speculative/prompt_lookup.rs, its tests inprompt_lookup_tests.rs, and the benchmark harness and results underdocs/benchmark_results/.Out of scope: first-use kernel costs per verify width, which made short replies much worse (0.65x) and were fixed in PR #2074 by warming every verify width before the timed run (
warm_up_verify_widths,prompt_lookup.rs:544). Server-side prompt lookup is also out of scope.Implementation Notes
in_flight/async_evalpipelining in the same loop and thePromptLookupStatscounters; keepMLXCEL_FORCE_SYNCas the kill switch (prompt_lookup.rs:696).trim_cacheswithdraft.len() - accepted) must stay exact if a verify starts from an in-flight token; samplers that need the emitted history (needs_history) stay synchronous.remaining <= 1), EOS read from the in-flight token, and--prompt-lookup-no-adaptive.Acceptance Criteria
prompt_lookup_tests.rscover any changed governor or pipelining rule.docs/benchmark_results/.Verification
Run the five prompts on all four models, median of 3, and diff the greedy outputs against the pre-change prompt-lookup outputs.
Refs #2074