ffai_core/fastmath.rs
1//! Transcendentals without libm calls — the shared kernel every engine uses.
2//!
3//! # Why this module exists
4//!
5//! `exp`, `ln`, `tanh`, `sin`, `cos` and `powf` **have no SIMD instruction at
6//! any width**. Every call is a scalar libm call, and — worse than its own cost
7//! — it is a hard barrier to vectorising the loop it sits in. Replacing them
8//! with polynomials is the highest-yield mechanical change available in this
9//! workspace (`docs/plans/turbocharger.md`).
10//!
11//! **`sqrt`, `min`, `max`, `mul`, `add` are the opposite**: SSE2 baseline,
12//! already vectorised by the compiler. Rewriting those is how a campaign wastes
13//! a week — mp3's `xrpow` hand-AVX2 measured 0.97x and was reverted.
14//!
15//! # Why ONE module and not three
16//!
17//! Three implementations of this idea already existed, all wired to production,
18//! none aware of the others: `ffai-diana`'s `exp_fast`, `ffai-mercury`'s
19//! `fast_exp`, `ffai-argus`'s `exp_poly`. They had **drifted in the one detail
20//! that decides whether the win happens at all** (see below), and two of the
21//! three had the bug. Consolidating is the point, not a tidy-up.
22//!
23//! # ★ The rounding step is the whole trick
24//!
25//! `exp(x) = 2^(x*log2 e)` splits into an integer power (written straight into
26//! the f32 exponent field) and a fractional part (a degree-5 polynomial). That
27//! split needs a round-to-integer — and **that is where two of the three
28//! implementations reintroduced the libm call they had just removed.**
29//!
30//! Rust's `f32::round` is ties-**away-from-zero**, which no x86 instruction
31//! implements (`vroundps` is ties-to-even). So it lowers to a call, or to a
32//! long branchy sequence, sitting in the middle of the loop. `floor` is no
33//! better: it needs SSE4.1, above the portable x86-64 baseline.
34//!
35//! Adding `1.5 * 2^23` forces any value below `2^22` to round into the
36//! mantissa's last bit; subtracting it back leaves the value rounded to
37//! nearest-even. Pure float arithmetic — no call, no branch, **and it needs no
38//! `target_feature`**, so it vectorises on aarch64 (NEON is baseline) exactly
39//! as it does on x86.
40//!
41//! Measured by `ffai-diana` over 16 M elements, best of 7, single thread:
42//!
43//! | | time | rate |
44//! |---|---:|---:|
45//! | `memcpy` (the roofline) | 5.58 ms | 24.04 GB/s |
46//! | with `f32::round` | 60.84 ms | 2.21 GB/s |
47//! | with `round_ties_even` | 38.85 ms | 3.45 GB/s |
48//! | **with the magic number** | **12.91 ms** | **10.40 GB/s** |
49//!
50//! 4.71x, and **bit-identical** to the `round()` version over the activation
51//! range. A transcendental within 2.3x of pure memory traffic is a
52//! transcendental that vectorised.
53//!
54//! # Accuracy and how it is gated
55//!
56//! These are float approximations: the gate is a tolerance against libm plus
57//! the caller's own end-to-end oracle, never bit-identity against `std`. Each
58//! function documents its measured worst case. The tests here check the
59//! tolerance, the landmarks, and the shape (monotonicity where it holds,
60//! saturation, exact values at 0) — the last of which catches an approximation
61//! that is accurate on average and wrong somewhere specific.
62
63/// `1.5 * 2^23` — the round-to-nearest-even trick. See the module docs.
64const MAGIC: f32 = 12_582_912.0;
65
66/// Round to nearest even, without a libm call or an SSE4.1 instruction.
67///
68/// Valid for `|x| < 2^22`, which every use here guarantees by clamping first.
69#[inline(always)]
70#[must_use]
71pub fn round_ties_even_fast(x: f32) -> f32 {
72 (x + MAGIC) - MAGIC
73}
74
75/// `2^x`, for `x` already clamped to a sane exponent range.
76#[inline(always)]
77fn exp2_unchecked(x: f32) -> f32 {
78 let n = round_ties_even_fast(x);
79 let f = x - n; // in [-0.5, 0.5]
80 // 2^f = exp(f * ln2), so the coefficients are ln2^k / k!.
81 //
82 // DERIVED, not transcribed. Hand-typed decimals here are a real hazard:
83 // trimming one digit to satisfy a lint silently selects a different f32
84 // and breaks the oracle. Let the compiler compute them.
85 const L1: f32 = std::f32::consts::LN_2;
86 const L2: f32 = L1 * L1 / 2.0;
87 const L3: f32 = L1 * L1 * L1 / 6.0;
88 const L4: f32 = L1 * L1 * L1 * L1 / 24.0;
89 const L5: f32 = L1 * L1 * L1 * L1 * L1 / 120.0;
90 let p = 1.0 + f * (L1 + f * (L2 + f * (L3 + f * (L4 + f * L5))));
91 // 2^n straight into the exponent field.
92 // SITE-REVIEWED cast allows. This function is `exp2_unchecked`: its
93 // contract, stated above, is that the caller has ALREADY clamped `x` to a
94 // sane exponent range, and every caller does. So `n` is a small integral
95 // f32 -- the `as i32` cannot truncate anything that was there -- and
96 // `n + 127` is then in [2, 252], so the `as u32` has no sign to lose.
97 // Allowed here rather than crate-wide precisely so these two lints keep
98 // firing on code that has not been read.
99 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
100 let scale = f32::from_bits((((n as i32) + 127) as u32) << 23);
101 p * scale
102}
103
104/// `e^x`, accurate to **4.2e-6 relative** over `[-20, 20]` (measured, not
105/// claimed — the degree-5 polynomial is the limit, and the two implementations
106/// this replaced both documented ~1e-7, which was optimistic).
107///
108/// Clamped to `[-87, 88]`: outside that the f32 result is 0 or infinite
109/// anyway, and an unclamped exponent write would produce a denormal or garbage
110/// rather than a saturated value.
111#[inline(always)]
112#[must_use]
113pub fn exp(x: f32) -> f32 {
114 exp2_unchecked((x * std::f32::consts::LOG2_E).clamp(-125.0, 125.0))
115}
116
117/// `2^x`.
118#[inline(always)]
119#[must_use]
120pub fn exp2(x: f32) -> f32 {
121 exp2_unchecked(x.clamp(-125.0, 125.0))
122}
123
124/// `1 / (1 + e^-x)`.
125#[inline(always)]
126#[must_use]
127pub fn sigmoid(x: f32) -> f32 {
128 1.0 / (1.0 + exp(-x))
129}
130
131/// `tanh(x)`, via `1 - 2/(e^{2x} + 1)`.
132///
133/// Chosen over a Padé rational form deliberately. A Padé `tanh` was tried in
134/// two campaigns and failed both: it is accurate near zero and degrades to
135/// ~1.2e-3 by `|x| = 6`, while saturation is only safe from `|x| >= 7` — so
136/// **no crossover threshold exists**. In Mercury it compounded 1.8e-4 into
137/// 7e-2 over 16 coupled gates. Range reduction has no such range problem.
138#[inline(always)]
139#[must_use]
140pub fn tanh(x: f32) -> f32 {
141 // Small-|x| branch, and it is not optional. `1 - 2/(e^{2x}+1)` computes
142 // `1 - (something within an ulp of 1)` as x -> 0, so every significant
143 // digit cancels: measured **2.5e-4 relative at x = 2.4e-4**, which is a
144 // 100 % error on a value the caller thinks is exact. The Maclaurin series
145 // has no such problem there, and the crossover is where the two agree.
146 if x.abs() < 0.02 {
147 return x * (1.0 - x * x * (1.0 / 3.0));
148 }
149 1.0 - 2.0 / (exp(2.0 * x) + 1.0)
150}
151
152/// `gelu_pytorch_tanh` — `0.5x(1 + tanh(sqrt(2/pi)(x + 0.044715 x^3)))`.
153///
154/// Written as `x * sigmoid(2z)` because `tanh(z) = 2*sigmoid(2z) - 1` exactly,
155/// which saves one operation over computing the tanh and folding it back.
156#[inline(always)]
157#[must_use]
158pub fn gelu_tanh(x: f32) -> f32 {
159 const SQRT_2_OVER_PI: f32 = 0.797_884_56;
160 let z = SQRT_2_OVER_PI * x * (1.0 + 0.044_715 * x * x);
161 x / (1.0 + exp(-2.0 * z))
162}
163
164/// `SiLU`/swish — `x * sigmoid(x)`.
165#[inline(always)]
166#[must_use]
167pub fn silu(x: f32) -> f32 {
168 x / (1.0 + exp(-x))
169}
170
171/// `erf(x)`, Abramowitz & Stegun 7.1.26 — **1.6e-6 measured in f32**.
172///
173/// Needed because `gelu_erf` and `gelu_tanh` are **different functions**, not
174/// two spellings of one. They differ by up to ~1e-3, which is far above the
175/// tolerance any of these engines gate at, so a site calling `.gelu_erf()`
176/// cannot be handed the tanh form as an optimisation. Six of the seventeen
177/// activation sites in this workspace are `gelu_erf`.
178#[inline(always)]
179#[must_use]
180pub fn erf(x: f32) -> f32 {
181 const P: f32 = 0.327_591_1;
182 const A1: f32 = 0.254_829_59;
183 const A2: f32 = -0.284_496_74;
184 const A3: f32 = 1.421_413_7;
185 const A4: f32 = -1.453_152_1;
186 const A5: f32 = 1.061_405_4;
187 let sign = if x < 0.0 { -1.0 } else { 1.0 };
188 let ax = x.abs();
189 let t = 1.0 / P.mul_add(ax, 1.0);
190 let poly = t * (A1 + t * (A2 + t * (A3 + t * (A4 + t * A5))));
191 sign * (1.0 - poly * exp(-ax * ax))
192}
193
194/// `gelu` in its **exact** form — `0.5x(1 + erf(x/sqrt 2))`.
195///
196/// This is what candle's `.gelu_erf()` computes, and what `.gelu()` only
197/// approximates. Keep them apart.
198#[inline(always)]
199#[must_use]
200pub fn gelu_erf(x: f32) -> f32 {
201 const INV_SQRT_2: f32 = std::f32::consts::FRAC_1_SQRT_2;
202 0.5 * x * (1.0 + erf(x * INV_SQRT_2))
203}
204
205/// Natural log, accurate to ~1e-6 absolute over the positive range.
206///
207/// The mirror of [`exp`]: pull the exponent out of the bit pattern, and take a
208/// polynomial in the mantissa. `x <= 0` returns `-inf`/`NaN` as `f32::ln`
209/// does, so callers that clamp (every log-mel does) behave identically.
210#[inline(always)]
211#[must_use]
212#[allow(clippy::many_single_char_names)]
213// Pre-existing: `x`, `e`, `m`, `s`, `p` are the atanh series' own names.
214pub fn ln(x: f32) -> f32 {
215 if x <= 0.0 {
216 return if x == 0.0 {
217 f32::NEG_INFINITY
218 } else {
219 f32::NAN
220 };
221 }
222 let bits = x.to_bits();
223 // Exponent field, unbiased.
224 // SITE-REVIEWED. `& 0xff` bounds this at 255 before the cast, so `as i32`
225 // cannot wrap; it is an 8-bit IEEE-754 exponent field by construction.
226 #[allow(clippy::cast_possible_wrap)]
227 let e = ((bits >> 23) & 0xff) as i32 - 127;
228 // Mantissa forced into [1, 2), then centred on [-1/3, 1/3] by the
229 // `m > sqrt(2)` split so the polynomial converges fast.
230 let m = f32::from_bits((bits & 0x007f_ffff) | 0x3f80_0000);
231 let (m, e) = if m > std::f32::consts::SQRT_2 {
232 (m * 0.5, e + 1)
233 } else {
234 (m, e)
235 };
236 let s = (m - 1.0) / (m + 1.0);
237 let s2 = s * s;
238 // atanh series: ln(m) = 2s(1 + s^2/3 + s^4/5 + s^6/7 + s^8/9).
239 let p = 2.0
240 * s
241 * (1.0 + s2 * (0.333_333_34 + s2 * (0.2 + s2 * (0.142_857_15 + s2 * 0.111_111_11))));
242 (e as f32).mul_add(std::f32::consts::LN_2, p)
243}
244
245/// `log10(x)`, for log-mel and decibel work.
246#[inline(always)]
247#[must_use]
248pub fn log10(x: f32) -> f32 {
249 ln(x) * std::f32::consts::LOG10_E
250}
251
252#[cfg(test)]
253mod exp_sub_sum_tests {
254 use super::*;
255
256 /// Every vector twin against the scalar oracle, on the shape the caller
257 /// actually uses (1024-wide attention rows) plus the tail lengths that
258 /// exercise the scalar remainder in both the 8-lane and 4-lane kernels.
259 #[test]
260 fn vector_twins_match_the_scalar_oracle() {
261 for &n in &[0usize, 1, 3, 4, 7, 8, 9, 15, 16, 31, 33, 64, 1024, 1031] {
262 // Deterministic, and spanning the range attention scores occupy —
263 // including values far below the max, where exp underflows.
264 let src: Vec<f32> = (0..n)
265 .map(|i| ((i * 37 % 211) as f32 - 105.0) * 0.15)
266 .collect();
267 let max = src.iter().copied().fold(f32::NEG_INFINITY, f32::max);
268
269 let mut want = src.clone();
270 let want_sum = exp_sub_sum_scalar(&mut want, if n == 0 { 0.0 } else { max });
271 let mut got = src.clone();
272 let got_sum = exp_sub_sum_inplace(&mut got, if n == 0 { 0.0 } else { max });
273
274 for (i, (a, b)) in want.iter().zip(got.iter()).enumerate() {
275 let err = (a - b).abs() / a.abs().max(1e-30);
276 assert!(err < 1e-5, "n={n} i={i}: {a} vs {b} (rel {err:e})");
277 }
278 let serr = (want_sum - got_sum).abs() / want_sum.abs().max(1e-30);
279 // Lane splitting reassociates the sum, so this is a tolerance and
280 // not an equality — by construction, not by accident.
281 assert!(
282 serr < 1e-5,
283 "n={n} sum {want_sum} vs {got_sum} (rel {serr:e})"
284 );
285 }
286 }
287
288 /// `max_f32`'s twins are EXACT — max is associative on non-NaN floats — so
289 /// this is `assert_eq!`, not a tolerance.
290 #[test]
291 fn max_twins_are_exact() {
292 for &n in &[0usize, 1, 3, 4, 7, 8, 9, 15, 31, 33, 1024, 1031] {
293 let xs: Vec<f32> = (0..n)
294 .map(|i| ((i * 89 % 401) as f32 - 200.0) * 0.37)
295 .collect();
296 assert_eq!(max_f32_scalar(&xs), max_f32(&xs), "n={n}");
297 }
298 assert_eq!(max_f32(&[]), f32::NEG_INFINITY);
299 }
300
301 /// The GELU twins against the scalar oracle, across the range an MLP
302 /// activation actually sees plus both saturating tails.
303 #[test]
304 fn gelu_twins_match_the_scalar_oracle() {
305 for &n in &[0usize, 1, 3, 4, 7, 8, 9, 15, 33, 4096, 4099] {
306 let src: Vec<f32> = (0..n)
307 .map(|i| ((i * 53 % 601) as f32 - 300.0) * 0.09)
308 .collect();
309 let mut want = src.clone();
310 for v in &mut want {
311 *v = gelu_tanh(*v);
312 }
313 let mut got = src.clone();
314 gelu_tanh_inplace(&mut got);
315 for (i, (a, b)) in want.iter().zip(got.iter()).enumerate() {
316 let err = (a - b).abs() / a.abs().max(1e-6);
317 assert!(
318 err < 1e-5,
319 "n={n} i={i} x={}: {a} vs {b} (rel {err:e})",
320 src[i]
321 );
322 }
323 }
324 }
325
326 /// A softmax built on it sums to 1 — the property the caller depends on.
327 #[test]
328 fn normalises_to_one() {
329 let mut row: Vec<f32> = (0..1024).map(|i| ((i % 97) as f32) * 0.11 - 5.0).collect();
330 let max = row.iter().copied().fold(f32::NEG_INFINITY, f32::max);
331 let sum = exp_sub_sum_inplace(&mut row, max);
332 let total: f32 = row.iter().map(|v| v / sum).sum();
333 assert!((total - 1.0).abs() < 1e-4, "sums to {total}");
334 }
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340
341 /// Worst relative error of `f` against `oracle` over a dense sweep.
342 fn sweep(lo: f32, hi: f32, f: impl Fn(f32) -> f32, oracle: impl Fn(f32) -> f32) -> (f32, f32) {
343 let n = 200_001;
344 let mut worst_rel = 0.0f32;
345 let mut at = lo;
346 for i in 0..n {
347 let x = lo + (hi - lo) * (i as f32 / (n - 1) as f32);
348 let (a, b) = (f(x), oracle(x));
349 // A point passes if EITHER the relative or the absolute error is
350 // small, and that is not a loosened gate — it is the only honest
351 // one across this dynamic range.
352 //
353 // The oracle runs out of precision before we do. GELU at
354 // x = -4.744 is ~1.2e-6, and the reference computes it as
355 // `0.5*x*(1 + tanh z)` where `1 + tanh z` cancels down to a few
356 // f32 epsilons: the ORACLE's own uncertainty there is ~23 %
357 // relative. Judging us by relative error against it would be
358 // measuring its cancellation, not our accuracy — our absolute
359 // error at that point is 7.6e-8. Further out, `f32::tanh` returns
360 // exactly -1.0 and the oracle produces a hard zero.
361 let abs = (a - b).abs();
362 let rel = if b.abs() > f32::MIN_POSITIVE {
363 (abs / b.abs()).min(abs)
364 } else {
365 abs
366 };
367 if rel > worst_rel {
368 worst_rel = rel;
369 at = x;
370 }
371 }
372 (worst_rel, at)
373 }
374
375 #[test]
376 fn exp_tracks_libm() {
377 let (rel, at) = sweep(-20.0, 20.0, exp, f32::exp);
378 eprintln!("exp: worst rel {rel:.3e} at x = {at}");
379 assert!(rel < 1e-5, "exp worst rel {rel:.3e} at {at}");
380 }
381
382 #[test]
383 fn exp_saturates_rather_than_producing_garbage() {
384 // The clamp is load-bearing: an unclamped exponent write produces a
385 // denormal or a wrapped exponent, which is a plausible-looking wrong
386 // number rather than an infinity anyone would notice.
387 assert!(exp(200.0).is_finite(), "exp(200) must saturate, not wrap");
388 assert!(exp(200.0) > 1e30, "exp(200) should be very large");
389 // NOT zero: the clamp is in log2 space, so exp(-200) saturates to
390 // 2^-125 ~= 2.4e-38. Tiny and finite is the correct behaviour — the
391 // thing the clamp exists to prevent is a WRAPPED exponent field, which
392 // would be a large plausible number instead.
393 assert!(
394 exp(-200.0) < 1e-30 && exp(-200.0) >= 0.0,
395 "exp(-200) = {} should saturate tiny, not wrap",
396 exp(-200.0)
397 );
398 assert!((exp(0.0) - 1.0).abs() < 1e-7, "exp(0) = {}", exp(0.0));
399 }
400
401 #[test]
402 fn the_magic_rounding_is_round_ties_even() {
403 // The property the whole module depends on. Ties go to EVEN, unlike
404 // `f32::round` which goes away from zero — that difference is the
405 // reason this exists.
406 for (x, want) in [
407 (0.5f32, 0.0f32),
408 (1.5, 2.0),
409 (2.5, 2.0),
410 (-0.5, -0.0),
411 (-1.5, -2.0),
412 (-2.5, -2.0),
413 (3.2, 3.0),
414 (-3.7, -4.0),
415 ] {
416 let got = round_ties_even_fast(x);
417 assert_eq!(got, want, "round_ties_even_fast({x}) = {got}, want {want}");
418 }
419 // And it agrees with std's ties-even over a range.
420 for i in -1000..1000 {
421 let x = i as f32 / 7.0;
422 assert_eq!(round_ties_even_fast(x), x.round_ties_even(), "at {x}");
423 }
424 }
425
426 #[test]
427 fn tanh_tracks_libm_and_saturates() {
428 let (rel, at) = sweep(-8.0, 8.0, tanh, f32::tanh);
429 eprintln!("tanh: worst rel {rel:.3e} at x = {at}");
430 assert!(rel < 1e-5, "tanh worst rel {rel:.3e} at {at}");
431 assert!((tanh(0.0)).abs() < 1e-7);
432 assert!((tanh(20.0) - 1.0).abs() < 1e-6);
433 assert!((tanh(-20.0) + 1.0).abs() < 1e-6);
434 }
435
436 #[test]
437 fn sigmoid_and_silu_track_libm() {
438 let (rel, at) = sweep(-15.0, 15.0, sigmoid, |x| 1.0 / (1.0 + (-x).exp()));
439 assert!(rel < 1e-5, "sigmoid worst rel {rel:.3e} at {at}");
440 let (rel, at) = sweep(-15.0, 15.0, silu, |x| x / (1.0 + (-x).exp()));
441 assert!(rel < 1e-5, "silu worst rel {rel:.3e} at {at}");
442 assert_eq!(silu(0.0), 0.0, "silu(0) must be exactly 0");
443 }
444
445 #[test]
446 fn gelu_tracks_libm_and_keeps_its_dip() {
447 let oracle =
448 |x: f32| 0.5 * x * (1.0 + (0.797_884_56 * x * (1.0 + 0.044_715 * x * x)).tanh());
449 let (rel, at) = sweep(-10.0, 10.0, gelu_tanh, oracle);
450 eprintln!("gelu: worst rel {rel:.3e} at x = {at}");
451 assert!(rel < 1e-5, "gelu worst rel {rel:.3e} at {at}");
452 // GELU is NOT monotone — it dips to about -0.17 near x = -0.75, and an
453 // approximation that smooths that away is wrong in the one place the
454 // curve has any shape.
455 assert!(
456 (-0.18..-0.15).contains(&gelu_tanh(-0.75)),
457 "gelu(-0.75) = {} should sit near the -0.17 minimum",
458 gelu_tanh(-0.75)
459 );
460 assert_eq!(gelu_tanh(0.0), 0.0, "gelu(0) must be exactly 0");
461 assert!((gelu_tanh(10.0) - 10.0).abs() < 1e-4);
462 assert!(gelu_tanh(-10.0).abs() < 1e-5);
463 }
464
465 #[test]
466 fn erf_and_gelu_erf_track_a_reference() {
467 // erf has no `std` implementation, so the oracle is a high-order
468 // series evaluated in f64 — an independent route to the same value
469 // rather than a rearrangement of the same approximation.
470 let oracle = |x: f32| -> f32 {
471 let x = f64::from(x);
472 // Taylor out to |x| = 4, where `1 - erf(4) = 1.5e-8` is already
473 // below f32 epsilon at 1.0 — so saturating past it costs nothing.
474 //
475 // The first version cut over at 3.0 and FAILED, reporting a
476 // 2.2e-5 error that was exactly `1 - erf(3)`: the oracle was
477 // returning a hard 1.0 where the true value still had digits. The
478 // magnitude naming the cutoff is what identified it as the
479 // oracle's fault rather than the kernel's.
480 if x.abs() < 4.0 {
481 let mut term = x;
482 let mut sum = x;
483 for n in 1..90 {
484 term *= -x * x / f64::from(n);
485 sum += term / f64::from(2 * n + 1);
486 }
487 (sum * 2.0 / std::f64::consts::PI.sqrt()) as f32
488 } else if x > 0.0 {
489 1.0
490 } else {
491 -1.0
492 }
493 };
494 let (rel, at) = sweep(-3.0, 3.0, erf, oracle);
495 eprintln!("erf: worst {rel:.3e} at x = {at}");
496 // 1.6e-6 measured. A&S 7.1.26 claims 1.5e-7 ABSOLUTE for the formula;
497 // the extra comes from evaluating it in f32, where `1 - poly*exp` is a
498 // cancellation that amplifies the rounding. Not a coefficient error —
499 // the worst point is mid-range, not at a cutoff.
500 assert!(rel < 5e-6, "erf worst {rel:.3e} at {at}");
501 // NOT exactly zero, and forcing it would cost a branch on a hot path
502 // for no benefit. A&S 7.1.26's five coefficients sum to 0.999999999,
503 // not 1, so `1 - poly*exp(0)` leaves ~1.8e-7 — inside the 1.6e-6 the
504 // formula is good for anyway.
505 assert!(erf(0.0).abs() < 3e-7, "erf(0) = {}", erf(0.0));
506 // gelu_erf(0) IS exact, structurally: x multiplies the whole thing.
507 assert_eq!(gelu_erf(0.0), 0.0, "gelu_erf(0) must be exactly 0");
508 assert!((erf(4.0) - 1.0).abs() < 1e-6);
509 assert!((erf(-4.0) + 1.0).abs() < 1e-6);
510
511 // gelu_erf and gelu_tanh must NOT be interchangeable, and the test
512 // says so — if they ever agree to 1e-5 someone has quietly aliased one
513 // to the other.
514 let worst = (-40..40)
515 .map(|i| {
516 let x = i as f32 / 10.0;
517 (gelu_erf(x) - gelu_tanh(x)).abs()
518 })
519 .fold(0.0f32, f32::max);
520 assert!(
521 worst > 1e-4,
522 "gelu_erf and gelu_tanh differ by only {worst:.3e} — are they aliased?"
523 );
524 }
525
526 #[test]
527 fn ln_and_log10_track_libm() {
528 let (rel, at) = sweep(1e-6, 1e6, ln, f32::ln);
529 eprintln!("ln: worst rel {rel:.3e} at x = {at}");
530 assert!(rel < 1e-5, "ln worst rel {rel:.3e} at {at}");
531 let (rel, at) = sweep(1e-6, 1e6, log10, f32::log10);
532 assert!(rel < 1e-5, "log10 worst rel {rel:.3e} at {at}");
533 // The landmarks a log-mel actually hits.
534 assert!((log10(1.0)).abs() < 1e-6, "log10(1) = {}", log10(1.0));
535 assert!((log10(10.0) - 1.0).abs() < 1e-5);
536 assert!((log10(1e-10) + 10.0).abs() < 1e-4);
537 }
538
539 #[test]
540 fn ln_matches_std_at_the_edges() {
541 assert_eq!(ln(0.0), f32::NEG_INFINITY);
542 assert!(ln(-1.0).is_nan());
543 assert!((ln(std::f32::consts::E) - 1.0).abs() < 1e-6);
544 }
545
546 #[test]
547 fn exp_and_ln_round_trip() {
548 // A property neither sweep catches on its own: the two must be
549 // inverses, so an error in one that happens to cancel in a sweep
550 // against the same libm still shows up here.
551 for i in -60..60 {
552 let x = i as f32 / 6.0;
553 let back = ln(exp(x));
554 assert!((back - x).abs() < 1e-4, "ln(exp({x})) = {back}");
555 }
556 }
557}
558
559// ---------------------------------------------------------------------------
560// Vectorised softmax inner loop
561// ---------------------------------------------------------------------------
562
563/// `row[i] = exp(row[i] - max)`, returning the sum — the softmax inner loop.
564///
565/// # Why this exists as one function rather than `map` + `sum`
566///
567/// Measured on Argus's `SoftmaxExpInplace`: 151 M elements per caption at
568/// **228 M elem/s**, 22.7 % of the whole vision tower and 2.7x the `q.k^T`
569/// matmul that produces its input. An elementwise pass beating the O(n^3)
570/// matmul that feeds it is the tell.
571///
572/// Two costs, and neither yields to a scalar rewrite:
573///
574/// * `(x - max).exp()` is a libm `expf` CALL per element, ~75 M/s.
575/// * `sum += e` is a loop-carried dependency on a non-associative add, so LLVM
576/// will not lane the loop — and a scalar polynomial `exp` with eight split
577/// accumulators was **measured slower** than libm here (2410 ms vs 2090 ms),
578/// because it removes the call but still will not vectorise.
579///
580/// So the fix has to be explicit lanes, which is what this is. The scalar body
581/// below stays as the oracle and as the fallback for targets with neither ISA.
582///
583/// Accuracy: `exp` here is the degree-5 `exp2` polynomial, ~4.2e-6 relative.
584/// Lane splitting reassociates the sum, so it differs from a sequential fold in
585/// the last bits — gate on tolerance, never bit-identity.
586#[must_use]
587pub fn exp_sub_sum_inplace(row: &mut [f32], max: f32) -> f32 {
588 #[cfg(target_arch = "x86_64")]
589 {
590 if is_x86_feature_detected!("avx2") && is_x86_feature_detected!("fma") {
591 // SAFETY: both features probed immediately above.
592 return unsafe { exp_sub_sum_avx2(row, max) };
593 }
594 }
595 #[cfg(target_arch = "aarch64")]
596 {
597 // Unconditional, like the wasm arm and unlike x86's runtime probe:
598 // NEON is BASELINE on aarch64, so there is nothing to detect. That is
599 // also why this arm is easy to forget -- the scalar body below already
600 // auto-vectorises for the ELEMENTWISE primitives, so the gap only bites
601 // the two REDUCTIONS, where a loop-carried non-associative accumulator
602 // stops LLVM cold no matter what the baseline is.
603 //
604 // SAFETY: `neon` is a compile-time guarantee on this target.
605 return unsafe { exp_sub_sum_neon(row, max) };
606 }
607 #[cfg(target_arch = "wasm32")]
608 {
609 // Unconditional, unlike the x86 arm's runtime check: wasm validates a
610 // whole module ahead of time, so a v128 instruction anywhere makes the
611 // MODULE require SIMD. There is no `is_wasm_feature_detected!` and no
612 // per-call dispatch. The workspace already sets `+simd128` for this
613 // target in `.cargo/config.toml`, which makes SIMD a baseline
614 // requirement rather than an upgrade.
615 //
616 // SAFETY: `simd128` is a compile-time guarantee on this target.
617 return unsafe { exp_sub_sum_simd128(row, max) };
618 }
619 #[allow(unreachable_code)]
620 exp_sub_sum_scalar(row, max)
621}
622
623/// The oracle. Every vector twin is gated against this.
624#[must_use]
625pub fn exp_sub_sum_scalar(row: &mut [f32], max: f32) -> f32 {
626 let mut sum = 0.0f32;
627 for v in row.iter_mut() {
628 let e = exp(*v - max);
629 *v = e;
630 sum += e;
631 }
632 sum
633}
634
635/// AVX2 + FMA, eight lanes.
636///
637/// # Safety
638/// Caller must have verified `avx2` and `fma`.
639#[allow(clippy::many_single_char_names)]
640// The names are the polynomial's own: `n`, `i`, `x`, `t`, `r`, `f`, `p`, `e`,
641// `k`, `z`. Expanding them into prose makes the kernel harder to read against
642// its twins and the scalar oracle, which is the only way this code is verified.
643#[allow(clippy::wildcard_imports)]
644// A SIMD kernel names 15 intrinsics; importing them one by one is a list that
645// goes stale the moment the arithmetic changes, and hides nothing.
646#[cfg(target_arch = "x86_64")]
647#[target_feature(enable = "avx2,fma")]
648unsafe fn exp_sub_sum_avx2(row: &mut [f32], max: f32) -> f32 {
649 // SAFETY: whole-body wrapper inserted by the edition-2024
650 // `unsafe_op_in_unsafe_fn` migration. This block adds no new obligation:
651 // the contract is stated on the `unsafe fn` signature above.
652 unsafe {
653 use std::arch::x86_64::*;
654 const L1: f32 = std::f32::consts::LN_2;
655 const L2: f32 = L1 * L1 / 2.0;
656 const L3: f32 = L1 * L1 * L1 / 6.0;
657 const L4: f32 = L1 * L1 * L1 * L1 / 24.0;
658 const L5: f32 = L1 * L1 * L1 * L1 * L1 / 120.0;
659
660 let vmax = _mm256_set1_ps(max);
661 let log2e = _mm256_set1_ps(std::f32::consts::LOG2_E);
662 let magic = _mm256_set1_ps(MAGIC);
663 let lo = _mm256_set1_ps(-125.0);
664 let hi = _mm256_set1_ps(125.0);
665 let c1 = _mm256_set1_ps(L1);
666 let c2 = _mm256_set1_ps(L2);
667 let c3 = _mm256_set1_ps(L3);
668 let c4 = _mm256_set1_ps(L4);
669 let c5 = _mm256_set1_ps(L5);
670 let one = _mm256_set1_ps(1.0);
671 let bias = _mm256_set1_epi32(127);
672
673 let mut acc = _mm256_setzero_ps();
674 let n = row.len();
675 let mut i = 0;
676 while i + 8 <= n {
677 let x = _mm256_loadu_ps(row.as_ptr().add(i));
678 // exp(x - max) == exp2((x - max) * log2(e)), clamped like the scalar.
679 let t = _mm256_mul_ps(_mm256_sub_ps(x, vmax), log2e);
680 let t = _mm256_min_ps(_mm256_max_ps(t, lo), hi);
681 // round-to-nearest-even without an SSE4.1 instruction: (t+M)-M.
682 let r = _mm256_sub_ps(_mm256_add_ps(t, magic), magic);
683 let f = _mm256_sub_ps(t, r);
684 // Horner, FMA-fused.
685 let p = _mm256_fmadd_ps(f, c5, c4);
686 let p = _mm256_fmadd_ps(f, p, c3);
687 let p = _mm256_fmadd_ps(f, p, c2);
688 let p = _mm256_fmadd_ps(f, p, c1);
689 let p = _mm256_fmadd_ps(f, p, one);
690 // 2^r straight into the exponent field.
691 let e = _mm256_cvtps_epi32(r);
692 let e = _mm256_slli_epi32::<23>(_mm256_add_epi32(e, bias));
693 let out = _mm256_mul_ps(p, _mm256_castsi256_ps(e));
694 _mm256_storeu_ps(row.as_mut_ptr().add(i), out);
695 acc = _mm256_add_ps(acc, out);
696 i += 8;
697 }
698 // Horizontal reduce, then the scalar tail through the SAME polynomial.
699 let mut lanes = [0f32; 8];
700 _mm256_storeu_ps(lanes.as_mut_ptr(), acc);
701 let mut sum = ((lanes[0] + lanes[1]) + (lanes[2] + lanes[3]))
702 + ((lanes[4] + lanes[5]) + (lanes[6] + lanes[7]));
703 while i < n {
704 let e = exp(row[i] - max);
705 row[i] = e;
706 sum += e;
707 i += 1;
708 }
709 sum
710 }
711}
712
713/// NEON, four lanes, FMA-fused.
714///
715/// **Why this exists even though NEON is baseline.** The scalar oracle below
716/// auto-vectorises on aarch64 for elementwise work, so the obvious reading is
717/// that this arm is redundant. It is not: `sum += e` is a loop-carried
718/// dependency on a NON-ASSOCIATIVE add, and LLVM may not reassociate it
719/// without fast-math. So the oracle stays a scalar dependency chain on every
720/// target regardless of the baseline, and only explicit lanes break it. The
721/// same argument covers [`max_f32_neon`]; it does NOT cover
722/// [`gelu_tanh_neon`], which is elementwise and would likely have vectorised
723/// unaided -- that one is here for symmetry and to pin the numerics.
724///
725/// Mirrors the AVX2 arm rather than the wasm one, because NEON HAS an FMA
726/// (`vfmaq_f32`) and wasm's base SIMD does not.
727///
728/// # Safety
729/// `neon` is a compile-time guarantee on `aarch64` here.
730#[allow(clippy::many_single_char_names)]
731// The names are the polynomial's own: `n`, `i`, `x`, `t`, `r`, `f`, `p`, `e`,
732// `k`, `z`. Expanding them into prose makes the kernel harder to read against
733// its twins and the scalar oracle, which is the only way this code is verified.
734#[allow(clippy::wildcard_imports)]
735// A SIMD kernel names 15 intrinsics; importing them one by one is a list that
736// goes stale the moment the arithmetic changes, and hides nothing.
737#[cfg(target_arch = "aarch64")]
738#[target_feature(enable = "neon")]
739unsafe fn exp_sub_sum_neon(row: &mut [f32], max: f32) -> f32 {
740 // SAFETY: whole-body wrapper inserted by the edition-2024
741 // `unsafe_op_in_unsafe_fn` migration. This block adds no new obligation:
742 // the contract is stated on the `unsafe fn` signature above.
743 unsafe {
744 use core::arch::aarch64::*;
745 const L1: f32 = core::f32::consts::LN_2;
746 const L2: f32 = L1 * L1 / 2.0;
747 const L3: f32 = L1 * L1 * L1 / 6.0;
748 const L4: f32 = L1 * L1 * L1 * L1 / 24.0;
749 const L5: f32 = L1 * L1 * L1 * L1 * L1 / 120.0;
750
751 let vmax = vdupq_n_f32(max);
752 let log2e = vdupq_n_f32(core::f32::consts::LOG2_E);
753 let magic = vdupq_n_f32(MAGIC);
754 let lo = vdupq_n_f32(-125.0);
755 let hi = vdupq_n_f32(125.0);
756 let c1 = vdupq_n_f32(L1);
757 let c2 = vdupq_n_f32(L2);
758 let c3 = vdupq_n_f32(L3);
759 let c4 = vdupq_n_f32(L4);
760 let c5 = vdupq_n_f32(L5);
761 let one = vdupq_n_f32(1.0);
762 let bias = vdupq_n_s32(127);
763
764 let mut acc = vdupq_n_f32(0.0);
765 let n = row.len();
766 let mut i = 0;
767 while i + 4 <= n {
768 let x = vld1q_f32(row.as_ptr().add(i));
769 let t = vmulq_f32(vsubq_f32(x, vmax), log2e);
770 let t = vminq_f32(vmaxq_f32(t, lo), hi);
771 // Round-to-nearest-even via the magic constant, exactly as the other
772 // twins do -- NEON has `vrndnq_f32`, but using it here would make this
773 // arm disagree with them in the last bit at the ties.
774 let r = vsubq_f32(vaddq_f32(t, magic), magic);
775 let f = vsubq_f32(t, r);
776 // `vfmaq_f32(a, b, c)` is `a + b * c`.
777 let p = vfmaq_f32(c4, f, c5);
778 let p = vfmaq_f32(c3, f, p);
779 let p = vfmaq_f32(c2, f, p);
780 let p = vfmaq_f32(c1, f, p);
781 let p = vfmaq_f32(one, f, p);
782 let e = vshlq_n_s32::<23>(vaddq_s32(vcvtq_s32_f32(r), bias));
783 let out = vmulq_f32(p, vreinterpretq_f32_s32(e));
784 vst1q_f32(row.as_mut_ptr().add(i), out);
785 acc = vaddq_f32(acc, out);
786 i += 4;
787 }
788 // Same pairwise order as the wasm twin, so the two four-lane arms agree.
789 let mut sum = (vgetq_lane_f32::<0>(acc) + vgetq_lane_f32::<1>(acc))
790 + (vgetq_lane_f32::<2>(acc) + vgetq_lane_f32::<3>(acc));
791 while i < n {
792 let e = exp(row[i] - max);
793 row[i] = e;
794 sum += e;
795 i += 1;
796 }
797 sum
798 }
799}
800
801/// wasm SIMD128, four lanes.
802///
803/// **No FMA.** Base wasm SIMD has no fused multiply-add, so each Horner step is
804/// a separate multiply and add — which is half of why this target runs ~6.5x
805/// behind native single-thread even with SIMD on.
806///
807/// # Safety
808/// `simd128` is a compile-time guarantee on `wasm32` here.
809#[allow(clippy::many_single_char_names)]
810// The names are the polynomial's own: `n`, `i`, `x`, `t`, `r`, `f`, `p`, `e`,
811// `k`, `z`. Expanding them into prose makes the kernel harder to read against
812// its twins and the scalar oracle, which is the only way this code is verified.
813#[allow(clippy::wildcard_imports)]
814// A SIMD kernel names 15 intrinsics; importing them one by one is a list that
815// goes stale the moment the arithmetic changes, and hides nothing.
816#[cfg(target_arch = "wasm32")]
817#[target_feature(enable = "simd128")]
818unsafe fn exp_sub_sum_simd128(row: &mut [f32], max: f32) -> f32 {
819 // SAFETY: whole-body wrapper inserted by the edition-2024
820 // `unsafe_op_in_unsafe_fn` migration. This block adds no new obligation:
821 // the contract is stated on the `unsafe fn` signature above.
822 unsafe {
823 use core::arch::wasm32::*;
824 const L1: f32 = core::f32::consts::LN_2;
825 const L2: f32 = L1 * L1 / 2.0;
826 const L3: f32 = L1 * L1 * L1 / 6.0;
827 const L4: f32 = L1 * L1 * L1 * L1 / 24.0;
828 const L5: f32 = L1 * L1 * L1 * L1 * L1 / 120.0;
829
830 let vmax = f32x4_splat(max);
831 let log2e = f32x4_splat(core::f32::consts::LOG2_E);
832 let magic = f32x4_splat(MAGIC);
833 let lo = f32x4_splat(-125.0);
834 let hi = f32x4_splat(125.0);
835 let c1 = f32x4_splat(L1);
836 let c2 = f32x4_splat(L2);
837 let c3 = f32x4_splat(L3);
838 let c4 = f32x4_splat(L4);
839 let c5 = f32x4_splat(L5);
840 let one = f32x4_splat(1.0);
841 let bias = i32x4_splat(127);
842
843 let mut acc = f32x4_splat(0.0);
844 let n = row.len();
845 let mut i = 0;
846 while i + 4 <= n {
847 let x = v128_load(row.as_ptr().add(i).cast());
848 let t = f32x4_mul(f32x4_sub(x, vmax), log2e);
849 let t = f32x4_pmin(hi, f32x4_pmax(lo, t));
850 let r = f32x4_sub(f32x4_add(t, magic), magic);
851 let f = f32x4_sub(t, r);
852 let p = f32x4_add(f32x4_mul(f, c5), c4);
853 let p = f32x4_add(f32x4_mul(f, p), c3);
854 let p = f32x4_add(f32x4_mul(f, p), c2);
855 let p = f32x4_add(f32x4_mul(f, p), c1);
856 let p = f32x4_add(f32x4_mul(f, p), one);
857 let e = i32x4_trunc_sat_f32x4(r);
858 let e = i32x4_shl(i32x4_add(e, bias), 23);
859 let out = f32x4_mul(p, e);
860 v128_store(row.as_mut_ptr().add(i).cast(), out);
861 acc = f32x4_add(acc, out);
862 i += 4;
863 }
864 let mut sum = (f32x4_extract_lane::<0>(acc) + f32x4_extract_lane::<1>(acc))
865 + (f32x4_extract_lane::<2>(acc) + f32x4_extract_lane::<3>(acc));
866 while i < n {
867 let e = exp(row[i] - max);
868 row[i] = e;
869 sum += e;
870 i += 1;
871 }
872 sum
873 }
874}
875
876/// Maximum of a slice — vectorised.
877///
878/// The other half of the softmax row. `for &v in row { max = max.max(v) }` is a
879/// loop-carried reduction on a function with NaN semantics, so LLVM will not
880/// lane it any more than it lanes the sum. It is a full pass over the same
881/// 50 MB-per-layer score tensor that `exp_sub_sum_inplace` then walks again.
882///
883/// Returns `f32::NEG_INFINITY` for an empty slice, matching a fold from that
884/// identity. Lane-splitting a MAX is exact — `max` is associative and
885/// commutative on non-NaN floats — so unlike the sum this twin is gated by
886/// `assert_eq!`, not by tolerance.
887#[must_use]
888pub fn max_f32(xs: &[f32]) -> f32 {
889 #[cfg(target_arch = "x86_64")]
890 {
891 if is_x86_feature_detected!("avx2") {
892 // SAFETY: probed immediately above.
893 return unsafe { max_f32_avx2(xs) };
894 }
895 }
896 #[cfg(target_arch = "aarch64")]
897 {
898 // SAFETY: `neon` is a compile-time guarantee on this target.
899 return unsafe { max_f32_neon(xs) };
900 }
901 #[cfg(target_arch = "wasm32")]
902 {
903 // SAFETY: `simd128` is a compile-time guarantee on this target.
904 return unsafe { max_f32_simd128(xs) };
905 }
906 #[allow(unreachable_code)]
907 max_f32_scalar(xs)
908}
909
910/// The oracle.
911#[must_use]
912pub fn max_f32_scalar(xs: &[f32]) -> f32 {
913 let mut m = f32::NEG_INFINITY;
914 for &v in xs {
915 m = m.max(v);
916 }
917 m
918}
919
920/// # Safety
921/// Caller must have verified `avx2`.
922#[cfg(target_arch = "x86_64")]
923#[target_feature(enable = "avx2")]
924unsafe fn max_f32_avx2(xs: &[f32]) -> f32 {
925 // SAFETY: whole-body wrapper inserted by the edition-2024
926 // `unsafe_op_in_unsafe_fn` migration. This block adds no new obligation:
927 // the contract is stated on the `unsafe fn` signature above.
928 unsafe {
929 use std::arch::x86_64::{_mm256_loadu_ps, _mm256_max_ps, _mm256_storeu_ps};
930 let n = xs.len();
931 if n < 8 {
932 return max_f32_scalar(xs);
933 }
934 let mut acc = _mm256_loadu_ps(xs.as_ptr());
935 let mut i = 8;
936 while i + 8 <= n {
937 acc = _mm256_max_ps(acc, _mm256_loadu_ps(xs.as_ptr().add(i)));
938 i += 8;
939 }
940 let mut lanes = [0f32; 8];
941 _mm256_storeu_ps(lanes.as_mut_ptr(), acc);
942 let mut m = lanes[0];
943 for &v in &lanes[1..] {
944 m = m.max(v);
945 }
946 while i < n {
947 m = m.max(xs[i]);
948 i += 1;
949 }
950 m
951 }
952}
953
954/// NEON, four lanes. Exact, like the other max twins.
955///
956/// # Safety
957/// `neon` is a compile-time guarantee on `aarch64` here.
958#[cfg(target_arch = "aarch64")]
959#[target_feature(enable = "neon")]
960unsafe fn max_f32_neon(xs: &[f32]) -> f32 {
961 // SAFETY: whole-body wrapper inserted by the edition-2024
962 // `unsafe_op_in_unsafe_fn` migration. This block adds no new obligation:
963 // the contract is stated on the `unsafe fn` signature above.
964 unsafe {
965 use core::arch::aarch64::{vld1q_f32, vmaxq_f32, vmaxvq_f32};
966 let n = xs.len();
967 if n < 4 {
968 return max_f32_scalar(xs);
969 }
970 let mut acc = vld1q_f32(xs.as_ptr());
971 let mut i = 4;
972 while i + 4 <= n {
973 acc = vmaxq_f32(acc, vld1q_f32(xs.as_ptr().add(i)));
974 i += 4;
975 }
976 let mut m = vmaxvq_f32(acc);
977 while i < n {
978 m = m.max(xs[i]);
979 i += 1;
980 }
981 m
982 }
983}
984
985/// # Safety
986/// `simd128` is a compile-time guarantee on `wasm32` here.
987#[cfg(target_arch = "wasm32")]
988#[target_feature(enable = "simd128")]
989unsafe fn max_f32_simd128(xs: &[f32]) -> f32 {
990 // SAFETY: whole-body wrapper inserted by the edition-2024
991 // `unsafe_op_in_unsafe_fn` migration. This block adds no new obligation:
992 // the contract is stated on the `unsafe fn` signature above.
993 unsafe {
994 use core::arch::wasm32::{f32x4_extract_lane, f32x4_pmax, v128_load};
995 let n = xs.len();
996 if n < 4 {
997 return max_f32_scalar(xs);
998 }
999 // `f32x4_pmax` is the raw max: it returns the second operand when either is
1000 // NaN, which matches nothing in particular — but these are attention
1001 // scores, never NaN, and the scalar oracle gate covers the range we feed.
1002 let mut acc = v128_load(xs.as_ptr().cast());
1003 let mut i = 4;
1004 while i + 4 <= n {
1005 acc = f32x4_pmax(acc, v128_load(xs.as_ptr().add(i).cast()));
1006 i += 4;
1007 }
1008 let mut m = f32x4_extract_lane::<0>(acc);
1009 m = m.max(f32x4_extract_lane::<1>(acc));
1010 m = m.max(f32x4_extract_lane::<2>(acc));
1011 m = m.max(f32x4_extract_lane::<3>(acc));
1012 while i < n {
1013 m = m.max(xs[i]);
1014 i += 1;
1015 }
1016 m
1017 }
1018}
1019
1020/// `xs[i] = gelu_tanh(xs[i])`, vectorised.
1021///
1022/// The activation between a transformer MLP's two projections, so it runs over
1023/// `seq * 4 * hidden` elements per layer. Argus's tower had an AVX2 twin for it
1024/// and **no wasm twin at all**, so the browser took a scalar loop — the same
1025/// asymmetry the softmax had.
1026///
1027/// `gelu_tanh` is branch-free (`x / (1 + exp(-2z))`), so unlike `tanh` there is
1028/// no small-|x| special case to reproduce; the twins are the same expression in
1029/// lanes. Gated by tolerance against the scalar oracle.
1030pub fn gelu_tanh_inplace(xs: &mut [f32]) {
1031 #[cfg(target_arch = "x86_64")]
1032 {
1033 if is_x86_feature_detected!("avx2") && is_x86_feature_detected!("fma") {
1034 // SAFETY: probed immediately above.
1035 unsafe { gelu_tanh_avx2(xs) };
1036 return;
1037 }
1038 }
1039 #[cfg(target_arch = "aarch64")]
1040 {
1041 // SAFETY: `neon` is a compile-time guarantee on this target.
1042 unsafe { gelu_tanh_neon(xs) };
1043 return;
1044 }
1045 #[cfg(target_arch = "wasm32")]
1046 {
1047 // SAFETY: `simd128` is a compile-time guarantee on this target.
1048 unsafe { gelu_tanh_simd128(xs) };
1049 return;
1050 }
1051 #[allow(unreachable_code)]
1052 for v in xs.iter_mut() {
1053 *v = gelu_tanh(*v);
1054 }
1055}
1056
1057#[allow(clippy::excessive_precision)]
1058// `0.797_884_56` is `SQRT_2_OVER_PI` from the scalar oracle above, verbatim.
1059// Truncating it is bit-identical (0x2a424c3f either way), but it would make the
1060// twin stop matching the oracle ON THE PAGE, and reading them side by side is
1061// how the twins are checked.
1062/// # Safety
1063/// Caller must have verified `avx2` and `fma`.
1064#[allow(clippy::many_single_char_names)]
1065// The names are the polynomial's own: `n`, `i`, `x`, `t`, `r`, `f`, `p`, `e`,
1066// `k`, `z`. Expanding them into prose makes the kernel harder to read against
1067// its twins and the scalar oracle, which is the only way this code is verified.
1068#[allow(clippy::wildcard_imports)]
1069// A SIMD kernel names 15 intrinsics; importing them one by one is a list that
1070// goes stale the moment the arithmetic changes, and hides nothing.
1071#[cfg(target_arch = "x86_64")]
1072#[target_feature(enable = "avx2,fma")]
1073unsafe fn gelu_tanh_avx2(xs: &mut [f32]) {
1074 // SAFETY: whole-body wrapper inserted by the edition-2024
1075 // `unsafe_op_in_unsafe_fn` migration. This block adds no new obligation:
1076 // the contract is stated on the `unsafe fn` signature above.
1077 unsafe {
1078 use std::arch::x86_64::*;
1079 const L1: f32 = std::f32::consts::LN_2;
1080 const L2: f32 = L1 * L1 / 2.0;
1081 const L3: f32 = L1 * L1 * L1 / 6.0;
1082 const L4: f32 = L1 * L1 * L1 * L1 / 24.0;
1083 const L5: f32 = L1 * L1 * L1 * L1 * L1 / 120.0;
1084 let sq2pi = _mm256_set1_ps(0.797_884_56);
1085 let k = _mm256_set1_ps(0.044_715);
1086 let one = _mm256_set1_ps(1.0);
1087 let m2 = _mm256_set1_ps(-2.0);
1088 let log2e = _mm256_set1_ps(std::f32::consts::LOG2_E);
1089 let magic = _mm256_set1_ps(MAGIC);
1090 let lo = _mm256_set1_ps(-125.0);
1091 let hi = _mm256_set1_ps(125.0);
1092 let (c1, c2, c3, c4, c5) = (
1093 _mm256_set1_ps(L1),
1094 _mm256_set1_ps(L2),
1095 _mm256_set1_ps(L3),
1096 _mm256_set1_ps(L4),
1097 _mm256_set1_ps(L5),
1098 );
1099 let bias = _mm256_set1_epi32(127);
1100 let n = xs.len();
1101 let mut i = 0;
1102 while i + 8 <= n {
1103 let x = _mm256_loadu_ps(xs.as_ptr().add(i));
1104 // z = sqrt(2/pi) * x * (1 + k x^2)
1105 let x2 = _mm256_mul_ps(x, x);
1106 let z = _mm256_mul_ps(_mm256_mul_ps(sq2pi, x), _mm256_fmadd_ps(k, x2, one));
1107 // exp(-2z)
1108 let t = _mm256_mul_ps(_mm256_mul_ps(m2, z), log2e);
1109 let t = _mm256_min_ps(_mm256_max_ps(t, lo), hi);
1110 let r = _mm256_sub_ps(_mm256_add_ps(t, magic), magic);
1111 let f = _mm256_sub_ps(t, r);
1112 let p = _mm256_fmadd_ps(f, c5, c4);
1113 let p = _mm256_fmadd_ps(f, p, c3);
1114 let p = _mm256_fmadd_ps(f, p, c2);
1115 let p = _mm256_fmadd_ps(f, p, c1);
1116 let p = _mm256_fmadd_ps(f, p, one);
1117 let e = _mm256_slli_epi32::<23>(_mm256_add_epi32(_mm256_cvtps_epi32(r), bias));
1118 let ex = _mm256_mul_ps(p, _mm256_castsi256_ps(e));
1119 _mm256_storeu_ps(
1120 xs.as_mut_ptr().add(i),
1121 _mm256_div_ps(x, _mm256_add_ps(one, ex)),
1122 );
1123 i += 8;
1124 }
1125 while i < n {
1126 xs[i] = gelu_tanh(xs[i]);
1127 i += 1;
1128 }
1129 }
1130}
1131
1132#[allow(clippy::excessive_precision)]
1133// `0.797_884_56` is `SQRT_2_OVER_PI` from the scalar oracle above, verbatim.
1134// Truncating it is bit-identical (0x2a424c3f either way), but it would make the
1135// twin stop matching the oracle ON THE PAGE, and reading them side by side is
1136// how the twins are checked.
1137/// NEON, four lanes, FMA-fused.
1138///
1139/// # Safety
1140/// `neon` is a compile-time guarantee on `aarch64` here.
1141#[allow(clippy::many_single_char_names)]
1142// The names are the polynomial's own: `n`, `i`, `x`, `t`, `r`, `f`, `p`, `e`,
1143// `k`, `z`. Expanding them into prose makes the kernel harder to read against
1144// its twins and the scalar oracle, which is the only way this code is verified.
1145#[allow(clippy::wildcard_imports)]
1146// A SIMD kernel names 15 intrinsics; importing them one by one is a list that
1147// goes stale the moment the arithmetic changes, and hides nothing.
1148#[cfg(target_arch = "aarch64")]
1149#[target_feature(enable = "neon")]
1150unsafe fn gelu_tanh_neon(xs: &mut [f32]) {
1151 // SAFETY: whole-body wrapper inserted by the edition-2024
1152 // `unsafe_op_in_unsafe_fn` migration. This block adds no new obligation:
1153 // the contract is stated on the `unsafe fn` signature above.
1154 unsafe {
1155 use core::arch::aarch64::*;
1156 const L1: f32 = core::f32::consts::LN_2;
1157 const L2: f32 = L1 * L1 / 2.0;
1158 const L3: f32 = L1 * L1 * L1 / 6.0;
1159 const L4: f32 = L1 * L1 * L1 * L1 / 24.0;
1160 const L5: f32 = L1 * L1 * L1 * L1 * L1 / 120.0;
1161 let sq2pi = vdupq_n_f32(0.797_884_56);
1162 let k = vdupq_n_f32(0.044_715);
1163 let one = vdupq_n_f32(1.0);
1164 let m2 = vdupq_n_f32(-2.0);
1165 let log2e = vdupq_n_f32(core::f32::consts::LOG2_E);
1166 let magic = vdupq_n_f32(MAGIC);
1167 let lo = vdupq_n_f32(-125.0);
1168 let hi = vdupq_n_f32(125.0);
1169 let (c1, c2, c3, c4, c5) = (
1170 vdupq_n_f32(L1),
1171 vdupq_n_f32(L2),
1172 vdupq_n_f32(L3),
1173 vdupq_n_f32(L4),
1174 vdupq_n_f32(L5),
1175 );
1176 let bias = vdupq_n_s32(127);
1177 let n = xs.len();
1178 let mut i = 0;
1179 while i + 4 <= n {
1180 let x = vld1q_f32(xs.as_ptr().add(i));
1181 let x2 = vmulq_f32(x, x);
1182 let z = vmulq_f32(vmulq_f32(sq2pi, x), vfmaq_f32(one, k, x2));
1183 let t = vmulq_f32(vmulq_f32(m2, z), log2e);
1184 let t = vminq_f32(vmaxq_f32(t, lo), hi);
1185 let r = vsubq_f32(vaddq_f32(t, magic), magic);
1186 let f = vsubq_f32(t, r);
1187 let p = vfmaq_f32(c4, f, c5);
1188 let p = vfmaq_f32(c3, f, p);
1189 let p = vfmaq_f32(c2, f, p);
1190 let p = vfmaq_f32(c1, f, p);
1191 let p = vfmaq_f32(one, f, p);
1192 let e = vshlq_n_s32::<23>(vaddq_s32(vcvtq_s32_f32(r), bias));
1193 let ex = vmulq_f32(p, vreinterpretq_f32_s32(e));
1194 vst1q_f32(xs.as_mut_ptr().add(i), vdivq_f32(x, vaddq_f32(one, ex)));
1195 i += 4;
1196 }
1197 while i < n {
1198 xs[i] = gelu_tanh(xs[i]);
1199 i += 1;
1200 }
1201 }
1202}
1203
1204#[allow(clippy::excessive_precision)]
1205// `0.797_884_56` is `SQRT_2_OVER_PI` from the scalar oracle above, verbatim.
1206// Truncating it is bit-identical (0x2a424c3f either way), but it would make the
1207// twin stop matching the oracle ON THE PAGE, and reading them side by side is
1208// how the twins are checked.
1209/// # Safety
1210/// `simd128` is a compile-time guarantee on `wasm32` here.
1211#[allow(clippy::many_single_char_names)]
1212// The names are the polynomial's own: `n`, `i`, `x`, `t`, `r`, `f`, `p`, `e`,
1213// `k`, `z`. Expanding them into prose makes the kernel harder to read against
1214// its twins and the scalar oracle, which is the only way this code is verified.
1215#[allow(clippy::wildcard_imports)]
1216// A SIMD kernel names 15 intrinsics; importing them one by one is a list that
1217// goes stale the moment the arithmetic changes, and hides nothing.
1218#[cfg(target_arch = "wasm32")]
1219#[target_feature(enable = "simd128")]
1220unsafe fn gelu_tanh_simd128(xs: &mut [f32]) {
1221 // SAFETY: whole-body wrapper inserted by the edition-2024
1222 // `unsafe_op_in_unsafe_fn` migration. This block adds no new obligation:
1223 // the contract is stated on the `unsafe fn` signature above.
1224 unsafe {
1225 use core::arch::wasm32::*;
1226 const L1: f32 = core::f32::consts::LN_2;
1227 const L2: f32 = L1 * L1 / 2.0;
1228 const L3: f32 = L1 * L1 * L1 / 6.0;
1229 const L4: f32 = L1 * L1 * L1 * L1 / 24.0;
1230 const L5: f32 = L1 * L1 * L1 * L1 * L1 / 120.0;
1231 let sq2pi = f32x4_splat(0.797_884_56);
1232 let k = f32x4_splat(0.044_715);
1233 let one = f32x4_splat(1.0);
1234 let m2 = f32x4_splat(-2.0);
1235 let log2e = f32x4_splat(core::f32::consts::LOG2_E);
1236 let magic = f32x4_splat(MAGIC);
1237 let lo = f32x4_splat(-125.0);
1238 let hi = f32x4_splat(125.0);
1239 let (c1, c2, c3, c4, c5) = (
1240 f32x4_splat(L1),
1241 f32x4_splat(L2),
1242 f32x4_splat(L3),
1243 f32x4_splat(L4),
1244 f32x4_splat(L5),
1245 );
1246 let bias = i32x4_splat(127);
1247 let n = xs.len();
1248 let mut i = 0;
1249 while i + 4 <= n {
1250 let x = v128_load(xs.as_ptr().add(i).cast());
1251 let x2 = f32x4_mul(x, x);
1252 let z = f32x4_mul(f32x4_mul(sq2pi, x), f32x4_add(f32x4_mul(k, x2), one));
1253 let t = f32x4_mul(f32x4_mul(m2, z), log2e);
1254 let t = f32x4_pmin(hi, f32x4_pmax(lo, t));
1255 let r = f32x4_sub(f32x4_add(t, magic), magic);
1256 let f = f32x4_sub(t, r);
1257 let p = f32x4_add(f32x4_mul(f, c5), c4);
1258 let p = f32x4_add(f32x4_mul(f, p), c3);
1259 let p = f32x4_add(f32x4_mul(f, p), c2);
1260 let p = f32x4_add(f32x4_mul(f, p), c1);
1261 let p = f32x4_add(f32x4_mul(f, p), one);
1262 let e = i32x4_shl(i32x4_add(i32x4_trunc_sat_f32x4(r), bias), 23);
1263 let ex = f32x4_mul(p, e);
1264 v128_store(
1265 xs.as_mut_ptr().add(i).cast(),
1266 f32x4_div(x, f32x4_add(one, ex)),
1267 );
1268 i += 4;
1269 }
1270 while i < n {
1271 xs[i] = gelu_tanh(xs[i]);
1272 i += 1;
1273 }
1274 }
1275}