[diffusion] feat: cross-node sequence parallelism (Ulysses x Ring) (#33327)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -774,6 +774,21 @@ def get_ring_parallel_rank() -> int:
|
||||
return get_sp_group().ring_rank
|
||||
|
||||
|
||||
def get_ulysses_ctx() -> tuple[int, int]:
|
||||
"""(world_size, rank) of the Ulysses group; (1, 0) when uninitialized
|
||||
(unit tests / single-process debug paths)."""
|
||||
if not model_parallel_is_initialized():
|
||||
return 1, 0
|
||||
return get_ulysses_parallel_world_size(), get_ulysses_parallel_rank()
|
||||
|
||||
|
||||
def get_ring_ctx() -> tuple[int, int]:
|
||||
"""(world_size, rank) of the Ring group; (1, 0) when uninitialized."""
|
||||
if not model_parallel_is_initialized():
|
||||
return 1, 0
|
||||
return get_ring_parallel_world_size(), get_ring_parallel_rank()
|
||||
|
||||
|
||||
# PP
|
||||
def get_pp_group() -> PipelineGroupCoordinator:
|
||||
assert _PP is not None, "pipeline model parallel group is not initialized"
|
||||
|
||||
@@ -19,7 +19,6 @@ from sglang.multimodal_gen.runtime.distributed.communication_op import (
|
||||
sequence_model_parallel_all_gather,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.distributed.parallel_state import (
|
||||
get_ring_parallel_world_size,
|
||||
get_sp_parallel_rank,
|
||||
get_sp_world_size,
|
||||
)
|
||||
@@ -223,8 +222,6 @@ def plan_text_strategy(txt_len: int) -> str:
|
||||
# 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:
|
||||
return "replicate"
|
||||
return "shard"
|
||||
|
||||
@@ -178,39 +178,48 @@ def launch_server(server_args: ServerArgs, launch_http_server: bool = True):
|
||||
# Start a new server with multiple worker processes
|
||||
logger.info("Starting server...")
|
||||
|
||||
# num_gpus is the total world size across every node; each node runs
|
||||
# its own num_gpus // nnodes local workers, offset by node_rank into the
|
||||
# global rank space (mirrors srt's tp_size_per_node convention). With
|
||||
# nnodes == 1 this is exactly the prior single-node arithmetic.
|
||||
num_gpus = server_args.num_gpus
|
||||
nnodes = server_args.nnodes
|
||||
node_rank = server_args.node_rank
|
||||
local_num_gpus = num_gpus // nnodes
|
||||
rank_offset = node_rank * local_num_gpus
|
||||
processes = []
|
||||
|
||||
# Pipes for master to talk to slaves
|
||||
# Pipes for master to talk to slaves (local to this node)
|
||||
task_pipes_to_slaves_w = []
|
||||
task_pipes_to_slaves_r = []
|
||||
for _ in range(num_gpus - 1):
|
||||
for _ in range(local_num_gpus - 1):
|
||||
r, w = mp.Pipe(duplex=False)
|
||||
task_pipes_to_slaves_r.append(r)
|
||||
task_pipes_to_slaves_w.append(w)
|
||||
|
||||
# Pipes for slaves to talk to master
|
||||
# Pipes for slaves to talk to master (local to this node)
|
||||
result_pipes_from_slaves_w = []
|
||||
result_pipes_from_slaves_r = []
|
||||
for _ in range(num_gpus - 1):
|
||||
for _ in range(local_num_gpus - 1):
|
||||
r, w = mp.Pipe(duplex=False)
|
||||
result_pipes_from_slaves_r.append(r)
|
||||
result_pipes_from_slaves_w.append(w)
|
||||
|
||||
# Launch all worker processes
|
||||
# Launch this node's local worker processes
|
||||
master_port = server_args.master_port
|
||||
scheduler_pipe_readers = []
|
||||
scheduler_pipe_writers = []
|
||||
|
||||
for i in range(num_gpus):
|
||||
for i in range(local_num_gpus):
|
||||
rank = rank_offset + i
|
||||
reader, writer = mp.Pipe(duplex=False)
|
||||
scheduler_pipe_writers.append(writer)
|
||||
if i == 0: # Master worker
|
||||
if i == 0: # This node's local pipe master
|
||||
process = mp.Process(
|
||||
target=run_scheduler_process,
|
||||
args=(
|
||||
i, # local_rank
|
||||
i, # rank
|
||||
rank,
|
||||
master_port,
|
||||
server_args,
|
||||
writer,
|
||||
@@ -219,7 +228,7 @@ def launch_server(server_args: ServerArgs, launch_http_server: bool = True):
|
||||
task_pipes_to_slaves_w,
|
||||
result_pipes_from_slaves_r,
|
||||
),
|
||||
name=f"sglang-diffusionWorker-{i}",
|
||||
name=f"sglang-diffusionWorker-{rank}",
|
||||
daemon=True,
|
||||
)
|
||||
else: # Slave workers
|
||||
@@ -227,7 +236,7 @@ def launch_server(server_args: ServerArgs, launch_http_server: bool = True):
|
||||
target=run_scheduler_process,
|
||||
args=(
|
||||
i, # local_rank
|
||||
i, # rank
|
||||
rank,
|
||||
master_port,
|
||||
server_args,
|
||||
writer,
|
||||
@@ -236,7 +245,7 @@ def launch_server(server_args: ServerArgs, launch_http_server: bool = True):
|
||||
task_pipes_to_slaves_r[i - 1],
|
||||
result_pipes_from_slaves_w[i - 1],
|
||||
),
|
||||
name=f"sglang-diffusionWorker-{i}",
|
||||
name=f"sglang-diffusionWorker-{rank}",
|
||||
daemon=True,
|
||||
)
|
||||
scheduler_pipe_readers.append(reader)
|
||||
@@ -263,7 +272,8 @@ def launch_server(server_args: ServerArgs, launch_http_server: bool = True):
|
||||
data = reader.recv()
|
||||
except EOFError:
|
||||
logger.error(
|
||||
f"Rank {i} scheduler is dead. Please check if there are relevant logs."
|
||||
f"Rank {rank_offset + i} scheduler is dead. Please check if "
|
||||
"there are relevant logs."
|
||||
)
|
||||
processes[i].join()
|
||||
logger.error(f"Exit code: {processes[i].exitcode}")
|
||||
@@ -278,6 +288,22 @@ def launch_server(server_args: ServerArgs, launch_http_server: bool = True):
|
||||
|
||||
logger.debug("All workers are ready")
|
||||
|
||||
if node_rank != 0:
|
||||
# The TokenizerManager / HTTP surface lives on the node that owns
|
||||
# global rank 0; this node only hosts local workers, which tear
|
||||
# down together with the distributed group on shutdown.
|
||||
logger.info(
|
||||
"Node %d ready with %d local worker(s); no local HTTP surface.",
|
||||
node_rank,
|
||||
local_num_gpus,
|
||||
)
|
||||
try:
|
||||
for p in processes:
|
||||
p.join()
|
||||
finally:
|
||||
shutdown_scheduler_processes(None, processes, request_shutdown=False)
|
||||
return processes
|
||||
|
||||
if launch_http_server:
|
||||
if server_args.pipeline_config.task_type.is_action_gen():
|
||||
logger.info(
|
||||
|
||||
@@ -25,11 +25,13 @@ from sglang.multimodal_gen.runtime.distributed.communication_op import (
|
||||
sequence_model_parallel_all_to_all_4D,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.distributed.parallel_state import (
|
||||
get_ring_parallel_rank,
|
||||
get_ring_parallel_world_size,
|
||||
get_sequence_parallel_world_size,
|
||||
get_sp_group,
|
||||
get_sp_parallel_rank,
|
||||
get_sp_world_size,
|
||||
get_ulysses_parallel_rank,
|
||||
get_ulysses_parallel_world_size,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.layers.attention.backends import (
|
||||
@@ -45,6 +47,8 @@ from sglang.multimodal_gen.runtime.layers.attention.turbo_layer import (
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.layers.usp import (
|
||||
_ipc_input_a2a_qkv,
|
||||
_merge_attention_partials,
|
||||
_ring_attention_varlen,
|
||||
_usp_input_all_to_all,
|
||||
_usp_input_all_to_all_qkv,
|
||||
_usp_input_all_to_all_varlen,
|
||||
@@ -786,6 +790,13 @@ class USPAttention(nn.Module):
|
||||
), "Varlen USPAttention does not support masks or replicated tokens"
|
||||
if effective_skip_sp or get_sequence_parallel_world_size() == 1:
|
||||
return self.attn_impl.forward(q, k, v, ctx_attn_metadata)
|
||||
if get_ring_parallel_world_size() > 1:
|
||||
# The varlen all-to-all spans the combined SP group and is not
|
||||
# ring-aware; it would shuffle rows across ring ranks instead
|
||||
# of rotating KV, corrupting the output silently.
|
||||
raise NotImplementedError(
|
||||
"Varlen USPAttention does not support ring parallelism yet."
|
||||
)
|
||||
qkv = torch.cat([q, k, v], dim=0)
|
||||
qkv = _usp_input_all_to_all_varlen(qkv, seq_lens, head_dim=2)
|
||||
qkv = self.attn_impl.preprocess_qkv(qkv, ctx_attn_metadata)
|
||||
@@ -955,8 +966,15 @@ class USPAttention(nn.Module):
|
||||
).transpose(1, 2)
|
||||
|
||||
if get_ring_parallel_world_size() > 1:
|
||||
if (
|
||||
meta_only_pad
|
||||
and q.shape[0] == 1
|
||||
and self.backend == AttentionBackendEnum.FA
|
||||
):
|
||||
return self._forward_ring_tail_pad(q, k, v, attn_mask_meta)
|
||||
raise NotImplementedError(
|
||||
"USPAttention masked path does not support ring parallelism yet."
|
||||
"USPAttention masked path supports ring parallelism only "
|
||||
"for batch-1 tail-pad metadata on the FA backend."
|
||||
)
|
||||
if attn_mask is not None and attn_mask.dim() != 2:
|
||||
raise NotImplementedError(
|
||||
@@ -1132,15 +1150,19 @@ class USPAttention(nn.Module):
|
||||
raise ValueError(
|
||||
"USPAttention supports at most one replicated-token mode per call."
|
||||
)
|
||||
if sp_size > 1 and num_replicated_prefix > 0:
|
||||
# Replicated-token handling is keyed on the full SP group: with u=1,
|
||||
# r>1 the plain ring path would rotate the replicated tokens as if
|
||||
# they were sharded rows, double-counting them.
|
||||
sp_ws = get_sequence_parallel_world_size()
|
||||
if sp_ws > 1 and num_replicated_prefix > 0:
|
||||
return self._forward_with_replicated_prefix(
|
||||
q, k, v, ctx_attn_metadata, num_replicated_prefix
|
||||
)
|
||||
if sp_size > 1 and num_replicated_suffix > 0:
|
||||
if sp_ws > 1 and num_replicated_suffix > 0:
|
||||
return self._forward_with_replicated_suffix(
|
||||
q, k, v, ctx_attn_metadata, num_replicated_suffix
|
||||
)
|
||||
if sp_size > 1 and num_replicated_kv_prefix > 0:
|
||||
if sp_ws > 1 and num_replicated_kv_prefix > 0:
|
||||
return self._forward_with_replicated_kv_prefix(
|
||||
q, k, v, ctx_attn_metadata, num_replicated_kv_prefix
|
||||
)
|
||||
@@ -1183,6 +1205,39 @@ class USPAttention(nn.Module):
|
||||
|
||||
return out
|
||||
|
||||
def _forward_ring_tail_pad(
|
||||
self,
|
||||
q: torch.Tensor,
|
||||
k: torch.Tensor,
|
||||
v: torch.Tensor,
|
||||
attn_mask_meta: dict,
|
||||
) -> torch.Tensor:
|
||||
"""Ring attention for a tail-padded shard.
|
||||
|
||||
After the Ulysses all-to-all each ring rank holds one contiguous block
|
||||
of the gathered sequence, and the tail-pad invariant keeps all padding
|
||||
at the global tail — exactly the ring kernel's real-length clamp, so
|
||||
no masks or repacking are needed. Pad rows receive garbage output,
|
||||
which the tail-pad consumers already trim.
|
||||
"""
|
||||
q, k, v = _usp_input_all_to_all_qkv(q, k, v)
|
||||
out = _ring_attention_varlen(
|
||||
q.squeeze(0),
|
||||
k.squeeze(0),
|
||||
v.squeeze(0),
|
||||
softmax_scale=self.softmax_scale,
|
||||
real_seq_len=int(attn_mask_meta["pad_start"]),
|
||||
ring_ws=get_ring_parallel_world_size(),
|
||||
)
|
||||
# Match the Ulysses tail path: masked query rows read as zeros. This
|
||||
# rank's chunk covers global rows [rank*chunk, (rank+1)*chunk).
|
||||
pad_from = (
|
||||
int(attn_mask_meta["pad_start"]) - get_ring_parallel_rank() * out.shape[0]
|
||||
)
|
||||
if pad_from < out.shape[0]:
|
||||
out[max(pad_from, 0) :].zero_()
|
||||
return _usp_output_all_to_all(out.unsqueeze(0), head_dim=2)
|
||||
|
||||
@staticmethod
|
||||
def _gather_sharded_sequence(
|
||||
tensor: torch.Tensor,
|
||||
@@ -1389,13 +1444,8 @@ class USPAttention(nn.Module):
|
||||
4. Concatenate [prefix_h_local, gathered_suffix] and run attention.
|
||||
5. Split output, all-to-all back the suffix, all-gather prefix heads.
|
||||
"""
|
||||
if get_ring_parallel_world_size() > 1:
|
||||
raise NotImplementedError(
|
||||
"USPAttention replicated-prefix/suffix path does not support "
|
||||
"ring parallelism yet."
|
||||
)
|
||||
sp_size = get_ulysses_parallel_world_size()
|
||||
sp_rank = get_sp_parallel_rank()
|
||||
u_rank = get_ulysses_parallel_rank()
|
||||
|
||||
q_rep, q_shard = q[:, :num_rep], q[:, num_rep:]
|
||||
k_rep, k_shard = k[:, :num_rep], k[:, num_rep:]
|
||||
@@ -1410,33 +1460,73 @@ class USPAttention(nn.Module):
|
||||
# For MHA (kv heads == q heads) this is identical to the q shard.
|
||||
h_local = q_shard.shape[2]
|
||||
kv_h_local = k_shard.shape[2]
|
||||
h_start = sp_rank * h_local
|
||||
kv_h_start = sp_rank * kv_h_local
|
||||
h_start = u_rank * h_local
|
||||
kv_h_start = u_rank * kv_h_local
|
||||
q_rep = q_rep[:, :, h_start : h_start + h_local, :].contiguous()
|
||||
k_rep = k_rep[:, :, kv_h_start : kv_h_start + kv_h_local, :].contiguous()
|
||||
v_rep = v_rep[:, :, kv_h_start : kv_h_start + kv_h_local, :].contiguous()
|
||||
|
||||
q = torch.cat([q_rep, q_shard], dim=1)
|
||||
k = torch.cat([k_rep, k_shard], dim=1)
|
||||
v = torch.cat([v_rep, v_shard], dim=1)
|
||||
|
||||
out = self.attn_impl.forward(q, k, v, ctx_attn_metadata)
|
||||
out = self._replicated_kv_attention(
|
||||
q, k_shard, v_shard, k_rep, v_rep, ctx_attn_metadata
|
||||
)
|
||||
|
||||
out_rep = out[:, :num_rep]
|
||||
out_shard = out[:, num_rep:]
|
||||
|
||||
out_shard = _usp_output_all_to_all(out_shard, head_dim=2)
|
||||
|
||||
gathered = [torch.empty_like(out_rep) for _ in range(sp_size)]
|
||||
torch.distributed.all_gather(
|
||||
gathered,
|
||||
out_rep.contiguous(),
|
||||
group=get_sp_group().ulysses_group,
|
||||
)
|
||||
out_rep = torch.cat(gathered, dim=2)
|
||||
if sp_size > 1:
|
||||
gathered = [torch.empty_like(out_rep) for _ in range(sp_size)]
|
||||
torch.distributed.all_gather(
|
||||
gathered,
|
||||
out_rep.contiguous(),
|
||||
group=get_sp_group().ulysses_group,
|
||||
)
|
||||
out_rep = torch.cat(gathered, dim=2)
|
||||
|
||||
return torch.cat([out_rep, out_shard], dim=1)
|
||||
|
||||
def _replicated_kv_attention(
|
||||
self,
|
||||
q: torch.Tensor,
|
||||
k_shard: torch.Tensor,
|
||||
v_shard: torch.Tensor,
|
||||
k_rep: torch.Tensor,
|
||||
v_rep: torch.Tensor,
|
||||
ctx_attn_metadata,
|
||||
rep_first: bool = True,
|
||||
) -> torch.Tensor:
|
||||
"""Attention of q against replicated + ring-sharded KV.
|
||||
|
||||
Without ring parallelism the two KV parts concatenate into one local
|
||||
kernel call, replicated part first unless `rep_first=False` (the
|
||||
suffix path keeps KV in tail order for bitwise stability). Under ring
|
||||
parallelism the sharded KV rotates around the ring while the
|
||||
replicated KV contributes one extra local partial, LSE-merged with
|
||||
the ring result (exact up to float reordering).
|
||||
"""
|
||||
if get_ring_parallel_world_size() > 1:
|
||||
out_ring, lse_ring = ring_attn(
|
||||
q, k_shard, v_shard, self.attn_impl, return_softmax_lse=True
|
||||
)
|
||||
out_rep, lse_rep, *_ = self.attn_impl.forward(
|
||||
q, k_rep, v_rep, attn_metadata=None, return_softmax_lse=True
|
||||
)
|
||||
merged = _merge_attention_partials(out_ring, lse_ring, out_rep, lse_rep)
|
||||
return merged.to(q.dtype)
|
||||
kv_parts = (
|
||||
([k_rep, k_shard], [v_rep, v_shard])
|
||||
if rep_first
|
||||
else (
|
||||
[k_shard, k_rep],
|
||||
[v_shard, v_rep],
|
||||
)
|
||||
)
|
||||
k = torch.cat(kv_parts[0], dim=1)
|
||||
v = torch.cat(kv_parts[1], dim=1)
|
||||
return self.attn_impl.forward(q, k, v, ctx_attn_metadata)
|
||||
|
||||
def forward_with_replicated_kv_prefix(
|
||||
self,
|
||||
q: torch.Tensor,
|
||||
@@ -1461,7 +1551,10 @@ 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 get_ulysses_parallel_world_size() == 1:
|
||||
if (
|
||||
get_ulysses_parallel_world_size() == 1
|
||||
and get_ring_parallel_world_size() == 1
|
||||
):
|
||||
k = torch.cat([k_prefix, k_suffix], dim=1)
|
||||
v = torch.cat([v_prefix, v_suffix], dim=1)
|
||||
return self(q, k, v)
|
||||
@@ -1508,14 +1601,9 @@ class USPAttention(nn.Module):
|
||||
ctx_attn_metadata,
|
||||
) -> torch.Tensor:
|
||||
"""split form avoids materializing full K/V before Ulysses all-to-all"""
|
||||
if get_ring_parallel_world_size() > 1:
|
||||
raise NotImplementedError(
|
||||
"USPAttention replicated-kv-prefix path does not support "
|
||||
"ring parallelism yet."
|
||||
)
|
||||
sp_rank = get_sp_parallel_rank()
|
||||
u_rank = get_ulysses_parallel_rank()
|
||||
|
||||
if q.device.type == "cuda":
|
||||
if q.device.type == "cuda" and get_ulysses_parallel_world_size() > 1:
|
||||
q, k_shard, v_shard = async_a2a_communicate(
|
||||
[q, k_shard, v_shard],
|
||||
get_ulysses_parallel_world_size(),
|
||||
@@ -1532,15 +1620,14 @@ class USPAttention(nn.Module):
|
||||
v_shard = _usp_input_all_to_all(v_shard, head_dim=2)
|
||||
|
||||
h_kv_local = k_shard.shape[2]
|
||||
h_start = sp_rank * h_kv_local
|
||||
h_start = u_rank * h_kv_local
|
||||
h_end = h_start + h_kv_local
|
||||
k_rep = k_rep[:, :, h_start:h_end, :].contiguous()
|
||||
v_rep = v_rep[:, :, h_start:h_end, :].contiguous()
|
||||
|
||||
k = torch.cat([k_rep, k_shard], dim=1)
|
||||
v = torch.cat([v_rep, v_shard], dim=1)
|
||||
|
||||
out = self.attn_impl.forward(q, k, v, ctx_attn_metadata)
|
||||
out = self._replicated_kv_attention(
|
||||
q, k_shard, v_shard, k_rep, v_rep, ctx_attn_metadata
|
||||
)
|
||||
return _usp_output_all_to_all(out, head_dim=2)
|
||||
|
||||
def _forward_with_replicated_suffix(
|
||||
@@ -1561,12 +1648,7 @@ class USPAttention(nn.Module):
|
||||
"""
|
||||
if num_rep <= 0:
|
||||
raise ValueError("num_rep must be positive for replicated suffix.")
|
||||
if get_ring_parallel_world_size() > 1:
|
||||
raise NotImplementedError(
|
||||
"USPAttention replicated-prefix/suffix path does not support "
|
||||
"ring parallelism yet."
|
||||
)
|
||||
sp_rank = get_sp_parallel_rank()
|
||||
u_rank = get_ulysses_parallel_rank()
|
||||
|
||||
q_shard, q_rep = q[:, :-num_rep], q[:, -num_rep:]
|
||||
k_shard, k_rep = k[:, :-num_rep], k[:, -num_rep:]
|
||||
@@ -1578,17 +1660,16 @@ class USPAttention(nn.Module):
|
||||
|
||||
h_local = q_shard.shape[2]
|
||||
kv_h_local = k_shard.shape[2]
|
||||
h_start = sp_rank * h_local
|
||||
kv_h_start = sp_rank * kv_h_local
|
||||
h_start = u_rank * h_local
|
||||
kv_h_start = u_rank * kv_h_local
|
||||
q_rep = q_rep[:, :, h_start : h_start + h_local, :].contiguous()
|
||||
k_rep = k_rep[:, :, kv_h_start : kv_h_start + kv_h_local, :].contiguous()
|
||||
v_rep = v_rep[:, :, kv_h_start : kv_h_start + kv_h_local, :].contiguous()
|
||||
|
||||
q = torch.cat([q_shard, q_rep], dim=1)
|
||||
k = torch.cat([k_shard, k_rep], dim=1)
|
||||
v = torch.cat([v_shard, v_rep], dim=1)
|
||||
|
||||
out = self.attn_impl.forward(q, k, v, ctx_attn_metadata)
|
||||
out = self._replicated_kv_attention(
|
||||
q, k_shard, v_shard, k_rep, v_rep, ctx_attn_metadata, rep_first=False
|
||||
)
|
||||
|
||||
out_shard = out[:, :-num_rep]
|
||||
out_rep = out[:, -num_rep:]
|
||||
@@ -1596,13 +1677,14 @@ class USPAttention(nn.Module):
|
||||
out_shard = _usp_output_all_to_all(out_shard, head_dim=2)
|
||||
|
||||
sp_size = get_ulysses_parallel_world_size()
|
||||
gathered = [torch.empty_like(out_rep) for _ in range(sp_size)]
|
||||
torch.distributed.all_gather(
|
||||
gathered,
|
||||
out_rep.contiguous(),
|
||||
group=get_sp_group().ulysses_group,
|
||||
)
|
||||
out_rep = torch.cat(gathered, dim=2)
|
||||
if sp_size > 1:
|
||||
gathered = [torch.empty_like(out_rep) for _ in range(sp_size)]
|
||||
torch.distributed.all_gather(
|
||||
gathered,
|
||||
out_rep.contiguous(),
|
||||
group=get_sp_group().ulysses_group,
|
||||
)
|
||||
out_rep = torch.cat(gathered, dim=2)
|
||||
|
||||
return torch.cat([out_shard, out_rep], dim=1)
|
||||
|
||||
|
||||
@@ -8,15 +8,20 @@ import torch.distributed as dist
|
||||
import torch.distributed._functional_collectives as ft_c
|
||||
from torch.distributed.tensor.experimental._attention import _cp_options
|
||||
|
||||
from sglang.kernels.ops.attention.flash_attention import flash_attn_varlen_func
|
||||
from sglang.kernels.ops.diffusion.triton.ulysses_qkv import (
|
||||
pack_qkv_destination_major,
|
||||
)
|
||||
from sglang.kernels.ops.diffusion.usp_relayout import usp_merge_heads
|
||||
from sglang.multimodal_gen.runtime.distributed.parallel_state import (
|
||||
get_ring_ctx,
|
||||
get_sp_group,
|
||||
get_ulysses_parallel_rank,
|
||||
get_ulysses_parallel_world_size,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.layers.attention.backends import (
|
||||
flash_attn as _fa_backend,
|
||||
)
|
||||
from sglang.srt.utils.common import torch_release
|
||||
|
||||
_cp_options.enable_load_balance = False
|
||||
@@ -675,6 +680,7 @@ def ring_attn(
|
||||
attn_impl: "AttentionImpl",
|
||||
is_causal: bool = False,
|
||||
dropout_p: float = 0.0,
|
||||
return_softmax_lse: bool = False,
|
||||
):
|
||||
"""
|
||||
Ring Attention implementation.
|
||||
@@ -748,15 +754,169 @@ def ring_attn(
|
||||
if use_segment_id:
|
||||
# For torch >= 2.6, segment_id is required. The value '1' is a placeholder
|
||||
# as we are not using complex segmentation features.
|
||||
out, *_ = _templated_ring_attention(
|
||||
out, lse, *_ = _templated_ring_attention(
|
||||
seq_dim=1, # segment_id
|
||||
**attn_kwargs,
|
||||
)
|
||||
else:
|
||||
out, *_ = _templated_ring_attention(
|
||||
out, lse, *_ = _templated_ring_attention(
|
||||
**attn_kwargs,
|
||||
)
|
||||
|
||||
# Permute the output back to [B, S, H, D] layout.
|
||||
output = torch.permute(out, [0, 2, 1, 3])
|
||||
if return_softmax_lse:
|
||||
return output, lse
|
||||
return output
|
||||
|
||||
|
||||
def _merge_attention_partials(
|
||||
out_a: torch.Tensor,
|
||||
lse_a: torch.Tensor,
|
||||
out_b: torch.Tensor,
|
||||
lse_b: torch.Tensor,
|
||||
) -> torch.Tensor:
|
||||
"""Merge two attention partials computed over disjoint KV sets.
|
||||
|
||||
`out_*` are `[B, S, H, D]`; `lse_*` are the dense-FA LSE layout `[B, H, S]`.
|
||||
Each partial is self-normalized over its own KV, so the exact combine is a
|
||||
two-term logsumexp reweighting; done in fp32 for stability.
|
||||
"""
|
||||
lse_a = lse_a.transpose(1, 2).unsqueeze(-1).to(torch.float32)
|
||||
lse_b = lse_b.transpose(1, 2).unsqueeze(-1).to(torch.float32)
|
||||
new_lse = torch.logaddexp(lse_a, lse_b)
|
||||
return out_a.to(torch.float32) * torch.exp(lse_a - new_lse) + out_b.to(
|
||||
torch.float32
|
||||
) * torch.exp(lse_b - new_lse)
|
||||
|
||||
|
||||
def _ring_merge_attention(
|
||||
out_acc: torch.Tensor | None,
|
||||
lse_acc: torch.Tensor | None,
|
||||
step_out: torch.Tensor,
|
||||
step_lse: torch.Tensor,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""Online-softmax combine of one more ring step's partial attention.
|
||||
|
||||
`step_out` is `[T, H, D]`; `step_lse` is FlashAttention's varlen LSE
|
||||
layout `[H, T]`. Both are already self-normalized over their own KV
|
||||
chunk, so combining chunks is the standard two-term logsumexp merge
|
||||
(exact up to float rounding); done in fp32 for stability.
|
||||
"""
|
||||
step_lse = step_lse.transpose(0, 1).unsqueeze(-1).to(torch.float32)
|
||||
step_out = step_out.to(torch.float32)
|
||||
if out_acc is None:
|
||||
return step_out, step_lse
|
||||
new_lse = torch.logaddexp(lse_acc, step_lse)
|
||||
out_acc = out_acc * torch.exp(lse_acc - new_lse) + step_out * torch.exp(
|
||||
step_lse - new_lse
|
||||
)
|
||||
return out_acc, new_lse
|
||||
|
||||
|
||||
def _ring_attention_varlen(
|
||||
q: torch.Tensor,
|
||||
k: torch.Tensor,
|
||||
v: torch.Tensor,
|
||||
*,
|
||||
softmax_scale: float,
|
||||
real_seq_len: int,
|
||||
ring_ws: int,
|
||||
) -> torch.Tensor:
|
||||
"""Ring-rotated varlen attention over one rank's local packed chunk.
|
||||
|
||||
`q, k, v` are this rank's full local ring chunk (`ring_chunk_len` rows,
|
||||
real rows followed by however many of this chunk's rows are padding).
|
||||
KV is P2P-rotated around the ring one hop per step (send this step's
|
||||
buffer to the next rank, receive the following step's buffer from the
|
||||
previous rank) so the hop for step+1 overlaps this step's attention
|
||||
compute -- unlike a single blocking all_gather, no step waits on the
|
||||
full ring's transfer before its own compute can start. Each hop's
|
||||
*real* prefix is attended locally and merged via online softmax.
|
||||
Padding rows never contribute to any KV chunk (their output is unused
|
||||
downstream, but must not be corrupted by attending across the padding
|
||||
boundary), and a chunk that is entirely padding is skipped outright.
|
||||
"""
|
||||
ring_pg = get_sp_group().ring_group
|
||||
assert ring_pg is not None, "Ring process group is not initialized."
|
||||
ring_chunk_len = q.shape[0]
|
||||
_, ring_rank = get_ring_ctx()
|
||||
|
||||
# `isend`/`irecv` (unlike collectives) address peers by global rank even
|
||||
# under a sub-group, so resolve this ring's neighbors once up front.
|
||||
next_global_rank = torch.distributed.get_global_rank(
|
||||
ring_pg, (ring_rank + 1) % ring_ws
|
||||
)
|
||||
prev_global_rank = torch.distributed.get_global_rank(
|
||||
ring_pg, (ring_rank - 1) % ring_ws
|
||||
)
|
||||
|
||||
# K and V travel as one stacked buffer so each hop is a single P2P
|
||||
# send/recv pair instead of two; stacking also makes the buffer
|
||||
# contiguous, so no separate .contiguous() call is needed.
|
||||
kv0 = torch.stack((k, v))
|
||||
kv_bufs = [kv0, torch.empty_like(kv0)]
|
||||
cur = 0
|
||||
|
||||
q_cu = torch.tensor([0, ring_chunk_len], dtype=torch.int32, device=q.device)
|
||||
out_acc: torch.Tensor | None = None
|
||||
lse_acc: torch.Tensor | None = None
|
||||
pending_ops = None
|
||||
for step in range(ring_ws):
|
||||
nxt = 1 - cur
|
||||
if step < ring_ws - 1:
|
||||
# kick off next hop before this step's compute so the transfer
|
||||
# overlaps it; wait for completion only after issuing compute.
|
||||
pending_ops = torch.distributed.batch_isend_irecv(
|
||||
[
|
||||
torch.distributed.P2POp(
|
||||
torch.distributed.isend,
|
||||
kv_bufs[cur],
|
||||
next_global_rank,
|
||||
group=ring_pg,
|
||||
),
|
||||
torch.distributed.P2POp(
|
||||
torch.distributed.irecv,
|
||||
kv_bufs[nxt],
|
||||
prev_global_rank,
|
||||
group=ring_pg,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
src_rank = (ring_rank - step) % ring_ws
|
||||
remote_used = min(
|
||||
max(real_seq_len - src_rank * ring_chunk_len, 0), ring_chunk_len
|
||||
)
|
||||
if remote_used > 0:
|
||||
k_cu = torch.tensor([0, remote_used], dtype=torch.int32, device=q.device)
|
||||
result = flash_attn_varlen_func(
|
||||
q,
|
||||
kv_bufs[cur][0, :remote_used],
|
||||
kv_bufs[cur][1, :remote_used],
|
||||
cu_seqlens_q=q_cu,
|
||||
cu_seqlens_k=k_cu,
|
||||
max_seqlen_q=ring_chunk_len,
|
||||
max_seqlen_k=remote_used,
|
||||
softmax_scale=softmax_scale,
|
||||
causal=False,
|
||||
ver=_fa_backend.fa_ver,
|
||||
return_softmax_lse=True,
|
||||
)
|
||||
if not isinstance(result, tuple):
|
||||
raise RuntimeError(
|
||||
"flash_attn_varlen_func did not return softmax_lse; ring "
|
||||
"parallelism requires a backend that supports "
|
||||
"return_softmax_lse=True."
|
||||
)
|
||||
step_out, step_lse, *_ = result
|
||||
out_acc, lse_acc = _ring_merge_attention(
|
||||
out_acc, lse_acc, step_out, step_lse
|
||||
)
|
||||
|
||||
if pending_ops is not None:
|
||||
for op in pending_ops:
|
||||
op.wait()
|
||||
pending_ops = None
|
||||
cur = nxt
|
||||
return out_acc.to(q.dtype)
|
||||
|
||||
@@ -223,12 +223,21 @@ class GPUWorker(GPUWorkerPostTrainingMixin):
|
||||
def init_device_and_model(self) -> None:
|
||||
"""Initialize the device and load the model."""
|
||||
current_platform.set_device(current_platform.get_device(self.local_rank))
|
||||
intra_op_threads = _worker_cpu_intra_op_threads(self.server_args.num_gpus)
|
||||
# num_gpus is the total world size across every node; the co-located,
|
||||
# CPU-contending worker count on THIS host is num_gpus // nnodes.
|
||||
local_num_gpus = self.server_args.num_gpus // self.server_args.nnodes
|
||||
intra_op_threads = _worker_cpu_intra_op_threads(local_num_gpus)
|
||||
if intra_op_threads is not None:
|
||||
torch.set_num_threads(intra_op_threads)
|
||||
# Set environment variables for distributed initialization
|
||||
os.environ["MASTER_ADDR"] = "localhost"
|
||||
os.environ["MASTER_PORT"] = str(self.master_port)
|
||||
# Set environment variables for distributed initialization. Single
|
||||
# node rendezvous stays on loopback; cross-node rendezvous must use
|
||||
# an address every node can reach, so --dist-init-addr takes over.
|
||||
if self.server_args.nnodes > 1:
|
||||
rendezvous_addr = NetworkAddress.parse(self.server_args.dist_init_addr)
|
||||
else:
|
||||
rendezvous_addr = NetworkAddress("127.0.0.1", self.master_port)
|
||||
os.environ["MASTER_ADDR"] = rendezvous_addr.host
|
||||
os.environ["MASTER_PORT"] = str(rendezvous_addr.port)
|
||||
os.environ["LOCAL_RANK"] = str(self.local_rank)
|
||||
os.environ["RANK"] = str(self.rank)
|
||||
os.environ["WORLD_SIZE"] = str(self.server_args.num_gpus)
|
||||
@@ -241,9 +250,7 @@ class GPUWorker(GPUWorkerPostTrainingMixin):
|
||||
ring_degree=self.server_args.ring_degree,
|
||||
sp_size=self.server_args.sp_degree,
|
||||
dp_size=self.server_args.dp_size,
|
||||
distributed_init_method=NetworkAddress(
|
||||
"127.0.0.1", self.master_port
|
||||
).to_tcp(),
|
||||
distributed_init_method=rendezvous_addr.to_tcp(),
|
||||
dist_timeout=self.server_args.dist_timeout,
|
||||
)
|
||||
|
||||
|
||||
@@ -36,6 +36,10 @@ from sglang.multimodal_gen.runtime.distributed import (
|
||||
get_tp_world_size,
|
||||
tensor_model_parallel_all_gather,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.distributed.parallel_state import (
|
||||
get_ring_ctx,
|
||||
get_ulysses_ctx,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.layers.attention.backends.attention_backend import (
|
||||
AttentionRequirements,
|
||||
)
|
||||
@@ -48,6 +52,7 @@ from sglang.multimodal_gen.runtime.layers.linear import (
|
||||
from sglang.multimodal_gen.runtime.layers.quantization.configs.base_config import (
|
||||
QuantizationConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.layers.usp import _ring_attention_varlen
|
||||
from sglang.multimodal_gen.runtime.managers.memory_managers.layerwise_offload import (
|
||||
LayerwiseOffloadableModuleMixin,
|
||||
is_layerwise_offloaded_module,
|
||||
@@ -118,34 +123,6 @@ _FORWARD_SUPPORTED_KWARGS = frozenset(
|
||||
)
|
||||
|
||||
|
||||
def _ulysses_ctx() -> tuple[int, int]:
|
||||
"""(world_size, rank) of the Ulysses sequence-parallel group.
|
||||
|
||||
Returns (1, 0) when model parallelism is not initialized (unit tests /
|
||||
single-process debug paths init tp=1 sp=1 which also yields ws=1).
|
||||
"""
|
||||
from sglang.multimodal_gen.runtime.distributed.parallel_state import (
|
||||
get_ulysses_parallel_rank,
|
||||
get_ulysses_parallel_world_size,
|
||||
model_parallel_is_initialized,
|
||||
)
|
||||
|
||||
if not model_parallel_is_initialized():
|
||||
return 1, 0
|
||||
return get_ulysses_parallel_world_size(), get_ulysses_parallel_rank()
|
||||
|
||||
|
||||
def _ring_world_size() -> int:
|
||||
from sglang.multimodal_gen.runtime.distributed.parallel_state import (
|
||||
get_ring_parallel_world_size,
|
||||
model_parallel_is_initialized,
|
||||
)
|
||||
|
||||
if not model_parallel_is_initialized():
|
||||
return 1
|
||||
return get_ring_parallel_world_size()
|
||||
|
||||
|
||||
def _reorder_grouped_qkv_to_qkv(
|
||||
weight: torch.Tensor,
|
||||
*,
|
||||
@@ -467,8 +444,9 @@ def _minimax_h3_attention_core_impl(
|
||||
cu_seqlens_host: tuple[int, ...] | None,
|
||||
max_seqlen: int,
|
||||
ulysses_active: bool,
|
||||
ring_active: bool = False,
|
||||
) -> torch.Tensor:
|
||||
"""Dynamic varlen attention and Ulysses collectives.
|
||||
"""Dynamic varlen attention and Ulysses/Ring collectives.
|
||||
|
||||
This is the narrow BCG break point: projections, normalization, RoPE,
|
||||
residuals, and MLPs remain captured while the dynamic packed attention
|
||||
@@ -491,14 +469,33 @@ def _minimax_h3_attention_core_impl(
|
||||
attention_requirements=AttentionRequirements(packed_varlen=True),
|
||||
)
|
||||
)
|
||||
out = attention._attention_impl.forward_varlen(
|
||||
q,
|
||||
k,
|
||||
v,
|
||||
cu_seqlens=cu_seqlens,
|
||||
max_seqlen=max_seqlen,
|
||||
cu_seqlens_host=cu_seqlens_host,
|
||||
)
|
||||
|
||||
if ring_active:
|
||||
ring_ws, _ = get_ring_ctx()
|
||||
if attention._attention_backend_enum is not AttentionBackendEnum.FA:
|
||||
raise NotImplementedError(
|
||||
"MiniMax H3 ring parallelism requires the FlashAttention "
|
||||
"backend (matches --ring-degree's general restriction)."
|
||||
)
|
||||
# max_seqlen is cu_seqlens[1] (`used`) by construction -- the real,
|
||||
# non-padding row count ring needs, already a host int here.
|
||||
out = _ring_attention_varlen(
|
||||
q,
|
||||
k,
|
||||
v,
|
||||
softmax_scale=attention.softmax_scale,
|
||||
real_seq_len=max_seqlen,
|
||||
ring_ws=ring_ws,
|
||||
)
|
||||
else:
|
||||
out = attention._attention_impl.forward_varlen(
|
||||
q,
|
||||
k,
|
||||
v,
|
||||
cu_seqlens=cu_seqlens,
|
||||
max_seqlen=max_seqlen,
|
||||
cu_seqlens_host=cu_seqlens_host,
|
||||
)
|
||||
if ulysses_active:
|
||||
out = _usp_output_all_to_all(out[None], head_dim=2)[0]
|
||||
return out
|
||||
@@ -531,6 +528,7 @@ class MiniMaxH3Attention(nn.Module):
|
||||
self.local_inner_dim = self.num_heads * self.head_dim
|
||||
self.softmax_scale = self.head_dim**-0.5
|
||||
self._attention_impl = None
|
||||
self._attention_backend_enum: AttentionBackendEnum | None = None
|
||||
# The checkpoint stores one fused qkv tensor. Each logical Q/K/V
|
||||
# matrix must be sharded independently; a plain ColumnParallelLinear
|
||||
# would instead slice across the concatenated tensor and is incorrect
|
||||
@@ -579,6 +577,10 @@ class MiniMaxH3Attention(nn.Module):
|
||||
softmax_scale=self.softmax_scale,
|
||||
num_kv_heads=self.num_heads,
|
||||
)
|
||||
# Ring only supports FA (see _minimax_h3_attention_core_impl); keep
|
||||
# the resolved enum alongside the impl instance instead of a second
|
||||
# get_attn_backend() call at the ring gate.
|
||||
self._attention_backend_enum = backend.get_enum()
|
||||
|
||||
def _install_qkv_weight_loader(self, arch: MiniMaxH3DiTArchConfig) -> None:
|
||||
weight = self.qkv_proj.weight
|
||||
@@ -620,6 +622,7 @@ class MiniMaxH3Attention(nn.Module):
|
||||
cu_seqlens_host: tuple[int, ...] | None = None,
|
||||
max_seqlen: int,
|
||||
ulysses_active: bool = False,
|
||||
ring_active: bool = False,
|
||||
) -> torch.Tensor:
|
||||
"""x: [T, hidden] packed thd rows -> [T, hidden].
|
||||
|
||||
@@ -686,6 +689,7 @@ class MiniMaxH3Attention(nn.Module):
|
||||
cu_seqlens_host=cu_seqlens_host,
|
||||
max_seqlen=max_seqlen,
|
||||
ulysses_active=ulysses_active,
|
||||
ring_active=ring_active,
|
||||
)
|
||||
out = out.reshape(total, self.num_heads * self.head_dim)
|
||||
out, _ = self.out_proj(out)
|
||||
@@ -905,6 +909,7 @@ class MiniMaxH3DiTBlock(nn.Module):
|
||||
cu_seqlens_host: tuple[int, ...] | None = None,
|
||||
max_seqlen: int,
|
||||
ulysses_active: bool = False,
|
||||
ring_active: bool = False,
|
||||
adaln_params: tuple[torch.Tensor, ...] | None = None,
|
||||
) -> torch.Tensor:
|
||||
"""x: [T, H]; adaln_input: [M, t_dim]; combined_indices: [T]
|
||||
@@ -930,6 +935,7 @@ class MiniMaxH3DiTBlock(nn.Module):
|
||||
cu_seqlens_host=cu_seqlens_host,
|
||||
max_seqlen=max_seqlen,
|
||||
ulysses_active=ulysses_active,
|
||||
ring_active=ring_active,
|
||||
)
|
||||
x = _modulate_gate(residual, gate_msa, h, combined_indices, dtype=_BF16_DTYPE)
|
||||
|
||||
@@ -1070,12 +1076,8 @@ class MiniMaxH3DiTModel(BaseDiT, LayerwiseOffloadableModuleMixin):
|
||||
) -> None:
|
||||
if ulysses_size <= 0:
|
||||
raise ValueError("MiniMax H3 Ulysses size must be positive.")
|
||||
if ring_size != 1:
|
||||
raise NotImplementedError(
|
||||
"MiniMax H3 packed multi-segment attention does not support "
|
||||
"Ring or mixed USP. Set --ring-degree 1 and use Ulysses "
|
||||
"sequence parallelism."
|
||||
)
|
||||
if ring_size <= 0:
|
||||
raise ValueError("MiniMax H3 ring size must be positive.")
|
||||
local_heads = arch.num_attention_heads // tp_size
|
||||
if local_heads % ulysses_size:
|
||||
raise ValueError(
|
||||
@@ -1083,13 +1085,19 @@ class MiniMaxH3DiTModel(BaseDiT, LayerwiseOffloadableModuleMixin):
|
||||
f"Ulysses size {ulysses_size} (total heads="
|
||||
f"{arch.num_attention_heads}, TP={tp_size})."
|
||||
)
|
||||
if MINIMAX_H3_PACKED_SEQUENCE_ALIGNMENT % ulysses_size:
|
||||
# ring never shards heads (only rows), so it has no head-divisibility
|
||||
# constraint; the packed sequence alignment constant must still
|
||||
# divide the *combined* sequence-parallel size, since ring adds an
|
||||
# outer row split on top of Ulysses's inner one (see forward()).
|
||||
sp_size = ulysses_size * ring_size
|
||||
if MINIMAX_H3_PACKED_SEQUENCE_ALIGNMENT % sp_size:
|
||||
raise ValueError(
|
||||
"MiniMax H3 packed sequence alignment "
|
||||
f"{MINIMAX_H3_PACKED_SEQUENCE_ALIGNMENT} must be divisible by "
|
||||
f"Ulysses size {ulysses_size}. Choose a Ulysses size that "
|
||||
"divides both the TP-local attention heads and the packed "
|
||||
"sequence alignment."
|
||||
f"the combined sequence-parallel size {sp_size} "
|
||||
f"(ulysses={ulysses_size} x ring={ring_size}). Choose degrees "
|
||||
"whose product divides both the TP-local attention heads and "
|
||||
"the packed sequence alignment."
|
||||
)
|
||||
|
||||
def __init__(
|
||||
@@ -1105,13 +1113,13 @@ class MiniMaxH3DiTModel(BaseDiT, LayerwiseOffloadableModuleMixin):
|
||||
self.num_attention_heads = arch.num_attention_heads
|
||||
self.num_channels_latents = arch.latents_dim
|
||||
tp_size = get_tp_world_size()
|
||||
ulysses_size, _ = _ulysses_ctx()
|
||||
ulysses_size, _ = get_ulysses_ctx()
|
||||
self._validate_tp_config(arch=arch, tp_size=tp_size)
|
||||
self._validate_sequence_parallel_config(
|
||||
arch=arch,
|
||||
tp_size=tp_size,
|
||||
ulysses_size=ulysses_size,
|
||||
ring_size=_ring_world_size(),
|
||||
ring_size=get_ring_ctx()[0],
|
||||
)
|
||||
|
||||
self.video_patch_proj = ColumnParallelLinear(
|
||||
@@ -1266,20 +1274,31 @@ class MiniMaxH3DiTModel(BaseDiT, LayerwiseOffloadableModuleMixin):
|
||||
*,
|
||||
device: torch.device,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""Build request-static RoPE inputs for this Ulysses rank."""
|
||||
"""Build request-static RoPE inputs for this rank's row shard.
|
||||
|
||||
Same 2D row split as forward(): ring first (outer, contiguous
|
||||
ring_chunk_len slice), Ulysses second (inner slice within that
|
||||
chunk) -- see forward()'s row_start derivation for the identity
|
||||
this must stay in sync with.
|
||||
"""
|
||||
if img_position_ids.dim() != 3 or img_position_ids.shape[0] != 1:
|
||||
raise ValueError(
|
||||
"img_position_ids must be [1, S, 3], got "
|
||||
f"{list(img_position_ids.shape)}"
|
||||
)
|
||||
seq_len = int(img_position_ids.shape[1])
|
||||
sp_ws, sp_rank = _ulysses_ctx()
|
||||
ulysses_ws, ulysses_rank = get_ulysses_ctx()
|
||||
ring_ws, ring_rank = get_ring_ctx()
|
||||
sp_ws = ulysses_ws * ring_ws
|
||||
if seq_len % sp_ws:
|
||||
raise ValueError(
|
||||
f"packed seq_len {seq_len} not divisible by ulysses world size {sp_ws}"
|
||||
f"packed seq_len {seq_len} not divisible by the combined "
|
||||
f"sequence-parallel world size {sp_ws} "
|
||||
f"(ulysses={ulysses_ws} x ring={ring_ws})"
|
||||
)
|
||||
local_seq_len = seq_len // sp_ws
|
||||
row_start = sp_rank * local_seq_len
|
||||
ring_chunk_len = local_seq_len * ulysses_ws
|
||||
row_start = ring_rank * ring_chunk_len + ulysses_rank * local_seq_len
|
||||
rope_freqs = self.rope(
|
||||
img_position_ids[:, row_start : row_start + local_seq_len]
|
||||
).to(device)
|
||||
@@ -1505,6 +1524,9 @@ class MiniMaxH3DiTModel(BaseDiT, LayerwiseOffloadableModuleMixin):
|
||||
else raw_cu_seqlens_host
|
||||
)
|
||||
)
|
||||
# max_seqlen_q is set to cu_seqlens[1] (`used`, the real/non-padding
|
||||
# row count) by construction -- already a plain host int here, so
|
||||
# ring can reuse it as real_seq_len below with no new device sync.
|
||||
max_seqlen = int(self._psp_field(psp, "packed_seq_params", "max_seqlen_q"))
|
||||
refiner_psp = _required_kwarg(kwargs, "refiner_packed_seq_params")
|
||||
refiner_cu = self._psp_field(
|
||||
@@ -1528,29 +1550,34 @@ class MiniMaxH3DiTModel(BaseDiT, LayerwiseOffloadableModuleMixin):
|
||||
)
|
||||
device = x.device
|
||||
self._resolve_attention_backend_once()
|
||||
if _ring_world_size() != 1:
|
||||
raise NotImplementedError(
|
||||
"MiniMax H3 packed multi-segment attention requires "
|
||||
"--ring-degree 1; Ring and mixed USP are unsupported."
|
||||
)
|
||||
|
||||
sp_ws, sp_rank = _ulysses_ctx()
|
||||
# Row split is 2D: ring first (an outer, contiguous ring_chunk_len
|
||||
# slice of the packed sequence), Ulysses second (an inner slice
|
||||
# within this rank's ring chunk). Only Ulysses shards heads inside
|
||||
# attention -- ring instead ring-rotates each rank's local KV chunk
|
||||
# and online-softmax merges partial outputs (see
|
||||
# _minimax_h3_attention_core_impl), so it has no head constraint.
|
||||
ulysses_ws, ulysses_rank = get_ulysses_ctx()
|
||||
ring_ws, ring_rank = get_ring_ctx()
|
||||
sp_ws = ulysses_ws * ring_ws
|
||||
local_seq_len = seq_len
|
||||
if sp_ws > 1:
|
||||
if seq_len % sp_ws:
|
||||
raise ValueError(
|
||||
f"packed seq_len {seq_len} not divisible by ulysses "
|
||||
f"world size {sp_ws}"
|
||||
f"packed seq_len {seq_len} not divisible by the combined "
|
||||
f"sequence-parallel world size {sp_ws} "
|
||||
f"(ulysses={ulysses_ws} x ring={ring_ws})"
|
||||
)
|
||||
local_heads = self.num_attention_heads // get_tp_world_size()
|
||||
if local_heads % sp_ws:
|
||||
if local_heads % ulysses_ws:
|
||||
raise ValueError(
|
||||
f"TP-local heads {local_heads} not divisible by Ulysses "
|
||||
f"world size {sp_ws} (total heads={self.num_attention_heads}, "
|
||||
f"TP={get_tp_world_size()})"
|
||||
f"world size {ulysses_ws} (total heads="
|
||||
f"{self.num_attention_heads}, TP={get_tp_world_size()})"
|
||||
)
|
||||
local_seq_len = seq_len // sp_ws
|
||||
row_start = sp_rank * local_seq_len
|
||||
ring_chunk_len = local_seq_len * ulysses_ws
|
||||
row_start = ring_rank * ring_chunk_len + ulysses_rank * local_seq_len
|
||||
row_stop = row_start + local_seq_len
|
||||
|
||||
# RoPE and latent projections are row-local before Ulysses exchanges
|
||||
@@ -1622,9 +1649,11 @@ class MiniMaxH3DiTModel(BaseDiT, LayerwiseOffloadableModuleMixin):
|
||||
block.adaln_proj.split_output(output)
|
||||
for block, output in zip(self.blocks, gathered_adaln)
|
||||
)
|
||||
# With Ulysses sequence parallelism, shard rows across the group for
|
||||
# the block stack. Attention trades sequence for heads internally;
|
||||
# everything else, including the final layer, is row-local.
|
||||
# With sequence parallelism, shard rows across the group for the
|
||||
# block stack. Attention trades sequence for heads internally
|
||||
# (Ulysses) and/or ring-rotates KV across ring ranks; everything
|
||||
# else, including the final layer, is row-local. Only the narrow
|
||||
# video/audio logits are gathered after the final layer.
|
||||
for index, block in enumerate(self.blocks):
|
||||
hidden = block(
|
||||
hidden,
|
||||
@@ -1634,7 +1663,8 @@ class MiniMaxH3DiTModel(BaseDiT, LayerwiseOffloadableModuleMixin):
|
||||
cu_seqlens=cu_seqlens,
|
||||
cu_seqlens_host=cu_seqlens_host,
|
||||
max_seqlen=max_seqlen,
|
||||
ulysses_active=sp_ws > 1,
|
||||
ulysses_active=ulysses_ws > 1,
|
||||
ring_active=ring_ws > 1,
|
||||
adaln_params=(
|
||||
None if block_adaln_params is None else block_adaln_params[index]
|
||||
),
|
||||
|
||||
@@ -6,13 +6,8 @@ import torch.nn as nn
|
||||
|
||||
from sglang.multimodal_gen.configs.models.dits.zimage import ZImageDitConfig
|
||||
from sglang.multimodal_gen.runtime.distributed import (
|
||||
get_sp_parallel_rank,
|
||||
get_sp_world_size,
|
||||
get_tp_world_size,
|
||||
sequence_model_parallel_all_gather,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.distributed.parallel_state import (
|
||||
get_ring_parallel_world_size,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.layers.activation import SiluAndMul
|
||||
from sglang.multimodal_gen.runtime.layers.attention import (
|
||||
@@ -1575,24 +1570,6 @@ class ZImageTransformer2DModel(CachableDiT, LayerwiseOffloadableModuleMixin):
|
||||
)
|
||||
|
||||
cap_seq_len = cap_feats.shape[1]
|
||||
use_full_unified_sequence = (
|
||||
get_sp_world_size() > 1 and get_ring_parallel_world_size() > 1
|
||||
)
|
||||
if use_full_unified_sequence:
|
||||
# Ring support for this attention layout is not implemented; the
|
||||
# full-sequence gather is correct but gives up ring's memory and
|
||||
# overlap benefits.
|
||||
logger.warning_once(
|
||||
"zimage under ring_degree > 1 falls back to a full-sequence "
|
||||
"K/V gather"
|
||||
)
|
||||
x_local_seq_len = x.shape[1]
|
||||
if use_full_unified_sequence:
|
||||
x = sequence_model_parallel_all_gather(x.contiguous(), dim=1)
|
||||
x_freqs_cis = (
|
||||
sequence_model_parallel_all_gather(x_freqs_cis[0].contiguous(), dim=0),
|
||||
sequence_model_parallel_all_gather(x_freqs_cis[1].contiguous(), dim=0),
|
||||
)
|
||||
unified = torch.cat([x, cap_feats], dim=1)
|
||||
unified_freqs_cis = (
|
||||
torch.cat([x_freqs_cis[0], cap_freqs_cis[0]], dim=-2),
|
||||
@@ -1608,7 +1585,7 @@ class ZImageTransformer2DModel(CachableDiT, LayerwiseOffloadableModuleMixin):
|
||||
unified_rope_cos_sin_cache, unified_rope_positions = self._get_rope_cache(
|
||||
"_cached_unified_rope_cache", unified_freqs_cis
|
||||
)
|
||||
num_replicated_suffix = cap_seq_len if not use_full_unified_sequence else 0
|
||||
num_replicated_suffix = cap_seq_len
|
||||
|
||||
for layer in self.layers:
|
||||
unified = layer(
|
||||
@@ -1620,17 +1597,11 @@ class ZImageTransformer2DModel(CachableDiT, LayerwiseOffloadableModuleMixin):
|
||||
attn_mask=unified_attn_mask,
|
||||
attn_mask_meta=unified_attn_mask_meta,
|
||||
num_replicated_suffix=num_replicated_suffix,
|
||||
skip_sequence_parallel_override=use_full_unified_sequence,
|
||||
)
|
||||
|
||||
unified = self.all_final_layer[f"{patch_size}-{f_patch_size}"](
|
||||
unified, adaln_input
|
||||
)
|
||||
if use_full_unified_sequence:
|
||||
sp_rank = get_sp_parallel_rank()
|
||||
start = sp_rank * x_local_seq_len
|
||||
end = start + x_local_seq_len
|
||||
unified = unified[:, start:end]
|
||||
x = list(unified.unbind(dim=0))
|
||||
x = self.unpatchify(x, x_size, patch_size, f_patch_size)
|
||||
|
||||
|
||||
+20
-19
@@ -16,6 +16,10 @@ import torch
|
||||
from sglang.multimodal_gen.configs.models.dits.minimax_h3 import (
|
||||
MINIMAX_H3_ADALN_MODALITY_NUM,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.distributed.parallel_state import (
|
||||
get_ring_ctx,
|
||||
get_ulysses_ctx,
|
||||
)
|
||||
|
||||
MINIMAX_H3_IMGVID_COND_TIMESTEP = 0.999
|
||||
# ref2va audio reference anchor timestep
|
||||
@@ -46,18 +50,6 @@ def _minimax_h3_update_target_rows_(
|
||||
torch.add(state, velocity, out=state)
|
||||
|
||||
|
||||
def _ulysses_ctx() -> tuple[int, int]:
|
||||
from sglang.multimodal_gen.runtime.distributed.parallel_state import (
|
||||
get_ulysses_parallel_rank,
|
||||
get_ulysses_parallel_world_size,
|
||||
model_parallel_is_initialized,
|
||||
)
|
||||
|
||||
if not model_parallel_is_initialized():
|
||||
return 1, 0
|
||||
return get_ulysses_parallel_world_size(), get_ulysses_parallel_rank()
|
||||
|
||||
|
||||
def _build_local_embedding_layout(
|
||||
*,
|
||||
seq_len: int,
|
||||
@@ -70,8 +62,8 @@ def _build_local_embedding_layout(
|
||||
) -> dict[str, torch.Tensor | int]:
|
||||
if seq_len % world_size:
|
||||
raise ValueError(
|
||||
f"packed seq_len {seq_len} not divisible by Ulysses world size "
|
||||
f"{world_size}"
|
||||
f"packed seq_len {seq_len} not divisible by the combined "
|
||||
f"sequence-parallel world size {world_size}"
|
||||
)
|
||||
local_seq_len = seq_len // world_size
|
||||
row_start = rank * local_seq_len
|
||||
@@ -180,10 +172,19 @@ class MiniMaxH3DenoiseBranch:
|
||||
1, seq_len, MINIMAX_H3_AUDIO_ROW_WIDTH, dtype=torch.float32, device=device
|
||||
)
|
||||
text_pos_dev = text_pos.to(device)
|
||||
ulysses_world_size, ulysses_rank = _ulysses_ctx()
|
||||
ulysses_world_size, ulysses_rank = get_ulysses_ctx()
|
||||
ring_world_size, ring_rank = get_ring_ctx()
|
||||
# Combined SP-local rank/world size: the group coordinator lays out
|
||||
# ring as the outer (slower-varying) dimension and Ulysses as the
|
||||
# inner one (see set_seq_parallel_pg_by_sp_groups), so this matches
|
||||
# minimax_h3.py's row_start = ring_rank*ring_chunk_len +
|
||||
# ulysses_rank*local_seq_len exactly -- `_build_local_embedding_layout`
|
||||
# below needs the same combined rank, not just the Ulysses component.
|
||||
sp_world_size = ulysses_world_size * ring_world_size
|
||||
sp_rank = ring_rank * ulysses_world_size + ulysses_rank
|
||||
token_tags_host = token_tags.view(-1).to(dtype=torch.long)
|
||||
local_seq_len = seq_len // ulysses_world_size
|
||||
local_row_start = ulysses_rank * local_seq_len
|
||||
local_seq_len = seq_len // sp_world_size
|
||||
local_row_start = sp_rank * local_seq_len
|
||||
local_row_stop = local_row_start + local_seq_len
|
||||
self.local_row_slice = slice(local_row_start, local_row_stop)
|
||||
self.block_token_tags = (
|
||||
@@ -210,8 +211,8 @@ class MiniMaxH3DenoiseBranch:
|
||||
text_pos=text_pos,
|
||||
img_pos=self.img_pos,
|
||||
audio_pos=self.audio_pos,
|
||||
world_size=ulysses_world_size,
|
||||
rank=ulysses_rank,
|
||||
world_size=sp_world_size,
|
||||
rank=sp_rank,
|
||||
device=device,
|
||||
),
|
||||
"packed_seq_params": {
|
||||
|
||||
@@ -224,6 +224,12 @@ class ServerArgs(DisaggServerArgsMixin):
|
||||
performance_mode: str = "auto"
|
||||
base_gpu_id: int = 0
|
||||
gpu_ids: list[int] | None = None
|
||||
# cross-node: num_gpus is the total world size across all nodes; each
|
||||
# node runs num_gpus // nnodes local GPU workers (mirrors srt's
|
||||
# tp_size_per_node convention)
|
||||
nnodes: int = 1
|
||||
node_rank: int = 0
|
||||
dist_init_addr: str | None = None
|
||||
tp_size: Optional[int] = None
|
||||
sp_degree: Optional[int] = None
|
||||
# sequence parallelism
|
||||
@@ -1467,6 +1473,27 @@ class ServerArgs(DisaggServerArgsMixin):
|
||||
help="The starting GPU ID for this instance. Used with --disagg-role "
|
||||
"to place role instances on specific GPUs without CUDA_VISIBLE_DEVICES.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--nnodes",
|
||||
type=int,
|
||||
default=ServerArgs.nnodes,
|
||||
help="The number of nodes for cross-node parallelism. --num-gpus is "
|
||||
"the total GPU count across all nodes; each node runs "
|
||||
"num_gpus // nnodes local workers.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--node-rank",
|
||||
type=int,
|
||||
default=ServerArgs.node_rank,
|
||||
help="The rank of this node among --nnodes nodes, in [0, nnodes).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dist-init-addr",
|
||||
type=str,
|
||||
default=ServerArgs.dist_init_addr,
|
||||
help="The host:port distributed rendezvous address, reachable from "
|
||||
"every node. Required when --nnodes > 1.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--gpu-ids",
|
||||
nargs="+",
|
||||
@@ -2496,6 +2523,19 @@ class ServerArgs(DisaggServerArgsMixin):
|
||||
"sequence parallelism after dp/tp/cfg"
|
||||
)
|
||||
|
||||
if self.nnodes < 1:
|
||||
raise ValueError("--nnodes must be a natural number")
|
||||
if not (0 <= self.node_rank < self.nnodes):
|
||||
raise ValueError(
|
||||
f"--node-rank ({self.node_rank}) must be in [0, nnodes={self.nnodes})"
|
||||
)
|
||||
if self.nnodes > 1 and self.dist_init_addr is None:
|
||||
raise ValueError("--dist-init-addr is required when --nnodes > 1")
|
||||
if self.num_gpus % self.nnodes != 0:
|
||||
raise ValueError(
|
||||
f"num_gpus ({self.num_gpus}) must be divisible by nnodes ({self.nnodes})"
|
||||
)
|
||||
|
||||
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})"
|
||||
|
||||
@@ -55,12 +55,6 @@
|
||||
"psnr_threshold": 13.5,
|
||||
"mean_abs_diff_threshold": 17.2
|
||||
},
|
||||
"qwen_image_t2i_2_gpus": {
|
||||
"clip_threshold": 0.98,
|
||||
"ssim_threshold": 0.79,
|
||||
"psnr_threshold": 15.7,
|
||||
"mean_abs_diff_threshold": 17.2
|
||||
},
|
||||
"flux_2_image_t2i": {
|
||||
"clip_threshold": 0.98,
|
||||
"ssim_threshold": 0.95,
|
||||
|
||||
@@ -39,7 +39,7 @@ logger = init_logger(__name__)
|
||||
# NPU/ascend) is read from sgl-project/ci-data-diffusion, where the GT-gen workflows
|
||||
# publish.
|
||||
SGL_TEST_FILES_CI_DATA_REPO = "sgl-project/ci-data-diffusion"
|
||||
SGL_TEST_FILES_CI_DATA_REVISION = "dc0e1bb34f2776313a259bcfab3e30daed85160e"
|
||||
SGL_TEST_FILES_CI_DATA_REVISION = "eccf85dcebaaded92df8b0fce3064ebea910c6d4"
|
||||
|
||||
# The NPU pin is kept as a separate branch so ascend GT can be bumped independently
|
||||
# when it's regenerated on its own cadence.
|
||||
|
||||
@@ -182,7 +182,7 @@ def test_rank_local_token_tags_match_reference_slice():
|
||||
for rank in range(world_size):
|
||||
with patch(
|
||||
"sglang.multimodal_gen.runtime.pipelines_core.stages."
|
||||
"model_specific_stages.minimax_h3.denoise_loop._ulysses_ctx",
|
||||
"model_specific_stages.minimax_h3.denoise_loop.get_ulysses_ctx",
|
||||
return_value=(world_size, rank),
|
||||
):
|
||||
branch = _branch(mode, token_tags=token_tags)
|
||||
|
||||
@@ -120,12 +120,36 @@ def test_tp_and_ulysses_admission_uses_tp_local_shapes():
|
||||
ulysses_size=4,
|
||||
ring_size=1,
|
||||
)
|
||||
with pytest.raises(NotImplementedError):
|
||||
# ring is implemented now: it splits rows, not heads, so it carries no
|
||||
# head-divisibility constraint of its own
|
||||
MiniMaxH3DiTModel._validate_sequence_parallel_config(
|
||||
arch=arch,
|
||||
tp_size=1,
|
||||
ulysses_size=1,
|
||||
ring_size=2,
|
||||
)
|
||||
MiniMaxH3DiTModel._validate_sequence_parallel_config(
|
||||
arch=arch,
|
||||
tp_size=1,
|
||||
ulysses_size=8,
|
||||
ring_size=2,
|
||||
)
|
||||
# what ring does constrain is the packed-sequence alignment, which has to
|
||||
# divide by the *combined* degree because ring adds an outer row split on
|
||||
# top of Ulysses's inner one
|
||||
with pytest.raises(ValueError):
|
||||
MiniMaxH3DiTModel._validate_sequence_parallel_config(
|
||||
arch=arch,
|
||||
tp_size=1,
|
||||
ulysses_size=8,
|
||||
ring_size=3,
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
MiniMaxH3DiTModel._validate_sequence_parallel_config(
|
||||
arch=arch,
|
||||
tp_size=1,
|
||||
ulysses_size=1,
|
||||
ring_size=2,
|
||||
ring_size=0,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -13,10 +13,9 @@ from sglang.multimodal_gen.runtime.distributed.sp_shard_utils import (
|
||||
)
|
||||
|
||||
|
||||
def _fake_sp(monkeypatch, sp_size, sp_rank=0, ring=1):
|
||||
def _fake_sp(monkeypatch, sp_size, sp_rank=0):
|
||||
monkeypatch.setattr(sps, "get_sp_world_size", lambda: sp_size)
|
||||
monkeypatch.setattr(sps, "get_sp_parallel_rank", lambda: sp_rank)
|
||||
monkeypatch.setattr(sps, "get_ring_parallel_world_size", lambda: ring)
|
||||
|
||||
|
||||
# --- build_shard_plan math --------------------------------------------------------
|
||||
@@ -150,10 +149,12 @@ def test_strategy_replicates_when_padding_spans_multiple_shards(monkeypatch):
|
||||
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
|
||||
assert sps.plan_text_strategy(16) == "shard" # divisible: no mask needed
|
||||
def test_strategy_shards_padded_text_under_ring(monkeypatch):
|
||||
# Tail-padded shards ride the ring kernel now; the strategy no longer
|
||||
# consults the ring degree at all.
|
||||
_fake_sp(monkeypatch, 2)
|
||||
assert sps.plan_text_strategy(15) == "shard"
|
||||
assert sps.plan_text_strategy(16) == "shard"
|
||||
|
||||
|
||||
def test_strategy_min_len_threshold(monkeypatch):
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Replicated-token paths under ring parallelism: dispatch, merge, KV order."""
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.runtime.layers.attention.layer import USPAttention
|
||||
from sglang.multimodal_gen.runtime.layers.usp import _merge_attention_partials
|
||||
from sglang.multimodal_gen.runtime.platforms import AttentionBackendEnum
|
||||
|
||||
_LAYER = "sglang.multimodal_gen.runtime.layers.attention.layer"
|
||||
|
||||
|
||||
def _sdpa(q, k, v, scale):
|
||||
return torch.nn.functional.scaled_dot_product_attention(
|
||||
q.transpose(1, 2).float(),
|
||||
k.transpose(1, 2).float(),
|
||||
v.transpose(1, 2).float(),
|
||||
scale=scale,
|
||||
).transpose(1, 2)
|
||||
|
||||
|
||||
class _LseImpl:
|
||||
"""SDPA with explicit LSE so merge math can be verified for real."""
|
||||
|
||||
def __init__(self, scale):
|
||||
self.scale = scale
|
||||
self.seen_k = []
|
||||
|
||||
def forward(self, q, k, v, attn_metadata=None, return_softmax_lse=False):
|
||||
self.seen_k.append(k)
|
||||
out = _sdpa(q, k, v, self.scale)
|
||||
if not return_softmax_lse:
|
||||
return out.to(q.dtype)
|
||||
logits = torch.einsum("bshd,bthd->bhst", q.float(), k.float()) * self.scale
|
||||
lse = torch.logsumexp(logits, dim=-1) # [B, H, S]
|
||||
return out, lse
|
||||
|
||||
|
||||
def _ring_pair_via_impl(impl, q, k_shard, v_shard):
|
||||
"""Stand-in for ring_attn on a 1-chunk ring: one local partial + LSE."""
|
||||
return impl.forward(
|
||||
q, k_shard, v_shard, attn_metadata=None, return_softmax_lse=True
|
||||
)
|
||||
|
||||
|
||||
class RingReplicatedBase(unittest.TestCase):
|
||||
B, S_SHARD, REP, H, D = 1, 6, 3, 2, 8
|
||||
|
||||
def _attn(self):
|
||||
obj = USPAttention.__new__(USPAttention)
|
||||
obj.skip_sequence_parallel = False
|
||||
obj.sp_attention_mode = "ulysses"
|
||||
obj.sp_attention_mode_is_auto = False
|
||||
obj.softmax_scale = self.D**-0.5
|
||||
obj.backend = AttentionBackendEnum.FA
|
||||
obj.causal = False
|
||||
obj.dropout_p = 0.0
|
||||
obj.attn_impl = _LseImpl(obj.softmax_scale)
|
||||
return obj
|
||||
|
||||
def _patches(self, ring_ws=2):
|
||||
return (
|
||||
patch(
|
||||
f"{_LAYER}.get_forward_context",
|
||||
return_value=SimpleNamespace(attn_metadata=None),
|
||||
),
|
||||
patch(f"{_LAYER}.get_sequence_parallel_world_size", return_value=ring_ws),
|
||||
patch(f"{_LAYER}.get_ulysses_parallel_world_size", return_value=1),
|
||||
patch(f"{_LAYER}.get_ring_parallel_world_size", return_value=ring_ws),
|
||||
patch(f"{_LAYER}.get_ulysses_parallel_rank", return_value=0),
|
||||
patch(f"{_LAYER}._usp_input_all_to_all", side_effect=lambda x, head_dim: x),
|
||||
patch(
|
||||
f"{_LAYER}._usp_output_all_to_all", side_effect=lambda x, head_dim: x
|
||||
),
|
||||
patch(
|
||||
f"{_LAYER}.ring_attn",
|
||||
side_effect=lambda q, k, v, impl, return_softmax_lse: _ring_pair_via_impl(
|
||||
impl, q, k, v
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def _rand(self, s):
|
||||
return torch.randn(self.B, s, self.H, self.D)
|
||||
|
||||
|
||||
class TestRingReplicatedPrefix(RingReplicatedBase):
|
||||
def test_u1_r2_dispatches_and_merges_exactly(self):
|
||||
obj = self._attn()
|
||||
q = self._rand(self.REP + self.S_SHARD)
|
||||
k = self._rand(self.REP + self.S_SHARD)
|
||||
v = self._rand(self.REP + self.S_SHARD)
|
||||
|
||||
ps = self._patches()
|
||||
with ps[0], ps[1], ps[2], ps[3], ps[4], ps[5], ps[6], ps[7]:
|
||||
out = obj.forward(q, k, v, num_replicated_prefix=self.REP)
|
||||
|
||||
# One local ring chunk + rep partial merged == full attention.
|
||||
ref = _sdpa(q, k, v, obj.softmax_scale)
|
||||
self.assertEqual(out.shape, q.shape)
|
||||
torch.testing.assert_close(out.float(), ref, atol=1e-5, rtol=1e-5)
|
||||
|
||||
def test_kv_prefix_u1_r2_matches_full_attention(self):
|
||||
obj = self._attn()
|
||||
q = self._rand(self.S_SHARD)
|
||||
k = self._rand(self.REP + self.S_SHARD)
|
||||
v = self._rand(self.REP + self.S_SHARD)
|
||||
|
||||
ps = self._patches()
|
||||
with ps[0], ps[1], ps[2], ps[3], ps[4], ps[5], ps[6], ps[7]:
|
||||
out = obj.forward(q, k, v, num_replicated_kv_prefix=self.REP)
|
||||
|
||||
ref = _sdpa(q, k, v, obj.softmax_scale)
|
||||
torch.testing.assert_close(out.float(), ref, atol=1e-5, rtol=1e-5)
|
||||
|
||||
|
||||
class TestRingReplicatedSuffix(RingReplicatedBase):
|
||||
def test_u1_r2_dispatches_and_merges_exactly(self):
|
||||
obj = self._attn()
|
||||
q = self._rand(self.S_SHARD + self.REP)
|
||||
k = self._rand(self.S_SHARD + self.REP)
|
||||
v = self._rand(self.S_SHARD + self.REP)
|
||||
|
||||
ps = self._patches()
|
||||
with ps[0], ps[1], ps[2], ps[3], ps[4], ps[5], ps[6], ps[7]:
|
||||
out = obj.forward(q, k, v, num_replicated_suffix=self.REP)
|
||||
|
||||
ref = _sdpa(q, k, v, obj.softmax_scale)
|
||||
torch.testing.assert_close(out.float(), ref, atol=1e-5, rtol=1e-5)
|
||||
|
||||
def test_non_ring_path_keeps_kv_tail_order(self):
|
||||
obj = self._attn()
|
||||
q = self._rand(self.S_SHARD + self.REP)
|
||||
k = self._rand(self.S_SHARD + self.REP)
|
||||
v = self._rand(self.S_SHARD + self.REP)
|
||||
|
||||
def _fake_gather(out_list, t, group=None):
|
||||
for o in out_list:
|
||||
o.copy_(t)
|
||||
|
||||
ps = self._patches(ring_ws=1)
|
||||
with ps[0], ps[4], ps[5], ps[6], patch(
|
||||
f"{_LAYER}.get_ring_parallel_world_size", return_value=1
|
||||
), patch(f"{_LAYER}.get_sequence_parallel_world_size", return_value=2), patch(
|
||||
f"{_LAYER}.get_ulysses_parallel_world_size", return_value=2
|
||||
), patch(
|
||||
f"{_LAYER}.get_sp_group", return_value=SimpleNamespace(ulysses_group=None)
|
||||
), patch(
|
||||
"torch.distributed.all_gather", side_effect=_fake_gather
|
||||
):
|
||||
# Identity-mocked collectives don't reproduce head-shard shapes,
|
||||
# so the final concat may fail — the kernel K order is recorded
|
||||
# before that and is all this test asserts.
|
||||
try:
|
||||
obj.forward(q, k, v, num_replicated_suffix=self.REP)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
# Bitwise contract: suffix KV stays at the tail in the kernel call.
|
||||
kernel_k = obj.attn_impl.seen_k[-1]
|
||||
torch.testing.assert_close(kernel_k[:, -self.REP :], k[:, -self.REP :])
|
||||
|
||||
|
||||
class TestMergePartials(unittest.TestCase):
|
||||
def test_two_disjoint_halves_merge_to_full_attention(self):
|
||||
torch.manual_seed(7)
|
||||
B, S, T, H, D = 1, 5, 8, 2, 16
|
||||
scale = D**-0.5
|
||||
q = torch.randn(B, S, H, D)
|
||||
k = torch.randn(B, T, H, D)
|
||||
v = torch.randn(B, T, H, D)
|
||||
|
||||
def part(ks, vs):
|
||||
logits = torch.einsum("bshd,bthd->bhst", q, ks) * scale
|
||||
lse = torch.logsumexp(logits, dim=-1)
|
||||
out = torch.softmax(logits, dim=-1)
|
||||
return torch.einsum("bhst,bthd->bshd", out, vs), lse
|
||||
|
||||
out_a, lse_a = part(k[:, :3], v[:, :3])
|
||||
out_b, lse_b = part(k[:, 3:], v[:, 3:])
|
||||
merged = _merge_attention_partials(out_a, lse_a, out_b, lse_b)
|
||||
|
||||
ref, _ = part(k, v)
|
||||
torch.testing.assert_close(merged, ref, atol=1e-6, rtol=1e-6)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Ring + tail-pad dispatch: a2a within Ulysses, ring clamped to pad_start."""
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.runtime.layers.attention.layer import USPAttention
|
||||
from sglang.multimodal_gen.runtime.platforms import AttentionBackendEnum
|
||||
|
||||
_LAYER = "sglang.multimodal_gen.runtime.layers.attention.layer"
|
||||
|
||||
|
||||
class TestRingTailPadDispatch(unittest.TestCase):
|
||||
def _attn(self):
|
||||
obj = USPAttention.__new__(USPAttention)
|
||||
obj.skip_sequence_parallel = False
|
||||
obj.sp_attention_mode = "ulysses"
|
||||
obj.sp_attention_mode_is_auto = False
|
||||
obj.softmax_scale = 0.5
|
||||
obj.backend = AttentionBackendEnum.FA
|
||||
obj.causal = False
|
||||
obj.dropout_p = 0.0
|
||||
return obj
|
||||
|
||||
def test_tail_pad_meta_reaches_the_ring_kernel(self):
|
||||
obj = self._attn()
|
||||
q = torch.randn(1, 4, 2, 8)
|
||||
meta = {"pad_start": 13, "pad_end": 16, "local_pad": 3}
|
||||
seen = {}
|
||||
|
||||
def fake_ring(qc, kc, vc, *, softmax_scale, real_seq_len, ring_ws):
|
||||
seen.update(
|
||||
shape=tuple(qc.shape),
|
||||
real=real_seq_len,
|
||||
ws=ring_ws,
|
||||
scale=softmax_scale,
|
||||
)
|
||||
return torch.ones_like(qc)
|
||||
|
||||
with (
|
||||
patch(
|
||||
f"{_LAYER}.get_forward_context",
|
||||
return_value=SimpleNamespace(attn_metadata=None),
|
||||
),
|
||||
patch(f"{_LAYER}.get_sequence_parallel_world_size", return_value=4),
|
||||
patch(f"{_LAYER}.get_ring_parallel_world_size", return_value=2),
|
||||
patch(
|
||||
f"{_LAYER}._usp_input_all_to_all_qkv",
|
||||
side_effect=lambda q, k, v: (q, k, v),
|
||||
),
|
||||
patch(
|
||||
f"{_LAYER}._usp_output_all_to_all", side_effect=lambda t, head_dim: t
|
||||
),
|
||||
patch(f"{_LAYER}._ring_attention_varlen", side_effect=fake_ring),
|
||||
patch(f"{_LAYER}.get_ring_parallel_rank", return_value=3),
|
||||
):
|
||||
out = obj.forward(q, q, q, attn_mask_meta=meta)
|
||||
|
||||
self.assertEqual(out.shape, q.shape)
|
||||
self.assertEqual(seen["real"], 13)
|
||||
self.assertEqual(seen["ws"], 2)
|
||||
self.assertEqual(seen["shape"], (4, 2, 8))
|
||||
# Last ring rank holds global rows [12, 16): row 13 onward is pad.
|
||||
self.assertTrue(torch.all(out[0, 1:] == 0))
|
||||
self.assertTrue(torch.all(out[0, :1] == 1))
|
||||
|
||||
def test_explicit_mask_under_ring_still_refuses(self):
|
||||
obj = self._attn()
|
||||
q = torch.randn(1, 4, 2, 8)
|
||||
mask = torch.ones(1, 4, dtype=torch.bool)
|
||||
with (
|
||||
patch(
|
||||
f"{_LAYER}.get_forward_context",
|
||||
return_value=SimpleNamespace(attn_metadata=None),
|
||||
),
|
||||
patch(f"{_LAYER}.get_sequence_parallel_world_size", return_value=4),
|
||||
patch(f"{_LAYER}.get_ring_parallel_world_size", return_value=2),
|
||||
):
|
||||
with self.assertRaisesRegex(NotImplementedError, "ring"):
|
||||
obj.forward(q, q, q, attn_mask=mask)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user