[diffusion] feat: support K/V-gather style sequence parallel (CP-like) attention (#32667)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mick
2026-08-07 09:39:28 +08:00
committed by GitHub
co-authored by Claude Fable 5
parent 9ee658d4f6
commit 1e08b865f9
13 changed files with 1105 additions and 27 deletions
@@ -218,6 +218,11 @@ def plan_text_strategy(txt_len: int) -> str:
sp_size = get_sp_world_size()
if sp_size <= 1:
return "replicate"
local_len = (txt_len + sp_size - 1) // sp_size
num_pad = local_len * sp_size - txt_len
# padding must fit in the final shard to remain one global-tail block
if num_pad > local_len:
return "replicate"
if txt_len % sp_size != 0 and get_ring_parallel_world_size() > 1:
return "replicate"
if txt_len < _TEXT_SHARD_MIN:
@@ -172,6 +172,7 @@ def action_metadata(server_args: ServerArgs) -> dict[str, Any]:
"sp_degree": server_args.sp_degree,
"ulysses_degree": server_args.ulysses_degree,
"ring_degree": server_args.ring_degree,
"kv_gather_degree": server_args.kv_gather_degree,
"prefix_strategy": pipeline_config.prefix_parallel_strategy,
"action_strategy": pipeline_config.action_parallel_strategy,
"layout_version": pipeline_config.parallel_layout_version,
@@ -75,6 +75,57 @@ _PYTORCH_DEFAULT_CUDA_SDP_BACKENDS = [
_VARLEN_FA_ENABLED = os.environ.get("SGLANG_VARLEN_FA", "1") != "0"
def _resolve_sp_attention_mode(
*, causal: bool, sparse_backend: bool
) -> tuple[str, bool]:
"""Resolve one layer's SP exchange; returns (mode, is_auto).
``kv_gather_degree > 1`` selects the gather exchange for the SP rows. When
the degree was auto-assigned, layers the gather path cannot serve fall
back to Ulysses; an explicit degree fails closed instead of degrading.
"""
from sglang.multimodal_gen.runtime.server_args import get_global_server_args
args = get_global_server_args()
if args.kv_gather_degree <= 1:
return "ulysses", False
if causal or sparse_backend:
if args.sp_split_auto:
return "ulysses", True
if causal:
raise ValueError("K/V-gather SP does not support causal attention.")
raise NotImplementedError(
"K/V-gather SP does not support sparse attention backends."
)
return "kv_gather", args.sp_split_auto
def _kv_gather_unsupported_reason(
*,
qkv_pre_all_to_all: bool,
replicated_mode_count: int,
attn_mask: torch.Tensor | None,
num_replicated_kv_prefix: int,
) -> str | None:
"""Call shapes the gather path does not take; explicit mode fails closed
on these, auto falls back to the Ulysses exchange for the call."""
if qkv_pre_all_to_all:
return (
"K/V-gather SP expects sequence-sharded Q/K/V; "
"caller-side pre-all-to-all is Ulysses-only."
)
if replicated_mode_count > 1:
return "K/V-gather SP supports at most one replicated-token mode per call."
if attn_mask is not None:
if num_replicated_kv_prefix:
return "K/V-gather SP masked attention does not support a KV-only prefix."
if attn_mask.dim() != 2:
return "K/V-gather SP masked attention expects a [B, S_local] mask."
if torch.is_floating_point(attn_mask):
return "K/V-gather SP supports boolean or integer padding masks."
return None
def build_varlen_mask_meta(
key_mask: torch.Tensor,
) -> dict:
@@ -265,6 +316,45 @@ class UlyssesAttention(nn.Module):
self.num_kv_heads = num_kv_heads
self.backend = attn_backend.get_enum()
self.dtype = dtype
self.causal = causal
self.sp_attention_mode, self.sp_attention_mode_is_auto = (
_resolve_sp_attention_mode(
causal=causal, sparse_backend=self.backend.is_sparse
)
)
def _forward_with_kv_gather(
self,
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
ctx_attn_metadata,
replicated_q: torch.Tensor | None,
replicated_k: torch.Tensor | None,
replicated_v: torch.Tensor | None,
seq_lens: list[int] | None,
) -> tuple[torch.Tensor, torch.Tensor | None]:
if seq_lens is not None:
raise NotImplementedError(
"K/V-gather SP does not support varlen UlyssesAttention."
)
if any(x is not None for x in (replicated_q, replicated_k, replicated_v)):
if any(x is None for x in (replicated_q, replicated_k, replicated_v)):
raise ValueError("Replicated Q, K, and V must be provided together.")
k = sequence_model_parallel_all_gather(k, dim=1)
v = sequence_model_parallel_all_gather(v, dim=1)
local_query_len = q.shape[1]
if replicated_q is not None:
q = torch.cat([q, replicated_q], dim=1)
k = torch.cat([k, replicated_k], dim=1)
v = torch.cat([v, replicated_v], dim=1)
output = self.attn_impl.forward(q, k, v, ctx_attn_metadata)
if replicated_q is None:
return output, None
return output[:, :local_query_len], output[:, local_query_len:]
def forward(
self,
@@ -294,12 +384,26 @@ class UlyssesAttention(nn.Module):
# Check input shapes
assert q.dim() == 4 and k.dim() == 4 and v.dim() == 4, "Expected 4D tensors"
batch_size, seq_len, num_heads, head_dim = q.shape
local_rank = get_sp_parallel_rank()
world_size = get_sp_world_size()
forward_context: ForwardContext = get_forward_context()
ctx_attn_metadata = forward_context.attn_metadata
if self.sp_attention_mode == "kv_gather" and not (
self.sp_attention_mode_is_auto and seq_lens is not None
):
return self._forward_with_kv_gather(
q,
k,
v,
ctx_attn_metadata,
replicated_q,
replicated_k,
replicated_v,
seq_lens,
)
local_rank = get_sp_parallel_rank()
world_size = get_sp_world_size()
if seq_lens is not None:
assert (
replicated_q is None and replicated_k is None and replicated_v is None
@@ -384,6 +488,10 @@ class UlyssesAttention_VSA(UlyssesAttention):
- o (torch.Tensor): Output tensor after attention for the main sequence
- replicated_o (Optional[torch.Tensor]): Output tensor for replicated tokens, if provided
"""
if self.sp_attention_mode == "kv_gather":
raise NotImplementedError(
"K/V-gather SP does not support video sparse attention."
)
# Check text tokens are not supported for VSA now
assert (
replicated_q is None and replicated_k is None and replicated_v is None
@@ -535,11 +643,12 @@ class LocalAttention(nn.Module):
class USPAttention(nn.Module):
"""
Ulysses Sequence Parallelism with Ring Attention.
Sequence-parallel attention with Ulysses, K/V gather, and Ring Attention.
This class implements the USP algorithm, which is a combination of
Ulysses-style all-to-all communication for sequence-head dimension sharding
and Ring Attention for fine-grained sequence parallelism within subgroups.
The default path implements USP, which combines Ulysses-style all-to-all
communication for sequence-head dimension sharding with Ring Attention
inside subgroups. The K/V-gather path keeps queries sequence-sharded and
gathers keys and values within the SP group.
"""
_usp_a2a_stream = None
@@ -612,6 +721,11 @@ class USPAttention(nn.Module):
self.skip_sequence_parallel = skip_sequence_parallel
self.enable_packed_qkv_input_a2a = bool(enable_packed_qkv_input_a2a)
self.sp_attention_mode, self.sp_attention_mode_is_auto = (
_resolve_sp_attention_mode(
causal=causal, sparse_backend=self.backend.is_sparse
)
)
def _get_usp_a2a_stream(self):
if USPAttention._usp_a2a_stream is None:
@@ -681,6 +795,40 @@ class USPAttention(nn.Module):
and not effective_skip_sp
and get_sequence_parallel_world_size() > 1
)
replicated_mode_count = sum(
value > 0
for value in (
num_replicated_prefix,
num_replicated_suffix,
num_replicated_kv_prefix,
)
)
if (
self.sp_attention_mode == "kv_gather"
and not effective_skip_sp
and get_sequence_parallel_world_size() > 1
):
unsupported = _kv_gather_unsupported_reason(
qkv_pre_all_to_all=qkv_pre_all_to_all,
replicated_mode_count=replicated_mode_count,
attn_mask=attn_mask,
num_replicated_kv_prefix=num_replicated_kv_prefix,
)
if unsupported is None:
return self._forward_with_kv_gather(
q,
k,
v,
ctx_attn_metadata,
attn_mask,
attn_mask_meta,
num_replicated_prefix,
num_replicated_suffix,
num_replicated_kv_prefix,
)
if not self.sp_attention_mode_is_auto:
raise NotImplementedError(unsupported)
if attn_mask is not None or meta_only_pad:
def _prepare_sdpa_mask(
@@ -944,11 +1092,7 @@ class USPAttention(nn.Module):
return out
sp_size = get_ulysses_parallel_world_size()
if (
(num_replicated_prefix > 0 and num_replicated_suffix > 0)
or (num_replicated_prefix > 0 and num_replicated_kv_prefix > 0)
or (num_replicated_suffix > 0 and num_replicated_kv_prefix > 0)
):
if replicated_mode_count > 1:
raise ValueError(
"USPAttention supports at most one replicated-token mode per call."
)
@@ -1003,6 +1147,193 @@ class USPAttention(nn.Module):
return out
@staticmethod
def _gather_sharded_sequence(
tensor: torch.Tensor,
num_replicated_prefix: int = 0,
num_replicated_suffix: int = 0,
) -> torch.Tensor:
if num_replicated_prefix and num_replicated_suffix:
raise ValueError(
"Replicated prefix and suffix cannot be used at the same time."
)
if num_replicated_prefix:
replicated = tensor[:, :num_replicated_prefix]
sharded = tensor[:, num_replicated_prefix:]
gathered = sequence_model_parallel_all_gather(sharded, dim=1)
return torch.cat([replicated, gathered], dim=1)
if num_replicated_suffix:
replicated = tensor[:, -num_replicated_suffix:]
sharded = tensor[:, :-num_replicated_suffix]
gathered = sequence_model_parallel_all_gather(sharded, dim=1)
return torch.cat([gathered, replicated], dim=1)
return sequence_model_parallel_all_gather(tensor, dim=1)
def _forward_with_kv_gather(
self,
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
ctx_attn_metadata,
attn_mask: torch.Tensor | None,
attn_mask_meta: dict | None,
num_replicated_prefix: int,
num_replicated_suffix: int,
num_replicated_kv_prefix: int,
) -> torch.Tensor:
if attn_mask is not None and num_replicated_kv_prefix:
raise NotImplementedError(
"K/V-gather SP masked attention does not support a KV-only prefix."
)
kv_prefix = num_replicated_prefix or num_replicated_kv_prefix
k = self._gather_sharded_sequence(
k,
num_replicated_prefix=kv_prefix,
num_replicated_suffix=num_replicated_suffix,
)
v = self._gather_sharded_sequence(
v,
num_replicated_prefix=kv_prefix,
num_replicated_suffix=num_replicated_suffix,
)
if attn_mask is None and attn_mask_meta is None:
return self.attn_impl.forward(q, k, v, ctx_attn_metadata)
explicit_mask = attn_mask is not None
if attn_mask is None:
local_pad = int(attn_mask_meta.get("local_pad", 0))
attn_mask = torch.ones(q.shape[:2], dtype=torch.bool, device=q.device)
if local_pad:
attn_mask[:, -local_pad:] = False
elif attn_mask.dim() != 2:
raise NotImplementedError(
"K/V-gather SP masked attention expects a [B, S_local] mask."
)
else:
if attn_mask.dtype not in (
torch.bool,
torch.uint8,
torch.int32,
torch.int64,
):
raise NotImplementedError(
"K/V-gather SP supports boolean or integer padding masks."
)
attn_mask = attn_mask.to(dtype=torch.bool)
cache_key = (
tuple(attn_mask.shape),
num_replicated_prefix,
num_replicated_suffix,
explicit_mask,
)
mask_cache = None
if attn_mask_meta is not None:
mask_cache = attn_mask_meta.get("_kv_gather_cache")
cached = mask_cache.get(cache_key) if mask_cache is not None else None
if cached is None:
key_mask = self._gather_sharded_sequence(
attn_mask,
num_replicated_prefix=num_replicated_prefix,
num_replicated_suffix=num_replicated_suffix,
)
cached = {"key_mask": key_mask}
if attn_mask_meta is not None:
if mask_cache is None:
mask_cache = {}
attn_mask_meta["_kv_gather_cache"] = mask_cache
mask_cache[cache_key] = cached
else:
key_mask = cached["key_mask"]
if (
_VARLEN_FA_ENABLED
and self.backend == AttentionBackendEnum.FA
and q.device.type == "cuda"
and q.dtype in (torch.float16, torch.bfloat16)
):
if (
explicit_mask
and attn_mask_meta is not None
and all(
key in attn_mask_meta
for key in (
"indices",
"cu_seqlens",
"max_seqlen",
"inv_indices",
)
)
):
query_meta = attn_mask_meta
else:
query_meta = cached.get("query_meta")
if query_meta is None:
query_meta = build_varlen_mask_meta(attn_mask)
cached["query_meta"] = query_meta
key_meta = cached.get("key_meta")
if key_meta is None:
key_meta = build_varlen_mask_meta(key_mask)
cached["key_meta"] = key_meta
batch_size, query_len = q.shape[:2]
q_unpad = q.reshape(-1, *q.shape[2:]).index_select(0, query_meta["indices"])
k_unpad = k.reshape(-1, *k.shape[2:]).index_select(0, key_meta["indices"])
v_unpad = v.reshape(-1, *v.shape[2:]).index_select(0, key_meta["indices"])
out_unpad = flash_attn_varlen_func(
q=q_unpad,
k=k_unpad,
v=v_unpad,
cu_seqlens_q=query_meta["cu_seqlens"],
cu_seqlens_k=key_meta["cu_seqlens"],
max_seqlen_q=query_meta["max_seqlen"],
max_seqlen_k=key_meta["max_seqlen"],
softmax_scale=self.softmax_scale,
causal=False,
ver=_fa_backend.fa_ver,
)
return fused_scatter_to_padded(
out_unpad,
query_meta["inv_indices"],
batch_size,
query_len,
)
q_ = q.transpose(1, 2)
k_ = k.transpose(1, 2)
v_ = v.transpose(1, 2)
if q_.shape[1] != k_.shape[1]:
if q_.shape[1] % k_.shape[1] != 0:
raise ValueError(
f"Query heads ({q_.shape[1]}) must be divisible by "
f"KV heads ({k_.shape[1]})."
)
repeat_factor = q_.shape[1] // k_.shape[1]
k_ = k_.repeat_interleave(repeat_factor, dim=1)
v_ = v_.repeat_interleave(repeat_factor, dim=1)
sdpa_context = (
sdpa_kernel(_PYTORCH_DEFAULT_CUDA_SDP_BACKENDS)
if self.allow_cudnn_sdp and q_.device.type == "cuda"
else nullcontext()
)
with sdpa_context:
out = torch.nn.functional.scaled_dot_product_attention(
q_,
k_,
v_,
attn_mask=key_mask[:, None, None, :],
dropout_p=0.0,
is_causal=False,
scale=self.softmax_scale,
).transpose(1, 2)
return out * attn_mask[:, :, None, None]
def _forward_with_replicated_prefix(
self,
q: torch.Tensor,
@@ -1087,6 +1418,13 @@ class USPAttention(nn.Module):
v = torch.cat([v_prefix, v_suffix], dim=1)
return self.attn_impl.forward(q, k, v, ctx_attn_metadata)
if self.sp_attention_mode == "kv_gather":
k_suffix = sequence_model_parallel_all_gather(k_suffix, dim=1)
v_suffix = sequence_model_parallel_all_gather(v_suffix, dim=1)
k = torch.cat([k_prefix, k_suffix], dim=1)
v = torch.cat([v_prefix, v_suffix], dim=1)
return self.attn_impl.forward(q, k, v, ctx_attn_metadata)
if get_ulysses_parallel_world_size() == 1:
k = torch.cat([k_prefix, k_suffix], dim=1)
v = torch.cat([v_prefix, v_suffix], dim=1)
@@ -768,7 +768,9 @@ class QwenImageCrossAttention(nn.Module):
# Joint order [text, image]; join_seqs relocates any SP text tail-pad
# behind the image (see sp_shard.join_seqs for why).
seg_qkv = None
if sp_text_sharded:
# The segmented pre-all-to-all emits Ulysses layout; K/V-gather takes
# the join_seqs path and exchanges inside the attention instead.
if sp_text_sharded and self.attn.sp_attention_mode == "ulysses":
from sglang.multimodal_gen.runtime.layers.usp import (
_ipc_input_a2a_qkv_segmented,
)
@@ -219,6 +219,12 @@ class ServerArgs(DisaggServerArgsMixin):
# sequence parallelism
ulysses_degree: Optional[int] = None
ring_degree: Optional[int] = None
# rows split inside attention, exchanged with one K/V all-gather instead
# of Ulysses a2a or ring rotation; auto-assigned at sp_degree=2 when no SP
# degree is set explicitly
kv_gather_degree: Optional[int] = None
# whether the SP split was auto-assigned (lets layers fall back per call)
sp_split_auto: bool = False
# data parallelism
# number of data parallelism groups
dp_size: int = 1
@@ -1042,12 +1048,35 @@ class ServerArgs(DisaggServerArgsMixin):
if (
self.ulysses_degree is None
and self.ring_degree is None
and self.kv_gather_degree is None
and self.sp_degree != 1
):
self.ulysses_degree = self.sp_degree
logger.info(
f"Automatically set ulysses_degree=sp_degree={self.ulysses_degree} for best performance"
)
if self.sp_degree == 2:
# measured-win zone for the K/V-gather exchange; layers whose
# calls the gather path cannot take fall back to Ulysses
self.kv_gather_degree = 2
self.sp_split_auto = True
logger.info(
"Automatically set kv_gather_degree=sp_degree=2; set "
"--ulysses-degree explicitly to keep the Ulysses exchange"
)
else:
self.ulysses_degree = self.sp_degree
logger.info(
"Automatically set ulysses_degree=sp_degree=%d for the "
"sequence-parallel process-group layout",
self.ulysses_degree,
)
if self.kv_gather_degree is None:
self.kv_gather_degree = 1
if self.kv_gather_degree > 1:
if (self.ulysses_degree or 1) != 1 or (self.ring_degree or 1) != 1:
raise ValueError(
"kv_gather_degree does not compose with ulysses_degree or "
"ring_degree yet; set exactly one of them above 1"
)
if self.ulysses_degree is None:
self.ulysses_degree = 1
@@ -1059,6 +1088,13 @@ class ServerArgs(DisaggServerArgsMixin):
self.ring_degree = 1
logger.debug(f"Ring degree not set, using default value {self.ring_degree}")
if self.kv_gather_degree > 1:
# K/V-gather rows occupy the contiguous inner SP dimension; the
# process groups are built from ulysses_degree, so alias it until
# gather gets a first-class dimension (needed only once it
# composes with Ulysses).
self.ulysses_degree = self.kv_gather_degree
def _model_default_uses_cfg(self) -> bool:
"""
Check whether the model uses classifier-free guidance by default.
@@ -1451,6 +1487,20 @@ class ServerArgs(DisaggServerArgsMixin):
"`replicate` disables both. The default is `auto`."
),
)
parser.add_argument(
"--kv-gather-degree",
type=int,
default=ServerArgs.kv_gather_degree,
help=(
"Sequence-parallel degree that splits rows inside attention "
"and exchanges with one K/V all-gather (queries stay local) "
"instead of Ulysses all-to-all. Non-causal attention only; "
"does not compose with --ulysses-degree/--ring-degree yet. "
"When no SP degree is set explicitly, sp_degree=2 defaults to "
"kv_gather_degree=2 (its measured-win zone) and higher "
"degrees default to Ulysses."
),
)
parser.add_argument(
"--enable-cfg-parallel",
action=StoreBoolean,
@@ -2398,6 +2448,15 @@ class ServerArgs(DisaggServerArgsMixin):
)
def _validate_parallelism(self):
if self.kv_gather_degree < 1:
raise ValueError("kv_gather_degree must be >= 1")
if self.kv_gather_degree > 1 and self.sp_degree != self.kv_gather_degree:
raise ValueError(
f"kv_gather_degree ({self.kv_gather_degree}) must equal "
f"sp_degree ({self.sp_degree}); check how many GPUs remain for "
"sequence parallelism after dp/tp/cfg"
)
if self.sp_degree > self.num_gpus or self.num_gpus % self.sp_degree != 0:
raise ValueError(
f"num_gpus ({self.num_gpus}) must be >= and divisible by sp_degree ({self.sp_degree})"
@@ -41,6 +41,8 @@ def _make_unit_server_args():
enable_breakable_cuda_graph=False,
enable_layerwise_nvtx_marker=False,
enable_torch_compile=False,
kv_gather_degree=1,
sp_split_auto=False,
model_loaded={},
model_paths={},
pipeline_config=pipeline_config,
@@ -182,6 +182,8 @@ def _fake_server_args(cfg=None):
disable_autocast=False,
enable_cfg_parallel=False,
attention_backend_config=None,
kv_gather_degree=1,
sp_split_auto=False,
)
@@ -357,7 +359,12 @@ class TestIdeogram4(unittest.TestCase):
prev_args = server_args_module._global_server_args
try:
set_global_server_args(
SimpleNamespace(attention_backend="torch_sdpa", comfyui_mode=False)
SimpleNamespace(
attention_backend="torch_sdpa",
comfyui_mode=False,
kv_gather_degree=1,
sp_split_auto=False,
)
)
torch.manual_seed(0)
batch_size, seq_len, num_heads, head_dim = 2, 5, 2, 8
@@ -769,7 +776,12 @@ class TestIdeogram4(unittest.TestCase):
prev_args = server_args_module._global_server_args
try:
set_global_server_args(
SimpleNamespace(attention_backend="torch_sdpa", comfyui_mode=False)
SimpleNamespace(
attention_backend="torch_sdpa",
comfyui_mode=False,
kv_gather_degree=1,
sp_split_auto=False,
)
)
with patch(
"sglang.multimodal_gen.runtime.layers.attention.layer.get_ring_parallel_world_size",
@@ -794,7 +806,12 @@ class TestIdeogram4(unittest.TestCase):
prev_args = server_args_module._global_server_args
try:
set_global_server_args(
SimpleNamespace(attention_backend="torch_sdpa", comfyui_mode=False)
SimpleNamespace(
attention_backend="torch_sdpa",
comfyui_mode=False,
kv_gather_degree=1,
sp_split_auto=False,
)
)
with patch(
"sglang.multimodal_gen.runtime.layers.attention.layer.get_ring_parallel_world_size",
@@ -842,7 +859,12 @@ class TestIdeogram4(unittest.TestCase):
prev_args = server_args_module._global_server_args
try:
set_global_server_args(
SimpleNamespace(attention_backend="torch_sdpa", comfyui_mode=False)
SimpleNamespace(
attention_backend="torch_sdpa",
comfyui_mode=False,
kv_gather_degree=1,
sp_split_auto=False,
)
)
with (
patch(
@@ -888,7 +910,12 @@ class TestIdeogram4(unittest.TestCase):
prev_args = server_args_module._global_server_args
try:
set_global_server_args(
SimpleNamespace(attention_backend="torch_sdpa", comfyui_mode=False)
SimpleNamespace(
attention_backend="torch_sdpa",
comfyui_mode=False,
kv_gather_degree=1,
sp_split_auto=False,
)
)
with (
patch(
@@ -941,7 +968,12 @@ class TestIdeogram4(unittest.TestCase):
prev_args = server_args_module._global_server_args
try:
set_global_server_args(
SimpleNamespace(attention_backend="torch_sdpa", comfyui_mode=False)
SimpleNamespace(
attention_backend="torch_sdpa",
comfyui_mode=False,
kv_gather_degree=1,
sp_split_auto=False,
)
)
with patch(
"sglang.multimodal_gen.runtime.layers.attention.layer.get_ring_parallel_world_size",
@@ -997,7 +1029,12 @@ class TestIdeogram4(unittest.TestCase):
prev_args = server_args_module._global_server_args
try:
set_global_server_args(
SimpleNamespace(attention_backend="torch_sdpa", comfyui_mode=False)
SimpleNamespace(
attention_backend="torch_sdpa",
comfyui_mode=False,
kv_gather_degree=1,
sp_split_auto=False,
)
)
with (
patch(
@@ -1210,7 +1247,12 @@ class TestIdeogram4(unittest.TestCase):
prev_args = server_args_module._global_server_args
try:
set_global_server_args(
SimpleNamespace(attention_backend="torch_sdpa", comfyui_mode=False)
SimpleNamespace(
attention_backend="torch_sdpa",
comfyui_mode=False,
kv_gather_degree=1,
sp_split_auto=False,
)
)
with (
patch.dict(os.environ, {W8A8_FP8_GEMM_ENV: "1"}),
@@ -1250,7 +1292,12 @@ class TestIdeogram4(unittest.TestCase):
prev_args = server_args_module._global_server_args
try:
set_global_server_args(
SimpleNamespace(attention_backend="torch_sdpa", comfyui_mode=False)
SimpleNamespace(
attention_backend="torch_sdpa",
comfyui_mode=False,
kv_gather_degree=1,
sp_split_auto=False,
)
)
with (
patch(
@@ -33,6 +33,7 @@ def _server_args(config: Pi05PipelineConfig | None = None) -> SimpleNamespace:
sp_degree=1,
ulysses_degree=1,
ring_degree=1,
kv_gather_degree=1,
pipeline_config=config or Pi05PipelineConfig(),
)
@@ -174,6 +175,7 @@ def test_action_metadata_reports_policy_shape_and_capabilities():
assert metadata["runtime"]["materialize_dtype"] == "bf16"
assert metadata["runtime"]["enable_autocast"] is True
assert metadata["runtime"]["parallelism"]["num_gpus"] == 1
assert metadata["runtime"]["parallelism"]["kv_gather_degree"] == 1
assert metadata["runtime"]["parallelism"]["prefix_strategy"] == "tp"
assert metadata["runtime"]["parallelism"]["action_strategy"] == "sp"
assert metadata["defaults"]["prefix_cache"] is False
@@ -1760,6 +1760,107 @@ class TestOffloadDefaults(unittest.TestCase):
self.assertEqual(server_args.ltx2_two_stage_device_mode, "original")
class TestKVGatherDegree(unittest.TestCase):
def test_sp2_defaults_to_kv_gather(self):
args = _from_dict_without_model_resolution(
{
"model_path": "/fake",
"num_gpus": 2,
"performance_mode": "manual",
}
)
self.assertEqual(args.kv_gather_degree, 2)
self.assertTrue(args.sp_split_auto)
# gather rows occupy the contiguous inner SP dimension
self.assertEqual(args.ulysses_degree, 2)
self.assertEqual(args.sp_degree, 2)
def test_higher_sp_defaults_to_ulysses(self):
args = _from_dict_without_model_resolution(
{
"model_path": "/fake",
"num_gpus": 4,
"performance_mode": "manual",
}
)
self.assertEqual(args.kv_gather_degree, 1)
self.assertFalse(args.sp_split_auto)
self.assertEqual(args.ulysses_degree, 4)
def test_explicit_ulysses_is_not_overridden(self):
args = _from_dict_without_model_resolution(
{
"model_path": "/fake",
"num_gpus": 2,
"ulysses_degree": 2,
"performance_mode": "manual",
}
)
self.assertEqual(args.kv_gather_degree, 1)
self.assertEqual(args.ulysses_degree, 2)
def test_explicit_degree_is_not_auto(self):
args = _from_dict_without_model_resolution(
{
"model_path": "/fake",
"num_gpus": 2,
"kv_gather_degree": 2,
"performance_mode": "manual",
}
)
self.assertEqual(args.kv_gather_degree, 2)
self.assertFalse(args.sp_split_auto)
def test_kv_gather_supports_tp(self):
args = _from_dict_without_model_resolution(
{
"model_path": "/fake",
"num_gpus": 4,
"tp_size": 2,
"sp_degree": 2,
"kv_gather_degree": 2,
"performance_mode": "manual",
}
)
self.assertEqual(args.tp_size, 2)
self.assertEqual(args.sp_degree, 2)
self.assertEqual(args.kv_gather_degree, 2)
def test_kv_gather_supports_fsdp(self):
args = _from_dict_without_model_resolution(
{
"model_path": "/fake",
"num_gpus": 2,
"sp_degree": 2,
"kv_gather_degree": 2,
"use_fsdp_inference": True,
"performance_mode": "manual",
}
)
self.assertTrue(args.use_fsdp_inference)
self.assertEqual(args.kv_gather_degree, 2)
def test_kv_gather_does_not_compose_yet(self):
for extra in ({"ulysses_degree": 2}, {"ring_degree": 2}):
with self.assertRaisesRegex(ValueError, "does not compose"):
_from_dict_without_model_resolution(
{
"model_path": "/fake",
"num_gpus": 4,
"sp_degree": 4,
"kv_gather_degree": 2,
"performance_mode": "manual",
**extra,
}
)
class TestFSDPShardConditions(unittest.TestCase):
def test_helpers_match_only_direct_block_entries(self):
self.assertTrue(
@@ -140,6 +140,16 @@ def test_strategy_shard_when_legal(monkeypatch):
assert sps.plan_text_strategy(16) == "shard"
def test_strategy_replicates_when_padding_spans_multiple_shards(monkeypatch):
_fake_sp(monkeypatch, 8)
assert sps.plan_text_strategy(1) == "replicate"
assert sps.plan_text_strategy(6) == "replicate"
assert sps.plan_text_strategy(7) == "shard"
assert sps.plan_text_strategy(9) == "replicate"
assert sps.plan_text_strategy(13) == "replicate"
assert sps.plan_text_strategy(14) == "shard"
def test_strategy_ring_blocks_padded_shard(monkeypatch):
_fake_sp(monkeypatch, 2, ring=2)
assert sps.plan_text_strategy(15) == "replicate" # padded shard needs mask
@@ -0,0 +1,348 @@
import unittest
from types import SimpleNamespace
from unittest.mock import patch
import torch
import torch.nn.functional as F
from sglang.multimodal_gen.runtime.layers.attention.layer import (
UlyssesAttention,
UlyssesAttention_VSA,
USPAttention,
_kv_gather_unsupported_reason,
_resolve_sp_attention_mode,
)
from sglang.multimodal_gen.runtime.platforms import AttentionBackendEnum
_LAYER = "sglang.multimodal_gen.runtime.layers.attention.layer"
class _SdpaAttention:
def __init__(self, scale: float):
self.scale = scale
def forward(self, q, k, v, _ctx):
return F.scaled_dot_product_attention(
q.transpose(1, 2),
k.transpose(1, 2),
v.transpose(1, 2),
dropout_p=0.0,
is_causal=False,
scale=self.scale,
).transpose(1, 2)
def _make_attention(head_dim: int) -> USPAttention:
obj = USPAttention.__new__(USPAttention)
obj.causal = False
obj.backend = AttentionBackendEnum.TORCH_SDPA
obj.softmax_scale = head_dim**-0.5
obj.attn_impl = _SdpaAttention(obj.softmax_scale)
obj.allow_cudnn_sdp = False
obj.skip_sequence_parallel = False
obj.sp_attention_mode = "kv_gather"
obj.sp_attention_mode_is_auto = False
return obj
def _reference_attention(q, k, v, scale, key_mask=None, query_mask=None):
out = F.scaled_dot_product_attention(
q.transpose(1, 2),
k.transpose(1, 2),
v.transpose(1, 2),
attn_mask=None if key_mask is None else key_mask[:, None, None, :],
dropout_p=0.0,
is_causal=False,
scale=scale,
).transpose(1, 2)
if query_mask is not None:
out = out * query_mask[:, :, None, None]
return out
class TestUSPAttentionKVGather(unittest.TestCase):
def setUp(self):
torch.manual_seed(0)
self.heads = 3
self.head_dim = 4
self.attn = _make_attention(self.head_dim)
def _run(self, q, k, v, gathered, **kwargs):
with (
patch(f"{_LAYER}.get_ring_parallel_world_size", return_value=1),
patch(
f"{_LAYER}.sequence_model_parallel_all_gather",
side_effect=gathered,
),
):
return self.attn._forward_with_kv_gather(
q,
k,
v,
None,
kwargs.pop("attn_mask", None),
kwargs.pop("attn_mask_meta", None),
kwargs.pop("num_replicated_prefix", 0),
kwargs.pop("num_replicated_suffix", 0),
kwargs.pop("num_replicated_kv_prefix", 0),
)
def test_local_queries_attend_gathered_kv(self):
q = torch.randn(1, 3, self.heads, self.head_dim)
k = torch.randn(1, 3, self.heads, self.head_dim)
v = torch.randn(1, 3, self.heads, self.head_dim)
full_k = torch.randn(1, 6, self.heads, self.head_dim)
full_v = torch.randn(1, 6, self.heads, self.head_dim)
out = self._run(q, k, v, [full_k, full_v])
expected = _reference_attention(q, full_k, full_v, self.attn.softmax_scale)
torch.testing.assert_close(out, expected)
def test_replicated_prefix_is_not_duplicated(self):
prefix = 2
q = torch.randn(1, 4, self.heads, self.head_dim)
k = torch.randn(1, 4, self.heads, self.head_dim)
v = torch.randn(1, 4, self.heads, self.head_dim)
gathered_k_suffix = torch.randn(1, 4, self.heads, self.head_dim)
gathered_v_suffix = torch.randn(1, 4, self.heads, self.head_dim)
full_k = torch.cat([k[:, :prefix], gathered_k_suffix], dim=1)
full_v = torch.cat([v[:, :prefix], gathered_v_suffix], dim=1)
out = self._run(
q,
k,
v,
[gathered_k_suffix, gathered_v_suffix],
num_replicated_prefix=prefix,
)
expected = _reference_attention(q, full_k, full_v, self.attn.softmax_scale)
torch.testing.assert_close(out, expected)
def test_padding_mask_uses_local_queries_and_global_keys(self):
q = torch.randn(2, 3, self.heads, self.head_dim)
k = torch.randn(2, 3, self.heads, self.head_dim)
v = torch.randn(2, 3, self.heads, self.head_dim)
full_k = torch.randn(2, 6, self.heads, self.head_dim)
full_v = torch.randn(2, 6, self.heads, self.head_dim)
query_mask = torch.tensor([[True, True, False], [True, True, True]])
key_mask = torch.tensor(
[
[True, True, False, True, False, False],
[True, True, True, True, True, False],
]
)
out = self._run(
q,
k,
v,
[full_k, full_v, key_mask],
attn_mask=query_mask,
attn_mask_meta={},
)
expected = _reference_attention(
q,
full_k,
full_v,
self.attn.softmax_scale,
key_mask=key_mask,
query_mask=query_mask,
)
torch.testing.assert_close(out, expected)
def test_separate_replicated_kv_prefix_gathers_only_suffix(self):
q = torch.randn(1, 3, self.heads, self.head_dim)
k_prefix = torch.randn(1, 2, self.heads, self.head_dim)
v_prefix = torch.randn(1, 2, self.heads, self.head_dim)
k_suffix = torch.randn(1, 3, self.heads, self.head_dim)
v_suffix = torch.randn(1, 3, self.heads, self.head_dim)
gathered_k_suffix = torch.randn(1, 6, self.heads, self.head_dim)
gathered_v_suffix = torch.randn(1, 6, self.heads, self.head_dim)
full_k = torch.cat([k_prefix, gathered_k_suffix], dim=1)
full_v = torch.cat([v_prefix, gathered_v_suffix], dim=1)
with (
patch(
f"{_LAYER}.get_forward_context",
return_value=SimpleNamespace(attn_metadata=None),
),
patch(f"{_LAYER}.get_sequence_parallel_world_size", return_value=2),
patch(f"{_LAYER}.get_ring_parallel_world_size", return_value=1),
patch(
f"{_LAYER}.sequence_model_parallel_all_gather",
side_effect=[gathered_k_suffix, gathered_v_suffix],
),
):
out = self.attn.forward_with_replicated_kv_prefix(
q, k_prefix, v_prefix, k_suffix, v_suffix
)
expected = _reference_attention(q, full_k, full_v, self.attn.softmax_scale)
torch.testing.assert_close(out, expected)
class TestUlyssesAttentionKVGather(unittest.TestCase):
def setUp(self):
torch.manual_seed(1)
self.heads = 3
self.head_dim = 4
self.attn = UlyssesAttention.__new__(UlyssesAttention)
self.attn.causal = False
self.attn.backend = AttentionBackendEnum.TORCH_SDPA
self.attn.softmax_scale = self.head_dim**-0.5
self.attn.attn_impl = _SdpaAttention(self.attn.softmax_scale)
self.attn.sp_attention_mode = "kv_gather"
self.attn.sp_attention_mode_is_auto = False
def _run(self, q, k, v, gathered, **kwargs):
with (
patch(
f"{_LAYER}.get_forward_context",
return_value=SimpleNamespace(attn_metadata=None),
),
patch(f"{_LAYER}.get_ring_parallel_world_size", return_value=1),
patch(
f"{_LAYER}.sequence_model_parallel_all_gather",
side_effect=gathered,
),
):
return self.attn.forward(
q,
k,
v,
kwargs.get("replicated_q"),
kwargs.get("replicated_k"),
kwargs.get("replicated_v"),
kwargs.get("seq_lens"),
)
def test_local_queries_attend_gathered_kv(self):
q = torch.randn(1, 3, self.heads, self.head_dim)
k = torch.randn(1, 3, self.heads, self.head_dim)
v = torch.randn(1, 3, self.heads, self.head_dim)
full_k = torch.randn(1, 6, self.heads, self.head_dim)
full_v = torch.randn(1, 6, self.heads, self.head_dim)
out, replicated_out = self._run(q, k, v, [full_k, full_v])
expected = _reference_attention(q, full_k, full_v, self.attn.softmax_scale)
torch.testing.assert_close(out, expected)
self.assertIsNone(replicated_out)
def test_replicated_suffix_is_computed_without_head_sharding(self):
q = torch.randn(1, 3, self.heads, self.head_dim)
k = torch.randn(1, 3, self.heads, self.head_dim)
v = torch.randn(1, 3, self.heads, self.head_dim)
replicated_q = torch.randn(1, 2, self.heads, self.head_dim)
replicated_k = torch.randn(1, 2, self.heads, self.head_dim)
replicated_v = torch.randn(1, 2, self.heads, self.head_dim)
full_k = torch.randn(1, 6, self.heads, self.head_dim)
full_v = torch.randn(1, 6, self.heads, self.head_dim)
out, replicated_out = self._run(
q,
k,
v,
[full_k, full_v],
replicated_q=replicated_q,
replicated_k=replicated_k,
replicated_v=replicated_v,
)
full_q = torch.cat([q, replicated_q], dim=1)
full_k = torch.cat([full_k, replicated_k], dim=1)
full_v = torch.cat([full_v, replicated_v], dim=1)
expected = _reference_attention(full_q, full_k, full_v, self.attn.softmax_scale)
torch.testing.assert_close(out, expected[:, : q.shape[1]])
torch.testing.assert_close(replicated_out, expected[:, q.shape[1] :])
def test_varlen_is_rejected(self):
q = torch.randn(1, 3, self.heads, self.head_dim)
with self.assertRaisesRegex(NotImplementedError, "varlen"):
self._run(q, q, q, [], seq_lens=[3, 3])
def test_video_sparse_attention_is_rejected(self):
attn = UlyssesAttention_VSA.__new__(UlyssesAttention_VSA)
attn.sp_attention_mode = "kv_gather"
q = torch.randn(1, 3, self.heads, self.head_dim)
with self.assertRaisesRegex(NotImplementedError, "video sparse"):
attn.forward(q, q, q, gate_compress=q)
class TestSpAttentionModeResolution(unittest.TestCase):
def _resolve(self, *, degree=2, auto=True, causal=False, sparse=False):
stub = SimpleNamespace(kv_gather_degree=degree, sp_split_auto=auto)
with patch(
"sglang.multimodal_gen.runtime.server_args.get_global_server_args",
return_value=stub,
):
return _resolve_sp_attention_mode(causal=causal, sparse_backend=sparse)
def test_gather_degree_selects_the_gather_exchange(self):
self.assertEqual(self._resolve(), ("kv_gather", True))
self.assertEqual(self._resolve(auto=False), ("kv_gather", False))
def test_degree_one_is_plain_ulysses(self):
self.assertEqual(self._resolve(degree=1), ("ulysses", False))
self.assertEqual(self._resolve(degree=1, causal=True), ("ulysses", False))
def test_auto_degree_falls_back_for_unsupported_layers(self):
self.assertEqual(self._resolve(causal=True), ("ulysses", True))
self.assertEqual(self._resolve(sparse=True), ("ulysses", True))
def test_explicit_degree_fails_closed(self):
with self.assertRaises(ValueError):
self._resolve(auto=False, causal=True)
with self.assertRaises(NotImplementedError):
self._resolve(auto=False, sparse=True)
class TestKVGatherCallSupport(unittest.TestCase):
def _reason(self, **overrides):
kwargs = dict(
qkv_pre_all_to_all=False,
replicated_mode_count=0,
attn_mask=None,
num_replicated_kv_prefix=0,
)
kwargs.update(overrides)
return _kv_gather_unsupported_reason(**kwargs)
def test_plain_and_masked_calls_are_supported(self):
self.assertIsNone(self._reason())
self.assertIsNone(self._reason(attn_mask=torch.ones(1, 4, dtype=torch.bool)))
def test_unsupported_shapes_are_reported(self):
self.assertIn("pre-all-to-all", self._reason(qkv_pre_all_to_all=True))
self.assertIn("replicated-token", self._reason(replicated_mode_count=2))
self.assertIn(
"KV-only prefix",
self._reason(
attn_mask=torch.ones(1, 4, dtype=torch.bool),
num_replicated_kv_prefix=2,
),
)
self.assertIn("[B, S_local]", self._reason(attn_mask=torch.ones(1, 1, 4)))
self.assertIn("integer padding", self._reason(attn_mask=torch.ones(1, 4)))
def test_explicit_mode_raises_and_auto_falls_back_at_dispatch(self):
attn = _make_attention(4)
attn.skip_sequence_parallel = False
q = torch.randn(1, 4, 3, 4)
with (
patch(
f"{_LAYER}.get_forward_context",
return_value=SimpleNamespace(attn_metadata=None),
),
patch(f"{_LAYER}.get_sequence_parallel_world_size", return_value=2),
):
attn.sp_attention_mode_is_auto = False
with self.assertRaisesRegex(NotImplementedError, "pre-all-to-all"):
attn.forward(q, q, q, qkv_pre_all_to_all=True)
if __name__ == "__main__":
unittest.main()