Fix CPU AvgPool ceil_mode + count_include_pad wrong average (opset 7-18 / MLAS path)#29629
Fix CPU AvgPool ceil_mode + count_include_pad wrong average (opset 7-18 / MLAS path)#29629titaiwangms wants to merge 6 commits into
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. |
| const auto* X = context->Input<Tensor>(0); | ||
| const TensorShape& x_shape = X->Shape(); | ||
| ORT_RETURN_IF_NOT(x_shape.NumDimensions() >= 3, "Input dimension cannot be less than 3."); | ||
|
|
||
| auto pads = pool_attrs.pads; | ||
| auto kernel_shape = pool_attrs.kernel_shape; | ||
|
|
||
| auto output_dims = pool_attrs.SetOutputSize(x_shape, x_shape[1], &pads); | ||
| Tensor* Y = context->Output(0, output_dims); |
| test.Run(OpTester::ExpectResult::kExpectSuccess, "", | ||
| {kCudaExecutionProvider, kTensorrtExecutionProvider, kAclExecutionProvider, | ||
| kOpenVINOExecutionProvider, kDmlExecutionProvider}); |
| test.Run(OpTester::ExpectResult::kExpectSuccess, "", | ||
| {kCudaExecutionProvider, kTensorrtExecutionProvider, kAclExecutionProvider, | ||
| kOpenVINOExecutionProvider, kDmlExecutionProvider}); |
| test.Run(OpTester::ExpectResult::kExpectSuccess, "", | ||
| {kCudaExecutionProvider, kTensorrtExecutionProvider, kAclExecutionProvider, | ||
| kOpenVINOExecutionProvider, kDmlExecutionProvider}); |
| test.Run(OpTester::ExpectResult::kExpectSuccess, "", | ||
| {kCudaExecutionProvider, kTensorrtExecutionProvider, kAclExecutionProvider, | ||
| kOpenVINOExecutionProvider, kDmlExecutionProvider}); |
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
|
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.