[CUDA] Fix MaxPool wrong values/indices for dilation>1 with non-zero begin padding - #29638
Conversation
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
Review summary (multi-model review team)Reviewed by a 5-reviewer fan-out (readability / code / critical / deep / integration). The core fix is mathematically correct and well-tested — the phase-preserving advance faithfully reproduces the CPU/ONNX oracle, and the new 1D/2D value+index vectors were independently re-derived and cross-checked against the ONNX reference evaluator (exact match). But there is one blocking issue the fix introduces. 🔴 Blocking — empty-window (no valid tap) now reads out of bounds
When a window has zero in-range taps, the phase advance pushes
The old Reachable with a valid model (passes the only guard Output length is 1; the single window's taps are {-1, 1}, both out of range for height 1 → adjusted Suggested fix — guard the empty window and make the seed safe, mirroring CPU: // initialize instead of reading p_slice[start]-1
T maxval = std::numeric_limits<T>::lowest();
...
if (h_index_max < 0 /* && w_/d_ */) { // no valid tap
p_output[id] = std::numeric_limits<T>::lowest();
if (p_indices) p_indices[...] = -1;
} else { ...existing output/index writes... }This also fixes a fragile pre-existing sentinel ( A 🟡 Minor
✅ Confirmed correct
Note: findings #2 ( |
There was a problem hiding this comment.
Pull request overview
This PR fixes a CUDA-only correctness issue in MaxPool when dilation > 1 and there is non-zero begin padding, where the CUDA custom MaxPool-with-indices kernel could start at the wrong input tap (wrong dilation phase), producing incorrect pooled values and indices (affecting downstream MaxUnpool).
Changes:
- Updates the CUDA
MaxPoolWithIndexKernelto advance negative window starts to the first non-negative phase-correct dilated tap (instead of clamping to 0). - Re-enables CUDA legs for existing dilation+padding MaxPool tests that were previously excluded due to this bug.
- Adds new regression tests that validate both
YandIndicesoutputs for 1D and 2D cases, using CPU as the oracle.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| onnxruntime/core/providers/cuda/nn/max_pool_with_index.cu | Fixes CUDA MaxPool window-start handling for dilated pooling with begin padding to preserve dilation phase. |
| onnxruntime/test/providers/cpu/nn/pool_op_test.cc | Re-enables CUDA coverage for prior dilation+padding tests and adds new value+index regression tests. |
|
@copilot if reviews and comments make sense to you, can you fix them. |
Addressed in ba67e47. Blocking (empty-window OOB read): the phase advance can push a window's start past its end when every dilated tap falls in the begin padding. I now seed Minor:
|
Re-review: update resolves the blocker ✅The revision addresses every finding from the prior review. Verified against the updated diff: 🔴 Blocking OOB — resolved. The Bonus fix: dropping the old Minors — all addressed: empty-window regression test ( No remaining blocking or major concerns from the review team. LGTM pending CI. |
tianleiwu
left a comment
There was a problem hiding this comment.
The core fix looks correct and is well-motivated. The phase-preserving advance start += ((-start + dilation - 1) / dilation) * dilation correctly snaps a negative window start up to the first non-negative dilated tap while preserving phase, and computing *_end from the raw start keeps the loop bound right. Replacing the p_slice[start] - (T)1 seed read with NumericLimits<T>::Lowest() plus the explicit empty-window early-return also closes the previous out-of-range dereference and the uint8 - 1 wrap, and dropping static_cast<float> fixes latent precision truncation for the double instantiation. Good test coverage too.
One residual correctness issue remains (inline): with the Lowest() seed and the strict > comparison, a valid window whose maximum equals the element type's minimum (uint8_t 0, int8_t -128) is misclassified as empty and emits Indices = -1, which corrupts downstream MaxUnpool. Since the kernel is instantiated for int8_t/uint8_t and all-zero uint8 windows are common in quantized data, this path is reachable. See the inline comment for the suggested one-line fix. A minor follow-up: the new tests are all float; an int8_t/uint8_t Indices case would lock this in.
For a valid (non-empty) pooling window whose maximum equals the type's
lowest representable value (e.g. all-zero uint8, all -128 int8), the
argmax reduction seeded max = NumericLimits<T>::Lowest() and used a
strict '>' comparison. The comparison never fired, so the recorded
index stayed -1 and was emitted as the Indices output for a non-empty
window, corrupting downstream MaxUnpool.
Record the first valid tap unconditionally via a 'index < 0' sentinel
(the reduction only visits valid taps), matching ONNX argmax
first-occurrence semantics. Empty windows are unaffected (loop never
runs, index stays -1). Applied symmetrically to the CUDA kernel and the
CPU MaxPool1D/2D/3D reference used as the test oracle.
Add int8/uint8 all-lowest-value regression tests covering both storage
orders; they fail (Indices {-1,-1} vs {0,2}) without the fix on both
CPU and CUDA EPs.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4bc292c0-ac9c-43d7-8cd4-7445efd12779
OpenVINOExecutionProvider does not tie-break argmax indices the same way as the CPU oracle when every value in a pooling window equals the type's lowest value (all -128 for int8 / all 0 for uint8), causing the new regression test to fail on the OpenVINO CI leg. Other MaxPool With-Index tests in this file already exclude kOpenVINOExecutionProvider for the same reason (e.g. MaxPool_8_With_Index); align the new LowestValue test helpers with that existing pattern.
|
cc @tianleiwu |
tianleiwu
left a comment
There was a problem hiding this comment.
The phase-preserving window-start adjustment is correct, and the follow-up changes safely handle empty windows and valid windows whose maximum equals the element type's lowest value. CPU and CUDA behavior are aligned for first-tap selection, the regression coverage exercises values and indices across the relevant edge cases, and all current checks pass.
Description
The CUDA MaxPool custom index kernel (
max_pool_with_index.cu) clamped a negative window start to0via_Max<int64_t>(start, 0), discarding the dilation phase. For any axis with begin-pad > 0 and dilation > 1, the loop began at input index0(padding) instead of the first valid dilated tap, silently producing wrongYvalues and wrongIndices(also corrupting downstreamMaxUnpool). CPU is unaffected.Repro (
k=2, s=1, pads=[1,1], dilations=[2],x=[1,9,2,8,3,7,4,6,5]): CUDA returnedY[0]=1/idx[0]=0vs CPU9/1.Changes
onnxruntime/core/providers/cuda/nn/max_pool_with_index.cu): replace eachX_start = _Max<int64_t>(X_start, 0)with a phase-preserving advance to the first non-negative dilated tap.*_endis computed from the original start, so it stays correct, and themaxvalseed reads a valid in-bounds tap.d_start,w_start,h_start, so all of 1D/2D/3D are covered.onnxruntime/test/providers/cpu/nn/pool_op_test.cc): un-excluded the CUDA legs fromMaxPool_10_DilationPadding_1d/_2d(previously excluded due to this bug), and addedMaxPool_DilationPadding_1d_Indices/_2d_Indicescovering both value and index outputs with CPU as the oracle.Motivation and Context
Dilated MaxPool on CUDA always lands on this custom-kernel path (
MaxPool<8>selects it wheneverI != nullptr || !default_dilations, anddilation > 1forces!default_dilations), so the wrong-result was unavoidable for dilated + padded MaxPool. Found as an adjacent finding during the AvgPoolceil_mode/ asymmetric-pad work; the CUDA AvgPool kernel does not share this bug.