diff --git a/docs/execution_providers/QNN-ExecutionProvider.md b/docs/execution_providers/QNN-ExecutionProvider.md index b9dd8d9d90..23a69dcc4a 100644 --- a/docs/execution_providers/QNN-ExecutionProvider.md +++ b/docs/execution_providers/QNN-ExecutionProvider.md @@ -567,6 +567,7 @@ ort.unregister_execution_provider_library(ep_registration_name) |ai.onnx:MatMulInteger|Supported exclusively via DynamicQuantizeLinear → MatMulInteger fusion pattern| |ai.onnx:Max|| |ai.onnx:MaxPool|| +|ai.onnx:MaxRoiPool|rois must be a constant initializer| |ai.onnx:Mean|| |ai.onnx:Min|| |ai.onnx:Mod|| diff --git a/onnxruntime/core/providers/qnn/builder/op_builder_factory.cc b/onnxruntime/core/providers/qnn/builder/op_builder_factory.cc index 3eb4e4c2bf..e746e0bb02 100644 --- a/onnxruntime/core/providers/qnn/builder/op_builder_factory.cc +++ b/onnxruntime/core/providers/qnn/builder/op_builder_factory.cc @@ -45,6 +45,7 @@ OpBuilderRegistrations::OpBuilderRegistrations() { CreateLSTMOpBuilder("LSTM", *this); CreateMatMulOpBuilder("MatMul", *this); CreateMatMulNBitsOpBuilder("MatMulNBits", *this); + CreateMaxRoiPoolOpBuilder("MaxRoiPool", *this); CreateMeanOpBuilder("Mean", *this); CreateModOpBuilder("Mod", *this); CreateNonMaxSuppressionOpBuilder("NonMaxSuppression", *this); diff --git a/onnxruntime/core/providers/qnn/builder/op_builder_factory.h b/onnxruntime/core/providers/qnn/builder/op_builder_factory.h index 78bf7c1a4a..dc17c9d9f3 100644 --- a/onnxruntime/core/providers/qnn/builder/op_builder_factory.h +++ b/onnxruntime/core/providers/qnn/builder/op_builder_factory.h @@ -88,6 +88,7 @@ void CreateLRNOpBuilder(const std::string& op_type, OpBuilderRegistrations& op_r void CreateLSTMOpBuilder(const std::string& op_type, OpBuilderRegistrations& op_registrations); void CreateMatMulOpBuilder(const std::string& op_type, OpBuilderRegistrations& op_registrations); void CreateMatMulNBitsOpBuilder(const std::string& op_type, OpBuilderRegistrations& op_registrations); +void CreateMaxRoiPoolOpBuilder(const std::string& op_type, OpBuilderRegistrations& op_registrations); void CreateMeanOpBuilder(const std::string& op_type, OpBuilderRegistrations& op_registrations); void CreateModOpBuilder(const std::string& op_type, OpBuilderRegistrations& op_registrations); void CreateNonMaxSuppressionOpBuilder(const std::string& op_type, OpBuilderRegistrations& op_registrations); diff --git a/onnxruntime/core/providers/qnn/builder/opbuilder/maxroipool_op_builder.cc b/onnxruntime/core/providers/qnn/builder/opbuilder/maxroipool_op_builder.cc new file mode 100644 index 0000000000..d78416578e --- /dev/null +++ b/onnxruntime/core/providers/qnn/builder/opbuilder/maxroipool_op_builder.cc @@ -0,0 +1,463 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: MIT + +#include "QnnOpDef.h" + +#include +#include +#include +#include + +#include "core/providers/qnn/builder/opbuilder/base_op_builder.h" +#include "core/providers/qnn/builder/opbuilder/qdq_constant_folding.h" +#include "core/providers/qnn/builder/qnn_model_wrapper.h" +#include "core/providers/qnn/builder/op_builder_factory.h" +#include "core/providers/qnn/builder/qnn_utils.h" +#include "core/providers/qnn/common/qnn_graph_utils.h" +#include "core/providers/qnn/ort_api.h" + +namespace onnxruntime { +namespace qnn { + +namespace { + +// Upper bound on the number of pooled bins (num_rois * pooled_h * pooled_w). The decomposition +// emits O(bins) QNN nodes, so reject very large configurations and let them fall back to the +// ORT CPU EP rather than exploding the QNN graph. +constexpr int64_t kMaxMaxRoiPoolBins = 4096; + +// Reads the constant rois [num_rois, 5] = [batch_index, x1, y1, x2, y2] and returns the +// floating-point ROI corner coordinates (still in input-image space, before spatial_scale). +// Handles both a plain fp32 initializer and a QDQ-folded 8-bit quantized constant. +Ort::Status ReadRoisAsFloat(QnnModelWrapper& qnn_model_wrapper, + const OrtNodeUnitIODef& rois_def, + uint32_t num_rois, + /*out*/ std::vector& rois_flat /* num_rois*5 */) { + TensorInfo rois_info = {}; + RETURN_IF_ERROR(qnn_model_wrapper.GetTensorInfo(rois_def, rois_info)); + + std::vector rois_bytes; + RETURN_IF_ERROR(GetEffectivelyConstantTensorBytes(qnn_model_wrapper, rois_def.name, rois_bytes)); + + const size_t num_elems = static_cast(num_rois) * 5; + rois_flat.resize(num_elems); + + if (rois_info.qnn_data_type == QNN_DATATYPE_FLOAT_32) { + RETURN_IF_NOT(rois_bytes.size() == num_elems * sizeof(float), "MaxRoiPool rois initializer size mismatch."); + const float* rois = reinterpret_cast(rois_bytes.data()); + std::copy(rois, rois + num_elems, rois_flat.begin()); + } else if (rois_info.qnn_data_type == QNN_DATATYPE_UFIXED_POINT_8 || + rois_info.qnn_data_type == QNN_DATATYPE_SFIXED_POINT_8 || + rois_info.qnn_data_type == QNN_DATATYPE_UFIXED_POINT_16 || + rois_info.qnn_data_type == QNN_DATATYPE_SFIXED_POINT_16) { + RETURN_IF_NOT(rois_info.quant_param.IsPerTensor(/*include_bw*/ true), + "MaxRoiPool requires per-tensor quantized rois."); + float scale = 0.0f; + int32_t offset = 0; + RETURN_IF_ERROR(rois_info.quant_param.GetPerTensorScaleOffset(scale, offset)); + const bool is_16bit = (rois_info.qnn_data_type == QNN_DATATYPE_UFIXED_POINT_16 || + rois_info.qnn_data_type == QNN_DATATYPE_SFIXED_POINT_16); + const bool is_signed = (rois_info.qnn_data_type == QNN_DATATYPE_SFIXED_POINT_8 || + rois_info.qnn_data_type == QNN_DATATYPE_SFIXED_POINT_16); + RETURN_IF_NOT(rois_bytes.size() == num_elems * (is_16bit ? 2u : 1u), + "MaxRoiPool rois initializer size mismatch."); + for (size_t i = 0; i < num_elems; ++i) { + double q; + if (is_16bit) { + const uint16_t raw = reinterpret_cast(rois_bytes.data())[i]; + q = is_signed ? static_cast(static_cast(raw)) : static_cast(raw); + } else { + q = is_signed ? static_cast(static_cast(rois_bytes[i])) + : static_cast(rois_bytes[i]); + } + rois_flat[i] = static_cast(utils::Dequantize(offset, scale, q)); + } + } else { + return MAKE_EP_FAIL("MaxRoiPool only supports float32 or 8/16-bit quantized rois."); + } + return Ort::Status(); +} + +} // namespace + +// Translates ONNX MaxRoiPool by decomposing it into QNN primitives that are supported on all +// backends (CPU, HTP, GPU); QNN's native RoiPooling op only exists in the CPU/DSP op packages. +// +// ONNX MaxRoiPool(X[N,C,H,W], rois[num_rois,5]) pools each ROI into a pooled_h x pooled_w grid +// using adaptive (possibly overlapping, non-uniform) bins: +// hstart = y1 + floor(i*roi_h/ph), hend = y1 + ceil((i+1)*roi_h/ph) (and similarly for w) +// where corners are roundf(coord * spatial_scale) and roi_h = y2-y1+1, roi_w = x2-x1+1. +// +// The feature map arrives in NHWC layout. Each bin is realized exactly as +// StridedSlice X[:, hstart:hend, wstart:wend, :] -> ReduceMax over {H,W} keepdims -> [1,1,1,C] +// (empty bins emit a static zero tensor, matching ONNX). Per ROI the ph*pw bin results are +// concatenated and reshaped to [1, ph, pw, C]; the per-ROI tensors are concatenated along the +// batch axis to form the [num_rois, ph, pw, C] NHWC output. +// +// The rois must be a constant initializer so the bin geometry can be computed at build time. +class MaxRoiPoolOpBuilder : public BaseOpBuilder { + public: + MaxRoiPoolOpBuilder() : BaseOpBuilder("MaxRoiPoolOpBuilder") {} + ORT_DISALLOW_COPY_ASSIGNMENT_AND_MOVE(MaxRoiPoolOpBuilder); + + Ort::Status IsOpSupported(QnnModelWrapper& qnn_model_wrapper, + const OrtNodeUnit& node_unit, + const Ort::Logger& logger) const final ORT_MUST_USE_RESULT; + + protected: + Ort::Status ProcessInputs(QnnModelWrapper& qnn_model_wrapper, + const OrtNodeUnit& node_unit, + const Ort::Logger& logger, + std::vector& input_names, + bool do_op_validation) const override ORT_MUST_USE_RESULT; + + Ort::Status ProcessAttributesAndOutputs(QnnModelWrapper& qnn_model_wrapper, + const OrtNodeUnit& node_unit, + std::vector&& input_names, + const Ort::Logger& logger, + bool do_op_validation) const override ORT_MUST_USE_RESULT; +}; + +Ort::Status MaxRoiPoolOpBuilder::IsOpSupported(QnnModelWrapper& qnn_model_wrapper, + const OrtNodeUnit& node_unit, + const Ort::Logger& logger) const { + // MaxRoiPool is sensitive to data layout and requires NHWC. Continue once converted. + if (node_unit.Domain() == kMSInternalNHWCDomain) { + return AddToModelBuilder(qnn_model_wrapper, node_unit, logger, true); + } + + OrtNodeAttrHelper node_helper(node_unit); + + // ROIs (input[1]) must be a constant so the bin geometry can be computed at build time. + // A non-constant rois (graph input) is rejected here, before the layout transform, for a clean + // CPU-EP fallback. A QDQ'd constant rois is not yet folded at GetCapability time, so the full + // constant check is deferred to ProcessInputs. + RETURN_IF(qnn_model_wrapper.IsGraphInput(node_unit.Inputs()[1].name), + "MaxRoiPool requires rois to be a constant initializer."); + + TensorInfo rois_info = {}; + RETURN_IF_ERROR(qnn_model_wrapper.GetTensorInfo(node_unit.Inputs()[1], rois_info)); + RETURN_IF_NOT(2 == static_cast(rois_info.shape.size()) && 5 == static_cast(rois_info.shape[1]), + "MaxRoiPool requires rois of shape [num_rois, 5]."); + const int64_t num_rois = static_cast(rois_info.shape[0]); + + // Output tensor size [num_rois, C, pooled_h, pooled_w]. + TensorInfo output_info = {}; + RETURN_IF_ERROR(qnn_model_wrapper.GetTensorInfo(node_unit.Outputs()[0], output_info)); + RETURN_IF_NOT(4 == static_cast(output_info.shape.size()), + "MaxRoiPool requires 4d output (num_rois, C, pooled_h, pooled_w)."); + + // pooled_shape is required and must match the output spatial dims. + std::vector pooled_shape = node_helper.Get("pooled_shape", std::vector{}); + RETURN_IF_NOT(pooled_shape.size() == 2, "MaxRoiPool requires the pooled_shape attribute with 2 values."); + RETURN_IF_NOT(pooled_shape[0] == static_cast(output_info.shape[2]), + "Expect pooled_shape[0] == output_tensor.shape[2]"); + RETURN_IF_NOT(pooled_shape[1] == static_cast(output_info.shape[3]), + "Expect pooled_shape[1] == output_tensor.shape[3]"); + + float spatial_scale = node_helper.Get("spatial_scale", 1.0f); + RETURN_IF(spatial_scale <= 0, "MaxRoiPool got invalid spatial_scale <= 0"); + + // The decomposition emits O(num_rois * ph * pw) QNN nodes; bound the graph size. + const int64_t num_bins = num_rois * pooled_shape[0] * pooled_shape[1]; + RETURN_IF_NOT(num_bins <= kMaxMaxRoiPoolBins, + "MaxRoiPool decomposition exceeds the supported bin count (num_rois * pooled_h * pooled_w)."); + + return Ort::Status(); +} + +Ort::Status MaxRoiPoolOpBuilder::ProcessInputs(QnnModelWrapper& qnn_model_wrapper, + const OrtNodeUnit& node_unit, + const Ort::Logger& logger, + std::vector& input_names, + bool do_op_validation) const { + ORT_UNUSED_PARAMETER(do_op_validation); + + const auto& inputs = node_unit.Inputs(); + + // input[0]: feature map (X). The layout transformer converts it to NHWC. Only X is wired into + // the QNN graph; the rois are consumed at build time to compute bin geometry (handled in + // ProcessAttributesAndOutputs), so input[1] is intentionally not processed here. + RETURN_IF_ERROR(ProcessInput(qnn_model_wrapper, inputs[0], logger, input_names)); + + RETURN_IF_NOT(qnn_model_wrapper.IsEffectivelyConstantInput(inputs[1].name), + "MaxRoiPool requires rois to be a constant initializer."); + + return Ort::Status(); +} + +Ort::Status MaxRoiPoolOpBuilder::ProcessAttributesAndOutputs(QnnModelWrapper& qnn_model_wrapper, + const OrtNodeUnit& node_unit, + std::vector&& input_names, + const Ort::Logger& logger, + bool do_op_validation) const { + ORT_UNUSED_PARAMETER(logger); + OrtNodeAttrHelper node_helper(node_unit); + + const std::string& x_name = input_names[0]; + const auto& inputs = node_unit.Inputs(); + + // X tensor info (NHWC: [N, H, W, C]). + TensorInfo x_info = {}; + RETURN_IF_ERROR(qnn_model_wrapper.GetTensorInfo(inputs[0], x_info)); + RETURN_IF_NOT(x_info.shape.size() == 4, "MaxRoiPool expects a 4D feature map."); + const uint32_t in_h = x_info.shape[1]; + const uint32_t in_w = x_info.shape[2]; + const uint32_t channels = x_info.shape[3]; + + // Output tensor info (NHWC: [num_rois, ph, pw, C]). + TensorInfo out_info = {}; + RETURN_IF_ERROR(qnn_model_wrapper.GetTensorInfo(node_unit.Outputs()[0], out_info)); + const std::string& output_name = node_unit.Outputs()[0].name; + const uint32_t num_rois = out_info.shape[0]; + const uint32_t pooled_h = out_info.shape[1]; + const uint32_t pooled_w = out_info.shape[2]; + + const Qnn_DataType_t dtype = out_info.qnn_data_type; + const float spatial_scale = node_helper.Get("spatial_scale", 1.0f); + + // Read the constant rois corners (input-image space). + std::vector rois_flat; + RETURN_IF_ERROR(ReadRoisAsFloat(qnn_model_wrapper, inputs[1], num_rois, rois_flat)); + + const std::string name_base = (node_unit.Name().empty() ? node_unit.OpType() : node_unit.Name()) + + std::to_string(node_unit.Index()); + auto local_name = [&name_base](std::string_view suffix) { return name_base + std::string(suffix); }; + + // Reusable zero tensor for empty bins (ONNX fills them with 0.0). Created lazily. + std::string zero_bin_name; + auto ensure_zero_bin = [&qnn_model_wrapper, &out_info, dtype, channels, + &zero_bin_name, &local_name]() -> Ort::Status { + if (!zero_bin_name.empty()) { + return Ort::Status(); + } + zero_bin_name = local_name("_zero_bin"); + const size_t num_bytes = qnn::utils::GetQnnTensorDataSizeInBytes(static_cast(channels), dtype); + std::vector zero_bytes(num_bytes, 0); + + // For a quantized output, the bytes must encode 0.0 under its scale/offset, not all-zero. + if (out_info.quant_param.IsQuantized()) { + RETURN_IF_NOT(out_info.quant_param.IsPerTensor(/*include_bw*/ true), + "MaxRoiPool requires a per-tensor quantized output."); + float scale = 0.0f; + int32_t offset = 0; + RETURN_IF_ERROR(out_info.quant_param.GetPerTensorScaleOffset(scale, offset)); + int quant_value = 0; + RETURN_IF_ERROR(utils::Quantize(0.0, scale, offset, dtype, quant_value)); + switch (dtype) { + case QNN_DATATYPE_UFIXED_POINT_8: + case QNN_DATATYPE_SFIXED_POINT_8: { + std::fill_n(zero_bytes.data(), channels, static_cast(quant_value)); + break; + } + case QNN_DATATYPE_UFIXED_POINT_16: + case QNN_DATATYPE_SFIXED_POINT_16: { + std::fill_n(reinterpret_cast(zero_bytes.data()), channels, + static_cast(quant_value)); + break; + } + default: + return MAKE_EP_FAIL("MaxRoiPool: unsupported quantized output element type for zero-bin."); + } + } + QnnTensorWrapper zero_tensor(zero_bin_name, QNN_TENSOR_TYPE_STATIC, dtype, + out_info.quant_param.Copy(), std::vector{1u, 1u, 1u, channels}, + std::move(zero_bytes)); + RETURN_IF_NOT(qnn_model_wrapper.AddTensorWrapper(std::move(zero_tensor)), + "Failed to add MaxRoiPool zero-bin tensor."); + return Ort::Status(); + }; + + // Emit a StridedSlice + ReduceMax for one bin region and return its [1,1,1,C] output name. + // batch_idx selects the image in the (NHWC) feature map; tag is a unique label for naming. + auto emit_bin = [&qnn_model_wrapper, &node_unit, &x_info, &out_info, dtype, channels, + &x_name, do_op_validation, &local_name, &ensure_zero_bin, &zero_bin_name]( + uint32_t batch_idx, const std::string& tag, + uint32_t hstart, uint32_t hend, uint32_t wstart, uint32_t wend, + /*out*/ std::string& bin_out_name) -> Ort::Status { + if (hend <= hstart || wend <= wstart) { + RETURN_IF_ERROR(ensure_zero_bin()); + bin_out_name = zero_bin_name; + return Ort::Status(); + } + + const std::string suffix = tag + "_h" + std::to_string(hstart) + "_w" + std::to_string(wstart); + + // StridedSlice is a byte-copy, so it stays in X's quant domain; the ReduceMax below requantizes + // to the output domain. + const std::string slice_out = local_name("_slice" + suffix); + const uint32_t bh = hend - hstart; + const uint32_t bw = wend - wstart; + std::vector slice_shape{1u, bh, bw, channels}; + QnnTensorWrapper slice_tensor(slice_out, QNN_TENSOR_TYPE_NATIVE, x_info.qnn_data_type, + x_info.quant_param.Copy(), std::vector(slice_shape)); + RETURN_IF_NOT(qnn_model_wrapper.AddTensorWrapper(std::move(slice_tensor)), + "Failed to add MaxRoiPool slice tensor."); + + std::vector ranges_dims{4u, 3u}; + std::vector ranges_data{ + batch_idx, batch_idx + 1u, 1u, + hstart, hend, 1u, + wstart, wend, 1u, + 0u, channels, 1u}; + QnnParamWrapper ranges_param(node_unit.Index(), slice_out, QNN_OP_STRIDED_SLICE_PARAM_RANGES, + std::move(ranges_dims), std::move(ranges_data), /*is_signed*/ true); + std::vector slice_params{ranges_param.GetParamTensorName()}; + RETURN_IF_NOT(qnn_model_wrapper.AddParamWrapper(std::move(ranges_param)), + "Failed to add MaxRoiPool StridedSlice ranges param."); + RETURN_IF_NOT(qnn_model_wrapper.CreateQnnNode(local_name("_slice_node" + suffix), + QNN_OP_PACKAGE_NAME_QTI_AISW, QNN_OP_STRIDED_SLICE, + {x_name}, {slice_out}, std::move(slice_params), + do_op_validation), + "Failed to add MaxRoiPool StridedSlice node."); + + // ReduceMax over H,W (axes 1,2) keepdims -> [1,1,1,C]. + bin_out_name = local_name("_rmax" + suffix); + QnnTensorWrapper rmax_tensor(bin_out_name, QNN_TENSOR_TYPE_NATIVE, dtype, + out_info.quant_param.Copy(), std::vector{1u, 1u, 1u, channels}); + RETURN_IF_NOT(qnn_model_wrapper.AddTensorWrapper(std::move(rmax_tensor)), + "Failed to add MaxRoiPool ReduceMax tensor."); + std::vector axes_data{1u, 2u}; + QnnParamWrapper axes_param = createQnnParamWrapper( + node_unit.Index(), bin_out_name, QNN_OP_REDUCE_MAX_PARAM_AXES, + std::vector{2u}, std::move(axes_data)); + std::vector rmax_params{axes_param.GetParamTensorName()}; + RETURN_IF_NOT(qnn_model_wrapper.AddParamWrapper(std::move(axes_param)), + "Failed to add MaxRoiPool ReduceMax axes param."); + RETURN_IF_ERROR(AddQnnScalar(qnn_model_wrapper, node_unit.Index(), bin_out_name, true, + QNN_OP_REDUCE_MAX_PARAM_KEEP_DIMS, rmax_params)); + RETURN_IF_NOT(qnn_model_wrapper.CreateQnnNode(local_name("_rmax_node" + suffix), + QNN_OP_PACKAGE_NAME_QTI_AISW, QNN_OP_REDUCE_MAX, + {slice_out}, {bin_out_name}, std::move(rmax_params), + do_op_validation), + "Failed to add MaxRoiPool ReduceMax node."); + return Ort::Status(); + }; + + // Concatenate a list of tensors along an axis, producing a new NATIVE output. A single-element + // list is passed through with a Reshape (QNN Concat requires >= 2 inputs). + auto emit_concat = [&qnn_model_wrapper, &node_unit, &out_info, dtype, do_op_validation, &local_name]( + const std::vector& parts, uint32_t axis, + const std::vector& part_shape, const std::vector& out_shape, + const std::string& name_suffix, /*out*/ std::string& concat_out) -> Ort::Status { + concat_out = local_name(name_suffix); + if (parts.size() == 1) { + RETURN_IF_ERROR(qnn_model_wrapper.AddReshapeNode( + parts[0], concat_out, std::vector(part_shape), std::vector(out_shape), + dtype, out_info.quant_param.Copy(), out_info.quant_param.Copy(), + do_op_validation, /*is_for_input*/ false, /*is_for_output*/ false)); + return Ort::Status(); + } + QnnTensorWrapper concat_tensor(concat_out, QNN_TENSOR_TYPE_NATIVE, dtype, + out_info.quant_param.Copy(), std::vector(out_shape)); + RETURN_IF_NOT(qnn_model_wrapper.AddTensorWrapper(std::move(concat_tensor)), + "Failed to add MaxRoiPool concat tensor."); + std::vector concat_params; + RETURN_IF_ERROR(AddQnnScalar(qnn_model_wrapper, node_unit.Index(), concat_out, axis, + QNN_OP_CONCAT_PARAM_AXIS, concat_params)); + RETURN_IF_NOT(qnn_model_wrapper.CreateQnnNode(local_name(name_suffix + "_node"), + QNN_OP_PACKAGE_NAME_QTI_AISW, QNN_OP_CONCAT, + std::vector(parts), {concat_out}, + std::move(concat_params), do_op_validation), + "Failed to add MaxRoiPool Concat node."); + return Ort::Status(); + }; + + // Build each ROI's [1, ph, pw, C] tile, then concat all ROIs along the batch axis. + std::vector roi_tile_names; + roi_tile_names.reserve(num_rois); + + for (uint32_t r = 0; r < num_rois; ++r) { + const float* roi = &rois_flat[static_cast(r) * 5]; + // roi = [batch_index, x1, y1, x2, y2]. batch_index selects the image in the feature map. + const int32_t batch_index = static_cast(std::lround(roi[0])); + RETURN_IF(batch_index < 0 || static_cast(batch_index) >= x_info.shape[0], + "MaxRoiPool rois batch_index is out of range."); + const uint32_t batch_idx = static_cast(batch_index); + const int32_t x1 = static_cast(std::lround(roi[1] * spatial_scale)); + const int32_t y1 = static_cast(std::lround(roi[2] * spatial_scale)); + const int32_t x2 = static_cast(std::lround(roi[3] * spatial_scale)); + const int32_t y2 = static_cast(std::lround(roi[4] * spatial_scale)); + const int32_t roi_h = std::max(y2 - y1 + 1, 1); + const int32_t roi_w = std::max(x2 - x1 + 1, 1); + + const std::string roi_tag = "_r" + std::to_string(r); + std::vector bin_names; + bin_names.reserve(static_cast(pooled_h) * pooled_w); + + for (uint32_t ph = 0; ph < pooled_h; ++ph) { + // Adaptive bin bounds along H (ONNX uses floor for start, ceil for end). + int32_t hstart = y1 + static_cast(std::floor(static_cast(ph) * roi_h / pooled_h)); + int32_t hend = y1 + static_cast(std::ceil(static_cast(ph + 1) * roi_h / pooled_h)); + hstart = std::min(std::max(hstart, 0), static_cast(in_h)); + hend = std::min(std::max(hend, 0), static_cast(in_h)); + + for (uint32_t pw = 0; pw < pooled_w; ++pw) { + int32_t wstart = x1 + static_cast(std::floor(static_cast(pw) * roi_w / pooled_w)); + int32_t wend = x1 + static_cast(std::ceil(static_cast(pw + 1) * roi_w / pooled_w)); + wstart = std::min(std::max(wstart, 0), static_cast(in_w)); + wend = std::min(std::max(wend, 0), static_cast(in_w)); + + std::string bin_out; + RETURN_IF_ERROR(emit_bin(batch_idx, roi_tag + "_p" + std::to_string(ph) + std::to_string(pw), + static_cast(hstart), static_cast(hend), + static_cast(wstart), static_cast(wend), bin_out)); + bin_names.push_back(std::move(bin_out)); + } + } + + // Concat the ph*pw bin tensors along axis 1 -> [1, ph*pw, 1, C], then reshape -> [1, ph, pw, C]. + std::string roi_concat; + RETURN_IF_ERROR(emit_concat(bin_names, /*axis*/ 1u, + std::vector{1u, 1u, 1u, channels}, + std::vector{1u, pooled_h * pooled_w, 1u, channels}, + "_roi_concat_r" + std::to_string(r), roi_concat)); + + std::string roi_tile = local_name("_roi_tile_r" + std::to_string(r)); + RETURN_IF_ERROR(qnn_model_wrapper.AddReshapeNode( + roi_concat, roi_tile, + std::vector{1u, pooled_h * pooled_w, 1u, channels}, + std::vector{1u, pooled_h, pooled_w, channels}, + dtype, out_info.quant_param.Copy(), out_info.quant_param.Copy(), + do_op_validation, /*is_for_input*/ false, /*is_for_output*/ false)); + roi_tile_names.push_back(std::move(roi_tile)); + } + + // Final output: concat per-ROI tiles along the batch axis -> [num_rois, ph, pw, C]. + const bool is_graph_output = qnn_model_wrapper.IsGraphOutput(output_name); + const Qnn_TensorType_t out_type = is_graph_output ? QNN_TENSOR_TYPE_APP_READ : QNN_TENSOR_TYPE_NATIVE; + + if (roi_tile_names.size() == 1) { + // Single ROI: the tile is already [1, ph, pw, C]; reshape it into the graph output tensor + // (QNN Concat requires >= 2 inputs). + RETURN_IF_ERROR(qnn_model_wrapper.AddReshapeNode( + roi_tile_names[0], output_name, + std::vector{1u, pooled_h, pooled_w, channels}, + std::vector{num_rois, pooled_h, pooled_w, channels}, + dtype, out_info.quant_param.Copy(), out_info.quant_param.Copy(), + do_op_validation, /*is_for_input*/ false, /*is_for_output*/ is_graph_output)); + return Ort::Status(); + } + + QnnTensorWrapper out_tensor(output_name, out_type, dtype, out_info.quant_param.Copy(), + std::vector{num_rois, pooled_h, pooled_w, channels}); + RETURN_IF_NOT(qnn_model_wrapper.AddTensorWrapper(std::move(out_tensor)), + "Failed to add MaxRoiPool output tensor."); + std::vector out_concat_params; + RETURN_IF_ERROR(AddQnnScalar(qnn_model_wrapper, node_unit.Index(), output_name, 0u, + QNN_OP_CONCAT_PARAM_AXIS, out_concat_params)); + RETURN_IF_NOT(qnn_model_wrapper.CreateQnnNode(local_name("_out_concat"), + QNN_OP_PACKAGE_NAME_QTI_AISW, QNN_OP_CONCAT, + std::move(roi_tile_names), {output_name}, + std::move(out_concat_params), do_op_validation), + "Failed to add MaxRoiPool output Concat node."); + + return Ort::Status(); +} + +void CreateMaxRoiPoolOpBuilder(const std::string& op_type, OpBuilderRegistrations& op_registrations) { + op_registrations.AddOpBuilder(op_type, std::make_unique()); +} + +} // namespace qnn +} // namespace onnxruntime diff --git a/onnxruntime/core/providers/qnn/qnn_execution_provider.cc b/onnxruntime/core/providers/qnn/qnn_execution_provider.cc index bb3be627ff..5a2337777d 100644 --- a/onnxruntime/core/providers/qnn/qnn_execution_provider.cc +++ b/onnxruntime/core/providers/qnn/qnn_execution_provider.cc @@ -2908,6 +2908,12 @@ OrtStatus* ORT_API_CALL QnnEp::ShouldConvertDataLayoutForOpImpl(_In_ OrtEp* this *should_convert = 1; } + if (std::string(domain) == kOnnxDomain && std::string(op_type) == "MaxRoiPool") { + // MaxRoiPool is decomposed into StridedSlice/ReduceMax/Concat, which require the NHWC layout + // for processing. + *should_convert = 1; + } + if (std::string(domain) == kOnnxDomain && std::string(op_type) == "LpPool") { // LpPool is translated to a QNN AvgPool-based decomposition, which requires the NHWC layout // for processing. diff --git a/onnxruntime/test/providers/qnn/maxroipool_test.cc b/onnxruntime/test/providers/qnn/maxroipool_test.cc new file mode 100644 index 0000000000..1987d5c6e8 --- /dev/null +++ b/onnxruntime/test/providers/qnn/maxroipool_test.cc @@ -0,0 +1,325 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: MIT + +#if !defined(ORT_MINIMAL_BUILD) + +#include +#include + +#include "test/providers/qnn/qnn_test_utils.h" + +#include "gtest/gtest.h" + +namespace onnxruntime { +namespace test { + +// Returns a function that creates a graph with a single MaxRoiPool operator. +// ONNX MaxRoiPool has two inputs: X [N, C, H, W] and rois [num_rois, 5] laid out as +// [batch_index, x1, y1, x2, y2]. +static GetTestModelFn BuildMaxRoiPoolTestCase(const TestInputDef& input_def, + const TestInputDef& roi_def, + const std::vector& attrs) { + return [input_def, roi_def, attrs](ModelTestBuilder& builder) { + MakeTestInput(builder, "X", input_def); + MakeTestInput(builder, "rois", roi_def); + + builder.AddNode("maxroipool_node", "MaxRoiPool", {"X", "rois"}, {"Y"}, kOnnxDomain, attrs); + + builder.MakeOutput("Y"); + }; +} + +// Returns a function that creates a graph with a QDQ MaxRoiPool operator. MaxRoiPool is decomposed +// into StridedSlice/ReduceMax/Concat, all of which run quantized on the HTP backend. +template +GetTestQDQModelFn BuildMaxRoiPoolQDQTestCase(const TestInputDef& input_def, + const TestInputDef& roi_def, + const std::vector& attrs, + bool use_contrib_qdq = false) { + return [input_def, roi_def, attrs, use_contrib_qdq](ModelTestBuilder& builder, + std::vector>& output_qparams) { + // X -> Q -> DQ -> + MakeTestInput(builder, "X", input_def); + QuantParams input_qparams = GetTestInputQuantParams(input_def); + std::string input_qdq = AddQDQNodePair(builder, "qdq1", "X", input_qparams.scale, + input_qparams.zero_point, use_contrib_qdq); + + // rois -> Q -> DQ -> + MakeTestInput(builder, "rois", roi_def); + QuantParams roi_qparams = GetTestInputQuantParams(roi_def); + std::string roi_qdq = AddQDQNodePair(builder, "qdq2", "rois", roi_qparams.scale, + roi_qparams.zero_point, use_contrib_qdq); + + builder.AddNode("maxroipool_node", "MaxRoiPool", {input_qdq, roi_qdq}, {"maxroipool_output"}, kOnnxDomain, attrs); + + // op_output -> Q -> DQ -> output + AddQDQNodePairWithOutputAsGraphOutput( + builder, "qdq_out", "maxroipool_output", + output_qparams[0].scale, output_qparams[0].zero_point, use_contrib_qdq); + }; +} + +// Runs a MaxRoiPool model on the QNN CPU/HTP backend. Checks the graph node assignment, and that +// inference outputs for QNN and CPU match. +static void RunMaxRoiPoolOpTest(const TestInputDef& input_def, + const TestInputDef& roi_def, + const std::vector& attrs, + ExpectedEPNodeAssignment expected_ep_assignment, + const std::string& backend_name = "cpu", + int opset = 13, + float f32_abs_err = 1e-5f) { + ProviderOptions provider_options; + provider_options["backend_type"] = backend_name; + provider_options["offload_graph_io_quantization"] = "0"; + if (backend_name != "cpu") { + provider_options["soc_model"] = std::to_string(QNN_SOC_MODEL_SM8850); + } + + RunQnnModelTest(BuildMaxRoiPoolTestCase(input_def, roi_def, attrs), + provider_options, + opset, + EPVerificationParams{expected_ep_assignment, ElementwiseAbsoluteVerifier(f32_abs_err)}); +} + +// Runs a QDQ MaxRoiPool model on the QNN HTP backend. Checks the graph node assignment, and that +// inference outputs for QNN and CPU match. +template +static void RunQDQMaxRoiPoolOpTest(const TestInputDef& input_def, + const TestInputDef& roi_def, + const std::vector& attrs, + ExpectedEPNodeAssignment expected_ep_assignment, + int opset = 13, + bool use_contrib_qdq = false) { + ProviderOptions provider_options; + provider_options["backend_type"] = "htp"; + provider_options["offload_graph_io_quantization"] = "0"; + provider_options["soc_model"] = std::to_string(QNN_SOC_MODEL_SM8850); + + TestQDQModelAccuracy(BuildMaxRoiPoolTestCase(input_def, roi_def, attrs), + BuildMaxRoiPoolQDQTestCase(input_def, roi_def, attrs, use_contrib_qdq), + provider_options, + opset, + expected_ep_assignment); +} + +// +// CPU tests: +// +TEST_F(QnnCPUBackendTests, TestMaxRoiPool) { + RunMaxRoiPoolOpTest(TestInputDef({1, 1, 4, 4}, false, + {1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f, 8.0f, + 9.0f, 10.0f, 11.0f, 12.0f, 13.0f, 14.0f, 15.0f, 16.0f}), + TestInputDef({1, 5}, true, {0.0f, 0.0f, 0.0f, 3.0f, 3.0f}), + {test::MakeAttribute("pooled_shape", std::vector{2, 2}), + test::MakeAttribute("spatial_scale", 1.0f)}, + ExpectedEPNodeAssignment::All); +} + +TEST_F(QnnCPUBackendTests, TestMaxRoiPool_spatial_scale) { + RunMaxRoiPoolOpTest(TestInputDef({1, 1, 4, 4}, false, + {1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f, 8.0f, + 9.0f, 10.0f, 11.0f, 12.0f, 13.0f, 14.0f, 15.0f, 16.0f}), + TestInputDef({1, 5}, true, {0.0f, 0.0f, 0.0f, 6.0f, 6.0f}), + {test::MakeAttribute("pooled_shape", std::vector{2, 2}), + test::MakeAttribute("spatial_scale", 0.5f)}, + ExpectedEPNodeAssignment::All); +} + +// MaxRoiPool requires the rois to be a constant initializer (bin geometry is computed at build +// time). A non-constant rois input must not be assigned to QNN. +TEST_F(QnnCPUBackendTests, TestMaxRoiPool_NonConstRois_Unsupported) { + RunMaxRoiPoolOpTest(TestInputDef({1, 1, 4, 4}, false, + {1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f, 8.0f, + 9.0f, 10.0f, 11.0f, 12.0f, 13.0f, 14.0f, 15.0f, 16.0f}), + TestInputDef({1, 5}, false, {0.0f, 0.0f, 0.0f, 3.0f, 3.0f}), + {test::MakeAttribute("pooled_shape", std::vector{2, 2}), + test::MakeAttribute("spatial_scale", 1.0f)}, + ExpectedEPNodeAssignment::None); +} + +// Adaptive binning: a 4x4 ROI pooled into a 3x3 grid produces non-uniform / overlapping bins. +// Exercises the per-bin StridedSlice + ReduceMax decomposition against the ORT CPU reference. +TEST_F(QnnCPUBackendTests, TestMaxRoiPool_AdaptiveBins) { + RunMaxRoiPoolOpTest(TestInputDef({1, 2, 4, 4}, false, GetFloatDataInRange(0.0f, 32.0f, 32)), + TestInputDef({1, 5}, true, {0.0f, 0.0f, 0.0f, 3.0f, 3.0f}), + {test::MakeAttribute("pooled_shape", std::vector{3, 3}), + test::MakeAttribute("spatial_scale", 1.0f)}, + ExpectedEPNodeAssignment::All); +} + +// Empty-bin path: a ROI extending past the boundary (y2=6 > H=4) yields empty bins after clamping. +TEST_F(QnnCPUBackendTests, TestMaxRoiPool_EmptyBins) { + RunMaxRoiPoolOpTest(TestInputDef({1, 1, 4, 4}, false, + {1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f, 8.0f, + 9.0f, 10.0f, 11.0f, 12.0f, 13.0f, 14.0f, 15.0f, 16.0f}), + TestInputDef({1, 5}, true, {0.0f, 0.0f, 3.0f, 1.0f, 6.0f}), + {test::MakeAttribute("pooled_shape", std::vector{2, 2}), + test::MakeAttribute("spatial_scale", 1.0f)}, + ExpectedEPNodeAssignment::All); +} + +// Multi-ROI: num_rois > 1 exercises the final Concat branch (single-ROI takes a reshape-only path). +TEST_F(QnnCPUBackendTests, TestMaxRoiPool_MultiRoi) { + RunMaxRoiPoolOpTest(TestInputDef({1, 2, 4, 4}, false, GetFloatDataInRange(0.0f, 32.0f, 32)), + TestInputDef({3, 5}, true, {0.0f, 0.0f, 0.0f, 3.0f, 3.0f, 0.0f, 1.0f, 1.0f, 3.0f, 3.0f, 0.0f, 0.0f, 0.0f, 1.0f, 1.0f}), + {test::MakeAttribute("pooled_shape", std::vector{2, 2}), + test::MakeAttribute("spatial_scale", 1.0f)}, + ExpectedEPNodeAssignment::All); +} + +// Cross-image sampling: N > 1 feature map with ROIs whose batch_index selects different images. +TEST_F(QnnCPUBackendTests, TestMaxRoiPool_MultiImage) { + RunMaxRoiPoolOpTest(TestInputDef({2, 1, 4, 4}, false, GetFloatDataInRange(0.0f, 32.0f, 32)), + TestInputDef({2, 5}, true, {0.0f, 0.0f, 0.0f, 3.0f, 3.0f, 1.0f, 0.0f, 0.0f, 3.0f, 3.0f}), + {test::MakeAttribute("pooled_shape", std::vector{2, 2}), + test::MakeAttribute("spatial_scale", 1.0f)}, + ExpectedEPNodeAssignment::All); +} + +#if defined(__aarch64__) || defined(_M_ARM64) || defined(__linux__) + +// +// HTP tests: +// +// MaxRoiPool is decomposed into StridedSlice/ReduceMax/Concat, which the HTP backend supports +// with 8-bit quantization, so the QDQ model fully offloads to QNN. +TEST_F(QnnHTPBackendTests, TestMaxRoiPoolQdq) { + RunQDQMaxRoiPoolOpTest(TestInputDef({1, 1, 4, 4}, false, + {1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f, 8.0f, + 9.0f, 10.0f, 11.0f, 12.0f, 13.0f, 14.0f, 15.0f, 16.0f}), + TestInputDef({1, 5}, true, {0.0f, 0.0f, 0.0f, 3.0f, 3.0f}), + {test::MakeAttribute("pooled_shape", std::vector{2, 2}), + test::MakeAttribute("spatial_scale", 1.0f)}, + ExpectedEPNodeAssignment::All); +} + +// Adaptive binning on HTP (QDQ). +TEST_F(QnnHTPBackendTests, TestMaxRoiPoolQdq_AdaptiveBins) { + RunQDQMaxRoiPoolOpTest(TestInputDef({1, 2, 4, 4}, false, GetFloatDataInRange(0.0f, 32.0f, 32)), + TestInputDef({1, 5}, true, {0.0f, 0.0f, 0.0f, 3.0f, 3.0f}), + {test::MakeAttribute("pooled_shape", std::vector{3, 3}), + test::MakeAttribute("spatial_scale", 1.0f)}, + ExpectedEPNodeAssignment::All); +} + +// Multi-ROI on HTP: exercises the final Concat branch. +TEST_F(QnnHTPBackendTests, TestMaxRoiPoolQdq_MultiRoi) { + RunQDQMaxRoiPoolOpTest(TestInputDef({1, 2, 4, 4}, false, GetFloatDataInRange(0.0f, 32.0f, 32)), + TestInputDef({3, 5}, true, {0.0f, 0.0f, 0.0f, 3.0f, 3.0f, 0.0f, 1.0f, 1.0f, 3.0f, 3.0f, 0.0f, 0.0f, 0.0f, 1.0f, 1.0f}), + {test::MakeAttribute("pooled_shape", std::vector{2, 2}), + test::MakeAttribute("spatial_scale", 1.0f)}, + ExpectedEPNodeAssignment::All); +} + +// Empty-bin path on HTP. All-negative data so the empty bins (filled with 0.0) pin the output max, +// forcing a non-zero output zero_point. +TEST_F(QnnHTPBackendTests, TestMaxRoiPoolQdq_EmptyBins) { + RunQDQMaxRoiPoolOpTest(TestInputDef({1, 1, 4, 4}, false, + {-1.0f, -2.0f, -3.0f, -4.0f, -5.0f, -6.0f, -7.0f, -8.0f, + -9.0f, -10.0f, -11.0f, -12.0f, -13.0f, -14.0f, -15.0f, -16.0f}), + TestInputDef({1, 5}, true, {0.0f, 0.0f, 3.0f, 1.0f, 6.0f}), + {test::MakeAttribute("pooled_shape", std::vector{2, 2}), + test::MakeAttribute("spatial_scale", 1.0f)}, + ExpectedEPNodeAssignment::All); +} + +// Cross-image sampling on HTP: ROIs select different images. +TEST_F(QnnHTPBackendTests, TestMaxRoiPoolQdq_MultiImage) { + RunQDQMaxRoiPoolOpTest(TestInputDef({2, 1, 4, 4}, false, GetFloatDataInRange(0.0f, 32.0f, 32)), + TestInputDef({2, 5}, true, {0.0f, 0.0f, 0.0f, 3.0f, 3.0f, 1.0f, 0.0f, 0.0f, 3.0f, 3.0f}), + {test::MakeAttribute("pooled_shape", std::vector{2, 2}), + test::MakeAttribute("spatial_scale", 1.0f)}, + ExpectedEPNodeAssignment::All); +} + +// spatial_scale on HTP (QDQ): rois are scaled before binning. +TEST_F(QnnHTPBackendTests, TestMaxRoiPoolQdq_spatial_scale) { + RunQDQMaxRoiPoolOpTest(TestInputDef({1, 1, 4, 4}, false, + {1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f, 8.0f, + 9.0f, 10.0f, 11.0f, 12.0f, 13.0f, 14.0f, 15.0f, 16.0f}), + TestInputDef({1, 5}, true, {0.0f, 0.0f, 0.0f, 6.0f, 6.0f}), + {test::MakeAttribute("pooled_shape", std::vector{2, 2}), + test::MakeAttribute("spatial_scale", 0.5f)}, + ExpectedEPNodeAssignment::All); +} + +// 16-bit quantized output exercises a different requantize/accumulation path through the +// decomposed StridedSlice/ReduceMax/Concat chain than the 8-bit cases above. +TEST_F(QnnHTPBackendTests, TestMaxRoiPoolQdqU16) { + RunQDQMaxRoiPoolOpTest(TestInputDef({1, 1, 4, 4}, false, + {1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f, 8.0f, + 9.0f, 10.0f, 11.0f, 12.0f, 13.0f, 14.0f, 15.0f, 16.0f}), + TestInputDef({1, 5}, true, {0.0f, 0.0f, 0.0f, 3.0f, 3.0f}), + {test::MakeAttribute("pooled_shape", std::vector{2, 2}), + test::MakeAttribute("spatial_scale", 1.0f)}, + ExpectedEPNodeAssignment::All, + /*opset=*/13, + /*use_contrib_qdq=*/true); +} + +// Adaptive binning on HTP (QDQ u16). +TEST_F(QnnHTPBackendTests, TestMaxRoiPoolQdqU16_AdaptiveBins) { + RunQDQMaxRoiPoolOpTest(TestInputDef({1, 2, 4, 4}, false, GetFloatDataInRange(0.0f, 32.0f, 32)), + TestInputDef({1, 5}, true, {0.0f, 0.0f, 0.0f, 3.0f, 3.0f}), + {test::MakeAttribute("pooled_shape", std::vector{3, 3}), + test::MakeAttribute("spatial_scale", 1.0f)}, + ExpectedEPNodeAssignment::All, + /*opset=*/13, + /*use_contrib_qdq=*/true); +} + +// Empty-bin path on HTP (QDQ u16). All-negative data so the empty bins (filled with 0.0) pin the +// output max, forcing a non-zero output zero_point. +TEST_F(QnnHTPBackendTests, TestMaxRoiPoolQdqU16_EmptyBins) { + RunQDQMaxRoiPoolOpTest(TestInputDef({1, 1, 4, 4}, false, + {-1.0f, -2.0f, -3.0f, -4.0f, -5.0f, -6.0f, -7.0f, -8.0f, + -9.0f, -10.0f, -11.0f, -12.0f, -13.0f, -14.0f, -15.0f, -16.0f}), + TestInputDef({1, 5}, true, {0.0f, 0.0f, 3.0f, 1.0f, 6.0f}), + {test::MakeAttribute("pooled_shape", std::vector{2, 2}), + test::MakeAttribute("spatial_scale", 1.0f)}, + ExpectedEPNodeAssignment::All, + /*opset=*/13, + /*use_contrib_qdq=*/true); +} + +// Multi-ROI on HTP (QDQ u16): exercises the final Concat branch. +TEST_F(QnnHTPBackendTests, TestMaxRoiPoolQdqU16_MultiRoi) { + RunQDQMaxRoiPoolOpTest(TestInputDef({1, 2, 4, 4}, false, GetFloatDataInRange(0.0f, 32.0f, 32)), + TestInputDef({3, 5}, true, {0.0f, 0.0f, 0.0f, 3.0f, 3.0f, 0.0f, 1.0f, 1.0f, 3.0f, 3.0f, 0.0f, 0.0f, 0.0f, 1.0f, 1.0f}), + {test::MakeAttribute("pooled_shape", std::vector{2, 2}), + test::MakeAttribute("spatial_scale", 1.0f)}, + ExpectedEPNodeAssignment::All, + /*opset=*/13, + /*use_contrib_qdq=*/true); +} + +// Cross-image sampling on HTP (QDQ u16): ROIs select different images. +TEST_F(QnnHTPBackendTests, TestMaxRoiPoolQdqU16_MultiImage) { + RunQDQMaxRoiPoolOpTest(TestInputDef({2, 1, 4, 4}, false, GetFloatDataInRange(0.0f, 32.0f, 32)), + TestInputDef({2, 5}, true, {0.0f, 0.0f, 0.0f, 3.0f, 3.0f, 1.0f, 0.0f, 0.0f, 3.0f, 3.0f}), + {test::MakeAttribute("pooled_shape", std::vector{2, 2}), + test::MakeAttribute("spatial_scale", 1.0f)}, + ExpectedEPNodeAssignment::All, + /*opset=*/13, + /*use_contrib_qdq=*/true); +} + +// spatial_scale on HTP (QDQ u16): rois are scaled before binning. +TEST_F(QnnHTPBackendTests, TestMaxRoiPoolQdqU16_spatial_scale) { + RunQDQMaxRoiPoolOpTest(TestInputDef({1, 1, 4, 4}, false, + {1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f, 8.0f, + 9.0f, 10.0f, 11.0f, 12.0f, 13.0f, 14.0f, 15.0f, 16.0f}), + TestInputDef({1, 5}, true, {0.0f, 0.0f, 0.0f, 6.0f, 6.0f}), + {test::MakeAttribute("pooled_shape", std::vector{2, 2}), + test::MakeAttribute("spatial_scale", 0.5f)}, + ExpectedEPNodeAssignment::All, + /*opset=*/13, + /*use_contrib_qdq=*/true); +} + +#endif // defined(__aarch64__) || defined(_M_ARM64) || defined(__linux__) + +} // namespace test +} // namespace onnxruntime + +#endif // !defined(ORT_MINIMAL_BUILD)