From 9b853e6832e71a3058212df02a025232a453e146 Mon Sep 17 00:00:00 2001 From: Liangsheng Yin Date: Thu, 23 Jul 2026 00:42:56 -0700 Subject: [PATCH] [Scheduler] Enable decode retraction ordering under speculative decoding (#32023) --- .../docs/references/environment_variables.mdx | 5 -- python/sglang/srt/environ.py | 1 - python/sglang/srt/managers/schedule_batch.py | 16 +---- .../sglang/srt/speculative/dflash_info_v2.py | 1 - python/sglang/srt/speculative/eagle_info.py | 42 +++---------- python/sglang/srt/speculative/ngram_info.py | 1 - .../unit/managers/test_retraction_order.py | 59 +++++++++++++++++++ .../unit/spec/test_dflash_overlap_hostsync.py | 3 +- 8 files changed, 71 insertions(+), 57 deletions(-) create mode 100644 test/registered/unit/managers/test_retraction_order.py diff --git a/docs_new/docs/references/environment_variables.mdx b/docs_new/docs/references/environment_variables.mdx index 275c32638..89870857f 100644 --- a/docs_new/docs/references/environment_variables.mdx +++ b/docs_new/docs/references/environment_variables.mdx @@ -1679,11 +1679,6 @@ SGLang supports various environment variables that can be used to configure its Plan the next step on a separate stream to overlap with the current step (Overlap Spec V2). false - - SGLANG_SPEC_ENABLE_STRICT_FILTER_CHECK - Enable strict filter checks in speculative decoding. - true - SGLANG_SPEC_SKIP_ZERO_STEP_DRAFT_EXTEND Skip draft_extend while adaptive spec is at steps=0; saves a draft forward but the draft KV goes stale. diff --git a/python/sglang/srt/environ.py b/python/sglang/srt/environ.py index 8bc444d8d..20d47e37e 100644 --- a/python/sglang/srt/environ.py +++ b/python/sglang/srt/environ.py @@ -764,7 +764,6 @@ class Envs: SGLANG_ENABLE_OVERLAP_PLAN_STREAM = EnvBool(False) # Spec Config - SGLANG_SPEC_ENABLE_STRICT_FILTER_CHECK = EnvBool(True) # A/B: keep the DFLASH draft greedy head eager (not folded in-graph). SGLANG_DFLASH_EAGER_DRAFT_SAMPLER = EnvBool(False) SGLANG_RAGGED_VERIFY_MODE = EnvStr("static") diff --git a/python/sglang/srt/managers/schedule_batch.py b/python/sglang/srt/managers/schedule_batch.py index 08a837833..cc02f4a61 100755 --- a/python/sglang/srt/managers/schedule_batch.py +++ b/python/sglang/srt/managers/schedule_batch.py @@ -2624,13 +2624,7 @@ class ScheduleBatch(ScheduleBatchDisaggregationDecodeMixin): self, server_args: ServerArgs ) -> Tuple[List[Req], float, List[Req]]: """Retract the decoding requests when there is not enough memory.""" - sorted_indices = self._get_decode_retraction_order( - self.reqs, - server_args, - allow_policy_sort=( - self.spec_algorithm is None or self.spec_algorithm.is_none() - ), - ) + sorted_indices = self._get_decode_retraction_order(self.reqs, server_args) retracted_reqs = [] first_iter = True @@ -2678,7 +2672,7 @@ class ScheduleBatch(ScheduleBatchDisaggregationDecodeMixin): @staticmethod def _get_decode_retraction_order( - reqs: List[Req], server_args: ServerArgs, *, allow_policy_sort: bool + reqs: List[Req], server_args: ServerArgs ) -> List[int]: """Return indices ordered from most-preferred to least-preferred to keep. @@ -2688,11 +2682,6 @@ class ScheduleBatch(ScheduleBatchDisaggregationDecodeMixin): sorted_indices = list(range(len(reqs))) # TODO(lsyin): improve retraction policy for radix cache - # For spec decoding, filter_batch API can only filter requests from the - # back, so we can only retract from the back. - # TODO(sang): Clean up finish path and support better retract policy. - if not allow_policy_sort: - return sorted_indices def length_key(req: Req) -> Tuple[int, int]: return (len(req.output_ids), -len(req.origin_input_ids)) @@ -2993,7 +2982,6 @@ class ScheduleBatch(ScheduleBatchDisaggregationDecodeMixin): if self.spec_info: self.spec_info.filter_batch( new_indices=keep_indices_device, - has_been_filtered=False, new_indices_cpu=keep_indices, ) diff --git a/python/sglang/srt/speculative/dflash_info_v2.py b/python/sglang/srt/speculative/dflash_info_v2.py index 8893a6541..72162ba51 100644 --- a/python/sglang/srt/speculative/dflash_info_v2.py +++ b/python/sglang/srt/speculative/dflash_info_v2.py @@ -215,7 +215,6 @@ class DFlashDraftInputV2(SpecInput): def filter_batch( self, new_indices: torch.Tensor, - has_been_filtered: bool = True, new_indices_cpu: Optional[List[int]] = None, ): if self.reserved_seq_lens_cpu is not None: diff --git a/python/sglang/srt/speculative/eagle_info.py b/python/sglang/srt/speculative/eagle_info.py index 60c702d19..e7b724165 100644 --- a/python/sglang/srt/speculative/eagle_info.py +++ b/python/sglang/srt/speculative/eagle_info.py @@ -6,7 +6,6 @@ import torch from sglang.kernels.ops.attention.utils import create_flashinfer_kv_indices_triton from sglang.srt.constrained.base_grammar_backend import BaseGrammarObject -from sglang.srt.environ import envs from sglang.srt.model_executor.forward_batch_info import CaptureHiddenMode from sglang.srt.runtime_context import get_server_args from sglang.srt.speculative.spec_info import SpecInput, SpecInputType @@ -211,44 +210,21 @@ class EagleDraftInput(SpecInput): def filter_batch( self, new_indices: torch.Tensor, - has_been_filtered: bool = True, new_indices_cpu: Optional[List[int]] = None, ): if self.future_indices is not None: self.future_indices = self.future_indices[new_indices] return - strict_check = envs.SGLANG_SPEC_ENABLE_STRICT_FILTER_CHECK.get() - if has_been_filtered: - # in eagle_utils.py:verify, we have already filtered the batch by `unfinished_index` - # therefore, we don't need to filter the batch again in scheduler - error_msg = f"length of new_indices: {len(new_indices)} != length of topk_p: {len(self.topk_p)}, this should not happen" - if len(new_indices) != len(self.topk_p): - if strict_check: - raise ValueError(error_msg) - else: - logger.warning(error_msg) - - self.topk_p = self.topk_p[: len(new_indices)] - self.topk_index = self.topk_index[: len(new_indices)] - if self.draft_probs is not None: - self.draft_probs = self.draft_probs[: len(new_indices)] - if self.hidden_states is not None: - self.hidden_states = self.hidden_states[: len(new_indices)] - self.bonus_tokens = self.bonus_tokens[: len(new_indices)] - if self.dsa_topk_indices is not None: - self.dsa_topk_indices = self.dsa_topk_indices[: len(new_indices)] - else: - # in some cases(e.g draft_extend), we have not filtered the batch by `unfinished_index` - self.topk_p = self.topk_p[new_indices] - self.topk_index = self.topk_index[new_indices] - if self.draft_probs is not None: - self.draft_probs = self.draft_probs[new_indices] - if self.hidden_states is not None: - self.hidden_states = self.hidden_states[new_indices] - self.bonus_tokens = self.bonus_tokens[new_indices] - if self.dsa_topk_indices is not None: - self.dsa_topk_indices = self.dsa_topk_indices[new_indices] + self.topk_p = self.topk_p[new_indices] + self.topk_index = self.topk_index[new_indices] + if self.draft_probs is not None: + self.draft_probs = self.draft_probs[new_indices] + if self.hidden_states is not None: + self.hidden_states = self.hidden_states[new_indices] + self.bonus_tokens = self.bonus_tokens[new_indices] + if self.dsa_topk_indices is not None: + self.dsa_topk_indices = self.dsa_topk_indices[new_indices] def merge_batch(self, spec_info: "EagleDraftInput"): if self.future_indices is not None: diff --git a/python/sglang/srt/speculative/ngram_info.py b/python/sglang/srt/speculative/ngram_info.py index 719ac050b..11d63fb9e 100644 --- a/python/sglang/srt/speculative/ngram_info.py +++ b/python/sglang/srt/speculative/ngram_info.py @@ -119,7 +119,6 @@ class NgramVerifyInput(SpecInput): def filter_batch( self, new_indices: torch.Tensor, - has_been_filtered: bool = True, new_indices_cpu: Optional[List[int]] = None, ): if self.future_indices is not None: diff --git a/test/registered/unit/managers/test_retraction_order.py b/test/registered/unit/managers/test_retraction_order.py new file mode 100644 index 000000000..d23b0d523 --- /dev/null +++ b/test/registered/unit/managers/test_retraction_order.py @@ -0,0 +1,59 @@ +import unittest +from types import SimpleNamespace + +from sglang.srt.managers.schedule_batch import ScheduleBatch +from sglang.test.ci.ci_register import register_cpu_ci +from sglang.test.test_utils import CustomTestCase + +register_cpu_ci(est_time=2, suite="base-a-test-cpu") + + +def _req(output_len: int, input_len: int = 8, priority=None): + return SimpleNamespace( + output_ids=[0] * output_len, + origin_input_ids=[0] * input_len, + priority=priority, + ) + + +def _args(policy: str = "length", low_first: bool = False): + return SimpleNamespace( + retraction_policy=policy, + schedule_low_priority_values_first=low_first, + ) + + +def _order(reqs, args): + return ScheduleBatch._get_decode_retraction_order(reqs, args) + + +class TestRetractionOrder(CustomTestCase): + """The retraction loop pops from the END of the returned list, so the + last index is the first request retracted.""" + + def test_length_policy_retracts_shortest_output_first(self): + reqs = [_req(5), _req(1), _req(3)] + self.assertEqual(_order(reqs, _args()), [0, 2, 1]) + + def test_length_policy_tie_breaks_on_longer_input(self): + # Equal outputs: the longer-input request is retracted first + # (frees more tokens for the same rework). + reqs = [_req(4, input_len=10), _req(4, input_len=20)] + self.assertEqual(_order(reqs, _args()), [0, 1]) + + def test_priority_policy_low_values_first(self): + # Low value = more important; None sorts as least important. + reqs = [_req(4, priority=2), _req(4, priority=0), _req(4, priority=None)] + self.assertEqual(_order(reqs, _args("priority", low_first=True)), [1, 0, 2]) + + def test_priority_policy_high_values_first(self): + reqs = [_req(4, priority=2), _req(4, priority=0), _req(4, priority=None)] + self.assertEqual(_order(reqs, _args("priority", low_first=False)), [0, 1, 2]) + + def test_priority_ties_fall_back_to_length(self): + reqs = [_req(1, priority=1), _req(5, priority=1)] + self.assertEqual(_order(reqs, _args("priority", low_first=True)), [1, 0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/registered/unit/spec/test_dflash_overlap_hostsync.py b/test/registered/unit/spec/test_dflash_overlap_hostsync.py index 099fa969a..f7f24f274 100644 --- a/test/registered/unit/spec/test_dflash_overlap_hostsync.py +++ b/test/registered/unit/spec/test_dflash_overlap_hostsync.py @@ -255,10 +255,9 @@ class TestFilterBatchHostIndices(CustomTestCase): keep = [0, 2] a, b = make(), make() - a.filter_batch(new_indices=torch.tensor(keep), has_been_filtered=False) + a.filter_batch(new_indices=torch.tensor(keep)) b.filter_batch( new_indices=torch.tensor(keep), - has_been_filtered=False, new_indices_cpu=keep, ) torch.testing.assert_close(a.reserved_seq_lens_cpu, b.reserved_seq_lens_cpu)