Skip to content

metal: max and average pooling kernels - #2553

Open
czoli1976 wants to merge 3 commits into
sonos:mainfrom
czoli1976:feat/metal-pooling
Open

metal: max and average pooling kernels#2553
czoli1976 wants to merge 3 commits into
sonos:mainfrom
czoli1976:feat/metal-pooling

Conversation

@czoli1976

Copy link
Copy Markdown
Contributor

Stacked on #2552 — its commit is the first of the two here, because the tests
compare against the CPU op and one of them exercises the path that fix repairs.

Pooling has no Metal kernel, so a pooled model leaves the GPU at every pool.
On Inception v3 that is 14 DeviceSyncToHost nodes; profiling it after the
convolution work lands shows the pools themselves at 28% of runtime on the CPU
side and the syncs dominating the rest.

This adds 2D max and sum pooling over channels-last tensors — one thread per
(n, oh, ow, c), so consecutive threads walk the contiguous channel axis and
every window read is coalesced — and routes MaxPool/SumPool and their
optimized forms to them. NCHW, rank ≠ 4, non-float dtypes and max-pool with an
index output stay on the existing path.

After this, Inception v3 has 14 pools on the GPU and its sync count goes
14 → 1.

Numbers

Inception v3 (TF, NHWC, 299×299) on --metal, M1 Pro:

ms/inference
main 478.4
main + this 460.3
#2549+#2550+#2551 (convolutions) 40.6
those + this 22.5

Worth being explicit about that first pair: on main this is close to neutral,
because the direct convolution kernel dominates everything and 16 ms of CPU
pooling is noise beside it. The gain only appears once convolution is off the
critical path, where it is 1.80×. If the convolution PRs are not wanted,
this one is not worth much on its own.

Output matches the CPU path (same predicted class, max difference 3e-7).

Validation

cargo test -p tract-metal --release: 83 passed, one failure —
test_mfa_attention_causal_const_is_noop, pre-existing on main and unrelated
(#2546). New tests cover max pooling with valid and same padding, average
pooling with and without count_include_pad, un-normalized sum pooling, f16,
and an end-to-end case asserting both pools land on the GPU and match CPU.
fmt and clippy clean.

One wart worth flagging for review: the translators live in ops::pool, which
nothing else calls into, and the linker drops the module — and with it the
inventory registrations — unless something references it. There is a
link_translators() call in the transform to hold it. If there is an idiom you
prefer for that, say so.

M1 Pro only; my M4 was asleep.

🍍

czoli1976 and others added 2 commits August 2, 2026 06:06
The eval only stored a value inside `if let Some(div)`, which is None
when normalize is false, so the output tensor was left untouched and the
op returned zeros. NNEF's `box` fragment defaults normalize to false and
the deserializer passes it straight through, so this is reachable from a
model file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pooling had no Metal kernel, so a pooled model bounced back to the host
at every pool. On Inception v3 that is 14 device syncs and, once the
convolutions are on the GPU, most of what is left: the pools alone were a
fifth of the runtime on the CPU side. Add 2D max and sum pooling over
channels-last tensors and route MaxPool/SumPool - and their optimized
forms - to them. Anything else, including NCHW and rank other than 4,
stays where it was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pooling kernels only covered channels-last tensors, so a channels-first
model — what an ONNX or PyTorch export gives — kept every max pool on the CPU.
A channels-first max pool now takes the width axis as the fastest-moving one,
so its reads stay coalesced the way the channels-last kernel's do. Sum pooling
stays channels-last.
@czoli1976

Copy link
Copy Markdown
Contributor Author

Added a second commit: channels-first max pooling.

The kernels here only covered channels-last, so a channels-first model — what an ONNX or PyTorch export gives — kept every max pool on the CPU. The new kernel takes the width axis as the fastest-moving one so its reads stay coalesced the way the channels-last one's do. Sum pooling stays channels-last.

On u2netp (rembg, ONNX/PyTorch so NCHW, 1x3x320x320, --metal -O, min of 3 interleaved rounds) its 33 max pools move to the GPU, and it goes 743.4 -> 718.0 ms. That is only 1.04x, for the same reason this PR already notes about itself: the model's 38 Resize nodes are still on the CPU here and their syncs dominate. Stacked with #2556 (Metal Resize) the same change is worth 460.7 -> 359.8 ms, which is where u2netp's Metal path overtakes its CPU path (359.8 vs 419.1 ms).

Output matches the CPU path to 1.8e-7 max abs. Two new unit tests cover channels-first max pooling in f32 and f16, valid and same-padding. test-metal 22348 passed / 0 failed, tract-metal unit tests 86 passed, onnx-tests.sh green, fmt and clippy clean.

This supersedes #2557, which I opened before noticing this PR existed; closing that one.

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.

1 participant