Skip to main content

burn_flex/ops/
activation.rs

1//! Activation function operations for the Flex backend.
2//!
3//! Each activation is implemented as a single-pass unary operation,
4//! replacing the default multi-op compositions from Burn's trait defaults.
5
6use alloc::vec;
7use alloc::vec::Vec;
8use burn_backend::Scalar;
9use burn_backend::ops::{ActivationOps, FloatTensorOps};
10use burn_backend::tensor::FloatTensor;
11use burn_backend::{DType, TensorMetadata};
12use burn_std::{Bytes, bf16, f16};
13#[cfg(not(feature = "std"))]
14#[allow(unused_imports)]
15use num_traits::Float;
16use num_traits::ToPrimitive;
17
18use crate::ops::binary::binary_op;
19use crate::ops::unary::unary_op;
20use crate::{Flex, FlexTensor, Layout};
21
22impl ActivationOps<Flex> for Flex {
23    fn relu(tensor: FloatTensor<Flex>) -> FloatTensor<Flex> {
24        // `max` returns the non-NaN operand, which would map NaN to the bound.
25        // Testing `is_nan` first lets NaN propagate, as PyTorch does. `!(x <= 0.0)` is
26        // equivalent and compiles to the same code, but trips
27        // `clippy::neg_cmp_op_on_partial_ord`.
28        unary_op(
29            tensor,
30            |x: f32| if x.is_nan() || x > 0.0 { x } else { 0.0 },
31            |x: f64| if x.is_nan() || x > 0.0 { x } else { 0.0 },
32        )
33    }
34
35    fn relu_backward(output: FloatTensor<Flex>, grad: FloatTensor<Flex>) -> FloatTensor<Flex> {
36        // Zero the gradient where the output was zero, but keep it for a NaN output:
37        // the trait default masks with `float_lower_equal_elem(output, 0)`, which is
38        // false for NaN.
39        binary_op(
40            output,
41            grad,
42            |out: f32, g| if out.is_nan() || out > 0.0 { g } else { 0.0 },
43            |out: f64, g| if out.is_nan() || out > 0.0 { g } else { 0.0 },
44            None,
45        )
46    }
47
48    fn leaky_relu(tensor: FloatTensor<Flex>, negative_slope: Scalar) -> FloatTensor<Flex> {
49        let ns32 = negative_slope.to_f32().unwrap();
50        let ns64 = negative_slope.to_f64().unwrap();
51        unary_op(
52            tensor,
53            move |x: f32| if x >= 0.0 { x } else { ns32 * x },
54            move |x: f64| if x >= 0.0 { x } else { ns64 * x },
55        )
56    }
57
58    fn prelu(tensor: FloatTensor<Flex>, alpha: FloatTensor<Flex>) -> FloatTensor<Flex> {
59        // x if x >= 0, alpha * x otherwise
60        binary_op(
61            tensor,
62            alpha,
63            |x: f32, a| if x >= 0.0 { x } else { a * x },
64            |x: f64, a| if x >= 0.0 { x } else { a * x },
65            None,
66        )
67    }
68
69    fn gelu(tensor: FloatTensor<Flex>) -> FloatTensor<Flex> {
70        // 0.5 * x * (1 + erf(x / sqrt(2)))
71        use crate::ops::unary::{erf_f32, erf_f64};
72        let sqrt2_f32: f32 = core::f32::consts::SQRT_2;
73        let sqrt2_f64: f64 = core::f64::consts::SQRT_2;
74        unary_op(
75            tensor,
76            move |x: f32| 0.5 * x * (1.0 + erf_f32(x / sqrt2_f32)),
77            move |x: f64| 0.5 * x * (1.0 + erf_f64(x / sqrt2_f64)),
78        )
79    }
80
81    fn gelu_backward(x: FloatTensor<Flex>, grad: FloatTensor<Flex>) -> FloatTensor<Flex> {
82        // d/dx[gelu(x)] = 0.5 * (1 + erf(x/sqrt(2))) + x * (1/sqrt(2*pi)) * exp(-x^2/2)
83        use crate::ops::unary::{erf_f32, erf_f64};
84        let sqrt2_f32: f32 = core::f32::consts::SQRT_2;
85        let sqrt2_f64: f64 = core::f64::consts::SQRT_2;
86        let inv_sqrt_2pi_f32: f32 = 1.0 / (2.0 * core::f32::consts::PI).sqrt();
87        let inv_sqrt_2pi_f64: f64 = 1.0 / (2.0 * core::f64::consts::PI).sqrt();
88        binary_op(
89            x,
90            grad,
91            move |x: f32, g| {
92                let cdf = 0.5 * (1.0 + erf_f32(x / sqrt2_f32));
93                let pdf = inv_sqrt_2pi_f32 * (-0.5 * x * x).exp();
94                g * (cdf + x * pdf)
95            },
96            move |x: f64, g| {
97                let cdf = 0.5 * (1.0 + erf_f64(x / sqrt2_f64));
98                let pdf = inv_sqrt_2pi_f64 * (-0.5 * x * x).exp();
99                g * (cdf + x * pdf)
100            },
101            None,
102        )
103    }
104
105    fn sigmoid(tensor: FloatTensor<Flex>) -> FloatTensor<Flex> {
106        unary_op(tensor, sigmoid_f32, sigmoid_f64)
107    }
108
109    fn sigmoid_backward(output: FloatTensor<Flex>, grad: FloatTensor<Flex>) -> FloatTensor<Flex> {
110        // grad * output * (1 - output)
111        binary_op(
112            output,
113            grad,
114            |s: f32, g| g * s * (1.0 - s),
115            |s: f64, g| g * s * (1.0 - s),
116            None,
117        )
118    }
119
120    fn hard_sigmoid(tensor: FloatTensor<Flex>, alpha: Scalar, beta: Scalar) -> FloatTensor<Flex> {
121        let alpha32 = alpha.to_f32().unwrap();
122        let beta32 = beta.to_f32().unwrap();
123        let alpha64 = alpha.to_f64().unwrap();
124        let beta64 = beta.to_f64().unwrap();
125        unary_op(
126            tensor,
127            move |x: f32| (alpha32 * x + beta32).clamp(0.0, 1.0),
128            move |x: f64| (alpha64 * x + beta64).clamp(0.0, 1.0),
129        )
130    }
131
132    fn log_sigmoid(tensor: FloatTensor<Flex>) -> FloatTensor<Flex> {
133        // Numerically stable: -softplus(-x) = -log(1 + exp(-x))
134        // For x >= 0: -log(1 + exp(-x))  (standard form, exp(-x) is small)
135        // For x < 0: x - log(1 + exp(x))  (avoids exp of large positive)
136        unary_op(
137            tensor,
138            |x: f32| {
139                if x >= 0.0 {
140                    -((-x).exp().ln_1p())
141                } else {
142                    x - x.exp().ln_1p()
143                }
144            },
145            |x: f64| {
146                if x >= 0.0 {
147                    -((-x).exp().ln_1p())
148                } else {
149                    x - x.exp().ln_1p()
150                }
151            },
152        )
153    }
154
155    fn log_sigmoid_backward(x: FloatTensor<Flex>, grad: FloatTensor<Flex>) -> FloatTensor<Flex> {
156        // d/dx[log_sigmoid(x)] = sigmoid(-x) * (-1) * (-1) = 1 - sigmoid(x) = sigmoid(-x)
157        // So: grad * sigmoid(-x)
158        binary_op(
159            x,
160            grad,
161            |x: f32, g| g * sigmoid_f32(-x),
162            |x: f64, g| g * sigmoid_f64(-x),
163            None,
164        )
165    }
166
167    fn softmax(tensor: FloatTensor<Flex>, dim: usize) -> FloatTensor<Flex> {
168        softmax(tensor, dim)
169    }
170}
171
172#[inline]
173fn sigmoid_f32(x: f32) -> f32 {
174    if x >= 0.0 {
175        1.0 / (1.0 + (-x).exp())
176    } else {
177        let e = x.exp();
178        e / (1.0 + e)
179    }
180}
181
182#[inline]
183fn sigmoid_f64(x: f64) -> f64 {
184    if x >= 0.0 {
185        1.0 / (1.0 + (-x).exp())
186    } else {
187        let e = x.exp();
188        e / (1.0 + e)
189    }
190}
191
192// ============================================================================
193// Fused softmax
194// ============================================================================
195//
196// Backs the `ActivationOps::softmax` hook, replacing the default 5-op
197// decomposition (`max_dim`/`sub`/`exp`/`sum_dim`/`div`).
198
199/// Fused softmax along `dim`.
200///
201/// Three-pass row-wise algorithm (max, exp+sum, normalize) keeping each row
202/// cache-hot. Rows are processed in parallel via rayon. For axes other than
203/// the last, the tensor is permuted to put `dim` last, the fused kernel runs,
204/// and the result is permuted back (both permutes are metadata-only; the
205/// fused kernel's internal `to_contiguous` materializes the permuted layout
206/// once).
207///
208/// # Panics
209///
210/// * If `dim` is out of range for `input`.
211/// * If `input`'s dtype is not one of `f32`/`f64`/`f16`/`bf16`.
212pub fn softmax(tensor: FloatTensor<Flex>, dim: usize) -> FloatTensor<Flex> {
213    let rank = tensor.shape().num_dims();
214    assert!(
215        dim < rank,
216        "softmax dim {} out of range for rank {}",
217        dim,
218        rank
219    );
220
221    if dim != rank - 1 {
222        let swapped = Flex::float_swap_dims(tensor, dim, rank - 1);
223        let normed = softmax_last(swapped);
224        return Flex::float_swap_dims(normed, dim, rank - 1);
225    }
226
227    softmax_last(tensor)
228}
229
230fn softmax_last(tensor: FloatTensor<Flex>) -> FloatTensor<Flex> {
231    let tensor = tensor.to_contiguous();
232    match tensor.dtype() {
233        DType::F32 => softmax_last_f32(tensor),
234        DType::F64 => softmax_last_f64(tensor),
235        DType::F16 => softmax_last_f16(tensor),
236        DType::BF16 => softmax_last_bf16(tensor),
237        dtype => panic!("softmax: unsupported dtype {:?}", dtype),
238    }
239}
240
241fn softmax_last_f32(tensor: FlexTensor) -> FlexTensor {
242    let shape = tensor.layout().shape().clone();
243    let last = *shape.last().expect("softmax: empty shape");
244    if last == 0 {
245        return tensor;
246    }
247    let input: &[f32] = tensor.storage();
248    let n = input.len();
249
250    // Zero-initialize the output. The previous implementation used
251    // `Vec::with_capacity` + `spare_capacity_mut` + a raw-pointer cast to
252    // `&mut [f32]` to skip the memset, but forming a `&mut [f32]` over
253    // uninitialized memory violates Rust's validity invariant (references
254    // must point to initialized values of the correct type) even if every
255    // element is written before it is read. The sound zero-memset
256    // alternative would require threading `&mut [MaybeUninit<f32>]` through
257    // the row kernel, which does not compose with macerator's `#[with_simd]`
258    // signature. The memset is a streaming write on a bandwidth-bound
259    // kernel, so the overhead is small (~10% at the largest bench shape)
260    // and the fused path remains well ahead of decomposed and candle.
261    let mut output: Vec<f32> = vec![0.0; n];
262    let out_slice = output.as_mut_slice();
263
264    // Row-parallel via rayon: one macerator dispatch per chunk of rows,
265    // amortized over all rows in the chunk.
266    #[cfg(feature = "rayon")]
267    {
268        use rayon::prelude::*;
269        const ROWS_PER_TASK: usize = 64;
270        let chunk_elems = ROWS_PER_TASK * last;
271        out_slice
272            .par_chunks_mut(chunk_elems)
273            .zip(input.par_chunks(chunk_elems))
274            .for_each(|(o, i)| softmax_rows_f32(i, o, last));
275    }
276    #[cfg(not(feature = "rayon"))]
277    {
278        softmax_rows_f32(input, out_slice, last);
279    }
280
281    FlexTensor::new(
282        Bytes::from_elems(output),
283        Layout::contiguous(shape),
284        DType::F32,
285    )
286}
287
288/// Row sweep for f32 softmax. With the `simd` feature, delegates to the
289/// `#[macerator::with_simd]` SIMD kernel (one dispatch per chunk of rows,
290/// amortized over all rows in the chunk). Without `simd`, uses a scalar
291/// row kernel.
292#[inline]
293fn softmax_rows_f32(input: &[f32], output: &mut [f32], row_len: usize) {
294    // Release-mode invariant checks. These run once per chunk of rows
295    // (dozens of times per call, not per-element), so the overhead is
296    // unmeasurable against the kernel work. A debug-only check would
297    // silently pass a short final chunk to the row kernel on release
298    // builds if a future refactor broke the row alignment at the call
299    // site, yielding wrong softmax output with no panic.
300    assert_eq!(input.len(), output.len());
301    assert_eq!(input.len() % row_len, 0);
302    #[cfg(feature = "simd")]
303    softmax_rows_f32_simd(input, output, row_len);
304    #[cfg(not(feature = "simd"))]
305    {
306        for (in_row, out_row) in input.chunks(row_len).zip(output.chunks_mut(row_len)) {
307            softmax_row_f32_scalar(in_row, out_row);
308        }
309    }
310}
311
312#[cfg(feature = "simd")]
313#[macerator::with_simd]
314fn softmax_rows_f32_simd<S: macerator::Simd>(input: &[f32], output: &mut [f32], row_len: usize) {
315    debug_assert_eq!(input.len(), output.len());
316    debug_assert_eq!(input.len() % row_len, 0);
317    for (in_row, out_row) in input.chunks(row_len).zip(output.chunks_mut(row_len)) {
318        softmax_row_f32_simd::<S>(in_row, out_row);
319    }
320}
321
322/// Scalar fallback row kernel for f32 softmax when the `simd` feature is
323/// disabled. Uses the same 3-pass algorithm as the SIMD path; LLVM
324/// autovectorizes the max-reduce and normalize loops on most targets.
325#[cfg(not(feature = "simd"))]
326#[inline]
327fn softmax_row_f32_scalar(input: &[f32], output: &mut [f32]) {
328    let mut max_val = f32::NEG_INFINITY;
329    for &x in input {
330        if x > max_val {
331            max_val = x;
332        }
333    }
334    let mut sum = 0.0f32;
335    for (i, &x) in input.iter().enumerate() {
336        let e = (x - max_val).exp();
337        output[i] = e;
338        sum += e;
339    }
340    let inv = 1.0f32 / sum;
341    for x in output.iter_mut() {
342        *x *= inv;
343    }
344}
345
346/// Inner row kernel for a single softmax row. `#[inline(always)]` so it
347/// inlines into `softmax_rows_f32_simd`'s loop body for each monomorphized S,
348/// avoiding a per-row call boundary.
349#[cfg(feature = "simd")]
350#[inline(always)]
351fn softmax_row_f32_simd<S: macerator::Simd>(input: &[f32], output: &mut [f32]) {
352    use macerator::{Scalar, vload_unaligned, vstore_unaligned};
353    let lanes = <f32 as Scalar>::lanes::<S>();
354    let len = input.len();
355    let simd_len = len / lanes * lanes;
356
357    // Pass 1: row max for numerical stability.
358    // SIMD max-reduction across the row, scalar tail.
359    let (mut max_val, tail_start) = if simd_len >= lanes {
360        let mut max_vec = unsafe { vload_unaligned::<S, _>(input.as_ptr()) };
361        let mut j = lanes;
362        while j < simd_len {
363            let v = unsafe { vload_unaligned::<S, _>(input.as_ptr().add(j)) };
364            max_vec = max_vec.max(v);
365            j += lanes;
366        }
367        (max_vec.reduce_max(), simd_len)
368    } else {
369        (f32::NEG_INFINITY, 0)
370    };
371    for &x in &input[tail_start..] {
372        if x > max_val {
373            max_val = x;
374        }
375    }
376
377    // Pass 2: compute exp(x - max), store in output, accumulate sum.
378    // Scalar exp (no SIMD exp in macerator). This pass is the one that
379    // actually does memory reads + writes on the whole row, so scalar
380    // here still lands us at memory bandwidth.
381    let mut sum = 0.0f32;
382    for idx in 0..len {
383        let e = (input[idx] - max_val).exp();
384        output[idx] = e;
385        sum += e;
386    }
387
388    // Pass 3: normalize.
389    // SIMD splat + multiply, scalar tail.
390    let inv = 1.0f32 / sum;
391    let inv_vec = inv.splat::<S>();
392    let mut i = 0;
393    while i < simd_len {
394        unsafe {
395            let v = vload_unaligned::<S, _>(output.as_ptr().add(i));
396            vstore_unaligned::<S, _>(output.as_mut_ptr().add(i), v * inv_vec);
397        }
398        i += lanes;
399    }
400    for x in &mut output[i..] {
401        *x *= inv;
402    }
403}
404
405// f64, f16, bf16 softmax share the same row-parallel dispatcher shell and
406// differ only in their row kernel (native f64 vs via-f32 for half
407// precision). Generated via macros to keep the three variants in lockstep.
408// Only f32 has a dedicated SIMD fast path above.
409
410macro_rules! softmax_last_dtype {
411    ($fn_name:ident, $T:ty, $zero:expr, $dtype:expr, $row_fn:ident) => {
412        fn $fn_name(tensor: FlexTensor) -> FlexTensor {
413            let shape = tensor.layout().shape().clone();
414            let last = *shape.last().expect("softmax: empty shape");
415            if last == 0 {
416                return tensor;
417            }
418            let input: &[$T] = tensor.storage();
419            let mut output: Vec<$T> = vec![$zero; input.len()];
420
421            #[cfg(feature = "rayon")]
422            {
423                use rayon::prelude::*;
424                const ROWS_PER_TASK: usize = 64;
425                let chunk_elems = ROWS_PER_TASK * last;
426                output
427                    .par_chunks_mut(chunk_elems)
428                    .zip(input.par_chunks(chunk_elems))
429                    .for_each(|(o_chunk, i_chunk)| {
430                        for (i, o) in i_chunk
431                            .chunks_exact(last)
432                            .zip(o_chunk.chunks_exact_mut(last))
433                        {
434                            $row_fn(i, o);
435                        }
436                    });
437            }
438            #[cfg(not(feature = "rayon"))]
439            {
440                for (i, o) in input.chunks_exact(last).zip(output.chunks_exact_mut(last)) {
441                    $row_fn(i, o);
442                }
443            }
444
445            FlexTensor::new(Bytes::from_elems(output), Layout::contiguous(shape), $dtype)
446        }
447    };
448}
449
450/// Half-precision softmax row kernel. Accumulates in f32 for numerical
451/// stability and converts back to the target type at each write. This
452/// double-rounds across passes 2 and 3; acceptable for half precision. An
453/// f32 scratch buffer would remove the double rounding at the cost of a
454/// per-row allocation.
455macro_rules! softmax_row_half {
456    ($fn_name:ident, $T:ty) => {
457        #[inline]
458        fn $fn_name(input: &[$T], output: &mut [$T]) {
459            let mut max_val = f32::NEG_INFINITY;
460            for &x in input {
461                let xf = x.to_f32();
462                if xf > max_val {
463                    max_val = xf;
464                }
465            }
466            let mut sum = 0.0f32;
467            for (i, &x) in input.iter().enumerate() {
468                let e = (x.to_f32() - max_val).exp();
469                output[i] = <$T>::from_f32(e);
470                sum += e;
471            }
472            let inv = 1.0f32 / sum;
473            for x in output.iter_mut() {
474                *x = <$T>::from_f32(x.to_f32() * inv);
475            }
476        }
477    };
478}
479
480#[inline]
481fn softmax_row_f64(input: &[f64], output: &mut [f64]) {
482    let mut max_val = f64::NEG_INFINITY;
483    for &x in input {
484        if x > max_val {
485            max_val = x;
486        }
487    }
488    let mut sum = 0.0f64;
489    for (i, &x) in input.iter().enumerate() {
490        let e = (x - max_val).exp();
491        output[i] = e;
492        sum += e;
493    }
494    let inv = 1.0f64 / sum;
495    for x in output.iter_mut() {
496        *x *= inv;
497    }
498}
499
500softmax_row_half!(softmax_row_f16, f16);
501softmax_row_half!(softmax_row_bf16, bf16);
502
503softmax_last_dtype!(softmax_last_f64, f64, 0.0f64, DType::F64, softmax_row_f64);
504softmax_last_dtype!(
505    softmax_last_f16,
506    f16,
507    f16::from_f32(0.0),
508    DType::F16,
509    softmax_row_f16
510);
511softmax_last_dtype!(
512    softmax_last_bf16,
513    bf16,
514    bf16::from_f32(0.0),
515    DType::BF16,
516    softmax_row_bf16
517);
518
519// ============================================================================
520// Fused layer_norm
521// ============================================================================
522//
523// Backs the `ModuleOps::layer_norm` hook, replacing the default decomposition
524// into ~6 primitive tensor ops with intermediate allocations. The f32 SIMD
525// row kernel makes three passes (mean, centered variance, normalize+affine)
526// via macerator; the scalar fallback and f64 paths use Welford.
527
528/// Fused layer normalization along the last axis.
529///
530/// Applies `y = ((x - mean) / sqrt(var + eps)) * gamma + beta`, where
531/// `mean` and `var` are computed per row along the last axis of `input`.
532/// `gamma` and `beta` are 1-D tensors of length `input.shape()[-1]`;
533/// `beta` is optional (set to `None` for a bias-free layer norm).
534///
535/// The f32 SIMD row kernel makes three passes (mean, centered variance,
536/// normalize+affine) via macerator; each row stays cache-hot across them.
537/// With the `simd` feature off, the scalar fallback uses Welford.
538///
539/// Supports `f32` (SIMD-vectorized), `f64` (scalar + LLVM autovec), and
540/// `f16`/`bf16` (via an f32 cast-fuse-cast shell; the f32 row kernel
541/// already accumulates in f32, so this matches the precision a
542/// half-precision-native kernel would produce).
543///
544/// # Panics
545///
546/// * If `input`'s dtype is not one of `f32`/`f64`/`f16`/`bf16`.
547/// * If `input` has rank 0.
548/// * If `gamma` (or `beta`, when present) is not a 1-D tensor of length
549///   equal to the last dim of `input`.
550pub fn layer_norm(
551    input: FloatTensor<Flex>,
552    gamma: FloatTensor<Flex>,
553    beta: Option<FloatTensor<Flex>>,
554    epsilon: f64,
555) -> FloatTensor<Flex> {
556    let rank = input.shape().num_dims();
557    assert!(rank >= 1, "layer_norm: input must have at least one dim");
558    // Keep gamma/beta dtypes aligned with the input. The half-precision path
559    // (see `layer_norm_via_f32`) ultimately accesses storage using the input's
560    // element type, and a mismatch would panic there; reject it up front with
561    // a clearer layer_norm-specific error message.
562    assert_eq!(
563        gamma.dtype(),
564        input.dtype(),
565        "layer_norm: gamma dtype {:?} does not match input dtype {:?}",
566        gamma.dtype(),
567        input.dtype(),
568    );
569    if let Some(ref b) = beta {
570        assert_eq!(
571            b.dtype(),
572            input.dtype(),
573            "layer_norm: beta dtype {:?} does not match input dtype {:?}",
574            b.dtype(),
575            input.dtype(),
576        );
577    }
578    let input = input.to_contiguous();
579    let gamma = gamma.to_contiguous();
580    let beta = beta.map(|b| b.to_contiguous());
581
582    let d_model = *input
583        .layout()
584        .shape()
585        .last()
586        .expect("layer_norm: empty shape");
587    // Validate rank + length explicitly rather than just last-dim == d_model.
588    // A gamma shaped like `[2, d_model]` would pass a last-dim check but
589    // has 2*d_model elements, which would index the wrong data in the row
590    // kernel (caught by an inner assert, but with a confusing message).
591    let gamma_shape = gamma.layout().shape();
592    assert!(
593        gamma_shape.len() == 1 && gamma_shape[0] == d_model,
594        "layer_norm: gamma must be a 1-D tensor of length equal to last dim of input \
595         (got shape {:?}, expected [{}])",
596        gamma_shape,
597        d_model,
598    );
599    if let Some(ref b) = beta {
600        let beta_shape = b.layout().shape();
601        assert!(
602            beta_shape.len() == 1 && beta_shape[0] == d_model,
603            "layer_norm: beta must be a 1-D tensor of length equal to last dim of input \
604             (got shape {:?}, expected [{}])",
605            beta_shape,
606            d_model,
607        );
608    }
609
610    match input.dtype() {
611        DType::F32 => layer_norm_f32(input, gamma, beta, epsilon as f32),
612        DType::F64 => layer_norm_f64(input, gamma, beta, epsilon),
613        DType::F16 => {
614            layer_norm_via_f32::<f16>(input, gamma, beta, epsilon, f16::to_f32, f16::from_f32)
615        }
616        DType::BF16 => {
617            layer_norm_via_f32::<bf16>(input, gamma, beta, epsilon, bf16::to_f32, bf16::from_f32)
618        }
619        dtype => panic!("burn_flex::layer_norm: unsupported dtype {:?}", dtype),
620    }
621}
622
623fn layer_norm_via_f32<E: burn_backend::Element + bytemuck::Pod + Copy>(
624    input: FlexTensor,
625    gamma: FlexTensor,
626    beta: Option<FlexTensor>,
627    epsilon: f64,
628    to_f32: fn(E) -> f32,
629    from_f32: fn(f32) -> E,
630) -> FlexTensor {
631    let input_f32 = crate::ops::module::cast_to_f32::<E>(input, to_f32);
632    let gamma_f32 = crate::ops::module::cast_to_f32::<E>(gamma, to_f32);
633    let beta_f32 = beta.map(|b| crate::ops::module::cast_to_f32::<E>(b, to_f32));
634    let out = layer_norm_f32(input_f32, gamma_f32, beta_f32, epsilon as f32);
635    crate::ops::module::cast_from_f32::<E>(out, from_f32)
636}
637
638/// Fused f64 layer_norm. The Welford mean/variance pass is serial (the
639/// mean update on iteration `k` depends on iteration `k-1`); the
640/// normalize+affine pass autovectorizes on targets with f64 SIMD. A
641/// macerator f64 path can be added if profiling shows it matters.
642fn layer_norm_f64(
643    input: FlexTensor,
644    gamma: FlexTensor,
645    beta: Option<FlexTensor>,
646    epsilon: f64,
647) -> FlexTensor {
648    let shape = input.layout().shape().clone();
649    let d_model = *shape.last().expect("layer_norm: empty shape");
650    if d_model == 0 {
651        return input;
652    }
653    let input_data: &[f64] = input.storage();
654    let gamma_data: &[f64] = gamma.storage();
655    let beta_data: Option<&[f64]> = beta.as_ref().map(|b| b.storage());
656    let mut output: Vec<f64> = vec![0.0; input_data.len()];
657
658    #[cfg(feature = "rayon")]
659    {
660        use rayon::prelude::*;
661        const ROWS_PER_TASK: usize = 64;
662        let chunk_elems = ROWS_PER_TASK * d_model;
663        match beta_data {
664            Some(beta_slice) => {
665                output
666                    .par_chunks_mut(chunk_elems)
667                    .zip(input_data.par_chunks(chunk_elems))
668                    .for_each(|(o, i)| {
669                        layer_norm_rows_f64_with_beta(
670                            i, o, gamma_data, beta_slice, d_model, epsilon,
671                        );
672                    });
673            }
674            None => {
675                output
676                    .par_chunks_mut(chunk_elems)
677                    .zip(input_data.par_chunks(chunk_elems))
678                    .for_each(|(o, i)| {
679                        layer_norm_rows_f64_no_beta(i, o, gamma_data, d_model, epsilon);
680                    });
681            }
682        }
683    }
684    #[cfg(not(feature = "rayon"))]
685    {
686        match beta_data {
687            Some(beta_slice) => layer_norm_rows_f64_with_beta(
688                input_data,
689                output.as_mut_slice(),
690                gamma_data,
691                beta_slice,
692                d_model,
693                epsilon,
694            ),
695            None => layer_norm_rows_f64_no_beta(
696                input_data,
697                output.as_mut_slice(),
698                gamma_data,
699                d_model,
700                epsilon,
701            ),
702        }
703    }
704
705    FlexTensor::new(
706        Bytes::from_elems(output),
707        Layout::contiguous(shape),
708        DType::F64,
709    )
710}
711
712#[inline]
713fn layer_norm_rows_f64_with_beta(
714    input: &[f64],
715    output: &mut [f64],
716    gamma: &[f64],
717    beta: &[f64],
718    d_model: usize,
719    epsilon: f64,
720) {
721    for (in_row, out_row) in input.chunks(d_model).zip(output.chunks_mut(d_model)) {
722        let (mean, inv_std) = welford_f64(in_row, epsilon);
723        for (i, &x) in in_row.iter().enumerate() {
724            out_row[i] = (x - mean) * (inv_std * gamma[i]) + beta[i];
725        }
726    }
727}
728
729#[inline]
730fn layer_norm_rows_f64_no_beta(
731    input: &[f64],
732    output: &mut [f64],
733    gamma: &[f64],
734    d_model: usize,
735    epsilon: f64,
736) {
737    for (in_row, out_row) in input.chunks(d_model).zip(output.chunks_mut(d_model)) {
738        let (mean, inv_std) = welford_f64(in_row, epsilon);
739        for (i, &x) in in_row.iter().enumerate() {
740            out_row[i] = (x - mean) * (inv_std * gamma[i]);
741        }
742    }
743}
744
745#[inline]
746fn welford_f64(row: &[f64], epsilon: f64) -> (f64, f64) {
747    let mut mean = 0.0f64;
748    let mut m2 = 0.0f64;
749    for (k, &x) in row.iter().enumerate() {
750        let n_k = (k + 1) as f64;
751        let delta = x - mean;
752        mean += delta / n_k;
753        m2 += delta * (x - mean);
754    }
755    let var = m2 / row.len() as f64;
756    (mean, 1.0f64 / (var + epsilon).sqrt())
757}
758
759fn layer_norm_f32(
760    input: FlexTensor,
761    gamma: FlexTensor,
762    beta: Option<FlexTensor>,
763    epsilon: f32,
764) -> FlexTensor {
765    let shape = input.layout().shape().clone();
766    let d_model = *shape.last().expect("layer_norm: empty shape");
767    if d_model == 0 {
768        return input;
769    }
770
771    let input_data: &[f32] = input.storage();
772    let gamma_data: &[f32] = gamma.storage();
773    let beta_data: Option<&[f32]> = beta.as_ref().map(|b| b.storage());
774
775    let n = input_data.len();
776    // See softmax_last_f32 for the rationale on zero-init instead of
777    // `spare_capacity_mut` + `&mut [f32]` cast: the latter creates a
778    // reference to uninitialized f32 values, which is UB under Rust's
779    // aliasing model even with no intervening read.
780    let mut output: Vec<f32> = vec![0.0; n];
781    let out_slice = output.as_mut_slice();
782
783    // `#[macerator::with_simd]` can't auto-lifetime through
784    // `Option<&[T]>`, so we dispatch two separate monomorphized
785    // versions, one with beta and one without. Both call into the
786    // same shared row kernel.
787    #[cfg(feature = "rayon")]
788    {
789        use rayon::prelude::*;
790        const ROWS_PER_TASK: usize = 64;
791        let chunk_elems = ROWS_PER_TASK * d_model;
792        match beta_data {
793            Some(beta_slice) => {
794                out_slice
795                    .par_chunks_mut(chunk_elems)
796                    .zip(input_data.par_chunks(chunk_elems))
797                    .for_each(|(o, i)| {
798                        layer_norm_rows_f32_with_beta(
799                            i, o, gamma_data, beta_slice, d_model, epsilon,
800                        );
801                    });
802            }
803            None => {
804                out_slice
805                    .par_chunks_mut(chunk_elems)
806                    .zip(input_data.par_chunks(chunk_elems))
807                    .for_each(|(o, i)| {
808                        layer_norm_rows_f32_no_beta(i, o, gamma_data, d_model, epsilon);
809                    });
810            }
811        }
812    }
813    #[cfg(not(feature = "rayon"))]
814    {
815        match beta_data {
816            Some(beta_slice) => layer_norm_rows_f32_with_beta(
817                input_data, out_slice, gamma_data, beta_slice, d_model, epsilon,
818            ),
819            None => {
820                layer_norm_rows_f32_no_beta(input_data, out_slice, gamma_data, d_model, epsilon)
821            }
822        }
823    }
824
825    FlexTensor::new(
826        Bytes::from_elems(output),
827        Layout::contiguous(shape),
828        DType::F32,
829    )
830}
831
832/// Row sweep for f32 layer_norm with bias. Delegates to the SIMD kernel
833/// when the `simd` feature is enabled; otherwise uses a scalar row loop.
834#[inline]
835fn layer_norm_rows_f32_with_beta(
836    input: &[f32],
837    output: &mut [f32],
838    gamma: &[f32],
839    beta: &[f32],
840    d_model: usize,
841    epsilon: f32,
842) {
843    // Release-mode invariant checks; see softmax_rows_f32 for rationale.
844    assert_eq!(input.len(), output.len());
845    assert_eq!(input.len() % d_model, 0);
846    assert_eq!(gamma.len(), d_model);
847    assert_eq!(beta.len(), d_model);
848    #[cfg(feature = "simd")]
849    layer_norm_rows_f32_with_beta_simd(input, output, gamma, beta, d_model, epsilon);
850    #[cfg(not(feature = "simd"))]
851    {
852        for (in_row, out_row) in input.chunks(d_model).zip(output.chunks_mut(d_model)) {
853            layer_norm_row_f32_scalar(in_row, out_row, gamma, Some(beta), epsilon);
854        }
855    }
856}
857
858/// Row sweep for f32 layer_norm without bias.
859#[inline]
860fn layer_norm_rows_f32_no_beta(
861    input: &[f32],
862    output: &mut [f32],
863    gamma: &[f32],
864    d_model: usize,
865    epsilon: f32,
866) {
867    // Release-mode invariant checks; see softmax_rows_f32 for rationale.
868    assert_eq!(input.len(), output.len());
869    assert_eq!(input.len() % d_model, 0);
870    assert_eq!(gamma.len(), d_model);
871    #[cfg(feature = "simd")]
872    layer_norm_rows_f32_no_beta_simd(input, output, gamma, d_model, epsilon);
873    #[cfg(not(feature = "simd"))]
874    {
875        for (in_row, out_row) in input.chunks(d_model).zip(output.chunks_mut(d_model)) {
876            layer_norm_row_f32_scalar(in_row, out_row, gamma, None, epsilon);
877        }
878    }
879}
880
881/// Scalar fallback row kernel for layer_norm when the `simd` feature is
882/// disabled. Welford mean/variance, then normalize+affine.
883#[cfg(not(feature = "simd"))]
884#[inline]
885fn layer_norm_row_f32_scalar(
886    input: &[f32],
887    output: &mut [f32],
888    gamma: &[f32],
889    beta: Option<&[f32]>,
890    epsilon: f32,
891) {
892    // Welford's online algorithm tracks a running mean instead of a raw
893    // sum, so it avoids the E[x^2] - E[x]^2 cancellation and the mean stays
894    // finite for inputs whose raw sum would overflow f32.
895    let len = input.len();
896    let mut mean = 0.0f32;
897    let mut m2 = 0.0f32;
898    for (k, &x) in input.iter().enumerate() {
899        let n_k = (k + 1) as f32;
900        let delta = x - mean;
901        mean += delta / n_k;
902        let delta2 = x - mean;
903        m2 += delta * delta2;
904    }
905    let var = m2 / len as f32;
906    let inv_std = 1.0f32 / (var + epsilon).sqrt();
907    for (i, &x) in input.iter().enumerate() {
908        let scale = inv_std * gamma[i];
909        let normed = (x - mean) * scale;
910        output[i] = match beta {
911            Some(b) => normed + b[i],
912            None => normed,
913        };
914    }
915}
916
917/// SIMD-dispatched row sweep for f32 layer_norm with bias (beta). One
918/// macerator dispatch per chunk of rows, amortized over the whole chunk.
919#[cfg(feature = "simd")]
920#[macerator::with_simd]
921fn layer_norm_rows_f32_with_beta_simd<S: macerator::Simd>(
922    input: &[f32],
923    output: &mut [f32],
924    gamma: &[f32],
925    beta: &[f32],
926    d_model: usize,
927    epsilon: f32,
928) {
929    debug_assert_eq!(input.len(), output.len());
930    debug_assert_eq!(input.len() % d_model, 0);
931    debug_assert_eq!(gamma.len(), d_model);
932    debug_assert_eq!(beta.len(), d_model);
933    for (in_row, out_row) in input.chunks(d_model).zip(output.chunks_mut(d_model)) {
934        layer_norm_row_f32_simd::<S>(in_row, out_row, gamma, Some(beta), epsilon);
935    }
936}
937
938/// SIMD-dispatched row sweep for f32 layer_norm without bias.
939#[cfg(feature = "simd")]
940#[macerator::with_simd]
941fn layer_norm_rows_f32_no_beta_simd<S: macerator::Simd>(
942    input: &[f32],
943    output: &mut [f32],
944    gamma: &[f32],
945    d_model: usize,
946    epsilon: f32,
947) {
948    debug_assert_eq!(input.len(), output.len());
949    debug_assert_eq!(input.len() % d_model, 0);
950    debug_assert_eq!(gamma.len(), d_model);
951    for (in_row, out_row) in input.chunks(d_model).zip(output.chunks_mut(d_model)) {
952        layer_norm_row_f32_simd::<S>(in_row, out_row, gamma, None, epsilon);
953    }
954}
955
956/// Single-row layer_norm kernel. Three vectorized passes: mean, centered
957/// variance, normalize+affine. The row stays cache-hot across all three.
958#[cfg(feature = "simd")]
959#[inline(always)]
960fn layer_norm_row_f32_simd<S: macerator::Simd>(
961    input: &[f32],
962    output: &mut [f32],
963    gamma: &[f32],
964    beta: Option<&[f32]>,
965    epsilon: f32,
966) {
967    use macerator::{Scalar, vload_unaligned, vstore_unaligned};
968    let lanes = <f32 as Scalar>::lanes::<S>();
969    let len = input.len();
970    let simd_len = len / lanes * lanes;
971    let n = len as f32;
972
973    // Pass 1: mean.
974    let mut acc = 0.0f32.splat::<S>();
975    let mut i = 0;
976    while i < simd_len {
977        unsafe {
978            acc += vload_unaligned::<S, _>(input.as_ptr().add(i));
979        }
980        i += lanes;
981    }
982    let mut sum = acc.reduce_add();
983    for &x in &input[simd_len..] {
984        sum += x;
985    }
986    let mean = sum / n;
987    let mean_vec = mean.splat::<S>();
988
989    // Pass 2: biased variance, centered to avoid the E[x^2] - E[x]^2
990    // cancellation (#5607). Matches burn::nn::LayerNorm (var_mean_bias).
991    let mut acc = 0.0f32.splat::<S>();
992    let mut i = 0;
993    while i < simd_len {
994        unsafe {
995            let d = vload_unaligned::<S, _>(input.as_ptr().add(i)) - mean_vec;
996            // Vector::mul_add(self, a, b) = self*a + b, so d*d + acc.
997            acc = d.mul_add(d, acc);
998        }
999        i += lanes;
1000    }
1001    let mut sumsq = acc.reduce_add();
1002    for &x in &input[simd_len..] {
1003        let d = x - mean;
1004        sumsq += d * d;
1005    }
1006    let var = sumsq / n;
1007    let inv_std = 1.0f32 / (var + epsilon).sqrt();
1008
1009    // Pass 3: normalize and affine transform.
1010    //   out[i] = (x[i] - mean) * inv_std * gamma[i] + beta[i]
1011    // mean_vec and inv_std_vec are splatted once per row. gamma and beta
1012    // are read once per element; both fit in L1 and are shared across all
1013    // rows within a rayon chunk.
1014    let inv_std_vec = inv_std.splat::<S>();
1015    let mut i = 0;
1016    while i < simd_len {
1017        unsafe {
1018            let x = vload_unaligned::<S, _>(input.as_ptr().add(i));
1019            let g = vload_unaligned::<S, _>(gamma.as_ptr().add(i));
1020            // scale = inv_std * g
1021            let scale = inv_std_vec * g;
1022            // centered = x - mean
1023            let centered = x - mean_vec;
1024            // out = centered * scale  (+ beta if present)
1025            let normed = centered * scale;
1026            let out = if let Some(b) = beta {
1027                let b_vec = vload_unaligned::<S, _>(b.as_ptr().add(i));
1028                normed + b_vec
1029            } else {
1030                normed
1031            };
1032            vstore_unaligned::<S, _>(output.as_mut_ptr().add(i), out);
1033        }
1034        i += lanes;
1035    }
1036    // Scalar tail
1037    while i < len {
1038        let centered = input[i] - mean;
1039        let normed = centered * inv_std * gamma[i];
1040        output[i] = match beta {
1041            Some(b) => normed + b[i],
1042            None => normed,
1043        };
1044        i += 1;
1045    }
1046}
1047
1048// Tests kept here exercise flex-specific behavior: SIMD boundaries, rayon
1049// chunk boundaries, non-contiguous input handling, the flex-internal
1050// layer_norm op (no public API yet), and dtype-specific fused softmax
1051// paths (f16/bf16/f64). Plain activation/softmax smoke tests have been
1052// migrated to burn-backend-tests so they cover every backend. When adding
1053// new tests, keep them here only if they probe flex internals; otherwise
1054// add them to crates/burn-backend-tests/tests/tensor/float/activation/.
1055#[cfg(test)]
1056mod tests {
1057    use alloc::vec;
1058    use burn_backend::{DType, TensorData, TensorMetadata, Tolerance};
1059    use burn_std::{bf16, f16};
1060    use num_traits::Float;
1061
1062    use crate::FlexTensor;
1063
1064    // ============================================================================
1065    // Reference implementations (per-row, last-axis).
1066    //
1067    // These mirror the contract the fused kernel commits to: stable softmax via
1068    // (x - max), layer_norm via (x - mean) * inv(sqrt(var + eps)) with optional
1069    // affine. Written in plain Rust over f32/f64 slices so the tests avoid any
1070    // tensor-library dependency.
1071    // ============================================================================
1072
1073    fn softmax_row<T: Float>(row_in: &[T], row_out: &mut [T]) {
1074        let max = row_in
1075            .iter()
1076            .copied()
1077            .fold(T::neg_infinity(), |a, b| if a > b { a } else { b });
1078        let mut sum = T::zero();
1079        for (i, &x) in row_in.iter().enumerate() {
1080            let e = (x - max).exp();
1081            row_out[i] = e;
1082            sum = sum + e;
1083        }
1084        for v in row_out.iter_mut() {
1085            *v = *v / sum;
1086        }
1087    }
1088
1089    fn softmax_last_ref<T: Float>(data: &[T], row_len: usize) -> Vec<T> {
1090        let mut out = vec![T::zero(); data.len()];
1091        for (i, o) in data.chunks(row_len).zip(out.chunks_mut(row_len)) {
1092            softmax_row(i, o);
1093        }
1094        out
1095    }
1096
1097    fn layer_norm_row<T: Float>(
1098        row_in: &[T],
1099        gamma: &[T],
1100        beta: Option<&[T]>,
1101        eps: T,
1102        row_out: &mut [T],
1103    ) {
1104        let n = T::from(row_in.len()).unwrap();
1105        let mean = row_in.iter().copied().fold(T::zero(), |a, b| a + b) / n;
1106        let var = row_in
1107            .iter()
1108            .map(|&x| (x - mean) * (x - mean))
1109            .fold(T::zero(), |a, b| a + b)
1110            / n;
1111        let inv_std = T::one() / (var + eps).sqrt();
1112        for (i, &x) in row_in.iter().enumerate() {
1113            let normed = (x - mean) * inv_std;
1114            let scaled = normed * gamma[i];
1115            row_out[i] = match beta {
1116                Some(b) => scaled + b[i],
1117                None => scaled,
1118            };
1119        }
1120    }
1121
1122    fn layer_norm_last_ref<T: Float>(
1123        data: &[T],
1124        gamma: &[T],
1125        beta: Option<&[T]>,
1126        eps: T,
1127        row_len: usize,
1128    ) -> Vec<T> {
1129        let mut out = vec![T::zero(); data.len()];
1130        for (i, o) in data.chunks(row_len).zip(out.chunks_mut(row_len)) {
1131            layer_norm_row(i, gamma, beta, eps, o);
1132        }
1133        out
1134    }
1135
1136    // ============================================================================
1137    // Helpers: FlexTensor constructors for typed inputs.
1138    // ============================================================================
1139
1140    fn flex_f32(data: Vec<f32>, shape: &[usize]) -> FlexTensor {
1141        FlexTensor::from_data(TensorData::new(data, shape.to_vec()))
1142    }
1143
1144    fn flex_f64(data: Vec<f64>, shape: &[usize]) -> FlexTensor {
1145        FlexTensor::from_data(TensorData::new(data, shape.to_vec()))
1146    }
1147
1148    fn flex_half<T: burn_backend::Element>(data: Vec<T>, shape: &[usize]) -> FlexTensor {
1149        FlexTensor::from_data(TensorData::new(data, shape.to_vec()))
1150    }
1151
1152    // ============================================================================
1153    // layer_norm tests
1154    // ============================================================================
1155
1156    #[test]
1157    fn test_layer_norm_2d_with_beta() {
1158        let t = flex_f32(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0], &[2, 4]);
1159        let gamma = flex_f32(vec![1.0; 4], &[4]);
1160        let beta = flex_f32(vec![0.0; 4], &[4]);
1161        let out = crate::ops::activation::layer_norm(t, gamma, Some(beta), 1e-5);
1162
1163        let expected: Vec<f32> = vec![
1164            -1.3416408, -0.4472136, 0.4472136, 1.3416408, -1.3416408, -0.4472136, 0.4472136,
1165            1.3416408,
1166        ];
1167        out.into_data().assert_approx_eq::<f32>(
1168            &TensorData::new(expected, vec![2, 4]),
1169            Tolerance::absolute(1e-4),
1170        );
1171    }
1172
1173    #[test]
1174    fn test_layer_norm_with_affine() {
1175        let t = flex_f32(vec![1.0, 2.0, 3.0, 4.0], &[1, 4]);
1176        let gamma = flex_f32(vec![2.0, 0.5, 1.0, 3.0], &[4]);
1177        let beta = flex_f32(vec![1.0, -1.0, 0.0, 2.0], &[4]);
1178        let out = crate::ops::activation::layer_norm(t, gamma, Some(beta), 1e-5);
1179
1180        // normalized = [-1.3416, -0.4472, 0.4472, 1.3416]
1181        // affine: [-1.6833, -1.2236, 0.4472, 6.0249]
1182        out.into_data().assert_approx_eq::<f32>(
1183            &TensorData::new(vec![-1.6833, -1.2236, 0.4472, 6.0249], vec![1, 4]),
1184            Tolerance::absolute(1e-3),
1185        );
1186    }
1187
1188    #[test]
1189    fn test_layer_norm_no_beta() {
1190        let t = flex_f32(vec![1.0, 2.0, 3.0, 4.0], &[1, 4]);
1191        let gamma = flex_f32(vec![1.0; 4], &[4]);
1192        let out = crate::ops::activation::layer_norm(t, gamma, None, 1e-5);
1193
1194        out.into_data().assert_approx_eq::<f32>(
1195            &TensorData::new(
1196                vec![-1.3416408, -0.4472136, 0.4472136, 1.3416408],
1197                vec![1, 4],
1198            ),
1199            Tolerance::absolute(1e-4),
1200        );
1201    }
1202
1203    // ============================================================================
1204    // softmax SIMD / rayon boundary tests
1205    // ============================================================================
1206
1207    #[test]
1208    fn test_softmax_simd_body_row() {
1209        // Row length 32 ensures the SIMD body runs on every supported target:
1210        // NEON (lanes=4), AVX2 (lanes=8), AVX-512 (lanes=16), SIMD128 (lanes=4).
1211        let data: Vec<f32> = (0..32).map(|i| i as f32 * 0.1).collect();
1212        let expected = softmax_last_ref(&data, 32);
1213        let fused = crate::ops::activation::softmax(flex_f32(data, &[1, 32]), 1);
1214        fused.into_data().assert_approx_eq::<f32>(
1215            &TensorData::new(expected, vec![1, 32]),
1216            Tolerance::absolute(1e-5),
1217        );
1218    }
1219
1220    #[test]
1221    fn test_softmax_multi_chunk_rayon() {
1222        // 100 rows > ROWS_PER_TASK (64) triggers the rayon par_chunks path.
1223        let data: Vec<f32> = (0..100 * 16).map(|i| ((i % 17) as f32) * 0.05).collect();
1224        let expected = softmax_last_ref(&data, 16);
1225        let fused = crate::ops::activation::softmax(flex_f32(data, &[100, 16]), 1);
1226        fused.into_data().assert_approx_eq::<f32>(
1227            &TensorData::new(expected, vec![100, 16]),
1228            Tolerance::absolute(1e-5),
1229        );
1230    }
1231
1232    #[test]
1233    fn test_softmax_f64() {
1234        // Exercises softmax_last_dtype! + softmax_row_native f64 path.
1235        let data: Vec<f64> = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
1236        let expected = softmax_last_ref(&data, 4);
1237        let fused = crate::ops::activation::softmax(flex_f64(data, &[2, 4]), 1);
1238        fused.into_data().assert_approx_eq::<f64>(
1239            &TensorData::new(expected, vec![2, 4]),
1240            Tolerance::absolute(1e-10),
1241        );
1242    }
1243
1244    #[test]
1245    fn test_softmax_f16() {
1246        // Exercises softmax_last_dtype! + softmax_row_half f16 path.
1247        let source: Vec<f32> = vec![1.0, 2.0, 3.0, 4.0, 0.5, 0.5, 0.5, 0.5];
1248        let data: Vec<f16> = source.iter().map(|&x| f16::from_f32(x)).collect();
1249        let expected = softmax_last_ref(&data, 4);
1250        let fused = crate::ops::activation::softmax(flex_half(data, &[2, 4]), 1);
1251        fused.into_data().assert_approx_eq::<f16>(
1252            &TensorData::new(expected, vec![2, 4]),
1253            Tolerance::absolute(1e-2),
1254        );
1255    }
1256
1257    #[test]
1258    fn test_softmax_bf16() {
1259        // Exercises softmax_last_dtype! + softmax_row_half bf16 path.
1260        let source: Vec<f32> = vec![1.0, 2.0, 3.0, 4.0, 0.5, 0.5, 0.5, 0.5];
1261        let data: Vec<bf16> = source.iter().map(|&x| bf16::from_f32(x)).collect();
1262        let expected = softmax_last_ref(&data, 4);
1263        let fused = crate::ops::activation::softmax(flex_half(data, &[2, 4]), 1);
1264        fused.into_data().assert_approx_eq::<bf16>(
1265            &TensorData::new(expected, vec![2, 4]),
1266            Tolerance::absolute(5e-2),
1267        );
1268    }
1269
1270    #[test]
1271    fn test_softmax_multi_chunk_f64() {
1272        // 150 rows > 64 triggers the multi-chunk rayon path for f64
1273        let n_rows = 150;
1274        let d_cols = 8;
1275        let data: Vec<f64> = (0..n_rows * d_cols)
1276            .map(|i| ((i % 11) as f64) * 0.1 - 0.5)
1277            .collect();
1278        let expected = softmax_last_ref(&data, d_cols);
1279        let fused = crate::ops::activation::softmax(flex_f64(data, &[n_rows, d_cols]), 1);
1280        fused.into_data().assert_approx_eq::<f64>(
1281            &TensorData::new(expected, vec![n_rows, d_cols]),
1282            Tolerance::absolute(1e-10),
1283        );
1284    }
1285
1286    #[test]
1287    fn test_softmax_multi_chunk_f16() {
1288        // 150 rows > 64 triggers the multi-chunk rayon path for f16
1289        let n_rows = 150;
1290        let d_cols = 8;
1291        let source: Vec<f32> = (0..n_rows * d_cols)
1292            .map(|i| ((i % 11) as f32) * 0.1 - 0.5)
1293            .collect();
1294        let data: Vec<f16> = source.iter().map(|&x| f16::from_f32(x)).collect();
1295        let expected = softmax_last_ref(&data, d_cols);
1296        let fused = crate::ops::activation::softmax(flex_half(data, &[n_rows, d_cols]), 1);
1297        fused.into_data().assert_approx_eq::<f16>(
1298            &TensorData::new(expected, vec![n_rows, d_cols]),
1299            Tolerance::absolute(1e-2),
1300        );
1301    }
1302
1303    #[test]
1304    fn test_softmax_multi_chunk_bf16() {
1305        // 150 rows > 64 triggers the multi-chunk rayon path for bf16
1306        let n_rows = 150;
1307        let d_cols = 8;
1308        let source: Vec<f32> = (0..n_rows * d_cols)
1309            .map(|i| ((i % 11) as f32) * 0.1 - 0.5)
1310            .collect();
1311        let data: Vec<bf16> = source.iter().map(|&x| bf16::from_f32(x)).collect();
1312        let expected = softmax_last_ref(&data, d_cols);
1313        let fused = crate::ops::activation::softmax(flex_half(data, &[n_rows, d_cols]), 1);
1314        fused.into_data().assert_approx_eq::<bf16>(
1315            &TensorData::new(expected, vec![n_rows, d_cols]),
1316            Tolerance::absolute(5e-2),
1317        );
1318    }
1319
1320    #[test]
1321    fn test_layer_norm_large_mean_small_spread() {
1322        // Row mean far larger than row spread. A one-pass E[x^2] - E[x]^2
1323        // variance cancels catastrophically here and is off by ~1.0 (issue
1324        // #5607). Odd length also covers the SIMD tail. Larger means are
1325        // omitted: f32 rounding of the mean itself then exceeds the tolerance.
1326        let data: Vec<f32> = (0..17).map(|i| 100.0 + i as f32 * 1e-3).collect();
1327        let data_f64: Vec<f64> = data.iter().map(|&x| x as f64).collect();
1328        let expected: Vec<f32> = layer_norm_last_ref(&data_f64, &[1.0; 17], None, 1e-5, 17)
1329            .into_iter()
1330            .map(|x| x as f32)
1331            .collect();
1332        let out = crate::ops::activation::layer_norm(
1333            flex_f32(data, &[1, 17]),
1334            flex_f32(vec![1.0; 17], &[17]),
1335            None,
1336            1e-5,
1337        );
1338        out.into_data().assert_approx_eq::<f32>(
1339            &TensorData::new(expected, vec![1, 17]),
1340            Tolerance::absolute(1e-2),
1341        );
1342    }
1343
1344    #[test]
1345    fn test_layer_norm_multi_chunk_rayon() {
1346        // 128 rows > ROWS_PER_TASK (64) triggers the rayon path.
1347        let data: Vec<f32> = (0..128 * 16).map(|i| ((i % 19) as f32) * 0.03).collect();
1348        let gamma_data: Vec<f32> = vec![1.0; 16];
1349        let beta_data: Vec<f32> = vec![0.0; 16];
1350        let expected = layer_norm_last_ref(&data, &gamma_data, Some(&beta_data), 1e-5f32, 16);
1351        let fused = crate::ops::activation::layer_norm(
1352            flex_f32(data, &[128, 16]),
1353            flex_f32(gamma_data, &[16]),
1354            Some(flex_f32(beta_data, &[16])),
1355            1e-5,
1356        );
1357        fused.into_data().assert_approx_eq::<f32>(
1358            &TensorData::new(expected, vec![128, 16]),
1359            Tolerance::absolute(1e-4),
1360        );
1361    }
1362
1363    #[test]
1364    fn test_softmax_empty_last_dim_returns_input() {
1365        // shape [2, 0]: empty last dim should round-trip unchanged instead
1366        // of producing NaN via 0/0.
1367        let t = flex_f32(Vec::<f32>::new(), &[2, 0]);
1368        let result = crate::ops::activation::softmax(t, 1);
1369        assert_eq!(result.shape().as_slice(), &[2, 0]);
1370    }
1371
1372    #[test]
1373    fn test_layer_norm_empty_last_dim_returns_input() {
1374        let t = flex_f32(Vec::<f32>::new(), &[3, 0]);
1375        let gamma = flex_f32(Vec::<f32>::new(), &[0]);
1376        let beta = flex_f32(Vec::<f32>::new(), &[0]);
1377        let result = crate::ops::activation::layer_norm(t, gamma, Some(beta), 1e-5);
1378        assert_eq!(result.shape().as_slice(), &[3, 0]);
1379    }
1380
1381    #[test]
1382    #[should_panic(expected = "gamma must be a 1-D tensor")]
1383    fn test_layer_norm_gamma_length_mismatch_panics() {
1384        let t = flex_f32(vec![1.0, 2.0, 3.0, 4.0], &[1, 4]);
1385        let gamma = flex_f32(vec![1.0, 1.0, 1.0], &[3]);
1386        let _ = crate::ops::activation::layer_norm(t, gamma, None, 1e-5);
1387    }
1388
1389    #[test]
1390    #[should_panic(expected = "beta must be a 1-D tensor")]
1391    fn test_layer_norm_beta_length_mismatch_panics() {
1392        let t = flex_f32(vec![1.0, 2.0, 3.0, 4.0], &[1, 4]);
1393        let gamma = flex_f32(vec![1.0, 1.0, 1.0, 1.0], &[4]);
1394        let beta = flex_f32(vec![0.0, 0.0, 0.0], &[3]);
1395        let _ = crate::ops::activation::layer_norm(t, gamma, Some(beta), 1e-5);
1396    }
1397
1398    #[test]
1399    #[should_panic(expected = "gamma must be a 1-D tensor")]
1400    fn test_layer_norm_gamma_rank_mismatch_panics() {
1401        // gamma [2, 4] has matching last-dim but rank 2, so the old last-dim
1402        // check alone would have accepted it and then indexed wrong storage.
1403        let t = flex_f32(vec![1.0, 2.0, 3.0, 4.0], &[1, 4]);
1404        let gamma = flex_f32(vec![1.0; 8], &[2, 4]);
1405        let _ = crate::ops::activation::layer_norm(t, gamma, None, 1e-5);
1406    }
1407
1408    // Row length 17 leaves exactly one scalar-tail element after every common
1409    // SIMD width (NEON/SSE f32x4: body=16, tail=1; AVX2 f32x8: body=16, tail=1;
1410    // AVX-512 f32x16: body=16, tail=1). Row lengths that divide evenly by the
1411    // SIMD width skip the tail branch entirely, so a bug in the scalar tail
1412    // kernel would sail past CI without a test like this.
1413    #[test]
1414    fn test_softmax_simd_body_plus_scalar_tail() {
1415        let data: Vec<f32> = (0..34).map(|i| (i as f32 * 0.137) - 2.3).collect();
1416        let expected = softmax_last_ref(&data, 17);
1417        let fused = crate::ops::activation::softmax(flex_f32(data, &[2, 17]), 1);
1418        fused.into_data().assert_approx_eq::<f32>(
1419            &TensorData::new(expected, vec![2, 17]),
1420            Tolerance::absolute(1e-5),
1421        );
1422    }
1423
1424    #[test]
1425    fn test_layer_norm_simd_body_plus_scalar_tail() {
1426        let data: Vec<f32> = (0..34).map(|i| (i as f32 * 0.137) - 2.3).collect();
1427        let gamma_data: Vec<f32> = (0..17).map(|i| 1.0 + i as f32 * 0.05).collect();
1428        let beta_data: Vec<f32> = (0..17).map(|i| i as f32 * 0.01).collect();
1429        let expected = layer_norm_last_ref(&data, &gamma_data, Some(&beta_data), 1e-5f32, 17);
1430        let fused = crate::ops::activation::layer_norm(
1431            flex_f32(data, &[2, 17]),
1432            flex_f32(gamma_data, &[17]),
1433            Some(flex_f32(beta_data, &[17])),
1434            1e-5,
1435        );
1436        fused.into_data().assert_approx_eq::<f32>(
1437            &TensorData::new(expected, vec![2, 17]),
1438            Tolerance::absolute(1e-5),
1439        );
1440    }
1441
1442    #[test]
1443    fn test_layer_norm_f64_with_beta_multi_chunk() {
1444        // 80 rows > ROWS_PER_TASK (64) exercises the rayon multi-chunk f64 path.
1445        let d_model = 16;
1446        let n_rows = 80;
1447        let data: Vec<f64> = (0..n_rows * d_model)
1448            .map(|i| ((i % 13) as f64) * 0.07 - 0.3)
1449            .collect();
1450        let gamma_data: Vec<f64> = vec![0.9; d_model];
1451        let beta_data: Vec<f64> = vec![0.05; d_model];
1452        let eps = 1e-5f64;
1453        let expected = layer_norm_last_ref(&data, &gamma_data, Some(&beta_data), eps, d_model);
1454        let fused = crate::ops::activation::layer_norm(
1455            flex_f64(data, &[n_rows, d_model]),
1456            flex_f64(gamma_data, &[d_model]),
1457            Some(flex_f64(beta_data, &[d_model])),
1458            eps,
1459        );
1460        fused.into_data().assert_approx_eq::<f64>(
1461            &TensorData::new(expected, vec![n_rows, d_model]),
1462            Tolerance::absolute(1e-10),
1463        );
1464    }
1465
1466    #[test]
1467    fn test_layer_norm_f64_no_beta() {
1468        let data: Vec<f64> = vec![1.0, 2.0, 3.0, 4.0, -1.0, 0.5, 1.5, -0.5];
1469        let gamma_data: Vec<f64> = vec![1.0; 4];
1470        let eps = 1e-5f64;
1471        let expected = layer_norm_last_ref(&data, &gamma_data, None, eps, 4);
1472        let fused = crate::ops::activation::layer_norm(
1473            flex_f64(data, &[2, 4]),
1474            flex_f64(gamma_data, &[4]),
1475            None,
1476            eps,
1477        );
1478        fused.into_data().assert_approx_eq::<f64>(
1479            &TensorData::new(expected, vec![2, 4]),
1480            Tolerance::absolute(1e-10),
1481        );
1482    }
1483
1484    // Shared body for f16/bf16 layer_norm tests. The fused half-precision
1485    // kernel casts to f32 internally, so the reference is computed in f32
1486    // and compared back against the half output with an f32 tolerance.
1487    fn check_layer_norm_half_precision<E>(from_f32: fn(f32) -> E, dtype: DType)
1488    where
1489        E: burn_backend::Element + Float,
1490    {
1491        let rows_f32: [f32; 12] = [
1492            1.0, 2.0, 3.0, 4.0, -1.0, 0.0, 1.0, 2.0, 0.5, -0.5, 1.5, -1.5,
1493        ];
1494        let gamma_f32: [f32; 4] = [1.0, 0.5, 1.5, 1.0];
1495        let beta_f32: [f32; 4] = [0.1, -0.1, 0.0, 0.2];
1496        let eps = 1e-5f32;
1497
1498        let expected_f32 = layer_norm_last_ref(&rows_f32, &gamma_f32, Some(&beta_f32), eps, 4);
1499
1500        let data: Vec<E> = rows_f32.iter().map(|&x| from_f32(x)).collect();
1501        let gamma_data: Vec<E> = gamma_f32.iter().map(|&x| from_f32(x)).collect();
1502        let beta_data: Vec<E> = beta_f32.iter().map(|&x| from_f32(x)).collect();
1503        assert_eq!(E::dtype(), dtype);
1504
1505        let fused = crate::ops::activation::layer_norm(
1506            flex_half(data, &[3, 4]),
1507            flex_half(gamma_data, &[4]),
1508            Some(flex_half(beta_data, &[4])),
1509            eps as f64,
1510        );
1511        fused.into_data().assert_approx_eq::<f32>(
1512            &TensorData::new(expected_f32, vec![3, 4]),
1513            Tolerance::absolute(3e-2),
1514        );
1515    }
1516
1517    #[test]
1518    fn test_layer_norm_f16_via_f32_cast() {
1519        check_layer_norm_half_precision::<f16>(f16::from_f32, DType::F16);
1520    }
1521
1522    #[test]
1523    fn test_layer_norm_bf16_via_f32_cast() {
1524        check_layer_norm_half_precision::<bf16>(bf16::from_f32, DType::BF16);
1525    }
1526
1527    #[test]
1528    #[should_panic(expected = "softmax dim")]
1529    fn test_softmax_dim_out_of_range_panics() {
1530        let t = flex_f32(vec![1.0, 2.0, 3.0, 4.0], &[2, 2]);
1531        let _ = crate::ops::activation::softmax(t, 2);
1532    }
1533
1534    #[test]
1535    #[should_panic(expected = "gamma dtype")]
1536    fn test_layer_norm_gamma_dtype_mismatch_panics() {
1537        // Input f32, gamma f64: layer_norm rejects the mismatch up front
1538        // rather than panicking later inside the storage-typed access.
1539        let t = flex_f32(vec![1.0, 2.0, 3.0, 4.0], &[1, 4]);
1540        let gamma = flex_f64(vec![1.0; 4], &[4]);
1541        let _ = crate::ops::activation::layer_norm(t, gamma, None, 1e-5);
1542    }
1543}