| 1 | #!/usr/bin/env python3 |
| 2 | # SPDX-License-Identifier: Apache-2.0 |
| 3 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 4 | """Correctness tests for the ROCm RDNA3 W4A16 GPTQ kernel (gfx1100). |
| 5 | |
| 6 | Exercises ``RDNA3W4A16LinearKernel`` end-to-end: it builds a layer with |
| 7 | GPTQ-format checkpoint parameters, runs ``process_weights_after_loading`` |
| 8 | (weight shuffle + zero-point synthesis), then ``apply_weights``, and compares |
| 9 | the result against an fp32 reference dequant-and-matmul. |
| 10 | |
| 11 | The kernel is exposed via ``torch.ops._rocm_C.gptq_gemm_rdna3`` and is only |
| 12 | built for gfx11; tests are skipped elsewhere. |
| 13 | |
| 14 | Run `pytest tests/kernels/quantization/test_rdna3_w4a16.py`. |
| 15 | """ |
| 16 | |
| 17 | import pytest |
| 18 | import torch |
| 19 | |
| 20 | from vllm.platforms import current_platform |
| 21 | |
| 22 | if not current_platform.is_rocm(): |
| 23 | pytest.skip("RDNA3 W4A16 kernel is ROCm-only", allow_module_level=True) |
| 24 | |
| 25 | from vllm.model_executor.kernels.linear.mixed_precision.MPLinearKernel import ( # noqa: E402 |
| 26 | MPLinearLayerConfig, |
| 27 | ) |
| 28 | from vllm.model_executor.kernels.linear.mixed_precision.rdna3_w4a16 import ( # noqa: E402 |
| 29 | RDNA3W4A16LinearKernel, |
| 30 | ) |
| 31 | from vllm.model_executor.layers.quantization.utils.quant_utils import ( # noqa: E402 |
| 32 | pack_quantized_values_into_int32, |
| 33 | ) |
| 34 | from vllm.model_executor.parameter import ( # noqa: E402 |
| 35 | GroupQuantScaleParameter, |
| 36 | PackedvLLMParameter, |
| 37 | ) |
| 38 | from vllm.platforms.rocm import on_gfx1100 # noqa: E402 |
| 39 | from vllm.scalar_type import scalar_types # noqa: E402 |
| 40 | from vllm.utils.torch_utils import set_random_seed # noqa: E402 |
| 41 | |
| 42 | device = "cuda" |
| 43 | |
| 44 | WEIGHT_TYPE = scalar_types.uint4b8 # symmetric int4, bias = 8 |
| 45 | PACK_FACTOR = 8 # 8 x 4-bit nibbles per int32 |
| 46 | |
| 47 | # Skip everything in this module unless we are on the only architecture the |
| 48 | # kernel is built/registered for. |
| 49 | gfx1100_only = pytest.mark.skipif( |
| 50 | not ( |
| 51 | on_gfx1100() |
| 52 | and hasattr(torch.ops, "_rocm_C") |
| 53 | and hasattr(torch.ops._rocm_C, "gptq_gemm_rdna3") |
| 54 | ), |
| 55 | reason="requires gfx1100 with the _rocm_C.gptq_gemm_rdna3 op built in", |
| 56 | ) |
| 57 | |
| 58 | |
| 59 | # --------------------------------------------------------------------------- |
| 60 | # Reference implementation |
| 61 | # --------------------------------------------------------------------------- |
| 62 | |
| 63 | |
| 64 | def _reference( |
| 65 | x_mk: torch.Tensor, |
| 66 | q_int4_kn: torch.Tensor, |
| 67 | scales_gn: torch.Tensor, |
| 68 | zeros_gn: torch.Tensor | None, |
| 69 | group_size: int, |
| 70 | bias: torch.Tensor | None, |
| 71 | ) -> torch.Tensor: |
| 72 | """fp32 reference for the RDNA3 W4A16 op. |
| 73 | |
| 74 | x_mk: [M, K] fp16/bf16 activations. |
| 75 | q_int4_kn: [K, N] int32 raw stored nibbles in [0, 15]. |
| 76 | scales_gn: [K//G, N] per-group scales (act dtype). |
| 77 | zeros_gn: [K//G, N] int32 raw stored zero points in [0, 15], or None |
| 78 | for the symmetric path (kernel synthesizes stored zero = 7). |
| 79 | group_size: G. |
| 80 | |
| 81 | The kernel applies the GPTQv1 "+1" zero-point quirk, so the effective |
| 82 | zero is ``stored_zero + 1`` (symmetric path: 7 + 1 == bias == 8). |
| 83 | """ |
| 84 | K, N = q_int4_kn.shape |
| 85 | s_full = scales_gn.repeat_interleave(group_size, dim=0).to(torch.float32) # [K,N] |
| 86 | if zeros_gn is None: |
| 87 | z_full = torch.full( |
| 88 | (K, N), float(WEIGHT_TYPE.bias), device=x_mk.device, dtype=torch.float32 |
| 89 | ) |
| 90 | else: |
| 91 | z_full = (zeros_gn + 1).repeat_interleave(group_size, dim=0).to(torch.float32) |
| 92 | w_fp = (q_int4_kn.to(torch.float32) - z_full) * s_full # [K, N] |
| 93 | out = x_mk.to(torch.float32) @ w_fp # [M, N] |
| 94 | if bias is not None: |
| 95 | out = out + bias.to(torch.float32) |
| 96 | return out.to(x_mk.dtype) |
| 97 | |
| 98 | |
| 99 | # --------------------------------------------------------------------------- |
| 100 | # Layer construction (GPTQ checkpoint format) |
| 101 | # --------------------------------------------------------------------------- |
| 102 | |
| 103 | |
| 104 | def _build_layer( |
| 105 | q_int4_kn: torch.Tensor, |
| 106 | scales_gn: torch.Tensor, |
| 107 | zeros_gn: torch.Tensor | None, |
| 108 | dtype: torch.dtype, |
| 109 | ) -> torch.nn.Module: |
| 110 | """Build a dummy layer carrying GPTQ-format params, as the loader would.""" |
| 111 | no_loader = lambda *args, **kwargs: None # noqa: E731 |
| 112 | |
| 113 | # qweight: int4 packed along K into int32 -> [K//8, N]. |
| 114 | qweight = pack_quantized_values_into_int32(q_int4_kn, WEIGHT_TYPE, packed_dim=0) |
| 115 | |
| 116 | class DummyLayer(torch.nn.Module): |
| 117 | pass |
| 118 | |
| 119 | layer = DummyLayer() |
| 120 | layer.register_parameter( |
| 121 | "qweight", |
| 122 | PackedvLLMParameter( |
| 123 | data=qweight, |
| 124 | weight_loader=no_loader, |
| 125 | input_dim=0, |
| 126 | output_dim=1, |
| 127 | packed_dim=0, |
| 128 | packed_factor=PACK_FACTOR, |
| 129 | ), |
| 130 | ) |
| 131 | layer.register_parameter( |
| 132 | "scales", |
| 133 | GroupQuantScaleParameter( |
| 134 | data=scales_gn.to(dtype), |
| 135 | weight_loader=no_loader, |
| 136 | input_dim=0, |
| 137 | output_dim=1, |
| 138 | ), |
| 139 | ) |
| 140 | if zeros_gn is not None: |
| 141 | # qzeros: int4 packed along N into int32 -> [K//G, N//8]. |
| 142 | qzeros = pack_quantized_values_into_int32(zeros_gn, WEIGHT_TYPE, packed_dim=1) |
| 143 | layer.register_parameter( |
| 144 | "qzeros", |
| 145 | PackedvLLMParameter( |
| 146 | data=qzeros, |
| 147 | weight_loader=no_loader, |
| 148 | input_dim=0, |
| 149 | output_dim=1, |
| 150 | packed_dim=1, |
| 151 | packed_factor=PACK_FACTOR, |
| 152 | ), |
| 153 | ) |
| 154 | return layer |
| 155 | |
| 156 | |
| 157 | def _run_kernel( |
| 158 | x_mk: torch.Tensor, |
| 159 | q_int4_kn: torch.Tensor, |
| 160 | scales_gn: torch.Tensor, |
| 161 | zeros_gn: torch.Tensor | None, |
| 162 | group_size: int, |
| 163 | bias: torch.Tensor | None, |
| 164 | dtype: torch.dtype, |
| 165 | ) -> torch.Tensor: |
| 166 | K, N = q_int4_kn.shape |
| 167 | has_zp = zeros_gn is not None |
| 168 | |
| 169 | config = MPLinearLayerConfig( |
| 170 | full_weight_shape=(K, N), |
| 171 | partition_weight_shape=(K, N), |
| 172 | weight_type=WEIGHT_TYPE, |
| 173 | act_type=dtype, |
| 174 | group_size=group_size, |
| 175 | zero_points=has_zp, |
| 176 | ) |
| 177 | ok, reason = RDNA3W4A16LinearKernel.can_implement(config) |
| 178 | assert ok, f"can_implement rejected a supported config: {reason}" |
| 179 | |
| 180 | layer = _build_layer(q_int4_kn, scales_gn, zeros_gn, dtype) |
| 181 | kernel = RDNA3W4A16LinearKernel( |
| 182 | config, |
| 183 | w_q_param_name="qweight", |
| 184 | w_s_param_name="scales", |
| 185 | w_zp_param_name="qzeros" if has_zp else None, |
| 186 | ) |
| 187 | kernel.process_weights_after_loading(layer) |
| 188 | return kernel.apply_weights(layer, x_mk, bias=bias) |
| 189 | |
| 190 | |
| 191 | # Relative-L2 tolerance per dtype. The bf16 path widens dequantized weights |
| 192 | # to fp32 and accumulates in fp32, so it matches the reference almost exactly |
| 193 | # (<0.4% incl. the WMMA prefill path). The fp16 path uses the exllamav2 |
| 194 | # "+1024" bit-trick (see qdq_4_rdna3.cuh): the dequantized weight is recovered |
| 195 | # as the fp16 difference of two ~1024*scale magnitudes, which sheds low-order |
| 196 | # mantissa bits and leaves ~2-3% relative noise that accumulates over K. We |
| 197 | # compare on the relative Frobenius norm rather than elementwise, since the |
| 198 | # bit-trick noise produces large *relative* errors on individual near-zero |
| 199 | # outputs that carry negligible absolute weight. |
| 200 | _REL_L2_TOL = {torch.float16: 5e-2, torch.bfloat16: 1e-2} |
| 201 | |
| 202 | |
| 203 | def _assert_close(out: torch.Tensor, ref: torch.Tensor, dtype: torch.dtype): |
| 204 | rel_l2 = (out.to(torch.float32) - ref.to(torch.float32)).norm() / ref.to( |
| 205 | torch.float32 |
| 206 | ).norm() |
| 207 | tol = _REL_L2_TOL[dtype] |
| 208 | assert rel_l2 < tol, f"relative L2 error {rel_l2:.4f} exceeds {tol} for {dtype}" |
| 209 | |
| 210 | |
| 211 | # --------------------------------------------------------------------------- |
| 212 | # Forward correctness |
| 213 | # --------------------------------------------------------------------------- |
| 214 | |
| 215 | |
| 216 | # (M, K, N, group_size). M spans the scalar decode path (small M) and the |
| 217 | # WMMA prefill path (M >= 16 on the bf16 dispatch). K/N satisfy the kernel's |
| 218 | # divisibility constraints (K % G == 0, K % 8 == 0, N % 8 == 0). |
| 219 | MKNG_SHAPES = [ |
| 220 | (1, 128, 128, 128), # single group, decode |
| 221 | (2, 256, 256, 128), # two groups |
| 222 | (8, 256, 512, 64), # M=8 scalar, smaller group |
| 223 | (16, 512, 256, 128), # M=16 -> WMMA path for bf16 |
| 224 | (32, 512, 512, 64), # larger prefill |
| 225 | ] |
| 226 | |
| 227 | |
| 228 | @gfx1100_only |
| 229 | @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) |
| 230 | @pytest.mark.parametrize("has_zp", [False, True], ids=["no_zp", "with_zp"]) |
| 231 | @pytest.mark.parametrize( |
| 232 | "M,K,N,G", MKNG_SHAPES, ids=[f"m{m}_k{k}_n{n}_g{g}" for m, k, n, g in MKNG_SHAPES] |
| 233 | ) |
| 234 | def test_rdna3_w4a16_matches_reference(dtype, has_zp, M, K, N, G, dist_init): |
| 235 | set_random_seed(0) |
| 236 | assert K % G == 0 and K % PACK_FACTOR == 0 and N % PACK_FACTOR == 0 |
| 237 | |
| 238 | groups = K // G |
| 239 | x_mk = (0.25 * torch.randn((M, K), device=device, dtype=torch.float32)).to(dtype) |
| 240 | q_int4_kn = torch.randint(0, 16, (K, N), device=device, dtype=torch.int32) |
| 241 | scales_gn = ( |
| 242 | 0.05 * torch.rand((groups, N), device=device, dtype=torch.float32) + 0.01 |
| 243 | ).to(dtype) |
| 244 | zeros_gn = ( |
| 245 | torch.randint(0, 16, (groups, N), device=device, dtype=torch.int32) |
| 246 | if has_zp |
| 247 | else None |
| 248 | ) |
| 249 | |
| 250 | out = _run_kernel(x_mk, q_int4_kn, scales_gn, zeros_gn, G, None, dtype) |
| 251 | ref = _reference(x_mk, q_int4_kn, scales_gn, zeros_gn, G, None) |
| 252 | |
| 253 | assert out.shape == (M, N) and out.dtype == dtype |
| 254 | _assert_close(out, ref, dtype) |
| 255 | |
| 256 | |
| 257 | @gfx1100_only |
| 258 | @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) |
| 259 | @pytest.mark.parametrize("M", [1, 32], ids=["decode", "prefill"]) |
| 260 | def test_rdna3_w4a16_bias(dtype, M, dist_init): |
| 261 | """Bias is added on both the scalar (M=1) and WMMA (M=32) paths.""" |
| 262 | set_random_seed(0) |
| 263 | K, N, G = 512, 256, 128 |
| 264 | groups = K // G |
| 265 | |
| 266 | x_mk = (0.25 * torch.randn((M, K), device=device, dtype=torch.float32)).to(dtype) |
| 267 | q_int4_kn = torch.randint(0, 16, (K, N), device=device, dtype=torch.int32) |
| 268 | scales_gn = ( |
| 269 | 0.05 * torch.rand((groups, N), device=device, dtype=torch.float32) + 0.01 |
| 270 | ).to(dtype) |
| 271 | bias = (0.1 * torch.randn(N, device=device, dtype=torch.float32)).to(dtype) |
| 272 | |
| 273 | out = _run_kernel(x_mk, q_int4_kn, scales_gn, None, G, bias, dtype) |
| 274 | ref = _reference(x_mk, q_int4_kn, scales_gn, None, G, bias) |
| 275 | |
| 276 | _assert_close(out, ref, dtype) |