Skip to content

Commit 47fd0d8

Browse files
authored
Arm backend: Avoid shared qspec bridges for uint8 IO (#20976)
When preserved uint8 model IO is used, SharedQspecQuantizer can merge image-scale tensors with wider internal activation ranges. Avoid using cat, concatenate, stack, pixel_shuffle, and slice as fallback shared-qspec bridge ops for graphs with uint8 IO. This keeps normal int8 quantisation behaviour unchanged while preserving image input qparams for uint8 IO flows. Add a regression test covering image-like uint8 IO joined with a high-range branch. cc @digantdesai @freddan80 @per @zingo @oscarandersson8218 @mansnils @Sebastian-Larsson @robell @rascani Signed-off-by: Baris Demir <baris.demir@arm.com>
1 parent f6cef83 commit 47fd0d8

2 files changed

Lines changed: 87 additions & 3 deletions

File tree

backends/arm/quantizer/arm_quantizer_utils.py

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,13 @@ class SharedQspecQuantizer(Quantizer, QuantizerReporterUser):
476476
torch.ops.higher_order.while_loop,
477477
torch.ops.higher_order.cond,
478478
]
479+
_UINT8_IO_BRIDGE_OPS: set[Callable[..., object]] = {
480+
torch.ops.aten.cat.default,
481+
torch.ops.aten.concatenate.default,
482+
torch.ops.aten.stack.default,
483+
torch.ops.aten.pixel_shuffle.default,
484+
torch.ops.aten.slice.Tensor,
485+
}
479486

480487
def __init__(self, targets: Optional[list[Callable[..., object]]] = None) -> None:
481488
super().__init__()
@@ -565,6 +572,32 @@ def _is_quantized_io_boundary(self, node: Node) -> bool:
565572
"""
566573
return node.op in ("placeholder", "output") and self._is_annotated(node)
567574

575+
def _qspec_contains_uint8(self, qspec: Any) -> bool:
576+
if isinstance(qspec, list):
577+
return any(self._qspec_contains_uint8(element) for element in qspec)
578+
return getattr(qspec, "dtype", None) == torch.uint8
579+
580+
def _is_uint8_quantized_io_boundary(self, node: Node) -> bool:
581+
if node.op not in ("placeholder", "output") or not self._is_annotated(node):
582+
return False
583+
584+
annotation = node.meta.get(Q_ANNOTATION_KEY)
585+
if annotation is None:
586+
return False
587+
588+
if self._qspec_contains_uint8(annotation.output_qspec):
589+
return True
590+
591+
return any(
592+
self._qspec_contains_uint8(qspec)
593+
for qspec in annotation.input_qspec_map.values()
594+
)
595+
596+
def _model_has_uint8_io(self, model: torch.fx.GraphModule) -> bool:
597+
return any(
598+
self._is_uint8_quantized_io_boundary(node) for node in model.graph.nodes
599+
)
600+
568601
def _get_shared_clique(self, root_node: Node) -> tuple[set[Node], list[Any], bool]:
569602
shared_nodes = set()
570603
bfs_queue = [root_node]
@@ -701,9 +734,20 @@ def _annotate_shared_cluster(self, root_node: Node) -> None:
701734
return
702735

703736
def annotate(self, model: torch.fx.GraphModule) -> None: # type: ignore[override]
704-
for node in model.graph.nodes:
705-
if node.target in self.targets and not self._is_annotated(node):
706-
self._annotate_shared_cluster(node)
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
707751

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

backends/arm/test/quantizer/test_uint8_io_quantization.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,20 @@ def forward(self, x: torch.Tensor) -> torch.Tensor:
4242
return torch.clone(x)
4343

4444

45+
class CatWithHighRangeBranch(torch.nn.Module):
46+
def forward(self, img0: torch.Tensor, img1: torch.Tensor) -> torch.Tensor:
47+
image = torch.cat([img0, img1], dim=1)
48+
high_range = image * 20.0
49+
merged = torch.cat([image, high_range], dim=1)
50+
return torch.clone(merged)
51+
52+
53+
def _get_observer_scale(prepared, observer_node_name: str) -> float:
54+
observer = prepared.get_submodule(observer_node_name)
55+
scale, _ = observer.calculate_qparams()
56+
return float(scale)
57+
58+
4559
def test_uint8_io_quantization_config_tosa_INT_applies_to_io():
4660
model = SimpleMLP().eval()
4761
test_data = (torch.rand(1, 4),)
@@ -94,3 +108,29 @@ def test_io_boundary_shared_cluster_is_quantized():
94108
assert (
95109
clone_node.meta[Q_ANNOTATION_KEY].output_qspec is not None
96110
), "clone node has no output_qspec — IO-boundary cluster stayed in float"
111+
112+
113+
def test_cat_does_not_bridge_shared_qspec_clusters():
114+
"""Regression: cat must not merge image IO and high-range activations into
115+
one fallback shared-qspec observer clique.
116+
"""
117+
model = CatWithHighRangeBranch().eval()
118+
test_data = (torch.rand(1, 3, 8, 8), torch.rand(1, 3, 8, 8))
119+
compile_spec = common.get_tosa_compile_spec("TOSA-1.0+INT")
120+
121+
tosa_quantizer = TOSAQuantizer(compile_spec, use_composable_quantizer=True)
122+
tosa_quantizer.set_global(get_symmetric_quantization_config())
123+
tosa_quantizer.set_io(get_uint8_io_quantization_config())
124+
125+
exported = torch.export.export(model, test_data, strict=True)
126+
prepared = prepare_pt2e(exported.module(), tosa_quantizer)
127+
prepared(*test_data)
128+
129+
graph_nodes = {node.name: node for node in prepared.graph.nodes}
130+
img0_observer = next(iter(graph_nodes["img0"].users))
131+
img1_observer = next(iter(graph_nodes["img1"].users))
132+
final_cat_observer = next(iter(graph_nodes["cat_1"].users))
133+
134+
assert _get_observer_scale(prepared, img0_observer.target) < 0.01
135+
assert _get_observer_scale(prepared, img1_observer.target) < 0.01
136+
assert _get_observer_scale(prepared, final_cat_observer.target) > 0.05

0 commit comments

Comments
 (0)