Skip to content

Commit 558c071

Browse files
authored
[XNNPACK] Keep NHWC across scalar and low-rank broadcast operands (#24183)
### Summary Split out of #24113 at review request: that PR now only lowers `upsample_nearest2d`, this one carries the `ChannelsLastTaggedReshapePass` change. Profiling the MI-GAN inpainting model (a conv encoder-decoder, fp16) on XNNPACK showed about 25% of the run time spent in NCHW<->NHWC transposes around elementwise ops whose second operand is a Python scalar or a low-rank tensor. `ChannelsLastTaggedReshapePass` now keeps NHWC across scalar and low-rank broadcast operands of add/sub/mul/div/maximum/minimum: - A single-element operand, such as a lifted Python scalar (the gain in `x * 1.4142`), no longer takes part in the layout decision. Before, the NHWC activation was transposed to NCHW for the multiply and back for the next convolution. The same filter is used by the re-converge loop from #20376. - A rank < 4 constant operand that broadcasts over the trailing dims (a `[H, W]` noise map added to `[N, C, H, W]`, or a `[C, 1, 1]` scale) is reshaped to 4D in the exported program and laid out as NHWC at serialization, so it costs nothing at inference. For this, `BroadcastBinaryConfig` delegates such a constant with the op, so it is serialized as constant data. Only constants the pass can fold are pulled in: non-mutated, static-shape, rank < 4, more than one element. A mutated buffer or a 4D constant stays a delegate input, as on `main`. A non-constant operand, or a constant shared by several ops, is reshaped with a `view_copy` and converted in the graph, instead of dragging the activation to NCHW. - Dequantized operands are left at their own rank, because a view between the dequantize and the op would carry no quant params. In qs8 graphs they take the existing NCHW path. - A single-element operand of rank > 4 would make the op output rank > 4, which cannot be NHWC, so it takes part in the layout decision and the op takes the NCHW path. - Operands with symbolic dims also keep the NCHW path, because `XNNStaticReshape` cannot express their shape. This covers the dynamic-shape `conv_out + y` case with `y` of shape `[H, W]` or `[C, H, W]` reported on #24113, which lowers again as it does on `main`. Measured on MI-GAN 256/512 fp16 with the host runtime, together with the `upsample_nearest2d` lowering from #24113, on an ExecuTorch 1.4.1-based branch: layout conversions per forward went from 128 to 26, the delegated share of run time from 87/82% to 98/95%, and `Method::execute` dropped by 29%/28%. The output was unchanged (max abs diff 2e-3, fp16 noise). These numbers were taken before the constants were folded ahead of time, when each of them still cost a reshape and a transpose per forward. Review order: `channels_last_tagged_reshape_pass.py`, then `is_foldable_broadcast_operand` in `utils/utils.py` and `BroadcastBinaryConfig` in `generic_node_configs.py`, then the tests. ### Test plan Tested on macOS arm64 with locally built pybindings. New tests in `backends/xnnpack/test/passes/test_channels_last_tagged_reshape.py` cover a scalar operand, a 5D single-element operand, `[H, W]`, `[C, 1, 1]` and `[C, H, W]` constants, a lower-rank left operand, a constant shared by two ops, a `[H, W]` graph input, qs8 lower-rank operands that keep their rank, a `[H, W]` operand with dynamic H and W that falls back to NCHW and runs at a second input size, every kind of constant (persistent and non-persistent buffer, parameter, lifted tensor), the scalar and lower-rank operand of all six ops, a mutated buffer operand, a 4D constant operand and a constant shared with a non-delegated op. Each static case also runs through `to_edge_transform_and_lower`, `to_executorch`, `serialize` and the runtime, and checks the serialized XNNPACK graph for the expected transposes and reshapes. ```bash python -m pytest backends/xnnpack/test/passes/test_channels_last_tagged_reshape.py backends/xnnpack/test/passes/test_remove_redundant_copy_pass.py backends/xnnpack/test/ops/test_{add,sub,multiply,div,maximum,minimum}.py backends/xnnpack/test/test_xnnpack_partitioner.py # 77 passed ``` `lintrunner` reports no issues on the changed files. cc @GregoryComer @digantdesai @cbilgin @JakeStevens @nil-is-all
1 parent c8220d9 commit 558c071

4 files changed

Lines changed: 786 additions & 32 deletions

File tree

‎backends/xnnpack/_passes/channels_last_tagged_reshape_pass.py‎

Lines changed: 149 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from typing import List, Optional, Tuple
1111

1212
import torch
13+
from executorch.backends.transforms.utils import set_param_tensor
1314
from executorch.backends.xnnpack._passes.xnnpack_pass import XNNPACKPass
1415
from executorch.backends.xnnpack.utils.quant_utils import (
1516
extract_qdq_affine_op_args_for_decomposed_ops,
@@ -19,9 +20,15 @@
1920
is_tagged_as_implicit_q_dq,
2021
tag_as_implicit_q_dq,
2122
)
22-
from executorch.backends.xnnpack.utils.utils import is_dtype_cast, is_param_node
23+
from executorch.backends.xnnpack.utils.utils import (
24+
get_param_tensor,
25+
is_dtype_cast,
26+
is_foldable_broadcast_operand,
27+
is_param_node,
28+
)
2329
from executorch.exir.dialects._ops import ops as exir_ops
2430
from executorch.exir.pass_base import PassResult
31+
from torch.fx.experimental.symbolic_shapes import has_free_symbols
2532

2633

2734
class InputDimOrder(Enum):
@@ -77,7 +84,9 @@ class ChannelsLastTaggedReshapePass(XNNPACKPass):
7784
exir_ops.edge.aten.linear.default,
7885
}
7986

80-
# Broadcasting binary ops whose operands must share one memory format. A
87+
# Broadcasting binary ops whose operands must share one memory format.
88+
# Single-element operands take no part in the layout decision, and rank < 4
89+
# operands are reshaped to 4D so they can follow an NHWC operand. A
8190
# dynamic-quant input_to_nhwc may blanket-replace a shared activation
8291
# (replace_all_uses_with), switching one operand of an already-processed
8392
# binary op to NHWC while its other (e.g. per-channel constant) operand
@@ -87,6 +96,8 @@ class ChannelsLastTaggedReshapePass(XNNPACKPass):
8796
exir_ops.edge.aten.mul.Tensor,
8897
exir_ops.edge.aten.sub.Tensor,
8998
exir_ops.edge.aten.div.Tensor,
99+
exir_ops.edge.aten.maximum.default,
100+
exir_ops.edge.aten.minimum.default,
90101
}
91102

92103
# Tag which is added to a node's meta to indicate that it uses NHWC format.
@@ -174,6 +185,136 @@ def is_nhwc_tensor(tensor: torch.Tensor) -> bool:
174185
def _is_nhwc(self, tensor: torch.Tensor) -> bool:
175186
return ChannelsLastTaggedReshapePass.is_nhwc_tensor(tensor)
176187

188+
@staticmethod
189+
def static_tensor_val(node: torch.fx.Node) -> Optional[torch.Tensor]:
190+
val = node.meta.get("val", None)
191+
if isinstance(val, torch.Tensor) and not has_free_symbols(val.shape):
192+
return val
193+
return None
194+
195+
@staticmethod
196+
def is_single_element(node: torch.fx.Node) -> bool:
197+
# A rank > 4 operand makes the op output rank > 4, which cannot be
198+
# NHWC, so such an operand must still take part in the layout decision.
199+
val = ChannelsLastTaggedReshapePass.static_tensor_val(node)
200+
return val is not None and val.numel() == 1 and val.dim() <= 4
201+
202+
@staticmethod
203+
def is_lower_rank_broadcast_operand(node: torch.fx.Node) -> bool:
204+
# A symbolic shape cannot be expressed in XNNStaticReshape, so such an
205+
# operand takes the NCHW path.
206+
val = ChannelsLastTaggedReshapePass.static_tensor_val(node)
207+
return (
208+
val is not None
209+
and 0 < val.dim() < 4
210+
and val.numel() > 1
211+
and not ChannelsLastTaggedReshapePass.is_nhwc_node(node)
212+
# A view between dequantize and the op would carry no qparams.
213+
and not is_dequant(node)
214+
)
215+
216+
def is_foldable_constant(self, node: torch.fx.Node) -> bool:
217+
"""
218+
A constant with a single user can be reshaped and laid out as NHWC
219+
ahead of time instead of at every inference.
220+
"""
221+
return (
222+
is_foldable_broadcast_operand(self.exported_program, node)
223+
and len(node.users) == 1
224+
)
225+
226+
def constant_to_4d_nhwc(self, input_node: torch.fx.Node) -> torch.fx.Node:
227+
"""
228+
Reshape a rank < 4 constant operand to 4D in place and tag it NHWC, so
229+
the serializer writes its data in channels_last and the graph carries
230+
no reshape or transpose for it.
231+
"""
232+
val = input_node.meta["val"]
233+
new_shape = [1] * (4 - val.dim()) + list(val.shape)
234+
data = get_param_tensor(self.exported_program, input_node)
235+
assert data is not None
236+
set_param_tensor(
237+
self.exported_program, input_node, data.reshape(new_shape).contiguous()
238+
)
239+
input_node.meta["val"] = val.reshape(new_shape)
240+
self.mark_as_nhwc_node(input_node)
241+
return input_node
242+
243+
def converge_layouts(
244+
self,
245+
graph_module: torch.fx.GraphModule,
246+
node: torch.fx.Node,
247+
layout_inputs: List[torch.fx.Node],
248+
) -> None:
249+
"""
250+
Bring operands of mixed layouts to one: NHWC if every operand can be
251+
converted, NCHW otherwise. A lower-rank operand of a broadcasting op
252+
(e.g. a [H, W] map added to [N, C, H, W]) can follow the NHWC operand
253+
once reshaped to 4D, so the layout is decided on the other operands
254+
first and only then is the graph or the constant touched.
255+
"""
256+
lower_rank = [
257+
node.target in self.broadcast_binary_ops
258+
and self.is_lower_rank_broadcast_operand(input_node)
259+
for input_node in layout_inputs
260+
]
261+
if all(
262+
is_lower_rank or self.can_be_converted_to_nhwc(input_node)
263+
for input_node, is_lower_rank in zip(layout_inputs, lower_rank)
264+
):
265+
for input_node, is_lower_rank in zip(layout_inputs, lower_rank):
266+
if is_lower_rank:
267+
input_node = self.unsqueeze_to_4d(graph_module, input_node, node)
268+
# A constant folded to NHWC needs no copy.
269+
if not ChannelsLastTaggedReshapePass.is_nhwc_node(input_node):
270+
self.input_to_nhwc(graph_module, input_node, node)
271+
self.mark_as_nhwc_node(node)
272+
else:
273+
for input_node in layout_inputs:
274+
self.input_to_nchw(graph_module, input_node, node)
275+
276+
def unsqueeze_to_4d(
277+
self,
278+
graph_module: torch.fx.GraphModule,
279+
input_node: torch.fx.Node,
280+
target_node: torch.fx.Node,
281+
) -> torch.fx.Node:
282+
"""
283+
Reshape a rank < 4 operand of `target_node` to 4D by prepending ones,
284+
which is how broadcasting aligns it, so it can be converted to NHWC.
285+
"""
286+
if self.is_foldable_constant(input_node):
287+
return self.constant_to_4d_nhwc(input_node)
288+
val = input_node.meta["val"]
289+
new_shape = [1] * (4 - val.dim()) + list(val.shape)
290+
with graph_module.graph.inserting_before(target_node):
291+
view = self.create_call_function_node(
292+
graph_module=graph_module,
293+
target=exir_ops.edge.aten.view_copy.default,
294+
args=(input_node, new_shape),
295+
)
296+
view.meta["val"] = val.reshape(new_shape)
297+
# Left untagged on purpose: it is NCHW, and the view_copy visitor
298+
# treats the presence of the tag key as "permute the shape".
299+
target_node.replace_input_with(input_node, view)
300+
return view
301+
302+
def layout_inputs(self, node: torch.fx.Node) -> List[torch.fx.Node]:
303+
"""
304+
Inputs that take part in the layout decision of `node`. Single-element
305+
operands of a broadcasting binary op (e.g. the scalar in `x * 1.4142`)
306+
broadcast the same way in either layout, so they must not force an
307+
NHWC activation back to NCHW.
308+
"""
309+
return [
310+
input_node
311+
for input_node in node.all_input_nodes
312+
if not (
313+
node.target in self.broadcast_binary_ops
314+
and self.is_single_element(input_node)
315+
)
316+
]
317+
177318
def requires_nhwc_input(self, node: torch.fx.Node) -> bool:
178319
return node.target in self.memory_sensitive_ops_nhwc
179320

@@ -571,29 +712,19 @@ def call(self, graph_module: torch.fx.GraphModule): # noqa: C901
571712
else:
572713
# The node can have inputs in any format (but all must be the
573714
# same format)
715+
layout_inputs = self.layout_inputs(node)
574716
is_or_isnt_nhwc_node = [
575717
ChannelsLastTaggedReshapePass.is_nhwc_node(input_node)
576-
for input_node in node.all_input_nodes
718+
for input_node in layout_inputs
577719
]
578-
if all(is_or_isnt_nhwc_node):
720+
if is_or_isnt_nhwc_node and all(is_or_isnt_nhwc_node):
579721
# All inputs are nhwc so this node's output is nhwc too
580722
self.mark_as_nhwc_node(node)
581723
elif any(is_or_isnt_nhwc_node):
582724
# There is at least one node of each format, so we need to
583725
# convert some of them to another format such that all
584726
# are the same.
585-
# If all nodes can be converted to nhwc, then convert them
586-
# all to nhwc. Otherwise, convert all to nchw
587-
if all(
588-
self.can_be_converted_to_nhwc(input_node)
589-
for input_node in node.all_input_nodes
590-
):
591-
for input_node in node.all_input_nodes:
592-
self.input_to_nhwc(graph_module, input_node, node)
593-
self.mark_as_nhwc_node(node)
594-
else:
595-
for input_node in node.all_input_nodes:
596-
self.input_to_nchw(graph_module, input_node, node)
727+
self.converge_layouts(graph_module, node, layout_inputs)
597728

598729
graph_module.recompile()
599730

@@ -616,7 +747,7 @@ def call(self, graph_module: torch.fx.GraphModule): # noqa: C901
616747
or node.target not in ChannelsLastTaggedReshapePass.broadcast_binary_ops
617748
):
618749
continue
619-
input_nodes = node.all_input_nodes
750+
input_nodes = self.layout_inputs(node)
620751
if len(input_nodes) != 2:
621752
continue
622753
layouts = [
@@ -625,15 +756,7 @@ def call(self, graph_module: torch.fx.GraphModule): # noqa: C901
625756
]
626757
if layouts[0] == layouts[1]:
627758
continue
628-
if all(
629-
self.can_be_converted_to_nhwc(input_node) for input_node in input_nodes
630-
):
631-
for input_node in input_nodes:
632-
self.input_to_nhwc(graph_module, input_node, node)
633-
self.mark_as_nhwc_node(node)
634-
else:
635-
for input_node in input_nodes:
636-
self.input_to_nchw(graph_module, input_node, node)
759+
self.converge_layouts(graph_module, node, input_nodes)
637760
reconverged = True
638761

639762
if reconverged:

‎backends/xnnpack/partition/config/generic_node_configs.py‎

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from executorch.backends.xnnpack.utils.utils import (
2121
get_input_node,
2222
get_param_tensor,
23+
is_foldable_broadcast_operand,
2324
is_param_node,
2425
normalize_mean_dims,
2526
normalize_pool2d_args,
@@ -166,6 +167,25 @@ def get_node_and_deps(
166167
return deps + quantized_deps
167168

168169

170+
class BroadcastBinaryConfig(GenericNodePartitionerConfig):
171+
"""
172+
A binary op whose rank < 4 constant operands are delegated with it, so
173+
they are serialized as constant data and laid out as NHWC ahead of time
174+
instead of being reshaped and transposed at every inference. Any other
175+
constant operand stays a delegate input, as for every other op.
176+
"""
177+
178+
def get_node_and_deps(
179+
self, node: torch.fx.Node, ep: ExportedProgram
180+
) -> List[torch.fx.Node]:
181+
deps = super().get_node_and_deps(node, ep)
182+
return deps + [
183+
input_node
184+
for input_node in node.all_input_nodes
185+
if is_foldable_broadcast_operand(ep, input_node)
186+
]
187+
188+
169189
class QuantizedPerTensorConfig(GenericNodePartitionerConfig):
170190
target_name = "quantize_per_tensor.default"
171191

@@ -197,7 +217,7 @@ def supported_precision_types(self) -> List[ConfigPrecisionType]:
197217
return [ConfigPrecisionType.FP32, ConfigPrecisionType.STATIC_QUANT]
198218

199219

200-
class AddConfig(GenericNodePartitionerConfig):
220+
class AddConfig(BroadcastBinaryConfig):
201221
target_name = "add.Tensor"
202222

203223
def __init__(self, **kwargs):
@@ -342,7 +362,7 @@ def supported_precision_types(self) -> List[ConfigPrecisionType]:
342362
return [ConfigPrecisionType.FP32, ConfigPrecisionType.STATIC_QUANT]
343363

344364

345-
class DivConfig(GenericNodePartitionerConfig):
365+
class DivConfig(BroadcastBinaryConfig):
346366
target_name = "div.Tensor"
347367

348368
def supported_precision_types(self) -> List[ConfigPrecisionType]:
@@ -448,14 +468,14 @@ def check_constraints(self, node: torch.fx.Node, ep: ExportedProgram) -> bool:
448468
)
449469

450470

451-
class MulConfig(GenericNodePartitionerConfig):
471+
class MulConfig(BroadcastBinaryConfig):
452472
target_name = "mul.Tensor"
453473

454474
def supported_precision_types(self) -> List[ConfigPrecisionType]:
455475
return [ConfigPrecisionType.FP32, ConfigPrecisionType.STATIC_QUANT]
456476

457477

458-
class MaximumConfig(GenericNodePartitionerConfig):
478+
class MaximumConfig(BroadcastBinaryConfig):
459479
target_name = "maximum.default"
460480

461481
def supported_precision_types(self) -> List[ConfigPrecisionType]:
@@ -730,7 +750,7 @@ def supported_precision_types(self) -> List[ConfigPrecisionType]:
730750
return [ConfigPrecisionType.FP32, ConfigPrecisionType.STATIC_QUANT]
731751

732752

733-
class MinimumConfig(GenericNodePartitionerConfig):
753+
class MinimumConfig(BroadcastBinaryConfig):
734754
target_name = "minimum.default"
735755

736756
def supported_precision_types(self) -> List[ConfigPrecisionType]:
@@ -864,7 +884,7 @@ def supported_precision_types(self) -> List[ConfigPrecisionType]:
864884
return [ConfigPrecisionType.FP32]
865885

866886

867-
class SubConfig(GenericNodePartitionerConfig):
887+
class SubConfig(BroadcastBinaryConfig):
868888
target_name = "sub.Tensor"
869889

870890
def supported_precision_types(self) -> List[ConfigPrecisionType]:

0 commit comments

Comments
 (0)