368 lines
11 KiB
Python
368 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
"""Correctness tests for the ROCm RDNA3 fused MoE W4A16 HIP kernel (gfx1100).
|
|
|
|
Tests ``moe_gptq_gemm_rdna3`` against the dense ``gptq_gemm_rdna3`` as
|
|
reference: builds RDNA3-format weights (shuffled int32, synthesized qzeros),
|
|
runs the fused MoE kernel, and compares per-expert results.
|
|
|
|
Model parameters taken from:
|
|
- cyankiwi/Qwen3-30B-A3B-Instruct-2507-AWQ-4bit
|
|
(hidden=2048, inter=768, E=128, top_k=8, G=32)
|
|
- Qwen3.6-35B-A3B-GPTQ-W4A16-G32
|
|
(hidden=2048, inter=512, E=256, top_k=8, G=32)
|
|
|
|
Run `pytest tests/kernels/quantization/test_rdna3_moe_w4a16.py`.
|
|
"""
|
|
|
|
import pytest
|
|
import torch
|
|
|
|
from vllm.platforms import current_platform
|
|
|
|
if not current_platform.is_rocm():
|
|
pytest.skip("RDNA3 MoE W4A16 kernel is ROCm-only", allow_module_level=True)
|
|
|
|
from vllm import _custom_ops as ops # noqa: E402
|
|
from vllm.model_executor.layers.fused_moe.activation import ( # noqa: E402
|
|
MoEActivation,
|
|
apply_moe_activation,
|
|
)
|
|
from vllm.model_executor.layers.fused_moe.moe_align_block_size import ( # noqa: E402
|
|
moe_align_block_size,
|
|
)
|
|
from vllm.model_executor.layers.quantization.utils.quant_utils import ( # noqa: E402
|
|
pack_quantized_values_into_int32,
|
|
)
|
|
from vllm.platforms.rocm import on_gfx1100 # noqa: E402
|
|
from vllm.scalar_type import scalar_types # noqa: E402
|
|
|
|
device = "cuda"
|
|
|
|
gfx1100_only = pytest.mark.skipif(
|
|
not (
|
|
on_gfx1100()
|
|
and hasattr(torch.ops, "_rocm_C")
|
|
and hasattr(torch.ops._rocm_C, "moe_gptq_gemm_rdna3")
|
|
),
|
|
reason="Requires gfx1100 with moe_gptq_gemm_rdna3 op",
|
|
)
|
|
|
|
# Model configurations: real K/N/top_k/group_size dims, E capped at 16 to
|
|
# fit in test GPU memory (full E=128/256 would need >20GB for weights alone).
|
|
# Kernel behavior is E-independent (per-expert tiling), so E=16 is sufficient.
|
|
MODEL_CONFIGS = [
|
|
# cyankiwi/Qwen3-30B-A3B-Instruct-2507-AWQ-4bit dims (E capped)
|
|
pytest.param(16, 2048, 768, 8, 32, id="Qwen3-30B-A3B"),
|
|
# Qwen3.6-35B-A3B-GPTQ-W4A16-G32 dims (E capped)
|
|
pytest.param(16, 2048, 512, 8, 32, id="Qwen3.6-35B-A3B"),
|
|
]
|
|
|
|
# Token counts: decode (1), small batch (4), medium (16), prefill (64)
|
|
NUM_TOKENS = [1, 4, 16, 64, 256, 512]
|
|
|
|
|
|
def _make_packed_weights(E, K, N):
|
|
"""Create random 4-bit packed weights [E, K/8, N] int32 + shuffle."""
|
|
w = torch.randint(0, 16, (E, K, N), dtype=torch.int32, device=device)
|
|
packed = torch.zeros(E, K // 8, N, dtype=torch.int32, device=device)
|
|
for i in range(8):
|
|
packed |= (w[:, i::8, :] & 0xF) << (i * 4)
|
|
g_idx = torch.empty(0, dtype=torch.int32, device=device)
|
|
for e in range(E):
|
|
we = packed[e].contiguous()
|
|
ops.gptq_shuffle(we, g_idx, 4)
|
|
packed[e] = we
|
|
return packed
|
|
|
|
|
|
def _make_scales(E, groups, N, dtype):
|
|
return torch.rand(E, groups, N, dtype=dtype, device=device) * 0.1
|
|
|
|
|
|
def _make_qzeros(E, groups, N):
|
|
zeros = torch.full(
|
|
(groups, N),
|
|
scalar_types.uint4b8.bias - 1,
|
|
dtype=torch.int32,
|
|
device=device,
|
|
)
|
|
qz = pack_quantized_values_into_int32(
|
|
zeros,
|
|
scalar_types.uint4b8,
|
|
packed_dim=1,
|
|
)
|
|
return qz.unsqueeze(0).expand(E, -1, -1).contiguous()
|
|
|
|
|
|
@gfx1100_only
|
|
@pytest.mark.parametrize("E, K, N_inter, top_k, group_size", MODEL_CONFIGS)
|
|
@pytest.mark.parametrize("M", NUM_TOKENS)
|
|
@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16])
|
|
@pytest.mark.parametrize("block_size_m", [1, 4])
|
|
def test_fused_moe_w1_matches_dense(
|
|
E, K, N_inter, top_k, group_size, M, dtype, block_size_m
|
|
):
|
|
"""w1 GEMM via fused kernel matches per-expert dense kernel."""
|
|
N_gate_up = N_inter * 2
|
|
groups = K // group_size
|
|
|
|
torch.manual_seed(42)
|
|
x = torch.randn(M, K, dtype=dtype, device=device)
|
|
w13 = _make_packed_weights(E, K, N_gate_up)
|
|
w13_s = _make_scales(E, groups, N_gate_up, dtype)
|
|
w13_z = _make_qzeros(E, groups, N_gate_up)
|
|
g_idx = torch.empty(0, dtype=torch.int32, device=device)
|
|
|
|
topk_ids = torch.randint(0, E, (M, top_k), device=device, dtype=torch.int32)
|
|
si, ei, ntp = moe_align_block_size(topk_ids, block_size_m, E)
|
|
|
|
# Fused kernel
|
|
fused_out = torch.zeros(M * top_k, N_gate_up, dtype=dtype, device=device)
|
|
ops.moe_gptq_gemm_rdna3(
|
|
x,
|
|
fused_out,
|
|
w13,
|
|
w13_s,
|
|
w13_z,
|
|
torch.empty(0, device=device),
|
|
si,
|
|
ei,
|
|
ntp,
|
|
top_k,
|
|
block_size_m,
|
|
False,
|
|
0,
|
|
)
|
|
|
|
# Per-expert dense reference
|
|
ref_out = torch.zeros(M * top_k, N_gate_up, dtype=dtype, device=device)
|
|
for m in range(M):
|
|
for k in range(top_k):
|
|
e = topk_ids[m, k].item()
|
|
flat = m * top_k + k
|
|
ref = ops.gptq_gemm_rdna3(
|
|
x[m : m + 1],
|
|
w13[e],
|
|
w13_z[e],
|
|
w13_s[e],
|
|
g_idx,
|
|
False,
|
|
)
|
|
ref_out[flat] = ref.squeeze()
|
|
|
|
# Split-K atomics can cause minor fp16/bf16 rounding differences
|
|
# at large K (e.g. K=2048 → 8 K-blocks). Use allclose, not equal.
|
|
atol = 0.5 if dtype == torch.bfloat16 else 0.1
|
|
assert torch.allclose(fused_out, ref_out, atol=atol, rtol=0.01), (
|
|
f"max diff: {(fused_out - ref_out).abs().max().item()}"
|
|
)
|
|
|
|
|
|
@gfx1100_only
|
|
@pytest.mark.parametrize("E, K, N_inter, top_k, group_size", MODEL_CONFIGS)
|
|
@pytest.mark.parametrize("M", NUM_TOKENS)
|
|
@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16])
|
|
def test_fused_moe_output_topk_reduces(E, K, N_inter, top_k, group_size, M, dtype):
|
|
"""output_topk fuses moe_sum: multiple experts write to same output row."""
|
|
groups = K // group_size
|
|
|
|
torch.manual_seed(123)
|
|
x = torch.randn(M * top_k, K, dtype=dtype, device=device)
|
|
w = _make_packed_weights(E, K, N_inter)
|
|
ws = _make_scales(E, groups, N_inter, dtype)
|
|
wz = _make_qzeros(E, groups, N_inter)
|
|
|
|
topk_ids = torch.randint(0, E, (M, top_k), device=device, dtype=torch.int32)
|
|
topk_w = torch.softmax(
|
|
torch.randn(M, top_k, device=device),
|
|
dim=-1,
|
|
).float()
|
|
|
|
si, ei, ntp = moe_align_block_size(topk_ids, 1, E)
|
|
|
|
# Without output_topk: write to [M*top_k, N] then moe_sum
|
|
flat_out = torch.zeros(M * top_k, N_inter, dtype=dtype, device=device)
|
|
ops.moe_gptq_gemm_rdna3(
|
|
x,
|
|
flat_out,
|
|
w,
|
|
ws,
|
|
wz,
|
|
topk_w.view(-1),
|
|
si,
|
|
ei,
|
|
ntp,
|
|
1,
|
|
1,
|
|
True,
|
|
0,
|
|
)
|
|
ref = torch.zeros(M, N_inter, dtype=dtype, device=device)
|
|
ops.moe_sum(flat_out.view(M, top_k, N_inter), ref)
|
|
|
|
# With output_topk: write directly to [M, N]
|
|
fused = torch.zeros(M, N_inter, dtype=dtype, device=device)
|
|
ops.moe_gptq_gemm_rdna3(
|
|
x,
|
|
fused,
|
|
w,
|
|
ws,
|
|
wz,
|
|
topk_w.view(-1),
|
|
si,
|
|
ei,
|
|
ntp,
|
|
1,
|
|
1,
|
|
True,
|
|
top_k,
|
|
)
|
|
|
|
atol = 1.0 if dtype == torch.bfloat16 else 0.1
|
|
assert torch.allclose(fused, ref, atol=atol, rtol=0.01), (
|
|
f"max diff: {(fused - ref).abs().max().item()}"
|
|
)
|
|
|
|
|
|
@gfx1100_only
|
|
@pytest.mark.parametrize("E, K, N_inter, top_k, group_size", MODEL_CONFIGS)
|
|
@pytest.mark.parametrize("M", NUM_TOKENS)
|
|
@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16])
|
|
def test_full_moe_e2e(E, K, N_inter, top_k, group_size, M, dtype):
|
|
"""Full MoE forward: w1 + silu_and_mul + w2 with output_topk reduce."""
|
|
N_gate_up = N_inter * 2
|
|
hidden = K
|
|
|
|
torch.manual_seed(7)
|
|
x = torch.randn(M, K, dtype=dtype, device=device)
|
|
w13 = _make_packed_weights(E, K, N_gate_up)
|
|
w13_s = _make_scales(E, K // group_size, N_gate_up, dtype)
|
|
w13_z = _make_qzeros(E, K // group_size, N_gate_up)
|
|
w2 = _make_packed_weights(E, N_inter, hidden)
|
|
w2_s = _make_scales(E, N_inter // group_size, hidden, dtype)
|
|
w2_z = _make_qzeros(E, N_inter // group_size, hidden)
|
|
g_idx = torch.empty(0, dtype=torch.int32, device=device)
|
|
|
|
topk_ids = torch.randint(0, E, (M, top_k), device=device, dtype=torch.int32)
|
|
topk_w = torch.softmax(
|
|
torch.randn(M, top_k, device=device),
|
|
dim=-1,
|
|
).float()
|
|
|
|
si, ei, ntp = moe_align_block_size(topk_ids, 1, E)
|
|
|
|
# Fused path (what apply() does)
|
|
w1_out = torch.zeros(M * top_k, N_gate_up, dtype=dtype, device=device)
|
|
ops.moe_gptq_gemm_rdna3(
|
|
x,
|
|
w1_out,
|
|
w13,
|
|
w13_s,
|
|
w13_z,
|
|
torch.empty(0, device=device),
|
|
si,
|
|
ei,
|
|
ntp,
|
|
top_k,
|
|
1,
|
|
False,
|
|
0,
|
|
)
|
|
act_out = torch.empty(M * top_k, N_inter, dtype=dtype, device=device)
|
|
apply_moe_activation(MoEActivation.SILU, act_out, w1_out)
|
|
fused = torch.zeros(M, hidden, dtype=dtype, device=device)
|
|
ops.moe_gptq_gemm_rdna3(
|
|
act_out,
|
|
fused,
|
|
w2,
|
|
w2_s,
|
|
w2_z,
|
|
topk_w.view(-1),
|
|
si,
|
|
ei,
|
|
ntp,
|
|
1,
|
|
1,
|
|
True,
|
|
top_k,
|
|
)
|
|
|
|
# Per-expert reference
|
|
ref = torch.zeros(M, hidden, dtype=dtype, device=device)
|
|
for m_idx in range(M):
|
|
for k_idx in range(top_k):
|
|
e = topk_ids[m_idx, k_idx].item()
|
|
w = topk_w[m_idx, k_idx].item()
|
|
r1 = ops.gptq_gemm_rdna3(
|
|
x[m_idx : m_idx + 1],
|
|
w13[e],
|
|
w13_z[e],
|
|
w13_s[e],
|
|
g_idx,
|
|
False,
|
|
)
|
|
a = torch.empty(1, N_inter, dtype=dtype, device=device)
|
|
apply_moe_activation(MoEActivation.SILU, a, r1)
|
|
r2 = ops.gptq_gemm_rdna3(
|
|
a,
|
|
w2[e],
|
|
w2_z[e],
|
|
w2_s[e],
|
|
g_idx,
|
|
False,
|
|
)
|
|
ref[m_idx] += r2.squeeze() * w
|
|
|
|
# E2E chains w1 + activation + w2 + topk_w + output_topk reduce.
|
|
# Each step accumulates rounding error (split-K atomics, topk_w
|
|
# multiply order). Use relative L2 norm like the dense kernel test.
|
|
diff_l2 = torch.norm(fused.float() - ref.float())
|
|
ref_l2 = torch.norm(ref.float())
|
|
rel_l2 = (diff_l2 / ref_l2).item() if ref_l2 > 0 else 0.0
|
|
threshold = 0.05 if dtype == torch.float16 else 0.10
|
|
assert rel_l2 < threshold, (
|
|
f"rel L2 = {rel_l2:.4f} (threshold {threshold}), "
|
|
f"max abs diff: {(fused - ref).abs().max().item()}"
|
|
)
|
|
|
|
|
|
@gfx1100_only
|
|
def test_expert_id_minus_one():
|
|
"""Kernel handles expert_id == -1 (expert parallelism) without crash."""
|
|
# Qwen3-30B-A3B dims (E capped for memory)
|
|
E, K, N = 16, 2048, 768
|
|
groups = K // 32
|
|
|
|
w = _make_packed_weights(E, K, N)
|
|
ws = _make_scales(E, groups, N, torch.bfloat16)
|
|
wz = _make_qzeros(E, groups, N)
|
|
x = torch.randn(1, K, dtype=torch.bfloat16, device=device)
|
|
|
|
# Manually create sorted_token_ids/expert_ids with -1
|
|
sorted_ids = torch.tensor([0], dtype=torch.int32, device=device)
|
|
expert_ids = torch.tensor([-1], dtype=torch.int32, device=device)
|
|
ntp = torch.tensor([1], dtype=torch.int32, device=device)
|
|
|
|
out = torch.zeros(1, N, dtype=torch.bfloat16, device=device)
|
|
ops.moe_gptq_gemm_rdna3(
|
|
x,
|
|
out,
|
|
w,
|
|
ws,
|
|
wz,
|
|
torch.empty(0, device=device),
|
|
sorted_ids,
|
|
expert_ids,
|
|
ntp,
|
|
1,
|
|
1,
|
|
False,
|
|
0,
|
|
)
|
|
current_platform.synchronize()
|
|
|
|
# Output should remain zero (expert skipped)
|
|
assert torch.equal(out, torch.zeros_like(out))
|