492 lines
21 KiB
Python
492 lines
21 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
|
|
import torch
|
|
import torch.distributed as dist
|
|
import torch.nn.functional as F
|
|
|
|
from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata
|
|
from vllm_omni.diffusion.attention.parallel.base import ParallelAttentionContext
|
|
from vllm_omni.diffusion.distributed.comm import SeqAllToAll4D
|
|
from vllm_omni.diffusion.distributed.group_coordinator import SequenceParallelGroupCoordinator
|
|
from vllm_omni.diffusion.forward_context import get_ulysses_mode
|
|
|
|
|
|
def _ceil_div(n: int, d: int) -> int:
|
|
return (n + d - 1) // d
|
|
|
|
|
|
def _positive_divisors(n: int) -> list[int]:
|
|
if n <= 0:
|
|
return []
|
|
divs = set()
|
|
i = 1
|
|
while i * i <= n:
|
|
if n % i == 0:
|
|
divs.add(i)
|
|
divs.add(n // i)
|
|
i += 1
|
|
return sorted(divs)
|
|
|
|
|
|
@torch.compiler.disable
|
|
def _all_gather_int(pg: dist.ProcessGroup, value: int, *, device: torch.device) -> list[int]:
|
|
"""All-gather a scalar int across pg.
|
|
|
|
Note: we use a device tensor so this works for NCCL subgroups (e.g. Ulysses/Ring).
|
|
"""
|
|
world_size = dist.get_world_size(pg)
|
|
if world_size == 1:
|
|
return [int(value)]
|
|
|
|
t = torch.tensor([int(value)], dtype=torch.int64, device=device)
|
|
gathered = [torch.empty_like(t) for _ in range(world_size)]
|
|
dist.all_gather(gathered, t, group=pg)
|
|
return [int(x.item()) for x in gathered]
|
|
|
|
|
|
def _ulysses_all_to_all_any_qkv(
|
|
pg: dist.ProcessGroup,
|
|
x: torch.Tensor, # (B, S_local, H, D)
|
|
*,
|
|
seq_lens: list[int],
|
|
use_sync: bool,
|
|
) -> tuple[torch.Tensor, int]:
|
|
"""UAA forward all-to-all: (B, S_local, H, D) -> (B, S_global, H_local, D).
|
|
|
|
Returns:
|
|
(resharded, orig_head_cnt)
|
|
"""
|
|
world_size = dist.get_world_size(pg)
|
|
if world_size == 1:
|
|
return x, int(x.shape[2])
|
|
|
|
bsz, s_local, head_cnt, head_dim = x.shape
|
|
orig_head_cnt = int(head_cnt)
|
|
padded_head_cnt = _ceil_div(orig_head_cnt, world_size) * world_size
|
|
head_pad = padded_head_cnt - orig_head_cnt
|
|
if head_pad:
|
|
x = F.pad(x, (0, 0, 0, head_pad))
|
|
|
|
head_cnt_local = padded_head_cnt // world_size
|
|
|
|
# (B, S_local, H, D) -> (world_size, S_local, B, H_local, D)
|
|
x_t = x.reshape(bsz, s_local, world_size, head_cnt_local, head_dim).permute(2, 1, 0, 3, 4).contiguous()
|
|
# (world_size, S_local, B, H_local, D) -> (world_size * S_local, B, H_local, D)
|
|
x_t = x_t.flatten(0, 1)
|
|
|
|
input_split_sizes = [s_local] * world_size
|
|
output_split_sizes = seq_lens
|
|
s_global = int(sum(output_split_sizes))
|
|
|
|
out = torch.empty((s_global, bsz, head_cnt_local, head_dim), device=x.device, dtype=x.dtype)
|
|
dist.all_to_all_single(
|
|
out,
|
|
x_t,
|
|
output_split_sizes=output_split_sizes,
|
|
input_split_sizes=input_split_sizes,
|
|
group=pg,
|
|
)
|
|
if use_sync:
|
|
from vllm_omni.platforms import current_omni_platform
|
|
|
|
current_omni_platform.synchronize()
|
|
|
|
# (S_global, B, H_local, D) -> (B, S_global, H_local, D)
|
|
out = out.permute(1, 0, 2, 3).contiguous()
|
|
return out, orig_head_cnt
|
|
|
|
|
|
def _ulysses_all_to_all_any_o(
|
|
pg: dist.ProcessGroup,
|
|
x: torch.Tensor, # (B, S_global, H_local, D)
|
|
*,
|
|
seq_lens: list[int],
|
|
local_seq_len: int,
|
|
orig_head_cnt: int,
|
|
use_sync: bool,
|
|
) -> torch.Tensor:
|
|
"""UAA reverse all-to-all: (B, S_global, H_local, D) -> (B, S_local, H, D)."""
|
|
world_size = dist.get_world_size(pg)
|
|
if world_size == 1:
|
|
return x
|
|
|
|
bsz, s_global, head_cnt_local, head_dim = x.shape
|
|
s_local = int(local_seq_len)
|
|
|
|
# (B, S_global, H_local, D) -> (S_global, B, H_local, D)
|
|
x_t = x.permute(1, 0, 2, 3).contiguous()
|
|
|
|
input_split_sizes = seq_lens
|
|
output_split_sizes = [s_local] * world_size
|
|
|
|
out = torch.empty((world_size * s_local, bsz, head_cnt_local, head_dim), device=x.device, dtype=x.dtype)
|
|
dist.all_to_all_single(
|
|
out,
|
|
x_t,
|
|
output_split_sizes=output_split_sizes,
|
|
input_split_sizes=input_split_sizes,
|
|
group=pg,
|
|
)
|
|
if use_sync:
|
|
from vllm_omni.platforms import current_omni_platform
|
|
|
|
current_omni_platform.synchronize()
|
|
|
|
# (world_size * S_local, B, H_local, D) -> (B, S_local, H, D)
|
|
out = out.reshape(world_size, s_local, bsz, head_cnt_local, head_dim).permute(2, 1, 0, 3, 4).contiguous()
|
|
out = out.reshape(bsz, s_local, world_size * head_cnt_local, head_dim)
|
|
|
|
if out.shape[2] != orig_head_cnt:
|
|
out = out[:, :, :orig_head_cnt, :].contiguous()
|
|
return out
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _UlyssesCtx(ParallelAttentionContext):
|
|
"""Per-forward context for Ulysses sequence-parallel attention."""
|
|
|
|
ulysses_pg: dist.ProcessGroup
|
|
scatter_idx: int
|
|
gather_idx: int
|
|
use_sync: bool
|
|
joint_len: int = 0
|
|
joint_strategy: str = "front"
|
|
# UAA (Ulysses Anything Attention) metadata
|
|
use_uaa: bool = False
|
|
uaa_seq_lens: tuple[int, ...] = ()
|
|
uaa_local_seq_len: int = 0
|
|
orig_head_cnt: int = 0
|
|
joint_orig_head_cnt: int = 0
|
|
|
|
|
|
class UlyssesParallelAttention:
|
|
"""Ulysses sequence-parallel strategy (all-to-all over seq/head dims).
|
|
|
|
This preserves the semantics previously implemented in
|
|
`Attention._forward_ulysses`:
|
|
- If `AttentionMetadata.joint_*` is provided, joint_query/key/value are
|
|
concatenated *after* all-to-all.
|
|
- joint_key/value are assumed to be replicated across SP ranks and are sliced
|
|
by ulysses head rank before concatenation.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
sp_group: SequenceParallelGroupCoordinator,
|
|
scatter_idx: int,
|
|
gather_idx: int,
|
|
use_sync: bool,
|
|
) -> None:
|
|
self._sp_group = sp_group
|
|
self._ulysses_pg = sp_group.ulysses_group
|
|
self._scatter_idx = scatter_idx
|
|
self._gather_idx = gather_idx
|
|
self._use_sync = use_sync
|
|
|
|
@property
|
|
def enabled(self) -> bool:
|
|
return True
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return "ulysses"
|
|
|
|
def pre_attention(
|
|
self,
|
|
query: torch.Tensor,
|
|
key: torch.Tensor,
|
|
value: torch.Tensor,
|
|
attn_metadata: AttentionMetadata | None,
|
|
):
|
|
mode = get_ulysses_mode(default="strict")
|
|
ulysses_world_size = self._sp_group.ulysses_world_size
|
|
|
|
# advanced_uaa pads non-divisible head counts before the Ulysses
|
|
# all-to-all. Padding K/V is not valid in hybrid Ulysses+Ring: the Ring
|
|
# GQA/MQA path would repeat the padded zero heads as real K/V heads.
|
|
# Reject this layout until K/V replication is performed before Ulysses.
|
|
if mode == "advanced_uaa" and self._sp_group.ring_world_size > 1:
|
|
for name, tensor in (("key", key), ("value", value)):
|
|
kv_head_cnt = int(tensor.shape[2])
|
|
if kv_head_cnt % ulysses_world_size != 0:
|
|
raise ValueError(
|
|
"ulysses_mode='advanced_uaa' with hybrid Ulysses+Ring "
|
|
"does not support K/V head padding. "
|
|
f"{name}_head_cnt={kv_head_cnt}, "
|
|
f"ulysses_degree={ulysses_world_size}. "
|
|
"Use ring_degree=1, choose a compatible ulysses_degree, "
|
|
"or replicate K/V heads before Ulysses."
|
|
)
|
|
|
|
joint_tensor_query = joint_tensor_key = joint_tensor_value = None
|
|
joint_strategy = "front"
|
|
joint_len = 0
|
|
joint_orig_head_cnt = 0
|
|
|
|
if attn_metadata is not None:
|
|
joint_tensor_query = attn_metadata.joint_query
|
|
joint_tensor_key = attn_metadata.joint_key
|
|
joint_tensor_value = attn_metadata.joint_value
|
|
joint_strategy = attn_metadata.joint_strategy
|
|
|
|
is_joint = False
|
|
if joint_tensor_query is not None and joint_tensor_key is not None and joint_tensor_value is not None:
|
|
supported_joint_strategy = ["front", "rear"]
|
|
if joint_strategy not in supported_joint_strategy:
|
|
raise ValueError(
|
|
f"joint_strategy: {joint_strategy} not supported."
|
|
f" supported joint strategy: {supported_joint_strategy}"
|
|
)
|
|
|
|
# Slice joint_query for this Ulysses rank
|
|
# joint_query is (B, S, H, D). We split H (dim 2).
|
|
ulysses_rank = self._sp_group.ulysses_rank
|
|
joint_head_cnt = int(joint_tensor_query.shape[-2])
|
|
joint_orig_head_cnt = joint_head_cnt
|
|
|
|
if mode == "advanced_uaa":
|
|
padded_joint_head_cnt = _ceil_div(joint_head_cnt, ulysses_world_size) * ulysses_world_size
|
|
joint_head_pad = padded_joint_head_cnt - joint_head_cnt
|
|
if joint_head_pad:
|
|
joint_tensor_query = F.pad(joint_tensor_query, (0, 0, 0, joint_head_pad))
|
|
joint_tensor_key = F.pad(joint_tensor_key, (0, 0, 0, joint_head_pad))
|
|
joint_tensor_value = F.pad(joint_tensor_value, (0, 0, 0, joint_head_pad))
|
|
joint_head_cnt = padded_joint_head_cnt
|
|
else:
|
|
if joint_head_cnt % ulysses_world_size != 0:
|
|
supported = _positive_divisors(joint_head_cnt)
|
|
raise ValueError(
|
|
"Ulysses-SP strict mode requires joint head_cnt divisible by ulysses_degree. "
|
|
f"joint_head_cnt={joint_head_cnt}, ulysses_degree={ulysses_world_size}. "
|
|
f"Try ulysses_degree in {supported}, or set ulysses_mode='advanced_uaa'."
|
|
)
|
|
|
|
attn_heads_per_ulysses_rank = joint_head_cnt // ulysses_world_size
|
|
|
|
# Note: We use the same heads for Q/K/V
|
|
joint_tensor_query = joint_tensor_query[
|
|
...,
|
|
attn_heads_per_ulysses_rank * ulysses_rank : attn_heads_per_ulysses_rank * (ulysses_rank + 1),
|
|
:,
|
|
]
|
|
|
|
joint_len = joint_tensor_query.shape[1]
|
|
|
|
is_joint = True
|
|
elif joint_tensor_query is None and joint_tensor_key is None and joint_tensor_value is None:
|
|
pass
|
|
else:
|
|
raise ValueError("joint_query, joint_key, and joint_value should be None or not None simultaneously.")
|
|
|
|
if is_joint:
|
|
# Slice joint key/value heads for this ulysses rank.
|
|
# Using same slicing logic as query
|
|
attn_heads_per_ulysses_rank_kv = joint_tensor_key.shape[-2] // ulysses_world_size
|
|
|
|
joint_tensor_key = joint_tensor_key[
|
|
...,
|
|
attn_heads_per_ulysses_rank_kv * ulysses_rank : attn_heads_per_ulysses_rank_kv * (ulysses_rank + 1),
|
|
:,
|
|
]
|
|
joint_tensor_value = joint_tensor_value[
|
|
...,
|
|
attn_heads_per_ulysses_rank_kv * ulysses_rank : attn_heads_per_ulysses_rank_kv * (ulysses_rank + 1),
|
|
:,
|
|
]
|
|
|
|
# Update metadata with sliced tensors so Ring attention can use them if needed
|
|
if attn_metadata is not None:
|
|
attn_metadata.joint_key = joint_tensor_key
|
|
attn_metadata.joint_value = joint_tensor_value
|
|
|
|
ulysses_world_size = self._sp_group.ulysses_world_size
|
|
if mode == "advanced_uaa":
|
|
if self._scatter_idx != 2 or self._gather_idx != 1:
|
|
raise ValueError(
|
|
"ulysses_mode='advanced_uaa' currently only supports scatter_idx=2, gather_idx=1 "
|
|
f"(got scatter_idx={self._scatter_idx}, gather_idx={self._gather_idx})."
|
|
)
|
|
|
|
local_seq_len = int(query.shape[1])
|
|
seq_lens = _all_gather_int(self._ulysses_pg, local_seq_len, device=query.device)
|
|
s_global = int(sum(seq_lens))
|
|
|
|
# In hybrid Ulysses+Ring, Ring attention uses P2P send/recv with fixed-shape
|
|
# buffers. This requires all ring ranks to have the same seq_len after the
|
|
# Ulysses all-to-all (i.e. per-ring-rank S_global must match).
|
|
if self._sp_group.ring_world_size > 1:
|
|
ring_s_globals = _all_gather_int(self._sp_group.ring_group, s_global, device=query.device)
|
|
if len(set(ring_s_globals)) != 1:
|
|
raise ValueError(
|
|
"ulysses_mode='advanced_uaa' with hybrid Ulysses+Ring requires the "
|
|
"post-Ulysses seq_len to be equal across ring ranks, but got "
|
|
f"{ring_s_globals} (ring_degree={self._sp_group.ring_world_size}). "
|
|
"This typically means the input sequence was not evenly shardable across the ring. "
|
|
"Try setting ring_degree=1, or choose a sequence length divisible by ring_degree."
|
|
)
|
|
query, orig_head_cnt = _ulysses_all_to_all_any_qkv(
|
|
self._ulysses_pg, query, seq_lens=seq_lens, use_sync=self._use_sync
|
|
)
|
|
key, _ = _ulysses_all_to_all_any_qkv(self._ulysses_pg, key, seq_lens=seq_lens, use_sync=self._use_sync)
|
|
value, _ = _ulysses_all_to_all_any_qkv(self._ulysses_pg, value, seq_lens=seq_lens, use_sync=self._use_sync)
|
|
else:
|
|
# Strict mode: fail fast with actionable errors for head divisibility.
|
|
for name, t in (("query", query), ("key", key), ("value", value)):
|
|
head_cnt = int(t.shape[2])
|
|
if head_cnt % ulysses_world_size != 0:
|
|
supported = _positive_divisors(head_cnt)
|
|
raise ValueError(
|
|
"Ulysses-SP strict mode requires head_cnt divisible by ulysses_degree. "
|
|
f"{name}_head_cnt={head_cnt}, ulysses_degree={ulysses_world_size}. "
|
|
f"Try ulysses_degree in {supported}, or set ulysses_mode='advanced_uaa'."
|
|
)
|
|
|
|
# (bs, seq_len/P, head_cnt, head_size) -> (bs, seq_len, head_cnt/P, head_size)
|
|
query = SeqAllToAll4D.apply(self._ulysses_pg, query, self._scatter_idx, self._gather_idx, self._use_sync)
|
|
key = SeqAllToAll4D.apply(self._ulysses_pg, key, self._scatter_idx, self._gather_idx, self._use_sync)
|
|
value = SeqAllToAll4D.apply(self._ulysses_pg, value, self._scatter_idx, self._gather_idx, self._use_sync)
|
|
seq_lens = []
|
|
local_seq_len = 0
|
|
orig_head_cnt = 0
|
|
|
|
if is_joint:
|
|
# Concatenate joint query AFTER AllToAll
|
|
# Image query is now (B, S, H/P, D). Joint query is (B, S_txt, H/P, D).
|
|
# This is dimensionally consistent.
|
|
if joint_strategy == "rear":
|
|
query = torch.cat([query, joint_tensor_query], dim=1)
|
|
else:
|
|
query = torch.cat([joint_tensor_query, query], dim=1)
|
|
|
|
# Check if Ring Attention is also active (Hybrid mode)
|
|
# If Ring is active, we should NOT concatenate joint_key/value to k/v here.
|
|
# Instead, they should remain in attn_metadata and be passed to the Ring kernel.
|
|
use_ring = self._sp_group.ring_world_size > 1
|
|
|
|
if is_joint and not use_ring:
|
|
# Concatenate joint key/value after all-to-all ONLY for pure Ulysses (Local Attention).
|
|
if joint_strategy == "front":
|
|
key = torch.cat([joint_tensor_key, key], dim=1)
|
|
value = torch.cat([joint_tensor_value, value], dim=1)
|
|
else: # "rear"
|
|
key = torch.cat([key, joint_tensor_key], dim=1)
|
|
value = torch.cat([value, joint_tensor_value], dim=1)
|
|
|
|
ctx = _UlyssesCtx(
|
|
name=self.name,
|
|
ulysses_pg=self._ulysses_pg,
|
|
scatter_idx=self._scatter_idx,
|
|
gather_idx=self._gather_idx,
|
|
use_sync=self._use_sync,
|
|
joint_len=joint_len,
|
|
joint_strategy=joint_strategy,
|
|
use_uaa=(mode == "advanced_uaa"),
|
|
uaa_seq_lens=tuple(int(x) for x in seq_lens) if mode == "advanced_uaa" else (),
|
|
uaa_local_seq_len=int(local_seq_len) if mode == "advanced_uaa" else 0,
|
|
orig_head_cnt=int(orig_head_cnt) if mode == "advanced_uaa" else 0,
|
|
joint_orig_head_cnt=int(joint_orig_head_cnt) if mode == "advanced_uaa" else 0,
|
|
)
|
|
use_2d_mask = False
|
|
if attn_metadata is not None:
|
|
if attn_metadata.attn_mask is not None and attn_metadata.attn_mask.ndim == 2:
|
|
use_2d_mask = True
|
|
if attn_metadata.joint_attn_mask is not None and attn_metadata.joint_attn_mask.ndim == 2:
|
|
use_2d_mask = True
|
|
|
|
if attn_metadata is not None and use_2d_mask:
|
|
if is_joint:
|
|
if attn_metadata.joint_attn_mask is None and attn_metadata.attn_mask is None:
|
|
attn_metadata.attn_mask = None
|
|
else:
|
|
if attn_metadata.attn_mask is None:
|
|
attn_metadata.attn_mask = torch.ones(
|
|
[query.shape[0], query.shape[1] - attn_metadata.joint_attn_mask.shape[1]],
|
|
dtype=torch.bool,
|
|
device=query.device,
|
|
)
|
|
elif attn_metadata.joint_attn_mask is None:
|
|
attn_metadata.joint_attn_mask = torch.ones(
|
|
[query.shape[0], query.shape[1] - attn_metadata.attn_mask.shape[1]],
|
|
dtype=torch.bool,
|
|
device=query.device,
|
|
)
|
|
attn_metadata.attn_mask = (
|
|
torch.cat([attn_metadata.joint_attn_mask, attn_metadata.attn_mask], dim=1)
|
|
if joint_strategy == "front"
|
|
else torch.cat([attn_metadata.attn_mask, attn_metadata.joint_attn_mask], dim=1)
|
|
)
|
|
|
|
if attn_metadata.attn_mask is not None:
|
|
# the final attn_mask is ready, the length should be aligedn with query length
|
|
assert attn_metadata.attn_mask.shape[1] == query.shape[1], (
|
|
f"attn_mask length: {attn_metadata.attn_mask.shape[1]} != query length: {query.shape[1]}"
|
|
)
|
|
attn_metadata.attn_mask = attn_metadata.attn_mask.bool().contiguous()
|
|
return query, key, value, attn_metadata, ctx
|
|
|
|
def post_attention(self, attn_output: torch.Tensor, ctx: ParallelAttentionContext | None) -> torch.Tensor:
|
|
assert isinstance(ctx, _UlyssesCtx), f"Unexpected ctx type: {type(ctx)!r}"
|
|
|
|
if ctx.joint_len > 0:
|
|
joint_len = ctx.joint_len
|
|
|
|
if ctx.joint_strategy == "front":
|
|
output_joint = attn_output[:, :joint_len]
|
|
output_img = attn_output[:, joint_len:]
|
|
else:
|
|
output_img = attn_output[:, :-joint_len]
|
|
output_joint = attn_output[:, -joint_len:]
|
|
|
|
# 1. Process Image part: Standard Ulysses Reverse (AllToAll)
|
|
# (bs, seq_len, head_cnt/P, head_size) -> (bs, seq_len/P, head_cnt, head_size)
|
|
# SeqAllToAll4D handles: Scatter gather_idx, Gather scatter_idx.
|
|
# Forward: Scatter 2 (H), Gather 1 (S).
|
|
# Reverse: Scatter 1 (S), Gather 2 (H).
|
|
if ctx.use_uaa:
|
|
output_img = _ulysses_all_to_all_any_o(
|
|
ctx.ulysses_pg,
|
|
output_img,
|
|
seq_lens=list(ctx.uaa_seq_lens),
|
|
local_seq_len=ctx.uaa_local_seq_len,
|
|
orig_head_cnt=ctx.orig_head_cnt,
|
|
use_sync=ctx.use_sync,
|
|
)
|
|
else:
|
|
output_img = SeqAllToAll4D.apply(
|
|
ctx.ulysses_pg, output_img, ctx.gather_idx, ctx.scatter_idx, ctx.use_sync
|
|
)
|
|
|
|
# 2. Process Joint part: AllGather on Heads
|
|
# Input: (B, JointLen, H/P, D). Output: (B, JointLen, H, D).
|
|
# AllGather along dim 2.
|
|
# Ensure tensor is contiguous for all_gather (slicing may create non-contiguous views)
|
|
output_joint = output_joint.contiguous()
|
|
gathered_joint = [torch.zeros_like(output_joint) for _ in range(dist.get_world_size(ctx.ulysses_pg))]
|
|
dist.all_gather(gathered_joint, output_joint, group=ctx.ulysses_pg)
|
|
output_joint = torch.cat(gathered_joint, dim=2)
|
|
if ctx.use_uaa and ctx.joint_orig_head_cnt > 0 and output_joint.shape[2] != ctx.joint_orig_head_cnt:
|
|
output_joint = output_joint[:, :, : ctx.joint_orig_head_cnt, :].contiguous()
|
|
|
|
# 3. Recombine
|
|
if ctx.joint_strategy == "front":
|
|
return torch.cat([output_joint, output_img], dim=1)
|
|
else:
|
|
return torch.cat([output_img, output_joint], dim=1)
|
|
|
|
# Standard Ulysses Reverse
|
|
if ctx.use_uaa:
|
|
return _ulysses_all_to_all_any_o(
|
|
ctx.ulysses_pg,
|
|
attn_output,
|
|
seq_lens=list(ctx.uaa_seq_lens),
|
|
local_seq_len=ctx.uaa_local_seq_len,
|
|
orig_head_cnt=ctx.orig_head_cnt,
|
|
use_sync=ctx.use_sync,
|
|
)
|
|
return SeqAllToAll4D.apply(ctx.ulysses_pg, attn_output, ctx.gather_idx, ctx.scatter_idx, ctx.use_sync)
|