Fix JSEP pooling output shape to honor ceil_mode#29627
Conversation
js/web PoolConvUtil.computePoolOutputShape was floor-only and silently ignored ceil_mode, allocating output tensors one element too small along each spatial axis for ceil_mode=1. Thread a ceilMode flag through computePoolOutputShape -> computeShapeHelper -> adjustPadAndReturnShape in both js/web/lib/wasm/jsep/util.ts and the legacy js/web/lib/onnxjs/util.ts, routing all branches through a new computeOutputSize helper that replicates the C++ pool_attributes.h ComputeOutputSize logic exactly, including the shrink-last-window-if-it-starts-in- padding rule. Pass attributes.ceilMode from pool.ts. Floor behavior (ceilMode=0, the default) is unchanged, so Conv and auto_pad adjustment are unaffected. Adds js/web/test/unittests/pool-output-shape.ts asserting output shapes for both implementations against the CPU reference ground-truth cases (maxpool_2d_ceil, AvgPool 1D/2D/3D ceil, dilation, VALID, a shrink-rule discriminator, and a floor-mode no-regression case). Agent-signed-off: Developer (284885ab) [claude-opus-4.8 via copilot] Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Core of the ORT-Web pooling ceil_mode shape fix: add the ceilMode parameter and the computeOutputSize helper (replicating C++ pool_attributes.h ComputeOutputSize, including the shrink-last-window rule) to js/web/lib/wasm/jsep/util.ts and js/web/lib/onnxjs/util.ts, and pass attributes.ceilMode from pool.ts. Floor-mode behavior is unchanged. Paired with the pool-output-shape unit tests. Agent-signed-off: Developer (284885ab) [claude-opus-4.8 via copilot] Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Clarify at the ceil_mode throw sites in parseAveragePool/MaxPoolAttributes (and in the pool-output-shape unit test header) that the output-shape math now honors ceil_mode but end-to-end execution stays gated because the WebGPU kernel does not yet implement ceil_mode trailing-padding handling. Removing the throws + adding kernel support is the tracked follow-up; the shape path is exercised directly by the unit tests until then. Agent-signed-off: Developer (284885ab) [claude-opus-4.8 via copilot] Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add SAME_UPPER and SAME_LOWER + ceil_mode=1 shape test cases (the auto_pad branch was modified but previously untested); reviewer-verified in=5, stride=2, kernel=3 -> out 3 (M1). - Use `ceilMode === 1` instead of truthy `ceilMode` in computeOutputSize for exact C++ `ceil_mode == 1` fidelity, so out-of-spec values fall through to floor (M2). - Reword the computeOutputSize doc comment: it produces identical RESULTS to the C++ reference (the JS signature takes a pre-computed numerator, not the raw attributes) (M3). - Comment the shrink guard explaining why inSize/padHead are passed again (M4). - Add a 'Keep in sync with the onnxjs/jsep copy.' breadcrumb in both copies (M5). - Note that the onnxjs copy's ceilMode path exists for test parity only; the onnxjs WebGL caller intentionally does not pass ceilMode (legacy, still throws) (M6). Agent-signed-off: Developer (284885ab) [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 ORT-Web pooling output-shape computation to correctly honor ceil_mode (including the ONNX “shrink the last window if it starts entirely in trailing padding” rule) in both the JSEP and legacy onnxjs copies of PoolConvUtil.computePoolOutputShape. It also adds unit tests that exercise the pure shape helper directly (since end-to-end WebGPU pooling still throws on ceil_mode != 0 pending kernel support).
Changes:
- Thread
ceilModethroughcomputePoolOutputShape → computeShapeHelper → adjustPadAndReturnShapeand centralize per-dimension sizing in acomputeOutputSize()helper. - Pass
attributes.ceilModeinto the WebGPU pooling output-shape computation. - Add unit tests covering ceil-mode shapes (including the shrink rule) for both implementations (jsep + onnxjs).
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| js/web/lib/wasm/jsep/util.ts | Adds ceilMode plumbing and computeOutputSize() for pooling/conv output shape calculation. |
| js/web/lib/onnxjs/util.ts | Mirrors the same ceilMode and computeOutputSize() logic for the legacy onnxjs copy. |
| js/web/lib/wasm/jsep/webgpu/ops/pool.ts | Passes ceilMode into shape computation and expands comments around current ceil_mode execution guards. |
| js/web/test/unittests/pool-output-shape.ts | New unit tests validating ceil-mode output shapes (and shrink rule) against CPU-reference expectations. |
| js/web/test/unittests/index.ts | Registers the new unit test module. |
Review summarySolid, well-tested, and correctly scoped (shape-only, execution still gated). The ceil-mode math matches the C++ reference ( 🔴 Major (1)
const legacyTargetSize = (inSize + stride - 1) / stride; // float; C++ uses int64 divisionReproduced with
Floor mode masks this (the extra pad is absorbed by Fix: use integer division to match C++, in both files: const legacyTargetSize = Math.floor((inSize + stride - 1) / stride);and add a non-divisible regression case, e.g. 🟡 Minor (2)
⚪ Nits
Follow-up (out of scope, already tracked)End-to-end Reviewed by a multi-model review team (readability / correctness / adversarial / spec-adherence / cross-module integration). |
…ity pass MAJOR: In both js/web/lib/wasm/jsep/util.ts and js/web/lib/onnxjs/util.ts the SAME_UPPER/SAME_LOWER legacyTargetSize used JS float division while C++ pool_attributes.h uses integer division. Wrap it in Math.floor so the auto_pad pad computation matches C++ ComputeSizePadDilations exactly. Pre-existing latent bug the new ceil_mode thread exposes: in=2,stride=2,kernel=3,SAME_UPPER,ceil=1 yielded [.,.,2] instead of the correct [.,.,1]. TEST: add non-divisible SAME_UPPER + SAME_LOWER + ceil_mode regression cases (input spatial [2] -> [1]) that fail without the Math.floor fix and pass with it. CLARITY (doc-only): reword the retained ceil_mode throw messages in pool.ts to point at KERNEL/EXECUTION (padding/divisor) rather than shape computation, and qualify the ceil_mode TODO banner to note the output shape is supported while kernel execution is pending. Agent-signed-off: Developer (284885ab) [claude-opus-4.8 via copilot] Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…icrosoft#29627) This fixup fixes SAME_UPPER/SAME_LOWER pad distribution for ALL ceil modes, not only ceil_mode=1: the earlier float-division legacyTargetSize mis-rounded the auto_pad pad split on the ceil_mode=0 (floor) path too, producing wrong (and sometimes fractional) pads even though the output SHAPE was unchanged there. The Math.floor(legacyTargetSize) fix aligns JSEP/onnxjs with the CPU/CUDA EP behavior; nothing previously-correct regresses (SAME_* ceil_mode=0 output shape is unchanged — only the pad split is corrected). - Readability: add a one-line WHY breadcrumb above the Math.floor line in both js/web/lib/wasm/jsep/util.ts and js/web/lib/onnxjs/util.ts. - Coverage: add ceilMode=0 SAME_UPPER and SAME_LOWER non-divisible regression cases (in=2, stride=2, kernel=3) that assert the corrected pad out-param distribution (SAME_UPPER -> [0,1], SAME_LOWER -> [1,0]), not just the output shape. Extended the test harness to capture and assert the mutated pads. These fail against the pre-fix float division ([1,1]) and pass with Math.floor. Agent-signed-off: Developer (284885ab) [claude-opus-4.8 via copilot] Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Review fold-in (post-review fixups pushed:
|
hariharans29
left a comment
There was a problem hiding this comment.
Review: PR #29627 — Fix JSEP pooling output shape to honor ceil_mode
Approve. Well-scoped correctness fix. PoolConvUtil.computePoolOutputShape in both js/web/lib/wasm/jsep/util.ts (JSEP) and js/web/lib/onnxjs/util.ts (legacy onnxjs) previously ignored ceil_mode, so under ceil_mode=1 the output tensor was allocated one element too small on affected spatial axes. This PR threads a ceilMode parameter through computePoolOutputShape → computeShapeHelper → adjustPadAndReturnShape and centralizes per-dimension sizing in a new computeOutputSize() helper whose body is a line-for-line port of the C++ PoolAttributes::ComputeOutputSize. Scope stays SHAPE-only — the throw guards in parseAveragePoolAttributes/parseMaxPoolAttributes remain, with reworded messages that make the split (shape vs kernel-execution) explicit. CI is green at 86/86.
Correctness — matches the C++ reference
Cross-checked against onnxruntime/core/providers/cpu/nn/pool_attributes.h ComputeOutputSize and ComputeSizePadDilations:
- Numerator formula. JS
numerator = inSize + padHead + padTail - dkernelwithdkernel = dilation * (kernel - 1) + 1→ same as C++int64_t numerator = in_size + pad_head + pad_tail - dilation * (kernel - 1) - 1. - Floor branch. JS
Math.floor(numerator / stride) + 1matches C++numerator / stride + 1on the reachable (non-negative-numerator) domain. TheMath.floorvsMath.truncdivergence on negative numerators is unreachable for valid models (kernel <= inSize + padsis a model-validity precondition), and Copilot C1/C2 was correctly declined for that reason. - Ceil branch. JS
Math.ceil(numerator / stride) + 1matches C++static_cast<int64_t>(std::ceil(static_cast<float>(numerator) / stride)) + 1. The float32-vs-double divergence only shows up fornumerator > 2^24, unreachable for real spatial dims. - Shrink rule. JS
if ((outSize - 1) * stride >= inSize + padHead) outSize -= 1is byte-identical to C++if ((out_size - 1) * stride >= in_size + pad_head) --out_size(both signed) and correctly cites the ONNX rationale (onnx/onnx#5741). - SAME_UPPER/SAME_LOWER integer division. The external-team Major (
legacyTargetSizewas using float division) is now fixed withMath.floor((inSize + stride - 1) / stride). Nice catch — this was a pre-existing latent bug on the floor path too: the output shape happened to be right becauseMath.floor(...+ 1)absorbed the extra half-unit, but the pad distribution written back to thepadsout-param was wrong (in=2, stride=2, kernel=3, SAME_UPPERused to give[1,1]instead of the correct[0,1]). Downstream consumers of thatpadsarray (e.g. anything that actually places the sliding window relative topad_head) were silently getting the wrong window offset. The PR adds regression tests that assert onexpectedPadsfor bothSAME_UPPER→[0,1]andSAME_LOWER→[1,0]atceil_mode=0, which is exactly the right guard.
I hand-verified each new test case against the C++ formula. All expected shapes match. Spot check of the shrink-rule discriminator: inSize=3, stride=2, kernel=2, pads=[1,1], ceil_mode=1:
- numerator = 3 + 1 + 1 − 1 − 1 = 3,
ceil(3/2) + 1 = 3, shrink test(3−1)*2 = 4 >= 3+1 = 4→ true → shrink → 2. ✓ (matchesexpectedShape: [1,1,2]).
The shrink test is exercised exactly at equality (4 >= 4), which is the boundary that would be the most likely to slip in a signed/unsigned mismatch. Good.
Scoping
- Backwards-compatible defaults.
ceilMode = 0on all touched signatures. Conv, GlobalPool (viaglobalPoolAttributeswhich stays atceilMode: 0), the onnxjs WebGL caller (which intentionally does not threadceilMode), and any pre-existing pool callers all continue to get the pre-PR floor semantics. No legitimate caller regresses. - Execution still gated. The
throws inparseAveragePoolAttributesandparseMaxPoolAttributesremain and are reworded to reflect the split ("output shape is computed, but kernel execution … is not yet implemented"). The pool.ts banner TODO is updated to match. This is the right containment — the WebGPU kernel still needs trailing-padding handling (and for AveragePool,count_include_paddivisor handling) beforeceil_modeend-to-end works, and shipping a correct shape helper while gating execution keeps the fix reviewable and unblocks whatever caller was already tripping the shape assertion. - Duplicated
computeOutputSizein onnxjs + jsep. The two implementations are line-for-line identical and the test matrix runs both against the same expected values, so silent drift would fail CI. The "keep in sync" comment is not compile-time-enforced, but the test coverage is the effective enforcement. Acceptable.
Test quality
- Sixteen shape cases against the C++ oracle, each with a comment tying it back to a specific CPU test case or C++ formula.
test_maxpool_2d_ceil,AveragePool_10_ceil1_2d, the AvgPool 1D/2D/3D ceil family frompool_op_test.cc, dilation, VALID / SAME_UPPER / SAME_LOWER auto_pad, shrink-rule boundary, floor-path no-regression, and the SAME_* integer-division regression guards. That's an honest coverage matrix. - Every case runs against both
PoolConvUtilcopies (jsep + onnxjs), so cross-copy drift is caught. runPoolConvUtilslices strides/dilations/kernelShape/pads before callingcomputePoolOutputShape— correctly defends against mutation of shared inputs across the two implementations (adjustPadAndReturnShapewrites back topadsin the auto_pad branches).- The
expectedPadsassertion on the twoceil_mode=0SAME_* non-divisible cases is exactly the right thing to catch the latent SAME_* pad-distribution bug — asserting only shape would have missed it because the pre-fix output shape was accidentally correct. defaults to floor when ceilMode is omittedguards the default parameter contract.
Non-blocking observations
- Cross-copy parity test. Team Nit A (a randomized cross-implementation test) would harden the "keep in sync" comment into CI enforcement. The hand-picked matrix is already good; a small
fast-check-style randomized case would make silent divergence structurally impossible. Fine to leave for a follow-up. computeOutputSizeparam shape. Team Nit B (numeratorparam feels indirect; callers still separately passinSizeandpadHeadfor the shrink test). Alternative would becomputeOutputSize(inSize, padHead, padTail, kernel, stride, dilation, ceilMode)and let the helper buildnumeratorinternally — a hair more overhead but a cleaner interface. Cosmetic.Math.truncvsMath.flooron the floor path. Author correctly declined this as unreachable for valid models. If anyone ever wants absolute-strict C++ parity even in the malformed-input case,Math.truncwould be the exact port; the current choice is defensible. A one-line comment noting "matches C++ integer division on the non-negative-numerator domain" wouldn't hurt.- Legacy onnxjs coverage. The PR touches
js/web/lib/onnxjs/util.tsand the internal comment there notes "the onnxjs WebGL pooling caller intentionally does NOT pass ceilMode (legacy path still throws on ceil_mode != 0), so it always uses the floor default." Worth spot-checking thatbackends/webgl/ops/pool.tsstill throws onceilMode != 0, i.e. this PR doesn't accidentally unlock a broken WebGL execution path. From what I can see the WebGL parse path is untouched by this PR, so the throw is preserved — good — but a one-line note in the PR description confirming that would help future readers. legacyTargetSizefix scope. TheMath.floorcorrection tolegacyTargetSizecorrects the pad distribution on theceil_mode=0floor path too (unrelated to ceil_mode). That's genuinely a correctness fix for pre-existing behavior, not just a ceil-mode enabler. Worth calling out more prominently in the commit message / squash-merge message so it's discoverable if a regression is later suspected somewhere unrelated to ceil_mode.- Auto-merge is already enabled — that's fine given CI is green, but if
hariharans29's review lands changes, the auto-merge will consume the follow-up commits. Not a concern, just an FYI.
Summary
Approve. Fix is correct against the C++ reference (verified by walking each test case through the formulas), correctly scoped as shape-only with clear kernel-execution follow-up documented in place, the external-team-caught SAME_* integer-division bug is fixed with a proper regression test that specifically pins the pad out-param, and both jsep and onnxjs copies stay in lockstep through the shared test matrix. The declined polish items are appropriately declined. Good to go once @hariharans29 weighs in.
…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>
Summary
The ORT-Web JSEP pooling output-shape helper (
PoolConvUtil.computePoolOutputShape→computeShapeHelper→adjustPadAndReturnShape) was floor-only: it had noceilModeparameter and silently ignoredceil_mode. Underceil_mode=1this allocated the output tensor one element too small along each affected spatial axis, producing a wrong output shape (and downstream shape mismatches) — independent of any pooling divisor concern.The same floor-only helper is duplicated in the legacy
js/web/lib/onnxjs/util.ts; both copies are fixed.Fix
ceilModeparameter (default0, i.e. floor) throughcomputePoolOutputShape→computeShapeHelper→adjustPadAndReturnShapein bothjs/web/lib/wasm/jsep/util.tsandjs/web/lib/onnxjs/util.ts.computeOutputSize()helper that produces results identical to the C++ referencePoolAttributes::ComputeOutputSize(onnxruntime/core/providers/cpu/nn/pool_attributes.h), including theceil_mode"shrink the last window if it starts entirely in the trailing padding" rule (ref: Fix corner case where output size need to reduce by one in MaxPool onnx/onnx#5741).ceilModedefault) is algebraically unchanged, so Conv andauto_padpad-adjustment are unaffected.js/web/lib/wasm/jsep/webgpu/ops/pool.tsnow passesattributes.ceilModeinto the shape computation.Scope: SHAPE-only
This PR fixes the output-shape computation only. End-to-end
ceil_modeexecution remains gated by the existingthrowguards inparseAveragePoolAttributes/parseMaxPoolAttributes, because the WebGPU pooling kernel does not yet implementceil_modetrailing-padding handling (and, for AveragePool, thecount_include_paddivisor). Removing those throws + adding kernel support is a tracked follow-up; the now-correct shape path is exercised directly by the added unit tests until then. Comments at the throw sites and in the test header document this.Tests
Adds
js/web/test/unittests/pool-output-shape.ts(registered inunittests/index.ts), asserting output shapes for both implementations (jsep + onnxjs) against the C++ CPU reference ground truth:test_maxpool_2d_ceil→[1,1,2,2],AveragePool_10_ceil1_2d→[1,1,2,3]ceil1D[1,2,4]/ 2D[1,1,3,3]/ 3D[1,1,2,2,2](matching the CPUpool_op_test.cccases)ceil_modewith dilation,VALID/SAME_UPPER/SAME_LOWERauto_padceil()would give 3; correct = 2)Related
avg_pool2dwithceil_mode=Trueandcount_include_pad=Truepytorch/pytorch#183528Post-review fixups
legacyTargetSize = (inSize + stride - 1) / stridecomputation now usesMath.floor(...)to match C++pool_attributes.hComputeSizePadDilations, which uses integer division. This fixes a latent float-division divergence that mis-rounded the auto_pad pad distribution. The correction applies to all ceil modes (not onlyceil_mode=1), aligning JSEP/onnxjs with the CPU/CUDA EPs. The floor-path (ceil_mode=0) output shape is unchanged — only the SAME_* pad split is corrected — so nothing previously-correct regresses. Addedceil_mode=0SAME_UPPER/SAME_LOWER non-divisible regression tests that assert the corrected pad out-param (SAME_UPPER →[0,1], SAME_LOWER →[1,0]).ceil_modethrowstatements and the pool op TODO banner were reworded to make explicit that the output shape is now computed correctly, whileceil_modekernel execution (padding/divisor) remains the pending WebGPU follow-up.