Skip to content

[Spec Decode] Add suffix decoding for full-attention MLX targets - #1144

Draft
suntp wants to merge 21 commits into
vllm-project:mainfrom
suntp:spec-decode-suffix
Draft

suntp wants to merge 21 commits into
vllm-project:mainfrom
suntp:spec-decode-suffix

Conversation

@suntp

@suntp suntp commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Add suffix speculative decoding to reuse response patterns across requests, without a draft model or additional draft KV cache. On the repeated workload, serving throughput is 2.18× at concurrency 1 and 1.43× at concurrency 8; first-pass concurrency 8 is 0.88×. Performance depends on reuse and acceptance.

Implementation and scope

Reuse vLLM's suffix proposer and Arctic's CPU suffix trees on the existing Metal greedy verifier. Committed history is ingested independently of drafting; request release, preemption and batch removal flush pending history. Request-state ownership isolates reused IDs, and drafts respect context and output limits.

The initial scope is MLX, text-only full-attention targets, plain greedy sampling and synchronous scheduling. Hybrid GDN is unsupported by suffix and remains n-gram-only. The optional dependency is arctic-inference==0.3.0, with macOS installation instructions. CI enables the real-tree tests by default, caches the compiled Arctic wheel, and runs them in the main pytest process. No attention or quantized-model kernel changes are included.

Numerical behavior and validation

The greedy verifier is exact for fixed target logits. As in vLLM's speculative decoding, floating-point and batch-shape effects can change tokens between ordinary and speculative execution, so this PR does not promise identical complete output sequences and adds no batch-invariant mode.

check_parity.py --method suffix is the strict diagnostic: it records complete-token equality and exits nonzero on any difference. On Qwen3-0.6B-4bit it gives 33/40 prompts with identical tokens at B=1, with the same result in two runs, so strict identical-token output is not achieved. In each of the 7 B=1 differences the suffix token is within the target's own top candidates at the first differing position: 3 are exact ties and 4 are near-ties (top-1/top-2 logprob gap at most 0.125; in one case a three-way near-tie within 0.0625). This is consistent with numerical changes from verifying several positions in one forward pass (different execution shape in bf16), but I have not isolated that cause with a controlled same-prefix experiment, and a small logprob gap is not by itself an acceptable-error bound. At B=2, run in a fresh process with a cold suffix tree, it gives 40/40 in two runs (160 drafted / 43 accepted tokens, similar drafting to B=1's 162 / 44). Because B=2 runs in a different execution shape from B=1, this does not show that larger batches are more consistent; it only shows no differences on this prompt set at B=2.

Eight boundary cases cover EOS/stops, request-ID reuse, preemption/resume, output/context limits and worker-level sleep/wake. All 884 recorded committed-token decisions match their serving-logit argmax. History-ingestion and cleanup assertions pass; matched sleep/no-sleep outputs are identical. Preemption/no-preemption and cold/warm-tree comparisons contain numerical divergences, reported separately; these checks do not establish complete-token equivalence across execution schedules. The boundary cases and the focused tests (317 passed / 10 skipped; the skips need Ray) were run on 11871be, which merges main 086e57c into the previous head ae8804c. These were first measured with vLLM 0.31.0+cpu and MLX 0.32.4, then repeated with vLLM 0.32.0+cpu (the version this branch targets) and MLX 0.32.4: the focused tests, both parity modes and all eight boundary cases gave the same results, with byte-identical parity outputs and the same 7 differing B=1 prompts. The serving numbers below were measured with vLLM 0.31.0+cpu only.

Serving performance

Qwen3-8B-4bit, M5 Max 48 GiB, MLX 0.32.4, vLLM 0.31.0+cpu, three draft tokens, prefix caching off. Real vllm serve + vllm bench serve, three paired repetitions with alternating arm order. Each measured pass contains 16 requests × 128 output tokens: ten natural and six repetitive prompts, with 20–91 input tokens. Both arms receive disjoint, input-length-matched warmup requests.

Measured on 11871be (main 086e57c merged into ae8804c), both arms.

Concurrency Workload pass Baseline tok/s Suffix tok/s Paired speedup TPOT ms, baseline → suffix
1 First 90.9 108.7 1.20× 10.8 → 9.4
1 Repeated 90.8 197.5 2.18× 10.8 → 4.3
8 First 344.0 303.9 0.88× 22.5 → 24.4
8 Repeated 339.1 487.9 1.43× 22.8 → 10.4

Values are medians; speedup is the median paired throughput ratio. Concurrency is the client/server request limit, not a fixed scheduler batch size. The first pass can already learn across requests; the repeated pass reuses the same workload. All measured requests complete, with zero swap-out pages during the measurement windows.

Repeated-pass acceptance is 81.1% / 75.2% at concurrency 1 / 8. These gains apply to this short, intentionally reused workload. First-pass concurrency 8 loses 12% throughput. Median TTFT at concurrency 1 rises from about 40–41 ms to 44–47 ms, and at concurrency 8 it falls from about 148–154 ms to 120–131 ms.

Cache memory and limitations

max_cached_requests limits response count, not bytes. In three-process cache-only measurements at depth 24, incremental RSS spans 0.70–9.00 MiB for 128 retained responses and 11.80–127.66 MiB for 2,048, across shared/diverse 64/256-token responses. At cap 128, growth from requests 512 to 2,048 stays within 0–0.5 MiB. These figures include allocator/Python effects and do not represent full-server overhead.

Low-reuse workloads can lose throughput; the concurrency-8 first-pass regression is left as a follow-up. Hybrid GDN, broader suffix feature coverage and an opt-in strict reproducibility mode are outside this integration.

@LxYuan0420 LxYuan0420 mentioned this pull request Oct 18, 2026
9 of 27 tasks
Comment thread docs/speculative_decoding.md Outdated
Follow the repository's spec-decoding standard instead: compare fresh,
independently started spec-off and spec-on runs with the same inputs and
settings via `tools/check_parity.py --method suffix --top-k 5`, and accept
`EXACT`, or a first divergence with mutual `TOP_K_MATCH` membership, with

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please do not weaken the spec-decoding contract to TOP_K_MATCH here. Keep suffix decoding unmerged until fresh spec-off/spec-on greedy runs produce identical token IDs. Any repository-wide contract change needs a separate proposal and evidence.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for calling out the distinction between validation and numerical behavior. I'd like to propose keeping this PR as a minimal integration of vLLM's default suffix behavior, with strict reproducibility handled as a separate, opt-in capability.

The standard greedy verifier and request-lifecycle checks remain unchanged. This does not introduce approximate draft acceptance. vLLM's speculative-decoding documentation explicitly describes floating-point/batch-size variations, and batch invariance is a separate mode with a performance trade-off. Our current local strict implementation also has a measured cost: in a same-base Qwen3-4B-4bit, 4K-input, concurrency-8 service comparison on M5 Max, its target-only throughput was about 5.6% below native main (three paired repetitions). That is a result for this implementation/workload, not a claim that strict reproducibility must always be slower.

The strict kernels and prompt-NAX optimization are kept out of this PR. check_parity.py --method suffix still fails on any complete-token difference; the fresh diagnostic result is 3/4 exact sequences at B=1 and 4/4 at B=2 (strict CLI exits 1 and 0); preemption and cold/warm comparisons also retain non-exact results. I am not treating TOP_K_MATCH as proof of equivalence. The previously reported 14% first-pass concurrency-8 regression remains disclosed with its original benchmark revision and is deferred to a focused follow-up.

Focused checks pass 311 tests (10 skipped). Eight fresh boundary cases pass their functional assertions, and all 1,077 observed committed tokens match the corresponding serving argmax. Matched sleep/no-sleep outputs are exact; this is distinct from cold/warm-tree equality. The PR now explicitly limits support to MLX, text-only full-attention targets, plain greedy sampling and synchronous scheduling. Hybrid GDN and broader suffix features remain out of scope. Marking this ready is a request to review this proposed default-numerics scope, not a statement that strict equality is fixed or that this policy change has already been accepted. I am leaving this discussion unresolved for maintainer agreement; a future strict mode would be proposed independently and disabled by default.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I merged main (086e57c) into the branch (11871be), re-ran the checks and updated the description. The strict diagnostic is unchanged and still exits nonzero on any complete-token difference.

Strict identity is still not met at B=1: check_parity.py --method suffix gives 33/40 identical sequences (same in two runs). At each first difference, the suffix token is among the target's own top candidates (3 exact ties, 4 near-ties with a top-1/top-2 gap ≤ 0.125). I have not isolated the cause with a controlled same-prefix experiment. B=2, run in a separate process with a cold suffix tree, gives 40/40 in two runs. All 884 recorded committed-token decisions match the serving-logit argmax, but that does not replace complete-sequence equality. These results reproduce identically on vLLM 0.32.0+cpu (unit, parity and boundary checks); serving numbers are from 0.31.0.

Since your condition (identical spec-off/spec-on token IDs) is not met, could you say which you prefer: accept this PR as a minimal integration under default numerics, with the remaining differences disclosed, or keep it blocked until I submit a separate correctness-contract proposal (or an opt-in strict mode)?

Comment thread vllm_metal/v1/suffix_proposer.py Outdated
def _stop(self, req_id: str) -> None:
anchor = self._last_len.pop(req_id, None)
state = self._states.pop(req_id, None)
cache = getattr(self._vllm, "suffix_cache", None)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Use self._vllm.suffix_cache directly. The pinned upstream proposer guarantees this field, and _stop needs it for cleanup. This fallback only supports test fakes and can silently skip cleanup.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed and retained in the rebased branch (current HEAD 0bde3a0). _stop accesses self._vllm.suffix_cache directly; the fake cache now implements the real contract, and the regression asserts stopped request IDs and an empty active set. The focused suite passes 311 tests (10 skipped), and fresh lifecycle checks also exercise automatic cleanup and tail ingestion.

@suntp
suntp marked this pull request as draft October 18, 2026 12:21
suntp added 19 commits October 20, 2026 11:39
Wrap vLLM's SuffixDecodingProposer (Snowflake suffix decoding,
arXiv 2411.04975) as a Metal proposer: model-free drafting from suffix
trees over prompts and past responses, learning across requests —
complementary to n-gram, whose matching never leaves one request's
history. Like n-gram there is no draft model and no draft KV cache; the
tree state is plain process memory in the arctic_inference package
(pure C++/nanobind core, no CUDA; sdist-only on PyPI — the macOS build
recipe is documented).

- suffix_proposer.py (new): translate each ProposeContext into the
  InputBatch-shaped stub upstream's engine loop runs off. Two
  Metal-specific decisions: (a) every live decode request keeps a stub
  row, not just drafting ones — upstream archives an active tree the
  first step its id is missing from the batch, so a drafting request
  that cannot draft this step would lose its live tree (rows that never
  drafted never start a tree); (b) trees are fed
  committed-history deltas since the last feeding (anchored at
  prompt_len on first sight, clamped on preemption rollback) rather
  than raw sampled ids.
- model_runner.py: dispatch method="suffix" alongside ngram; the
  unsupported-method error lists it.
- Draft lengths are dynamic per request (bounded by
  num_speculative_tokens); verify is the established greedy span path
  shared with n-gram.

Targets are full-attention paged-attention models; the hybrid GDN gate
stays ngram-only until suffix is validated there (follow-up).

Validation: 8 marshalling tests against a recording fake (no arctic
install needed, CI-safe) plus a real-arctic integration test
(skipif-gated; locally verified); e2e on Qwen3-4B: 63 verify rounds,
2.21 avg committed tokens/round, 0.66 draft acceptance; greedy output
matched the no-spec reference on that run (not guaranteed in general:
verify-shape numerics can flip near-tie logits, as seen with ngram on
Qwen3-8B). CI-equivalent suite green
except three pre-existing environment failures (test_memory_info,
test_mps_attention, one order-dependent GQA routing test) reproduced on
pristine main.

Signed-off-by: suntp <20786998+suntp@users.noreply.github.com>
Pass every request scheduled this step (decode and prefill rows) to
upstream's SuffixDecodingProposer, as the GPU runner does with its whole
InputBatch. Upstream archives any active tree missing from the batch, so
a request recomputing its prefill after preemption lost its live tree,
and a fresh request got no draft on the step its prefill finished.

The final prefill chunk now drafts. When upstream holds no active tree
for an id (first sighting, or archived while absent) the wrapper feeds
the whole response since the prompt boundary, so a resumed request's
tree is rebuilt with its earlier output rather than only the latest
token.

Signed-off-by: suntp <20786998+suntp@users.noreply.github.com>
Upstream's propose asserts the configured num_speculative_tokens, so keep
passing that, then trim each draft to ctx.num_speculative_tokens (and the
per-request cap). With a batch-size-dependent width the wrapper used to
return 4 drafts for a step that scheduled 2.

Signed-off-by: suntp <20786998+suntp@users.noreply.github.com>
Stop the request in arctic's cache on finished ids and on release
(finished, preempted, resumed), guarded by membership in its active set.
Upstream only archives an absent request on its next propose call, so a
finished request's tree stayed active, and out of the global cache, for
as long as the server sat idle.

Signed-off-by: suntp <20786998+suntp@users.noreply.github.com>
…nobs

- The arctic-missing error in our wrapper was unreachable: vLLM's
  SpeculativeConfig validation raises ImportError (pinning
  arctic-inference==0.1.1) before any platform hook runs, so an earlier
  check with our recipe isn't possible. Drop the dead branch; the docs
  now say which error users see, that Metal was tested on 0.3.0, and
  that the package is Apache-2.0.
- Sleep mode: allow suffix alongside ngram. Neither keeps KV of its own;
  suffix trees are host memory keyed by token ids and survive sleep.
- Correct the rationale for keeping all scheduled rows: rows that never
  drafted never start a tree, so the rows protect drafting requests that
  cannot draft this step from being archived.
- Log all four suffix_decoding_* knobs at startup.
- Docs: greedy outputs can diverge from a no-spec run (verify-shape
  numerics, also seen with ngram on Qwen3-8B); compare by first divergent
  position, not an exact-match claim.

Signed-off-by: suntp <20786998+suntp@users.noreply.github.com>
Cap each suffix draft at max_tokens - generated_tokens - 1, as the block
draft proposer does (vllm-project#1078): verification also emits a correction or
bonus token, so longer drafts are verified only to be discarded. A
non-positive limit now skips the request instead of slicing.

Signed-off-by: suntp <20786998+suntp@users.noreply.github.com>
…trip

Fake-based (CI, no arctic): prefill rows in the stub with the final chunk
drafting, configured width to upstream with truncation to the step's
width, remaining-output cap, and an absent request re-fed from its
prompt boundary. The fake now asserts upstream's width contract.

Real arctic (skipped when not installed): a preempted-then-resumed
request keeps its response in the tree, finish archives immediately,
the final prefill chunk drafts, and a mixed drafting/non-drafting batch
with variable-length drafts round-trips through build_decode_segments
and verify_greedy before the next delta is fed.

Signed-off-by: suntp <20786998+suntp@users.noreply.github.com>
Upstream reads token_ids_cpu for a row only to seed a new tree (the
prompt) and to take the match pattern (the last max_tree_depth tokens).
Copy just those for drafting rows, and nothing for rows without a delta,
instead of zeroing the buffer and copying every row's full history each
step. Synthetic 8 rows x ~6k-token histories: 0.74-0.80 -> 0.05 ms CPU
per propose; drafts over a randomized 900-step lifecycle trace are
byte-identical to the full-copy version.

Signed-off-by: suntp <20786998+suntp@users.noreply.github.com>
History ingestion followed drafting eligibility, so a request's last
committed tokens never reached its tree: a row that could not draft (its
output budget was exhausted) was handed an empty delta -- which upstream
skips before add_active_response -- and a request that finished after its
last drafting step archived without the tail. A response [20, 21, 22]
capped at max_tokens=3 taught the cache only [20, 21], so later requests
could not reuse the ending.

Decouple the two: a non-drafting row with a live tree still feeds its
delta through the stub batch, and the wrapper holds each anchored
request's state so release/finish can feed whatever committed after the
last step before stop_request archives the tree. The held reference is
the finished request's own state -- request_states cannot be used there
because a finished id may already be reused by a new request.

Signed-off-by: suntp <20786998+suntp@users.noreply.github.com>
The suffix docs claimed equivalence is checked by reporting the first
divergent token position, but locating a divergence neither shows it is
a near-tie nor proves the runs equivalent. State the repository's actual
standard instead: independently started spec-off/spec-on runs accepted
as EXACT or first-divergence mutual TOP_K_MATCH with zero FAIL, and wire
tools/check_parity.py --method suffix so that comparison can actually be
run (suffix takes no draft checkpoint; the schedule key applies
unchanged).

Signed-off-by: suntp <20786998+suntp@users.noreply.github.com>
Keep the ingestion anchor unchanged when upstream skips a context-capped row, and flush pending tokens before a missing request is archived. Cover both explicit release paths and a batch gap after drafting is disabled with real suffix trees.

Signed-off-by: suntp <20786998+suntp@users.noreply.github.com>
Convert nonempty checkpoint arguments to Path before inspecting local directories, while preserving raw repository IDs for Hub resolution. Exercise suffix without a draft checkpoint through the existing CLI worker and report tests for both local and Hub models.

Signed-off-by: suntp <20786998+suntp@users.noreply.github.com>
The docs stated sleep mode is supported because the trees are host
memory independent of the KV cache, but no sleep-to-wake run has
actually exercised the suffix proposer across a sleep cycle. State the
expectation and the pending validation instead of claiming support;
restore the claim once the test exists.

Signed-off-by: suntp <20786998+suntp@users.noreply.github.com>
sleep(level=1) -> wake_up() was exercised end to end under suffix spec
decode: the trees survived the cycle (host memory, KV-independent),
post-wake generation completed, the consumer's drafted/accepted stats
matched the no-sleep control (96/95 vs 95/95), and greedy outputs were
identical. State the support with that scope; level 2 is not available
on Metal.

Signed-off-by: suntp <20786998+suntp@users.noreply.github.com>
Document the worker-level sleep and wake behavior without turning one experiment into a general guarantee about draft counts or token equality. Matched level-1 and no-sleep checks preserve cached responses and produce identical 128-token continuations with 95 drafts verified and accepted in both arms.

Signed-off-by: suntp <20786998+suntp@users.noreply.github.com>
Install the optional arctic 0.3.0 backend in CI and fail explicitly if its native cache cannot load. Document the matching interpreter-pinned macOS source build.

Signed-off-by: suntp <20786998+suntp@users.noreply.github.com>
Fail during argument validation before resolving models or starting workers. Cover both the orchestrator and direct suffix-worker entry points with model-free CLI regressions.

Signed-off-by: suntp <20786998+suntp@users.noreply.github.com>
Signed-off-by: suntp <20786998+suntp@users.noreply.github.com>
Signed-off-by: suntp <20786998+suntp@users.noreply.github.com>
@suntp
suntp force-pushed the spec-decode-suffix branch from 6581226 to 0bde3a0 Compare October 20, 2026 04:05
@suntp
suntp marked this pull request as ready for review October 20, 2026 04:05
Enable real suffix-backend tests by default without a second pytest
process. Reuse a compatible compiled Arctic wheel across CI runs,
while retaining hard backend checks and an explicit local opt-out.

Signed-off-by: suntp <20786998+suntp@users.noreply.github.com>
@WindChimeRan WindChimeRan added ci/build CI, builds, packaging, and installation speculative-decoding Draft models and speculative decoding labels Oct 21, 2026 — with ChatGPT Codex Connector
@suntp
suntp marked this pull request as draft October 27, 2026 10:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/build CI, builds, packaging, and installation speculative-decoding Draft models and speculative decoding

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants