Skip to content

Commit 289a570

Browse files
Fix XNNPACK channels-last reshape for per-channel binary ops under dynamic quant
ChannelsLastTaggedReshapePass.input_to_nhwc has a dynamic-quant branch that calls input_node.replace_all_uses_with(input_node_nhwc), globally redirecting a shared activation to its NHWC copy. The main traversal visits nodes in topological order, so this can retroactively 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 two operands then have incompatible logical 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 execution fails (observed on DETR with w8a8 dynamic quantization). Add a re-convergence sweep after the main traversal that restores the pass's own invariant -- all operands of a node share one memory format -- for broadcasting binary ops (add/mul/sub/div), converging to NHWC when possible (else NCHW). It runs after the retrace so it observes the settled graph, and retraces once more only if anything changed. Graphs with no diverged binary operands are untouched. Add a regression test (DynamicQuantPerChannelBinaryChain) whose per-channel binary op is the first consumer of an input activation, with the convolution chain running through it; it fails to execute (xnn_status_invalid_parameter) without this fix and passes with it.
1 parent 24d8bab commit 289a570

2 files changed

Lines changed: 142 additions & 0 deletions

File tree

backends/xnnpack/_passes/channels_last_tagged_reshape_pass.py

Lines changed: 55 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,47 @@ 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+
# Re-converge broadcasting binary ops whose operand layouts diverged.
583+
# The main traversal above processes nodes in topological order, but the
584+
# dynamic-quant path of input_to_nhwc calls replace_all_uses_with, which
585+
# can retroactively switch a binary op's activation operand to NHWC after
586+
# the op was already processed - without converging its other operand
587+
# (e.g. a per-channel constant that stays NCHW). XNNPACK then fails at
588+
# runtime in xnn_reshape_binary_elementwise_nd because the operand shapes
589+
# are not broadcast-compatible. Run this after the retrace above so it
590+
# sees the settled graph, then retrace once more if anything changed.
591+
reconverged = False
592+
for node in list(graph_module.graph.nodes):
593+
if (
594+
node.op != "call_function"
595+
or node.target not in ChannelsLastTaggedReshapePass.broadcast_binary_ops
596+
):
597+
continue
598+
input_nodes = node.all_input_nodes
599+
if len(input_nodes) != 2:
600+
continue
601+
layouts = [
602+
ChannelsLastTaggedReshapePass.is_nhwc_node(input_node)
603+
for input_node in input_nodes
604+
]
605+
if layouts[0] == layouts[1]:
606+
continue
607+
if all(
608+
self.can_be_converted_to_nhwc(input_node) for input_node in input_nodes
609+
):
610+
for input_node in input_nodes:
611+
self.input_to_nhwc(graph_module, input_node, node)
612+
self.mark_as_nhwc_node(node)
613+
else:
614+
for input_node in input_nodes:
615+
self.input_to_nchw(graph_module, input_node, node)
616+
reconverged = True
617+
618+
if reconverged:
619+
graph_module.recompile()
620+
for node in graph_module.graph.nodes:
621+
if ChannelsLastTaggedReshapePass.PARTNER_NODE in node.meta:
622+
node.meta.pop(ChannelsLastTaggedReshapePass.PARTNER_NODE)
623+
graph_module = super().call(graph_module).graph_module
624+
570625
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)