Skip to content

linalg/wasm: fused simd128 f32 kernels for gelu, silu and erf - #2591

Open
czoli1976 wants to merge 2 commits into
sonos:mainfrom
czoli1976:wasm-f32-activations
Open

linalg/wasm: fused simd128 f32 kernels for gelu, silu and erf#2591
czoli1976 wants to merge 2 commits into
sonos:mainfrom
czoli1976:wasm-f32-activations

Conversation

@czoli1976

Copy link
Copy Markdown
Contributor

On plain +simd128 builds (stable toolchain, no relaxed-simd) the gelu_f32, silu_f32 and erf_f32 slots fall back to generic kernels that go through libm (tanh, exp) per element; this adds fused single-pass f32x4 kernels for those three, reusing the scalar kernels' coefficients, and wires them in wasm::plug.

Numbers

Through ElementWiseImpl on 64k elements, wasmtime on x86-64, values in [-3, 3) refilled from a source buffer every iteration (iterated in-place application decays gelu/silu into subnormals and poisons the timing):

kernel generic simd128 speedup
gelu 25.6 ns/elem 3.8 ns/elem 6.7x
silu 6.9 ns/elem 2.6 ns/elem 2.6x
erf 1.65 ns/elem 1.44 ns/elem 1.14x

gelu and silu win big because their generic kernels call libm per element. erf's generic kernel is a pure polynomial that LLVM already auto-vectorizes, so the fused kernel only shaves the powi(16)/store schedule.

What is deliberately absent

No simd128 sigmoid or tanh. I wrote them, measured them, and deleted them: the generic polynomial loops auto-vectorize under +simd128 and beat the handwritten kernels (sigmoid 1.43 vs 2.54 ns/elem, tanh 1.30 vs 2.14 — the auto-vectorized loop unrolls wider and hides the f32x4.div latency). Only the relaxed-simd FMA variants in act.rs beat the generic baseline, and those already exist and keep their override.

The gelu tail

The kernel clamps the pre-tanh argument to [-8.9, 8.9] and the Padé polynomial lands one ulp short of -1 at the low bound, the same unbounded-error tail #2582 fixes on arm64. Rather than ship the bug and fix it later, the lanes the low clamp pinned substitute an exact -1.0 (f32x4_eq + v128_bitselect, two extra ops); gelu_saturates_to_zero_below_the_tanh_clamp covers it out to ±1e6.

Tests

Frame tests for all three kernels plus the tail test, 489 green on wasm32-wasip1 +simd128 under wasmtime; the +relaxed-simd configuration still installs its sigmoid/tanh overrides on top and its act suite is green; native and wasm32-unknown-unknown (no simd) builds are unaffected (linalg::wasm is feature-gated out). cargo fmt / clippy clean.

🍍

@czoli1976

Copy link
Copy Markdown
Contributor Author

Pushed a fix: both kernels were returning the wrong sign on the negative tail, which sign_on_tails (added to the gelu/silu frame tests in d666e1f, after this PR was opened) now catches — so this branch would have failed CI as it stood.

gelu(-5.1262207) returned +3.05e-7. The Padé quotient overshoots past -1 before the argument reaches the low clamp, so the pinned_low bitselect never fires and 1 + tanh goes negative. Saturating the quotient to [-1, 1] fixes it and subsumes the bitselect. silu had the same shape of bug from the other direction: it computed p/q + 0.5 with no [0, 1] clamp, which is exactly what ssigmoid clamps for.

Magnitude is tiny in both cases, so magnitude_on_tails passes and only the sign test fails.

Verified on wasm32-wasip1 under wasmtime, both configurations: 2254 green on +simd128, 2273 on +simd128,+relaxed-simd.

ckristian and others added 2 commits August 25, 2026 07:09
On plain +simd128 builds the gelu, silu and erf slots fall back to generic
kernels that go through libm per element; give them fused single-pass
f32x4 kernels reusing the scalar coefficients, with gelu's tanh saturated
to exactly -1 on low-clamped lanes so it decays to zero like the scalar
path. Sigmoid and tanh keep the generic polynomial kernels, which LLVM
auto-vectorizes into faster code than a handwritten simd128 loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…egative tail

Both kernels built their result from a factor that cancels through zero on the
negative tail: gelu's Pade quotient overshoots past -1 before the argument
reaches the low clamp, so the pinned-lane substitution never fires and 1 + tanh
turns negative, and silu's sigmoid sum was left unclamped where ssigmoid clamps.
Either one returns a positive value for a negative input. Saturate both factors,
which also subsumes the pinned-lane substitution.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@czoli1976

Copy link
Copy Markdown
Contributor Author

Rebasing this onto current main; resolving a real conflict against the matmul dispatch refactor that landed since. Full workspace suite running before I push.

@czoli1976
czoli1976 force-pushed the wasm-f32-activations branch from 5425c87 to 1d252ca Compare August 25, 2026 10:48
@czoli1976

Copy link
Copy Markdown
Contributor Author

Rebased onto current main and pushed. Found and fixed a real latent bug in the process: the activation-kernel wiring in plug() referenced WasmErf4/WasmSilu4/WasmGelu4 unconditionally, but those types are gated behind #[cfg(target_feature = "simd128")] — main has since started compiling this module on native hosts too (via the foreign-inventory feature tract-cli enables), which surfaced it. Gated the wiring to match. The metal-kernel test failures I initially saw were GPU contention from a concurrent test run on my end, not real — confirmed clean in isolation (same known MFA probe-test failure as #2546, nothing else).

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