Skip to main content

rlx_tensor/
lib.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! Small host-side tensor kernels used by the native SAM3 bring-up.
17//!
18//! Compute-heavy ops go through `rlx_cpu::blas::sgemm_auto` so we get
19//! Accelerate/OpenBLAS instead of naive Rust loops. The pure-Rust path
20//! falls back to a scalar gemm with the same signature (see
21//! `rlx-cpu/src/blas.rs`).
22
23use anyhow::{Result, ensure};
24use rlx_cpu::blas;
25
26pub fn layer_norm(
27    x: &[f32],
28    gamma: &[f32],
29    beta: &[f32],
30    dim: usize,
31    eps: f32,
32) -> Result<Vec<f32>> {
33    ensure!(
34        x.len().is_multiple_of(dim),
35        "layer_norm input len must be divisible by dim"
36    );
37    ensure!(gamma.len() == dim, "layer_norm gamma len mismatch");
38    ensure!(beta.len() == dim, "layer_norm beta len mismatch");
39    let rows = x.len() / dim;
40    let mut out = vec![0.0; x.len()];
41    for r in 0..rows {
42        let row = &x[r * dim..(r + 1) * dim];
43        let mean = row.iter().sum::<f32>() / dim as f32;
44        let var = row
45            .iter()
46            .map(|v| {
47                let d = *v - mean;
48                d * d
49            })
50            .sum::<f32>()
51            / dim as f32;
52        let inv = 1.0 / (var + eps).sqrt();
53        for c in 0..dim {
54            out[r * dim + c] = (row[c] - mean) * inv * gamma[c] + beta[c];
55        }
56    }
57    Ok(out)
58}
59
60/// `out = x @ w_t + b` where `w_t` is `[in_dim, out_dim]` (already
61/// transposed at load time by `WeightMap::take_transposed`).
62pub fn linear(
63    x: &[f32],
64    rows: usize,
65    in_dim: usize,
66    w_t: &[f32],
67    out_dim: usize,
68    b: &[f32],
69) -> Result<Vec<f32>> {
70    ensure!(x.len() == rows * in_dim, "linear input shape mismatch");
71    ensure!(
72        w_t.len() == in_dim * out_dim,
73        "linear weight shape mismatch"
74    );
75    ensure!(b.len() == out_dim, "linear bias shape mismatch");
76    let mut out = vec![0.0f32; rows * out_dim];
77    blas::sgemm_bias(x, w_t, b, &mut out, rows, in_dim, out_dim);
78    Ok(out)
79}
80
81/// `out = a @ b` with default row-major no-transpose strides.
82pub fn matmul(a: &[f32], b: &[f32], out: &mut [f32], m: usize, k: usize, n: usize) {
83    blas::sgemm(a, b, out, m, k, n);
84}
85
86/// `out = alpha * a @ b^T` (no bias). `b` is `[n, k]`.
87pub fn matmul_bt(a: &[f32], b: &[f32], out: &mut [f32], m: usize, k: usize, n: usize, alpha: f32) {
88    blas::sgemm_bt(a, b, out, m, k, n, alpha);
89}
90
91pub fn gelu_tanh(x: &mut [f32]) {
92    const K: f32 = 0.797_884_6;
93    for v in x {
94        let x3 = *v * *v * *v;
95        *v = 0.5 * *v * (1.0 + (K * (*v + 0.044715 * x3)).tanh());
96    }
97}
98
99// Kept for the eventual mask-decoder sigmoid epilogue (referenced
100// in SAM3's prediction-head spec but not yet wired into a caller).
101#[allow(dead_code)]
102pub fn sigmoid(x: f32) -> f32 {
103    1.0 / (1.0 + (-x).exp())
104}
105
106/// Multi-head attention forward for an upstream `nn.MultiheadAttention`
107/// with the standard `in_proj_weight [3*E, E]` / `out_proj.weight [E, E]`
108/// parameterisation. Inputs are batch-first `[B, L_q, E]` / `[B, L_k, E]`.
109/// `key_padding_mask` (when present) is `[B, L_k]` with `true` meaning the
110/// position must be ignored.
111#[allow(clippy::too_many_arguments)]
112pub fn multihead_attention(
113    q: &[f32],
114    k: &[f32],
115    v: &[f32],
116    in_proj_w_t: &[f32], // [E, 3*E]
117    in_proj_b: &[f32],   // [3*E]
118    out_proj_w_t: &[f32],
119    out_proj_b: &[f32],
120    batch: usize,
121    l_q: usize,
122    l_k: usize,
123    embed_dim: usize,
124    num_heads: usize,
125    key_padding_mask: Option<&[u8]>,
126) -> Result<Vec<f32>> {
127    ensure!(
128        embed_dim.is_multiple_of(num_heads),
129        "embed_dim {embed_dim} not divisible by num_heads {num_heads}"
130    );
131    let head_dim = embed_dim / num_heads;
132
133    // PyTorch's nn.MultiheadAttention concatenates Q,K,V projections into
134    // in_proj_weight = [W_q; W_k; W_v]. For self-attention (q==k==v) we
135    // could fuse them; for safety we always do three linears.
136    let (wq, wk, wv) = split_in_proj_w(in_proj_w_t, embed_dim);
137    let bq = &in_proj_b[0..embed_dim];
138    let bk = &in_proj_b[embed_dim..2 * embed_dim];
139    let bv = &in_proj_b[2 * embed_dim..3 * embed_dim];
140
141    let q_proj = linear(q, batch * l_q, embed_dim, &wq, embed_dim, bq)?;
142    let k_proj = linear(k, batch * l_k, embed_dim, &wk, embed_dim, bk)?;
143    let v_proj = linear(v, batch * l_k, embed_dim, &wv, embed_dim, bv)?;
144
145    // Reshape to [B, H, L, D].
146    let bh = batch * num_heads;
147    let mut qh = vec![0f32; bh * l_q * head_dim];
148    let mut kh = vec![0f32; bh * l_k * head_dim];
149    let mut vh = vec![0f32; bh * l_k * head_dim];
150    repack_heads(&q_proj, &mut qh, batch, l_q, num_heads, head_dim);
151    repack_heads(&k_proj, &mut kh, batch, l_k, num_heads, head_dim);
152    repack_heads(&v_proj, &mut vh, batch, l_k, num_heads, head_dim);
153
154    let scale = 1.0f32 / (head_dim as f32).sqrt();
155    let mut scores = vec![0f32; l_q * l_k];
156    let mut attn_out = vec![0f32; bh * l_q * head_dim];
157    for bi in 0..batch {
158        for h in 0..num_heads {
159            let bhi = bi * num_heads + h;
160            let q_h = &qh[bhi * l_q * head_dim..(bhi + 1) * l_q * head_dim];
161            let k_h = &kh[bhi * l_k * head_dim..(bhi + 1) * l_k * head_dim];
162            let v_h = &vh[bhi * l_k * head_dim..(bhi + 1) * l_k * head_dim];
163            matmul_bt(q_h, k_h, &mut scores, l_q, head_dim, l_k, scale);
164            if let Some(mask) = key_padding_mask {
165                let mask_b = &mask[bi * l_k..(bi + 1) * l_k];
166                for r in 0..l_q {
167                    let row = &mut scores[r * l_k..(r + 1) * l_k];
168                    for (c, m) in mask_b.iter().enumerate() {
169                        if *m != 0 {
170                            row[c] = f32::NEG_INFINITY;
171                        }
172                    }
173                }
174            }
175            softmax_rows(&mut scores, l_q, l_k);
176            let out_h = &mut attn_out[bhi * l_q * head_dim..(bhi + 1) * l_q * head_dim];
177            matmul(&scores, v_h, out_h, l_q, l_k, head_dim);
178        }
179    }
180
181    // Repack [B, H, L_q, D] → [B, L_q, E].
182    let mut packed = vec![0f32; batch * l_q * embed_dim];
183    for bi in 0..batch {
184        for l in 0..l_q {
185            for h in 0..num_heads {
186                let src = ((bi * num_heads + h) * l_q + l) * head_dim;
187                let dst = (bi * l_q + l) * embed_dim + h * head_dim;
188                packed[dst..dst + head_dim].copy_from_slice(&attn_out[src..src + head_dim]);
189            }
190        }
191    }
192    linear(
193        &packed,
194        batch * l_q,
195        embed_dim,
196        out_proj_w_t,
197        embed_dim,
198        out_proj_b,
199    )
200}
201
202fn split_in_proj_w(in_proj_w_t: &[f32], embed_dim: usize) -> (Vec<f32>, Vec<f32>, Vec<f32>) {
203    // in_proj_w_t is stored as the transposed [E, 3*E] (row-major). The
204    // upstream nn.MultiheadAttention has [3*E, E] in source order. After
205    // `take_transposed`, output shape is [E, 3*E]; we want to extract the
206    // three [E, E] slabs at output stride 3*E.
207    let e = embed_dim;
208    let mut wq = vec![0f32; e * e];
209    let mut wk = vec![0f32; e * e];
210    let mut wv = vec![0f32; e * e];
211    for i in 0..e {
212        for j in 0..e {
213            wq[i * e + j] = in_proj_w_t[i * 3 * e + j];
214            wk[i * e + j] = in_proj_w_t[i * 3 * e + e + j];
215            wv[i * e + j] = in_proj_w_t[i * 3 * e + 2 * e + j];
216        }
217    }
218    (wq, wk, wv)
219}
220
221fn repack_heads(
222    src: &[f32],
223    dst: &mut [f32],
224    batch: usize,
225    l: usize,
226    num_heads: usize,
227    head_dim: usize,
228) {
229    let e = num_heads * head_dim;
230    for bi in 0..batch {
231        for li in 0..l {
232            for h in 0..num_heads {
233                let s = (bi * l + li) * e + h * head_dim;
234                let d = ((bi * num_heads + h) * l + li) * head_dim;
235                dst[d..d + head_dim].copy_from_slice(&src[s..s + head_dim]);
236            }
237        }
238    }
239}
240
241/// In-place row-wise softmax: each `cols`-long row is normalised
242/// independently. Numerically stable.
243pub fn softmax_rows(x: &mut [f32], rows: usize, cols: usize) {
244    for r in 0..rows {
245        let row = &mut x[r * cols..(r + 1) * cols];
246        let mut m = row[0];
247        for &v in row.iter().skip(1) {
248            if v > m {
249                m = v;
250            }
251        }
252        let mut sum = 0.0f32;
253        for v in row.iter_mut() {
254            *v = (*v - m).exp();
255            sum += *v;
256        }
257        let inv = 1.0 / sum;
258        for v in row.iter_mut() {
259            *v *= inv;
260        }
261    }
262}