Skip to content

Commit 586a79c

Browse files
authored
[cuda backend] store scale/zero in int4_plain_mm in [N, n_groups] layout (#20038)
This PR updates int4_plain_mm in cuda backend to reads scale/zero in the transposed [N, n_groups] layout instead of [n_groups, N]. In this way every warp can load both scale and zero together in one cache line, instead of 32 cache lines previously. gemma4-31b decode perf: ~27 token/s -> 37.36 token/s. cc @digantdesai @freddan80 @per @zingo @oscarandersson8218 @mansnils @Sebastian-Larsson @robell @rascani
1 parent af92b60 commit 586a79c

8 files changed

Lines changed: 436 additions & 76 deletions

File tree

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
"""ExecuTorch-internal INT4 tensor for the CUDA W4A8 dp4a decode kernel.
8+
9+
``CudaCoalescedInt4Tensor`` is an ExecuTorch-internal tensor subclass. It is
10+
**NOT** torchao's ``Int4Tensor`` and is intentionally not a subclass of it, so
11+
torchao's ``Int4Tensor`` F.linear handlers never match it via the method
12+
resolution order. The CUDA decode/prefill dispatch (``int4_dispatch.py``) is
13+
selected by *type* — it is registered on this class only — so stock
14+
``Int4Tensor`` weights keep falling back to torchao's default (mslk/tinygemm)
15+
path.
16+
17+
Layout difference from torchao ``Int4Tensor``:
18+
qdata : packed int4 weight (N, K/2), nibble-packed (same as Int4Tensor)
19+
scale : (N, n_groups) — the *coalesced* layout, transposed from
20+
torchao's documented (n_groups, N)
21+
zero_point : (N, n_groups) — coalesced, transposed from (n_groups, N)
22+
23+
The coalesced [N, n_groups] layout is exactly what the W4A8 dp4a matvec kernel
24+
(``executorch_cuda::int4_plain_mm`` / ``int4_plain_mm.cuh``) reads row-for-row
25+
with qdata, so the exported decode graph carries no per-step transpose. The
26+
transpose is owned by :meth:`from_int4_tensor` so it is baked into the
27+
serialized weight constant once at pack time.
28+
"""
29+
30+
from typing import List, Optional
31+
32+
import torch
33+
from torchao.quantization.quantize_.workflows.int4.int4_tensor import Int4Tensor
34+
from torchao.utils import TorchAOBaseTensor
35+
36+
__all__ = [
37+
"CudaCoalescedInt4Tensor",
38+
]
39+
40+
41+
class CudaCoalescedInt4Tensor(TorchAOBaseTensor):
42+
"""INT4 weight with scale/zero_point in the coalesced [N, n_groups] layout.
43+
44+
ExecuTorch-internal; see the module docstring. Mirrors torchao
45+
``Int4Tensor``'s data/attribute layout (so the common tensor utilities and
46+
serialization work) but owns the [n_groups, N] -> [N, n_groups] transpose
47+
of scale/zero_point via :meth:`from_int4_tensor`.
48+
"""
49+
50+
tensor_data_names = ["qdata", "scale", "zero_point"]
51+
tensor_attribute_names = ["block_size", "shape"]
52+
optional_tensor_data_names = ["act_pre_scale"]
53+
optional_tensor_attribute_names = ["activation_dtype"]
54+
55+
def __new__(
56+
cls,
57+
qdata: torch.Tensor,
58+
scale: torch.Tensor,
59+
zero_point: torch.Tensor,
60+
block_size: List[int],
61+
shape: torch.Size,
62+
act_pre_scale: Optional[torch.Tensor] = None,
63+
activation_dtype: Optional[torch.dtype] = None,
64+
):
65+
kwargs = {}
66+
kwargs["device"] = qdata.device
67+
kwargs["dtype"] = scale.dtype
68+
kwargs["requires_grad"] = False
69+
return torch.Tensor._make_wrapper_subclass(cls, shape, **kwargs) # type: ignore[attr-defined]
70+
71+
def __init__(
72+
self,
73+
qdata: torch.Tensor,
74+
scale: torch.Tensor,
75+
zero_point: torch.Tensor,
76+
block_size: List[int],
77+
shape: torch.Size,
78+
act_pre_scale: Optional[torch.Tensor] = None,
79+
activation_dtype: Optional[torch.dtype] = None,
80+
):
81+
super().__init__()
82+
self.qdata = qdata
83+
self.scale = scale
84+
self.zero_point = zero_point
85+
self.block_size = block_size
86+
self.activation_dtype = (
87+
activation_dtype if activation_dtype is not None else torch.bfloat16
88+
)
89+
self.act_pre_scale = act_pre_scale
90+
91+
def _quantization_type(self):
92+
s = f"shape={self.shape}, block_size={self.block_size}, device={self.device}, activation_dtype={self.activation_dtype}"
93+
if self.act_pre_scale is not None:
94+
s += f", act_pre_scale.shape={self.act_pre_scale.shape}"
95+
return s
96+
97+
@classmethod
98+
def from_int4_tensor(cls, t: Int4Tensor) -> "CudaCoalescedInt4Tensor":
99+
"""Build a coalesced tensor from a torchao ``Int4Tensor``.
100+
101+
Owns the transpose: torchao stores scale/zero_point as (n_groups, N);
102+
the CUDA decode kernel reads (N, n_groups). The ``.t().contiguous()``
103+
here is baked into the serialized weight constant so the exported
104+
decode graph has no per-step transpose/clone.
105+
"""
106+
return cls(
107+
t.qdata,
108+
t.scale.t().contiguous(),
109+
t.zero_point.t().contiguous(),
110+
t.block_size,
111+
t.shape,
112+
t.act_pre_scale,
113+
t.activation_dtype,
114+
)
115+
116+
117+
# Allow a model with CudaCoalescedInt4Tensor weights to be loaded with
118+
# `weights_only=True` (mirrors torchao Int4Tensor).
119+
torch.serialization.add_safe_globals([CudaCoalescedInt4Tensor])

backends/cuda/quantize_op_dispatch/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
weight tensors so that torch.export traces through ExecuTorch's custom ops and
1111
dequant logic instead of torchao's defaults. It registers:
1212
13-
* INT4 (``Int4Tensor``) → ``executorch_cuda::int4_plain_mm``
14-
* INT8 (``IntxUnpackedToInt8Tensor``) → ``executorch_cuda::int8_plain_mm``
13+
* INT4 (``CudaCoalescedInt4Tensor``) → ``executorch_cuda::int4_plain_mm``
14+
* INT8 (``IntxUnpackedToInt8Tensor``) → ``executorch_cuda::int8_plain_mm``
1515
1616
See ``int4_dispatch`` and ``int8_dispatch`` for the per-dtype details.
1717

backends/cuda/quantize_op_dispatch/int4_dispatch.py

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@
44
# This source code is licensed under the BSD-style license found in the
55
# LICENSE file in the root directory of this source tree.
66

7-
"""Int4Tensor F.linear dispatch for CUDA — runs at eager / export trace time.
7+
"""CudaCoalescedInt4Tensor F.linear dispatch for CUDA — runs at eager / export trace time.
88
9-
This module overrides Int4Tensor's F.linear dispatch so that torch.export
10-
traces through our custom op and dequant logic instead of torchao's default
11-
(mslk/tinygemm). The code here executes during eager inference and during
12-
AOTI export tracing — it does NOT run at .pte runtime.
9+
This module registers an F.linear dispatch on ``CudaCoalescedInt4Tensor`` (an
10+
ExecuTorch-internal subclass, see ``coalesced_int4_tensor.py``) so that
11+
torch.export traces through our custom op and dequant logic. Routing is by
12+
*type*: stock torchao ``Int4Tensor`` weights are left untouched and keep using
13+
torchao's default (mslk/tinygemm) path. The code here executes during eager
14+
inference and during AOTI export tracing — it does NOT run at .pte runtime.
1315
1416
At .pte runtime, the captured graph is executed by the AOTI-generated .so:
1517
- The custom op ``executorch_cuda::int4_plain_mm`` maps to a C shim that
@@ -22,17 +24,17 @@
2224
Prefill (M>4): Inline dequant + F.linear (standard PyTorch ops)
2325
2426
Importing the parent ``quantize_op_dispatch`` package registers this dispatch
25-
override (along with the INT8 one) before using nn.Linear with Int4Tensor
26-
weights::
27+
override (along with the INT8 one) before using nn.Linear with
28+
CudaCoalescedInt4Tensor weights::
2729
2830
import executorch.backends.cuda.quantize_op_dispatch # noqa: F401
2931
"""
3032

3133
import torch
3234
import torch.nn.functional as F
35+
from executorch.backends.cuda.coalesced_int4_tensor import CudaCoalescedInt4Tensor
3336
from executorch.backends.cuda.quantize_op_dispatch._library import lib as _lib
3437
from torch.library import impl
35-
from torchao.quantization.quantize_.workflows.int4.int4_tensor import Int4Tensor
3638

3739
# ---------------------------------------------------------------------------
3840
# Custom op for decode (M=1): dp4a matvec in C shim, dequant+F.linear in eager
@@ -52,11 +54,18 @@ def _meta(self, qdata, scale, zero, group_size):
5254

5355
@impl(_lib, "int4_plain_mm", "CUDA")
5456
def _cuda(self, qdata, scale, zero, group_size):
57+
# scale/zero are stored in the coalesced [N, n_groups] layout (transposed
58+
# at pack time, see pack_cuda.pack_linear_for_cuda), which is exactly what
59+
# _dequant_matmul expects.
5560
return _dequant_matmul(self, qdata, scale, zero, group_size)
5661

5762

5863
def _dequant_matmul(x, qdata, scale, zero, group_size):
59-
"""Dequant INT4 weights to input dtype and call F.linear."""
64+
"""Dequant INT4 weights to input dtype and call F.linear.
65+
66+
scale/zero are in the coalesced [N, n_groups] layout (baked into the
67+
weight constant at pack time), aligned row-for-row with qdata's [N, *].
68+
"""
6069
N, K_half = qdata.shape
6170
K = K_half * 2
6271
n_groups = K // group_size
@@ -68,20 +77,20 @@ def _dequant_matmul(x, qdata, scale, zero, group_size):
6877
high = ((p >> 4) & 0x0F).to(dtype)
6978
data = torch.stack([low, high], dim=-1).reshape(N, n_groups, group_size)
7079

71-
s = scale.to(dtype).t().unsqueeze(-1)
72-
z = zero.to(dtype).t().unsqueeze(-1)
80+
s = scale.to(dtype).unsqueeze(-1)
81+
z = zero.to(dtype).unsqueeze(-1)
7382
w_deq = ((data - z) * s).reshape(N, K)
7483

7584
return F.linear(x, w_deq)
7685

7786

7887
# ---------------------------------------------------------------------------
79-
# Int4Tensor F.linear dispatch
88+
# CudaCoalescedInt4Tensor F.linear dispatch
8089
# ---------------------------------------------------------------------------
8190

8291
aten = torch.ops.aten
83-
_implements = Int4Tensor.implements
84-
_implements_torch_function = Int4Tensor.implements_torch_function
92+
_implements = CudaCoalescedInt4Tensor.implements
93+
_implements_torch_function = CudaCoalescedInt4Tensor.implements_torch_function
8594

8695

8796
@_implements([aten.linear.default])
@@ -101,6 +110,11 @@ def _(func, types, args, kwargs):
101110

102111
M = x_2d.shape[0]
103112
if M <= 4:
113+
# scale/zero are already in the coalesced [N, n_groups] layout the
114+
# decode kernel reads directly (baked into the weight constant at pack
115+
# time). Passing them straight through keeps the export graph free of
116+
# any per-step transpose/clone, so the coalesced layout is realized
117+
# without recomputing it every decode step.
104118
out = torch.ops.executorch_cuda.int4_plain_mm(x_2d, qdata, scale, zero, gs)
105119
else:
106120
out = _dequant_matmul(x_2d, qdata, scale, zero, gs)

backends/cuda/runtime/shims/int4_plain_mm.cu

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,43 @@ AOTITorchError aoti_torch_cuda_int4_plain_mm(
5252
InvalidArgument,
5353
"aoti_torch_cuda_int4_plain_mm: ret0 is null");
5454

55+
// Validate the coalesced scale/zero layout [N, K/group_size]
56+
57+
const int64_t N = qdata->size(0);
58+
const int64_t K = qdata->size(1) * 2;
59+
60+
ET_CHECK_OR_RETURN_ERROR(
61+
group_size > 0 && (group_size & (group_size - 1)) == 0,
62+
InvalidArgument,
63+
"aoti_torch_cuda_int4_plain_mm: group_size=%lld must be a positive power of 2",
64+
static_cast<long long>(group_size));
65+
66+
const int64_t n_groups = K / group_size;
67+
68+
ET_CHECK_OR_RETURN_ERROR(
69+
scale->dim() == 2 && zero->dim() == 2,
70+
InvalidArgument,
71+
"aoti_torch_cuda_int4_plain_mm: scale/zero must be 2D (got scale.dim()=%lld, zero.dim()=%lld)",
72+
static_cast<long long>(scale->dim()),
73+
static_cast<long long>(zero->dim()));
74+
75+
ET_CHECK_OR_RETURN_ERROR(
76+
scale->size(0) == N && zero->size(0) == N,
77+
InvalidArgument,
78+
"aoti_torch_cuda_int4_plain_mm: scale/zero must be coalesced [N, K/group_size] (AOT layout); native [n_groups, N] is not supported - repack via pack_linear_for_cuda. Expected size(0)=N=%lld, got scale.size(0)=%lld, zero.size(0)=%lld",
79+
static_cast<long long>(N),
80+
static_cast<long long>(scale->size(0)),
81+
static_cast<long long>(zero->size(0)));
82+
83+
ET_CHECK_OR_RETURN_ERROR(
84+
scale->size(1) == n_groups && zero->size(1) == n_groups,
85+
InvalidArgument,
86+
"aoti_torch_cuda_int4_plain_mm: scale/zero must be coalesced [N, K/group_size] (AOT layout); native [n_groups, N] is not supported - repack via pack_linear_for_cuda. Expected size(1)=K/group_size=%lld, got scale.size(1)=%lld, zero.size(1)=%lld",
87+
static_cast<long long>(n_groups),
88+
static_cast<long long>(scale->size(1)),
89+
static_cast<long long>(zero->size(1)));
90+
5591
int32_t M = self->size(0);
56-
int32_t N = qdata->size(0);
5792
Tensor* C = nullptr;
5893
std::array<int64_t, 2> c_shape = {M, N};
5994
std::array<int64_t, 2> c_stride = {N, 1};

backends/cuda/runtime/shims/int4_plain_mm.cuh

Lines changed: 35 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
// W4A8 dp4a matvec for INT4 decode (M <= 4).
1010
//
1111
// Reads plain nibble-packed [N, K//2] weights (Int4Tensor format).
12-
// Scale/zero layout: [K//gs, N] (Int4Tensor's native layout).
12+
// Scale/zero layout: [N, K//gs] (transposed AOT for coalesced loads).
1313
//
1414
// Dynamically quantizes bf16 activations to INT8 (per-32-element blocks),
1515
// then uses dp4a for fused int4×int8 dot products with 16-byte vectorized
@@ -98,18 +98,28 @@ __global__ void quantize_activations_q8_kernel(
9898
}
9999

100100
// ---------------------------------------------------------------------------
101-
// W4A8 dp4a matvec kernel
101+
// Coalesced-scale W4A8 dp4a matvec
102+
//
103+
// Reads scale/zero in the transposed [N, n_groups] layout (transposed AOT at
104+
// export time). With group_size >= 32, one uint4 (32 weights) maps to exactly
105+
// one activation block and one weight group, so within a warp the 32 lanes
106+
// touch 32 consecutive groups. In [N, n_groups] layout those 32 group scales
107+
// are contiguous => a single coalesced load, vs 32 stride-N cache lines in the
108+
// native layout. For the gemma group_size=32 weights this is the dominant
109+
// decode-matvec cost.
102110
// ---------------------------------------------------------------------------
103111

104-
__global__ void __launch_bounds__(MV_THREADS) int4_w4a8_matvec_kernel(
105-
const uint8_t* __restrict__ qdata,
106-
const __nv_bfloat16* __restrict__ w_scale,
107-
const __nv_bfloat16* __restrict__ w_zero,
108-
const Q8Block* __restrict__ q8,
109-
__nv_bfloat16* __restrict__ out,
110-
int32_t N,
111-
int32_t K,
112-
int32_t gs_shift) {
112+
__global__ void __launch_bounds__(MV_THREADS)
113+
int4_w4a8_matvec_coalesced_kernel(
114+
const uint8_t* __restrict__ qdata,
115+
const __nv_bfloat16* __restrict__ w_scale_t, // [N, n_groups]
116+
const __nv_bfloat16* __restrict__ w_zero_t, // [N, n_groups]
117+
const Q8Block* __restrict__ q8,
118+
__nv_bfloat16* __restrict__ out,
119+
int32_t N,
120+
int32_t K,
121+
int32_t gs_shift,
122+
int32_t n_groups) {
113123
const int32_t n = blockIdx.x * MV_NWARPS + threadIdx.y;
114124
const int32_t m = blockIdx.y;
115125
if (n >= N)
@@ -120,9 +130,10 @@ __global__ void __launch_bounds__(MV_THREADS) int4_w4a8_matvec_kernel(
120130
const int32_t n_q8_blocks = K / Q8_BLOCK_SIZE;
121131

122132
const uint8_t* qrow = qdata + static_cast<int64_t>(n) * K_half;
123-
const __nv_bfloat16* scale_base = w_scale + n;
124-
const __nv_bfloat16* zero_base = w_zero + n;
125-
const int32_t scale_stride = N;
133+
const __nv_bfloat16* scale_row =
134+
w_scale_t + static_cast<int64_t>(n) * n_groups;
135+
const __nv_bfloat16* zero_row =
136+
w_zero_t + static_cast<int64_t>(n) * n_groups;
126137
const Q8Block* q8_row = q8 + static_cast<int64_t>(m) * n_q8_blocks;
127138

128139
const uint4* qrow16 = reinterpret_cast<const uint4*>(qrow);
@@ -145,8 +156,8 @@ __global__ void __launch_bounds__(MV_THREADS) int4_w4a8_matvec_kernel(
145156
int32_t g = k_word >> gs_shift;
146157

147158
if (g != prev_g) {
148-
ws = __bfloat162float(__ldg(&scale_base[g * scale_stride]));
149-
wz = __bfloat162float(__ldg(&zero_base[g * scale_stride]));
159+
ws = __bfloat162float(__ldg(&scale_row[g]));
160+
wz = __bfloat162float(__ldg(&zero_row[g]));
150161
prev_g = g;
151162
}
152163

@@ -227,8 +238,8 @@ static Q8Block* get_q8_buffer(size_t needed) {
227238
void _int4_plain_mm_cuda(
228239
const Tensor& A, // [M, K] bf16
229240
const Tensor& qdata, // [N, K//2] uint8
230-
const Tensor& scale, // [K//gs, N] bf16
231-
const Tensor& zero, // [K//gs, N] bf16
241+
const Tensor& scale, // [N, K//gs] bf16
242+
const Tensor& zero, // [N, K//gs] bf16
232243
int64_t group_size,
233244
Tensor* output) { // [M, N] bf16, pre-allocated
234245
int32_t M = A.size(0);
@@ -245,9 +256,9 @@ void _int4_plain_mm_cuda(
245256
ET_CHECK(qdata.dim() == 2);
246257
ET_CHECK(qdata.size(1) == K / 2);
247258
ET_CHECK(scale.dim() == 2);
248-
ET_CHECK(scale.size(1) == N);
259+
ET_CHECK(scale.size(0) == N);
249260
ET_CHECK(zero.dim() == 2);
250-
ET_CHECK(zero.size(1) == N);
261+
ET_CHECK(zero.size(0) == N);
251262

252263
int32_t gs = static_cast<int32_t>(group_size);
253264
ET_CHECK_MSG(
@@ -279,15 +290,15 @@ void _int4_plain_mm_cuda(
279290
// dp4a matvec
280291
dim3 grid((N + MV_NWARPS - 1) / MV_NWARPS, M);
281292
dim3 block(MV_WARP_SIZE, MV_NWARPS);
282-
int4_w4a8_matvec_kernel<<<grid, block, 0, stream>>>(
293+
294+
int32_t n_groups = static_cast<int32_t>(scale.size(1));
295+
int4_w4a8_matvec_coalesced_kernel<<<grid, block, 0, stream>>>(
283296
reinterpret_cast<const uint8_t*>(qdata.data_ptr()),
284297
reinterpret_cast<const __nv_bfloat16*>(scale.data_ptr()),
285298
reinterpret_cast<const __nv_bfloat16*>(zero.data_ptr()),
286299
q8_buf,
287300
reinterpret_cast<__nv_bfloat16*>(output->data_ptr()),
288-
N,
289-
K,
290-
gs_shift);
301+
N, K, gs_shift, n_groups);
291302
}
292303

293304
} // namespace executorch::backends::cuda

0 commit comments

Comments
 (0)