59a0a3844c
PR Test AMD / cancel-on-close (push) Has been skipped
PR Test NVIDIA ARM / scan (push) Has been skipped
PR Test NVIDIA / cancel-on-close (push) Has been skipped
PR Test AMD / scan (push) Has been skipped
PR Test NVIDIA ARM / cancel-on-close (push) Has been skipped
PR Test NVIDIA / scan (push) Has been skipped
Release Docker Images / build (cu129-torch-2.11.0) (push) Has been skipped
Release Docker Images / build (cu130-torch-2.11.0) (push) Has been skipped
Release PyPI / publish (push) Has been skipped
Scheduler Python Test / test (push) Successful in 27m19s
Docs / build (push) Successful in 28m8s
Scheduler C++ Test / test (push) Successful in 28m19s
Scheduler C++ Test / test-flat (push) Successful in 28m18s
Docs / deploy (push) Has been cancelled
PR Test AMD / finish (push) Has been cancelled
PR Test NVIDIA / finish (push) Has been cancelled
PR Test NVIDIA ARM / finish (push) Has been cancelled
PR Test NVIDIA ARM / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test AMD / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test NVIDIA / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
421 lines
14 KiB
Python
421 lines
14 KiB
Python
# Copyright (c) 2026 LightSeek Foundation
|
|
#
|
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
# of this software and associated documentation files (the "Software"), to deal
|
|
# in the Software without restriction, including without limitation the rights
|
|
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
# copies of the Software, and to permit persons to whom the Software is
|
|
# furnished to do so, subject to the following conditions:
|
|
#
|
|
# The above copyright notice and this permission notice shall be included in
|
|
# all copies or substantial portions of the Software.
|
|
#
|
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
# SOFTWARE.
|
|
|
|
|
|
import socket
|
|
import traceback
|
|
from typing import List, Tuple
|
|
|
|
import pytest
|
|
import torch
|
|
import torch.distributed as dist
|
|
import torch.multiprocessing as mp
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _get_open_port() -> int:
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
sock.bind(("", 0))
|
|
return sock.getsockname()[1]
|
|
|
|
|
|
def _skip_if_unsupported(world_size: int, reason_prefix: str) -> None:
|
|
if not torch.cuda.is_available():
|
|
pytest.skip(f"CUDA/ROCm is required for {reason_prefix}")
|
|
if world_size > torch.cuda.device_count():
|
|
pytest.skip(f"Need {world_size} GPUs, have {torch.cuda.device_count()}")
|
|
if not torch.version.hip:
|
|
pytest.skip(f"{reason_prefix} only targets AMD ROCm")
|
|
try:
|
|
import iris # noqa: F401
|
|
except ImportError:
|
|
pytest.skip("iris is not installed")
|
|
|
|
|
|
def _spawn_and_collect(worker_fn, args, world_size: int) -> None:
|
|
error_dict = mp.Manager().dict()
|
|
mp.spawn(
|
|
worker_fn,
|
|
args=args + (error_dict,),
|
|
nprocs=world_size,
|
|
join=True,
|
|
)
|
|
|
|
if error_dict:
|
|
raise RuntimeError("\n".join(f"Rank {r}: {e}" for r, e in error_dict.items()))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Suite 1: iris_all_reduce
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _ar_shape_cases() -> List[Tuple[int, ...]]:
|
|
"""Shapes covering small, vector, and 2-D cases."""
|
|
return [
|
|
(8,),
|
|
(16, 64),
|
|
(4, 7, 32),
|
|
]
|
|
|
|
|
|
def _ar_worker_fn(rank, world_size, port, error_dict):
|
|
try:
|
|
_ar_worker_main(rank, world_size, port)
|
|
except Exception:
|
|
error_dict[rank] = traceback.format_exc()
|
|
|
|
|
|
def _ar_worker_main(rank: int, world_size: int, port: int) -> None:
|
|
device = torch.device(f"cuda:{rank}")
|
|
torch.cuda.set_device(device)
|
|
# Iris's example uses gloo because heap-base exchange is host-side; nccl
|
|
# also works, but gloo avoids contending with the iris-managed device
|
|
# memory and matches the upstream example.
|
|
dist.init_process_group(
|
|
backend="gloo",
|
|
init_method=f"tcp://localhost:{port}",
|
|
rank=rank,
|
|
world_size=world_size,
|
|
)
|
|
|
|
try:
|
|
# Importing inside the worker avoids pulling iris into the parent
|
|
# process (which has no distributed context).
|
|
from tokenspeed_kernel.ops.communication.iris import create_iris_state
|
|
|
|
max_numel = max(int(torch.tensor(s).prod()) for s in _ar_shape_cases())
|
|
state = create_iris_state(
|
|
group=dist.group.WORLD,
|
|
rank_in_group=rank,
|
|
max_numel=max_numel,
|
|
dtype=torch.bfloat16,
|
|
)
|
|
for shape in _ar_shape_cases():
|
|
_check_all_reduce(state, rank, world_size, shape, device)
|
|
finally:
|
|
dist.destroy_process_group()
|
|
|
|
|
|
def _check_all_reduce(state, rank: int, world_size: int, shape, device) -> None:
|
|
from tokenspeed_kernel.ops.communication.iris import iris_all_reduce
|
|
|
|
# Each rank contributes a tensor filled with ``rank + 1``; the reduction
|
|
# is therefore ``sum(1..world_size) = world_size*(world_size+1)/2``.
|
|
local = torch.full(shape, rank + 1, dtype=torch.bfloat16, device=device)
|
|
|
|
result = iris_all_reduce(state, local)
|
|
|
|
expected_value = world_size * (world_size + 1) // 2
|
|
expected = torch.full(shape, expected_value, dtype=torch.bfloat16, device=device)
|
|
|
|
assert (
|
|
result.shape == expected.shape
|
|
), f"shape mismatch: {result.shape} vs {expected.shape}"
|
|
torch.testing.assert_close(result, expected, atol=0, rtol=0)
|
|
|
|
|
|
def _run_ar_test(world_size: int) -> None:
|
|
_skip_if_unsupported(world_size, "Iris all-reduce tests")
|
|
port = _get_open_port()
|
|
_spawn_and_collect(_ar_worker_fn, (world_size, port), world_size)
|
|
|
|
|
|
def test_iris_all_reduce_correctness_world2():
|
|
_run_ar_test(world_size=2)
|
|
|
|
|
|
def test_iris_all_reduce_correctness_world4():
|
|
_run_ar_test(world_size=4)
|
|
|
|
|
|
def test_iris_all_reduce_correctness_world8():
|
|
_run_ar_test(world_size=8)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Suite 2: IrisRSAG (reduce-scatter / all-gather)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _rsag_uniform_token_cases(world_size: int) -> List[List[int]]:
|
|
return [
|
|
[8] * world_size,
|
|
[16] * world_size,
|
|
[64] * world_size,
|
|
]
|
|
|
|
|
|
def _rsag_worker_fn(rank, world_size, port, hidden_size, error_dict):
|
|
try:
|
|
_rsag_worker_main(rank, world_size, port, hidden_size)
|
|
except Exception:
|
|
error_dict[rank] = traceback.format_exc()
|
|
|
|
|
|
def _rsag_worker_main(rank: int, world_size: int, port: int, hidden_size: int) -> None:
|
|
device = torch.device(f"cuda:{rank}")
|
|
torch.cuda.set_device(device)
|
|
# Match the upstream iris example - gloo for the host-side rendezvous.
|
|
dist.init_process_group(
|
|
backend="gloo",
|
|
init_method=f"tcp://localhost:{port}",
|
|
rank=rank,
|
|
world_size=world_size,
|
|
)
|
|
|
|
try:
|
|
from tokenspeed_kernel.ops.communication.iris import create_iris_rsag_state
|
|
|
|
cases = _rsag_uniform_token_cases(world_size)
|
|
max_tokens = max(sum(tokens) for tokens in cases)
|
|
rsag = create_iris_rsag_state(
|
|
group=dist.group.WORLD,
|
|
rank_in_group=rank,
|
|
max_tokens=max_tokens,
|
|
hidden_size=hidden_size,
|
|
)
|
|
|
|
# The generic ``all_gather`` / ``reduce_scatter`` dispatchers in
|
|
# ``communication.triton`` route AMD calls to ``amd_rsag_*`` (which
|
|
# require ``state.symm_mem_hdl``); we deliberately bypass that
|
|
# dispatcher and call the iris RSAG state directly. ``rsag`` IS the
|
|
# IrisRSAG instance now (no TritonCommState wrapper).
|
|
ag_fn = lambda state, t, **kw: rsag.all_gather(t, **kw) # noqa: E731
|
|
rs_fn = lambda state, t, **kw: rsag.reduce_scatter(t, **kw) # noqa: E731
|
|
|
|
for tokens in cases:
|
|
_check_all_gather(
|
|
rsag, rank, world_size, tokens, hidden_size, device, ag_fn
|
|
)
|
|
_check_reduce_scatter(
|
|
rsag, rank, world_size, tokens, hidden_size, device, rs_fn
|
|
)
|
|
finally:
|
|
dist.destroy_process_group()
|
|
|
|
|
|
def _check_all_gather(rsag, rank, world_size, tokens, hidden_size, device, all_gather):
|
|
local_tokens = tokens[rank]
|
|
local = torch.full(
|
|
(local_tokens, hidden_size),
|
|
rank + 1,
|
|
dtype=torch.bfloat16,
|
|
device=device,
|
|
)
|
|
|
|
result = all_gather(rsag, local, token_list_in_group=tokens)
|
|
|
|
expected = torch.empty(
|
|
(sum(tokens), hidden_size), dtype=torch.bfloat16, device=device
|
|
)
|
|
offset = 0
|
|
for peer, peer_tokens in enumerate(tokens):
|
|
expected[offset : offset + peer_tokens].fill_(peer + 1)
|
|
offset += peer_tokens
|
|
|
|
assert result.shape == expected.shape, f"{result.shape} vs {expected.shape}"
|
|
torch.testing.assert_close(result, expected, atol=0, rtol=0)
|
|
|
|
|
|
def _check_reduce_scatter(
|
|
rsag, rank, world_size, tokens, hidden_size, device, reduce_scatter
|
|
):
|
|
full = torch.full(
|
|
(sum(tokens), hidden_size),
|
|
rank + 1,
|
|
dtype=torch.bfloat16,
|
|
device=device,
|
|
)
|
|
|
|
result = reduce_scatter(rsag, full, token_list_in_group=tokens)
|
|
|
|
expected_value = world_size * (world_size + 1) // 2
|
|
expected = torch.full(
|
|
(tokens[rank], hidden_size),
|
|
expected_value,
|
|
dtype=torch.bfloat16,
|
|
device=device,
|
|
)
|
|
|
|
assert result.shape == expected.shape, f"{result.shape} vs {expected.shape}"
|
|
torch.testing.assert_close(result, expected, atol=0, rtol=0)
|
|
|
|
|
|
def _run_rsag_test(world_size: int, hidden_size: int) -> None:
|
|
_skip_if_unsupported(world_size, "IrisRSAG tests")
|
|
port = _get_open_port()
|
|
_spawn_and_collect(_rsag_worker_fn, (world_size, port, hidden_size), world_size)
|
|
|
|
|
|
def test_iris_rsag_correctness_world2():
|
|
_run_rsag_test(world_size=2, hidden_size=2880)
|
|
|
|
|
|
def test_iris_rsag_correctness_world4():
|
|
_run_rsag_test(world_size=4, hidden_size=2880)
|
|
|
|
|
|
def test_iris_rsag_correctness_world8():
|
|
_run_rsag_test(world_size=8, hidden_size=2880)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Suite 3: fused allreduce + residual + RMSNorm
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Token shapes spanning decode (1), short/long prefill (256, 1024), and
|
|
# the full ``max_token_num`` (8192) so we exercise both the small-M code
|
|
# path and the path that walks the full symmetric heap buffer. Hidden=2880
|
|
# is the gpt-oss-120b size we use elsewhere.
|
|
_ARRMS_TOKEN_CASES: List[int] = [1, 64, 256, 1024, 8192]
|
|
_ARRMS_HIDDEN_DIM = 2880
|
|
_ARRMS_EPS = 1e-6
|
|
|
|
|
|
def _arrms_worker_fn(rank, world_size, port, persistent, error_dict):
|
|
try:
|
|
_arrms_worker_main(rank, world_size, port, persistent)
|
|
except Exception:
|
|
error_dict[rank] = traceback.format_exc()
|
|
|
|
|
|
def _arrms_worker_main(rank: int, world_size: int, port: int, persistent: bool) -> None:
|
|
device = torch.device(f"cuda:{rank}")
|
|
torch.cuda.set_device(device)
|
|
# NCCL is fine here — iris's heap-base exchange is host-side and works
|
|
# the same over any default group.
|
|
dist.init_process_group(
|
|
backend="nccl",
|
|
init_method=f"tcp://localhost:{port}",
|
|
rank=rank,
|
|
world_size=world_size,
|
|
)
|
|
|
|
try:
|
|
from tokenspeed_kernel.ops.communication.iris import (
|
|
create_iris_ar_rmsnorm_state,
|
|
)
|
|
|
|
max_token_num = max(_ARRMS_TOKEN_CASES)
|
|
state = create_iris_ar_rmsnorm_state(
|
|
group=dist.group.WORLD,
|
|
rank_in_group=rank,
|
|
max_token_num=max_token_num,
|
|
hidden_dim=_ARRMS_HIDDEN_DIM,
|
|
dtype=torch.bfloat16,
|
|
persistent=persistent,
|
|
)
|
|
|
|
# Use a fixed RMSNorm weight that is *not* identity, so a bug in
|
|
# the weight load path would fail the test.
|
|
weight = torch.linspace(
|
|
0.5, 1.5, _ARRMS_HIDDEN_DIM, dtype=torch.bfloat16, device=device
|
|
)
|
|
|
|
for tokens in _ARRMS_TOKEN_CASES:
|
|
_check_arrms_one(
|
|
state,
|
|
rank=rank,
|
|
world_size=world_size,
|
|
tokens=tokens,
|
|
weight=weight,
|
|
device=device,
|
|
)
|
|
finally:
|
|
dist.destroy_process_group()
|
|
|
|
|
|
def _check_arrms_one(state, rank, world_size, tokens, weight, device) -> None:
|
|
from tokenspeed_kernel.ops.communication.iris import (
|
|
iris_allreduce_residual_rmsnorm,
|
|
)
|
|
|
|
# Each rank contributes ``rank + 1``; sum across ranks is therefore
|
|
# ``world_size * (world_size + 1) / 2``. Residual is non-uniform
|
|
# (linspace) so the kernel can't accidentally short-circuit it.
|
|
x = torch.full(
|
|
(tokens, _ARRMS_HIDDEN_DIM), rank + 1, dtype=torch.bfloat16, device=device
|
|
)
|
|
residual = (
|
|
torch.arange(tokens * _ARRMS_HIDDEN_DIM, dtype=torch.float32, device=device)
|
|
.reshape(tokens, _ARRMS_HIDDEN_DIM)
|
|
.mul_(0.001)
|
|
.to(torch.bfloat16)
|
|
)
|
|
|
|
norm_out, residual_out = iris_allreduce_residual_rmsnorm(
|
|
state,
|
|
input_tensor=x,
|
|
residual=residual,
|
|
weight=weight,
|
|
eps=_ARRMS_EPS,
|
|
)
|
|
|
|
# Reference: do everything in fp32, mirroring the AMD test exactly so
|
|
# tolerance differences only reflect implementation noise, not
|
|
# reference noise.
|
|
reduced = torch.full(
|
|
(tokens, _ARRMS_HIDDEN_DIM),
|
|
world_size * (world_size + 1) // 2,
|
|
dtype=torch.float32,
|
|
device=device,
|
|
)
|
|
ref_residual = reduced + residual.float()
|
|
ref_norm = ref_residual * torch.rsqrt(
|
|
ref_residual.pow(2).mean(dim=-1, keepdim=True) + _ARRMS_EPS
|
|
)
|
|
ref_norm = ref_norm * weight.float()
|
|
|
|
torch.testing.assert_close(residual_out.float(), ref_residual, atol=2e-2, rtol=2e-2)
|
|
torch.testing.assert_close(norm_out.float(), ref_norm, atol=2e-2, rtol=2e-2)
|
|
|
|
|
|
def _run_arrms_test(world_size: int, persistent: bool) -> None:
|
|
_skip_if_unsupported(world_size, "Iris fused tests")
|
|
port = _get_open_port()
|
|
_spawn_and_collect(_arrms_worker_fn, (world_size, port, persistent), world_size)
|
|
|
|
|
|
@pytest.mark.parametrize("persistent", [False, True], ids=["per_row", "persistent"])
|
|
def test_iris_allreduce_residual_rmsnorm_world1(persistent: bool):
|
|
# Single-rank smoke test: exercises the inline-barrier self-signal/wait
|
|
# path (rank sends to itself) and the v1 device_barrier no-op case.
|
|
_run_arrms_test(world_size=1, persistent=persistent)
|
|
|
|
|
|
@pytest.mark.parametrize("persistent", [False, True], ids=["per_row", "persistent"])
|
|
def test_iris_allreduce_residual_rmsnorm_world2(persistent: bool):
|
|
_run_arrms_test(world_size=2, persistent=persistent)
|
|
|
|
|
|
@pytest.mark.parametrize("persistent", [False, True], ids=["per_row", "persistent"])
|
|
def test_iris_allreduce_residual_rmsnorm_world4(persistent: bool):
|
|
_run_arrms_test(world_size=4, persistent=persistent)
|
|
|
|
|
|
@pytest.mark.parametrize("persistent", [False, True], ids=["per_row", "persistent"])
|
|
def test_iris_allreduce_residual_rmsnorm_world8(persistent: bool):
|
|
_run_arrms_test(world_size=8, persistent=persistent)
|