Skip to content

Commit 56c4a36

Browse files
authored
refactor: expand CoreMLExportError to error on non-compatible configs for CoreML and remove more CoreML export tests (#42)
* refactor: centralize CoreML export compatibility validation Weight/activation/LUT dtype checks and the activation-granularity check were duplicated across the eager, graph, and palettization export paths. Consolidate them into validate_coreml_compatibility() so CoreML's actual export constraints live in one place, including the per-channel/per-block activation restriction: an upstream coremltools MIL optimizer pass can silently corrupt per-channel-quantized activations across reshape boundaries, so CoreML export now rejects that configuration outright instead of letting it through to a numerics-level failure. * test: reject per-channel and per-block activation quantization on CoreML export CoreML export now rejects these configs outright instead of letting them through to a numerics-level SNR failure. Replace the xfail-based coverage with direct CoreMLExportError assertions, and collapse the now-redundant per-axis/per-dtype combinatorial sweeps since the rejection doesn't depend on any of those variables. * test: rename CoreML rejection assertion helper for dtype+granularity clarity assert_coreml_finalize_rejects_unsupported_dtype and COREML_DTYPE_REJECTION_MATCH are now used for granularity rejections too, not just dtype ones, so their names no longer matched what they check.
1 parent 7d1805b commit 56c4a36

11 files changed

Lines changed: 295 additions & 182 deletions

File tree

changelog.d/42.fixed

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Reject per-channel activation quantization on CoreML export

src/coreai_opt/_utils/export_utils.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
import torch
1010

1111
from coreai_opt._utils.torch_utils import is_tensor_on_cpu
12-
from coreai_opt.common import ExportBackend
12+
from coreai_opt.common import CoreMLExportError, ExportBackend
13+
from coreai_opt.config.spec import CompressionTargetTensor
14+
from coreai_opt.quantization.spec.granularity import PerTensorGranularity, QuantizationGranularity
1315

1416
COREML_SUPPORTED_WEIGHT_DTYPES: frozenset[torch.dtype] = frozenset(
1517
{
@@ -34,6 +36,47 @@
3436
}
3537
)
3638

39+
COREML_SUPPORTED_ACTIVATION_GRANULARITIES: frozenset[type[QuantizationGranularity]] = frozenset(
40+
{PerTensorGranularity}
41+
)
42+
43+
_COREML_SUPPORTED_DTYPES_BY_TARGET: dict[CompressionTargetTensor, frozenset[torch.dtype]] = {
44+
CompressionTargetTensor.WEIGHT: COREML_SUPPORTED_WEIGHT_DTYPES,
45+
CompressionTargetTensor.ACTIVATION: COREML_SUPPORTED_ACTIVATION_DTYPES,
46+
CompressionTargetTensor.LUT: COREML_SUPPORTED_LUT_DTYPES,
47+
}
48+
49+
50+
def validate_coreml_compatibility(
51+
target: CompressionTargetTensor,
52+
dtype: torch.dtype,
53+
context: str,
54+
granularity: QuantizationGranularity | None = None,
55+
) -> None:
56+
"""Raise CoreMLExportError if this weight/activation/LUT config isn't CoreML-exportable.
57+
58+
Centralizes every reason CoreML export can reject a quantization config, so
59+
new restrictions are added here once rather than at each call site.
60+
61+
Args:
62+
target (CompressionTargetTensor): Which tensor category is being checked.
63+
dtype (torch.dtype): The quantization dtype to validate.
64+
context (str): Human-readable description of what's being checked, used
65+
in the error message (e.g. "weight 'conv.weight' of module 'conv'").
66+
granularity (QuantizationGranularity | None): The quantization
67+
granularity, if applicable. Only checked for ACTIVATION — CoreML
68+
only supports per-tensor activation quantization.
69+
70+
Raises:
71+
CoreMLExportError: If the dtype or granularity isn't supported.
72+
"""
73+
if dtype not in _COREML_SUPPORTED_DTYPES_BY_TARGET[target]:
74+
raise CoreMLExportError.from_dtype(dtype, context)
75+
if target == CompressionTargetTensor.ACTIVATION and not isinstance(
76+
granularity, tuple(COREML_SUPPORTED_ACTIVATION_GRANULARITIES)
77+
):
78+
raise CoreMLExportError.from_config(granularity, context)
79+
3780

3881
def validate_mmap_backend_and_device(
3982
model: torch.nn.Module,

src/coreai_opt/common.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -163,8 +163,15 @@ class ExportBackend(_StrEnum, metaclass=_DeprecatedMemberEnumMeta):
163163
class CoreMLExportError(ValueError):
164164
"""Raised when a model cannot be exported to the CoreML backend."""
165165

166-
def __init__(self, dtype: Any, context: str) -> None:
167-
super().__init__(
168-
f"CoreML export does not support dtype {dtype} on {context}. "
169-
f"Use backend=ExportBackend.CoreAI instead."
170-
)
166+
def __init__(self, message: str) -> None:
167+
super().__init__(f"{message} Use backend=ExportBackend.CoreAI instead.")
168+
169+
@classmethod
170+
def from_dtype(cls, dtype: Any, context: str) -> CoreMLExportError:
171+
"""Build the error for an unsupported weight/activation/LUT dtype."""
172+
return cls(f"CoreML export does not support dtype {dtype} on {context}.")
173+
174+
@classmethod
175+
def from_config(cls, config: object, context: str) -> CoreMLExportError:
176+
"""Build the error for an unsupported quantization config attribute (e.g. granularity)."""
177+
return cls(f"CoreML export does not support {type(config).__name__} on {context}.")

src/coreai_opt/palettization/kmeans/_prepare_for_export.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,17 @@
1212
import torch.nn.utils.parametrize as P
1313

1414
from coreai_opt._utils.export_utils import (
15-
COREML_SUPPORTED_LUT_DTYPES,
1615
clear_parametrization_original as _clear_parametrization_original,
1716
prepare_mmap_dir as _prepare_mmap_dir,
17+
validate_coreml_compatibility,
1818
)
1919
from coreai_opt._utils.import_utils import lazy_import_coreai_torch
2020
from coreai_opt._utils.metadata_utils import CompressionType, MILCompressionMetadata
2121
from coreai_opt._utils.torch_utils import (
2222
mmap_module_state_dict as _mmap_module_state_dict,
2323
)
24-
from coreai_opt.common import CoreMLExportError, ExportBackend
24+
from coreai_opt.common import ExportBackend
25+
from coreai_opt.config.spec import CompressionTargetTensor
2526
from coreai_opt.palettization.spec.fake_palettize import (
2627
_FakePalettizeImplBase,
2728
)
@@ -430,11 +431,11 @@ def prepare_for_mil_export(model: nn.Module) -> nn.Module:
430431
for param_name, parametrizations in module.parametrizations.items():
431432
_, fake_palett_mod = _find_fake_palett_parametrization(parametrizations)
432433
if fake_palett_mod is not None and fake_palett_mod.lut_qspec is not None:
433-
if fake_palett_mod.lut_qspec.dtype not in COREML_SUPPORTED_LUT_DTYPES:
434-
raise CoreMLExportError(
435-
fake_palett_mod.lut_qspec.dtype,
436-
f"LUT of parameter '{param_name}' of module '{module_name}'",
437-
)
434+
validate_coreml_compatibility(
435+
CompressionTargetTensor.LUT,
436+
fake_palett_mod.lut_qspec.dtype,
437+
f"LUT of parameter '{param_name}' of module '{module_name}'",
438+
)
438439

439440
_process_weight_palettization(model, backend=ExportBackend.CoreML)
440441

src/coreai_opt/quantization/_eager/_prepare_for_mil_export.py

Lines changed: 16 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,11 @@
1717
import torch.nn.utils.parametrize as P
1818
from torch import nn
1919

20-
from coreai_opt._utils.export_utils import (
21-
COREML_SUPPORTED_ACTIVATION_DTYPES,
22-
COREML_SUPPORTED_WEIGHT_DTYPES,
23-
)
20+
from coreai_opt._utils.export_utils import validate_coreml_compatibility
2421
from coreai_opt._utils.metadata_utils import CompressionType, MILCompressionMetadata
2522
from coreai_opt._utils.torch_utils import (
2623
get_parent_module_and_attr_name as _get_parent_module_and_attr_name,
2724
)
28-
from coreai_opt.common import CoreMLExportError
2925
from coreai_opt.config.spec import CompressionTargetTensor
3026
from coreai_opt.quantization._export_utils import (
3127
convert_dtype_for_torch_quantize as _convert_dtype_for_torch_quantize,
@@ -35,7 +31,6 @@
3531
validate_qformulation_for_mil_export as _validate_qformulation_for_mil_export,
3632
)
3733
from coreai_opt.quantization.spec.fake_quantize import FakeQuantizeImplBase
38-
from coreai_opt.quantization.spec.granularity import PerBlockGranularity
3934

4035

4136
def _process_weight_quantization(
@@ -96,22 +91,16 @@ def _process_activation_quantization(
9691
) -> None:
9792
"""Process activation quantization by replacing with Sequential quantize/dequantize.
9893
99-
Supports both per-tensor and per-channel quantization based on the
100-
granularity axis of the fake quantization module.
94+
By the time this runs, CoreML export compatibility has already been
95+
validated, so only per-tensor activation granularity ever reaches here.
10196
10297
Args:
10398
parent_module: The parent module containing the fake quantizer
10499
attr_name: The attribute name of the fake quantizer in the parent
105100
fake_quant_mod: The fake quantization module to replace
106101
107-
Raises:
108-
ValueError: If the granularity is per-block (not supported for MIL
109-
activation export).
110-
111102
"""
112103
_validate_qformulation_for_mil_export(fake_quant_mod)
113-
if isinstance(fake_quant_mod.granularity, PerBlockGranularity):
114-
raise ValueError("MIL export does not support per-block granularity for activations.")
115104

116105
scale, zero_point, _ = _extract_quantization_params(fake_quant_mod)
117106
converted_dtype, converted_zero_point = _convert_dtype_for_torch_quantize(
@@ -151,24 +140,24 @@ def prepare_for_mil_export(model: nn.Module) -> nn.Module:
151140
"""
152141
processed_fq_ids: set[int] = set()
153142

154-
# CoreML does not support certain dtypes (FP4, FP8, INT2, UINT2). Fail fast
155-
# before mutating the model below.
143+
# Fail fast if model is not coreml-exportable
156144
for module_name, module in model.named_modules():
157145
if P.is_parametrized(module):
158146
for param_name, parametrizations in module.parametrizations.items():
159147
for p in parametrizations:
160-
if (
161-
_is_module_fake_quant_target(p, CompressionTargetTensor.WEIGHT)
162-
and p.dtype not in COREML_SUPPORTED_WEIGHT_DTYPES
163-
):
164-
raise CoreMLExportError(
165-
p.dtype, f"weight '{param_name}' of module '{module_name}'"
148+
if _is_module_fake_quant_target(p, CompressionTargetTensor.WEIGHT):
149+
validate_coreml_compatibility(
150+
CompressionTargetTensor.WEIGHT,
151+
p.dtype,
152+
f"weight '{param_name}' of module '{module_name}'",
166153
)
167-
if (
168-
_is_module_fake_quant_target(module, CompressionTargetTensor.ACTIVATION)
169-
and module.dtype not in COREML_SUPPORTED_ACTIVATION_DTYPES
170-
):
171-
raise CoreMLExportError(module.dtype, f"activation quantizer of module '{module_name}'")
154+
if _is_module_fake_quant_target(module, CompressionTargetTensor.ACTIVATION):
155+
validate_coreml_compatibility(
156+
CompressionTargetTensor.ACTIVATION,
157+
module.dtype,
158+
f"activation quantizer of module '{module_name}'",
159+
module.granularity,
160+
)
172161

173162
for name, module in list(model.named_modules()):
174163
# Handle weight quantization parametrizations

src/coreai_opt/quantization/_graph/_prepare_for_export.py

Lines changed: 16 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,14 @@
1717
import torch
1818
from torch.fx import Node
1919

20-
from coreai_opt._utils.export_utils import (
21-
COREML_SUPPORTED_ACTIVATION_DTYPES,
22-
COREML_SUPPORTED_WEIGHT_DTYPES,
23-
)
20+
from coreai_opt._utils.export_utils import validate_coreml_compatibility
2421
from coreai_opt._utils.fx_utils import get_node_type as _get_node_type
2522
from coreai_opt._utils.import_utils import lazy_import_coreai_torch
2623
from coreai_opt._utils.metadata_utils import CompressionType, MILCompressionMetadata
2724
from coreai_opt._utils.torch_utils import (
2825
is_float4_dtype as _is_float4_dtype,
2926
sanitize_module_name as _sanitize_module_name,
3027
)
31-
from coreai_opt.common import CoreMLExportError
3228
from coreai_opt.config.spec import CompressionTargetTensor
3329
from coreai_opt.quantization._export_utils import (
3430
canonicalize_qparam_shape as _canonicalize_qparam_shape,
@@ -45,7 +41,6 @@
4541
resolve_attr,
4642
)
4743
from coreai_opt.quantization.spec.fake_quantize import FakeQuantizeImplBase
48-
from coreai_opt.quantization.spec.granularity import PerBlockGranularity
4944

5045
logger = logging.getLogger(__name__)
5146

@@ -503,23 +498,17 @@ def _process_mil_activation_quantization(
503498
504499
Converts activation FakeQuantize modules to Sequential modules containing
505500
quantize and dequantize operations that CoreMLTools can understand.
506-
Supports both per-tensor (quantize_per_tensor) and per-channel
507-
(quantize_per_channel) quantization based on the granularity axis.
508-
Buffers are stored inside the modules.
501+
Buffers are stored inside the modules. By the time this runs, CoreML
502+
export compatibility has already been validated, so only per-tensor
503+
activation granularity ever reaches here.
509504
510505
Args:
511506
model: The graph module being modified
512507
fake_quant_node: The fake quantization node to process
513508
fake_quant_mod: The fake quantization module
514509
515-
Raises:
516-
ValueError: If the granularity is per-block (not supported for MIL
517-
activation export).
518-
519510
"""
520511
_validate_qformulation_for_mil_export(fake_quant_mod)
521-
if isinstance(fake_quant_mod.granularity, PerBlockGranularity):
522-
raise ValueError("MIL export does not support per-block granularity for activations.")
523512

524513
scale, zero_point, _ = _extract_quantization_params(fake_quant_mod)
525514
converted_dtype, converted_zero_point = _convert_dtype_for_torch_quantize(
@@ -599,22 +588,22 @@ def prepare_for_mil_export(model: torch.fx.GraphModule) -> torch.fx.GraphModule:
599588
msg = "Model contains no fake quantization nodes"
600589
raise ValueError(msg)
601590

602-
# CoreML does not support certain dtypes. Fail fast before mutating the
603-
# graph below.
591+
# Fail fast if model is not coreml-exportable
604592
for fake_quant_node, fake_quant_mod in fake_quant_nodes:
605593
node_id = str(fake_quant_node.target)
606594
if _is_weight_fake_quant(fake_quant_node, fake_quant_mod):
607-
if fake_quant_mod.dtype not in COREML_SUPPORTED_WEIGHT_DTYPES:
608-
raise CoreMLExportError(
609-
fake_quant_mod.dtype,
610-
f"weight quantizer '{node_id}'",
611-
)
595+
validate_coreml_compatibility(
596+
CompressionTargetTensor.WEIGHT,
597+
fake_quant_mod.dtype,
598+
f"weight quantizer '{node_id}'",
599+
)
612600
else:
613-
if fake_quant_mod.dtype not in COREML_SUPPORTED_ACTIVATION_DTYPES:
614-
raise CoreMLExportError(
615-
fake_quant_mod.dtype,
616-
f"activation quantizer '{node_id}'",
617-
)
601+
validate_coreml_compatibility(
602+
CompressionTargetTensor.ACTIVATION,
603+
fake_quant_mod.dtype,
604+
f"activation quantizer '{node_id}'",
605+
fake_quant_mod.granularity,
606+
)
618607

619608
# Process all fake quantization nodes
620609
for fake_quant_node, fake_quant_mod in fake_quant_nodes:

tests/export/export_utils.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,10 @@
2929
if platform.system() == "Darwin":
3030
from coreai.runtime import ComputeUnitKind, SpecializationOptions
3131

32-
# Substring of the dtype guard message raised by the CoreML export validation. Shared so
33-
# test files asserting the rejection don't drift from one another.
34-
COREML_DTYPE_REJECTION_MATCH = "CoreML export does not support"
32+
# Substring of the guard message raised by CoreML export validation (dtype or
33+
# granularity/config rejection). Shared so test files asserting the rejection
34+
# don't drift from one another.
35+
COREML_REJECTION_MATCH = "CoreML export does not support"
3536

3637
# Compute unit selection driven by the --compute-unit-kind pytest option (see
3738
# tests/conftest.py). Default is "interpreter" so a plain `pytest` run uses the
@@ -87,17 +88,18 @@ def _get_test_specialization_options() -> "SpecializationOptions | None":
8788
raise ValueError(msg)
8889

8990

90-
def assert_coreml_finalize_rejects_unsupported_dtype(finalizer: Any) -> None:
91-
"""Assert ``finalizer.finalize(backend=CoreML)`` rejects an unsupported dtype.
91+
def assert_coreml_finalize_rejects(finalizer: Any) -> None:
92+
"""Assert ``finalizer.finalize(backend=CoreML)`` rejects an unsupported config.
9293
9394
CoreML does not support FP4, FP8, INT2, or UINT2 quantization or
94-
palettization dtypes, so finalize must raise a ``CoreMLExportError`` rather
95-
than emit an invalid model.
95+
palettization dtypes, nor per-channel/per-block activation quantization
96+
granularity, so finalize must raise a ``CoreMLExportError`` rather than
97+
emit an invalid model.
9698
9799
Args:
98100
finalizer (Any): A prepared ``Quantizer`` or ``KMeansPalettizer``.
99101
"""
100-
with pytest.raises(CoreMLExportError, match=COREML_DTYPE_REJECTION_MATCH):
102+
with pytest.raises(CoreMLExportError, match=COREML_REJECTION_MATCH):
101103
finalizer.finalize(backend=ExportBackend.CoreML)
102104

103105

tests/export/test_eager_mil_export.py

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ def test_simple_model_export(
9494
parametrized_quant_config_general: ParametrizedQuantConfigs,
9595
) -> None:
9696
"""Test eager CoreML export with various quantization configurations."""
97+
# CoreML rejects per-channel activation quantization outright.
98+
if parametrized_quant_config_general.has_per_channel_activation_granularity:
99+
pytest.skip("CoreML export rejects per-channel activation quantization")
100+
97101
has_act_quant = parametrized_quant_config_general.has_activation_quantization
98102

99103
_run_eager_mil_export_test(
@@ -114,6 +118,10 @@ def test_mnist_export(
114118
parametrized_quant_config_general: ParametrizedQuantConfigs,
115119
) -> None:
116120
"""Test eager CoreML export on MNIST model with various quantization configurations."""
121+
# CoreML rejects per-channel activation quantization outright.
122+
if parametrized_quant_config_general.has_per_channel_activation_granularity:
123+
pytest.skip("CoreML export rejects per-channel activation quantization")
124+
117125
has_act_quant = parametrized_quant_config_general.has_activation_quantization
118126

119127
_run_eager_mil_export_test(
@@ -167,22 +175,31 @@ def test_gated_mlp_perchannel_act_export(
167175
parametrized_quant_config_perchannel_act_axis_coverage: ParametrizedQuantConfigs,
168176
) -> None:
169177
"""Test eager CoreML export with per-channel activation quantization axes.
170-
Uses GatedMLPModel (uniform rank-3 activations throughout the model) to
171-
test per-channel activation quantization across all valid axis values without
172-
out-of-bounds errors.
178+
179+
Uses GatedMLPModel (uniform rank-3 activations throughout the model). CoreML
180+
export rejects per-channel activation quantization outright; per-tensor cases
181+
from this fixture should still export and verify normally.
173182
"""
174-
has_act_quant = (
175-
parametrized_quant_config_perchannel_act_axis_coverage.has_activation_quantization
176-
)
183+
config = parametrized_quant_config_perchannel_act_axis_coverage
184+
185+
if config.has_per_channel_activation_granularity:
186+
with pytest.raises(CoreMLExportError):
187+
_run_eager_mil_export_test(
188+
model=gated_mlp_model,
189+
input_data=gated_mlp_model_input,
190+
parametrized_quant_config=config,
191+
expected_ops={},
192+
)
193+
return
177194

178195
_run_eager_mil_export_test(
179196
model=gated_mlp_model,
180197
input_data=gated_mlp_model_input,
181-
parametrized_quant_config=parametrized_quant_config_perchannel_act_axis_coverage,
198+
parametrized_quant_config=config,
182199
expected_ops={
183200
"constexpr_blockwise_shift_scale": 3,
184-
"quantize": 6 if has_act_quant else 0,
185-
"dequantize": 6 if has_act_quant else 0,
201+
"quantize": 6,
202+
"dequantize": 6,
186203
},
187204
)
188205

0 commit comments

Comments
 (0)