[AMD ]Feat/dsv4 ep tbo prefill (#29362)

Co-authored-by: wunhuang <wunhuang@amd.com>
Co-authored-by: At1a8 <fangyuan@amd.com>
This commit is contained in:
kk
2026-07-05 21:18:34 -07:00
committed by GitHub
co-authored by wunhuang At1a8
parent c016c6f355
commit 81735ecf80
11 changed files with 1008 additions and 31 deletions
@@ -63,6 +63,15 @@ class OperationsStrategy:
for layer in layers for layer in layers
] ]
) )
elif layer_name == "DeepseekV4DecoderLayer":
return OperationsStrategy.concat(
[
_compute_moe_deepseek_v4_layer_operations_strategy_tbo(
layer, forward_mode
)
for layer in layers
]
)
else: else:
raise NotImplementedError raise NotImplementedError
@@ -150,6 +159,73 @@ def _compute_moe_deepseek_blog_decode(layer):
) )
# -------------------------------- Strategy for DeepSeek V4 ---------------------------------------
# DSV4 prefill TBO (EP / mori path). Cross-layer mHC fusion is disabled under
# TBO, so each layer is self-contained: attn-side mHC pre+norm -> attn ->
# ffn-side mHC pre+norm -> MoE (a2a dispatch/combine overlapped) -> mHC post.
# The MoE ops are reused from self.mlp (DeepseekV2MoE) and decompose
# forward_deepep; the layer-level op_mhc_* wrap DSV4's hc_pre / hc_post.
def _compute_moe_deepseek_v4_layer_operations_strategy_tbo(
layer: torch.nn.Module,
forward_mode: ForwardMode,
) -> OperationsStrategy:
if forward_mode == ForwardMode.EXTEND:
return _compute_moe_deepseek_v4_prefill(layer)
else:
# Decode TBO for DSV4 is not implemented yet (ATOM data: decode TBO
# regresses; needs cuda-graph capture work). Prefill-only for now.
raise NotImplementedError(
f"DeepseekV4 TBO only supports prefill (EXTEND), got {forward_mode=}"
)
def _compute_moe_deepseek_v4_prefill(layer):
from sglang.srt.layers.moe import get_moe_a2a_backend
if get_moe_a2a_backend().is_none():
# Non-EP DP TP-MoE: overlap the DP all_gatherv (gather) + reduce_scatterv
# (combine) with the other ubatch's attn+MoE compute (ATOM's DSV4 path).
ops = [
layer.op_mhc_prepare_attn,
layer.self_attn.op_attn,
layer.op_mhc_post_attn_pre_mlp,
layer.op_gather_a,
operations.YieldOperation(),
layer.op_gather_b,
layer.op_moe,
layer.op_combine_a,
operations.YieldOperation(),
layer.op_combine_b,
layer.op_mhc_postprocess,
]
else:
# EP / mori a2a: reuse DeepseekV2MoE's deepep dispatch/combine ops.
ops = [
layer.op_mhc_prepare_attn,
layer.self_attn.op_attn,
layer.op_mhc_post_attn_pre_mlp,
layer.mlp.op_gate,
layer.mlp.op_select_experts,
layer.mlp.op_dispatch_a,
operations.YieldOperation(),
layer.mlp.op_dispatch_b,
layer.mlp.op_experts,
layer.mlp.op_combine_a,
operations.YieldOperation(),
layer.mlp.op_shared_experts,
layer.mlp.op_combine_b,
layer.mlp.op_output,
layer.op_mhc_postprocess,
]
return OperationsStrategy(
deep_gemm_num_sms=None,
tbo_delta_stages=0,
operations=ops,
)
# -------------------------------- Strategy for Qwen3 --------------------------------------- # -------------------------------- Strategy for Qwen3 ---------------------------------------
@@ -103,9 +103,28 @@ def _is_two_chunk_split_enabled(extend_lens: Sequence[int]) -> bool:
overall_sum = sum(extend_lens) overall_sum = sum(extend_lens)
threshold = get_tbo_token_distribution_threshold() threshold = get_tbo_token_distribution_threshold()
assert threshold <= 0.5, f"{threshold=}" assert threshold <= 0.5, f"{threshold=}"
return left_sum < overall_sum * threshold or left_sum > overall_sum * ( want_two_chunk = left_sum < overall_sum * threshold or left_sum > overall_sum * (
1 - threshold 1 - threshold
) )
if not want_two_chunk:
return False
# Two-chunk splits a single seq across both micro-batches by cutting at
# overall_sum // 2. child_a then spans seqs [0 : split_seq_index + 1]
# (batch_size = split_seq_index + 1) but only receives overall_sum // 2
# query tokens. For a degenerate batch (a single seq, or a near-empty
# DP-sync batch) this cut is 0 or tiny, leaving child_a with more seqs
# than query tokens (e.g. (bs=1, tok=0)). That violates the DSV4 compress
# planner invariant `batch_size <= num_q_tokens` and crashes the kernel.
# Fall back to a seq-boundary split, whose child_a is seq-aligned (each
# seq contributes >= 1 token) and cannot become empty-with-count.
split_seq_index = _split_array_by_cum_less_than_half(extend_lens)
child_a_batch_size = split_seq_index + 1
child_a_num_q_tokens = overall_sum // 2
if child_a_batch_size > child_a_num_q_tokens:
return False
return True
def _split_extend_seqs(arr: Sequence[int]) -> int: def _split_extend_seqs(arr: Sequence[int]) -> int:
@@ -652,9 +671,10 @@ class TboForwardBatchPreparer:
output_dict[key] = old_value[start_token_index:end_token_index] output_dict[key] = old_value[start_token_index:end_token_index]
attention_tp_size = get_parallel().attn_tp_size attention_tp_size = get_parallel().attn_tp_size
output_dict["tbo_padded_len"] = ( _tbo_padded_len = (
(end_token_index - start_token_index - 1) // attention_tp_size + 1 (end_token_index - start_token_index - 1) // attention_tp_size + 1
) * attention_tp_size ) * attention_tp_size
output_dict["tbo_padded_len"] = _tbo_padded_len
for key in [ for key in [
"req_pool_indices", "req_pool_indices",
@@ -406,6 +406,14 @@ class _GraphBucket(enum.Enum):
class DeepseekV4HipRadixBackend( class DeepseekV4HipRadixBackend(
AttentionBackend, C4IndexerBackendMixin, CompressorBackendMixin AttentionBackend, C4IndexerBackendMixin, CompressorBackendMixin
): ):
# DSV4 TBO runs ONLY in eager prefill (prefill cuda-graph is disabled);
# decode/target-verify graphs are non-TBO (primary backend only). So the TBO
# child backends must not be driven through cuda-graph capture/replay — doing
# so rebuilds this backend's compressor/indexer metadata per replay step on
# both children and leaks ROCm HSA resources (HSA_STATUS_ERROR_OUT_OF_RESOURCES).
# TboAttnBackend reads this to skip children in the *_graph paths only.
tbo_supports_cuda_graph = False
def __init__( def __init__(
self, self,
model_runner: ModelRunner, model_runner: ModelRunner,
@@ -25,6 +25,20 @@ class TboAttnBackend(AttentionBackend):
children=[creator() for _ in range(2)], children=[creator() for _ in range(2)],
) )
def _children_use_cuda_graph(self) -> bool:
"""Whether the TBO child backends participate in CUDA-graph capture/replay.
Some models only run TBO in eager prefill and keep their graph-captured
modes (decode / target-verify) NON-TBO on the primary backend. For those,
the children must NOT be driven through the cuda-graph paths: doing so
rebuilds their per-step metadata on every replay even though the captured
graph never uses them. For DeepSeek-V4 that metadata build (compressor /
indexer) leaks ROCm HSA resources across the 2 children -> eventual
HSA_STATUS_ERROR_OUT_OF_RESOURCES. Eager prefill TBO (init_forward_metadata)
is unaffected; only the *_graph paths are gated.
"""
return getattr(self.primary, "tbo_supports_cuda_graph", True)
def init_forward_metadata_out_graph( def init_forward_metadata_out_graph(
self, self,
forward_batch: "ForwardBatch", forward_batch: "ForwardBatch",
@@ -33,6 +47,8 @@ class TboAttnBackend(AttentionBackend):
self.primary.init_forward_metadata_out_graph( self.primary.init_forward_metadata_out_graph(
forward_batch=forward_batch, in_capture=in_capture forward_batch=forward_batch, in_capture=in_capture
) )
if not self._children_use_cuda_graph():
return
tbo_children = getattr(forward_batch, "tbo_children", None) tbo_children = getattr(forward_batch, "tbo_children", None)
if tbo_children is not None: if tbo_children is not None:
for child, forward_batch_child in zip( for child, forward_batch_child in zip(
@@ -97,6 +113,8 @@ class TboAttnBackend(AttentionBackend):
def init_forward_metadata_in_graph(self, forward_batch: "ForwardBatch"): def init_forward_metadata_in_graph(self, forward_batch: "ForwardBatch"):
self.primary.init_forward_metadata_in_graph(forward_batch=forward_batch) self.primary.init_forward_metadata_in_graph(forward_batch=forward_batch)
if not self._children_use_cuda_graph():
return
tbo_children = getattr(forward_batch, "tbo_children", None) tbo_children = getattr(forward_batch, "tbo_children", None)
if tbo_children is not None: if tbo_children is not None:
for child, forward_batch_child in zip( for child, forward_batch_child in zip(
@@ -118,17 +136,23 @@ class TboAttnBackend(AttentionBackend):
def init_cuda_graph_state(self, max_bs: int, max_num_tokens: int): def init_cuda_graph_state(self, max_bs: int, max_num_tokens: int):
self.primary.init_cuda_graph_state(max_bs=max_bs, max_num_tokens=max_num_tokens) self.primary.init_cuda_graph_state(max_bs=max_bs, max_num_tokens=max_num_tokens)
if not self._children_use_cuda_graph():
return
for item in self.children: for item in self.children:
# TODO for children, maybe can provide *smaller* max_bs to optimize # TODO for children, maybe can provide *smaller* max_bs to optimize
item.init_cuda_graph_state(max_bs=max_bs, max_num_tokens=max_num_tokens) item.init_cuda_graph_state(max_bs=max_bs, max_num_tokens=max_num_tokens)
def on_after_cuda_graph_warmup(self): def on_after_cuda_graph_warmup(self):
self.primary.on_after_cuda_graph_warmup() self.primary.on_after_cuda_graph_warmup()
if not self._children_use_cuda_graph():
return
for child in self.children: for child in self.children:
child.on_after_cuda_graph_warmup() child.on_after_cuda_graph_warmup()
def get_cuda_graph_seq_len_fill_value(self): def get_cuda_graph_seq_len_fill_value(self):
ans = self.primary.get_cuda_graph_seq_len_fill_value() ans = self.primary.get_cuda_graph_seq_len_fill_value()
if not self._children_use_cuda_graph():
return ans
for child in self.children: for child in self.children:
assert ans == child.get_cuda_graph_seq_len_fill_value() assert ans == child.get_cuda_graph_seq_len_fill_value()
return ans return ans
@@ -145,6 +169,19 @@ class TboAttnBackend(AttentionBackend):
def get_indexer_metadata(self, layer_id: int, forward_batch: "ForwardBatch"): def get_indexer_metadata(self, layer_id: int, forward_batch: "ForwardBatch"):
return self.primary.get_indexer_metadata(layer_id, forward_batch) return self.primary.get_indexer_metadata(layer_id, forward_batch)
def __getattr__(self, name):
# Delegate backend-specific attributes/methods not explicitly wrapped
# above (e.g. DSV4's get_unified_swa_loc / get_swa_out_cache_loc, which
# the model calls directly via get_attn_backend()) to the primary
# full-batch backend. Inside TBO the per-child backend is resolved
# directly from the forward context, so this path only serves the
# non-overlapped forward (warmup / decode / TBO-ineligible batches).
# NOTE: __getattr__ runs only when normal lookup fails; guard `primary`
# to avoid infinite recursion before __init__ sets it.
if name == "primary":
raise AttributeError(name)
return getattr(self.primary, name)
def _build_tbo_child_replay_fb_view( def _build_tbo_child_replay_fb_view(
fb_view, fb_view,
+106
View File
@@ -691,6 +691,112 @@ def dp_reduce_scatter_tensor(output: torch.Tensor, input: torch.Tensor):
get_attention_tp_group().all_gather_into_tensor(output, scattered_local_tokens) get_attention_tp_group().all_gather_into_tensor(output, scattered_local_tokens)
# ---------------------------------------------------------------------------
# Two-batch-overlap (non-EP / DP TP-MoE) async gather + combine.
#
# The DP TP-MoE path (deepseek_v4) gathers local hidden -> a global buffer
# before the experts and reduce-scatters back after. For TBO we run those two
# collectives on a single shared comm stream (mirroring the mori dispatcher's
# _comm_stream) and return a CUDA event, so the op engine can yield and let the
# OTHER ubatch's attn+MoE compute run on the compute stream while this ubatch's
# gather/combine proceeds on the comm stream. Both ubatches share ONE comm
# stream -> their collectives serialize in-order (no concurrent-collective
# deadlock on the RCCL communicator), each overlapping the other's compute.
# ---------------------------------------------------------------------------
_DP_TBO_COMM_STREAM: Optional[torch.cuda.Stream] = None
def get_dp_tbo_comm_stream() -> torch.cuda.Stream:
global _DP_TBO_COMM_STREAM
if _DP_TBO_COMM_STREAM is None:
_DP_TBO_COMM_STREAM = torch.cuda.Stream()
return _DP_TBO_COMM_STREAM
# Persistent reusable CUDA events for non-EP DP TBO, keyed by (kind, subbatch).
# CRITICAL: do NOT create a fresh event per gather/combine -- that is ~244 new
# torch.cuda.Event per forward (61 layers x 2 ubatches x 2), and the HSA signal
# pool is exhausted after a few hundred forwards -> HSA_STATUS_ERROR_OUT_OF_RESOURCES
# ("...create internal OS-specific events"). Reuse one event per (kind, subbatch)
# and just re-record it (mirrors the mori CommStreamPool event reuse).
_TBO_EVENT_POOL: dict = {}
def _tbo_event(key) -> torch.cuda.Event:
ev = _TBO_EVENT_POOL.get(key)
if ev is None:
ev = torch.cuda.Event()
_TBO_EVENT_POOL[key] = ev
return ev
def dp_gather_partial_async(
global_tokens: torch.Tensor,
local_tokens: torch.Tensor,
forward_batch: ForwardBatch,
event_key=("gather", 0),
) -> torch.cuda.Event:
"""Launch `dp_gather_partial` (all_gatherv) on the shared DP TBO comm stream;
re-record + return a PERSISTENT event (keyed by `event_key`) that fires when
the gather completes. Caller yields, then `compute_stream.wait_event(ev)`
before reading `global_tokens`."""
comm = get_dp_tbo_comm_stream()
compute = torch.cuda.current_stream()
# Keep buffers alive across streams (caching allocator).
local_tokens.record_stream(comm)
global_tokens.record_stream(comm)
ev = _tbo_event(event_key)
with torch.cuda.stream(comm):
comm.wait_stream(compute) # inputs were produced on the compute stream
dp_gather_partial(global_tokens, local_tokens, forward_batch)
ev.record(comm)
return ev
# Persistent grow-only buffers for non-EP DP TBO, keyed by (kind, tbo_subbatch).
# Reused across ALL layers (and forwards) so the caching allocator does not churn
# a fresh per-layer `torch.empty` for the 8x DP-gather / combine buffers. That
# churn (different sizes per forward x 2 ubatches x 61 layers, kept alive by the
# comm-stream record_stream) ballooned `reserved` to ~270GB and tripped
# HSA_STATUS_ERROR_OUT_OF_RESOURCES at large prefill chunks, even though the live
# (allocated) working set was only ~10GB.
_TBO_PERSIST_BUF: dict = {}
def get_tbo_persistent_buffer(
key, rows: int, hidden: int, dtype: torch.dtype, device
) -> torch.Tensor:
"""Return a [rows, hidden] view of a grow-only persistent buffer for `key`.
Reallocates only when the request exceeds the cached capacity / changes
dtype|hidden. Caller must treat the returned view as scratch (overwritten)."""
buf = _TBO_PERSIST_BUF.get(key)
cap = 0 if buf is None else buf.shape[0]
if buf is None or rows > cap or buf.shape[1] != hidden or buf.dtype != dtype:
new_rows = max(rows, cap)
buf = torch.empty((new_rows, hidden), dtype=dtype, device=device)
_TBO_PERSIST_BUF[key] = buf
return buf[:rows]
def dp_reduce_scatterv_async(
output_local: torch.Tensor,
global_tokens: torch.Tensor,
sizes: List[int],
event_key=("combine", 0),
) -> torch.cuda.Event:
"""Launch the variable-length reduce_scatterv (combine) on the shared DP TBO
comm stream; re-record + return a PERSISTENT event (keyed by `event_key`).
Matches the gatherv (SUM_LEN) path."""
comm = get_dp_tbo_comm_stream()
compute = torch.cuda.current_stream()
ev = _tbo_event(event_key)
with torch.cuda.stream(comm):
comm.wait_stream(compute)
get_tp_group().reduce_scatterv(global_tokens, output=output_local, sizes=sizes)
ev.record(comm)
return ev
def attn_tp_reduce_scatter_tensor(output: torch.Tensor, input: torch.Tensor): def attn_tp_reduce_scatter_tensor(output: torch.Tensor, input: torch.Tensor):
return get_attention_tp_group().reduce_scatter_tensor(output, input) return get_attention_tp_group().reduce_scatter_tensor(output, input)
+9
View File
@@ -1452,6 +1452,14 @@ class DeepseekV2MoE(nn.Module):
router_logits = state.pop("router_logits") router_logits = state.pop("router_logits")
hidden_states = state.hidden_states_mlp_input hidden_states = state.hidden_states_mlp_input
# Hash MoE layers (e.g. DeepSeek-V4) route on input_ids; forward_deepep
# passes them as a topk kwarg. The per-ubatch forward_batch.input_ids is
# already sliced+padded to match hidden_states rows (and equals the
# global ids under EP dp-attention). No-op for non-hash models.
topk_kwargs = {}
if getattr(self, "is_hash", False):
topk_kwargs["input_ids"] = state.forward_batch.input_ids
if router_logits is not None: if router_logits is not None:
with get_global_expert_distribution_recorder().with_current_layer( with get_global_expert_distribution_recorder().with_current_layer(
self.layer_id self.layer_id
@@ -1463,6 +1471,7 @@ class DeepseekV2MoE(nn.Module):
expert_location_dispatch_info=ExpertLocationDispatchInfo.init_new( expert_location_dispatch_info=ExpertLocationDispatchInfo.init_new(
layer_id=self.layer_id, layer_id=self.layer_id,
), ),
**topk_kwargs,
) )
else: else:
state.topk_output = self.topk.empty_topk_output( state.topk_output = self.topk.empty_topk_output(
+375 -24
View File
@@ -53,15 +53,21 @@ from sglang.srt.layers.deepseek_v4_rope import (
) )
from sglang.srt.layers.dp_attention import ( from sglang.srt.layers.dp_attention import (
_DpGatheredBufferWrapper, _DpGatheredBufferWrapper,
_tbo_event,
attn_tp_all_gather, attn_tp_all_gather,
attn_tp_all_reduce, attn_tp_all_reduce,
dp_gather_partial, dp_gather_partial,
dp_gather_replicate, dp_gather_replicate,
dp_reduce_scatter_tensor, dp_reduce_scatter_tensor,
dp_reduce_scatterv_async,
dp_scatter, dp_scatter,
get_dp_global_num_tokens, get_dp_global_num_tokens,
get_dp_tbo_comm_stream,
get_global_dp_buffer, get_global_dp_buffer,
get_global_dp_buffer_len,
get_local_dp_buffer, get_local_dp_buffer,
get_local_dp_buffer_len,
get_tbo_persistent_buffer,
is_dp_attention_enabled, is_dp_attention_enabled,
is_dp_gatherv_active, is_dp_gatherv_active,
) )
@@ -1108,6 +1114,21 @@ class MQALayer(nn.Module):
return o return o
# ---- TBO op decomposition (prefill two-batch-overlap) ----
def op_attn(self, state):
"""Run the attention forward as a single TBO op.
Consumes the post-input-norm hidden states produced by
``DeepseekV4DecoderLayer.op_mhc_prepare_attn`` and stores the attention
output for ``op_mhc_post_attn_pre_mlp``.
"""
state.hidden_states_after_attn = self.forward(
x=state.pop("hidden_states_after_input_norm"),
positions=state.positions,
forward_batch=state.forward_batch,
x_quant=state.pop("attn_x_quant"),
)
class DeepseekV4DecoderLayer(nn.Module): class DeepseekV4DecoderLayer(nn.Module):
def __init__( def __init__(
@@ -1600,6 +1621,217 @@ class DeepseekV4DecoderLayer(nn.Module):
# cross-layer fusion, and the final layer is completed in DeepseekV4Model. # cross-layer fusion, and the final layer is completed in DeepseekV4Model.
return hidden_states, residual, post, comb return hidden_states, residual, post, comb
# ------------------------------------------------------------------
# TBO op decomposition (prefill two-batch-overlap, EP / mori path)
#
# These mirror the NON-fused branch of ``forward`` (cross-layer mHC
# fusion is disabled under TBO, so every layer is self-contained), split
# into ops so the operations engine can overlap one ubatch's MoE a2a
# dispatch/combine with the other ubatch's attention + expert GEMM.
# The MoE ops themselves (op_gate / op_select_experts / op_dispatch_a/b /
# op_experts / op_combine_a/b / op_shared_experts / op_output) are reused
# as-is from ``self.mlp`` (DeepseekV2MoE) — they decompose ``forward_deepep``.
# ------------------------------------------------------------------
def op_mhc_prepare_attn(
self,
state,
positions: torch.Tensor,
hidden_states: torch.Tensor,
forward_batch: ForwardBatch,
residual: Optional[torch.Tensor] = None,
tbo_subbatch_index: Optional[int] = None,
**kwargs,
):
# Non-fused attention-side mHC pre + input layernorm.
attn_residual = hidden_states
hidden_states, post, comb, norm_fused = self.hc_pre(
hidden_states,
self.hc_attn_fn,
self.hc_attn_scale,
self.hc_attn_base,
norm=self.input_layernorm,
forward_batch=forward_batch,
)
if not norm_fused:
if _use_aiter and _is_gfx95_supported:
x_quant, hidden_states = _fused_rmsnorm_fp8_quant(
hidden_states,
self.input_layernorm.weight,
self.rms_norm_eps,
)
else:
hidden_states = self.input_layernorm(hidden_states)
x_quant = None
else:
x_quant = None
state.attn_residual = attn_residual
state.attn_post = post
state.attn_comb = comb
state.hidden_states_after_input_norm = hidden_states
state.attn_x_quant = x_quant
# mori's op_output slices final_hidden_states[:num_tokens].
if get_moe_a2a_backend().is_mori():
state.num_tokens = attn_residual.shape[0]
state.update(
dict(
forward_batch=forward_batch,
positions=positions,
tbo_subbatch_index=tbo_subbatch_index,
)
)
def op_mhc_post_attn_pre_mlp(self, state):
# Close the attention mHC (hc_post), then open the FFN-side mHC pre +
# post-attention layernorm. Produces the 2D MoE input.
hidden_states = self.hc_post(
state.pop("hidden_states_after_attn"),
state.pop("attn_residual"),
state.pop("attn_post"),
state.pop("attn_comb"),
)
ffn_residual = hidden_states
hidden_states, post, comb, norm_fused = self.hc_pre(
hidden_states,
self.hc_ffn_fn,
self.hc_ffn_scale,
self.hc_ffn_base,
norm=self.post_attention_layernorm,
forward_batch=state.forward_batch,
)
if not norm_fused:
hidden_states = self.post_attention_layernorm(hidden_states)
state.ffn_residual = ffn_residual
state.ffn_post = post
state.ffn_comb = comb
state.hidden_states_mlp_input = hidden_states
def op_mhc_postprocess(self, state):
# Close the FFN mHC (hc_post) and emit the next layer's input dict.
hidden_states = self.hc_post(
state.pop("hidden_states_mlp_output"),
state.pop("ffn_residual"),
state.pop("ffn_post"),
state.pop("ffn_comb"),
)
output = dict(
positions=state.positions,
hidden_states=hidden_states,
# DSV4 non-fused layers carry no residual across layers; the key is
# required by the next layer's op_mhc_prepare_attn (ignored) and by
# _model_forward_tbo_merge_outputs (None -> None).
residual=None,
forward_batch=state.forward_batch,
tbo_subbatch_index=state.tbo_subbatch_index,
)
state.clear(
expect_keys={
"positions",
"forward_batch",
"tbo_subbatch_index",
}
)
return output
# ------------------------------------------------------------------
# Non-EP (DP TP-MoE) TBO ops. Overlap the DP all_gatherv (pre-MoE gather)
# + reduce_scatterv (post-MoE combine) with the OTHER ubatch's attn+MoE
# compute. Used when moe_a2a_backend is "none" (DP-attention, TP-MoE) —
# the path ATOM uses for DSV4 (+~7.7% prefill). Replaces the EP mori
# op_dispatch/op_combine. op_mhc_* and op_attn are reused (local hidden).
# ------------------------------------------------------------------
def op_gather_a(self, state):
# Launch the all_gatherv (local hidden -> global buffer) + the input_ids
# replicate-gather on the shared comm stream; record an event.
fb = state.forward_batch
local = state.pop("hidden_states_mlp_input") # LOCAL [M_local, hidden]
# Shared-expert-local: compute on LOCAL hidden before the gather; added
# back after the combine (same as the non-fused forward). Skipped in the
# global MoE via skip_shared_experts.
do_shared_local = (
_SHARED_EXPERT_LOCAL
and getattr(self.mlp, "shared_experts", None) is not None
and getattr(self.mlp, "_shared_expert_tp1", False)
)
state.do_shared_local = do_shared_local
state.shared_local = (
self.mlp._forward_shared_experts(local)
if (do_shared_local and local.shape[0] > 0)
else None
)
# Persistent grow-only scratch (keyed per ubatch) instead of a fresh
# torch.empty each layer -> stops the allocator's `reserved` from
# ballooning at large prefill chunks. input_ids_global is gathered ONCE
# per ubatch in _forward_layers_tbo (cached on fb), not here.
sub = state.tbo_subbatch_index
global_rows = get_global_dp_buffer_len()
global_hidden = get_tbo_persistent_buffer(
("gh", sub), global_rows, local.shape[1], local.dtype, local.device
)
comm = get_dp_tbo_comm_stream()
compute = torch.cuda.current_stream()
with torch.cuda.stream(comm):
comm.wait_stream(compute)
dp_gather_partial(global_hidden, local, fb)
state.gather_event = _tbo_event(("gather", sub))
state.gather_event.record(comm)
state.gather_keepalive = local
state.global_hidden = global_hidden
def op_gather_b(self, state):
torch.cuda.current_stream().wait_event(state.pop("gather_event"))
# Compute now ordered after the gather -> the gather input is safe to
# release (freed on the compute stream, no record_stream deferral).
state.pop("gather_keepalive")
def op_moe(self, state):
# MoE (gate/topk/experts) on the GLOBAL gathered buffer. use_reduce_scatter
# skips the MoE-internal all_reduce (we reduce_scatterv in op_combine).
fb = state.forward_batch
global_hidden = state.pop("global_hidden")
global_ids = fb._tbo_global_input_ids
state.global_expert_out = self.mlp(
global_hidden,
fb,
use_reduce_scatter=True,
input_ids=global_ids,
input_ids_global=global_ids,
skip_shared_experts=state.do_shared_local,
)
def op_combine_a(self, state):
# Launch reduce_scatterv (global partial expert sums -> per-rank local) on
# the comm stream; record an event. Symmetric inverse of the all_gatherv.
global_out = state.pop("global_expert_out")
local_out = get_tbo_persistent_buffer(
("lo", state.tbo_subbatch_index),
get_local_dp_buffer_len(),
global_out.shape[1],
global_out.dtype,
global_out.device,
)
state.combine_event = dp_reduce_scatterv_async(
local_out,
global_out,
get_dp_global_num_tokens(),
event_key=("combine", state.tbo_subbatch_index),
)
state.local_out = local_out
# Keep the (variable-size) MoE output alive until op_combine_b waits on
# the combine event (replaces record_stream; avoids reserved churn).
state.combine_keepalive = global_out
def op_combine_b(self, state):
torch.cuda.current_stream().wait_event(state.pop("combine_event"))
state.pop("combine_keepalive")
hidden = state.pop("local_out")
shared_local = state.pop("shared_local")
state.pop("do_shared_local")
if shared_local is not None:
n = hidden.shape[0]
hidden = hidden + shared_local[:n]
state.hidden_states_mlp_output = hidden
class DeepseekV4Model(nn.Module): class DeepseekV4Model(nn.Module):
fall_back_to_pt_during_load = False fall_back_to_pt_during_load = False
@@ -1695,6 +1927,116 @@ class DeepseekV4Model(nn.Module):
y = torch.sum(pre.unsqueeze(-1) * x.view(shape), dim=1) y = torch.sum(pre.unsqueeze(-1) * x.view(shape), dim=1)
return y.to(dtype) return y.to(dtype)
def _can_run_tbo(self, forward_batch: ForwardBatch) -> bool:
"""DSV4 prefill-only two-batch-overlap gate.
TBO batch prep (tbo_split_seq_index / tbo_children) is populated
model-agnostically when --enable-two-batch-overlap is set and the
DP-attention preparer allows it (mori `normal` mode permits prefill
TBO). We additionally restrict to: prefill (EXTEND), single PP, and the
non-CP path, which is the only case the DSV4 op strategy implements.
"""
from sglang.srt.layers.moe import is_tbo_enabled
return (
is_tbo_enabled()
and forward_batch.can_run_tbo
and forward_batch.tbo_children is not None
and forward_batch.global_forward_mode is not None
and forward_batch.global_forward_mode.is_extend()
and not dsa_use_prefill_cp(forward_batch)
and self.pp_group.world_size == 1
)
def _forward_layers_tbo(
self,
positions: torch.Tensor,
hidden_states: torch.Tensor,
forward_batch: ForwardBatch,
) -> torch.Tensor:
from sglang.srt.batch_overlap.operations import execute_overlapped_operations
from sglang.srt.batch_overlap.operations_strategy import OperationsStrategy
from sglang.srt.batch_overlap.two_batch_overlap import (
_model_forward_filter_inputs,
_model_forward_tbo_merge_outputs,
)
layers = [self.layers[i] for i in range(self.start_layer, self.end_layer)]
operations_strategy = OperationsStrategy.init_new_tbo(
layers, forward_batch.global_forward_mode
)
# Split the per-rank batch into the 2 ubatches (token-range slice + pad
# to tbo_padded_len). residual is unused by the DSV4 non-fused layer ops.
inputs_arr = [
_model_forward_filter_inputs(
hidden_states=hidden_states,
residual=None,
positions=positions,
output_forward_batch=child,
tbo_subbatch_index=idx,
)
for idx, child in enumerate(forward_batch.tbo_children)
]
# Non-EP DP TP-MoE: the per-ubatch DP gather/combine (op_gather/op_combine)
# needs each ubatch's per-rank token counts, but tbo_padded_len is computed
# per-rank locally (not synced). All-gather both ubatches' padded lengths
# once across DP ranks, then populate each child's global_num_tokens +
# global_dp_buffer_len so the gatherv/reduce_scatterv buffers size correctly.
if get_moe_a2a_backend().is_none() and get_parallel().attn_dp_size > 1:
tp_group = get_tp_group()
world = tp_group.world_size
children = forward_batch.tbo_children
local_lens = torch.tensor(
[int(c.tbo_padded_len) for c in children],
dtype=torch.int64,
device=hidden_states.device,
)
gathered = torch.empty(
(world, local_lens.shape[0]),
dtype=torch.int64,
device=hidden_states.device,
)
tp_group.all_gather_into_tensor(gathered, local_lens)
gathered_cpu = gathered.tolist()
rank = tp_group.rank_in_group
for idx, child in enumerate(children):
sizes = [gathered_cpu[r][idx] for r in range(world)]
child.global_num_tokens_cpu = sizes
child.global_num_tokens_gpu = gathered[:, idx].contiguous()
child.global_dp_buffer_len = sum(sizes)
# Gather the ubatch's input_ids -> global ONCE here (cached on the
# child) instead of per-layer in op_gather_a. The hash MoE reads
# the SAME global ids every layer, so 61x2 per-layer all_gatherv of
# VARYING size (-> RCCL registers a new internal buffer per size ->
# HSA_STATUS_ERROR_OUT_OF_RESOURCES) collapses to 1 per ubatch.
local_ids = child.input_ids
rows = sizes[rank]
if local_ids.shape[0] < rows:
padded_ids = local_ids.new_zeros((rows,))
padded_ids[: local_ids.shape[0]] = local_ids
elif local_ids.shape[0] > rows:
padded_ids = local_ids[:rows]
else:
padded_ids = local_ids
gids = torch.empty(
(sum(sizes),), dtype=local_ids.dtype, device=local_ids.device
)
tp_group.all_gatherv(padded_ids, sizes=sizes, output=gids)
child._tbo_global_input_ids = gids
outputs_arr = execute_overlapped_operations(
inputs_arr=inputs_arr,
operations_arr=[operations_strategy.operations] * 2,
delta_stages=[0, operations_strategy.tbo_delta_stages],
)
hidden_states, _ = _model_forward_tbo_merge_outputs(
outputs_arr[0], outputs_arr[1], hidden_states.shape[0]
)
return hidden_states
def forward( def forward(
self, self,
input_ids: torch.Tensor, input_ids: torch.Tensor,
@@ -1740,32 +2082,41 @@ class DeepseekV4Model(nn.Module):
if hasattr(forward_batch, _attr): if hasattr(forward_batch, _attr):
delattr(forward_batch, _attr) delattr(forward_batch, _attr)
use_fused = self.use_fused_mhc_post_pre if self._can_run_tbo(forward_batch):
prev_residual, prev_post, prev_comb = None, None, None # Two-batch-overlap prefill (EP / mori). Cross-layer mHC fusion is
last_layer = None # disabled here (each layer self-contained), so no trailing hc_post.
for i in range(self.start_layer, self.end_layer): hidden_states = self._forward_layers_tbo(
layer = self.layers[i] positions=positions,
last_layer = layer hidden_states=hidden_states,
ctx = ( forward_batch=forward_batch,
nullcontext()
if check_cuda_graph_backend(Phase.PREFILL, Backend.TC_PIECEWISE)
else get_global_expert_distribution_recorder().with_current_layer(i)
) )
with ctx: else:
hidden_states, prev_residual, prev_post, prev_comb = layer( use_fused = self.use_fused_mhc_post_pre
positions=positions, prev_residual, prev_post, prev_comb = None, None, None
hidden_states=hidden_states, last_layer = None
forward_batch=forward_batch, for i in range(self.start_layer, self.end_layer):
input_ids=input_ids, layer = self.layers[i]
input_ids_global=input_ids_global, last_layer = layer
prev_residual=prev_residual, ctx = (
prev_post=prev_post, nullcontext()
prev_comb=prev_comb, if check_cuda_graph_backend(Phase.PREFILL, Backend.TC_PIECEWISE)
else get_global_expert_distribution_recorder().with_current_layer(i)
)
with ctx:
hidden_states, prev_residual, prev_post, prev_comb = layer(
positions=positions,
hidden_states=hidden_states,
forward_batch=forward_batch,
input_ids=input_ids,
input_ids_global=input_ids_global,
prev_residual=prev_residual,
prev_post=prev_post,
prev_comb=prev_comb,
)
if use_fused and last_layer is not None:
hidden_states = last_layer.hc_post(
hidden_states, prev_residual, prev_post, prev_comb
) )
if use_fused and last_layer is not None:
hidden_states = last_layer.hc_post(
hidden_states, prev_residual, prev_post, prev_comb
)
# CP all-gather only on the last PP rank; PP IPC carries CP-split tensors. # CP all-gather only on the last PP rank; PP IPC carries CP-split tensors.
if self.pp_group.is_last_rank and dsa_use_prefill_cp(forward_batch): if self.pp_group.is_last_rank and dsa_use_prefill_cp(forward_batch):
+18 -5
View File
@@ -6434,6 +6434,22 @@ class ServerArgs:
self._mamba_cache_chunk_size = max(chunk_size, self.page_size) self._mamba_cache_chunk_size = max(chunk_size, self.page_size)
return self._mamba_cache_chunk_size return self._mamba_cache_chunk_size
def _check_two_batch_overlap(self):
# With no EP a2a backend, two-batch-overlap is only valid on the non-EP
# DP TP-MoE path (overlapping the DP all_gatherv / reduce_scatterv with
# the other ubatch's compute), which requires DP attention. Enabling it
# there needs no extra opt-in env flag.
if (
self.enable_two_batch_overlap
and self.moe_a2a_backend == "none"
and not self.enable_dp_attention
):
raise ValueError(
"When enabling two batch overlap without an EP a2a backend "
"(moe_a2a_backend='none'), --enable-dp-attention is required "
"(DeepSeek-V4 non-EP DP TBO path)."
)
def check_server_args(self): def check_server_args(self):
# Check parallel size constraints # Check parallel size constraints
assert ( assert (
@@ -6581,11 +6597,8 @@ class ServerArgs:
"--export-metrics-to-file-dir is required when --export-metrics-to-file is enabled" "--export-metrics-to-file-dir is required when --export-metrics-to-file is enabled"
) )
# Check two batch overlap # Check two batch overlap backend requirement.
if self.enable_two_batch_overlap and self.moe_a2a_backend == "none": self._check_two_batch_overlap()
raise ValueError(
"When enabling two batch overlap, moe_a2a_backend cannot be 'none'."
)
# Check communications compression # Check communications compression
if self.enable_quant_communications and self.tp_size == 1: if self.enable_quant_communications and self.tp_size == 1:
@@ -0,0 +1,163 @@
"""MI35x DeepSeek-V4-Flash FP8 + non-EP DP two-batch-overlap (TBO) test (8-GPU)
End-to-end accuracy test for DeepSeek-V4-Flash (285B) FP8 with the non-EP DP
two-batch-overlap path on MI35x ROCm 7.2.
TBO here is the DP-attention TP-MoE variant (moe_a2a_backend='none'): it overlaps
one micro-batch's DP all_gatherv (pre-MoE gather) + reduce_scatterv (post-MoE
combine) with the other micro-batch's attention + expert compute (prefill only).
Enabled purely via `--enable-dp-attention` + `--enable-two-batch-overlap` (no opt-in
env). This test guards that TBO does not regress GSM8K accuracy and that the DP TBO
server launches + runs to completion (exercises op_gather/op_moe/op_combine and the
event+ref combine-buffer lifetime that fixed the reserved-memory OOM at mem0.9).
Unlike the CPU-only server-args guard unit test (TestTwoBatchOverlapBackend), this
actually runs the TBO forward on the real model — which only DeepSeek-V4 implements,
so it needs the real 8-GPU model (a dummy model path would not exercise TBO).
Registry: nightly-amd-8-gpu-mi35x-deepseek-v4-flash suite
"""
import os
import unittest
from types import SimpleNamespace
from sglang.srt.utils import kill_process_tree
from sglang.test.ci.ci_register import register_amd_ci
from sglang.test.few_shot_gsm8k import run_eval as run_eval_few_shot_gsm8k
from sglang.test.test_utils import (
DEFAULT_URL_FOR_TEST,
CustomTestCase,
is_in_ci,
popen_launch_server,
write_github_step_summary,
)
register_amd_ci(
est_time=7200, suite="nightly-amd-8-gpu-mi35x-deepseek-v4-flash", nightly=True
)
DEEPSEEK_V4_FLASH_FP8_MODEL_PATH = os.environ.get(
"DEEPSEEK_V4_FP8_MODEL_PATH", "sgl-project/DeepSeek-V4-Flash-FP8"
)
SERVER_LAUNCH_TIMEOUT = 3600
FLASHMLA_BACKEND = os.environ.get("SGLANG_HACK_FLASHMLA_BACKEND", "unified_kv_triton")
# DSV4 fused-kernel optimal set (mirrors the validated dp-tbo launch config).
# The DP + TBO forward path is sensitive to these; the non-TBO tp8 test can use a
# leaner set, but DP TBO needs the full DSV4 opt env or the MoE/attn kernels hit
# shape mismatches at warmup.
COMMON_ENV_VARS = {
"SGLANG_DEFAULT_THINKING": "1",
"SGLANG_DSV4_REASONING_EFFORT": "max",
"SGLANG_OPT_DEEPGEMM_HC_PRENORM": "false",
"SGLANG_USE_AITER": "1",
"SGLANG_USE_ROCM700A": "0",
"SGLANG_OPT_USE_FUSED_COMPRESS": "true",
"SGLANG_HACK_FLASHMLA_BACKEND": FLASHMLA_BACKEND,
"SGLANG_OPT_FP8_WO_A_GEMM": "false",
"SGLANG_OPT_USE_JIT_INDEXER_METADATA": "false",
"SGLANG_OPT_USE_TOPK_V2": "false",
"SGLANG_OPT_USE_AITER_INDEXER": "true",
"SGLANG_OPT_USE_TILELANG_INDEXER": "false",
"SGLANG_OPT_USE_TILELANG_MHC_PRE": "false",
"SGLANG_OPT_USE_TILELANG_MHC_POST": "false",
"SGLANG_FP8_PAGED_MQA_LOGITS_TORCH": "1",
"SGLANG_OPT_USE_FUSED_COMPRESS_TRITON": "true",
"SGLANG_OPT_USE_MULTI_STREAM_OVERLAP": "false",
"SGLANG_ROCM_USE_MULTI_STREAM": "false",
"AITER_BF16_FP8_MOE_BOUND": "0",
"SGLANG_EAGER_INPUT_NO_COPY": "true",
# DP TP-MoE collective path that non-EP DP TBO overlaps.
"SGLANG_DP_USE_GATHERV": "1",
"SGLANG_DP_USE_REDUCE_SCATTER": "1",
"SGLANG_SHARED_EXPERT_TP1": "1",
"SGLANG_DP_SHARED_EXPERT_LOCAL": "1",
# ROCm HSA-resource stability for TBO at high concurrency.
"GPU_MAX_HW_QUEUES": "5",
# FP8 variant
"SGLANG_DSV4_FP4_EXPERTS": "false",
}
class TestDeepseekV4FlashFp8Tbo(CustomTestCase):
@classmethod
def setUpClass(cls):
cls.model = DEEPSEEK_V4_FLASH_FP8_MODEL_PATH
cls.base_url = DEFAULT_URL_FOR_TEST
env = os.environ.copy()
env.update(COMMON_ENV_VARS)
other_args = [
"--trust-remote-code",
"--tp",
"8",
# DP attention + TBO: non-EP DP TP-MoE two-batch-overlap. DP TBO is
# selected because moe_a2a_backend stays 'none'; no opt-in env needed.
"--dp",
"8",
"--enable-dp-attention",
"--enable-prefill-delayer",
"--enable-two-batch-overlap",
"--disable-radix-cache",
"--attention-backend",
"dsv4",
"--kv-cache-dtype",
"fp8_e4m3",
"--max-running-requests",
"512",
"--cuda-graph-max-bs",
"512",
"--page-size",
"256",
"--mem-fraction-static",
"0.90",
"--swa-full-tokens-ratio",
"0.15",
# global chunk; DP-attention divides by dp_size=8 -> 8192/rank.
"--chunked-prefill-size",
"65536",
"--disable-shared-experts-fusion",
"--tool-call-parser",
"deepseekv4",
"--reasoning-parser",
"deepseek-v4",
]
cls.process = popen_launch_server(
cls.model,
cls.base_url,
timeout=SERVER_LAUNCH_TIMEOUT,
other_args=other_args,
env=env,
)
@classmethod
def tearDownClass(cls):
kill_process_tree(cls.process.pid)
def test_gsm8k_tbo(self):
args = SimpleNamespace(
num_shots=8,
data_path=None,
num_questions=1319,
parallel=1319,
max_new_tokens=512,
host="http://127.0.0.1",
port=int(self.base_url.split(":")[-1]),
)
metrics = run_eval_few_shot_gsm8k(args)
print(f"{metrics=}")
if is_in_ci():
write_github_step_summary(
f"### test_gsm8k_tbo (deepseek-v4-flash-fp8 DP+TBO, {FLASHMLA_BACKEND})\n"
f'{metrics["accuracy"]=:.3f}\n'
)
# TBO must not regress accuracy vs the non-TBO baseline (>0.91).
self.assertGreater(metrics["accuracy"], 0.91)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,151 @@
"""MI35x DeepSeek-V4-Pro FP4 + non-EP DP two-batch-overlap (TBO) test (8-GPU)
End-to-end accuracy test for DeepSeek-V4-Pro (1.6T) FP4 with the non-EP DP
two-batch-overlap path on MI35x ROCm 7.2.
TBO here is the DP-attention TP-MoE variant (moe_a2a_backend='none'): it overlaps
one micro-batch's DP all_gatherv (pre-MoE gather) + reduce_scatterv (post-MoE
combine) with the other micro-batch's attention + expert compute (prefill only).
Enabled purely via `--enable-dp-attention` + `--enable-two-batch-overlap` (no opt-in
env). This test guards that TBO does not regress GSM8K accuracy and that the DP TBO
server launches + runs (exercises op_gather/op_moe/op_combine and the event+ref
combine-buffer lifetime that fixed the reserved-memory OOM at mem0.9).
Unlike the CPU-only server-args guard unit test (TestTwoBatchOverlapBackend), this
runs the TBO forward on the real model — which only DeepSeek-V4 implements — so it
needs the real 8-GPU model (a dummy model path would not exercise TBO). Uses the FP4
Pro model (fp4 routed experts); do NOT force SGLANG_DSV4_FP4_EXPERTS=false here or
the expert weights are read at the wrong (fp8) shape.
Registry: nightly-amd-8-gpu-mi35x-deepseek-v4-pro suite
"""
import os
import unittest
from types import SimpleNamespace
from sglang.srt.utils import kill_process_tree
from sglang.test.ci.ci_register import register_amd_ci
from sglang.test.few_shot_gsm8k import run_eval as run_eval_few_shot_gsm8k
from sglang.test.test_utils import (
DEFAULT_URL_FOR_TEST,
CustomTestCase,
is_in_ci,
popen_launch_server,
write_github_step_summary,
)
register_amd_ci(
est_time=14400, suite="nightly-amd-8-gpu-mi35x-deepseek-v4-pro", nightly=True
)
DEEPSEEK_V4_PRO_FP4_MODEL_PATH = os.environ.get(
"DEEPSEEK_V4_PRO_MODEL_PATH_FP4", "deepseek-ai/DeepSeek-V4-Pro"
)
# Pro is 1.6T; weight load + warmup is much longer than Flash 285B.
SERVER_LAUNCH_TIMEOUT = 5400
FLASHMLA_BACKEND = os.environ.get("SGLANG_HACK_FLASHMLA_BACKEND", "unified_kv_triton")
COMMON_ENV_VARS = {
"SGLANG_DEFAULT_THINKING": "1",
"SGLANG_DSV4_REASONING_EFFORT": "max",
"SGLANG_USE_ROCM700A": "0",
"SGLANG_HACK_FLASHMLA_BACKEND": FLASHMLA_BACKEND,
"AITER_BF16_FP8_MOE_BOUND": "0",
# DP TP-MoE collective path that non-EP DP TBO overlaps.
"SGLANG_DP_USE_GATHERV": "1",
"SGLANG_DP_USE_REDUCE_SCATTER": "1",
"SGLANG_SHARED_EXPERT_TP1": "1",
"SGLANG_DP_SHARED_EXPERT_LOCAL": "1",
# ROCm HSA-resource stability for TBO at high concurrency.
"GPU_MAX_HW_QUEUES": "5",
}
# FP4 variant
FP4_ENV_VARS = {
"SGLANG_DSV4_FP4_EXPERTS": "true",
}
class TestDeepseekV4ProFp4Tbo(CustomTestCase):
@classmethod
def setUpClass(cls):
cls.model = DEEPSEEK_V4_PRO_FP4_MODEL_PATH
cls.base_url = DEFAULT_URL_FOR_TEST
env = os.environ.copy()
env.update(COMMON_ENV_VARS)
env.update(FP4_ENV_VARS)
other_args = [
"--trust-remote-code",
"--tp",
"8",
# DP attention + TBO: non-EP DP TP-MoE two-batch-overlap. DP TBO is
# selected because moe_a2a_backend stays 'none'; no opt-in env needed.
"--dp",
"8",
"--enable-dp-attention",
"--enable-prefill-delayer",
"--enable-two-batch-overlap",
"--disable-radix-cache",
"--attention-backend",
"dsv4",
"--kv-cache-dtype",
"fp8_e4m3",
"--max-running-requests",
"512",
"--cuda-graph-max-bs",
"512",
"--page-size",
"256",
"--mem-fraction-static",
"0.90",
"--swa-full-tokens-ratio",
"0.15",
# global chunk; DP-attention divides by dp_size=8 -> 8192/rank.
"--chunked-prefill-size",
"65536",
"--disable-shared-experts-fusion",
"--tool-call-parser",
"deepseekv4",
"--reasoning-parser",
"deepseek-v4",
]
cls.process = popen_launch_server(
cls.model,
cls.base_url,
timeout=SERVER_LAUNCH_TIMEOUT,
other_args=other_args,
env=env,
)
@classmethod
def tearDownClass(cls):
kill_process_tree(cls.process.pid)
def test_gsm8k_tbo(self):
args = SimpleNamespace(
num_shots=8,
data_path=None,
num_questions=1319,
parallel=1319,
max_new_tokens=512,
host="http://127.0.0.1",
port=int(self.base_url.split(":")[-1]),
)
metrics = run_eval_few_shot_gsm8k(args)
print(f"{metrics=}")
if is_in_ci():
write_github_step_summary(
f"### test_gsm8k_tbo (deepseek-v4-pro-fp4 DP+TBO, {FLASHMLA_BACKEND})\n"
f'{metrics["accuracy"]=:.3f}\n'
)
# TBO must not regress accuracy vs the non-TBO baseline (>0.91).
self.assertGreater(metrics["accuracy"], 0.91)
if __name__ == "__main__":
unittest.main()
@@ -1364,5 +1364,48 @@ class TestSamplingBackendTokenOracleEnvGate(CustomTestCase):
self.assertEqual(parsed.sampling_backend, "token_oracle") self.assertEqual(parsed.sampling_backend, "token_oracle")
class TestTwoBatchOverlapBackend(CustomTestCase):
"""Non-EP DP two-batch-overlap backend requirement.
With no EP a2a backend (moe_a2a_backend='none'), --enable-two-batch-overlap
is only valid on the DeepSeek-V4 non-EP DP TP-MoE path (overlapping the DP
all_gatherv / reduce_scatterv with the other ubatch's compute), which
requires --enable-dp-attention. This replaced the removed opt-in
SGLANG_ENABLE_DP_TBO env: enabling DP TBO now needs no extra flag.
dummy-model short-circuits __post_init__, so the guard handler is invoked
directly (same pattern as TestDeepEPWaterfillArgs)."""
def _args(self, **overrides):
args = ServerArgs(model_path="dummy")
args.enable_two_batch_overlap = True
args.moe_a2a_backend = "none"
args.enable_dp_attention = False
for key, value in overrides.items():
setattr(args, key, value)
return args
def test_no_a2a_without_dp_attention_raises(self):
args = self._args(enable_dp_attention=False)
with self.assertRaisesRegex(ValueError, "enable-dp-attention"):
args._check_two_batch_overlap()
def test_no_a2a_with_dp_attention_ok(self):
# DP TBO path is valid: --enable-dp-attention + --enable-two-batch-overlap
# with a2a backend 'none' must NOT raise (no SGLANG_ENABLE_DP_TBO needed).
args = self._args(enable_dp_attention=True)
args._check_two_batch_overlap()
def test_ep_a2a_backend_ok_without_dp_attention(self):
# EP a2a path (e.g. deepep) overlaps dispatch/combine; the guard does not
# require dp-attention there.
args = self._args(moe_a2a_backend="deepep", enable_dp_attention=False)
args._check_two_batch_overlap()
def test_tbo_disabled_is_noop(self):
args = self._args(enable_two_batch_overlap=False, enable_dp_attention=False)
args._check_two_batch_overlap()
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()