Repository navigation
Lower mean.dim beyond 4D pooling as an XNNPACK static mean - #24741
JakeStevens wants to merge 1 commit into
Conversation
🔗 Helpful Links🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/24741
Note: Links to docs will display an error until the docs builds have been completed. ❌ 1 New Failure, 1 Pending, 3 Unrelated FailuresAs of commit 99c4063 with merge base 558c071 ( NEW FAILURE - The following job has failed:
BROKEN TRUNK - The following jobs failed but were present on the merge base:👉 Rebase onto the `viable/strict` branch to avoid these failures
This comment was automatically generated by Dr. CI and updates every 15 minutes. |
## Ulterior Motive Run RMSNorm in LeRobot policies on XNNPACK (pytorch#24667), where `mean.dim` fails to partition with "only supports averaging 4D tensors, got tensor of rank 3". ## Rationale **What**: Add an `XNNStaticMean` node backed by `xnn_define_static_reduce(xnn_reduce_mean)`, and lower `mean.dim` that is not Global Average Pooling to it: ranks 1 to 6, any dims, and either `keepdim`, except small static means (see Details). **Why**: XNNPACK only lowered a 4D mean over dims [2, 3] with `keepdim=True`. RMSNorm averages the last dim of a 3-D tensor, so its mean stayed on the portable kernel and split the delegate around every norm. ## Details Review order: the serialization schema and `XNNCompiler.cpp`, then `op_mean_dim.py` and `is_global_avg_pool_mean`, then `ChannelsLastTaggedReshapePass`, then `MeanDimConfig`, then the tests. - The 4D, dims [2, 3], `keepdim=True` case still lowers to Global Average Pooling on NHWC, at every size. For those programs the `.pte` is byte-identical to main (fp32, fp16, qs8, conv-bn-hardtanh-mean, and a CNN ending in `AdaptiveAvgPool2d(1)`). - Every other `mean.dim` reduces over NCHW dims, so `ChannelsLastTaggedReshapePass` gives it an NCHW input. After a convolution that is the same NHWC to NCHW copy main made at the delegate boundary. - The node uses a shared `_XNNStaticReduce` table, so other XNNPACK reductions can reuse it and `defineGenericReduceNode`. It is appended to `XNodeUnion`, so existing node IDs do not change. Re-exporting a model with a mean that is not Global Average Pooling, such as RMSNorm, emits the new node, and that program needs an updated runtime. - An empty dim list averages over every dim, as in PyTorch. - XNNPACK splits a reduction with more than one output into threadpool tasks, so on a multi-threaded pool a small mean costs more in XNNPACK than on the portable kernel. A 1x8x64 mean over the last dim took 4.9 us on the portable kernel and 27.9 us in XNNPACK at 16 threads; at 1 thread both took about 5 us. A static mean with more than one output and fewer than 8192 input elements therefore stays on the portable kernel and produces the same program as main. Dynamic shapes and single-output means are lowered at any size, so a small mean with a dynamic shape can be slower than on main with many threads. - Latency, median against main on an Apple M-series CPU (12 performance + 4 efficiency cores) with the default 16 threads: RMSNorm 1x128x2048 0.48x, 3-D last-dim mean 0.45x, RMSNorm then Linear 0.77x, 4 pre-norm residual MLP blocks 0.99x for decode (1 token) and 0.80x for prefill (64 tokens), conv then mean over channels 0.88x. RMSNorm on 10 static shapes from 1x1x128 to 1x32x2048, at 1, 4 and 16 threads, is 0.43x to 1.02x. Each number is a median over at least 1000 timed runs per program, alternating main and branch programs on one runtime built from this branch. - A mean alone in its delegate, with nothing else lowered around it, pays the threadpool wake-up on its own and can be slower than the portable kernel at high thread counts: at 16 threads a bare fp32 mean over the last dim of 1x128x128 is 3.26x, 1x64x512 1.90x and 1x128x512 1.02x, and larger means are faster (1x128x2048 0.39x). At 4 threads only 1x128x128 is slower (1.14x), and at 1 thread every size is faster. The same holds for any op lowered alone, such as `add` (2.95x at 1x128x128 and 16 threads on main), so this is the existing single-op trade-off of the XNNPACK partitioner. Inside a model the neighbouring delegated ops already pay the wake-up, which is why the RMSNorm and block numbers above do not regress. - The LoRA CI scripts check the start of the 8da4w-quantized Qwen3 base model's greedy output with a regex. With RMSNorm and QK-norm now in XNNPACK, its first token can tip from `:\n` to `:` (`assistant: me` instead of `assistant:\n<think>`): unquantized, main and this branch generate the same tokens with logits within 1e-4, but 8da4w re-rounds activations and the two candidates are within about 0.5 logits. The regex now accepts any text on the `assistant:` line and an optional `<think>`, and still requires `Okay, so I need to calculate 15% of 80.`. - Tests cover 1-D to 5-D inputs, all dims, `keepdim=False`, fp16 and qs8 over the innermost and a middle dim, dynamic kept and reduced dims, RMSNorm, convolution followed by a mean that is not Global Average Pooling, the size threshold on both sides, and the layout copies the pass inserts. Test plan: ``` python -m pytest backends/xnnpack/test/ops/test_mean_dim.py backends/xnnpack/test/passes/test_channels_last_tagged_reshape.py python -m pytest backends/xnnpack/test/ops backends/xnnpack/test/passes backends/xnnpack/test/test_xnnpack_partitioner.py backends/xnnpack/test/serialization backends/xnnpack/test/quantizer ``` Authored with AI using Claude Code.
7e905e0 to
99c4063
Compare
|
@claude review effort=max |
|
Claude finished @JakeStevens's task in 36m 9s —— View job Needs Discussion: the 8192-element cut-off in Detailed reviewPR Review: #24741SummaryThis PR adds an Code Quality
Testing
Backward Compatibility
Performance
Specific Comments
RecommendationNeeds Discussion The cut-off is the open design question: either drop it, or justify it with in-model data and make it a partitioner option. The node-type assertions and the tighter LoRA regex should land with this PR. Everything else is minor or can be a follow-up. |
### Ulterior Motive Improving overall BF16 support in light of #24667 ### Summary `ToDimOrderCopyConfig` already lowers fp32 <-> fp16 casts to XNNPACK convert nodes. This allows fp32 <-> bf16 as well, when bf16 is enabled in the partitioner. fp16 <-> bf16 stays unsupported, because XNNPACK has no direct convert between them. bf16 models that upcast a step to fp32 (HF softmax with `dtype=torch.float32`, Llama-style RMSNorm) now stay in one delegate, instead of running the casts in portable kernels between partitions. ExecuTorch's current XNNPACK pin already has the bf16 <-> fp32 convert kernels, so no runtime or pin changes are needed. The config matches `_to_dim_order_copy`, which export emits when dim order is kept, the `to_edge_transform_and_lower` default. Programs exported with `skip_dim_order=True`, as `LLMEdgeManager` and `export_llama` do by default, keep `aten._to_copy`, which no XNNPACK partitioner config matches yet, so their casts (fp16 ones included) still run in portable kernels. Partitioning `_to_copy` dtype casts under the same rules would be a follow-up. ### Benchmarks Measured with `executor_runner` (Release; XNNPACK + portable ops), median of 50 iterations. "Before" exports the same model with the cast lowering disabled. Shapes: - softmax: `softmax(x, -1, dtype=float32).to(bf16)` on `[1,16,S,S]`. - RMSNorm: Llama-style, `[1,T,2048]`. - elementwise: `(x.float()*2+1).to(bf16)` on `[1,128,4096]`. Single thread (Android pinned to the fastest core with `taskset`), ms, before -> after: | model | Apple M4 | iPhone 17 (A19) | Pixel 10 (Tensor G5, X4) | Pixel 6 (Tensor G1, X1; no BF16) | |---|---|---|---|---| | softmax S=128 | 1.172 -> 0.141 (8.3x) | 1.415 -> 0.403 (3.5x) | 1.785 -> 0.701 (2.5x) | 1.760 -> 0.684 (2.6x) | | softmax S=512 | 18.64 -> 2.343 (8.0x) | 20.18 -> 3.246 (6.2x) | 31.16 -> 5.031 (6.2x) | 35.60 -> 6.484 (5.5x) | | RMSNorm T=1 | 0.012 -> 0.002 (5.6x) | 0.021 -> 0.004 (4.7x) | 0.098 -> 0.012 (8.3x) | 0.013 -> 0.004 (3.1x) | | RMSNorm T=128 | 1.266 -> 0.243 (5.2x) | 1.620 -> 0.652 (2.5x) | 2.502 -> 0.820 (3.1x) | 1.731 -> 0.712 (2.4x) | | elementwise | 2.161 -> 0.118 (18.3x) | 2.244 -> 0.418 (5.4x) | 3.754 -> 0.467 (8.0x) | 4.053 -> 0.576 (7.0x) | Default `executor_runner` threads (M4: 16, iPhone: 6, Pixel 10: 6, Pixel 6: 4; unpinned), ms, before -> after: | model | Apple M4 | iPhone 17 (A19) | Pixel 10 (Tensor G5) | Pixel 6 (Tensor G1) | |---|---|---|---|---| | softmax S=128 | 1.199 -> 0.152 (7.9x) | 1.483 -> 0.294 (5.0x) | 3.858 -> 1.020 (3.8x) | 1.980 -> 0.317 (6.2x) | | softmax S=512 | 17.11 -> 0.635 (27x) | 18.98 -> 1.956 (9.7x) | 38.88 -> 5.518 (7.0x) | 25.97 -> 5.074 (5.1x) | | RMSNorm T=1 | 0.010 -> 0.002 (4.8x) | 0.020 -> 0.004 (4.4x) | 0.101 -> 0.101 (1.0x)* | 0.133 -> 0.025 (5.3x) | | RMSNorm T=128 | 1.323 -> 0.347 (3.8x) | 1.616 -> 0.720 (2.2x) | 8.936 -> 1.765 (5.1x) | 2.544 -> 0.642 (4.0x) | | elementwise | 2.154 -> 0.154 (14x) | 2.376 -> 0.355 (6.7x) | 3.241 -> 0.839 (3.9x) | 2.806 -> 0.462 (6.1x) | \* Unpinned, this ~10 us graph runs on a core that doesn't ramp its clock up, so the run hits a ~0.1 ms floor even with 1 thread. Pinned, it's 0.012 ms (table above). RMSNorm still has `mean` outside the delegate (`mean.dim` over the last dim isn't lowered yet; #24741 lowers it). The casts no longer split it further (3 -> 2 delegates). ### Test plan ``` python -m pytest backends/xnnpack/test/ops/test_to_copy_dtype.py backends/xnnpack/test/ops/test_to_copy.py ``` New tests cover: - fp32 I/O around a bf16 linear; - a bf16 upcast; - a bf16 softmax computed in fp32 and cast back, as one delegate; - fp32 -> bf16 rounding matching PyTorch exactly (zero tolerance), including values halfway between two bf16 numbers; - no lowering without `enable_bf16`; - fp16 <-> bf16 not partitioned. The tests that run bf16 in XNNPACK are skipped in fbcode, like the other XNNPACK bf16 runtime tests. The delegation tests fail without the config change.
Ulterior Motive
Run RMSNorm in LeRobot policies on XNNPACK (#24667), where
mean.dimfails to partition with "only supports averaging 4D tensors, got tensor of rank 3".Rationale
What: Add an
XNNStaticMeannode backed byxnn_define_static_reduce(xnn_reduce_mean), and lowermean.dimthat is not Global Average Pooling to it: ranks 1 to 6, any dims, and eitherkeepdim, except small static means (see Details).Why: XNNPACK only lowered a 4D mean over dims [2, 3] with
keepdim=True. RMSNorm averages the last dim of a 3-D tensor, so its mean stayed on the portable kernel and split the delegate around every norm.Details
Review order: the serialization schema and
XNNCompiler.cpp, thenop_mean_dim.pyandis_global_avg_pool_mean, thenChannelsLastTaggedReshapePass, thenMeanDimConfig, then the tests.keepdim=Truecase still lowers to Global Average Pooling on NHWC, at every size. For those programs the.pteis byte-identical to main (fp32, fp16, qs8, conv-bn-hardtanh-mean, and a CNN ending inAdaptiveAvgPool2d(1)).mean.dimreduces over NCHW dims, soChannelsLastTaggedReshapePassgives it an NCHW input. After a convolution that is the same NHWC to NCHW copy main made at the delegate boundary._XNNStaticReducetable, so other XNNPACK reductions can reuse it anddefineGenericReduceNode. It is appended toXNodeUnion, so existing node IDs do not change. Re-exporting a model with a mean that is not Global Average Pooling, such as RMSNorm, emits the new node, and that program needs an updated runtime.add(2.95x at 1x128x128 and 16 threads on main), so this is the existing single-op trade-off of the XNNPACK partitioner. Inside a model the neighbouring delegated ops already pay the wake-up, which is why the RMSNorm and block numbers above do not regress.:\nto:(assistant: meinstead ofassistant:\n<think>): unquantized, main and this branch generate the same tokens with logits within 1e-4, but 8da4w re-rounds activations and the two candidates are within about 0.5 logits. The regex now accepts any text on theassistant:line and an optional<think>, and still requiresOkay, so I need to calculate 15% of 80..keepdim=False, fp16 and qs8 over the innermost and a middle dim, dynamic kept and reduced dims, RMSNorm, convolution followed by a mean that is not Global Average Pooling, the size threshold on both sides, and the layout copies the pass inserts.Test plan:
Authored with AI using Claude Code.