diff --git a/onnxruntime/core/providers/cpu/tensor/upsample.cc b/onnxruntime/core/providers/cpu/tensor/upsample.cc index 18bb38622c8cd..5576e5dd07235 100644 --- a/onnxruntime/core/providers/cpu/tensor/upsample.cc +++ b/onnxruntime/core/providers/cpu/tensor/upsample.cc @@ -1398,7 +1398,7 @@ Status Upsample::Compute(OpKernelContext* context) const { } } - ComputeROIWithAxes(roi_array, input_dims.size()); + ORT_RETURN_IF_ERROR(ComputeROIWithAxes(roi_array, input_dims.size())); // Get scales data InlinedVector scales_array(input_dims.size()); diff --git a/onnxruntime/core/providers/cpu/tensor/upsamplebase.h b/onnxruntime/core/providers/cpu/tensor/upsamplebase.h index d5d6ea614c688..1b60580a8c86d 100644 --- a/onnxruntime/core/providers/cpu/tensor/upsamplebase.h +++ b/onnxruntime/core/providers/cpu/tensor/upsamplebase.h @@ -233,6 +233,9 @@ class UpsampleBase { // guard against unit tests that can add an attribute auto axes = info.template GetAttrsOrDefault("axes"); axes_.assign(axes.cbegin(), axes.cend()); + // Uniqueness of axes is enforced after negative-axis normalization in + // ValidateAndNormalizeAxes, so two raw entries that collide after canonicalization + // (e.g. {-1, rank-1}) are still rejected. } extrapolation_value_ = info.template GetAttrOrDefault("extrapolation_value", 0.0f); @@ -301,6 +304,12 @@ class UpsampleBase { auto tensor_info = type_info.GetTensorTypeAndShapeInfo(); rank = static_cast(tensor_info.GetDimensionsCount()); } + // When axes are present and the input rank is statically known, validate axes and cache + // the normalized form so the inference hot path can skip the bitmap allocation. + if (rank > 0 && !axes_.empty()) { + ORT_THROW_IF_ERROR(ValidateAndNormalizeAxes(rank, normalized_axes_)); + normalized_axes_rank_ = rank; + } if (get_scale && scale->Shape().Size() > 0 && ((opset < 18) || (rank > 0 && opset >= 18))) { ORT_THROW_IF_ERROR(ParseScalesData(scale, scales_, rank)); scales_cached_ = true; @@ -335,6 +344,8 @@ class UpsampleBase { InlinedVector scales_; InlinedVector roi_; TensorShapeVector axes_; + TensorShapeVector normalized_axes_; + int64_t normalized_axes_rank_ = -1; bool scales_cached_; bool roi_cached_; @@ -497,7 +508,37 @@ class UpsampleBase { } } + // Resolve negative entries in axes_ against the supplied rank, verify each is in range, + // and verify that no two entries collide after normalization. Populates normalized_axes + // with the canonical non-negative indices in the original order. When the input rank is + // statically known, the constructor pre-populates normalized_axes_ and this call becomes + // a copy on the inference hot path. + Status ValidateAndNormalizeAxes(int64_t rank, TensorShapeVector& normalized_axes) const { + ORT_RETURN_IF_NOT(rank > 0, "Rank must be positive when axes is provided."); + if (rank == normalized_axes_rank_) { + normalized_axes = normalized_axes_; + return Status::OK(); + } + normalized_axes.clear(); + normalized_axes.reserve(axes_.size()); + InlinedVector seen(static_cast(rank), false); + for (int64_t raw_axis : axes_) { + ORT_RETURN_IF_NOT(IsAxisInRange(raw_axis, rank), "axis ", raw_axis, + " is not in valid range [-", rank, ",", rank - 1, "]"); + const int64_t axis = raw_axis < 0 ? raw_axis + rank : raw_axis; + const auto idx = static_cast(axis); + ORT_RETURN_IF(seen[idx], "axes attribute contains duplicate axis ", axis, + " after negative-axis normalization (rank=", rank, ")."); + seen[idx] = true; + normalized_axes.push_back(axis); + } + return Status::OK(); + } + [[nodiscard]] Status ScalesValidation(gsl::span scales, const UpsampleMode mode) const { + for (auto& scale : scales) { + ORT_RETURN_IF_NOT(std::isfinite(scale), "Scale value must be finite."); + } if (!is_resize_) { for (auto& scale : scales) { ORT_RETURN_IF_NOT(scale >= 1, "Scale value should be greater than or equal to 1."); @@ -571,9 +612,10 @@ class UpsampleBase { "Number of elements in scales should be equal to number of axes."); InlinedVector new_scales(size_t(rank), 1.0f); - for (size_t i = 0; i < axes_.size(); i++) { - const int64_t axis = HandleNegativeAxis(axes_[i], rank); - new_scales[static_cast(axis)] = scales[i]; + TensorShapeVector normalized_axes; + ORT_RETURN_IF_ERROR(ValidateAndNormalizeAxes(rank, normalized_axes)); + for (size_t i = 0; i < normalized_axes.size(); i++) { + new_scales[static_cast(normalized_axes[i])] = scales[i]; } scales.swap(new_scales); } @@ -598,11 +640,14 @@ class UpsampleBase { "Resize: input tensor's rank does not match the output tensor's rank."); if (axes_.size()) { + ORT_RETURN_IF_NOT(axes_.size() == size_span.size(), + "Number of elements in sizes should be equal to number of axes."); output_dims.assign(input_dims.begin(), input_dims.end()); const int64_t rank = static_cast(output_dims.size()); - for (size_t i = 0; i < axes_.size(); i++) { - const int64_t axis = HandleNegativeAxis(axes_[i], rank); - output_dims[static_cast(axis)] = size_span[i]; + TensorShapeVector normalized_axes; + ORT_RETURN_IF_ERROR(ValidateAndNormalizeAxes(rank, normalized_axes)); + for (size_t i = 0; i < normalized_axes.size(); i++) { + output_dims[static_cast(normalized_axes[i])] = size_span[i]; } } else { std::copy(size_span.begin(), size_span.end(), output_dims.begin()); @@ -651,21 +696,33 @@ class UpsampleBase { // Roi is redefined in Opset-18, we have a concept of axes. // So we need to update it accordingly. - void ComputeROIWithAxes(InlinedVector& roi_array, size_t rank) const { + Status ComputeROIWithAxes(InlinedVector& roi_array, size_t rank) const { if (axes_.size()) { - InlinedVector roi_tmp(rank * 2, 0); - for (size_t i = rank; i < rank * 2; ++i) { - roi_tmp[i] = 1; - } + // Per-axis ROI is supplied as a flat [start..., end...] of length 2 * len(axes). + // The default-ROI path fills roi_array with [0..., 1...] of length 2 * rank, in which + // case the canonical full-rank ROI is already correct and no scatter is needed. + const bool per_axis_roi = roi_array.size() == 2 * axes_.size(); + ORT_RETURN_IF_NOT(per_axis_roi || roi_array.size() == 2 * rank, + "roi input length (", roi_array.size(), + ") must be either 2 * rank (", 2 * rank, + ") or 2 * number of axes (", 2 * axes_.size(), ")."); const int64_t rank_i64 = static_cast(rank); - for (size_t i = 0; i < axes_.size(); i++) { - const int64_t axis = HandleNegativeAxis(axes_[i], rank_i64); - auto v_in_axes = static_cast(axis); - roi_tmp[v_in_axes] = (roi_array[i]); - roi_tmp[rank + v_in_axes] = (roi_array[axes_.size() + i]); + TensorShapeVector normalized_axes; + ORT_RETURN_IF_ERROR(ValidateAndNormalizeAxes(rank_i64, normalized_axes)); + if (per_axis_roi) { + InlinedVector roi_tmp(rank * 2, 0); + for (size_t i = rank; i < rank * 2; ++i) { + roi_tmp[i] = 1; + } + for (size_t i = 0; i < normalized_axes.size(); i++) { + const auto v_in_axes = static_cast(normalized_axes[i]); + roi_tmp[v_in_axes] = roi_array[i]; + roi_tmp[rank + v_in_axes] = roi_array[axes_.size() + i]; + } + roi_array.swap(roi_tmp); } - roi_array.swap(roi_tmp); } + return Status::OK(); } public: diff --git a/onnxruntime/core/providers/cuda/tensor/upsample.cc b/onnxruntime/core/providers/cuda/tensor/upsample.cc index 6c75500350e2c..2aa1a7ac34c39 100644 --- a/onnxruntime/core/providers/cuda/tensor/upsample.cc +++ b/onnxruntime/core/providers/cuda/tensor/upsample.cc @@ -426,7 +426,7 @@ Status Upsample::ComputeInternal(OpKernelContext* context) const { } } - ComputeROIWithAxes(roi_array, input_dims.size()); + ORT_RETURN_IF_ERROR(ComputeROIWithAxes(roi_array, input_dims.size())); InlinedVector scales_array(input_dims.size()); // opset < 10 diff --git a/onnxruntime/core/providers/webgpu/tensor/upsample.cc b/onnxruntime/core/providers/webgpu/tensor/upsample.cc index 9b88731832fc4..b7e0d6e6c0fa7 100644 --- a/onnxruntime/core/providers/webgpu/tensor/upsample.cc +++ b/onnxruntime/core/providers/webgpu/tensor/upsample.cc @@ -86,7 +86,7 @@ Status Upsample::ComputeInternal(ComputeContext& context) const { } } - ComputeROIWithAxes(roi_array, input_dims.size()); + ORT_RETURN_IF_ERROR(ComputeROIWithAxes(roi_array, input_dims.size())); InlinedVector scales_array(input_dims.size()); // opset < 10 diff --git a/onnxruntime/test/providers/cpu/tensor/resize_op_test.cc b/onnxruntime/test/providers/cpu/tensor/resize_op_test.cc index 4b4d9e9ddd1ba..35d6ee3bbeb14 100644 --- a/onnxruntime/test/providers/cpu/tensor/resize_op_test.cc +++ b/onnxruntime/test/providers/cpu/tensor/resize_op_test.cc @@ -2,6 +2,7 @@ // Licensed under the MIT License. #include +#include #include "gtest/gtest.h" #include "test/providers/provider_test_utils.h" #include "test/util/include/default_providers.h" @@ -3187,6 +3188,209 @@ TEST(ResizeOpTest, Axes_OutOfRange_18) { {kTensorrtExecutionProvider, kQnnExecutionProvider, kDmlExecutionProvider}); } +// Negative axis below the valid range must be rejected before being used as a scatter index. +TEST(ResizeOpTest, Axes_NegativeOutOfRange_18) { + std::vector X(16 * 4); + std::iota(X.begin(), X.end(), 0.f); + std::vector roi{}; + std::vector scales{0.75f, 0.75f, 0.75f}; + std::vector axes{2, 3, -6}; + std::vector Y(16 * 4, 0.0f); + + OpTester test("Resize", 18); + test.AddShapeToTensorData(false); + test.AddAttribute("mode", "linear"); + test.AddAttribute>("axes", axes); + + test.AddInput("X", {1, 1, 4, 4, 4}, X); + test.AddInput("roi", {0}, roi); + test.AddInput("scales", {int64_t(scales.size())}, scales); + test.AddOutput("Y", {1, 1, 4, 4, 4}, Y); + + // TensorRT, QNN, and DML do not exercise the CPU axes-validation path. + test.Run(OpTester::ExpectResult::kExpectFailure, + "axis -6 is not in valid range [-5,4]", + {kTensorrtExecutionProvider, kQnnExecutionProvider, kDmlExecutionProvider}); +} + +// Valid negative axes (within [-rank, -1]) must still produce correct output. +TEST(ResizeOpTest, Axes_NegativeInRange_18) { + std::vector X(16 * 4); + std::iota(X.begin(), X.end(), 0.f); + std::vector Y = {3.5f, 4.8333335f, 6.1666665f, 8.833333f, 10.166667f, 11.5f, 14.166667f, + 15.5f, 16.833334f, 24.833334f, 26.166666f, 27.5f, 30.166666f, 31.5f, + 32.833332f, 35.5f, 36.833332f, 38.166668f, 46.166668f, 47.5f, 48.833332f, + 51.5f, 52.833332f, 54.166668f, 56.833332f, 58.166668f, 59.5f}; + std::vector roi{}; + std::vector scales{3 / 4.0f, 3 / 4.0f, 3 / 4.0f}; + std::vector output_shape{1, 1, 3, 3, 3}; + std::vector axes{-3, -2, -1}; + + OpTester test("Resize", 18); + test.AddAttribute("exclude_outside", 0LL); + test.AddAttribute>("axes", axes); + test.AddAttribute("antialias", 0LL); + test.AddAttribute("mode", "linear"); + + test.AddInput("X", {1, 1, 4, 4, 4}, X); + test.AddInput("roi", {int64_t(roi.size())}, roi); + test.AddInput("scales", {int64_t(scales.size())}, scales, true); + + test.AddOutput("Y", output_shape, Y); + // OpenVINO EP's Resize importer does not normalize negative axes against the input rank, + // so it rejects models that the ONNX spec accepts. Tracked by GH issue #28788. + test.Run(OpTester::ExpectResult::kExpectSuccess, "", + {kTensorrtExecutionProvider, kQnnExecutionProvider, kOpenVINOExecutionProvider}); +} + +// When axes is provided, the sizes input length must match axes length so the scatter +// loop does not read past the end of sizes. +TEST(ResizeOpTest, Axes_and_Sizes_CountMismatch_18) { + std::vector X(16 * 4); + std::iota(X.begin(), X.end(), 0.f); + std::vector roi{}; + std::vector scales{}; + std::vector sizes{3, 3}; + std::vector axes{2, 3, 4}; + std::vector Y(16 * 4, 0.0f); + + OpTester test("Resize", 18); + test.AddShapeToTensorData(false); + test.AddAttribute("mode", "linear"); + test.AddAttribute>("axes", axes); + + test.AddInput("X", {1, 1, 4, 4, 4}, X); + test.AddInput("roi", {0}, roi); + test.AddInput("", {0}, scales); + test.AddInput("sizes", {int64_t(sizes.size())}, sizes); + test.AddOutput("Y", {1, 1, 4, 4, 4}, Y); + + test.Run(OpTester::ExpectResult::kExpectFailure, + "Number of elements in sizes should be equal to number of axes.", + {kTensorrtExecutionProvider, kQnnExecutionProvider, kDmlExecutionProvider}); +} + +// Non-finite scale values must be rejected before being multiplied into output dimensions. +TEST(ResizeOpTest, Scales_NaN_Rejected_18) { + std::vector X(16, 1.0f); + std::vector roi{}; + std::vector scales{1.0f, 1.0f, std::numeric_limits::quiet_NaN(), 2.0f}; + std::vector Y(32, 0.0f); + + OpTester test("Resize", 18); + test.AddShapeToTensorData(false); + test.AddAttribute("mode", "linear"); + + test.AddInput("X", {1, 1, 4, 4}, X); + test.AddInput("roi", {0}, roi); + test.AddInput("scales", {int64_t(scales.size())}, scales); + test.AddOutput("Y", {1, 1, 4, 8}, Y); + + // EPs that do their own validation or do not exercise the CPU ScalesValidation path. + test.Run(OpTester::ExpectResult::kExpectFailure, "Scale value must be finite.", + {kTensorrtExecutionProvider, kQnnExecutionProvider, kDmlExecutionProvider, + kOpenVINOExecutionProvider}); +} + +TEST(ResizeOpTest, Scales_PositiveInf_Rejected_18) { + std::vector X(16, 1.0f); + std::vector roi{}; + std::vector scales{1.0f, 1.0f, 2.0f, std::numeric_limits::infinity()}; + std::vector Y(32, 0.0f); + + OpTester test("Resize", 18); + test.AddShapeToTensorData(false); + test.AddAttribute("mode", "linear"); + + test.AddInput("X", {1, 1, 4, 4}, X); + test.AddInput("roi", {0}, roi); + test.AddInput("scales", {int64_t(scales.size())}, scales); + test.AddOutput("Y", {1, 1, 8, 4}, Y); + + test.Run(OpTester::ExpectResult::kExpectFailure, "Scale value must be finite.", + {kTensorrtExecutionProvider, kQnnExecutionProvider, kDmlExecutionProvider, + kOpenVINOExecutionProvider}); +} + +// Duplicate values in the axes attribute violate the ONNX spec. The check runs after +// negative-axis normalization, so it covers both raw duplicates and pairs that collide +// only after canonicalization (e.g. {-1, rank-1}). +TEST(ResizeOpTest, Axes_Duplicate_Rejected_18) { + std::vector X(16 * 4); + std::iota(X.begin(), X.end(), 0.f); + std::vector roi{}; + std::vector scales{0.75f, 0.75f, 0.75f}; + std::vector axes{2, 3, 2}; + std::vector Y(16 * 4, 0.0f); + + OpTester test("Resize", 18); + test.AddShapeToTensorData(false); + test.AddAttribute("mode", "linear"); + test.AddAttribute>("axes", axes); + + test.AddInput("X", {1, 1, 4, 4, 4}, X); + test.AddInput("roi", {0}, roi); + test.AddInput("scales", {int64_t(scales.size())}, scales); + test.AddOutput("Y", {1, 1, 4, 4, 4}, Y); + + test.Run(OpTester::ExpectResult::kExpectFailure, + "axes attribute contains duplicate axis 2", + {kTensorrtExecutionProvider, kQnnExecutionProvider, kDmlExecutionProvider}); +} + +// Negative and positive axis entries that resolve to the same canonical index must be +// rejected. For rank=5, axes={-1, 4} both map to 4. +TEST(ResizeOpTest, Axes_Duplicate_AfterNormalization_Rejected_18) { + std::vector X(16 * 4); + std::iota(X.begin(), X.end(), 0.f); + std::vector roi{}; + std::vector scales{0.75f, 0.75f}; + std::vector axes{-1, 4}; + std::vector Y(16 * 4, 0.0f); + + OpTester test("Resize", 18); + test.AddShapeToTensorData(false); + test.AddAttribute("mode", "linear"); + test.AddAttribute>("axes", axes); + + test.AddInput("X", {1, 1, 4, 4, 4}, X); + test.AddInput("roi", {0}, roi); + test.AddInput("scales", {int64_t(scales.size())}, scales); + test.AddOutput("Y", {1, 1, 4, 4, 4}, Y); + + test.Run(OpTester::ExpectResult::kExpectFailure, + "axes attribute contains duplicate axis 4", + {kTensorrtExecutionProvider, kQnnExecutionProvider, kDmlExecutionProvider}); +} + +// When axes is provided in tf_crop_and_resize mode, the roi input must contain at least +// 2 * len(axes) entries so the per-axis start/end pairs can be read safely. +TEST(ResizeOpTest, Roi_TooShortForAxes_18) { + std::vector X(16 * 4); + std::iota(X.begin(), X.end(), 0.f); + // roi length 4 (= 2 * 2), but axes has 3 entries, so 2 * len(axes) = 6 are required. + std::vector roi{0.0f, 0.0f, 1.0f, 1.0f}; + std::vector scales{0.75f, 0.75f, 0.75f}; + std::vector axes{2, 3, 4}; + std::vector Y(16 * 4, 0.0f); + + OpTester test("Resize", 18); + test.AddShapeToTensorData(false); + test.AddAttribute("mode", "linear"); + test.AddAttribute("coordinate_transformation_mode", "tf_crop_and_resize"); + test.AddAttribute>("axes", axes); + + test.AddInput("X", {1, 1, 4, 4, 4}, X); + test.AddInput("roi", {int64_t(roi.size())}, roi); + test.AddInput("scales", {int64_t(scales.size())}, scales); + test.AddOutput("Y", {1, 1, 4, 4, 4}, Y); + + test.Run(OpTester::ExpectResult::kExpectFailure, + "roi input length", + {kTensorrtExecutionProvider, kQnnExecutionProvider, kDmlExecutionProvider, + kOpenVINOExecutionProvider}); +} + TEST(ResizeOpTest, Sizes_RankMismatch_13) { OpTester test("Resize", 13); test.AddShapeToTensorData(false);