Skip to content

Add env-gated prompt-lookup speculative decoding for greedy generation#396

Open
rwl4 wants to merge 1 commit into
antirez:mainfrom
rwl4:prompt-lookup-draft
Open

Add env-gated prompt-lookup speculative decoding for greedy generation#396
rwl4 wants to merge 1 commit into
antirez:mainfrom
rwl4:prompt-lookup-draft

Conversation

@rwl4

@rwl4 rwl4 commented Jun 11, 2026

Copy link
Copy Markdown

Add env-gated prompt-lookup speculative decoding for greedy generation

This adds a prompt-lookup draft source for greedy decoding. When enabled
(DS4_PROMPT_LOOKUP_DRAFT=1), the CPU searches the existing token history for a
repeat of the current 4-gram, proposes the tokens that followed it as a draft
(up to 7), and verifies [anchor | drafts] in one batched pass with the existing
target-model speculative verification path (metal_graph_verify_suffix_tops +
frontier rollback). Only target-agreed tokens commit; when no match is found the
path falls back to ordinary one-token decode. No new GPU kernels, no second
model, no MTP behavior changes (the speculative state buffers now allocate when
either MTP or prompt-lookup is active).

The feature is off by default and greedy/temp-0 only. It is workload-
dependent by design: it pays off when the output revisits text that exists in
context (code edits, file reproduction, quoting, structured/templated output —
i.e. coding-agent workloads), and stays out of the way otherwise.

Validation (M5 Max, Flash, full-resident; details in speed-bench/prompt-lookup-{results,validation}.md)

  • agent-style mixed workload (explain a fix + output the corrected file): 1.47×
  • copy workload: 2.24× (97% acceptance, ~6.7 tokens/verify pass)
  • code-edit workload: 1.66× (91% acceptance)
  • prose / long-context recall / guaranteed-no-match workloads: ~1.0× with
    microsecond-scale scan overhead (e.g. 0.11 ms total over a 96-token run at 9K ctx)
  • output byte-identical to the same greedy decode loop across all
    validation workloads; correctness stress green: EOS arriving mid-draft, partial accepts,
    ambiguous repeated n-grams, context-wall and sub-wall runs, no-match prompts
  • --long-context passes with the feature enabled
  • depth default (7 drafts → an 8-position verify batch) chosen by ablation: it is
    the small-batch mat-vec kernel ceiling (2..8 tokens); batch 9+ falls onto the
    prefill matmul path and roughly doubles the pass cost. DS4_PROMPT_LOOKUP_MAX
    (1..15) tunes it.

Known bounded worst case (documented in the validation note): an adversarial
short output over highly ambiguous repeated n-grams can lose ~20% to misfiring
verify passes; output remains byte-identical to the same greedy decode loop.
A miss-backoff is possible future
work, as are a hybrid with the MTP drafter for novel text and server/agent
wiring.

For comparison on the same probes, --mtp --mtp-draft 2 measures ~1.06×, which
matches the project's own description of the MTP path as a slight speedup —
the two draft sources are complementary rather than competing (MTP drafts novel
text where lookup finds no match).

Env-gated (DS4_PROMPT_LOOKUP_DRAFT=1, greedy/temp-0 only): draft the continuation
from the session's own token history -- the tokens that followed the most recent
earlier occurrence of the current 4-gram -- and verify [anchor | drafts] in one
batched target pass using the existing speculative verify and frontier-rollback
machinery (metal_graph_verify_suffix_tops + spec_frontier_*), committing only
target-agreed tokens. The spec shadow-state and spec_logits allocations now key on
(MTP || prompt-lookup); no new GPU kernels, no second model, no MTP behavior
changes. Proposals truncate at EOS; no-match cycles fall back to the ordinary
one-token decode.

Default draft depth is 7 so the fused verify pass (8 positions) stays on the
small-batch mat-vec kernels (2..8 tokens, metal/dense.metal); batch 9+ falls onto
the full prefill matmul path and roughly doubles the pass cost (measured
80 -> 167 ms), which erases the win. DS4_PROMPT_LOOKUP_MAX (1..15) tunes depth;
the ablation in speed-bench/prompt-lookup-validation.md justifies the default.

Measured on an M5 Max (Flash, full-resident, details and falsification pass in
speed-bench/prompt-lookup-results.md and speed-bench/prompt-lookup-validation.md):
copy-heavy ~2.2x, code edits ~1.7-1.9x, a realistic agent turn 1.47x; prose,
long-context recall, and guaranteed-no-match prompts ~1.0x with microsecond-scale
scan overhead. Output is byte-identical to the same decode loop across all
validation workloads, including EOS-through-drafts, partial accepts, ambiguous
n-grams, and context-wall stress; --long-context passes with the feature enabled.
Shipped MTP (--mtp-draft 2) measures ~1.06x on the same probes. Known bounded
worst case: adversarial short repeated-ambiguity can cost ~20% on tiny outputs
(documented; miss-backoff is future work).
@rwl4
rwl4 force-pushed the prompt-lookup-draft branch from c93cfb5 to 359638f Compare June 11, 2026 19:44
@unsaltedbutter-ai

Copy link
Copy Markdown
Contributor

@antirez

Tested on Apple M3 Ultra (60-core GPU, 256 GB, Metal), macOS 26.5.
Model: DeepSeek V4 Flash (Q4 routed experts), --ctx 512000. Greedy, thinking off. Same two workloads as my #371 test:

  • extract — 1 request: multi-page PDF → structured JSON rows (~5,400 tokens; copy-heavy, output echoes the document).
  • classify — 20 requests: ~960-token prompt → one short JSON category (short novel output).

A/B: DS4_PROMPT_LOOKUP_DRAFT=1 vs main (--mtp-draft 2). Cold disk-KV cache each side. Each cell is main → #396. Note: dispatch is if (mtp) … else if (prompt-lookup), so prompt-lookup only runs with MTP off — this is prompt-lookup vs MTP draft-2, not stacked on it.

workload decode tok/s (↑ faster) wall clock (↓ faster)
extract 25.96 → 27.80 (+7.1%) 233.8 s → 219.4 s (−6.2%)
classify 31.39 → 26.75 (−14.8%) 6.79 s → 7.38 s (+8.7%)

Clean split: extract is faster on both columns, classify is slower on both.

Prompt-lookup commit rate ~68% (extract-dominated). Win where output copies the input (extract); regression on short novel output (classify), where it mostly misses and also gives up MTP's speedup. Both correct (extract reconciled, 20/20 labels) — but extract output wasn't byte-identical (5,382 vs 5,401 tokens), unlike the MTP path.

Useful for copy-heavy / extraction servers. On a mixed workload it's a net loss vs MTP draft-2, since it can't run alongside MTP.

@gsrunion

gsrunion commented Jul 9, 2026

Copy link
Copy Markdown

ROCm / gfx1151 (Strix Halo) validation — works, +23% on agent workloads, byte-identical.

Tested this PR cherry-picked onto main (80ebbc3) together with #446/#498/#381/#385, built with make strix-halo, ROCm 7.2.4, on a Strix Halo box (Radeon 8060S, 128GB unified, 124GiB GTT) serving DeepSeek-V4-Flash IQ2XXS-w2Q2K-AProjQ8-SExpQ8-OutQ8-chat-v2 (81GB, full residency).

Method: ds4-server on a test port, identical greedy request (3.1k-token "rewrite every handler in this module" code-edit prompt, think:false, temperature 0, 640 tokens), A/B with/without DS4_PROMPT_LOOKUP_DRAFT=1.

plain lookup
decode avg 15.25 tok/s 18.71 tok/s (+23%)
late (echo-heavy) chunks ~15.0 23.2 tok/s
output byte-identical (2791 chars, verified)

Stats line: attempts=101 no_match=19 first_miss=1 verify_passes=81 drafted=561 committed=539 accept=96.1% tokens/pass=6.65 scan=0.04 ms total

So the backend-shared verify path works fine on ROCm. Two notes: (1) ds4-bench doesn't exercise this path (only cli/server are wired), which cost me an hour of confusion — maybe worth a line in the PR description; (2) I've been running it in production since, no issues.

@OPS-NeoRetro OPS-NeoRetro left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rwl4 Well done! You used an AI agent to write most of the code and to also create writeups, but I think this is one of your first PRs. You should implement some of the changes that I recommended and also discuss more about your findings when reimplementing this for DeepSeek V4 and eventually, GLM-5.x.

Comment thread ds4.c
Comment on lines +11063 to +11065
if (spec_state) {
g->spec_logits = ds4_gpu_tensor_alloc((uint64_t)16 * DS4_N_VOCAB * sizeof(float));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is reasonable

Comment thread ds4.c
g->mtp_enabled = enable_mtp;
/* The speculative verify/rollback buffers serve both the MTP drafter and
* the prompt-lookup drafter; allocate them when either is active. */
const bool spec_state = enable_mtp || prompt_lookup_draft_enabled();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reasonable

Comment thread ds4.c
Comment on lines +26712 to +26716
#ifdef DS4_NO_GPU
(void)eos_token;
snprintf(err, errlen, "GPU support is not compiled in");
return -1;
#else

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a CPU fallback or else this is going to be a GPU-only or Metal-only PR

Comment thread ds4.c
Comment on lines +26643 to +26654
/* Prompt-lookup drafter: propose the tokens that followed the most recent
* earlier occurrence of the checkpoint's last DS4_PL_NGRAM tokens. Host-only,
* a backward memcmp scan of the token history (sub-millisecond even at very
* long contexts; the CPU is idle during decode). */
#define DS4_PL_NGRAM 4
/* Array bound; the effective depth defaults to 7 so the fused verify pass
* (anchor + drafts = 8 positions) stays on the small-batch mat-vec kernels,
* which cover 2..8 tokens (metal/dense.metal); batch 9+ falls onto the full
* prefill matmul path whose fixed cost eats the speculation win. Tunable via
* DS4_PROMPT_LOOKUP_MAX (1..15; spec_logits holds 16 rows). */
#define DS4_PL_MAX_DRAFT 15

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can move those #defines into ds4.h or the first lines of the file so people who have problems or want to tune the greedy decoder can easily tune it because it's a compile-time constant

Comment thread ds4_server.c
Comment on lines +10421 to +10438
} else if (temperature <= 0.0f &&
getenv("DS4_PROMPT_LOOKUP_DRAFT") != NULL) {
/* Prompt-lookup drafting covers greedy decode spans, which includes
* forced-greedy tool-call payloads on otherwise sampled requests.
* Committed tokens flow through the same per-token stop/stream
* handling below, like the MTP batch path. */
ntok = ds4_session_eval_prompt_lookup_argmax(s->session,
token,
max_tokens - completion,
ds4_token_eos(s->engine),
toks,
(int)(sizeof(toks) / sizeof(toks[0])),
err,
sizeof(err));
if (ntok < 0) {
finish = "error";
break;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good greedy decoding implementation

Comment thread ds4_cli.c
Comment on lines +500 to +516
} else if (cfg->gen.temperature <= 0.0f &&
getenv("DS4_PROMPT_LOOKUP_DRAFT") != NULL) {
cli_dist_busy_set(cfg, true);
ntok = ds4_session_eval_prompt_lookup_argmax(session,
token,
max_tokens - generated,
ds4_token_eos(engine),
toks,
(int)(sizeof(toks) / sizeof(toks[0])),
err,
sizeof(err));
cli_dist_busy_set(cfg, false);
if (ntok < 0) {
fprintf(stderr, "ds4: decode failed: %s\n", err);
ds4_session_free(session);
return 1;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the logic from ds4-server.c

Comment thread ds4.h
Comment on lines 265 to +275
int ds4_session_eval_speculative_argmax(ds4_session *s, int first_token,
int max_tokens, int eos_token,
int *accepted, int accepted_cap,
char *err, size_t errlen);
/* Greedy prompt-lookup speculative eval (DS4_PROMPT_LOOKUP_DRAFT=1): same
* contract as ds4_session_eval_speculative_argmax, drafting the continuation
* from the session's own token history instead of an MTP head. */
int ds4_session_eval_prompt_lookup_argmax(ds4_session *s, int first_token,
int max_tokens, int eos_token,
int *accepted, int accepted_cap,
char *err, size_t errlen);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both of these go hand-in-hand

@robotnursenyc

Copy link
Copy Markdown

Measurements for this PR on M3 Ultra 256 GB (60-core bin), Flash q4-imatrix, greedy, ctx 32768, merged onto the #555 branch. (Disclosure: AI-assisted — Claude driving the box; branch passes --server, --metal-kernels, --logprob-vectors.)

Workload Baseline Prompt-lookup Notes
extract/copy-heavy 37.4 t/s 54.6 t/s (+46%) 85.7% acceptance, 6.0 tok/verify
classify w/ repetitive output 37.2 45.3 (+22%) drafts from output history win
novel prose 37.8 37.5 (neutral) 220/220 no-match; scans cost 0.02 ms total

Greedy output byte-identical to baseline in all three (same SHA-256). The no-match path is effectively free — the risk is only miss-heavy matching workloads burning verify passes.

Two follow-ups on our branch (https://github.com/robotnursenyc/ds4/tree/dsv4-speculative-stack), offered if useful:

  1. Adaptive economics guard (commit b6e3ce5): a verify pass costs ~4 baseline tokens of weight streaming, so we track an EMA of per-event value in baseline-token units and latch prompt-lookup off per session when it goes negative, re-probing every 256 tokens; the server then falls back to the MTP drafter while latched (commit 4808b68 prefers PL over MTP for greedy spans since measured upside is much larger). This bounds the downside on adversarial workloads without giving up the copy-heavy win.

  2. A server-side finding that affects this PR's reach (commit 66b50d5): with think-mode enabled, ds4_server.c forces temperature = DS4_DEFAULT_TEMPERATURE (1.0) for the whole request, which disables every greedy speculative path — this PR and the MTP drafter — for think-mode chat requests, and silently overrides an explicit client temperature: 0 for the final answer. Restoring client sampling once the thinking span closes re-enables speculation for the answer span: measured server answer-span chunks 44.7–47.8 t/s vs 34.5 before, thinking spans unchanged. If that behavior change is wanted upstream we'd propose it flag-free as the default for explicit client temp<=0, per AGENT.md.

With #468-style per-position verify checkpointing also applied (see comment there), partial accepts drop from ~180 ms to ~112 ms and the extract case reaches 56.6 t/s.

@OPS-NeoRetro

Copy link
Copy Markdown

@rwl4 have you added the CPU fallback path? If you don't add a CPU fallback, or a non-Metal fallback sometime later before @antirez merges this, then please rename this PR to "Metal: Add env-gated prompt-lookup speculative decoding for greedy generation" if you only support Metal or "Metal/ROCm/CUDA: Add env-gated prompt-lookup speculative decoding for greedy generation" if you support Metal, CUDA and ROCm backends.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants