Fix disagg PP MTP for GLM-5.2 (#39378)

Co-authored-by: Julien Lin <jullin@nvidia.com>
Co-authored-by: YAMY1234 <74099316+YAMY1234@users.noreply.github.com>
Co-authored-by: Yangmin Li <yangminl@nvidia.com>
This commit is contained in:
Po-Han Huang (NVIDIA)
2026-09-19 13:55:07 -07:00
committed by GitHub
co-authored by Julien Lin YAMY1234 Yangmin Li
parent 8139a1740e
commit 3a64faa1f2
12 changed files with 627 additions and 105 deletions
+70 -56
View File
@@ -10,6 +10,7 @@ from typing import Any, Dict, List, Optional
from sglang.srt.arg_groups.overrides import (
_hisparse_validation,
model_config_of,
resolved_view,
resolving_view,
run_post_process_pass,
@@ -24,6 +25,20 @@ from sglang.srt.utils.runai_utils import is_runai_obj_uri
logger = logging.getLogger(__name__)
_PP_EAGLE_SUPPORTED_ARCHITECTURES = frozenset(
{
"DeepseekV2ForCausalLM",
"DeepseekV3ForCausalLM",
"DeepseekV32ForCausalLM",
"GlmMoeDsaForCausalLM",
# Qwen3.5 (dense / MoE, text and multimodal); folded in from #39602.
"Qwen3_5ForCausalLM",
"Qwen3_5MoeForCausalLM",
"Qwen3_5ForConditionalGeneration",
"Qwen3_5MoeForConditionalGeneration",
}
)
def validate_response_store(server_args: Any) -> None:
cfg = resolving_view(server_args)
@@ -35,6 +50,58 @@ def validate_response_store(server_args: Any) -> None:
)
def check_pipeline_parallel_compat(
cfg: Any, *, model_architecture: Optional[str] = None
) -> None:
"""Validate features used with pipeline parallelism."""
assert cfg.disable_overlap_schedule, (
"Pipeline parallelism is not compatible with overlap schedule"
)
if cfg.speculative_algorithm is not None:
assert (
cfg.speculative_algorithm.upper() == "EAGLE"
and not cfg.enable_multi_layer_eagle
), (
"Pipeline parallelism currently only supports EAGLE "
"(non-multi-layer) speculative decoding"
)
if envs.SGLANG_ENABLE_PP_SPEC.get():
# The aggregate relay carries an EAGLE-shaped tree and only
# EAGLEWorkerV2 tail-drafts. PD prefill relays topk_p /
# topk_index / hidden states through RelayPayload; the gated
# flow replaces that relay with its own and does not carry
# those fields.
assert cfg.disaggregation_mode == "null", (
"SGLANG_ENABLE_PP_SPEC is not compatible with --disaggregation-mode"
)
# The PP relay slices spec results with the configured
# num_draft_tokens; adaptive spec changes it at runtime.
assert not cfg.speculative_adaptive, (
"SGLANG_ENABLE_PP_SPEC is not compatible with --speculative-adaptive"
)
# Every stage rebuilds the same verify input from the relayed
# per-request state, so all stages must see the same batch.
# DP attention partitions it per DP rank.
assert not cfg.enable_dp_attention, (
"SGLANG_ENABLE_PP_SPEC is not compatible with --enable-dp-attention"
)
else:
assert cfg.disaggregation_mode == "prefill", (
"PP + speculative decoding (MTP) is only supported on prefill nodes "
"(disaggregation-mode=prefill)"
)
assert model_architecture in _PP_EAGLE_SUPPORTED_ARCHITECTURES, (
"PP + speculative decoding is only supported for DeepSeek/GLM/Qwen3.5 "
"models whose last pipeline stage supplies the EAGLE draft "
f"embedding; got architecture={model_architecture}"
)
assert cfg.min_free_slots_delay is None, (
"--min-free-slots-delay is not supported with pipeline "
"parallelism: allocatable slots per microbatch are bounded by "
"pp-max-micro-batch-size, so the threshold may never be reached"
)
def check_server_args(server_args: Any):
from sglang.srt.arg_groups.lora_hook import check_lora_server_args
@@ -60,63 +127,10 @@ def check_server_args(server_args: Any):
)
if cfg.pp_size > 1:
if get_platform().is_npu:
# NPU: allow PP + EAGLE speculative decoding
assert cfg.disable_overlap_schedule, (
"Pipeline parallelism is not compatible with overlap schedule"
)
model_architecture = None
if cfg.speculative_algorithm is not None:
assert (
cfg.speculative_algorithm.upper() == "EAGLE"
and not cfg.enable_multi_layer_eagle
), (
"Pipeline parallelism currently only supports EAGLE "
"(non-multi-layer) speculative decoding"
)
assert cfg.disaggregation_mode == "prefill", (
"NPU PP + speculative decoding (MTP) is only supported "
"on prefill nodes (disaggregation-mode=prefill)"
)
elif envs.SGLANG_ENABLE_PP_SPEC.get():
assert cfg.disable_overlap_schedule, (
"SGLANG_ENABLE_PP_SPEC requires --disable-overlap-schedule"
)
# The relay carries an EAGLE-shaped tree and only EAGLEWorkerV2
# tail-drafts; every other algorithm would be mis-rebuilt.
assert (
cfg.speculative_algorithm == "EAGLE"
and not cfg.enable_multi_layer_eagle
), (
"SGLANG_ENABLE_PP_SPEC supports single-layer EAGLE/MTP only, "
f"got {cfg.speculative_algorithm}"
)
# PD prefill relays topk_p / topk_index / hidden states through
# RelayPayload; the gated flow replaces that relay with its own
# and does not carry those fields.
assert cfg.disaggregation_mode == "null", (
"SGLANG_ENABLE_PP_SPEC is not compatible with --disaggregation-mode"
)
# The PP relay slices spec results with the configured
# num_draft_tokens; adaptive spec changes it at runtime.
assert not cfg.speculative_adaptive, (
"SGLANG_ENABLE_PP_SPEC is not compatible with --speculative-adaptive"
)
# Every stage rebuilds the same verify input from the relayed
# per-request state, so all stages must see the same batch.
# DP attention partitions it per DP rank.
assert not cfg.enable_dp_attention, (
"SGLANG_ENABLE_PP_SPEC is not compatible with --enable-dp-attention"
)
else:
# Non-NPU: PP + speculative decoding is not supported
assert cfg.disable_overlap_schedule and cfg.speculative_algorithm is None, (
"Pipeline parallelism is not compatible with overlap schedule, speculative decoding"
)
assert cfg.min_free_slots_delay is None, (
"--min-free-slots-delay is not supported with pipeline "
"parallelism: allocatable slots per microbatch are bounded by "
"pp-max-micro-batch-size, so the threshold may never be reached"
)
model_architecture = model_config_of(server_args).hf_config.architectures[0]
check_pipeline_parallel_compat(cfg, model_architecture=model_architecture)
assert not (cfg.dp_size > 1 and cfg.nnodes != 1 and not cfg.enable_dp_attention), (
"multi-node data parallel is not supported unless dp attention!"
@@ -1265,12 +1265,18 @@ class CommonKVManager(BaseKVManager):
)
# Regular MLA PP slicing
start_layer = self.kv_args.prefill_start_layer
end_layer = start_layer + len(src_kv_ptrs)
# Decode pp size should be equal to prefill pp size or 1
start_layer, end_layer = self._mla_kv_entry_span_with_pp(len(src_kv_ptrs))
sliced_dst_kv_ptrs = dst_kv_ptrs[start_layer:end_layer]
return src_kv_ptrs, sliced_dst_kv_ptrs, len(src_kv_ptrs)
def _mla_kv_entry_span_with_pp(self, n_src: int) -> Tuple[int, int]:
# A plain MLA pool registers one region per layer ascending, addressed as
# layer_id - start_layer, so this stage occupies [start, start + n_src) of a
# peer that registered the whole model. Pointer view: get_mla_kv_ptrs_with_pp.
start_layer = self.kv_args.prefill_start_layer
return start_layer, start_layer + n_src
def _mla_slice_ptrs_for_pp(
self,
src_kv_ptrs: List[int],
+55 -7
View File
@@ -1026,6 +1026,59 @@ class NixlKVManager(StagingManagerMixin, CommonKVManager):
)
peer_info.kv_xfer_segments = prepared_segments
def _build_transfer_dst_indices(
self, *, peer_info: KVArgsRegisterInfo, n_src: int, n_dst: int
) -> List[int]:
"""Map source entries to destination entries for this transfer.
Heterogeneous PP over a plain MLA pool uses the source stage's layer span.
All other layouts use explicit layer IDs, or positional pairing for non-PP.
"""
use_pp_mla_offsets = (
self.pp_size > 1
and n_src != n_dst
and not self.kv_args.kv_layer_ids
and not peer_info.dst_kv_layer_ids
and self.is_mla_backend
and not self.is_hybrid_mla_backend
and not self.kv_args.mla_compression_ratios
)
if not use_pp_mla_offsets:
pairs = build_transfer_entry_pairs(
self.kv_args.kv_layer_ids,
peer_info.dst_kv_layer_ids,
n_src,
n_dst,
allow_positional_fallback=self.pp_size == 1,
)
return [j for _, j in pairs]
start, end = self._mla_kv_entry_span_with_pp(n_src)
# Bootstrap admits a peer running our pp or 1, so a peer that does not cover the
# span is a matched-pp stage above 0, whose entries start at its own index 0.
if end > n_dst:
pairs = build_transfer_entry_pairs(
self.kv_args.kv_layer_ids,
peer_info.dst_kv_layer_ids,
n_src,
n_dst,
allow_positional_fallback=False,
)
return [j for _, j in pairs]
indices = list(range(start, end))
src_item_lens = list(self.kv_args.kv_item_lens)
dst_item_lens = [peer_info.dst_kv_item_lens[j] for j in indices]
if src_item_lens != dst_item_lens:
# Disagreeing cell sizes mean the peers did not build the same KV geometry;
# writing anyway would silently corrupt the peer's pool.
raise RuntimeError(
"PP-heterogeneous MLA transfer: decode KV cell geometry differs from "
f"prefill over layers [{start}, {end}): prefill item_lens="
f"{src_item_lens}, decode item_lens={dst_item_lens}"
)
return indices
def _prepare_payload_xfer(self, peer_info: KVArgsRegisterInfo):
# If prefill does not run speculative decoding (the usual case),
# decode with speculative decoding will have more kv items.
@@ -1110,14 +1163,9 @@ class NixlKVManager(StagingManagerMixin, CommonKVManager):
else self._num_slots_src
)
pairs = build_transfer_entry_pairs(
self.kv_args.kv_layer_ids,
peer_info.dst_kv_layer_ids,
n_src,
n_dst,
allow_positional_fallback=self.pp_size == 1,
dst_indices = self._build_transfer_dst_indices(
peer_info=peer_info, n_src=n_src, n_dst=n_dst
)
dst_indices = [j for _, j in pairs]
dst_kv_ptrs = [peer_info.dst_kv_ptrs[j] for j in dst_indices]
dst_kv_item_lens = [peer_info.dst_kv_item_lens[j] for j in dst_indices]
dst_kv_data_lens = [
@@ -890,6 +890,11 @@ class SchedulerPPMixin:
tensor_dict["draft_topk_p"] = draft_input.topk_p.contiguous()
tensor_dict["draft_topk_index"] = draft_input.topk_index.contiguous()
tensor_dict["draft_hidden_states"] = draft_input.hidden_states.contiguous()
# Preserve the DSA IndexShare seed when rebuilding spec_info on each rank.
if draft_input.dsa_topk_indices is not None:
tensor_dict["draft_dsa_topk_indices"] = (
draft_input.dsa_topk_indices.contiguous()
)
has_sampling_mask_output = (
result.logits_output is not None
@@ -1130,6 +1135,7 @@ class SchedulerPPMixin:
bonus_tokens=next_token_ids,
num_tokens_per_req=1,
num_tokens_for_logprob_per_req=1,
dsa_topk_indices=pp_outputs.tensors.get("draft_dsa_topk_indices"),
)
batch.spec_info = next_draft_input
@@ -683,7 +683,6 @@ class ModelRunner:
model=self.model,
model_config=self.model_config,
is_draft_worker=self.is_draft_worker,
spec_algorithm=self.spec_algorithm,
)
adjust_hybrid_swa_layer_ids(
model_config=self.model_config,
@@ -5,12 +5,8 @@ from typing import TYPE_CHECKING, Any, NamedTuple
import msgspec
from torch import nn
from sglang.srt.environ import envs
from sglang.srt.utils import is_npu
if TYPE_CHECKING:
from sglang.srt.configs.model_config import ModelConfig
from sglang.srt.speculative.spec_info import SpeculativeAlgorithm
class AttentionAndMoeLayers(NamedTuple):
@@ -141,7 +137,6 @@ def resolve_layer_indices(
model: Any,
model_config: ModelConfig,
is_draft_worker: bool,
spec_algorithm: SpeculativeAlgorithm,
) -> ModelLayerInfo:
# For MTP models like DeepSeek-V3 or GLM-4.5, the MTP layer(s) are used separately as draft
# models for speculative decoding. In those cases, `num_nextn_predict_layers` is used to
@@ -149,8 +144,6 @@ def resolve_layer_indices(
model_num_layers = _compute_model_num_layers(
model=model, model_config=model_config, is_draft_worker=is_draft_worker
)
_nnpl = model_config.num_nextn_predict_layers
model_has_mtp_layers = _nnpl is not None and _nnpl > 0
pp_range = _resolve_pp_layer_range(model=model, model_num_layers=model_num_layers)
num_effective_layers = pp_range.end_layer - pp_range.start_layer
@@ -159,14 +152,6 @@ def resolve_layer_indices(
if loop_num > 1:
num_effective_layers = num_effective_layers * loop_num
if not is_npu():
_assert_pp_mtp_compat(
model_has_mtp_layers=model_has_mtp_layers,
spec_algorithm=spec_algorithm,
num_effective_layers=num_effective_layers,
model_num_layers=model_num_layers,
)
return ModelLayerInfo(
start_layer=pp_range.start_layer,
end_layer=pp_range.end_layer,
@@ -213,25 +198,6 @@ def _resolve_pp_layer_range(*, model: Any, model_num_layers: int) -> _PPLayerRan
)
def _assert_pp_mtp_compat(
*,
model_has_mtp_layers: bool,
spec_algorithm: SpeculativeAlgorithm,
num_effective_layers: int,
model_num_layers: int,
) -> None:
if envs.SGLANG_ENABLE_PP_SPEC.get():
return
assert (
(not model_has_mtp_layers)
or (spec_algorithm.is_none())
or (
(not spec_algorithm.is_none())
and (num_effective_layers == model_num_layers)
)
), "PP is not compatible with MTP models."
def adjust_hybrid_swa_layer_ids(
*,
model_config: ModelConfig,
@@ -403,8 +403,8 @@ class DeepseekV2WeightLoaderMixin:
# Skip loading extra bias for GPTQ models.
if name.endswith(".bias") and name not in params_dict:
continue
# Skip loading embed_tokens if not first rank in pipeline parallelism
if ".embed_tokens." in name and not self.pp_group.is_first_rank:
# The last PP stage also owns an embedding for its NextN draft.
if ".embed_tokens." in name and name not in params_dict:
continue
# Skip loading norm if not last rank in pipeline parallelism
if ".norm." in name and not self.pp_group.is_last_rank:
+8 -1
View File
@@ -2793,6 +2793,13 @@ class DeepseekV2DecoderLayer(nn.Module):
return output
def pp_stage_needs_embedding(pp_group, speculative_algorithm) -> bool:
"""The first stage embeds inputs; the last supplies the EAGLE draft embedding."""
return pp_group.is_first_rank or (
pp_group.is_last_rank and speculative_algorithm is not None
)
class DeepseekV2Model(nn.Module):
fall_back_to_pt_during_load = False
@@ -2810,7 +2817,7 @@ class DeepseekV2Model(nn.Module):
self.first_k_dense_replace = config.first_k_dense_replace
self.pp_group = get_parallel().pp_group
if self.pp_group.is_first_rank or (_is_npu and self.pp_group.is_last_rank):
if pp_stage_needs_embedding(self.pp_group, get_spec().speculative_algorithm):
self.embed_tokens = VocabParallelEmbedding(
config.vocab_size,
config.hidden_size,
@@ -0,0 +1,258 @@
"""Unit tests for NIXL KV transfer from a prefill with pp_size > 1 to a decode
peer that is not pipelined, on plain-MLA models (MLATokenToKVPool)."""
import unittest
from types import SimpleNamespace
from sglang.srt.disaggregation.common.conn import CommonKVManager
from sglang.srt.disaggregation.nixl.conn import NixlKVManager
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
register_cpu_ci(est_time=10, suite="base-a-test-cpu")
NUM_LAYERS = 78
ITEM_LEN = 576
def _pp_layer_split(total: int, pp: int) -> list:
"""(start_layer, num_layers) per PP rank, remainder to the last ranks."""
base, rem = divmod(total, pp)
out, start = [], 0
for rank in range(pp):
num = base + (1 if rank >= pp - rem else 0)
out.append((start, num))
start += num
return out
class _StubKVManager:
"""Carries the real span primitive, so the cases exercise the shared mapping
rather than a reimplementation of it."""
_mla_kv_entry_span_with_pp = CommonKVManager._mla_kv_entry_span_with_pp
get_mla_kv_ptrs_with_pp = CommonKVManager.get_mla_kv_ptrs_with_pp
def __init__(
self,
*,
pp_size: int,
prefill_start_layer: int,
n_src: int,
is_mla_backend: bool = True,
is_hybrid_mla_backend: bool = False,
kv_layer_ids: list = (),
mla_compression_ratios=None,
item_len: int = ITEM_LEN,
):
self.pp_size = pp_size
self.is_mla_backend = is_mla_backend
self.is_hybrid_mla_backend = is_hybrid_mla_backend
self.kv_args = SimpleNamespace(
prefill_start_layer=prefill_start_layer,
kv_layer_ids=list(kv_layer_ids),
kv_item_lens=[item_len] * n_src,
mla_compression_ratios=mla_compression_ratios,
)
def _peer(*, n_dst: int, dst_kv_layer_ids: list = (), item_len: int = ITEM_LEN):
return SimpleNamespace(
dst_kv_layer_ids=list(dst_kv_layer_ids),
dst_kv_item_lens=[item_len] * n_dst,
)
def _resolve(manager, peer, n_src, n_dst):
return NixlKVManager._build_transfer_dst_indices(
manager, peer_info=peer, n_src=n_src, n_dst=n_dst
)
class TestPpPrefillToUnpipelinedDecode(CustomTestCase):
"""Bug regression: a pp>1 prefill paired with a pp=1 decode never transferred
KV on NIXL, and failed after /health was green so the request hung rather
than the launch failing. Each stage must write into its own layer range of
the peer's pool."""
def test_stages_tile_the_decode_pool_exactly_once(self):
covered = []
for start, num in _pp_layer_split(NUM_LAYERS, 4):
with self.subTest(start_layer=start):
indices = _resolve(
_StubKVManager(pp_size=4, prefill_start_layer=start, n_src=num),
_peer(n_dst=NUM_LAYERS),
num,
NUM_LAYERS,
)
self.assertEqual(indices, list(range(start, start + num)))
covered += indices
self.assertEqual(sorted(covered), list(range(NUM_LAYERS)))
self.assertEqual(len(covered), len(set(covered)))
def test_decode_side_draft_regions_are_never_targeted(self):
"""Decode-only speculative decoding appends draft buffers after the
target's, so the span must stay inside the target layer range."""
start, num = _pp_layer_split(NUM_LAYERS, 4)[3]
indices = _resolve(
_StubKVManager(pp_size=4, prefill_start_layer=start, n_src=num),
_peer(n_dst=NUM_LAYERS + 1),
num,
NUM_LAYERS + 1,
)
self.assertEqual(indices, list(range(start, start + num)))
self.assertNotIn(NUM_LAYERS, indices)
def test_cell_geometry_disagreement_raises(self):
"""A per-layer item_len mismatch means the peers did not build the same
KV geometry, which must fail loudly instead of corrupting the pool."""
start, num = _pp_layer_split(NUM_LAYERS, 4)[3]
with self.assertRaisesRegex(RuntimeError, "geometry differs"):
_resolve(
_StubKVManager(pp_size=4, prefill_start_layer=start, n_src=num),
_peer(n_dst=NUM_LAYERS, item_len=ITEM_LEN + 80),
num,
NUM_LAYERS,
)
class TestExistingPairingIsUnchanged(CustomTestCase):
"""All layouts outside heterogeneous plain MLA retain existing pairing."""
def test_unpipelined_prefill_uses_positional_pairing(self):
for n_dst in (NUM_LAYERS, NUM_LAYERS + 1):
with self.subTest(n_dst=n_dst):
self.assertEqual(
_resolve(
_StubKVManager(
pp_size=1, prefill_start_layer=0, n_src=NUM_LAYERS
),
_peer(n_dst=n_dst),
NUM_LAYERS,
n_dst,
),
list(range(NUM_LAYERS)),
)
def test_matched_pp_uses_identity_pairing(self):
for start, num in _pp_layer_split(NUM_LAYERS, 4):
with self.subTest(start_layer=start):
self.assertEqual(
_resolve(
_StubKVManager(pp_size=4, prefill_start_layer=start, n_src=num),
_peer(n_dst=num),
num,
num,
),
list(range(num)),
)
def test_one_sided_layer_ids_are_rejected(self):
start, num = _pp_layer_split(NUM_LAYERS, 4)[2]
with self.assertRaisesRegex(RuntimeError, "both PD peers or neither"):
_resolve(
_StubKVManager(
pp_size=4,
prefill_start_layer=start,
n_src=num,
kv_layer_ids=range(start, start + num),
),
_peer(n_dst=NUM_LAYERS),
num,
NUM_LAYERS,
)
with self.assertRaisesRegex(RuntimeError, "both PD peers or neither"):
_resolve(
_StubKVManager(pp_size=4, prefill_start_layer=start, n_src=num),
_peer(n_dst=NUM_LAYERS, dst_kv_layer_ids=range(NUM_LAYERS)),
num,
NUM_LAYERS,
)
def test_heterogeneous_non_plain_mla_requires_layer_ids(self):
start, num = _pp_layer_split(NUM_LAYERS, 4)[2]
for label, kwargs, n_dst in (
("mha", {"is_mla_backend": False}, 2 * NUM_LAYERS),
("hybrid_mla", {"is_hybrid_mla_backend": True}, NUM_LAYERS),
(
"compressed_mla",
{"mla_compression_ratios": [4] * NUM_LAYERS},
2 * NUM_LAYERS,
),
):
with self.subTest(pool=label):
with self.assertRaisesRegex(
RuntimeError, "PP-heterogeneous transfer requires layer ids"
):
_resolve(
_StubKVManager(
pp_size=4,
prefill_start_layer=start,
n_src=num,
**kwargs,
),
_peer(n_dst=n_dst),
num,
n_dst,
)
class TestMatchedPpWithDraftRegions(CustomTestCase):
"""Derived property: bootstrap admits a peer at our pp or at 1, so under
matched pp a decode-side draft buffer makes n_src != n_dst and reaches the
span. Stage 0 degenerates to the identity and stays correct; every stage
above it must be rejected by the bound rather than writing at an offset the
peer does not have."""
def test_rank0_identity_span_stays_correct(self):
start, num = _pp_layer_split(NUM_LAYERS, 4)[0]
self.assertEqual(start, 0)
self.assertEqual(
_resolve(
_StubKVManager(pp_size=4, prefill_start_layer=start, n_src=num),
_peer(n_dst=num + 1),
num,
num + 1,
),
list(range(num)),
)
def test_later_ranks_are_rejected(self):
for start, num in _pp_layer_split(NUM_LAYERS, 4)[1:]:
with self.subTest(start_layer=start):
with self.assertRaisesRegex(
RuntimeError, "PP-heterogeneous transfer requires layer ids"
):
_resolve(
_StubKVManager(pp_size=4, prefill_start_layer=start, n_src=num),
_peer(n_dst=num + 1),
num,
num + 1,
)
class TestPointerAndIndexViewsAgree(CustomTestCase):
"""Derived property: get_mla_kv_ptrs_with_pp (pointer view) and
_build_transfer_dst_indices (index view) read the same span, so a change to
one must not silently diverge from the other."""
def test_views_select_the_same_destination_entries(self):
dst_ptrs = [9000 + i for i in range(NUM_LAYERS)]
for start, num in _pp_layer_split(NUM_LAYERS, 4):
with self.subTest(start_layer=start):
manager = _StubKVManager(
pp_size=4, prefill_start_layer=start, n_src=num
)
src_ptrs = [1000 + i for i in range(num)]
_, sliced_dst, count = manager.get_mla_kv_ptrs_with_pp(
src_ptrs, dst_ptrs
)
indices = _resolve(manager, _peer(n_dst=NUM_LAYERS), num, NUM_LAYERS)
self.assertEqual(count, num)
self.assertEqual(sliced_dst, [dst_ptrs[j] for j in indices])
if __name__ == "__main__":
unittest.main()
@@ -25,6 +25,7 @@ from sglang.srt.model_executor.forward_batch_info import PPProxyTensors
from sglang.srt.model_executor.model_runner import ModelRunner
from sglang.srt.runtime_context import publish, reset_context
from sglang.srt.server_args import ServerArgs
from sglang.srt.speculative.eagle_info import EagleDraftInput
from sglang.srt.speculative.spec_info import SpeculativeAlgorithm
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import published_topology
@@ -689,6 +690,50 @@ def test_pipeline_parallel_auxiliary_output_round_trip():
receiver.future_map.stash.assert_called_once()
@pytest.mark.parametrize("dsa_topk_indices", [None, torch.tensor([[2, 5, 7]])])
def test_pipeline_parallel_dsa_seed_round_trip(dsa_topk_indices):
draft_input = EagleDraftInput(
topk_p=torch.tensor([[0.8, 0.2]]),
topk_index=torch.tensor([[11, 13]]),
hidden_states=torch.tensor([[1.0, 2.0]]),
dsa_topk_indices=dsa_topk_indices,
)
result = GenerationBatchResult(
logits_output=None,
next_token_ids=torch.tensor([17]),
next_draft_input=draft_input,
)
batch = SimpleNamespace(
return_logprob=False,
req_pool_indices=torch.tensor([3]),
input_ids=torch.tensor([5]),
spec_info=None,
)
tensors = Scheduler._pp_prepare_tensor_dict(
object.__new__(Scheduler), result, batch
)
if dsa_topk_indices is None:
assert "draft_dsa_topk_indices" not in tensors
else:
assert torch.equal(tensors["draft_dsa_topk_indices"], dsa_topk_indices)
receiver = object.__new__(Scheduler)
receiver.pp_group = SimpleNamespace(is_first_rank=False)
receiver.future_map = SimpleNamespace(stash=Mock())
Scheduler._pp_prep_batch_result(
receiver,
batch,
PPBatchMetadata(can_run_cuda_graph=True),
PPProxyTensors(tensors),
)
if dsa_topk_indices is None:
assert batch.spec_info.dsa_topk_indices is None
else:
assert torch.equal(batch.spec_info.dsa_topk_indices, dsa_topk_indices)
def test_pipeline_parallel_auxiliary_output_stays_packed_before_first_rank():
device_output = DeviceOutput(torch.tensor([1.0]))
result = GenerationBatchResult(
@@ -0,0 +1,58 @@
"""Pipeline-stage embedding ownership for DeepSeek/GLM NextN."""
import unittest
from types import SimpleNamespace
import torch
from sglang.srt.layers.utils import PPMissingLayer
from sglang.srt.models.deepseek_v2 import pp_stage_needs_embedding
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
register_cpu_ci(est_time=5, suite="base-a-test-cpu")
def _pp_group(*, rank: int, size: int) -> SimpleNamespace:
return SimpleNamespace(is_first_rank=rank == 0, is_last_rank=rank == size - 1)
class TestDeepseekPPStageEmbedding(CustomTestCase):
def test_without_speculative_decoding_only_the_first_stage_embeds(self):
for rank in range(4):
with self.subTest(rank=rank):
self.assertEqual(
pp_stage_needs_embedding(_pp_group(rank=rank, size=4), None),
rank == 0,
)
def test_speculative_decoding_adds_the_last_stage(self):
wants = [
pp_stage_needs_embedding(_pp_group(rank=rank, size=4), "EAGLE")
for rank in range(4)
]
self.assertEqual(wants, [True, False, False, True])
def test_middle_stages_never_embed(self):
self.assertFalse(pp_stage_needs_embedding(_pp_group(rank=2, size=4), "EAGLE"))
def test_without_pipeline_parallelism_the_single_stage_embeds_either_way(self):
for spec in (None, "EAGLE"):
with self.subTest(spec=spec):
self.assertTrue(
pp_stage_needs_embedding(_pp_group(rank=0, size=1), spec)
)
def test_pp_missing_layer_registers_no_parameters(self):
placeholder = PPMissingLayer()
self.assertEqual(dict(placeholder.named_parameters()), {})
def test_a_real_embedding_registers_weight_under_its_prefix(self):
model = torch.nn.Module()
model.embed_tokens = torch.nn.Embedding(8, 4)
params = dict(model.named_parameters())
self.assertIn("embed_tokens.weight", params)
if __name__ == "__main__":
unittest.main()
@@ -13,7 +13,12 @@ import msgspec
import msgspec.structs
import sglang.srt.server_args as server_args_module
from sglang.srt.arg_groups import parallel_hook, pd_disaggregation_hook, serving_hook
from sglang.srt.arg_groups import (
parallel_hook,
pd_disaggregation_hook,
serving_hook,
validation_hook,
)
from sglang.srt.arg_groups.attention_hook import (
handle_attention_backend_compatibility,
handle_deterministic_inference,
@@ -68,6 +73,7 @@ from sglang.srt.arg_groups.serving_hook import (
)
from sglang.srt.arg_groups.speculative_hook import handle_speculative_decoding
from sglang.srt.arg_groups.validation_hook import (
check_pipeline_parallel_compat,
check_two_batch_overlap,
)
from sglang.srt.entrypoints.sidecar import (
@@ -2364,6 +2370,115 @@ class TestCudaGraphConfigDataclassAccess(CustomTestCase):
self.assertEqual(config.compiler, "eager")
class TestPipelineParallelCompat(CustomTestCase):
"""Features supported with `pipeline-parallel-size > 1`."""
_SUPPORTED_ARCH = "GlmMoeDsaForCausalLM"
@staticmethod
def _cfg(**overrides):
cfg = dict(
disable_overlap_schedule=True,
speculative_algorithm=None,
enable_multi_layer_eagle=False,
disaggregation_mode="prefill",
min_free_slots_delay=None,
)
cfg.update(overrides)
return SimpleNamespace(**cfg)
def test_overlap_schedule_must_be_off(self):
with self.assertRaisesRegex(AssertionError, "overlap schedule"):
check_pipeline_parallel_compat(self._cfg(disable_overlap_schedule=False))
def test_no_speculative_decoding_is_fine(self):
check_pipeline_parallel_compat(self._cfg())
def test_eagle_is_allowed_on_prefill(self):
check_pipeline_parallel_compat(
self._cfg(speculative_algorithm="EAGLE"),
model_architecture=self._SUPPORTED_ARCH,
)
def test_eagle_is_rejected_outside_prefill(self):
for mode in ("decode", "null"):
with self.subTest(disaggregation_mode=mode):
with self.assertRaisesRegex(AssertionError, "prefill nodes"):
check_pipeline_parallel_compat(
self._cfg(
speculative_algorithm="EAGLE", disaggregation_mode=mode
),
model_architecture=self._SUPPORTED_ARCH,
)
def test_eagle_is_rejected_for_unsupported_model(self):
with self.assertRaisesRegex(AssertionError, "DeepSeek/GLM/Qwen3.5 models"):
check_pipeline_parallel_compat(
self._cfg(speculative_algorithm="EAGLE"),
model_architecture="LlamaForCausalLM",
)
def test_supported_architectures(self):
for architecture in (
"DeepseekV2ForCausalLM",
"DeepseekV3ForCausalLM",
"DeepseekV32ForCausalLM",
"GlmMoeDsaForCausalLM",
"Qwen3_5ForCausalLM",
"Qwen3_5MoeForCausalLM",
"Qwen3_5ForConditionalGeneration",
"Qwen3_5MoeForConditionalGeneration",
):
with self.subTest(architecture=architecture):
check_pipeline_parallel_compat(
self._cfg(speculative_algorithm="EAGLE"),
model_architecture=architecture,
)
def test_pp_spec_env_gate_allows_aggregate_and_rejects_pd(self):
cfg = self._cfg(
speculative_algorithm="EAGLE",
disaggregation_mode="null",
speculative_adaptive=False,
enable_dp_attention=False,
)
with patch.object(
validation_hook.envs.SGLANG_ENABLE_PP_SPEC, "get", return_value=True
):
check_pipeline_parallel_compat(cfg, model_architecture="LlamaForCausalLM")
with self.assertRaisesRegex(AssertionError, "SGLANG_ENABLE_PP_SPEC"):
check_pipeline_parallel_compat(
self._cfg(speculative_algorithm="EAGLE"),
model_architecture=self._SUPPORTED_ARCH,
)
def test_nextn_resolves_to_eagle_and_is_allowed(self):
"""`--speculative-algorithm NEXTN` has collapsed to EAGLE by the time the
validation hook runs, so the check only ever sees the resolved name."""
check_pipeline_parallel_compat(
self._cfg(speculative_algorithm="eagle"),
model_architecture=self._SUPPORTED_ARCH,
)
def test_non_eagle_speculative_algorithms_are_rejected(self):
with self.assertRaisesRegex(AssertionError, "only supports EAGLE"):
check_pipeline_parallel_compat(
self._cfg(speculative_algorithm="EAGLE3"),
model_architecture=self._SUPPORTED_ARCH,
)
def test_multi_layer_eagle_is_rejected(self):
with self.assertRaisesRegex(AssertionError, "only supports EAGLE"):
check_pipeline_parallel_compat(
self._cfg(speculative_algorithm="EAGLE", enable_multi_layer_eagle=True),
model_architecture=self._SUPPORTED_ARCH,
)
def test_min_free_slots_delay_is_rejected(self):
with self.assertRaisesRegex(AssertionError, "min-free-slots-delay"):
check_pipeline_parallel_compat(self._cfg(min_free_slots_delay=4))
class TestCudaGraphPrefillMaxContextResolution(CustomTestCase):
@staticmethod
def _make_args(max_context_size, model_context_len=4096, page_size=64):