Support topk > 1 tree drafting for mamba/hybrid-linear models on spec v2 (#27463)

This commit is contained in:
Liangsheng Yin
2026-06-07 17:04:09 -07:00
committed by GitHub
parent 70db73afce
commit f68c79675f
8 changed files with 45 additions and 57 deletions
@@ -285,28 +285,8 @@ def _handle_eagle_family(server_args: "ServerArgs") -> None:
"Max running requests is reset to 48 for speculative decoding. You can override this by explicitly setting --max-running-requests." "Max running requests is reset to 48 for speculative decoding. You can override this by explicitly setting --max-running-requests."
) )
# Spec v2 tree drafting supports topk > 1 with page_size == 1 and page_size > 1
# (the latter via partial-page duplication; backend-gated below).
spec_v1_reason = None spec_v1_reason = None
# mamba / linear-attn state models only support topk == 1 on spec v2.
# mamba2_cache_params exists iff the config carries such state; check the
# class descriptor so the property getter is not invoked.
text_config = server_args.get_model_config().hf_config.get_text_config()
is_mamba_state_model = hasattr(type(text_config), "mamba2_cache_params")
if ( if (
server_args.speculative_eagle_topk is not None
and server_args.speculative_eagle_topk > 1
and is_mamba_state_model
and not server_args.disable_overlap_schedule
):
# Spec v2 topk > 1 is not supported for mamba/linear-attn state models
# (only topk == 1); fall back to v1 for those. page_size > 1 is supported
# on v2 (partial-page duplication), so it no longer forces v1.
server_args.disable_overlap_schedule = True
spec_v1_reason = (
"spec v2 topk > 1 is not supported for mamba/linear-attn models"
)
elif (
not envs.SGLANG_ENABLE_SPEC_V2.get() not envs.SGLANG_ENABLE_SPEC_V2.get()
and not server_args.disable_overlap_schedule and not server_args.disable_overlap_schedule
): ):
@@ -38,6 +38,16 @@ class LightningAttentionBackend(MambaAttnBackendBase):
def __init__(self, model_runner: ModelRunner): def __init__(self, model_runner: ModelRunner):
super().__init__(model_runner) super().__init__(model_runner)
# seg_la processes draft tokens as a chain -- it has no parent-indices
# plumbing for tree-shaped drafts, so spec v2 tree verify (topk > 1) would
# commit wrong mamba states silently. Fail fast instead of mis-decoding.
if self.topk > 1:
raise NotImplementedError(
"Lightning (seg_la) linear-attention backend does not support "
f"speculative decoding with topk > 1 (got topk={self.topk}); "
"seg_la verifies a draft tree as a chain. Use "
"--speculative-eagle-topk 1."
)
# lightning attn does not need conv cache, but to keep the interface for mamba cache # lightning attn does not need conv cache, but to keep the interface for mamba cache
self.conv_states_shape = ( self.conv_states_shape = (
model_runner.req_to_token_pool.mamba_pool.mamba_cache.conv[0].shape model_runner.req_to_token_pool.mamba_pool.mamba_cache.conv[0].shape
@@ -1485,6 +1485,7 @@ class HybridLinearKVPool(KVCache):
device: str, device: str,
mamba_pool: MambaPool, mamba_pool: MambaPool,
enable_memory_saver: bool = False, enable_memory_saver: bool = False,
enable_kv_cache_copy: bool = False,
# TODO: refactor mla related args # TODO: refactor mla related args
use_mla: bool = False, use_mla: bool = False,
kv_lora_rank: int = None, kv_lora_rank: int = None,
@@ -1525,6 +1526,7 @@ class HybridLinearKVPool(KVCache):
layer_num=self.full_layer_nums, layer_num=self.full_layer_nums,
device=device, device=device,
enable_memory_saver=enable_memory_saver, enable_memory_saver=enable_memory_saver,
enable_kv_cache_copy=enable_kv_cache_copy,
) )
else: else:
TokenToKVPoolClass = MLATokenToKVPool TokenToKVPoolClass = MLATokenToKVPool
@@ -664,6 +664,9 @@ class ModelRunnerKVCacheMixin:
device=self.device, device=self.device,
mamba_pool=self.req_to_token_pool.mamba_pool, mamba_pool=self.req_to_token_pool.mamba_pool,
enable_memory_saver=self.server_args.enable_memory_saver, enable_memory_saver=self.server_args.enable_memory_saver,
enable_kv_cache_copy=(
self.server_args.speculative_algorithm is not None
),
use_mla=self.use_mla_backend, use_mla=self.use_mla_backend,
start_layer=self.start_layer, start_layer=self.start_layer,
**extra_args, **extra_args,
@@ -1276,9 +1276,7 @@ class EAGLEWorkerV2(BaseSpecWorker):
or self.target_worker.model_runner.mamba2_config is not None or self.target_worker.model_runner.mamba2_config is not None
or self.target_worker.model_runner.hybrid_lightning_config is not None or self.target_worker.model_runner.hybrid_lightning_config is not None
): ):
self._mamba_verify_update( self._mamba_verify_update(batch, accept_lens, accept_index, bs)
batch, verify_input, accept_lens, accept_index, bs
)
if not batch.forward_mode.is_idle(): if not batch.forward_mode.is_idle():
accept_tokens = predict[accept_index] accept_tokens = predict[accept_index]
@@ -1328,7 +1326,6 @@ class EAGLEWorkerV2(BaseSpecWorker):
def _mamba_verify_update( def _mamba_verify_update(
self, self,
batch: ScheduleBatch, batch: ScheduleBatch,
verify_input: EagleVerifyInput,
accept_lens: torch.Tensor, accept_lens: torch.Tensor,
accept_index: torch.Tensor, accept_index: torch.Tensor,
bs: int, bs: int,
@@ -1336,9 +1333,6 @@ class EAGLEWorkerV2(BaseSpecWorker):
"""Update mamba state for hybrid GDN models after verification.""" """Update mamba state for hybrid GDN models after verification."""
# `accept_lens` already includes the bonus token (drafts + 1 per req). # `accept_lens` already includes the bonus token (drafts + 1 per req).
if not batch.forward_mode.is_idle() and accept_index.numel() > 0: if not batch.forward_mode.is_idle() and accept_index.numel() > 0:
if verify_input.topk != 1:
raise ValueError("Spec v2 currently only supports topk = 1.")
accepted_indices_offset = torch.arange( accepted_indices_offset = torch.arange(
0, 0,
bs * self.speculative_num_draft_tokens, bs * self.speculative_num_draft_tokens,
@@ -1346,7 +1340,13 @@ class EAGLEWorkerV2(BaseSpecWorker):
dtype=accept_lens.dtype, dtype=accept_lens.dtype,
device=accept_lens.device, device=accept_lens.device,
) )
last_correct_step_indices = accept_lens - 1 req_idx = torch.arange(bs, dtype=torch.int64, device=accept_lens.device)
# Per-req tree step of the last accepted node, i.e. the step whose
# mamba state to commit; reduces to accept_lens - 1 for topk == 1.
last_correct_step_indices = (
accept_index[req_idx, (accept_lens - 1).to(torch.int64)]
- accepted_indices_offset
)
if batch.mamba_track_indices is not None: if batch.mamba_track_indices is not None:
# If after verify, the request's seq_lens has crossed a mamba track interval, # If after verify, the request's seq_lens has crossed a mamba track interval,
@@ -1364,11 +1364,6 @@ class EAGLEWorkerV2(BaseSpecWorker):
to_track_ith = torch.clamp( to_track_ith = torch.clamp(
tracking_point - seq_lens_pre_verify - 1, min=0 tracking_point - seq_lens_pre_verify - 1, min=0
).to(torch.int64) ).to(torch.int64)
req_idx = torch.arange(
bs,
dtype=torch.int64,
device=accept_lens.device,
)
candidate_track_steps = ( candidate_track_steps = (
accept_index[req_idx, to_track_ith] - accepted_indices_offset accept_index[req_idx, to_track_ith] - accepted_indices_offset
) )
@@ -1003,22 +1003,18 @@ def expected_mamba2_verify_output_from_inputs(
) -> torch.Tensor: ) -> torch.Tensor:
"""Reference output for chain (topk=1) target-verify cases. """Reference output for chain (topk=1) target-verify cases.
Mamba2's SSM kernel does not consume the tree mask: under any topk it This reference (`_pure_torch_mamba2_reference`) is a chain recurrence.
processes the per-request draft tokens linearly through the chunked-scan For `topk == 1` it matches the chain semantics the EAGLE verifier
recurrence, just like EXTEND. For `topk == 1` this matches the expects, so it doubles as the verify reference. For `topk > 1` the
chain semantics the EAGLE verifier expects, so the eager SSM production SSM kernel DOES follow the draft tree (it consumes the
reference (`_pure_torch_mamba2_reference`) doubles as the verify parent-indices plumbing), but this test has no tree-aware reference to
reference. For `topk > 1` the production kernel still processes compare against, so tree verify is skipped here rather than validated.
siblings as a chain — this is documented at the call site as
structurally unsupported rather than wired through a tree-aware
reference.
""" """
if topk != 1: if topk != 1:
raise ValueError( raise ValueError(
"Mamba2 tree verify (topk>1) is not exercised: the SSM kernel " "Mamba2 tree verify (topk>1) is not exercised here: this "
"ignores the tree mask and processes draft tokens linearly. " "reference is chain-only. The production kernel supports tree "
"Wiring a parent-indices-aware reference here would not match " "verify; a tree-aware reference is future work."
"production behavior. Only chain (topk=1) is supported."
) )
del inputs del inputs
# `state` is the (ssm_states, conv_states) snapshot captured before # `state` is the (ssm_states, conv_states) snapshot captured before
@@ -1196,19 +1196,18 @@ def run_mamba2_eagle_verify_case(
atol: float = MAMBA2_ATOL, atol: float = MAMBA2_ATOL,
rtol: float = MAMBA2_RTOL, rtol: float = MAMBA2_RTOL,
): ):
"""Mamba2 chain verify (eager). Mamba2's SSM kernel processes draft """Mamba2 chain verify (eager). This test's reference
tokens linearly regardless of the spec_info tree mask, so only (`_pure_torch_mamba2_reference`) is a chain recurrence, so it can only
`topk == 1` is supported here. The EXTEND-style recurrence reference validate `topk == 1`; it doubles as the chain verify reference across
(`_pure_torch_mamba2_reference`) doubles as the chain verify all chain spec kinds (eagle / frozen_kv_mtp / dflash / ngram). Tree
reference across all chain spec kinds (eagle / frozen_kv_mtp / verify (topk > 1) is skipped only for lack of a tree-aware reference --
dflash / ngram). Tree verify (topk > 1) is structurally blocked the production SSM kernel does consume the parent-indices plumbing and
(the kernel doesn't consume the parent-indices plumbing); see supports tree verify. See `expected_mamba2_verify_output_from_inputs`."""
`expected_mamba2_verify_output_from_inputs`."""
if topk != 1: if topk != 1:
testcase.skipTest( testcase.skipTest(
"Mamba2 tree verify (topk>1) is structurally unsupported — " "Mamba2 tree verify (topk>1) skipped: this test has no tree-aware "
"the SSM kernel ignores tree masks; only chain (topk=1) is " "reference. The production kernel supports tree verify. See "
"exercised. See `expected_mamba2_verify_output_from_inputs`." "`expected_mamba2_verify_output_from_inputs`."
) )
fixture = build_mamba2_attention_fixture( fixture = build_mamba2_attention_fixture(
testcase, testcase,
@@ -14,6 +14,9 @@ QWEN3_NEXT_MODEL = "Qwen/Qwen3-Next-80B-A3B-Instruct"
class TestQwen3NextMTPTopk( class TestQwen3NextMTPTopk(
GSM8KMixin, KLDivergenceMixin, PrefixCacheBranchingMixin, DefaultServerBase GSM8KMixin, KLDivergenceMixin, PrefixCacheBranchingMixin, DefaultServerBase
): ):
# topk > 1 (tree) MTP on a hybrid-GDN model, on spec v2: the tree-aware mamba
# state update lives in the spec v2 verify path, so mamba + topk > 1 no longer
# falls back to spec v1.
model = QWEN3_NEXT_MODEL model = QWEN3_NEXT_MODEL
cache_chunk_size = 64 cache_chunk_size = 64
gsm8k_accuracy_thres = 0.93 gsm8k_accuracy_thres = 0.93