Files
sglang/test/registered/kernels/ops/kimi_k3/test_ar_fusion.py
T

461 lines
17 KiB
Python

"""Correctness test for the K3 MNNVL fused all-reduce (ar_fusion) kernels.
Compares the 1shot multicast-push and the in-place low-SM NVLS 2shot pull
(with and without the fused residual) against
NCCL, bit-exact on small-int bf16 inputs; the fused-RMSNorm pull against a
torch reference; the pull tuning knobs (num_blocks, unroll) on sizes whose
shard split is uneven; plus a CUDA-graph capture/replay pass and a mixed
stress loop exercising the push phase double-buffering and the pull
semaphore window cycling.
Usage::
python test/registered/kernels/ops/kimi_k3/test_ar_fusion.py # relaunches under torchrun (8 GPUs)
"""
from __future__ import annotations
import atexit
import logging
import os
import pytest
import torch
import torch.distributed as dist
import sglang.srt.distributed.parallel_state as ps
from sglang.kernels.jit.utils import cache_once, get_ci_test_range
from sglang.kernels.ops.communication.mp import register_comm_cleanup
from sglang.kernels.ops.kimi_k3 import all_reduce
from sglang.srt.distributed.device_communicators.custom_all_reduce_v2 import (
CustomAllReduceV2,
)
from sglang.srt.runtime_context import get_parallel
from sglang.test.ci.ci_register import register_cuda_ci
from sglang.test.kernels.utils import multigpu_pytest_main
register_cuda_ci(
est_time=240,
stage="extra-b",
runner_config="8-gpu-h200",
)
H = 7168 # Kimi-K3 hidden size; the kernels are tuned/used at multiples of it
NORM_DIM = 3584 # latent width; the norm buffer is [N, NORM_DIM] + [N, 2*NORM_DIM]
MB = 1024 * 1024
PUSH_BS = [1, 2, 8, 32, 128]
PULL_BS = [1, 8, 64, 1024, 4096]
PUSH_BS = get_ci_test_range(PUSH_BS, [1, 32, 128])
PULL_BS = get_ci_test_range(PULL_BS, [1, 64, 4096])
def _precompile(num_gpus):
for ws in num_gpus:
all_reduce._jit_module(ws)
@cache_once
def _init_world():
local_rank = int(os.environ["LOCAL_RANK"])
world_size = int(os.environ["WORLD_SIZE"])
torch.cuda.set_device(local_rank)
dist.init_process_group(backend="gloo")
ps._WORLD = coord = ps.init_world_group(
ranks=list(range(world_size)),
local_rank=local_rank,
backend="nccl",
)
get_parallel().override_permanently(world_group=coord)
atexit.register(dist.destroy_process_group)
logging.disable(logging.INFO)
torch.cuda.set_stream(torch.cuda.Stream())
return coord.cpu_group
@cache_once
def _init_nccl_group():
_init_world()
local_rank = int(os.environ["LOCAL_RANK"])
group = dist.new_group(backend="nccl", device_id=torch.device(f"cuda:{local_rank}"))
assert isinstance(group, dist.ProcessGroup)
return group
def _symm_alloc_mc(shape, dtype) -> tuple[torch.Tensor, int]:
import torch.distributed._symmetric_memory as torch_symm_mem
cpu_group = _init_world()
rank = dist.get_rank()
device = torch.device(f"cuda:{int(os.environ['LOCAL_RANK'])}")
pool = torch_symm_mem.get_mem_pool(device)
with torch.cuda.use_mem_pool(pool):
buf = torch.empty(shape, dtype=dtype, device=device)
hdl = torch_symm_mem.rendezvous(buf, cpu_group.group_name)
assert hdl.multicast_ptr != 0
mc = hdl.multicast_ptr + (buf.data_ptr() - hdl.buffer_ptrs[rank])
return buf, mc
@cache_once
def _init_comm() -> CustomAllReduceV2:
cpu_group = _init_world()
device = torch.device(f"cuda:{int(os.environ['LOCAL_RANK'])}")
comm = CustomAllReduceV2(
cpu_group, device, max_pull_size=1 * MB, max_push_size=2 * MB
)
if comm.disabled or not comm.has_multicast:
raise RuntimeError("ar_fusion requires CustomAllReduceV2 with multicast")
all_reduce.register_comm(comm.obj)
register_comm_cleanup(comm)
return comm
@cache_once
def _init_pool_buf() -> tuple[torch.Tensor, int]:
# 1.5x headroom: the norm tests view the buffer as [N, 3584 + 7168]
return _symm_alloc_mc((max(PULL_BS) * H * 3 // 2,), torch.bfloat16)
def _device() -> torch.device:
return torch.device(f"cuda:{int(os.environ['LOCAL_RANK'])}")
def _int_input(n: int, seed: int, per_rank: bool) -> torch.Tensor:
# small ints are exact in bf16 even after an fp32-accumulated 8-way sum,
# so the comparison against NCCL is bit-exact
rank = dist.get_rank() if per_rank else 0
g = torch.Generator().manual_seed(seed * 1009 + rank)
return torch.randint(0, 16, (n,), dtype=torch.bfloat16, generator=g).to(_device())
def _nccl_ref(x: torch.Tensor, residual):
ref = x.clone()
dist.all_reduce(ref, group=_init_nccl_group())
return ref if residual is None else ref + residual
def _norm_ref(x_reduced: torch.Tensor, num_norm_rows: int, weight, eps: float):
"""allreduce result -> RMSNorm over the first num_norm_rows rows of the
[numel / NORM_DIM, NORM_DIM] row view, in fp32 like the kernels."""
out = x_reduced.clone()
normed_part = out[: num_norm_rows * NORM_DIM].view(num_norm_rows, NORM_DIM).float()
factor = torch.rsqrt(normed_part.pow(2).mean(-1, keepdim=True) + eps)
normed = (normed_part * factor * weight.float()).to(torch.bfloat16)
out[: num_norm_rows * NORM_DIM] = normed.view(-1)
return out
def _assert_norm_close(x: torch.Tensor, ref: torch.Tensor, num_norm_rows: int):
# the non-normed tail is a plain allreduce: bit-exact; the normed prefix
# gets the fp32 norm epilogue: bf16 tolerances
torch.testing.assert_close(
x[num_norm_rows * NORM_DIM :], ref[num_norm_rows * NORM_DIM :], atol=0, rtol=0
)
torch.testing.assert_close(
x[: num_norm_rows * NORM_DIM], ref[: num_norm_rows * NORM_DIM]
)
@pytest.mark.parametrize("bs", PUSH_BS)
@pytest.mark.parametrize("use_residual", [False, True])
@torch.inference_mode()
def test_ar_fusion_push(bs: int, use_residual: bool):
comm = _init_comm()
world = comm.world_size
n = bs * H
x = _int_input(n, bs, per_rank=True)
residual = _int_input(n, bs + 7, per_rank=False) if use_residual else None
ref = _nccl_ref(x, residual)
all_reduce.all_reduce_push_res(world, x, residual)
torch.cuda.synchronize()
torch.testing.assert_close(x, ref, atol=0, rtol=0)
@pytest.mark.parametrize("bs", PULL_BS)
@pytest.mark.parametrize("use_residual", [False, True])
@torch.inference_mode()
def test_ar_fusion_pull_2shot(bs: int, use_residual: bool):
comm = _init_comm()
world = comm.world_size
buf, mc = _init_pool_buf()
n = bs * H
x = buf[:n]
x.copy_(_int_input(n, bs + 13, per_rank=True))
residual = _int_input(n, bs + 17, per_rank=False) if use_residual else None
ref = _nccl_ref(x, residual)
all_reduce.all_reduce_pull_res(world, x, residual, input_mc_ptr=mc)
torch.cuda.synchronize()
torch.testing.assert_close(x, ref, atol=0, rtol=0)
@pytest.mark.parametrize("num_blocks", [1, 2, 4, 8])
@pytest.mark.parametrize("unroll", [4, 8])
@torch.inference_mode()
def test_ar_fusion_pull_tuning_grid(num_blocks: int, unroll: int):
"""Every (num_blocks, unroll) combination must agree with NCCL on a size
whose 16B-vector count is not divisible by the world size (uneven shards)
and whose per-thread range leaves an unrolled-loop tail."""
_init_comm()
world = dist.get_world_size()
buf, mc = _init_pool_buf()
n = (3 * H + 7) * 8 # 21511 vecs: % 8 ranks != 0, small vs blocks*512*unroll
x = buf[:n]
x.copy_(_int_input(n, num_blocks * 10 + unroll, per_rank=True))
ref = _nccl_ref(x, None)
all_reduce.all_reduce_pull_res(
world, x, None, input_mc_ptr=mc, num_blocks=num_blocks, unroll=unroll
)
torch.cuda.synchronize()
torch.testing.assert_close(x, ref, atol=0, rtol=0)
@pytest.mark.parametrize("num_tokens", PULL_BS)
@pytest.mark.parametrize("rows_per_token", [3, 1]) # [N|2N] MoE buf / latent-only
@torch.inference_mode()
def test_ar_fusion_pull_norm(num_tokens: int, rows_per_token: int):
_init_comm()
world = dist.get_world_size()
buf, mc = _init_pool_buf()
n = num_tokens * rows_per_token * NORM_DIM
x = buf[:n]
x.copy_(_int_input(n, num_tokens + 23 + rows_per_token, per_rank=True))
weight = _int_input(NORM_DIM, 29, per_rank=False) + 1 # small positive ints
ref = _norm_ref(_nccl_ref(x, None), num_tokens, weight, eps=1e-6)
all_reduce.all_reduce_pull_norm(
world, x, weight, 1e-6, num_norm_rows=num_tokens, input_mc_ptr=mc
)
torch.cuda.synchronize()
_assert_norm_close(x, ref, num_tokens)
@pytest.mark.parametrize("num_tokens", [1, 8, 24])
@pytest.mark.parametrize("rows_per_token", [3, 1])
@torch.inference_mode()
def test_ar_fusion_push_norm(num_tokens: int, rows_per_token: int):
"""The push-side norm (small-message regime of the serving dispatch) with
an explicit num_norm_rows, on both the MoE-buffer and latent-only row
layouts."""
comm = _init_comm()
world = comm.world_size
n = num_tokens * rows_per_token * NORM_DIM
x = _int_input(n, num_tokens + 41 + rows_per_token, per_rank=True)
weight = _int_input(NORM_DIM, 43, per_rank=False) + 1
ref = _norm_ref(_nccl_ref(x, None), num_tokens, weight, eps=1e-6)
all_reduce.all_reduce_push_norm(world, x, weight, 1e-6, num_norm_rows=num_tokens)
torch.cuda.synchronize()
_assert_norm_close(x, ref, num_tokens)
FIN_TOPK = 16
def _build_permuted_layout(
num_tokens: int, seed: int, w_dtype: torch.dtype = torch.float32
):
"""trtllm-gen permuted gemm2 layout (rows grouped by expert, per-expert
tile padding). Deterministic on CPU: idx/weights are identical on every
rank (TP semantics — same routing), gemm2 values are per-rank.
``w_dtype`` is the routing-weight dtype the deferred finalize hands back."""
num_experts, tile = 896, 8
gen = torch.Generator(device="cpu").manual_seed(seed)
topk_ids = torch.stack(
[
torch.randperm(num_experts, generator=gen)[:FIN_TOPK]
for _ in range(num_tokens)
]
)
counts = torch.bincount(topk_ids.flatten(), minlength=num_experts)
padded = (counts + tile - 1) // tile * tile
bases = torch.cumsum(padded, 0) - padded
fill = torch.zeros(num_experts, dtype=torch.long)
idx = torch.empty(num_tokens * FIN_TOPK, dtype=torch.int32)
for i, e in enumerate(topk_ids.flatten().tolist()):
idx[i] = bases[e] + fill[e]
fill[e] += 1
weights = torch.rand(num_tokens, FIN_TOPK, generator=gen).to(w_dtype)
num_rows = int(padded.sum())
g = torch.Generator(device="cpu").manual_seed(seed * 31 + dist.get_rank())
gemm2 = (torch.randn(num_rows, NORM_DIM, generator=g) * 2).to(torch.bfloat16)
dev = _device()
return gemm2.to(dev), idx.to(dev), weights.to(dev)
def _finalize_norm_ref(gemm2, idx, weights, norm_w, eps: float) -> torch.Tensor:
"""Replicates the fused kernel numerics: fp32 ascending-k local finalize
cast to bf16 (the staged push value), rank-ordered fp32 cross-rank sum,
fp32 RMSNorm. Only the rsqrt may differ from the kernel by ulps."""
num_tokens = weights.shape[0]
idx2 = idx.view(num_tokens, FIN_TOPK).long()
acc = torch.zeros(num_tokens, NORM_DIM, dtype=torch.float32, device=gemm2.device)
for k in range(FIN_TOPK):
acc += weights[:, k, None].float() * gemm2[idx2[:, k]].float()
local = acc.to(torch.bfloat16)
world = dist.get_world_size()
gathered = [torch.empty_like(local) for _ in range(world)]
dist.all_gather(gathered, local, group=_init_nccl_group())
total = torch.zeros_like(acc)
for r in range(world):
total += gathered[r].float()
factor = torch.rsqrt(total.square().mean(dim=-1, keepdim=True) + eps)
return (total * factor * norm_w.float()).to(torch.bfloat16)
@pytest.mark.parametrize("w_dtype", [torch.float32, torch.bfloat16])
@pytest.mark.parametrize("bs", PUSH_BS)
@torch.inference_mode()
def test_ar_fusion_finalize_push_norm(bs: int, w_dtype: torch.dtype):
comm = _init_comm()
world = comm.world_size
eps = 1e-6
gemm2, idx, weights = _build_permuted_layout(bs, seed=bs + 23, w_dtype=w_dtype)
g = torch.Generator(device="cpu").manual_seed(77)
norm_w = (torch.rand(NORM_DIM, generator=g) + 0.5).to(torch.bfloat16).to(_device())
ref = _finalize_norm_ref(gemm2, idx, weights, norm_w, eps)
out = torch.empty(bs, NORM_DIM, dtype=torch.bfloat16, device=_device())
all_reduce.finalize_all_reduce_push_norm(
world, out, gemm2, idx, weights, norm_w, eps
)
torch.cuda.synchronize()
torch.testing.assert_close(out, ref, atol=1e-2, rtol=1e-2)
@torch.inference_mode()
def test_ar_fusion_finalize_push_norm_stress():
"""Back-to-back fused calls interleaved with plain pushes exercise the
shared push-workspace phase double-buffering across kernel variants."""
comm = _init_comm()
world = comm.world_size
eps = 1e-5
g = torch.Generator(device="cpu").manual_seed(78)
norm_w = (torch.rand(NORM_DIM, generator=g) + 0.5).to(torch.bfloat16).to(_device())
for it in range(12):
bs = (1, 8, 32)[it % 3]
# alternate the routing-weight precision: both kernel instantiations
# share the one push workspace
w_dtype = (torch.float32, torch.bfloat16)[it % 2]
gemm2, idx, weights = _build_permuted_layout(
bs, seed=9000 + it, w_dtype=w_dtype
)
ref = _finalize_norm_ref(gemm2, idx, weights, norm_w, eps)
out = torch.empty(bs, NORM_DIM, dtype=torch.bfloat16, device=_device())
all_reduce.finalize_all_reduce_push_norm(
world, out, gemm2, idx, weights, norm_w, eps
)
torch.testing.assert_close(out, ref, atol=1e-2, rtol=1e-2)
x = _int_input(bs * H, 8000 + it, per_rank=True)
ref2 = _nccl_ref(x, None)
all_reduce.all_reduce_push_res(world, x, None)
torch.testing.assert_close(x, ref2, atol=0, rtol=0)
@pytest.mark.parametrize("num_blocks", [1, 4, 16])
@pytest.mark.parametrize("unroll", [4, 8])
@torch.inference_mode()
def test_ar_fusion_pull_norm_tuning_grid(num_blocks: int, unroll: int):
"""Every (num_blocks, unroll) combination must agree on a token count
whose row count is not divisible by the world size (uneven row shards)
and not by unroll (partial last row group per block)."""
_init_comm()
world = dist.get_world_size()
buf, mc = _init_pool_buf()
num_tokens = 13 # 39 rows: % 8 ranks != 0, per-rank rows < num_blocks*unroll
n = num_tokens * 3 * NORM_DIM
x = buf[:n]
x.copy_(_int_input(n, 500 + num_blocks * 10 + unroll, per_rank=True))
weight = _int_input(NORM_DIM, 31, per_rank=False) + 1
ref = _norm_ref(_nccl_ref(x, None), num_tokens, weight, eps=1e-6)
all_reduce.all_reduce_pull_norm(
world,
x,
weight,
1e-6,
num_norm_rows=num_tokens,
input_mc_ptr=mc,
num_blocks=num_blocks,
unroll=unroll,
)
torch.cuda.synchronize()
_assert_norm_close(x, ref, num_tokens)
@torch.inference_mode()
def test_ar_fusion_stress_mixed():
"""Back-to-back mixed calls exercise the push phase double-buffering and
the pull semaphore window cycling (with varying grids)."""
comm = _init_comm()
world = comm.world_size
buf, mc = _init_pool_buf()
for it in range(32):
n = (1, 8, 64)[it % 3] * H
num_blocks = (1, 2, 4, 8)[it % 4]
x = _int_input(n, 3000 + it, per_rank=True)
ref = _nccl_ref(x, None)
all_reduce.all_reduce_push_res(world, x, None)
torch.testing.assert_close(x, ref, atol=0, rtol=0)
y = buf[:n]
y.copy_(_int_input(n, 4000 + it, per_rank=True))
ref2 = _nccl_ref(y, None)
all_reduce.all_reduce_pull_res(
world, y, None, input_mc_ptr=mc, num_blocks=num_blocks
)
torch.testing.assert_close(y, ref2, atol=0, rtol=0)
@torch.inference_mode()
def test_ar_fusion_graph_capture():
comm = _init_comm()
world = comm.world_size
buf, mc = _init_pool_buf()
cpu_group = _init_world()
n = 64 * H
gres = _int_input(n, 99, per_rank=False)
gx = torch.zeros(n, dtype=torch.bfloat16, device=_device())
# two disjoint regions of the symm buffer, one per captured pull kernel
gy, mc_y = buf[:n], mc
gz, mc_z = buf[n : 2 * n], mc + n * buf.element_size()
def _run_all():
all_reduce.all_reduce_push_res(world, gx, gres)
all_reduce.all_reduce_pull_res(world, gy, gres, input_mc_ptr=mc_y)
all_reduce.all_reduce_pull_res(world, gz, gres, input_mc_ptr=mc_z)
stream = torch.cuda.Stream()
stream.wait_stream(torch.cuda.current_stream())
with torch.cuda.stream(stream):
_run_all()
torch.cuda.current_stream().wait_stream(stream)
torch.cuda.synchronize()
dist.barrier(group=cpu_group)
graph = torch.cuda.CUDAGraph()
with torch.cuda.graph(graph):
_run_all()
for it in range(4):
vx = _int_input(n, 5000 + it, per_rank=True)
vy = _int_input(n, 6000 + it, per_rank=True)
vz = _int_input(n, 7000 + it, per_rank=True)
ref_x = _nccl_ref(vx, gres)
ref_y = _nccl_ref(vy, gres)
ref_z = _nccl_ref(vz, gres)
gx.copy_(vx)
gy.copy_(vy)
gz.copy_(vz)
dist.barrier(group=cpu_group)
torch.cuda.synchronize()
graph.replay()
torch.cuda.synchronize()
torch.testing.assert_close(gx, ref_x, atol=0, rtol=0)
torch.testing.assert_close(gy, ref_y, atol=0, rtol=0)
torch.testing.assert_close(gz, ref_z, atol=0, rtol=0)
if __name__ == "__main__":
multigpu_pytest_main(
__name__,
__file__,
num_gpus=(8,),
pre_launch_fn=_precompile,
)