ML Interview Notes
36 min read14 sections
Part 6 · Decoding algorithms · 06-03

Draft sources: draft models, n-gram, suffix decoding

Status
SOURCE PINNED
Primary sources
  • vllm/v1/spec_decode/ngram_proposer.py
  • vllm/v1/spec_decode/suffix_decoding.py
  • python/sglang/srt/speculative/ngram_worker.py
Edition pins
vllm a556f3f · sglang 7d89325

A draft that costs a forward pass has to earn it. Before a Llama-3.2-1B drafter proposes its first token for Llama-3-70B it has already taken 10.5% of your KV pool — six concurrent sequences at 8k context on four H100s. A string matcher running on the host takes none of it. The question this chapter answers is when the free guess wins.

§1

The problem

§6.2 proved that any draft distribution $q$ leaves the emitted distribution exactly equal to the target's $p$, and reduced the whole economics to one expression: speedup $= E(\alpha,\kappa)/(1+\kappa c)$, with $E = (1-\alpha^{\kappa+1})/(1-\alpha)$, $\alpha$ the per-token acceptance rate and $c$ the cost of one draft step relative to one target step. It then spent its time on the verifier. This chapter is about the other half of that fraction — where $q$ comes from.

As of a556f3f, vLLM's method literal names nine entries — which resolve to thirteen distinct methods once the aliases collapse (§6.6 inventories all of them) — behind that one sampler:

vllm/config/speculative.py:L69-L80 vLLM
SpeculativeMethod = Literal[
    "ngram",
    "medusa",
    "mlp_speculator",
    "draft_model",
    "suffix",
    "custom_class",
    EagleModelTypes,
    NgramGPUTypes,
    DSparkModelTypes,
]
RejectionSampleMethod = Literal["standard", "synthetic", "block"]

Four of those — ngram, ngram_gpu, suffix, and draft_model — are this chapter's. The EAGLE family and MTP, which draft from the target's own hidden states, belong to §6.4; DSpark, DFlash and the rest of SGLang's zoo to §6.6.

The concrete symptom that makes this a real decision rather than a taxonomy: on a summarisation or code-edit workload, a matcher that costs zero GPU time routinely beats a trained 1B draft model, and on open-ended chat it produces nothing at all — not a smaller speedup, nothing, because there is no repeated span to match. The same two configs swap places when the target grows from 8B to 70B. Neither $\alpha$ nor $c$ alone tells you which. The pair does.

§2

Mental model

Every draft source is a point on the $(\alpha, c)$ plane. $c$ you can derive from first principles before you own the hardware: in the memory-bound regime it is a ratio of streamed bytes. $\alpha$ you cannot — it depends on the model, the workload, and the temperature, and it is the one number in this chapter that must be measured. So the useful picture is not a scoreboard of speedups; it is the plane with each source's derivable $c$ fixed and its $\alpha$ left free, crossed by the contours of equal speedup.

Fix $\kappa$. The set of $(\alpha, c)$ pairs that deliver the same speedup $s$ is

$$c(\alpha) \;=\; \frac{1}{\kappa}\left(\frac{E(\alpha,\kappa)}{s} - 1\right), \qquad E(\alpha,\kappa) = \frac{1-\alpha^{\kappa+1}}{1-\alpha}.$$

Two contours matter. $s = 1$ is the break-even against not speculating at all: anything below that curve is a net win. And the contour through the point where your draft model actually sits is the indifference curve — the locus of free drafts that are worth exactly as much as that draft model. Where it crosses $c = 0$ is the acceptance rate a zero-cost matcher needs to tie.

Figure 1 — the $(\alpha, c)$ plane at $\kappa = 3$, with each draft source placed at its derived cost. All $c$ values are derived as ratios of streamed weight bytes (§3). The contours are derived from $c(\alpha)$ above. The two draft-model markers are at an illustrative $\alpha = 0.70$ — the marker's height is derived, its horizontal position is an assumption, which is exactly the asymmetry this figure exists to show.

Acceptance rate against relative draft cost, with equal-speedup contours A plane with acceptance rate alpha on the horizontal axis from 0 to 1 and relative draft cost c on the vertical axis from 0 to 0.30. Three rising contours mark equal speedup: break-even at speedup 1, indifference with a 1B draft model at speedup 1.69, and speedup 2. A 1B draft for an 8B target sits at c = 0.165; the same draft for a 70B target sits at c = 0.018; n-gram, GPU n-gram and suffix decoding sit in a band close to c = 0. 0 0.05 0.10 0.15 0.20 0.25 0.30 0 0.2 0.4 0.6 0.8 1.0 per-token acceptance rate α — must be measured relative draft cost c — derivable ngram · ngram_gpu · suffix — c ≈ 0, host-side or a few GPU microseconds Llama-3.2-1B drafting Llama-3-8B c = 0.165 — speedup 1.69× Llama-3.2-1B drafting Llama-3-70B c = 0.018 — speedup 2.40× break-even, s = 1 indifference, s = 1.69 s = 2 α = 0.43 free draft that ties the 8B case α = 0.67 — free draft that ties the 70B case

Read the figure as a sentence: a matcher that is right 43% of the time is worth as much as a 1B draft model that is right 70% of the time, when the target is 8B — and the bar rises to 67% when the target is 70B. The bar moves because $c$ moves, and $c$ moves because it is approximated by a parameter ratio under the following assumptions.

§3

First principles: deriving c

If target and draft are both weight-bandwidth-bound, use the same precision and achieve equal bandwidth, streamed weight bytes give an approximate cost ratio:

$$c = t_D/t_T \approx N_D^{\text{stream}}/N_T^{\text{stream}}.$$

where $N^{\text{stream}}$ counts parameters actually read per token — every matmul weight plus the output head, but not the input embedding table, which is gathered. Under tensor parallelism of the same degree their ideal shard factors cancel. Actual c can differ because of KV traffic, small-GEMM efficiency, collectives and launch costs. vLLM validates TP compatibility:

vllm/v1/spec_decode/draft_model.py:L63-L78 vLLM
    def _raise_if_draft_tp_mismatch(self):
        # Note(Tomas Ruiz) If we run the target model with TP > 1 and
        # the draft model with TP = 1, then the different TP ranks collide.
        # Specifically when all ranks compile the draft model on rank 0
        # (because TP=1), then the torch compile cache is overwritten and corrupted.
        # We need a mechanism like this: https://github.com/vllm-project/vllm/pull/5414
        # To prevent this error, we assert that both TP sizes must be the same.
        spec_cfg = self.speculative_config
        tgt_tp = spec_cfg.target_parallel_config.tensor_parallel_size
        draft_tp = spec_cfg.draft_parallel_config.tensor_parallel_size
        if draft_tp != tgt_tp:
            raise ValueError(
                f"Currently, 'draft_tensor_parallel_size' and 'tensor_parallel_size' "
                f"must be the same. Got {draft_tp} and {tgt_tp}. "
                "Please pass 'draft_tensor_parallel_size' in the speculative_config."
            )

Llama-3.2-1B drafting Llama-3-70B

Llama-3-70B: $L=80$, $d=8192$, $h=64$, $h_{kv}=8$, $d_h=128$, FFN 28,672, vocab 128,256. Per layer that is $2 \times 8192^2$ for Q and O, $2 \times 8192 \times 1024$ for K and V, and $3 \times 8192 \times 28672$ for the gated MLP — 855.6 M parameters. Times 80 layers is 68.451 B, plus a $128{,}256 \times 8192 = 1.051$ B output head: 69.502 B streamed, 139.0 GB in bf16. On four H100s that is 34.75 GB per GPU and a decode floor of $34.75/3350 = 10.37$ ms at batch 1 — 96 tokens per second. Derived.

Llama-3.2-1B ($L=16$, $d=2048$, $h=32$, $h_{kv}=8$, $d_h=64$, FFN 8192, tied embeddings) streams 1.236 B as computed in §6.2. So

$$c_{70\text{B}} = \frac{1.236}{69.502} = 0.0178, \qquad c_{8\text{B}} = \frac{1.236}{7.50} = 0.165.$$

A ninefold change in $c$ from one target swap, with the draft untouched. At $\kappa=3$ and an illustrative $\alpha = 0.70$, speedup goes from $2.533/1.495 = 1.69\times$ on the 8B target to $2.533/1.053 = 2.40\times$ on the 70B one. Solving $E(\alpha,3) = 2.4047$ for the free-draft tie gives $\alpha^{\dagger} = 0.666$; at $\kappa=5$ it is 0.661, at $\kappa=7$ it is 0.654. Against the 70B target the indifference threshold is nearly flat in $\kappa$ — because $c$ is small enough that the cost term $1+\kappa c$ barely moves. That flatness is a property of small $c$, not of the formula: repeat the solve at $c = 0.165$ and $\alpha^{\dagger}$ falls from 0.459 at $\kappa=1$ to 0.430 at $\kappa=3$ to 0.314 at $\kappa=7$, because there the cost term more than doubles across that range while $E$ saturates. Derived; recompute it yourself in Exercise 2.

0.0178
c — 1B draft, 70B target
0.165
c — 1B draft, 8B target
0.43 → 0.67
free-draft tie α, 8B → 70B
10.5%
concurrency the 70B draft costs

The memory the draft takes before it drafts anything

$c$ is only the time term. The draft model also has weights and a KV cache, and in vLLM its attention layers join the same paged pool as the target's — the proposer looks itself up in the target's already-built kv_cache_config rather than allocating its own:

vllm/v1/spec_decode/llm_base_proposer.py:L1745-L1752 vLLM
        # Find which kv_cache_group the draft layers belong to
        self.validate_same_kv_cache_group(kv_cache_config)
        kv_cache_spec = None
        for gid, group in enumerate(kv_cache_config.kv_cache_groups):
            if self._draft_attn_layer_names & set(group.layer_names):
                self.kv_cache_gid = gid
                kv_cache_spec = group.kv_cache_spec
                break

Price it. Llama-3-70B on 4×H100 at gpu_memory_utilization=0.9. An “80 GB” H100 is 79.65 GiB — not 80 GiB, and not 80×109 bytes (§2.1) — so the budget is 71.69 GiB per GPU, minus 32.85 GiB of weight shard — the whole 70.55 B-parameter checkpoint, which is the 69.50 B streamed above plus the 1.05 B input embedding table that is resident but gathered rather than streamed — minus roughly 2 GiB of activations and workspace, leaves a 36.83 GiB KV pool per GPU — 147.3 GiB aggregate. Per token the target's KV is $2 \times 80 \times 8 \times 128 \times 2 = 320$ KiB, so the pool holds 482,772 tokens, or 58 sequences at 8k context.

Add the draft. Its weights are 2.30 GiB, 0.575 GiB per shard, straight off the KV pool. Its KV is $2 \times 16 \times 8 \times 64 \times 2 = 32$ KiB per token — 10% on top of every token you cache. The pool becomes 145.0 GiB holding 352 KiB tokens: 432,026 tokens, 52 sequences, a 10.3% loss of integer request concurrency. The unrounded token-capacity loss is about 10.5%. Derived. This precedes per-step drafting slot reservation, and before you have measured a single acceptance. A zero-parameter draft source costs exactly none of it.

Same tokenizer

Practical model pairing needs compatible token identities or a validated mapping. Equal-size different tokenizers can yield poor proposals, but do not by themselves invalidate exact rejection over the common integer-ID space: any correctly accounted proposal q is allowed. Correctness fails if IDs/probabilities are mapped inconsistently. The engine guard says: "Target and draft model should have the same vocabulary size. ... Using models with different tokenizers can cause out-of-bounds errors during speculative decoding." (vllm/config/speculative.py:L1418-L1433). The opt-in escape is use_heterogeneous_vocab, which builds a token-level intersection, maps ids in both directions, and masks everything outside the intersection to $-\infty$ in the draft's logits (vllm/v1/spec_decode/vocab_mapping.py:L103-L120, L152-L154). Restricting $q$'s support keeps verification well-posed, but it caps $\alpha$ at the intersection's probability mass, and it requires greedy draft sampling as of a556f3f (docs/features/speculative_decoding/draft_model.md:L79-L84).

§4

N-gram: a draft with no model at all

The n-gram proposer takes the last $n$ tokens of prompt-plus-generation, finds an earlier occurrence of that same $n$-gram in the same sequence, and proposes whatever followed it. No weights, no GPU, no distribution — the rejection sampler treats $q$ as one-hot, which §6.2 showed degenerates the accept test to "accept the drafted token with its own target probability".

Figure 2 — one n-gram lookup on a code-editing request. The last five tokens of the sequence are the pattern. An earlier occurrence exists in the prompt, so the three tokens that followed it there become the draft. $\kappa = 3$, prompt_lookup_min = 3, prompt_lookup_max = 5; the matcher takes the longest match in that window.

N-gram lookup finding a repeated span and proposing its continuation Two rows of token cells. The upper row is the prompt and contains the span def parse underscore config open-paren followed by path close-paren colon. The lower row is the generation so far and ends with the same five-token span, highlighted. Arrows carry the three tokens that followed the prompt occurrence down into three draft cells appended to the generation. prompt tokens defparse_ config(path ): cfg=load (path) earlier occurrence, positions 0–4 what followed it — the draft generated so far #reloadhelper defparse_ config( path): pattern — the last 5 tokens κ = 3 drafted, verified in the next target pass GPU cost of this lookup: none. It runs on the host, in numba.

Why does this work at all? Because for a large class of production traffic the output is mostly a copy of the input. Summarise this document: the summary quotes it. Retrieval-augmented QA: the answer restates retrieved spans, including names and numbers that a 1B draft model would never guess. Code editing: the model reprints the function it is modifying with three tokens changed. Agentic loops and RL rollouts: the same tool-call scaffolding, over and over. In all of these there is a literal earlier occurrence of the current suffix, and the matcher finds it. On open-ended generation — write me a poem — there is not, and $\alpha$ collapses to whatever the model's own repetitiveness gives you, which is close to nothing.

The matcher, read

vLLM's is numba-JIT'd KMP. It does not scan for a fixed $n$; it finds the longest suffix match from prompt_lookup_min through prompt_lookup_max in one linear pass, by reversing the sequence and running the failure function:

vllm/v1/spec_decode/ngram_proposer.py:L214-L241, L230-L241 vLLM
    """
    Find the longest n-gram which matches the suffix of the given tokens
    whose length is within [min_ngram, max_ngram] (inclusive).

    If found, we will extract k right after the matched ngram.
    """
# ...
    # Flip tokens, and the goal become to find longest ngram
    # on the rightmost position which matches the prefix with
    # length [min_n, max_n] (inclusive).
    tokens = origin_tokens[::-1]

    # Longest prefix (not including itself) which is a suffix of
    # the current position.
    #   lps[i] = max{v, where tokens[0:v] == tokens[i+1-v:i+1]}
    #
    # As ngram is capped by max_ngram to save memory, we only need to
    # store lps for the first max_ngram prefix.
    lps = np.zeros(max_ngram, dtype=np.int32)

One detail contradicts the folklore. "Prompt lookup" is usually described as finding the most recent occurrence. This implementation deliberately does the opposite:

vllm/v1/spec_decode/ngram_proposer.py:L255-L263 vLLM
            # Check if we found a longer valid ngram.
            #
            # Update position when longest_ngram matched prev_lps,
            # as we want to get the target n-gram of the earliest position
            # in the original tokens (i.e.
            # latest position in the reversed tokens)
            if prev_lps >= longest_ngram:
                longest_ngram = prev_lps
                position = i

The >= keeps overwriting position with later indices in the reversed array, which are earlier indices in the original. The GPU implementation makes the same choice with a different mechanism — torch.argmax over the match indicator returns the first true — and says so: "Searches for the earliest prior occurrence of the trailing n-gram" (vllm/v1/spec_decode/ngram_proposer_gpu.py:L56-L58). Among equally long matches, vLLM prefers the oldest occurrence, on the theory that the prompt is a better source of truth than the model's own recent output. Suffix decoding, below, makes recency an explicit and tunable ranking instead.

Defaults, version-stamped at a556f3f: if neither bound is given, both become 5.

vllm/config/speculative.py:L815-L820 vLLM
        if self.method in ("ngram", "ngram_gpu"):
            # Set default values if not provided
            if self.prompt_lookup_min is None and self.prompt_lookup_max is None:
                # TODO(woosuk): Tune these values. They are arbitrarily chosen.
                self.prompt_lookup_min = 5
                self.prompt_lookup_max = 5

Note that the field's own docstring says prompt_lookup_min "Defaults to 1" (L163-L165) while the code sets 5. The code wins. The e2e test uses min 3 / max 5 with $\kappa=3$ (tests/v1/e2e/spec_decode/ngram_suffix/test_ngram_suffix.py:L27-L32); the shipped example defaults to min 2 / max 5 (examples/features/speculative_decoding/spec_decode_offline.py:L55-L56). Larger $n$ means fewer but more trustworthy matches — it moves you right along Figure 1 only if the extra precision buys more $\alpha$ than the lost coverage costs.

§5

Why a free matcher was moved onto the GPU

"Costs nothing" is a claim about FLOPs, not about wall clock. The CPU proposer runs inside the step loop, after sampling, and it needs the sampled token ids as Python lists. Materialising them is not free:

vllm/v1/worker/gpu_model_runner.py:L7936-L7949 vLLM
    def _to_list(self, sampled_token_ids: torch.Tensor) -> list[list[int]]:
        # This is a short term mitigation for issue mentioned in
        # https://github.com/vllm-project/vllm/issues/22754.
        # `tolist` would trigger a cuda wise stream sync, which
        # would block other copy ops from other cuda streams.
        # A cuda event sync would avoid such a situation. Since
        # this is in the critical path of every single model
        # forward loop, this has caused perf issue for a disagg
        # setup.
        pinned = self.sampled_token_ids_pinned_cpu[: sampled_token_ids.shape[0]]
        pinned.copy_(sampled_token_ids, non_blocking=True)
        self.transfer_event.record()
        self.transfer_event.synchronize()
        return pinned.tolist()

Then the matching itself. It is $O(T)$ per request in the context length $T$, single-threaded by construction, and the thread cap is written so that it can never be anything else:

vllm/v1/spec_decode/ngram_proposer.py:L39-L53 vLLM
        # Max number of threads for numba parallel processing.
        if cpu_count:
            # Divide by 2 to use physical cores
            # and not logical cores (hyper-threading).
            # Cap the number of threads to 8 to avoid using too many threads
            # since other components like frontend (incl tokenization)
            # and Structured Outputs also use multiple threads.
            # TODO(ekagra-ranjan): bump up the cap from 1 to 8
            # when TP parallelization for ngram is implemented.
            self.num_numba_thread_available = min(1, (cpu_count // 2))
            # Divide by tp_size to ensure each tensor parallel rank
            # has some threads since all ranks will run this.
            self.num_numba_thread_available //= tp_size
        else:
            self.num_numba_thread_available = 1

The comment says "cap ... to 8"; the expression is min(1, ...), and the TODO explains why. A @njit(parallel=True) kernel with a prange over requests (L177-L189) is therefore running on one thread, and the num_tokens_threshold = 8192 switch that is supposed to enable multi-threading (L36, L102-L106) cannot select more than one. At batch 64 with 8k of context each, that single thread walks roughly $5\times10^{5}$ token positions per step, serially, while the GPU waits.

That is the entire motivation for ngram_gpu: not arithmetic, but keeping the step loop free of host round-trips. Its inner loop replaces KMP with brute-force windowed comparison, which is embarrassingly wasteful and completely parallel:

vllm/v1/spec_decode/ngram_proposer_gpu.py:L84-L108 vLLM
        for i, ngram_len in enumerate(range(min_ngram_len, max_ngram_len + 1)):
            # Sliding windows of size ngram_len; unfold is O(1) view.
            search_windows = token_ids.unfold(1, ngram_len, 1)
            num_windows = search_windows.shape[1]

            # Trailing suffix (last ngram_len tokens) for each sequence.
            suffix_starts = seq_lengths - ngram_len
            suffix_indices = suffix_starts.unsqueeze(1) + torch.arange(
                ngram_len, device=device
            )
            suffix_indices.clamp_(min=0)
            suffix = torch.gather(token_ids, 1, suffix_indices)

            # Window matches for each sequence.
            matches = (search_windows == suffix.unsqueeze(1)).all(dim=-1)

            # Match must leave room for at least one draft token.
            max_valid_suffix_start = seq_lengths - ngram_len - 1
            window_positions = torch.arange(num_windows, device=device)
            valid_mask = window_positions <= max_valid_suffix_start.unsqueeze(1)
            final_matches = matches & valid_mask

            # Find earliest match (argmax=0 when empty; verify with has_match).
            first_match_idx = torch.argmax(final_matches.int(), dim=1)
            has_match = final_matches[batch_indices, first_match_idx]

Every comparison is data-independent, so the whole thing is one torch.compiled region with CUDAGraphMode.NONE and no branch on any device value — the file's comments say so explicitly ("Avoid data-dependent branching", L120; "Extract draft tokens; gather always runs", L140). The consequence is that the proposer can no longer tell the scheduler how many tokens it produced without a sync, so it doesn't: it pads with $-1$, ships the count home on a dedicated stream, and trims the scheduler's own bookkeeping afterwards (update_scheduler_for_invalid_drafts, L475-L515) — which is also why the runner has to shallow-copy SchedulerOutput before handing it over (vllm/v1/worker/gpu_model_runner.py:L4299-L4314). A sampler can skip invalid $-1$ slots, but scheduled verification positions and bookkeeping are not free end to end.

When it pays: long contexts, large batches, async scheduling, or a disaggregated setup where a host sync stalls other streams. At batch 4 with 512-token contexts, the CPU matcher is fine and the GPU version is paying a kernel launch to save a microsecond.

§6

Suffix decoding: the same idea with a real data structure

Fixed-$n$ lookup has three limitations that all come from the same place: it commits to one match. It cannot tell a span it has seen forty times from one it has seen once; it cannot vary the draft length with how confident the match is; and it can only match within the current request. A suffix automaton fixes all three, because it stores every substring of everything it has ingested, in linear space.

vLLM's suffix method is a thin wrapper — the cache, tree, and speculation logic live in Arctic Inference, out of tree. What the wrapper shows is the interface and the per-request lifecycle:

vllm/v1/spec_decode/suffix_decoding.py:L77-L89 vLLM
            # Suffix decoding only uses the most recent tokens up to max_tree_depth, so
            # we extract the pattern from the end of the input.
            start = max(0, num_tokens - self.max_tree_depth)
            pattern = input_batch.token_ids_cpu[i, start:num_tokens]
            draft = self.suffix_cache.speculate(
                req_id,
                pattern,
                max_spec_tokens=min(
                    self.num_speculative_tokens, self.max_model_len - num_tokens - 1
                ),
                max_spec_factor=self.max_spec_factor,
                min_token_prob=self.min_token_prob,
            )

Three knobs there are the three fixes. max_spec_factor makes the draft length a function of match quality — "max_spec_tokens = max_spec_factor * prefix_match_length" (vllm/config/speculative.py:L205-L209), so a 10-token match speculates ten times further than a 1-token one at the default factor of 1.0. min_token_prob = 0.1 is a frequency-count threshold: "Will only speculate tokens with estimated probability (based on frequency counts) greater than or equal to this value" (L210-L212). And suffix_decoding_max_cached_requests = 10,000 keeps a global tree of past responses, FIFO-evicted, so a match can come from a different request entirely (L199-L203). Because the draft length is dynamic, num_speculative_tokens stops being a length and becomes a ceiling — the docs recommend 16 or 32 rather than 3 (docs/features/speculative_decoding/suffix.md:L12-L13).

Unverified

SuffixDecodingCache.speculate ships in the external arctic-inference package and is not in either pinned tree, so I cannot show you how the frequency estimate is computed or how max_spec_factor interacts with min_token_prob. Everything above comes from vLLM's wrapper and config docstrings. The readable equivalent is SGLang's, below.

SGLang's suffix automaton, in tree

SGLang builds the same structure itself, in C++, JIT-compiled, and it is readable at this SHA. The state is a textbook suffix automaton with two extra statistics per state:

python/sglang/kernels/jit/csrc/ngram_corpus/suffix_automaton.h:L20-L28 SGLang
struct SamState {
  int link = -1;
  int32_t max_len = 0;
  std::unordered_map<int32_t, int> next;
  uint64_t occ_count = 0;
  int64_t max_end_pos = -1;
  std::vector<std::pair<int32_t, int>> children_by_freq;
  std::vector<std::pair<int32_t, int>> children_by_recency;
};

occ_count is how many times this substring occurred; max_end_pos is where it occurred last. Both are computed once at finalize() by propagating up the suffix-link tree in order of decreasing max_len (suffix_automaton.cpp:L98-L112), then each state's children are pre-sorted into two orders — by frequency and by recency. Fixed-$n$ lookup has to choose one occurrence; the automaton keeps a ranked list of continuations at every matched length simultaneously.

Matching is a walk with suffix-link fallback, which is what makes it strictly more informative than a fixed window: on a miss it does not give up, it shortens the match and continues.

python/sglang/kernels/jit/csrc/ngram_corpus/suffix_automaton.cpp:L146-L178 SGLang
  const auto start = len > max_depth ? len - max_depth : 0;
  int state = 0;
  int32_t matched_len = 0;
  for (size_t i = start; i < len; ++i) {
    const auto token = context[i];
    while (state != 0 && !states_[state].next.contains(token)) {
      state = states_[state].link;
      matched_len = std::min<int32_t>(matched_len, states_[state].max_len);
    }
    // ...
  }

  std::vector<SamAnchor> anchors;
  while (state > 0 && matched_len > 0) {
    if (!states_[state].children_by_freq.empty()) {
      anchors.push_back({state, matched_len});
    }
    state = states_[state].link;
    if (state <= 0) {
      break;
    }
    matched_len = std::min<int32_t>(matched_len, states_[state].max_len);
  }
  return anchors;

The second loop is the payoff. It walks the suffix links upward from the deepest match, collecting one anchor per achievable match length: a 9-token match, a 7-token match, a 4-token one, down to 1. The draft is then built from all of them, with the fan-out at each anchor scaled by how long its match was — long match, narrow and confident; short match, wide and hedged:

python/sglang/kernels/jit/csrc/ngram_corpus/suffix_automaton.cpp:L183-L193 SGLang
  auto anchors = match(context, len, param.max_trie_depth);
  const auto max_match_depth = std::max<int32_t>(1, static_cast<int32_t>(param.max_trie_depth - 1));
  const double bfs_breadth_scale = double(param.max_bfs_breadth - param.min_bfs_breadth) / max_match_depth;
  std::vector<Node> tree(draft_token_num + 1);
  int root = 0;
  int cursor = 1;

  for (const auto& anchor : anchors) {
    std::queue<std::tuple<int, double, int>> queue;
    queue.push(
        {root, (max_match_depth - anchor.matched_len) * bfs_breadth_scale + param.min_bfs_breadth, anchor.state});

With the shipped defaults — max_trie_depth = 18, min_bfs_breadth = 1, max_bfs_breadth = 10 (python/sglang/srt/server_args.py:L2327-L2342) — the scale is $(10-1)/17 = 0.53$, so a 17-token match starts at breadth 1 (a straight chain) and a 2-token match starts at breadth $15 \times 0.53 + 1 = 8.9$, and breadth decays by 0.53 per level as the BFS descends. The alternative match_type = "PROB" replaces the queue with a max-heap ordered by a frequency-derived probability (buildFrequency, L217-L283), expanding the globally most likely node next rather than level by level.

Figure 3 — SGLang's n-gram path: from context tail to a verified draft tree. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…

Two things separate SGLang's implementation from vLLM's n-gram. First, the draft is a tree, not a chain: speculative_num_draft_tokens is a node budget for the whole tree, and NgramVerifyInput carries a custom_mask and parent/sibling links so one target pass verifies every path (python/sglang/srt/speculative/ngram_info.py:L11-L41; the mask machinery is §6.4's subject). The header comment is blunt about the shape: "NGRAM trees are node-budgeted with no depth cap ... a single long match can chain all draft_token_num nodes" (L47-L52). Second, the corpus is shared across requests and grows as the server runs — every step feeds accepted output back in:

python/sglang/srt/speculative/ngram_worker.py:L416-L423 SGLang
            put_ids = self._efficient_concat_last_n(
                list(req.origin_input_ids[-self.max_trie_depth :]),
                list(req.output_ids[-self.max_trie_depth :]) + prev_tokens,
                self.max_trie_depth,
            )
            batch_tokens.append(put_ids)
            i += 1
        self.ngram_corpus.batch_put(batch_tokens)

And it can be pre-seeded offline: --speculative-ngram-external-corpus-path takes a JSONL corpus tokenized at startup, capped by speculative_ngram_external_corpus_max_tokens (default 10 M) with a separate node budget speculative_ngram_external_sam_budget reserved for its subtree in every draft (python/sglang/srt/server_args.py:L2346-L2359). That budget flag defaults to 0, and 0 is rejected: passing the corpus path without also passing a positive budget (and a budget no larger than speculative_num_draft_tokens - 1) raises at startup — "--speculative-ngram-external-sam-budget must be positive when --speculative-ngram-external-corpus-path is set." (python/sglang/srt/arg_groups/speculative_hook.py:L729-L747). The corpus is opt-in and costs you draft-tree nodes you must explicitly hand it. Corpora can also be added and removed at runtime through POST /add_external_corpus, loaded on a background thread and committed from the scheduler loop (python/sglang/srt/speculative/external_corpus_manager.py:L40-L52). That turns "the draft has to already be in your prompt" into "the draft has to already be in your domain corpus" — a materially different, and much larger, hit rate.

§7

Worked trace: one n-gram step in SGLang

Follow one decode step of a batch of 8 requests with --speculative-algorithm NGRAM --speculative-num-draft-tokens 8.

  1. NGRAMWorker._prepare_draft_tokens (python/sglang/srt/speculative/ngram_worker.py:L238) assembles, for each request, the last max_trie_depth = 18 tokens of prompt-plus-output. In overlap mode the previous step's accepted tokens are not yet in req.output_ids, so they are spliced in from spec_info.accept_tokens — which is the one .cpu() in this path, and the code hoists everything it can above it ("Accept-independent prep, hoisted above the blocking .cpu() below", L250).
  2. self.ngram_corpus.synchronize() (L283) flushes the previous step's inserts into the automaton, then batch_get(req_ids, batch_tokens, total_lens) (L299) calls into match_stateful, which returns (req_drafts, mask) — a flat $8 \times 8$ token array and its $8 \times 8 \times 8$ ancestry mask.
  3. The worker asserts the corpus returned exactly bs * draft_token_num tokens ("here we always enforce it", L302-L306). Unlike vLLM's proposer, which returns an empty list when nothing matches, SGLang always fills the tree — a padded node still costs a verified position.
  4. _prepare_for_speculative_decoding (L310) turns the mask into retrieve_next_token/retrieve_next_sibling and rewrites the batch's forward_mode to TARGET_VERIFY. One target forward runs over $8 \times 8 = 64$ positions.
  5. Verification is the shared path from §6.2 — eagle_sample, then move_accept_tokens_to_target_kvcache, because a tree's accepted path is scattered.
  6. _update_ngram_corpus (L399) pushes the accepted tokens back in, and requests that left the batch have their match state dropped: self.ngram_corpus.erase_match_state(list(departed_rids)) (L522). The automaton keeps the tokens; only the per-request walk cursor is freed.

vLLM's chain path is shorter. propose_draft_token_ids (vllm/v1/worker/gpu_model_runner.py:L5138-L5149) branches on spec_config.method, hands the CPU proposer sampled_token_ids as a list plus token_ids_cpu, and gets back one list[int] per request — empty where no match was found, which the scheduler reads as "this request does not speculate this step". Mixed batches where some requests match and some do not are the normal case, and §6.2's ragged metadata is what makes them a single forward pass.

§8

Choosing: workload shape to draft source

Decision table. $c$ column derived as a parameter ratio or from the absence of GPU work; the $\alpha$ column is a qualitative expectation from the mechanism, not a measurement — Lab 08 is where you get numbers.
WorkloadDraft sourcecWhy the α side works
Summarisation, RAG answers over retrieved textngram, or suffix for long inputs≈ 0Output copies literal spans from the prompt; the match is in the same sequence.
Code editing, refactor, diff applicationsuffix (dynamic length)≈ 0Long verbatim regions between edits; max_spec_factor converts a long match into a long draft.
Agentic loops, self-consistency, RL rolloutssuffix, or SGLang NGRAM with a global corpus≈ 0Repetition is across requests, so a per-request matcher misses it and a shared tree catches it.
Domain-narrow serving — one product's docs, one codebaseSGLang NGRAM + --speculative-ngram-external-corpus-path and a positive --speculative-ngram-external-sam-budget (required together)≈ 0The corpus supplies matches the prompt never contained.
Open-ended chat, creative writing, translationdraft_model if a same-tokenizer small model exists; otherwise §6.40.02–0.17No literal repetition to match. Only a model generalises.
Very large target (70B+, MoE)draft_model becomes cheap≈ 0.02$c$ is a parameter ratio, so a fixed draft gets relatively free as the target grows.
Long context, big batch, async or disaggregated schedulingngram_gpu over ngram≈ 0Same matcher; the win is removing a host sync from the step loop.
High load near the measured crossovertest shorter κ or disablingThe §6.2 cost model predicts a loss; measure the actual workload.

LoRA compatibility is a method/runner-specific contract, not a general prohibition on model-based drafts. The quoted SGLang validator lists NGRAM, EAGLE, NEXTN, EAGLE3, DFLASH and DSPARK as compatible methods (python/sglang/srt/server_args.py:L9569-L9583). Weightless sources avoid draft-adapter synchronization, while model-based sources still need the supported configuration and an acceptance measurement under the active target adapter.

§9

Pitfalls and war stories

The draft's memory is subtracted before the pool is sized

The 10.5% concurrency loss derived in §3 is not a runtime slowdown you can profile; it appears as a smaller num_gpu_blocks at startup and shows up later as queueing delay under load. If you add a draft model to a server that was already running near its concurrency limit, the symptom is a longer queue, not a longer step. Check the KV-cache block count in the startup log before and after.

Long wrong drafts burn verification budget

Suffix decoding's dynamic length is a double-edged knob. At max_spec_factor = 2.0 (what the e2e test uses, tests/v1/e2e/spec_decode/ngram_suffix/test_ngram_suffix.py:L33-L36) a 12-token match asks for 24 draft tokens. If the match was coincidental, all 24 positions are verified and discarded — and, worse, all 24 wrote KV entries the scheduler must roll back (§6.2). The cost is not $\kappa c$; $c$ is zero here. The cost is $\kappa+1$ verified positions against the roofline ridge of 295, which at batch 16 is already $16 \times 25 = 400$ positions and past it. For a free draft source, the binding constraint is not draft cost but verification width. min_token_prob exists for exactly this and defaults to 0.1.

Acceptance collapses when the workload shifts, silently

An n-gram proposer configured for a summarisation fleet keeps working when the traffic turns into open-ended chat — it just returns empty drafts, then the occasional wrong one. Nothing errors. The observable is vLLM's mean_acceptance_length and its per-position vector (vllm/v1/spec_decode/metrics.py:L113-L118, walked through in §6.2). Alert on it. It is the only thing in this chapter that tells you your draft source has stopped matching the traffic.

Tokenizer mismatch does not fail loudly enough

Equal vocabulary size is not semantic tokenizer compatibility. Different merges can pass an ID-range check yet produce poorly aligned proposals. Exact rejection remains valid over consistent integer IDs/probabilities; a low acceptance rate is not itself output bias. For deliberate cross-tokenizer pairing, inspect the mapping and probability accounting, use use_heterogeneous_vocab: true and read the intersection size the mapper logs: "VocabMapping initialized: target_vocab=%d, draft_vocab=%d, intersection=%d" (vllm/v1/spec_decode/vocab_mapping.py:L122-L136), which warns below 100 shared tokens.

Earliest, not most recent

If you are reasoning about why a particular draft was proposed, remember that vLLM picks the oldest occurrence of the longest match (L255-L263 above), while SGLang's default match_type = "BFS" ranks continuations by children_by_recency, i.e. max_end_pos — the newest. On a request whose prompt and generation disagree about what follows a span, the two engines will propose different tokens from the same context. Both are correct; only their hit rates differ.

Coverage is not conditional acceptance

A lookup method can have high acceptance when it finds a match yet match few requests. Report match coverage, proposals per matched request, accepted length and full draft/verify latency separately. Shared suffix stores also need tenant/model/tokenizer namespaces, bounded retention and access controls; retrieving another tenant's text is a privacy boundary even if target verification preserves output probabilities.

Independent CPU reference; not an engine or GPU benchmark
def expected_emitted(alpha, depth):
    return sum(alpha ** i for i in range(depth + 1))

def mixture_yield(coverage, alpha, depth):
    return 1 + coverage * (expected_emitted(alpha, depth) - 1)

rare_accurate = mixture_yield(0.1, 0.99, 3)
common_moderate = mixture_yield(0.9, 0.7, 3)
assert rare_accurate < common_moderate
assert abs(mixture_yield(0, 1, 3) - 1) < 1e-12
assert abs(mixture_yield(1, 1, 3) - 4) < 1e-12
print("Expected emitted tokens:", rare_accurate, common_moderate)
print("Compare these with measured full iteration cost, not acceptance alone.")
§10

Hands-on

Run the same prompts through three draft sources and read mean_acceptance_length off the metrics, which is $E$ from §6.2 directly.

from the vLLM checkout at a556f3f shell
# free draft, chain, host-side matcher
python examples/features/speculative_decoding/spec_decode_offline.py \
    --method ngram --num-spec-tokens 3 --prompt-lookup-min 3 --prompt-lookup-max 5 \
    --temp 0 --output-len 256

# same matcher, on the GPU — no flag in the example, so use the server
vllm serve Qwen/Qwen3-8B \
    --speculative-config '{"method": "ngram_gpu", "num_speculative_tokens": 3, "prompt_lookup_min": 2, "prompt_lookup_max": 3}'

# suffix automaton, dynamic draft length (needs: pip install arctic-inference)
vllm serve Qwen/Qwen3-8B \
    --speculative-config '{"method": "suffix", "num_speculative_tokens": 32, "suffix_decoding_max_spec_factor": 2.0}'

# a real draft model, for the contrast
python examples/features/speculative_decoding/spec_decode_offline.py \
    --method draft_model --draft-model Qwen/Qwen3-0.6B --num-spec-tokens 5 --temp 0

The experiment worth doing is not "which is fastest on my laptop prompt". It is: run each source on a summarisation dataset and on an open-ended chat dataset, record $E$ for both, and plot the four points on Figure 1. You will have derived $c$ already; the measurement supplies $\alpha$, and the plane tells you which side of the indifference curve you are on. That sweep is Lab 08, spec-decode acceptance. Everything numeric in this chapter is derived or cited; nothing here was measured.

SGLang's equivalent, with the tree budget rather than a chain length:

from the SGLang checkout at 7d89325 shell
python -m sglang.launch_server --model-path Qwen/Qwen3-8B \
    --speculative-algorithm NGRAM --speculative-num-draft-tokens 8 \
    --speculative-ngram-max-trie-depth 18 --speculative-ngram-max-bfs-breadth 10 \
    --speculative-ngram-match-type BFS
§11

Exercises

  1. Read vllm/v1/spec_decode/ngram_proposer.py:L283-L293. The function returns origin_tokens[start_position : start_position + k] where start_position = total_token - 1 - position + longest_ngram. Construct a token sequence where the matched n-gram ends within $k$ tokens of the sequence end, and say what the final k = min(k, total_token - start_position) line prevents.
  2. Derive the free-draft indifference acceptance $\alpha^{\dagger}$ for a Llama-3.2-1B draft against a Llama-3-8B target at $\kappa = 1$ and at $\kappa = 7$, using $c = 0.165$. Compare with the $\kappa=3$ answer of 0.43, then repeat the whole sweep at $c = 0.018$. Explain in one sentence why the threshold is nearly flat in $\kappa$ at the smaller $c$ and clearly is not at the larger one.
  3. Predict, then verify: you run ngram_gpu with prompt_lookup_max = 5 on a request whose context is 200 tokens, but the batch's token_ids_gpu tensor is max_model_len = 16384 wide and zero-padded. Does token_ids.unfold(1, 5, 1) produce spurious matches in the padding? Read vllm/v1/spec_decode/ngram_proposer_gpu.py:L96-L102 and say which line stops them.
  4. SGLang's buildRecency starts the BFS at breadth $(\texttt{max\_match\_depth} - \texttt{matched\_len}) \times \texttt{scale} + \texttt{min\_bfs\_breadth}$ and decrements by scale per level. With the defaults, how many levels deep can a match of length 2 go before its breadth clamps to 1? Read suffix_automaton.cpp:L181-L215 and check your arithmetic against std::max(1, static_cast<int32_t>(cur_breadth)).
  5. Suppose your workload is 70% RAG and 30% open-ended chat on one server. Using Figure 1's contours, argue for or against running suffix globally versus routing the two populations to different configurations. What would you have to measure to decide, and what does §6.2's $S(B)$ curve say about doing it at high load?
Answers

1. Take [A, B, C, A, B] with $n=2$, $k=3$. The match is A B at position 0; start_position = 2, and only three tokens remain from index 2, of which the draft can legitimately use C, A, B. If the match had ended at index 4 there would be nothing after it, and the clamp returns a shorter — possibly empty — array rather than reading past the end of origin_tokens. The earlier clamp k = min(k, max_model_len - total_token) (L226) is the different guard: it stops the draft from pushing the request past max_model_len.

2. Solve $E(\alpha,\kappa) = E(0.7,\kappa)/(1+0.165\kappa)$. At $\kappa=1$: RHS $= 1.700/1.165 = 1.459$, and $E(\alpha,1) = 1+\alpha$, so $\alpha^{\dagger} = 0.459$. At $\kappa=7$: RHS $= 3.141/2.155 = 1.458$, and solving $\sum_{i=0}^{7}\alpha^i = 1.458$ gives $\alpha^{\dagger} \approx 0.314$. Versus 0.430 at $\kappa=3$: a swing of 0.145 across a sevenfold change in $\kappa$, while changing $c$ from 0.165 to 0.018 at fixed $\kappa=3$ moved it from 0.43 to 0.67 — a swing of 0.24 from a parameter you never touched the draft to change. The direction is worth understanding: $E$ saturates in $\kappa$ (the marginal token is worth only $\alpha^{\kappa+1}$) while the cost term $1+\kappa c$ is linear, so at $c = 0.165$ the model-based draft's speedup decays through about1.46 at kappa=7 and ultimately toward zero for fixed c>0 and alpha<1. A negligible-cost matcher can require lower acceptance to match that increasingly costly chain. Redo the solve at $c = 0.018$ and the threshold barely moves (0.670 → 0.654), because there the cost term only reaches 1.125 at $\kappa=7$. Flatness in $\kappa$ is a small-$c$ phenomenon; sensitivity to $c$ is unconditional.

3. No spurious matches survive, because of valid_mask = window_positions <= max_valid_suffix_start.unsqueeze(1) with max_valid_suffix_start = seq_lengths - ngram_len - 1. Every window starting at or beyond position $200-5$ is masked out, so the padding region — and the trailing suffix itself — can never be selected, no matter how many identical zeros it contains. Windows of zeros do get compared and do set matches; it is the AND with valid_mask, one line later, that discards them.

4. max_match_depth = 17, scale = (10-1)/17 = 0.529. A match of length 2 starts at $15 \times 0.529 + 1 = 8.94$, and the truncating cast means the first level uses breadth 8. Subtracting 0.529 per level, the value reaches 2.0 after 13 levels and 1.0 after 15 — but the tree can only hold draft_token_num nodes total, so with NGRAM's default speculative_num_draft_tokens of 12 (python/sglang/srt/arg_groups/speculative_hook.py:L717-L723) the BFS exhausts the node budget long before the breadth decays. The breadth schedule matters at large budgets; at small ones the node cap dominates, which is what the cursor <= draft_token_num condition in the while loop enforces.

5. Running suffix globally is defensible: it costs no GPU time, so on the chat traffic it returns short or empty drafts and the loss is confined to the verified-position width, not to a wasted draft pass. That is the asymmetry that makes free drafts safe to leave on. What you must measure is $E$ separately for each population — a single blended mean_acceptance_length hides a 3.0 and a 1.05 averaging to a respectable-looking 2.4. Under §6.2's ideal compute-bound model, the ratio is at most one when c=0, with equality for perfect acceptance. Imperfect extra positions can still cost throughput; the result is model-dependent, not a universal theorem. The response to test is the same one §6.2 identified — shrink $\kappa$ as batch grows, via num_speculative_tokens_per_batch_size.

§12

Key takeaways

  • Parameter ratios provide an idealized estimate of draft cost, not an exact latency ratio. Measure c and acceptance separately, including communication, KV, matching overhead and actual kernel efficiency.
  • The indifference threshold — the acceptance a free matcher needs to tie a draft model — is driven far harder by $c$ than by $\kappa$: at $\kappa=3$ it is 0.43 against the 8B target and 0.67 against the 70B one. It is flat in $\kappa$ only when $c$ is small (0.670 → 0.654 across $\kappa=1$ to 7 at $c=0.018$); at $c=0.165$ the same sweep walks it from 0.459 down to 0.314, because there the $1+\kappa c$ term more than doubles.
  • A draft model costs concurrency before it costs time. Its layers join the target's paged KV pool, so on 4×H100 a 1B draft for Llama-3-70B takes 0.575 GiB per shard in weights and adds 32 KiB to every cached token — 10.5% fewer sequences at 8k context, charged at startup, visible only as queueing.
  • N-gram lookup wins wherever the output copies the input, and returns nothing where it does not. Both of vLLM's implementations prefer the oldest occurrence of the longest match, not the most recent; SGLang's default ranks by recency. The folklore is wrong about vLLM.
  • GPU matching can avoid host-visible token transfers and synchronization. The cited CPU path's event-specific wait should not be described as necessarily CUDA-wide. The Numba thread expression also needs later clamping and TP context inspected before asserting exactly one worker. GPU drafting retains scheduler and verification overhead.
  • A suffix automaton buys three things a fixed window cannot: ranked continuations at every achievable match length simultaneously (via the suffix-link walk), draft length scaled to match confidence, and matches drawn from other requests or a pre-loaded corpus. Its cost is not draft time — it is verification width, and the roofline ridge of 295 positions is what bounds it.
§13

Further reading

  • Oliaro et al., SuffixDecoding: A Model-Free Approach to Speeding Up Large Language Model Inference (arXiv:2411.04975) — the technical report vLLM's suffix method points at (vllm/v1/spec_decode/suffix_decoding.py:L10-L14), implemented in ArcticInference.
  • Saxena, prompt-lookup-decoding, and the thread vLLM's own docs cite as the reference (docs/features/speculative_decoding/n_gram.md:L3-L4). The original observation that input-grounded tasks make a string matcher a viable drafter.
  • vllm/v1/spec_decode/ngram_proposer_gpu.py at a556f3f — read update_scheduler_for_invalid_drafts (L475-L515) alongside gpu_model_runner.py:L4299-L4315 for a complete example of removing a host sync from a step loop by deferring the bookkeeping instead of the work.
  • python/sglang/kernels/jit/csrc/ngram_corpus/ — a compact, readable suffix automaton with occurrence counts and recency, plus two draft-tree builders (buildRecency and buildFrequency). Under 300 lines, and the clearest in-tree explanation of what suffix decoding actually does in either project.
  • Where this chapter hands off: the accept/reject rule and the $S(B)$ roofline argument are §6.2; drafts that come from the target's own hidden states, and the tree masks that verify them, are §6.4; SGLang's adaptive speculation, DFlash, DSpark and frozen-KV MTP are §6.6. The two formulas reused here live in FORMULAS.

The Inference Engineering Course · code read at vllm@a556f3fccb and sglang@7d893255c3, pinned 2026-08-21. See SOURCES for the full provenance record.

Explore the library

Reading preferences

Appearance
18 px