[ROCm][Kernel] Add HybridW4A16LinearKernel: Triton prefill + HIP skinny decode (#40977)
Signed-off-by: Matthias Gehre <matthias.gehre@amd.com> Co-authored-by: Andreas Karatzas <akaratza@amd.com>
Matthias Gehre committed
Jul 14, 2026 at 05:22 UTC
382bbd51448b2f58c73b3e51d051bc352166ba91
11 files changed
+2165
-5
CMakeLists.txt
+1
@@ -1364,6 +1364,7 @@ if(VLLM_GPU_LANG STREQUAL "HIP")
1364
set(VLLM_ROCM_EXT_SRC
1365
"csrc/rocm/torch_bindings.cpp"
1366
"csrc/rocm/skinny_gemms.cu"
1367
+ "csrc/rocm/skinny_gemms_int4.cu"
1368
"csrc/rocm/attention.cu")
1369
1370
set(VLLM_ROCM_HAS_GFX1100 OFF)
benchmarks/kernels/benchmark_rdna_hybrid_w4a16_gemm.py
new
+201
@@ -0,0 +1,201 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""
4
+Benchmark the RDNAHybridW4A16LinearKernel across decode and prefill shapes.
5
+
6
+Usage:
7
+ python benchmark_int4_gemm.py
8
+ python benchmark_int4_gemm.py --models Qwen/Qwen3-4B
9
+ python benchmark_int4_gemm.py --group-size 128
10
+"""
11
+
12
+import argparse
13
+import copy
14
+import itertools
15
+import os
16
+
17
+import torch
18
+
19
+from vllm.triton_utils import triton
20
+
21
+# ---------------------------------------------------------------------------
22
+# Weight shapes: [K, N], TP_SPLIT_DIM
23
+# ---------------------------------------------------------------------------
24
+WEIGHT_SHAPES = {
25
+ "Qwen/Qwen3-4B": [
26
+ ([2560, 3840], 1), # qkv_proj
27
+ ([2560, 2560], 0), # o_proj
28
+ ([2560, 19456], 1), # gate_up_proj
29
+ ([9728, 2560], 0), # down_proj
30
+ ],
31
+ "Qwen/Qwen2.5-7B-Instruct": [
32
+ ([3584, 4608], 1),
33
+ ([3584, 3584], 0),
34
+ ([3584, 37888], 1),
35
+ ([18944, 3584], 0),
36
+ ],
37
+ "trymirai/SmolLM2-1.7B-Instruct-AWQ": [
38
+ ([2048, 6144], 1), # qkv_proj
39
+ ([2048, 2048], 0), # o_proj
40
+ ([2048, 16384], 1), # gate_up_proj
41
+ ([8192, 2048], 0), # down_proj
42
+ ],
43
+ "RedHatAI/Qwen3-8B-quantized.w4a16": [
44
+ ([4096, 6144], 1), # qkv_proj
45
+ ([4096, 4096], 0), # o_proj
46
+ ([4096, 24576], 1), # gate_up_proj
47
+ ([12288, 4096], 0), # down_proj
48
+ ],
49
+}
50
+
51
+
52
+# ---------------------------------------------------------------------------
53
+# Weight packing
54
+# ---------------------------------------------------------------------------
55
+def prepare_hybrid_weights(K, N, group_size, device="cuda"):
56
+ """Create random weights for benchmarking.
57
+
58
+ Returns (w_q_skinny, w_s_skinny, w_fp16, w_zp). The triton path derives
59
+ its int32 view from w_q_skinny, so no separate int32 buffer is returned.
60
+ """
61
+ num_groups = K // group_size
62
+
63
+ # Random packed weights — actual values don't matter for throughput
64
+ w_q_skinny_i32 = torch.randint(
65
+ 0, 2**31, (N, K // 8), dtype=torch.int32, device=device
66
+ )
67
+ w_q_skinny = w_q_skinny_i32.view(torch.int8).contiguous()
68
+ w_s_skinny = torch.randn(N, num_groups, dtype=torch.float16, device=device) * 0.01
69
+
70
+ # Raw per-group zero-points for asymmetric benchmarks
71
+ w_zp = torch.randint(0, 16, (N, num_groups), dtype=torch.int32, device=device).to(
72
+ torch.float16
73
+ )
74
+
75
+ # FP16 baseline for F.linear
76
+ w_fp16 = torch.randn(N, K, dtype=torch.float16, device=device) * 0.01
77
+
78
+ return w_q_skinny, w_s_skinny, w_fp16, w_zp
79
+
80
+
81
+# ---------------------------------------------------------------------------
82
+# Benchmark
83
+# ---------------------------------------------------------------------------
84
+PROVIDERS = ["torch-fp16", "hybrid-w4a16", "hybrid-w4a16-zp"]
85
+
86
+
87
+@triton.testing.perf_report(
88
+ triton.testing.Benchmark(
89
+ x_names=["batch_size"],
90
+ x_vals=[1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096],
91
+ x_log=False,
92
+ line_arg="provider",
93
+ line_vals=PROVIDERS,
94
+ line_names=PROVIDERS,
95
+ ylabel="TFLOP/s (larger is better)",
96
+ plot_name="FP16 vs Hybrid W4A16",
97
+ args={},
98
+ )
99
+)
100
+def benchmark(batch_size, provider, N, K, group_size, weights):
101
+ M = batch_size
102
+ device = "cuda"
103
+ dtype = torch.float16
104
+ a = torch.randn((M, K), device=device, dtype=dtype)
105
+
106
+ quantiles = [0.5, 0.2, 0.8]
107
+
108
+ if provider == "torch-fp16":
109
+ w_fp16 = weights["w_fp16"]
110
+ ms, min_ms, max_ms = triton.testing.do_bench_cudagraph(
111
+ lambda: torch.nn.functional.linear(a, w_fp16),
112
+ quantiles=quantiles,
113
+ )
114
+ elif provider in ("hybrid-w4a16", "hybrid-w4a16-zp"):
115
+ from vllm.model_executor.kernels.linear.mixed_precision import (
116
+ rdna_hybrid_w4a16 as _k,
117
+ )
118
+
119
+ _rdna_hybrid_w4a16_apply_impl = _k._rdna_hybrid_w4a16_apply_impl
120
+ from vllm.utils.platform_utils import num_compute_units
121
+
122
+ w = weights
123
+ cu_count = num_compute_units()
124
+ use_zp = provider == "hybrid-w4a16-zp"
125
+
126
+ def run():
127
+ return _rdna_hybrid_w4a16_apply_impl(
128
+ a,
129
+ w["w_q_skinny"],
130
+ w["w_s_skinny"],
131
+ w["w_zp"] if use_zp else None,
132
+ None, # bias
133
+ cu_count,
134
+ group_size,
135
+ )
136
+
137
+ ms, min_ms, max_ms = triton.testing.do_bench_cudagraph(
138
+ run,
139
+ quantiles=quantiles,
140
+ )
141
+ else:
142
+ return 0.0, 0.0, 0.0
143
+
144
+ to_tflops = lambda t_ms: (2 * M * N * K) * 1e-12 / (t_ms * 1e-3)
145
+ return to_tflops(ms), to_tflops(max_ms), to_tflops(min_ms)
146
+
147
+
148
+def prepare_shapes(args):
149
+ KN_model_names = []
150
+ for model, tp_size in itertools.product(args.models, args.tp_sizes):
151
+ for KN, tp_dim in copy.deepcopy(WEIGHT_SHAPES[model]):
152
+ KN[tp_dim] //= tp_size
153
+ KN.append(model)
154
+ KN_model_names.append(KN)
155
+ return KN_model_names
156
+
157
+
158
+if __name__ == "__main__":
159
+ parser = argparse.ArgumentParser(
160
+ description="Benchmark RDNAHybridW4A16LinearKernel"
161
+ )
162
+ parser.add_argument(
163
+ "--models",
164
+ nargs="+",
165
+ type=str,
166
+ default=["Qwen/Qwen3-4B"],
167
+ choices=list(WEIGHT_SHAPES.keys()),
168
+ )
169
+ parser.add_argument("--tp-sizes", nargs="+", type=int, default=[1])
170
+ parser.add_argument("--group-size", type=int, default=128)
171
+ parser.add_argument("--save-path", type=str, default=None)
172
+ args = parser.parse_args()
173
+
174
+ for K, N, model in prepare_shapes(args):
175
+ group_size = args.group_size
176
+ print(f"\n{'=' * 70}")
177
+ print(f"{model}, N={N} K={K}, group_size={group_size}")
178
+ print(f"{'=' * 70}")
179
+
180
+ w_q_skinny, w_s_skinny, w_fp16, w_zp = prepare_hybrid_weights(K, N, group_size)
181
+
182
+ weights = {
183
+ "w_q_skinny": w_q_skinny,
184
+ "w_s_skinny": w_s_skinny,
185
+ "w_fp16": w_fp16,
186
+ "w_zp": w_zp,
187
+ }
188
+
189
+ save_path = args.save_path or f"bench_int4_res_n{N}_k{K}"
190
+ os.makedirs(save_path, exist_ok=True)
191
+ benchmark.run(
192
+ print_data=True,
193
+ show_plots=False,
194
+ save_path=save_path,
195
+ N=N,
196
+ K=K,
197
+ group_size=group_size,
198
+ weights=weights,
199
+ )
200
+
201
+ print("\nBenchmark finished!")
csrc/rocm/ops.h
+6
@@ -9,6 +9,12 @@ torch::Tensor wvSplitK(const at::Tensor& in_a, const at::Tensor& in_b,
9
const std::optional<at::Tensor>& in_bias,
10
const int64_t CuCount);
11
12
+torch::Tensor wvSplitK_int4_g(const at::Tensor& in_a, const at::Tensor& in_b,
13
+ const at::Tensor& in_scale,
14
+ const std::optional<at::Tensor>& in_zero_points,
15
+ const std::optional<at::Tensor>& in_bias,
16
+ const int64_t CuCount, const int64_t group_size);
17
+
18
torch::Tensor wvSplitKrc(const at::Tensor& in_a, const at::Tensor& in_b,
19
const std::optional<at::Tensor>& in_bias,
20
const int64_t CuCount);
csrc/rocm/skinny_gemms_int4.cu
new
+795
@@ -0,0 +1,795 @@
1
+#include <torch/all.h>
2
+#include <ATen/cuda/CUDAContext.h>
3
+#include <c10/cuda/CUDAGuard.h>
4
+
5
+#include <cuda_runtime.h>
6
+#include <cuda_fp16.h>
7
+#include <cuda_bf16.h>
8
+
9
+#include <stdexcept>
10
+#include <algorithm>
11
+
12
+#include "../cuda_compat.h"
13
+#include "dispatch_utils.h"
14
+
15
+// Combined RDNA macro (gfx11 + gfx12) - both use 32-wide wavefronts
16
+#if defined(__GFX11__) || defined(__GFX12__)
17
+ #define __HIP__GFX1X__
18
+#endif
19
+
20
+#define LDS_SIZE 64 * 1024
21
+
22
+static constexpr int get_lds_size_int4() { return 64 * 1024; }
23
+
24
+#if defined(NDEBUG)
25
+ #undef NDEBUG
26
+ #include <assert.h>
27
+ #define UNREACHABLE_CODE assert(false);
28
+ #define NDEBUG
29
+#else
30
+ #define UNREACHABLE_CODE assert(false);
31
+#endif
32
+
33
+template <typename T>
34
+__device__ __forceinline__ float __s2float(T v);
35
+
36
+template <>
37
+__device__ __forceinline__ float __s2float(half v) {
38
+ return __half2float(v);
39
+}
40
+
41
+template <>
42
+__device__ __forceinline__ float __s2float(__hip_bfloat16 v) {
43
+ return __bfloat162float(v);
44
+}
45
+
46
+template <typename T>
47
+__device__ __forceinline__ T __float2s(float v);
48
+
49
+template <>
50
+__device__ __forceinline__ half __float2s(float v) {
51
+ return __float2half(v);
52
+}
53
+
54
+template <>
55
+__device__ __forceinline__ __hip_bfloat16 __float2s(float v) {
56
+ return __float2bfloat16(v);
57
+}
58
+
59
+template <typename T>
60
+__device__ __forceinline__ T loadnt(T* addr) {
61
+ return __builtin_nontemporal_load(addr);
62
+}
63
+
64
+template <typename T>
65
+struct scalar {};
66
+
67
+template <>
68
+struct scalar<c10::Half> {
69
+ using type = half;
70
+};
71
+
72
+template <>
73
+struct scalar<c10::BFloat16> {
74
+ using type = __hip_bfloat16;
75
+};
76
+
77
+#define DOT2C(V0, V2, V3) \
78
+ if constexpr (std::is_same_v<scalar_t, half>) { \
79
+ V0 = __builtin_amdgcn_fdot2(*((half2*)(&(V2))), *((half2*)(&(V3))), V0, \
80
+ false); \
81
+ } else if constexpr (std::is_same_v<scalar_t, __hip_bfloat16>) { \
82
+ typedef short __attribute__((ext_vector_type(2))) bf16x2_t; \
83
+ V0 = __builtin_amdgcn_fdot2_f32_bf16(*((bf16x2_t*)(&(V2))), \
84
+ *((bf16x2_t*)(&(V3))), V0, false); \
85
+ }
86
+
87
+// DPP-based wave32 reduction for GFX1X (matches skinny_gemms.cu).
88
+// row_shr within 16-lane rows, then cross-row __shfl_xor.
89
+// Result accumulates in lane (THRDS - 1).
90
+#if defined(__HIP__GFX1X__)
91
+ #define REDUCE_SUM_DPP_WAVE32(val) \
92
+ do { \
93
+ val += __builtin_amdgcn_mov_dpp(val, 0x118, 0xf, 0xf, 1); /* shr8 */ \
94
+ val += __builtin_amdgcn_mov_dpp(val, 0x114, 0xf, 0xf, 1); /* shr4 */ \
95
+ val += __builtin_amdgcn_mov_dpp(val, 0x112, 0xf, 0xf, 1); /* shr2 */ \
96
+ val += __builtin_amdgcn_mov_dpp(val, 0x111, 0xf, 0xf, 1); /* shr1 */ \
97
+ val += __shfl_xor(val, 16); \
98
+ } while (0)
99
+#endif
100
+
101
+__device__ inline unsigned int min__(uint32_t a, uint32_t b) {
102
+ return min(a, b);
103
+}
104
+
105
+// W4A16 skinny GEMM kernel: packed int4 weights, fp16/bf16 activations
106
+// Targets the "sml" case where activations fit in LDS.
107
+// A_CHUNK: number of K-elements processed per thread per step.
108
+// Weights are packed as 2 int4 values per byte: [M, K/2] bytes.
109
+// GROUP_SIZE: 0 = per-channel scale [M], >0 = per-group scale [M,
110
+// K/GROUP_SIZE].
111
+// Requires GROUP_SIZE % A_CHUNK == 0 when GROUP_SIZE > 0.
112
+#if defined(__HIP__GFX1X__)
113
+template <typename scalar_t, int THRDS, int YTILE, int WvPrGrp, int A_CHUNK,
114
+ int UNRL, int N, int GROUP_SIZE = 0, bool HAS_ZERO_POINTS = false>
115
+__global__ void __launch_bounds__(WvPrGrp* THRDS)
116
+ wvSplitK_int4_hf_sml_(const int K, const int M, const int Bx, const int By,
117
+ const uint8_t* B_packed,
118
+ const scalar_t* __restrict__ A, const scalar_t* scale,
119
+ const scalar_t* zero_points,
120
+ const scalar_t* __restrict__ BIAS, scalar_t* C,
121
+ const int _WvPrGrp, const int CuCount) {
122
+ constexpr int max_lds_len = LDS_SIZE / 2;
123
+ const int K_packed = K / 2;
124
+
125
+ union bigTypeA {
126
+ scalar_t h[A_CHUNK];
127
+ float f[A_CHUNK / 2];
128
+ };
129
+
130
+ union bigTypeW {
131
+ uint8_t b[A_CHUNK / 2];
132
+ uint32_t u32[A_CHUNK / 8];
133
+ float f[A_CHUNK / 8];
134
+ };
135
+
136
+ __shared__ scalar_t s[max_lds_len];
137
+
138
+ for (uint32_t k = 0; k < min__(K * N, max_lds_len);
139
+ k += THRDS * WvPrGrp * A_CHUNK) {
140
+ uint32_t k_in = k + ((threadIdx.y * THRDS + threadIdx.x) * A_CHUNK);
141
+
142
+ if (k_in >= min__(K * N, max_lds_len)) break;
143
+
144
+ *((bigTypeA*)(&s[k_in])) = *((bigTypeA*)(&A[k_in]));
145
+ }
146
+ __syncthreads();
147
+
148
+ if (threadIdx.y >= _WvPrGrp) return;
149
+
150
+ uint32_t m = (blockIdx.x * _WvPrGrp + (threadIdx.y % _WvPrGrp)) * YTILE;
151
+
152
+ // For per-group, precompute num_groups and scale stride
153
+ [[maybe_unused]] const int num_groups =
154
+ (GROUP_SIZE > 0) ? (K / GROUP_SIZE) : 0;
155
+
156
+ float sum[N][YTILE];
157
+
158
+ while (m < M) {
159
+ for (int i = 0; i < YTILE; i++)
160
+ for (int n = 0; n < N; n++) sum[n][i] = 0;
161
+
162
+ bigTypeA bigA[N][UNRL];
163
+ bigTypeW bigB[YTILE][UNRL];
164
+
165
+ for (uint32_t k1 = 0; k1 < K; k1 += THRDS * A_CHUNK * UNRL) {
166
+ #pragma unroll
167
+ for (uint32_t k2 = 0; k2 < UNRL; k2++) {
168
+ uint32_t k = k1 + k2 * THRDS * A_CHUNK;
169
+ uint32_t k_ = k + threadIdx.x * A_CHUNK;
170
+ if (k_ >= K) break;
171
+
172
+ const uint8_t* B_ = &B_packed[(m + 0) * K_packed + k_ / 2];
173
+ for (int y = 0; y < YTILE; y++) {
174
+ const float* src = (const float*)(&B_[y * K_packed]);
175
+ #pragma unroll
176
+ for (int i = 0; i < A_CHUNK / 8; i++)
177
+ bigB[y][k2].f[i] = loadnt((float*)&src[i]);
178
+ }
179
+ }
180
+
181
+ #pragma unroll
182
+ for (uint32_t k2 = 0; k2 < UNRL; k2++) {
183
+ uint32_t k = k1 + k2 * THRDS * A_CHUNK;
184
+ uint32_t k_ = k + threadIdx.x * A_CHUNK;
185
+ if (k_ >= K) break;
186
+
187
+ for (int n = 0; n < N; n++) {
188
+ bigA[n][k2] = *((const bigTypeA*)(&(s[k_ + K * n])));
189
+ }
190
+ }
191
+
192
+ #pragma unroll
193
+ for (uint32_t k2 = 0; k2 < UNRL; k2++) {
194
+ uint32_t k = k1 + k2 * THRDS * A_CHUNK;
195
+ uint32_t k_ = k + threadIdx.x * A_CHUNK;
196
+ if (k_ >= K) break;
197
+
198
+ #pragma unroll
199
+ for (uint32_t n = 0; n < N; n++) {
200
+ #pragma unroll
201
+ for (int y = 0; y < YTILE; y++) {
202
+ bigTypeA cvtB;
203
+
204
+ if constexpr (std::is_same_v<scalar_t, half>) {
205
+ constexpr uint32_t FP16_MAGIC = 0x64006400u;
206
+ // When HAS_ZERO_POINTS, store raw nibble values;
207
+ // the zero-point subtraction below handles the full shift.
208
+ // When symmetric, bake -8 into the constants.
209
+ constexpr uint32_t BIAS_LO =
210
+ HAS_ZERO_POINTS ? 0x64006400u : 0x64086408u;
211
+ constexpr uint32_t SCALE16 = 0x2C002C00u;
212
+ constexpr uint32_t BIAS_HI =
213
+ HAS_ZERO_POINTS ? 0xD400D400u : 0xD480D480u;
214
+ #pragma unroll
215
+ for (uint32_t w = 0; w < A_CHUNK / 8; w++) {
216
+ uint32_t qa = bigB[y][k2].u32[w];
217
+ uint32_t lo0 = (qa & 0x000F000Fu) | FP16_MAGIC;
218
+ uint32_t hi0 = (qa & 0x00F000F0u) | FP16_MAGIC;
219
+ qa >>= 8;
220
+ uint32_t lo1 = (qa & 0x000F000Fu) | FP16_MAGIC;
221
+ uint32_t hi1 = (qa & 0x00F000F0u) | FP16_MAGIC;
222
+
223
+ *(half2*)&cvtB.f[w * 4 + 0] =
224
+ __hsub2(*(half2*)&lo0, *(const half2*)&BIAS_LO);
225
+ *(half2*)&cvtB.f[w * 4 + 1] =
226
+ __hfma2(*(half2*)&hi0, *(const half2*)&SCALE16,
227
+ *(const half2*)&BIAS_HI);
228
+ *(half2*)&cvtB.f[w * 4 + 2] =
229
+ __hsub2(*(half2*)&lo1, *(const half2*)&BIAS_LO);
230
+ *(half2*)&cvtB.f[w * 4 + 3] =
231
+ __hfma2(*(half2*)&hi1, *(const half2*)&SCALE16,
232
+ *(const half2*)&BIAS_HI);
233
+ }
234
+ } else {
235
+ // bf16 path: marlin-style magic-number trick.
236
+ // Skip the expensive bias subtraction (avoids fp32 round-trip
237
+ // on gfx1151); use magic values directly in DOT2C and correct
238
+ // the accumulator with bias * sum(activations) afterward.
239
+ constexpr uint32_t BF16_MAGIC = 0x43004300u;
240
+ #pragma unroll
241
+ for (uint32_t w = 0; w < A_CHUNK / 8; w++) {
242
+ uint32_t qa = bigB[y][k2].u32[w];
243
+ *(uint32_t*)&cvtB.f[w * 4 + 0] =
244
+ (qa & 0x000F000Fu) | BF16_MAGIC;
245
+ qa >>= 4;
246
+ *(uint32_t*)&cvtB.f[w * 4 + 1] =
247
+ (qa & 0x000F000Fu) | BF16_MAGIC;
248
+ qa >>= 4;
249
+ *(uint32_t*)&cvtB.f[w * 4 + 2] =
250
+ (qa & 0x000F000Fu) | BF16_MAGIC;
251
+ qa >>= 4;
252
+ *(uint32_t*)&cvtB.f[w * 4 + 3] =
253
+ (qa & 0x000F000Fu) | BF16_MAGIC;
254
+ }
255
+ }
256
+
257
+ if constexpr (!std::is_same_v<scalar_t, __hip_bfloat16>) {
258
+ if constexpr (HAS_ZERO_POINTS && GROUP_SIZE > 0) {
259
+ uint32_t group_idx = k_ / GROUP_SIZE;
260
+ scalar_t zp = zero_points[(m + y) * num_groups + group_idx];
261
+ #pragma unroll
262
+ for (uint32_t b = 0; b < A_CHUNK; b++) {
263
+ cvtB.h[b] = cvtB.h[b] - zp;
264
+ }
265
+ }
266
+ }
267
+
268
+ if constexpr (GROUP_SIZE > 0) {
269
+ float partial = 0;
270
+ #pragma unroll
271
+ for (uint32_t b = 0; b < A_CHUNK / 2; b++) {
272
+ DOT2C(partial, bigA[n][k2].f[b], cvtB.f[b])
273
+ }
274
+ if constexpr (std::is_same_v<scalar_t, __hip_bfloat16>) {
275
+ constexpr uint32_t BF16_ONES = 0x3F803F80u;
276
+ float act_sum = 0.0f;
277
+ #pragma unroll
278
+ for (uint32_t b = 0; b < A_CHUNK / 2; b++) {
279
+ DOT2C(act_sum, bigA[n][k2].f[b], *(const float*)&BF16_ONES)
280
+ }
281
+ if constexpr (HAS_ZERO_POINTS) {
282
+ uint32_t group_idx_zp = k_ / GROUP_SIZE;
283
+ float zp_f = __s2float(
284
+ zero_points[(m + y) * num_groups + group_idx_zp]);
285
+ partial -= (128.0f + zp_f) * act_sum;
286
+ } else {
287
+ partial -= 136.0f * act_sum;
288
+ }
289
+ }
290
+ uint32_t group_idx = k_ / GROUP_SIZE;
291
+ sum[n][y] +=
292
+ partial * __s2float(scale[(m + y) * num_groups + group_idx]);
293
+ } else {
294
+ #pragma unroll
295
+ for (uint32_t b = 0; b < A_CHUNK / 2; b++) {
296
+ DOT2C(sum[n][y], bigA[n][k2].f[b], cvtB.f[b])
297
+ }
298
+ if constexpr (std::is_same_v<scalar_t, __hip_bfloat16>) {
299
+ constexpr float BIAS_VAL = HAS_ZERO_POINTS ? 128.0f : 136.0f;
300
+ constexpr uint32_t BF16_ONES = 0x3F803F80u;
301
+ float act_sum = 0.0f;
302
+ #pragma unroll
303
+ for (uint32_t b = 0; b < A_CHUNK / 2; b++) {
304
+ DOT2C(act_sum, bigA[n][k2].f[b], *(const float*)&BF16_ONES)
305
+ }
306
+ sum[n][y] -= BIAS_VAL * act_sum;
307
+ }
308
+ }
309
+ }
310
+ }
311
+ }
312
+ }
313
+
314
+ for (int n = 0; n < N; n++)
315
+ for (int y = 0; y < YTILE; y++) REDUCE_SUM_DPP_WAVE32(sum[n][y]);
316
+
317
+ if (threadIdx.x == (THRDS - 1)) {
318
+ for (int n = 0; n < N; n++) {
319
+ for (int i = 0; i < YTILE; i++) {
320
+ if constexpr (GROUP_SIZE == 0) {
321
+ sum[n][i] *= __s2float(scale[m + i]);
322
+ }
323
+ if (BIAS) sum[n][i] += __s2float(BIAS[(m + i) % Bx + (n % By) * M]);
324
+ C[m + i + n * M] = __float2s<scalar_t>(sum[n][i]);
325
+ }
326
+ }
327
+ }
328
+ m += CuCount * _WvPrGrp * YTILE;
329
+ }
330
+}
331
+#else // !defined(__HIP__GFX1X__)
332
+template <typename scalar_t, int THRDS, int YTILE, int WvPrGrp, int A_CHUNK,
333
+ int UNRL, int N, int GROUP_SIZE = 0, bool HAS_ZERO_POINTS = false>
334
+__global__ void wvSplitK_int4_hf_sml_(const int K, const int M, const int Bx,
335
+ const int By, const uint8_t* B_packed,
336
+ const scalar_t* __restrict__ A,
337
+ const scalar_t* scale,
338
+ const scalar_t* zero_points,
339
+ const scalar_t* __restrict__ BIAS,
340
+ scalar_t* C, const int _WvPrGrp,
341
+ const int CuCount) {
342
+ UNREACHABLE_CODE
343
+}
344
+#endif // defined(__HIP__GFX1X__)
345
+
346
+// W4A16 skinny GEMM "medium" kernel: activation matrix marginally exceeds LDS.
347
+// Loads as much of A into LDS as fits; overflowing rows fall back to global
348
+// memory. Also handles M not divisible by YTILE via commitColumn tracking.
349
+#if defined(__HIP__GFX1X__)
350
+template <typename scalar_t, int THRDS, int YTILE, int WvPrGrp, int A_CHUNK,
351
+ int UNRL, int N, int GROUP_SIZE = 0, bool HAS_ZERO_POINTS = false>
352
+__global__ void __launch_bounds__(WvPrGrp* THRDS)
353
+ wvSplitK_int4_hf_(const int K, const int M, const int Bx, const int By,
354
+ const uint8_t* B_packed, const scalar_t* __restrict__ A,
355
+ const scalar_t* scale, const scalar_t* zero_points,
356
+ const scalar_t* __restrict__ BIAS, scalar_t* C,
357
+ const int _WvPrGrp, const int CuCount) {
358
+ constexpr int max_lds_len = LDS_SIZE / 2;
359
+ const int K_packed = K / 2;
360
+
361
+ union bigTypeA {
362
+ scalar_t h[A_CHUNK];
363
+ float f[A_CHUNK / 2];
364
+ };
365
+
366
+ union bigTypeW {
367
+ uint8_t b[A_CHUNK / 2];
368
+ uint32_t u32[A_CHUNK / 8];
369
+ float f[A_CHUNK / 8];
370
+ };
371
+
372
+ __shared__ scalar_t s[max_lds_len];
373
+
374
+ uint32_t commitColumn[YTILE];
375
+ for (uint32_t i = 0; i < YTILE; i++) {
376
+ commitColumn[i] = 1;
377
+ }
378
+
379
+ uint32_t m = (blockIdx.x * _WvPrGrp + (threadIdx.y % _WvPrGrp)) * YTILE;
380
+
381
+ if (m < M && (m + YTILE) >= M) {
382
+ uint32_t startColumn = M - YTILE;
383
+ for (uint32_t i = 0; i < (m - startColumn); i++) {
384
+ commitColumn[i] = 0;
385
+ }
386
+ m = startColumn;
387
+ }
388
+
389
+ for (uint32_t k = 0; k < min__(K * N, max_lds_len);
390
+ k += THRDS * WvPrGrp * A_CHUNK) {
391
+ uint32_t k_in = k + ((threadIdx.y * THRDS + threadIdx.x) * A_CHUNK);
392
+
393
+ if (k_in >= min__(K * N, max_lds_len)) break;
394
+
395
+ *((bigTypeA*)(&s[k_in])) = *((bigTypeA*)(&A[k_in]));
396
+ }
397
+ __syncthreads();
398
+
399
+ if (threadIdx.y >= _WvPrGrp) return;
400
+
401
+ [[maybe_unused]] const int num_groups =
402
+ (GROUP_SIZE > 0) ? (K / GROUP_SIZE) : 0;
403
+
404
+ float sum[N][YTILE];
405
+
406
+ while (m < M) {
407
+ for (int i = 0; i < YTILE; i++)
408
+ for (int n = 0; n < N; n++) sum[n][i] = 0;
409
+
410
+ bigTypeA bigA[N][UNRL];
411
+ bigTypeW bigB[YTILE][UNRL];
412
+
413
+ for (uint32_t k1 = 0; k1 < K; k1 += THRDS * A_CHUNK * UNRL) {
414
+ #pragma unroll
415
+ for (uint32_t k2 = 0; k2 < UNRL; k2++) {
416
+ uint32_t k = k1 + k2 * THRDS * A_CHUNK;
417
+ uint32_t k_ = k + threadIdx.x * A_CHUNK;
418
+ if (k_ >= K) break;
419
+
420
+ const uint8_t* B_ = &B_packed[(m + 0) * K_packed + k_ / 2];
421
+ for (int y = 0; y < YTILE; y++) {
422
+ const float* src = (const float*)(&B_[y * K_packed]);
423
+ #pragma unroll
424
+ for (int i = 0; i < A_CHUNK / 8; i++)
425
+ bigB[y][k2].f[i] = loadnt((float*)&src[i]);
426
+ }
427
+ }
428
+
429
+ #pragma unroll
430
+ for (uint32_t k2 = 0; k2 < UNRL; k2++) {
431
+ uint32_t k = k1 + k2 * THRDS * A_CHUNK;
432
+ uint32_t k_ = k + threadIdx.x * A_CHUNK;
433
+ if (k_ >= K) break;
434
+
435
+ for (int n = 0; n < N; n++) {
436
+ if (k_ + K * n < max_lds_len)
437
+ bigA[n][k2] = *((const bigTypeA*)(&(s[k_ + K * n])));
438
+ else
439
+ bigA[n][k2] = *((const bigTypeA*)(&(A[k_ + K * n])));
440
+ }
441
+ }
442
+
443
+ #pragma unroll
444
+ for (uint32_t k2 = 0; k2 < UNRL; k2++) {
445
+ uint32_t k = k1 + k2 * THRDS * A_CHUNK;
446
+ uint32_t k_ = k + threadIdx.x * A_CHUNK;
447
+ if (k_ >= K) break;
448
+
449
+ #pragma unroll
450
+ for (uint32_t n = 0; n < N; n++) {
451
+ #pragma unroll
452
+ for (int y = 0; y < YTILE; y++) {
453
+ bigTypeA cvtB;
454
+
455
+ if constexpr (std::is_same_v<scalar_t, half>) {
456
+ constexpr uint32_t FP16_MAGIC = 0x64006400u;
457
+ // When HAS_ZERO_POINTS, store raw nibble values;
458
+ // the zero-point subtraction below handles the full shift.
459
+ // When symmetric, bake -8 into the constants.
460
+ constexpr uint32_t BIAS_LO =
461
+ HAS_ZERO_POINTS ? 0x64006400u : 0x64086408u;
462
+ constexpr uint32_t SCALE16 = 0x2C002C00u;
463
+ constexpr uint32_t BIAS_HI =
464
+ HAS_ZERO_POINTS ? 0xD400D400u : 0xD480D480u;
465
+ #pragma unroll
466
+ for (uint32_t w = 0; w < A_CHUNK / 8; w++) {
467
+ uint32_t qa = bigB[y][k2].u32[w];
468
+ uint32_t lo0 = (qa & 0x000F000Fu) | FP16_MAGIC;
469
+ uint32_t hi0 = (qa & 0x00F000F0u) | FP16_MAGIC;
470
+ qa >>= 8;
471
+ uint32_t lo1 = (qa & 0x000F000Fu) | FP16_MAGIC;
472
+ uint32_t hi1 = (qa & 0x00F000F0u) | FP16_MAGIC;
473
+
474
+ *(half2*)&cvtB.f[w * 4 + 0] =
475
+ __hsub2(*(half2*)&lo0, *(const half2*)&BIAS_LO);
476
+ *(half2*)&cvtB.f[w * 4 + 1] =
477
+ __hfma2(*(half2*)&hi0, *(const half2*)&SCALE16,
478
+ *(const half2*)&BIAS_HI);
479
+ *(half2*)&cvtB.f[w * 4 + 2] =
480
+ __hsub2(*(half2*)&lo1, *(const half2*)&BIAS_LO);
481
+ *(half2*)&cvtB.f[w * 4 + 3] =
482
+ __hfma2(*(half2*)&hi1, *(const half2*)&SCALE16,
483
+ *(const half2*)&BIAS_HI);
484
+ }
485
+ } else {
486
+ // bf16 path: marlin-style magic-number trick.
487
+ // Skip the expensive bias subtraction (avoids fp32 round-trip
488
+ // on gfx1151); use magic values directly in DOT2C and correct
489
+ // the accumulator with bias * sum(activations) afterward.
490
+ constexpr uint32_t BF16_MAGIC = 0x43004300u;
491
+ #pragma unroll
492
+ for (uint32_t w = 0; w < A_CHUNK / 8; w++) {
493
+ uint32_t qa = bigB[y][k2].u32[w];
494
+ *(uint32_t*)&cvtB.f[w * 4 + 0] =
495
+ (qa & 0x000F000Fu) | BF16_MAGIC;
496
+ qa >>= 4;
497
+ *(uint32_t*)&cvtB.f[w * 4 + 1] =
498
+ (qa & 0x000F000Fu) | BF16_MAGIC;
499
+ qa >>= 4;
500
+ *(uint32_t*)&cvtB.f[w * 4 + 2] =
501
+ (qa & 0x000F000Fu) | BF16_MAGIC;
502
+ qa >>= 4;
503
+ *(uint32_t*)&cvtB.f[w * 4 + 3] =
504
+ (qa & 0x000F000Fu) | BF16_MAGIC;
505
+ }
506
+ }
507
+
508
+ if constexpr (!std::is_same_v<scalar_t, __hip_bfloat16>) {
509
+ if constexpr (HAS_ZERO_POINTS && GROUP_SIZE > 0) {
510
+ uint32_t group_idx = k_ / GROUP_SIZE;
511
+ scalar_t zp = zero_points[(m + y) * num_groups + group_idx];
512
+ #pragma unroll
513
+ for (uint32_t b = 0; b < A_CHUNK; b++) {
514
+ cvtB.h[b] = cvtB.h[b] - zp;
515
+ }
516
+ }
517
+ }
518
+
519
+ if constexpr (GROUP_SIZE > 0) {
520
+ float partial = 0;
521
+ #pragma unroll
522
+ for (uint32_t b = 0; b < A_CHUNK / 2; b++) {
523
+ DOT2C(partial, bigA[n][k2].f[b], cvtB.f[b])
524
+ }
525
+ if constexpr (std::is_same_v<scalar_t, __hip_bfloat16>) {
526
+ constexpr uint32_t BF16_ONES = 0x3F803F80u;
527
+ float act_sum = 0.0f;
528
+ #pragma unroll
529
+ for (uint32_t b = 0; b < A_CHUNK / 2; b++) {
530
+ DOT2C(act_sum, bigA[n][k2].f[b], *(const float*)&BF16_ONES)
531
+ }
532
+ if constexpr (HAS_ZERO_POINTS) {
533
+ uint32_t group_idx_zp = k_ / GROUP_SIZE;
534
+ float zp_f = __s2float(
535
+ zero_points[(m + y) * num_groups + group_idx_zp]);
536
+ partial -= (128.0f + zp_f) * act_sum;
537
+ } else {
538
+ partial -= 136.0f * act_sum;
539
+ }
540
+ }
541
+ uint32_t group_idx = k_ / GROUP_SIZE;
542
+ sum[n][y] +=
543
+ partial * __s2float(scale[(m + y) * num_groups + group_idx]);
544
+ } else {
545
+ #pragma unroll
546
+ for (uint32_t b = 0; b < A_CHUNK / 2; b++) {
547
+ DOT2C(sum[n][y], bigA[n][k2].f[b], cvtB.f[b])
548
+ }
549
+ if constexpr (std::is_same_v<scalar_t, __hip_bfloat16>) {
550
+ constexpr float BIAS_VAL = HAS_ZERO_POINTS ? 128.0f : 136.0f;
551
+ constexpr uint32_t BF16_ONES = 0x3F803F80u;
552
+ float act_sum = 0.0f;
553
+ #pragma unroll
554
+ for (uint32_t b = 0; b < A_CHUNK / 2; b++) {
555
+ DOT2C(act_sum, bigA[n][k2].f[b], *(const float*)&BF16_ONES)
556
+ }
557
+ sum[n][y] -= BIAS_VAL * act_sum;
558
+ }
559
+ }
560
+ }
561
+ }
562
+ }
563
+ }
564
+
565
+ for (int n = 0; n < N; n++)
566
+ for (int y = 0; y < YTILE; y++) REDUCE_SUM_DPP_WAVE32(sum[n][y]);
567
+
568
+ if (threadIdx.x == (THRDS - 1)) {
569
+ for (int n = 0; n < N; n++) {
570
+ for (int i = 0; i < YTILE; i++) {
571
+ if (commitColumn[i]) {
572
+ if constexpr (GROUP_SIZE == 0) {
573
+ sum[n][i] *= __s2float(scale[m + i]);
574
+ }
575
+ if (BIAS) sum[n][i] += __s2float(BIAS[(m + i) % Bx + (n % By) * M]);
576
+ C[m + i + n * M] = __float2s<scalar_t>(sum[n][i]);
577
+ }
578
+ }
579
+ }
580
+ }
581
+ m += CuCount * _WvPrGrp * YTILE;
582
+
583
+ if (m < M && (m + YTILE) >= M) {
584
+ uint32_t startColumn = M - YTILE;
585
+ for (uint32_t i = 0; i < (m - startColumn); i++) {
586
+ commitColumn[i] = 0;
587
+ }
588
+ m = startColumn;
589
+ }
590
+ }
591
+}
592
+#else // !defined(__HIP__GFX1X__)
593
+template <typename scalar_t, int THRDS, int YTILE, int WvPrGrp, int A_CHUNK,
594
+ int UNRL, int N, int GROUP_SIZE = 0, bool HAS_ZERO_POINTS = false>
595
+__global__ void wvSplitK_int4_hf_(const int K, const int M, const int Bx,
596
+ const int By, const uint8_t* B_packed,
597
+ const scalar_t* __restrict__ A,
598
+ const scalar_t* scale,
599
+ const scalar_t* zero_points,
600
+ const scalar_t* __restrict__ BIAS,
601
+ scalar_t* C, const int _WvPrGrp,
602
+ const int CuCount) {
603
+ UNREACHABLE_CODE
604
+}
605
+#endif // defined(__HIP__GFX1X__)
606
+
607
+static int mindiv_int4(int N, int div1, int div2) {
608
+ int nPrRnd = div1 * div2;
609
+ int limit = div2 < 13 ? div2 : 13;
610
+ int rnds[16];
611
+ for (int i = 0; i < limit; i++) {
612
+ rnds[i] = (N + nPrRnd - 1) / nPrRnd;
613
+ nPrRnd -= div1;
614
+ }
615
+ for (int i = limit - 1; i >= 0; i--)
616
+ if (rnds[0] == rnds[i]) return (div2 - i);
617
+ return 0;
618
+}
619
+
620
+// Per-group W4A16 skinny GEMM: packed int4 weights with group-wise scales.
621
+// in_a: packed int4 weights [M, K/2] (int8) or [M, K/8] (int32)
622
+// in_b: activations [N, K] (fp16/bf16)
623
+// in_scale: group scales [M, K/group_size] (fp16/bf16)
624
+// in_zero_points: optional raw zero points [M, K/group_size] (fp16/bf16)
625
+// If provided, kernel dequants as (nibble - zp_raw) * scale (asymmetric).
626
+// If absent, kernel dequants as (nibble - 8) * scale (symmetric uint4b8).
627
+// group_size: 32, 64, or 128
628
+
629
+torch::Tensor wvSplitK_int4_g(const at::Tensor& in_a, const at::Tensor& in_b,
630
+ const at::Tensor& in_scale,
631
+ const std::optional<at::Tensor>& in_zero_points,
632
+ const std::optional<at::Tensor>& in_bias,
633
+ const int64_t CuCount, const int64_t group_size) {
634
+ auto M_in = in_a.size(0);
635
+ auto K_in = in_b.size(1);
636
+ auto N_in = in_b.size(0);
637
+ auto Bx_in =
638
+ (in_bias.has_value() && in_bias->numel() > 0)
639
+ ? (in_bias->sizes().size() == 2) ? in_bias->size(1) : in_bias->size(0)
640
+ : 1;
641
+ auto By_in = (in_bias.has_value() && in_bias->numel() > 0 &&
642
+ in_bias->sizes().size() == 2)
643
+ ? in_bias->size(0)
644
+ : 1;
645
+
646
+ int64_t expected_weight_bytes = M_in * K_in / 2;
647
+ int64_t actual_weight_bytes = in_a.numel() * in_a.element_size();
648
+ TORCH_CHECK(actual_weight_bytes == expected_weight_bytes,
649
+ "Weight tensor must contain M*K/2 bytes for int4 packing");
650
+ TORCH_CHECK(
651
+ in_b.dtype() == torch::kFloat16 || in_b.dtype() == torch::kBFloat16,
652
+ "Activation must be float16 or bfloat16");
653
+ TORCH_CHECK(in_scale.dtype() == in_b.dtype(),
654
+ "Scale dtype must match activation dtype");
655
+ TORCH_CHECK(group_size == 32 || group_size == 64 || group_size == 128,
656
+ "group_size must be 32, 64, or 128, got ", group_size);
657
+ TORCH_CHECK(K_in % group_size == 0,
658
+ "K must be divisible by group_size=", group_size);
659
+ int64_t num_groups = K_in / group_size;
660
+ TORCH_CHECK(in_scale.dim() == 2,
661
+ "Scale must be 2D [M, K/group_size], got shape ",
662
+ in_scale.sizes());
663
+ TORCH_CHECK(in_scale.size(0) == M_in && in_scale.size(1) == num_groups,
664
+ "Scale must be [M, K/group_size] = [", M_in, ", ", num_groups,
665
+ "] but got [", in_scale.size(0), ", ", in_scale.size(1), "]");
666
+ if (in_zero_points.has_value()) {
667
+ TORCH_CHECK(in_zero_points->dtype() == in_b.dtype(),
668
+ "Zero points dtype must match activation dtype");
669
+ TORCH_CHECK(in_zero_points->dim() == 2,
670
+ "Zero points must be 2D [M, K/group_size], got shape ",
671
+ in_zero_points->sizes());
672
+ TORCH_CHECK(in_zero_points->size(0) == M_in &&
673
+ in_zero_points->size(1) == num_groups,
674
+ "Zero points must be [M, K/group_size] = [", M_in, ", ",
675
+ num_groups, "] but got [", in_zero_points->size(0), ", ",
676
+ in_zero_points->size(1), "]");
677
+ }
678
+ TORCH_CHECK(K_in % 16 == 0, "K must be divisible by 16");
679
+
680
+ const int max_lds_len = get_lds_size_int4() / 2;
681
+ TORCH_CHECK(K_in * N_in <= (int64_t)(max_lds_len * 1.2),
682
+ "K*N exceeds LDS capacity (medium limit). K=", K_in, " N=", N_in);
683
+
684
+ auto out_c = torch::empty(
685
+ {N_in, M_in},
686
+ torch::TensorOptions().dtype(in_b.dtype()).device(in_b.device()));
687
+
688
+ dim3 grid(CuCount);
689
+
690
+ const at::cuda::OptionalCUDAGuard device_guard(device_of(in_a));
691
+ const cudaStream_t stream = at::cuda::getCurrentCUDAStream();
692
+
693
+// Dispatch macro: _HAS_ZP selects the HAS_ZERO_POINTS template parameter
694
+#define WVSPLITK_INT4G_LAUNCH(_THRDS, _YTILE, _UNRL, _N, _GS, _HAS_ZP) \
695
+ { \
696
+ dim3 block(_THRDS, 16); \
697
+ int __wvPrGrp = mindiv_int4(M_in, CuCount * _YTILE, 16); \
698
+ if (K_in * N_in <= max_lds_len && M_in % _YTILE == 0) \
699
+ wvSplitK_int4_hf_sml_<fptype, _THRDS, _YTILE, 16, 16, _UNRL, _N, _GS, \
700
+ _HAS_ZP><<<grid, block, 0, stream>>>( \
701
+ K_in, M_in, Bx_in, By_in, wptr, aptr, sptr, zpptr, biasptr, cptr, \
702
+ __wvPrGrp, CuCount); \
703
+ else \
704
+ wvSplitK_int4_hf_<fptype, _THRDS, _YTILE, 16, 16, _UNRL, _N, _GS, \
705
+ _HAS_ZP><<<grid, block, 0, stream>>>( \
706
+ K_in, M_in, Bx_in, By_in, wptr, aptr, sptr, zpptr, biasptr, cptr, \
707
+ __wvPrGrp, CuCount); \
708
+ }
709
+
710
+#define WVSPLITK_INT4G(_YTILE, _UNRL, _N, _GS, _HAS_ZP) \
711
+ WVSPLITK_INT4G_LAUNCH(32, _YTILE, _UNRL, _N, _GS, _HAS_ZP)
712
+
713
+#define WVSPLIT_INT4G_GS(_YTILE, _UNRL, _N, _HAS_ZP) \
714
+ if (group_size == 32) \
715
+ WVSPLITK_INT4G(_YTILE, _UNRL, _N, 32, _HAS_ZP) \
716
+ else if (group_size == 64) \
717
+ WVSPLITK_INT4G(_YTILE, _UNRL, _N, 64, _HAS_ZP) \
718
+ else \
719
+ WVSPLITK_INT4G(_YTILE, _UNRL, _N, 128, _HAS_ZP)
720
+
721
+#define WVSPLIT_INT4G_TILE(_sYT, __N, _HAS_ZP) \
722
+ { \
723
+ if (K_in * N_in > max_lds_len) { \
724
+ if (_sYT < 30) \
725
+ WVSPLIT_INT4G_GS(4, 2, __N, _HAS_ZP) \
726
+ else \
727
+ WVSPLIT_INT4G_GS(4, 1, __N, _HAS_ZP) \
728
+ } else if (__N >= 4 && _sYT >= 480) \
729
+ WVSPLIT_INT4G_GS(4, 1, __N, _HAS_ZP) \
730
+ else if (__N >= 3 && _sYT >= 40) \
731
+ WVSPLIT_INT4G_GS(4, 1, __N, _HAS_ZP) \
732
+ else if (__N >= 3 && _sYT < 40 && (K_in <= 2048 || K_in >= 4096)) \
733
+ WVSPLIT_INT4G_GS(2, 4, __N, _HAS_ZP) \
734
+ else if (__N >= 3 && _sYT < 40) \
735
+ WVSPLIT_INT4G_GS(2, 2, __N, _HAS_ZP) \
736
+ else if (__N >= 2) \
737
+ WVSPLIT_INT4G_GS(2, 2, __N, _HAS_ZP) \
738
+ else if (_sYT >= 30) \
739
+ WVSPLIT_INT4G_GS(2, 4, __N, _HAS_ZP) \
740
+ else \
741
+ WVSPLIT_INT4G_GS(1, 4, __N, _HAS_ZP) \
742
+ }
743
+
744
+// Inner dispatch: shared by both symmetric and asymmetric paths
745
+#define WVSPLIT_INT4G_DISPATCH(_HAS_ZP) \
746
+ { \
747
+ int sYT = (M_in + CuCount * 4 - 1) / (CuCount * 4); \
748
+ switch (N_in) { \
749
+ case 1: \
750
+ WVSPLIT_INT4G_TILE(sYT, 1, _HAS_ZP) break; \
751
+ case 2: \
752
+ WVSPLIT_INT4G_TILE(sYT, 2, _HAS_ZP) break; \
753
+ case 3: \
754
+ WVSPLIT_INT4G_TILE(sYT, 3, _HAS_ZP) break; \
755
+ case 4: \
756
+ WVSPLIT_INT4G_TILE(sYT, 4, _HAS_ZP) break; \
757
+ case 5: \
758
+ WVSPLIT_INT4G_TILE(sYT, 5, _HAS_ZP) break; \
759
+ default: \
760
+ throw std::runtime_error("Unsupported N value: " + \
761
+ std::to_string(N_in)); \
762
+ } \
763
+ }
764
+
765
+ AT_DISPATCH_REDUCED_FLOATING_TYPES(
766
+ in_b.scalar_type(), "wvSplitK_int4_g", [&] {
767
+ using fptype = typename scalar<scalar_t>::type;
768
+ const uint8_t* wptr = reinterpret_cast<const uint8_t*>(in_a.data_ptr());
769
+ const fptype* aptr = reinterpret_cast<const fptype*>(in_b.data_ptr());
770
+ const fptype* sptr =
771
+ reinterpret_cast<const fptype*>(in_scale.data_ptr());
772
+ const fptype* zpptr =
773
+ in_zero_points.has_value()
774
+ ? reinterpret_cast<const fptype*>(in_zero_points->data_ptr())
775
+ : nullptr;
776
+ const fptype* biasptr =
777
+ (in_bias.has_value() && in_bias->numel() > 0)
778
+ ? reinterpret_cast<const fptype*>(in_bias->data_ptr())
779
+ : nullptr;
780
+ fptype* cptr = reinterpret_cast<fptype*>(out_c.data_ptr());
781
+
782
+ if (in_zero_points.has_value())
783
+ WVSPLIT_INT4G_DISPATCH(true)
784
+ else
785
+ WVSPLIT_INT4G_DISPATCH(false)
786
+ });
787
+
788
+#undef WVSPLITK_INT4G_LAUNCH
789
+#undef WVSPLITK_INT4G
790
+#undef WVSPLIT_INT4G_GS
791
+#undef WVSPLIT_INT4G_TILE
792
+#undef WVSPLIT_INT4G_DISPATCH
793
+
794
+ return out_c;
795
+}
csrc/rocm/torch_bindings.cpp
+8
@@ -26,6 +26,14 @@ TORCH_LIBRARY_EXPAND(TORCH_EXTENSION_NAME, rocm_ops) {
26
"Tensor");
27
rocm_ops.impl("wvSplitK", torch::kCUDA, &wvSplitK);
28
29
+ // W4A16 grouped skinny GEMM: packed int4 weights, per-group scales,
30
+ // optional zero points for asymmetric quantization
31
+ rocm_ops.def(
32
+ "wvSplitK_int4_g(Tensor in_a, Tensor in_b, Tensor in_scale, "
33
+ "Tensor? in_zero_points, Tensor? in_bias, int CuCount, "
34
+ "int group_size) -> Tensor");
35
+ rocm_ops.impl("wvSplitK_int4_g", torch::kCUDA, &wvSplitK_int4_g);
36
+
37
// Custom gemm op for skinny matrix-matrix multiplication
38
rocm_ops.def(
39
"wvSplitKrc(Tensor in_a, Tensor in_b, Tensor? in_bias, int CuCount) -> "
tests/kernels/quantization/test_rdna_hybrid_w4a16.py
new
+525
@@ -0,0 +1,525 @@
1
+#!/usr/bin/env python3
2
+# SPDX-License-Identifier: Apache-2.0
3
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
4
+"""Tests for the ROCm Hybrid W4A16 kernel (HIP skinny + Triton prefill).
5
+
6
+Run `pytest tests/kernels/quantization/test_rdna_hybrid_w4a16.py`.
7
+"""
8
+
9
+import importlib
10
+
11
+import pytest
12
+import torch
13
+
14
+from vllm.platforms import current_platform
15
+from vllm.utils.torch_utils import set_random_seed
16
+
17
+if not current_platform.is_rocm():
18
+ pytest.skip("ROCm only", allow_module_level=True)
19
+
20
+pytest.importorskip("triton")
21
+
22
+from vllm.platforms.rocm import on_gfx1x # noqa: E402
23
+
24
+device = "cuda"
25
+
26
+hybrid_module = importlib.import_module(
27
+ "vllm.model_executor.kernels.linear.mixed_precision.rdna_hybrid_w4a16"
28
+)
29
+RDNAHybridW4A16LinearKernel = hybrid_module.RDNAHybridW4A16LinearKernel
30
+pack_int4_exllama_shuffle = hybrid_module.pack_int4_exllama_shuffle
31
+SUPPORTED_GROUP_SIZES = hybrid_module.SUPPORTED_GROUP_SIZES
32
+MAX_SKINNY_BATCH_SIZE = hybrid_module.MAX_SKINNY_BATCH_SIZE
33
+
34
+
35
+# ---------------------------------------------------------------------------
36
+# Reference implementation
37
+# ---------------------------------------------------------------------------
38
+
39
+
40
+def _rdna_hybrid_w4a16_reference(
41
+ x_mk: torch.Tensor,
42
+ w_int4_nk: torch.Tensor,
43
+ scales_nkg: torch.Tensor,
44
+ zp_nkg: torch.Tensor | None,
45
+ group_size: int,
46
+ bias: torch.Tensor | None,
47
+) -> torch.Tensor:
48
+ """Reference for the Hybrid W4A16 op.
49
+
50
+ x_mk: [M, K] fp16/bf16
51
+ w_int4_nk: [N, K] int32 with raw uint4 values in [0, 15]
52
+ 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)
55
+ """
56
+ G = group_size
57
+ N, K = w_int4_nk.shape
58
+ assert K % G == 0
59
+ s_full = scales_nkg.repeat_interleave(G, dim=1).to(torch.float32) # [N, K]
60
+ if zp_nkg is None:
61
+ z_full = torch.full((N, K), 8.0, device=x_mk.device, dtype=torch.float32)
62
+ else:
63
+ z_full = zp_nkg.repeat_interleave(G, dim=1).to(torch.float32)
64
+ w_fp = (w_int4_nk.to(torch.float32) - z_full) * s_full # [N, K]
65
+ out = x_mk.to(torch.float32) @ w_fp.t() # [M, N]
66
+ if bias is not None:
67
+ out = out + bias.to(torch.float32)
68
+ return out.to(x_mk.dtype)
69
+
70
+
71
+# ---------------------------------------------------------------------------
72
+# Forward correctness: decode (HIP skinny) + prefill (Triton)
73
+# ---------------------------------------------------------------------------
74
+
75
+
76
+@pytest.mark.skipif(not on_gfx1x(), reason="Hybrid path is gfx11/gfx12 only")
77
+@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16])
78
+@pytest.mark.parametrize("group_size", SUPPORTED_GROUP_SIZES)
79
+@pytest.mark.parametrize("has_zp", [False, True])
80
+@pytest.mark.parametrize(
81
+ "M",
82
+ [1, MAX_SKINNY_BATCH_SIZE, MAX_SKINNY_BATCH_SIZE + 1, 64],
83
+ ids=["M=1_decode", "M=5_decode", "M=6_prefill", "M=64_prefill"],
84
+)
85
+def test_rdna_hybrid_w4a16_apply_matches_reference(dtype, group_size, has_zp, M):
86
+ """Smoke test the registered custom op for both decode and prefill batches.
87
+
88
+ Verifies the dispatch logic in `_rdna_hybrid_w4a16_apply_impl`:
89
+ - M <= MAX_SKINNY_BATCH_SIZE: HIP wvSplitK_int4_g
90
+ - M > MAX_SKINNY_BATCH_SIZE: Triton prefill kernel
91
+ """
92
+ if not torch.cuda.is_available():
93
+ pytest.skip("CUDA/HIP device not available")
94
+
95
+ set_random_seed(0)
96
+
97
+ K, N = 1024, 256
98
+ assert K % group_size == 0 and K % 8 == 0 and N % 8 == 0
99
+
100
+ # Activations.
101
+ x_mk = (0.25 * torch.randn((M, K), device=device, dtype=torch.float32)).to(dtype)
102
+
103
+ # Weights as raw uint4 in [N, K], packed to ExLlama shuffle [N, K//8].
104
+ w_int4_nk = torch.randint(0, 16, (N, K), device=device, dtype=torch.int32)
105
+ w_q_i32 = pack_int4_exllama_shuffle(w_int4_nk).contiguous() # [N, K//8] int32
106
+ w_q = w_q_i32.view(torch.int8) # same bytes viewed as int8 [N, K//2]
107
+
108
+ # Scales [N, K//G] in act dtype.
109
+ scales_nkg = (
110
+ 0.05 * torch.rand((N, K // group_size), device=device, dtype=torch.float32)
111
+ ).to(dtype)
112
+
113
+ # Optional raw zero points [N, K//G] in act dtype.
114
+ if has_zp:
115
+ zp_nkg = torch.randint(
116
+ 0, 16, (N, K // group_size), device=device, dtype=torch.int32
117
+ ).to(dtype)
118
+ else:
119
+ zp_nkg = None
120
+
121
+ from vllm.utils.platform_utils import num_compute_units
122
+
123
+ out = torch.ops.vllm.rdna_hybrid_w4a16_apply(
124
+ x_mk,
125
+ w_q,
126
+ scales_nkg,
127
+ zp_nkg,
128
+ None, # bias
129
+ num_compute_units(),
130
+ group_size,
131
+ )
132
+
133
+ ref = _rdna_hybrid_w4a16_reference(
134
+ x_mk, w_int4_nk, scales_nkg, zp_nkg, group_size, bias=None
135
+ )
136
+
137
+ torch.testing.assert_close(out, ref, rtol=2e-2, atol=2e-2)
138
+
139
+
140
+@pytest.mark.skipif(not on_gfx1x(), reason="Hybrid path is gfx11/gfx12 only")
141
+@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16])
142
+@pytest.mark.parametrize("M", [1, MAX_SKINNY_BATCH_SIZE + 1])
143
+def test_rdna_hybrid_w4a16_apply_with_bias(dtype, M):
144
+ """Bias is added correctly on both decode and prefill paths."""
145
+ if not torch.cuda.is_available():
146
+ pytest.skip("CUDA/HIP device not available")
147
+
148
+ set_random_seed(0)
149
+ K, N, G = 1024, 128, 128
150
+
151
+ x_mk = (0.25 * torch.randn((M, K), device=device, dtype=torch.float32)).to(dtype)
152
+ w_int4_nk = torch.randint(0, 16, (N, K), device=device, dtype=torch.int32)
153
+ w_q_i32 = pack_int4_exllama_shuffle(w_int4_nk).contiguous()
154
+ w_q = w_q_i32.view(torch.int8)
155
+ scales_nkg = (
156
+ 0.05 * torch.rand((N, K // G), device=device, dtype=torch.float32)
157
+ ).to(dtype)
158
+ bias = torch.randn(N, device=device, dtype=dtype) * 0.1
159
+
160
+ from vllm.utils.platform_utils import num_compute_units
161
+
162
+ out = torch.ops.vllm.rdna_hybrid_w4a16_apply(
163
+ x_mk,
164
+ w_q,
165
+ scales_nkg,
166
+ None,
167
+ bias,
168
+ num_compute_units(),
169
+ G,
170
+ )
171
+ ref = _rdna_hybrid_w4a16_reference(x_mk, w_int4_nk, scales_nkg, None, G, bias=bias)
172
+
173
+ torch.testing.assert_close(out, ref, rtol=2e-2, atol=2e-2)
174
+
175
+
176
+# ---------------------------------------------------------------------------
177
+# pack_int4_exllama_shuffle round-trips correctly
178
+# ---------------------------------------------------------------------------
179
+
180
+
181
+def test_pack_int4_exllama_shuffle_layout():
182
+ """Pack 8 K-values per int32 in interleave [0,2,4,6,1,3,5,7] order."""
183
+ if not torch.cuda.is_available():
184
+ pytest.skip("CUDA/HIP device not available")
185
+ set_random_seed(0)
186
+ N, K = 4, 16
187
+ w = torch.randint(0, 16, (N, K), device=device, dtype=torch.int32)
188
+ packed = pack_int4_exllama_shuffle(w)
189
+ assert packed.shape == (N, K // 8) and packed.dtype == torch.int32
190
+
191
+ # Manual unshuffle using ExLlama shifts [0,16,4,20,8,24,12,28].
192
+ shifts = torch.tensor(
193
+ [0, 16, 4, 20, 8, 24, 12, 28], device=device, dtype=torch.int32
194
+ )
195
+ unshuffled = (packed.unsqueeze(-1) >> shifts) & 0xF
196
+ unshuffled = unshuffled.reshape(N, K)
197
+ torch.testing.assert_close(unshuffled, w)
198
+
199
+
200
+# ---------------------------------------------------------------------------
201
+# process_weights_after_loading: layout repack and zp normalization
202
+# ---------------------------------------------------------------------------
203
+
204
+
205
+def _pack_int4_along_k_to_ckpt(w_int4_kn: torch.Tensor) -> torch.Tensor:
206
+ """Pack int4 values along K into CT checkpoint layout: [K,N] -> [N, K//8]."""
207
+ assert w_int4_kn.dtype == torch.int32
208
+ K, N = w_int4_kn.shape
209
+ assert K % 8 == 0
210
+ out = torch.zeros((N, K // 8), dtype=torch.int32, device=w_int4_kn.device)
211
+ for i in range(8):
212
+ out |= (w_int4_kn[i::8, :].t() & 0xF) << (i * 4)
213
+ return out.contiguous()
214
+
215
+
216
+def _pack_int4_along_n_for_zp(zp_int4_gn: torch.Tensor) -> torch.Tensor:
217
+ """Pack int4 zero points along N: [G, N] -> [G, N//8] int32 (CT layout)."""
218
+ assert zp_int4_gn.dtype == torch.int32
219
+ G, N = zp_int4_gn.shape
220
+ assert N % 8 == 0
221
+ shifts = torch.arange(8, device=zp_int4_gn.device, dtype=torch.int32) * 4
222
+ return torch.sum(
223
+ (zp_int4_gn.view(G, N // 8, 8) & 0xF) << shifts, dim=2, dtype=torch.int32
224
+ ).contiguous()
225
+
226
+
227
+def _build_dummy_layer(
228
+ w_ckpt_nk8: torch.Tensor,
229
+ scales_ckpt_nkg: torch.Tensor,
230
+ zeros_ckpt: torch.Tensor | None,
231
+):
232
+ from vllm.model_executor.parameter import (
233
+ GroupQuantScaleParameter,
234
+ PackedColumnParameter,
235
+ PackedvLLMParameter,
236
+ )
237
+
238
+ weight_loader = lambda *args, **kwargs: None
239
+
240
+ class DummyLayer(torch.nn.Module):
241
+ pass
242
+
243
+ layer = DummyLayer()
244
+ layer.register_parameter(
245
+ "weight_packed",
246
+ PackedvLLMParameter(
247
+ data=w_ckpt_nk8,
248
+ weight_loader=weight_loader,
249
+ input_dim=1,
250
+ output_dim=0,
251
+ packed_factor=8,
252
+ packed_dim=1,
253
+ ),
254
+ )
255
+ layer.register_parameter(
256
+ "weight_scale",
257
+ GroupQuantScaleParameter(
258
+ data=scales_ckpt_nkg,
259
+ weight_loader=weight_loader,
260
+ input_dim=1,
261
+ output_dim=0,
262
+ ),
263
+ )
264
+ if zeros_ckpt is not None:
265
+ layer.register_parameter(
266
+ "weight_zero_point",
267
+ PackedColumnParameter(
268
+ data=zeros_ckpt,
269
+ weight_loader=weight_loader,
270
+ output_dim=0,
271
+ packed_factor=8,
272
+ packed_dim=0,
273
+ ),
274
+ )
275
+ return layer
276
+
277
+
278
+@pytest.mark.parametrize("group_size", SUPPORTED_GROUP_SIZES)
279
+def test_rdna_hybrid_w4a16_process_weights_symmetric_repack(group_size, dist_init):
280
+ """uint4b8 (symmetric): w_q -> [N, K//8] int8 ExLlama shuffle, no zp param."""
281
+ if not torch.cuda.is_available():
282
+ pytest.skip("CUDA/HIP device not available")
283
+
284
+ from vllm.model_executor.kernels.linear.mixed_precision.MPLinearKernel import (
285
+ MPLinearLayerConfig,
286
+ )
287
+ from vllm.scalar_type import scalar_types
288
+
289
+ set_random_seed(0)
290
+
291
+ K, N = 256, 128
292
+ G = group_size
293
+ assert K % G == 0
294
+
295
+ # Reference unpacked weights, then pack into CT checkpoint layout [N, K//8].
296
+ w_int4_kn = torch.randint(0, 16, (K, N), device=device, dtype=torch.int32)
297
+ w_ckpt_nk8 = _pack_int4_along_k_to_ckpt(w_int4_kn)
298
+ scales_ckpt_nkg = 0.05 * torch.rand((N, K // G), device=device, dtype=torch.float16)
299
+
300
+ layer = _build_dummy_layer(w_ckpt_nk8, scales_ckpt_nkg, zeros_ckpt=None)
301
+
302
+ config = MPLinearLayerConfig(
303
+ full_weight_shape=(K, N),
304
+ partition_weight_shape=(K, N),
305
+ weight_type=scalar_types.uint4b8,
306
+ act_type=torch.float16,
307
+ group_size=G,
308
+ zero_points=False,
309
+ has_g_idx=False,
310
+ )
311
+ kernel = RDNAHybridW4A16LinearKernel(
312
+ config,
313
+ w_q_param_name="weight_packed",
314
+ w_s_param_name="weight_scale",
315
+ w_zp_param_name=None,
316
+ w_gidx_param_name=None,
317
+ )
318
+ kernel.process_weights_after_loading(layer)
319
+
320
+ # Skinny weight is stored once as int8 [N, K//2]; the Triton path
321
+ # reinterprets it as int32 [N, K//8] via a view (no separate parameter).
322
+ assert layer.weight_packed.dtype == torch.int8
323
+ assert tuple(layer.weight_packed.shape) == (N, K // 2)
324
+ w_q_i32 = layer.weight_packed.view(torch.int32)
325
+ assert tuple(w_q_i32.shape) == (N, K // 8)
326
+
327
+ expected_packed = pack_int4_exllama_shuffle(w_int4_kn.t().contiguous())
328
+ torch.testing.assert_close(w_q_i32, expected_packed)
329
+
330
+ # Scales: [N, K//G] (skinny layout, no transpose since CT already had it).
331
+ assert tuple(layer.weight_scale.shape) == (N, K // G)
332
+ torch.testing.assert_close(layer.weight_scale, scales_ckpt_nkg)
333
+
334
+
335
+@pytest.mark.parametrize("group_size", SUPPORTED_GROUP_SIZES)
336
+def test_rdna_hybrid_w4a16_process_weights_asymmetric_repack(group_size, dist_init):
337
+ """uint4 (asymmetric): zero points unpacked to raw values in act dtype."""
338
+ if not torch.cuda.is_available():
339
+ pytest.skip("CUDA/HIP device not available")
340
+
341
+ from vllm.model_executor.kernels.linear.mixed_precision.MPLinearKernel import (
342
+ MPLinearLayerConfig,
343
+ )
344
+ from vllm.scalar_type import scalar_types
345
+
346
+ set_random_seed(0)
347
+
348
+ K, N = 256, 128
349
+ G = group_size
350
+ assert K % G == 0 and N % 8 == 0
351
+
352
+ w_int4_kn = torch.randint(0, 16, (K, N), device=device, dtype=torch.int32)
353
+ w_ckpt_nk8 = _pack_int4_along_k_to_ckpt(w_int4_kn)
354
+ scales_ckpt_nkg = 0.05 * torch.rand((N, K // G), device=device, dtype=torch.float16)
355
+
356
+ # CT zero-point layout is N-packed: [N//8, K//G] int32.
357
+ zeros_int4_gn = torch.randint(0, 16, (K // G, N), device=device, dtype=torch.int32)
358
+ zeros_packed_gn8 = _pack_int4_along_n_for_zp(zeros_int4_gn) # [K//G, N//8]
359
+ zeros_ckpt_n8kg = zeros_packed_gn8.t().contiguous() # [N//8, K//G]
360
+
361
+ layer = _build_dummy_layer(w_ckpt_nk8, scales_ckpt_nkg, zeros_ckpt=zeros_ckpt_n8kg)
362
+
363
+ config = MPLinearLayerConfig(
364
+ full_weight_shape=(K, N),
365
+ partition_weight_shape=(K, N),
366
+ weight_type=scalar_types.uint4,
367
+ act_type=torch.float16,
368
+ group_size=G,
369
+ zero_points=True,
370
+ has_g_idx=False,
371
+ )
372
+ kernel = RDNAHybridW4A16LinearKernel(
373
+ config,
374
+ w_q_param_name="weight_packed",
375
+ w_s_param_name="weight_scale",
376
+ w_zp_param_name="weight_zero_point",
377
+ w_gidx_param_name=None,
378
+ )
379
+ kernel.process_weights_after_loading(layer)
380
+
381
+ # Zero-points: unpacked to [N, K//G], cast to act dtype, raw values [0..15].
382
+ assert layer.weight_zero_point.dtype == torch.float16
383
+ assert tuple(layer.weight_zero_point.shape) == (N, K // G)
384
+ expected_zp = zeros_int4_gn.t().to(torch.float16) # [N, K//G]
385
+ torch.testing.assert_close(layer.weight_zero_point, expected_zp)
386
+
387
+ # Quantized weights match symmetric path's layout regardless of zp.
388
+ w_q_i32 = layer.weight_packed.view(torch.int32)
389
+ assert tuple(w_q_i32.shape) == (N, K // 8)
390
+ expected_packed = pack_int4_exllama_shuffle(w_int4_kn.t().contiguous())
391
+ torch.testing.assert_close(w_q_i32, expected_packed)
392
+
393
+
394
+# ---------------------------------------------------------------------------
395
+# can_implement enforces the supported-group-size policy
396
+# ---------------------------------------------------------------------------
397
+
398
+
399
+@pytest.mark.skipif(not on_gfx1x(), reason="Hybrid path is gfx11/gfx12 only")
400
+@pytest.mark.parametrize(
401
+ "group_size,expected_ok", [(32, True), (64, True), (128, True), (256, False)]
402
+)
403
+def test_hybrid_can_implement_group_size(group_size, expected_ok):
404
+ from vllm.model_executor.kernels.linear.mixed_precision.MPLinearKernel import (
405
+ MPLinearLayerConfig,
406
+ )
407
+ from vllm.scalar_type import scalar_types
408
+
409
+ K, N = 1024, 256
410
+ config = MPLinearLayerConfig(
411
+ full_weight_shape=(K, N),
412
+ partition_weight_shape=(K, N),
413
+ weight_type=scalar_types.uint4b8,
414
+ act_type=torch.float16,
415
+ group_size=group_size,
416
+ zero_points=False,
417
+ has_g_idx=False,
418
+ )
419
+ ok, _ = RDNAHybridW4A16LinearKernel.can_implement(config)
420
+ assert ok is expected_ok
421
+
422
+
423
+# ---------------------------------------------------------------------------
424
+# Tests for the HIP wvSplitK_int4_g decode kernel
425
+# ---------------------------------------------------------------------------
426
+
427
+
428
+def _hip_skinny_reference(
429
+ a_mk: torch.Tensor,
430
+ w_int4_nk: torch.Tensor,
431
+ scales_nkg: torch.Tensor,
432
+ *,
433
+ group_size: int,
434
+ zp_bias: int,
435
+) -> torch.Tensor:
436
+ """Reference for symmetric HIP skinny: C = A @ (W - zp_bias) * S."""
437
+ K = a_mk.shape[1]
438
+ N = w_int4_nk.shape[0]
439
+ num_groups = K // group_size
440
+
441
+ w_fp = (w_int4_nk.to(torch.float32) - zp_bias).view(N, num_groups, group_size)
442
+ s = scales_nkg.to(torch.float32).unsqueeze(-1)
443
+ w_dequant = (w_fp * s).view(N, K)
444
+
445
+ return (a_mk.to(torch.float32) @ w_dequant.t()).to(a_mk.dtype)
446
+
447
+
448
+@pytest.mark.skipif(not on_gfx1x(), reason="Hybrid path is gfx11/gfx12 only")
449
+@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16])
450
+@pytest.mark.parametrize(
451
+ "M,K,N,G",
452
+ [
453
+ (1, 256, 256, 32),
454
+ (1, 256, 256, 64),
455
+ (1, 512, 256, 128),
456
+ (2, 512, 256, 64),
457
+ (3, 256, 512, 64),
458
+ ],
459
+)
460
+def test_hip_skinny_wvSplitK_int4_g(dtype, M, K, N, G):
461
+ """Test HIP wvSplitK_int4_g kernel directly via _custom_ops."""
462
+ import vllm._custom_ops as ops
463
+ from vllm.utils.platform_utils import num_compute_units
464
+
465
+ set_random_seed(0)
466
+
467
+ a = (0.25 * torch.randn((M, K), device=device, dtype=torch.float32)).to(dtype)
468
+ w_int4_nk = torch.randint(0, 16, (N, K), device=device, dtype=torch.int32)
469
+
470
+ b_packed_i32 = pack_int4_exllama_shuffle(w_int4_nk)
471
+ b_packed_i8 = b_packed_i32.view(torch.int8)
472
+
473
+ scales = (0.05 * torch.rand((N, K // G), device=device, dtype=torch.float32)).to(
474
+ dtype
475
+ )
476
+
477
+ cu_count = num_compute_units()
478
+ out = ops.wvSplitK_int4_g(b_packed_i8, a, scales, cu_count, G)
479
+
480
+ ref = _hip_skinny_reference(a, w_int4_nk, scales, group_size=G, zp_bias=8)
481
+
482
+ torch.testing.assert_close(out, ref, rtol=1e-2, atol=5e-2)
483
+
484
+
485
+# ---------------------------------------------------------------------------
486
+# Tests for the full hybrid dispatch (HIP decode + Triton prefill)
487
+# ---------------------------------------------------------------------------
488
+
489
+
490
+@pytest.mark.skipif(not on_gfx1x(), reason="Hybrid path is gfx11/gfx12 only")
491
+@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16])
492
+@pytest.mark.parametrize(
493
+ "M,K,N,G",
494
+ [
495
+ (1, 256, 256, 64),
496
+ (1, 512, 256, 32),
497
+ (1, 512, 256, 128),
498
+ (32, 512, 256, 64),
499
+ (64, 1024, 256, 128),
500
+ ],
501
+)
502
+def test_rdna_hybrid_w4a16_dispatch(dtype, M, K, N, G):
503
+ """Test the full hybrid dispatch via the custom op."""
504
+ from vllm.utils.platform_utils import num_compute_units
505
+
506
+ set_random_seed(0)
507
+
508
+ a = (0.25 * torch.randn((M, K), device=device, dtype=torch.float32)).to(dtype)
509
+ w_int4_nk = torch.randint(0, 16, (N, K), device=device, dtype=torch.int32)
510
+
511
+ b_packed_i32 = pack_int4_exllama_shuffle(w_int4_nk)
512
+ b_packed_i8 = b_packed_i32.view(torch.int8)
513
+
514
+ scales = (0.05 * torch.rand((N, K // G), device=device, dtype=torch.float32)).to(
515
+ dtype
516
+ )
517
+
518
+ cu_count = num_compute_units()
519
+ out = torch.ops.vllm.rdna_hybrid_w4a16_apply(
520
+ a, b_packed_i8, scales, None, None, cu_count, G
521
+ )
522
+
523
+ ref = _hip_skinny_reference(a, w_int4_nk, scales, group_size=G, zp_bias=8)
524
+
525
+ torch.testing.assert_close(out, ref, rtol=1e-2, atol=5e-2)
tests/kernels/quantization/test_w4a16_kernel_selection.py
+24
-5
@@ -16,9 +16,28 @@ from vllm.model_executor.kernels.linear import (
16
from vllm.platforms import current_platform
17
from vllm.scalar_type import scalar_types
18
19
+if current_platform.is_rocm():
20
+ from vllm.platforms.rocm import on_gfx1x, on_gfx1100
21
+else:
22
+ on_gfx1100 = on_gfx1x = lambda: False # noqa: E731
23
+
24
+# Group sizes the HIP skinny path of RDNAHybridW4A16LinearKernel instantiates.
25
+_HYBRID_GROUP_SIZES = (32, 64, 128)
26
+
27
+
28
+def _expected_rocm_kernel(weight_type, group_size: int) -> str:
29
+ """Mirror the ROCm priority order in ``_POSSIBLE_KERNELS[ROCM]``:
30
+ RDNA3 (gfx1100, symmetric uint4b8) -> Hybrid (gfx11/gfx12) -> Triton.
31
+ """
32
+ if on_gfx1100() and weight_type == scalar_types.uint4b8:
33
+ return "RDNA3W4A16LinearKernel"
34
+ if on_gfx1x() and group_size in _HYBRID_GROUP_SIZES:
35
+ return "RDNAHybridW4A16LinearKernel"
36
+ return "TritonW4A16LinearKernel"
37
+
38
39
@pytest.mark.skipif(not current_platform.is_rocm(), reason="ROCm only")
21
-def test_choose_mp_linear_kernel_picks_triton_w4a16_for_uint4b8():
40
+def test_choose_mp_linear_kernel_uint4b8():
41
# int4 weights, 16-bit activations (CT W4A16 typical config).
42
K, N = 1024, 256
43
config = MPLinearLayerConfig(
@@ -32,12 +51,12 @@ def test_choose_mp_linear_kernel_picks_triton_w4a16_for_uint4b8():
51
)
52
53
kernel_type = choose_mp_linear_kernel(config)
35
- assert kernel_type.__name__ == "TritonW4A16LinearKernel"
54
+ assert kernel_type.__name__ == _expected_rocm_kernel(scalar_types.uint4b8, 128)
55
56
57
@pytest.mark.skipif(not current_platform.is_rocm(), reason="ROCm only")
39
-def test_choose_mp_linear_kernel_picks_triton_w4a16_for_uint4_asymmetric():
40
- # Asymmetric int4 weights should also be supported (explicit zero points).
58
+def test_choose_mp_linear_kernel_uint4_asymmetric():
59
+ # Asymmetric int4 weights (explicit zero points).
60
K, N = 512, 512
61
config = MPLinearLayerConfig(
62
full_weight_shape=(K, N),
@@ -50,4 +69,4 @@ def test_choose_mp_linear_kernel_picks_triton_w4a16_for_uint4_asymmetric():
69
)
70
71
kernel_type = choose_mp_linear_kernel(config)
53
- assert kernel_type.__name__ == "TritonW4A16LinearKernel"
72
+ assert kernel_type.__name__ == _expected_rocm_kernel(scalar_types.uint4, 64)
vllm/_custom_ops.py
+36
@@ -2077,6 +2077,42 @@ def wvSplitK(
2077
return torch.ops._rocm_C.wvSplitK(a, b, bias, cu_count)
2078
2079
2080
+def wvSplitK_int4_g(
2081
+ weight: torch.Tensor,
2082
+ activation: torch.Tensor,
2083
+ scale: torch.Tensor,
2084
+ cu_count: int,
2085
+ group_size: int,
2086
+ zero_points: torch.Tensor | None = None,
2087
+ bias: torch.Tensor | None = None,
2088
+) -> torch.Tensor:
2089
+ # NOTE: the kernel is weight-major; `weight` is the packed int4 operand
2090
+ # (in_a, [out_features, K/2]) and `activation` is in_b ([num_tokens, K]).
2091
+ return torch.ops._rocm_C.wvSplitK_int4_g(
2092
+ weight, activation, scale, zero_points, bias, cu_count, group_size
2093
+ )
2094
+
2095
+
2096
+if hasattr(torch.ops, "_rocm_C") and hasattr(torch.ops._rocm_C, "wvSplitK_int4_g"):
2097
+
2098
+ @register_fake("_rocm_C::wvSplitK_int4_g")
2099
+ def _wvSplitK_int4_g_fake(
2100
+ in_a: torch.Tensor, # packed int4 weight [out_features, K/2]
2101
+ in_b: torch.Tensor, # activation [num_tokens, K]
2102
+ in_scale: torch.Tensor,
2103
+ in_zero_points: torch.Tensor | None,
2104
+ in_bias: torch.Tensor | None,
2105
+ CuCount: int,
2106
+ group_size: int,
2107
+ ) -> torch.Tensor:
2108
+ # Kernel returns {in_b.size(0), in_a.size(0)} = [num_tokens, out_features].
2109
+ num_tokens = in_b.size(0)
2110
+ out_features = in_a.size(0)
2111
+ return torch.empty(
2112
+ (num_tokens, out_features), dtype=in_b.dtype, device=in_b.device
2113
+ )
2114
+
2115
+
2116
def wvSplitKrc(
2117
a: torch.Tensor, b: torch.Tensor, cu_count: int, bias: torch.Tensor = None
2118
) -> torch.Tensor:
vllm/model_executor/kernels/linear/__init__.py
+5
@@ -57,6 +57,9 @@ from vllm.model_executor.kernels.linear.mixed_precision.marlin import (
57
from vllm.model_executor.kernels.linear.mixed_precision.rdna3_w4a16 import (
58
RDNA3W4A16LinearKernel,
59
)
60
+from vllm.model_executor.kernels.linear.mixed_precision.rdna_hybrid_w4a16 import (
61
+ RDNAHybridW4A16LinearKernel,
62
+)
63
from vllm.model_executor.kernels.linear.mixed_precision.triton_w4a16 import (
64
TritonW4A16LinearKernel,
65
)
@@ -403,6 +406,7 @@ _POSSIBLE_KERNELS: dict[PlatformEnum, list[type[MPLinearKernel]]] = {
406
],
407
PlatformEnum.ROCM: [
408
RDNA3W4A16LinearKernel,
409
+ RDNAHybridW4A16LinearKernel,
410
TritonW4A16LinearKernel,
411
ConchLinearKernel,
412
ExllamaLinearKernel,
@@ -1063,6 +1067,7 @@ __all__ = [
1067
"CutlassW4A8LinearKernel",
1068
"Dynamic4bitLinearKernel",
1069
"ExllamaLinearKernel",
1070
+ "RDNAHybridW4A16LinearKernel",
1071
"MacheteLinearKernel",
1072
"MarlinLinearKernel",
1073
"TritonW4A16LinearKernel",
vllm/model_executor/kernels/linear/mixed_precision/__init__.py
+4
@@ -35,6 +35,9 @@ from vllm.model_executor.kernels.linear.mixed_precision.MPLinearKernel import (
35
from vllm.model_executor.kernels.linear.mixed_precision.rdna3_w4a16 import (
36
RDNA3W4A16LinearKernel,
37
)
38
+from vllm.model_executor.kernels.linear.mixed_precision.rdna_hybrid_w4a16 import (
39
+ RDNAHybridW4A16LinearKernel,
40
+)
41
from vllm.model_executor.kernels.linear.mixed_precision.triton_w4a16 import (
42
TritonW4A16LinearKernel,
43
)
@@ -59,6 +62,7 @@ __all__ = [
62
"MacheteLinearKernel",
63
"MarlinLinearKernel",
64
"RDNA3W4A16LinearKernel",
65
+ "RDNAHybridW4A16LinearKernel",
66
"TritonW4A16LinearKernel",
67
"XPUW4A8IntLinearKernel",
68
"XPUwNa16LinearKernel",
vllm/model_executor/kernels/linear/mixed_precision/rdna_hybrid_w4a16.py
new
+560
@@ -0,0 +1,560 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""
4
+Hybrid W4A16 kernel: Triton for prefill, HIP skinny for decode.
5
+
6
+Routes based on batch size M:
7
+ M <= MAX_SKINNY_BATCH_SIZE: HIP skinny GEMM (wvSplitK_int4_g)
8
+ M > MAX_SKINNY_BATCH_SIZE: Triton W4A16 fused dequant GEMM
9
+
10
+Stores the weights ONCE as int8 [N, K//2] (ExLlama shuffle packed). Both
11
+paths read this single buffer: the HIP skinny kernel uses it directly, and
12
+the triton kernel reinterprets it as int32 [N, K//8] via a view (and
13
+transposes tiles in-register). No dual weight storage.
14
+"""
15
+
16
+from contextlib import nullcontext
17
+
18
+import torch
19
+
20
+from vllm.model_executor.layers.quantization.utils.quant_utils import (
21
+ unpack_quantized_values_into_int32,
22
+)
23
+from vllm.model_executor.parameter import (
24
+ permute_param_layout_,
25
+)
26
+from vllm.platforms import current_platform
27
+from vllm.scalar_type import scalar_types
28
+from vllm.triton_utils import tl, triton
29
+from vllm.utils.torch_utils import direct_register_custom_op
30
+
31
+from .MPLinearKernel import MPLinearKernel, MPLinearLayerConfig
32
+
33
+SUPPORTED_GROUP_SIZES = [32, 64, 128]
34
+
35
+
36
+def _on_gfx12x() -> bool:
37
+ if not current_platform.is_rocm():
38
+ return False
39
+ from vllm.platforms.rocm import on_gfx12x
40
+
41
+ return on_gfx12x()
42
+
43
+
44
+def _on_gfx1x() -> bool:
45
+ if not current_platform.is_rocm():
46
+ return False
47
+ from vllm.platforms.rocm import on_gfx1x
48
+
49
+ return on_gfx1x()
50
+
51
+
52
+def _on_gfx1151() -> bool:
53
+ if not current_platform.is_rocm():
54
+ return False
55
+ from vllm.platforms.rocm import on_gfx1151
56
+
57
+ return on_gfx1151()
58
+
59
+
60
+# Maximum batch size M for the HIP skinny kernel path (C++ supports N_in
61
+# up to 5). When M is below this AND K*M fits in LDS, the skinny kernel is
62
+# used; otherwise the Triton prefill path handles the GEMM.
63
+MAX_SKINNY_BATCH_SIZE = 5
64
+# 64 KiB per-workgroup LDS limit expressed in fp16 elements.
65
+# (AMD RDNA has 128 KiB total LDS per CU, but 64 KiB per workgroup.)
66
+LDS_CAPACITY_ELEMENTS = 64 * 1024 // 2 # 32768 fp16 elements
67
+
68
+
69
+# ---------------------------------------------------------------------------
70
+# Triton kernel for the prefill path (reads skinny-format weights [N, K//8])
71
+# ---------------------------------------------------------------------------
72
+
73
+
74
+@triton.jit
75
+def _triton_w4a16_skinny_fmt_kernel(
76
+ # Pointers
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)
81
+ c_ptr, # [M, N] fp16/bf16 output
82
+ # Dimensions
83
+ M,
84
+ N,
85
+ K,
86
+ K8, # K // 8
87
+ num_groups, # K // group_size
88
+ # Quantization parameters
89
+ group_size,
90
+ ZP_BIAS: tl.constexpr,
91
+ HAS_ZP: tl.constexpr,
92
+ # Block sizes
93
+ BLOCK_M: tl.constexpr,
94
+ BLOCK_N: tl.constexpr,
95
+ BLOCK_K: tl.constexpr,
96
+):
97
+ """
98
+ Fused W4A16 GEMM reading weights from skinny format [N, K//8].
99
+
100
+ B is stored as [N, K//8] int32 using ExLlama shuffle packing:
101
+ each int32 packs 8 K-values with interleave [0,2,4,6,1,3,5,7]:
102
+ packed = val[0] | (val[2]<<4) | (val[4]<<8) | (val[6]<<12)
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.
108
+ When HAS_ZP=False, only the constant ZP_BIAS is subtracted (symmetric).
109
+ """
110
+ pid_m = tl.program_id(0)
111
+ pid_n = tl.program_id(1)
112
+
113
+ offs_m = pid_m * BLOCK_M + tl.arange(0, BLOCK_M)
114
+ offs_n = pid_n * BLOCK_N + tl.arange(0, BLOCK_N)
115
+
116
+ # ExLlama unshuffle shifts: shift[j] = (j//2)*4 + (j%2)*16
117
+ # For 8 values: [0, 16, 4, 20, 8, 24, 12, 28]
118
+ exllama_shifts_row = (tl.arange(0, 8) // 2) * 4 + (tl.arange(0, 8) % 2) * 16
119
+ shifts_1d = tl.reshape(
120
+ tl.broadcast_to(exllama_shifts_row[None, :], (BLOCK_K // 8, 8)),
121
+ (BLOCK_K,),
122
+ )
123
+ shifts_full = tl.broadcast_to(shifts_1d[None, :], (BLOCK_N, BLOCK_K))
124
+
125
+ accumulator = tl.zeros((BLOCK_M, BLOCK_N), dtype=tl.float32)
126
+
127
+ for k_start in range(0, tl.cdiv(K, BLOCK_K)):
128
+ offs_k = k_start * BLOCK_K + tl.arange(0, BLOCK_K)
129
+ mask_k = offs_k < K
130
+
131
+ a_ptrs = a_ptr + offs_m[:, None] * K + offs_k[None, :]
132
+ mask_a = (offs_m[:, None] < M) & mask_k[None, :]
133
+ a = tl.load(a_ptrs, mask=mask_a, other=0.0)
134
+
135
+ offs_k8 = k_start * (BLOCK_K // 8) + tl.arange(0, BLOCK_K // 8)
136
+ b_ptrs = b_ptr + offs_n[:, None] * K8 + offs_k8[None, :]
137
+ mask_b = (offs_n[:, None] < N) & (offs_k8[None, :] < K8)
138
+ b_packed = tl.load(b_ptrs, mask=mask_b, other=0)
139
+
140
+ b = tl.interleave(b_packed, b_packed)
141
+ b = tl.interleave(b, b)
142
+ b = tl.interleave(b, b)
143
+ b = (b >> shifts_full) & 0xF
144
+
145
+ g_idx = (k_start * BLOCK_K) // group_size
146
+ scale_ptrs = scales_ptr + offs_n * num_groups + g_idx
147
+ scale_mask = offs_n < N
148
+ scales = tl.load(scale_ptrs, mask=scale_mask, other=1.0)
149
+
150
+ if HAS_ZP:
151
+ zp_ptrs = zp_ptr + offs_n * num_groups + g_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]
154
+ else:
155
+ b_fp = (b - ZP_BIAS).to(scales.dtype) * scales[:, None]
156
+
157
+ b_fp_t = tl.trans(b_fp)
158
+ accumulator += tl.dot(a, b_fp_t, out_dtype=tl.float32)
159
+
160
+ c = accumulator.to(c_ptr.type.element_ty)
161
+ c_ptrs = c_ptr + offs_m[:, None] * N + offs_n[None, :]
162
+ mask_c = (offs_m[:, None] < M) & (offs_n[None, :] < N)
163
+ tl.store(c_ptrs, c, mask=mask_c)
164
+
165
+
166
+# Per-shape (group_size, K, N) -> (BLOCK_M, BLOCK_N, BLOCK_K, num_warps,
167
+# num_stages) tile-config overrides for prefill (M <= 128) on gfx1x.
168
+# Picked by sweeping benchmarks/kernels/benchmark_rdna_hybrid_w4a16_gemm.py + a
169
+# per-config sweep script; only added when better than the generic heuristic
170
+# by > 20% at M=128. Re-run benchmarks after edits.
171
+_GFX1X_PREFILL_OVERRIDES: dict[tuple[int, int, int], tuple[int, int, int, int, int]] = {
172
+ # SmolLM2-1.7B-Instruct-AWQ (gs=32, K=2048; gs forces BLOCK_K to 32 so
173
+ # widen BLOCK_M and let Triton pipeline 4 stages to amortize the small
174
+ # K-tile).
175
+ (32, 2048, 6144): (128, 32, 32, 4, 4), # qkv_proj
176
+ (32, 2048, 2048): (128, 32, 32, 4, 4), # o_proj
177
+ (32, 2048, 16384): (128, 32, 32, 4, 4), # gate_up_proj
178
+ (32, 8192, 2048): (128, 64, 32, 8, 2), # down_proj
179
+ # Qwen3-8B-quantized.w4a16 (gs=128, K=4096 / 12288). For these K's a
180
+ # full BLOCK_K=group_size with no software pipelining beats the generic
181
+ # 64x64x64 — single-stage keeps register pressure down.
182
+ (128, 4096, 6144): (128, 64, 128, 8, 1), # qkv_proj
183
+ (128, 4096, 4096): (128, 32, 128, 4, 1), # o_proj
184
+ (128, 4096, 24576): (64, 32, 128, 2, 1), # gate_up_proj
185
+ (128, 12288, 4096): (128, 64, 128, 8, 1), # down_proj
186
+}
187
+
188
+
189
+def triton_w4a16_skinny_fmt_gemm(
190
+ a: torch.Tensor, # [M, K] fp16/bf16
191
+ b_q: torch.Tensor, # [N, K//8] int32 (ExLlama shuffle packed)
192
+ scales: torch.Tensor, # [N, K//G] fp16/bf16
193
+ group_size: int,
194
+ zp_bias: int = 8,
195
+ zp: torch.Tensor | None = None, # [N, K//G] per-group zero-points
196
+) -> torch.Tensor:
197
+ """
198
+ Fused W4A16 GEMM reading from skinny weight format [N, K//8].
199
+
200
+ Args:
201
+ a: Activation matrix [M, K], float16 or bfloat16.
202
+ b_q: Packed weight matrix [N, K//8], int32 (ExLlama shuffle).
203
+ scales: Per-group scales [N, K//G], same dtype as a.
204
+ group_size: Quantization group size (resolved from -1 to K by caller).
205
+ 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
+ dequant is (nibble - zp_raw) * scale.
209
+
210
+ Returns:
211
+ Output matrix [M, N], same dtype as a.
212
+ """
213
+ assert a.is_contiguous(), "Activation matrix must be contiguous"
214
+ assert b_q.is_contiguous(), "Weight matrix must be contiguous"
215
+ assert scales.is_contiguous(), "Scales must be contiguous"
216
+
217
+ M, K = a.shape
218
+ N = b_q.shape[0]
219
+ K8 = K // 8
220
+ num_groups = K // group_size
221
+
222
+ assert b_q.shape == (N, K8), f"b_q shape mismatch: {b_q.shape} vs ({N}, {K8})"
223
+ assert scales.shape == (N, num_groups), (
224
+ f"scales shape mismatch: {scales.shape} vs ({N}, {num_groups})"
225
+ )
226
+ if zp is not None:
227
+ 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
+ )
231
+ has_zp = zp is not None
232
+
233
+ c = torch.empty((M, N), dtype=a.dtype, device=a.device)
234
+
235
+ # num_stages stays None unless a per-shape override sets it, so the
236
+ # generic heuristics fall back to Triton's default pipeline depth.
237
+ num_stages: int | None = None
238
+ if _on_gfx12x():
239
+ # Tuned on gfx1201 (Radeon AI PRO R9700, 32 CUs, 32-wide wavefronts)
240
+ # using Llama-3.1-8B AWQ weight shapes with group_size=128.
241
+ if M <= 32:
242
+ BLOCK_M, BLOCK_N, BLOCK_K, num_warps = 16, 16, 128, 4
243
+ elif M <= 64:
244
+ if K >= 2 * N: # tall K (e.g. down_proj)
245
+ BLOCK_M, BLOCK_N, BLOCK_K, num_warps = 64, 32, 128, 8
246
+ elif N > K: # wide N (e.g. qkv_proj, gate_up_proj)
247
+ BLOCK_M, BLOCK_N, BLOCK_K, num_warps = 64, 32, 64, 8
248
+ else: # N ~= K (e.g. o_proj)
249
+ BLOCK_M, BLOCK_N, BLOCK_K, num_warps = 32, 64, 128, 4
250
+ elif M <= 128:
251
+ if K >= 2 * N: # tall K (e.g. down_proj)
252
+ BLOCK_M, BLOCK_N, BLOCK_K, num_warps = 64, 16, 64, 1
253
+ elif N >= 2 * K: # very wide N (e.g. gate_up_proj)
254
+ BLOCK_M, BLOCK_N, BLOCK_K, num_warps = 64, 128, 64, 8
255
+ else: # N ~= K (e.g. o_proj, qkv_proj)
256
+ BLOCK_M, BLOCK_N, BLOCK_K, num_warps = 64, 64, 64, 8
257
+ elif M <= 512:
258
+ if K >= 2 * N: # tall K (e.g. down_proj)
259
+ BLOCK_M, BLOCK_N, BLOCK_K, num_warps = 128, 64, 64, 8
260
+ elif N >= 4 * K: # very wide N (e.g. gate_up_proj)
261
+ BLOCK_M, BLOCK_N, BLOCK_K, num_warps = 128, 128, 64, 8
262
+ else:
263
+ BLOCK_M, BLOCK_N, BLOCK_K, num_warps = 64, 128, 64, 8
264
+ else:
265
+ if K >= 2 * N: # tall K (e.g. down_proj)
266
+ BLOCK_M, BLOCK_N, BLOCK_K, num_warps = 128, 64, 64, 8
267
+ elif N >= 4 * K: # very wide N (e.g. gate_up_proj)
268
+ BLOCK_M, BLOCK_N, BLOCK_K, num_warps = 256, 64, 64, 8
269
+ else:
270
+ BLOCK_M, BLOCK_N, BLOCK_K, num_warps = 128, 128, 32, 8
271
+ elif _on_gfx1151():
272
+ # Tuned on gfx1151 (Strix Halo, 40 CUs, 32-wide wavefronts)
273
+ # using Qwen3-4B weight shapes with group_size=128.
274
+ # Per-shape overrides for known prefill regressions live in a small
275
+ # lookup table — see _GFX1X_PREFILL_OVERRIDES below. Re-run
276
+ # benchmarks/kernels/benchmark_rdna_hybrid_w4a16_gemm.py after edits.
277
+ override = (
278
+ _GFX1X_PREFILL_OVERRIDES.get((group_size, K, N)) if M <= 128 else None
279
+ )
280
+ if override is not None:
281
+ BLOCK_M, BLOCK_N, BLOCK_K, num_warps, num_stages = override
282
+ elif M <= 32:
283
+ BLOCK_M, BLOCK_N, BLOCK_K, num_warps = 32, 32, 128, 4
284
+ elif M <= 64:
285
+ BLOCK_M, BLOCK_N, BLOCK_K, num_warps = 64, 64, 32, 4
286
+ elif M <= 128:
287
+ if K >= 2 * N: # tall K (e.g. down_proj)
288
+ BLOCK_M, BLOCK_N, BLOCK_K, num_warps = 64, 16, 64, 1
289
+ elif N > K: # wide N (e.g. qkv_proj, gate_up_proj)
290
+ BLOCK_M, BLOCK_N, BLOCK_K, num_warps = 64, 64, 64, 4
291
+ else: # N ~= K (e.g. o_proj)
292
+ BLOCK_M, BLOCK_N, BLOCK_K, num_warps = 64, 32, 64, 4
293
+ elif M <= 1024:
294
+ if K >= 2 * N: # tall K (e.g. down_proj)
295
+ BLOCK_M, BLOCK_N, BLOCK_K, num_warps = 64, 64, 64, 4
296
+ elif N >= 4 * K: # very wide N (e.g. gate_up_proj)
297
+ BLOCK_M, BLOCK_N, BLOCK_K, num_warps = 128, 64, 64, 8
298
+ else:
299
+ BLOCK_M, BLOCK_N, BLOCK_K, num_warps = 64, 128, 32, 4
300
+ else:
301
+ if K >= 2 * N: # tall K (e.g. down_proj)
302
+ BLOCK_M, BLOCK_N, BLOCK_K, num_warps = 128, 512, 32, 16
303
+ else:
304
+ BLOCK_M, BLOCK_N, BLOCK_K, num_warps = 128, 64, 64, 8
305
+ else:
306
+ num_warps = 4
307
+ if M <= 32:
308
+ BLOCK_M, BLOCK_N, BLOCK_K = 32, 64, 32
309
+ elif M <= 64:
310
+ BLOCK_M, BLOCK_N, BLOCK_K = 64, 64, 32
311
+ else:
312
+ BLOCK_M, BLOCK_N, BLOCK_K = 128, 128, 32
313
+
314
+ # The kernel loads one scale per BLOCK_K tile, so BLOCK_K must not
315
+ # exceed group_size — otherwise elements in the tile that belong to
316
+ # a different group would get the wrong scale.
317
+ BLOCK_K = min(BLOCK_K, group_size)
318
+
319
+ grid = (triton.cdiv(M, BLOCK_M), triton.cdiv(N, BLOCK_N))
320
+
321
+ extra_kwargs = {} if num_stages is None else {"num_stages": num_stages}
322
+ _triton_w4a16_skinny_fmt_kernel[grid](
323
+ a,
324
+ b_q,
325
+ scales,
326
+ zp if has_zp else scales, # dummy pointer when no zp (unused)
327
+ c,
328
+ M,
329
+ N,
330
+ K,
331
+ K8,
332
+ num_groups,
333
+ group_size=group_size,
334
+ ZP_BIAS=zp_bias,
335
+ HAS_ZP=has_zp,
336
+ BLOCK_M=BLOCK_M,
337
+ BLOCK_N=BLOCK_N,
338
+ BLOCK_K=BLOCK_K,
339
+ num_warps=num_warps,
340
+ **extra_kwargs,
341
+ )
342
+ return c
343
+
344
+
345
+# ---------------------------------------------------------------------------
346
+# Weight packing
347
+# ---------------------------------------------------------------------------
348
+
349
+
350
+def pack_int4_exllama_shuffle(w_uint4: torch.Tensor) -> torch.Tensor:
351
+ """Pack uint4 values into ExLlama shuffle format: [N, K] -> [N, K//8] int32.
352
+
353
+ Each int32 packs 8 K-values with interleave order [0,2,4,6,1,3,5,7].
354
+ """
355
+ N_dim, K_dim = w_uint4.shape
356
+ assert K_dim % 8 == 0
357
+ g = w_uint4.to(torch.uint8).view(N_dim, K_dim // 8, 8).to(torch.int32)
358
+ return (
359
+ g[:, :, 0]
360
+ | (g[:, :, 2] << 4)
361
+ | (g[:, :, 4] << 8)
362
+ | (g[:, :, 6] << 12)
363
+ | (g[:, :, 1] << 16)
364
+ | (g[:, :, 3] << 20)
365
+ | (g[:, :, 5] << 24)
366
+ | (g[:, :, 7] << 28)
367
+ )
368
+
369
+
370
+# ---------------------------------------------------------------------------
371
+# Hybrid dispatch logic
372
+# ---------------------------------------------------------------------------
373
+
374
+
375
+def _rdna_hybrid_w4a16_apply_impl(
376
+ x_2d: torch.Tensor,
377
+ w_q: torch.Tensor,
378
+ w_s: torch.Tensor,
379
+ w_zp: torch.Tensor | None,
380
+ bias: torch.Tensor | None,
381
+ cu_count: int,
382
+ group_size: int,
383
+) -> torch.Tensor:
384
+ """Dispatch between skinny GEMM and Triton based on batch size M."""
385
+ import vllm._custom_ops as ops
386
+
387
+ M = x_2d.shape[0]
388
+ K = x_2d.shape[1]
389
+ N = w_q.shape[0]
390
+
391
+ if M <= MAX_SKINNY_BATCH_SIZE and K * M <= LDS_CAPACITY_ELEMENTS:
392
+ # record_function is not torch.compile-safe; use nullcontext when
393
+ # compiling to keep the op traceable.
394
+ ctx = (
395
+ nullcontext()
396
+ if torch.compiler.is_compiling()
397
+ else torch.profiler.record_function(f"wvsplitk_int4 {M}x{N}x{K}")
398
+ )
399
+ with ctx:
400
+ return ops.wvSplitK_int4_g(w_q, x_2d, w_s, cu_count, group_size, w_zp, bias)
401
+
402
+ ctx = (
403
+ nullcontext()
404
+ if torch.compiler.is_compiling()
405
+ else torch.profiler.record_function(f"hybrid_triton_w4a16 {M}x{N}x{K}")
406
+ )
407
+ with ctx:
408
+ output = triton_w4a16_skinny_fmt_gemm(
409
+ a=x_2d,
410
+ b_q=w_q.view(torch.int32),
411
+ scales=w_s,
412
+ group_size=group_size,
413
+ zp=w_zp,
414
+ )
415
+ if bias is not None:
416
+ output.add_(bias)
417
+ return output
418
+
419
+
420
+def _rdna_hybrid_w4a16_apply_fake(
421
+ x_2d: torch.Tensor,
422
+ w_q: torch.Tensor,
423
+ w_s: torch.Tensor,
424
+ w_zp: torch.Tensor | None,
425
+ bias: torch.Tensor | None,
426
+ cu_count: int,
427
+ group_size: int,
428
+) -> torch.Tensor:
429
+ M = x_2d.size(0)
430
+ N = w_q.size(0)
431
+ return torch.empty((M, N), dtype=x_2d.dtype, device=x_2d.device)
432
+
433
+
434
+direct_register_custom_op(
435
+ op_name="rdna_hybrid_w4a16_apply",
436
+ op_func=_rdna_hybrid_w4a16_apply_impl,
437
+ mutates_args=[],
438
+ fake_impl=_rdna_hybrid_w4a16_apply_fake,
439
+)
440
+
441
+
442
+class RDNAHybridW4A16LinearKernel(MPLinearKernel):
443
+ """Hybrid W4A16 kernel: HIP skinny for decode, Triton for prefill.
444
+
445
+ Stores the weights once as int8 [N, K//2] (ExLlama shuffle packed). The
446
+ HIP skinny kernel reads it directly; the triton kernel reinterprets the
447
+ same buffer as int32 [N, K//8] via a view, so there is no dual weight
448
+ storage.
449
+ """
450
+
451
+ SUPPORTED_QUANT_TYPES = [
452
+ scalar_types.uint4b8, # symmetric GPTQ (bias=8)
453
+ scalar_types.uint4, # asymmetric (zero_points)
454
+ ]
455
+
456
+ @classmethod
457
+ def get_min_capability(cls) -> int:
458
+ # Arch filtering is handled by can_implement (_on_gfx1x check)
459
+ return 0
460
+
461
+ @classmethod
462
+ def can_implement(cls, c: MPLinearLayerConfig) -> tuple[bool, str | None]:
463
+ if not current_platform.is_rocm():
464
+ return False, "RDNAHybridW4A16LinearKernel only targets ROCm"
465
+
466
+ if not _on_gfx1x():
467
+ return False, "RDNAHybridW4A16LinearKernel only targets gfx11/gfx12"
468
+
469
+ if c.weight_type not in cls.SUPPORTED_QUANT_TYPES:
470
+ return (
471
+ False,
472
+ f"Quant type {c.weight_type} not supported; "
473
+ f"supported: {cls.SUPPORTED_QUANT_TYPES}",
474
+ )
475
+
476
+ if c.act_type not in (torch.float16, torch.bfloat16):
477
+ return False, "requires float16 or bfloat16 activations"
478
+
479
+ if c.has_g_idx:
480
+ return False, "does not support g_idx reordering"
481
+
482
+ gs = c.group_size
483
+ if gs not in SUPPORTED_GROUP_SIZES:
484
+ return (
485
+ False,
486
+ f"Group size {gs} not supported; supported: {SUPPORTED_GROUP_SIZES}",
487
+ )
488
+
489
+ K = c.partition_weight_shape[0]
490
+ if K % 16 != 0:
491
+ return False, f"K={K} must be divisible by 16"
492
+
493
+ if K % gs != 0:
494
+ return (
495
+ False,
496
+ f"K={K} must be divisible by group_size={gs}",
497
+ )
498
+
499
+ return True, None
500
+
501
+ def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
502
+ c = self.config
503
+
504
+ w_q_raw = getattr(layer, self.w_q_name)
505
+ w_s_raw = getattr(layer, self.w_s_name)
506
+
507
+ unpacked = unpack_quantized_values_into_int32(
508
+ w_q_raw.data, c.weight_type, packed_dim=w_q_raw.packed_dim
509
+ )
510
+ # AWQ weights arrive as (K, N) with output_dim=1;
511
+ # compressed-tensors arrive as (N, K) with output_dim=0.
512
+ if getattr(w_q_raw, "output_dim", 0) != 0:
513
+ unpacked = unpacked.t().contiguous()
514
+
515
+ shuffled = pack_int4_exllama_shuffle(unpacked)
516
+ # Store as int8; Triton reinterprets via .view(torch.int32) at apply time.
517
+ w_q_skinny = shuffled.contiguous().view(torch.int8)
518
+
519
+ permute_param_layout_(w_s_raw, input_dim=1, output_dim=0)
520
+ w_s_skinny = w_s_raw.data.contiguous()
521
+
522
+ if c.zero_points:
523
+ assert self.w_zp_name is not None
524
+ w_zp_raw = getattr(layer, self.w_zp_name)
525
+ permute_param_layout_(w_zp_raw, input_dim=1, output_dim=0, packed_dim=0)
526
+ zp_unpacked = unpack_quantized_values_into_int32(
527
+ w_zp_raw.data, c.weight_type, packed_dim=0
528
+ )
529
+ w_zp = zp_unpacked.to(c.act_type).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)
533
+ self._transform_param(layer, self.w_s_name, lambda x: w_s_skinny)
534
+
535
+ def apply_weights(
536
+ self,
537
+ layer: torch.nn.Module,
538
+ x: torch.Tensor,
539
+ bias: torch.Tensor | None = None,
540
+ ) -> torch.Tensor:
541
+ from vllm.utils.platform_utils import num_compute_units
542
+
543
+ c = self.config
544
+ w_q, w_s, w_zp, _ = self._get_weight_params(layer)
545
+
546
+ x_2d = x.reshape(-1, x.shape[-1])
547
+ N = w_q.shape[0]
548
+ out_shape = x.shape[:-1] + (N,)
549
+
550
+ cu_count = num_compute_units()
551
+ output = torch.ops.vllm.rdna_hybrid_w4a16_apply(
552
+ x_2d,
553
+ w_q,
554
+ w_s,
555
+ w_zp,
556
+ bias,
557
+ cu_count,
558
+ c.group_size,
559
+ )
560
+ return output.reshape(out_shape)