Skip to content

[XNNPACK] Lower fp32 <-> bf16 casts to XNNPACK converts - #24745

Merged
JakeStevens merged 2 commits into
pytorch:mainfrom
JakeStevens:xnnpack-bf16-cast
Nov 6, 2026
Merged

JakeStevens merged 2 commits into
pytorch:mainfrom
JakeStevens:xnnpack-bf16-cast

Conversation

@JakeStevens

@JakeStevens JakeStevens commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

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.

ToDimOrderCopyConfig already lowers fp32 <-> fp16 casts to XNNPACK convert
nodes; allow fp32 <-> bf16 too (when bf16 is enabled in the partitioner).
fp16 <-> bf16 stays unsupported. This keeps bf16 models that upcast a step to
fp32 (softmax, RMSNorm) in one delegate instead of running the casts in
portable kernels between partitions.

executor_runner, single thread pinned to the fastest core, before -> after:
upcast softmax [1,16,512,512] 5.5-8.0x, Llama RMSNorm [1,128,2048]
2.4-5.2x, elementwise upcast [1,128,4096] 5.4-18x (Apple M4, iPhone 17,
Pixel 10, Pixel 6).

Test plan: python -m pytest backends/xnnpack/test/ops/test_to_copy_dtype.py
backends/xnnpack/test/ops/test_to_copy.py
@pytorch-bot

pytorch-bot Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/24745

Note: Links to docs will display an error until the docs builds have been completed.

✅ No Failures

As of commit b87ab61 with merge base a0d6be8 (image):
💚 Looks good so far! There are no failures yet. 💚

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Nov 6, 2026
@github-actions

github-actions Bot commented Nov 6, 2026

Copy link
Copy Markdown

This PR needs a release notes: label

If your change should be included in the release notes (i.e. would users of this library care about this change?), please use a label starting with release notes:. This helps us keep track and include your important work in the next release notes.

To add a label, you can comment to pytorchbot, for example
@pytorchbot label "release notes: none"

For more information, see
https://github.com/pytorch/pytorch/wiki/PyTorch-AutoLabel-Bot#why-categorize-for-release-notes-and-how-does-it-work.

@JakeStevens

Copy link
Copy Markdown
Contributor Author

@claude review effort=max

@claude

claude Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Claude finished @JakeStevens's task in 19m 15s —— View job


This PR needs a few small fixes: the two new bf16 runtime tests are missing the is_fbcode() skip that every other XNNPACK bf16 runtime test has, no test would catch a change in fp32→bf16 rounding, and the speedup doesn't reach export_llama, which exports with dim order skipped.

PR Review: #24745

Summary

This PR lets ToDimOrderCopyConfig lower fp32 <-> bf16 casts to XNNPACK converts when enable_bf16 is set. There are three problems:

  • The two new tests that run bf16 in XNNPACK will probably fail in internal CI.
  • The tests can't detect a change in how fp32→bf16 casts round.
  • For models exported through export_llama, the casts this PR targets still stay outside the delegate.

What I couldn't check:

  • I didn't run the tests, because running Python needs approval in this job.
  • I couldn't check the claim that the XNNPACK pin has bf16→fp32 converts, because the submodule isn't checked out here.

Infrastructure

  • The two new bf16 runtime tests need the fbcode skip. test_fp32_io_around_bf16_linear and test_bf16_upcast (test_to_copy_dtype.py:124-132) run bf16 kernels in the XNNPACK runtime through _run.
    • Every other XNNPACK test that does this has @unittest.skipIf(is_fbcode(), ...): test_bmm.py:53, test_xnnpack_partitioner.py:223 and :267, and test_linear.py:919. The skip reason in test_linear.py is "wait for XNNPACK pin bump to enable".
    • The bf16 tests without the skip are the conv1d/conv2d fallback tests and test_enable_bf16_leaves_ops_xnnpack_cannot_define_in_bf16. All of them assert that XNNPACK runs nothing in bf16.
    • This file is in the internal test_xnnpack_ops target (ops/*.py glob, backends/xnnpack/test/BUCK:91-94). If the internal pin is still behind, both tests will fail when the PR is imported.
    • Fix: add @unittest.skipIf(is_fbcode(), "skipping for fbcode") with from executorch.export.utils import is_fbcode, as test_bmm.py does. The two negative tests don't run the program, so they don't need the skip. Fix this →

Testing

  • No test would notice a change in fp32→bf16 rounding. With enable_bf16, these casts used to run in the portable kernel, which rounds to nearest-even like eager PyTorch. XNNPACK's convert now does the rounding, and none of the new tests can tell whether it rounds the same way.
    • In Bf16Upcast (:122), x.to(torch.float32) * 2.0 is exactly representable in bf16, so the final convert never rounds.
    • Bf16Inner (:116) does round its input, but the 2e-2 tolerance (:53) is far larger than a 1-ulp bf16 difference.
    • Fix: add a cast-only x.to(torch.bfloat16) test on fp32 randn inputs and compare with atol=0, rtol=0. That catches truncation or any other mismatch. Fix this →
  • The cases this PR is about aren't tested. Every new test uses an explicit .to().
    • The description and benchmarks are about torch.softmax(x, -1, dtype=torch.float32).to(torch.bfloat16) and Llama-style RMSNorm. I found no test for either under backends/xnnpack/test/.
    • A later change to the softmax or cast configs, or to how export emits these casts, could split the graph again without any test failing.
    • Fix: run the softmax case through _run(..., enable_bf16=True). That locks in the single-delegate result. Fix this →

Performance

  • The speedup doesn't reach export_llama. Not blocking.
    • ToDimOrderCopyConfig only matches _to_dim_order_copy.default (generic_node_configs.py:666). No XNNPACK partitioner config matches aten._to_copy.default.
    • _to_copy is only rewritten to _to_dim_order_copy when _skip_dim_order is False (exir/program/_program.py:701, :854).
    • LLMEdgeManager defaults to skip_dim_order=True (extension/llm/export/builder.py:100, passed on at :203). export_llama_lib.py:1829-1834 turns it off only for TOSA, VGF and Ethos-U.
    • So in a bf16 Llama exported with backend.xnnpack.enable_bf16=True, the RMSNorm casts (self._norm(x.float()).type_as(x) in examples/models/llama/norm.py:38) still run in portable kernels between delegates. fp16 casts have the same gap today.
    • ToCopyVisitor already serializes _to_copy dtype casts as XNNConvert (operators/op_to_copy.py:51-65), so the missing piece looks like partitioner support.
    • Fix, either way:
      • Add a _to_copy.default config with the same dtype and dim-order rules, in this PR or a follow-up. Fix this →
      • Or say in the description that the speedup needs a dim-order export, which is the to_edge_transform_and_lower default.

Specific Comments

  • backends/xnnpack/partition/config/generic_node_configs.py:669-671: stale docstring. "Only support dim order conversion partitioning, not DType conversions" contradicts the body this PR extends.
  • backends/xnnpack/partition/config/generic_node_configs.py:681-682: the comment doesn't say that the bf16 pair also needs enable_bf16, which is checked elsewhere in _check_node_has_valid_dtype (xnnpack_config.py:288). It also doesn't say why fp16 <-> bf16 is left out.
  • backends/xnnpack/partition/config/generic_node_configs.py:693: when a supported pair is rejected only because the dim order changes, the why() reason still says "dtype conversion from torch.float32 to torch.bfloat16 is not supported". One example is x.to(torch.bfloat16, memory_format=torch.channels_last).

Fix these →

Recommendation

Request Changes

The fbcode skips are what blocks this PR, because the internal import will probably fail without them. The exact-tolerance cast test is cheap and covers the one numerical change this PR makes. The export_llama gap can wait for a follow-up, but then the description should say the speedup needs a dim-order export.

Skip the two bf16 runtime tests in fbcode like the other XNNPACK bf16 runtime tests, add an exact-tolerance fp32 -> bf16 cast test with values halfway between two bf16 numbers, and add the bf16 softmax upcast as a single-delegate test. ToDimOrderCopyConfig's docstring now covers dtype casts, its comment says why fp16 <-> bf16 is excluded and that bf16 needs enable_bf16, and a dim order change gets its own why() reason.

Authored with AI using Claude Code.
@JakeStevens

Copy link
Copy Markdown
Contributor Author

@claude review effort=max

@claude

claude Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Claude finished @JakeStevens's task in 22m 32s —— View job


Approve: I found no problems in the partitioner change, and what's left is minor: two bf16 tests could check their outputs more tightly, the rounding test is missing some inputs, and the PR body should disclose AI authorship.

PR Review: #24745

Summary

This PR lets ToDimOrderCopyConfig lower fp32 <-> bf16 casts to XNNPACK converts when enable_bf16 is set. I reviewed both commits, including b87ab61, and found no problems in the partitioner logic. The remaining issues are minor:

  • Two of the new bf16 runtime tests use a tolerance too loose to catch the errors they could hit.
  • The rounding test leaves out the inputs where a convert kernel is most likely to differ from PyTorch.
  • The PR body doesn't disclose AI authorship.

What I couldn't check:

  • I didn't run the tests, because running Python needs approval in this job.
  • The XNNPACK submodule isn't checked out. So I couldn't confirm its convert support at the pin (bf16 → fp32, fp16 <-> bf16), or how its fp32 → bf16 kernel handles NaN, inf and subnormals.

Code Quality

  • The PR body doesn't say the PR was authored with AI. CLAUDE.md asks PRs to disclose this and name the tool. b87ab61's commit message says "Authored with AI using Claude Code", but the PR body doesn't.
    • Also in the body: the summary says the softmax and RMSNorm cases "now stay in one delegate", but the benchmark notes say RMSNorm goes from 3 delegates to 2.

Testing

  • _run's atol=2e-2, rtol=2e-2 (test_to_copy_dtype.py:54) is too loose for two of the new bf16 tests.
    • test_bf16_upcast (:139-145): x.float() * 2 cast back to bf16 is exact, so the output should match exactly. atol=rtol=0 would check that.
    • test_bf16_softmax_upcast (:147-153): softmax over 32 values gives outputs around 1/32 ≈ 0.031. The allowed error is ≈ 0.0206, about two thirds of a typical output. The reference and the delegate both compute in fp32 and round to bf16, so they should agree within one bf16 ulp (rtol around 1e-2 with a small atol).
    • Fix: give _run atol/rtol parameters, and have it pass inputs to run_method_and_compare_outputs. Without inputs, the harness draws fresh randn inputs (backends/test/harness/tester.py:335). With both changes, test_fp32_to_bf16_rounds_like_pytorch can call _run instead of repeating its pipeline (:165-178). Fix this →
  • The rounding test leaves out the inputs where a convert kernel is most likely to differ from PyTorch. Its docstring says the convert must round "exactly as PyTorch does". In the input (:161-164):
    • torch.allclose treats -0.0 and 0.0 as equal (backends/test/harness/tester.py:454-459). So the -0.0 entry can't catch a convert that drops the sign.

    • Nothing rounds up to inf. 3.0e38 is below bf16's largest finite value (≈ 3.3895e38). torch.finfo(torch.float32).max would round up to inf.

    • There are no ±inf, NaN or fp32 subnormal inputs. 2**-100 * (1 + 3 * 2**-8) is a normal number. The harness compares with equal_nan=True, so NaN inputs work.

    • The special values are the last 7 of 4,103 elements. A vectorized kernel would only see them in its remainder path. Low confidence: I couldn't read XNNPACK's kernel.

    • Fix:

      • Put the special values first, or repeat them to fill full vectors.
      • Add finfo.max, ±inf, NaN and finfo.tiny / 4.
      • Drop -0.0 unless something else checks the sign.
      • If XNNPACK is expected to differ on any of these, narrow the docstring instead.

      Fix this →

Specific Comments

  • backends/xnnpack/partition/config/generic_node_configs.py:682-683: the comment says "XNNPACK has no fp16 <-> bf16 convert", which is stronger than the PR body's "no direct convert between them". I couldn't check either at the pin. Make them agree, and scope the comment to the pin as xnnpack_config.py:55 does ("At the pinned XNNPACK commit, …").
  • backends/xnnpack/test/ops/test_to_copy_dtype.py:189-191: HalfToBf16 is the same module as ToBf16 (:131-133). The test name and the test plan say fp16 <-> bf16, but only fp16 → bf16 is tested.

Fix these →

Recommendation

Approve

Nothing blocks merging. Both test fixes are cheap, and they would make the new tests catch the numerical differences their names describe.

@JakeStevens
JakeStevens merged commit 11821a3 into pytorch:main Nov 6, 2026
JakeStevens added a commit to JakeStevens/executorch that referenced this pull request Nov 9, 2026
## Ulterior Motive

Keep bf16 RMSNorm in XNNPACK for bf16 models, such as the bf16 LeRobot policies in pytorch#24667, where bf16 ops that XNNPACK does not support fall back to the portable kernels.

## Rationale

**What**: With `enable_bf16=True`, lower a bf16 `mean.dim` as a convert to fp32, the fp32 `XNNStaticMean` from pytorch#24741, and a convert back to bf16, all inside the delegate.

**Why**: XNNPACK has no bf16 reduction, so `MeanDimConfig` rejected every bf16 mean, and a bf16 RMSNorm split the delegate around its mean. XNNPACK does convert between bf16 and fp32, and when the mean has lowered neighbours the two converts cost less than leaving the mean outside the delegate.

## Details

Stacked on pytorch#24741, which adds `XNNStaticMean`; only the last commit is new.

- The converts are written into the program rather than added by the runtime at load time, as bf16 matmul results are. That keeps the change in Python and matches eager bf16 exactly, but a program exported now keeps the converts if XNNPACK's `xnn_define_static_reduce` later accepts bf16, which is the only thing that rejects a bf16 mean today. Each bf16 mean adds an fp32 copy of its input to the XNNPACK workspace (1 MiB for 1x128x2048).
- A bf16 mean never takes the Global Average Pooling path, which has no bf16 kernel. A 4D mean over dims [2, 3] with `keepdim=True` goes through the same converts as any other bf16 mean.
- bf16 means are lowered at any size, as fp16 and fp32 means are in pytorch#24741. fp32 and fp16 programs are byte-identical to pytorch#24741.
- Latency, branch time over main time (below 1 is faster), each a median over at least 1000 timed runs per program, alternating main and branch programs on one runtime, on an Apple M-series CPU (12 performance + 4 efficiency cores) with the default 16 threads: bf16 mean over the last dim of 1x128x2048 0.42x, bf16 RMSNorm 1x128x2048 0.56x, decode RMSNorm 1x1x2048 0.80x, 4 residual blocks of `x + Linear(RMSNorm(x))` at width 2048 0.87x for prefill (64 tokens) and 0.98x for decode (1 token). bf16 RMSNorm on 13 static shapes from 1x1x128 to 1x32x2048 is 0.47x to 0.96x at 1 thread. At 4 threads the shapes under 8K elements with several outputs are 1.19x to 1.57x (1.6 to 3.0 us more) and the rest 0.57x to 0.96x; at 16 threads those small shapes are 3.8x to 5.4x (22 to 24 us more) and the rest 0.70x to 0.99x, the same threadpool cost as for fp32 in pytorch#24741.
- 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 1x128x128 and 16 threads, a lone fp32 mean is 3.2x (as in pytorch#24741) and a lone bf16 mean 5.7x, because of its two converts; at 4 threads the bf16 mean is 1.35x, and at 1 thread it is faster. Any op lowered alone pays this, such as `add` (2.95x at 1x128x128 and 16 threads on main).
- Accuracy against fp32 eager is unchanged for single ops. It improves when the means were the only places a model's delegate split: for the 4 blocks above at prefill, max abs error drops from 4.6e-2 to 1.8e-2, because fewer intermediates are rounded to bf16 at delegate boundaries.
- An RMSNorm that casts to fp32 and back, as Hugging Face Llama does, already has an fp32 mean. Its remaining cost is the bf16 <-> fp32 casts, which pytorch#24745 lowers for programs exported with dim order, and pytorch#24768 for programs exported with `skip_dim_order`.

Test plan:

```
python -m pytest backends/xnnpack/test/ops/test_mean_dim.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.
JakeStevens added a commit to JakeStevens/executorch that referenced this pull request Nov 9, 2026
## Ulterior Motive

Keep bf16 RMSNorm in XNNPACK for bf16 models, such as the bf16 LeRobot policies in pytorch#24667, where bf16 ops that XNNPACK does not support fall back to the portable kernels.

## Rationale

**What**: With `enable_bf16=True`, lower a bf16 `mean.dim` as a convert to fp32, the fp32 `XNNStaticMean` from pytorch#24741, and a convert back to bf16, all inside the delegate.

**Why**: XNNPACK has no bf16 reduction, so `MeanDimConfig` rejected every bf16 mean, and a bf16 RMSNorm split the delegate around its mean. XNNPACK does convert between bf16 and fp32, and when the mean has lowered neighbours the two converts cost less than leaving the mean outside the delegate.

## Details

Stacked on pytorch#24741, which adds `XNNStaticMean`; only the last commit is new.

- The converts are written into the program rather than added by the runtime at load time, as bf16 matmul results are. That keeps the change in Python and matches eager bf16 exactly, but a program exported now keeps the converts if XNNPACK's `xnn_define_static_reduce` later accepts bf16, which is the only thing that rejects a bf16 mean today. Each bf16 mean adds an fp32 copy of its input to the XNNPACK workspace (1 MiB for 1x128x2048).
- A bf16 mean never takes the Global Average Pooling path, which has no bf16 kernel. A 4D mean over dims [2, 3] with `keepdim=True` goes through the same converts as any other bf16 mean.
- bf16 means are lowered at any size, as fp16 and fp32 means are in pytorch#24741. fp32 and fp16 programs are byte-identical to pytorch#24741.
- Latency, branch time over main time (below 1 is faster), each a median over at least 1000 timed runs per program, alternating main and branch programs on one runtime, on an Apple M-series CPU (12 performance + 4 efficiency cores) with the default 16 threads: bf16 mean over the last dim of 1x128x2048 0.42x, bf16 RMSNorm 1x128x2048 0.56x, decode RMSNorm 1x1x2048 0.80x, 4 residual blocks of `x + Linear(RMSNorm(x))` at width 2048 0.87x for prefill (64 tokens) and 0.98x for decode (1 token). bf16 RMSNorm on 13 static shapes from 1x1x128 to 1x32x2048 is 0.47x to 0.96x at 1 thread. At 4 threads the shapes under 8K elements with several outputs are 1.19x to 1.57x (1.6 to 3.0 us more) and the rest 0.57x to 0.96x; at 16 threads those small shapes are 3.8x to 5.4x (22 to 24 us more) and the rest 0.70x to 0.99x, the same threadpool cost as for fp32 in pytorch#24741.
- 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 1x128x128 and 16 threads, a lone fp32 mean is 3.2x (as in pytorch#24741) and a lone bf16 mean 5.7x, because of its two converts; at 4 threads the bf16 mean is 1.35x, and at 1 thread it is faster. Any op lowered alone pays this, such as `add` (2.95x at 1x128x128 and 16 threads on main).
- Accuracy against fp32 eager is unchanged for single ops. It improves when the means were the only places a model's delegate split: for the 4 blocks above at prefill, max abs error drops from 4.6e-2 to 1.8e-2, because fewer intermediates are rounded to bf16 at delegate boundaries.
- An RMSNorm that casts to fp32 and back, as Hugging Face Llama does, already has an fp32 mean. Its remaining cost is the bf16 <-> fp32 casts, which pytorch#24745 lowers for programs exported with dim order, and pytorch#24768 for programs exported with `skip_dim_order`.
- `test_enable_bf16_leaves_ops_xnnpack_cannot_define_in_bf16` no longer lists `mean`, which is now delegated in bf16.

Test plan:

```
python -m pytest backends/xnnpack/test/ops/test_mean_dim.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.
JakeStevens added a commit to JakeStevens/executorch that referenced this pull request Nov 9, 2026
## Ulterior Motive

Keep bf16 RMSNorm in XNNPACK for bf16 models, such as the bf16 LeRobot policies in pytorch#24667, where bf16 ops that XNNPACK does not support fall back to the portable kernels.

## Rationale

**What**: With `enable_bf16=True`, lower a bf16 `mean.dim` as a convert to fp32, the fp32 `XNNStaticMean` from pytorch#24741, and a convert back to bf16, all inside the delegate.

**Why**: XNNPACK has no bf16 reduction, so `MeanDimConfig` rejected every bf16 mean, and a bf16 RMSNorm split the delegate around its mean. XNNPACK does convert between bf16 and fp32, and when the mean has lowered neighbours the two converts cost less than leaving the mean outside the delegate.

## Details

Stacked on pytorch#24741, which adds `XNNStaticMean`; only the last commit is new.

- The converts are written into the program rather than added by the runtime at load time, as bf16 matmul results are. That keeps the change in Python and matches eager bf16 exactly, but a program exported now keeps the converts if XNNPACK's `xnn_define_static_reduce` later accepts bf16, which is the only thing that rejects a bf16 mean today. Each bf16 mean adds an fp32 copy of its input to the XNNPACK workspace (1 MiB for 1x128x2048).
- A bf16 mean never takes the Global Average Pooling path, which has no bf16 kernel. A 4D mean over dims [2, 3] with `keepdim=True` goes through the same converts as any other bf16 mean.
- bf16 means are lowered at any size, as fp16 and fp32 means are in pytorch#24741. fp32 and fp16 programs are byte-identical to pytorch#24741.
- Latency, branch time over main time (below 1 is faster), each a median over at least 1000 timed runs per program, alternating main and branch programs on one runtime, on an Apple M-series CPU (12 performance + 4 efficiency cores) with the default 16 threads: bf16 mean over the last dim of 1x128x2048 0.42x, bf16 RMSNorm 1x128x2048 0.56x, decode RMSNorm 1x1x2048 0.80x, 4 residual blocks of `x + Linear(RMSNorm(x))` at width 2048 0.87x for prefill (64 tokens) and 0.98x for decode (1 token). bf16 RMSNorm on 13 static shapes from 1x1x128 to 1x32x2048 is 0.47x to 0.96x at 1 thread. At 4 threads the shapes under 8K elements with several outputs are 1.19x to 1.57x (1.6 to 3.0 us more) and the rest 0.57x to 0.96x; at 16 threads those small shapes are 3.8x to 5.4x (22 to 24 us more) and the rest 0.70x to 0.99x, the same threadpool cost as for fp32 in pytorch#24741.
- 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 1x128x128 and 16 threads, a lone fp32 mean is 3.2x (as in pytorch#24741) and a lone bf16 mean 5.7x, because of its two converts; at 4 threads the bf16 mean is 1.35x, and at 1 thread it is faster. Any op lowered alone pays this, such as `add` (2.95x at 1x128x128 and 16 threads on main).
- Accuracy against fp32 eager is unchanged for single ops. It improves when the means were the only places a model's delegate split: for the 4 blocks above at prefill, max abs error drops from 4.6e-2 to 1.8e-2, because fewer intermediates are rounded to bf16 at delegate boundaries.
- An RMSNorm that casts to fp32 and back, as Hugging Face Llama does, already has an fp32 mean. Its remaining cost is the bf16 <-> fp32 casts, which pytorch#24745 lowers for programs exported with dim order, and pytorch#24768 for programs exported with `skip_dim_order`.
- `test_enable_bf16_leaves_ops_xnnpack_cannot_define_in_bf16` no longer lists `mean`, which is now delegated in bf16.

Test plan:

```
python -m pytest backends/xnnpack/test/ops/test_mean_dim.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.
JakeStevens added a commit to JakeStevens/executorch that referenced this pull request Nov 9, 2026
## Ulterior Motive

Keep bf16 RMSNorm in XNNPACK for bf16 models, such as the bf16 LeRobot policies in pytorch#24667, where bf16 ops that XNNPACK does not support fall back to the portable kernels.

## Rationale

**What**: With `enable_bf16=True`, lower a bf16 `mean.dim` to the `XNNStaticMean` from pytorch#24741. The serialized graph stays bf16; when the program loads, the runtime reduces in fp32 between a convert from bf16 and a convert back, as it already does for bf16 matmul.

**Why**: XNNPACK has no bf16 reduction, so `MeanDimConfig` rejected every bf16 mean, and a bf16 RMSNorm split the delegate around its mean. XNNPACK does convert between bf16 and fp32, and when the mean has lowered neighbours the two converts cost less than leaving the mean outside the delegate.

## Details

Stacked on pytorch#24741, which adds `XNNStaticMean`; only the last commit is new.

- `defineGenericReduceNode` adds the converts when the serialized input and output are bf16, next to `defineBf16MatMulResult`. The fp32 tensor definition is shared with the matmul path as `defineFp32Like`, and `convertBf16MatMulResult` becomes `convertBf16Result`. Because the program does not encode the converts, a runtime can switch to a native bf16 reduction if XNNPACK adds one, without re-exporting. The XNNPACK subgraph is the same as with the converts serialized: outputs are bitwise identical and latency is within noise. Each bf16 mean adds an fp32 copy of its input to the XNNPACK workspace (1 MiB for 1x128x2048).
- A bf16 mean never takes the Global Average Pooling path, which has no bf16 kernel. A 4D mean over dims [2, 3] with `keepdim=True` is a static mean like any other bf16 mean, so a channels-last input is copied to NCHW for it.
- bf16 means are lowered at any size, as fp16 and fp32 means are in pytorch#24741. fp32 and fp16 programs are byte-identical to pytorch#24741.
- Latency, branch time over main time (below 1 is faster), each a median over at least 1000 timed runs per program, alternating main and branch programs on one runtime, on an Apple M-series CPU (12 performance + 4 efficiency cores) with the default 16 threads: bf16 mean over the last dim of 1x128x2048 0.42x, bf16 RMSNorm 1x128x2048 0.56x, decode RMSNorm 1x1x2048 0.80x, 4 residual blocks of `x + Linear(RMSNorm(x))` at width 2048 0.87x for prefill (64 tokens) and 0.98x for decode (1 token). bf16 RMSNorm on 13 static shapes from 1x1x128 to 1x32x2048 is 0.47x to 0.96x at 1 thread. At 4 threads the shapes under 8K elements with several outputs are 1.19x to 1.57x (1.6 to 3.0 us more) and the rest 0.57x to 0.96x; at 16 threads those small shapes are 3.8x to 5.4x (22 to 24 us more) and the rest 0.70x to 0.99x, the same threadpool cost as for fp32 in pytorch#24741.
- 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 1x128x128 and 16 threads, a lone fp32 mean is 3.2x (as in pytorch#24741) and a lone bf16 mean 5.7x, because of its two converts; at 4 threads the bf16 mean is 1.35x, and at 1 thread it is faster. Any op lowered alone pays this, such as `add` (2.95x at 1x128x128 and 16 threads on main).
- Accuracy against fp32 eager is unchanged for single ops. It improves when the means were the only places a model's delegate split: for the 4 blocks above at prefill, max abs error drops from 4.6e-2 to 1.8e-2, because fewer intermediates are rounded to bf16 at delegate boundaries.
- An RMSNorm that casts to fp32 and back, as Hugging Face Llama does, already has an fp32 mean. Its remaining cost is the bf16 <-> fp32 casts, which pytorch#24745 lowers for programs exported with dim order, and pytorch#24768 for programs exported with `skip_dim_order`.
- `test_enable_bf16_leaves_ops_xnnpack_cannot_define_in_bf16` no longer lists `mean`, which is now delegated in bf16.

Test plan:

```
python -m pytest backends/xnnpack/test/ops/test_mean_dim.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.
JakeStevens added a commit to JakeStevens/executorch that referenced this pull request Nov 9, 2026
Keep bf16 RMSNorm in XNNPACK for bf16 models, such as the bf16 LeRobot policies in pytorch#24667, where bf16 ops that XNNPACK does not support fall back to the portable kernels.

**What**: With `enable_bf16=True`, lower a bf16 `mean.dim` to the `XNNStaticMean` from pytorch#24741. The serialized graph stays bf16; when the program loads, the runtime reduces in fp32 between a convert from bf16 and a convert back, as it already does for bf16 matmul.

**Why**: XNNPACK has no bf16 reduction, so `MeanDimConfig` rejected every bf16 mean, and a bf16 RMSNorm split the delegate around its mean. XNNPACK does convert between bf16 and fp32, and when the mean has lowered neighbours the two converts cost less than leaving the mean outside the delegate.

Stacked on pytorch#24741, which adds `XNNStaticMean`; only the last commit is new.

- `defineGenericReduceNode` adds the converts when the serialized input and output are bf16, next to `defineBf16MatMulResult`. The fp32 tensor definition is shared with the matmul path as `defineFp32Like`, and `convertBf16MatMulResult` becomes `convertBf16Result`. Because the program does not encode the converts, a runtime can switch to a native bf16 reduction if XNNPACK adds one, without re-exporting. The XNNPACK subgraph is the same as with the converts serialized: outputs are bitwise identical and latency is within noise. Each bf16 mean adds an fp32 copy of its input to the XNNPACK workspace (1 MiB for 1x128x2048).
- A bf16 mean never takes the Global Average Pooling path, which has no bf16 kernel. A 4D mean over dims [2, 3] with `keepdim=True` is a static mean like any other bf16 mean, so a channels-last input is copied to NCHW for it.
- bf16 means are lowered at any size, as fp16 and fp32 means are in pytorch#24741. fp32 and fp16 programs are byte-identical to pytorch#24741.
- Latency, branch time over main time (below 1 is faster), each a median over at least 1000 timed runs per program, alternating main and branch programs on one runtime, on an Apple M-series CPU (12 performance + 4 efficiency cores) with the default 16 threads: bf16 mean over the last dim of 1x128x2048 0.42x, bf16 RMSNorm 1x128x2048 0.56x, decode RMSNorm 1x1x2048 0.80x, 4 residual blocks of `x + Linear(RMSNorm(x))` at width 2048 0.87x for prefill (64 tokens) and 0.98x for decode (1 token). bf16 RMSNorm on 13 static shapes from 1x1x128 to 1x32x2048 is 0.47x to 0.96x at 1 thread. At 4 threads the shapes under 8K elements with several outputs are 1.19x to 1.57x (1.6 to 3.0 us more) and the rest 0.57x to 0.96x; at 16 threads those small shapes are 3.8x to 5.4x (22 to 24 us more) and the rest 0.70x to 0.99x, the same threadpool cost as for fp32 in pytorch#24741.
- 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 1x128x128 and 16 threads, a lone fp32 mean is 3.2x (as in pytorch#24741) and a lone bf16 mean 5.7x, because of its two converts; at 4 threads the bf16 mean is 1.35x, and at 1 thread it is faster. Any op lowered alone pays this, such as `add` (2.95x at 1x128x128 and 16 threads on main).
- Accuracy against fp32 eager is unchanged for single ops. It improves when the means were the only places a model's delegate split: for the 4 blocks above at prefill, max abs error drops from 4.6e-2 to 1.8e-2, because fewer intermediates are rounded to bf16 at delegate boundaries.
- An RMSNorm that casts to fp32 and back, as Hugging Face Llama does, already has an fp32 mean. Its remaining cost is the bf16 <-> fp32 casts, which pytorch#24745 lowers for programs exported with dim order, and pytorch#24768 for programs exported with `skip_dim_order`.
- `test_enable_bf16_leaves_ops_xnnpack_cannot_define_in_bf16` no longer lists `mean`, which is now delegated in bf16.

Test plan:

```
python -m pytest backends/xnnpack/test/ops/test_mean_dim.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.
JakeStevens added a commit to JakeStevens/executorch that referenced this pull request Nov 9, 2026
Keep bf16 RMSNorm in XNNPACK for bf16 models, such as the bf16 LeRobot policies in pytorch#24667, where bf16 ops that XNNPACK does not support fall back to the portable kernels.

**What**: With `enable_bf16=True`, lower a bf16 `mean.dim` to the `XNNStaticMean` from pytorch#24741. The serialized graph stays bf16; when the program loads, the runtime reduces in fp32 between a convert from bf16 and a convert back, as it already does for bf16 matmul.

**Why**: XNNPACK has no bf16 reduction, so `MeanDimConfig` rejected every bf16 mean, and a bf16 RMSNorm split the delegate around its mean. XNNPACK does convert between bf16 and fp32, and when the mean has lowered neighbours the two converts cost less than leaving the mean outside the delegate.

Stacked on pytorch#24741, which adds `XNNStaticMean`; only the last commit is new.

- `defineGenericReduceNode` adds the converts when the serialized input and output are bf16, next to `defineBf16MatMulResult`. The fp32 tensor definition is shared with the matmul path as `defineFp32Like`, and `convertBf16MatMulResult` becomes `convertBf16Result`. Because the program does not encode the converts, a runtime can switch to a native bf16 reduction if XNNPACK adds one, without re-exporting. The XNNPACK subgraph is the same as with the converts serialized: outputs are bitwise identical and latency is within noise. Each bf16 mean adds an fp32 copy of its input to the XNNPACK workspace (1 MiB for 1x128x2048).
- A bf16 mean never takes the Global Average Pooling path, which has no bf16 kernel. A 4D mean over dims [2, 3] with `keepdim=True` is a static mean like any other bf16 mean, so a channels-last input is copied to NCHW for it.
- bf16 means are lowered at any size, as fp16 and fp32 means are in pytorch#24741. fp32 and fp16 programs are byte-identical to pytorch#24741.
- Latency, branch time over main time (below 1 is faster), each a median over at least 1000 timed runs per program, alternating main and branch programs on one runtime, on an Apple M-series CPU (12 performance + 4 efficiency cores) with the default 16 threads: bf16 mean over the last dim of 1x128x2048 0.42x, bf16 RMSNorm 1x128x2048 0.56x, decode RMSNorm 1x1x2048 0.80x, 4 residual blocks of `x + Linear(RMSNorm(x))` at width 2048 0.87x for prefill (64 tokens) and 0.98x for decode (1 token). bf16 RMSNorm on 13 static shapes from 1x1x128 to 1x32x2048 is 0.47x to 0.96x at 1 thread. At 4 threads the shapes under 8K elements with several outputs are 1.19x to 1.57x (1.6 to 3.0 us more) and the rest 0.57x to 0.96x; at 16 threads those small shapes are 3.8x to 5.4x (22 to 24 us more) and the rest 0.70x to 0.99x, the same threadpool cost as for fp32 in pytorch#24741.
- 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 1x128x128 and 16 threads, a lone fp32 mean is 3.2x (as in pytorch#24741) and a lone bf16 mean 5.7x, because of its two converts; at 4 threads the bf16 mean is 1.35x, and at 1 thread it is faster. Any op lowered alone pays this, such as `add` (2.95x at 1x128x128 and 16 threads on main).
- Accuracy against fp32 eager is unchanged for single ops. It improves when the means were the only places a model's delegate split: for the 4 blocks above at prefill, max abs error drops from 4.6e-2 to 1.8e-2, because fewer intermediates are rounded to bf16 at delegate boundaries.
- An RMSNorm that casts to fp32 and back, as Hugging Face Llama does, already has an fp32 mean. Its remaining cost is the bf16 <-> fp32 casts, which pytorch#24745 lowers for programs exported with dim order, and pytorch#24768 for programs exported with `skip_dim_order`.
- `test_enable_bf16_leaves_ops_xnnpack_cannot_define_in_bf16` no longer lists `mean`, which is now delegated in bf16.

Test plan:

```
python -m pytest backends/xnnpack/test/ops/test_mean_dim.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.
JakeStevens added a commit to JakeStevens/executorch that referenced this pull request Nov 10, 2026
## Ulterior Motive

Keep bf16 RMSNorm in XNNPACK for bf16 models, such as the bf16 LeRobot policies in pytorch#24667, where bf16 ops that XNNPACK does not support fall back to the portable kernels.

## Rationale

**What**: With `enable_bf16=True`, lower a bf16 `mean.dim` to the `XNNStaticMean` from pytorch#24741. The serialized graph stays bf16; when the program loads, the runtime reduces in fp32 between a convert from bf16 and a convert back, as it already does for bf16 matmul.

**Why**: XNNPACK has no bf16 reduction, so `MeanDimConfig` rejected every bf16 mean, and a bf16 RMSNorm split the delegate around its mean. XNNPACK does convert between bf16 and fp32, and when the mean has lowered neighbours the two converts cost less than leaving the mean outside the delegate.

## Details

- `defineGenericReduceNode` adds the converts when a serialized value is bf16, the same way `defineSoftmaxNode` does since pytorch#24805. It reuses that change's helpers: `convertIfDistinct`, and `defineBf16SoftmaxFp32Value`, renamed `defineFp32ValueForBf16` now that reductions use it too. Because the program does not encode the converts, a runtime can switch to a native bf16 reduction if XNNPACK adds one, without re-exporting. The XNNPACK subgraph is the same as with the converts serialized: outputs are bitwise identical and latency is within noise. Each bf16 mean adds an fp32 copy of its input to the XNNPACK workspace (1 MiB for 1x128x2048).
- A bf16 mean never takes the Global Average Pooling path, which has no bf16 kernel. A 4D mean over dims [2, 3] with `keepdim=True` is a static mean like any other bf16 mean, so a channels-last input is copied to NCHW for it.
- bf16 means are lowered at any size, as fp16 and fp32 means are in pytorch#24741. fp32 and fp16 programs are byte-identical to main.
- Latency, branch time over main time (below 1 is faster), each a median over at least 1000 timed runs per program, alternating main and branch programs on one runtime, on an Apple M-series CPU (12 performance + 4 efficiency cores) with the default 16 threads: bf16 mean over the last dim of 1x128x2048 0.42x, bf16 RMSNorm 1x128x2048 0.56x, decode RMSNorm 1x1x2048 0.80x, 4 residual blocks of `x + Linear(RMSNorm(x))` at width 2048 0.87x for prefill (64 tokens) and 0.98x for decode (1 token). bf16 RMSNorm on 13 static shapes from 1x1x128 to 1x32x2048 is 0.47x to 0.96x at 1 thread. At 4 threads the shapes under 8K elements with several outputs are 1.19x to 1.57x (1.6 to 3.0 us more) and the rest 0.57x to 0.96x; at 16 threads those small shapes are 3.8x to 5.4x (22 to 24 us more) and the rest 0.70x to 0.99x, the same threadpool cost as for fp32 in pytorch#24741.
- 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 1x128x128 and 16 threads, a lone fp32 mean is 3.2x (as in pytorch#24741) and a lone bf16 mean 5.7x, because of its two converts; at 4 threads the bf16 mean is 1.35x, and at 1 thread it is faster. Any op lowered alone pays this, such as `add` (2.95x at 1x128x128 and 16 threads on main).
- Accuracy against fp32 eager is unchanged for single ops. It improves when the means were the only places a model's delegate split: for the 4 blocks above at prefill, max abs error drops from 4.6e-2 to 1.8e-2, because fewer intermediates are rounded to bf16 at delegate boundaries.
- An RMSNorm that casts to fp32 and back, as Hugging Face Llama does, already has an fp32 mean. Its remaining cost is the bf16 <-> fp32 casts, which pytorch#24745 lowers for programs exported with dim order, and pytorch#24768 for programs exported with `skip_dim_order`.
- `test_enable_bf16_leaves_ops_xnnpack_cannot_define_in_bf16` no longer lists `mean`, which is now delegated in bf16.

Test plan:

```
python -m pytest backends/xnnpack/test/ops/test_mean_dim.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.
JakeStevens added a commit that referenced this pull request Nov 10, 2026
## Ulterior Motive

Keep bf16 RMSNorm in XNNPACK for bf16 models, such as the bf16 LeRobot
policies in #24667, where bf16 ops that XNNPACK does not support fall
back to the portable kernels.

## Rationale

**What**: With `enable_bf16=True`, lower a bf16 `mean.dim` to the
`XNNStaticMean` from #24741. The serialized graph stays bf16; when the
program loads, the runtime reduces in fp32 between a convert from bf16
and a convert back, as it already does for bf16 matmul.

**Why**: XNNPACK has no bf16 reduction, so `MeanDimConfig` rejected
every bf16 mean, and a bf16 RMSNorm split the delegate around its mean.
XNNPACK does convert between bf16 and fp32, and when the mean has
lowered neighbours the two converts cost less than leaving the mean
outside the delegate.

## Details

- `defineGenericReduceNode` adds the converts when a serialized value is
bf16, the same way `defineSoftmaxNode` does since #24805. It reuses that
change's helpers: `convertIfDistinct`, and `defineBf16SoftmaxFp32Value`,
renamed `defineFp32ValueForBf16` now that reductions use it too. Because
the program does not encode the converts, a runtime can switch to a
native bf16 reduction if XNNPACK adds one, without re-exporting. The
XNNPACK subgraph is the same as with the converts serialized: outputs
are bitwise identical and latency is within noise. Each bf16 mean adds
an fp32 copy of its input to the XNNPACK workspace (1 MiB for
1x128x2048).
- A bf16 mean never takes the Global Average Pooling path, which has no
bf16 kernel. A 4D mean over dims [2, 3] with `keepdim=True` is a static
mean like any other bf16 mean, so a channels-last input is copied to
NCHW for it.
- bf16 means are lowered at any size, as fp16 and fp32 means are in
#24741. fp32 and fp16 programs are byte-identical to main.
- Latency, branch time over main time (below 1 is faster), each a median
over at least 1000 timed runs per program, alternating main and branch
programs on one runtime, on an Apple M-series CPU (12 performance + 4
efficiency cores) with the default 16 threads: bf16 mean over the last
dim of 1x128x2048 0.42x, bf16 RMSNorm 1x128x2048 0.56x, decode RMSNorm
1x1x2048 0.80x, 4 residual blocks of `x + Linear(RMSNorm(x))` at width
2048 0.87x for prefill (64 tokens) and 0.98x for decode (1 token). bf16
RMSNorm on 13 static shapes from 1x1x128 to 1x32x2048 is 0.47x to 0.96x
at 1 thread. At 4 threads the shapes under 8K elements with several
outputs are 1.19x to 1.57x (1.6 to 3.0 us more) and the rest 0.57x to
0.96x; at 16 threads those small shapes are 3.8x to 5.4x (22 to 24 us
more) and the rest 0.70x to 0.99x, the same threadpool cost as for fp32
in #24741.
- 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 1x128x128 and 16 threads, a
lone fp32 mean is 3.2x (as in #24741) and a lone bf16 mean 5.7x, because
of its two converts; at 4 threads the bf16 mean is 1.35x, and at 1
thread it is faster. Any op lowered alone pays this, such as `add`
(2.95x at 1x128x128 and 16 threads on main).
- Accuracy against fp32 eager is unchanged for single ops. It improves
when the means were the only places a model's delegate split: for the 4
blocks above at prefill, max abs error drops from 4.6e-2 to 1.8e-2,
because fewer intermediates are rounded to bf16 at delegate boundaries.
- An RMSNorm that casts to fp32 and back, as Hugging Face Llama does,
already has an fp32 mean. Its remaining cost is the bf16 <-> fp32 casts,
which #24745 lowers for programs exported with dim order, and #24768 for
programs exported with `skip_dim_order`.
- `test_enable_bf16_leaves_ops_xnnpack_cannot_define_in_bf16` no longer
lists `mean`, which is now delegated in bf16.

Test plan:

```
python -m pytest backends/xnnpack/test/ops/test_mean_dim.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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants