[ROCm][Perf] W4A16: keep skinny GEMM zero-points packed 4-bit (#54965)
Signed-off-by: Matthias Gehre <matthias.gehre@amd.com>
Matthias Gehre committed
Sep 14, 2026 at 11:40 UTC
dc2e8f1157173d0f0fc09712f6897e9f6eee52a0
4 files changed
+117
-45
csrc/rocm/skinny_gemms_int4.cu
+41
-18
@@ -102,6 +102,18 @@ __device__ inline unsigned int min__(uint32_t a, uint32_t b) {
102
return min(a, b);
103
}
104
105
+// Reads row `row` of packed zero points [M/8, num_groups] uint32.
106
+//
107
+// The parameters must be signed and the indexing must use shift/mask: with
108
+// unsigned parameters and row/8, row%8, the (A_CHUNK=32, UNRL=8)
109
+// instantiation needs 256 VGPRs and 68 bytes of scratch instead of 239.
110
+__device__ __forceinline__ uint32_t zp_nibble(const uint32_t* zp_packed,
111
+ const int row, const int group,
112
+ const int num_groups) {
113
+ const uint32_t w = zp_packed[(row >> 3) * num_groups + group];
114
+ return (w >> (4 * (row & 7))) & 0xFu;
115
+}
116
+
117
// W4A16 skinny GEMM kernel: packed int4 weights, fp16/bf16 activations
118
// Targets the "sml" case where activations fit in LDS.
119
// A_CHUNK: number of K-elements processed per thread per step.
@@ -116,7 +128,7 @@ __global__ void __launch_bounds__(WvPrGrp* THRDS)
128
wvSplitK_int4_hf_sml_(const int K, const int M, const int Bx, const int By,
129
const uint8_t* B_packed,
130
const scalar_t* __restrict__ A, const scalar_t* scale,
119
- const scalar_t* zero_points,
131
+ const uint32_t* zero_points,
132
const scalar_t* __restrict__ BIAS, scalar_t* C,
133
const int _WvPrGrp, const int CuCount) {
134
constexpr int max_lds_len = LDS_SIZE / 2;
@@ -257,7 +269,8 @@ __global__ void __launch_bounds__(WvPrGrp* THRDS)
269
if constexpr (!std::is_same_v<scalar_t, __hip_bfloat16>) {
270
if constexpr (HAS_ZERO_POINTS && GROUP_SIZE > 0) {
271
uint32_t group_idx = k_ / GROUP_SIZE;
260
- scalar_t zp = zero_points[(m + y) * num_groups + group_idx];
272
+ scalar_t zp = __float2s<scalar_t>(static_cast<float>(
273
+ zp_nibble(zero_points, m + y, group_idx, num_groups)));
274
#pragma unroll
275
for (uint32_t b = 0; b < A_CHUNK; b++) {
276
cvtB.h[b] = cvtB.h[b] - zp;
@@ -280,8 +293,8 @@ __global__ void __launch_bounds__(WvPrGrp* THRDS)
293
}
294
if constexpr (HAS_ZERO_POINTS) {
295
uint32_t group_idx_zp = k_ / GROUP_SIZE;
283
- float zp_f = __s2float(
284
- zero_points[(m + y) * num_groups + group_idx_zp]);
296
+ float zp_f = static_cast<float>(
297
+ zp_nibble(zero_points, m + y, group_idx_zp, num_groups));
298
partial -= (128.0f + zp_f) * act_sum;
299
} else {
300
partial -= 136.0f * act_sum;
@@ -335,7 +348,7 @@ __global__ void wvSplitK_int4_hf_sml_(const int K, const int M, const int Bx,
348
const int By, const uint8_t* B_packed,
349
const scalar_t* __restrict__ A,
350
const scalar_t* scale,
338
- const scalar_t* zero_points,
351
+ const uint32_t* zero_points,
352
const scalar_t* __restrict__ BIAS,
353
scalar_t* C, const int _WvPrGrp,
354
const int CuCount) {
@@ -352,7 +365,7 @@ template <typename scalar_t, int THRDS, int YTILE, int WvPrGrp, int A_CHUNK,
365
__global__ void __launch_bounds__(WvPrGrp* THRDS)
366
wvSplitK_int4_hf_(const int K, const int M, const int Bx, const int By,
367
const uint8_t* B_packed, const scalar_t* __restrict__ A,
355
- const scalar_t* scale, const scalar_t* zero_points,
368
+ const scalar_t* scale, const uint32_t* zero_points,
369
const scalar_t* __restrict__ BIAS, scalar_t* C,
370
const int _WvPrGrp, const int CuCount) {
371
constexpr int max_lds_len = LDS_SIZE / 2;
@@ -508,7 +521,8 @@ __global__ void __launch_bounds__(WvPrGrp* THRDS)
521
if constexpr (!std::is_same_v<scalar_t, __hip_bfloat16>) {
522
if constexpr (HAS_ZERO_POINTS && GROUP_SIZE > 0) {
523
uint32_t group_idx = k_ / GROUP_SIZE;
511
- scalar_t zp = zero_points[(m + y) * num_groups + group_idx];
524
+ scalar_t zp = __float2s<scalar_t>(static_cast<float>(
525
+ zp_nibble(zero_points, m + y, group_idx, num_groups)));
526
#pragma unroll
527
for (uint32_t b = 0; b < A_CHUNK; b++) {
528
cvtB.h[b] = cvtB.h[b] - zp;
@@ -531,8 +545,8 @@ __global__ void __launch_bounds__(WvPrGrp* THRDS)
545
}
546
if constexpr (HAS_ZERO_POINTS) {
547
uint32_t group_idx_zp = k_ / GROUP_SIZE;
534
- float zp_f = __s2float(
535
- zero_points[(m + y) * num_groups + group_idx_zp]);
548
+ float zp_f = static_cast<float>(
549
+ zp_nibble(zero_points, m + y, group_idx_zp, num_groups));
550
partial -= (128.0f + zp_f) * act_sum;
551
} else {
552
partial -= 136.0f * act_sum;
@@ -596,7 +610,7 @@ __global__ void wvSplitK_int4_hf_(const int K, const int M, const int Bx,
610
const int By, const uint8_t* B_packed,
611
const scalar_t* __restrict__ A,
612
const scalar_t* scale,
599
- const scalar_t* zero_points,
613
+ const uint32_t* zero_points,
614
const scalar_t* __restrict__ BIAS,
615
scalar_t* C, const int _WvPrGrp,
616
const int CuCount) {
@@ -621,7 +635,8 @@ static int mindiv_int4(int N, int div1, int div2) {
635
// in_a: packed int4 weights [M, K/2] (int8) or [M, K/8] (int32)
636
// in_b: activations [N, K] (fp16/bf16)
637
// in_scale: group scales [M, K/group_size] (fp16/bf16)
624
-// in_zero_points: optional raw zero points [M, K/group_size] (fp16/bf16)
638
+// in_zero_points: optional raw zero points [M/8, K/group_size] (int32,
639
+// 8 uint4 per word along dim 0)
640
// If provided, kernel dequants as (nibble - zp_raw) * scale (asymmetric).
641
// If absent, kernel dequants as (nibble - 8) * scale (symmetric uint4b8).
642
// group_size: 32, 64, or 128
@@ -664,14 +679,22 @@ torch::Tensor wvSplitK_int4_g(const at::Tensor& in_a, const at::Tensor& in_b,
679
"Scale must be [M, K/group_size] = [", M_in, ", ", num_groups,
680
"] but got [", in_scale.size(0), ", ", in_scale.size(1), "]");
681
if (in_zero_points.has_value()) {
667
- TORCH_CHECK(in_zero_points->dtype() == in_b.dtype(),
668
- "Zero points dtype must match activation dtype");
682
+ // The kernel reads the words as uint32, so either signedness is accepted.
683
+ TORCH_CHECK(in_zero_points->dtype() == at::kInt ||
684
+ in_zero_points->dtype() == at::kUInt32,
685
+ "Zero points must be int32 or uint32 (packed 8x uint4 along "
686
+ "dim 0), got ",
687
+ in_zero_points->dtype());
688
TORCH_CHECK(in_zero_points->dim() == 2,
670
- "Zero points must be 2D [M, K/group_size], got shape ",
689
+ "Zero points must be 2D [M/8, K/group_size], got shape ",
690
in_zero_points->sizes());
672
- TORCH_CHECK(in_zero_points->size(0) == M_in &&
691
+ TORCH_CHECK(M_in % 8 == 0,
692
+ "M must be divisible by 8 for packed zero points, got ", M_in);
693
+ TORCH_CHECK(in_zero_points->is_contiguous(),
694
+ "Zero points must be contiguous");
695
+ TORCH_CHECK(in_zero_points->size(0) == M_in / 8 &&
696
in_zero_points->size(1) == num_groups,
674
- "Zero points must be [M, K/group_size] = [", M_in, ", ",
697
+ "Zero points must be [M/8, K/group_size] = [", M_in / 8, ", ",
698
num_groups, "] but got [", in_zero_points->size(0), ", ",
699
in_zero_points->size(1), "]");
700
}
@@ -769,9 +792,9 @@ torch::Tensor wvSplitK_int4_g(const at::Tensor& in_a, const at::Tensor& in_b,
792
const fptype* aptr = reinterpret_cast<const fptype*>(in_b.data_ptr());
793
const fptype* sptr =
794
reinterpret_cast<const fptype*>(in_scale.data_ptr());
772
- const fptype* zpptr =
795
+ const uint32_t* zpptr =
796
in_zero_points.has_value()
774
- ? reinterpret_cast<const fptype*>(in_zero_points->data_ptr())
797
+ ? reinterpret_cast<const uint32_t*>(in_zero_points->data_ptr())
798
: nullptr;
799
const fptype* biasptr =
800
(in_bias.has_value() && in_bias->numel() > 0)
csrc/rocm/torch_bindings.cpp
+2
-1
@@ -31,7 +31,8 @@ TORCH_LIBRARY_EXPAND(TORCH_EXTENSION_NAME, rocm_ops) {
31
rocm_ops.impl("wvSplitK", torch::kCUDA, &wvSplitK);
32
33
// W4A16 grouped skinny GEMM: packed int4 weights, per-group scales,
34
- // optional zero points for asymmetric quantization
34
+ // optional zero points [M/8, K/group_size] int32 for asymmetric
35
+ // quantization
36
rocm_ops.def(
37
"wvSplitK_int4_g(Tensor in_a, Tensor in_b, Tensor in_scale, "
38
"Tensor? in_zero_points, Tensor? in_bias, int CuCount, "
tests/kernels/quantization/test_rdna_hybrid_w4a16.py
+55
-10
@@ -37,6 +37,20 @@ MAX_SKINNY_BATCH_SIZE = hybrid_module.MAX_SKINNY_BATCH_SIZE
37
# ---------------------------------------------------------------------------
38
39
40
+def _pack_zp_rows_for_kernel(zp_nkg: torch.Tensor) -> torch.Tensor:
41
+ """Pack raw uint4 zero points along N: [N, G] int32 -> [N//8, G] int32.
42
+
43
+ Row n's nibble lands in word[n//8] at bits 4*(n%8).
44
+ """
45
+ assert zp_nkg.dtype == torch.int32
46
+ N, G = zp_nkg.shape
47
+ assert N % 8 == 0
48
+ shifts = (torch.arange(8, device=zp_nkg.device, dtype=torch.int32) * 4)[:, None]
49
+ return torch.sum(
50
+ (zp_nkg.view(N // 8, 8, G) & 0xF) << shifts, dim=1, dtype=torch.int32
51
+ ).contiguous()
52
+
53
+
54
def _rdna_hybrid_w4a16_reference(
55
x_mk: torch.Tensor,
56
w_int4_nk: torch.Tensor,
@@ -50,8 +64,8 @@ def _rdna_hybrid_w4a16_reference(
64
x_mk: [M, K] fp16/bf16
65
w_int4_nk: [N, K] int32 with raw uint4 values in [0, 15]
66
scales_nkg: [N, K//G] fp16/bf16
53
- zp_nkg: [N, K//G] fp16/bf16 raw zero points (already in act dtype),
54
- or None for symmetric (uint4b8, dequant subtracts 8)
67
+ zp_nkg: [N, K//G] int32 raw zero points in [0, 15], or None for
68
+ symmetric (uint4b8, dequant subtracts 8)
69
"""
70
G = group_size
71
N, K = w_int4_nk.shape
@@ -110,13 +124,16 @@ def test_rdna_hybrid_w4a16_apply_matches_reference(dtype, group_size, has_zp, M)
124
0.05 * torch.rand((N, K // group_size), device=device, dtype=torch.float32)
125
).to(dtype)
126
113
- # Optional raw zero points [N, K//G] in act dtype.
127
+ # Optional raw zero points, [N, K//G] int32 for the reference and packed
128
+ # along N for the op.
129
if has_zp:
130
zp_nkg = torch.randint(
131
0, 16, (N, K // group_size), device=device, dtype=torch.int32
117
- ).to(dtype)
132
+ )
133
+ w_zp = _pack_zp_rows_for_kernel(zp_nkg)
134
else:
135
zp_nkg = None
136
+ w_zp = None
137
138
from vllm.utils.platform_utils import num_compute_units
139
@@ -124,7 +141,7 @@ def test_rdna_hybrid_w4a16_apply_matches_reference(dtype, group_size, has_zp, M)
141
x_mk,
142
w_q,
143
scales_nkg,
127
- zp_nkg,
144
+ w_zp,
145
None, # bias
146
num_compute_units(),
147
group_size,
@@ -332,7 +349,7 @@ def test_rdna_hybrid_w4a16_process_weights_symmetric_repack(group_size, dist_ini
349
350
@pytest.mark.parametrize("group_size", SUPPORTED_GROUP_SIZES)
351
def test_rdna_hybrid_w4a16_process_weights_asymmetric_repack(group_size, dist_init):
335
- """uint4 (asymmetric): zero points unpacked to raw values in act dtype."""
352
+ """uint4 (asymmetric): zero points are packed [N//8, K//G] int32."""
353
if not torch.cuda.is_available():
354
pytest.skip("CUDA/HIP device not available")
355
@@ -374,10 +391,11 @@ def test_rdna_hybrid_w4a16_process_weights_asymmetric_repack(group_size, dist_in
391
)
392
kernel.process_weights_after_loading(layer)
393
377
- # Zero-points: unpacked to [N, K//G], cast to act dtype, raw values [0..15].
378
- assert layer.weight_zero_point.dtype == torch.float16
379
- assert tuple(layer.weight_zero_point.shape) == (N, K // G)
380
- expected_zp = zeros_int4_gn.t().to(torch.float16) # [N, K//G]
394
+ # Zero-points: [N//8, K//G] int32, row n's nibble at word[n//8]
395
+ # bits 4*(n%8).
396
+ assert layer.weight_zero_point.dtype == torch.int32
397
+ assert tuple(layer.weight_zero_point.shape) == (N // 8, K // G)
398
+ expected_zp = _pack_zp_rows_for_kernel(zeros_int4_gn.t().contiguous())
399
torch.testing.assert_close(layer.weight_zero_point, expected_zp)
400
401
# Quantized weights match symmetric path's layout regardless of zp.
@@ -518,3 +536,30 @@ def test_rdna_hybrid_w4a16_dispatch(dtype, M, K, N, G):
536
ref = _hip_skinny_reference(a, w_int4_nk, scales, group_size=G, zp_bias=8)
537
538
torch.testing.assert_close(out, ref, rtol=1e-2, atol=5e-2)
539
+
540
+
541
+@pytest.mark.skipif(not on_gfx1x(), reason="Hybrid path is gfx11/gfx12 only")
542
+@pytest.mark.skipif(
543
+ not hasattr(torch.ops, "_rocm_C")
544
+ or not hasattr(torch.ops._rocm_C, "wvSplitK_int4_g"),
545
+ reason="wvSplitK_int4_g not built",
546
+)
547
+def test_wvsplitk_int4_g_rejects_unpacked_zero_points():
548
+ """Act-dtype zero points raise instead of being read as packed words.
549
+
550
+ Both layouts are 2D with compatible extents, so only the dtype separates
551
+ them; misreading one as the other returns wrong numbers silently.
552
+ """
553
+ import vllm._custom_ops as ops
554
+ from vllm.utils.platform_utils import num_compute_units
555
+
556
+ K, N, G, M = 256, 64, 128, 1
557
+ a = torch.randn((M, K), device=device, dtype=torch.float16)
558
+ w = torch.randint(0, 255, (N, K // 2), device=device, dtype=torch.uint8).view(
559
+ torch.int8
560
+ )
561
+ scales = torch.rand((N, K // G), device=device, dtype=torch.float16)
562
+ zp_unpacked = torch.zeros((N, K // G), device=device, dtype=torch.float16)
563
+
564
+ with pytest.raises(RuntimeError, match="Zero points must be int32 or uint32"):
565
+ ops.wvSplitK_int4_g(w, a, scales, num_compute_units(), G, zp_unpacked, None)
vllm/model_executor/kernels/linear/mixed_precision/rdna_hybrid_w4a16.py
+19
-16
@@ -77,7 +77,7 @@ def _triton_w4a16_skinny_fmt_kernel(
77
a_ptr, # [M, K] fp16/bf16 activations
78
b_ptr, # [N, K//8] int32 packed (ExLlama shuffle, K is packed dim)
79
scales_ptr, # [N, K//G] fp16/bf16 scales (skinny layout)
80
- zp_ptr, # [N, K//G] fp16/bf16 raw zero-points (when HAS_ZP=True)
80
+ zp_ptr, # [N//8, K//G] int32 zero-points (when HAS_ZP=True)
81
c_ptr, # [M, N] fp16/bf16 output
82
# Dimensions
83
M,
@@ -103,8 +103,9 @@ def _triton_w4a16_skinny_fmt_kernel(
103
| (val[1]<<16) | (val[3]<<20) | (val[5]<<24) | (val[7]<<28)
104
105
Scales are [N, K//G] (skinny layout, NOT transposed).
106
- When HAS_ZP=True, raw zero-points zp_raw are loaded from zp_ptr [N, K//G]
107
- and subtracted directly: (nibble - zp_raw) * scale.
106
+ When HAS_ZP=True, zp_ptr holds [N//8, K//G] int32 with row n's raw
107
+ zero-point at word[n//8] bits 4*(n%8), and dequant is
108
+ (nibble - zp_raw) * scale.
109
When HAS_ZP=False, only the constant ZP_BIAS is subtracted (symmetric).
110
"""
111
pid_m = tl.program_id(0)
@@ -148,9 +149,10 @@ def _triton_w4a16_skinny_fmt_kernel(
149
scales = tl.load(scale_ptrs, mask=scale_mask, other=1.0)
150
151
if HAS_ZP:
151
- zp_ptrs = zp_ptr + offs_n * num_groups + group_idx
152
- zp_raw = tl.load(zp_ptrs, mask=scale_mask, other=0.0)
153
- b_fp = (b.to(scales.dtype) - zp_raw[:, None]) * scales[:, None]
152
+ zp_ptrs = zp_ptr + (offs_n // 8) * num_groups + group_idx
153
+ zp_word = tl.load(zp_ptrs, mask=scale_mask, other=0)
154
+ zp_raw = (zp_word >> (4 * (offs_n % 8))) & 0xF
155
+ b_fp = (b - zp_raw[:, None]).to(scales.dtype) * scales[:, None]
156
else:
157
b_fp = (b - ZP_BIAS).to(scales.dtype) * scales[:, None]
158
@@ -192,7 +194,7 @@ def triton_w4a16_skinny_fmt_gemm(
194
scales: torch.Tensor, # [N, K//G] fp16/bf16
195
group_size: int,
196
zp_bias: int = 8,
195
- zp: torch.Tensor | None = None, # [N, K//G] per-group zero-points
197
+ zp: torch.Tensor | None = None, # [N//8, K//G] int32 zero-points
198
) -> torch.Tensor:
199
"""
200
Fused W4A16 GEMM reading from skinny weight format [N, K//8].
@@ -203,8 +205,8 @@ def triton_w4a16_skinny_fmt_gemm(
205
scales: Per-group scales [N, K//G], same dtype as a.
206
group_size: Quantization group size (resolved from -1 to K by caller).
207
zp_bias: Constant zero bias (default 8 for unsigned int4).
206
- zp: Raw per-group zero-points [N, K//G] (asymmetric),
207
- stored as zp_raw in activation dtype. When provided,
208
+ zp: Raw per-group zero-points [N//8, K//G] int32, row n at
209
+ word[n//8] bits 4*(n%8) (asymmetric). When provided,
210
dequant is (nibble - zp_raw) * scale.
211
212
Returns:
@@ -225,8 +227,9 @@ def triton_w4a16_skinny_fmt_gemm(
227
)
228
if zp is not None:
229
assert zp.is_contiguous(), "Zero-points must be contiguous"
228
- assert zp.shape == (N, num_groups), (
229
- f"zp shape mismatch: {zp.shape} vs ({N}, {num_groups})"
230
+ assert N % 8 == 0, f"N must be divisible by 8 for packed zp, got {N}"
231
+ assert zp.shape == (N // 8, num_groups), (
232
+ f"zp shape mismatch: {zp.shape} vs ({N // 8}, {num_groups})"
233
)
234
has_zp = zp is not None
235
@@ -381,7 +384,10 @@ def _rdna_hybrid_w4a16_apply_impl(
384
cu_count: int,
385
group_size: int,
386
) -> torch.Tensor:
384
- """Dispatch between skinny GEMM and Triton based on batch size M."""
387
+ """Dispatch between skinny GEMM and Triton based on batch size M.
388
+
389
+ ``w_zp`` is [N//8, K//G] int32 for asymmetric layers, None for symmetric.
390
+ """
391
import vllm._custom_ops as ops
392
393
M = x_2d.shape[0]
@@ -520,10 +526,7 @@ class RDNAHybridW4A16LinearKernel(MPLinearKernel):
526
assert self.w_zp_name is not None
527
w_zp_raw = getattr(layer, self.w_zp_name)
528
permute_param_layout_(w_zp_raw, input_dim=1, output_dim=0, packed_dim=0)
523
- zp_unpacked = unpack_quantized_values_into_int32(
524
- w_zp_raw.data, c.weight_type, packed_dim=0
525
- )
526
- w_zp = zp_unpacked.to(c.act_type).contiguous()
529
+ w_zp = w_zp_raw.data.contiguous()
530
self._transform_param(layer, self.w_zp_name, lambda x: w_zp)
531
532
self._transform_param(layer, self.w_q_name, lambda x: w_q_skinny)