Skip to content

Commit 218cc45

Browse files
rascaniclaude
andauthored
V2 quantizer: fix IO-boundary shared clusters left in float (#20291)
Summary: Shared-op clusters (e.g. `cat`, `view`, `reshape`) on the quantized IO boundary were silently left in float by the composable TOSA quantizer (`_TOSAQuantizerV2`), causing them to fall off the Ethos-U integer delegate onto CPU. `SharedQspecQuantizer` propagates a qspec only from already-quantized neighbors. A cluster whose only quantized neighbors are a uint8 model input (intentionally skipped by `_skip_shared_qspec_from_io` to confine uint8 to the IO boundary) and/or an input-state placeholder with no `output_qspec` had no qspec to propagate, so it was rejected and remained in float. The fix adds `_is_quantized_io_boundary`, which detects annotated `placeholder`/`output` nodes that signal the cluster is on the quantized data path even when their qspec is filtered. `_get_shared_clique` now returns a `touches_quantized_io` flag alongside the usual results. When `_annotate_shared_cluster` finds an empty `adjacent_qspecs` but a boundary-touching cluster, it initiates quantization from the global config input-activation qspec instead of rejecting. `_TOSAQuantizerV2.set_global` now also propagates to `shared_qspec_quantizer.global_config` so the fallback is wired automatically. This restores the correctness fix from D107320847, which was abandoned because its other fix (parameter-operand weight misclassification) had already been resolved via the `is_weight` `PARAMETER_TARGETS` refactor. This change was developed with assistance from Claude. Differential Revision: D108662081 cc @digantdesai @freddan80 @per @zingo @oscarandersson8218 @mansnils @Sebastian-Larsson @robell --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 01e951f commit 218cc45

3 files changed

Lines changed: 93 additions & 3 deletions

File tree

backends/arm/quantizer/arm_quantizer.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1220,6 +1220,7 @@ def set_global(
12201220
quantization_config, node_finder, self.pattern_matcher
12211221
)
12221222
self.global_config = quantization_config
1223+
self.shared_qspec_quantizer.global_config = quantization_config
12231224
return self
12241225

12251226
def set_node_target(

backends/arm/quantizer/arm_quantizer_utils.py

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,7 @@ class SharedQspecQuantizer(Quantizer, QuantizerReporterUser):
480480
def __init__(self, targets: Optional[list[Callable[..., object]]] = None) -> None:
481481
super().__init__()
482482
QuantizerReporterUser.__init__(self)
483+
self.global_config: Optional[QuantizationConfig] = None
483484
if targets is None:
484485
self.targets = self.SHARED_QSPEC_OPS_DEFAULT
485486
self.support_config_path = (
@@ -551,10 +552,24 @@ def _append_input_qspec(
551552
return
552553
adjacent_qspecs.append(input_qspec)
553554

554-
def _get_shared_clique(self, root_node: Node) -> tuple[set[Node], list[Any]]:
555+
def _is_quantized_io_boundary(self, node: Node) -> bool:
556+
"""Return True if node is a model input/output annotated by the
557+
quantizer.
558+
559+
Such a node sits on the quantized interface but its qspec is often
560+
filtered out of shared-cluster propagation: a uint8 IO qspec is skipped
561+
by _skip_shared_qspec_from_io, and an input-state placeholder may carry
562+
an annotation with no output_qspec. Its presence still signals that the
563+
cluster is on the quantized data path.
564+
565+
"""
566+
return node.op in ("placeholder", "output") and self._is_annotated(node)
567+
568+
def _get_shared_clique(self, root_node: Node) -> tuple[set[Node], list[Any], bool]:
555569
shared_nodes = set()
556570
bfs_queue = [root_node]
557571
adjacent_qspecs: list[Any] = []
572+
touches_quantized_io = False
558573

559574
while bfs_queue:
560575
node = bfs_queue.pop(0)
@@ -563,12 +578,14 @@ def _get_shared_clique(self, root_node: Node) -> tuple[set[Node], list[Any]]:
563578
for input_node in node.all_input_nodes:
564579
self._maybe_enqueue_shared_node(input_node, shared_nodes, bfs_queue)
565580
self._append_output_qspec(input_node, adjacent_qspecs)
581+
touches_quantized_io |= self._is_quantized_io_boundary(input_node)
566582

567583
for output_node in node.users.keys():
568584
self._maybe_enqueue_shared_node(output_node, shared_nodes, bfs_queue)
569585
self._append_input_qspec(output_node, node, adjacent_qspecs)
586+
touches_quantized_io |= self._is_quantized_io_boundary(output_node)
570587

571-
return shared_nodes, adjacent_qspecs
588+
return shared_nodes, adjacent_qspecs, touches_quantized_io
572589

573590
def _should_skip_while_shared_qspec(self, node: Node) -> bool:
574591
return node.target == torch.ops.higher_order.while_loop and bool(
@@ -623,7 +640,25 @@ def _annotate_shared_cluster(self, root_node: Node) -> None:
623640
)
624641
return
625642

626-
shared_nodes, adjacent_qspecs = self._get_shared_clique(root_node)
643+
shared_nodes, adjacent_qspecs, touches_quantized_io = self._get_shared_clique(
644+
root_node
645+
)
646+
647+
# If there is no neighbor qspec to propagate but the cluster sits on the
648+
# quantized I/O boundary (e.g. a state-passthrough cat whose only neighbors
649+
# are a uint8 model input skipped by _skip_shared_qspec_from_io and an
650+
# input-state placeholder with no output_qspec), initiate quantization from
651+
# the global config rather than leaving the cluster in float. Without this,
652+
# such clusters fall off the integer delegate onto CPU.
653+
if (
654+
len(adjacent_qspecs) == 0
655+
and touches_quantized_io
656+
and self.global_config is not None
657+
):
658+
global_input_qspec = self.global_config.get_input_act_qspec()
659+
if global_input_qspec is not None:
660+
adjacent_qspecs = [global_input_qspec]
661+
627662
node_order = {node: index for index, node in enumerate(root_node.graph.nodes)}
628663
ordered_nodes = sorted(shared_nodes, key=lambda node: node_order.get(node, 0))
629664

backends/arm/test/quantizer/test_uint8_io_quantization.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@
66
import torch
77

88
from executorch.backends.arm.quantizer import (
9+
get_symmetric_quantization_config,
910
get_uint8_io_quantization_config,
1011
TOSAQuantizer,
1112
)
1213
from executorch.backends.arm.test import common
1314
from executorch.backends.arm.test.tester.test_pipeline import QuantizationPipeline
15+
from torchao.quantization.pt2e.quantize_pt2e import prepare_pt2e
16+
from torchao.quantization.pt2e.quantizer.quantizer import Q_ANNOTATION_KEY
1417

1518

1619
class SimpleMLP(torch.nn.Module):
@@ -24,6 +27,21 @@ def forward(self, x):
2427
return self.fc2(self.relu(self.fc1(x)))
2528

2629

30+
class CloneAtIoBoundary(torch.nn.Module):
31+
"""Zero-arithmetic cluster whose only adjacent annotated neighbours are
32+
uint8-annotated IO nodes (input placeholder + graph output).
33+
34+
With set_global(int8) + set_io(uint8), both the placeholder and the output
35+
node carry uint8 qspecs that _skip_shared_qspec_from_io filters out, leaving
36+
adjacent_qspecs empty. Before the IO-boundary fallback fix in
37+
SharedQspecQuantizer, this caused the cluster to stay in float.
38+
39+
"""
40+
41+
def forward(self, x: torch.Tensor) -> torch.Tensor:
42+
return torch.clone(x)
43+
44+
2745
def test_uint8_io_quantization_config_tosa_INT_applies_to_io():
2846
model = SimpleMLP().eval()
2947
test_data = (torch.rand(1, 4),)
@@ -40,3 +58,39 @@ def test_uint8_io_quantization_config_tosa_INT_applies_to_io():
4058
output_qspecs={io_config.output_activation: 1},
4159
)
4260
pipeline.run()
61+
62+
63+
def test_io_boundary_shared_cluster_is_quantized():
64+
"""Regression: a zero-arithmetic cluster adjacent only to uint8-annotated IO
65+
nodes must be annotated with the global int8 qspec, not left in float.
66+
67+
_skip_shared_qspec_from_io filters the uint8 qspec from IO nodes, so when
68+
the cluster's only neighbours are such nodes adjacent_qspecs ends up empty.
69+
The fix in SharedQspecQuantizer detects the IO-boundary via
70+
_is_quantized_io_boundary and falls back to global_config.get_input_act_qspec().
71+
"""
72+
model = CloneAtIoBoundary().eval()
73+
test_data = (torch.rand(1, 4),)
74+
compile_spec = common.get_tosa_compile_spec("TOSA-1.0+INT")
75+
76+
quantizer = TOSAQuantizer(compile_spec, use_composable_quantizer=True)
77+
quantizer.set_global(get_symmetric_quantization_config())
78+
quantizer.set_io(get_uint8_io_quantization_config())
79+
80+
exported = torch.export.export(model, test_data, strict=True)
81+
prepared = prepare_pt2e(exported.module(), quantizer)
82+
83+
clone_nodes = [
84+
n
85+
for n in prepared.graph.nodes
86+
if n.op == "call_function" and n.target == torch.ops.aten.clone.default
87+
]
88+
assert len(clone_nodes) == 1, f"Expected 1 clone node, got {len(clone_nodes)}"
89+
clone_node = clone_nodes[0]
90+
91+
assert (
92+
Q_ANNOTATION_KEY in clone_node.meta
93+
), "clone node was not annotated — IO-boundary cluster stayed in float"
94+
assert (
95+
clone_node.meta[Q_ANNOTATION_KEY].output_qspec is not None
96+
), "clone node has no output_qspec — IO-boundary cluster stayed in float"

0 commit comments

Comments
 (0)