microsoft / microsoft/onnxruntime

[Performance] CPU EP does not fuse float16 Swish/SiLU to QuickGelu (slow on ARM)

Open
#29,076 1 comment 1 reaction 2 assignees View on GitHub

@tianleiwu is already working on this.

Since Jun 16, 2026.

platform:mobile
Dominant language
C++
Stars
21.9k
Forks
4.2k
Avg merge
4d 11h
Merged PRs (30d)
184

Description

### Describe the issue

On the CPU execution provider, the Swish/SiLU activation `x * Sigmoid(x)` is fused into the fused `QuickGelu` kernel only for `float`. On a `float16` graph the activation runs as separate `Sigmoid` + `Mul` ops, which is meaningfully slower on ARMv8.2-A CPUs (Raspberry Pi 5 / Cortex-A76). Weights and MatMul/Conv are fine in fp16; only the activation regresses.

This is separate from #25824 / #25913 (which enabled fp16 `MatMul`/`Gemm` on CPU). I tested 1.25.1 and 1.26.0 and the activation behavior is the same; that work did not cover the activation fusion.

### Verified against source (checked locally on rel-1.25.1; QuickGelu also confirmed on main)

- `core/providers/cpu/cpu_execution_provider.cc` registers `Sigmoid` on the CPU EP for `float` and `double` only. There is no `MLFloat16` `Sigmoid` CPU kernel, so an fp16 `Sigmoid` on CPU must be cast-wrapped (fp16 -> fp32 -> fp16).
- `contrib_ops/cpu/activations.cc` registers `QuickGelu` for `float` only (`QuickGelu`, kMSDomain, kCpuExecutionProvider). No `MLFloat16` registration.
- `core/optimizer/quick_gelu_fusion.cc` matches `x * Sigmoid(alpha*x)` and fuses to `QuickGelu`. It is EP-gated (CPU is in the target list) but not data-type-gated, and it matches a contiguous `Sigmoid -> Mul`; it does not match through intervening `Cast` nodes.
- `core/mlas/lib/activate_fp16.cpp` / `MlasFp16Activation` (`mlas.h`) already provide fp16 activation routines, so a CPU fp16 kernel has an existing MLAS backing.

Net effect on a CPU fp16 graph: the Swish runs as cast-wrapped `Sigmoid` + `Mul` and is not fused, and there is no fp16 `QuickGelu` CPU kernel to fall back to.

### Measured impact

BirdNET v2.4 (CNN, EfficientNet-style backbone), single 3s @ 48 kHz segment, RPi5 Cortex-A76 x4, CPU EP, governor=performance, 150 iterations, median latency:

| Threads | fp32 | fp16 (all) | fp16, Swish kept fp32 |
|--------:|-----:|-----------:|----------------------:|
| 1 | 145 ms | 162 ms | 140 ms |
| 2 | 83 ms | 104 ms | 85 ms |
| 4 | 58 ms | 79 ms | 64 ms |

Keeping just the Swish `Sigmoid`/`Mul` in fp32 (so the existing fp32 `QuickGelu` fusion fires) while conv/matmul weights stay fp16 recovers ~20% at 4 threads and brings fp16 close to fp32. That isolates the activation fusion as the cause.

Op-level profile (single inference, fp16 model, CPU EP):

```
fp32 model: MatMul 288 ms | QuickGelu 27.7 ms (fused) | Conv ~100 ms | Cast ~0
fp16 model: MatMul 285 ms | Sigmoid 22 ms + Mul 95.6 ms (UNFUSED) | NhwcFusedConv 58 ms | Cast 14.8 ms (2.5%)
```

The `Cast` overhead is small (~2.5%), and `MatMul` is precision-neutral on A76 (it lacks `asimdfhm`/FMLAL, so fp16 GEMM widens to fp32). The whole gap is the unfused Swish: ~118 ms of `Sigmoid`+`Mul` vs ~28 ms of fused `QuickGelu`.

x86 note: on a CPU without native fp16 (i7-1260P, avx2+f16c, no AVX512-FP16) fp16 is within ~4% of fp32 and the activation fusion barely matters, because elementwise throughput is high. The regression is ARM-specific.

### Question for maintainers

Since the fusion pass is not dtype-gated, what prevents the fp16 Swish from fusing on CPU in practice? My working assumption is that precision `Cast` nodes get inserted around the fp16 activation (which has no native CPU kernel) and break the contiguous `Sigmoid -> Mul` the pass requires, and that even if it fused there is no fp16 `QuickGelu` CPU kernel to target. Could you confirm the exact interaction?

### Proposed fix

Register an `MLFloat16` `QuickGelu` kernel (and any fp16 elementwise activation kernel needed so casts are not inserted ahead of the fusion) on the CPU EP, with a graceful fp32 fallback where the CPU lacks native fp16 (so x86 is unaffected). CUDA EP already registers `QuickGelu` for `MLFloat16`, so the fp16 path works there.

This mainly benefits ARM64 edge/mobile inference, where fp16 is attractive for model size / memory.

### Willing to contribute

Yes. I am happy to implement the kernel registration plus optimizer and kernel tests, and provide the before/after numbers above, pending agreement on the approach.

### Urgency

Not urgent. There is a workaround (keep the activation in fp32 during conversion), but it gives back some of the fp16 memory benefit.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.