Skip to content

Commit 110ac65

Browse files
authored
Refactor quantization into a shared extension/llm/export/quant package (#21044) (#21044)
Summary: Consolidates the quantization/packing logic that was previously duplicated across per-model directories into a single reusable package under extension/llm/export/quant, and introduces a model-free checkpoint loader. Key changes: - New extension/llm/export/quant package exposing a stable API: - convert.py: Convert, to_exportable/to_default, fuse_along_output, identity - quantize.py: quantize_model/quantize_weight/quantize_stream + dequantize - recipe.py: QuantConfig / QuantRecipe / QuantRule - New extension/llm/export/load.py: model-free (iter_checkpoint) and model-aware (load_checkpoint) readers that stream GGUF / safetensors weights, remap names to FQNs, and wrap quantized subclasses in portable Exportable* form. Backend-specific layout (CUDA coalesced int4, MLX gather buffers) is a separate terminal pass. - Extended int4.py / gguf.py to plug into the shared conversion path. - Removed the old per-model examples/models/gemma4_31b/quant/ packers (pack.py, pack_cuda.py, pack_mlx.py) and moved packing into examples/models/gemma4_31b/cuda_packers.py, wiring gemma4_31b and qwen3_5_moe onto the shared package. - Added unit tests for the new package and the gemma4_31b packers/pipelines. Reviewed By: Gasoonjia Differential Revision: D112867629
1 parent bfed808 commit 110ac65

42 files changed

Lines changed: 3346 additions & 1122 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/cuda.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ jobs:
211211
212212
# Run Gemma 4 31B tests (quant unit tests + pipeline integration tests)
213213
pip install gguf
214-
python -m pytest examples/models/gemma4_31b/quant/tests/ examples/models/gemma4_31b/tests/ --ignore=examples/models/gemma4_31b/tests/test_mlx_pipeline.py -v -o "addopts="
214+
python -m pytest examples/models/gemma4_31b/tests/ --ignore=examples/models/gemma4_31b/tests/test_mlx_pipeline.py -v -o "addopts="
215215
216216
unittest-cuda-runtime:
217217
name: unittest-cuda-runtime

.github/workflows/mlx.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,6 @@ jobs:
8585
backends/mlx/test/test_serialization_dedup.py \
8686
backends/mlx/test/test_slot_recycling.py \
8787
backends/mlx/test/test_sample.py \
88-
examples/models/gemma4_31b/quant/tests/test_pack_mlx.py \
8988
examples/models/gemma4_31b/tests/test_mlx_pipeline.py \
9089
-v
9190
echo "::endgroup::"

backends/cuda/coalesced_int4_tensor.py

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,13 @@
4646
The coalesced [N, n_groups] layout is exactly what the W4A8 dp4a matvec kernel
4747
(``executorch_cuda::int4_plain_mm`` / ``int4_plain_mm.cuh``) reads row-for-row
4848
with qdata, so the exported decode graph carries no per-step transpose. The
49-
transpose (and the uint8 re-encoding) is owned by :meth:`from_int4_tensor` so it
50-
is baked into the serialized weight constant once at pack time.
49+
is owned by :meth:`from_exportable_int4_tensor` so
50+
it is baked into the serialized weight constant once at pack time.
5151
"""
5252

5353
from typing import List, Optional, Tuple
5454

5555
import torch
56-
from torchao.quantization.quantize_.workflows.int4.int4_tensor import Int4Tensor
5756
from torchao.utils import TorchAOBaseTensor
5857

5958
__all__ = [
@@ -113,10 +112,10 @@ def _unpack_nibble_qdata(qdata: torch.Tensor, N: int, K: int) -> torch.Tensor:
113112
class CudaCoalescedInt4Tensor(TorchAOBaseTensor):
114113
"""INT4 weight, uint8 scale + per-256 fp16 step, uint8 zero + per-256 fp16 step.
115114
116-
ExecuTorch-internal; see the module docstring. Mirrors torchao
115+
ExecuTorch-internal; see the module docstring. Mirrors torchao
117116
``Int4Tensor``'s data/attribute layout (so the common tensor utilities and
118-
serialization work) but owns the [n_groups, N] -> [N, n_groups] transpose and
119-
the uint8 re-encoding via :meth:`from_int4_tensor`.
117+
serialization work) but owns the [n_groups, N] -> [N, n_groups] transpose and
118+
the uint8 re-encoding via :meth:`from_exportable_int4_tensor`.
120119
"""
121120

122121
tensor_data_names = [
@@ -181,31 +180,34 @@ def _quantization_type(self):
181180
return s
182181

183182
@classmethod
184-
def from_int4_tensor(cls, t: Int4Tensor) -> "CudaCoalescedInt4Tensor":
185-
"""Build a coalesced tensor from a torchao ``Int4Tensor``.
186-
187-
Owns the transpose AND the uint8 re-encoding: torchao stores
188-
scale/zero_point as (n_groups, N) bf16. The CUDA decode kernel reads the
189-
(N, n_groups) uint8 scale/zero *codes* plus per-256-super-block
190-
(N, K/256) fp16 ``scale_step`` / ``zero_point_step`` (scale = scale_code *
191-
scale_step[:, g//8], zero = zero_code * zero_point_step[:, g//8]). The
192-
transpose + encode here is baked into the serialized weight constant so
193-
the exported decode graph has no per-step transpose/clone.
183+
def from_exportable_int4_tensor(
184+
cls, t: "ExportableInt4Tensor" # noqa: F821
185+
) -> "CudaCoalescedInt4Tensor":
186+
"""Build a coalesced tensor from an ``ExportableInt4Tensor``.
187+
188+
Owns the [n_groups, N] -> [N, n_groups] transpose and the uint8
189+
re-encoding, baked into the serialized weight constant at pack time.
190+
torchao stores scale/zero_point as (n_groups, N) bf16; the CUDA decode
191+
kernel reads (N, n_groups) uint8 scale/zero *codes* plus per-256-super-
192+
block (N, K/256) fp16 ``scale_step`` / ``zero_point_step`` (scale =
193+
scale_code * scale_step[:, g//8], zero = zero_code * zero_point_step[:,
194+
g//8]), so the exported decode graph has no per-step transpose/clone. An
195+
``ExportableInt4Tensor`` (e.g. a decoded GGUF Q4_K) stores ``group_size``
196+
as a scalar rather than a ``block_size`` list and carries no activation
197+
quantization, so ``block_size`` is reconstructed as ``[1, group_size]``.
194198
"""
195-
scale_codes, scale_step = _encode_uint8_per_super(t.scale, t.block_size[-1])
199+
scale_codes, scale_step = _encode_uint8_per_super(t.scale, t.group_size)
196200
zero_codes, zero_point_step = _encode_uint8_per_super(
197-
t.zero_point, t.block_size[-1]
201+
t.zero_point, t.group_size
198202
)
199203
return cls(
200204
t.qdata,
201205
scale_codes,
202206
scale_step,
203207
zero_codes,
204208
zero_point_step,
205-
t.block_size,
209+
[1, t.group_size],
206210
t.shape,
207-
t.act_pre_scale,
208-
t.activation_dtype,
209211
)
210212

211213
def dequantize(self, output_dtype: Optional[torch.dtype] = None) -> torch.Tensor:

backends/cuda/runtime/shims/tests/gen_plain_mm_test_vectors.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@
2323
1. torch.manual_seed(case.seed) on CPU.
2424
2. Draw a random bf16 weight ``[N, K]`` then activation ``[M, K]`` (weight
2525
first, then activation: fixed order is part of the seed contract).
26-
3. quantize_weight(..., bits=4, min_max, asymmetric) -> torchao Int4Tensor.
27-
4. CudaCoalescedInt4Tensor.from_int4_tensor(...) -> qdata [N, K/2] uint8,
26+
3. quantize_weight(..., bits=4, min_max, asymmetric) -> torchao Int4Tensor,
27+
wrapped as the canonical ExportableInt4Tensor.
28+
4. CudaCoalescedInt4Tensor.from_exportable_int4_tensor(...) -> qdata [N, K/2] uint8,
2829
scale codes [N, K/gs] uint8, scale_step [N, K/256] fp16, zero_point codes
2930
[N, K/gs] uint8, zero_point_step [N, K/256] fp16.
3031
5. expected = F.linear(A, tensor.dequantize(bf16)).
@@ -157,8 +158,9 @@ def _i8(t: torch.Tensor) -> List[int]:
157158
def build_int4(case: Case) -> Dict[str, tuple]:
158159
"""Return {array_name: (ctype, [ints])} for one INT4 case (CPU only)."""
159160
from executorch.backends.cuda.coalesced_int4_tensor import CudaCoalescedInt4Tensor
160-
from executorch.examples.models.gemma4_31b.quant.quantize import quantize_weight
161-
from executorch.examples.models.gemma4_31b.quant.recipe import QuantConfig
161+
from executorch.extension.llm.export.int4 import ExportableInt4Tensor
162+
from executorch.extension.llm.export.quant.quantize import quantize_weight
163+
from executorch.extension.llm.export.quant.recipe import QuantConfig
162164

163165
torch.manual_seed(case.seed)
164166
# Weight first, then activation: fixed order is part of the seed contract.
@@ -167,7 +169,9 @@ def build_int4(case: Case) -> Dict[str, tuple]:
167169

168170
config = QuantConfig(bits=4, group_size=case.gs, symmetric=False, method="min_max")
169171
int4 = quantize_weight(w, config)
170-
c = CudaCoalescedInt4Tensor.from_int4_tensor(int4)
172+
c = CudaCoalescedInt4Tensor.from_exportable_int4_tensor(
173+
ExportableInt4Tensor.from_int4_tensor(int4)
174+
)
171175

172176
# bf16 dequant @ F.linear reference (kernel adds activation-quant noise).
173177
w_deq = c.dequantize(torch.bfloat16)

backends/cuda/tests/test_int4_dispatch.py

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,13 @@
3434
import torch.nn.functional as F
3535
from executorch.backends.cuda.coalesced_int4_tensor import CudaCoalescedInt4Tensor
3636
from executorch.backends.cuda.quantize_op_dispatch.int4_dispatch import _dequant_matmul
37-
from executorch.examples.models.gemma4_31b.quant.pack_cuda import pack_linear_for_cuda
38-
from executorch.examples.models.gemma4_31b.quant.quantize import (
37+
from executorch.examples.models.gemma4_31b.cuda_packers import pack_linear_for_cuda
38+
from executorch.extension.llm.export.int4 import ExportableInt4Tensor
39+
from executorch.extension.llm.export.quant.quantize import (
3940
dequantize_weight,
4041
quantize_weight,
4142
)
42-
from executorch.examples.models.gemma4_31b.quant.recipe import QuantConfig
43+
from executorch.extension.llm.export.quant.recipe import QuantConfig
4344

4445

4546
def _require_cuda(tc: unittest.TestCase) -> None:
@@ -48,22 +49,26 @@ def _require_cuda(tc: unittest.TestCase) -> None:
4849

4950

5051
def _make_int4_linear(N, K, group_size=128, symmetric=False, bias=False):
51-
"""Build an nn.Linear with Int4Tensor weight and return (module, bf16_ref_weight).
52+
"""Build an nn.Linear with ExportableInt4Tensor weight + bf16 ref weight.
5253
53-
The bf16 reference is the original unquantized weight, so tests can
54-
measure quantization error against the true value.
54+
Mirrors production: weights are converted to ExportableInt4Tensor (the
55+
canonical portable int4 form) before packing for CUDA. The bf16 reference
56+
is the original unquantized weight, so tests can measure quantization
57+
error against the true value.
5558
"""
5659
w_bf16 = torch.randn(N, K, dtype=torch.bfloat16)
5760
config = QuantConfig(
5861
bits=4, group_size=group_size, symmetric=symmetric, method="min_max"
5962
)
60-
int4_w = quantize_weight(w_bf16, config)
63+
exportable_w = ExportableInt4Tensor.from_int4_tensor(
64+
quantize_weight(w_bf16, config)
65+
)
6166

6267
# device="cuda" so the random init draws from the CUDA RNG to match the
6368
# same random weight as regular int4 dispatch and fit the same numerical
6469
# error tolerance.
6570
module = nn.Linear(K, N, bias=bias, dtype=torch.bfloat16, device="cuda")
66-
pack_linear_for_cuda(module, {"weight": int4_w})
71+
pack_linear_for_cuda(module, {"weight": exportable_w})
6772
module.cuda()
6873
return module, w_bf16.cuda()
6974

@@ -184,9 +189,11 @@ def _check(self, out, ref, tol=0.15):
184189
def test_to_cuda(self):
185190
w_bf16 = torch.randn(256, 512, dtype=torch.bfloat16)
186191
config = QuantConfig(bits=4, group_size=128, symmetric=False, method="min_max")
187-
int4_w = quantize_weight(w_bf16, config)
192+
exportable_w = ExportableInt4Tensor.from_int4_tensor(
193+
quantize_weight(w_bf16, config)
194+
)
188195
module = nn.Linear(512, 256, bias=False)
189-
pack_linear_for_cuda(module, {"weight": int4_w})
196+
pack_linear_for_cuda(module, {"weight": exportable_w})
190197
module = module.to("cuda")
191198
x = torch.randn(1, 512, dtype=torch.bfloat16, device="cuda")
192199
self._check(module(x), F.linear(x, w_bf16.cuda()))
@@ -228,6 +235,12 @@ def _make_int4_tensor(N, K, group_size=128, symmetric=False):
228235
return quantize_weight(w, config), w
229236

230237

238+
def _make_exportable_int4_tensor(N, K, group_size=128, symmetric=False):
239+
"""Build an ``ExportableInt4Tensor`` (canonical portable int4) + bf16 ref."""
240+
t, w = _make_int4_tensor(N, K, group_size=group_size, symmetric=symmetric)
241+
return ExportableInt4Tensor.from_int4_tensor(t), w
242+
243+
231244
@contextlib.contextmanager
232245
def _record_int4_plain_mm():
233246
"""Record calls to the decode custom op without needing a GPU.
@@ -278,8 +291,8 @@ def test_stock_int4tensor_does_not_route_to_int4_plain_mm(self):
278291

279292
def test_coalesced_tensor_routes_to_int4_plain_mm(self):
280293
"""CudaCoalescedInt4Tensor with M<=4 routes to the decode custom op."""
281-
t, _ = _make_int4_tensor(16, 256, group_size=32)
282-
c = CudaCoalescedInt4Tensor.from_int4_tensor(t)
294+
t, _ = _make_exportable_int4_tensor(16, 256, group_size=32)
295+
c = CudaCoalescedInt4Tensor.from_exportable_int4_tensor(t)
283296
x = torch.randn(1, 256, dtype=torch.bfloat16) # M=1 (decode regime)
284297
with _record_int4_plain_mm() as calls:
285298
out = F.linear(x, c)
@@ -288,8 +301,8 @@ def test_coalesced_tensor_routes_to_int4_plain_mm(self):
288301

289302
def test_coalesced_tensor_prefill_uses_dequant(self):
290303
"""M>4 uses inline dequant (no custom op) and is numerically correct."""
291-
t, _ = _make_int4_tensor(16, 256, group_size=32)
292-
c = CudaCoalescedInt4Tensor.from_int4_tensor(t)
304+
t, _ = _make_exportable_int4_tensor(16, 256, group_size=32)
305+
c = CudaCoalescedInt4Tensor.from_exportable_int4_tensor(t)
293306
x = torch.randn(8, 256, dtype=torch.bfloat16) # M=8 > 4 (prefill regime)
294307
with _record_int4_plain_mm() as calls:
295308
out = F.linear(x, c)
@@ -312,10 +325,10 @@ def test_square_shape_not_misrouted(self):
312325
F.linear(x, t)
313326
self.assertEqual(calls, [])
314327

315-
def test_from_int4_tensor_transpose_correct(self):
316-
"""from_int4_tensor owns the (n_groups, N) -> (N, n_groups) transpose."""
317-
t, _ = _make_int4_tensor(24, 256, group_size=64)
318-
c = CudaCoalescedInt4Tensor.from_int4_tensor(t)
328+
def test_from_exportable_int4_tensor_transpose_correct(self):
329+
"""from_exportable_int4_tensor owns the (n_groups, N) -> (N, n_groups) transpose."""
330+
t, _ = _make_exportable_int4_tensor(24, 256, group_size=64)
331+
c = CudaCoalescedInt4Tensor.from_exportable_int4_tensor(t)
319332
n_groups = 256 // 64
320333
self.assertEqual(tuple(t.scale.shape), (n_groups, 24)) # torchao layout
321334
self.assertEqual(tuple(c.scale.shape), (24, n_groups)) # coalesced layout

0 commit comments

Comments
 (0)