Skip to content

Commit efb18c1

Browse files
authored
Arm backend: Keep internal shared qspec with uint8 IO (#20978)
cc @digantdesai @freddan80 @per @zingo @oscarandersson8218 @mansnils @Sebastian-Larsson @robell @rascani
1 parent 47fd0d8 commit efb18c1

2 files changed

Lines changed: 77 additions & 21 deletions

File tree

backends/arm/quantizer/arm_quantizer_utils.py

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -598,11 +598,14 @@ def _model_has_uint8_io(self, model: torch.fx.GraphModule) -> bool:
598598
self._is_uint8_quantized_io_boundary(node) for node in model.graph.nodes
599599
)
600600

601-
def _get_shared_clique(self, root_node: Node) -> tuple[set[Node], list[Any], bool]:
601+
def _get_shared_clique(
602+
self, root_node: Node
603+
) -> tuple[set[Node], list[Any], bool, bool]:
602604
shared_nodes = set()
603605
bfs_queue = [root_node]
604606
adjacent_qspecs: list[Any] = []
605607
touches_quantized_io = False
608+
touches_uint8_quantized_io = False
606609

607610
while bfs_queue:
608611
node = bfs_queue.pop(0)
@@ -612,13 +615,24 @@ def _get_shared_clique(self, root_node: Node) -> tuple[set[Node], list[Any], boo
612615
self._maybe_enqueue_shared_node(input_node, shared_nodes, bfs_queue)
613616
self._append_output_qspec(input_node, adjacent_qspecs)
614617
touches_quantized_io |= self._is_quantized_io_boundary(input_node)
618+
touches_uint8_quantized_io |= self._is_uint8_quantized_io_boundary(
619+
input_node
620+
)
615621

616622
for output_node in node.users.keys():
617623
self._maybe_enqueue_shared_node(output_node, shared_nodes, bfs_queue)
618624
self._append_input_qspec(output_node, node, adjacent_qspecs)
619625
touches_quantized_io |= self._is_quantized_io_boundary(output_node)
626+
touches_uint8_quantized_io |= self._is_uint8_quantized_io_boundary(
627+
output_node
628+
)
620629

621-
return shared_nodes, adjacent_qspecs, touches_quantized_io
630+
return (
631+
shared_nodes,
632+
adjacent_qspecs,
633+
touches_quantized_io,
634+
touches_uint8_quantized_io,
635+
)
622636

623637
def _should_skip_while_shared_qspec(self, node: Node) -> bool:
624638
return node.target == torch.ops.higher_order.while_loop and bool(
@@ -673,9 +687,12 @@ def _annotate_shared_cluster(self, root_node: Node) -> None:
673687
)
674688
return
675689

676-
shared_nodes, adjacent_qspecs, touches_quantized_io = self._get_shared_clique(
677-
root_node
678-
)
690+
(
691+
shared_nodes,
692+
adjacent_qspecs,
693+
touches_quantized_io,
694+
touches_uint8_quantized_io,
695+
) = self._get_shared_clique(root_node)
679696

680697
# If there is no neighbor qspec to propagate but the cluster sits on the
681698
# quantized I/O boundary (e.g. a state-passthrough cat whose only neighbors
@@ -695,6 +712,15 @@ def _annotate_shared_cluster(self, root_node: Node) -> None:
695712
node_order = {node: index for index, node in enumerate(root_node.graph.nodes)}
696713
ordered_nodes = sorted(shared_nodes, key=lambda node: node_order.get(node, 0))
697714

715+
if touches_uint8_quantized_io and any(
716+
node.target in self._UINT8_IO_BRIDGE_OPS for node in shared_nodes
717+
):
718+
self.report_reject(
719+
ordered_nodes,
720+
"Shared-qspec bridge cluster touches uint8 model IO.",
721+
)
722+
return
723+
698724
if self._annotate_while_with_additional_inputs(root_node, adjacent_qspecs):
699725
return
700726

@@ -734,20 +760,9 @@ def _annotate_shared_cluster(self, root_node: Node) -> None:
734760
return
735761

736762
def annotate(self, model: torch.fx.GraphModule) -> None: # type: ignore[override]
737-
targets = self.targets
738-
if self._model_has_uint8_io(model):
739-
targets = [
740-
target for target in targets if target not in self._UINT8_IO_BRIDGE_OPS
741-
]
742-
743-
original_targets = self.targets
744-
self.targets = targets
745-
try:
746-
for node in model.graph.nodes:
747-
if node.target in self.targets and not self._is_annotated(node):
748-
self._annotate_shared_cluster(node)
749-
finally:
750-
self.targets = original_targets
763+
for node in model.graph.nodes:
764+
if node.target in self.targets and not self._is_annotated(node):
765+
self._annotate_shared_cluster(node)
751766

752767
def validate(self, model: torch.fx.GraphModule) -> bool: # type: ignore[override]
753768
return True

backends/arm/test/quantizer/test_uint8_io_quantization.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,19 @@ def forward(self, img0: torch.Tensor, img1: torch.Tensor) -> torch.Tensor:
5050
return torch.clone(merged)
5151

5252

53+
class InternalCatBetweenConvs(torch.nn.Module):
54+
def __init__(self):
55+
super().__init__()
56+
self.conv0 = torch.nn.Conv2d(3, 4, 1)
57+
self.conv1 = torch.nn.Conv2d(3, 4, 1)
58+
self.conv2 = torch.nn.Conv2d(8, 4, 1)
59+
60+
def forward(self, x: torch.Tensor) -> torch.Tensor:
61+
x0 = self.conv0(x)
62+
x1 = self.conv1(x)
63+
return self.conv2(torch.cat([x0, x1], dim=1))
64+
65+
5366
def _get_observer_scale(prepared, observer_node_name: str) -> float:
5467
observer = prepared.get_submodule(observer_node_name)
5568
scale, _ = observer.calculate_qparams()
@@ -129,8 +142,36 @@ def test_cat_does_not_bridge_shared_qspec_clusters():
129142
graph_nodes = {node.name: node for node in prepared.graph.nodes}
130143
img0_observer = next(iter(graph_nodes["img0"].users))
131144
img1_observer = next(iter(graph_nodes["img1"].users))
132-
final_cat_observer = next(iter(graph_nodes["cat_1"].users))
145+
clone_observer = next(iter(graph_nodes["clone"].users))
133146

134147
assert _get_observer_scale(prepared, img0_observer.target) < 0.01
135148
assert _get_observer_scale(prepared, img1_observer.target) < 0.01
136-
assert _get_observer_scale(prepared, final_cat_observer.target) > 0.05
149+
assert Q_ANNOTATION_KEY not in graph_nodes["cat_1"].meta
150+
assert _get_observer_scale(prepared, clone_observer.target) > 0.05
151+
152+
153+
def test_internal_cat_still_shares_qspec_with_uint8_io():
154+
"""Regression: preserved uint8 model IO must not disable shared-qspec
155+
annotation for internal cats between quantized operators.
156+
"""
157+
model = InternalCatBetweenConvs().eval()
158+
test_data = (torch.rand(1, 3, 8, 8),)
159+
compile_spec = common.get_tosa_compile_spec("TOSA-1.0+INT")
160+
161+
tosa_quantizer = TOSAQuantizer(compile_spec, use_composable_quantizer=True)
162+
tosa_quantizer.set_global(get_symmetric_quantization_config())
163+
tosa_quantizer.set_io(get_uint8_io_quantization_config())
164+
165+
exported = torch.export.export(model, test_data, strict=True)
166+
prepared = prepare_pt2e(exported.module(), tosa_quantizer)
167+
168+
cat_nodes = [
169+
n
170+
for n in prepared.graph.nodes
171+
if n.op == "call_function" and n.target == torch.ops.aten.cat.default
172+
]
173+
assert len(cat_nodes) == 1, f"Expected 1 cat node, got {len(cat_nodes)}"
174+
cat_node = cat_nodes[0]
175+
176+
assert Q_ANNOTATION_KEY in cat_node.meta
177+
assert cat_node.meta[Q_ANNOTATION_KEY].output_qspec is not None

0 commit comments

Comments
 (0)