Skip to content

Fix JSEP pooling output shape to honor ceil_mode#29627

Open
titaiwangms wants to merge 6 commits into
microsoft:mainfrom
titaiwangms:user/ceilmode-jsep-shape
Open

Fix JSEP pooling output shape to honor ceil_mode#29627
titaiwangms wants to merge 6 commits into
microsoft:mainfrom
titaiwangms:user/ceilmode-jsep-shape

Conversation

@titaiwangms

@titaiwangms titaiwangms commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Summary

The ORT-Web JSEP pooling output-shape helper (PoolConvUtil.computePoolOutputShapecomputeShapeHelperadjustPadAndReturnShape) was floor-only: it had no ceilMode parameter and silently ignored ceil_mode. Under ceil_mode=1 this 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

  • Thread a ceilMode parameter (default 0, i.e. floor) through computePoolOutputShapecomputeShapeHelperadjustPadAndReturnShape in both js/web/lib/wasm/jsep/util.ts and js/web/lib/onnxjs/util.ts.
  • Route the NOTSET / VALID / SAME_UPPER / SAME_LOWER branches through a new computeOutputSize() helper that produces results identical to the C++ reference PoolAttributes::ComputeOutputSize (onnxruntime/core/providers/cpu/nn/pool_attributes.h), including the ceil_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).
  • The floor path (ceilMode default) is algebraically unchanged, so Conv and auto_pad pad-adjustment are unaffected.
  • js/web/lib/wasm/jsep/webgpu/ops/pool.ts now passes attributes.ceilMode into the shape computation.

Scope: SHAPE-only

This PR fixes the output-shape computation only. End-to-end ceil_mode execution remains gated by the existing throw guards in parseAveragePoolAttributes / parseMaxPoolAttributes, because the WebGPU pooling kernel does not yet implement ceil_mode trailing-padding handling (and, for AveragePool, the count_include_pad divisor). 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 in unittests/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]
  • AvgPool ceil 1D [1,2,4] / 2D [1,1,3,3] / 3D [1,1,2,2,2] (matching the CPU pool_op_test.cc cases)
  • ceil_mode with dilation, VALID / SAME_UPPER / SAME_LOWER auto_pad
  • a shrink-rule discriminator (naive ceil() would give 3; correct = 2)
  • floor-mode no-regression case

Related

Post-review fixups

  • SAME_UPPER/SAME_LOWER integer division (external-review Major). The legacyTargetSize = (inSize + stride - 1) / stride computation now uses Math.floor(...) to match C++ pool_attributes.h ComputeSizePadDilations, 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 only ceil_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. Added ceil_mode=0 SAME_UPPER/SAME_LOWER non-divisible regression tests that assert the corrected pad out-param (SAME_UPPER → [0,1], SAME_LOWER → [1,0]).
  • Throw-message / TODO clarity. The retained ceil_mode throw statements and the pool op TODO banner were reworded to make explicit that the output shape is now computed correctly, while ceil_mode kernel execution (padding/divisor) remains the pending WebGPU follow-up.

titaiwangms and others added 4 commits July 8, 2026 22:46
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ceilMode through computePoolOutputShape → computeShapeHelper → adjustPadAndReturnShape and centralize per-dimension sizing in a computeOutputSize() helper.
  • Pass attributes.ceilMode into 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.

Comment thread js/web/lib/wasm/jsep/util.ts
Comment thread js/web/lib/onnxjs/util.ts
Comment thread js/web/lib/wasm/jsep/webgpu/ops/pool.ts
Comment thread js/web/lib/wasm/jsep/webgpu/ops/pool.ts
@titaiwangms

Copy link
Copy Markdown
Contributor Author

Review summary

Solid, well-tested, and correctly scoped (shape-only, execution still gated). The ceil-mode math matches the C++ reference (PoolAttributes::ComputeOutputSize), all test expectations check out (including the shrink-rule boundary tested exactly at equality), the two computeOutputSize copies are byte-identical, and all other callers (Conv, WebGL, GlobalPool) safely keep the floor default. One Major to address before the ceil path can be fully trusted; the rest are Minor/Nit.

🔴 Major (1)

SAME_UPPER/SAME_LOWER uses float division for legacyTargetSize, diverging from the C++ reference in ceil mode.
js/web/lib/wasm/jsep/util.ts:523 and js/web/lib/onnxjs/util.ts:1420

const legacyTargetSize = (inSize + stride - 1) / stride;   // float; C++ uses int64 division

Reproduced with inSize=2, stride=2, kernel=3, SAME_UPPER, ceilMode=1:

  • JS: legacyTargetSize = 1.5padNeeded = 2 → output […,2]
  • C++ (legacy_target_size = 1) → pad_needed = 1 → output […,1]

Floor mode masks this (the extra pad is absorbed by floor), so it's a pre-existing latent bug that the new ceil path newly exposes — and it breaks the "identical to the C++ reference" claim for the SAME_* branch. The added SAME_* tests only use in=5, stride=2, where the division is integral and hides the issue.

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. inputDims=[1,1,2], strides=[2], kernelShape=[3], autoPad='SAME_UPPER', ceilMode=1 → expected [1,1,1].

🟡 Minor (2)

  1. float32 vs float64 ceil. C++ does std::ceil(static_cast<float>(numerator) / stride) (float32); JS uses double Math.ceil (jsep/util.ts:488, onnxjs/util.ts:1385). These diverge only for numerator > 2^24, which is unreachable for real pooling spatial dims — so no practical impact. Either emulate float32 with Math.fround(...) for strict parity, or soften the "identical to the C++ reference" comment to note JS intentionally uses exact double math. Non-blocking.
  2. Stale banner TODO at the top of pool.ts still lists ceil_mode as an unqualified TODO, which now contradicts the precise per-function comments in parseAveragePoolAttributes/parseMaxPoolAttributes. Suggest updating it to note the output-shape math is supported and only kernel execution is gated.

⚪ Nits

  • The "Keep in sync" comment on the duplicated computeOutputSize isn't enforced by anything but hand-picked case parity. Since the test already imports both PoolConvUtils, consider an explicit "both implementations agree" test over a matrix of random inputs — turns the promise into CI coverage.
  • computeOutputSize's numerator param is semantically indirect; and the pool-output-shape.ts "Reviewer-verified" comment on the SAME_UPPER case would be more traceable as the actual hand-derivation.

Follow-up (out of scope, already tracked)

End-to-end ceil_mode execution remains correctly gated by the throws in parseAveragePool/MaxPoolAttributes pending the kernel trailing-padding (and AveragePool count_include_pad) support — the tracked follow-up.

Reviewed by a multi-model review team (readability / correctness / adversarial / spec-adherence / cross-module integration).

titaiwangms and others added 2 commits July 9, 2026 00:07
…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>
@titaiwangms

Copy link
Copy Markdown
Contributor Author

Review fold-in (post-review fixups pushed: 134ab29f8, 1ea30f903)

Thanks all for the thorough passes — and a special thanks to the external review team for catching the SAME_* integer-division issue.

Folded in ✅

  • SAME_UPPER/SAME_LOWER integer-division Major (external team). legacyTargetSize now uses Math.floor((inSize + stride - 1) / stride) to match C++ pool_attributes.h ComputeSizePadDilations (integer division). This was a latent float-division divergence that mis-rounded the auto_pad pad split. It's live on all ceil modes — on the ceil_mode=0 floor path the output shape is unchanged but the pad distribution was wrong (e.g. in=2, stride=2, kernel=3, SAME_UPPER gave pads [1,1] instead of C++ [0,1]). Added ceil_mode=0 SAME_UPPER/SAME_LOWER non-divisible pad-distribution regression tests (assert the corrected out-param, not just the shape); they fail against the pre-fix float code and pass with the fix.
  • Throw-message / TODO clarity (Copilot C3/C4 + team Minor 2). Reworded the retained ceil_mode throw messages and the pool op TODO banner to make clear the output shape is computed, while ceil_mode kernel execution (padding/divisor) is the pending WebGPU follow-up.

Respectfully declined (with rationale) 🙏

  • Copilot C1/C2 — Math.floorMath.trunc on the floor path. These differ only for a negative numerator with a remainder, i.e. kernel > inSize + pads producing an invalid <= 0 output dim — unreachable for valid models. It's also the ceil_mode=0 path this PR intentionally scopes out. Happy to file separately if desired.
  • Team Minor 1 — float32 vs double for the ceil computation. Diverges only for numerator > 2^24, impossible for real spatial dims; non-blocking, as the team itself noted.
  • Team Nits A/B — random-matrix cross-impl test + numerator rename. Optional polish; left out to keep the fixup tight and reviewable.

@titaiwangms titaiwangms enabled auto-merge (squash) July 9, 2026 17:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants