[Spec] Route seq_lens through FutureMap; drop verify_done.wait (#25879)

This commit is contained in:
Liangsheng Yin
2026-05-21 01:51:40 -07:00
committed by GitHub
parent 19f55c0e6d
commit baeac179f7
9 changed files with 111 additions and 127 deletions
@@ -180,9 +180,8 @@ class ScheduleBatchDisaggregationDecodeMixin:
from sglang.srt.managers.overlap_utils import FutureIndices
spec_info.future_indices = FutureIndices(indices=self.req_pool_indices)
future_map.store_to_map_for_new_batch(
spec_info.future_indices, spec_info
)
future_map.publish(spec_info.future_indices, spec_info.new_seq_lens)
future_map.stash(spec_info.future_indices, spec_info)
self.spec_info = spec_info
else:
# Non-spec: input_ids feeds the next decode forward directly.
+50 -52
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Optional
import torch
@@ -10,7 +10,6 @@ from sglang.srt.utils import is_cuda, is_hip
if TYPE_CHECKING:
from sglang.srt.managers.schedule_batch import ScheduleBatch
from sglang.srt.managers.scheduler import GenerationBatchResult
from sglang.srt.mem_cache.memory_pool import ReqToTokenPool
from sglang.srt.speculative.eagle_info import EagleDraftInput
from sglang.srt.speculative.spec_info import SpeculativeAlgorithm
@@ -57,21 +56,25 @@ class FutureMap:
self.req_pool_size = req_to_token_pool.req_to_token.shape[0]
if self.spec_algo.is_none():
self.buf_initialized = True
self.token_ids_buf = torch.empty(
(self.req_pool_size,), dtype=torch.int64, device=self.device
)
else:
self.buf_initialized = False
# Schedule-consumed buf, eager fixed dtype.
self.new_seq_lens_buf = torch.empty(
(self.req_pool_size,), dtype=torch.int64, device=self.device
)
# Forward-only bufs are lazy (worker-dependent shape).
self._forward_buf_initialized = False
def _lazy_init_buf(self, draft_input: EagleDraftInput):
self.buf_initialized = True
# Fences the schedule-consumed buf fields.
self.publish_ready: Optional[torch.cuda.Event] = None
def _lazy_init_forward_buf(self, draft_input: EagleDraftInput):
self._forward_buf_initialized = True
topk_p0 = draft_input.topk_p[0]
topk_index0 = draft_input.topk_index[0]
bonus_token0 = draft_input.bonus_tokens[0]
new_seq_lens0 = draft_input.new_seq_lens[0]
self.topk_p_buf = torch.empty(
(self.req_pool_size, *topk_p0.shape),
dtype=topk_p0.dtype,
@@ -83,16 +86,8 @@ class FutureMap:
device=self.device,
)
self.bonus_tokens_buf = torch.empty(
(self.req_pool_size, *bonus_token0.shape),
dtype=bonus_token0.dtype,
device=self.device,
(self.req_pool_size,), dtype=torch.int64, device=self.device
)
self.new_seq_lens_buf = torch.empty(
(self.req_pool_size, *new_seq_lens0.shape),
dtype=new_seq_lens0.dtype,
device=self.device,
)
if spec_need_hidden_states():
hidden_states0 = draft_input.hidden_states[0]
self.hidden_states_buf = torch.empty(
@@ -118,51 +113,54 @@ class FutureMap:
draft_input.topk_index = self.topk_index_buf[indices]
draft_input.bonus_tokens = self.bonus_tokens_buf[indices]
draft_input.new_seq_lens = self.new_seq_lens_buf[indices]
# Resolve seq_lens placeholder (-indices) to the post-verify view.
batch.seq_lens = draft_input.new_seq_lens
if spec_need_hidden_states():
draft_input.hidden_states = self.hidden_states_buf[indices]
def store_to_map(
self, future_indices: FutureIndices, batch_result: GenerationBatchResult
):
if self.spec_algo.is_none():
indices = future_indices.indices
if indices.shape[0] == 0:
# DP attention idle rank: indices is empty but next_token_ids
# may carry padded values from sibling ranks. Nothing to store
# for this rank.
return
# next_token_ids is int32; buf is int64. Slice assignment used to
# cast implicitly, but advanced indexing requires an explicit match.
self.token_ids_buf[indices] = batch_result.next_token_ids.to(torch.int64)
else:
draft_input: EagleDraftInput = batch_result.next_draft_input
self.store_to_map_for_new_batch(future_indices, draft_input)
def resolve_seq_lens_cpu(self, batch: ScheduleBatch) -> None:
fi = batch.spec_info.future_indices if batch.spec_info is not None else None
if fi is None:
return
if self.publish_ready is not None:
self.publish_ready.wait()
batch.seq_lens_cpu = self.new_seq_lens_buf[fi.indices].cpu()
batch.seq_lens_sum = int(batch.seq_lens_cpu.sum())
def store_to_map_for_new_batch(
self, future_indices: FutureIndices, draft_input: EagleDraftInput
):
def publish(
self, future_indices: FutureIndices, new_seq_lens: torch.Tensor
) -> None:
"""Store schedule-consumed fields and signal publish_ready."""
if self.spec_algo.is_none():
return
indices = future_indices.indices
if indices.shape[0] == 0:
# DP idle rank: draft_input fields are empty stubs without a usable
# shape, so _lazy_init_buf's shape peek (draft_input.topk_p[0])
# would IndexError. Defer init until a real batch arrives.
return # DP idle
self.new_seq_lens_buf[indices] = new_seq_lens.to(self.new_seq_lens_buf.dtype)
if self.publish_ready is None:
self.publish_ready = torch.get_device_module(self.device).Event()
self.publish_ready.record()
def stash(self, future_indices: FutureIndices, payload) -> None:
"""Store forward-only fields for the next forward batch to pick up."""
indices = future_indices.indices
if indices.shape[0] == 0:
return # DP idle
if self.spec_algo.is_none():
# next_token_ids is int32; buf is int64. Advanced indexing requires
# an explicit cast.
self.token_ids_buf[indices] = payload.to(torch.int64)
return
if not self.buf_initialized:
self._lazy_init_buf(draft_input)
# Slice assignment used to coerce src dtype to buf dtype implicitly;
# advanced index requires an explicit cast. bonus_tokens / new_seq_lens
# in particular differ across disagg (int64) and forward (int32) paths.
self.topk_p_buf[indices] = draft_input.topk_p.to(self.topk_p_buf.dtype)
self.topk_index_buf[indices] = draft_input.topk_index.to(
self.topk_index_buf.dtype
)
draft_input: EagleDraftInput = payload
if not self._forward_buf_initialized:
self._lazy_init_forward_buf(draft_input)
self.bonus_tokens_buf[indices] = draft_input.bonus_tokens.to(
self.bonus_tokens_buf.dtype
)
self.new_seq_lens_buf[indices] = draft_input.new_seq_lens.to(
self.new_seq_lens_buf.dtype
self.topk_p_buf[indices] = draft_input.topk_p.to(self.topk_p_buf.dtype)
self.topk_index_buf[indices] = draft_input.topk_index.to(
self.topk_index_buf.dtype
)
if spec_need_hidden_states():
self.hidden_states_buf[indices] = draft_input.hidden_states.to(
+3 -38
View File
@@ -2420,8 +2420,7 @@ class ScheduleBatch(ScheduleBatchDisaggregationDecodeMixin):
self.seq_lens.add_(1)
self.seq_lens_cpu.add_(1)
self.orig_seq_lens.add_(1)
# Defer compute to refresh_seq_lens_cpu (either pre-forward in scheduler.py
# or lazily in ForwardBatch.init_new).
# Sum is recomputed lazily by ForwardBatch.init_new.
self.seq_lens_sum = None
if self.hisparse_coordinator is not None:
@@ -2447,25 +2446,6 @@ class ScheduleBatch(ScheduleBatchDisaggregationDecodeMixin):
.to(device=self.device, non_blocking=True)
)
def maybe_wait_verify_done(self):
# Use event.wait() (stream-level wait) instead of .synchronize()
# (CPU block). Schedule-stream prep ops following this call get
# ordered after the forward-stream verify via the wait; CPU is not
# blocked. Subsequent .cpu()/.item() naturally sync the stream.
if self.is_spec_v2:
draft_input: EagleDraftInput = self.spec_info
if draft_input.verify_done is not None:
draft_input.verify_done.wait()
def refresh_seq_lens_cpu(self, sync: bool = True):
# sync=True: D2H from seq_lens (needed when seq_lens_cpu is stale
# relative to seq_lens, i.e. spec v2's mid-forward GPU rebind).
# sync=False: caller asserts seq_lens_cpu already fresh — skip D2H,
# only recompute the cached sum.
if sync and self.is_spec_v2:
self.seq_lens_cpu = self.seq_lens.cpu()
self.seq_lens_sum = int(self.seq_lens_cpu.sum())
def filter_batch(
self,
chunked_req_to_exclude: Optional[Union[Req, List[Req]]] = None,
@@ -2473,10 +2453,6 @@ class ScheduleBatch(ScheduleBatchDisaggregationDecodeMixin):
# FIXME(lsyin): deprecate this API after spec v1 is deprecated
v1_spec_info_filtered: Optional[bool] = False,
):
# FIXME(lsyin): used here to get the correct seq_lens
# The batch has been launched but we need it verified to get correct next batch info
self.maybe_wait_verify_done()
if keep_indices is None:
if isinstance(chunked_req_to_exclude, Req):
chunked_req_to_exclude = [chunked_req_to_exclude]
@@ -2516,8 +2492,7 @@ class ScheduleBatch(ScheduleBatchDisaggregationDecodeMixin):
self.seq_lens_cpu = self.seq_lens_cpu[keep_indices]
self.orig_seq_lens = self.orig_seq_lens[keep_indices_device]
self.out_cache_loc = None
# Defer compute to refresh_seq_lens_cpu (either pre-forward in scheduler.py
# or lazily in ForwardBatch.init_new).
# Sum is recomputed lazily by ForwardBatch.init_new.
self.seq_lens_sum = None
if self.input_ids is not None:
@@ -2553,15 +2528,6 @@ class ScheduleBatch(ScheduleBatchDisaggregationDecodeMixin):
)
def merge_batch(self, other: "ScheduleBatch"):
# In the regular scheduler path:
# 1) self is always prefill, whose seq_lens is not a future
# 2) other is always decode, which is finished in previous step
# so verify_done is already synced and this is a no-op.
# In disagg decode + overlap, merge_batch can be called before
# filter_batch, so running_batch.seq_lens may still be a forward_stream
# future. Synchronize here to avoid a cross-stream data race.
self.maybe_wait_verify_done()
# Penalizer orchestrator must be merged before Batch.reqs is merged. This is because
# orchestrator.merge() depends on Batch.reqs during preparation of each penalizers, so it
# needs to be called with pre-merged Batch.reqs.
@@ -2578,8 +2544,7 @@ class ScheduleBatch(ScheduleBatchDisaggregationDecodeMixin):
self.seq_lens_cpu = torch.cat([self.seq_lens_cpu, other.seq_lens_cpu])
self.orig_seq_lens = torch.cat([self.orig_seq_lens, other.orig_seq_lens])
self.out_cache_loc = None
# Defer compute to refresh_seq_lens_cpu (either pre-forward in scheduler.py
# or lazily in ForwardBatch.init_new).
# Sum is recomputed lazily by ForwardBatch.init_new.
self.seq_lens_sum = None
if self.input_ids is not None:
self.input_ids = torch.cat([self.input_ids, other.input_ids])
+36 -13
View File
@@ -22,6 +22,7 @@ import sys
import time
from collections import deque
from contextlib import contextmanager, nullcontext
from functools import partial
from http import HTTPStatus
from typing import Any, Deque, Dict, List, Optional, Tuple, Union
@@ -2841,18 +2842,36 @@ class Scheduler(
# Run forward
if self.is_generation:
if self.enable_overlap:
# Refresh BEFORE _overlap_forward_isolation so snapshot
# captures fresh values and restore preserves them.
batch.refresh_seq_lens_cpu()
# Spec v2 pre-isolation CPU mirror prep: D2H new_seq_lens_buf
# into batch.seq_lens_cpu + set seq_lens_sum. For non-spec_v2,
# ForwardBatch.init_new lazily computes the sum.
if batch.is_spec_v2:
# FIXME: make this optional to different backends.
self.future_map.resolve_seq_lens_cpu(batch)
with self._overlap_forward_isolation(batch):
future_indices = FutureIndices(indices=batch.req_pool_indices)
# Spec_v2 worker fires this between sample-end and
# draft_extend; publish moves the fence to verify-end so
# schedule prep can overlap with draft_extend.
fwd_kwargs = (
{
"on_verify_complete": partial(
self.future_map.publish, future_indices
)
}
if batch.is_spec_v2
else {}
)
with self.forward_stream_ctx:
self.forward_stream.wait_stream(self.schedule_stream)
self.future_map.resolve_future(batch)
# FIXME: pp is not compatible with overlap
batch_result = self.model_worker.forward_batch_generation(batch)
batch_result = self.model_worker.forward_batch_generation(
batch, **fwd_kwargs
)
# Park any refs the worker wants kept alive 2 iters
# (cross-stream tensor lifetime; pinned in the same
# ring slot as the SB attr snapshot).
@@ -2863,7 +2882,12 @@ class Scheduler(
# FIXME(lsyin): maybe move this to forward_batch_generation
batch_result.copy_done = self.device_module.Event()
if batch_result.delay_sample_func is None:
self.future_map.store_to_map(future_indices, batch_result)
stash_payload = (
batch_result.next_draft_input
if batch.is_spec_v2
else batch_result.next_token_ids
)
self.future_map.stash(future_indices, stash_payload)
batch_result.copy_to_cpu(
return_logprob=batch.return_logprob,
return_hidden_states=batch.return_hidden_states,
@@ -2876,15 +2900,11 @@ class Scheduler(
batch.input_ids = -future_indices.indices
if batch.is_spec_v2:
# FIXME(lsyin): tmp code for spec v2
# We only keep future indices for next draft input
batch.spec_info = batch_result.next_draft_input
batch.spec_info.future_indices = future_indices
# The future value, usually for next batch preparation
# Current implementation strictly synchronizes the seq_lens
batch.seq_lens = batch_result.next_draft_input.new_seq_lens
# Schedule-stream sentinel between iters; next iter's
# resolve_future reassigns batch.seq_lens from new_seq_lens_buf.
batch.seq_lens = -future_indices.indices
elif self.enable_pdmux and batch.forward_mode.is_split_prefill():
batch_result = self.tp_worker.forward_batch_split_prefill(batch)
if isinstance(batch_result.next_token_ids, torch.Tensor):
@@ -2968,7 +2988,10 @@ class Scheduler(
self.forward_stream.wait_stream(self.schedule_stream)
_batch_result = batch_result.delay_sample_func()
assert _batch_result is batch_result
self.future_map.store_to_map(batch_result.future_indices, batch_result)
# Delay-sample is non-spec only; stash takes next_token_ids tensor.
self.future_map.stash(
batch_result.future_indices, batch_result.next_token_ids
)
batch_result.copy_to_cpu(
return_logprob=self.cur_batch.return_logprob,
return_hidden_states=self.cur_batch.return_hidden_states,
@@ -502,7 +502,7 @@ class ForwardBatch(ForwardBatchDeepSeekMHAMixin):
seq_lens_cpu = batch.seq_lens_cpu
if batch.seq_lens_sum is None:
batch.refresh_seq_lens_cpu(sync=False)
batch.seq_lens_sum = int(batch.seq_lens_cpu.sum())
ret = cls(
forward_mode=batch.forward_mode,
@@ -696,7 +696,6 @@ class EagleDraftInput(SpecInput, EagleDraftInputV2Mixin):
# V2 overlap worker only
future_indices: Optional[FutureIndices] = None
new_seq_lens: Optional[torch.Tensor] = None
verify_done: Optional[torch.cuda.Event] = None
# V2 reuses `EagleDraftInput` across phases (V1 has a separate
# `EagleDraftExtendInput` for these). Set during V2's draft-extend.
num_correct_drafts: Optional[torch.Tensor] = None
@@ -96,9 +96,6 @@ class EagleDraftInputV2Mixin:
bs = batch.batch_size()
# Now seq_lens is correct
batch.maybe_wait_verify_done()
# Accumulate penalty
# This is a relaxed version of penalties for speculative decoding.
if batch.sampling_info.penalizer_orchestrator.is_required:
@@ -231,9 +228,7 @@ class EagleDraftInputV2Mixin:
batch.input_ids = predict
batch.seq_lens = batch.seq_lens + num_draft_tokens
batch.seq_lens_cpu = batch.seq_lens_cpu + num_draft_tokens
# seq_lens_cpu was just CPU-updated in tandem — sync=False avoids
# a redundant D2H on the draft hot path.
batch.refresh_seq_lens_cpu(sync=False)
batch.seq_lens_sum = int(batch.seq_lens_cpu.sum())
batch.extend_lens = [num_draft_tokens for _ in range(len(batch.seq_lens))]
batch.prefix_lens = seq_lens_cpu_.tolist()
batch.extend_num_tokens = extend_num_tokens
@@ -756,7 +756,7 @@ class EAGLEWorkerV2(BaseSpecWorker):
# allocator and kv cache pool are shared with target worker, which are cleared in scheduler
pass
def forward_batch_generation(self, batch: ScheduleBatch):
def forward_batch_generation(self, batch: ScheduleBatch, on_verify_complete=None):
if batch.forward_mode.is_extend() or batch.is_extend_in_batch:
# Target prefill
target_capture_mode = (
@@ -767,6 +767,10 @@ class EAGLEWorkerV2(BaseSpecWorker):
batch.capture_hidden_mode = target_capture_mode
batch_output = self.target_worker.forward_batch_generation(batch)
# Publish before draft_extend so the fence is at target-end.
if on_verify_complete is not None:
on_verify_complete(batch.seq_lens)
# Draft prefill
with (
self.draft_worker.draft_tp_context(
@@ -809,6 +813,9 @@ class EAGLEWorkerV2(BaseSpecWorker):
assert verify_input.is_verify_input()
batch.spec_info = verify_input
batch_output = self.verify(batch)
# Publish before draft_extend so the fence is at verify-end.
if on_verify_complete is not None:
on_verify_complete(batch_output.next_draft_input.new_seq_lens)
with (
self.draft_worker.draft_tp_context(
self.draft_worker.draft_runner.tp_group
@@ -1068,9 +1075,6 @@ class EAGLEWorkerV2(BaseSpecWorker):
batch, verify_input, accept_lens, accept_index, bs
)
verify_done = torch.get_device_module(self.device).Event()
verify_done.record()
if not batch.forward_mode.is_idle():
accept_tokens = predict[accept_index]
bonus_tokens = torch.empty_like(accept_lens, dtype=torch.int32)
@@ -1089,15 +1093,12 @@ class EAGLEWorkerV2(BaseSpecWorker):
)
next_draft_input = EagleDraftInput(
bonus_tokens=bonus_tokens,
new_seq_lens=new_seq_lens,
verify_done=verify_done,
bonus_tokens=bonus_tokens, new_seq_lens=new_seq_lens
)
# verify_forward_batch transitively holds verify-time GPU tensors
# (draft_token / out_cache_loc / ...) that must outlive the imminent
# batch.input_ids rebind in prepare_for_extend_to_fill_draft_kvcache,
# until the next iter's verify_done.synchronize() in filter_batch.
# batch.input_ids rebind in prepare_for_extend_to_fill_draft_kvcache.
# Scheduler pins it in batch_record_buf for the 2-iter window.
return GenerationBatchResult(
logits_output=logits_output,
@@ -669,7 +669,7 @@ class MultiLayerEagleWorkerV2(BaseSpecWorker):
# allocator and kv cache pool are shared with target worker, which are cleared in scheduler
pass
def forward_batch_generation(self, batch: ScheduleBatch):
def forward_batch_generation(self, batch: ScheduleBatch, on_verify_complete=None):
if batch.forward_mode.is_extend() or batch.is_extend_in_batch:
# Target prefill
target_capture_mode = (
@@ -680,6 +680,10 @@ class MultiLayerEagleWorkerV2(BaseSpecWorker):
batch.capture_hidden_mode = target_capture_mode
batch_output = self.target_worker.forward_batch_generation(batch)
# Publish before draft_extend so the fence is at target-end.
if on_verify_complete is not None:
on_verify_complete(batch.seq_lens)
# Chain-style MTP needs FULL to get all-token hidden states;
# non-chain only needs LAST (the target model's hidden states).
batch_output.next_draft_input = self.draft_worker._draft_extend_for_prefill(
@@ -706,6 +710,9 @@ class MultiLayerEagleWorkerV2(BaseSpecWorker):
assert verify_input.is_verify_input()
batch.spec_info = verify_input
batch_output = self.verify(batch)
# Publish before draft_extend so the fence is at verify-end.
if on_verify_complete is not None:
on_verify_complete(batch_output.next_draft_input.new_seq_lens)
self.draft_worker._draft_extend_for_decode(batch, batch_output)
return batch_output
@@ -767,8 +774,6 @@ class MultiLayerEagleWorkerV2(BaseSpecWorker):
accept_index,
) = verify_input.sample(batch, logits_output)
new_seq_lens = batch.seq_lens + accept_lens
verify_done = torch.get_device_module(self.device).Event()
verify_done.record()
if not batch.forward_mode.is_idle():
accept_tokens = predict[accept_index]
@@ -790,7 +795,6 @@ class MultiLayerEagleWorkerV2(BaseSpecWorker):
next_draft_input = EagleDraftInput(
bonus_tokens=bonus_tokens,
new_seq_lens=new_seq_lens,
verify_done=verify_done,
)
# verify_forward_batch transitively holds verify-time GPU tensors that
# must outlive the imminent batch.input_ids rebind; scheduler pins it