Fix CPU AvgPool ceil_mode + count_include_pad wrong average (opset 7-18 / MLAS path)#29629
Conversation
…#183528) Pool<float, AveragePool>::Compute routed float AvgPool opset 7-18 to MLAS, whose include-pad divisor is the full kernel size and cannot drop the ceil_mode phantom tail cells, producing a wrong average. Extract the v19 reference average-pool loop into a shared free function ComputeAveragePoolReference<T> (reading strides from PoolAttributes, p=0) and make AveragePoolV19<T>::Compute a thin wrapper over it. Add a guard in Pool<float, AveragePool>::Compute that routes only the buggy combo (ceil_mode==1 && count_include_pad && !global_pooling) to the reference loop; all other cases keep the MLAS fast path. Zero MLAS edits. Tests: opset-18 clone of AveragePool_19_ceil_count_include_pad_1d (the direct #183528 repro) plus 2D/3D include-pad cases and a count_include_pad=0 no-regression case, with GPU/other EPs excluded so CI stays green. Agent-signed-off: Developer (118f158e) [claude-opus-4.8 via copilot] Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add ORT_ENFORCE(!pool_attrs.global_pooling, ...) at the top of ComputeAveragePoolReference to self-enforce the non-global precondition (global pooling leaves strides/dilations unpopulated), protecting a future third caller from OOB-reading empty strides[]. - Expand the Pool<float,AveragePool>::Compute guard comment to explain why !global_pooling is part of the condition. - Drop the internal 'INV-3' design-doc label from the exclude-pad test comment; keep the plain-English explanation. No functional change; full PoolTest suite still green (55 passed, 2 DML-skipped). Agent-signed-off: Developer (118f158e) [claude-opus-4.8 via copilot] Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes incorrect CPU EP AveragePool results for opset 7–18 when ceil_mode=1 and count_include_pad=1 on the float/MLAS path by selectively falling back to the already-correct reference loop used by opset ≥19, and adds CPU-focused regression tests for the scenario.
Changes:
- Extracts the opset-19 reference average-pool implementation into a shared
ComputeAveragePoolReference<T>helper and makesAveragePoolV19<T>::Computedelegate to it. - Routes only the problematic opset 7–18 float case (
ceil_mode=1 && count_include_pad && !global_pooling) away from MLAS to the reference loop. - Adds opset-18 regression tests for 1D/2D/3D
ceil_mode=1 && count_include_pad=1, plus a no-regression case forcount_include_pad=0.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| onnxruntime/core/providers/cpu/nn/pool.cc | Adds shared reference AvgPool loop and uses it as a targeted fallback for opset 7–18 float to avoid MLAS’s incorrect divisor under ceil-mode tail windows. |
| onnxruntime/test/providers/cpu/nn/pool_op_test.cc | Adds opset-18 CPU regression coverage for ceil+include-pad (1D/2D/3D) and a no-regression test for exclude-pad. |
Review summaryThe core fix is correct and well-tested: the extracted However, the fix is incomplete — two other CPU float paths reintroduce or bypass the same behavior. 🔴 Major1. The NCHWc optimizer silently defeats this fix. 2. FP16 CPU path remains broken. 3. The diverted path drops const size_t pooling_dims = x_shape.NumDimensions() - 2;
ORT_RETURN_IF_NOT(pooling_dims <= 3, "Unsupported pooling size.");
ORT_RETURN_IF_NOT(pooling_dims == pool_attrs.kernel_shape.size(),
"kernel_shape num_dims is not compatible with X num_dims.");(Lower real-world likelihood since ONNX shape inference may also reject rank mismatch, but it's a defensive regression worth restoring.) 🟡 Minor / Nit
Cross-EP noteThe tests correctly exclude CUDA/TRT/ACL/OpenVINO/DML — those EPs use vendor full-kernel divisors and now diverge from the corrected CPU oracle. Follow-up, not this PR's scope. Reviewed by a multi-model review team (readability / correctness / adversarial / spec-adherence+numerical / cross-module integration). The four new expected-value arrays were independently re-derived from the functor algorithm and confirmed exact. |
The NCHWc graph transform rewrites AveragePool to NchwcAveragePool, which routes to MlasAveragePoolingIncludePad. That MLAS path divides every window by the full kernel size and cannot drop the ceil_mode phantom tail cells, so optimized x86 graphs silently bypassed the opset 7-18 float divisor fix (PR microsoft#29629) and produced a too-small trailing average. Bail out of the conversion in TransformPool for AveragePool nodes with ceil_mode==1 && count_include_pad==1, leaving them on the fixed ONNX CPU kernel. All other AveragePool conversions (and MaxPool / global pooling) are untouched, so there is zero perf impact on the common case. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…test EP exclusion M3: Pool<float, AveragePool>::Compute routes into ComputeAveragePoolReference before PoolBase::Compute runs its rank checks, so a rank-mismatched model would reach SetOutputSize/InferOutputSize and read out of bounds. Mirror the two PoolBase rank checks (pooling_dims <= 3 and pooling_dims == kernel_shape rank) into the reference path before it computes. N1: Remove the unused AveragePoolV19::p_ member. AveragePool has no 'p' attribute and the average functors never read it, so the member was left uninitialized. (LpPoolV18::p_ is unrelated and retained.) Copilot#2-5: Add kCudaNHWCExecutionProvider to the exclusion list of the four new float ceil_mode tests, matching every sibling CPU-reference test (the 2D cases can flap on the cuDNN-NHWC path). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The fp16 AveragePool path (PoolFp16, ARM64) uses MlasNhwcAvgPool, whose divisor at mlas/lib/pooling_fp16.cpp is the uniform full kernel size and cannot drop the ceil_mode phantom tail cells -- the same bug the float path had. This gave a different (too-small) result than the fixed float kernel for the same model, which is a dtype-consistency problem. Route only the !is_max_pool_ && ceil_mode==1 && count_include_pad && !global_pooling combo to a new self-contained ComputeAveragePoolFp16Reference helper: an N-D loop (NCHW + channels-last, 1D/2D/3D) that clamps each window end to input+pad_tail (the fix), accumulates taps in float, and rounds the result back to fp16. Every other case keeps the fast MLAS im2col path. Zero MLAS edits, zero float-path edits. Adds fp16 parity tests: a 1D and 2D ceil+count_include_pad case (expected = float reference rounded to fp16) plus count_exclude_pad and floor-mode no-regression controls. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ency + fp16 comments
M1 Minor: the NCHWc bail-out guard tested count_include_pad == 1, but PoolBase
and the float/fp16 kernels enable include-pad on any nonzero value. An
out-of-spec count_include_pad=2 && ceil_mode=1 model would escape the bail-out,
convert to NchwcAveragePool, and silently hit the buggy full-kernel divisor in
the optimized graph. Gate on count_include_pad != 0 to match the kernels.
Readability: reconcile ComputeAveragePoolFp16Reference with its float sibling
ComputeAveragePoolReference (why it rolls its own float-accumulate odometer
instead of reusing the AveragePool{1,2,3}DTask functors: no fp16 functor exists
and MLFloat16 has no arithmetic operators). Document the odometer underflow
sentinel and the ARM64-only compile-out in the fp16 test file header.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Review feedback folded in — head now
|
…d tests The four opset-18 AveragePool ceil_mode + count_include_pad parity tests validate the CPU-reference clamped-divisor fix (PyTorch #183528). Several EPs do not yet implement that divisor and return the pre-fix full-kernel-size average, so they fail the tests on their CI legs (WebGPU tripped microsoft#29629; DNNL/CoreML/QNN tripped the sibling microsoft#29631). Factor the shared exclusion set into a single named constant kPoolingEpsExcludedFromCeilCountIncludePadTests and reuse it across all four tests to prevent scoping drift, with one dual-rationale WHY-comment. The set covers the EPs lacking the impl (kTensorrt, kNvTensorRTRTX, kAcl, kOpenVINO, kDml, kWebGpu, kDnnl, kCoreML, kQnn) plus kCuda/kCudaNHWC (excluded as CPU-reference gate). Test-only; zero kernel logic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
CI test-scoping fold (WebGPU red leg + defensive EP completeness) Pushed a test-only follow-up (head
Note: a deny-by-default "CPU-only" structuring of these gate tests is a possible future |
…on (#29631) ### Summary Fixes wrong `AveragePool` output on the **CUDA** EP whenever cuDNN's pooling descriptor cannot represent the requested pooling — i.e. **asymmetric padding** or **dilation > 1**. This is the CUDA-EP counterpart of the CPU fix in #29629 and, together with it, the JSEP shape fix in #29627, closes out the CUDA leg of pytorch/pytorch#183528. ### Root cause `CudnnPoolingDescriptor::Set` copies only the **begin** pads (`pads[0..rank)`) into the cuDNN pooling descriptor, which stores a *single symmetric pad value per axis* and applies it to both sides. The ONNX **end** pads (`pads[rank..2*rank)`) are silently dropped. As a result, **all** asymmetric-pad AveragePool on CUDA is wrong: - explicit asymmetric `pads`, - `auto_pad = SAME_UPPER` / `SAME_LOWER` (which produce naturally asymmetric pads), - `ceil_mode = 1` boundary windows. Separately, the cuDNN pooling descriptor has **no dilation parameter at all**, so any `dilations > 1` is also silently ignored — even with symmetric pads. Example divergence from the CPU reference (QA probe): 1D `pad(0,3)`, k7, s3, `ceil_mode=1`, `count_include_pad=1` gave CUDA `[4, 6.5, 8]` vs. correct `[4, 5.571, 4]`; 2D `pad(0,0,3,3)` gave `71.0` vs. correct `17.75`. ### Fix Add a custom CUDA average-pool kernel (`avg_pool_impl.cu` / `.h`, modeled on `max_pool_with_index.cu`) that honors **per-side pads** and **dilation**, and computes the `count_include_pad` divisor exactly like the CPU v19 reference functor (`AveragePool{1,2,3}DTask`): - include-pad divisor clamps the window end to `input + pad_tail` (drops the ceil-mode phantom cells), dividing by `∏ (1 + (end - start - 1)/dilation)`; - exclude-pad divisor counts only in-bounds cells. In `Pool<T, AveragePool, Layout>::ComputeInternal`, a cheap dispatch guard routes to the custom kernel **only** when the pooling is non-global **and** (`asymmetric pads` **or** `!default_dilations`). Every symmetric, non-dilated case — the overwhelmingly common path, including **all** `GlobalAveragePool` — stays on the existing cuDNN fast path, so there is **zero perf regression** on the common path. `GlobalAveragePool` is excluded explicitly because `PoolAttributes` leaves its `kernel_shape`/`strides`/`dilations` unpopulated. `MaxPool` is unaffected (it ignores pad cells; `MaxPool<8>` already routes dilation to its own custom kernel via the same `!default_dilations` check we mirror here). Covers fp32, fp64, fp16, and bf16 (fp16/bf16 accumulate in float). ### Tests Added CUDA-**un-excluded** parity tests in `pool_op_test.cc` — the CUDA leg actually runs and must match the CPU reference oracle: - asymmetric tail-pad 1D/2D, include- and exclude-pad; - `auto_pad=SAME_UPPER` and `SAME_LOWER` (naturally asymmetric); - **symmetric-pad + dilation>1** (wrong on cuDNN, correct via the kernel — locks in the dilation guard); - a symmetric-pad regression case (must stay on cuDNN and remain correct); - fp16; - a `MaxPool` asymmetric-pad case confirming MaxPool is unaffected. The `ceil_mode + count_include_pad` cases use opset 19 so the CPU leg runs the already-correct v19 reference functor and validates the CUDA kernel independently of the separate opset-7..18 CPU MLAS fix (#29629); CUDA routing is opset-independent. Full `PoolTest` suite passes on an A100 (53 passed / 2 DML-skipped / 0 failures). ### Related - CPU (a): #29629 — opset 7-18 MLAS `ceil_mode + count_include_pad` divisor fix. - JSEP (c): #29627 — JSEP pooling output shape honoring `ceil_mode`. - pytorch/pytorch#183528. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…pool # Conflicts: # onnxruntime/test/providers/cpu/nn/pool_op_test.cc
Per xadupre's review on microsoft#29629: instead of the standalone N-D odometer in ComputeAveragePoolFp16Reference, extract the core float pooling computation from ComputeAveragePoolReference into a shared, non-template helper ComputeAveragePoolReferenceCompute (operating on plain NCHW float buffers + PoolAttributes). The float production path (AveragePoolV19 / Pool<float, AveragePool> opset 7-18 fallback) is a pure behavior-preserving extraction and still dispatches the same AveragePool{1,2,3}DTask functors. The fp16 fallback now casts X MLFloat16->float (transposing NHWC->NCHW when channels_last), calls the shared float core, and casts float->MLFloat16 with a single round at store. Accumulation stays in float, so results are numerically equivalent to the old accumulate-in-float + round-once loop and remain in lockstep with the float path's clamped-divisor (window-end clamp to input+pad tail) semantics. Deletes the redundant ~110-line odometer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
tianleiwu
left a comment
There was a problem hiding this comment.
Review: AvgPool ceil_mode + count_include_pad fix (opset 7-18 / MLAS path)
Verdict: COMMENT — the fix is correct and thoroughly tested. Just a couple of minor maintainability nits below.
Correctness (verified against the code):
- The root cause is accurate: for
ceil_mode=1 && count_include_pad, the MLAS include-pad path divides by the full kernel size, counting the ceil-mode phantom tail cells. The reference functors (AveragePool{1,2,3}DTask) clamphend/wendtoinput + tail_pad, so the include-pad divisor(1 + (hend - hstart - 1)/dilation)excludes phantom cells. I hand-verified the 1D test (k=7 s=3 pad=3over{1,2,9}): out0 = 12/7, out1 = 12/6 = 2. ✔ - Scoping is tight and justified: the discrepancy only arises with
ceil_mode=1(without it,hendnever exceedsinput+tail_pad, so clamped == full divisor), andglobal_poolingis correctly excluded (no ceil/pads, and itsstrides[]/dilations[]aren't populated). ✔ PoolAttributesalways resizesstrides/dilationstokernel_shape.size()for non-global pooling, so the opset 7-18Pool<float, AveragePool>fallback safely indexesstrides[0..2]/dilations[0..2]. ✔- The removed dead
p_member ofAveragePoolV19was never initialized — safe removal. ✔
Good decisions:
- Single-source-of-truth extraction into
ComputeAveragePoolReferenceCompute(float accumulation) shared by the float and fp16 paths keeps the two dtypes in lockstep and eliminates the duplicated N-D loop, addressing the earlier factorization request. - The fp16 fallback correctly accumulates in float and rounds once at the
float -> MLFloat16store, and the NHWC<->NCHW transpose indexing is correct. - Rank checks in
ComputeAveragePoolReferencemirrorPoolBase::Compute, and the fp16Computealready validateskernel_shape.size() == spatial_dimsbefore reaching the guard, so neither path can readx_shape[3]/[4]out of bounds — the earlier validation concern is fully covered. - Strong no-regression coverage:
count_include_pad=0control tests confirm the MLAS fast path is untouched, and the NCHWc transformer bail-out (with Level2-vs-Level3 output comparison) prevents the optimizer from silently reintroducing the buggy divisor.
Minor suggestions (non-blocking):
- The two
kPoolingEps...sets carry long cross-referencing comments with hardcoded approximate line numbers (~L21,~L1150). These drift as the file changes. Prefer referencing the sets by symbol name only and dropping the~Lxxxanchors (see inline note). ComputeAveragePoolReference(the template wrapper inpool.cc) has external linkage but is only instantiated in this TU. Consider giving it internal linkage (anonymous namespace /static) to avoid unintended ODR exposure; onlyComputeAveragePoolReferenceComputeneeds to be exported viapool.h.- The fp16 fallback runs the cast/transpose loops single-threaded and allocates two full-size float scratch buffers. Acceptable for this rare edge-case combo, but worth a note if profiling ever shows it on a hot model.
Pull request was closed
Summary
Fixes wrong
AveragePooloutput on the CPU EP whenceil_mode=1andcount_include_pad=1for opset 7–18 (the float MLAS path). This is the direct fix for the CPU-EP repro in pytorch/pytorch#183528.Root cause
Pool<float, AveragePool>::Computeroutes floatAveragePoolopset 7–18 to MLAS. Withcount_include_pad=1, MLAS divides every window by the full kernel size. Underceil_mode=1the output grid gains a trailing window whose extent runs pastinput + pad_tail(phantom cells). MLAS's full-kernel divisor counts those phantom cells, producing an average that is too small on the boundary windows.The opset-19 reference functor (
AveragePool{1,2,3}DTaskinpool_functors.h) already handles this correctly: it clamps the window end toinput + pad_tailand divides by1 + (end - start - 1)/dilation, excluding the phantom cells.Fix
Dispatch-fallback, zero MLAS edits:
ComputeAveragePoolReference<T>(context, pool_attrs, tp)(reads strides fromPoolAttributes,p=0), and makeAveragePoolV19<T>::Computea thin wrapper over it — one canonical loop, no copy-paste drift.Pool<float, AveragePool>::Compute, route only the buggy comboceil_mode == 1 && count_include_pad && !global_poolingto the reference loop. Every other case (ceil_mode=0,count_include_pad=0/exclude-pad, global pooling, all MaxPool) keeps the fast MLAS path unchanged.global_poolingis excluded because it has no ceil/pads (already correct) and leavesstrides[]unpopulated;ComputeAveragePoolReferenceORT_ENFORCEs that precondition.Perf
Zero impact on the common path. The reference loop runs only for
ceil_mode=1 && count_include_pad=1(rare); the guard is a cheap early branch.Relationship to #16752
ORT PR #16752 fixed this behavior for opset ≥ 19 only (via the new v19 reference functor). Opset 7–18 float still went through MLAS and remained wrong. This PR closes that gap by reusing the same already-correct functor for the 7–18 dispatch fallback.
Tests
Added to
pool_op_test.cc(CPU-focused; GPU/other EPs excluded so CI stays green):AveragePool_18_ceil_count_include_pad_1d— opset-18 clone of the existing v19 test; the direct #183528 repro.AveragePool_18_ceil_count_include_pad_2d— thearange(1,17).reshape(1,1,4,4), k3/s2/pad1 case →[1.556, 3.333, 2.0, 6.333, 11.0, 6.0, 4.5, 7.5, 4.0].AveragePool_18_ceil_count_include_pad_3d— exercises the 3D functor path.AveragePool_18_ceil_count_exclude_pad_2d—count_include_pad=0no-regression guard (stays on MLAS).Expected values are derived from the CPU v19 reference (equivalently, hand-computed per the ONNX spec). Full
PoolTestsuite passes locally.