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. Two-pass row
525// kernel (sum+sumsq sweep, then normalize+affine sweep), both vectorized via
526// macerator.
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/// Two-pass row kernel (mean/variance via a single sum+sum-of-squares
536/// sweep, then one normalize+affine sweep). Both passes are SIMD via
537/// macerator; each row stays cache-hot across both passes.
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. Two-pass algorithm matching the SIMD version (sum+sumsq,
883/// then normalize+affine).
884#[cfg(not(feature = "simd"))]
885#[inline]
886fn layer_norm_row_f32_scalar(
887    input: &[f32],
888    output: &mut [f32],
889    gamma: &[f32],
890    beta: Option<&[f32]>,
891    epsilon: f32,
892) {
893    // Welford's online algorithm for mean and variance, rather than the
894    // `sumsq / n - mean * mean` identity the SIMD path uses. The identity
895    // is vulnerable to catastrophic cancellation when the two terms are
896    // close in magnitude (large mean relative to variance). Welford's
897    // single-pass formulation avoids that by tracking the running mean
898    // and accumulating squared deviations from it. The scalar path is
899    // the contract used when `simd` is disabled, so we prefer numerical
900    // stability over bit-for-bit match with the SIMD tree reduction.
901    let len = input.len();
902    let mut mean = 0.0f32;
903    let mut m2 = 0.0f32;
904    for (k, &x) in input.iter().enumerate() {
905        let n_k = (k + 1) as f32;
906        let delta = x - mean;
907        mean += delta / n_k;
908        let delta2 = x - mean;
909        m2 += delta * delta2;
910    }
911    let var = m2 / len as f32;
912    let inv_std = 1.0f32 / (var + epsilon).sqrt();
913    for (i, &x) in input.iter().enumerate() {
914        let scale = inv_std * gamma[i];
915        let normed = (x - mean) * scale;
916        output[i] = match beta {
917            Some(b) => normed + b[i],
918            None => normed,
919        };
920    }
921}
922
923/// SIMD-dispatched row sweep for f32 layer_norm with bias (beta). One
924/// macerator dispatch per chunk of rows, amortized over the whole chunk.
925#[cfg(feature = "simd")]
926#[macerator::with_simd]
927fn layer_norm_rows_f32_with_beta_simd<S: macerator::Simd>(
928    input: &[f32],
929    output: &mut [f32],
930    gamma: &[f32],
931    beta: &[f32],
932    d_model: usize,
933    epsilon: f32,
934) {
935    debug_assert_eq!(input.len(), output.len());
936    debug_assert_eq!(input.len() % d_model, 0);
937    debug_assert_eq!(gamma.len(), d_model);
938    debug_assert_eq!(beta.len(), d_model);
939    for (in_row, out_row) in input.chunks(d_model).zip(output.chunks_mut(d_model)) {
940        layer_norm_row_f32_simd::<S>(in_row, out_row, gamma, Some(beta), epsilon);
941    }
942}
943
944/// SIMD-dispatched row sweep for f32 layer_norm without bias.
945#[cfg(feature = "simd")]
946#[macerator::with_simd]
947fn layer_norm_rows_f32_no_beta_simd<S: macerator::Simd>(
948    input: &[f32],
949    output: &mut [f32],
950    gamma: &[f32],
951    d_model: usize,
952    epsilon: f32,
953) {
954    debug_assert_eq!(input.len(), output.len());
955    debug_assert_eq!(input.len() % d_model, 0);
956    debug_assert_eq!(gamma.len(), d_model);
957    for (in_row, out_row) in input.chunks(d_model).zip(output.chunks_mut(d_model)) {
958        layer_norm_row_f32_simd::<S>(in_row, out_row, gamma, None, epsilon);
959    }
960}
961
962/// Single-row layer_norm kernel. Two vectorized passes.
963#[cfg(feature = "simd")]
964#[inline(always)]
965fn layer_norm_row_f32_simd<S: macerator::Simd>(
966    input: &[f32],
967    output: &mut [f32],
968    gamma: &[f32],
969    beta: Option<&[f32]>,
970    epsilon: f32,
971) {
972    use macerator::{Scalar, vload_unaligned, vstore_unaligned};
973    let lanes = <f32 as Scalar>::lanes::<S>();
974    let len = input.len();
975    let simd_len = len / lanes * lanes;
976
977    // Pass 1: compute sum and sum-of-squares in one sweep, then derive
978    // mean and variance. Two independent SIMD accumulators (sum, sumsq)
979    // expose ILP to the two FMA ports.
980    let (sum, sumsq) = if simd_len >= lanes {
981        let mut acc_sum = 0.0f32.splat::<S>();
982        let mut acc_sumsq = 0.0f32.splat::<S>();
983        let mut i = 0;
984        while i < simd_len {
985            unsafe {
986                let v = vload_unaligned::<S, _>(input.as_ptr().add(i));
987                acc_sum += v;
988                // acc_sumsq += v * v; Vector::mul_add(self, a, b) = self*a + b,
989                // so v.mul_add(v, acc_sumsq) = v*v + acc_sumsq.
990                acc_sumsq = v.mul_add(v, acc_sumsq);
991            }
992            i += lanes;
993        }
994        let mut s = acc_sum.reduce_add();
995        let mut sq = acc_sumsq.reduce_add();
996        for &x in &input[simd_len..] {
997            s += x;
998            sq += x * x;
999        }
1000        (s, sq)
1001    } else {
1002        let mut s = 0.0f32;
1003        let mut sq = 0.0f32;
1004        for &x in input {
1005            s += x;
1006            sq += x * x;
1007        }
1008        (s, sq)
1009    };
1010
1011    let n = len as f32;
1012    let mean = sum / n;
1013    // Biased variance: E[x^2] - E[x]^2. Matches burn::nn::LayerNorm which
1014    // uses var_mean_bias (the biased estimator) rather than Bessel's
1015    // correction.
1016    let var = (sumsq / n) - mean * mean;
1017    let inv_std = 1.0f32 / (var + epsilon).sqrt();
1018
1019    // Pass 2: normalize and affine transform.
1020    //   out[i] = (x[i] - mean) * inv_std * gamma[i] + beta[i]
1021    // mean_vec and inv_std_vec are hoisted outside the loop (one splat
1022    // each per row). gamma and beta are read once per element; both
1023    // fit in L1 and are shared across all rows within a rayon chunk.
1024    let mean_vec = mean.splat::<S>();
1025    let inv_std_vec = inv_std.splat::<S>();
1026    let mut i = 0;
1027    while i < simd_len {
1028        unsafe {
1029            let x = vload_unaligned::<S, _>(input.as_ptr().add(i));
1030            let g = vload_unaligned::<S, _>(gamma.as_ptr().add(i));
1031            // scale = inv_std * g
1032            let scale = inv_std_vec * g;
1033            // centered = x - mean
1034            let centered = x - mean_vec;
1035            // out = centered * scale  (+ beta if present)
1036            let normed = centered * scale;
1037            let out = if let Some(b) = beta {
1038                let b_vec = vload_unaligned::<S, _>(b.as_ptr().add(i));
1039                normed + b_vec
1040            } else {
1041                normed
1042            };
1043            vstore_unaligned::<S, _>(output.as_mut_ptr().add(i), out);
1044        }
1045        i += lanes;
1046    }
1047    // Scalar tail
1048    while i < len {
1049        let centered = input[i] - mean;
1050        let normed = centered * inv_std * gamma[i];
1051        output[i] = match beta {
1052            Some(b) => normed + b[i],
1053            None => normed,
1054        };
1055        i += 1;
1056    }
1057}
1058
1059// Tests kept here exercise flex-specific behavior: SIMD boundaries, rayon
1060// chunk boundaries, non-contiguous input handling, the flex-internal
1061// layer_norm op (no public API yet), and dtype-specific fused softmax
1062// paths (f16/bf16/f64). Plain activation/softmax smoke tests have been
1063// migrated to burn-backend-tests so they cover every backend. When adding
1064// new tests, keep them here only if they probe flex internals; otherwise
1065// add them to crates/burn-backend-tests/tests/tensor/float/activation/.
1066#[cfg(test)]
1067mod tests {
1068    use alloc::vec;
1069    use burn_backend::{DType, TensorData, TensorMetadata, Tolerance};
1070    use burn_std::{bf16, f16};
1071    use num_traits::Float;
1072
1073    use crate::FlexTensor;
1074
1075    // ============================================================================
1076    // Reference implementations (per-row, last-axis).
1077    //
1078    // These mirror the contract the fused kernel commits to: stable softmax via
1079    // (x - max), layer_norm via (x - mean) * inv(sqrt(var + eps)) with optional
1080    // affine. Written in plain Rust over f32/f64 slices so the tests avoid any
1081    // tensor-library dependency.
1082    // ============================================================================
1083
1084    fn softmax_row<T: Float>(row_in: &[T], row_out: &mut [T]) {
1085        let max = row_in
1086            .iter()
1087            .copied()
1088            .fold(T::neg_infinity(), |a, b| if a > b { a } else { b });
1089        let mut sum = T::zero();
1090        for (i, &x) in row_in.iter().enumerate() {
1091            let e = (x - max).exp();
1092            row_out[i] = e;
1093            sum = sum + e;
1094        }
1095        for v in row_out.iter_mut() {
1096            *v = *v / sum;
1097        }
1098    }
1099
1100    fn softmax_last_ref<T: Float>(data: &[T], row_len: usize) -> Vec<T> {
1101        let mut out = vec![T::zero(); data.len()];
1102        for (i, o) in data.chunks(row_len).zip(out.chunks_mut(row_len)) {
1103            softmax_row(i, o);
1104        }
1105        out
1106    }
1107
1108    fn layer_norm_row<T: Float>(
1109        row_in: &[T],
1110        gamma: &[T],
1111        beta: Option<&[T]>,
1112        eps: T,
1113        row_out: &mut [T],
1114    ) {
1115        let n = T::from(row_in.len()).unwrap();
1116        let mean = row_in.iter().copied().fold(T::zero(), |a, b| a + b) / n;
1117        let var = row_in
1118            .iter()
1119            .map(|&x| (x - mean) * (x - mean))
1120            .fold(T::zero(), |a, b| a + b)
1121            / n;
1122        let inv_std = T::one() / (var + eps).sqrt();
1123        for (i, &x) in row_in.iter().enumerate() {
1124            let normed = (x - mean) * inv_std;
1125            let scaled = normed * gamma[i];
1126            row_out[i] = match beta {
1127                Some(b) => scaled + b[i],
1128                None => scaled,
1129            };
1130        }
1131    }
1132
1133    fn layer_norm_last_ref<T: Float>(
1134        data: &[T],
1135        gamma: &[T],
1136        beta: Option<&[T]>,
1137        eps: T,
1138        row_len: usize,
1139    ) -> Vec<T> {
1140        let mut out = vec![T::zero(); data.len()];
1141        for (i, o) in data.chunks(row_len).zip(out.chunks_mut(row_len)) {
1142            layer_norm_row(i, gamma, beta, eps, o);
1143        }
1144        out
1145    }
1146
1147    // ============================================================================
1148    // Helpers: FlexTensor constructors for typed inputs.
1149    // ============================================================================
1150
1151    fn flex_f32(data: Vec<f32>, shape: &[usize]) -> FlexTensor {
1152        FlexTensor::from_data(TensorData::new(data, shape.to_vec()))
1153    }
1154
1155    fn flex_f64(data: Vec<f64>, shape: &[usize]) -> FlexTensor {
1156        FlexTensor::from_data(TensorData::new(data, shape.to_vec()))
1157    }
1158
1159    fn flex_half<T: burn_backend::Element>(data: Vec<T>, shape: &[usize]) -> FlexTensor {
1160        FlexTensor::from_data(TensorData::new(data, shape.to_vec()))
1161    }
1162
1163    // ============================================================================
1164    // layer_norm tests
1165    // ============================================================================
1166
1167    #[test]
1168    fn test_layer_norm_2d_with_beta() {
1169        let t = flex_f32(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0], &[2, 4]);
1170        let gamma = flex_f32(vec![1.0; 4], &[4]);
1171        let beta = flex_f32(vec![0.0; 4], &[4]);
1172        let out = crate::ops::activation::layer_norm(t, gamma, Some(beta), 1e-5);
1173
1174        let expected: Vec<f32> = vec![
1175            -1.3416408, -0.4472136, 0.4472136, 1.3416408, -1.3416408, -0.4472136, 0.4472136,
1176            1.3416408,
1177        ];
1178        out.into_data().assert_approx_eq::<f32>(
1179            &TensorData::new(expected, vec![2, 4]),
1180            Tolerance::absolute(1e-4),
1181        );
1182    }
1183
1184    #[test]
1185    fn test_layer_norm_with_affine() {
1186        let t = flex_f32(vec![1.0, 2.0, 3.0, 4.0], &[1, 4]);
1187        let gamma = flex_f32(vec![2.0, 0.5, 1.0, 3.0], &[4]);
1188        let beta = flex_f32(vec![1.0, -1.0, 0.0, 2.0], &[4]);
1189        let out = crate::ops::activation::layer_norm(t, gamma, Some(beta), 1e-5);
1190
1191        // normalized = [-1.3416, -0.4472, 0.4472, 1.3416]
1192        // affine: [-1.6833, -1.2236, 0.4472, 6.0249]
1193        out.into_data().assert_approx_eq::<f32>(
1194            &TensorData::new(vec![-1.6833, -1.2236, 0.4472, 6.0249], vec![1, 4]),
1195            Tolerance::absolute(1e-3),
1196        );
1197    }
1198
1199    #[test]
1200    fn test_layer_norm_no_beta() {
1201        let t = flex_f32(vec![1.0, 2.0, 3.0, 4.0], &[1, 4]);
1202        let gamma = flex_f32(vec![1.0; 4], &[4]);
1203        let out = crate::ops::activation::layer_norm(t, gamma, None, 1e-5);
1204
1205        out.into_data().assert_approx_eq::<f32>(
1206            &TensorData::new(
1207                vec![-1.3416408, -0.4472136, 0.4472136, 1.3416408],
1208                vec![1, 4],
1209            ),
1210            Tolerance::absolute(1e-4),
1211        );
1212    }
1213
1214    // ============================================================================
1215    // softmax SIMD / rayon boundary tests
1216    // ============================================================================
1217
1218    #[test]
1219    fn test_softmax_simd_body_row() {
1220        // Row length 32 ensures the SIMD body runs on every supported target:
1221        // NEON (lanes=4), AVX2 (lanes=8), AVX-512 (lanes=16), SIMD128 (lanes=4).
1222        let data: Vec<f32> = (0..32).map(|i| i as f32 * 0.1).collect();
1223        let expected = softmax_last_ref(&data, 32);
1224        let fused = crate::ops::activation::softmax(flex_f32(data, &[1, 32]), 1);
1225        fused.into_data().assert_approx_eq::<f32>(
1226            &TensorData::new(expected, vec![1, 32]),
1227            Tolerance::absolute(1e-5),
1228        );
1229    }
1230
1231    #[test]
1232    fn test_softmax_multi_chunk_rayon() {
1233        // 100 rows > ROWS_PER_TASK (64) triggers the rayon par_chunks path.
1234        let data: Vec<f32> = (0..100 * 16).map(|i| ((i % 17) as f32) * 0.05).collect();
1235        let expected = softmax_last_ref(&data, 16);
1236        let fused = crate::ops::activation::softmax(flex_f32(data, &[100, 16]), 1);
1237        fused.into_data().assert_approx_eq::<f32>(
1238            &TensorData::new(expected, vec![100, 16]),
1239            Tolerance::absolute(1e-5),
1240        );
1241    }
1242
1243    #[test]
1244    fn test_softmax_f64() {
1245        // Exercises softmax_last_dtype! + softmax_row_native f64 path.
1246        let data: Vec<f64> = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
1247        let expected = softmax_last_ref(&data, 4);
1248        let fused = crate::ops::activation::softmax(flex_f64(data, &[2, 4]), 1);
1249        fused.into_data().assert_approx_eq::<f64>(
1250            &TensorData::new(expected, vec![2, 4]),
1251            Tolerance::absolute(1e-10),
1252        );
1253    }
1254
1255    #[test]
1256    fn test_softmax_f16() {
1257        // Exercises softmax_last_dtype! + softmax_row_half f16 path.
1258        let source: Vec<f32> = vec![1.0, 2.0, 3.0, 4.0, 0.5, 0.5, 0.5, 0.5];
1259        let data: Vec<f16> = source.iter().map(|&x| f16::from_f32(x)).collect();
1260        let expected = softmax_last_ref(&data, 4);
1261        let fused = crate::ops::activation::softmax(flex_half(data, &[2, 4]), 1);
1262        fused.into_data().assert_approx_eq::<f16>(
1263            &TensorData::new(expected, vec![2, 4]),
1264            Tolerance::absolute(1e-2),
1265        );
1266    }
1267
1268    #[test]
1269    fn test_softmax_bf16() {
1270        // Exercises softmax_last_dtype! + softmax_row_half bf16 path.
1271        let source: Vec<f32> = vec![1.0, 2.0, 3.0, 4.0, 0.5, 0.5, 0.5, 0.5];
1272        let data: Vec<bf16> = source.iter().map(|&x| bf16::from_f32(x)).collect();
1273        let expected = softmax_last_ref(&data, 4);
1274        let fused = crate::ops::activation::softmax(flex_half(data, &[2, 4]), 1);
1275        fused.into_data().assert_approx_eq::<bf16>(
1276            &TensorData::new(expected, vec![2, 4]),
1277            Tolerance::absolute(5e-2),
1278        );
1279    }
1280
1281    #[test]
1282    fn test_softmax_multi_chunk_f64() {
1283        // 150 rows > 64 triggers the multi-chunk rayon path for f64
1284        let n_rows = 150;
1285        let d_cols = 8;
1286        let data: Vec<f64> = (0..n_rows * d_cols)
1287            .map(|i| ((i % 11) as f64) * 0.1 - 0.5)
1288            .collect();
1289        let expected = softmax_last_ref(&data, d_cols);
1290        let fused = crate::ops::activation::softmax(flex_f64(data, &[n_rows, d_cols]), 1);
1291        fused.into_data().assert_approx_eq::<f64>(
1292            &TensorData::new(expected, vec![n_rows, d_cols]),
1293            Tolerance::absolute(1e-10),
1294        );
1295    }
1296
1297    #[test]
1298    fn test_softmax_multi_chunk_f16() {
1299        // 150 rows > 64 triggers the multi-chunk rayon path for f16
1300        let n_rows = 150;
1301        let d_cols = 8;
1302        let source: Vec<f32> = (0..n_rows * d_cols)
1303            .map(|i| ((i % 11) as f32) * 0.1 - 0.5)
1304            .collect();
1305        let data: Vec<f16> = source.iter().map(|&x| f16::from_f32(x)).collect();
1306        let expected = softmax_last_ref(&data, d_cols);
1307        let fused = crate::ops::activation::softmax(flex_half(data, &[n_rows, d_cols]), 1);
1308        fused.into_data().assert_approx_eq::<f16>(
1309            &TensorData::new(expected, vec![n_rows, d_cols]),
1310            Tolerance::absolute(1e-2),
1311        );
1312    }
1313
1314    #[test]
1315    fn test_softmax_multi_chunk_bf16() {
1316        // 150 rows > 64 triggers the multi-chunk rayon path for bf16
1317        let n_rows = 150;
1318        let d_cols = 8;
1319        let source: Vec<f32> = (0..n_rows * d_cols)
1320            .map(|i| ((i % 11) as f32) * 0.1 - 0.5)
1321            .collect();
1322        let data: Vec<bf16> = source.iter().map(|&x| bf16::from_f32(x)).collect();
1323        let expected = softmax_last_ref(&data, d_cols);
1324        let fused = crate::ops::activation::softmax(flex_half(data, &[n_rows, d_cols]), 1);
1325        fused.into_data().assert_approx_eq::<bf16>(
1326            &TensorData::new(expected, vec![n_rows, d_cols]),
1327            Tolerance::absolute(5e-2),
1328        );
1329    }
1330
1331    #[test]
1332    fn test_layer_norm_multi_chunk_rayon() {
1333        // 128 rows > ROWS_PER_TASK (64) triggers the rayon path.
1334        let data: Vec<f32> = (0..128 * 16).map(|i| ((i % 19) as f32) * 0.03).collect();
1335        let gamma_data: Vec<f32> = vec![1.0; 16];
1336        let beta_data: Vec<f32> = vec![0.0; 16];
1337        let expected = layer_norm_last_ref(&data, &gamma_data, Some(&beta_data), 1e-5f32, 16);
1338        let fused = crate::ops::activation::layer_norm(
1339            flex_f32(data, &[128, 16]),
1340            flex_f32(gamma_data, &[16]),
1341            Some(flex_f32(beta_data, &[16])),
1342            1e-5,
1343        );
1344        fused.into_data().assert_approx_eq::<f32>(
1345            &TensorData::new(expected, vec![128, 16]),
1346            Tolerance::absolute(1e-4),
1347        );
1348    }
1349
1350    #[test]
1351    fn test_softmax_empty_last_dim_returns_input() {
1352        // shape [2, 0]: empty last dim should round-trip unchanged instead
1353        // of producing NaN via 0/0.
1354        let t = flex_f32(Vec::<f32>::new(), &[2, 0]);
1355        let result = crate::ops::activation::softmax(t, 1);
1356        assert_eq!(result.shape().as_slice(), &[2, 0]);
1357    }
1358
1359    #[test]
1360    fn test_layer_norm_empty_last_dim_returns_input() {
1361        let t = flex_f32(Vec::<f32>::new(), &[3, 0]);
1362        let gamma = flex_f32(Vec::<f32>::new(), &[0]);
1363        let beta = flex_f32(Vec::<f32>::new(), &[0]);
1364        let result = crate::ops::activation::layer_norm(t, gamma, Some(beta), 1e-5);
1365        assert_eq!(result.shape().as_slice(), &[3, 0]);
1366    }
1367
1368    #[test]
1369    #[should_panic(expected = "gamma must be a 1-D tensor")]
1370    fn test_layer_norm_gamma_length_mismatch_panics() {
1371        let t = flex_f32(vec![1.0, 2.0, 3.0, 4.0], &[1, 4]);
1372        let gamma = flex_f32(vec![1.0, 1.0, 1.0], &[3]);
1373        let _ = crate::ops::activation::layer_norm(t, gamma, None, 1e-5);
1374    }
1375
1376    #[test]
1377    #[should_panic(expected = "beta must be a 1-D tensor")]
1378    fn test_layer_norm_beta_length_mismatch_panics() {
1379        let t = flex_f32(vec![1.0, 2.0, 3.0, 4.0], &[1, 4]);
1380        let gamma = flex_f32(vec![1.0, 1.0, 1.0, 1.0], &[4]);
1381        let beta = flex_f32(vec![0.0, 0.0, 0.0], &[3]);
1382        let _ = crate::ops::activation::layer_norm(t, gamma, Some(beta), 1e-5);
1383    }
1384
1385    #[test]
1386    #[should_panic(expected = "gamma must be a 1-D tensor")]
1387    fn test_layer_norm_gamma_rank_mismatch_panics() {
1388        // gamma [2, 4] has matching last-dim but rank 2, so the old last-dim
1389        // check alone would have accepted it and then indexed wrong storage.
1390        let t = flex_f32(vec![1.0, 2.0, 3.0, 4.0], &[1, 4]);
1391        let gamma = flex_f32(vec![1.0; 8], &[2, 4]);
1392        let _ = crate::ops::activation::layer_norm(t, gamma, None, 1e-5);
1393    }
1394
1395    // Row length 17 leaves exactly one scalar-tail element after every common
1396    // SIMD width (NEON/SSE f32x4: body=16, tail=1; AVX2 f32x8: body=16, tail=1;
1397    // AVX-512 f32x16: body=16, tail=1). Row lengths that divide evenly by the
1398    // SIMD width skip the tail branch entirely, so a bug in the scalar tail
1399    // kernel would sail past CI without a test like this.
1400    #[test]
1401    fn test_softmax_simd_body_plus_scalar_tail() {
1402        let data: Vec<f32> = (0..34).map(|i| (i as f32 * 0.137) - 2.3).collect();
1403        let expected = softmax_last_ref(&data, 17);
1404        let fused = crate::ops::activation::softmax(flex_f32(data, &[2, 17]), 1);
1405        fused.into_data().assert_approx_eq::<f32>(
1406            &TensorData::new(expected, vec![2, 17]),
1407            Tolerance::absolute(1e-5),
1408        );
1409    }
1410
1411    #[test]
1412    fn test_layer_norm_simd_body_plus_scalar_tail() {
1413        let data: Vec<f32> = (0..34).map(|i| (i as f32 * 0.137) - 2.3).collect();
1414        let gamma_data: Vec<f32> = (0..17).map(|i| 1.0 + i as f32 * 0.05).collect();
1415        let beta_data: Vec<f32> = (0..17).map(|i| i as f32 * 0.01).collect();
1416        let expected = layer_norm_last_ref(&data, &gamma_data, Some(&beta_data), 1e-5f32, 17);
1417        let fused = crate::ops::activation::layer_norm(
1418            flex_f32(data, &[2, 17]),
1419            flex_f32(gamma_data, &[17]),
1420            Some(flex_f32(beta_data, &[17])),
1421            1e-5,
1422        );
1423        fused.into_data().assert_approx_eq::<f32>(
1424            &TensorData::new(expected, vec![2, 17]),
1425            Tolerance::absolute(1e-5),
1426        );
1427    }
1428
1429    #[test]
1430    fn test_layer_norm_f64_with_beta_multi_chunk() {
1431        // 80 rows > ROWS_PER_TASK (64) exercises the rayon multi-chunk f64 path.
1432        let d_model = 16;
1433        let n_rows = 80;
1434        let data: Vec<f64> = (0..n_rows * d_model)
1435            .map(|i| ((i % 13) as f64) * 0.07 - 0.3)
1436            .collect();
1437        let gamma_data: Vec<f64> = vec![0.9; d_model];
1438        let beta_data: Vec<f64> = vec![0.05; d_model];
1439        let eps = 1e-5f64;
1440        let expected = layer_norm_last_ref(&data, &gamma_data, Some(&beta_data), eps, d_model);
1441        let fused = crate::ops::activation::layer_norm(
1442            flex_f64(data, &[n_rows, d_model]),
1443            flex_f64(gamma_data, &[d_model]),
1444            Some(flex_f64(beta_data, &[d_model])),
1445            eps,
1446        );
1447        fused.into_data().assert_approx_eq::<f64>(
1448            &TensorData::new(expected, vec![n_rows, d_model]),
1449            Tolerance::absolute(1e-10),
1450        );
1451    }
1452
1453    #[test]
1454    fn test_layer_norm_f64_no_beta() {
1455        let data: Vec<f64> = vec![1.0, 2.0, 3.0, 4.0, -1.0, 0.5, 1.5, -0.5];
1456        let gamma_data: Vec<f64> = vec![1.0; 4];
1457        let eps = 1e-5f64;
1458        let expected = layer_norm_last_ref(&data, &gamma_data, None, eps, 4);
1459        let fused = crate::ops::activation::layer_norm(
1460            flex_f64(data, &[2, 4]),
1461            flex_f64(gamma_data, &[4]),
1462            None,
1463            eps,
1464        );
1465        fused.into_data().assert_approx_eq::<f64>(
1466            &TensorData::new(expected, vec![2, 4]),
1467            Tolerance::absolute(1e-10),
1468        );
1469    }
1470
1471    // Shared body for f16/bf16 layer_norm tests. The fused half-precision
1472    // kernel casts to f32 internally, so the reference is computed in f32
1473    // and compared back against the half output with an f32 tolerance.
1474    fn check_layer_norm_half_precision<E>(from_f32: fn(f32) -> E, dtype: DType)
1475    where
1476        E: burn_backend::Element + Float,
1477    {
1478        let rows_f32: [f32; 12] = [
1479            1.0, 2.0, 3.0, 4.0, -1.0, 0.0, 1.0, 2.0, 0.5, -0.5, 1.5, -1.5,
1480        ];
1481        let gamma_f32: [f32; 4] = [1.0, 0.5, 1.5, 1.0];
1482        let beta_f32: [f32; 4] = [0.1, -0.1, 0.0, 0.2];
1483        let eps = 1e-5f32;
1484
1485        let expected_f32 = layer_norm_last_ref(&rows_f32, &gamma_f32, Some(&beta_f32), eps, 4);
1486
1487        let data: Vec<E> = rows_f32.iter().map(|&x| from_f32(x)).collect();
1488        let gamma_data: Vec<E> = gamma_f32.iter().map(|&x| from_f32(x)).collect();
1489        let beta_data: Vec<E> = beta_f32.iter().map(|&x| from_f32(x)).collect();
1490        assert_eq!(E::dtype(), dtype);
1491
1492        let fused = crate::ops::activation::layer_norm(
1493            flex_half(data, &[3, 4]),
1494            flex_half(gamma_data, &[4]),
1495            Some(flex_half(beta_data, &[4])),
1496            eps as f64,
1497        );
1498        fused.into_data().assert_approx_eq::<f32>(
1499            &TensorData::new(expected_f32, vec![3, 4]),
1500            Tolerance::absolute(3e-2),
1501        );
1502    }
1503
1504    #[test]
1505    fn test_layer_norm_f16_via_f32_cast() {
1506        check_layer_norm_half_precision::<f16>(f16::from_f32, DType::F16);
1507    }
1508
1509    #[test]
1510    fn test_layer_norm_bf16_via_f32_cast() {
1511        check_layer_norm_half_precision::<bf16>(bf16::from_f32, DType::BF16);
1512    }
1513
1514    #[test]
1515    #[should_panic(expected = "softmax dim")]
1516    fn test_softmax_dim_out_of_range_panics() {
1517        let t = flex_f32(vec![1.0, 2.0, 3.0, 4.0], &[2, 2]);
1518        let _ = crate::ops::activation::softmax(t, 2);
1519    }
1520
1521    #[test]
1522    #[should_panic(expected = "gamma dtype")]
1523    fn test_layer_norm_gamma_dtype_mismatch_panics() {
1524        // Input f32, gamma f64: layer_norm rejects the mismatch up front
1525        // rather than panicking later inside the storage-typed access.
1526        let t = flex_f32(vec![1.0, 2.0, 3.0, 4.0], &[1, 4]);
1527        let gamma = flex_f64(vec![1.0; 4], &[4]);
1528        let _ = crate::ops::activation::layer_norm(t, gamma, None, 1e-5);
1529    }
1530}