Skip to content

Commit 26f7def

Browse files
committed
update to latest extern API and cleanup tests
Signed-off-by: Prathamesh Mandke <46148373+pkmandke@users.noreply.github.com>
1 parent 6200c57 commit 26f7def

9 files changed

Lines changed: 771 additions & 137 deletions

pyproject.toml

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[build-system]
22
build-backend = "setuptools.build_meta"
3-
requires = [ "setuptools>=42", "wheel" ]
3+
requires = [ "setuptools>=42" ]
44

55
[project]
66
name = "coreai-opt"
@@ -50,8 +50,8 @@ dependencies = [
5050
name = "Apple Core AI Optimization Team"
5151
[project.optional-dependencies]
5252
coreai = [
53-
"coreai-core==1.0.0b1",
54-
"coreai-torch==0.4.0",
53+
"coreai-core==1.0.0b2",
54+
"coreai-torch>=0.4.0",
5555
"scikit-learn>=1.7.2",
5656
]
5757
[project.urls]
@@ -135,8 +135,8 @@ pre-commit = [
135135
]
136136
# TODO: deduplicate coreai dependencies across groups
137137
stable-coreai = [
138-
"coreai-core==1.0.0b1",
139-
"coreai-torch==0.4.0",
138+
"coreai-core==1.0.0b2",
139+
"coreai-torch>=0.4.0",
140140
"scikit-learn>=1.7.2",
141141
]
142142
tamm-export = []
@@ -193,6 +193,12 @@ conflicts = [
193193
],
194194
]
195195
[tool.uv.sources]
196+
# TEMPORARY: resolve coreai-torch from the module externalization API branch
197+
# instead of the PyPI release. That branch adds the `_patch_model_for_externalization`
198+
# and `_subexport_and_restore` entry points used by the externalization tests.
199+
# It lives on a fork; apple/coreai-torch does not carry it yet. Drop this entry
200+
# (and re-pin the versions above) once the work lands upstream and is released.
201+
coreai-torch = { git = "https://github.com/gokulkrishna98/coreai-torch.git", branch = "dev/gokul/module-externalization-api" }
196202
torch = [
197203
{ index = "pytorch-cpu", marker = "sys_platform != 'linux'" },
198204
{ index = "pytorch-cu128", marker = "sys_platform == 'linux'" },

tests/conftest.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,19 @@
3434
PerTensorGranularity,
3535
QuantizationScheme,
3636
QuantizationSpec,
37+
default_activation_quantization_spec,
38+
default_weight_quantization_spec,
3739
)
3840
from coreai_opt.quantization.spec.fake_quantize import _DefaultFakeQuantizeImpl
3941
from coreai_opt.quantization.spec.qparams_calculator import StaticQParamsCalculator
4042
from coreai_opt.quantization.spec.range_calculator import MinMaxRangeCalculator
43+
from tests.models.composite import ( # noqa: F401
44+
composite_rmsnorm_input,
45+
composite_rmsnorm_model,
46+
mnist_composite_rmsnorm_example_input,
47+
mnist_composite_rmsnorm_pretrained_model,
48+
mnist_composite_rmsnorm_pretrained_state,
49+
)
4150
from tests.models.mnist import ( # noqa: F401
4251
custom_test_mnist_model,
4352
mnist_data,
@@ -124,6 +133,27 @@ def _spec(dtype: torch.dtype | str) -> QuantizationSpec:
124133
)
125134

126135

136+
def make_graph_mode_ptq_config(*, quantize_activations: bool) -> QuantizerConfig:
137+
"""Build a graph-mode w8 (weight-only) or w8a8 PTQ QuantizerConfig.
138+
139+
Args:
140+
quantize_activations (bool): True for w8a8, False for w8 weight-only.
141+
142+
Returns:
143+
QuantizerConfig: Config with the default weight spec globally, plus the
144+
default activation spec on every op input/output when requested.
145+
"""
146+
activation_spec = default_activation_quantization_spec() if quantize_activations else None
147+
return QuantizerConfig(
148+
global_config=ModuleQuantizerConfig(
149+
op_state_spec={"weight": default_weight_quantization_spec()},
150+
op_input_spec={"*": activation_spec} if activation_spec else None,
151+
op_output_spec={"*": activation_spec} if activation_spec else None,
152+
),
153+
execution_mode="graph",
154+
)
155+
156+
127157
@pytest.fixture(autouse=True)
128158
def seed_every_test(request: pytest.FixtureRequest) -> None:
129159
"""Seeding policy for test reproducibility.

tests/export/export_utils.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -376,10 +376,14 @@ def convert(
376376
self,
377377
traced_model: torch.export.ExportedProgram,
378378
input_data: torch.Tensor,
379+
externalize_model: Any = None,
379380
**kwargs: Any,
380381
) -> AIProgram:
381382
_, _ = input_data, kwargs
382-
coreai_program = self._lower_to_coreai(traced_model)
383+
coreai_program = self._lower_to_coreai(
384+
traced_model,
385+
externalize_model=externalize_model,
386+
)
383387
assert type(coreai_program) is AIProgram
384388

385389
return coreai_program
@@ -457,10 +461,32 @@ def _verify_custom_ops_in_torch_program(
457461
@staticmethod
458462
def _lower_to_coreai(
459463
exported_program: torch.export.ExportedProgram,
464+
externalize_model: Any = None,
460465
) -> AIProgram:
461-
"""Lower exported program to Core AI."""
466+
"""Lower exported program to Core AI.
467+
468+
Args:
469+
exported_program: The exported program to lower.
470+
externalize_model: Optional ``torch.nn.Module`` that was marked in
471+
place by ``coreai_torch._patch_model_for_externalization``.
472+
When provided,
473+
``_subexport_and_restore(model, exported_program)`` is run to
474+
sub-export each marked composite (and restore the patched
475+
forwards); the resulting ``_ExternalizedExportedProgram`` list
476+
is passed to ``TorchConverter.add_exported_program`` via
477+
``_externalized_exported_programs`` so the composites survive
478+
lowering as opaque calls.
479+
"""
462480
converter = coreai_torch.TorchConverter()
463-
converter.add_exported_program(exported_program)
481+
externalized_exported_programs = (
482+
coreai_torch._subexport_and_restore(externalize_model, exported_program)
483+
if externalize_model is not None
484+
else None
485+
)
486+
converter.add_exported_program(
487+
exported_program,
488+
_externalized_exported_programs=externalized_exported_programs,
489+
)
464490
return converter.to_coreai()
465491

466492

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
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+
"""Externalize-specific structural tests for _patch_model_for_externalization
7+
in presence of coreai-opt graph mode quantization.
8+
9+
Test structural assertions: after ``_patch_model_for_externalization``
10+
patches a composite submodule's forward into a ``torch.library.custom_op``,
11+
the resulting opaque call_function node survives Graph-mode ``prepare`` + ``finalize``.
12+
Coverage spans:
13+
14+
- the RMSNorm composite under both w8 weight-only and w8a8:
15+
``test_composite_op_survives_prepare_and_finalize``
16+
- explicit quantization of the composite's own input/output boundary
17+
via a module-level config, by name and by type, on a bare composite, on a
18+
mixed model with other quantized ops, and on a multi-tensor (q / k / v) SDPA
19+
composite; a distinct dtype on the composite config proves it outranks the
20+
global spec at the boundary (``TestCompositeOpIOQuantization``).
21+
22+
End-to-end lowering and execution tests live in
23+
``tests/export/test_graph_mode_mlir_export.py::test_composite_externalize_export``.
24+
"""
25+
26+
from __future__ import annotations
27+
28+
import pytest
29+
import torch
30+
import torch.nn as nn
31+
from coreai_torch import ExternalizeSpec, _patch_model_for_externalization
32+
from coreai_torch.composite_ops import SDPA, RMSNormImpl
33+
34+
from coreai_opt import ExportBackend
35+
from coreai_opt.quantization import (
36+
ModuleQuantizerConfig,
37+
Quantizer,
38+
QuantizerConfig,
39+
)
40+
from coreai_opt.quantization.spec import (
41+
PerTensorGranularity,
42+
QuantizationScheme,
43+
QuantizationSpec,
44+
default_activation_quantization_spec,
45+
default_weight_quantization_spec,
46+
)
47+
from tests.conftest import make_graph_mode_ptq_config
48+
from tests.models.composite import (
49+
CompositeRMSNormModel,
50+
CompositeRMSNormOnlyModel,
51+
CompositeSDPAModel,
52+
)
53+
from tests.test_utils.general import (
54+
assert_single_call_function_node,
55+
get_quantize_dtype,
56+
is_coreai_dequantize,
57+
is_coreai_quantize,
58+
)
59+
60+
_RMSNORM_SPEC = ExternalizeSpec(
61+
target_class=RMSNormImpl,
62+
composite_op_name="rms_norm",
63+
composite_attrs=["axes", "eps"],
64+
)
65+
_SDPA_SPEC = ExternalizeSpec(
66+
target_class=SDPA,
67+
composite_op_name="scaled_dot_product_attention",
68+
composite_attrs=["scale", "is_causal", "window_size"],
69+
)
70+
71+
72+
@pytest.mark.parametrize(
73+
"quantize_activations",
74+
[
75+
pytest.param(False, id="w8-weight-only"),
76+
pytest.param(True, id="w8a8"),
77+
],
78+
)
79+
def test_composite_op_survives_prepare_and_finalize(
80+
composite_rmsnorm_model,
81+
composite_rmsnorm_input,
82+
quantize_activations: bool,
83+
) -> None:
84+
"""The externalized composite must remain a single opaque
85+
call_function node end-to-end, under both w8 and w8a8.
86+
87+
``_patch_model_for_externalization`` swaps the submodule's forward for a
88+
``torch.library.custom_op`` BEFORE the quantizer runs, so graph mode's
89+
annotator never sees the composite body and cannot insert q-dq
90+
inside it. The structural guarantee here is that the custom-op
91+
call survives ``Quantizer.prepare`` (which traces the model into
92+
a GraphModule and inserts observers) and ``Quantizer.finalize``
93+
(which converts observers into coreai q-dq / constexpr nodes),
94+
irrespective of whether activation observers are inserted on
95+
surrounding ops.
96+
"""
97+
model = composite_rmsnorm_model
98+
sample = composite_rmsnorm_input
99+
100+
_patch_model_for_externalization(model, [_RMSNORM_SPEC])
101+
op_name = model.norm._externalize_op_name
102+
target_substr = f"coreai_torch_ext.{op_name}"
103+
104+
quantizer = Quantizer(
105+
model, make_graph_mode_ptq_config(quantize_activations=quantize_activations)
106+
)
107+
prepared = quantizer.prepare((sample,))
108+
assert_single_call_function_node(prepared, target_substr, stage="prepared")
109+
110+
finalized = quantizer.finalize(backend=ExportBackend.CoreAI)
111+
assert_single_call_function_node(finalized, target_substr, stage="finalized")
112+
113+
114+
# Composite op I/O boundary quantization
115+
116+
# (model class, externalize spec, submodule attribute name, tensor input count).
117+
# All three models default to dim=32 and accept the same rank-3 fp16 sample.
118+
_BOUNDARY_CASES = [
119+
pytest.param(CompositeRMSNormOnlyModel, _RMSNORM_SPEC, "norm", 1, id="rmsnorm-only"),
120+
pytest.param(CompositeRMSNormModel, _RMSNORM_SPEC, "norm", 1, id="rmsnorm-mixed"),
121+
pytest.param(CompositeSDPAModel, _SDPA_SPEC, "composite", 3, id="sdpa-qkv"),
122+
]
123+
124+
125+
class TestCompositeOpIOQuantization:
126+
"""Ensure externalized composite's I/O boundary can be quantized
127+
via a module-level config, by name and by type.
128+
129+
The composite is opaque to the op-pattern annotator, but the custom-op
130+
node retains ``nn_module_stack`` metadata (path and type), so a module-level
131+
config can target it for i/o quantization at the boundary. A global config
132+
quantizes the rest of the model with the default activation dtype (int8) and
133+
the composite config provides a distinct ``_COMPOSITE_ACT_DTYPE`` (uint8) on
134+
the composite's edges. Module config outranks global, so the composite
135+
boundary must carry the composite dtype while every other quantized edge
136+
carries the global dtype.
137+
"""
138+
139+
_COMPOSITE_ACT_DTYPE = torch.uint8
140+
141+
@classmethod
142+
def _config(cls, spec: ExternalizeSpec, module_name: str, target_by: str) -> QuantizerConfig:
143+
# The composite config must use a dtype DISTINCT from the global
144+
# (default) activation dtype: a matching dtype collapses via observer
145+
# sharing into a vacuous no-op, so the composite's effect at the
146+
# boundary would not be observable.
147+
assert cls._COMPOSITE_ACT_DTYPE != default_activation_quantization_spec().dtype
148+
composite_act = QuantizationSpec(
149+
dtype=cls._COMPOSITE_ACT_DTYPE,
150+
qscheme=QuantizationScheme.SYMMETRIC,
151+
granularity=PerTensorGranularity(),
152+
)
153+
global_config = ModuleQuantizerConfig(
154+
op_state_spec={"weight": default_weight_quantization_spec()},
155+
op_input_spec={"*": default_activation_quantization_spec()},
156+
op_output_spec={"*": default_activation_quantization_spec()},
157+
)
158+
composite_config = ModuleQuantizerConfig(
159+
module_input_spec={"*": composite_act},
160+
module_output_spec={"*": composite_act},
161+
)
162+
if target_by == "name":
163+
scope = {"module_name_configs": {module_name: composite_config}}
164+
else:
165+
scope = {"module_type_configs": {spec.target_class: composite_config}}
166+
return QuantizerConfig(global_config=global_config, execution_mode="graph", **scope)
167+
168+
def _finalize(
169+
self,
170+
model: nn.Module,
171+
sample: torch.Tensor,
172+
spec: ExternalizeSpec,
173+
module_name: str,
174+
target_by: str,
175+
) -> tuple[torch.fx.GraphModule, str]:
176+
_patch_model_for_externalization(model, [spec])
177+
op_name = model.get_submodule(module_name)._externalize_op_name
178+
target_substr = f"coreai_torch_ext.{op_name}"
179+
180+
quantizer = Quantizer(model, self._config(spec, module_name, target_by))
181+
prepared = quantizer.prepare((sample,))
182+
assert_single_call_function_node(prepared, target_substr, stage="prepared")
183+
184+
finalized = quantizer.finalize(backend=ExportBackend.CoreAI)
185+
assert_single_call_function_node(finalized, target_substr, stage="finalized")
186+
return finalized, target_substr
187+
188+
def _assert_boundary_quantized(
189+
self,
190+
finalized: torch.fx.GraphModule,
191+
target_substr: str,
192+
num_tensor_inputs: int,
193+
) -> None:
194+
composite = assert_single_call_function_node(finalized, target_substr, stage="finalized")
195+
196+
# A composite's non-tensor captured attributes appear either as baked-in
197+
# constants (SDPA's scale / is_causal / window_size) or as a get_attr arg
198+
# (RMSNorm's scale), and neither is a quantized activation edge, so
199+
# filter get_attr out rather than indexing fixed arg positions.
200+
tensor_inputs = [
201+
a for a in composite.args if isinstance(a, torch.fx.Node) and a.op != "get_attr"
202+
]
203+
assert len(tensor_inputs) == num_tensor_inputs, (
204+
f"Expected {num_tensor_inputs} tensor inputs to {composite.name}, "
205+
f"got {[n.name for n in tensor_inputs]}"
206+
)
207+
for act_input in tensor_inputs:
208+
assert is_coreai_dequantize(act_input.target)
209+
assert get_quantize_dtype(act_input.args[0]) == self._COMPOSITE_ACT_DTYPE
210+
211+
users = list(composite.users)
212+
assert len(users) == 1
213+
assert is_coreai_quantize(users[0].target)
214+
assert get_quantize_dtype(users[0]) == self._COMPOSITE_ACT_DTYPE
215+
216+
composite_dtype_quant = [
217+
n
218+
for n in finalized.graph.nodes
219+
if is_coreai_quantize(n.target) and get_quantize_dtype(n) == self._COMPOSITE_ACT_DTYPE
220+
]
221+
assert len(composite_dtype_quant) == num_tensor_inputs + 1
222+
223+
@pytest.mark.parametrize("target_by", ["name", "type"])
224+
@pytest.mark.parametrize("model_cls, spec, module_name, num_tensor_inputs", _BOUNDARY_CASES)
225+
def test_composite_boundary_quantized(
226+
self,
227+
model_cls: type[nn.Module],
228+
spec: ExternalizeSpec,
229+
module_name: str,
230+
num_tensor_inputs: int,
231+
target_by: str,
232+
) -> None:
233+
model = model_cls().eval().half()
234+
sample = torch.randn(2, 4, 32, dtype=torch.float16)
235+
finalized, target_substr = self._finalize(model, sample, spec, module_name, target_by)
236+
self._assert_boundary_quantized(finalized, target_substr, num_tensor_inputs)

0 commit comments

Comments
 (0)