Skip to content

Commit 5bea649

Browse files
authored
Fix dim_order derivation for channels-last tensors with size-1 dims (#22745)
Fixes #22520. Stride sorting can give a channels-last tensor with a size-1 channel an unsupported dimension order, such as `(0, 2, 1, 3)` for the KWS input `(1, 1, 49, 10)`. Pass sizes through tensor specs, weight emission, and serialization so these orders can be corrected when strides exactly match channels-last. Preserve existing default orders, which keeps physically equivalent singleton layouts compatible with portable kernels. Reuse PyTorch's stride calculation; memory-format pass behavior is unchanged. Add coverage for the convolution export, mixed-format addition, 5D ReLU, and symbolic sizes. Re-enable the NXP channels-last PTQ/QAT cases with the portable C++ reference and the comparator's standard finite MSE tolerance (`3.5e-4`). Validation: 287 tests passed across tensor, dim-order, passes, native serialization, EXIR serialization, and layout transforms; 49 skipped and 1 expected failure. The unrelated `test_to_out_variant_none_output` failure was confirmed with pre-PR stride inference. Lintrunner is clean on all touched files. Portable runtime probes match Python for addition, 5D ReLU, and PTQ/QAT convolution and DSCNN KWS models (XNNPACK PT2E quantization, no delegation). The full NXP simulator suite requires CI and was not run locally. This PR was authored with AI assistance (Muse Code and Codex).
1 parent 2aebdf6 commit 5bea649

8 files changed

Lines changed: 175 additions & 36 deletions

File tree

‎backends/native/serialization/graph_serialize.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ def _dim_order(t: torch.Tensor) -> list[int]:
205205
strides = tuple(t.stride())
206206
sizes = list(t.shape)
207207
# dim_order_from_stride handles symbolic strides and rejects stride-0 layouts.
208-
dim_order = [int(d) for d in dim_order_from_stride(strides)]
208+
dim_order = [int(d) for d in dim_order_from_stride(strides, tuple(sizes))]
209209
expected = stride_from_dim_order(sizes, dim_order)
210210
for i in range(ndim):
211211
# A size-1 dim only ever indexes 0, so its stride is arbitrary and need not

‎backends/native/test/test_serialize.py‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -571,10 +571,9 @@ def test_permuted_contiguous(self):
571571
self.assertEqual(_dim_order(t), [0, 2, 1])
572572

573573
def test_channels_last_with_size_one_channel(self):
574-
# channels-last leaves the size-1 channel with an arbitrary stride, which
575-
# must not be treated as a non-expressible layout.
574+
# A size-1 channel must not make channels-last non-expressible.
576575
t = torch.randn(2, 1, 3, 4).to(memory_format=torch.channels_last)
577-
self.assertEqual(_dim_order(t), [0, 2, 1, 3])
576+
self.assertEqual(_dim_order(t), [0, 2, 3, 1])
578577

579578
def test_sliced_layout_raises(self):
580579
t = torch.randn(4, 8)[:, :4]

‎backends/nxp/tests/models/test_mlperf_tiny_keyword_spotting.py‎

Lines changed: 4 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,11 @@
3131

3232
BOUNDS_MSE = {
3333
"PTQ": {
34-
"channels-last": np.inf,
34+
"channels-last": 3.5e-4,
3535
"channels-first": 2.0e-6,
3636
},
3737
"QAT": {
38-
"channels-last": np.inf,
38+
"channels-last": 3.5e-4,
3939
"channels-first": 5.0e-5,
4040
},
4141
}
@@ -47,19 +47,7 @@ def reseed_model_per_test_run():
4747
np.random.seed(23)
4848

4949

50-
@pytest.mark.parametrize(
51-
"channels_last",
52-
[
53-
False,
54-
pytest.param(
55-
True,
56-
marks=pytest.mark.xfail(
57-
reason="EIEX-1082, don't forget to readjust bounds when it start working",
58-
strict=True,
59-
),
60-
),
61-
],
62-
)
50+
@pytest.mark.parametrize("channels_last", [False, True])
6351
def test_mlperf_tiny_kws_mse_cpu_vs_npu(mocker, request, channels_last, use_qat):
6452
# approx. 5 samples per class
6553
num_samples = 60
@@ -89,16 +77,6 @@ def test_mlperf_tiny_kws_mse_cpu_vs_npu(mocker, request, channels_last, use_qat)
8977
partial(kws.train_model_fn, channels_last=channels_last) if use_qat else None
9078
)
9179

92-
# This model does not work in channels-last format when running with portable kernels.
93-
# See more information below.
94-
# Github issue: https://github.com/pytorch/executorch/issues/22520
95-
# NXP internal issue ID: EIEX-1074
96-
ref_model = (
97-
ReferenceModel.QUANTIZED_EDGE_PYTHON
98-
if channels_last
99-
else ReferenceModel.QUANTIZED_EXECUTORCH_CPP
100-
)
101-
10280
lower_run_compare(
10381
model,
10482
[input_spec],
@@ -107,7 +85,7 @@ def test_mlperf_tiny_kws_mse_cpu_vs_npu(mocker, request, channels_last, use_qat)
10785
dataset_creator=dataset_creator,
10886
output_comparator=comparator,
10987
mocker=mocker,
110-
reference_model=ref_model,
88+
reference_model=ReferenceModel.QUANTIZED_EXECUTORCH_CPP,
11189
use_qat=use_qat,
11290
train_fn=train_fn,
11391
)

‎exir/emit/_emitter.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2077,7 +2077,7 @@ def _is_buffer(node: Node, graph_signature: ExportGraphSignature) -> bool:
20772077

20782078
spec.storage = real_tensor.untyped_storage()
20792079
spec.stride = real_tensor.stride()
2080-
spec.dim_order = dim_order_from_stride(spec.stride)
2080+
spec.dim_order = dim_order_from_stride(spec.stride, tuple(spec.shape))
20812081
# User inputs and mutable buffers are not constants, other buffers or parameters are.
20822082
if initialize_buffer and is_mutable_buffer:
20832083
spec.const = True

‎exir/tensor.py‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@ def contiguous_stride_from_shape(shape: torch.Size) -> Tuple[int]:
4949
return tuple(reversed(strides))
5050

5151

52-
def dim_order_from_stride(stride: Tuple[int]) -> Tuple[bytes]:
52+
def dim_order_from_stride(
53+
stride: Tuple[int], sizes: Optional[Tuple[int]] = None
54+
) -> Tuple[bytes]:
5355
"""
5456
Dimension order represents how dimensions are laid out in memory,
5557
starting from the outer-most to the inner-most dimension.
@@ -65,8 +67,16 @@ def dim_order_from_stride(stride: Tuple[int]) -> Tuple[bytes]:
6567
in original order. Thus when strides = (4, 3, 1, 1) returned value is (0, 1, 2, 3)
6668
Another example is: sizes = (1, 3, 1, 1) with strides = (3, 1, 3, 3), returned
6769
value is (0, 2, 3, 1)
70+
71+
With sizes, ambiguous non-canonical orders are corrected when strides
72+
exactly match channels-last. Preserve existing default orders: portable
73+
kernels require matching dim orders even for physically equivalent layouts.
6874
"""
69-
from torch.fx.experimental.symbolic_shapes import guard_or_false, guard_or_true
75+
from torch.fx.experimental.symbolic_shapes import (
76+
guard_or_false,
77+
guard_or_true,
78+
statically_known_true,
79+
)
7080

7181
for s in stride:
7282
torch._check(s != 0, lambda: "0 in strides is not supported for ExecuTorch.")
@@ -93,6 +103,18 @@ def __lt__(self, other):
93103
sorted_dims = [
94104
i[0] for i in sorted(enumerate(stride), key=lambda x: K(x[1]), reverse=True)
95105
]
106+
ndim = len(stride)
107+
if (
108+
sizes is not None
109+
and len(sizes) == ndim
110+
and ndim in (4, 5)
111+
and sorted_dims != list(range(ndim))
112+
):
113+
from torch._prims_common import make_channels_last_strides_for
114+
115+
expected = make_channels_last_strides_for(sizes)
116+
if all(statically_known_true(s == e) for s, e in zip(stride, expected)):
117+
sorted_dims = [0, *range(2, ndim), 1]
96118
return tuple(typing.cast(Tuple[bytes], sorted_dims))
97119

98120

@@ -201,7 +223,7 @@ def from_tensor(cls, tensor: torch.Tensor, const: bool = False) -> TensorSpec:
201223
is_sparse=tensor.is_sparse,
202224
)
203225
spec.stride = tensor.stride()
204-
spec.dim_order = dim_order_from_stride(spec.stride)
226+
spec.dim_order = dim_order_from_stride(spec.stride, tuple(spec.shape))
205227
spec.requires_grad = tensor.requires_grad
206228
spec.storage = tensor.untyped_storage() if const else None
207229

‎exir/tensor_layout.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,5 +35,5 @@ def from_tensor(cls, tensor: torch.Tensor) -> "TensorLayout":
3535
return TensorLayout(
3636
scalar_type=scalar_type_enum(tensor.dtype),
3737
sizes=list(tensor.shape),
38-
dim_order=list(dim_order_from_stride(tensor.stride())),
38+
dim_order=list(dim_order_from_stride(tensor.stride(), tuple(tensor.shape))),
3939
)

‎exir/tests/test_dim_order_utils.py‎

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@
77
# pyre-strict
88
import unittest
99

10+
from typing import List
11+
1012
import torch
11-
from executorch.exir import to_edge_transform_and_lower
13+
from executorch.exir import to_edge, to_edge_transform_and_lower
1214
from executorch.exir.dim_order_utils import get_dim_order, get_memory_format
15+
from executorch.exir.schema import KernelCall, Tensor
1316

1417

1518
class TestDimOrderUtils(unittest.TestCase):
@@ -47,3 +50,77 @@ def forward(self, t1, t2):
4750
expo_prog = torch.export.export(M, (x, y))
4851
edge_prog = to_edge_transform_and_lower(expo_prog)
4952
edge_prog.to_executorch()
53+
54+
def test_channels_last_single_channel_conv_dim_order(self) -> None:
55+
class Conv(torch.nn.Module):
56+
def __init__(self) -> None:
57+
super().__init__()
58+
self.conv = torch.nn.Conv2d(1, 8, kernel_size=3, padding=1)
59+
60+
def forward(self, x: torch.Tensor) -> torch.Tensor:
61+
return self.conv(x)
62+
63+
model = Conv().to(memory_format=torch.channels_last).eval()
64+
inputs = (torch.randn(1, 1, 12, 12).to(memory_format=torch.channels_last),)
65+
edge_prog = to_edge(torch.export.export(model, inputs, strict=True))
66+
et_program = edge_prog.to_executorch().executorch_program
67+
68+
conv_found = False
69+
for inst in et_program.execution_plan[0].chains[0].instructions:
70+
kernel = inst.instr_args
71+
if not isinstance(kernel, KernelCall):
72+
continue
73+
op = et_program.execution_plan[0].operators[kernel.op_index]
74+
if op.name != "aten::convolution":
75+
continue
76+
conv_found = True
77+
tensors: List[Tensor] = []
78+
for arg in dict.fromkeys(kernel.args):
79+
val = et_program.execution_plan[0].values[arg].val
80+
if isinstance(val, Tensor) and len(val.sizes) == 4:
81+
tensors.append(val)
82+
# Input, weight, and output.
83+
self.assertEqual(len(tensors), 3)
84+
for tensor in tensors:
85+
self.assertIn(
86+
list(tensor.dim_order),
87+
([0, 1, 2, 3], [0, 2, 3, 1]),
88+
)
89+
self.assertEqual(list(tensors[0].dim_order), list(tensors[-1].dim_order))
90+
self.assertTrue(conv_found)
91+
92+
def test_singleton_dims_preserve_default_order(self) -> None:
93+
class Add(torch.nn.Module):
94+
def forward(self, x, y):
95+
return x + y
96+
97+
cases = (
98+
(
99+
Add(),
100+
(
101+
torch.randn(2, 1, 3, 1).to(memory_format=torch.channels_last),
102+
torch.randn(2, 1, 3, 1),
103+
),
104+
),
105+
(
106+
torch.nn.ReLU(),
107+
(torch.randn(2, 1, 3, 1, 1).to(memory_format=torch.channels_last_3d),),
108+
),
109+
)
110+
for model, inputs in cases:
111+
with self.subTest(model=type(model).__name__):
112+
program = (
113+
to_edge_transform_and_lower(torch.export.export(model, inputs))
114+
.to_executorch()
115+
.executorch_program
116+
)
117+
tensors = [
118+
value.val
119+
for value in program.execution_plan[0].values
120+
if isinstance(value.val, Tensor)
121+
]
122+
self.assertEqual(len(tensors), len(inputs) + 1)
123+
for tensor in tensors:
124+
self.assertEqual(
125+
list(tensor.dim_order), list(range(len(tensor.sizes)))
126+
)

‎exir/tests/test_tensor.py‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,69 @@ def test_dim_order_from_stride_unbacked(self) -> None:
321321
with self.assertRaises(RuntimeError):
322322
dim_order_from_stride((u0, 0, 1))
323323

324+
def test_dim_order_from_stride_with_sizes(self) -> None:
325+
dim_order = dim_order_from_stride((490, 1, 10, 1), (1, 1, 49, 10))
326+
self.assertEqual((0, 2, 3, 1), dim_order)
327+
self.assertEqual(
328+
[490, 1, 10, 1],
329+
stride_from_dim_order([1, 1, 49, 10], [0, 2, 3, 1]),
330+
)
331+
332+
self.assertEqual(
333+
(0, 1, 2, 3),
334+
dim_order_from_stride((490, 490, 10, 1), (1, 1, 49, 10)),
335+
)
336+
337+
self.assertEqual(
338+
(0, 1, 2, 3), dim_order_from_stride((1, 1, 1, 1), (2, 1, 1, 1))
339+
)
340+
341+
self.assertEqual(
342+
(0, 2, 3, 4, 1),
343+
dim_order_from_stride((120, 1, 30, 6, 1), (1, 1, 4, 5, 6)),
344+
)
345+
346+
self.assertEqual(
347+
(3, 1, 2, 0),
348+
dim_order_from_stride((1, 20, 5, 60), (2, 3, 4, 5)),
349+
)
350+
351+
self.assertEqual((0, 2, 1, 3), dim_order_from_stride((490, 1, 10, 1)))
352+
353+
t = torch.empty(1, 1, 49, 10).to(memory_format=torch.channels_last)
354+
spec = TensorSpec.from_tensor(t)
355+
self.assertEqual((0, 2, 3, 1), spec.dim_order)
356+
357+
def test_dim_order_from_stride_preserves_supported_orders(self) -> None:
358+
for sizes, strides in (
359+
((2, 1, 3, 1), (3, 1, 1, 1)),
360+
((2, 1, 3, 1, 1), (3, 1, 1, 1, 1)),
361+
((2, 3, 4, 5), (60, 1, 15, 3)),
362+
((2, 3, 4, 5, 6), (360, 1, 90, 18, 3)),
363+
):
364+
with self.subTest(sizes=sizes):
365+
self.assertEqual(
366+
dim_order_from_stride(strides),
367+
dim_order_from_stride(strides, sizes),
368+
)
369+
370+
def test_dim_order_from_stride_with_symbolic_sizes(self) -> None:
371+
from torch.fx.experimental.symbolic_shapes import ShapeEnv
372+
373+
shape_env = ShapeEnv()
374+
height = shape_env.create_unbacked_symint()
375+
torch._check_is_size(height)
376+
torch._check(height >= 2)
377+
self.assertEqual(
378+
(0, 2, 3, 1),
379+
dim_order_from_stride((10 * height, 1, 10, 1), (2, 1, height, 10)),
380+
)
381+
self.assertEqual(
382+
(0, 2, 1, 3),
383+
dim_order_from_stride((490, 1, 10, 1), (2, 1, height, 10)),
384+
)
385+
self.assertEqual(shape_env.guards, [])
386+
324387
def test_strides_from_dim_order(self) -> None:
325388
sizes = []
326389
dim_order = []

0 commit comments

Comments
 (0)