vLLM Explained

Speculative decoding in vLLM serving

AdvancedvLLM conceptsabout 13 min read + 6 min deep dives

Code version: vLLM @ 3e44c0d · 2026-10-22

In one sentence

A drafter proposes k tokens per request on the GPU. The next forward pass checks them all, so one step can emit up to k + 1 tokens.

The idea#

Speculative decoding lets a cheap drafter guess the next tokens. The large target model then checks all guesses in one forward pass. A rejection test keeps the output distribution of the target model (Speculative decoding: draft, verify, accept/reject). This page shows how vLLM does this inside vllm serve.

You turn it on with one flag, --speculative-config, a JSON object that becomes a SpeculativeConfig. The method names the drafter: an EAGLE head, an MTP layer of the model, a separate draft model, an n-gram lookup, and others. num_speculative_tokens (k) sets how many drafts each request gets per step.

In Model Runner V2 the drafts stay on the GPU until the next step checks them. Only a batch with structured outputs copies them to the CPU for the grammar check (vllm/v1/worker/gpu/spec_decode/utils.py:64-80). The scheduler only reserves room for them, with -1 placeholders. The worker samples, drafts the next k tokens right away on the GPU, and puts them into the next input. The rejection sampler checks them in the next step.

An everyday comparison. Think of a junior writer and an editor. The junior writes the next three words. The editor reads them in one pass, keeps the words up to the first wrong one, and writes one correct word there. If all three are right, the editor adds a fourth word. The comparison stops being true in one place: the editor is not slower per word than the junior. The target model checks four positions for about the price of one decode step, because decode is memory-bound (Prefill and decode).

The picture#

Figure: one step with EAGLE and k = 3. The GPU verifies and drafts again without a CPU round trip (panels 1-2); the engine core only fixes its counters when the result arrives (panel 3).
Open full size ↗

Read the panels from top to bottom. Blue and teal are the target forward pass and the check, amber and violet are the GPU work after sampling, emerald is the engine core. The numbers are one example request; the worked example in the deep dive follows the same numbers.

In the code#

Step 1: SpeculativeConfig and the methods#

--speculative-config (short -sc) takes a JSON string. vLLM keeps it as a dict until it builds the engine config, because SpeculativeConfig needs the target model config (vllm/engine/arg_utils.py:1851-1857). Three short flags exist too: --spec-method, --spec-model and --spec-tokens. A short flag and the same key in the JSON raise ValueError (vllm/engine/arg_utils.py:2098-2110).

vllm serve meta-llama/Llama-3.1-8B-Instruct \
  --speculative-config '{"method": "eagle3",
                         "model": "yuhuili/EAGLE3-LLaMA3.1-Instruct-8B",
                         "num_speculative_tokens": 3}'

The method names are a Literal type:

NgramGPUTypes = Literal["ngram_gpu"]
DFlashModelTypes = Literal["dflash"]
DSparkModelTypes = Literal["dspark"]
EagleModelTypes = Literal[
    "eagle", "eagle3", "extract_hidden_states", MTPModelTypes, DFlashModelTypes
]
SpeculativeMethod = Literal[
    "ngram",
    "draft_model",
    "suffix",
    "custom_class",
    EagleModelTypes,
    NgramGPUTypes,
    DSparkModelTypes,
]
RejectionSampleMethod = Literal["standard", "synthetic", "block", "fly"]
DraftSampleMethod = Literal["greedy", "probabilistic"]

With a model but no method, vLLM guesses the method from the model name and architecture. It looks for "eagle-", "eagle3", "dflash", "dspark" or an MTP model_type (vllm/config/speculative.py:1363-1398). num_speculative_tokens has no default. It comes from n_predict in the draft config when that exists (for DSpark, from its block size); otherwise it is required (vllm/config/speculative.py:1508-1542).

Step 2: which methods Model Runner V2 supports#

Model Runner V2 is the default. Some speculative settings switch the whole worker back to Model Runner V1 (The model runner (Model Runner V2)):

        if speculative_config is not None:
            if speculative_config.method in ("suffix", "custom_class"):
                unsupported.append(f"speculative method '{speculative_config.method}'")

            # V2 EagleSpeculator does not support parallel_drafting (for P-Eagle).
            # DFlash and DSpark use parallel drafting natively in V2 via their
            # own speculators.
            if (
                speculative_config.parallel_drafting
                and speculative_config.method not in ("dflash", "dspark")
            ):
                unsupported.append("parallel drafting for EAGLE speculative decoding")

            # The V2 draft-model speculator has no token mapping between the
            # draft and target vocabularies (TLI is TBD in #47172).
            if getattr(speculative_config, "use_heterogeneous_vocab", False):
                unsupported.append("heterogeneous-vocabulary draft models")

So suffix, custom_class, parallel drafting with EAGLE (P-EAGLE) and draft models with a different vocabulary run on the fallback. vLLM logs "Model Runner V2 does not yet support ..." (vllm/config/vllm.py:740-747). The reverse also exists: dspark, adaptive verification and draft LM-head quantization work only on Model Runner V2 (vllm/config/vllm.py:3056-3063).

In Model Runner V2, init_speculator maps the method to a speculator class. ngram and ngram_gpu both get the GPU n-gram speculator (vllm/v1/worker/gpu/spec_decode/__init__.py:81-86, vllm/config/speculative.py:2048-2049). Only the last pipeline-parallel rank builds a speculator (vllm/v1/worker/gpu/model_runner.py:298-308).

Step 3: drafts in the scheduler#

The scheduler treats drafts as normal tokens of the request. A decode with k drafts schedules 1 + k tokens, and they count against the token budget. After scheduling, it keeps only the drafts that fit and moves them into scheduled_spec_decode_tokens (vllm/v1/core/sched/scheduler.py:916-932). The page The V1 scheduler: one token budget, no phases (Step 6) shows the budget rule.

Two extra reservations exist:

With async scheduling (the default), the scheduler plans step N+1 before the drafts of step N exist. It puts [-1] * k as placeholder drafts and adds 1 + k output placeholders (vllm/v1/core/sched/async_scheduler.py:28-50). The real drafts are only on the GPU. Async scheduling: overlap the CPU and the GPU explains the placeholders.

Step 4: verify, then draft again, in one sample_tokens call#

In sample(), the model runner chooses the sampler. Without drafts in the batch the normal sampler runs; with drafts the RejectionSampler runs:

        sampler_output: SamplerOutput | None
        if input_batch.num_reqs == 0:
            # This rank owns no requests this step. It contributes an
            # all-padding block to the gather below.
            sampler_output = None
        elif input_batch.num_draft_tokens == 0 or self.rejection_sampler is None:
            assert self.sampler is not None
            sampler_output = self.sampler(logits, input_batch)
        else:
            # Rejection sampling for spec decoding.
            assert self.rejection_sampler is not None
            assert self.speculator is not None
            sampler_output = self.rejection_sampler(
                logits,
                input_batch,
                # Draft logits are needed for probabilistic rejection sampling.
                self.speculator.draft_logits,
                invalid_drafts,
            )

After sampling, sample_tokens starts the output copy (vllm/v1/worker/gpu/model_runner.py:2209-2218), updates the request state (vllm/v1/worker/gpu/model_runner.py:2249-2255), and then calls the speculator at once:

            with use_workspace_lane(self._draft_workspace_lane):
                draft_tokens = self.speculator.propose(
                    input_batch,
                    attn_metadata,
                    slot_mappings_by_layer,
                    spec_hidden_states,
                    aux_hidden_states,
                    num_sampled,
                    num_rejected,
                    self.req_states.last_sampled_tokens,
                    self.req_states.next_prefill_tokens,
                    self.sampler.sampling_states.temperature.gpu,
                    self.sampler.sampling_states.seeds.gpu,
                    num_speculative_tokens=num_spec_tokens,
                    dp_sync_state=dp_sync_state,
                    mm_inputs=mm_inputs,
                )
            if num_spec_tokens < self.num_speculative_steps:
                draft_tokens[:, num_spec_tokens:] = -1
            self.req_states.draft_tokens[input_batch.idx_mapping] = draft_tokens
  1. Line 2271: propose() gets the hidden states, the number of sampled and rejected tokens, and the last sampled token, all on the GPU.
  2. Lines 2287-2288: with dynamic speculative decoding the step may use fewer drafts; the unused columns become -1.
  3. Line 2289: the drafts go into req_states.draft_tokens. The next prepare_inputs copies them into input_ids (vllm/v1/worker/gpu/model_runner.py:1493-1504).

The EAGLE speculator first runs a "draft prefill" over the same token rows as the target. So it can reuse the target's attention metadata and slot mappings. Then it runs k - 1 decode steps of one token per request (vllm/v1/worker/gpu/spec_decode/target_dependent_ar/speculator.py:204-209, vllm/v1/worker/gpu/spec_decode/target_dependent_ar/speculator.py:281-299, vllm/v1/worker/gpu/spec_decode/target_dependent_ar/speculator.py:331-366).

Step 5: the RejectionSampler#

The RejectionSampler wraps the normal sampler. First it applies the sampling parameters of each request (penalties, temperature, top-k, top-p) to all 1 + k logits rows; then it runs the test (vllm/v1/worker/gpu/spec_decode/rejection_sampler.py:264-293). The test depends on rejection_sample_method:

    rejection_sample_method: RejectionSampleMethod = "standard"
    """The rejection sampling method to use. 'standard' uses probabilistic
    rejection sampling (with or without cached draft logits, controlled by
    draft_sample_method). 'synthetic' accepts draft tokens with a decaying
    probability calibrated to synthetic_acceptance_rate. 'block' uses block
    verification (Sun et al.), which jointly verifies the draft tokens as a
    block instead of one at a time. 'fly' applies FLy's lossy, entropy-gated
    deferred window to the native per-token acceptance decisions."""
VariantWhat it does (high level)Lossless?
standard (default)Checks each draft in order. Greedy requests accept a draft only if it equals the target argmax (vllm/v1/worker/gpu/spec_decode/rejection_sampler_utils.py:600-616). Random requests accept x when p(x) > u · q(x) (vllm/v1/worker/gpu/spec_decode/rejection_sampler_utils.py:695-697).yes
blockBlock verification (Sun et al.): judges the drafts as one block, with the joint probability of the prefix (vllm/v1/worker/gpu/spec_decode/rejection_sampler_utils.py:628-660).yes (per the paper)
syntheticIgnores the logits for the decision and accepts with fixed per-position rates. You must give exactly one of synthetic_acceptance_rates and synthetic_acceptance_length (vllm/config/speculative.py:581-585). For benchmarks.no
flyFLy: a lossy, entropy-gated window over the normal decisions. Needs k ≥ 2; the window defaults to min(6, k - 1); vLLM logs a warning (vllm/config/speculative.py:1913-1931).no

The acceptance math. p is the target probability and q the draft probability of the draft token x, both after temperature. u is a uniform random number. The draft is kept when p(x) > u · q(x), that is with probability min(1, p(x) / q(x)). The default draft_sample_method is greedy: the drafter takes the argmax, and q counts as one-hot. So q(x) = 1, and the draft is kept with probability p(x) (vllm/config/speculative.py:608-614, vllm/v1/worker/gpu/spec_decode/rejection_sampler_utils.py:191-194). At the first rejection, vLLM draws a new token from the residual max(p - q, 0). With a one-hot draft, that is the target distribution without the rejected token (vllm/v1/worker/gpu/spec_decode/rejection_sampler_utils.py:937-938, vllm/v1/worker/gpu/spec_decode/rejection_sampler_utils.py:950-951). If all k drafts pass, the last row gives a bonus token.

Step 6: the engine core rolls back rejected drafts#

The model runner already corrected its own GPU counters. num_rejected is the number of logits rows minus the number of sampled tokens, and num_computed_tokens grows by the query length minus that (vllm/v1/worker/gpu/input_batch.py:528-534, vllm/v1/worker/gpu/input_batch.py:612-615). The scheduler does the same on the CPU when the output arrives:

            scheduled_spec_token_ids = (
                scheduler_output.scheduled_spec_decode_tokens.get(req_id)
            )
            if scheduled_spec_token_ids and (
                generated_token_ids or self.num_sampled_tokens_per_step == 0
            ):
                num_draft_tokens = len(scheduled_spec_token_ids)
                num_sampled = self.num_sampled_tokens_per_step
                num_accepted = max(len(generated_token_ids) - num_sampled, 0)
                num_rejected = num_draft_tokens - num_accepted
                # Rejections roll back num_computed_tokens (and, under async
                # scheduling, num_output_placeholders, which covers the spec
                # tokens). A stale rejection count predates the preemption
                # rollback and must not apply.
                if not output_is_stale:
                    if request.num_computed_tokens > 0:
                        request.num_computed_tokens -= num_rejected
                    if request.num_output_placeholders > 0:
                        request.num_output_placeholders -= num_rejected
                spec_decoding_stats = self.make_spec_decoding_stats(
                    spec_decoding_stats,
                    num_draft_tokens=num_draft_tokens,
                    num_accepted_tokens=num_accepted,
                    num_invalid_spec_tokens=scheduler_output.num_invalid_spec_tokens,
                    request_id=req_id,
                )

The KV entries of rejected drafts are not freed. The blocks stay with the request: the KV manager notes that a request can hold more blocks than it needs after a rejection (vllm/v1/core/single_type_kv_cache_manager.py:233-236). The next step writes the correct token over the stale entry, because num_computed_tokens moved back (derived from the counters above). With async scheduling, post_step does not take draft tokens from the workers (vllm/v1/engine/core.py:689-696). The engine core takes them only for the grammar check of a structured-output step (vllm/v1/engine/core.py:792-803).

Step 7: outputs with more than one token#

One step can give a request up to 1 + k new tokens, and they travel in one EngineCoreOutput. This changes the latency numbers:

Deep dive#

The sections below give the full detail. Open them one at a time, or use Open all deep dives at the top of the page.

Deep dive

Worked example: EAGLE, k = 3, two drafts accepted

Server: vllm serve meta-llama/Llama-3.1-8B-Instruct with the EAGLE3 command of Step 1, one GPU, block size 16, async scheduling on. One request is in decode. 100 of its tokens are in the KV cache (positions 0 to 99). Its last sampled token x0 (position 100) is not computed yet. The drafter proposed d1, d2, d3 in the previous step.

  1. Schedule. The scheduler plans 1 + 3 = 4 tokens. num_computed_tokens goes from 100 to 104 at once (vllm/v1/core/sched/scheduler.py:1716). allocate_slots must hold 4 tokens plus 3 lookahead slots: positions up to 106, all inside block 6 (positions 96 to 111). The placeholders grow by 4, and the next draft list is [-1, -1, -1].
  2. Forward. The input is [x0, d1, d2, d3] at positions [100, 101, 102, 103]. The KV of all four positions is written. Logits come back for all 4 rows: cu_num_logits gives this request 4 rows, not 1.
  3. Verify. Row 0 checks d1 (accept). Row 1 checks d2 (accept). Row 2 checks d3: reject. A recovered token r is drawn from row 2 without d3. Row 3 (the bonus row) is not used. Result: sampled = [d1, d2, r], num_sampled = 3, num_rejected = 4 - 3 = 1.
  4. Worker counters. num_computed_tokens on the GPU grows by 4 - 1 = 3: from 100 to 103. last_sampled_tokens = r. The KV at position 103 still holds d3, but position 103 is not computed any more.
  5. Draft again. EAGLE runs the draft prefill over the 4 rows, then 2 draft decode steps, and writes [e1, e2, e3] into req_states.draft_tokens.
  6. Next step. The input is [r, e1, e2, e3] at positions 103 to 106. The KV of r overwrites the stale d3 entry at 103.
  7. Engine core. The output has 3 tokens. num_accepted = 3 - 1 = 2, num_rejected = 3 - 2 = 1, so num_computed_tokens goes from 104 to 103 and the placeholders drop by 1 (vllm/v1/core/sched/scheduler.py:2209-2221). The 3 new tokens remove the other 3 placeholders (vllm/v1/core/sched/async_scheduler.py:71-73). The client gets d1, d2 and r: 3 tokens for one target forward pass.
  8. Metrics. num_drafts +1, num_draft_tokens +3, num_accepted_tokens +2, and the per-position counters +1 at positions 0 and 1 (vllm/v1/spec_decode/metrics.py:41-49).
Deep dive

Metrics: acceptance rate and mean acceptance length

The scheduler builds SpecDecodingStats per step when stats logging is on (vllm/v1/core/sched/scheduler.py:3061-3078). Prometheus gets four counters (vllm/v1/spec_decode/metrics.py:229-231, vllm/v1/spec_decode/metrics.py:254-258):

MetricCounts
vllm:spec_decode_num_draftsverification rounds (one per request per step with drafts)
vllm:spec_decode_num_draft_tokensdraft tokens that were checked
vllm:spec_decode_num_accepted_tokensaccepted drafts (not the bonus or recovered token)
vllm:spec_decode_num_accepted_tokens_per_posaccepted drafts per draft position, label position

The class docstring gives the PromQL (vllm/v1/spec_decode/metrics.py:177-196):

  • acceptance rate = rate(..._num_accepted_tokens_total) / rate(..._num_draft_tokens_total).
  • Mean acceptance length = 1 + rate(..._num_accepted_tokens_total) / rate(..._num_drafts_total). The 1 is the bonus or recovered token of each round (vllm/v1/spec_decode/metrics.py:113-114).

In the worked example: acceptance rate 2/3 = 67 %, acceptance length 1 + 2/1 = 3. The log line "SpecDecoding metrics: Mean acceptance length ..." prints the same numbers every log interval (vllm/v1/spec_decode/metrics.py:120-137). --per-request-spec-decode-metrics summary or detailed adds per-request numbers to the response; the default is none, and it needs speculative decoding (vllm/config/observability.py:50-56, vllm/config/vllm.py:1713-1720).

Deep dive

CUDA graphs with drafts

A decode with k drafts has 1 + k query tokens. vLLM calls this the uniform decode query length (vllm/config/vllm.py:676-695); Model Runner V2 stores it as decode_query_len (vllm/v1/worker/gpu/model_runner.py:442-445). A FULL CUDA graph replays only for a batch in which every request has exactly that many tokens. Prefill chunks in the batch give a PIECEWISE graph. Without dynamic k, the scheduler pads a waiting 1-token request to 1 + k tokens when no prefill runs, to keep the batch uniform (vllm/v1/core/sched/scheduler.py:1196-1216).

vLLM adds decode capture sizes of 1 + k times 1, 2 or 4 requests, or a multiple of 8 requests (vllm/config/vllm.py:2466-2473, vllm/config/vllm.py:2520-2527). With k = 3, a batch of 3 verifying requests (12 tokens) replays the FULL graph of 16 tokens; torch.compile and CUDA graphs in vLLM has this example. The drafter has its own graphs. The draft prefill can replay a FULL graph when all requests decode. Each draft decode step is a uniform 1-token batch (vllm/v1/worker/gpu/spec_decode/target_dependent_ar/speculator.py:248-258, vllm/v1/worker/gpu/spec_decode/target_dependent_ar/speculator.py:331-345).

Deep dive

SpeculativeConfig fields that matter for serving

FieldDefaultEffect
methodguessed, else draft_modelStep 1 (vllm/config/speculative.py:1186-1195).
num_speculative_tokensnone; from n_predict if presentk (vllm/config/speculative.py:393-395).
draft_tensor_parallel_sizetarget TPOnly 1 or the target TP size (vllm/config/speculative.py:407-409). tensor_parallel_size inside the JSON raises ValueError (vllm/config/speculative.py:1837-1841).
rejection_sample_methodstandardStep 5.
draft_sample_methodgreedyprobabilistic samples drafts and keeps draft logits, at the cost of GPU memory (vllm/config/speculative.py:608-614).
prompt_lookup_min / max5 / 5 (n-gram)n-gram window (vllm/config/speculative.py:1252-1256).
num_speculative_tokens_per_batch_sizenoneDynamic k by batch size, as (start, end, k) ranges (vllm/config/speculative.py:498-503). Turned off with data parallelism (vllm/config/vllm.py:1092-1109).
parallel_draftingfalse (true for DFlash, DSpark)EAGLE with it falls back to Model Runner V1 (Step 2).

One cross-check applies with LoRA and speculative decoding together. Then max_num_batched_tokens must be at least max_num_seqs × (k + 1), else start-up fails with "Consider increasing max_num_batched_tokens or decreasing num_speculative_tokens" (vllm/engine/arg_utils.py:2700-2713).

Deep dive

Tuning with the defaults that the code gives

The code does not choose k for you. The only exceptions are opt-in: dynamic k by batch size, and adaptive verification for DSpark (vllm/config/speculative.py:559-561). Use the metrics above to choose:

  • Start from the drafter's own k. EAGLE-type and MTP checkpoints often carry n_predict, which vLLM uses when you omit k (vllm/config/speculative.py:1524-1536). If you set k above n_predict, k must be a multiple of n_predict (same lines).
  • Watch the per-position rates. If position 3 rarely accepts, k = 3 wastes target compute on the third row. Each extra draft costs one more logits row and one more draft step for every request.
  • Batch size matters. Verification adds k tokens per request to the token budget. In large batches the step is less memory-bound, so drafts cost more. num_speculative_tokens_per_batch_size lowers k for larger batches.
  • Greedy requests accept only exact argmax matches; random requests use the probability test. The same drafter can show different rates for different temperatures.

The docs give measured speedups only for a few setups, for example FLy against standard verification (docs/features/speculative_decoding/fly.md:55-62). Measure your own workload with vllm bench serve (Benchmarking vLLM: vllm bench serve, throughput, latency and startup).

What can go wrong#

Why it matters. Speculative decoding trades extra GPU work per step for fewer steps per answer. It helps most at small batch sizes, where decode leaves the GPU idle. The acceptance metrics tell you whether the trade pays off.

Check yourself#

  1. 1
    Which flag turns on speculative decoding, and what is k?
    easy
    Show answer

    --speculative-config with a JSON object; k is num_speculative_tokens, the number of drafts per request per step (vllm/engine/arg_utils.py:1854-1857, vllm/config/speculative.py:393-395).

  2. 2
    With async scheduling, what draft tokens does the scheduler put into the SchedulerOutput, and where are the real ones?
    medium
    Show answer

    A list of -1 placeholders, k long (vllm/v1/core/sched/async_scheduler.py:28-50). The real drafts are in req_states.draft_tokens on the GPU, written by sample_tokens right after sampling (vllm/v1/worker/gpu/model_runner.py:2289).

  3. 3
    With the default draft_sample_method, a random request (temperature 0.8) checks a draft x with target probability 0.3. What is the chance to accept it?
    medium
    Show answer

    0.3. Greedy drafting treats q as one-hot, so q(x) = 1, and the test p(x) > u · q(x) passes with probability p(x) (vllm/v1/worker/gpu/spec_decode/rejection_sampler_utils.py:191-194, vllm/v1/worker/gpu/spec_decode/rejection_sampler_utils.py:695-697).

  4. 4
    k = 4. A request had 200 computed tokens before the step; the target accepts 1 draft. What are num_sampled, num_rejected (worker) and num_accepted (scheduler), and where does num_computed_tokens end?
    hard
    Show answer

    The query has 5 tokens and 5 logits rows. Sampled: 1 accepted draft + 1 recovered token, so num_sampled = 2 and the worker's num_rejected = 5 - 2 = 3 (vllm/v1/worker/gpu/input_batch.py:528-534). The scheduler computes num_accepted = 2 - 1 = 1 and num_rejected = 4 - 1 = 3 (vllm/v1/core/sched/scheduler.py:2209-2213). num_computed_tokens goes 200 → 205 at schedule time and back to 202.

  5. 5
    Why can a 3-token output make the Prometheus ITL histogram look worse while TPOT improves?
    hard
    Show answer

    ITL adds one sample per engine-core output, the gap since the previous output, no matter how many tokens it holds (vllm/v1/metrics/stats.py:551-557). TPOT divides the decode time by all generated tokens minus one (vllm/v1/metrics/stats.py:612-616). A step with 3 tokens is a little slower than a plain step, so each ITL sample grows, but the time per token falls.