Skip to content

Commit 5ff4858

Browse files
pssrawatfacebook-github-bot
authored andcommitted
Keep the shape operand of view_as and expand_as out of shared qparams (#24153)
Summary: `SharedQspecQuantizer` gives each shared-qparams cluster one set of qparams for every float input and output of its ops. `view_as(x, other)` is in that list, and the cluster was built from all of its inputs, so `other` was pulled in even though it contributes only its shape. `x` and `other` were then quantized with one scale, set by whichever has the wider range: the data loses precision, or the shape operand is clipped. For example, in `(x * y).view_as(other) + other` with `other = z + z`, `x * y` spans about ±2.5 and `other` about ±51. `x * y` was quantized at `other`'s scale of 0.349, about 18x coarser than the 0.0195 it gets when the same reshape is written `reshape(4, 8)`, and the output error against float rose from 0.258 to 0.307. `expand_as` has the same shape-only second operand but was not in the shared-qparams list at all, so every `expand_as` broadcast stayed in float behind a dequantize and a requantize. Shape-as ops (`view_as`, `expand_as`) now share qparams only through their data operand, `args[0]`. The cluster walk does not cross the edge to the shape operand in either direction, and that operand gets no shared input qspec, so it keeps the qparams of its own producer. `expand_as` joins the shared-qparams ops, so like `expand` it is quantized to int8 when its neighbours are, and lowers on Cortex-M to an int8 `expand_copy`. Authored with Claude Code. Reviewed By: rascani Differential Revision: D127380012
1 parent 893d815 commit 5ff4858

4 files changed

Lines changed: 66 additions & 3 deletions

File tree

‎backends/arm/quantizer/arm_quantizer_utils.py‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,20 @@ def validate(self, model: torch.fx.GraphModule) -> bool: # type: ignore[overrid
411411
return True
412412

413413

414+
# Ops whose second operand contributes only its shape, never its values, so it
415+
# must not be tied into the shared qparams of the first.
416+
_SHAPE_AS_OPS = {
417+
torch.ops.aten.expand_as.default,
418+
torch.ops.aten.view_as.default,
419+
}
420+
421+
422+
# Whether ``node`` shares qparams with its input ``input_node``: always for an
423+
# ordinary shared op, but for a shape-as op only through its data operand.
424+
def _shares_qparams_with(node: Node, input_node: Node) -> bool:
425+
return node.target not in _SHAPE_AS_OPS or input_node is node.args[0]
426+
427+
414428
class SharedQspecQuantizer(Quantizer, QuantizerReporterUser):
415429
"""Assures that specific ops share quantization parameters on all
416430
inputs/outputs.
@@ -442,6 +456,7 @@ class SharedQspecQuantizer(Quantizer, QuantizerReporterUser):
442456
torch.ops.aten.repeat_interleave.self_int,
443457
torch.ops.aten.expand_copy.default,
444458
torch.ops.aten.expand.default,
459+
torch.ops.aten.expand_as.default,
445460
torch.ops.aten.select.int,
446461
torch.ops.aten.select_copy.int,
447462
torch.ops.aten.slice.Tensor,
@@ -543,7 +558,11 @@ def _is_annotated(self, node: Node) -> bool:
543558
return Q_ANNOTATION_KEY in node.meta
544559

545560
def _get_input_nodes_with_float_output(self, node: Node) -> list[Node]:
546-
return [n for n in node.all_input_nodes if has_float_output(n)]
561+
return [
562+
n
563+
for n in node.all_input_nodes
564+
if has_float_output(n) and _shares_qparams_with(node, n)
565+
]
547566

548567
def _get_user_nodes_with_float_input(self, node: Node) -> list[Node]:
549568
return [n for n in node.users.keys() if has_float_output(node)]
@@ -640,6 +659,8 @@ def _get_shared_clique(
640659
shared_nodes.add(node)
641660

642661
for input_node in node.all_input_nodes:
662+
if not _shares_qparams_with(node, input_node):
663+
continue
643664
self._maybe_enqueue_shared_node(input_node, shared_nodes, bfs_queue)
644665
self._append_output_qspec(input_node, adjacent_qspecs)
645666
touches_quantized_io |= self._is_quantized_io_boundary(input_node)
@@ -648,6 +669,8 @@ def _get_shared_clique(
648669
)
649670

650671
for output_node in node.users.keys():
672+
if not _shares_qparams_with(output_node, node):
673+
continue
651674
self._maybe_enqueue_shared_node(output_node, shared_nodes, bfs_queue)
652675
self._append_input_qspec(output_node, node, adjacent_qspecs)
653676
touches_quantized_io |= self._is_quantized_io_boundary(output_node)

‎backends/arm/test/misc/test_shared_qspecs.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
from executorch.backends.arm.test.tester.test_pipeline import QuantizationPipeline
1717
from executorch.backends.arm.tosa import TosaSpecification
1818
from executorch.backends.cortex_m.test.tester import McuTestCase, ramp_tensor
19+
from torch.export import export
20+
from torchao.quantization.pt2e.quantize_pt2e import convert_pt2e, prepare_pt2e
1921

2022

2123
_QUANT_CONFIG_INT8 = get_symmetric_quantization_config()
@@ -734,3 +736,36 @@ def test_silu_does_not_share_input_qspec():
734736
)
735737
pipeline.run()
736738
_check_quant_params(pipeline, model.quant_params)
739+
740+
741+
class ShapeAsOperand(torch.nn.Module):
742+
"""``op(x + x, other)`` reads only the shape of ``other``, so its qparams
743+
must stay ``other``'s own rather than join those of ``x + x``.
744+
"""
745+
746+
def __init__(self, op: str) -> None:
747+
super().__init__()
748+
self.op = op
749+
750+
def forward(self, x, y):
751+
other = y + y
752+
return getattr(x + x, self.op)(other) + other
753+
754+
755+
@parametrize(
756+
"test_data",
757+
{"view_as": ("view_as", (2, 16)), "expand_as": ("expand_as", (1, 8))},
758+
)
759+
def test_shape_operand_does_not_share_qspec(test_data):
760+
op, x_shape = test_data
761+
inputs = (ramp_tensor(-1, 1, x_shape), ramp_tensor(-50, 50, (4, 8)))
762+
model = ShapeAsOperand(op).eval()
763+
prepared = prepare_pt2e(export(model, inputs).module(), _get_quantizer())
764+
prepared(*inputs)
765+
quantized = convert_pt2e(prepared)
766+
767+
(node,) = quantized.graph.find_nodes(
768+
op="call_function", target=getattr(torch.ops.aten, op).default
769+
)
770+
data_dequantize, other_dequantize = node.args
771+
assert data_dequantize.args[1] != other_dequantize.args[1]

‎backends/arm/test/models/test_swin2sr_arm.py‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
]
3030

3131
# TODO/MLETORCH-2163: Investigate Swin2SR delegation gaps around index/view
32-
# in FP and Q/DQ, clamp, and expand_copy in INT.
32+
# in FP and Q/DQ and clamp in INT.
3333
swin2sr_fp_lowered_outer_graph_ops = {
3434
"torch.ops.higher_order.executorch_call_delegate": 2,
3535
"executorch_exir_dialects_edge__ops_aten_index_Tensor": 2,
@@ -38,7 +38,6 @@
3838
swin2sr_int_lowered_outer_graph_ops = {
3939
"torch.ops.higher_order.executorch_call_delegate": 3,
4040
"executorch_exir_dialects_edge__ops_aten_clamp_default": 4,
41-
"executorch_exir_dialects_edge__ops_aten_expand_copy_default": 4,
4241
"executorch_exir_dialects_edge__ops_quantized_decomposed_dequantize_per_tensor_default": 5,
4342
"executorch_exir_dialects_edge__ops_quantized_decomposed_quantize_per_tensor_default": 5,
4443
}

‎backends/cortex_m/test/misc/test_portable_int8.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,12 @@ def _quantize_and_export(
221221
(torch.randn(1, 3, 4, 5), torch.randn(1, 3, 4, 5)),
222222
None,
223223
),
224+
"expand_as": OpCase(
225+
torch.ops.aten.expand_as.default,
226+
_build_module(lambda x, y: torch.ops.aten.expand_as.default(x, y)),
227+
(torch.randn(1, 3, 4, 5), torch.randn(2, 3, 4, 5)),
228+
None,
229+
),
224230
"select": OpCase(
225231
torch.ops.aten.select.int,
226232
_build_module(lambda x, y: torch.ops.aten.select.int(x, 1, 0)),

0 commit comments

Comments
 (0)