[Spec] Remove the DFLASH V1 worker path (#27959)
This commit is contained in:
@@ -230,15 +230,13 @@ def _handle_dflash(server_args: "ServerArgs") -> None:
|
||||
"Max running requests is reset to 48 for speculative decoding. You can override this by explicitly setting --max-running-requests."
|
||||
)
|
||||
|
||||
if not envs.SGLANG_ENABLE_SPEC_V2.get():
|
||||
# The V1 worker only supports non-overlap scheduling.
|
||||
# SGLANG_ENABLE_SPEC_V2=False selects the non-overlap (synchronous) spec v2
|
||||
# path instead of the overlap-scheduled one; both run the V2 worker.
|
||||
if (
|
||||
not envs.SGLANG_ENABLE_SPEC_V2.get()
|
||||
and not server_args.disable_overlap_schedule
|
||||
):
|
||||
server_args.disable_overlap_schedule = True
|
||||
logger.warning(
|
||||
"Spec v1 is used for DFLASH speculative decoding because "
|
||||
"SGLANG_ENABLE_SPEC_V2 is off; overlap schedule is disabled."
|
||||
)
|
||||
else:
|
||||
logger.warning("Spec v2 is enabled by default for DFLASH speculative decoding.")
|
||||
|
||||
if server_args.enable_mixed_chunk:
|
||||
server_args.enable_mixed_chunk = False
|
||||
|
||||
@@ -1,161 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, List, Optional, Tuple
|
||||
from typing import TYPE_CHECKING, Optional, Tuple
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.srt.layers.attention.utils import create_flashinfer_kv_indices_triton
|
||||
from sglang.srt.layers.logits_processor import LogitsProcessorOutput
|
||||
from sglang.srt.managers.schedule_batch import ScheduleBatch
|
||||
from sglang.srt.mem_cache.common import (
|
||||
alloc_paged_token_slots_extend,
|
||||
alloc_token_slots,
|
||||
get_last_loc,
|
||||
)
|
||||
from sglang.srt.model_executor.forward_batch_info import (
|
||||
CaptureHiddenMode,
|
||||
ForwardBatch,
|
||||
ForwardMode,
|
||||
)
|
||||
from sglang.srt.speculative.dflash_utils import (
|
||||
apply_dflash_verify_logits_adjustments,
|
||||
compute_dflash_correct_drafts_and_bonus,
|
||||
compute_dflash_sampling_correct_drafts_and_bonus,
|
||||
is_dflash_sampling_verify_available,
|
||||
)
|
||||
from sglang.srt.speculative.spec_info import SpecInput, SpecInputType
|
||||
from sglang.srt.speculative.spec_utils import assign_req_to_token_pool_func
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sglang.srt.managers.tp_worker import TpModelWorker
|
||||
|
||||
|
||||
def _compute_paged_keep_slots(
|
||||
*,
|
||||
prefix_lens: torch.Tensor,
|
||||
commit_lens: torch.Tensor,
|
||||
draft_token_num: int,
|
||||
page_size: int,
|
||||
) -> torch.Tensor:
|
||||
"""Compute how many draft slots per request must remain allocated.
|
||||
|
||||
The allocator frees at page granularity for paged mode, so we can only release
|
||||
full pages from the tail after verify.
|
||||
"""
|
||||
|
||||
if page_size <= 1:
|
||||
raise ValueError(f"Expected page_size > 1, got {page_size}.")
|
||||
|
||||
seq_dtype = prefix_lens.dtype
|
||||
extended_lens = prefix_lens + int(draft_token_num)
|
||||
new_lens = prefix_lens + commit_lens.to(seq_dtype)
|
||||
aligned_new_lens = ((new_lens + page_size - 1) // page_size) * page_size
|
||||
keep_lens = torch.minimum(aligned_new_lens, extended_lens)
|
||||
keep_slots = (keep_lens - prefix_lens).to(torch.int64)
|
||||
keep_slots.clamp_(min=0, max=int(draft_token_num))
|
||||
return keep_slots
|
||||
|
||||
|
||||
@dataclass
|
||||
class DFlashDraftInput(SpecInput):
|
||||
"""Per-batch DFlash draft state for spec-v1 (non-overlap) scheduling.
|
||||
|
||||
This object is stored on `ScheduleBatch.spec_info` between decode iterations.
|
||||
It is NOT sent to model attention backends; the DFlash worker uses it to run
|
||||
the draft model and to track draft-side cache progress.
|
||||
|
||||
When draft windowing is disabled, `draft_seq_lens` matches the committed target
|
||||
prefix length already materialized in the draft KV cache. When windowing is
|
||||
enabled, `draft_seq_lens` is the logical resident length in the draft worker's
|
||||
compact req-to-token mapping. In paged mode this may exceed the requested
|
||||
window by up to `page_size - 1` so the local page table remains valid. `ctx_lens`
|
||||
tracks newly committed target tokens that still need draft KV materialization.
|
||||
"""
|
||||
|
||||
# Current token to start the next DFlash block (one per request).
|
||||
bonus_tokens: torch.Tensor
|
||||
|
||||
# Flattened context features for tokens that need to be appended into the draft cache.
|
||||
# Shape: [sum(ctx_lens), K * hidden_size], where K is the number of target-layer
|
||||
# hidden-state features concatenated per token (len(dflash_config.target_layer_ids),
|
||||
# or default K == draft_num_layers for existing checkpoints).
|
||||
target_hidden: torch.Tensor
|
||||
|
||||
# Context lengths per request, used to slice `target_hidden`. Device tensor (int32).
|
||||
ctx_lens: torch.Tensor
|
||||
|
||||
# How many committed tokens are visible to the draft worker per request.
|
||||
draft_seq_lens: torch.Tensor
|
||||
|
||||
def __post_init__(self):
|
||||
super().__init__(spec_input_type=SpecInputType.DFLASH_DRAFT)
|
||||
|
||||
def get_spec_adjust_token_coefficient(self) -> Tuple[int, int]:
|
||||
# Draft state does not change token accounting.
|
||||
return (1, 1)
|
||||
|
||||
def filter_batch(self, new_indices: torch.Tensor, has_been_filtered: bool = True):
|
||||
old_ctx_lens = self.ctx_lens
|
||||
old_target_hidden = self.target_hidden
|
||||
|
||||
self.bonus_tokens = self.bonus_tokens[new_indices]
|
||||
self.ctx_lens = old_ctx_lens[new_indices]
|
||||
self.draft_seq_lens = self.draft_seq_lens[new_indices]
|
||||
|
||||
if old_target_hidden is None or old_target_hidden.numel() == 0:
|
||||
self.target_hidden = old_target_hidden
|
||||
return
|
||||
|
||||
# Rebuild target_hidden for the filtered batch using vectorized indexing.
|
||||
old_bs = int(old_ctx_lens.shape[0])
|
||||
offsets = torch.zeros(
|
||||
(old_bs + 1,), dtype=torch.int64, device=old_ctx_lens.device
|
||||
)
|
||||
offsets[1:].copy_(old_ctx_lens.to(torch.int64).cumsum(0))
|
||||
|
||||
start = offsets[:-1]
|
||||
seg_start = start[new_indices]
|
||||
seg_lens = old_ctx_lens[new_indices].to(torch.int64)
|
||||
|
||||
max_len = int(seg_lens.max().item()) if seg_lens.numel() > 0 else 0
|
||||
if max_len <= 0:
|
||||
self.target_hidden = old_target_hidden[:0]
|
||||
return
|
||||
|
||||
r = torch.arange(max_len, device=old_ctx_lens.device, dtype=torch.int64)[
|
||||
None, :
|
||||
]
|
||||
pos2d = seg_start[:, None] + r
|
||||
mask = r < seg_lens[:, None]
|
||||
flat_pos = pos2d[mask]
|
||||
self.target_hidden = (
|
||||
old_target_hidden.index_select(0, flat_pos)
|
||||
if flat_pos.numel() > 0
|
||||
else old_target_hidden[:0]
|
||||
)
|
||||
|
||||
def merge_batch(self, spec_info: "DFlashDraftInput"):
|
||||
self.bonus_tokens = torch.cat(
|
||||
[self.bonus_tokens, spec_info.bonus_tokens], dim=0
|
||||
)
|
||||
self.ctx_lens = torch.cat([self.ctx_lens, spec_info.ctx_lens], dim=0)
|
||||
self.draft_seq_lens = torch.cat(
|
||||
[self.draft_seq_lens, spec_info.draft_seq_lens], dim=0
|
||||
)
|
||||
if self.target_hidden is None or self.target_hidden.numel() == 0:
|
||||
self.target_hidden = spec_info.target_hidden
|
||||
elif (
|
||||
spec_info.target_hidden is not None and spec_info.target_hidden.numel() > 0
|
||||
):
|
||||
self.target_hidden = torch.cat(
|
||||
[self.target_hidden, spec_info.target_hidden], dim=0
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DFlashVerifyInput(SpecInput):
|
||||
"""Inputs for a target-model verify forward in DFlash (spec-v1).
|
||||
"""Inputs for a target-model verify forward in DFlash.
|
||||
|
||||
The verify forward is run with `ForwardMode.TARGET_VERIFY` so that the target
|
||||
model returns logits for all tokens in the block, enabling accept-length
|
||||
@@ -184,80 +49,6 @@ class DFlashVerifyInput(SpecInput):
|
||||
def get_spec_adjust_token_coefficient(self) -> Tuple[int, int]:
|
||||
return self.draft_token_num, self.draft_token_num
|
||||
|
||||
def prepare_for_verify(
|
||||
self,
|
||||
batch: ScheduleBatch,
|
||||
page_size: int,
|
||||
*,
|
||||
build_custom_mask: bool = True,
|
||||
):
|
||||
if batch.forward_mode.is_idle():
|
||||
return
|
||||
|
||||
batch.input_ids = self.draft_token
|
||||
|
||||
if page_size == 1:
|
||||
batch.out_cache_loc = alloc_token_slots(
|
||||
batch.tree_cache, len(batch.input_ids)
|
||||
)
|
||||
end_offset = batch.seq_lens + self.draft_token_num
|
||||
else:
|
||||
prefix_lens = batch.seq_lens
|
||||
prefix_lens_cpu = batch.seq_lens_cpu
|
||||
end_offset = prefix_lens + self.draft_token_num
|
||||
end_offset_cpu = prefix_lens_cpu + self.draft_token_num
|
||||
last_loc = get_last_loc(
|
||||
batch.req_to_token_pool.req_to_token,
|
||||
batch.req_pool_indices,
|
||||
prefix_lens,
|
||||
)
|
||||
batch.out_cache_loc = alloc_paged_token_slots_extend(
|
||||
batch.tree_cache,
|
||||
prefix_lens,
|
||||
prefix_lens_cpu,
|
||||
end_offset,
|
||||
end_offset_cpu,
|
||||
last_loc,
|
||||
len(batch.input_ids),
|
||||
)
|
||||
|
||||
bs = batch.batch_size()
|
||||
assign_req_to_token_pool_func(
|
||||
batch.req_pool_indices,
|
||||
batch.req_to_token_pool.req_to_token,
|
||||
batch.seq_lens,
|
||||
end_offset,
|
||||
batch.out_cache_loc,
|
||||
bs,
|
||||
)
|
||||
|
||||
if not build_custom_mask:
|
||||
self.custom_mask = None
|
||||
return
|
||||
|
||||
if self.draft_token_num <= 0:
|
||||
raise ValueError(
|
||||
f"DFLASH draft_token_num must be positive, got {self.draft_token_num}."
|
||||
)
|
||||
mask_chunks: List[torch.Tensor] = []
|
||||
q_len = int(self.draft_token_num)
|
||||
q_idx = torch.arange(q_len, device=batch.device, dtype=torch.int32).unsqueeze(1)
|
||||
for prefix_len in batch.seq_lens_cpu.tolist():
|
||||
prefix_len_i = int(prefix_len)
|
||||
kv_len = prefix_len_i + q_len
|
||||
k_idx = torch.arange(
|
||||
kv_len, device=batch.device, dtype=torch.int32
|
||||
).unsqueeze(0)
|
||||
# Allow attending to the full prefix and to tokens up to (and including) the
|
||||
# current query position within the verify block (standard causal masking).
|
||||
allow = k_idx <= (prefix_len_i + q_idx)
|
||||
mask_chunks.append(allow.flatten())
|
||||
self.custom_mask = (
|
||||
torch.cat(mask_chunks, dim=0)
|
||||
if mask_chunks
|
||||
else torch.empty((0,), dtype=torch.bool, device=batch.device)
|
||||
)
|
||||
|
||||
def prepare_for_v2_verify(
|
||||
self,
|
||||
batch: ScheduleBatch,
|
||||
@@ -265,9 +56,8 @@ class DFlashVerifyInput(SpecInput):
|
||||
) -> tuple[ForwardBatch, bool]:
|
||||
"""Prepare a DFLASH verify forward batch for overlap scheduling.
|
||||
|
||||
Unlike spec-v1, the overlap path already computes and stores
|
||||
`batch.out_cache_loc` before this method is called. This helper only
|
||||
packages the verify forward and pre-initializes either CUDA-graph replay
|
||||
The caller computes and stores `batch.out_cache_loc` before this
|
||||
method is called. This helper only packages the verify forward and pre-initializes either CUDA-graph replay
|
||||
metadata or eager attention metadata so the actual forward can run with
|
||||
`skip_attn_backend_init=True`.
|
||||
"""
|
||||
@@ -357,184 +147,3 @@ class DFlashVerifyInput(SpecInput):
|
||||
)
|
||||
self.custom_mask = mask
|
||||
return kv_indices, cum_kv_seq_len, qo_indptr, mask
|
||||
|
||||
def verify(
|
||||
self,
|
||||
*,
|
||||
batch: ScheduleBatch,
|
||||
logits_output: LogitsProcessorOutput,
|
||||
page_size: int,
|
||||
) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor, List[int]]:
|
||||
"""DFlash verification for greedy and non-greedy sampling.
|
||||
|
||||
Returns:
|
||||
new_bonus_tokens: int64 tensor [bs] (the new current token per request)
|
||||
commit_lens: int32 tensor [bs] (how many verify-input tokens are committed)
|
||||
next_target_hidden: tensor [sum(commit_lens), feature_dim]
|
||||
num_correct_drafts_per_req_cpu: list[int] (accepted draft tokens per request)
|
||||
"""
|
||||
if batch.forward_mode.is_idle():
|
||||
empty = torch.empty((0,), dtype=torch.int64, device=batch.device)
|
||||
return empty, empty.to(torch.int32), empty, []
|
||||
|
||||
bs = batch.batch_size()
|
||||
device = logits_output.next_token_logits.device
|
||||
|
||||
sampling_info = batch.sampling_info
|
||||
if sampling_info is not None:
|
||||
if len(sampling_info) != bs:
|
||||
raise RuntimeError(
|
||||
"DFLASH verify sampling_info size mismatch: "
|
||||
f"len(sampling_info)={len(sampling_info)}, bs={bs}."
|
||||
)
|
||||
apply_dflash_verify_logits_adjustments(
|
||||
next_token_logits=logits_output.next_token_logits,
|
||||
sampling_info=sampling_info,
|
||||
draft_token_num=self.draft_token_num,
|
||||
)
|
||||
|
||||
candidates = self.draft_token.view(bs, self.draft_token_num)
|
||||
if (
|
||||
sampling_info is not None
|
||||
and not sampling_info.is_all_greedy
|
||||
and is_dflash_sampling_verify_available()
|
||||
):
|
||||
top_ks = [int(req.sampling_params.top_k) for req in batch.reqs]
|
||||
correct_len, bonus = compute_dflash_sampling_correct_drafts_and_bonus(
|
||||
candidates=candidates,
|
||||
next_token_logits=logits_output.next_token_logits,
|
||||
sampling_info=sampling_info,
|
||||
max_top_k=max(max(top_ks), 1) if top_ks else 1,
|
||||
uniform_top_k_value=(
|
||||
top_ks[0]
|
||||
if top_ks and all(top_k == top_ks[0] for top_k in top_ks)
|
||||
else None
|
||||
),
|
||||
)
|
||||
else:
|
||||
target_predict = torch.argmax(logits_output.next_token_logits, dim=-1).view(
|
||||
bs, self.draft_token_num
|
||||
)
|
||||
correct_len, bonus = compute_dflash_correct_drafts_and_bonus(
|
||||
candidates=candidates,
|
||||
target_predict=target_predict,
|
||||
)
|
||||
|
||||
# Single D2H transfer: candidates[1:] + correct_len + bonus
|
||||
packed = torch.cat(
|
||||
[candidates[:, 1:], correct_len.unsqueeze(1), bonus.unsqueeze(1)], dim=1
|
||||
).cpu()
|
||||
|
||||
max_acc = self.draft_token_num - 1
|
||||
num_correct_drafts_per_req_cpu: List[int] = []
|
||||
commit_lens_cpu: List[int] = []
|
||||
new_bonus_tokens_list: List[int] = []
|
||||
|
||||
for i, req in enumerate(batch.reqs):
|
||||
acc_len = int(packed[i, max_acc].item())
|
||||
proposed = packed[i, :acc_len].tolist() + [
|
||||
int(packed[i, max_acc + 1].item())
|
||||
]
|
||||
|
||||
appended = 0
|
||||
for token_id in proposed:
|
||||
token_id = int(token_id)
|
||||
req.output_ids.append(token_id)
|
||||
appended += 1
|
||||
req.update_finish_state()
|
||||
if req.finished():
|
||||
break
|
||||
if req.grammar is not None:
|
||||
req.grammar.accept_token(token_id)
|
||||
|
||||
if req.output_ids:
|
||||
new_bonus_token = int(req.output_ids[-1])
|
||||
elif req.origin_input_ids:
|
||||
# If no token was appended in this verify step, keep the current token unchanged.
|
||||
new_bonus_token = int(req.origin_input_ids[-1])
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"DFLASH verify cannot determine current token: both output_ids and origin_input_ids are empty."
|
||||
)
|
||||
|
||||
commit_lens_cpu.append(appended)
|
||||
new_bonus_tokens_list.append(new_bonus_token)
|
||||
num_correct_drafts_per_req_cpu.append(max(0, appended - 1))
|
||||
req.spec_verify_ct += 1
|
||||
req.spec_num_correct_drafts += num_correct_drafts_per_req_cpu[-1]
|
||||
|
||||
commit_lens = torch.tensor(commit_lens_cpu, dtype=torch.int32, device=device)
|
||||
new_bonus_tokens = torch.tensor(
|
||||
new_bonus_tokens_list, dtype=torch.int64, device=device
|
||||
)
|
||||
|
||||
# Free uncommitted KV cache slots and compact out_cache_loc.
|
||||
if page_size == 1:
|
||||
out_cache_loc = batch.out_cache_loc.view(bs, self.draft_token_num)
|
||||
keep_mask = (
|
||||
torch.arange(self.draft_token_num, device=device)[None, :]
|
||||
< commit_lens[:, None]
|
||||
)
|
||||
batch.token_to_kv_pool_allocator.free(out_cache_loc[~keep_mask])
|
||||
batch.out_cache_loc = out_cache_loc[keep_mask]
|
||||
else:
|
||||
out_cache_loc = batch.out_cache_loc.view(bs, self.draft_token_num)
|
||||
row_offsets = torch.arange(self.draft_token_num, device=device)[None, :]
|
||||
keep_slots = _compute_paged_keep_slots(
|
||||
prefix_lens=batch.seq_lens,
|
||||
commit_lens=commit_lens,
|
||||
draft_token_num=self.draft_token_num,
|
||||
page_size=page_size,
|
||||
)
|
||||
free_mask = row_offsets >= keep_slots[:, None]
|
||||
batch.token_to_kv_pool_allocator.free(out_cache_loc[free_mask])
|
||||
|
||||
keep_mask = row_offsets < commit_lens[:, None]
|
||||
batch.out_cache_loc = out_cache_loc[keep_mask]
|
||||
|
||||
# Update req-level KV cache accounting.
|
||||
for req, commit_len in zip(batch.reqs, commit_lens_cpu, strict=True):
|
||||
req.kv_committed_len += commit_len
|
||||
req.kv_allocated_len = req.kv_committed_len
|
||||
|
||||
# Update req_to_token pool mapping for newly committed tokens.
|
||||
end_offset = batch.seq_lens + commit_lens.to(batch.seq_lens.dtype)
|
||||
assign_req_to_token_pool_func(
|
||||
batch.req_pool_indices,
|
||||
batch.req_to_token_pool.req_to_token,
|
||||
batch.seq_lens,
|
||||
end_offset,
|
||||
batch.out_cache_loc,
|
||||
bs,
|
||||
)
|
||||
|
||||
# Update batch seq lens.
|
||||
batch.seq_lens.add_(commit_lens.to(batch.seq_lens.dtype))
|
||||
batch.seq_lens_cpu.add_(
|
||||
torch.tensor(commit_lens_cpu, dtype=batch.seq_lens_cpu.dtype)
|
||||
)
|
||||
# Keep seq_lens_sum in sync; flashinfer indices updaters rely on this for buffer sizing.
|
||||
batch.seq_lens_sum += sum(commit_lens_cpu)
|
||||
|
||||
# Build next-step context features from the committed verify-input tokens.
|
||||
hidden = logits_output.hidden_states
|
||||
if hidden is None:
|
||||
raise RuntimeError(
|
||||
"DFLASH verify requires target hidden states, but got None."
|
||||
)
|
||||
hidden = hidden.view(bs, self.draft_token_num, -1)
|
||||
segments: List[torch.Tensor] = []
|
||||
for i, ln in enumerate(commit_lens_cpu):
|
||||
if ln > 0:
|
||||
segments.append(hidden[i, :ln, :])
|
||||
next_target_hidden = torch.cat(segments, dim=0) if segments else hidden[:0]
|
||||
|
||||
# Avoid confusing downstream consumers (spec-v1 decode doesn't use this).
|
||||
logits_output.hidden_states = None
|
||||
|
||||
return (
|
||||
new_bonus_tokens,
|
||||
commit_lens,
|
||||
next_target_hidden,
|
||||
num_correct_drafts_per_req_cpu,
|
||||
)
|
||||
|
||||
@@ -7,28 +7,18 @@ import torch
|
||||
|
||||
from sglang.srt.distributed import get_tp_group
|
||||
from sglang.srt.managers.schedule_batch import ScheduleBatch
|
||||
from sglang.srt.managers.scheduler import GenerationBatchResult
|
||||
from sglang.srt.managers.tp_worker import TpModelWorker
|
||||
from sglang.srt.mem_cache.common import get_last_loc
|
||||
from sglang.srt.model_executor.forward_batch_info import (
|
||||
CaptureHiddenMode,
|
||||
ForwardBatch,
|
||||
ForwardMode,
|
||||
)
|
||||
from sglang.srt.model_executor.forward_batch_info import CaptureHiddenMode
|
||||
from sglang.srt.server_args import (
|
||||
ServerArgs,
|
||||
get_global_server_args,
|
||||
set_global_server_args_for_scheduler,
|
||||
)
|
||||
from sglang.srt.speculative.dflash_info import DFlashDraftInput, DFlashVerifyInput
|
||||
from sglang.srt.speculative.dflash_info import DFlashVerifyInput
|
||||
from sglang.srt.speculative.dflash_utils import (
|
||||
can_dflash_use_fused_qkv_proj,
|
||||
is_dflash_sampling_verify_available,
|
||||
parse_dflash_draft_config,
|
||||
resolve_dflash_verify_mask_policy,
|
||||
)
|
||||
from sglang.srt.speculative.spec_info import SpeculativeAlgorithm
|
||||
from sglang.srt.speculative.spec_utils import assign_req_to_token_pool_func
|
||||
from sglang.srt.utils import is_cuda, is_npu
|
||||
|
||||
_is_npu = is_npu()
|
||||
@@ -51,7 +41,7 @@ def _get_fused_kv_materialize_helper():
|
||||
|
||||
|
||||
class DFlashWorker:
|
||||
"""DFlash speculative decoding worker (spec-v1, tp>=1/pp=1)."""
|
||||
"""Shared DFLASH infrastructure (draft model, draft KV materialization)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -540,184 +530,6 @@ class DFlashWorker:
|
||||
|
||||
return int(resolved_id)
|
||||
|
||||
def _prepare_for_speculative_decoding(
|
||||
self, batch: ScheduleBatch, draft_input: DFlashDraftInput
|
||||
):
|
||||
if batch.forward_mode.is_extend() or batch.forward_mode.is_idle():
|
||||
return
|
||||
|
||||
if batch.has_grammar:
|
||||
raise RuntimeError(
|
||||
"Invariant broken: DFLASH batch has grammar constraints, but scheduler should have rejected this request."
|
||||
)
|
||||
if batch.sampling_info is not None and not batch.sampling_info.is_all_greedy:
|
||||
if (
|
||||
not is_dflash_sampling_verify_available()
|
||||
and not self._warned_sampling_fallback
|
||||
and self.tp_rank == 0
|
||||
):
|
||||
logger.warning(
|
||||
"DFLASH non-greedy verification is unavailable on this build/device; "
|
||||
"falling back to greedy argmax verification."
|
||||
)
|
||||
self._warned_sampling_fallback = True
|
||||
|
||||
bs = batch.batch_size()
|
||||
|
||||
# --- 1) Append any newly committed tokens into the draft KV cache.
|
||||
self._append_target_hidden_to_draft_kv(batch, draft_input)
|
||||
|
||||
target_model = self.target_worker.model_runner.model
|
||||
embed_module = target_model.get_input_embeddings()
|
||||
lm_head = getattr(target_model, "lm_head", None)
|
||||
if (
|
||||
lm_head is None
|
||||
or not hasattr(lm_head, "weight")
|
||||
or not hasattr(lm_head, "shard_indices")
|
||||
):
|
||||
raise RuntimeError(
|
||||
"DFLASH requires the target model to expose a vocab-parallel `lm_head` with `weight` and "
|
||||
"`shard_indices` attributes."
|
||||
)
|
||||
|
||||
# --- 2) Draft a fixed block with the draft model.
|
||||
self._ensure_draft_block_buffers(bs)
|
||||
assert self._draft_block_ids_buf is not None
|
||||
assert self._draft_block_positions_buf is not None
|
||||
assert self._draft_block_tokens_buf is not None
|
||||
assert self._draft_block_end_buf is not None
|
||||
assert self._draft_seq_lens_cpu_buf is not None
|
||||
|
||||
allocator = self.draft_model_runner.token_to_kv_pool_allocator
|
||||
token_to_kv_pool_state_backup = allocator.backup_state()
|
||||
try:
|
||||
block_ids = self._draft_block_ids_buf[:bs]
|
||||
block_ids.fill_(int(self._mask_token_id))
|
||||
block_ids[:, 0].copy_(draft_input.bonus_tokens.to(torch.long))
|
||||
|
||||
noise_embedding = embed_module(block_ids)
|
||||
input_embeds = noise_embedding.view(-1, noise_embedding.shape[-1])
|
||||
|
||||
# For spec-v1, the draft KV cache is always materialized before drafting the
|
||||
# next block. `target_prefix_lens` stay absolute for RoPE; `draft_prefix_lens`
|
||||
# are the logical resident lengths in the draft-local cache.
|
||||
target_prefix_lens = batch.seq_lens # int32, device
|
||||
draft_prefix_lens = draft_input.draft_seq_lens
|
||||
if draft_prefix_lens.dtype != torch.int32:
|
||||
draft_prefix_lens = draft_prefix_lens.to(torch.int32)
|
||||
if draft_prefix_lens.device != self.device:
|
||||
draft_prefix_lens = draft_prefix_lens.to(self.device, non_blocking=True)
|
||||
|
||||
positions_2d = self._draft_block_positions_buf[:bs]
|
||||
torch.add(
|
||||
target_prefix_lens.unsqueeze(1),
|
||||
self._block_pos_offsets,
|
||||
out=positions_2d,
|
||||
)
|
||||
positions = positions_2d.reshape(-1)
|
||||
|
||||
block_start = draft_prefix_lens
|
||||
block_end = self._draft_block_end_buf[:bs]
|
||||
torch.add(block_start, int(self.block_size), out=block_end)
|
||||
|
||||
seq_lens_cpu = self._draft_seq_lens_cpu_buf[:bs]
|
||||
seq_lens_cpu.copy_(draft_prefix_lens.to(device="cpu", dtype=torch.int32))
|
||||
if self.page_size == 1:
|
||||
block_cache_loc = allocator.alloc(bs * self.block_size)
|
||||
else:
|
||||
block_end_cpu = seq_lens_cpu + int(self.block_size)
|
||||
last_loc = get_last_loc(
|
||||
self.draft_model_runner.req_to_token_pool.req_to_token,
|
||||
batch.req_pool_indices,
|
||||
block_start,
|
||||
)
|
||||
block_cache_loc = allocator.alloc_extend(
|
||||
block_start,
|
||||
seq_lens_cpu,
|
||||
block_end,
|
||||
block_end_cpu,
|
||||
last_loc,
|
||||
bs * self.block_size,
|
||||
)
|
||||
if block_cache_loc is None:
|
||||
raise RuntimeError(
|
||||
f"DFLASH draft OOM when allocating {bs * self.block_size} block tokens."
|
||||
)
|
||||
|
||||
assign_req_to_token_pool_func(
|
||||
batch.req_pool_indices,
|
||||
self.draft_model_runner.req_to_token_pool.req_to_token,
|
||||
block_start,
|
||||
block_end,
|
||||
block_cache_loc,
|
||||
bs,
|
||||
)
|
||||
|
||||
# Use TARGET_VERIFY mode (cuda-graphable) to run a fixed-size draft block.
|
||||
# In this mode, `seq_lens` stores the prefix lengths; attention backends
|
||||
# derive kv_len by adding `draft_token_num`.
|
||||
draft_spec_info = self._draft_block_spec_info
|
||||
seq_lens = draft_prefix_lens
|
||||
seq_lens_sum = int(draft_prefix_lens.sum().item())
|
||||
forward_batch = ForwardBatch(
|
||||
forward_mode=ForwardMode.TARGET_VERIFY,
|
||||
batch_size=bs,
|
||||
input_ids=block_ids.flatten(),
|
||||
req_pool_indices=batch.req_pool_indices,
|
||||
seq_lens=seq_lens,
|
||||
out_cache_loc=block_cache_loc,
|
||||
seq_lens_sum=seq_lens_sum,
|
||||
seq_lens_cpu=seq_lens_cpu,
|
||||
positions=positions,
|
||||
input_embeds=input_embeds,
|
||||
spec_algorithm=SpeculativeAlgorithm.DFLASH,
|
||||
spec_info=draft_spec_info,
|
||||
capture_hidden_mode=CaptureHiddenMode.NULL,
|
||||
)
|
||||
|
||||
with torch.inference_mode():
|
||||
draft_logits_output = self.draft_model_runner.forward(
|
||||
forward_batch
|
||||
).logits_output
|
||||
finally:
|
||||
# Drop the speculative block from the shared allocator (EAGLE3-style).
|
||||
allocator.restore_state(token_to_kv_pool_state_backup)
|
||||
|
||||
draft_hidden = draft_logits_output.hidden_states
|
||||
if draft_hidden is None:
|
||||
raise RuntimeError("DFLASH draft model returned no hidden states.")
|
||||
draft_hidden = draft_hidden.view(bs, self.block_size, -1)
|
||||
draft_next = self._greedy_sample_from_vocab_parallel_head(
|
||||
hidden_states=draft_hidden[:, 1:, :].reshape(-1, draft_hidden.shape[-1]),
|
||||
lm_head=lm_head,
|
||||
).view(bs, self.block_size - 1)
|
||||
draft_tokens = self._draft_block_tokens_buf[:bs]
|
||||
draft_tokens[:, 0].copy_(block_ids[:, 0])
|
||||
draft_tokens[:, 1:].copy_(draft_next)
|
||||
positions = positions_2d.reshape(-1)
|
||||
|
||||
verify_input = DFlashVerifyInput(
|
||||
draft_token=draft_tokens.reshape(-1),
|
||||
positions=positions,
|
||||
draft_token_num=self.block_size,
|
||||
)
|
||||
_, build_custom_mask = resolve_dflash_verify_mask_policy(
|
||||
self.model_runner.attn_backend
|
||||
)
|
||||
verify_input.prepare_for_verify(
|
||||
batch,
|
||||
self.page_size,
|
||||
build_custom_mask=build_custom_mask,
|
||||
)
|
||||
|
||||
batch.forward_mode = (
|
||||
ForwardMode.TARGET_VERIFY
|
||||
if not batch.forward_mode.is_idle()
|
||||
else ForwardMode.IDLE
|
||||
)
|
||||
batch.spec_info = verify_input
|
||||
batch.return_hidden_states = False
|
||||
|
||||
def _greedy_sample_from_vocab_parallel_head(
|
||||
self,
|
||||
*,
|
||||
@@ -928,144 +740,6 @@ class DFlashWorker:
|
||||
|
||||
return out_tokens
|
||||
|
||||
def _append_target_hidden_to_draft_kv(
|
||||
self,
|
||||
batch: ScheduleBatch,
|
||||
draft_input: DFlashDraftInput,
|
||||
) -> None:
|
||||
"""Materialize the target hidden-state features into the draft KV cache.
|
||||
|
||||
This must be run before exposing new tokens to radix cache (prefix hits), otherwise
|
||||
another request could reuse target KV indices without having draft KV values.
|
||||
"""
|
||||
|
||||
bs = batch.batch_size()
|
||||
device = self.model_runner.device
|
||||
|
||||
if draft_input.target_hidden is None:
|
||||
raise RuntimeError(
|
||||
"DFLASH draft state missing target_hidden context features."
|
||||
)
|
||||
if draft_input.ctx_lens.numel() != bs:
|
||||
raise RuntimeError(
|
||||
f"DFLASH ctx_lens length mismatch: got {draft_input.ctx_lens.numel()} for bs={bs}."
|
||||
)
|
||||
if draft_input.draft_seq_lens.numel() != bs:
|
||||
raise RuntimeError(
|
||||
f"DFLASH draft_seq_lens length mismatch: got {draft_input.draft_seq_lens.numel()} for bs={bs}."
|
||||
)
|
||||
|
||||
total_ctx = int(draft_input.target_hidden.shape[0])
|
||||
if total_ctx <= 0:
|
||||
draft_input.ctx_lens = torch.zeros_like(draft_input.ctx_lens)
|
||||
draft_input.target_hidden = draft_input.target_hidden[:0]
|
||||
return
|
||||
|
||||
target_req_to_token = batch.req_to_token_pool.req_to_token
|
||||
draft_req_to_token = self.draft_model_runner.req_to_token_pool.req_to_token
|
||||
|
||||
req_pool_indices = batch.req_pool_indices
|
||||
if req_pool_indices.dtype != torch.int64:
|
||||
req_pool_indices = req_pool_indices.to(torch.int64)
|
||||
|
||||
ctx_lens = draft_input.ctx_lens
|
||||
if ctx_lens.dtype != torch.int32:
|
||||
ctx_lens = ctx_lens.to(torch.int32)
|
||||
if ctx_lens.device != device:
|
||||
ctx_lens = ctx_lens.to(device, non_blocking=True)
|
||||
ctx_start = batch.seq_lens.to(torch.int64) - ctx_lens.to(torch.int64)
|
||||
|
||||
if bs == 1:
|
||||
# Fast path for single request.
|
||||
max_ctx = int(total_ctx)
|
||||
if max_ctx <= self._block_pos_offsets.numel():
|
||||
r = self._block_pos_offsets[:max_ctx]
|
||||
else:
|
||||
r = torch.arange(max_ctx, device=device, dtype=torch.int64)
|
||||
pos2d = ctx_start[:, None] + r[None, :] # [1, ctx]
|
||||
cache2d = target_req_to_token[req_pool_indices[:, None], pos2d] # [1, ctx]
|
||||
ctx_cache_loc = cache2d.reshape(-1).to(torch.int64) # [ctx]
|
||||
ctx_positions = pos2d.reshape(-1) # [ctx]
|
||||
else:
|
||||
# In decode mode, ctx_lens <= block_size so we can skip the .item() sync.
|
||||
if batch.forward_mode.is_extend() or batch.is_extend_in_batch:
|
||||
max_ctx = int(ctx_lens.max().item())
|
||||
else:
|
||||
max_ctx = int(self.block_size)
|
||||
if max_ctx <= 0:
|
||||
raise RuntimeError(f"DFLASH invalid max_ctx={max_ctx} for KV append.")
|
||||
|
||||
if max_ctx <= self._block_pos_offsets.numel():
|
||||
r = self._block_pos_offsets[:max_ctx]
|
||||
else:
|
||||
r = torch.arange(max_ctx, device=device, dtype=torch.int64)
|
||||
r = r[None, :] # [1, max_ctx]
|
||||
pos2d = ctx_start[:, None] + r # [bs, max_ctx]
|
||||
mask = r < ctx_lens[:, None]
|
||||
|
||||
# Batched gather of cache locations and positions.
|
||||
ctx_cache_loc = self._gather_req_to_token_masked(
|
||||
req_to_token=target_req_to_token,
|
||||
req_pool_indices=req_pool_indices,
|
||||
pos2d=pos2d,
|
||||
mask=mask,
|
||||
context="DFLASH target hidden KV append",
|
||||
) # [sum(ctx_lens)]
|
||||
ctx_positions = pos2d[mask] # [sum(ctx_lens)]
|
||||
|
||||
with torch.inference_mode():
|
||||
ctx_hidden = self.draft_model.project_target_hidden(
|
||||
draft_input.target_hidden
|
||||
) # [sum(ctx), hidden]
|
||||
if ctx_hidden.shape[0] != ctx_cache_loc.numel():
|
||||
raise RuntimeError(
|
||||
f"DFLASH ctx_hidden/cache_loc mismatch: {ctx_hidden.shape[0]} vs {ctx_cache_loc.numel()}."
|
||||
)
|
||||
|
||||
wrote_with_fused_kv = False
|
||||
if self._use_fused_kv_materialize and self._fused_kv_helper is not None:
|
||||
try:
|
||||
self._append_target_hidden_fused(
|
||||
ctx_hidden, ctx_positions, ctx_cache_loc
|
||||
)
|
||||
wrote_with_fused_kv = True
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"DFLASH fused KV append failed; falling back to sequential path: %s",
|
||||
e,
|
||||
)
|
||||
self._use_fused_kv_materialize = False
|
||||
self._fused_kv_helper = None
|
||||
if not wrote_with_fused_kv:
|
||||
self._append_target_hidden_sequential(
|
||||
ctx_hidden, ctx_positions, ctx_cache_loc
|
||||
)
|
||||
|
||||
if self.use_compact_draft_cache:
|
||||
new_draft_seq_lens = self._compute_compact_draft_seq_lens(batch.seq_lens)
|
||||
suffix_start = batch.seq_lens.to(torch.int64) - new_draft_seq_lens.to(
|
||||
torch.int64
|
||||
)
|
||||
suffix_cache_loc = self._gather_req_to_token_segments(
|
||||
req_to_token=target_req_to_token,
|
||||
req_pool_indices=req_pool_indices,
|
||||
start=suffix_start,
|
||||
lengths=new_draft_seq_lens,
|
||||
)
|
||||
assign_req_to_token_pool_func(
|
||||
batch.req_pool_indices,
|
||||
draft_req_to_token,
|
||||
torch.zeros_like(new_draft_seq_lens),
|
||||
new_draft_seq_lens,
|
||||
suffix_cache_loc,
|
||||
bs,
|
||||
)
|
||||
draft_input.draft_seq_lens = new_draft_seq_lens
|
||||
else:
|
||||
draft_input.draft_seq_lens = batch.seq_lens.to(dtype=torch.int32)
|
||||
draft_input.ctx_lens = torch.zeros_like(ctx_lens)
|
||||
draft_input.target_hidden = draft_input.target_hidden[:0]
|
||||
|
||||
def _append_target_hidden_to_draft_kv_by_loc(
|
||||
self,
|
||||
*,
|
||||
@@ -1335,133 +1009,3 @@ class DFlashWorker:
|
||||
mamba_steps_to_track=mamba_steps_to_track,
|
||||
model=self.target_worker.model_runner.model,
|
||||
)
|
||||
|
||||
def forward_batch_generation(
|
||||
self, batch: ScheduleBatch, **kwargs
|
||||
) -> GenerationBatchResult:
|
||||
if getattr(batch, "return_logprob", False):
|
||||
raise RuntimeError(
|
||||
"Invariant broken: DFLASH batch requested return_logprob, but scheduler should have rejected this request."
|
||||
)
|
||||
|
||||
if batch.forward_mode.is_extend() or batch.is_extend_in_batch:
|
||||
batch.capture_hidden_mode = CaptureHiddenMode.FULL
|
||||
batch_result = self.target_worker.forward_batch_generation(batch, **kwargs)
|
||||
logits_output, next_token_ids = (
|
||||
batch_result.logits_output,
|
||||
batch_result.next_token_ids,
|
||||
)
|
||||
if logits_output.hidden_states is None:
|
||||
raise RuntimeError(
|
||||
"DFLASH requires target aux hidden capture for prefill, but got None. "
|
||||
"Make sure the target model has DFlash layers-to-capture configured."
|
||||
)
|
||||
|
||||
if batch.extend_lens is None or batch.prefix_lens is None:
|
||||
raise RuntimeError(
|
||||
"DFLASH expected extend_lens / prefix_lens to be populated in extend mode, but got None."
|
||||
)
|
||||
|
||||
# Materialize the prompt tokens into the draft KV cache immediately. This is required
|
||||
# for radix cache support, since the scheduler may update radix after prefill returns.
|
||||
device = next_token_ids.device
|
||||
|
||||
def _to_int32_device_tensor(x, *, device=device):
|
||||
if isinstance(x, torch.Tensor):
|
||||
if x.device != device:
|
||||
x = x.to(device, non_blocking=True)
|
||||
return x if x.dtype == torch.int32 else x.to(torch.int32)
|
||||
return torch.tensor(x, dtype=torch.int32, device=device)
|
||||
|
||||
extend_seq_lens = _to_int32_device_tensor(batch.extend_lens)
|
||||
draft_input = DFlashDraftInput(
|
||||
bonus_tokens=next_token_ids.to(torch.int64),
|
||||
target_hidden=logits_output.hidden_states,
|
||||
ctx_lens=extend_seq_lens,
|
||||
draft_seq_lens=(
|
||||
torch.zeros_like(extend_seq_lens)
|
||||
if self.use_compact_draft_cache
|
||||
else _to_int32_device_tensor(batch.prefix_lens)
|
||||
),
|
||||
)
|
||||
self._append_target_hidden_to_draft_kv(batch, draft_input)
|
||||
batch.spec_info = draft_input
|
||||
|
||||
return GenerationBatchResult(
|
||||
logits_output=logits_output,
|
||||
next_token_ids=next_token_ids,
|
||||
num_correct_drafts=0,
|
||||
can_run_cuda_graph=batch_result.can_run_cuda_graph,
|
||||
)
|
||||
|
||||
# Decode / target-verify stage.
|
||||
draft_input = batch.spec_info
|
||||
if not isinstance(draft_input, DFlashDraftInput):
|
||||
raise RuntimeError(
|
||||
"DFLASH decode requires DFlashDraftInput state on the running batch. "
|
||||
"This usually means the request did not complete the prefill stage."
|
||||
)
|
||||
|
||||
self._prepare_for_speculative_decoding(batch, draft_input)
|
||||
|
||||
assert batch.forward_mode.is_target_verify()
|
||||
verify_input = batch.spec_info
|
||||
assert isinstance(verify_input, DFlashVerifyInput)
|
||||
need_mamba_verify_commit = hasattr(
|
||||
self.target_worker.model_runner.attn_backend,
|
||||
"update_mamba_state_after_mtp_verify",
|
||||
)
|
||||
seq_lens_pre_verify = (
|
||||
batch.seq_lens.clone() if need_mamba_verify_commit else None
|
||||
)
|
||||
|
||||
batch_result = self.target_worker.forward_batch_generation(
|
||||
batch, is_verify=True, **kwargs
|
||||
)
|
||||
logits_output, can_run_cuda_graph = (
|
||||
batch_result.logits_output,
|
||||
batch_result.can_run_cuda_graph,
|
||||
)
|
||||
|
||||
(
|
||||
new_bonus_tokens,
|
||||
commit_lens,
|
||||
next_target_hidden,
|
||||
num_correct_drafts_per_req_cpu,
|
||||
) = verify_input.verify(
|
||||
batch=batch,
|
||||
logits_output=logits_output,
|
||||
page_size=self.page_size,
|
||||
)
|
||||
if need_mamba_verify_commit:
|
||||
assert seq_lens_pre_verify is not None
|
||||
self._update_target_mamba_state_after_verify(
|
||||
batch=batch,
|
||||
seq_lens_pre_verify=seq_lens_pre_verify,
|
||||
commit_lens=commit_lens,
|
||||
)
|
||||
|
||||
# Update draft state for the next iteration. Also materialize the committed verify tokens
|
||||
# into the draft KV cache immediately so radix cache entries are safe to reuse.
|
||||
draft_input.bonus_tokens = new_bonus_tokens
|
||||
draft_input.target_hidden = next_target_hidden
|
||||
draft_input.ctx_lens = commit_lens
|
||||
self._append_target_hidden_to_draft_kv(batch, draft_input)
|
||||
batch.spec_info = draft_input
|
||||
batch.forward_mode = ForwardMode.DECODE
|
||||
|
||||
num_correct_drafts = sum(num_correct_drafts_per_req_cpu)
|
||||
if not self._logged_first_verify and self.tp_rank == 0:
|
||||
logger.info(
|
||||
"DFLASH verify completed. num_correct_drafts_per_req=%s",
|
||||
num_correct_drafts_per_req_cpu,
|
||||
)
|
||||
self._logged_first_verify = True
|
||||
|
||||
return GenerationBatchResult(
|
||||
logits_output=logits_output,
|
||||
next_token_ids=new_bonus_tokens,
|
||||
num_correct_drafts=num_correct_drafts,
|
||||
num_correct_drafts_per_req_cpu=num_correct_drafts_per_req_cpu,
|
||||
can_run_cuda_graph=can_run_cuda_graph,
|
||||
)
|
||||
|
||||
@@ -37,11 +37,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DFlashWorkerV2(DFlashWorker):
|
||||
"""DFLASH speculative decoding worker (spec-v2 overlap scheduling).
|
||||
"""DFLASH speculative decoding worker (spec-v2).
|
||||
|
||||
This is intentionally implemented as a *separate* worker from the existing
|
||||
spec-v1 `DFlashWorker` (non-overlap), to keep the v1 path stable and to
|
||||
minimize risk while bringing up overlap scheduling.
|
||||
Drives both overlap and non-overlap scheduling, same as EAGLE: the
|
||||
scheduler runs it synchronously when overlap is disabled.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
|
||||
@@ -152,16 +152,11 @@ class SpeculativeAlgorithm(Enum):
|
||||
return None
|
||||
|
||||
def supports_spec_v2(self) -> bool:
|
||||
from sglang.srt.environ import envs
|
||||
|
||||
# DFLASH still ships a V1 worker; SGLANG_ENABLE_SPEC_V2=0 selects it
|
||||
# and must flip the scheduler schema together with the worker.
|
||||
# TODO: drop the env gate once the DFLASH V1 worker is removed.
|
||||
return (
|
||||
self.is_eagle()
|
||||
or self.is_standalone()
|
||||
or self.is_ngram()
|
||||
or (self.is_dflash() and envs.SGLANG_ENABLE_SPEC_V2.get())
|
||||
or self.is_dflash()
|
||||
)
|
||||
|
||||
def need_topk(self) -> bool:
|
||||
@@ -185,16 +180,11 @@ class SpeculativeAlgorithm(Enum):
|
||||
), "Cannot create worker for NONE speculative algorithm."
|
||||
|
||||
if self.is_dflash():
|
||||
# Keyed off the same env gate as supports_spec_v2() so the worker
|
||||
# and the scheduler schema always agree. With the gate on, the V2
|
||||
# worker drives both overlap and non-overlap, same as EAGLE.
|
||||
if self.supports_spec_v2():
|
||||
from sglang.srt.speculative.dflash_worker_v2 import DFlashWorkerV2
|
||||
# V2 worker drives both overlap and non-overlap (scheduler runs it
|
||||
# synchronously when overlap is disabled), same as EAGLE.
|
||||
from sglang.srt.speculative.dflash_worker_v2 import DFlashWorkerV2
|
||||
|
||||
return DFlashWorkerV2
|
||||
from sglang.srt.speculative.dflash_worker import DFlashWorker
|
||||
|
||||
return DFlashWorker
|
||||
return DFlashWorkerV2
|
||||
|
||||
if self.is_frozen_kv_mtp():
|
||||
# V2 worker drives both overlap and non-overlap (scheduler runs it
|
||||
|
||||
Reference in New Issue
Block a user