From e1d05170706be8c6fce48fc8f028733752927fa3 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Mon, 2 Nov 2026 08:44:48 -0800 Subject: [PATCH] Add a portable kernel for antialiased bicubic resize (#24400) `to_edge` cannot lower `F.interpolate(x, mode="bicubic", antialias=True)` today. It exports to `aten._upsample_bicubic2d_aa`, which is not a core ATen op, so `to_edge` stops with: ``` SpecViolationError: Operator torch._ops.aten._upsample_bicubic2d_aa.default is not in Core ATen opset ``` ExecuTorch also has no kernel for it. ## What this change does It adds a portable kernel for `_upsample_bicubic2d_aa.out` and lists the op as an exception in the edge verifier, as the antialiased bilinear resize does. Like ATen's CPU kernel, it uses the Keys cubic filter (a = -0.5), widened when shrinking, resizes the width, then the height, and copies a dim that keeps its size. float16 and bfloat16, which ATen's CPU kernel lacks, are computed in float32 and rounded once. uint8 follows ATen's fixed-point path: 16-bit integer weights, and each pass rounds and clamps. Other integer types fail in the kernel, as they do in ATen. Between passes it keeps only the rows one output row reads, so shrinking 1080x1920 to 224x224 needs about 95 KB of temporary memory (43 KB for uint8). It rejects scales `F.interpolate` never produces: too small for the input, or (uint8 only) with weights too large for 16 bits. Where ATen's generic CPU path is wrong (contiguous input, output width 1, changing height; [PyTorch issue 200568](https://github.com/pytorch/pytorch/issues/200568)), it differs from eager on purpose. The shared upsample helper now ignores a non-positive scale, as ATen does; of the other upsample kernels, only antialiased bilinear can pass one. A stale llava comment is updated, and the Arm all-ops test marks the kernel as needing a recipe. Like several portable kernels, it has no experimental CPU backend route yet. ## Testing - New kernel tests check against ATen: shrinking, `align_corners`, scales, windows past the input, kept dims, both layouts, a batch, all float types, uint8 bit for bit, a dynamic output, running out of temporary memory, and error cases. Each case catches a bug that no other case catches. - The `to_edge` test compares static and dynamic programs (both layouts) with eager in float32 and, bit for bit, in uint8. It fails without the kernel or the verifier exception. - Outside the suite, a random sweep matched ATen's uint8 results exactly on x86 and Arm, on both its vectorized and generic paths; float32 within 7e-6 for unit-scale inputs, half types within one unit in the last place. - With XNNPACK, the resize stays outside the delegate. On one laptop thread, shrinking a contiguous 1080x1920 input to 224x224 takes about 7 ms (12 ms channels last). (cherry picked from commit a5476a5c864572402db3d252623eef18416cff26) --- .../cmsis_pack/test/all_ops/SKIPPED_OPS.md | 9 +- .../arm/cmsis_pack/test/all_ops/op_recipes.py | 1 + examples/models/llava/model.py | 6 +- exir/program/test/test_program.py | 96 ++++ exir/verification/verifier.py | 1 + kernels/aten/functions.yaml | 2 + .../portable/cpu/op_upsample_bicubic2d_aa.cpp | 453 +++++++++++++++ kernels/portable/cpu/util/upsample_util.h | 6 +- kernels/portable/functions.yaml | 5 + kernels/test/CMakeLists.txt | 1 + .../test/op_upsample_bicubic2d_aa_test.cpp | 528 ++++++++++++++++++ .../test/op_upsample_bilinear2d_aa_test.cpp | 21 + kernels/test/targets.bzl | 1 + .../executorch/build/build_variables.bzl | 1 + .../kernels/portable/op_registration_util.bzl | 6 + 15 files changed, 1128 insertions(+), 9 deletions(-) create mode 100644 kernels/portable/cpu/op_upsample_bicubic2d_aa.cpp create mode 100644 kernels/test/op_upsample_bicubic2d_aa_test.cpp diff --git a/backends/arm/cmsis_pack/test/all_ops/SKIPPED_OPS.md b/backends/arm/cmsis_pack/test/all_ops/SKIPPED_OPS.md index cb419bf98ae..11c5874bcd2 100644 --- a/backends/arm/cmsis_pack/test/all_ops/SKIPPED_OPS.md +++ b/backends/arm/cmsis_pack/test/all_ops/SKIPPED_OPS.md @@ -2,7 +2,7 @@ This test gives **every** operator the pack ships two kinds of coverage: -- **Build / link** — `gen_cproject.py` selects all 198 operator components, so the +- **Build / link** — `gen_cproject.py` selects all 199 operator components, so the consumer firmware links every op's generated registration, forward declaration and kernel source. *No operator is exempt from this.* - **Execution** — `generate_test_models.py` turns each op's recipe in `op_recipes.py` @@ -11,7 +11,7 @@ This test gives **every** operator the pack ships two kinds of coverage: A **skip** only means an op is not *executed*; it is still build/link-covered. Every skip carries a reason (enforced by `test_op_recipes.py::test_no_silent_coverage_gaps`), so there are no silent gaps. Current status on the CPU target (Corstone-315): **167 executed -(incl. 2 shape variants), 33 skipped** of 198 operator components, all passing. +(incl. 2 shape variants), 34 skipped** of 199 operator components, all passing. The Ethos-U variant has a separate, weaker status — see [Ethos-U variant status](#ethos-u-variant-status) before citing its results. @@ -107,7 +107,7 @@ APIs to force the conversion into the graph. --- -## 7. Just needs a bespoke recipe — `grid_sampler_2d`, `cdist_forward`, `pdist_forward`, `upsample_bilinear2d_aa` +## 7. Just needs a bespoke recipe — `grid_sampler_2d`, `cdist_forward`, `pdist_forward`, `upsample_bicubic2d_aa`, `upsample_bilinear2d_aa` **Why:** no fundamental blocker — they only need specific inputs the bulk recipes don't provide (a sampling grid in `[-1, 1]`; two point sets for the distance ops; the @@ -117,6 +117,7 @@ provide (a sampling grid in `[-1, 1]`; two point sets for the distance ops; the - `grid_sampler_2d`: `F.grid_sample(input, grid)` with `grid` in `[-1, 1]`. - `cdist_forward`: `torch.cdist(a, b)` with `a:(B,N,D)`, `b:(B,M,D)`. - `pdist_forward`: `torch.pdist(x)` with `x:(N,D)`. +- `upsample_bicubic2d_aa`: `F.interpolate(x, scale_factor=2, mode="bicubic", antialias=True)`. - `upsample_bilinear2d_aa`: `F.interpolate(x, scale_factor=2, mode="bilinear", antialias=True)`. **Effort:** small — a few lines each in `op_recipes.py`. @@ -195,7 +196,7 @@ The Docker image used for the consumer build already provides this, so they expo ## Priority to raise execution coverage -1. **§7 + §8 bespoke recipes** (5 ops) — trivial, immediate wins. +1. **§7 + §8 bespoke recipes** (6 ops) — trivial, immediate wins. 2. **§1 + §2 structural-assertion mode** (5 ops) — one small runner/manifest feature. 3. **Cortex-M env** — install `cmsis_nn`; unlocks 16 ops with no new code. 4. **§10 quantized path** (9 ops) — one quantized export path. diff --git a/backends/arm/cmsis_pack/test/all_ops/op_recipes.py b/backends/arm/cmsis_pack/test/all_ops/op_recipes.py index 09c5f2debbd..2c89e9b25a4 100644 --- a/backends/arm/cmsis_pack/test/all_ops/op_recipes.py +++ b/backends/arm/cmsis_pack/test/all_ops/op_recipes.py @@ -752,6 +752,7 @@ def _skip(category: str, name: str, reason: str) -> None: "grid_sampler_2d": "recipe TODO (sampler grid setup)", "cdist_forward": "recipe TODO", "pdist_forward": "recipe TODO", + "upsample_bicubic2d_aa": "antialiased upsample; recipe TODO", "upsample_bilinear2d_aa": "antialiased upsample; recipe TODO", "view_as_real_copy": "complex dtype unsupported on target", "allclose": "returns scalar bool; ET out-variant shape TODO", diff --git a/examples/models/llava/model.py b/examples/models/llava/model.py index 93005069609..6f0ad060450 100644 --- a/examples/models/llava/model.py +++ b/examples/models/llava/model.py @@ -214,9 +214,9 @@ def image_preprocess(self, img: torch.Tensor) -> torch.Tensor: # fill=tuple(int(x * 255) for x in self.image_mean), # ) - # TODO: implement _upsample_bicubic_aa.out in portable kernel library. - # here padded shape should be max(h, w) x max(h, w) - # skipping resize for now due to missing _upsample_bicubic_aa kernel in portable + # prepare_image() already resized the image to fit in the crop size, + # so only the padding is exported. The original pads to + # max(h, w) x max(h, w) and then resizes: # resized = resize( # padded, # size=[ diff --git a/exir/program/test/test_program.py b/exir/program/test/test_program.py index 4f6ef27b3a7..e44d5d241dd 100644 --- a/exir/program/test/test_program.py +++ b/exir/program/test/test_program.py @@ -337,6 +337,102 @@ def forward(self, x, y): ) edge_manager.to_executorch() + def test_check_tensor_all(self): + class M(torch.nn.Module): + def forward(self, x): + torch._check_tensor_all(x.abs() < 1e9) + return x * 2 + + to_edge(export(M(), (torch.randn(4),))).to_executorch() + + def test_antialiased_resize(self): + class Resize(torch.nn.Module): + def forward(self, x): + return ( + F.interpolate(x, size=(24, 18), mode="bicubic", antialias=True), + F.interpolate( + x, scale_factor=(1.75, 0.45), mode="bicubic", antialias=True + ), + F.interpolate( + x, + scale_factor=(0.4, 1.3), + mode="bicubic", + align_corners=True, + antialias=True, + ), + ) + + generator = torch.Generator().manual_seed(0) + + def image(h, w, dtype, memory_format=torch.contiguous_format): + if dtype == torch.uint8: + t = torch.randint( + 0, 256, (1, 3, h, w), dtype=dtype, generator=generator + ) + else: + t = torch.randn(1, 3, h, w, generator=generator) + return t.to(memory_format=memory_format) + + dims = {"x": {2: Dim("h", min=8, max=64), 3: Dim("w", min=8, max=64)}} + sizes = [(24, 32), (64, 64)] + cases = [] + for dtype in (torch.float32, torch.uint8): + cases.append((None, [image(24, 32, dtype)])) + for f in (torch.contiguous_format, torch.channels_last): + cases.append((dims, [image(h, w, dtype, f) for h, w in sizes])) + for dynamic_shapes, inputs in cases: + edge = to_edge( + export(Resize(), (inputs[0],), dynamic_shapes=dynamic_shapes) + ) + # The op stays whole and runs on its portable kernel. + self.assertEqual( + self._count_nodes( + edge.exported_program().graph_module, + [exir_ops.edge.aten._upsample_bicubic2d_aa.default], + ), + 3, + ) + program = _load_for_executorch_from_buffer(edge.to_executorch().buffer) + for t in inputs: + with self.subTest( + dtype=t.dtype, + dynamic=dynamic_shapes is not None, + shape=t.shape, + channels_last=t.is_contiguous(memory_format=torch.channels_last), + ): + # uint8 follows ATen's fixed-point path exactly. + tol = 0 if t.dtype == torch.uint8 else 1e-5 + for actual, expected in zip(program.forward((t,)), Resize()(t)): + torch.testing.assert_close(actual, expected, atol=tol, rtol=tol) + + def test_float_sym_max_min_rejected(self): + # The runtime's sym_max and sym_min kernels only take ints. + def model(sym_op): + class M(torch.nn.Module): + def forward(self, x, y): + return x + sym_op(y.item(), 2) + + return M() + + for sym_op in (torch.sym_max, torch.sym_min): + to_edge(export(model(sym_op), (torch.ones(1), torch.tensor(3)))) + with self.assertRaisesRegex(SpecViolationError, "only supports int"): + to_edge(export(model(sym_op), (torch.ones(1), torch.tensor(1.25)))) + + class Cond(torch.nn.Module): + def forward(self, pred, x, y): + return torch.cond( + pred, + lambda x, y: x + torch.sym_max(y.item(), 2), + lambda x, y: x - y, + (x, y), + ) + + with self.assertRaisesRegex(SpecViolationError, "only supports int"): + to_edge( + export(Cond(), (torch.tensor(True), torch.ones(1), torch.tensor(1.25))) + ) + def test_data_dependent(self): with torch.library._scoped_library("mylib", "FRAGMENT") as lib: torch.library.define( diff --git a/exir/verification/verifier.py b/exir/verification/verifier.py index e15c907de18..4367f76c438 100644 --- a/exir/verification/verifier.py +++ b/exir/verification/verifier.py @@ -114,6 +114,7 @@ def _get_core_aten_ops_exception_list(self) -> List[torch._ops.OpOverload]: exception_list = ( [ torch.ops.aten.mkldnn_rnn_layer.default, + torch.ops.aten._upsample_bicubic2d_aa.default, torch.ops.aten._upsample_bilinear2d_aa.default, torch.ops.aten.quantize_per_tensor.default, torch.ops.aten.dequantize.self, diff --git a/kernels/aten/functions.yaml b/kernels/aten/functions.yaml index 0e152cbea62..c489c18b179 100644 --- a/kernels/aten/functions.yaml +++ b/kernels/aten/functions.yaml @@ -38,6 +38,8 @@ - op: _unique2.out +- op: _upsample_bicubic2d_aa.out + - op: _upsample_bilinear2d_aa.out - op: abs.out diff --git a/kernels/portable/cpu/op_upsample_bicubic2d_aa.cpp b/kernels/portable/cpu/op_upsample_bicubic2d_aa.cpp new file mode 100644 index 00000000000..3bbd2f100a9 --- /dev/null +++ b/kernels/portable/cpu/op_upsample_bicubic2d_aa.cpp @@ -0,0 +1,453 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include +#include +#include +#include + +#include +#include + +namespace torch { +namespace executor { +namespace native { + +using executorch::aten::ArrayRef; +using executorch::aten::OptionalArrayRef; +using std::optional; + +namespace { + +// Keys' cubic with a = -0.5, the one ATen uses when it antialiases. +template +T cubic_aa_filter(T x) { + constexpr T a = -0.5; + x = std::abs(x); + if (x < 1) { + return ((a + 2) * x - (a + 3)) * x * x + 1; + } + if (x < 2) { + return ((a * x - 5 * a) * x + 8 * a) * x - 4 * a; + } + return 0; +} + +// The inputs that each output reads along one dim: `size[o]` of them from +// `first[o]` on, with their weights at `values + o * max_size`. +template +struct Weights { + int64_t max_size; + int64_t* first; + int64_t* size; + T* values; +}; + +// The same in fixed point with `precision` fraction bits, as ATen's uint8 path +// uses them. +struct FixedPointWeights { + int64_t max_size; + int64_t* first; + int64_t* size; + int16_t* values; + int precision; +}; + +// Returns null when the memory is not available, including when its size does +// not fit in size_t. +template +U* allocate_temp(KernelRuntimeContext& ctx, int64_t count) { + if (static_cast(count) > SIZE_MAX / sizeof(U)) { + return nullptr; + } + Result memory = + ctx.allocate_temp(static_cast(count) * sizeof(U), alignof(U)); + return memory.ok() ? static_cast(memory.get()) : nullptr; +} + +// What the windows of one dim share: the shrink ratio, and the filter support +// widened by it. +template +struct Filter { + int64_t in_size; + int64_t max_size; + T ratio; + T support; + T invscale; +}; + +template +Error make_filter( + int64_t in_size, + int64_t out_size, + bool align_corners, + const optional scale, + Filter& filter) { + const T ratio = + area_pixel_compute_scale(in_size, out_size, align_corners, scale); + // F.interpolate never makes the ratio larger than the input size, and the + // + 1 leaves room for rounding. A larger ratio comes from a scale that is + // too small for the input, and could overflow the window bounds below. + ET_CHECK_OR_RETURN_ERROR( + ratio <= in_size + 1, + InvalidArgument, + "_upsample_bicubic2d_aa got a scale that is too small for its input " + "size"); + filter.in_size = in_size; + filter.ratio = ratio; + // The filter spans 4 inputs, widened by the shrink factor. + filter.support = static_cast((ratio >= 1.0) ? (4 * 0.5) * ratio : 4 * 0.5); + filter.invscale = static_cast((ratio >= 1.0) ? 1.0 / ratio : 1.0); + filter.max_size = static_cast(std::ceil(filter.support)) * 2 + 1; + return Error::Ok; +} + +// Computes the window of output `o` and its weights as ATen's antialiased CPU +// kernel does, expression for expression, so that they round the same way. +template +void compute_window( + const Filter& filter, + int64_t o, + int64_t& first, + int64_t& size, + T* values) { + const T center = static_cast(filter.ratio * (o + 0.5)); + const int64_t start = + std::max(static_cast(center - filter.support + 0.5), int64_t(0)); + size = std::clamp( + std::min( + static_cast(center + filter.support + 0.5), filter.in_size) - + start, + int64_t(0), + filter.max_size); + T total = 0; + for (const auto j : c10::irange(size)) { + values[j] = cubic_aa_filter( + static_cast((j + start - center + 0.5) * filter.invscale)); + total += values[j]; + } + if (total != 0) { + for (const auto j : c10::irange(size)) { + values[j] /= total; + } + } + // An empty window reads nothing, but its start still has to point into the + // input. + first = std::min(start, filter.in_size - 1); +} + +template +Error compute_weights( + KernelRuntimeContext& ctx, + int64_t in_size, + int64_t out_size, + bool align_corners, + const optional scale, + Weights& weights) { + Filter filter{}; + ET_CHECK_OK_OR_RETURN_ERROR( + make_filter(in_size, out_size, align_corners, scale, filter)); + weights.max_size = filter.max_size; + weights.first = allocate_temp(ctx, out_size); + weights.size = weights.first != nullptr + ? allocate_temp(ctx, out_size) + : nullptr; + weights.values = weights.size != nullptr + ? allocate_temp(ctx, out_size * weights.max_size) + : nullptr; + ET_CHECK_OR_RETURN_ERROR( + weights.values != nullptr, + MemoryAllocationFailed, + "_upsample_bicubic2d_aa could not get temporary memory for its " + "weights"); + for (const auto o : c10::irange(out_size)) { + compute_window( + filter, + o, + weights.first[o], + weights.size[o], + weights.values + o * weights.max_size); + } + return Error::Ok; +} + +// Converts the weights as ATen's uint8 path does, with the most fraction bits, +// up to 22, that keep the largest weight below 2^15. Each window is computed +// in double twice, to find the largest weight and then to convert it, so that +// only one window of doubles is kept. +Error compute_weights( + KernelRuntimeContext& ctx, + int64_t in_size, + int64_t out_size, + bool align_corners, + const optional scale, + FixedPointWeights& weights) { + Filter filter{}; + ET_CHECK_OK_OR_RETURN_ERROR( + make_filter(in_size, out_size, align_corners, scale, filter)); + weights.max_size = filter.max_size; + double* exact = allocate_temp(ctx, filter.max_size); + weights.first = + exact != nullptr ? allocate_temp(ctx, out_size) : nullptr; + weights.size = weights.first != nullptr + ? allocate_temp(ctx, out_size) + : nullptr; + weights.values = weights.size != nullptr + ? allocate_temp(ctx, out_size * weights.max_size) + : nullptr; + ET_CHECK_OR_RETURN_ERROR( + weights.values != nullptr, + MemoryAllocationFailed, + "_upsample_bicubic2d_aa could not get temporary memory for its " + "weights"); + double max_weight = 0; + for (const auto o : c10::irange(out_size)) { + compute_window(filter, o, weights.first[o], weights.size[o], exact); + for (const auto j : c10::irange(weights.size[o])) { + max_weight = std::max(max_weight, exact[j]); + } + } + int precision = 0; + while (precision < 22 && + 0.5 + max_weight * (1 << (precision + 1)) < (1 << 15)) { + ++precision; + } + // Only a scale that F.interpolate never gives, with windows that nearly + // cancel out, gets here. ATen's generic CPU path fails on it too. + ET_CHECK_OR_RETURN_ERROR( + precision > 0, + InvalidArgument, + "_upsample_bicubic2d_aa got a scale whose uint8 weights do not fit in " + "fixed point"); + for (const auto o : c10::irange(out_size)) { + compute_window(filter, o, weights.first[o], weights.size[o], exact); + int16_t* values = weights.values + o * weights.max_size; + for (const auto j : c10::irange(weights.size[o])) { + const double value = exact[j] * (1 << precision); + // The precision only bounds the largest weight, so with such a scale a + // negative one can still be out of range. + ET_CHECK_OR_RETURN_ERROR( + value > -32768.5 && value < 32767.5, + InvalidArgument, + "_upsample_bicubic2d_aa got a scale whose uint8 weights do not fit " + "in fixed point"); + values[j] = static_cast( + value < 0 ? static_cast(-0.5 + value) + : static_cast(0.5 + value)); + } + } + weights.precision = precision; + return Error::Ok; +} + +// The value of output `o`, from the inputs it reads. They start at `in` and +// are `step` elements apart. +template +T weighted_sum( + const CTYPE_IN* in, + int64_t step, + const Weights& weights, + int64_t o) { + const T* values = weights.values + o * weights.max_size; + T sum = 0; + for (const auto j : c10::irange(weights.size[o])) { + sum += static_cast(in[j * step]) * values[j]; + } + return sum; +} + +// ATen's uint8 sum: starts at one half so that dropping the fraction bits +// rounds, and clamps the result. It adds up in 64 bits, not in int as ATen +// does, because a scale F.interpolate never gives can make a window wide +// enough to overflow int. +uint8_t weighted_sum( + const uint8_t* in, + int64_t step, + const FixedPointWeights& weights, + int64_t o) { + const int16_t* values = weights.values + o * weights.max_size; + int64_t sum = int64_t{1} << (weights.precision - 1); + for (const auto j : c10::irange(weights.size[o])) { + sum += in[j * step] * values[j]; + } + return static_cast( + std::clamp(sum >> weights.precision, 0, 255)); +} + +// Like ATen, resizes the width first and then the height, and copies a dim +// whose size does not change. Half precision is computed in float and the +// result is rounded once. uint8 follows ATen's fixed-point path and rounds +// after each pass. ATen's generic CPU path gives every row the first row's +// values when the input is contiguous, the output width is 1 and the height +// changes, because basic_loop_separable_1d_vertical in +// aten/src/ATen/native/cpu/UpSampleKernel.cpp reuses the first row's weights +// (https://github.com/pytorch/pytorch/issues/200568). This kernel resizes the +// height there too. +template +void upsample_bicubic2d_aa_kernel_impl( + KernelRuntimeContext& ctx, + const Tensor& in, + bool align_corners, + const optional scale_h, + const optional scale_w, + Tensor& out) { + constexpr bool is_byte = std::is_same_v; + using T = std::conditional_t, double, float>; + using W = std::conditional_t>; + // uint8 keeps the rows between the passes in uint8, as ATen does. + using Row = std::conditional_t; + const int64_t in_h = in.size(2); + const int64_t out_h = out.size(2); + const int64_t out_w = out.size(3); + const bool resize_h = in_h != out_h; + const bool resize_w = in.size(3) != out_w; + const CTYPE* in_data = in.const_data_ptr(); + CTYPE* out_data = out.mutable_data_ptr(); + if (!resize_h && !resize_w) { + // Same sizes and dim order, so the same layout. + std::copy(in_data, in_data + in.numel(), out_data); + return; + } + + W weights_h{}; + W weights_w{}; + Error error = Error::Ok; + if (resize_h) { + error = + compute_weights(ctx, in_h, out_h, align_corners, scale_h, weights_h); + } + if (error == Error::Ok && resize_w) { + error = compute_weights( + ctx, in.size(3), out_w, align_corners, scale_w, weights_w); + } + if (error != Error::Ok) { + ctx.fail(error); + return; + } + + // When both dims change, the height pass reads rows of the width pass. If + // the windows are short, only the last `ring_size` rows are kept, which is + // enough for one output row, and each is stored twice, `ring_size` rows + // apart, so that the rows one output reads are always next to each other. + // Otherwise all rows are kept, once each. + const bool ring_wraps = 2 * weights_h.max_size < in_h; + const int64_t ring_size = ring_wraps ? weights_h.max_size : in_h; + Row* ring = nullptr; + if (resize_h && resize_w) { + ring = allocate_temp(ctx, (ring_wraps ? 2 : 1) * ring_size * out_w); + ET_KERNEL_CHECK_MSG( + ctx, + ring != nullptr, + MemoryAllocationFailed, + , + "_upsample_bicubic2d_aa could not get temporary memory for the resized " + "rows"); + } + + const auto in_strides = in.strides(); + const auto out_strides = out.strides(); + for (const auto n : c10::irange(out.size(0))) { + for (const auto c : c10::irange(out.size(1))) { + const CTYPE* in_plane = in_data + n * in_strides[0] + c * in_strides[1]; + CTYPE* out_plane = out_data + n * out_strides[0] + c * out_strides[1]; + // The width pass has already resized every input row before this one. + int64_t next_row = 0; + for (const auto h : c10::irange(out_h)) { + CTYPE* out_row = out_plane + h * out_strides[2]; + if (!resize_h) { + const CTYPE* in_row = in_plane + h * in_strides[2]; + for (const auto w : c10::irange(out_w)) { + out_row[w * out_strides[3]] = static_cast(weighted_sum( + in_row + weights_w.first[w] * in_strides[3], + in_strides[3], + weights_w, + w)); + } + continue; + } + const int64_t first = weights_h.first[h]; + if (!resize_w) { + for (const auto w : c10::irange(out_w)) { + out_row[w * out_strides[3]] = static_cast(weighted_sum( + in_plane + first * in_strides[2] + w * in_strides[3], + in_strides[2], + weights_h, + h)); + } + continue; + } + const int64_t end = first + weights_h.size[h]; + for (int64_t row = std::max(next_row, first); row < end; ++row) { + const CTYPE* in_row = in_plane + row * in_strides[2]; + Row* resized = ring + (row % ring_size) * out_w; + for (const auto w : c10::irange(out_w)) { + resized[w] = weighted_sum( + in_row + weights_w.first[w] * in_strides[3], + in_strides[3], + weights_w, + w); + if (ring_wraps) { + resized[w + ring_size * out_w] = resized[w]; + } + } + } + next_row = std::max(next_row, end); + const Row* rows = ring + (first % ring_size) * out_w; + for (const auto w : c10::irange(out_w)) { + out_row[w * out_strides[3]] = + static_cast(weighted_sum(rows + w, out_w, weights_h, h)); + } + } + } + } +} + +} // namespace + +Tensor& _upsample_bicubic2d_aa_out( + KernelRuntimeContext& ctx, + const Tensor& in, + const ArrayRef output_size, + bool align_corners, + const optional scale_h, + const optional scale_w, + Tensor& out) { + const OptionalArrayRef size(output_size); + ET_KERNEL_CHECK( + ctx, + check_upsample_2d_common_args(in, size, {}, out), + InvalidArgument, + out); + ET_KERNEL_CHECK_MSG( + ctx, + in.size(2) > 0 && in.size(3) > 0, + InvalidArgument, + out, + "_upsample_bicubic2d_aa needs a non-empty input height and width"); + + double unused_scale_h, unused_scale_w; + ET_KERNEL_CHECK_MSG( + ctx, + resize_upsample_2d(in, size, {}, unused_scale_h, unused_scale_w, out) == + Error::Ok, + InvalidArgument, + out, + "Failed to resize output tensor"); + + ET_SWITCH_FLOATHBF16_TYPES_AND( + Byte, in.scalar_type(), ctx, "_upsample_bicubic2d_aa.out", CTYPE, [&]() { + upsample_bicubic2d_aa_kernel_impl( + ctx, in, align_corners, scale_h, scale_w, out); + }); + + return out; +} + +} // namespace native +} // namespace executor +} // namespace torch diff --git a/kernels/portable/cpu/util/upsample_util.h b/kernels/portable/cpu/util/upsample_util.h index 6940c63dedb..70bfbe498bb 100644 --- a/kernels/portable/cpu/util/upsample_util.h +++ b/kernels/portable/cpu/util/upsample_util.h @@ -51,8 +51,10 @@ inline scalar_t compute_scales_value( const std::optional& scale, int64_t input_size, int64_t output_size) { - return scale.has_value() ? static_cast(1.0 / scale.value()) - : (static_cast(input_size) / output_size); + // Like ATen, ignores a scale that is not positive. + return (scale.has_value() && scale.value() > 0) + ? static_cast(1.0 / scale.value()) + : (static_cast(input_size) / output_size); } // Ported from aten/src/ATen/native/UpSample.h diff --git a/kernels/portable/functions.yaml b/kernels/portable/functions.yaml index e3a5dba0f20..af851fe528b 100644 --- a/kernels/portable/functions.yaml +++ b/kernels/portable/functions.yaml @@ -1010,6 +1010,11 @@ - arg_meta: null kernel_name: torch::executor::unsqueeze_copy_out +- op: _upsample_bicubic2d_aa.out + kernels: + - arg_meta: null + kernel_name: torch::executor::_upsample_bicubic2d_aa_out + - op: upsample_bilinear2d.vec_out kernels: - arg_meta: null diff --git a/kernels/test/CMakeLists.txt b/kernels/test/CMakeLists.txt index 0bce3855e5d..98dd5c7de84 100644 --- a/kernels/test/CMakeLists.txt +++ b/kernels/test/CMakeLists.txt @@ -311,6 +311,7 @@ set(all_test_sources "op_unbind_copy_test.cpp" "op_unfold_copy_test.cpp" "op_unsqueeze_copy_test.cpp" + "op_upsample_bicubic2d_aa_test.cpp" "op_upsample_bilinear2d_test.cpp" "op_upsample_bilinear2d_aa_test.cpp" "op_upsample_nearest2d_test.cpp" diff --git a/kernels/test/op_upsample_bicubic2d_aa_test.cpp b/kernels/test/op_upsample_bicubic2d_aa_test.cpp new file mode 100644 index 00000000000..e0d3fb060e5 --- /dev/null +++ b/kernels/test/op_upsample_bicubic2d_aa_test.cpp @@ -0,0 +1,528 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include // Declares the operator +#include +#include +#include +#include + +#include + +#include + +using namespace ::testing; +using namespace executorch::aten; +using torch::executor::testing::TensorFactory; + +namespace { + +// Shape [1, 2, 6, 8]. Multiples of 1/4, so every floating type holds them. +const std::vector kInput = { + -2.75, 0.75, -1.5, 2, -0.25, -2.5, 1, -1.25, 2.25, 0, -2.25, + 1.25, -1, 2.5, 0.25, -2, 1.5, -0.75, 2.75, 0.5, -1.75, 1.75, + -0.5, -2.75, 0.75, -1.5, 2, -0.25, -2.5, 1, -1.25, 2.25, 0, + -2.25, 1.25, -1, 2.5, 0.25, -2, 1.5, -0.75, 2.75, 0.5, -1.75, + 1.75, -0.5, -2.75, 0.75, -1.5, 2, -0.25, -2.5, 1, -1.25, 2.25, + 0, -2.25, 1.25, -1, 2.5, 0.25, -2, 1.5, -0.75, 2.75, 0.5, + -1.75, 1.75, -0.5, -2.75, 0.75, -1.5, 2, -0.25, -2.5, 1, -1.25, + 2.25, 0, -2.25, 1.25, -1, 2.5, 0.25, -2, 1.5, -0.75, 2.75, + 0.5, -1.75, 1.75, -0.5, -2.75, 0.75, -1.5, 2}; + +// Shape [1, 1, 3, 4]. +const std::vector kSmallInput = + {-0.75, 0.5, 0, -0.5, 0.75, 0.25, -0.25, -0.75, 0.5, 0, -0.5, 0.75}; + +// The expected values below are what ATen's CPU kernel gives in float32. + +// kInput resized to (3, 5). +const std::vector kShrunk = { + 0.0906679854, -0.475160062, 0.374634027, 0.143204734, -0.922974527, + 0.203037277, 0.835856259, -0.634037733, 0.384397358, -0.519408643, + -0.163436651, 0.299764723, 0.306681991, -0.405743986, -0.157753274, + -0.223817319, 0.0853600204, 0.205771729, -0.521300793, 0.519143701, + 1.23301566, -0.724639535, 0.290732175, -0.220765993, -0.693045139, + -0.00643681083, 0.718924642, -1.07855761, 0.0471052192, 0.779319167}; + +// kInput resized to (3, 11) with align_corners. +const std::vector kAlignCorners = { + 0.395212859, 0.270034224, -0.177529454, -0.820639431, 0.857328475, + 0.699784875, -1.11745334, 0.230914891, 0.775949657, 0.120987602, + -1.70900011, 0.757739007, -0.483611494, -0.616503656, 1.71716607, + 0.306986153, -0.677421331, -0.310276628, 0.818704128, -0.196790308, + -1.37936914, 0.524807692, -0.896195412, 0.633612096, 1.69014382, + 0.616090477, -1.3802079, -0.500471592, 2.38540888, 0.339915723, + -1.89413214, -2.42607069, 0.7296018, -0.928531647, 0.535698175, + 1.00333369, -1.07555234, -0.00527923973, 0.776648641, 0.323259175, + -1.72325718, -0.389089495, 1.5565505, -0.577305675, 1.88765359, + 0.536147237, -0.666489065, -0.388005495, 0.688801527, 0.140282094, + -1.49791741, 0.327211022, 0.468800664, -0.297805637, -0.12769644, + 0.666050732, -0.949824452, -1.00370514, 2.31604958, 0.739088535, + -1.57536066, -2.68413806, 0.481709063, 0.0198896192, -1.17022157, + 2.18622065}; + +// kInput resized to (6, 3) with scales 1.05 and 0.45: the height keeps its +// size, so it is copied whatever its scale. +const std::vector kKeptHeight = { + -1.0862571, 0.426353812, -0.821281493, 0.500302792, -0.473454088, + 0.912124515, 0.831802487, 0.638152242, -0.020045612, 0.0818024501, + -0.129340187, -0.544154108, -0.832585812, 0.4716717, 0.18996644, + 1.00280011, 0.0817186236, -0.591454029, 0.252799958, -0.848054767, + 0.30904001, -0.586257041, 0.926353991, -0.321281493, 1.00114834, + -0.00857725739, -1.07128155, 0.250302762, -0.723454058, 0.662124455, + 0.581802487, 0.370659858, -0.0441541485, -0.168197602, -0.379340231, + -0.794154167}; + +// kInput resized to (3, 8): the width keeps its size, so it is copied. +const std::vector kKeptWidth = { + -0.0784518421, 0.342050225, -1.49895394, 1.59205019, -0.730125546, + 0.171548143, 0.592050135, -1.94665277, 1.32251918, -1.32251906, + 1.93606877, 0.124999993, -1.68606877, 1.57251906, -0.87499994, + -0.25, -0.342050225, 0.0784519166, 0.980125546, -1.34205019, + 1.74895406, -0.0920502245, -2.34205031, 1.44665277, -1.49895394, + 1.59205019, -0.730125546, 0.171548143, 0.592050135, -1.94665277, + 1.84205019, -0.480125576, 1.93606877, 0.124999993, -1.68606877, + 1.57251906, -0.87499994, -0.25, 0.37500003, -1.43606877, + 0.980125546, -1.34205019, 1.74895406, -0.0920502245, -2.34205031, + 1.44665277, -1.09205019, 1.99895406}; + +// Shape [1, 1, 2, 10]. The rows have opposite signs, so resizing to (1, 5) +// averages them into small values. A kernel that rounded its first pass to a +// half type would be off here by far more than the test tolerance. +const std::vector kOppositeRows = { + 67, 66, 67, 63, 64.5, 63, 63.5, 67, 66, 66, + -63.5, -64.5, -64, -62, -66.5, -63.5, -67, -65, -66.5, -62}; + +// kOppositeRows resized to (1, 5). +const std::vector kAveraged = + {1.33681488, 0.797298431, -0.77734375, -0.417949677, 1.00313568}; + +// Shape [1, 1, 6, 8] of uint8 with sharp edges, so the width pass overshoots +// and its rows have to be clamped before the height pass, as ATen does. +const std::vector kUint8Input = { + 38, 23, 25, 15, 225, 222, 220, 13, 247, 38, 223, 239, + 39, 0, 38, 251, 252, 250, 239, 251, 227, 254, 247, 15, + 249, 9, 17, 237, 255, 0, 252, 38, 26, 20, 4, 228, + 236, 244, 7, 14, 1, 7, 247, 2, 0, 247, 251, 9}; + +// kUint8Input resized to (2, 10). One fraction bit more or less in the +// fixed-point weights changes it. +const std::vector kUint8Wide = {201, 115, 112, 163, 187, 170, 133, + 144, 150, 97, 109, 58, 68, 126, + 182, 176, 178, 173, 126, 13}; + +} // namespace + +class OpUpsampleBicubic2dAAOutTest : public OperatorTest { + protected: + void SetUp() override { + OperatorTest::SetUp(); + context_ = torch::executor::KernelRuntimeContext(nullptr, &temp_allocator_); + } + + Tensor& op_upsample_bicubic2d_aa_out( + const Tensor& input, + const ArrayRef output_size, + bool align_corners, + const std::optional scales_h, + const std::optional scales_w, + Tensor& out) { + // Like the runtime, frees the temporary memory between kernel calls. + temp_allocator_.reset(); + return torch::executor::aten::_upsample_bicubic2d_aa_outf( + context_, input, output_size, align_corners, scales_h, scales_w, out); + } + + template + void expect_resize( + const std::vector& in_sizes, + const std::vector& in_data, + const std::vector& output_size, + bool align_corners, + const std::optional scales_h, + const std::optional scales_w, + const std::vector& expected, + bool channels_last = false) { + TensorFactory tf; + using CTYPE = typename TensorFactory::ctype; + const std::vector out_sizes = { + in_sizes[0], + in_sizes[1], + static_cast(output_size[0]), + static_cast(output_size[1])}; + Tensor in = + tf.make(in_sizes, std::vector(in_data.begin(), in_data.end())); + Tensor want = tf.make( + out_sizes, std::vector(expected.begin(), expected.end())); + Tensor out = tf.zeros(out_sizes); + if (channels_last) { + in = tf.channels_last_like(in); + want = tf.channels_last_like(want); + out = tf.zeros_channels_last(out_sizes); + } + op_upsample_bicubic2d_aa_out( + in, + ArrayRef(output_size.data(), output_size.size()), + align_corners, + scales_h, + scales_w, + out); + // Half types are computed in float and rounded once, so they can be one + // unit in the last place from the rounded float32 result. As a relative + // tolerance, that unit is 2^-10 for half and 2^-7 for bfloat16 at a power + // of two, so the check allows up to two units just below the next one. + if constexpr (DTYPE == ScalarType::Half) { + EXPECT_TENSOR_CLOSE_WITH_TOL(out, want, 1.0 / 1024, 1e-6); + } else if constexpr (DTYPE == ScalarType::BFloat16) { + EXPECT_TENSOR_CLOSE_WITH_TOL(out, want, 1.0 / 128, 1e-6); + } else if constexpr (DTYPE == ScalarType::Byte) { + EXPECT_TENSOR_EQ(out, want); + } else { + EXPECT_TENSOR_CLOSE_WITH_TOL(out, want, 1e-5, 1e-5); + } + } + + private: + uint8_t temp_memory_[16 * 1024] = {}; + executorch::runtime::MemoryAllocator temp_allocator_{ + sizeof(temp_memory_), + temp_memory_}; +}; + +TEST_F(OpUpsampleBicubic2dAAOutTest, ShrinkMatchesATen) { + // A second batch holding the negated input gives the negated result. + std::vector in = kInput; + std::vector expected = kShrunk; + for (const float value : kInput) { + in.push_back(-value); + } + for (const float value : kShrunk) { + expected.push_back(-value); + } + expect_resize( + {2, 2, 6, 8}, in, {3, 5}, false, std::nullopt, std::nullopt, expected); +} + +TEST_F(OpUpsampleBicubic2dAAOutTest, AlignCornersMatchesATen) { + expect_resize( + {1, 2, 6, 8}, + kInput, + {3, 11}, + true, + std::nullopt, + std::nullopt, + kAlignCorners); +} + +TEST_F(OpUpsampleBicubic2dAAOutTest, ScalesMatchATen) { + // Like ATen, a scale that is not positive is ignored. + expect_resize( + {1, 2, 6, 8}, kInput, {3, 5}, false, -1.0, std::nan(""), kShrunk); + // A scale this small puts the windows of the last two rows past the input, + // so they read nothing and give 0. The width changes too, so this runs the + // path that resizes both dims. + expect_resize( + {1, 1, 3, 4}, + kSmallInput, + {5, 3}, + false, + 0.4, + std::nullopt, + {0.225569099, + 0.0334828235, + -0.313413143, + 0.543530881, + -0.355477989, + 0.389086157, + 0.421826512, + -0.290672064, + 0.340768456, + 0, + 0, + 0, + 0, + 0, + 0}); +} + +TEST_F(OpUpsampleBicubic2dAAOutTest, CopiesDimsThatKeepTheirSize) { + expect_resize( + {1, 2, 6, 8}, kInput, {6, 8}, false, 1.05, 0.95, kInput); +} + +TEST_F(OpUpsampleBicubic2dAAOutTest, ChannelsLastMatchesATen) { + expect_resize( + {1, 2, 6, 8}, + kInput, + {3, 5}, + false, + std::nullopt, + std::nullopt, + kShrunk, + /*channels_last=*/true); + expect_resize( + {1, 2, 6, 8}, + kInput, + {6, 3}, + false, + 1.05, + 0.45, + kKeptHeight, + /*channels_last=*/true); + expect_resize( + {1, 2, 6, 8}, + kInput, + {3, 8}, + false, + std::nullopt, + 0.95, + kKeptWidth, + /*channels_last=*/true); +} + +// ATen's CPU kernel has no float16 or bfloat16 version, so these compare with +// its float32 results. +TEST_F(OpUpsampleBicubic2dAAOutTest, HalfTypesMatchFloat32ATen) { + expect_resize( + {1, 1, 2, 10}, + kOppositeRows, + {1, 5}, + false, + std::nullopt, + std::nullopt, + kAveraged); + expect_resize( + {1, 1, 2, 10}, + kOppositeRows, + {1, 5}, + false, + std::nullopt, + std::nullopt, + kAveraged); +} + +TEST_F(OpUpsampleBicubic2dAAOutTest, DoubleMatchesFloat64ATen) { + TensorFactory tf; + const Tensor in = tf.make( + {1, 1, 3, 4}, + std::vector(kSmallInput.begin(), kSmallInput.end())); + Tensor out = tf.zeros({1, 1, 2, 3}); + const std::vector output_size = {2, 3}; + op_upsample_bicubic2d_aa_out( + in, + ArrayRef(output_size.data(), output_size.size()), + false, + std::nullopt, + std::nullopt, + out); + // ATen's float64 result. A float32 computation is about 1e-7 away. + const Tensor want = tf.make( + {1, 1, 2, 3}, + {-0.02889796548038389, + 0.2150704092175421, + -0.5353515689565878, + 0.5353515689565879, + -0.2150704092175421, + 0.028897965480383748}); + EXPECT_TENSOR_CLOSE_WITH_TOL(out, want, 1e-12, 1e-12); +} + +TEST_F(OpUpsampleBicubic2dAAOutTest, Uint8MatchesATenExactly) { + expect_resize( + {1, 1, 6, 8}, + kUint8Input, + {2, 10}, + false, + std::nullopt, + std::nullopt, + kUint8Wide); +} + +TEST_F(OpUpsampleBicubic2dAAOutTest, ResizesDynamicOutput) { + TensorFactory tf; + Tensor in = tf.make({1, 2, 6, 8}, kInput); + Tensor out = tf.zeros( + {1, 2, 6, 8}, torch::executor::TensorShapeDynamism::DYNAMIC_BOUND); + const std::vector output_size = {3, 5}; + op_upsample_bicubic2d_aa_out( + in, + ArrayRef(output_size.data(), output_size.size()), + false, + std::nullopt, + std::nullopt, + out); + EXPECT_TENSOR_CLOSE_WITH_TOL(out, tf.make({1, 2, 3, 5}, kShrunk), 1e-5, 1e-5); +} + +TEST_F(OpUpsampleBicubic2dAAOutTest, RejectsIntegerInput) { + TensorFactory tf; + Tensor in = tf.zeros({1, 1, 4, 4}); + Tensor out = tf.zeros({1, 1, 2, 2}); + const std::vector output_size = {2, 2}; + ET_EXPECT_KERNEL_FAILURE( + context_, + op_upsample_bicubic2d_aa_out( + in, + ArrayRef(output_size.data(), output_size.size()), + false, + std::nullopt, + std::nullopt, + out)); +} + +TEST_F(OpUpsampleBicubic2dAAOutTest, FailsWithoutTempMemory) { + const std::vector output_size = {6, 5}; + context_ = torch::executor::KernelRuntimeContext(); + TensorFactory tf; + Tensor in = tf.make({1, 2, 6, 8}, kInput); + Tensor out = tf.zeros({1, 2, 6, 5}); + ET_EXPECT_KERNEL_FAILURE( + context_, + op_upsample_bicubic2d_aa_out( + in, + ArrayRef(output_size.data(), output_size.size()), + false, + std::nullopt, + std::nullopt, + out)); + // uint8 computes its weights on its own path. A new context clears the + // failure, so that this check can fail too. + context_ = torch::executor::KernelRuntimeContext(); + TensorFactory tb; + Tensor in_u8 = tb.zeros({1, 2, 6, 8}); + Tensor out_u8 = tb.zeros({1, 2, 6, 5}); + ET_EXPECT_KERNEL_FAILURE( + context_, + op_upsample_bicubic2d_aa_out( + in_u8, + ArrayRef(output_size.data(), output_size.size()), + false, + std::nullopt, + std::nullopt, + out_u8)); +} + +TEST_F(OpUpsampleBicubic2dAAOutTest, FailsWhenTempMemoryRunsOut) { + // The weights fit in the fixture's 16 KiB, but the rows between the passes + // do not. + TensorFactory tf; + Tensor in = tf.zeros({1, 1, 3, 600}); + Tensor out = tf.zeros({1, 1, 2, 300}); + const std::vector output_size = {2, 300}; + ET_EXPECT_KERNEL_FAILURE( + context_, + op_upsample_bicubic2d_aa_out( + in, + ArrayRef(output_size.data(), output_size.size()), + false, + std::nullopt, + std::nullopt, + out)); +} + +TEST_F(OpUpsampleBicubic2dAAOutTest, RejectsEmptyInput) { + TensorFactory tf; + Tensor in = tf.zeros({1, 1, 0, 4}); + Tensor out = tf.zeros({1, 1, 2, 2}); + const std::vector output_size = {2, 2}; + ET_EXPECT_KERNEL_FAILURE( + context_, + op_upsample_bicubic2d_aa_out( + in, + ArrayRef(output_size.data(), output_size.size()), + false, + std::nullopt, + std::nullopt, + out)); +} + +TEST_F(OpUpsampleBicubic2dAAOutTest, RejectsScaleTooSmallForInput) { + TensorFactory tf; + // The width changes too, so computing its weights must not hide the + // height's error. + Tensor in = tf.zeros({1, 1, 64, 8}); + Tensor out = tf.zeros({1, 1, 2, 4}); + const std::vector output_size = {2, 4}; + ET_EXPECT_KERNEL_FAILURE( + context_, + op_upsample_bicubic2d_aa_out( + in, + ArrayRef(output_size.data(), output_size.size()), + false, + 0.01, + std::nullopt, + out)); +} + +TEST_F(OpUpsampleBicubic2dAAOutTest, RejectsUint8WeightsTooLargeForFixedPoint) { + // With this scale, the window of the middle output nearly sums to zero, so + // its largest normalized weight leaves no fraction bit in 16-bit fixed + // point, although every weight still fits in 16 bits. + TensorFactory tf; + Tensor in = tf.make({1, 1, 1, 4}, {10, 20, 30, 40}); + Tensor out = tf.zeros({1, 1, 1, 5}); + const std::vector output_size = {1, 5}; + ET_EXPECT_KERNEL_FAILURE( + context_, + op_upsample_bicubic2d_aa_out( + in, + ArrayRef(output_size.data(), output_size.size()), + false, + std::nullopt, + 0.4638698, + out)); +} + +TEST_F(OpUpsampleBicubic2dAAOutTest, RejectsUint8WeightsOutsideInt16) { + // With this scale, the last output's window sums to a negative value, so + // its most negative weight is larger than any positive one and does not fit + // in 16 bits at the precision the positive ones allow. + TensorFactory tf; + Tensor in = tf.full({1, 1, 1, 19}, 100); + Tensor out = tf.zeros({1, 1, 1, 6}); + const std::vector output_size = {1, 6}; + ET_EXPECT_KERNEL_FAILURE( + context_, + op_upsample_bicubic2d_aa_out( + in, + ArrayRef(output_size.data(), output_size.size()), + false, + std::nullopt, + 0.25, + out)); +} + +TEST_F(OpUpsampleBicubic2dAAOutTest, ResizesBothDimsLikeTwoOneDimResizes) { + // Inputs this tall make the kernel keep only a few resized rows between its + // passes. All 64 of them would not fit in the fixture's 16 KiB of temporary + // memory. + TensorFactory tf; + std::vector data(64 * 128); + for (const auto i : c10::irange(data.size())) { + data[i] = std::sin(0.37f * i); + } + const Tensor in = tf.make({1, 1, 64, 128}, data); + const auto resize = [&](const Tensor& from, int64_t h, int64_t w) { + Tensor to = + tf.zeros({1, 1, static_cast(h), static_cast(w)}); + const std::vector output_size = {h, w}; + op_upsample_bicubic2d_aa_out( + from, + ArrayRef(output_size.data(), output_size.size()), + false, + std::nullopt, + std::nullopt, + to); + return to; + }; + const Tensor wide = resize(in, 64, 64); + for (const int64_t height : {48, 80}) { + EXPECT_TENSOR_CLOSE_WITH_TOL( + resize(in, height, 64), resize(wide, height, 64), 1e-6, 1e-6); + } +} diff --git a/kernels/test/op_upsample_bilinear2d_aa_test.cpp b/kernels/test/op_upsample_bilinear2d_aa_test.cpp index f69f7e238b2..24f28733024 100644 --- a/kernels/test/op_upsample_bilinear2d_aa_test.cpp +++ b/kernels/test/op_upsample_bilinear2d_aa_test.cpp @@ -733,3 +733,24 @@ TEST_F(OpUpsampleBilinear2dAAOutTest, TestSpecificInputCase) { EXPECT_FALSE(std::isinf(out_data[i])); } } + +TEST_F(OpUpsampleBilinear2dAAOutTest, IgnoresNonPositiveScales) { + TensorFactory tf; + Tensor input = tf.make({1, 1, 2, 4}, {1, 2, 3, 4, 5, 6, 7, 8}); + int64_t output_size_data[2] = {3, 3}; + ArrayRef output_size(output_size_data, 2); + Tensor expected = tf.zeros({1, 1, 3, 3}); + op_upsample_bilinear2d_aa_out( + input, + output_size, + /*align_corners=*/false, + std::nullopt, + std::nullopt, + expected); + + // Like ATen, a scale that is not positive is ignored. + Tensor out = tf.zeros({1, 1, 3, 3}); + op_upsample_bilinear2d_aa_out( + input, output_size, /*align_corners=*/false, 0.0, -1.0, out); + EXPECT_TENSOR_CLOSE(out, expected); +} diff --git a/kernels/test/targets.bzl b/kernels/test/targets.bzl index 5078dc996ea..82f01f01a79 100644 --- a/kernels/test/targets.bzl +++ b/kernels/test/targets.bzl @@ -368,6 +368,7 @@ def define_common_targets(): _common_op_test("op_unbind_copy_test", ["aten", "portable"]) _common_op_test("op_unfold_copy_test", ["aten", "portable"]) _common_op_test("op_unsqueeze_copy_test", ["aten", "portable"]) + _common_op_test("op_upsample_bicubic2d_aa_test", ["portable"]) _common_op_test("op_upsample_bilinear2d_test", ["aten", "portable"]) _common_op_test("op_upsample_bilinear2d_aa_test", ["portable"]) _common_op_test("op_upsample_nearest2d_test", ["aten", "portable"]) diff --git a/shim_et/xplat/executorch/build/build_variables.bzl b/shim_et/xplat/executorch/build/build_variables.bzl index bed219b04af..f816957804b 100644 --- a/shim_et/xplat/executorch/build/build_variables.bzl +++ b/shim_et/xplat/executorch/build/build_variables.bzl @@ -229,6 +229,7 @@ PORTABLE_KERNELS_SRCS = [ "kernels/portable/cpu/op_unbind_copy.cpp", "kernels/portable/cpu/op_unfold_copy.cpp", "kernels/portable/cpu/op_unsqueeze_copy.cpp", + "kernels/portable/cpu/op_upsample_bicubic2d_aa.cpp", "kernels/portable/cpu/op_upsample_bilinear2d.cpp", "kernels/portable/cpu/op_upsample_bilinear2d_aa.cpp", "kernels/portable/cpu/op_upsample_nearest2d.cpp", diff --git a/shim_et/xplat/executorch/kernels/portable/op_registration_util.bzl b/shim_et/xplat/executorch/kernels/portable/op_registration_util.bzl index 0a3ae4409c0..c2d606dc212 100644 --- a/shim_et/xplat/executorch/kernels/portable/op_registration_util.bzl +++ b/shim_et/xplat/executorch/kernels/portable/op_registration_util.bzl @@ -1337,6 +1337,12 @@ ATEN_OPS = ( "//executorch/kernels/portable/cpu/util:copy_ops_util", ], ), + op_target( + name = "op_upsample_bicubic2d_aa", + deps = [ + "//executorch/kernels/portable/cpu/util:upsample_util", + ], + ), op_target( name = "op_upsample_bilinear2d", deps = [