From b73b1d81d8540b3586afc9ba16a60aaa0ff5d3b7 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 04:56:48 -0700 Subject: [PATCH 01/25] Check the bias length in the portable convolution kernel check_convolution_args meant to compare the bias length with the number of output channels, but the conditional operator binds less tightly than ==, so bias.size(0) == transposed ? groups * weight.size(1) : weight.size(0) compared the length with transposed and then used a channel count as the condition. The check passed for any weight with channels, so a bias shorter than the number of output channels got through, and the kernel read past its end. Add the parentheses. The new test gives a regular and a transposed convolution a bias of the wrong length and expects both to fail. --- kernels/portable/cpu/util/kernel_ops_util.cpp | 4 +-- kernels/test/op_convolution_test.cpp | 31 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/kernels/portable/cpu/util/kernel_ops_util.cpp b/kernels/portable/cpu/util/kernel_ops_util.cpp index 1e3bbcdb92d..283cd03a1f3 100644 --- a/kernels/portable/cpu/util/kernel_ops_util.cpp +++ b/kernels/portable/cpu/util/kernel_ops_util.cpp @@ -461,8 +461,8 @@ bool check_convolution_args( if (bias.has_value()) { ET_LOG_AND_RETURN_IF_FALSE(tensor_is_rank(bias.value(), 1)); ET_CHECK_OR_RETURN_FALSE( - bias.value().size(0) == transposed ? groups * weight.size(1) - : weight.size(0), + bias.value().size(0) == + (transposed ? groups * weight.size(1) : weight.size(0)), "bias length must equal number of output channels, but got %" ET_PRI_TENSOR_SIZE "; expected %" PRId64, bias.value().size(0), diff --git a/kernels/test/op_convolution_test.cpp b/kernels/test/op_convolution_test.cpp index e5f8ff4f58e..70d8a7c0e7f 100644 --- a/kernels/test/op_convolution_test.cpp +++ b/kernels/test/op_convolution_test.cpp @@ -643,6 +643,37 @@ TEST_F(OpConvCorrectnessTest, InvalidInputShape) { out)); } +TEST_F(OpConvCorrectnessTest, InvalidBiasLength) { + TensorFactory tf; + + Tensor input = tf.ones({1, 2, 3, 3}); + Tensor bias = tf.ones({1}); + + Tensor weight = tf.ones({4, 2, 2, 2}); + Tensor out = tf.zeros({1, 4, 2, 2}); + ET_EXPECT_KERNEL_FAILURE( + context_, + op_convolution_out( + input, weight, bias, {1}, {0}, {1}, false, {0}, 1, out)); + + Tensor transposed_weight = tf.ones({2, 4, 2, 2}); + Tensor transposed_out = tf.zeros({1, 4, 4, 4}); + context_ = torch::executor::KernelRuntimeContext(); + ET_EXPECT_KERNEL_FAILURE( + context_, + op_convolution_out( + input, + transposed_weight, + bias, + {1}, + {0}, + {1}, + true, + {0}, + 1, + transposed_out)); +} + TEST_F(OpConvCorrectnessTest, TransposedDefaultParams) { TensorFactory tf; From 67e9fed9d1fbf3d451132391fce8a3131677946f Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 04:57:16 -0700 Subject: [PATCH 02/25] Reject groups below 1 in the portable convolution kernel check_convolution_args computed weight.size(0) % groups without rejecting groups = 0 first, so it divided by zero, which crashes on x86. Reject groups below 1, as PyTorch does. The new test passes groups = 0 and expects the call to fail. --- kernels/portable/cpu/util/kernel_ops_util.cpp | 2 ++ kernels/test/op_convolution_test.cpp | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/kernels/portable/cpu/util/kernel_ops_util.cpp b/kernels/portable/cpu/util/kernel_ops_util.cpp index 283cd03a1f3..87d776f0e48 100644 --- a/kernels/portable/cpu/util/kernel_ops_util.cpp +++ b/kernels/portable/cpu/util/kernel_ops_util.cpp @@ -488,6 +488,8 @@ bool check_convolution_args( output_padding_is_valid(output_padding, stride, dilation, kernel_ndim)); } + ET_CHECK_OR_RETURN_FALSE( + groups > 0, "groups must be positive, but got %" PRId64, groups); ET_CHECK_OR_RETURN_FALSE( weight.size(0) >= groups, "Given groups=%" PRId64 ", expected weight to be at least %" PRId64 diff --git a/kernels/test/op_convolution_test.cpp b/kernels/test/op_convolution_test.cpp index 70d8a7c0e7f..2cd4cd7bf07 100644 --- a/kernels/test/op_convolution_test.cpp +++ b/kernels/test/op_convolution_test.cpp @@ -674,6 +674,20 @@ TEST_F(OpConvCorrectnessTest, InvalidBiasLength) { transposed_out)); } +TEST_F(OpConvCorrectnessTest, InvalidGroups) { + TensorFactory tf; + + Tensor input = tf.ones({1, 2, 3, 3}); + Tensor weight = tf.ones({4, 2, 2, 2}); + optional bias; + Tensor out = tf.zeros({1, 4, 2, 2}); + + ET_EXPECT_KERNEL_FAILURE( + context_, + op_convolution_out( + input, weight, bias, {1}, {0}, {1}, false, {0}, 0, out)); +} + TEST_F(OpConvCorrectnessTest, TransposedDefaultParams) { TensorFactory tf; From 878e8beb112541fce6bf6534936060774f274c9f Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Fri, 30 Oct 2026 21:55:48 -0700 Subject: [PATCH 03/25] Support 3-D convolution in the portable convolution kernel A program with an nn.Conv3d could be lowered with the XNNPACK partitioner and saved, but then failed at run time: Check failed (in.dim() == 3 || in.dim() == 4): Expect input tensor to be 3-D or 4-D, but got, 5. XNNPACK does not take convolutions with more than 2 spatial dims, or any bfloat16 convolution, so they run on the portable convolution kernel, which only handled 1-D and 2-D. Vision encoders that embed image patches with a Conv3d, such as Qwen2.5-VL's, hit this. The kernel now runs every convolution as a 3-D one. It views a 1-D or 2-D convolution as 3-D, with leading spatial dims of size 1, stride 1, padding 0 and dilation 1, the same way it used to view 1-D as 2-D. - The regular loop adds up the depth taps outside the channel loop, so a depth of 1 adds no work per channel. 1-D and 2-D convolutions have one depth tap, so they add up their terms in the same order as before and give the same results. Its innermost loop steps the indices by the strides, and it reads the bias once per output channel of each batch item instead of once per output value. - The transposed loop handles one output depth at a time. - The argument checks and the output size take the number of spatial dims from the weight, so 3-D arguments get the same checks as 1-D and 2-D ones. get_unsqueezed_sizes and get_unsqueezed_dim_order are no longer used and are removed. - The Cortex-M Conv3d test no longer expects a failure. Tests: a grouped 3-D convolution and a grouped transposed 3-D convolution match PyTorch. Depth has its own stride, padding and dilation in both, and the input and weight values do not repeat along any dim, so mixing up dims or indices changes the result. Both fail without this change. The invalid input test also checks that a 3-D convolution rejects a stride, padding or dilation list of the wrong length, and that a 6-D input is rejected. --- backends/cortex_m/test/ops/test_conv.py | 1 - kernels/portable/cpu/op_convolution.cpp | 403 +++++++++--------- kernels/portable/cpu/util/kernel_ops_util.cpp | 61 +-- kernels/portable/cpu/util/kernel_ops_util.h | 11 - kernels/test/op_convolution_test.cpp | 105 +++++ 5 files changed, 311 insertions(+), 270 deletions(-) diff --git a/backends/cortex_m/test/ops/test_conv.py b/backends/cortex_m/test/ops/test_conv.py index f2ac0df0cc2..c18ac8fbcec 100644 --- a/backends/cortex_m/test/ops/test_conv.py +++ b/backends/cortex_m/test/ops/test_conv.py @@ -349,7 +349,6 @@ def test_dialect_conv2d(test_case, cortex_m_target): xfails_implementation: dict[str, xfail_type] = { "conv1d": "Currently not supported.", - "conv3d": "Currently not supported.", } diff --git a/kernels/portable/cpu/op_convolution.cpp b/kernels/portable/cpu/op_convolution.cpp index 25186b92121..4052bf7d029 100644 --- a/kernels/portable/cpu/op_convolution.cpp +++ b/kernels/portable/cpu/op_convolution.cpp @@ -12,7 +12,6 @@ #include #include -#include #include #include @@ -24,8 +23,6 @@ using Tensor = executorch::aten::Tensor; using ScalarType = executorch::aten::ScalarType; using IntArrayRef = executorch::aten::ArrayRef; using SizesArrayRef = executorch::aten::ArrayRef; -using DimOrderArrayRef = - executorch::aten::ArrayRef; using StridesArrayRef = executorch::aten::ArrayRef; @@ -65,7 +62,7 @@ IndexRange transposed_input_range( // Keep the tile buffer out of the regular convolution's stack frame. template -ET_NOINLINE void transposed_conv2d_impl( +ET_NOINLINE void transposed_conv3d_impl( const CTYPE* const in_ptr, SizesArrayRef in_sizes, StridesArrayRef in_strides, @@ -88,13 +85,17 @@ ET_NOINLINE void transposed_conv2d_impl( size_t in_C = in_sizes[1]; size_t out_C = out_sizes[1]; - int64_t out_H = out_sizes[2]; - int64_t in_H = in_sizes[2]; - int64_t w_H = w_sizes[2]; + int64_t out_D = out_sizes[2]; + int64_t in_D = in_sizes[2]; + int64_t w_D = w_sizes[2]; - int64_t out_W = out_sizes[3]; - int64_t in_W = in_sizes[3]; - int64_t w_W = w_sizes[3]; + int64_t out_H = out_sizes[3]; + int64_t in_H = in_sizes[3]; + int64_t w_H = w_sizes[3]; + + int64_t out_W = out_sizes[4]; + int64_t in_W = in_sizes[4]; + int64_t w_W = w_sizes[4]; size_t in_C_per_group = in_C / groups; size_t in_c_start = group * in_C_per_group; @@ -102,73 +103,94 @@ ET_NOINLINE void transposed_conv2d_impl( size_t out_C_per_group = out_C / groups; size_t out_c_start = group * out_C_per_group; - const int64_t stride_y = val_at(stride, 0); - const int64_t padding_y = val_at(padding, 0, /*default_value=*/0); - const int64_t dilation_y = val_at(dilation, 0); - const int64_t stride_x = val_at(stride, 1); - const int64_t padding_x = val_at(padding, 1, /*default_value=*/0); - const int64_t dilation_x = val_at(dilation, 1); + const int64_t stride_z = stride[0]; + const int64_t padding_z = padding[0]; + const int64_t dilation_z = dilation[0]; + const int64_t stride_y = stride[1]; + const int64_t padding_y = padding[1]; + const int64_t dilation_y = dilation[1]; + const int64_t stride_x = stride[2]; + const int64_t padding_x = padding[2]; + const int64_t dilation_x = dilation[2]; using COMPUTE_T = typename executorch::runtime::promote_types::type; // Reuse weights across a bounded tile, narrowing only after reduction. constexpr int64_t kTileHeight = 8; constexpr int64_t kTileWidth = 32; - for (int64_t tile_y = 0; tile_y < out_H; tile_y += kTileHeight) { - const int64_t tile_y_end = std::min(out_H, tile_y + kTileHeight); - const auto kernel_y = transposed_kernel_range( - tile_y, tile_y_end, in_H, w_H, stride_y, padding_y, dilation_y); - for (int64_t tile_x = 0; tile_x < out_W; tile_x += kTileWidth) { - const int64_t tile_x_end = std::min(out_W, tile_x + kTileWidth); - const auto kernel_x = transposed_kernel_range( - tile_x, tile_x_end, in_W, w_W, stride_x, padding_x, dilation_x); - COMPUTE_T accum[kTileHeight * kTileWidth] = {}; - for (int64_t w_y = kernel_y.begin; w_y < kernel_y.end; ++w_y) { - const int64_t offset_y = w_y * dilation_y - padding_y; - const auto input_y = transposed_input_range( - tile_y, tile_y_end, in_H, offset_y, stride_y); - if (input_y.begin >= input_y.end) { - continue; - } - for (int64_t w_x = kernel_x.begin; w_x < kernel_x.end; ++w_x) { - const int64_t offset_x = w_x * dilation_x - padding_x; - const auto input_x = transposed_input_range( - tile_x, tile_x_end, in_W, offset_x, stride_x); - if (input_x.begin >= input_x.end) { + for (int64_t out_z = 0; out_z < out_D; ++out_z) { + const auto kernel_z = transposed_kernel_range( + out_z, out_z + 1, in_D, w_D, stride_z, padding_z, dilation_z); + for (int64_t tile_y = 0; tile_y < out_H; tile_y += kTileHeight) { + const int64_t tile_y_end = std::min(out_H, tile_y + kTileHeight); + const auto kernel_y = transposed_kernel_range( + tile_y, tile_y_end, in_H, w_H, stride_y, padding_y, dilation_y); + for (int64_t tile_x = 0; tile_x < out_W; tile_x += kTileWidth) { + const int64_t tile_x_end = std::min(out_W, tile_x + kTileWidth); + const auto kernel_x = transposed_kernel_range( + tile_x, tile_x_end, in_W, w_W, stride_x, padding_x, dilation_x); + COMPUTE_T accum[kTileHeight * kTileWidth] = {}; + for (int64_t w_z = kernel_z.begin; w_z < kernel_z.end; ++w_z) { + // A tile is one output deep, so at most one input depth lands on it. + const auto input_z = transposed_input_range( + out_z, out_z + 1, in_D, w_z * dilation_z - padding_z, stride_z); + if (input_z.begin >= input_z.end) { continue; } - for (const auto in_c : - c10::irange(in_c_start, in_c_start + in_C_per_group)) { - const size_t w_idx = in_c * w_strides[0] + - (out_c - out_c_start) * w_strides[1] + w_y * w_strides[2] + - w_x * w_strides[3]; - const COMPUTE_T w_val = w_ptr[w_idx]; - int64_t out_y = input_y.begin * stride_y + offset_y - tile_y; - for (int64_t in_y = input_y.begin; in_y < input_y.end; ++in_y) { - size_t in_idx = batch * in_strides[0] + in_c * in_strides[1] + - in_y * in_strides[2] + input_x.begin * in_strides[3]; - int64_t out_x = input_x.begin * stride_x + offset_x - tile_x; - for (int64_t in_x = input_x.begin; in_x < input_x.end; ++in_x) { - const COMPUTE_T in_val = in_ptr[in_idx]; - accum[out_y * kTileWidth + out_x] += in_val * w_val; - in_idx += in_strides[3]; - out_x += stride_x; + const size_t in_z_idx = + batch * in_strides[0] + input_z.begin * in_strides[2]; + const size_t w_z_idx = + (out_c - out_c_start) * w_strides[1] + w_z * w_strides[2]; + for (int64_t w_y = kernel_y.begin; w_y < kernel_y.end; ++w_y) { + const int64_t offset_y = w_y * dilation_y - padding_y; + const auto input_y = transposed_input_range( + tile_y, tile_y_end, in_H, offset_y, stride_y); + if (input_y.begin >= input_y.end) { + continue; + } + for (int64_t w_x = kernel_x.begin; w_x < kernel_x.end; ++w_x) { + const int64_t offset_x = w_x * dilation_x - padding_x; + const auto input_x = transposed_input_range( + tile_x, tile_x_end, in_W, offset_x, stride_x); + if (input_x.begin >= input_x.end) { + continue; + } + for (const auto in_c : + c10::irange(in_c_start, in_c_start + in_C_per_group)) { + const size_t w_idx = w_z_idx + in_c * w_strides[0] + + w_y * w_strides[3] + w_x * w_strides[4]; + const COMPUTE_T w_val = w_ptr[w_idx]; + int64_t out_y = input_y.begin * stride_y + offset_y - tile_y; + for (int64_t in_y = input_y.begin; in_y < input_y.end; ++in_y) { + size_t in_idx = in_z_idx + in_c * in_strides[1] + + in_y * in_strides[3] + input_x.begin * in_strides[4]; + int64_t out_x = input_x.begin * stride_x + offset_x - tile_x; + for (int64_t in_x = input_x.begin; in_x < input_x.end; + ++in_x) { + const COMPUTE_T in_val = in_ptr[in_idx]; + accum[out_y * kTileWidth + out_x] += in_val * w_val; + in_idx += in_strides[4]; + out_x += stride_x; + } + out_y += stride_y; + } } - out_y += stride_y; } } } - } - const COMPUTE_T bias_val = bias_ptr == nullptr - ? COMPUTE_T{0} - : load_bias(&bias_ptr[out_c * bias.value().element_size()]); - for (int64_t out_y = tile_y; out_y < tile_y_end; ++out_y) { - size_t out_idx = batch * out_strides[0] + out_c * out_strides[1] + - out_y * out_strides[2] + tile_x * out_strides[3]; - for (int64_t out_x = tile_x; out_x < tile_x_end; ++out_x) { - out_ptr[out_idx] = - accum[(out_y - tile_y) * kTileWidth + out_x - tile_x] + bias_val; - out_idx += out_strides[3]; + const COMPUTE_T bias_val = bias_ptr == nullptr + ? COMPUTE_T{0} + : load_bias(&bias_ptr[out_c * bias.value().element_size()]); + for (int64_t out_y = tile_y; out_y < tile_y_end; ++out_y) { + size_t out_idx = batch * out_strides[0] + out_c * out_strides[1] + + out_z * out_strides[2] + out_y * out_strides[3] + + tile_x * out_strides[4]; + for (int64_t out_x = tile_x; out_x < tile_x_end; ++out_x) { + out_ptr[out_idx] = + accum[(out_y - tile_y) * kTileWidth + out_x - tile_x] + + bias_val; + out_idx += out_strides[4]; + } } } } @@ -176,7 +198,7 @@ ET_NOINLINE void transposed_conv2d_impl( } template -void conv2d_impl( +void conv3d_impl( const CTYPE* const in_ptr, SizesArrayRef in_sizes, StridesArrayRef in_strides, @@ -198,7 +220,7 @@ void conv2d_impl( const size_t out_c, bool transposed) { if (transposed) { - transposed_conv2d_impl( + transposed_conv3d_impl( in_ptr, in_sizes, in_strides, @@ -222,78 +244,102 @@ void conv2d_impl( } size_t in_C = in_sizes[1]; - int64_t out_H = out_sizes[2]; - int64_t in_H = in_sizes[2]; - int64_t w_H = w_sizes[2]; + int64_t out_D = out_sizes[2]; + int64_t in_D = in_sizes[2]; + int64_t w_D = w_sizes[2]; + + int64_t out_H = out_sizes[3]; + int64_t in_H = in_sizes[3]; + int64_t w_H = w_sizes[3]; - int64_t out_W = out_sizes[3]; - int64_t in_W = in_sizes[3]; - int64_t w_W = w_sizes[3]; + int64_t out_W = out_sizes[4]; + int64_t in_W = in_sizes[4]; + int64_t w_W = w_sizes[4]; size_t in_C_per_group = in_C / groups; size_t in_c_start = group * in_C_per_group; - executorch::aten::SizesType in_coord[kTensorDimensionLimit]; - in_coord[0] = batch; - executorch::aten::SizesType out_coord[kTensorDimensionLimit]; - out_coord[0] = batch; - out_coord[1] = out_c; - executorch::aten::SizesType w_coord[kTensorDimensionLimit]; - - const int64_t stride_y = val_at(stride, 0); - const int64_t padding_y = val_at(padding, 0, /*default_value=*/0); - const int64_t dilation_y = val_at(dilation, 0); - const int64_t stride_x = val_at(stride, 1); - const int64_t padding_x = val_at(padding, 1, /*default_value=*/0); - const int64_t dilation_x = val_at(dilation, 1); + const int64_t stride_z = stride[0]; + const int64_t padding_z = padding[0]; + const int64_t dilation_z = dilation[0]; + const int64_t stride_y = stride[1]; + const int64_t padding_y = padding[1]; + const int64_t dilation_y = dilation[1]; + const int64_t stride_x = stride[2]; + const int64_t padding_x = padding[2]; + const int64_t dilation_x = dilation[2]; using COMPUTE_T = typename executorch::runtime::promote_types::type; - - for (const auto out_y : c10::irange(out_H)) { - out_coord[2] = out_y; - for (const auto out_x : c10::irange(out_W)) { - out_coord[3] = out_x; - COMPUTE_T accum = 0; - for (const auto in_c : - c10::irange(in_c_start, in_c_start + in_C_per_group)) { - in_coord[1] = in_c; - w_coord[0] = out_c; - w_coord[1] = in_c - in_c_start; - for (const auto w_y : c10::irange(w_H)) { - w_coord[2] = w_y; - int64_t in_y = stride_y * out_y + dilation_y * w_y - padding_y; - if (in_y < 0 || in_y >= in_H) { + const COMPUTE_T bias_val = bias_ptr == nullptr + ? COMPUTE_T{0} + : load_bias(&bias_ptr[out_c * bias.value().element_size()]); + + for (const auto out_z : c10::irange(out_D)) { + for (const auto out_y : c10::irange(out_H)) { + for (const auto out_x : c10::irange(out_W)) { + COMPUTE_T accum = 0; + // Depth taps sit outside the channel loop, so lower ranks, whose depth + // is 1, add no work per channel. + for (const auto w_z : c10::irange(w_D)) { + int64_t in_z = stride_z * out_z + dilation_z * w_z - padding_z; + if (in_z < 0 || in_z >= in_D) { continue; } - in_coord[2] = in_y; - for (const auto w_x : c10::irange(w_W)) { - w_coord[3] = w_x; - int64_t in_x = stride_x * out_x + dilation_x * w_x - padding_x; - if (in_x < 0 || in_x >= in_W) { - continue; + for (const auto in_c : + c10::irange(in_c_start, in_c_start + in_C_per_group)) { + const int64_t in_plane_idx = batch * in_strides[0] + + in_c * in_strides[1] + in_z * in_strides[2]; + const int64_t w_plane_idx = out_c * w_strides[0] + + (in_c - in_c_start) * w_strides[1] + w_z * w_strides[2]; + for (const auto w_y : c10::irange(w_H)) { + int64_t in_y = stride_y * out_y + dilation_y * w_y - padding_y; + if (in_y < 0 || in_y >= in_H) { + continue; + } + int64_t in_x = stride_x * out_x - padding_x; + int64_t in_idx = + in_plane_idx + in_y * in_strides[3] + in_x * in_strides[4]; + int64_t w_idx = w_plane_idx + w_y * w_strides[3]; + for (int64_t w_x = 0; w_x < w_W; ++w_x) { + if (in_x >= 0 && in_x < in_W) { + const COMPUTE_T in_val = in_ptr[in_idx]; + const COMPUTE_T w_val = w_ptr[w_idx]; + accum += in_val * w_val; + } + in_x += dilation_x; + in_idx += dilation_x * in_strides[4]; + w_idx += w_strides[4]; + } } - in_coord[3] = in_x; - const size_t in_idx = - calculate_linear_index(in_coord, in_strides.data(), 4); - const size_t w_idx = - calculate_linear_index(w_coord, w_strides.data(), 4); - const COMPUTE_T in_val = in_ptr[in_idx]; - const COMPUTE_T w_val = w_ptr[w_idx]; - accum += in_val * w_val; } } + const int64_t out_idx = batch * out_strides[0] + + out_c * out_strides[1] + out_z * out_strides[2] + + out_y * out_strides[3] + out_x * out_strides[4]; + out_ptr[out_idx] = accum + bias_val; } - if (bias_ptr != nullptr) { - accum += load_bias(&bias_ptr[out_c * bias.value().element_size()]); - } - const size_t out_idx = - calculate_linear_index(out_coord, out_strides.data(), 4); - out_ptr[out_idx] = accum; } } } +// Views t as (N, C, D, H, W) by inserting leading spatial dims of size 1. +void get_3d_view( + const Tensor& t, + executorch::aten::SizesType* sizes, + executorch::aten::StridesType* strides) { + for (const auto d : c10::irange(5)) { + sizes[d] = 1; + strides[d] = 0; + } + const ssize_t added_dims = 5 - t.dim(); + for (const auto d : c10::irange(t.dim())) { + const ssize_t view_d = d < 2 ? d : d + added_dims; + sizes[view_d] = t.size(d); + strides[view_d] = t.strides()[d]; + } +} + // Keep dtype-specific loops separate from the scalar-type dispatch. template ET_NOINLINE void convolution_wrapper( @@ -307,87 +353,30 @@ ET_NOINLINE void convolution_wrapper( bool transposed, int64_t groups, Tensor& out) { - SizesArrayRef in_sizes = in.sizes(); - SizesArrayRef weight_sizes = weight.sizes(); - SizesArrayRef out_sizes = out.sizes(); - - DimOrderArrayRef in_dim_order = in.dim_order(); - DimOrderArrayRef weight_dim_order = weight.dim_order(); - DimOrderArrayRef out_dim_order = out.dim_order(); - - IntArrayRef stride_ = stride; - IntArrayRef padding_ = padding; - IntArrayRef dilation_ = dilation; - - // Define arrays for modified sizes, etc. which will potentially be used - executorch::aten::SizesType in_sizes_arr[kTensorDimensionLimit]; - executorch::aten::DimOrderType in_dim_order_arr[kTensorDimensionLimit]; - size_t in_ndim; - executorch::aten::SizesType weight_sizes_arr[kTensorDimensionLimit]; - executorch::aten::DimOrderType weight_dim_order_arr[kTensorDimensionLimit]; - size_t weight_ndim; - executorch::aten::SizesType out_sizes_arr[kTensorDimensionLimit]; - executorch::aten::DimOrderType out_dim_order_arr[kTensorDimensionLimit]; - size_t out_ndim; - - int64_t stride_arr[2]; - int64_t padding_arr[2]; - int64_t dilation_arr[2]; - - // If in has a dim of 3, then a 1D convolution will be performed. A 1D - // convolution is equivalent to a 2D convolution where the height dim of - // all tensors is 1, and stride = 1, padding = 0, and dilation = 1 for - // the height dimension. Therefore the tensor sizes are unsqueezed and - // the stride, padding, and dilation are adjusted so that a 2D - // convolution implementation can be used. - if (in.dim() == 3) { - get_unsqueezed_sizes(in, 2, in_sizes_arr, in_ndim); - in_sizes = {in_sizes_arr, in_ndim}; - get_unsqueezed_dim_order(in, 2, in_dim_order_arr); - in_dim_order = {in_dim_order_arr, in_ndim}; - - get_unsqueezed_sizes(weight, 2, weight_sizes_arr, weight_ndim); - weight_sizes = {weight_sizes_arr, weight_ndim}; - get_unsqueezed_dim_order(weight, 2, weight_dim_order_arr); - weight_dim_order = {weight_dim_order_arr, weight_ndim}; - - get_unsqueezed_sizes(out, 2, out_sizes_arr, out_ndim); - out_sizes = {out_sizes_arr, out_ndim}; - get_unsqueezed_dim_order(out, 2, out_dim_order_arr); - out_dim_order = {out_dim_order_arr, out_ndim}; - - stride_arr[0] = 1; - stride_arr[1] = stride[0]; - stride_ = {stride_arr, 2}; - - padding_arr[0] = 0; - padding_arr[1] = padding[0]; - padding_ = {padding_arr, 2}; - - dilation_arr[0] = 1; - if (dilation.size() > 0) { - dilation_arr[1] = dilation[0]; - } else { - dilation_arr[1] = 1; - } - dilation_ = {dilation_arr, 2}; + // A 1-D or 2-D convolution is a 3-D one whose leading spatial dims have size + // 1, stride 1, padding 0 and dilation 1, so every rank shares the 3-D + // implementation. + executorch::aten::SizesType in_sizes[5]; + executorch::aten::StridesType in_strides[5]; + get_3d_view(in, in_sizes, in_strides); + executorch::aten::SizesType weight_sizes[5]; + executorch::aten::StridesType weight_strides[5]; + get_3d_view(weight, weight_sizes, weight_strides); + executorch::aten::SizesType out_sizes[5]; + executorch::aten::StridesType out_strides[5]; + get_3d_view(out, out_sizes, out_strides); + + int64_t stride_3d[] = {1, 1, 1}; + int64_t padding_3d[] = {0, 0, 0}; + int64_t dilation_3d[] = {1, 1, 1}; + const ssize_t spatial_dims = in.dim() - 2; + for (const auto i : c10::irange(spatial_dims)) { + const ssize_t d = 3 - spatial_dims + i; + stride_3d[d] = val_at(stride, i); + padding_3d[d] = val_at(padding, i, /*default_value=*/0); + dilation_3d[d] = val_at(dilation, i); } - executorch::aten::StridesType in_strides[kTensorDimensionLimit]; - dim_order_to_stride_nocheck( - in_sizes.data(), in_dim_order.data(), in_sizes.size(), in_strides); - - executorch::aten::StridesType weight_strides[kTensorDimensionLimit]; - dim_order_to_stride_nocheck( - weight_sizes.data(), - weight_dim_order.data(), - weight_sizes.size(), - weight_strides); - - executorch::aten::StridesType out_strides[kTensorDimensionLimit]; - dim_order_to_stride_nocheck( - out_sizes.data(), out_dim_order.data(), out_sizes.size(), out_strides); - CTYPE* const out_ptr = out.mutable_data_ptr(); const CTYPE* const in_ptr = in.const_data_ptr(); const CTYPE* const w_ptr = weight.const_data_ptr(); @@ -406,23 +395,23 @@ ET_NOINLINE void convolution_wrapper( // Populate all the out channels in the group for (const auto out_c : c10::irange(out_c_start, out_c_start + out_C_per_group)) { - conv2d_impl( + conv3d_impl( in_ptr, in_sizes, - {in_strides, 4}, + in_strides, w_ptr, weight_sizes, - {weight_strides, 4}, + weight_strides, bias, bias_ptr, load_bias, - stride_, - padding_, - dilation_, + stride_3d, + padding_3d, + dilation_3d, groups, out_ptr, out_sizes, - {out_strides, 4}, + out_strides, batch, group, out_c, diff --git a/kernels/portable/cpu/util/kernel_ops_util.cpp b/kernels/portable/cpu/util/kernel_ops_util.cpp index 87d776f0e48..8962466a2df 100644 --- a/kernels/portable/cpu/util/kernel_ops_util.cpp +++ b/kernels/portable/cpu/util/kernel_ops_util.cpp @@ -164,39 +164,6 @@ bool output_size_is_valid( return valid; } -void get_unsqueezed_sizes( - const Tensor& t, - int64_t unsqueeze_dim, - executorch::aten::SizesType* sizes_arr, - size_t& ndim) { - ndim = t.dim() + 1; - for (const auto d : c10::irange(unsqueeze_dim)) { - sizes_arr[d] = t.size(d); - } - sizes_arr[unsqueeze_dim] = 1; - for (const auto d : c10::irange(unsqueeze_dim + 1, ndim)) { - sizes_arr[d] = t.size(d - 1); - } -} - -void get_unsqueezed_dim_order( - const Tensor& t, - executorch::aten::DimOrderType unsqueeze_dim, - executorch::aten::DimOrderType* dim_order_arr) { - int offset = 0; - for (const auto i : c10::irange(t.dim())) { - executorch::aten::DimOrderType dim = t.dim_order()[i]; - if (dim == unsqueeze_dim) { - dim_order_arr[i] = dim; - dim_order_arr[i + 1] = dim + 1; - offset = 1; - } else { - dim_order_arr[i + offset] = dim > unsqueeze_dim ? dim + 1 : dim; - } - } - return; -} - int64_t _kernel_output_size_helper( size_t inputSize, int64_t kernelSize, @@ -452,8 +419,8 @@ bool check_convolution_args( ET_LOG_AND_RETURN_IF_FALSE(tensor_is_default_or_channels_last_dim_order(out)); ET_CHECK_OR_RETURN_FALSE( - in.dim() == 3 || in.dim() == 4, - "Expect input tensor to be 3-D or 4-D, but got, %zu.", + in.dim() >= 3 && in.dim() <= 5, + "Expect input tensor to be 3-D, 4-D or 5-D, but got, %zu.", static_cast(in.dim())); ET_LOG_AND_RETURN_IF_FALSE(tensor_is_rank(weight, in.dim())); ET_LOG_AND_RETURN_IF_FALSE(tensor_is_rank(out, in.dim())); @@ -469,14 +436,10 @@ bool check_convolution_args( transposed ? groups * weight.size(1) : weight.size(0)); } - int64_t kernel_size[2]; - size_t kernel_ndim = 2; - if (weight.dim() == 3) { - kernel_size[0] = weight.size(2); - kernel_ndim = 1; - } else { - kernel_size[0] = weight.size(2); - kernel_size[1] = weight.size(3); + int64_t kernel_size[kTensorDimensionLimit]; + const size_t kernel_ndim = weight.dim() - 2; + for (const auto i : c10::irange(kernel_ndim)) { + kernel_size[i] = weight.size(i + 2); } ET_LOG_AND_RETURN_IF_FALSE( stride_is_valid(stride, kernel_ndim, /*allow_empty=*/false)); @@ -549,14 +512,10 @@ void get_convolution_out_target_size( out_sizes[1] = in.size(1) == 0 ? 0 : groups * weight.size(1); } - int64_t kernel_size[2]; - size_t kernel_ndim = 2; - if (weight.dim() == 3) { - kernel_size[0] = weight.size(2); - kernel_ndim = 1; - } else { - kernel_size[0] = weight.size(2); - kernel_size[1] = weight.size(3); + int64_t kernel_size[kTensorDimensionLimit]; + const size_t kernel_ndim = weight.dim() - 2; + for (const auto i : c10::irange(kernel_ndim)) { + kernel_size[i] = weight.size(i + 2); } calculate_kernel_output_sizes( in, diff --git a/kernels/portable/cpu/util/kernel_ops_util.h b/kernels/portable/cpu/util/kernel_ops_util.h index c776210eeac..12e78a04a86 100644 --- a/kernels/portable/cpu/util/kernel_ops_util.h +++ b/kernels/portable/cpu/util/kernel_ops_util.h @@ -50,17 +50,6 @@ bool output_size_is_valid( executorch::aten::ArrayRef output_size, size_t kernel_ndim); -void get_unsqueezed_sizes( - const Tensor& t, - int64_t unsqueeze_dim, - executorch::aten::SizesType* sizes_arr, - size_t& ndim); - -void get_unsqueezed_dim_order( - const Tensor& t, - executorch::aten::DimOrderType unsqueeze_dim, - executorch::aten::DimOrderType* dim_order_arr); - /** * Given an input tensor and N-dim kernel parameters, calculates the output size * of the N-dim kernel region. diff --git a/kernels/test/op_convolution_test.cpp b/kernels/test/op_convolution_test.cpp index 2cd4cd7bf07..c771f4620ad 100644 --- a/kernels/test/op_convolution_test.cpp +++ b/kernels/test/op_convolution_test.cpp @@ -641,6 +641,44 @@ TEST_F(OpConvCorrectnessTest, InvalidInputShape) { executorch::aten::ArrayRef{output_padding, 1}, groups, out)); + + // A 3-D convolution takes one stride, padding and dilation for all spatial + // dims or one per dim, and no input has more than three spatial dims. + Tensor input_3d = tf.ones({1, 2, 3, 3, 3}); + Tensor weight_3d = tf.ones({4, 2, 2, 2, 2}); + Tensor out_3d = tf.zeros({1, 4, 2, 2, 2}); + auto conv_3d = [&](executorch::aten::ArrayRef stride_3d, + executorch::aten::ArrayRef padding_3d, + executorch::aten::ArrayRef dilation_3d) { + context_ = torch::executor::KernelRuntimeContext(); + return op_convolution_out( + input_3d, + weight_3d, + bias, + stride_3d, + padding_3d, + dilation_3d, + false, + {0}, + 1, + out_3d); + }; + const int64_t one[] = {1}; + const int64_t zero[] = {0}; + const int64_t four_ones[] = {1, 1, 1, 1}; + const int64_t four_zeros[] = {0, 0, 0, 0}; + ET_EXPECT_KERNEL_FAILURE(context_, conv_3d(four_ones, zero, one)); + ET_EXPECT_KERNEL_FAILURE(context_, conv_3d(one, four_zeros, one)); + ET_EXPECT_KERNEL_FAILURE(context_, conv_3d(one, zero, four_ones)); + + Tensor input_6d = tf.ones({1, 2, 3, 3, 3, 3}); + Tensor weight_6d = tf.ones({4, 2, 2, 2, 2, 2}); + Tensor out_6d = tf.zeros({1, 4, 2, 2, 2, 2}); + context_ = torch::executor::KernelRuntimeContext(); + ET_EXPECT_KERNEL_FAILURE( + context_, + op_convolution_out( + input_6d, weight_6d, bias, {1}, {0}, {1}, false, {0}, 1, out_6d)); } TEST_F(OpConvCorrectnessTest, InvalidBiasLength) { @@ -1130,3 +1168,70 @@ TEST_F(OpConvCorrectnessTest, TransposedOutputTiles) { EXPECT_TENSOR_EQ(out, exp); } } + +namespace { +// Small integers that keep every sum exact in float. The tests pick periods +// that leave no repeats along any dim, so reading the wrong element changes the +// result. +std::vector cycling_values(size_t count, int period) { + std::vector values(count); + for (size_t i = 0; i < count; ++i) { + values[i] = static_cast(static_cast(i) % period - period / 2); + } + return values; +} +} // namespace + +TEST_F(OpConvCorrectnessTest, Grouped3DStridePaddingDilation) { + TensorFactory tf; + // Each spatial dim has its own size, kernel size, stride, padding and + // dilation, none of which is 1 (or 0 for padding), so mixing up dims or + // dropping a factor changes the result. + const int64_t stride[] = {2, 3, 4}; + const int64_t padding[] = {1, 2, 3}; + const int64_t dilation[] = {3, 4, 2}; + auto input = tf.make({1, 4, 10, 8, 5}, cycling_values(1600, 23)); + auto weight = tf.make({2, 2, 4, 2, 3}, cycling_values(96, 11)); + auto bias = tf.make({2}, {1, -1}); + // PyTorch reference. + auto expected = + tf.make({1, 2, 2, 3, 2}, {-52, -34, -31, 64, 86, 62, -38, -87, + 81, 0, 19, 62, 56, -21, -19, -60, + 49, 26, -63, -44, 40, -173, 24, 74}); + auto out = tf.zeros_like(expected); + op_convolution_out( + input, weight, bias, stride, padding, dilation, false, {0}, 2, out); + EXPECT_TENSOR_EQ(out, expected); +} + +TEST_F(OpConvCorrectnessTest, GroupedTransposed3DStridePaddingDilation) { + TensorFactory tf; + // Depth has its own stride, padding, dilation and output padding, and height + // and width have their own padding and dilation. + const int64_t stride[] = {2, 1, 1}; + const int64_t padding[] = {3, 1, 2}; + const int64_t dilation[] = {4, 2, 3}; + const int64_t output_padding[] = {1, 0, 0}; + auto input = tf.make({1, 4, 4, 2, 3}, cycling_values(96, 13)); + auto weight = tf.make({4, 1, 2, 2, 2}, cycling_values(32, 11)); + auto bias = tf.make({2}, {1, -1}); + // PyTorch reference. Even output depths get only the bias. + auto expected = tf.make( + {1, 2, 6, 2, 2}, + {1, 1, 1, 1, 12, 8, 54, -48, 1, 1, 1, 1, -30, -10, -5, 20, + 1, 1, 1, 1, 19, 19, -16, 9, -1, -1, -1, -1, -30, 36, -11, -3, + -1, -1, -1, -1, 8, -6, -4, -24, -1, -1, -1, -1, -20, 30, -15, -9}); + auto out = tf.zeros_like(expected); + op_convolution_out( + input, + weight, + bias, + stride, + padding, + dilation, + true, + output_padding, + 2, + out); + EXPECT_TENSOR_EQ(out, expected); +} From 4a928124f56769439e9d2d09bd6d9c09a024f1d4 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 10:59:56 -0700 Subject: [PATCH 04/25] Validate groups before the convolution bias length check The bias length check of a transposed convolution multiplies groups by weight.size(1). It ran before groups was checked, so a program with a huge groups value overflowed int64_t there, which is undefined behavior, even though a later check rejects the call. Run the bias check after groups is known to be between 1 and weight.size(0). The InvalidGroups test now also passes INT64_MAX as groups to a transposed convolution with a bias. Without this change, UndefinedBehaviorSanitizer reports the overflow. --- kernels/portable/cpu/util/kernel_ops_util.cpp | 22 +++++++++---------- kernels/test/op_convolution_test.cpp | 20 +++++++++++++++++ 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/kernels/portable/cpu/util/kernel_ops_util.cpp b/kernels/portable/cpu/util/kernel_ops_util.cpp index 8962466a2df..eb6cf60da1e 100644 --- a/kernels/portable/cpu/util/kernel_ops_util.cpp +++ b/kernels/portable/cpu/util/kernel_ops_util.cpp @@ -425,17 +425,6 @@ bool check_convolution_args( ET_LOG_AND_RETURN_IF_FALSE(tensor_is_rank(weight, in.dim())); ET_LOG_AND_RETURN_IF_FALSE(tensor_is_rank(out, in.dim())); - if (bias.has_value()) { - ET_LOG_AND_RETURN_IF_FALSE(tensor_is_rank(bias.value(), 1)); - ET_CHECK_OR_RETURN_FALSE( - bias.value().size(0) == - (transposed ? groups * weight.size(1) : weight.size(0)), - "bias length must equal number of output channels, but got %" ET_PRI_TENSOR_SIZE - "; expected %" PRId64, - bias.value().size(0), - transposed ? groups * weight.size(1) : weight.size(0)); - } - int64_t kernel_size[kTensorDimensionLimit]; const size_t kernel_ndim = weight.dim() - 2; for (const auto i : c10::irange(kernel_ndim)) { @@ -468,6 +457,17 @@ bool check_convolution_args( groups, weight.size(0)); + if (bias.has_value()) { + ET_LOG_AND_RETURN_IF_FALSE(tensor_is_rank(bias.value(), 1)); + ET_CHECK_OR_RETURN_FALSE( + bias.value().size(0) == + (transposed ? groups * weight.size(1) : weight.size(0)), + "bias length must equal number of output channels, but got %" ET_PRI_TENSOR_SIZE + "; expected %" PRId64, + bias.value().size(0), + transposed ? groups * weight.size(1) : weight.size(0)); + } + if (!transposed) { ET_CHECK_OR_RETURN_FALSE( in.size(1) == groups * weight.size(1), diff --git a/kernels/test/op_convolution_test.cpp b/kernels/test/op_convolution_test.cpp index c771f4620ad..208b11d2151 100644 --- a/kernels/test/op_convolution_test.cpp +++ b/kernels/test/op_convolution_test.cpp @@ -724,6 +724,26 @@ TEST_F(OpConvCorrectnessTest, InvalidGroups) { context_, op_convolution_out( input, weight, bias, {1}, {0}, {1}, false, {0}, 0, out)); + + // A transposed bias has groups * weight.size(1) elements, so groups must be + // in range before that product is formed. + Tensor transposed_weight = tf.ones({2, 4, 2, 2}); + Tensor transposed_bias = tf.ones({4}); + Tensor transposed_out = tf.zeros({1, 4, 4, 4}); + context_ = torch::executor::KernelRuntimeContext(); + ET_EXPECT_KERNEL_FAILURE( + context_, + op_convolution_out( + input, + transposed_weight, + transposed_bias, + {1}, + {0}, + {1}, + true, + {0}, + std::numeric_limits::max(), + transposed_out)); } TEST_F(OpConvCorrectnessTest, TransposedDefaultParams) { From 676b1fec4113989ff22c26cdf84d6ff7c12a2366 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 11:02:41 -0700 Subject: [PATCH 05/25] Keep get_unsqueezed_sizes and get_unsqueezed_dim_order The 3-D convolution change stopped using these two helpers and deleted them. They belong to kernel_ops_util, a public library that other kernels and backends link, so code outside this repository may still call them. Deleting them is a separate cleanup, so restore them unchanged. --- kernels/portable/cpu/util/kernel_ops_util.cpp | 33 +++++++++++++++++++ kernels/portable/cpu/util/kernel_ops_util.h | 11 +++++++ 2 files changed, 44 insertions(+) diff --git a/kernels/portable/cpu/util/kernel_ops_util.cpp b/kernels/portable/cpu/util/kernel_ops_util.cpp index eb6cf60da1e..6f46fc3dfa9 100644 --- a/kernels/portable/cpu/util/kernel_ops_util.cpp +++ b/kernels/portable/cpu/util/kernel_ops_util.cpp @@ -164,6 +164,39 @@ bool output_size_is_valid( return valid; } +void get_unsqueezed_sizes( + const Tensor& t, + int64_t unsqueeze_dim, + executorch::aten::SizesType* sizes_arr, + size_t& ndim) { + ndim = t.dim() + 1; + for (const auto d : c10::irange(unsqueeze_dim)) { + sizes_arr[d] = t.size(d); + } + sizes_arr[unsqueeze_dim] = 1; + for (const auto d : c10::irange(unsqueeze_dim + 1, ndim)) { + sizes_arr[d] = t.size(d - 1); + } +} + +void get_unsqueezed_dim_order( + const Tensor& t, + executorch::aten::DimOrderType unsqueeze_dim, + executorch::aten::DimOrderType* dim_order_arr) { + int offset = 0; + for (const auto i : c10::irange(t.dim())) { + executorch::aten::DimOrderType dim = t.dim_order()[i]; + if (dim == unsqueeze_dim) { + dim_order_arr[i] = dim; + dim_order_arr[i + 1] = dim + 1; + offset = 1; + } else { + dim_order_arr[i + offset] = dim > unsqueeze_dim ? dim + 1 : dim; + } + } + return; +} + int64_t _kernel_output_size_helper( size_t inputSize, int64_t kernelSize, diff --git a/kernels/portable/cpu/util/kernel_ops_util.h b/kernels/portable/cpu/util/kernel_ops_util.h index 12e78a04a86..c776210eeac 100644 --- a/kernels/portable/cpu/util/kernel_ops_util.h +++ b/kernels/portable/cpu/util/kernel_ops_util.h @@ -50,6 +50,17 @@ bool output_size_is_valid( executorch::aten::ArrayRef output_size, size_t kernel_ndim); +void get_unsqueezed_sizes( + const Tensor& t, + int64_t unsqueeze_dim, + executorch::aten::SizesType* sizes_arr, + size_t& ndim); + +void get_unsqueezed_dim_order( + const Tensor& t, + executorch::aten::DimOrderType unsqueeze_dim, + executorch::aten::DimOrderType* dim_order_arr); + /** * Given an input tensor and N-dim kernel parameters, calculates the output size * of the N-dim kernel region. From 64a9c867e62f2924c004074b18ccbf4cc563f1b8 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 11:15:21 -0700 Subject: [PATCH 06/25] Test 3-D convolution with channels-last tensors and single values The 3-D convolution tests used contiguous tensors only, so nothing checked that a channels-last (NDHWC) tensor with more than one channel is read through its strides. Both 3-D tests now also run with channels-last input, weight and output, like the 2-D tests. For this, TensorFactory::channels_last_like accepts 5-D tensors: depth and height are next to each other in both layouts, so a 5-D tensor is permuted like a 4-D one whose height covers both. The checks that reject 3-D stride, padding and dilation lists of the wrong length also passed before 3-D support, because the rank check rejected every 5-D input. A call with single values now runs first and must succeed, so those checks fail only because of the list length. It also covers single values that apply to every spatial dim. --- kernels/test/op_convolution_test.cpp | 47 ++++++++++++------- .../exec_aten/testing_util/tensor_factory.h | 19 +++++--- 2 files changed, 43 insertions(+), 23 deletions(-) diff --git a/kernels/test/op_convolution_test.cpp b/kernels/test/op_convolution_test.cpp index 208b11d2151..32d1756e5d9 100644 --- a/kernels/test/op_convolution_test.cpp +++ b/kernels/test/op_convolution_test.cpp @@ -667,6 +667,10 @@ TEST_F(OpConvCorrectnessTest, InvalidInputShape) { const int64_t zero[] = {0}; const int64_t four_ones[] = {1, 1, 1, 1}; const int64_t four_zeros[] = {0, 0, 0, 0}; + // Single values apply to every spatial dim, so this call succeeds and the + // calls below fail only because of their four-element lists. + conv_3d(one, zero, one); + EXPECT_TENSOR_EQ(out_3d, tf.full({1, 4, 2, 2, 2}, 16)); ET_EXPECT_KERNEL_FAILURE(context_, conv_3d(four_ones, zero, one)); ET_EXPECT_KERNEL_FAILURE(context_, conv_3d(one, four_zeros, one)); ET_EXPECT_KERNEL_FAILURE(context_, conv_3d(one, zero, four_ones)); @@ -1218,10 +1222,19 @@ TEST_F(OpConvCorrectnessTest, Grouped3DStridePaddingDilation) { tf.make({1, 2, 2, 3, 2}, {-52, -34, -31, 64, 86, 62, -38, -87, 81, 0, 19, 62, 56, -21, -19, -60, 49, 26, -63, -44, 40, -173, 24, 74}); - auto out = tf.zeros_like(expected); - op_convolution_out( - input, weight, bias, stride, padding, dilation, false, {0}, 2, out); - EXPECT_TENSOR_EQ(out, expected); + for (const bool channels_last : {false, true}) { + SCOPED_TRACE(channels_last); + auto in = channels_last ? tf.channels_last_like(input) : input; + auto w = channels_last ? tf.channels_last_like(weight) : weight; + auto exp = channels_last ? tf.channels_last_like(expected) : expected; + auto out = tf.zeros_like(exp); + if (channels_last) { + out = tf.channels_last_like(out); + } + op_convolution_out( + in, w, bias, stride, padding, dilation, false, {0}, 2, out); + EXPECT_TENSOR_EQ(out, exp); + } } TEST_F(OpConvCorrectnessTest, GroupedTransposed3DStridePaddingDilation) { @@ -1241,17 +1254,17 @@ TEST_F(OpConvCorrectnessTest, GroupedTransposed3DStridePaddingDilation) { {1, 1, 1, 1, 12, 8, 54, -48, 1, 1, 1, 1, -30, -10, -5, 20, 1, 1, 1, 1, 19, 19, -16, 9, -1, -1, -1, -1, -30, 36, -11, -3, -1, -1, -1, -1, 8, -6, -4, -24, -1, -1, -1, -1, -20, 30, -15, -9}); - auto out = tf.zeros_like(expected); - op_convolution_out( - input, - weight, - bias, - stride, - padding, - dilation, - true, - output_padding, - 2, - out); - EXPECT_TENSOR_EQ(out, expected); + for (const bool channels_last : {false, true}) { + SCOPED_TRACE(channels_last); + auto in = channels_last ? tf.channels_last_like(input) : input; + auto w = channels_last ? tf.channels_last_like(weight) : weight; + auto exp = channels_last ? tf.channels_last_like(expected) : expected; + auto out = tf.zeros_like(exp); + if (channels_last) { + out = tf.channels_last_like(out); + } + op_convolution_out( + in, w, bias, stride, padding, dilation, true, output_padding, 2, out); + EXPECT_TENSOR_EQ(out, exp); + } } diff --git a/runtime/core/exec_aten/testing_util/tensor_factory.h b/runtime/core/exec_aten/testing_util/tensor_factory.h index 562db9759a2..79cfd590f03 100644 --- a/runtime/core/exec_aten/testing_util/tensor_factory.h +++ b/runtime/core/exec_aten/testing_util/tensor_factory.h @@ -367,7 +367,8 @@ class TensorFactory { const at::Tensor& input, TensorShapeDynamism dynamism = TensorShapeDynamism::STATIC) { ET_CHECK_MSG( - input.sizes().size() == 4, "Only 4D tensors can be channels last"); + input.sizes().size() == 4 || input.sizes().size() == 5, + "Only 4D and 5D tensors can be channels last"); const std::vector sizes( input.sizes().begin(), input.sizes().end()); @@ -387,8 +388,10 @@ class TensorFactory { int32_t N = sizes[0]; int32_t C = sizes[1]; - int32_t H = sizes[2]; - int32_t W = sizes[3]; + // Depth and height are adjacent in both layouts, so a 5-D tensor is + // permuted like a 4-D one whose height covers both. + int32_t H = sizes.size() == 5 ? sizes[2] * sizes[3] : sizes[2]; + int32_t W = sizes.back(); std::vector contiguous_data( input.data_ptr(), input.data_ptr() + input.numel()); @@ -891,14 +894,18 @@ class TensorFactory { const std::vector sizes( input.sizes().begin(), input.sizes().end()); - ET_CHECK_MSG(sizes.size() == 4, "Only 4D tensors can be channels last"); + ET_CHECK_MSG( + sizes.size() == 4 || sizes.size() == 5, + "Only 4D and 5D tensors can be channels last"); ET_CHECK_MSG( is_contiguous_dim_order(input.dim_order().data(), input.dim()) == true, "Input tensor is not contiguous"); int32_t N = sizes[0]; int32_t C = sizes[1]; - int32_t H = sizes[2]; - int32_t W = sizes[3]; + // Depth and height are adjacent in both layouts, so a 5-D tensor is + // permuted like a 4-D one whose height covers both. + int32_t H = sizes.size() == 5 ? sizes[2] * sizes[3] : sizes[2]; + int32_t W = sizes.back(); std::vector contiguous_data( input.data_ptr(), input.data_ptr() + input.numel()); From 1d199ac82581a6db4397c47172dd5441d4c30ebd Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 11:19:33 -0700 Subject: [PATCH 07/25] Reject a convolution kernel that is larger than the padded input For a regular convolution, PyTorch raises an error when the dilated kernel is larger than the padded input in any spatial dim. The portable kernel did not check this. Its output size math rounds a negative quotient toward zero, so with a stride above 1 the output got size 1 and was computed from only part of the kernel. For example, an input depth of 2, a kernel depth of 3 and a stride of 2 gave an output depth of 1. 1-D and 2-D convolutions had the same problem. check_convolution_args now applies the PyTorch rule. Transposed convolutions are not affected. --- kernels/portable/cpu/util/kernel_ops_util.cpp | 18 +++++++++++++ kernels/test/op_convolution_test.cpp | 25 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/kernels/portable/cpu/util/kernel_ops_util.cpp b/kernels/portable/cpu/util/kernel_ops_util.cpp index 6f46fc3dfa9..01462d18b3a 100644 --- a/kernels/portable/cpu/util/kernel_ops_util.cpp +++ b/kernels/portable/cpu/util/kernel_ops_util.cpp @@ -471,6 +471,24 @@ bool check_convolution_args( if (transposed) { ET_LOG_AND_RETURN_IF_FALSE( output_padding_is_valid(output_padding, stride, dilation, kernel_ndim)); + } else { + // Like PyTorch, reject a dilated kernel larger than the padded input. The + // output size math would otherwise round such a size up to 1 for strides + // above 1. + for (const auto i : c10::irange(kernel_ndim)) { + const int64_t padded_input_size = + in.size(i + 2) + 2 * val_at(padding, i, /*default_value=*/0); + const int64_t dilated_kernel_size = + val_at(dilation, i, /*default_value=*/1) * (kernel_size[i] - 1) + 1; + ET_CHECK_OR_RETURN_FALSE( + padded_input_size >= dilated_kernel_size, + "Padded input size %" PRId64 + " is smaller than the dilated kernel size %" PRId64 + " in spatial dim %zu", + padded_input_size, + dilated_kernel_size, + i); + } } ET_CHECK_OR_RETURN_FALSE( diff --git a/kernels/test/op_convolution_test.cpp b/kernels/test/op_convolution_test.cpp index 32d1756e5d9..fd98bb11105 100644 --- a/kernels/test/op_convolution_test.cpp +++ b/kernels/test/op_convolution_test.cpp @@ -750,6 +750,31 @@ TEST_F(OpConvCorrectnessTest, InvalidGroups) { transposed_out)); } +TEST_F(OpConvCorrectnessTest, KernelLargerThanPaddedInput) { + TensorFactory tf; + + // Depth dilation 2 stretches the kernel depth of 2 to 3. + Tensor input = tf.ones({1, 1, 2, 2, 2}); + Tensor weight = tf.ones({1, 1, 2, 2, 2}); + optional bias; + Tensor out = tf.zeros({1, 1, 1, 1, 1}); + const int64_t stride[] = {2, 1, 1}; + const int64_t dilation[] = {2, 1, 1}; + + // Padding makes the input depth 4, so the kernel fits. + const int64_t padding[] = {1, 0, 0}; + op_convolution_out( + input, weight, bias, stride, padding, dilation, false, {0}, 1, out); + EXPECT_TENSOR_EQ(out, tf.full({1, 1, 1, 1, 1}, 4)); + + // Without padding the input depth of 2 is too small. Stride 2 must not round + // the output depth up to 1. + ET_EXPECT_KERNEL_FAILURE( + context_, + op_convolution_out( + input, weight, bias, stride, {0}, dilation, false, {0}, 1, out)); +} + TEST_F(OpConvCorrectnessTest, TransposedDefaultParams) { TensorFactory tf; From 53cf1bc6c109b15920b39504f1f86845e89d1183 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 11:51:32 -0700 Subject: [PATCH 08/25] Keep the transposed convolution range helpers out of line GCC at -Os inlines transposed_kernel_range and transposed_input_range at every call site, once per spatial dim in every dtype instantiation of the transposed convolution. With the depth loop, that made op_convolution.o 14 percent larger than before 3-D support when built for a Cortex-M55 with the Arm GNU toolchain at -Os. The helpers do not depend on the dtype, so one out-of-line copy can serve them all, and the object is now 3 percent larger than before. The helpers run once per output tile or kernel tap, outside the multiply-add loops. --- kernels/portable/cpu/op_convolution.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/kernels/portable/cpu/op_convolution.cpp b/kernels/portable/cpu/op_convolution.cpp index 4052bf7d029..e27298374f2 100644 --- a/kernels/portable/cpu/op_convolution.cpp +++ b/kernels/portable/cpu/op_convolution.cpp @@ -33,7 +33,9 @@ struct IndexRange { int64_t end; }; -IndexRange transposed_kernel_range( +// The range helpers do not depend on the dtype, so out of line one copy serves +// every dtype and spatial dim. +ET_NOINLINE IndexRange transposed_kernel_range( int64_t begin, int64_t end, int64_t input_size, @@ -49,7 +51,7 @@ IndexRange transposed_kernel_range( std::min(kernel_size, (end - 1 + padding) / dilation + 1)}; } -IndexRange transposed_input_range( +ET_NOINLINE IndexRange transposed_input_range( int64_t begin, int64_t end, int64_t input_size, From bb3abf882aa0fc5fde06c49686ad2f547d64bd6f Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 13:23:09 -0700 Subject: [PATCH 09/25] Make the 3-D convolution tests catch single-value and weight stride mistakes The call that checks single stride, padding and dilation values used 1, 0 and 1, which the kernel also uses for a dim that gets no value, so it could not catch a single value that is not applied to the depth. It now uses stride 2, padding 1 and dilation 2, and the calls that must fail use four-element lists of the same values. The transposed 3-D test had one output channel per group, so its weight had the same layout in contiguous and channels-last order, and a wrong weight stride went unnoticed. It now has two output channels per group, with new PyTorch reference values. --- kernels/test/op_convolution_test.cpp | 37 ++++++++++++++++------------ 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/kernels/test/op_convolution_test.cpp b/kernels/test/op_convolution_test.cpp index fd98bb11105..dd125e15081 100644 --- a/kernels/test/op_convolution_test.cpp +++ b/kernels/test/op_convolution_test.cpp @@ -664,16 +664,17 @@ TEST_F(OpConvCorrectnessTest, InvalidInputShape) { out_3d); }; const int64_t one[] = {1}; - const int64_t zero[] = {0}; + const int64_t two[] = {2}; const int64_t four_ones[] = {1, 1, 1, 1}; - const int64_t four_zeros[] = {0, 0, 0, 0}; - // Single values apply to every spatial dim, so this call succeeds and the - // calls below fail only because of their four-element lists. - conv_3d(one, zero, one); - EXPECT_TENSOR_EQ(out_3d, tf.full({1, 4, 2, 2, 2}, 16)); - ET_EXPECT_KERNEL_FAILURE(context_, conv_3d(four_ones, zero, one)); - ET_EXPECT_KERNEL_FAILURE(context_, conv_3d(one, four_zeros, one)); - ET_EXPECT_KERNEL_FAILURE(context_, conv_3d(one, zero, four_ones)); + const int64_t four_twos[] = {2, 2, 2, 2}; + // Single values apply to every spatial dim. With stride 2, padding 1 and + // dilation 2, each output sees one kernel tap per dim. The calls below differ + // only in the length of one list. + conv_3d(two, one, two); + EXPECT_TENSOR_EQ(out_3d, tf.full({1, 4, 2, 2, 2}, 2)); + ET_EXPECT_KERNEL_FAILURE(context_, conv_3d(four_twos, one, two)); + ET_EXPECT_KERNEL_FAILURE(context_, conv_3d(two, four_ones, two)); + ET_EXPECT_KERNEL_FAILURE(context_, conv_3d(two, one, four_twos)); Tensor input_6d = tf.ones({1, 2, 3, 3, 3, 3}); Tensor weight_6d = tf.ones({4, 2, 2, 2, 2, 2}); @@ -1265,20 +1266,24 @@ TEST_F(OpConvCorrectnessTest, Grouped3DStridePaddingDilation) { TEST_F(OpConvCorrectnessTest, GroupedTransposed3DStridePaddingDilation) { TensorFactory tf; // Depth has its own stride, padding, dilation and output padding, and height - // and width have their own padding and dilation. + // and width have their own padding and dilation. Two output channels per + // group make every weight stride matter in the channels-last layout. const int64_t stride[] = {2, 1, 1}; const int64_t padding[] = {3, 1, 2}; const int64_t dilation[] = {4, 2, 3}; const int64_t output_padding[] = {1, 0, 0}; auto input = tf.make({1, 4, 4, 2, 3}, cycling_values(96, 13)); - auto weight = tf.make({4, 1, 2, 2, 2}, cycling_values(32, 11)); - auto bias = tf.make({2}, {1, -1}); + auto weight = tf.make({4, 2, 2, 2, 2}, cycling_values(64, 11)); + auto bias = tf.make({4}, {1, -1, 2, -2}); // PyTorch reference. Even output depths get only the bias. auto expected = tf.make( - {1, 2, 6, 2, 2}, - {1, 1, 1, 1, 12, 8, 54, -48, 1, 1, 1, 1, -30, -10, -5, 20, - 1, 1, 1, 1, 19, 19, -16, 9, -1, -1, -1, -1, -30, 36, -11, -3, - -1, -1, -1, -1, 8, -6, -4, -24, -1, -1, -1, -1, -20, 30, -15, -9}); + {1, 4, 6, 2, 2}, + {1, 1, 1, 1, 0, -14, 54, -31, 1, 1, 1, 1, -12, -2, -2, 2, + 1, 1, 1, 1, -13, -29, -34, -3, -1, -1, -1, -1, 6, -6, -42, -5, + -1, -1, -1, -1, -12, 0, 1, -4, -1, -1, -1, -1, 3, -1, 27, 9, + 2, 2, 2, 2, -5, -13, 3, -6, 2, 2, 2, 2, -1, 1, -11, 55, + 2, 2, 2, 2, 29, 9, 12, 4, -2, -2, -2, -2, -9, -16, 1, 5, + -2, -2, -2, -2, 1, 5, -6, -43, -2, -2, -2, -2, 28, -19, -10, -6}); for (const bool channels_last : {false, true}) { SCOPED_TRACE(channels_last); auto in = channels_last ? tf.channels_last_like(input) : input; From 6105ad9a026a912da6bf30c71ecc88f7cb2f5153 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 16:03:57 -0700 Subject: [PATCH 10/25] Bound stride, padding and dilation in the convolution checks Stride, padding and dilation had only lower bounds. A huge value overflowed int64_t in the new kernel size check and in the output size math. For example, with dilation 2^62 the dilated kernel size wrapped around to a negative number and passed the check, and the output size that came out was narrowed to a valid 32-bit size, so a 1-D convolution that should fail ran. check_convolution_args now rejects values that do not fit in 32 bits, like the 32-bit tensor sizes, so this int64_t math cannot overflow. A new test passes a huge dilation, padding and stride. It skips ATen, which does not reject these values. --- kernels/portable/cpu/util/kernel_ops_util.cpp | 10 ++++ kernels/test/op_convolution_test.cpp | 52 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/kernels/portable/cpu/util/kernel_ops_util.cpp b/kernels/portable/cpu/util/kernel_ops_util.cpp index 01462d18b3a..1eb7b8def2b 100644 --- a/kernels/portable/cpu/util/kernel_ops_util.cpp +++ b/kernels/portable/cpu/util/kernel_ops_util.cpp @@ -468,6 +468,16 @@ bool check_convolution_args( ET_LOG_AND_RETURN_IF_FALSE( padding_is_valid(padding, {kernel_size, kernel_ndim}, kernel_ndim)); ET_LOG_AND_RETURN_IF_FALSE(dilation_is_valid(dilation, kernel_ndim)); + // Larger values would overflow int64_t in the kernel size check below and in + // the output size math. + for (const IntArrayRef param : {stride, padding, dilation}) { + for (const int64_t value : param) { + ET_CHECK_OR_RETURN_FALSE( + value <= std::numeric_limits::max(), + "Expected stride, padding and dilation to fit in 32 bits, but got %" PRId64, + value); + } + } if (transposed) { ET_LOG_AND_RETURN_IF_FALSE( output_padding_is_valid(output_padding, stride, dilation, kernel_ndim)); diff --git a/kernels/test/op_convolution_test.cpp b/kernels/test/op_convolution_test.cpp index dd125e15081..b58ea077664 100644 --- a/kernels/test/op_convolution_test.cpp +++ b/kernels/test/op_convolution_test.cpp @@ -776,6 +776,58 @@ TEST_F(OpConvCorrectnessTest, KernelLargerThanPaddedInput) { input, weight, bias, stride, {0}, dilation, false, {0}, 1, out)); } +TEST_F(OpConvCorrectnessTest, HugeStridePaddingDilation) { + ET_SKIP_IF( + torch::executor::testing::SupportedFeatures::get()->is_aten, + "ATen does not reject these values"); + TensorFactory tf; + + Tensor input = tf.ones({1, 1, 3}); + Tensor weight = tf.ones({1, 1, 3}); + Tensor point_weight = tf.ones({1, 1, 1}); + optional bias; + Tensor out = tf.zeros({1, 1, 3}); + Tensor point_out = tf.zeros({1, 1, 1}); + const int64_t above_int32[] = {int64_t{1} << 31}; + const int64_t huge[] = {std::numeric_limits::max() / 2 + 1}; + + // The dilated kernel size wraps around to a negative number. + ET_EXPECT_KERNEL_FAILURE( + context_, + op_convolution_out( + input, weight, bias, {1}, {0}, huge, false, {0}, 1, out)); + + // These output sizes are 2^32 + 1, which a 32-bit size truncates to 1. + context_ = torch::executor::KernelRuntimeContext(); + ET_EXPECT_KERNEL_FAILURE( + context_, + op_convolution_out( + input, + weight, + bias, + {1}, + above_int32, + {1}, + false, + {0}, + 1, + point_out)); + context_ = torch::executor::KernelRuntimeContext(); + ET_EXPECT_KERNEL_FAILURE( + context_, + op_convolution_out( + input, + point_weight, + bias, + above_int32, + {0}, + {1}, + true, + {0}, + 1, + point_out)); +} + TEST_F(OpConvCorrectnessTest, TransposedDefaultParams) { TensorFactory tf; From d07ae6d57473281c09841d345daa1fbd86525bba Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 16:04:54 -0700 Subject: [PATCH 11/25] Check the convolution bias dtype check_convolution_args compared the dtypes of the input, weight and output, but never looked at the bias dtype. For a bias outside the real, half and bfloat16 types, for example Bool, the kernel failed to get a function that loads the bias, then ran anyway and called a null function pointer. Reject such a bias in the argument checks. A new test passes a Bool bias, which crashed without this change. It skips ATen, which accepts a Bool bias. --- kernels/portable/cpu/util/kernel_ops_util.cpp | 2 ++ kernels/test/op_convolution_test.cpp | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/kernels/portable/cpu/util/kernel_ops_util.cpp b/kernels/portable/cpu/util/kernel_ops_util.cpp index 1eb7b8def2b..670fac5045a 100644 --- a/kernels/portable/cpu/util/kernel_ops_util.cpp +++ b/kernels/portable/cpu/util/kernel_ops_util.cpp @@ -520,6 +520,8 @@ bool check_convolution_args( if (bias.has_value()) { ET_LOG_AND_RETURN_IF_FALSE(tensor_is_rank(bias.value(), 1)); + ET_LOG_AND_RETURN_IF_FALSE( + executorch::runtime::tensor_is_realhbf16_type(bias.value())); ET_CHECK_OR_RETURN_FALSE( bias.value().size(0) == (transposed ? groups * weight.size(1) : weight.size(0)), diff --git a/kernels/test/op_convolution_test.cpp b/kernels/test/op_convolution_test.cpp index b58ea077664..3dd3d1b788d 100644 --- a/kernels/test/op_convolution_test.cpp +++ b/kernels/test/op_convolution_test.cpp @@ -717,6 +717,23 @@ TEST_F(OpConvCorrectnessTest, InvalidBiasLength) { transposed_out)); } +TEST_F(OpConvCorrectnessTest, InvalidBiasDtype) { + ET_SKIP_IF( + torch::executor::testing::SupportedFeatures::get()->is_aten, + "ATen accepts a Bool bias"); + TensorFactory tf; + TensorFactory tf_bool; + + Tensor input = tf.ones({1, 2, 3, 3}); + Tensor weight = tf.ones({4, 2, 2, 2}); + Tensor bias = tf_bool.ones({4}); + Tensor out = tf.zeros({1, 4, 2, 2}); + ET_EXPECT_KERNEL_FAILURE( + context_, + op_convolution_out( + input, weight, bias, {1}, {0}, {1}, false, {0}, 1, out)); +} + TEST_F(OpConvCorrectnessTest, InvalidGroups) { TensorFactory tf; From 89b3fd016ac7dd23059b8f6f5688d5866ba6d412 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 16:05:05 -0700 Subject: [PATCH 12/25] Say which build catches the groups overflow case With the old check order, the INT64_MAX groups case still failed the bias length check, because the overflowed product wrapped to a wrong length. So the test only notices the overflow in an UndefinedBehaviorSanitizer build that halts on errors. CI builds the sanitizer without halting, so say that in the comment. --- kernels/test/op_convolution_test.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/kernels/test/op_convolution_test.cpp b/kernels/test/op_convolution_test.cpp index 3dd3d1b788d..86bf56b0321 100644 --- a/kernels/test/op_convolution_test.cpp +++ b/kernels/test/op_convolution_test.cpp @@ -748,7 +748,8 @@ TEST_F(OpConvCorrectnessTest, InvalidGroups) { input, weight, bias, {1}, {0}, {1}, false, {0}, 0, out)); // A transposed bias has groups * weight.size(1) elements, so groups must be - // in range before that product is formed. + // in range before that product is formed. Only an UndefinedBehaviorSanitizer + // build that halts on errors fails here when the order is wrong. Tensor transposed_weight = tf.ones({2, 4, 2, 2}); Tensor transposed_bias = tf.ones({4}); Tensor transposed_out = tf.zeros({1, 4, 4, 4}); From b0b9851d2a04b6559dcdc821bb4ec36cd22ede70 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 16:07:32 -0700 Subject: [PATCH 13/25] Make the Cortex-M conv3d test exercise the 3-D indexing The conv3d case ran a 1x1x1 kernel with constant weights on one channel, so its output was just twice the input. It could not catch an indexing mistake, and with one channel its channels-last input had the same layout as a contiguous one. It now has two input and four output channels, a 2x3x3 kernel, stride (1, 2, 2), padding 1 and the default random weights, on a channels-last input. --- backends/cortex_m/test/ops/test_conv.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/backends/cortex_m/test/ops/test_conv.py b/backends/cortex_m/test/ops/test_conv.py index c18ac8fbcec..86b15032ca6 100644 --- a/backends/cortex_m/test/ops/test_conv.py +++ b/backends/cortex_m/test/ops/test_conv.py @@ -83,7 +83,6 @@ class CortexMConv3D(torch.nn.Module): def __init__(self, *args, **kwargs): super().__init__() self.conv = torch.nn.Conv3d(*args, **kwargs, bias=False) - self.conv.weight.data.fill_(2.0) def forward(self, x): return self.conv(x) @@ -224,9 +223,9 @@ def forward(self, x): example_inputs=(ramp_tensor(0, 10, (1, 3, 2)),), ), "conv3d": McuTestCase( - model=CortexMConv3D(1, 1, 1), + model=CortexMConv3D(2, 4, (2, 3, 3), stride=(1, 2, 2), padding=1), example_inputs=( - ramp_tensor(-1000, 1000, (2, 1, 3, 3, 3)).to( + ramp_tensor(-10, 10, (2, 2, 4, 6, 6)).to( memory_format=torch.channels_last_3d ), ), From 20324525e18744db931e87444c4ebe4f9d91ac5a Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 16:51:31 -0700 Subject: [PATCH 14/25] Use a contiguous input in the Cortex-M conv3d test PyTorch's CPU conv3d returns a contiguous output even for a channels_last_3d input, so the exported program gave the convolution a channels-last input and a contiguous output. The portable kernel needs both in the same layout, so the run on the Arm simulator failed. With one channel the two layouts were the same, which is why the old case passed. The C++ tests cover the kernel's channels-last 3-D path. --- backends/cortex_m/test/ops/test_conv.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/backends/cortex_m/test/ops/test_conv.py b/backends/cortex_m/test/ops/test_conv.py index 86b15032ca6..1212379bb5b 100644 --- a/backends/cortex_m/test/ops/test_conv.py +++ b/backends/cortex_m/test/ops/test_conv.py @@ -224,11 +224,7 @@ def forward(self, x): ), "conv3d": McuTestCase( model=CortexMConv3D(2, 4, (2, 3, 3), stride=(1, 2, 2), padding=1), - example_inputs=( - ramp_tensor(-10, 10, (2, 2, 4, 6, 6)).to( - memory_format=torch.channels_last_3d - ), - ), + example_inputs=(ramp_tensor(-10, 10, (2, 2, 4, 6, 6)),), ), "conv2d_x3": McuTestCase( model=CortexMConv2Dx3(), From 2cffcd299771a975b3452ca98de856c73be0561c Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 17:03:58 -0700 Subject: [PATCH 15/25] Reject empty spatial dims and output sizes above 32 bits in convolutions The 32-bit bound on stride, padding and dilation left two gaps: - An output size above 2^31 - 1 still fit int64_t, but was then cut to a 32-bit tensor size. Padding 2^31 - 1 on an input of 3 with a kernel of 1 gives 2^32 + 1, which became 1, so a call that should fail ran. - An empty spatial dim made the transposed output size math compute 0 - 1 in size_t. On 32-bit targets that overflowed int64_t. PyTorch rejects empty spatial dims, and now check_convolution_args does too. It also computes each output size and rejects one that does not fit in the 32-bit tensor size. The tests cover both. --- kernels/portable/cpu/util/kernel_ops_util.cpp | 41 ++++++++++++++----- kernels/test/op_convolution_test.cpp | 29 +++++++++++-- 2 files changed, 55 insertions(+), 15 deletions(-) diff --git a/kernels/portable/cpu/util/kernel_ops_util.cpp b/kernels/portable/cpu/util/kernel_ops_util.cpp index 670fac5045a..e334baf0503 100644 --- a/kernels/portable/cpu/util/kernel_ops_util.cpp +++ b/kernels/portable/cpu/util/kernel_ops_util.cpp @@ -468,8 +468,7 @@ bool check_convolution_args( ET_LOG_AND_RETURN_IF_FALSE( padding_is_valid(padding, {kernel_size, kernel_ndim}, kernel_ndim)); ET_LOG_AND_RETURN_IF_FALSE(dilation_is_valid(dilation, kernel_ndim)); - // Larger values would overflow int64_t in the kernel size check below and in - // the output size math. + // Larger values would overflow int64_t in the size math below. for (const IntArrayRef param : {stride, padding, dilation}) { for (const int64_t value : param) { ET_CHECK_OR_RETURN_FALSE( @@ -481,15 +480,20 @@ bool check_convolution_args( if (transposed) { ET_LOG_AND_RETURN_IF_FALSE( output_padding_is_valid(output_padding, stride, dilation, kernel_ndim)); - } else { - // Like PyTorch, reject a dilated kernel larger than the padded input. The - // output size math would otherwise round such a size up to 1 for strides - // above 1. - for (const auto i : c10::irange(kernel_ndim)) { - const int64_t padded_input_size = - in.size(i + 2) + 2 * val_at(padding, i, /*default_value=*/0); - const int64_t dilated_kernel_size = - val_at(dilation, i, /*default_value=*/1) * (kernel_size[i] - 1) + 1; + } + for (const auto i : c10::irange(kernel_ndim)) { + const int64_t input_size = in.size(i + 2); + const int64_t pad = val_at(padding, i, /*default_value=*/0); + const int64_t dil = val_at(dilation, i, /*default_value=*/1); + // Like PyTorch, only the batch and channel dims may be empty. + ET_CHECK_OR_RETURN_FALSE( + input_size > 0, "Expected input spatial dim %zu to be non-empty", i); + if (!transposed) { + // Like PyTorch, reject a dilated kernel larger than the padded input. The + // output size math would otherwise round such a size up to 1 for strides + // above 1. + const int64_t padded_input_size = input_size + 2 * pad; + const int64_t dilated_kernel_size = dil * (kernel_size[i] - 1) + 1; ET_CHECK_OR_RETURN_FALSE( padded_input_size >= dilated_kernel_size, "Padded input size %" PRId64 @@ -499,6 +503,21 @@ bool check_convolution_args( dilated_kernel_size, i); } + // calculate_kernel_output_sizes narrows the output sizes to SizesType. + const int64_t output_size = _kernel_output_size_helper( + input_size, + kernel_size[i], + pad, + val_at(stride, i), + dil, + /*ceil_mode=*/false, + transposed, + transposed ? val_at(output_padding, i, /*default_value=*/0) : 0); + ET_CHECK_OR_RETURN_FALSE( + output_size <= std::numeric_limits::max(), + "Output size %" PRId64 " in spatial dim %zu is too large", + output_size, + i); } ET_CHECK_OR_RETURN_FALSE( diff --git a/kernels/test/op_convolution_test.cpp b/kernels/test/op_convolution_test.cpp index 86bf56b0321..8883a3808c1 100644 --- a/kernels/test/op_convolution_test.cpp +++ b/kernels/test/op_convolution_test.cpp @@ -684,6 +684,26 @@ TEST_F(OpConvCorrectnessTest, InvalidInputShape) { context_, op_convolution_out( input_6d, weight_6d, bias, {1}, {0}, {1}, false, {0}, 1, out_6d)); + + // Like PyTorch, an empty spatial dim is rejected even when padding would + // give it an output. + Tensor empty_input = tf.ones({1, 1, 0}); + Tensor point_weight = tf.ones({1, 1, 1}); + Tensor padded_out = tf.zeros({1, 1, 2}); + context_ = torch::executor::KernelRuntimeContext(); + ET_EXPECT_KERNEL_FAILURE( + context_, + op_convolution_out( + empty_input, + point_weight, + bias, + {1}, + {1}, + {1}, + false, + {0}, + 1, + padded_out)); } TEST_F(OpConvCorrectnessTest, InvalidBiasLength) { @@ -806,25 +826,26 @@ TEST_F(OpConvCorrectnessTest, HugeStridePaddingDilation) { optional bias; Tensor out = tf.zeros({1, 1, 3}); Tensor point_out = tf.zeros({1, 1, 1}); + const int64_t int32_max[] = {std::numeric_limits::max()}; const int64_t above_int32[] = {int64_t{1} << 31}; const int64_t huge[] = {std::numeric_limits::max() / 2 + 1}; - // The dilated kernel size wraps around to a negative number. + // The dilated kernel size would wrap around to a negative number. ET_EXPECT_KERNEL_FAILURE( context_, op_convolution_out( input, weight, bias, {1}, {0}, huge, false, {0}, 1, out)); - // These output sizes are 2^32 + 1, which a 32-bit size truncates to 1. + // These output sizes are 2^32 + 1, which 32-bit sizes would cut to 1. context_ = torch::executor::KernelRuntimeContext(); ET_EXPECT_KERNEL_FAILURE( context_, op_convolution_out( input, - weight, + point_weight, bias, {1}, - above_int32, + int32_max, {1}, false, {0}, From 53cd3909cc34baae3136b60ff45c9bd1f7a2a467 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 17:21:09 -0700 Subject: [PATCH 16/25] Reject negative convolution output sizes before narrowing them A transposed convolution with a large padding can have a negative output size. For example, an input of 3, a kernel of 1 and padding 2^31 - 1 give 5 - 2^32. The output size check only had an upper bound, and the size became a positive 5 when cut to 32 bits, so the call ran. PyTorch rejects it as too small. Now each output size must be at least 1. The test now covers that case instead of a stride above the 32-bit bound, which the output size check catches too, so it is renamed to HugePaddingAndDilation. --- kernels/portable/cpu/util/kernel_ops_util.cpp | 6 +++-- kernels/test/op_convolution_test.cpp | 26 +++++++------------ 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/kernels/portable/cpu/util/kernel_ops_util.cpp b/kernels/portable/cpu/util/kernel_ops_util.cpp index e334baf0503..dd19b3bb449 100644 --- a/kernels/portable/cpu/util/kernel_ops_util.cpp +++ b/kernels/portable/cpu/util/kernel_ops_util.cpp @@ -514,8 +514,10 @@ bool check_convolution_args( transposed, transposed ? val_at(output_padding, i, /*default_value=*/0) : 0); ET_CHECK_OR_RETURN_FALSE( - output_size <= std::numeric_limits::max(), - "Output size %" PRId64 " in spatial dim %zu is too large", + output_size > 0 && + output_size <= + std::numeric_limits::max(), + "Output size %" PRId64 " in spatial dim %zu is out of range", output_size, i); } diff --git a/kernels/test/op_convolution_test.cpp b/kernels/test/op_convolution_test.cpp index 8883a3808c1..6acec9c007e 100644 --- a/kernels/test/op_convolution_test.cpp +++ b/kernels/test/op_convolution_test.cpp @@ -814,29 +814,29 @@ TEST_F(OpConvCorrectnessTest, KernelLargerThanPaddedInput) { input, weight, bias, stride, {0}, dilation, false, {0}, 1, out)); } -TEST_F(OpConvCorrectnessTest, HugeStridePaddingDilation) { +TEST_F(OpConvCorrectnessTest, HugePaddingAndDilation) { ET_SKIP_IF( torch::executor::testing::SupportedFeatures::get()->is_aten, - "ATen does not reject these values"); + "ATen does not reject all of these values"); TensorFactory tf; Tensor input = tf.ones({1, 1, 3}); Tensor weight = tf.ones({1, 1, 3}); Tensor point_weight = tf.ones({1, 1, 1}); optional bias; - Tensor out = tf.zeros({1, 1, 3}); - Tensor point_out = tf.zeros({1, 1, 1}); const int64_t int32_max[] = {std::numeric_limits::max()}; - const int64_t above_int32[] = {int64_t{1} << 31}; const int64_t huge[] = {std::numeric_limits::max() / 2 + 1}; // The dilated kernel size would wrap around to a negative number. + Tensor out = tf.zeros({1, 1, 3}); ET_EXPECT_KERNEL_FAILURE( context_, op_convolution_out( input, weight, bias, {1}, {0}, huge, false, {0}, 1, out)); - // These output sizes are 2^32 + 1, which 32-bit sizes would cut to 1. + // These output sizes, 2^32 + 1 and 5 - 2^32, would turn into 1 and 5 in + // 32-bit sizes. + Tensor out_1 = tf.zeros({1, 1, 1}); context_ = torch::executor::KernelRuntimeContext(); ET_EXPECT_KERNEL_FAILURE( context_, @@ -850,21 +850,13 @@ TEST_F(OpConvCorrectnessTest, HugeStridePaddingDilation) { false, {0}, 1, - point_out)); + out_1)); + Tensor out_5 = tf.zeros({1, 1, 5}); context_ = torch::executor::KernelRuntimeContext(); ET_EXPECT_KERNEL_FAILURE( context_, op_convolution_out( - input, - point_weight, - bias, - above_int32, - {0}, - {1}, - true, - {0}, - 1, - point_out)); + input, point_weight, bias, {1}, int32_max, {1}, true, {0}, 1, out_5)); } TEST_F(OpConvCorrectnessTest, TransposedDefaultParams) { From 176524a1e0ab85f32cf587aa8b7858bdc165a663 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 17:27:05 -0700 Subject: [PATCH 17/25] Make the dilation case show that the 32-bit bound matters With a kernel of 3, dilation 2^62 gave a negative output size once the bound was removed, so the new check on output sizes rejected the call too, and the test passed without the bound. With a kernel of 5 the dilated kernel size wraps to exactly 1 and the output size to a valid 3, so only the bound rejects the call. --- kernels/test/op_convolution_test.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kernels/test/op_convolution_test.cpp b/kernels/test/op_convolution_test.cpp index 6acec9c007e..0f76671543d 100644 --- a/kernels/test/op_convolution_test.cpp +++ b/kernels/test/op_convolution_test.cpp @@ -821,13 +821,13 @@ TEST_F(OpConvCorrectnessTest, HugePaddingAndDilation) { TensorFactory tf; Tensor input = tf.ones({1, 1, 3}); - Tensor weight = tf.ones({1, 1, 3}); + Tensor weight = tf.ones({1, 1, 5}); Tensor point_weight = tf.ones({1, 1, 1}); optional bias; const int64_t int32_max[] = {std::numeric_limits::max()}; const int64_t huge[] = {std::numeric_limits::max() / 2 + 1}; - // The dilated kernel size would wrap around to a negative number. + // The dilated kernel size would wrap around to 1, which fits the input. Tensor out = tf.zeros({1, 1, 3}); ET_EXPECT_KERNEL_FAILURE( context_, From c99a97574381546b325c8cc0f836c25afd025939 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 18:38:06 -0700 Subject: [PATCH 18/25] Let the convolution input and output have different dim orders PyTorch's CPU conv3d returns a contiguous output even for a channels_last_3d input, so an exported Conv3d with a channels-last input gives the kernel a channels-last input and a contiguous output. The kernel rejected that pair, so such a model failed at run time. The kernel reads the input, weight and output through their own strides, and check_convolution_args already limits the input, the output and a non-transposed weight to the contiguous or the channels-last dim order, so the input and output do not need to match. Drop that check. Both 3-D tests now also run a channels-last input and weight with a contiguous output, and the Cortex-M conv3d test uses a channels_last_3d input again. --- backends/cortex_m/test/ops/test_conv.py | 6 +++++- kernels/portable/cpu/op_convolution.cpp | 3 --- kernels/test/op_convolution_test.cpp | 28 +++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/backends/cortex_m/test/ops/test_conv.py b/backends/cortex_m/test/ops/test_conv.py index 1212379bb5b..86b15032ca6 100644 --- a/backends/cortex_m/test/ops/test_conv.py +++ b/backends/cortex_m/test/ops/test_conv.py @@ -224,7 +224,11 @@ def forward(self, x): ), "conv3d": McuTestCase( model=CortexMConv3D(2, 4, (2, 3, 3), stride=(1, 2, 2), padding=1), - example_inputs=(ramp_tensor(-10, 10, (2, 2, 4, 6, 6)),), + example_inputs=( + ramp_tensor(-10, 10, (2, 2, 4, 6, 6)).to( + memory_format=torch.channels_last_3d + ), + ), ), "conv2d_x3": McuTestCase( model=CortexMConv2Dx3(), diff --git a/kernels/portable/cpu/op_convolution.cpp b/kernels/portable/cpu/op_convolution.cpp index e27298374f2..cff516a72ff 100644 --- a/kernels/portable/cpu/op_convolution.cpp +++ b/kernels/portable/cpu/op_convolution.cpp @@ -455,9 +455,6 @@ Tensor& convolution_out( InvalidArgument, out); - ET_KERNEL_CHECK( - ctx, tensors_have_same_dim_order(in, out), InvalidArgument, out); - size_t output_ndim = 0; executorch::aten::SizesType output_sizes[kTensorDimensionLimit]; get_convolution_out_target_size( diff --git a/kernels/test/op_convolution_test.cpp b/kernels/test/op_convolution_test.cpp index 0f76671543d..27aa3715c51 100644 --- a/kernels/test/op_convolution_test.cpp +++ b/kernels/test/op_convolution_test.cpp @@ -1344,6 +1344,20 @@ TEST_F(OpConvCorrectnessTest, Grouped3DStridePaddingDilation) { in, w, bias, stride, padding, dilation, false, {0}, 2, out); EXPECT_TENSOR_EQ(out, exp); } + // Export gives a channels-last 5-D input a contiguous output. + auto out = tf.zeros_like(expected); + op_convolution_out( + tf.channels_last_like(input), + tf.channels_last_like(weight), + bias, + stride, + padding, + dilation, + false, + {0}, + 2, + out); + EXPECT_TENSOR_EQ(out, expected); } TEST_F(OpConvCorrectnessTest, GroupedTransposed3DStridePaddingDilation) { @@ -1380,4 +1394,18 @@ TEST_F(OpConvCorrectnessTest, GroupedTransposed3DStridePaddingDilation) { in, w, bias, stride, padding, dilation, true, output_padding, 2, out); EXPECT_TENSOR_EQ(out, exp); } + // Export gives a channels-last 5-D input a contiguous output. + auto out = tf.zeros_like(expected); + op_convolution_out( + tf.channels_last_like(input), + tf.channels_last_like(weight), + bias, + stride, + padding, + dilation, + true, + output_padding, + 2, + out); + EXPECT_TENSOR_EQ(out, expected); } From 294b080d2ec12a5115e3c722f02d56202953b61d Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 19:24:39 -0700 Subject: [PATCH 19/25] Reject a convolution weight with an empty kernel dim check_convolution_args read the kernel size from the weight but never checked it. With a kernel dim of 0, the dilated kernel size became 1 - dilation, so even the kernel size check passed, and an input of 3 gave an output of 4 that no kernel tap ever wrote. PyTorch rejects a kernel size of 0. Check it with kernel_size_is_valid, like the pooling checks do. --- kernels/portable/cpu/util/kernel_ops_util.cpp | 2 ++ kernels/test/op_convolution_test.cpp | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/kernels/portable/cpu/util/kernel_ops_util.cpp b/kernels/portable/cpu/util/kernel_ops_util.cpp index dd19b3bb449..36e62a4a676 100644 --- a/kernels/portable/cpu/util/kernel_ops_util.cpp +++ b/kernels/portable/cpu/util/kernel_ops_util.cpp @@ -463,6 +463,8 @@ bool check_convolution_args( for (const auto i : c10::irange(kernel_ndim)) { kernel_size[i] = weight.size(i + 2); } + ET_LOG_AND_RETURN_IF_FALSE( + kernel_size_is_valid({kernel_size, kernel_ndim}, kernel_ndim)); ET_LOG_AND_RETURN_IF_FALSE( stride_is_valid(stride, kernel_ndim, /*allow_empty=*/false)); ET_LOG_AND_RETURN_IF_FALSE( diff --git a/kernels/test/op_convolution_test.cpp b/kernels/test/op_convolution_test.cpp index 27aa3715c51..7c187970b0f 100644 --- a/kernels/test/op_convolution_test.cpp +++ b/kernels/test/op_convolution_test.cpp @@ -685,6 +685,25 @@ TEST_F(OpConvCorrectnessTest, InvalidInputShape) { op_convolution_out( input_6d, weight_6d, bias, {1}, {0}, {1}, false, {0}, 1, out_6d)); + // Like PyTorch, a kernel dim must not be empty. + Tensor narrow_input = tf.ones({1, 1, 3}); + Tensor empty_weight = tf.ones({1, 1, 0}); + Tensor wide_out = tf.zeros({1, 1, 4}); + context_ = torch::executor::KernelRuntimeContext(); + ET_EXPECT_KERNEL_FAILURE( + context_, + op_convolution_out( + narrow_input, + empty_weight, + bias, + {1}, + {0}, + {1}, + false, + {0}, + 1, + wide_out)); + // Like PyTorch, an empty spatial dim is rejected even when padding would // give it an output. Tensor empty_input = tf.ones({1, 1, 0}); From 76b63200f54894df8ed34c30dbd62089dac17fe3 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 19:26:11 -0700 Subject: [PATCH 20/25] Allow an empty spatial dim when the batch or channel dim is empty The new check for empty spatial dims also rejected inputs whose batch or channel dim is empty, which main and PyTorch accept and turn into an empty output. Allow those again, like PyTorch. The transposed output size math now subtracts 1 from the input size in int64_t instead of size_t, so an empty spatial dim cannot make it wrap on 32-bit targets. --- kernels/portable/cpu/util/kernel_ops_util.cpp | 11 +++++--- kernels/test/op_convolution_test.cpp | 26 +++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/kernels/portable/cpu/util/kernel_ops_util.cpp b/kernels/portable/cpu/util/kernel_ops_util.cpp index 36e62a4a676..b1030cb9a25 100644 --- a/kernels/portable/cpu/util/kernel_ops_util.cpp +++ b/kernels/portable/cpu/util/kernel_ops_util.cpp @@ -207,8 +207,8 @@ int64_t _kernel_output_size_helper( bool transposed, int64_t output_padding) { if (transposed) { - return (inputSize - 1) * stride - 2 * pad + dilation * (kernelSize - 1) + - output_padding + 1; + return (static_cast(inputSize) - 1) * stride - 2 * pad + + dilation * (kernelSize - 1) + output_padding + 1; } int64_t numerator = inputSize + 2 * pad - dilation * (kernelSize - 1) - 1 + (ceil_mode ? stride - 1 : 0); @@ -487,9 +487,12 @@ bool check_convolution_args( const int64_t input_size = in.size(i + 2); const int64_t pad = val_at(padding, i, /*default_value=*/0); const int64_t dil = val_at(dilation, i, /*default_value=*/1); - // Like PyTorch, only the batch and channel dims may be empty. + // Like PyTorch, a spatial dim may only be empty when the batch or channel + // dim is empty too. ET_CHECK_OR_RETURN_FALSE( - input_size > 0, "Expected input spatial dim %zu to be non-empty", i); + input_size > 0 || in.size(0) == 0 || in.size(1) == 0, + "Input spatial dim %zu is empty, but the batch and channel dims are not", + i); if (!transposed) { // Like PyTorch, reject a dilated kernel larger than the padded input. The // output size math would otherwise round such a size up to 1 for strides diff --git a/kernels/test/op_convolution_test.cpp b/kernels/test/op_convolution_test.cpp index 7c187970b0f..bc4dbe48da3 100644 --- a/kernels/test/op_convolution_test.cpp +++ b/kernels/test/op_convolution_test.cpp @@ -725,6 +725,32 @@ TEST_F(OpConvCorrectnessTest, InvalidInputShape) { padded_out)); } +TEST_F(OpConvCorrectnessTest, EmptyBatchWithEmptySpatialDim) { + TensorFactory tf; + optional bias; + + // Like PyTorch, an empty spatial dim is fine when the batch is empty too. + Tensor input = tf.ones({0, 1, 0}); + Tensor weight = tf.ones({1, 1, 1}); + Tensor out = tf.zeros({0, 1, 2}); + op_convolution_out(input, weight, bias, {1}, {1}, {1}, false, {0}, 1, out); + + Tensor transposed_input = tf.ones({0, 2, 0}); + Tensor transposed_weight = tf.ones({2, 1, 3}); + Tensor transposed_out = tf.zeros({0, 1, 2}); + op_convolution_out( + transposed_input, + transposed_weight, + bias, + {1}, + {0}, + {1}, + true, + {0}, + 1, + transposed_out); +} + TEST_F(OpConvCorrectnessTest, InvalidBiasLength) { TensorFactory tf; From b66fc35831d693bffb1c79267ab0c651067893d1 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 20:33:27 -0700 Subject: [PATCH 21/25] Use a contiguous input in the Cortex-M conv3d test again With a channels_last_3d input the case now ran on the Arm simulator but gave wrong values. The Arm test runner writes an input in its logical order unless its dim order is NHWC or the runner's own 5-D order, so a channels_last_3d input reached the program in the wrong byte order. The kernel handles a channels-last input with a contiguous output, as the C++ 3-D tests show, so give the Cortex-M case a contiguous input, with a comment that says why. --- backends/cortex_m/test/ops/test_conv.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/backends/cortex_m/test/ops/test_conv.py b/backends/cortex_m/test/ops/test_conv.py index 86b15032ca6..c3b7863dc7b 100644 --- a/backends/cortex_m/test/ops/test_conv.py +++ b/backends/cortex_m/test/ops/test_conv.py @@ -222,13 +222,12 @@ def forward(self, x): model=CortexMConv1D(1, 1, 1), example_inputs=(ramp_tensor(0, 10, (1, 3, 2)),), ), + # Contiguous on purpose: the Arm test runner only reorders NHWC inputs and its + # own 5-D order, so a channels_last_3d input would reach the simulator in the + # wrong byte order. "conv3d": McuTestCase( model=CortexMConv3D(2, 4, (2, 3, 3), stride=(1, 2, 2), padding=1), - example_inputs=( - ramp_tensor(-10, 10, (2, 2, 4, 6, 6)).to( - memory_format=torch.channels_last_3d - ), - ), + example_inputs=(ramp_tensor(-10, 10, (2, 2, 4, 6, 6)),), ), "conv2d_x3": McuTestCase( model=CortexMConv2Dx3(), From 426cc90eb3f2548c84a8bffb2e2622f3bdadadc7 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 21:58:57 -0700 Subject: [PATCH 22/25] Test the kernel size check with a 1-D convolution too The kernel size check fixes 1-D and 2-D convolutions as well, but its test used only a 5-D input, which main rejected at the rank check anyway. Add a 1-D case: an input of 2 with a kernel of 3 and stride 2, which main ran from two of the three kernel taps. --- kernels/test/op_convolution_test.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/kernels/test/op_convolution_test.cpp b/kernels/test/op_convolution_test.cpp index bc4dbe48da3..5b1ccaccbdc 100644 --- a/kernels/test/op_convolution_test.cpp +++ b/kernels/test/op_convolution_test.cpp @@ -857,6 +857,16 @@ TEST_F(OpConvCorrectnessTest, KernelLargerThanPaddedInput) { context_, op_convolution_out( input, weight, bias, stride, {0}, dilation, false, {0}, 1, out)); + + // The same holds in 1-D. + Tensor input_1d = tf.ones({1, 1, 2}); + Tensor weight_1d = tf.ones({1, 1, 3}); + Tensor out_1d = tf.zeros({1, 1, 1}); + context_ = torch::executor::KernelRuntimeContext(); + ET_EXPECT_KERNEL_FAILURE( + context_, + op_convolution_out( + input_1d, weight_1d, bias, {2}, {0}, {1}, false, {0}, 1, out_1d)); } TEST_F(OpConvCorrectnessTest, HugePaddingAndDilation) { From 1f17c1a07834fc27098d4193ec6da552586cbe7a Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sun, 1 Nov 2026 13:14:02 -0800 Subject: [PATCH 23/25] Clarify comments in the portable convolution kernel Say why the bias length check comes after the groups checks: they keep groups * weight.size(1) from overflowing. Before this, only a test comment said so. Also fix three comments that were hard to read or not quite right: the output size math can round a too-large kernel up to 1, but only when the kernel is too large by less than the stride; the range helper comment is reworded; and the depth loop comment now says the loop runs once for 1-D and 2-D inputs. --- kernels/portable/cpu/op_convolution.cpp | 8 ++++---- kernels/portable/cpu/util/kernel_ops_util.cpp | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/kernels/portable/cpu/op_convolution.cpp b/kernels/portable/cpu/op_convolution.cpp index cff516a72ff..3a8a2f40014 100644 --- a/kernels/portable/cpu/op_convolution.cpp +++ b/kernels/portable/cpu/op_convolution.cpp @@ -33,8 +33,8 @@ struct IndexRange { int64_t end; }; -// The range helpers do not depend on the dtype, so out of line one copy serves -// every dtype and spatial dim. +// The range helpers do not depend on the dtype, so a single out-of-line copy +// serves every dtype and spatial dim. ET_NOINLINE IndexRange transposed_kernel_range( int64_t begin, int64_t end, @@ -281,8 +281,8 @@ void conv3d_impl( for (const auto out_y : c10::irange(out_H)) { for (const auto out_x : c10::irange(out_W)) { COMPUTE_T accum = 0; - // Depth taps sit outside the channel loop, so lower ranks, whose depth - // is 1, add no work per channel. + // Depth taps sit outside the channel loop, so for 1-D and 2-D inputs + // the depth loop runs once and adds no work per channel. for (const auto w_z : c10::irange(w_D)) { int64_t in_z = stride_z * out_z + dilation_z * w_z - padding_z; if (in_z < 0 || in_z >= in_D) { diff --git a/kernels/portable/cpu/util/kernel_ops_util.cpp b/kernels/portable/cpu/util/kernel_ops_util.cpp index b1030cb9a25..7d6501e08bd 100644 --- a/kernels/portable/cpu/util/kernel_ops_util.cpp +++ b/kernels/portable/cpu/util/kernel_ops_util.cpp @@ -495,7 +495,7 @@ bool check_convolution_args( i); if (!transposed) { // Like PyTorch, reject a dilated kernel larger than the padded input. The - // output size math would otherwise round such a size up to 1 for strides + // output size math can otherwise round such a size up to 1 for strides // above 1. const int64_t padded_input_size = input_size + 2 * pad; const int64_t dilated_kernel_size = dil * (kernel_size[i] - 1) + 1; @@ -544,6 +544,7 @@ bool check_convolution_args( groups, weight.size(0)); + // The groups checks above keep groups * weight.size(1) from overflowing. if (bias.has_value()) { ET_LOG_AND_RETURN_IF_FALSE(tensor_is_rank(bias.value(), 1)); ET_LOG_AND_RETURN_IF_FALSE( From 3ef697f80ef5f0ec6b35d5b702eb3c9993b2835f Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sun, 1 Nov 2026 13:14:02 -0800 Subject: [PATCH 24/25] Test more convolution argument and layout cases Three cases had no test, so the code that handles them could break without any test failing: - An empty spatial dim with an empty channel dim, which PyTorch accepts. Only the empty batch case was tested. - A transposed convolution with a huge stride or padding, where the 64-bit output size math would wrap around to a valid size. Only the dilation part of the 32-bit bound was tested. - A channels-last 5-D input with a contiguous weight and a contiguous output, which is what export gives when only the input is channels-last. The mixed layout calls used a channels-last weight. --- kernels/test/op_convolution_test.cpp | 97 +++++++++++++++++++--------- 1 file changed, 67 insertions(+), 30 deletions(-) diff --git a/kernels/test/op_convolution_test.cpp b/kernels/test/op_convolution_test.cpp index 5b1ccaccbdc..3ef8b2fa05b 100644 --- a/kernels/test/op_convolution_test.cpp +++ b/kernels/test/op_convolution_test.cpp @@ -725,16 +725,32 @@ TEST_F(OpConvCorrectnessTest, InvalidInputShape) { padded_out)); } -TEST_F(OpConvCorrectnessTest, EmptyBatchWithEmptySpatialDim) { +TEST_F(OpConvCorrectnessTest, EmptyBatchOrChannelWithEmptySpatialDim) { TensorFactory tf; optional bias; - // Like PyTorch, an empty spatial dim is fine when the batch is empty too. + // Like PyTorch, an empty spatial dim is fine when the batch or channel dim is + // empty too. Tensor input = tf.ones({0, 1, 0}); Tensor weight = tf.ones({1, 1, 1}); Tensor out = tf.zeros({0, 1, 2}); op_convolution_out(input, weight, bias, {1}, {1}, {1}, false, {0}, 1, out); + Tensor channelless_input = tf.ones({1, 0, 0}); + Tensor channelless_weight = tf.ones({1, 0, 1}); + Tensor channelless_out = tf.zeros({1, 0, 2}); + op_convolution_out( + channelless_input, + channelless_weight, + bias, + {1}, + {1}, + {1}, + false, + {0}, + 1, + channelless_out); + Tensor transposed_input = tf.ones({0, 2, 0}); Tensor transposed_weight = tf.ones({2, 1, 3}); Tensor transposed_out = tf.zeros({0, 1, 2}); @@ -912,6 +928,21 @@ TEST_F(OpConvCorrectnessTest, HugePaddingAndDilation) { context_, op_convolution_out( input, point_weight, bias, {1}, int32_max, {1}, true, {0}, 1, out_5)); + + // A huge stride or padding would wrap these transposed output sizes around to + // 1 and 5 in 64 bits. + Tensor input_5 = tf.ones({1, 1, 5}); + context_ = torch::executor::KernelRuntimeContext(); + ET_EXPECT_KERNEL_FAILURE( + context_, + op_convolution_out( + input_5, point_weight, bias, huge, {0}, {1}, true, {0}, 1, out_1)); + const int64_t int64_max[] = {std::numeric_limits::max()}; + context_ = torch::executor::KernelRuntimeContext(); + ET_EXPECT_KERNEL_FAILURE( + context_, + op_convolution_out( + input, point_weight, bias, {1}, int64_max, {1}, true, {0}, 1, out_5)); } TEST_F(OpConvCorrectnessTest, TransposedDefaultParams) { @@ -1399,20 +1430,23 @@ TEST_F(OpConvCorrectnessTest, Grouped3DStridePaddingDilation) { in, w, bias, stride, padding, dilation, false, {0}, 2, out); EXPECT_TENSOR_EQ(out, exp); } - // Export gives a channels-last 5-D input a contiguous output. - auto out = tf.zeros_like(expected); - op_convolution_out( - tf.channels_last_like(input), - tf.channels_last_like(weight), - bias, - stride, - padding, - dilation, - false, - {0}, - 2, - out); - EXPECT_TENSOR_EQ(out, expected); + // Export gives a channels-last 5-D input a contiguous output, and the + // weight may keep its contiguous layout. + for (const Tensor& w : {weight, tf.channels_last_like(weight)}) { + auto out = tf.zeros_like(expected); + op_convolution_out( + tf.channels_last_like(input), + w, + bias, + stride, + padding, + dilation, + false, + {0}, + 2, + out); + EXPECT_TENSOR_EQ(out, expected); + } } TEST_F(OpConvCorrectnessTest, GroupedTransposed3DStridePaddingDilation) { @@ -1449,18 +1483,21 @@ TEST_F(OpConvCorrectnessTest, GroupedTransposed3DStridePaddingDilation) { in, w, bias, stride, padding, dilation, true, output_padding, 2, out); EXPECT_TENSOR_EQ(out, exp); } - // Export gives a channels-last 5-D input a contiguous output. - auto out = tf.zeros_like(expected); - op_convolution_out( - tf.channels_last_like(input), - tf.channels_last_like(weight), - bias, - stride, - padding, - dilation, - true, - output_padding, - 2, - out); - EXPECT_TENSOR_EQ(out, expected); + // Export gives a channels-last 5-D input a contiguous output, and the + // weight may keep its contiguous layout. + for (const Tensor& w : {weight, tf.channels_last_like(weight)}) { + auto out = tf.zeros_like(expected); + op_convolution_out( + tf.channels_last_like(input), + w, + bias, + stride, + padding, + dilation, + true, + output_padding, + 2, + out); + EXPECT_TENSOR_EQ(out, expected); + } } From caf610b39a2610410eac7a9d082ef1c024ba59a8 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sun, 1 Nov 2026 13:14:03 -0800 Subject: [PATCH 25/25] Run 3-D convolutions in the backend test suite The backend test suite skipped every model with a 5-D convolution, because the portable kernel could not run one. Now it can, so remove the skip, and the suite's Conv3d and ConvTranspose3d cases run in every flow. --- backends/test/suite/runner.py | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/backends/test/suite/runner.py b/backends/test/suite/runner.py index e90e951db20..f509a75b3de 100644 --- a/backends/test/suite/runner.py +++ b/backends/test/suite/runner.py @@ -37,7 +37,6 @@ TestResult, ) from executorch.exir import EdgeProgramManager -from executorch.exir.dialects._ops import ops as exir_ops # A list of all runnable test suites and the corresponding python package. @@ -47,24 +46,6 @@ } -def _graph_has_unsupported_patterns(program: torch.export.ExportedProgram) -> bool: - # Returns true if the model contains patterns that will fail when running on the ET - # portable kernel library. - - # Check for 3d convolutions. All convs (1d, 2d, 3d) use the same op, so we need to look at - # the input meta to determine the rank. - for node in program.graph.nodes: - if ( - node.op == "call_function" - and node.target == exir_ops.edge.aten.convolution.default - ): - in_rank = node.args[0].meta["val"].dim() - if in_rank > 4: - return True - - return False - - def _get_test_seed(test_base_name: str) -> int: # Set the seed based on the test base name to give consistent inputs between backends. Add the # run seed to allow for reproducible results, but still allow for run-to-run variation. @@ -185,7 +166,7 @@ def build_result( # Check if any undelegated ops are in the unsupported ops set. has_unsupported_ops = any( op in UNSUPPORTED_PORTABLE_OPS for op in undelegated_op_counts.keys() - ) or _graph_has_unsupported_patterns(edge_manager._etrecord.edge_dialect_program) + ) # Skip the test if there are unsupported portable ops remaining. if has_unsupported_ops: