spec: size the speculative buffers from the bags, not the startup record (#35024)

This commit is contained in:
Cheng Wan
2026-08-17 16:16:56 -07:00
committed by GitHub
parent 3d7ec00179
commit d2bc697396
28 changed files with 216 additions and 179 deletions
@@ -88,13 +88,9 @@ patches:
- target: sglang.srt.mem_cache.allocation_sizing.get_req_to_token_extra_context_len
edits:
- match: |
if (
server_args.speculative_algorithm is not None
and server_args.page_size > 1
and (server_args.speculative_eagle_topk or 1) > 1
):
extra = max(extra, get_alloc_reserve_per_decode(server_args))
replacement: ""
extra = max(extra, get_alloc_reserve_per_decode() + page_size - 1)
replacement: |
pass
"""
+1 -1
View File
@@ -2335,7 +2335,7 @@ class SchedulerDisaggregationDecodeMixin:
# construct fake completed prefill
new_batch.prepare_for_prebuilt()
new_batch.process_prebuilt(self.server_args, self.future_map)
new_batch.process_prebuilt(self.future_map)
return new_batch
@@ -16,7 +16,6 @@ logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from sglang.srt.managers.overlap_utils import FutureMap
from sglang.srt.managers.schedule_batch import ScheduleBatch
from sglang.srt.server_args import ServerArgs
class ScheduleBatchDisaggregationDecodeMixin:
@@ -113,7 +112,6 @@ class ScheduleBatchDisaggregationDecodeMixin:
def process_prebuilt(
self: ScheduleBatch,
server_args: ServerArgs,
future_map: FutureMap,
):
"""Assign the buffered last input id to schedule batch"""
@@ -146,7 +144,6 @@ class ScheduleBatchDisaggregationDecodeMixin:
spec_info = self.spec_algorithm.build_disagg_draft_input(
self,
server_args,
last_tokens_tensor,
future_map,
)
@@ -13,6 +13,8 @@ from sglang.srt.model_executor.runner_backend_utils.tc_piecewise_cuda_graph impo
is_in_tc_piecewise_cuda_graph,
)
from sglang.srt.runtime_context import (
get_disagg,
get_memory,
get_parallel,
process_model_config,
)
@@ -67,27 +69,24 @@ INDEXER_K_CACHE_PRESHUFFLE_TILE = 16
if TYPE_CHECKING:
from sglang.srt.model_executor.forward_batch_info import ForwardBatch
from sglang.srt.server_args import ServerArgs
def compute_dsa_seqlens(original_seq_lens, dsa_index_topk: int):
return original_seq_lens.clamp(max=dsa_index_topk)
def should_remap_pd_dsa_seed_to_local_slots(server_args: "ServerArgs") -> bool:
def should_remap_pd_dsa_seed_to_local_slots() -> bool:
"""Whether a PD seed should enter the allocator-local fused TopK domain."""
return (
is_cuda()
and envs.SGLANG_DSA_FUSE_TOPK.get()
and server_args.disaggregation_mode == "decode"
and not server_args.enable_hisparse
and get_disagg().disaggregation_mode == "decode"
and not get_memory().enable_hisparse
and not get_parallel().dcp_enabled
)
def should_use_dsa_fused_topk(
server_args: "ServerArgs", seed_dsa_topk_from_draft_extend: bool
) -> bool:
def should_use_dsa_fused_topk(seed_dsa_topk_from_draft_extend: bool) -> bool:
"""Select fused TopK for PD IndexShare.
PD Prefill worker:
@@ -98,10 +97,10 @@ def should_use_dsa_fused_topk(
- Draft decode / target verify / draft extend: fused TopK enabled.
"""
pd_index_share_seed = (
server_args.disaggregation_mode != "null" and seed_dsa_topk_from_draft_extend
get_disagg().disaggregation_mode != "null" and seed_dsa_topk_from_draft_extend
)
return envs.SGLANG_DSA_FUSE_TOPK.get() and (
not pd_index_share_seed or should_remap_pd_dsa_seed_to_local_slots(server_args)
not pd_index_share_seed or should_remap_pd_dsa_seed_to_local_slots()
)
@@ -393,9 +393,7 @@ class DeepseekSparseAttnBackend(
self.speculative_num_steps = speculative_num_steps
self.speculative_num_draft_tokens = get_spec().speculative_num_draft_tokens
self.speculative_step_id = speculative_step_id
self.use_fused_topk = should_use_dsa_fused_topk(
model_runner.server_args, seed_dsa_topk_from_draft_extend
)
self.use_fused_topk = should_use_dsa_fused_topk(seed_dsa_topk_from_draft_extend)
if envs.SGLANG_DSA_FUSE_TOPK.get() and not self.use_fused_topk:
print_warning_once(
"Disabling fused DSA top-k for IndexShare under PD disaggregation."
@@ -219,7 +219,6 @@ class FlashAttentionBackend(AttentionBackend):
# Static verify width; NOTE: overwrites the config-named attr in place.
self.speculative_num_draft_tokens = resolve_num_tokens_per_req(
phase="target_verify",
server_args=model_runner.server_args,
spec_algorithm=SpeculativeAlgorithm.from_string(
get_spec().speculative_algorithm
),
+1 -3
View File
@@ -726,9 +726,7 @@ class RustServer:
server_args["version"] = __version__
# Not a `server_args` field: `TokenizerManager` derives it, and the rust
# ingress needs the same number for its total-token check.
server_args["num_reserved_tokens"] = compute_num_reserved_tokens(
scheduler.server_args
)
server_args["num_reserved_tokens"] = compute_num_reserved_tokens()
server_args["max_total_num_tokens"] = scheduler.max_total_num_tokens
return msgspec.json.encode(server_args, enc_hook=str).decode("utf-8")
@@ -462,7 +462,7 @@ class TokenizerManager(TokenizerControlMixin, TokenizerManagerScoreMixin):
self.max_req_input_len = None # Will be set later in engine.py
self.enable_priority_scheduling = server_args.enable_priority_scheduling
self.default_priority_value = server_args.default_priority_value
self.num_reserved_tokens = compute_num_reserved_tokens(server_args)
self.num_reserved_tokens = compute_num_reserved_tokens()
self.validate_total_tokens = True
def init_tokenizer_and_processor(self):
+10 -6
View File
@@ -15,12 +15,12 @@ from sglang.srt.layers.logits_processor import LogitsProcessorOutput
from sglang.srt.managers import io_struct
from sglang.srt.managers.schedule_batch import Req
from sglang.srt.model_executor.forward_batch_info import PPProxyTensors
from sglang.srt.runtime_context import get_spec, max_speculative_num_draft_tokens
from sglang.srt.speculative.spec_info import SpeculativeAlgorithm
from sglang.srt.state_capturer.base import TopkCaptureOutput
if TYPE_CHECKING:
from sglang.srt.managers.scheduler import GenerationBatchResult
from sglang.srt.server_args import ServerArgs
from sglang.srt.speculative.eagle_info import EagleDraftInput
@@ -377,19 +377,23 @@ def msgpack_decode_explained(data: bytes) -> Any:
raise MsgpackDecodeError(rid, msg) from e
def compute_num_reserved_tokens(server_args: ServerArgs) -> int:
def compute_num_reserved_tokens() -> int:
"""Output token slots reserved per request, on top of its input.
The current eagle implementation stores draft tokens in the output token
slots, so the context budget has to account for them; every other algorithm
reserves nothing. Shared by `TokenizerManager` and the rust server's
`server_args` blob (`RustServer._build_server_args`), which needs the same
number to run the total-token check in Rust.
number to run the total-token check in Rust. Both stamp the number once at
launch, so it has to cover every step an adaptive-spec run may switch to:
it reads the bags for the candidate-table ceiling and the current
`topk * steps`, not the untouched startup record.
"""
algorithm = SpeculativeAlgorithm.from_string(server_args.speculative_algorithm)
spec = get_spec()
algorithm = SpeculativeAlgorithm.from_string(spec.speculative_algorithm)
if not algorithm.is_eagle():
return 0
return max(
server_args.speculative_eagle_topk * server_args.speculative_num_steps,
server_args.max_speculative_num_draft_tokens,
spec.speculative_eagle_topk * spec.speculative_num_steps,
max_speculative_num_draft_tokens(),
)
@@ -1,33 +1,31 @@
from __future__ import annotations
from typing import Optional
from sglang.srt.runtime_context import get_server_args
from sglang.srt.server_args import ServerArgs
from sglang.srt.runtime_context import (
get_schedule,
get_spec,
max_speculative_num_draft_tokens,
)
def get_alloc_len_per_decode(
server_args: ServerArgs, *, max_draft_tokens: Optional[int] = None
) -> int:
"""``max_draft_tokens`` lets a caller that already resolved the draft-token
bound (the KV-cache configurator reads it off the bags) size with that same
value; the default is the handed instance's own member, never the global."""
if server_args.speculative_algorithm is None:
def get_alloc_len_per_decode() -> int:
"""KV length one request may allocate in a single decode step.
Reads the bags: adaptive speculative decoding moves the step count and the
draft-token bound after publish, and this runs per decode batch.
"""
spec = get_spec()
if spec.speculative_algorithm is None:
return 1
# Spec decoding allocates max(topk * num_steps, num_draft_tokens) per decode step.
spec_steps = server_args.speculative_num_steps or 1
spec_topk = server_args.speculative_eagle_topk or 1
spec_tokens = (
max_draft_tokens
if max_draft_tokens is not None
else server_args.max_speculative_num_draft_tokens
)
page_size = server_args.page_size
spec_steps = spec.speculative_num_steps or 1
spec_topk = spec.speculative_eagle_topk or 1
spec_tokens = max_speculative_num_draft_tokens()
page_size = get_schedule().page_size
from sglang.srt.speculative.spec_info import SpeculativeAlgorithm
spec_algo = SpeculativeAlgorithm.from_string(server_args.speculative_algorithm)
spec_algo = SpeculativeAlgorithm.from_string(spec.speculative_algorithm)
if page_size == 1 or spec_topk == 1 or not spec_algo.has_draft_kv():
return max(spec_steps * spec_topk, spec_tokens)
else:
@@ -40,51 +38,29 @@ def get_alloc_len_per_decode(
return max(num_new_pages_per_topk * page_size * spec_topk, spec_tokens)
def get_alloc_reserve_per_decode(
server_args: Optional[ServerArgs] = None,
*,
max_draft_tokens: Optional[int] = None,
) -> int:
def get_alloc_reserve_per_decode() -> int:
"""KV length reserved per request at each decode step.
The 2x is a double-buffer that absorbs the kv_committed_len lag in overlap
mode; see eagle_utils.eagle_prepare_for_decode.
Callers on a request path have no config in hand, so this is the module's
single "which config" decision point: everything below it takes the
instance explicitly.
"""
if server_args is None:
server_args = get_server_args()
return 2 * get_alloc_len_per_decode(server_args, max_draft_tokens=max_draft_tokens)
return 2 * get_alloc_len_per_decode()
def get_req_to_token_extra_context_len(
server_args: ServerArgs, *, max_draft_tokens: Optional[int] = None
) -> int:
def get_req_to_token_extra_context_len() -> int:
"""req_to_token row headroom beyond the model context length.
Sized to hold the decode over-allocation; the spec v2 page>1 topk>1 holey
draft footprint can outgrow the default num_draft_tokens headroom.
``max_draft_tokens`` keeps this row headroom and the caller's other
draft-token-sized buffers on ONE resolved value: the KV-cache configurator
passes the bag-derived bound it also hands the pools, so the two cannot
disagree after a post-publish override. The default stays the handed
instance's member for callers sizing against a specific config object.
draft footprint can outgrow the default num_draft_tokens headroom. The row
headroom and the pools it sits next to derive from the same bag leaves, so
they cannot disagree after a post-publish override.
"""
if max_draft_tokens is None:
max_draft_tokens = server_args.max_speculative_num_draft_tokens
# FIXME(lsyin): temporary fix for the context length issue under spec decoding
extra = 4 + (max_draft_tokens or 0)
if server_args.speculative_algorithm is not None and server_args.page_size > 1:
extra = 4 + (max_speculative_num_draft_tokens() or 0)
page_size = get_schedule().page_size
if get_spec().speculative_algorithm is not None and page_size > 1:
# kv_allocated_len is page-aligned (eagle_prepare_for_decode), so near
# the context limit the aligned reserve can overshoot by page_size - 1;
# without the headroom the row write silently lands in the neighbor row.
extra = max(
extra,
get_alloc_reserve_per_decode(server_args, max_draft_tokens=max_draft_tokens)
+ server_args.page_size
- 1,
)
extra = max(extra, get_alloc_reserve_per_decode() + page_size - 1)
return extra
@@ -759,13 +759,7 @@ class KVCacheConfigurator:
)
def _build_req_to_token_pool(self, *, max_num_reqs: int) -> ReqToTokenPool:
# The same bag-derived bound the pools below receive, so the row
# headroom and the speculative buffers cannot disagree after a
# post-publish override.
extra_max_context_len = get_req_to_token_extra_context_len(
self.server_args,
max_draft_tokens=max_speculative_num_draft_tokens(),
)
extra_max_context_len = get_req_to_token_extra_context_len()
if get_disagg().disaggregation_mode == "decode":
# Extra slots for pre-allocated requests
@@ -784,7 +784,6 @@ class ModelRunner:
if self.spec_algorithm.is_speculative():
return resolve_num_tokens_per_req(
phase="target_verify",
server_args=self.server_args,
spec_algorithm=self.spec_algorithm,
is_draft_worker=self.is_draft_worker,
num_draft_tokens=num_draft_tokens,
@@ -43,7 +43,6 @@ from sglang.srt.runtime_context import (
get_parallel,
get_schedule,
get_spec,
max_speculative_num_draft_tokens,
)
from sglang.srt.utils.common import (
ceil_align,
@@ -633,10 +632,7 @@ class SWAChunkCapPoolConfigurator(HybridSWAPoolConfigurator):
else:
# spec-v2: the overlap allocator keeps 2 * alloc_len outstanding
# (eagle_utils.eagle_prepare_for_decode: kv_committed_len + 2 * alloc_len).
decode_alloc = 2 * get_alloc_len_per_decode(
kvc.server_args,
max_draft_tokens=max_speculative_num_draft_tokens(),
)
decode_alloc = 2 * get_alloc_len_per_decode()
per_request = trailing_tokens + decode_alloc
num_reqs = get_schedule().max_running_requests // kvc.ps.attn_dp_size
@@ -554,7 +554,6 @@ class BaseRunner(ABC):
# Speculative metadata and hidden-state capture mode.
spec_info = create_dummy_verify_input(
mr.spec_algorithm,
mr.server_args,
buffers.custom_mask,
num_tokens_per_req,
mr.is_draft_worker,
@@ -10,13 +10,11 @@ from sglang.srt.speculative.dspark_components.dspark_draft import make_next_draf
if TYPE_CHECKING:
from sglang.srt.managers.overlap_utils import FutureMap
from sglang.srt.managers.schedule_batch import ScheduleBatch
from sglang.srt.server_args import ServerArgs
from sglang.srt.speculative.spec_info import SpecInput
def build_dspark_disagg_draft_input(
batch: ScheduleBatch,
server_args: ServerArgs,
last_tokens_tensor: torch.Tensor,
future_map: FutureMap,
) -> SpecInput:
@@ -9,23 +9,25 @@ from sglang.srt.layers.attention.dsa.utils import (
)
from sglang.srt.managers.overlap_utils import RelayPayload
from sglang.srt.model_executor.forward_batch_info import CaptureHiddenMode
from sglang.srt.runtime_context import get_spec
from sglang.srt.speculative.eagle_info import EagleDraftInput
if TYPE_CHECKING:
from sglang.srt.managers.overlap_utils import FutureMap
from sglang.srt.managers.schedule_batch import ScheduleBatch
from sglang.srt.server_args import ServerArgs
def build_eagle_disagg_draft_input(
batch: ScheduleBatch,
server_args: ServerArgs,
last_tokens_tensor: torch.Tensor,
future_map: FutureMap,
) -> EagleDraftInput:
num_states = server_args.speculative_eagle_topk
if server_args.enable_multi_layer_eagle:
num_states *= server_args.speculative_num_steps
# Adaptive spec moves the step count after publish, and this runs once per
# prebuilt batch.
spec = get_spec()
num_states = spec.speculative_eagle_topk
if spec.enable_multi_layer_eagle:
num_states *= spec.speculative_num_steps
topk_p = torch.stack(
[
@@ -58,7 +60,7 @@ def build_eagle_disagg_draft_input(
dsa_indices_list = [req.output_dsa_topk_indices for req in batch.reqs]
if dsa_indices_list and all(t is not None for t in dsa_indices_list):
dsa_topk_indices = torch.stack(dsa_indices_list, dim=0).to(batch.device)
if should_remap_pd_dsa_seed_to_local_slots(server_args):
if should_remap_pd_dsa_seed_to_local_slots():
# PD sends request-relative positions; fused TopK consumes
# decode-local physical slots. Remap once before the draft loop/graph.
req_to_token = batch.req_to_token_pool.req_to_token
@@ -147,9 +147,7 @@ class EAGLEDraftCudaGraphRunner(DecodeCudaGraphRunner):
# Bucket sizes
self.capture_bs, _ = get_batch_sizes_to_capture(model_runner)
# Static capture width.
self.captured_req_width = resolve_num_tokens_per_req(
phase="draft_decode", server_args=model_runner.server_args
)
self.captured_req_width = resolve_num_tokens_per_req(phase="draft_decode")
self.max_bs = max(self.capture_bs)
self.max_num_token = self.max_bs * self.captured_req_width
@@ -130,9 +130,7 @@ class EAGLEDraftExtendCudaGraphRunner(DecodeCudaGraphRunner):
# Static capture width: full tree width (num_draft_tokens), not
# num_steps + 1 -- topk > 1 draft-extend overflows the buffers.
self.captured_req_width = resolve_num_tokens_per_req(
phase="draft_extend", server_args=model_runner.server_args
)
self.captured_req_width = resolve_num_tokens_per_req(phase="draft_extend")
self.max_bs = max(self.capture_bs)
self.max_num_token = self.max_bs * self.captured_req_width
@@ -116,9 +116,7 @@ class FrozenKVMTPCudaGraphRunner(DecodeCudaGraphRunner):
self.capture_hidden_mode = CaptureHiddenMode.LAST
# Static capture width.
self.captured_req_width = resolve_num_tokens_per_req(
phase="draft_decode", server_args=model_runner.server_args
)
self.captured_req_width = resolve_num_tokens_per_req(phase="draft_decode")
self.capture_bs, _ = get_batch_sizes_to_capture(
model_runner, self.captured_req_width
)
@@ -189,10 +189,7 @@ class MultiLayerEagleDraftExtendCudaGraphRunner(DecodeCudaGraphRunner):
# tokens, which lets all steps share one buffer set.
self.num_front_tokens = eagle_worker.draft_extend_num_front_tokens
self.captured_req_width = (
resolve_num_tokens_per_req(
phase="draft_extend", server_args=model_runner.server_args
)
+ self.num_front_tokens
resolve_num_tokens_per_req(phase="draft_extend") + self.num_front_tokens
)
self.max_bs = max(self.capture_bs)
self.max_num_token = self.max_bs * self.captured_req_width
+15 -11
View File
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Callable, List, Optional, Tuple, Type, Union
import torch
from sglang.srt.runtime_context import get_spec as get_spec_config
from sglang.srt.speculative.spec_registry import (
CustomSpecAlgo,
ServerArgsValidator,
@@ -172,7 +173,6 @@ class SpeculativeAlgorithm(Enum):
def build_disagg_draft_input(
self,
batch: ScheduleBatch,
server_args: ServerArgs,
last_tokens_tensor: torch.Tensor,
future_map: FutureMap,
) -> Optional[SpecInput]:
@@ -181,16 +181,14 @@ class SpeculativeAlgorithm(Enum):
build_eagle_disagg_draft_input,
)
return build_eagle_disagg_draft_input(
batch, server_args, last_tokens_tensor, future_map
)
return build_eagle_disagg_draft_input(batch, last_tokens_tensor, future_map)
if self.is_dspark():
from sglang.srt.speculative.dspark_disaggregation import (
build_dspark_disagg_draft_input,
)
return build_dspark_disagg_draft_input(
batch, server_args, last_tokens_tensor, future_map
batch, last_tokens_tensor, future_map
)
return None
@@ -386,14 +384,20 @@ def spec_scale_global_num_tokens(
def create_dummy_verify_input(
spec_algorithm: SpeculativeAlgorithm,
server_args: ServerArgs,
custom_mask: torch.Tensor,
num_tokens_per_req: int,
is_draft_worker: bool,
) -> Optional[SpecInput]:
"""Dummy verify ``SpecInput`` for CUDA-graph capture (per-algorithm dispatch)."""
"""Dummy verify ``SpecInput`` for CUDA-graph capture (per-algorithm dispatch).
The tree shape comes from the bags so the dummy matches the step config
being captured; adaptive spec captures each candidate with that config's
leaves overridden.
"""
from sglang.srt.model_executor.forward_batch_info import CaptureHiddenMode
spec = get_spec_config()
spec_info = None
if spec_algorithm.is_eagle() or spec_algorithm.is_standalone():
from sglang.srt.speculative.eagle_info import EagleVerifyInput
@@ -409,9 +413,9 @@ def create_dummy_verify_input(
retrieve_next_token=None,
retrieve_next_sibling=None,
retrieve_cum_len=None,
spec_steps=server_args.speculative_num_steps,
topk=server_args.speculative_eagle_topk,
draft_token_num=server_args.speculative_num_draft_tokens,
spec_steps=spec.speculative_num_steps,
topk=spec.speculative_eagle_topk,
draft_token_num=spec.speculative_num_draft_tokens,
capture_hidden_mode=CaptureHiddenMode.FULL,
seq_lens_sum=None,
seq_lens_cpu=None,
@@ -423,7 +427,7 @@ def create_dummy_verify_input(
spec_info = DFlashVerifyInput(
draft_token=None,
positions=None,
draft_token_num=server_args.speculative_num_draft_tokens,
draft_token_num=spec.speculative_num_draft_tokens,
custom_mask=None,
capture_hidden_mode=(
CaptureHiddenMode.NULL if is_draft_worker else CaptureHiddenMode.FULL
@@ -152,10 +152,14 @@ class CustomSpecAlgo:
def build_disagg_draft_input(
self,
batch: ScheduleBatch,
server_args: ServerArgs,
last_tokens_tensor: torch.Tensor,
future_map: FutureMap,
) -> Optional[SpecInput]:
"""Build the disaggregation draft input for ``batch``, or ``None``.
The speculative config comes from ``runtime_context.get_spec()``, which
follows a runtime override where the startup record does not.
"""
return None
+8 -5
View File
@@ -78,7 +78,6 @@ if TYPE_CHECKING:
from sglang.srt.managers.tp_worker import TpModelWorker
from sglang.srt.mem_cache.allocator import BaseTokenToKVPoolAllocator
from sglang.srt.sampling.sampling_batch_info import SamplingBatchInfo
from sglang.srt.server_args import ServerArgs
if _is_cuda:
@@ -98,7 +97,6 @@ logger = logging.getLogger(__name__)
def resolve_num_tokens_per_req(
*,
phase: Literal["draft_decode", "draft_extend", "target_verify"],
server_args: ServerArgs,
spec_algorithm=None,
is_draft_worker: bool = False,
num_draft_tokens: Optional[int] = None,
@@ -107,14 +105,19 @@ def resolve_num_tokens_per_req(
width (sizes capture shapes / buffers); the per-forward dynamic width
lives on ``SpecInput.num_tokens_per_req``. Draft phases are
EAGLE-family-only; "target_verify" is algorithm-generic via the hook.
The widths come from the bags: adaptive spec captures each candidate step
config with that config's leaves overridden, so the buffers being sized
must follow the override rather than the startup values.
"""
spec = get_spec()
if phase == "draft_decode":
return server_args.speculative_eagle_topk
return spec.speculative_eagle_topk
if phase == "draft_extend":
return server_args.speculative_num_draft_tokens
return spec.speculative_num_draft_tokens
if phase == "target_verify":
if num_draft_tokens is None:
num_draft_tokens = server_args.speculative_num_draft_tokens
num_draft_tokens = spec.speculative_num_draft_tokens
return spec_algorithm.get_num_tokens_per_req_for_target_verify(
num_draft_tokens, is_draft_worker
)