Skip to content

Commit 687bf22

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 2b1ccd4 commit 687bf22

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)