ML Interview Notes
47 min read15 sections
Part 6 · Decoding algorithms · 06-04

Medusa, EAGLE-1/2/3, MTP, and tree attention

Status
SOURCE PINNED
Primary sources
  • vllm/v1/spec_decode/llm_base_proposer.py
  • vllm/model_executor/models/llama_eagle3.py
  • vllm/model_executor/models/medusa.py
  • python/sglang/srt/speculative/eagle_utils.py
  • python/sglang/kernels/ops/speculative/spec_tree.py
Edition pins
vllm a556f3f · sglang 7d89325

A separate draft model is a second weight stream you pay for on every step. The target's own last hidden state is already sitting in a register file, already contains the information that produced the next token, and costs nothing to reuse. Everything in this chapter falls out of taking that observation seriously — and out of the mask you need when the guess branches.

§1

The problem

§6.2 left us with one expression and one disappointment:

$$\text{speedup} \;=\; \frac{E}{1 + \kappa c}, \qquad E = \frac{1-\alpha^{\kappa+1}}{1-\alpha}.$$

$\alpha$ is the per-token acceptance probability, $\kappa$ the chain length, and $c$ the cost of one draft forward pass as a fraction of one target forward pass. The disappointment is $c$. Put Llama-3.2-1B in front of Llama-3-8B on an H100 and the numbers are, derived from the §0.4 bandwidth floor: the target streams 15.01 GB per step (4.48 ms), the draft streams 2.47 GB (0.74 ms), so $c = 0.74/4.48 = 0.165$. At $\kappa=5$ the denominator is $1 + 5(0.165) = 1.83$. Even at a generous $\alpha = 0.70$, where $E = (1-0.7^{6})/0.3 = 2.94$, the speedup is $2.94/1.83 = 1.61\times$. You threw away 45% of the acceptance you paid for, to the drafter's own weight traffic.

Now suppose the draft cost per step were $c = 0.05$ instead. Same $\alpha$, same $\kappa$: denominator $1.25$, speedup $2.35\times$. Nothing about the draft's quality changed. The entire 46% improvement came from making the guess cheaper.

0.165
c, Llama-3.2-1B drafting for Llama-3-8B (derived)
0.104
c, one EAGLE layer, full 128,256 vocab (derived)
0.051
c, same layer, 32,000-token draft vocab (derived)

§6.3 drew the $(\alpha, c)$ plane and pointed at its best corner: high acceptance, near-zero cost. A separate draft model sits in the wrong part of it because it must rediscover, from scratch and from tokens alone, context the target has already computed. This chapter is about the family of methods that refuse to rediscover anything — they bolt a head onto the target and read its hidden state directly — and about the mask that lets you verify a branching guess in a single target pass.

§2

Mental model

Run the target over a prefix. The final decoder layer emits a hidden state $h_t \in \mathbb{R}^{4096}$; the LM head turns it into a distribution over 128,256 tokens and you sample $x_{t+1}$. Then you throw $h_t$ away and start over.

But $h_t$ contains far more than one token's worth of information. It has just been produced by 32 layers of attention over the whole context; it knows the syntactic frame, the entity being described, the fact that the sentence is halfway through a list. Predicting $x_{t+2}$ from $h_t$ is a much easier problem than predicting $x_{t+2}$ from the token string alone, which is what a standalone 1B draft model is doing.

Two families exploit this, and the difference between them is the whole story. Medusa attaches $K$ independent heads to $h_t$; head $k$ predicts $x_{t+k}$ directly. EAGLE attaches one small autoregressive module that consumes $h_t$ and the embedding of the token it just drafted, producing a new feature vector that the next draft step consumes — autoregression at the feature level rather than the token level.

Figure 1 — Medusa's independent heads against EAGLE's autoregressive feature-level draft. Shapes are Llama-3-8B: $d=4096$, vocab 128,256. The arrow that exists on the right and not on the left is the entire difference in acceptance.

Medusa independent heads versus EAGLE autoregressive feature drafting On the left, one target hidden state fans out to four independent Medusa heads, each emitting one token; no head sees another head's output. On the right, one EAGLE module is applied repeatedly: it concatenates the token embedding with the incoming feature, projects through fc, runs one decoder layer, and feeds its output feature into the next application. MEDUSA — independent heads, one pass target h_t [4096] head 1head 2 head 3head 4 x+SiLU(Wx)x+SiLU(Wx) x+SiLU(Wx)x+SiLU(Wx) lm_headlm_head lm_headlm_head t+1t+2t+3t+4 no edge between heads head 3 models p(x_t+3 | h_t), marginalising over x_t+1 and x_t+2 that it never gets to see. cost: 1 draft pass for all K guesses vllm/model_executor/models/medusa.py EAGLE — one module, applied autoregressively target h_t [4096] EAGLE module cat(embed(x), h) [8192] fc: 8192 to 4096 1 decoder layer (no in-LN) lm_head to draft vocab output feature becomes next h t+1t+2t+3t+4 each token conditions on the last cost: K draft passes, each tiny vllm/model_executor/models/llama_eagle.py
§3

Medusa: independent heads, and why independence fails

vLLM's Medusa proposer is eleven lines of substance, and every one of them is diagnostic.

vllm/v1/spec_decode/medusa.py:L48-L58 vLLM
    ) -> torch.Tensor:
        assert num_speculative_tokens == self.num_speculative_tokens
        # Generate blocks and compute logits
        blocks = self.model(target_hidden_states)
        logits = self.model.compute_logits(blocks)

        # Compute argmax for each Medusa head and stack into a single tensor
        # Shape: [batch_size, num_heads]
        draft_tokens = torch.stack([logit.argmax(dim=-1) for logit in logits], dim=1)

        return draft_tokens

Everything the drafter uses is target_hidden_states; the other two parameters of propose() (sampling_metadata and a slot_mappings the signature marks # unused) never reach the model. No input_ids, no positions, no attention metadata, no KV cache. There is no loop. All $K$ draft tokens appear from one call, and the model itself is a list comprehension:

vllm/model_executor/models/medusa.py:L105-L106 vLLM
    def forward(self, hidden_states: torch.Tensor) -> list[torch.Tensor]:
        return [block(hidden_states) for block in self.blocks]

Every block sees the same hidden_states. Head 3 has no way to know what head 1 and head 2 produced. Written out, head $k$ is trained to model

$$q_k(x) \;=\; p\big(x_{t+k} = x \mid h_t\big) \;=\; \sum_{x_{t+1},\dots,x_{t+k-1}} p\big(x_{t+k} \mid x_{t+1{:}t+k-1}, h_t\big)\, p\big(x_{t+1{:}t+k-1} \mid h_t\big),$$

a marginal. The draft you actually submit is the tuple $(\arg\max q_1, \arg\max q_2, \dots)$, which is the coordinatewise mode of a product of marginals — not a mode, or even a likely sample, of the joint. When the continuation is genuinely uncertain, independent argmaxes produce incoherent strings: each token is individually the most likely thing to appear at that offset, and together they are a sentence nobody would write. The verification pass then rejects at the first incoherence, and the accepted length collapses toward 1 regardless of how good each individual head is.

The Medusa paper's answer was a tree: take the top-$s_k$ tokens from head $k$ and verify the Cartesian product $s_1 \times s_2 \times \cdots$ as a branching draft, so that at least one branch is coherent. That works — and it is where tree attention entered speculative decoding — but it is a coverage patch, not a fix. The cost of covering a joint distribution with a product of marginals grows multiplicatively in the depth. The field's response was to fix the model instead, and make the draft autoregressive.

What actually runs

vLLM does not implement the Medusa tree. The torch.stack(..., dim=1) above is the whole proposal: $K$ independent argmaxes in a [batch_size, num_heads] chain, and the model's own docstring says so — "Currently this only supports generating proposals from top-1 tokens" (vllm/model_executor/models/medusa.py:L45-L46). Everything below about Medusa's cost is about the heads; the Cartesian-product draft tree that made Medusa competitive in the paper exists in neither engine at these SHAs — vLLM ships the heads without it, and SGLang has no Medusa path at all. The vLLM path itself is live, not dead code: medusa is a first-class SpeculativeMethod (vllm/config/speculative.py:L69-L79), MedusaProposer is constructed and driven from the V1 GPU runner (vllm/v1/worker/gpu_model_runner.py:L693-L694, L5205-L5216), and the config resolver still carries fixes for old-format FasterDecoding/medusa-* checkpoints (vllm/config/speculative.py:L895-L901).

What a Medusa head costs

The head body is trivial: ResidualBlock is num_hidden_layers applications of $x \mathrel{+}= \mathrm{SiLU}(Wx)$ with $W \in \mathbb{R}^{4096\times4096}$, so one layer is 16.78 M parameters. The vocabulary projection is not trivial, and the constructor says so:

vllm/model_executor/models/medusa.py:L70-L90 vLLM
        self.orig_vocab_size = config.vocab_size
        self.truncated_vocab_size = config.truncated_vocab_size

        if getattr(config, "original_lm_head", False):
            self.lm_head = ParallelLMHead(
                self.truncated_vocab_size,
                config.hidden_size,
                prefix=maybe_prefix(prefix, "lm_head"),
            )
            self.lm_heads = [self.lm_head for _ in range(self.config.num_heads)]
        else:
            self.lm_heads = nn.ModuleList(
                [
                    ParallelLMHead(
                        config.vocab_size,
                        config.hidden_size,
                        prefix=maybe_prefix(prefix, f"lm_heads.{i}"),
                    )
                    for i in range(self.config.num_heads)
                ]
            )

Derived. With four heads and separate full-vocabulary projections, each head carries $4096 \times 128{,}256 = 525.3$ M parameters of LM head against 16.8 M of body: 4 heads = 2.168 B parameters = 4.34 GB in bf16 = 1.29 ms of weight streaming, so $c = 0.289$. That is larger than the 1B draft's streamed-weight estimate. With original_lm_head, unique resident parameters fall to 592 M (1.19 GB); truncating to 32,000 tokens gives 198 M (0.40 GB). The corresponding 0.354 ms and 0.118 ms figures assume each shared weight is streamed only once across heads. Sharing a tensor does not itself guarantee one HBM read: separate projection calls, cache residency and batching determine traffic. These are optimistic cost estimates.

Cited

Cai et al., Medusa (arXiv:2401.10774), report "over 2.2x speedup" for Medusa-1 (heads trained on a frozen backbone) and "2.3-3.6x" for Medusa-2 (backbone fine-tuned jointly), across models of several sizes. Those are the paper's own end-to-end numbers on its own benchmark configuration; nothing in this chapter reproduces them.

Note also that Medusa is a vLLM-only path at these SHAs. SGLang's builtin algorithm list is EAGLE, EAGLE3, NEXTN, STANDALONE, NGRAM, DFLASH, DSPARK (python/sglang/srt/server_args.py:L2101-L2105) and the string "medusa" does not appear anywhere under python/.

§4

EAGLE: autoregression at the feature level

EAGLE keeps the "read the target's hidden state" idea and adds the missing edge. Its draft module is one Llama decoder layer preceded by a projection, and the projection is the interesting part:

vllm/model_executor/models/llama_eagle.py:L95-L124 vLLM
        self.fc = ReplicatedLinear(
            input_size=self.config.hidden_size * 2,
            output_size=self.config.hidden_size,
            bias=False,
            params_dtype=vllm_config.model_config.dtype,
            quant_config=self.quant_config,
            prefix=maybe_prefix(prefix, "fc"),
            return_bias=False,
        )

    def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
        return self.embed_tokens(input_ids)

    def forward(
        self,
        input_ids: torch.Tensor,
        positions: torch.Tensor,
        hidden_states: torch.Tensor,
    ) -> tuple[torch.Tensor, torch.Tensor]:
        input_embeds = self.embed_tokens(input_ids)
        hidden_states = self.fc(torch.cat((input_embeds, hidden_states), dim=-1))
        residual = None
        for layer in self.layers:
            hidden_states, residual = layer(
                positions,
                hidden_states,
                residual,
            )
        hidden_states = hidden_states + residual
        return hidden_states, hidden_states

That is the entire idea in one line. The input to draft step $j$ is $\mathrm{fc}\big(\,[\,\mathrm{embed}(\tilde{x}_j)\;;\;f_{j-1}\,]\,\big)$ where $\tilde{x}_j$ is the token drafted at step $j-1$ and $f_{j-1}$ is the feature that step $j-1$ emitted ($f_0 = h_t$, the target's own hidden state). The layer runs with a real KV cache and real positions, so it also attends to every previous position. Its output is returned twice: once as the thing the LM head consumes, once as $f_j$ for the next step.

Two consequences.

conditioning

The draft models a joint distribution

Draft token $j+1$ is conditioned on draft token $j$ through both embedding and feature. This removes the independence restriction of separate marginal heads. It does not guarantee a coherent sample or sustained acceptance: a poorly trained autoregressive draft can still fail.

why feature, not token

Features carry the uncertainty

A token id is a 17-bit summary of a 4096-dimensional state. Feeding the next step a feature instead of only a token means the draft carries forward everything the previous step knew, including how confident it was — the paper's framing is that feature-level sequences are more regular than token-level ones, so the same tiny model predicts them better.

One more detail worth reading, because it explains a load-error class you will hit: the first draft layer has its input LayerNorm deleted.

vllm/model_executor/models/llama_eagle.py:L40-L44 vLLM
        # Skip the input_layernorm
        # https://github.com/SafeAILab/EAGLE/blob/35c78f6cdc19a73e05cf5c330b4c358dad970c6a/eagle/model/cnets.py#L427
        if disable_input_layernorm:
            del self.input_layernorm
            self.input_layernorm = nn.Identity()

The checkpoint was trained with this normalization placement. Adding another norm changes the learned function and can reduce acceptance even when shapes remain valid. The size and direction of that change require measurement; there is no general factor-of-two penalty.

What one EAGLE step costs

Derived, all Llama-3-8B shapes. One decoder layer is $4\!\cdot\!4096\!\cdot\!4096$-equivalent attention projections ($q$: 16.78 M, $k$: 4.19 M, $v$: 4.19 M, $o$: 16.78 M under GQA with $h_{kv}=8$) plus a 14,336-wide MLP ($3 \times 4096 \times 14336 = 176.2$ M), total 218.1 M. Add fc at $8192 \times 4096 = 33.6$ M. Add the LM head.

Bytes streamed by one EAGLE draft step, Llama-3-8B target, bf16 — derived from parameter counts and the 3.35 TB/s H100 SXM floor. Compare the target's 15.01 GB / 4.48 ms.
ComponentParamsGB (bf16)share
1 decoder layer218.1 M0.43628.1%
fc (8192→4096)33.6 M0.0674.3%
LM head, full vocab 128,256525.3 M1.05167.6%
total, full vocab777.0 M1.5540.464 ms, c = 0.104
LM head, draft vocab 32,000131.1 M0.26234.2%
total, 32k draft vocab382.7 M0.7650.229 ms, c = 0.051

Two thirds of a full-vocabulary EAGLE draft's byte traffic is the LM head. The transformer is the cheap part. This is why every serious variant of this family ships a vocabulary reduction: Medusa's truncated_vocab_size/token_map, EAGLE-3's draft_vocab_size with a d2t index map back to target ids. It is also why the head cost is a fixed tax you pay $S$ times per iteration, once per draft forward.

Cited

Li et al., EAGLE (arXiv:2401.15077): "For LLaMA2-Chat 70B, EAGLE achieved a latency speedup ratio of 2.7x-3.5x, doubled throughput, while maintaining the distribution of the generated text." The abstract describes the method as "autoregression at the feature (second-to-top-layer) level" with "a token sequence advanced by one time step" — exactly the cat(embed(x), h) above.

vLLM's draft loop is a chain

vLLM's EagleProposer is a thirteen-line subclass of SpecDecodeBaseProposer — the whole file is 22 lines (vllm/v1/spec_decode/eagle.py:L10-L22) — whose only job is to pass pass_hidden_states_to_model=True. The loop lives in the base class:

vllm/v1/spec_decode/llm_base_proposer.py:L688-L694 vLLM
        block_size = self.block_size
        assert block_size > 0, "block_size has not been initialized."
        for token_index in range(self.num_speculative_tokens - 1):
            # Update the inputs.
            # cast to int32 is crucial when eagle model is compiled.
            # tensor.argmax() returns int64 by default.
            input_ids = draft_token_ids_list[-1].int()
vllm/v1/spec_decode/llm_base_proposer.py:L769-L772 vLLM
            draft_token_ids_list.append(draft_token_ids)

        # [batch_size, num_speculative_tokens]
        draft_token_ids = torch.stack(draft_token_ids_list, dim=1)

[batch_size, num_speculative_tokens]. One token per step per request: a chain, not a tree. As of a556f3f, vLLM's V1 speculative path does not implement tree drafting for EAGLE. The only tree_mask identifier anywhere in vllm/ is an optional parameter of the CPU MLA extend op cpu_mla_extend (vllm/_custom_ops.py:L3999-L4020, backed by csrc/cpu/sgl-kernels/extend.cpp); its one caller, the AMX MLA backend at vllm/v1/attention/backends/mla/amx_mla.py:L377-L397, passes None for it, so nothing in the tree ever builds one. Meanwhile the drafter carries a standing note:

vllm/v1/spec_decode/llm_base_proposer.py:L1645-L1646 vLLM
        # FIXME: when using tree-based specdec, adjust number of forward-passes
        # according to the depth of the tree.

That is a genuine divergence, and it is not an oversight so much as a different bet. A chain verifies $\kappa+1$ positions per request; a tree of the same depth verifies several times as many. §6.2 showed that the verify pass's token count is what pushes a batched server over the roofline ridge, so trees pay off in the latency-critical, small-batch regime and hurt soonest in the throughput regime. vLLM's V1 spec path is built around a padded, chain-shaped drafter batch (disable_padded_drafter_batch, prepare_inputs_padded) which a tree would have to break. SGLang made the other choice.

§5

EAGLE-2 and EAGLE-3: what changed, and what you can check

EAGLE-2: the tree stops being a constant

EAGLE-1 verified a fixed tree — a shape chosen offline and reused for every step, every prompt. EAGLE-2's observation is that the draft model's own confidence is a usable estimate of how likely each branch is to survive verification, so the tree should be re-shaped every step. SGLang implements this directly. At each draft step it multiplies the surviving paths' cumulative scores by the new conditional probabilities and keeps the best topk paths:

python/sglang/srt/speculative/spec_utils.py:L306-L325 SGLang
@torch.compile(dynamic=True, disable=_is_npu or _is_xpu)
def _select_top_k_tokens_later(
    i: int,
    topk_p: torch.Tensor,
    topk_index: torch.Tensor,
    hidden_states: torch.Tensor,
    scores: torch.Tensor,
    topk: int,
):
    topk_sq = topk * topk

    expand_scores = scores.unsqueeze(2) * topk_p.view(-1, topk, topk)
    # (b, topk, 1) * (b, topk, topk) -> (b, topk, topk)

    topk_cs_p, topk_cs_index = fast_topk(
        expand_scores.flatten(start_dim=1), topk, dim=-1
    )  # (b, topk)

    topk_index = topk_index.view(-1, topk_sq)
    input_ids = torch.gather(topk_index, 1, topk_cs_index).flatten()

expand_scores is the cumulative path probability $\prod_{j\le i} q(\tilde{x}_j \mid \tilde{x}_{<j})$ under the draft's own distribution. Note what this is not: it is not a fixed per-level branching factor. The $\mathrm{topk}^2$ children of this level's $\mathrm{topk}$ survivors compete against each other on cumulative probability, so a single confident path can consume the whole budget while its uncertain siblings die at depth 1.

Then, once all steps have run, every candidate ever generated competes for the verify budget:

python/sglang/srt/speculative/eagle_utils.py:L107-L126 SGLang
def organize_draft_results(
    score_list: List[torch.Tensor],
    token_list: List[torch.Tensor],
    parents_list: List[torch.Tensor],
    num_draft_token: int,
):
    # b, n, topk; n = 1 + (num_steps-1) * topk
    score_list = torch.cat(score_list, dim=1).flatten(1)
    # b, (topk + (num_steps-1) * topk)
    ss_token_list = torch.cat(token_list, dim=1)
    top_scores = torch.topk(score_list, num_draft_token - 1, dim=-1)
    top_scores_index = top_scores.indices
    top_scores_index = torch.sort(top_scores_index).values
    # ...
    draft_tokens = torch.gather(ss_token_list, index=top_scores_index, dim=1)

With SGLang's auto-chosen Llama parameters — (num_steps, topk, num_draft_tokens) = (5, 4, 8), from python/sglang/srt/arg_groups/speculative_hook.py:L821-L822; those defaults are filled in only when you pass none of the three (speculative_hook.py:L606-L619) — the candidate pool is $\mathrm{topk} + (\text{num\_steps}-1)\cdot\mathrm{topk}^2 = 4 + 4\cdot16 = 68$ tokens, of which the global top 7 are kept, plus the root, giving 8 verified nodes. The selection is global across depths, prioritizing joint draft confidence, not guaranteed target acceptance. It also needs ancestor closure: every selected node's parent must be selected or be the root. Products of probabilities are nonincreasing, but ties (including probability-one edges and floating-point underflow) invalidate a strict-ranking proof. An ancestor-first tie rule or explicit closure repair is required before building the mask.

Cited

Li et al., EAGLE-2 (arXiv:2406.16858): "EAGLE-2 achieving speedup ratios 3.05x-4.26x, which is 20%-40% faster than EAGLE-1", over three LLM series and six tasks. The 20-40% figure is the like-for-like measure of what dynamic tree shaping buys at a fixed draft budget.

EAGLE-3: two things the serving code shows, one it does not

EAGLE-3's changes are mostly training-time, but two of them leave unmistakable fingerprints in the model definition. First, the draft consumes several target layers concatenated, not just the last:

vllm/model_executor/models/llama_eagle3.py:L183-L187 vLLM
            target_hidden_size = getattr(
                self.config, "target_hidden_size", self.config.hidden_size
            )
            self.fc_input_size = target_hidden_size * self.num_aux_hidden_states
vllm/model_executor/models/llama_eagle3.py:L359-L379 vLLM
    def combine_hidden_states(
        self,
        hidden_states: torch.Tensor,
    ) -> torch.Tensor:
        if not self.model.use_aux_hidden_state:
            return hidden_states
        # combine multiple auxiliary hidden states returned by eagle3

        if self.model.norm_before_fc:
            hidden_states = self.model.input_norm(hidden_states)

        # `norm_before_fc` adds a single RMSNorm before the FC layer, whereas `fc_norm`
        # applies separate RMSNorms to each chunk of the hidden states.
        if self.model.fc_norm is not None:
            chunks = hidden_states.chunk(self.model.num_aux_hidden_states, dim=-1)
            hidden_states = torch.cat(
                [norm(chunk) for norm, chunk in zip(self.model.fc_norm, chunks)],
                dim=-1,
            )

        return self.model.fc(hidden_states)

Which layers get captured is a checkpoint field, eagle_aux_hidden_state_layer_ids, defaulting to three (llama_eagle3.py:L176-L182). For Llama-3-8B that means the fc is $3\times4096 \to 4096 = 50.3$ M parameters instead of 33.6 M — 16.8 M extra parameters. Under one-stream-per-weight accounting this adds 33.6 MB per projection invocation, not per recurrent draft step. The projection provides three target layers' features and is called once per verify iteration, on the whole prefill/verify batch, before the draft loop: vllm/v1/spec_decode/llm_base_proposer.py:L548-L550.

Second, the draft gets its own smaller vocabulary and a map back:

vllm/model_executor/models/llama_eagle3.py:L294-L307 vLLM
        self.lm_head = ParallelLMHead(
            self.config.draft_vocab_size,
            self.config.hidden_size,
            quant_config=get_draft_quant_config(vllm_config),
            prefix=maybe_prefix(prefix, "lm_head"),
        )
        self.logits_processor = LogitsProcessor(
            self.config.draft_vocab_size, scale=logit_scale
        )
        self.draft_id_to_target_id = nn.Parameter(
            torch.zeros(self.config.draft_vocab_size, dtype=torch.long),
            requires_grad=False,
        )

The d2t tensor in the checkpoint is renamed to draft_id_to_target_id on load (llama_eagle3.py:L387-L390) and t2d is skipped entirely. This is the row of the cost table above that takes the draft from $c=0.104$ to $c=0.051$.

Unverified

EAGLE-3's two headline training changes — dropping the feature-prediction loss in favour of direct token prediction, and the "training-time test" procedure that lets the draft learn from its own multi-step rollouts — leave no trace in either serving tree, by construction: they change the loss, not the forward pass. I could not verify them from vllm/model_executor/models/llama_eagle3.py, vllm/v1/spec_decode/, or python/sglang/srt/speculative/ at these SHAs, and there is no training code in either repo to check. Treat the description as reported by the paper (arXiv:2503.01840), which reports speedups up to 6.5x and about 1.4x over EAGLE-2. What the serving code does verify is that an EAGLE-3 checkpoint carries aux-layer fusion and a reduced draft vocabulary.

§6

MTP: the draft ships with the checkpoint

EAGLE modules are trained after the fact, against a specific target, by a third party. Multi-Token Prediction inverts that: the model author trains extra prediction modules jointly with the base model and releases them in the same checkpoint. DeepSeek-V3 is the canonical case, and vLLM's MTP layer is almost line-for-line EAGLE:

vllm/model_executor/models/deepseek_mtp.py:L106-L137 vLLM
    def forward(
        self,
        input_ids: torch.Tensor,
        positions: torch.Tensor,
        previous_hidden_states: torch.Tensor,
        inputs_embeds: torch.Tensor | None = None,
        spec_step_index: int = 0,
    ) -> torch.Tensor:
        assert inputs_embeds is not None
        # masking inputs at position 0, as not needed by MTP
        inputs_embeds = torch.where(positions.unsqueeze(-1) == 0, 0, inputs_embeds)
        inputs_embeds = self.enorm(inputs_embeds)
        previous_hidden_states = self.hnorm(previous_hidden_states)

        hidden_states = self.eh_proj(
            torch.cat([inputs_embeds, previous_hidden_states], dim=-1)
        )

        hidden_states, residual = self.mtp_block(
            positions=positions,
            hidden_states=hidden_states,
            residual=None,
        )
        hidden_states = residual + hidden_states  # pre-final-norm (logits hidden)
        # ...
        return hidden_states, self.shared_head(hidden_states)

eh_proj(cat(enorm(embed), hnorm(hidden))) resembles EAGLE's feature-and-token fusion, but separate normalization, the underlying decoder block, attention/MoE structure, shared head, and joint-training objective are architectural and training differences. The methods share a speculative interface; their checkpoints are not interchangeable.

provenance

Trained with the target

The MTP module saw the same data, the same tokenizer, and the same optimiser state as the layers it predicts for. No separate distillation run, no vocabulary mismatch, no d2t map — shared_head.head is the model's own ParallelLMHead over the full vocab.

depth

A fixed number of modules

num_nextn_predict_layers in the config decides how many MTP layers exist. DeepSeek-V3 ships one. vLLM reuses it cyclically for deeper chains: current_step_idx = spec_step_idx % self.num_mtp_layers (deepseek_mtp.py:L209-L212), so the module drafts step 2 from its own step-1 output.

reach

Twenty-plus families

vllm/config/speculative.py:L37-L56 lists deepseek_mtp, glm4_moe_mtp, qwen3_next_mtp, ernie_mtp, mimo_mtp, nemotron_h_mtp and more as first-class methods. MTP has quietly become the default way a new open-weights model ships a drafter. SGLang spells the same path NEXTN, an alias resolving to its EAGLE worker.

SGLang's auto-chosen parameters for DeepSeek architectures are (num_steps, topk, num_draft_tokens) = (3, 1, 4)topk = 1, i.e. a chain, while Llama with an EAGLE head gets (5, 4, 8), a tree (python/sglang/srt/arg_groups/speculative_hook.py:L819-L843). Worth reading that function to the bottom: only LlamaForCausalLM and the two Grok architectures get (5, 4, 8); the else arm at L842-L843 hands every other architecture (3, 1, 4) too. Trees are live in SGLang but they are not its default shape — a chain is, unless you are serving Llama, Grok, or you set --speculative-eagle-topk yourself. One MTP block of a 671B-parameter MoE is a much more expensive draft step than one dense Llama layer, so the budget goes into depth rather than breadth.

Everything past this — frozen-KV MTP, multi-layer EAGLE drafts, DFlash, DSpark, standalone draft workers, and adaptive speculation that retunes $\kappa$ at runtime — belongs to §6.6.

§7

Tree attention: the mask is the mechanism

Here is the problem trees solve. A chain draft of length 5 gives the target exactly one continuation to check. If the draft's first token is wrong, four correct guesses behind it are wasted. A tree gives the target several continuations at once and lets it pick — but a transformer forward pass eats a linear sequence, not a tree.

The trick is that a tree of $T$ nodes can be flattened into $T$ linear positions, and the tree structure recovered entirely inside the attention mask. Node $i$ attends to the shared prefix, to its own ancestors, and to itself — and to nothing else. Its siblings and their subtrees are invisible to it, so the logits at position $i$ are exactly the logits the target would have produced had you fed it that root-to-$i$ path alone. Verify $T$ candidate continuations in one pass, at the cost of $T$ positions instead of one.

Figure 2 — a draft tree and its attention mask. SGLang's Llama defaults: topk=4, num_draft_tokens=8, so 8 nodes are verified. Node 0 is the bonus token the target itself sampled last step. Filled cells are attend-True. The left block is the shared prefix (all True, seq_len wide); the right block is the 8×8 tree block, which is the only part carrying information. Read row 6: it attends to the prefix, to node 0, node 2, node 5, and itself — its ancestor chain — and to nothing else.

Draft tree with corresponding attention mask matrix On the left, an eight-node tree rooted at node 0 with children 1, 2 and 3; node 1 has child 4; node 2 has children 5 and 7; node 5 has child 6. On the right, an eight by eight lower-triangular-like boolean matrix in which row i has a filled cell exactly at i and at each ancestor of i, preceded by a fully filled prefix block. the tree 012 345 76 depth 0 (root / bonus) depth 1 depth 2 depth 3 accepted path: 0 → 2 → 5 → 6 node ids are the flattened positions; siblings are ordered by draft score, so ids need not be depth-sorted. the mask row for each node columns: shared prefix (seq_len) then the 8 tree nodes prefix all True 0 .. seq_len 0123 4567 row 0row 1row 2row 3 row 4row 5row 6row 7 row 6: {0, 2, 5, 6} rows are NOT lower-triangular: row 3 skips columns 1 and 2, because node 3 is a sibling of node 2, not a descendant. positions[] = seq_len + depth(node), so node 6 gets seq_len+3 and nodes 4, 5, 7 all share seq_len+2. Duplicate positions are fine.

Building the mask, for real

SGLang builds mask, positions, and the tree index structures in one kernel. The dispatch in build_tree_kernel_efficient picks one implementation per device: NPU gets a torch.ops.npu op, XPU the in-tree Triton kernel, CPU a C++ one, and everything else — including CUDA — falls through to sgl_build_tree_kernel_efficient, imported from the sgl_kernel package (python/sglang/srt/speculative/eagle_utils.py:L226-L285). That op's CUDA source is nevertheless readable at this SHA, vendored under python/sglang/kernels/aot/csrc/speculative/eagle_utils.cu:L34-L123, and it is a line-for-line translation of the Triton kernel below: same token_tree_idx address arithmetic, same unconditional root bit, same selected_index / topk ancestor walk, same positions = depth + seq_len. So the Triton version really is the reference, and it repays slow reading:

python/sglang/kernels/ops/speculative/spec_tree.py:L108-L134 SGLang
    # Process all draft token indices for tree mask
    for draft_tokenx in range(draft_token_num):
        if tree_mask_mode == 0:  # FULL_MASK
            token_tree_idx = (
                seq_tree_idx + (seq_len + draft_token_num) * draft_tokenx + seq_len + 1
            )
        else:
            token_tree_idx = (
                draft_token_num * draft_token_num * batch_idx
                + draft_token_num * draft_tokenx
                + 1
            )

        tl.store(tree_mask_ptr + token_tree_idx - 1, 1)
        for i in range(draft_token_num - 1):
            tl.store(tree_mask_ptr + token_tree_idx + i, 0)

        if draft_tokenx > 0:
            # Build tree path for draft_tokenx > 0
            cur_position = draft_tokenx - 1
            position = 0
            should_continue = 1

            for _ in range(depth):
                if should_continue:
                    position += 1
                    tl.store(tree_mask_ptr + token_tree_idx + cur_position, 1)

Unpack the address arithmetic. Under FULL_MASK the mask is one flat bool array of $\big(\sum_b \text{seq\_len}_b\big)\cdot T + T^2 \cdot \mathrm{bs}$ entries; each row is $\text{seq\_len} + T$ wide, so row draft_tokenx starts at seq_tree_idx + (seq_len + draft_token_num) * draft_tokenx. token_tree_idx is that plus seq_len + 1, so token_tree_idx - 1 is column seq_len — the tree's node 0. Every row gets that bit set unconditionally: every node descends from the root. The following loop zeroes the remaining $T-1$ tree columns. The prefix columns are never touched by the kernel; they are pre-filled True by the caller, which is why fill_prefix_mask exists as a knob.

Then the ancestor walk. cur_position = draft_tokenx - 1 is an index into selected_index (the $T-1$ chosen candidates, root excluded), and token_tree_idx + cur_position is column seq_len + 1 + cur_position — which on the first iteration is the node's own diagonal. Each iteration sets one bit and climbs one level, up to depth times. position counts the climb, and that count is the node's depth, written out as positions[draft_tokenx] = position + seq_len (spec_tree.py:L170-L173). The RoPE position of a tree node is its depth. Siblings share a position; that is correct and not a bug.

The parent lookup is the one genuinely non-obvious step:

python/sglang/kernels/ops/speculative/spec_tree.py:L136-L168 SGLang
                    parent_tb_idx = (
                        tl.load(
                            selected_index_ptr
                            + batch_idx * selected_index_stride
                            + cur_position
                        )
                        // topk
                    )
                    if parent_tb_idx == 0:
                        should_continue = 0
                    else:
                        parent_token_idx = tl.load(
                            parent_list_ptr
                            + batch_idx * parent_list_stride
                            + parent_tb_idx
                        )

                        # Find cur_position for next iteration
                        found = 0
                        for cp in range(draft_token_num - 1):
                            if found == 0:
                                if (
                                    tl.load(
                                        selected_index_ptr
                                        + batch_idx * selected_index_stride
                                        + cp
                                    )
                                    == parent_token_idx
                                ):
                                    cur_position = cp
                                    found = 1
                        if found == 0:
                            should_continue = 0

selected_index[cur_position] is the node's index into the flat 68-wide candidate pool. Integer-dividing by topk recovers which expansion group produced it: group 0 is the step-0 expansion of the root, group $1+(i-1)\cdot\mathrm{topk}+r$ is the expansion of the rank-$r$ survivor at step $i$. parent_list is the table mapping group id to the flat candidate index of that group's parent — built in _select_top_k_tokens_first as arange(-1, topk) (the $-1$ is the root sentinel) and extended each step by topk_cs_index + (topk_sq * (i - 1) + topk) (python/sglang/srt/speculative/spec_utils.py:L296-L300, L338-L341). So parent_tb_idx == 0 means "my parent is the root, stop climbing". Otherwise a linear scan seeks the selected parent. This assumes a closed selected set; the excerpt alone does not establish tie-safe closure. A missing parent must be rejected/repaired, never treated as a root. Test identical scores and underflow before trusting the downstream mask.

Flattening, and the two link arrays

The same kernel writes three retrieve_* arrays that turn the tree into something a verifier can walk. retrieve_index[b][i] is node $i$'s slot in the flattened batch-wide draft-token buffer. retrieve_next_token[b][p] is $p$'s first child. retrieve_next_sibling[b][i] is $i$'s next sibling. That is the classic first-child/next-sibling encoding: two $T$-wide int arrays represent an arbitrary tree with no pointers and no recursion.

Figure 3 — the same tree flattened, with its index arrays and the accepted path. draft_token is what goes into the target's input_ids; positions is depth + seq_len; parent is shown for readability but is not a materialised array — the kernel derives it from selected_index // topk into parent_list. The verifier walks next_token to descend and next_sibling to try alternatives.

Flattened draft tree with parent, position, and sibling index arrays A table of eight columns, one per tree node, listing the draft token, position, parent index, first-child index and next-sibling index for each. Nodes 0, 2, 5 and 6 are highlighted as the accepted path. flat slot draft_token positions parent next_token next_sibling 0123 4567 " The"" quick"" cat"" dog" " brown"" sat"" on"" ran" LL+1L+1L+1 L+2L+2L+3L+2 -000 1252 145-1 -16-1-1 -123-1 -17-1-1 shaded: the accepted path 0, 2, 5, 6 — accept_lens = 4 the verifier starts at 0, follows next_token to 1, compares with the target argmax, misses, follows next_sibling 1→2, matches, descends into 5, and so on.

Walking the accepted path

Verification is that walk, done on the GPU with one program per request. Note that target_predict here is the target's argmax at each node's position — computed for all $T$ nodes by the single verify forward.

python/sglang/kernels/ops/speculative/spec_tree.py:L203-L237 SGLang
    # Tree traversal loop
    should_continue = 1
    for j in range(1, num_speculative_tokens):
        if should_continue:  # Early exit guard
            cur_index = tl.load(
                retrieve_next_token_ptr + bx * num_draft_tokens + cur_index
            )

            # Load target token once per level (before sibling search)
            # last_accept_retrieve_idx is constant during sibling traversal
            target_row = last_accept_retrieve_idx // num_draft_tokens
            target_col = last_accept_retrieve_idx % num_draft_tokens
            target_token = tl.load(
                target_predict_ptr + target_row * num_draft_tokens + target_col
            )

            # Traverse siblings
            found_match = 0
            for _ in range(num_draft_tokens):  # Max iterations = num_draft_tokens
                if found_match == 0:  # Early exit guard
                    # Check if we've reached end of sibling list
                    is_valid = cur_index != -1
                    # ...
                    draft_index = tl.load(retrieve_index_ptr + safe_index)
                    draft_token = tl.load(candidates_ptr + safe_index)

                    # Check for token match (only valid when is_valid is True)
                    token_match = is_valid & (draft_token == target_token)

Descend to the first child, then scan siblings for one whose draft token equals the target's argmax at the parent's position. If one matches, accept it and recurse; if the sibling list runs out, stop. Because at most one sibling can match a single argmax, the walk is deterministic and the accepted set is a path. Correctness is inherited from §6.2's argument applied along that path — for sampling the same walk runs under tree_speculative_sampling_target_only, dispatched at python/sglang/srt/speculative/eagle_utils.py:L772-L775.

Note the loop bound: num_speculative_tokens is accept_index.shape[1], which is max_tree_depth = spec_steps + 1 (python/sglang/srt/speculative/eagle_info.py:L43-L48) — not num_draft_tokens. With (5, 4, 8) you verify 8 nodes but can accept at most 6 tokens. A tree buys breadth, not depth.

Discarding the rejected branches' KV

All $T$ nodes wrote KV during the verify pass, into $T$ contiguous slots starting at seq_lens (assign_extend_cache_locs_uniform_func, python/sglang/srt/speculative/eagle_utils.py:L527-L534). Only the accepted path's KV is real. For a chain that is already the front of the block and nothing needs doing; for a tree the accepted nodes are scattered, so SGLang gathers them forward:

python/sglang/srt/speculative/eagle_worker_common.py:L406-L435 SGLang
def _finalize_accept_tree_path(
    batch: ScheduleBatch,
    accept_index: torch.Tensor,
    accept_lens: torch.Tensor,
    predict: torch.Tensor,
    logits_output: Any,
    bs: int,
    *,
    token_to_kv_pool_allocator: Any,
    num_draft_tokens: int,
) -> torch.Tensor:
    """Tree drafting (topk > 1): move the accepted path -- KV slots, predict,
    hidden_states -- to the contiguous front of each per-req block, which the
    downstream chain-layout code (draft-extend select_index, committed-KV reads)
    assumes. Returns compacted predict; mutates logits_output.hidden_states
    (moved only when present)."""
    move_accept_tokens_to_target_kvcache(
        batch, accept_index, accept_lens - 1, token_to_kv_pool_allocator
    )
    predict = _compact_accept_to_front(
        predict, accept_index, bs, num_draft_tokens=num_draft_tokens
    )

move_accept_tokens_to_target_kvcache computes the destination slots $[\,\text{seq\_lens},\ \text{seq\_lens}+\text{accept\_lens}\,)$, gathers the source slots via out_cache_loc[accept_index], and issues one move_kv_cache(tgt, src) (python/sglang/srt/speculative/spec_utils.py:L723-L756). The rejected branches are never explicitly freed here — their slots become trailing overshoot past the request's new seq_lens, reclaimed by exactly the machinery §6.2 describes for a rejected chain suffix. That is the whole point of allocating the tree contiguously.

Finally, the mask reaches the kernel. FlashInfer's paged prefill wrapper takes an arbitrary boolean mask, and SGLang hands the tree mask straight through:

python/sglang/srt/layers/attention/flashinfer_backend.py:L2197-L2208 SGLang
        wrapper_paged.begin_forward(
            qo_indptr,
            kv_indptr,
            kv_indices,
            self.kv_last_page_len[:bs],
            self.num_qo_heads,
            self.num_kv_heads,
            self.head_dim,
            1,
            q_data_type=self.q_data_type,
            kv_data_type=self.data_type,
            custom_mask=use_custom_mask,

The mask arrives as spec_info.custom_mask from EagleVerifyInput.generate_attn_arg_prefill (python/sglang/srt/speculative/eagle_info.py:L82-L136), which also computes mask_numel = paged_kernel_lens_sum * draft_token_num + draft_token_num**2 * batch_size — the same layout the Triton kernel wrote into.

The mask's memory problem

FULL_MASK's size is $\big(\sum_b \text{seq\_len}_b\big)\cdot T + T^2\,\mathrm{bs}$ bools. Derived: at batch 1, seq 2,000, $T=8$ that is 16,064 bytes — nothing. At batch 256 with 32k contexts it is 67.1 MB, rewritten every decode step. The code says so:

python/sglang/srt/speculative/eagle_utils.py:L179-L184 SGLang
        elif tree_mask_mode == TreeMaskMode.FULL_MASK:
            # Only the [0, seq_len) prefix columns depend on this fill; the
            # kernel below writes every tree cell itself. Skip the (up to
            # 100s of MB) per-step memset when nothing reads the mask.
            if fill_prefix_mask:
                tree_mask.fill_(True)

The information content of a tree mask is $T^2$ bits per request. Everything else is a constant True. So SGLang keeps two cheaper encodings — QLEN_ONLY ($T \times T$ bools per request, 64 bytes at $T=8$) and QLEN_ONLY_BITPACKING, which picks the narrowest unsigned int that holds $T$ bits: packed_dtype_idx = int(math.ceil(math.log2((num_verify_tokens + 7) // 8))) (eagle_utils.py:L193-L199), so $T=8$ becomes one uint8 per row, 8 bytes per request. Which mode is used is a property of the attention kernel, not of the tree: default_tree_mask_mode() returns QLEN_ONLY on CPU because the Intel AMX verify kernel consumes it directly, and FULL_MASK otherwise (eagle_utils.py:L145-L149).

§8

The cost model, with a tree in it

Redo §6.2's accounting with breadth. Let $T$ be the number of verified nodes, $D$ the maximum tree depth, $S$ the number of draft forward passes per iteration, and $c$ the cost of one draft forward as a fraction of one target forward. Let $\beta_i$ be the probability that the target's continuation at depth $i$ appears among the tree's candidates at depth $i$, given that depth $i-1$ was accepted. Then the expected number of tokens emitted per iteration is

$$E_{\text{tree}} \;=\; \sum_{i=0}^{D}\ \prod_{j=1}^{i}\beta_j, \qquad \text{speedup} \;=\; \frac{E_{\text{tree}}}{1 + S\,c}\quad\text{while } T\cdot B < I^{*}.$$

Three things to check against §6.2. First, set $\mathrm{topk}=1$: then $T = D+1 = \kappa+1$, $S = \kappa$, every $\beta_j = \alpha$, and $E_{\text{tree}}$ collapses to $\sum_{i=0}^{\kappa}\alpha^i = (1-\alpha^{\kappa+1})/(1-\alpha)$ — §6.2's $E$ exactly, with $1+\kappa c$ in the denominator. The chain is the degenerate tree.

Second, this denominator assumes target verification time is independent of tree width in an ideal weight-bandwidth model. At 8 positions the 1.05 MB of new KV writes is small beside 15.01 GB of weights, but attention, logits, layouts, routing, launches and achieved bandwidth can still change. Width is not free by a hardware theorem. Measure $t_V(T,B)$ and use $E_{\mathrm{tree}}/[t_V(T,B)/t_T+S c]$ when the constant-cost approximation fails.

Third, for a fixed prefix and nested candidate sets, adding candidates cannot decrease greedy target coverage: $\beta(k)=\sum_{r\le k}p_r\ge p_1$. The inequality is strict only if added candidates carry positive target-argmax probability. At a fixed total node budget, widening can remove depth or change surviving prefixes, so neither per-depth acceptance nor expected emitted length is ordered. A perfect chain already has coverage one; breadth cannot improve it. Global reranking is a heuristic whose value depends on target/draft agreement.

The same $\alpha=0.70$, $\kappa=5$ chain under three draft sources — derived arithmetic from the byte counts above and $E=(1-\alpha^{6})/(1-\alpha)=2.94$. Only $c$ changes.
Draft sourceGB / draft stepc1 + 5cspeedup
Llama-3.2-1B draft model2.470.1651.831.61×
EAGLE layer, full vocab1.5540.1041.521.93×
EAGLE layer, 32k draft vocab0.7650.0511.262.33×
Read this table correctly

It holds $\alpha$ fixed to isolate $c$. In reality EAGLE's $\alpha$ is also higher than a standalone 1B model's, and a tree raises the effective $\beta_i$ further — which is where the papers' 2.7×-4.3× end-to-end figures come from. This table is not a benchmark and is not comparable to one; it is the $c$ term alone, at batch 1, on an H100.

One more term the formula hides: at $c=0.051$ a draft forward is 229 µs of weight traffic, and five of them per iteration is 1.15 ms of work split across five separate model launches. At that size, Python dispatch and kernel launch overhead are not rounding errors. Hence SGLang gives the drafter its own CUDA graph — one that captures the entire multi-step loop, not one step:

python/sglang/srt/speculative/eagle_draft_cuda_graph_runner.py:L440-L462 SGLang
        def run_once():
            self.draft_attn_backend.init_forward_metadata_in_graph(forward_batch)
            # ...
            output_cache_loc_backup = forward_batch.out_cache_loc
            hidden_states_backup = forward_batch.spec_info.hidden_states
            dsa_topk_indices_backup = forward_batch.spec_info.dsa_topk_indices

            ret = self.eagle_worker.draft_forward(forward_batch)

            forward_batch.out_cache_loc = output_cache_loc_backup
            forward_batch.spec_info.hidden_states = hidden_states_backup
            forward_batch.spec_info.dsa_topk_indices = dsa_topk_indices_backup
            forward_batch.positions.sub_(self.eagle_worker.speculative_num_steps - 1)
            return ret

draft_forward — all num_steps - 1 model calls, all the select_top_k_tokens reranking, the final organize_draft_results — is one graph replay. The positions.sub_() at the end undoes the in-place advance the loop made, so the captured buffers come back to their entry state and the graph is replayable. This also forces the drafter to own a per-step container of attention backends rather than one backend, because each captured step has different metadata: python/sglang/srt/speculative/draft_utils.py:L71-L78 is explicit that create_decode_backend "Returns a per-step CONTAINER, not an AttentionBackend".

§9

Worked trace: one SGLang EAGLE-3 step

Llama-3-8B target, EAGLE-3 draft head, (num_steps, topk, num_draft_tokens) = (5, 4, 8), batch 1, prefix length 2,000, greedy sampling. In order, with real function names:

Figure 4 — one SGLang speculative iteration. Boxes are functions; annotations are tensor shapes at bs=1 with the Llama defaults. The draft box is one CUDA graph replay.

Loading…
  1. draft() (eagle_worker_v2.py:L501-L512) builds the draft forward batch from EagleDraftInput.bonus_tokens plus the topk_p/topk_index the previous draft-extend left behind.
  2. draft_forward() (eagle_worker_v2.py:L621-L633) loops for i in range(5), breaking before the model call at $i=4$, so four draft model calls happen and score_list accumulates $4 + 4\cdot16 = 68$ scored candidates. per_step_draft_out_cache_loc (eagle_utils.py:L64-L86) reshapes the draft's KV slots to [num_steps, bs*topk].
  3. organize_draft_results() keeps the global top-7 by cumulative path probability, then build_tree_kernel_efficient() prepends the bonus token as node 0 and writes a $2000\cdot8 + 8\cdot8 = 16{,}064$-bool FULL_MASK plus the three retrieve_* arrays.
  4. eagle_prepare_for_verify() allocates 8 contiguous KV slots at position 2,000, then the target verify forward runs 8 query positions against 2,008 KV positions under the custom mask. This is the 4.48 ms.
  5. eagle_sample() (eagle_utils.py:L727-L742) takes argmax over all 8 rows and calls verify_tree_greedy_func, which walks the tree. Suppose nodes 0, 2, 5, 6 accept: accept_lens = 4, accept_index = [0, 2, 5, 6, -1, -1].
  6. _finalize_accept_tree_path() moves slots $\{0,2,5,6\}$ of the 8-slot block to positions 0..3 and compacts predict and hidden_states the same way. Slots 4..7 become overshoot.
  7. _draft_extend_for_decode() runs the draft head over the 4 accepted positions to bring its own KV cache up to date and to produce the next topk_p. That is the fifth and last draft forward of the iteration.

Four emitted tokens cost one target verify, four recurrent draft calls, one draft extension, and one auxiliary-feature projection. If the four calls and extension are each approximated by 0.229 ms and the extra projection traffic by 0.010 ms, the illustration gives 4.48 + 5(0.229) + 0.010 = 5.635 ms, or about 3.18 times against 17.92 ms unspeculated. This optimistic accounting charges the once-per-iteration projection once. Extension batch shape, cache reads, projection invocation boundaries and acceptance require actual profiling; the figure is not a measured speedup.

§10

Pitfalls and war stories

Confusing num_draft_tokens with max_tree_depth

This is the single most common tree bug and SGLang carries scar tissue for it in comments. The accepted-path arrays are spec_steps + 1 wide; the node arrays are num_draft_tokens wide. For a chain those are equal and everything works. Turn on topk > 1 and they diverge:

python/sglang/srt/speculative/eagle_worker_common.py:L612-L622 SGLang
        accept_tokens = predict[accept_index]
        bonus_tokens = torch.empty_like(accept_lens, dtype=torch.int32)
        # stride = accept_tokens per-req width = accept_index.shape[1]
        # (spec_steps + 1); NOT num_draft_tokens, wrong for topk > 1 trees.
        fill_bonus_tokens_func(
            accept_tokens,
            accept_lens,
            bonus_tokens,
            accept_index.shape[1],
            bs,
        )

and in move_accept_tokens_to_target_kvcache:

python/sglang/srt/speculative/spec_utils.py:L711-L713 SGLang
    # accept_index element count, NOT bs * num_draft_tokens: for topk > 1 the
    # tree exceeds the accepted chain, over-reading accept_index (illegal memory).
    size = bs * accept_index.shape[1]

The failure mode when you get it wrong is an illegal memory access inside a kernel launched two steps later, which is about the worst diagnostic in CUDA. If you are writing a new tree-shaped speculative algorithm, the width you want is max_tree_depth everywhere an accepted thing is indexed and num_draft_tokens everywhere a node is indexed.

Draft slot count assertions

A mismatch between the drafter's assumed shape and the allocator's is caught early and loudly:

python/sglang/srt/speculative/eagle_utils.py:L76-L80 SGLang
    expected = batch_size * topk * num_steps
    assert out_cache_loc.shape[0] == expected, (
        f"out_cache_loc.shape[0]={out_cache_loc.shape[0]} != "
        f"batch_size * topk * num_steps = {batch_size}*{topk}*{num_steps}={expected}"
    )

Note what that means for memory: a tree drafter reserves $\mathrm{bs}\cdot\mathrm{topk}\cdot \text{num\_steps}$ draft KV slots per iteration — 20 per request at (5,4,·) — plus $T=8$ verify slots. That is 28 slots per request per iteration held transiently, against 1 for plain decode.

Attention backends that cannot express the tree

Trees are not portable across attention backends, and SGLang refuses at startup rather than silently producing a wrong-but-plausible answer. Three separate gates. trtllm_mha is rejected outright for any tree — "trtllm_mha backend only supports topk = 1 for speculative decoding." (python/sglang/srt/arg_groups/speculative_hook.py:L621-L625). With page_size > 1 a tree needs the two-pass cascade draft-decode, which only flashinfer, fa3 and triton implement; the comment above the check is blunt — "flashmla / trtllm_mla / cutlass_mla can't express the per-branch tree, so reject" (python/sglang/srt/arg_groups/speculative_hook.py:L685-L699). And the multi-step draft loop needs a decode backend from a fixed map at all, or you get "EAGLE is not supported in decode attention backend {backend_type}" (python/sglang/srt/speculative/draft_utils.py:L98-L104). Separately, switching to rejection sampling requires the drafter to expose a target-vocabulary proposal distribution, which a reduced-draft-vocab EAGLE-3 head does not have for free:

python/sglang/srt/speculative/eagle_utils.py:L821-L825 SGLang
                raise ValueError(
                    "Rejection sampling requires a target-vocab draft proposal "
                    "distribution; the current speculative algorithm/draft worker "
                    "does not produce one (draft_probs missing or vocab-mismatched)."
                )

The mask memset you did not budget for

If you run trees at large batch with long contexts and see an unexplained per-step cost that scales with total context length rather than with anything speculative, it is the FULL_MASK fill. Check whether your backend can take QLEN_ONLY, and check verify_mask.is_read — SGLang only pays the memset when something actually reads the prefix columns (eagle_worker_common.py:L350-L356).

An ancestor-closed tree with tied scores

The root is already committed and predicts the first draft token. A selected node attends to that root and its own ancestral path, not a sibling. The tiny reference checks closure before constructing the mask. Production global score selection needs a tie rule or repair that supplies this precondition. Target probabilities must be read at the parent prediction position; the final accepted node supplies the bonus distribution.

Independent CPU reference; not an engine or GPU benchmark
import numpy as np

parents = [-1, 0, 1, 0]  # root, first branch, its child, sibling
scores = [1.0, 0.5, 0.5, 0.4]
def depth(i):
    n = 0
    while parents[i] != -1:
        i = parents[i]
        n += 1
    return n

ranked = sorted(range(1, 4), key=lambda i: (-scores[i], depth(i), i))
selected = {0, *ranked[:2]}
assert selected == {0, 1, 2}
assert all(parents[i] in selected for i in selected if i != 0)
mask = np.zeros((4, 4), dtype=bool)
for i in selected:
    ancestor = i
    while ancestor != -1:
        mask[i, ancestor] = True
        ancestor = parents[ancestor]
assert mask[2].tolist() == [True, True, True, False]
bad_topk = {0, 2}  # tied child selected without parent
assert any(parents[i] not in bad_topk for i in bad_topk if i)
print("Ancestor closure and sibling exclusion pass.")
§11

Hands-on

The cleanest experiment in this chapter is chain-versus-tree at a fixed depth. Same draft head, same number of draft forwards, only topk changes — so any acceptance difference is the tree's doing.

chain: topk=1, 6 verified nodes shell
python -m sglang.launch_server --model-path meta-llama/Llama-3.1-8B-Instruct \
  --speculative-algorithm EAGLE3 \
  --speculative-draft-model-path <eagle3-head> \
  --speculative-num-steps 5 --speculative-eagle-topk 1 --speculative-num-draft-tokens 6
tree: topk=4, 8 verified nodes, same 5 steps shell
python -m sglang.launch_server --model-path meta-llama/Llama-3.1-8B-Instruct \
  --speculative-algorithm EAGLE3 \
  --speculative-draft-model-path <eagle3-head> \
  --speculative-num-steps 5 --speculative-eagle-topk 4 --speculative-num-draft-tokens 8

Three things to measure. (1) Accepted length per iteration, at batch 1 and again at batch 64 — the tree's advantage should survive at batch 1 and evaporate as $T\cdot B$ approaches $I^{*}=295$. (2) The draft's own share of step time; SGLang's draft loop is one graph replay, so it shows up as a single fat kernel region. (3) Set SGLANG_SIMULATE_ACC_LEN=3.0 (python/sglang/srt/environ.py:L468) to force a synthetic acceptance length: that removes acceptance from the equation entirely and isolates the pure plumbing cost of the tree machinery, which is the number you want when you suspect the mask build rather than the model.

For vLLM, inspect the selected runner before comparing an eagle3 configuration. The quoted V1 path drafts chains, but runner selection is a separate dispatch decision. Cross-engine accepted-length or latency differences also include kernels, numerics, tokenization and scheduling. A within-engine chain/tree ablation with the same checkpoint and sampled workload isolates tree policy more closely.

Note

All three measurements need a GPU and none of them are reported here. Every number in this chapter is derived arithmetic from parameter counts and the H100 bandwidth floor, or cited to a paper. If you run them, the acceptance-length metric to read is vLLM's vllm/v1/spec_decode/metrics.py and SGLang's per-iteration accept_lens.

§12

Exercises

  1. Read the file and answer. In python/sglang/kernels/ops/speculative/spec_tree.py, the mask kernel unconditionally sets one bit for every row before the ancestor walk. Which column, and why is it correct for every node including node 0?
  2. Derive. SGLang's Llama default is (num_steps, topk, num_draft_tokens) = (5, 4, 8). How many candidate tokens does organize_draft_results choose from, and how many draft model forward passes run inside draft_forward? Then: what is the largest num_draft_tokens that could ever be useful at these settings, and what is the largest accept_lens?
  3. Predict, then verify. You set --speculative-eagle-topk 8 and --speculative-num-draft-tokens 8, leaving --speculative-num-steps 5. Predict what shape of tree the reranker will produce and what happens to expected accepted length. Then check your prediction against _select_top_k_tokens_later and organize_draft_results.
  4. Read the file and answer. SGLang picks (5, 4, 8) for Llama but (3, 1, 4) for every DeepSeek architecture (speculative_hook.py:L819-L843). Using the byte accounting in §3 and §6, say why a chain is the right choice for an MTP block of a 671B-parameter MoE and a tree is the right choice for one dense Llama layer.
  5. Read the file and answer. _compact_accept_to_front (eagle_worker_common.py:L438-L458) gathers into gathered, then clones x, then writes only the first spec_steps + 1 slots of each block. Two questions: what does the clone() preserve that a fresh empty_like would not, and what does safe = accept_index.clamp(min=0) do to the padded -1 entries?
Answers

1. Column seq_len, written as tl.store(tree_mask_ptr + token_tree_idx - 1, 1) where token_tree_idx already includes + seq_len + 1. That column is tree node 0, the bonus token the target itself sampled last step. Every node in the draft tree is a descendant of it, and node 0 attends to itself, so the bit is correct universally — which is why it sits outside the ancestor walk.

2. score_list holds one $(b,1,\mathrm{topk})$ entry from step 0 and four $(b,\mathrm{topk},\mathrm{topk})$ entries from steps 1-4, so $4 + 4\cdot 16 = 68$ candidates. draft_forward runs num_steps - 1 = 4 model calls (the loop breaks before the call at $i = \text{num\_steps}-1$); the fifth draft forward of the iteration is _draft_extend_for_decode. num_draft_tokens could in principle go to 69 (all candidates plus the root), though the verify pass cost grows with it. accept_lens is capped by accept_index.shape[1] = spec_steps + 1 = 6.

3. With topk=8 the pool becomes 264 candidates but only seven non-root nodes survive. Broader expansion can improve candidate discovery, displace useful depth, or leave the selected tree unchanged; expected accepted length need not fall or rise. Compute the actual selected paths and their target coverage before concluding. Draft KV reservation and kernel shapes can grow even if the selected set is unchanged.

4. The tree's cost is $S$ draft forwards in the denominator and $T$ verified positions in the numerator's reach. A DeepSeek MTP block is a full MoE decoder layer of a 671B-parameter model — a very expensive draft forward — so each extra draft forward buys much less than it does for a 218 M-parameter dense Llama layer, and the budget should go to the cheapest thing that raises $E$: depth. Breadth additionally multiplies the drafter's transient KV reservation by topk ($\mathrm{bs}\cdot\mathrm{topk}\cdot\text{num\_steps}$ slots), which for a 671B MoE serving long contexts is memory you would rather spend on batch. For a dense 8B target the draft forward is cheap enough that four parallel branches cost almost nothing extra, and $\beta_i > \alpha$ pays immediately.

5. There is no aliasing hazard to avoid: gathered = x[safe] is advanced indexing, so the whole gather is materialised into a fresh tensor before the clone and the write. What clone() buys is the tail. The output has to stay num_draft_tokens wide per request, but only the leading spec_steps + 1 slots are written; the remaining slots keep their original node data and become the trailing overshoot the allocator reclaims — the docstring says exactly that ("trailing unaccepted slots stay and are freed as overshoot", eagle_worker_common.py:L450). empty_like would leave that tail uninitialised. The other half: clamp(min=0) makes padded -1 entries gather harmlessly from node 0; they land past accept_lens and are never read, but they must not be allowed to index out of bounds.

§13

Key takeaways

  • These methods trade proposal quality against proposal cost. Holding $\alpha=0.70$ and $\kappa=5$ fixed, moving from a 1B draft model ($c=0.165$) to a reduced-vocab EAGLE head ($c=0.051$) takes the speedup from 1.61× to 2.33× — derived, batch 1, H100. That is before any acceptance gain.
  • Two thirds of an EAGLE draft step's bytes are the LM head (525.3 M of 777.0 M parameters at Llama-3-8B's 128,256-token vocabulary). Every serious variant ships a vocabulary reduction for exactly this reason — Medusa's truncated_vocab_size, EAGLE-3's draft_vocab_size plus d2t. The transformer is the cheap part.
  • Autoregression removes Medusa's independent-head restriction. Separate heads need not produce a likely joint continuation. EAGLE restores conditioning on preceding draft tokens and features, but joint factorization alone does not guarantee model quality or acceptance at depth.
  • A tree mask carries $T^2$ bits per request; everything else in it is a constant. The tree structure lives entirely in the $T\times T$ block — prefix columns are unconditionally True. FULL_MASK's $\big(\sum\text{seq\_len}\big)\cdot T$ bytes exist to satisfy a kernel API, which is why QLEN_ONLY and bitpacked modes exist and why the code warns about "up to 100s of MB" of per-step memset.
  • Tree width trades expected yield against measured verification cost. The ideal weight-read term may stay fixed below a ridge, but total runtime need not. Broader trees also change masks, logits, KV traffic and draft work; profile the full iteration.
  • The two engines made opposite bets and both are defensible. As of a556f3f vLLM's V1 EAGLE path drafts chains only — torch.stack(..., dim=1) and a standing FIXME — and its Medusa path is a top-1 chain too, not the paper's tree. SGLang at 7d89325 builds dynamic trees, passes a custom mask into FlashInfer, and carries a tree-shaped accept path through KV compaction — but note that even there the tree is not the default shape: auto-chosen parameters give topk=4 only to Llama and Grok and topk=1 to everything else. Chains are simpler and degrade more gracefully under batch; trees win at the latency frontier.
§14

Further reading

  • Medusa: Simple LLM Inference Acceleration Framework with Multiple Decoding Heads — Cai et al., 2024. arXiv:2401.10774. Where the multi-head idea and the Cartesian-product draft tree come from.
  • EAGLE: Speculative Sampling Requires Rethinking Feature Uncertainty — Li et al., 2024. arXiv:2401.15077. The feature-level autoregression argument, and the ablation showing why token-level drafting from hidden states is not enough.
  • EAGLE-2: Faster Inference of Language Models with Dynamic Draft Trees — Li et al., 2024. arXiv:2406.16858. Context-aware tree shaping; the mechanism SGLang's _select_top_k_tokens_later and organize_draft_results implement.
  • EAGLE-3: Scaling up Inference Acceleration of Large Language Models via Training-Time Test — Li et al., 2025. arXiv:2503.01840. Multi-layer feature fusion and the dropped feature-prediction constraint. See the Unverified callout in §5 for what serving code can and cannot confirm.
  • DeepSeek-V3 Technical Report — DeepSeek-AI, 2024. arXiv:2412.19437. The MTP training objective, and the reason a pretrained checkpoint ships its own drafter.
  • SpecInfer — Miao et al., 2023. arXiv:2305.09781. Tree-based speculative verification stated in general terms, independent of any particular drafter; the ancestry-mask construction in this chapter is its descendant.
  • Source, read this session. vllm/v1/spec_decode/{eagle,medusa,llm_base_proposer,extract_hidden_states}.py, vllm/model_executor/models/{medusa,llama_eagle,llama_eagle3,deepseek_mtp}.py; python/sglang/srt/speculative/{eagle_utils,eagle_info,eagle_worker_v2,eagle_worker_common,spec_utils,draft_utils,eagle_draft_cuda_graph_runner,ragged_verify}.py and python/sglang/kernels/ops/speculative/spec_tree.py.
  • Neighbours: the algorithm and its proof in §6.2; draft models, n-gram and suffix decoding in §6.3; DFlash, DSpark, frozen-KV MTP, multi-layer EAGLE, standalone workers and adaptive speculation in §6.6. Formula symbols 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