Skip to content

Commit 0699c5d

Browse files
authored
[ExecuTorch][Core AI][3/x] PT2E quantizer front-end (pytorch#21511)
Adds `CoreAIQuantizer`, a PT2E front end wrapping `coreai_opt`. `convert()` runs the full `coreai_opt` finalize (convert_pt2e, conv/bn fold, Q/DQ to `coreai::` rewrite) so the graph is export ready, then lowers through the Core AI partitioner. Includes quantization and quantized lowering tests.
1 parent 4d3b46d commit 0699c5d

3 files changed

Lines changed: 294 additions & 0 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
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+
from executorch.backends.apple.coreai.quantizer.quantizer import CoreAIQuantizer
8+
9+
__all__ = [
10+
"CoreAIQuantizer",
11+
]
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
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+
"""Core AI quantization front-end for ExecuTorch.
8+
9+
Keeps the canonical ExecuTorch quantization pipeline::
10+
11+
prepare -> calibrate -> convert -> export -> to_edge_transform_and_lower
12+
13+
``CoreAIQuantizer.convert()`` produces the export-ready, fully quantized graph
14+
(Core AI ``coreai::`` ops). It intentionally runs the entire ``coreai_opt``
15+
``finalize(CoreAI)`` (``convert_pt2e`` + conv/bn fold + Q/DQ to ``coreai::``
16+
rewrite + kv-cache relocation), because that whole rewrite must run before
17+
``torch.export``: a graph still carrying ``coreai_opt`` fake-quant cannot be
18+
strict-``torch.export``ed (its ``FakeQuantize.forward`` has a data-dependent
19+
guard). Nothing quant-related is left for the backend ``preprocess`` to do; it
20+
simply lowers the ``coreai::`` ops via ``add_exported_program``.
21+
22+
``coreai_opt`` (the ``coreai-optimization`` package) is imported lazily so this
23+
module stays importable in environments without it installed.
24+
"""
25+
26+
from __future__ import annotations
27+
28+
from typing import Any, Optional, Sequence
29+
30+
from torch import fx, nn
31+
32+
33+
class CoreAIQuantizer:
34+
"""PT2E quantization front-end for Core AI, wrapping ``coreai_opt``.
35+
36+
Standard PT2E lifecycle: :meth:`prepare` (exports + inserts fake-quant),
37+
:meth:`calibration_mode` / :meth:`training_mode`, then :meth:`convert`.
38+
39+
Unlike stock PT2E (where ``convert`` yields a backend-agnostic Q/DQ graph),
40+
:meth:`convert` here returns the Core AI quantized graph (``coreai::`` ops),
41+
because ``coreai_opt``'s fake-quant has no meaningful generic Q/DQ stage and
42+
the full rewrite must complete before ``torch.export``.
43+
44+
Args:
45+
model: the ``nn.Module`` to quantize.
46+
config: a ``coreai_opt.quantization.QuantizerConfig``. Defaults to
47+
``coreai_opt``'s default (int8 weight + activation) when ``None``.
48+
"""
49+
50+
def __init__(self, model: nn.Module, config: Optional[Any] = None) -> None:
51+
from coreai_opt.quantization import Quantizer, QuantizerConfig
52+
53+
self._quantizer = Quantizer(model, config or QuantizerConfig())
54+
self._prepared: Optional[fx.GraphModule] = None
55+
56+
def prepare(
57+
self,
58+
example_inputs: Sequence[Any],
59+
dynamic_shapes: Any = None,
60+
) -> fx.GraphModule:
61+
"""PT2E prepare: export + insert fake-quant. Returns the prepared graph."""
62+
self._prepared = self._quantizer.prepare(
63+
tuple(example_inputs), dynamic_shapes=dynamic_shapes
64+
)
65+
return self._prepared
66+
67+
def _require_prepared(self, method: str) -> None:
68+
if self._prepared is None:
69+
raise RuntimeError(f"Call prepare() before {method}().")
70+
71+
def calibration_mode(self):
72+
"""Context manager to collect activation statistics on calibration data."""
73+
self._require_prepared("calibration_mode")
74+
return self._quantizer.calibration_mode()
75+
76+
def training_mode(self):
77+
"""Context manager for quantization-aware training."""
78+
self._require_prepared("training_mode")
79+
return self._quantizer.training_mode()
80+
81+
def convert(self) -> fx.GraphModule:
82+
"""Produce the export-ready, fully quantized Core AI graph.
83+
84+
Runs the entire ``coreai_opt`` ``finalize(CoreAI)`` (``convert_pt2e`` +
85+
conv/bn fold + Q/DQ -> ``coreai::`` rewrite + kv-cache relocation). The
86+
result has no fake-quant, so it can be strict-``torch.export``ed and then
87+
lowered via ``to_edge_transform_and_lower([CoreAIPartitioner()])``.
88+
"""
89+
from coreai_opt.common import ExportBackend
90+
91+
self._require_prepared("convert")
92+
return self._quantizer.finalize(backend=ExportBackend.CoreAI)
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
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+
import unittest
8+
from collections import Counter
9+
10+
import torch
11+
import torch.nn as nn
12+
13+
from executorch.backends.apple.coreai.partition.partitioner import CoreAIPartitioner
14+
from executorch.backends.apple.coreai.quantizer.quantizer import CoreAIQuantizer
15+
from executorch.exir import to_edge_transform_and_lower
16+
from executorch.exir.lowered_backend_module import (
17+
executorch_call_delegate,
18+
get_lowered_backend_modules,
19+
)
20+
21+
22+
def _coreai_quant_op_names(graph_module):
23+
"""Names of call_function targets in a converted graph (test helper)."""
24+
return [
25+
getattr(node.target, "__name__", str(node.target))
26+
for node in graph_module.graph.nodes
27+
if node.op == "call_function"
28+
]
29+
30+
31+
def _model():
32+
return nn.Sequential(nn.Linear(32, 32), nn.ReLU(), nn.Linear(32, 32)).eval()
33+
34+
35+
class _ConvBN(nn.Module):
36+
def __init__(self):
37+
super().__init__()
38+
self.conv = nn.Conv2d(3, 8, 3)
39+
self.bn = nn.BatchNorm2d(8)
40+
self.relu = nn.ReLU()
41+
42+
def forward(self, x):
43+
return self.relu(self.bn(self.conv(x)))
44+
45+
46+
def _full_finalize(model, example_inputs):
47+
"""coreai_opt standalone finalize(CoreAI) for equivalence checks."""
48+
from coreai_opt.common import ExportBackend
49+
from coreai_opt.quantization import Quantizer, QuantizerConfig
50+
51+
q = Quantizer(model, QuantizerConfig())
52+
prepared = q.prepare(example_inputs)
53+
with q.calibration_mode():
54+
prepared(*example_inputs)
55+
return q.finalize(backend=ExportBackend.CoreAI)
56+
57+
58+
class CoreAIQuantizerTest(unittest.TestCase):
59+
def setUp(self):
60+
self.example_inputs = (torch.randn(2, 32),)
61+
62+
def _convert(self, model):
63+
q = CoreAIQuantizer(model)
64+
prepared = q.prepare(self.example_inputs)
65+
with q.calibration_mode():
66+
prepared(*self.example_inputs)
67+
return q.convert()
68+
69+
def test_convert_produces_coreai_quant_ops(self):
70+
names = set(_coreai_quant_op_names(self._convert(_model())))
71+
self.assertIn("quantize", names)
72+
self.assertIn("dequantize", names)
73+
self.assertIn("constexpr_blockwise_shift_scale", names)
74+
75+
def test_convert_matches_full_finalize(self):
76+
# convert() IS the full coreai_opt finalize(CoreAI).
77+
converted = self._convert(_model())
78+
full = _full_finalize(_model(), self.example_inputs)
79+
self.assertEqual(
80+
Counter(_coreai_quant_op_names(converted)),
81+
Counter(_coreai_quant_op_names(full)),
82+
)
83+
84+
def test_convert_output_is_exportable(self):
85+
# The key property: no fake-quant remains, so strict torch.export works.
86+
converted = self._convert(_model())
87+
ep = torch.export.export(converted, self.example_inputs)
88+
namespaces = {
89+
getattr(n.target, "namespace", None)
90+
for n in ep.graph.nodes
91+
if n.op == "call_function"
92+
}
93+
# Quant ops are lowered into the coreai namespace after export.
94+
self.assertIn("coreai", namespaces)
95+
96+
def test_prepare_is_required_before_convert(self):
97+
q = CoreAIQuantizer(_model())
98+
with self.assertRaises(RuntimeError):
99+
q.convert()
100+
101+
def test_prepare_is_required_before_the_mode_context_managers(self):
102+
"""Otherwise coreai_opt's own error surfaces instead of ours."""
103+
for method in ("calibration_mode", "training_mode"):
104+
with self.subTest(method):
105+
q = CoreAIQuantizer(_model())
106+
with self.assertRaisesRegex(RuntimeError, f"before {method}"):
107+
getattr(q, method)()
108+
109+
110+
class CoreAIQuantizerConvBNTest(unittest.TestCase):
111+
def setUp(self):
112+
self.example_inputs = (torch.randn(1, 3, 16, 16),)
113+
114+
def _convert(self, model):
115+
q = CoreAIQuantizer(model)
116+
prepared = q.prepare(self.example_inputs)
117+
with q.calibration_mode():
118+
prepared(*self.example_inputs)
119+
return q.convert()
120+
121+
def test_convert_folds_conv_bn(self):
122+
# convert() folds conv+bn (pre-export) so no batch_norm survives.
123+
names = _coreai_quant_op_names(self._convert(_ConvBN().eval()))
124+
self.assertTrue(any("conv" in n for n in names), names)
125+
self.assertFalse(
126+
any("batch_norm" in n for n in names),
127+
f"batch_norm was not folded: {names}",
128+
)
129+
130+
def test_convert_matches_full_finalize_conv_bn(self):
131+
converted = self._convert(_ConvBN().eval())
132+
full = _full_finalize(_ConvBN().eval(), self.example_inputs)
133+
self.assertEqual(
134+
Counter(_coreai_quant_op_names(converted)),
135+
Counter(_coreai_quant_op_names(full)),
136+
)
137+
138+
139+
class CoreAIQuantizedLoweringTest(unittest.TestCase):
140+
"""Full quantized lowering: quantize -> export -> to_edge_transform_and_lower."""
141+
142+
def setUp(self):
143+
self.example_inputs = (torch.randn(2, 32),)
144+
145+
def _lower(self):
146+
q = CoreAIQuantizer(_model())
147+
prepared = q.prepare(self.example_inputs)
148+
with q.calibration_mode():
149+
prepared(*self.example_inputs)
150+
ep = torch.export.export(q.convert(), self.example_inputs)
151+
return to_edge_transform_and_lower(ep, partitioner=[CoreAIPartitioner()])
152+
153+
def test_quantized_linear_lowers_to_coreai(self):
154+
lowered = self._lower()
155+
gm = lowered.exported_program().graph_module
156+
157+
delegates = [
158+
n
159+
for n in gm.graph.nodes
160+
if n.op == "call_function" and n.target is executorch_call_delegate
161+
]
162+
leftover = [
163+
n
164+
for n in gm.graph.nodes
165+
if n.op == "call_function"
166+
and n.target is not executorch_call_delegate
167+
and "getitem" not in str(n.target)
168+
]
169+
# The quantized subgraph (incl. coreai:: quant ops) is delegated and
170+
# converted by coreai-torch, leaving nothing outside the delegate.
171+
self.assertGreaterEqual(len(delegates), 1)
172+
self.assertEqual(
173+
leftover,
174+
[],
175+
f"unexpected ops left outside the delegate: "
176+
f"{[str(n.target) for n in leftover]}",
177+
)
178+
179+
def test_quantized_lowers_through_to_executorch(self):
180+
lowered = self._lower()
181+
# A single CoreAI delegate, with its asset embedded (NDS present).
182+
lbms = get_lowered_backend_modules(lowered.exported_program().graph_module)
183+
self.assertEqual([lbm.backend_id for lbm in lbms], ["CoreAIBackend"])
184+
self.assertIsNotNone(lbms[0].named_data_store_output)
185+
# It lowers all the way to a non-empty .pte.
186+
buffer = bytes(lowered.to_executorch().buffer)
187+
self.assertGreater(len(buffer), 0)
188+
189+
190+
if __name__ == "__main__":
191+
unittest.main()

0 commit comments

Comments
 (0)