Skip to content

Commit efd6b55

Browse files
Fix XNNPACK channels-last reshape for per-channel binary ops under dynamic quant (#20376)
### Summary `ChannelsLastTaggedReshapePass.input_to_nhwc`'s dynamic-quant branch calls `input_node.replace_all_uses_with(input_node_nhwc)` on a shared activation. Because the main traversal runs in topological order, this can switch a broadcasting binary op's activation operand to NHWC *after* that op was already processed, while its other operand (e.g. a per-channel constant) stays NCHW. The operands then have incompatible shapes (e.g. `[1, H, W, C]` vs `[1, C, 1, 1]`), and XNNPACK fails at runtime in `xnn_reshape_binary_elementwise_nd` with `xnn_status_invalid_parameter`. Lowering succeeds; only `execute()` fails. Found on a ResNet-50-backbone detection model lowered with w8a8 dynamic quantization. ### Fix After the main traversal, re-converge any broadcasting binary op (`add`/`mul`/`sub`/ `div`) whose operands ended up in different memory formats, reusing the existing `input_to_nhwc`/`input_to_nchw` helpers. It runs after the retrace so it sees the settled graph, and retraces again only if something changed; it is a no-op for graphs without diverged operands. ### Test `test_dynamic_quant_per_channel_binary_chain_lowers_and_runs` builds a graph where a per-channel binary op is the first consumer of an input activation and the convolution chain runs through it. It fails to execute without this fix and passes with it. ``` pytest backends/xnnpack/test/passes/test_channels_last_tagged_reshape.py -k per_channel_binary_chain ``` cc @GregoryComer @digantdesai @cbilgin
1 parent 42157d7 commit efd6b55

2 files changed

Lines changed: 137 additions & 0 deletions

File tree

backends/xnnpack/_passes/channels_last_tagged_reshape_pass.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,18 @@ class ChannelsLastTaggedReshapePass(XNNPACKPass):
7474
exir_ops.edge.aten.linear.default,
7575
}
7676

77+
# Broadcasting binary ops whose operands must share one memory format. A
78+
# dynamic-quant input_to_nhwc may blanket-replace a shared activation
79+
# (replace_all_uses_with), switching one operand of an already-processed
80+
# binary op to NHWC while its other (e.g. per-channel constant) operand
81+
# stays NCHW. These ops are re-converged after the main traversal.
82+
broadcast_binary_ops = {
83+
exir_ops.edge.aten.add.Tensor,
84+
exir_ops.edge.aten.mul.Tensor,
85+
exir_ops.edge.aten.sub.Tensor,
86+
exir_ops.edge.aten.div.Tensor,
87+
}
88+
7789
# Tag which is added to a node's meta to indicate that it uses NHWC format.
7890
# A constant data tensor with this tag assigned for use in a particular
7991
# format in one place cannot be used in other places in the other format
@@ -567,4 +579,42 @@ def call(self, graph_module: torch.fx.GraphModule): # noqa: C901
567579
# to retrace the graph and regenerate metadata
568580
graph_module = super().call(graph_module).graph_module
569581

582+
# The dynamic-quant path of input_to_nhwc can replace_all_uses_with a
583+
# binary op's activation operand to NHWC after the op was processed,
584+
# leaving its other operand (e.g. a per-channel constant) NCHW and failing
585+
# at runtime. Re-converge such binary ops now that the graph has settled.
586+
reconverged = False
587+
for node in list(graph_module.graph.nodes):
588+
if (
589+
node.op != "call_function"
590+
or node.target not in ChannelsLastTaggedReshapePass.broadcast_binary_ops
591+
):
592+
continue
593+
input_nodes = node.all_input_nodes
594+
if len(input_nodes) != 2:
595+
continue
596+
layouts = [
597+
ChannelsLastTaggedReshapePass.is_nhwc_node(input_node)
598+
for input_node in input_nodes
599+
]
600+
if layouts[0] == layouts[1]:
601+
continue
602+
if all(
603+
self.can_be_converted_to_nhwc(input_node) for input_node in input_nodes
604+
):
605+
for input_node in input_nodes:
606+
self.input_to_nhwc(graph_module, input_node, node)
607+
self.mark_as_nhwc_node(node)
608+
else:
609+
for input_node in input_nodes:
610+
self.input_to_nchw(graph_module, input_node, node)
611+
reconverged = True
612+
613+
if reconverged:
614+
graph_module.recompile()
615+
for node in graph_module.graph.nodes:
616+
if ChannelsLastTaggedReshapePass.PARTNER_NODE in node.meta:
617+
node.meta.pop(ChannelsLastTaggedReshapePass.PARTNER_NODE)
618+
graph_module = super().call(graph_module).graph_module
619+
570620
return PassResult(graph_module, True)

backends/xnnpack/test/passes/test_channels_last_tagged_reshape.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -665,3 +665,90 @@ def test_dq_conv_cat_immutable_list(self):
665665
.run_passes(self.PassStage)
666666
.run_method_and_compare_outputs()
667667
)
668+
669+
class DynamicQuantPerChannelBinaryChain(torch.nn.Module):
670+
"""A per-channel broadcasting binary op that is the first consumer of an
671+
input activation, followed by a dynamically-quantized convolution.
672+
673+
This reproduces the graph shape that the dynamic-quant path of
674+
``input_to_nhwc`` mishandles. The input activation feeds a per-channel
675+
``mul``/``add`` (an NCHW ``[1, C, 1, 1]`` constant operand), and the
676+
convolution chain runs *through* that binary op. When the convolution
677+
requests NHWC, ``input_to_nhwc`` traces back through the binary op to the
678+
input activation and calls ``replace_all_uses_with``, switching the binary
679+
op's activation operand to NHWC while its constant operand stays NCHW. At
680+
runtime XNNPACK then fails in ``xnn_reshape_binary_elementwise_nd`` with
681+
``xnn_status_invalid_parameter`` because ``[1, H, W, C]`` and
682+
``[1, C, 1, 1]`` are not broadcast-compatible -- unless the pass
683+
re-converges the binary op's operands.
684+
685+
The quantize/dequantize ops are emitted directly (rather than via the
686+
quantizer) so the graph reliably reproduces the shared back-traced source;
687+
the whole graph delegates to XNNPACK.
688+
"""
689+
690+
def __init__(self):
691+
super().__init__()
692+
out_channels, in_channels, kernel = 8, 8, 3
693+
self.register_buffer(
694+
"weight",
695+
torch.randint(
696+
-127,
697+
127,
698+
(out_channels, in_channels, kernel, kernel),
699+
dtype=torch.int8,
700+
),
701+
)
702+
self.register_buffer(
703+
"weight_scale", torch.rand(out_channels) * 0.02 + 0.001
704+
)
705+
self.register_buffer(
706+
"weight_zero_point", torch.zeros(out_channels, dtype=torch.int64)
707+
)
708+
self.register_buffer("scale", torch.rand(1, out_channels, 1, 1) + 0.5)
709+
self.register_buffer("bias", torch.rand(1, out_channels, 1, 1))
710+
711+
def forward(self, activation):
712+
qd = torch.ops.quantized_decomposed
713+
# Per-channel binary op as the first consumer of the input activation.
714+
scaled = activation * self.scale + self.bias
715+
relued = torch.relu(scaled)
716+
# Dynamic (runtime-chosen) quantization feeding the convolution.
717+
q_scale, q_zero_point = qd.choose_qparams.tensor(
718+
relued, -128, 127, 1e-5, torch.int8
719+
)
720+
dequantized = qd.dequantize_per_tensor.tensor(
721+
qd.quantize_per_tensor.tensor(
722+
relued, q_scale, q_zero_point, -128, 127, torch.int8
723+
),
724+
q_scale,
725+
q_zero_point,
726+
-128,
727+
127,
728+
torch.int8,
729+
)
730+
weight = qd.dequantize_per_channel(
731+
self.weight,
732+
self.weight_scale,
733+
self.weight_zero_point,
734+
0,
735+
-127,
736+
127,
737+
torch.int8,
738+
)
739+
return torch.nn.functional.conv2d(dequantized, weight, padding=1)
740+
741+
def test_dynamic_quant_per_channel_binary_chain_lowers_and_runs(self):
742+
# Regression test: the full XNNPACK lowering of this graph must run
743+
# without an xnn_status_invalid_parameter from a binary op whose operands
744+
# ended up in mismatched (NHWC vs NCHW) memory formats.
745+
model = self.DynamicQuantPerChannelBinaryChain().eval()
746+
activation = torch.randn(1, 8, 16, 16)
747+
(
748+
Tester(model, (activation,))
749+
.export()
750+
.to_edge_transform_and_lower()
751+
.to_executorch()
752+
.serialize()
753+
.run_method_and_compare_outputs()
754+
)

0 commit comments

Comments
 (0)