Skip to content

Commit 440b4f0

Browse files
rascaniclaude
andauthored
Add channels_last edge-op dialect (conv, avg_pool2d, permute_copy) (#20559)
### Summary First vertical slice of the common memory-layout handling infra (RFC #19299): a backend-agnostic channels_last operator dialect whose ops interpret their activations as (N, H, W, C) contiguous with a fixed dim-order. Kernels wrap the existing aten ops with permutes, since efficiency is a non-goal at this layer; later passes (the #20094 conversion and #20098 fusion) make channels-last regions explicit and fuse the permutes away. Runtime C++ kernels, the conversion pass, and the remaining ops are deferred to follow-ups. Part of #20093. ### Test plan pytest backends/transforms/test/test_channels_last_ops.py Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a812429 commit 440b4f0

3 files changed

Lines changed: 252 additions & 0 deletions

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
"""The ``channels_last`` operator dialect.
8+
9+
Operators in this dialect interpret their activation input/output as channels-last
10+
``(N, H, W, C)`` with contiguous strides and a fixed (identity) dim-order, as
11+
opposed to the implicit dim-order handling used elsewhere. They let layout-handling
12+
passes (see RFC #19299) make channels-last regions explicit in the graph.
13+
14+
Efficiency is a non-goal: kernels are implemented as ``permute -> aten op -> permute``.
15+
Importing this module registers the dialect.
16+
"""
17+
18+
import torch
19+
from torch.library import Library, register_fake
20+
21+
lib = Library("channels_last", "DEF")
22+
23+
24+
def _conv(
25+
input, weight, bias, stride, padding, dilation, transposed, output_padding, groups
26+
):
27+
nchw = input.permute(0, 3, 1, 2)
28+
out = torch.ops.aten.convolution(
29+
nchw,
30+
weight,
31+
bias,
32+
stride,
33+
padding,
34+
dilation,
35+
transposed,
36+
output_padding,
37+
groups,
38+
)
39+
return out.permute(0, 2, 3, 1).contiguous()
40+
41+
42+
def _avg_pool2d(
43+
input, kernel_size, stride, padding, ceil_mode, count_include_pad, divisor_override
44+
):
45+
nchw = input.permute(0, 3, 1, 2)
46+
out = torch.ops.aten.avg_pool2d(
47+
nchw,
48+
kernel_size,
49+
stride,
50+
padding,
51+
ceil_mode,
52+
count_include_pad,
53+
divisor_override,
54+
)
55+
return out.permute(0, 2, 3, 1).contiguous()
56+
57+
58+
def _permute_copy(input, dims):
59+
return torch.ops.aten.permute_copy(input, dims).contiguous()
60+
61+
62+
lib.define(
63+
"convolution(Tensor input, Tensor weight, Tensor? bias, int[] stride, "
64+
"int[] padding, int[] dilation, bool transposed, int[] output_padding, "
65+
"int groups) -> Tensor"
66+
)
67+
lib.impl("convolution", _conv, "CompositeExplicitAutograd")
68+
register_fake("channels_last::convolution", _conv, lib=lib)
69+
70+
lib.define(
71+
"avg_pool2d(Tensor input, int[2] kernel_size, int[2] stride, int[2] padding, "
72+
"bool ceil_mode, bool count_include_pad, int? divisor_override) -> Tensor"
73+
)
74+
lib.impl("avg_pool2d", _avg_pool2d, "CompositeExplicitAutograd")
75+
register_fake("channels_last::avg_pool2d", _avg_pool2d, lib=lib)
76+
77+
lib.define("permute_copy(Tensor input, int[] dims) -> Tensor")
78+
lib.impl("permute_copy", _permute_copy, "CompositeExplicitAutograd")
79+
register_fake("channels_last::permute_copy", _permute_copy, lib=lib)

backends/transforms/targets.bzl

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,19 @@ def define_common_targets():
191191
],
192192
)
193193

194+
runtime.python_library(
195+
name = "channels_last_ops",
196+
srcs = [
197+
"channels_last_ops.py",
198+
],
199+
visibility = [
200+
"//executorch/backends/...",
201+
],
202+
deps = [
203+
"//caffe2:torch",
204+
],
205+
)
206+
194207
runtime.python_library(
195208
name = "rank_0_to_rank_1",
196209
srcs = [
@@ -269,6 +282,19 @@ def define_common_targets():
269282
],
270283
)
271284

285+
runtime.python_test(
286+
name = "test_channels_last_ops",
287+
srcs = [
288+
"test/test_channels_last_ops.py",
289+
],
290+
deps = [
291+
"//caffe2:torch",
292+
":channels_last_ops",
293+
"//executorch/exir:lib",
294+
"fbsource//third-party/pypi/pytest:pytest",
295+
],
296+
)
297+
272298
runtime.python_test(
273299
name = "test_rank_0_to_rank_1",
274300
srcs = [
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
# Importing the module registers the channels_last operator dialect.
8+
import executorch.backends.transforms.channels_last_ops # noqa: F401
9+
import pytest
10+
import torch
11+
from executorch.exir import to_edge
12+
from executorch.exir.dialects._ops import ops as exir_ops
13+
14+
15+
def _to_nhwc(nchw: torch.Tensor) -> torch.Tensor:
16+
return nchw.permute(0, 2, 3, 1).contiguous()
17+
18+
19+
def _find(graph_module: torch.fx.GraphModule, target):
20+
nodes = [
21+
n
22+
for n in graph_module.graph.nodes
23+
if n.op == "call_function" and n.target == target
24+
]
25+
assert len(nodes) == 1, f"expected exactly one {target}, found {len(nodes)}"
26+
return nodes[0]
27+
28+
29+
_CONV_CASES = [
30+
# (N, C_in, H, W, C_out, kernel, stride, padding, dilation, groups, bias)
31+
(2, 3, 8, 8, 4, 3, 1, 0, 1, 1, True),
32+
(2, 3, 8, 8, 4, 3, 1, 0, 1, 1, False),
33+
(1, 4, 10, 10, 6, 3, 2, 1, 1, 1, True),
34+
(1, 4, 7, 7, 4, 3, 1, 1, 1, 4, True), # depthwise (groups == C_in == C_out)
35+
]
36+
37+
38+
@pytest.mark.parametrize("n,cin,h,w,cout,k,stride,pad,dil,groups,bias", _CONV_CASES)
39+
def test_convolution_matches_aten(
40+
n, cin, h, w, cout, k, stride, pad, dil, groups, bias
41+
):
42+
torch.manual_seed(0)
43+
nchw = torch.randn(n, cin, h, w)
44+
weight = torch.randn(cout, cin // groups, k, k)
45+
bias_t = torch.randn(cout) if bias else None
46+
nhwc = _to_nhwc(nchw)
47+
48+
expected = _to_nhwc(
49+
torch.ops.aten.convolution(
50+
nchw,
51+
weight,
52+
bias_t,
53+
[stride, stride],
54+
[pad, pad],
55+
[dil, dil],
56+
False,
57+
[0, 0],
58+
groups,
59+
)
60+
)
61+
actual = torch.ops.channels_last.convolution(
62+
nhwc,
63+
weight,
64+
bias_t,
65+
[stride, stride],
66+
[pad, pad],
67+
[dil, dil],
68+
False,
69+
[0, 0],
70+
groups,
71+
)
72+
73+
assert actual.shape == expected.shape
74+
assert torch.allclose(actual, expected, atol=1e-5)
75+
76+
77+
@pytest.mark.parametrize(
78+
"kernel,stride,pad,ceil_mode,count_include_pad",
79+
[
80+
(2, 2, 0, False, True),
81+
(3, 1, 1, False, True),
82+
(3, 2, 1, True, False),
83+
],
84+
)
85+
def test_avg_pool2d_matches_aten(kernel, stride, pad, ceil_mode, count_include_pad):
86+
torch.manual_seed(0)
87+
nchw = torch.randn(2, 3, 9, 9)
88+
nhwc = _to_nhwc(nchw)
89+
90+
expected = _to_nhwc(
91+
torch.ops.aten.avg_pool2d(
92+
nchw,
93+
[kernel, kernel],
94+
[stride, stride],
95+
[pad, pad],
96+
ceil_mode,
97+
count_include_pad,
98+
None,
99+
)
100+
)
101+
actual = torch.ops.channels_last.avg_pool2d(
102+
nhwc,
103+
[kernel, kernel],
104+
[stride, stride],
105+
[pad, pad],
106+
ceil_mode,
107+
count_include_pad,
108+
None,
109+
)
110+
111+
assert actual.shape == expected.shape
112+
assert torch.allclose(actual, expected, atol=1e-5)
113+
114+
115+
@pytest.mark.parametrize("dims", [(0, 3, 1, 2), (0, 2, 3, 1), (3, 2, 1, 0)])
116+
def test_permute_copy_moves_data(dims):
117+
torch.manual_seed(0)
118+
x = torch.randn(2, 4, 5, 3)
119+
120+
expected = torch.ops.aten.permute_copy(x, list(dims))
121+
actual = torch.ops.channels_last.permute_copy(x, list(dims))
122+
123+
assert actual.shape == expected.shape
124+
assert torch.equal(actual, expected)
125+
assert actual.is_contiguous()
126+
127+
128+
def test_convolution_lowers_to_edge_dialect():
129+
class M(torch.nn.Module):
130+
def forward(self, x, w, b):
131+
return torch.ops.channels_last.convolution(
132+
x, w, b, [1, 1], [0, 0], [1, 1], False, [0, 0], 1
133+
)
134+
135+
nhwc = torch.randn(2, 8, 8, 3)
136+
weight = torch.randn(4, 3, 3, 3)
137+
bias = torch.randn(4)
138+
139+
ep = torch.export.export(M().eval(), (nhwc, weight, bias), strict=True)
140+
edge = to_edge(ep)
141+
142+
node = _find(
143+
edge.exported_program().graph_module,
144+
exir_ops.edge.channels_last.convolution.default,
145+
)
146+
# Fake kernel must yield the correct NHWC output shape (N, H_out, W_out, C_out).
147+
assert tuple(node.meta["val"].shape) == (2, 6, 6, 4)

0 commit comments

Comments
 (0)