Repository navigation
Conversation
A speculative step becomes a round: the speculator plans the rows the target verifies (VerifyPlan), the target runs them, a path through them is accepted (AcceptResult) and the runner commits along that path. SpecResources declares what speculation holds, once at startup. This commit adds the types (spec_decode/round.py) and the chain plan. A speculator that does not override BaseSpeculator.plan keeps today's behavior: the runner builds the chain plan from its own buffers and nothing else changes. A speculator that overrides plan may return a chain with fewer drafts per request (trim_draft_tokens); a plan that is not a chain is refused until the next commit. Assisted-by: Claude Signed-off-by: Seyfal Sultanov <seyfal.s@outlook.com>
…ate commit A plan whose parents are not a chain verifies a tree. The runner lays its rows out after the chain slot mappings (plan_kernels.py), at the positions of their depth, gives attention the ancestor masks and M-RoPE the positions of each path, and accepts the longest greedy path (tree_accept.py). After the step the runner commits along the accepted path (commit.py): it moves the path's KV to the chain slots, aligns the conv window and has the model state resume from the path's last row, so the next step sees what a chain would have left. Trees need SpecResources.max_branching > 1 and an attention backend with supports_tree_verify in every target layer; the runner refuses a tree it cannot lay out. Assisted-by: Claude Signed-off-by: Seyfal Sultanov <seyfal.s@outlook.com>
GDN and Mamba2 verify spec-decode rows with chain kernels. For a tree, each row must read the recurrent and conv state of its parent rather than of the row before it. The Triton causal_conv1d_update and the selective-state and gated-delta-rule update kernels take the tree parents and follow them; the GDN and Mamba2 metadata builders pass them through and declare supports_tree_verify. Chains (no parents) run the existing kernel code unchanged. Tree support is not inherited. MambaBase.supports_tree_verify resets to False in every subclass that does not set it in its own body, and the runner refuses a tree speculator when any target recurrent layer's class does not declare it. OLMo Hybrid's and Kimi's GDN subclasses run their own chain kernels behind the GDN backend, so they stay chain-only rather than silently treating sibling rows as a chain. Assisted-by: Claude Signed-off-by: Seyfal Sultanov <seyfal.s@outlook.com>
A speculator may override BaseSpeculator.accept to choose the accepted path itself, for instance by sampling a tree instead of taking the greedy path. The rejection sampler calls it with the plan and the target's verify output; returning None falls back to the built-in chain or greedy tree acceptance. Logprobs and the sampling mask of each committed token are taken from its path row. Assisted-by: Claude Signed-off-by: Seyfal Sultanov <seyfal.s@outlook.com>
BaseSpeculator.observe_step receives a StepObservation once per sampled step, on the last pipeline-parallel rank, after the commit and before propose: the batch shape, the CUDA graph dispatch, the drafts verified and the tokens committed. Every field is something the runner already holds; device counts stay on the device. The runner only calls it when the speculator overrides it. Assisted-by: Claude Signed-off-by: Seyfal Sultanov <seyfal.s@outlook.com>
SpeculatorConfig.candidate_budget(BudgetView) chooses each step's candidate budget (drafts + 1) from what the scheduler knows after scheduling the step: requests running and waiting, and free blocks. The default keeps the configured num_speculative_tokens; the dynamic SD schedule and adaptive verification move onto it, and the CUDA graph capture shapes come from SpecResources. At budget 1 the next round verifies no drafts, so the runner does not call propose: draft rows are set to -1 and observe_step still runs. Under data parallelism it skips only when every rank does, since the drafter's forward runs in lockstep across ranks. Assisted-by: Claude Signed-off-by: Seyfal Sultanov <seyfal.s@outlook.com>
A recurrent (Mamba/GDN) layer keeps one state block per draft row it verifies. Today every running request holds `num_speculative_blocks` of them for its whole life, whether a step verifies K drafts or none. With a candidate budget that drops drafts under load, those blocks are the capacity the budget is trying to win back. `allocate_slots(num_draft_tokens=)` now tells the Mamba groups how many drafts a request verifies this step. They hold one speculative block per draft, at most `num_speculative_blocks`, and keep the previous step's span one step longer: the step that runs next reads the state the last verify accepted, which can sit in any of its draft columns (and, with async scheduling, is allocated before the acceptance is known). Blocks past that go back to the pool before this step's allocation, and the scheduler output carries the shortened row lengths (`spec_state_block_counts`) so the V2 runner cuts its block table rows before appending. Its gather writes the null block past a request's row in recurrent-state groups, so ids a shortened row or a reused batch slot held before never reach a kernel. `BudgetView` gains `block_size` and `num_spec_blocks_held` so a budget can see the blocks drafts cost. Every budget takes effect as asked: the draft lookahead stays reserved on every step, because a step's budget, which decides the drafts its drafter proposes, is chosen after that step's allocation. The scheduler passes counts only with spec decode, a recurrent group, the V2 runner, no adaptive verification (it can verify past the scheduled drafts), no PP and no KV connector (append-only tables). Every other path passes None and keeps today's layout; models without recurrent layers are unchanged. Tests: a property test drives random draft counts and acceptances through `MambaManager` in `none` and `align` modes against a model of the worker's row (truncate, then append) and checks the state the next step reads stays put, the held-block sum, and the pool, also with one step in flight (allocated before the last one's acceptance, rejections rolled back after); plus boundary crossing while shrinking, preemption mid-shrink, prefix-cache hits equal to the static layout's, scheduler steps under alternating and random budgets (sync and async, rejected drafts) verifying the drafts the previous step asked for, and the gather's null tail. Assisted-by: Claude Signed-off-by: Seyfal Sultanov <seyfal.s@outlook.com>
SpeculatorConfig.admit_limit(BudgetView) caps the requests that may be running once a step admits waiting ones. The scheduler asks once per schedule() that has waiting requests, after the running requests are scheduled and before the waiting loop, and takes the smaller of the answer and max_num_active_seqs, so a policy can lower admission and never raise it. The waiting loop is unchanged otherwise: running requests are never preempted for the limit, waiting ones (preempted ones included) are admitted in queue order under FCFS and priority scheduling, and under async scheduling the requests of the step still executing count as running, as they do for max_num_active_seqs. The limit and the candidate budget read the same BudgetView, block size and recurrent-state blocks held for draft rows included; those two fields no longer have defaults, so every caller fills them. The default sets no limit and is never called; a limit below 1 is an error. Assisted-by: Claude Signed-off-by: Seyfal Sultanov <seyfal.s@outlook.com>
A speculator whose plans branch ran only on models whose every layer verifies trees, and refused to start on every other model. Each worker now reports what its target layers verify after the attention backends are built (SpecCapabilities: a plan kind of "none", "chain" or "tree", with a reason per layer kind or setting that lowers it). The engine core reduces the reports of every rank, before compiling the model, and lets the speculator adapt its declared resources to them (SpeculatorConfig.adapt_resources; the default runs a tree as a chain of the same length). The scheduler, the runner, the CUDA graph manager and the speculator (BaseSpeculator.bind_resources) then run with the adapted resources; the KV and recurrent-state pools stay sized for the declared ones. The engine logs one line per reason it lowers the plan kind. An attention layer verifies what its metadata builder's spec_verify_plan_kind returns for the layer's features (window, sinks, soft cap, ALiBi, chunk, RSWA window, KV sharing); the default is "chain". A recurrent layer verifies trees only when its own class declares a spec_layer_variant, which subclasses do not inherit, and its builder verifies them. Mamba1 (spec_chain_supported = False) cannot verify a chain, so a model with it refuses speculative decoding. Pipeline, prefill and decode context parallelism, batch-sharded sampling, micro-batching and a model state without tree metadata lower trees to chains instead of refusing them. The KV compactor skips caches not paged by slot (the DeepSeek V4 ring) and hidden-state caches. Assisted-by: Claude Signed-off-by: Seyfal Sultanov <seyfal.s@outlook.com>
A speculator could not replace the kernels a round runs: the recurrent verify of GDN and Mamba2 layers was fixed, and which plan kinds a recurrent layer verified was its metadata builder's answer. The KV compactor and the conv window aligner were built inside the runner at the first commit. Every kernel speculative decoding adds now sits in a KernelRegistry (vllm/v1/worker/gpu/spec_decode/kernels.py), keyed by a layer kind and an op: "verify" (a recurrent layer's spec-decode rows, e.g. mamba:gdn@slots/verify) and "commit" (layout:paged_token/commit for attention KV, layout:mamba_state@slots/commit for the state select and conv window). ReferenceKernels holds the in-tree ones; SpeculatorConfig .spec_kernel_providers names provider factories that overlay them in order, a later provider winning a key, each override logged once. A provider may supply any subset of keys. A layer verifies the highest plan kind that every kernel it needs supports for it (SpecKernel.supports), and chains when one is missing or declines the layer, so a missing kernel is never called. layer_spec_limits reads a recurrent layer's plan kind from the registry, the state mode being part of the key: ReplaySSM and KDA recovery have no in-tree verify and chain, as before. An attention layer is capped by its builder and by its KV commit: KV quantized with separate scales (nvfp4, per-token-head) has no commit kernel yet and chains instead of moving its data without its scales. Short conv has no tree kernel and chains. The runner builds the registry once per worker, binds every kernel with the engine core's resources in set_spec_resources (a recurrent layer gets its provider verify kernel as spec_verify_kernel; commit kernels are bound only above chains), runs each bound commit kernel once per capture shape when capturing, and commits a round through the registry. The GDN and Mamba2 builders' spec_verify_plan_kind is gone; a provider's verify kernel gets each row's tree parent. Assisted-by: Claude Signed-off-by: Seyfal Sultanov <seyfal.s@outlook.com>
…lexAttention A speculator could place its verify rows only at num_computed_tokens plus their depth, and each row saw exactly its ancestors. A VerifyPlan may now carry positions ([R, C] offsets from num_computed_tokens, replacing the depth) and a mask ([R, C, C] bool, the rows each row sees; the lower triangle is read and a row always sees itself). A plan with a mask needs resources that declare custom_mask, which is plan kind "mask", above "tree". PlanLayout writes the explicit positions (M-RoPE takes the same per-row shift on every section) and writes the rows each token sees, its ancestors or its mask row, into the bits of tree_ancestors, the one tree buffer attention reads. Position-sensitive attention measures plan rows by CommonAttentionMetadata.positions instead of their KV slots. The FlexAttention builder verifies masks, and so trees, on full and sliding-window layers: its mask_mod reads tree_ancestors, and a sliding window measures plan rows by position. It verifies chains with a quantized KV cache, sinks, a logits soft cap, ALiBi, chunked local attention, R-SWA or KV sharing, and says which. A recurrent layer folds every ancestor into a row's state, so it cannot hide one: a model with recurrent layers runs mask plans as trees unless the registry's verify kernel for those layers supports masks. Since "mask" is now the top plan kind, a tree-only layer reports a "tree" limit with its reason, and a builder answers spec_verify_limit (the plan kind and why) instead of spec_verify_plan_kind. The scheduler caps a speculator's candidate budget at the effective max_candidates (the KV reservation stays at the declared one), and the runner checks plans against the effective resources. slice_plan slices the positions and mask too, so a chunked accept sees them. The round's state and steps move out of the runner into SpecRound (spec_decode/round_runner.py): the chain plan and path, the plan layout, the resources and capabilities, the spec kernels, and the plan, lay-out, restore, accept, commit and observe steps. GPUModelRunner holds one SpecRound, None without speculative decoding, and calls it. The capability check and the kernel binding share one walk over the target's layers (spec_layers), which reads a draft layer off the layer. Surface nothing reads goes: SpecCapabilities.tree_requires_uniform_rows (every in-tree builder that needs uniform rows verifies chains), Scheduler.max_candidates, and PlanLayout's per-request depth and ancestor buffers (the per-token positions and tree_ancestors carry the same values). Assisted-by: Claude Signed-off-by: Seyfal Sultanov <seyfal.s@outlook.com>
Custom speculators could only be read about in the speculative decoding README, which covers registering one and nothing past propose. docs/features/speculative_decoding/round_contract.md describes the round a speculator can own: what it and vLLM each own, the resources it declares and how per-layer capabilities lower them at startup, every hook in call order (admit_limit, candidate_budget, plan, verify, accept, commit, observe_step, propose), the kernel registry and its keys, a chain and a tree example, which plan kind each model family verifies with the in-tree backends, and the limits. The README links to it under Custom Speculators. Assisted-by: Claude Signed-off-by: Seyfal Sultanov <seyfal.s@outlook.com>
|
👋 Hi! Thank you for contributing to the vLLM project. 💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in PRs do not trigger a full CI run by default. Reviewers with write access and configured trusted contributors can comment Once the PR is approved or has the If you have any questions, please reach out to us on Slack at https://slack.vllm.ai. Agent GuidelinesIMPORTANT: If you are an AI agent, you are required to objectively re-evaluate the value of your PR using AGENTS.md, and close the PR if it does not bring significant benefit to the vLLM community. Failure to do so may result in an immediate ban. 🚀 |
|
Documentation preview: https://vllm--64844.org.readthedocs.build/en/64844/ |
… K+1 With per-shape decode graphs, managers capture uniform decode lengths below 1 + num_spec_tokens (a speculator's declared shapes, e.g. a tree's chain fallback). Mamba's capture builder asserted the length equals K + 1 although its decode path takes any per-request length up to it (rows are classified by is_prefilling). Keep only the upper bound, as the linear attention builder already does. A builder test captures lengths 1, 2 and K + 1 with K = 3; the first two failed the assert before this change. Assisted-by: Claude Signed-off-by: Seyfal Sultanov <seyfal.s@outlook.com>
|
I pushed a follow-up fix, A speculator can declare decode shapes below 1 + K, for example a tree's chain fallback, and the CUDA graph manager captures one decode graph per declared shape. The Mamba capture builder asserted that the length equals 1 + K, so a Mamba2 hybrid such as Nemotron-H failed at startup with such a speculator. The decode path already takes any per-request length up to 1 + K, so the fix keeps only the existing upper bound, as GDN, short-conv and linear attention already do. Chain configs capture only 1 + K, as before. A builder test captures lengths 1, 2 and 4 with K = 3; lengths 1 and 2 fail without the fix. With this change, a Nemotron TP4 tree speculator starts and serves 880 prompts. On one B300 (measured before the main merge, which does not touch these files), the 13 GPU test files listed in the description pass with the same counts (770 passed, 2 skipped) and I also measured the PR against main on the same build: Qwen3.8-27B, TP4, B300, one vLLM wheel for both sides, 4 starts per arm, median tok/s with the min-max ratio range.
|
|
This pull request has merge conflicts that must be resolved before it can be |
Overview
This PR lets a custom speculator (added in #63205) decide the whole speculative round on the V2 model runner: what the target verifies, which rows are accepted, how many drafts the next step takes and how many requests run. vLLM keeps execution, memory, CUDA graphs and parallelism, and checks every plan against what the model's layers can verify.
Builds on #63205. Replaces #62962 and #62963.
Claims
BaseSpeculatorandSpeculatorConfig, and it can supply its own verify and commit kernels by key.adapt_resources.Validation
AI assistance (Claude) was used to prepare this change; I reviewed every changed line and ran the tests listed below.
Not a duplicate. I searched open PRs (
gh pr list --repo vllm-project/vllm --state open --search ...) for "speculative tree verify", "speculator plan accept", "custom speculator", "spec decode tree mamba", "speculative round", "tree attention speculative", "speculator config admission", "mamba spec decode blocks", "flex attention spec decode mask" and "MRV2 speculator". I also searched issues for RFCs on tree verification and custom speculators. No open PR adds the runtime round (plan, accept, budget, admission, per-layer downgrade). The ones that touch nearby code:BaseSpeculator. They compose with this PR.Tests. The new test files are in directories that existing CI steps already run (
v1/worker,v1/core,v1/attention,kernels/mamba), so CI config is unchanged.test_tree_verify_kernels.py, whose Triton kernels fail under the CPU interpreter (TRITON_INTERPRET=1). All 68 of its tests pass on B300. Every other file fails exactly as on main.tests/v1/kv_connector/unit/offloading_connector/test_scheduler.pyat commit 6 that also fail on main. Every commit imports cleanly.pre-commit(all hooks, including mypy) passes on the range and on each commit.Evidence. All runs use B300 GPUs, bf16 weights, greedy sampling and the V2 runner. These were measured on an earlier version of this series. The reruns on this head are pending.
Sources and notes:
Reproducing rows 1, 2 and 4
Rows 1 and 2, one server per arm:
vllm serve openai/gpt-oss-120b --max-model-len 32768 --gpu-memory-utilization 0.85 \ --max-num-seqs 64 --seed 721 --no-enable-prefix-caching \ --speculative-config '{"method": "eagle3", "model": "nvidia/gpt-oss-120b-Eagle3-v3", "num_speculative_tokens": 3}'Speculation off: the same command without
--speculative-config.Budget 1 (row 2):
"method": "eagle3_budget1", registered by a plugin:Load: the first turn of each SPEED-Bench
question_multiturnprompt, after the system prompt "You are a helpful assistant.", chat-templated and sent as token ids to/v1/completionswithtemperature=0,top_k=1,max_tokens=256. Per concurrency C: a warm-up of max(20, C) requests, then max(64, 2C) timed requests (20 at C1) with C in flight. Throughput is generated tokens over wall time.Row 4, offline, one engine per arm:
mtp_dropis anMTPSpeculatorConfigsubclass whosecandidate_budgetreturns 1 whenview.num_running > 128, else 4.Scheduler.dynamic_spec_blocksforced to False.LLM.chat,ignore_eos=True, 1024 tokens each.Details
#63205 gives a plugin a place to register a speculator: a
SpeculatorConfigand aBaseSpeculator. The round itself still runs one way, a chain of K drafts with the same K every step. Tree speculators, per-step budgets and admission control each need changes inside the model runner and the scheduler today. This PR adds the round as optional hooks with chain defaults:Nonefromplanoracceptmeans vLLM runs that part of the round as it does on main. The Dynamic SD schedule (num_speculative_tokens_per_batch_size) becomes the defaultcandidate_budget, with the same schedule.docs/features/speculative_decoding/round_contract.mddocuments every hook.plan)accept)candidate_budget)admit_limit)propose) and policy such as tree widthspec_kernel_providers)Plan kinds are lowered once at startup, so memory and CUDA graphs are sized once. Plan buffers belong to the speculator and keep their addresses, so captured graphs replay them. Kernels are keyed by layer kind and op: a provider can replace one key, and a missing or declining kernel lowers that layer to chains.
How to review, commit by commit:
round.pyand the chain plan, no behaviour change) and the docs page in commit 12.spec_decode/round_runner.py(SpecRound), somodel_runner.pygrows by 174 lines.accept,observe_stepandcandidate_budget.kv_cache_manager.pyandsingle_type_kv_cache_manager.py. It is on only for models with a Mamba group on the V2 runner without pipeline parallelism, a KV connector or adaptive verification.admit_limit. The scheduler calls it only when the speculator config overrides it.AttentionMetadataBuilder.spec_verify_limit, default"chain") and the downgrade. Commit 10 adds the kernel registry and commit 11 the masks and FlexAttention.Limits:
Pull Request Checklist
I used vLLM's
/pr-checklistskill. (Mandatory for agents, optional for humans).AI assistance was used during the creation of this PR.
Design Fit: Minimizes impact on core components, reuses existing functionality, and justifies added complexity.
Testing and Validation: Validates the change and ensures any added tests are meaningful and reliable, with CI coverage or documented CI resource constraints and validation performed outside CI.
Code Quality and Style: Keeps code and comments clear and concise, and updates relevant documentation and examples.
Pull Request Contents: Includes a brief summary and relevant links, supports claims with evidence, explains root causes and implementation trade-offs, and follows the contributing guide.