Skip to content

Commit 256d4c4

Browse files
authored
Enable support for FP4 weight quantization for non-conv layers (#34)
Enables FP4 quantization by fixing the following issues: 1. Handling subbyte tensors with safetensors, which doesn't have that support by default. 2. Update the validate_fp4_export method to enable 4D tensors as well with 32 block size.
1 parent 5cdb1f1 commit 256d4c4

7 files changed

Lines changed: 326 additions & 61 deletions

File tree

src/coreai_opt/_utils/torch_utils.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -439,23 +439,41 @@ def mmap_module_state_dict(module: torch.nn.Module, path: str | PathLike[str]) -
439439
reload it via mmap, replacing the module's parameters/buffers with mmap
440440
views so the in-RAM tensors can be released.
441441
442+
Handles ``SubbyteTensor`` subclasses (e.g. ``Float4Tensor``) that safetensors
443+
cannot serialize directly by unwrapping to plain uint8 for storage and
444+
re-wrapping after reload.
445+
442446
Requires all tensors in ``module.state_dict()`` to be on CPU. Raises
443447
``ValueError`` otherwise — mmap is a CPU-only mechanism
444448
"""
449+
from coreai_torch._compression._floatx import SubbyteTensor as _SubbyteTensor # noqa: PLC0415
445450
from safetensors.torch import load_file, save_file # noqa: PLC0415
446451

447452
state_dict = module.state_dict()
453+
454+
# Keys whose tensors are SubbyteTensor wrappers (Float4Tensor, etc.) that
455+
# safetensors cannot serialize. Track their class for re-wrapping after load.
456+
subbyte_keys: dict[str, type] = {}
457+
tensors_to_save: dict[str, torch.Tensor] = {}
458+
448459
for name, tensor in state_dict.items():
449460
if not isinstance(tensor, torch.Tensor):
450461
continue
451462
if not is_tensor_on_cpu(tensor):
452463
raise ValueError(
453464
f"mmap_module_state_dict requires CPU tensors; '{name}' is on {tensor.device}."
454465
)
466+
if isinstance(tensor, _SubbyteTensor):
467+
subbyte_keys[name] = type(tensor)
468+
tensors_to_save[name] = tensor.elem.contiguous()
469+
else:
470+
tensors_to_save[name] = tensor.contiguous()
455471

456-
save_file(
457-
{k: v.contiguous() for k, v in state_dict.items() if isinstance(v, torch.Tensor)},
458-
path,
459-
)
472+
save_file(tensors_to_save, path)
460473
mmap_sd = load_file(path, device="cpu")
474+
475+
# Re-wrap SubbyteTensor keys from their underlying uint8 representation.
476+
for name, tensor_cls in subbyte_keys.items():
477+
mmap_sd[name] = tensor_cls(mmap_sd[name])
478+
461479
module.load_state_dict(mmap_sd, assign=True)

src/coreai_opt/quantization/_export_utils.py

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -313,12 +313,17 @@ def validate_fp4_export(
313313
) -> None:
314314
"""Validate that FP4 quantization config is supported for MLIR export.
315315
316-
FP4 export only supports weight-only quantization on 2D weight tensors
317-
(Linear layers) with PerBlockGranularity and block_size=32.
316+
FP4 export requires weight-only quantization with ``PerBlockGranularity``
317+
that resolves to blocks of size 32 along the weight's last axis and no
318+
blocking along any other axis (i.e. resolved block sizes
319+
``(1, ..., 1, 32)``). This covers 2D Linear weights, which resolve to
320+
``(1, 32)``, and higher-rank weights such as MoE experts that resolve to
321+
``(1, ..., 1, 32)``.
318322
319323
Args:
320324
fake_quant_mod (FakeQuantizeImplBase): The fake quantization module to validate.
321-
quantized_data (torch.Tensor | None): The quantized weight tensor to validate shape.
325+
quantized_data (torch.Tensor | None): The quantized weight tensor used
326+
to resolve per-axis block sizes against the actual weight shape.
322327
323328
Raises:
324329
ValueError: If the FP4 configuration is not supported for export.
@@ -329,20 +334,21 @@ def validate_fp4_export(
329334
f"Got quantization_target={fake_quant_mod.quantization_target}."
330335
)
331336

332-
if quantized_data is not None and quantized_data.ndim != 2:
337+
granularity = fake_quant_mod._granularity
338+
if not isinstance(granularity, PerBlockGranularity):
333339
raise ValueError(
334-
"FP4 weight quantization export is only supported for "
335-
f"2D weight tensors (Linear layers). Got {quantized_data.ndim}D tensor."
340+
f"FP4 quantization requires PerBlockGranularity. Got {type(granularity).__name__}."
336341
)
337342

338-
granularity = fake_quant_mod._granularity
339-
if (
340-
not isinstance(granularity, PerBlockGranularity)
341-
or granularity.block_size != _FP4_EXPORT_BLOCK_SIZE
342-
):
343+
if quantized_data is None:
344+
return
345+
346+
resolved_block_size = granularity.get_block_size(quantized_data.shape)
347+
expected_block_size = (1,) * (quantized_data.ndim - 1) + (_FP4_EXPORT_BLOCK_SIZE,)
348+
if resolved_block_size != expected_block_size:
343349
raise ValueError(
344-
f"FP4 quantization requires PerBlockGranularity with "
345-
f"block_size={_FP4_EXPORT_BLOCK_SIZE}. "
346-
f"Got {type(granularity).__name__} with "
347-
f"block_size={getattr(granularity, 'block_size', 'N/A')}."
350+
f"FP4 export requires per-axis block sizes {expected_block_size} for a "
351+
f"{quantized_data.ndim}D weight (blocks of {_FP4_EXPORT_BLOCK_SIZE} along "
352+
f"the last axis, no blocking elsewhere). Got resolved block sizes "
353+
f"{resolved_block_size} from granularity={granularity!r}."
348354
)
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# Copyright 2026 Apple Inc.
2+
#
3+
# Use of this source code is governed by a BSD-3-Clause license that can
4+
# be found in the LICENSE file or at https://opensource.org/licenses/BSD-3-Clause
5+
6+
"""Eager Core AI export tests for FP4-quantized nn.Embedding variants.
7+
8+
These mirror the embedding patterns seen in common LLMs:
9+
- plain : standard nn.Embedding
10+
- scaled : embedding output scaled by sqrt(hidden_size)
11+
- tied : embedding weight shared with an lm_head Linear
12+
13+
All variants use FP4 (float4_e2m1fn) weight-only quantization with symmetric
14+
per-block granularity (block_size=32), which is the only granularity supported
15+
for FP4 MLIR export.
16+
"""
17+
18+
import pytest
19+
import torch
20+
from torch import nn
21+
22+
from coreai_opt import ExportBackend
23+
from coreai_opt.quantization import (
24+
ModuleQuantizerConfig,
25+
QuantizationSpec,
26+
Quantizer,
27+
QuantizerConfig,
28+
)
29+
from coreai_opt.quantization.spec import PerBlockGranularity, QuantizationScheme
30+
from tests.models.simple import (
31+
PlainEmbeddingModel,
32+
ScaledEmbeddingModel,
33+
TiedEmbeddingModel,
34+
)
35+
36+
from . import export_utils
37+
38+
_VOCAB_SIZE = 256
39+
_EMBED_DIM = 64
40+
_SEQ_LEN = 8
41+
42+
43+
def _fp4_config() -> QuantizerConfig:
44+
"""FP4 weight-only config: symmetric, per-block (block_size=32) along axis 1."""
45+
return QuantizerConfig(
46+
global_config=ModuleQuantizerConfig(
47+
op_state_spec={
48+
"weight": QuantizationSpec(
49+
dtype="float4_e2m1fn",
50+
qscheme=QuantizationScheme.SYMMETRIC,
51+
granularity=PerBlockGranularity(axis=1, block_size=32),
52+
),
53+
},
54+
op_input_spec=None,
55+
op_output_spec=None,
56+
),
57+
execution_mode="eager",
58+
)
59+
60+
61+
def _run_fp4_embedding_export(model: nn.Module, expected_shift_scale_ops: int) -> None:
62+
"""Quantize the embedding model with FP4, finalize, and export/run on Core AI."""
63+
model = model.eval().to(dtype=torch.float16)
64+
input_ids = torch.randint(0, _VOCAB_SIZE, (1, _SEQ_LEN), dtype=torch.int32)
65+
66+
quantizer = Quantizer(model, _fp4_config())
67+
prepared_model = quantizer.prepare((input_ids,))
68+
69+
with torch.no_grad():
70+
prepared_model_output = prepared_model(input_ids)
71+
72+
finalized_model = quantizer.finalize(backend=ExportBackend.CoreAI)
73+
74+
export_utils.convert_and_verify(
75+
finalized_model=finalized_model,
76+
input_data=input_ids,
77+
expected_ops={"constexpr_blockwise_shift_scale": expected_shift_scale_ops},
78+
export_backend=ExportBackend.CoreAI,
79+
prepared_model_output=prepared_model_output,
80+
)
81+
82+
83+
@pytest.mark.parametrize(
84+
"model_factory, expected_shift_scale_ops",
85+
[
86+
pytest.param(PlainEmbeddingModel, 1, id="plain"),
87+
pytest.param(ScaledEmbeddingModel, 1, id="scaled"),
88+
pytest.param(TiedEmbeddingModel, 2, id="tied"),
89+
],
90+
)
91+
def test_fp4_embedding_export(
92+
model_factory: type[nn.Module], expected_shift_scale_ops: int
93+
) -> None:
94+
"""Eager Core AI export with FP4-quantized nn.Embedding variants."""
95+
_run_fp4_embedding_export(
96+
model_factory(vocab_size=_VOCAB_SIZE, embed_dim=_EMBED_DIM),
97+
expected_shift_scale_ops,
98+
)

tests/models/simple.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,3 +171,67 @@ def simple_mha_model():
171171
def simple_mha_model_input():
172172
"""Fixture providing example input tensor for MHA model."""
173173
return torch.randn(1, 10, 64)
174+
175+
176+
class PlainEmbeddingModel(nn.Module):
177+
"""Simple model with standard nn.Embedding."""
178+
179+
def __init__(self, vocab_size: int = 256, embed_dim: int = 64) -> None:
180+
super().__init__()
181+
self.embed_tokens = nn.Embedding(vocab_size, embed_dim)
182+
183+
def forward(self, input_ids: torch.Tensor) -> torch.Tensor:
184+
return self.embed_tokens(input_ids)
185+
186+
187+
class ScaledEmbeddingModel(nn.Module):
188+
"""Simple model with embedding output scaled by sqrt(hidden_size)."""
189+
190+
def __init__(self, vocab_size: int = 256, embed_dim: int = 64) -> None:
191+
super().__init__()
192+
self.embed_tokens = nn.Embedding(vocab_size, embed_dim)
193+
self.embed_scale = embed_dim**0.5
194+
195+
def forward(self, input_ids: torch.Tensor) -> torch.Tensor:
196+
return self.embed_tokens(input_ids) * torch.tensor(
197+
self.embed_scale, dtype=self.embed_tokens.weight.dtype
198+
)
199+
200+
201+
class TiedEmbeddingModel(nn.Module):
202+
"""Simple model with embedding weight tied with an lm_head Linear."""
203+
204+
def __init__(self, vocab_size: int = 256, embed_dim: int = 64) -> None:
205+
super().__init__()
206+
self.embed_tokens = nn.Embedding(vocab_size, embed_dim)
207+
self.lm_head = nn.Linear(embed_dim, vocab_size, bias=False)
208+
# Tie weights.
209+
self.lm_head.weight = self.embed_tokens.weight
210+
211+
def forward(self, input_ids: torch.Tensor) -> torch.Tensor:
212+
hidden = self.embed_tokens(input_ids)
213+
return self.lm_head(hidden)
214+
215+
216+
@pytest.fixture
217+
def plain_embedding_model():
218+
"""Fixture providing a plain nn.Embedding model."""
219+
return PlainEmbeddingModel()
220+
221+
222+
@pytest.fixture
223+
def scaled_embedding_model():
224+
"""Fixture providing a sqrt(hidden_size)-scaled embedding model."""
225+
return ScaledEmbeddingModel()
226+
227+
228+
@pytest.fixture
229+
def tied_embedding_model():
230+
"""Fixture providing an embedding model with weight tied to an lm_head."""
231+
return TiedEmbeddingModel()
232+
233+
234+
@pytest.fixture
235+
def embedding_model_input():
236+
"""Fixture providing example token-id input for the embedding models."""
237+
return torch.randint(0, 256, (1, 8), dtype=torch.int32)

0 commit comments

Comments
 (0)