[AMD] Enable AITER custom all-gather on ROCm (#25093)

This commit is contained in:
Hubert Lu
2026-06-02 15:57:37 -07:00
committed by GitHub
parent a711c57a32
commit 72929c7000
4 changed files with 625 additions and 0 deletions
@@ -0,0 +1,439 @@
"""
Benchmark SGLang logical TP all-gather against Aiter custom all-gather.
This benchmark is intended for captured logits all-gather shapes such as
``1,32320;2,32320;4,32320`` and for correctness coverage across metadata
integer dtypes. It compares the current RCCL ``dist.all_gather_into_tensor``
route with Aiter's custom all-gather when RCCL supports the dtype, validates
candidate correctness against deterministic expected outputs, and reports
per-rank average latency.
Usage:
torchrun --nproc_per_node=4 benchmark/kernels/all_gather/benchmark_aiter.py \
--dtype bfloat16 --shapes "1,32320;2,32320;4,32320"
"""
from __future__ import annotations
import argparse
import os
import statistics
import torch
import torch.distributed as dist
Shape = tuple[int, ...]
def parse_shape_list(value: str) -> list[Shape]:
shapes: list[Shape] = []
for item in value.split(";"):
item = item.strip()
if not item:
continue
shape = tuple(int(dim.strip()) for dim in item.split(",") if dim.strip())
if not shape or any(dim <= 0 for dim in shape):
raise argparse.ArgumentTypeError(f"invalid shape: {item!r}")
shapes.append(shape)
if not shapes:
raise argparse.ArgumentTypeError("at least one shape is required")
return shapes
DTYPE_MAP = {
"float32": torch.float32,
"fp32": torch.float32,
"float16": torch.float16,
"fp16": torch.float16,
"bfloat16": torch.bfloat16,
"bf16": torch.bfloat16,
"uint64_t": torch.uint64,
"u64": torch.uint64,
"int64_t": torch.int64,
"i64": torch.int64,
"uint32_t": torch.uint32,
"u32": torch.uint32,
"int32_t": torch.int32,
"i32": torch.int32,
"int16_t": torch.int16,
"i16": torch.int16,
"uint8_t": torch.uint8,
"u8": torch.uint8,
"int8_t": torch.int8,
"i8": torch.int8,
}
def parse_dtype_list(value: str) -> list[str]:
names = [item.strip() for item in value.split(",") if item.strip()]
if not names:
raise argparse.ArgumentTypeError("at least one dtype is required")
unknown = [name for name in names if name not in DTYPE_MAP]
if unknown:
raise argparse.ArgumentTypeError(
f"unknown dtype(s): {unknown}; choices={sorted(DTYPE_MAP)}"
)
return names
def parse_dim_list(value: str) -> list[int]:
dims = [int(item.strip()) for item in value.split(",") if item.strip()]
if not dims:
raise argparse.ArgumentTypeError("at least one dim is required")
return dims
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Benchmark RCCL vs Aiter custom all-gather for explicit shapes."
)
parser.add_argument(
"--backend",
default="cpu:gloo,cuda:nccl",
help="Process group backend for torch.distributed.",
)
parser.add_argument(
"--shapes",
type=parse_shape_list,
default=parse_shape_list("1,32320;2,32320;4,32320"),
help='Semicolon-separated input shapes, e.g. "1,32320;2,32320;4,32320".',
)
parser.add_argument(
"--dtype",
type=parse_dtype_list,
default=parse_dtype_list("bfloat16"),
help="Input dtype or comma-separated dtypes.",
)
parser.add_argument(
"--dims",
type=parse_dim_list,
default=parse_dim_list("-1"),
help='Comma-separated logical gather dims, e.g. "-1" or "0,-1".',
)
parser.add_argument("--warmup", type=int, default=20)
parser.add_argument("--iters", type=int, default=100)
parser.add_argument(
"--correctness-only",
action="store_true",
help="Run correctness checks without latency timing.",
)
parser.add_argument(
"--max-size-bytes",
type=int,
default=64 * 1024 * 1024,
help="Aiter CustomAllreduce IPC pool size.",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Print per-rank diagnostic details.",
)
return parser.parse_args()
def dtype_from_name(name: str) -> torch.dtype:
return DTYPE_MAP[name]
def logical_output_shape(input_shape: Shape, world_size: int, dim: int) -> Shape:
if dim < 0:
dim += len(input_shape)
return input_shape[:dim] + (input_shape[dim] * world_size,) + input_shape[dim + 1 :]
def raw_allgather_shape(input_shape: Shape, world_size: int) -> Shape:
return (input_shape[0] * world_size,) + input_shape[1:]
def reshape_logical(raw: torch.Tensor, input_shape: Shape, world_size: int, dim: int):
if dim < 0:
dim += len(input_shape)
return (
raw.reshape((world_size,) + input_shape)
.movedim(0, dim)
.reshape(logical_output_shape(input_shape, world_size, dim))
)
def make_input(shape: Shape, dtype: torch.dtype, device: torch.device, rank: int):
# Distinct per-rank values make rank-order errors visible in correctness.
numel = int(torch.tensor(shape).prod().item())
x = torch.arange(rank * (numel + 17), rank * (numel + 17) + numel, device=device)
if dtype == torch.uint64:
return x.reshape(shape).to(torch.uint64)
if dtype == torch.uint32:
return (x % (2**31)).reshape(shape).to(torch.uint32)
if dtype == torch.int16:
return (x % (2**14)).reshape(shape).to(torch.int16)
if dtype == torch.uint8:
return (x % (2**8)).reshape(shape).to(torch.uint8)
if dtype == torch.int8:
return (x % (2**7)).reshape(shape).to(torch.int8)
return x.reshape(shape).to(dtype)
def expected_logical_all_gather(
input_shape: Shape,
dtype: torch.dtype,
device: torch.device,
world_size: int,
dim: int,
) -> torch.Tensor:
parts = [make_input(input_shape, dtype, device, rank) for rank in range(world_size)]
if dim < 0:
dim += len(input_shape)
return torch.cat(parts, dim=dim)
@torch.inference_mode()
def rccl_logical_all_gather(
inp: torch.Tensor,
raw_out: torch.Tensor,
pg: dist.ProcessGroup,
dim: int = -1,
):
dist.all_gather_into_tensor(raw_out, inp, group=pg)
return reshape_logical(raw_out, tuple(inp.shape), dist.get_world_size(pg), dim)
@torch.inference_mode()
def aiter_logical_all_gather(
comm,
inp: torch.Tensor,
raw_out: torch.Tensor,
dim: int = -1,
):
# SGLang's patched path writes Aiter output into the same preallocated raw
# buffer used by all_gather_into_tensor, then applies the standard reshape.
comm.all_gather_unreg(inp, out=raw_out, dim=0)
return reshape_logical(raw_out, tuple(inp.shape), comm.world_size, dim)
def sync_avg(value: float, device: torch.device, pg: dist.ProcessGroup) -> float:
tensor = torch.tensor([value], dtype=torch.float64, device=device)
dist.all_reduce(tensor, op=dist.ReduceOp.AVG, group=pg)
return float(tensor.item())
def sync_max(value: float, device: torch.device, pg: dist.ProcessGroup) -> float:
tensor = torch.tensor([value], dtype=torch.float64, device=device)
dist.all_reduce(tensor, op=dist.ReduceOp.MAX, group=pg)
return float(tensor.item())
def fmt_optional_us(value: object) -> str:
if value is None:
return "None"
return f"{float(value):.2f}"
def install_aiter_aot_import_shims() -> None:
"""Let this standalone test import only the AITER pieces it needs.
Some dev environments have optional top-level AITER deps (for example
FlyDSL) that are unrelated to custom all-gather. `AITER_AOT_IMPORT=1`
avoids importing those modules; these shims provide the attributes that
AITER's distributed helpers expect from the top-level package.
"""
if os.getenv("AITER_AOT_IMPORT") != "1":
return
import aiter
from aiter.jit.utils.torch_guard import torch_compile_guard
from aiter.ops import custom_all_reduce
from aiter.ops.quant import get_hip_quant
aiter.torch_compile_guard = torch_compile_guard
aiter.get_hip_quant = get_hip_quant
for name in dir(custom_all_reduce):
if not name.startswith("_"):
setattr(aiter, name, getattr(custom_all_reduce, name))
def time_us(fn, warmup: int, iters: int) -> tuple[float, float]:
for _ in range(warmup):
fn()
torch.cuda.synchronize()
times: list[float] = []
start = torch.cuda.Event(enable_timing=True)
end = torch.cuda.Event(enable_timing=True)
for _ in range(iters):
start.record()
fn()
end.record()
torch.cuda.synchronize()
times.append(start.elapsed_time(end) * 1000.0)
return statistics.median(times), statistics.mean(times)
def check_correctness(
comm,
inp: torch.Tensor,
rccl_raw: torch.Tensor,
aiter_raw: torch.Tensor,
expected: torch.Tensor,
pg: dist.ProcessGroup | None,
dim: int = -1,
):
ref = expected
out = aiter_logical_all_gather(comm, inp, aiter_raw, dim=dim)
if ref.shape != out.shape:
raise AssertionError(
f"shape mismatch: ref={tuple(ref.shape)} out={tuple(out.shape)}"
)
if not torch.equal(ref, out):
max_abs = (ref.float() - out.float()).abs().max().item()
raise AssertionError(f"Aiter output mismatch, max_abs={max_abs}")
def main() -> None:
args = parse_args()
dist.init_process_group(backend=args.backend, init_method="env://")
rank = dist.get_rank()
world_size = dist.get_world_size()
local_rank = int(torch.cuda.device_count() > 0 and rank % torch.cuda.device_count())
torch.cuda.set_device(local_rank)
device = torch.device(f"cuda:{local_rank}")
pg = dist.group.WORLD
install_aiter_aot_import_shims()
from aiter.dist.device_communicators.custom_all_reduce import (
CustomAllreduce as AiterCustomAllreduce,
)
gloo_group = dist.new_group(ranks=list(range(world_size)), backend="gloo")
dtypes = [dtype_from_name(name) for name in args.dtype]
max_input_bytes = max(
int(torch.tensor(shape).prod().item())
* torch.tensor([], dtype=dtype).element_size()
for shape in args.shapes
for dtype in dtypes
)
pool_size = max(args.max_size_bytes, max_input_bytes * world_size * 2)
comm = AiterCustomAllreduce(group=gloo_group, device=device, max_size=pool_size)
rows: list[dict[str, object]] = []
for dtype_name, dtype in zip(args.dtype, dtypes):
for shape in args.shapes:
for dim in args.dims:
inp = make_input(shape, dtype, device, rank).contiguous()
input_bytes = inp.numel() * inp.element_size()
raw_shape = raw_allgather_shape(shape, world_size)
rccl_raw = torch.empty(raw_shape, dtype=dtype, device=device)
aiter_raw = torch.empty_like(rccl_raw)
expected = expected_logical_all_gather(
shape, dtype, device, world_size, dim
)
can_aiter = bool(comm.should_custom_ag(inp))
if not can_aiter:
if rank == 0:
print(
f"SKIP dtype={dtype_name} shape={shape} dim={dim}: "
"Aiter should_custom_ag=False"
)
rows.append(
{
"dtype": dtype_name,
"shape": shape,
"dim": dim,
"input_bytes": input_bytes,
"correct": False,
"rccl_us": None,
"aiter_us": None,
"speedup": None,
}
)
continue
check_correctness(comm, inp, rccl_raw, aiter_raw, expected, pg, dim=dim)
correct_flag = sync_max(0.0, device, pg) == 0.0
rccl_median_us = rccl_mean_us = None
aiter_median_us = aiter_mean_us = None
speedup = None
if not args.correctness_only:
dist.barrier(group=pg)
try:
rccl_median_us, rccl_mean_us = time_us(
lambda: rccl_logical_all_gather(inp, rccl_raw, pg, dim=dim),
args.warmup,
args.iters,
)
except Exception:
rccl_median_us = rccl_mean_us = None
dist.barrier(group=pg)
aiter_median_us, aiter_mean_us = time_us(
lambda: aiter_logical_all_gather(comm, inp, aiter_raw, dim=dim),
args.warmup,
args.iters,
)
dist.barrier(group=pg)
if rccl_median_us is not None:
rccl_median_us = sync_avg(rccl_median_us, device, pg)
rccl_mean_us = sync_avg(rccl_mean_us, device, pg)
aiter_median_us = sync_avg(aiter_median_us, device, pg)
aiter_mean_us = sync_avg(aiter_mean_us, device, pg)
speedup = (
rccl_median_us / aiter_median_us
if rccl_median_us is not None and aiter_median_us > 0
else None
)
rows.append(
{
"dtype": dtype_name,
"shape": shape,
"dim": dim,
"input_bytes": input_bytes,
"correct": correct_flag,
"rccl_us": rccl_median_us,
"aiter_us": aiter_median_us,
"rccl_mean_us": rccl_mean_us,
"aiter_mean_us": aiter_mean_us,
"speedup": speedup,
}
)
if args.verbose:
print(
f"[rank {rank}] dtype={dtype_name} shape={shape} dim={dim} "
f"rccl_median_us={fmt_optional_us(rccl_median_us)} "
f"aiter_median_us={fmt_optional_us(aiter_median_us)}"
)
if hasattr(comm, "close"):
comm.close()
if rank == 0:
print("\nResults (logical all-gather, avg median us across ranks)")
header = (
f"{'DType':>10} {'Shape':>14} {'Dim':>4} "
f"{'Input Bytes':>12} {'Correct':>7} "
f"{'RCCL us':>10} {'Aiter us':>10} {'Speedup':>8}"
)
print(header)
print("-" * len(header))
for row in rows:
rccl = row["rccl_us"]
aiter = row["aiter_us"]
speedup = row["speedup"]
print(
f"{str(row['dtype']):>10} "
f"{str(row['shape']):>14} "
f"{row['dim']:>4} "
f"{row['input_bytes']:>12} "
f"{str(row['correct']):>7} "
f"{fmt_optional_us(rccl):>10} "
f"{fmt_optional_us(aiter):>10} "
f"{speedup if speedup is not None else 0.0:>7.2f}x"
)
dist.barrier(group=pg)
dist.destroy_process_group()
if __name__ == "__main__":
main()
@@ -689,6 +689,21 @@ class GroupCoordinator:
total_bytes = input_.numel() * input_.element_size()
use_1stage_ar = total_bytes <= 128 * 1024
if (
getattr(ca_comm, "_IS_CAPTURING", False)
and not torch.cuda.is_current_stream_capturing()
and is_in_piecewise_cuda_graph()
):
if not hasattr(ca_comm, "fused_ar_rms"):
return None
return ca_comm.fused_ar_rms(
input_,
residual_inp_,
w=weight_,
eps=eps,
registered=False,
use_1stage=use_1stage_ar,
)
fused_outputs = ca_comm.custom_fused_ar_rms(
input_,
residual_inp_,
@@ -804,6 +819,35 @@ class GroupCoordinator:
return output
def _all_gather_into_tensor(self, output: torch.Tensor, input: torch.Tensor):
# Aiter custom all-gather (ROCm). Set SGLANG_USE_AITER_AG=0 to disable.
# Aiter's should_custom_ag still owns shape/layout validation:
# 16B alignment, weak-contiguous, supported topology, and per-rank
# size <= max_size/(world*2).
# On a hit, writes directly into the caller's pre-allocated `output` via
# all_gather_reg during CUDA-graph capture and all_gather_unreg otherwise.
ca_comm = self.ca_comm
if (
is_hip()
and envs.SGLANG_USE_AITER_AG.get()
and self._has_aiter_custom_all_gather()
and input.is_contiguous()
and output.is_contiguous()
and input.dtype in (torch.float32, torch.float16, torch.bfloat16)
and ca_comm.should_custom_ag(input)
):
if getattr(ca_comm, "_IS_CAPTURING", False):
if torch.cuda.is_current_stream_capturing():
ca_comm.all_gather_reg(input, out=output, dim=0)
elif is_in_piecewise_cuda_graph():
ca_comm.all_gather_unreg(input, out=output, dim=0)
else:
# True CUDA graph warmup: avoid a different host collective.
output.zero_()
return
else:
ca_comm.all_gather_unreg(input, out=output, dim=0)
return
pynccl_comm = self.pynccl_comm
if pynccl_comm is not None and (
not pynccl_comm.disabled or self.is_symmetric_memory_enabled()
@@ -818,6 +862,24 @@ class GroupCoordinator:
output, input, group=self.device_group
)
def _has_aiter_custom_all_gather(self) -> bool:
if self._deterministic_collectives_enabled():
return False
ca_comm = self.ca_comm
return (
ca_comm is not None
and not getattr(ca_comm, "disabled", True)
and hasattr(ca_comm, "should_custom_ag")
and hasattr(ca_comm, "all_gather_reg")
and hasattr(ca_comm, "all_gather_unreg")
)
@staticmethod
def _deterministic_collectives_enabled() -> bool:
if envs.SGLANG_USE_1STAGE_ALLREDUCE.is_set():
return envs.SGLANG_USE_1STAGE_ALLREDUCE.get()
return envs.SGLANG_ENABLE_DETERMINISTIC_INFERENCE.get()
def all_gather_into_tensor(self, output: torch.Tensor, input: torch.Tensor):
if _is_npu or _is_xpu:
self._all_gather_into_tensor(output, input)
+1
View File
@@ -382,6 +382,7 @@ class Envs:
# AMD & ROCm
SGLANG_USE_AITER = EnvBool(False)
SGLANG_USE_AITER_AG = EnvBool(True)
SGLANG_USE_AITER_UNIFIED_ATTN = EnvBool(False)
# Select the gate/up tile layout for AITER MoE: True -> interleave
# (matches FlyDSL `gate_mode="interleave"` kernels), False -> separated
@@ -0,0 +1,123 @@
import os
import subprocess
import sys
import unittest
from pathlib import Path
import torch
from sglang.test.ci.ci_register import register_amd_ci
register_amd_ci(est_time=180, suite="stage-c-test-large-8-gpu-amd")
class TestAiterAllGatherAmd(unittest.TestCase):
@staticmethod
def _gpu_count():
return torch.cuda.device_count() if torch.cuda.is_available() else 0
def test_aiter_allgather_matches_rccl(self):
gpu_count = self._gpu_count()
if gpu_count < 2:
self.skipTest("This test requires at least 2 GPUs.")
repo_root = Path(__file__).resolve().parents[3]
benchmark_script = (
repo_root / "benchmark" / "kernels" / "all_gather" / "benchmark_aiter.py"
)
self.assertTrue(
benchmark_script.exists(),
f"Missing benchmark script: {benchmark_script}",
)
dtype_names = [
"float32",
"float16",
"bfloat16",
"uint64_t",
"int64_t",
"uint32_t",
"int32_t",
"int16_t",
"uint8_t",
"int8_t",
]
# Keep the CI matrix compact: one small metadata shape and one
# medium aligned 2-D shape exercise both naive and vectorized kernels.
shapes = "16,;8,1024"
dims = "0,-1"
tp_sizes = [tp for tp in (2, 4, 8) if gpu_count >= tp]
outputs = []
for tp_size in tp_sizes:
cmd = [
sys.executable,
"-m",
"torch.distributed.run",
"--standalone",
f"--nproc_per_node={tp_size}",
str(benchmark_script),
"--dtype",
",".join(dtype_names),
"--shapes",
shapes,
"--dims",
dims,
"--warmup",
"0",
"--iters",
"1",
"--correctness-only",
]
env = os.environ.copy()
env.setdefault("AITER_AOT_IMPORT", "1")
result = subprocess.run(
cmd,
cwd=str(repo_root),
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
timeout=240,
)
outputs.append(f"### TP={tp_size}\n{result.stdout}")
if result.returncode != 0:
self.fail(
"Aiter all-gather correctness sweep failed.\n"
f"Return code: {result.returncode}\n"
f"Command: {' '.join(cmd)}\n"
f"Output:\n{result.stdout}"
)
expected_rows = (
len(dtype_names) * len(shapes.split(";")) * len(dims.split(","))
)
rows = [
line.strip()
for line in result.stdout.splitlines()
if line.strip().startswith(tuple(dtype_names))
]
self.assertEqual(
expected_rows,
len(rows),
f"Expected {expected_rows} rows for TP={tp_size}, got:\n{result.stdout}",
)
bad_rows = [row for row in rows if " True " not in row]
self.assertEqual(
[],
bad_rows,
f"Correctness failed for one or more all-gather rows:\n{result.stdout}",
)
if gpu_count >= 8:
self.assertEqual([2, 4, 8], tp_sizes)
else:
self.assertEqual([2, 4][: len(tp_sizes)], tp_sizes)
if __name__ == "__main__":
unittest.main()