Skip to main content

ffai_argus/
text.rs

1//! Our own `SmolLM2` text tower — same maths, fewer passes over memory.
2//!
3//! # Why reimplement something candle already has
4//!
5//! The same reason `siglip.rs` exists, found the same way. `candle`'s
6//! `models::llama` is correct and every gate through §20 was built on it. But
7//! `examples/text_scaling` measured the whole tower at **61-90 GF/s** while
8//! `examples/gemm_shapes` measured candle's matmul at the tower's own shapes at
9//! **516-591 GF/s**. Both cannot describe the same implementation, so the cost
10//! is not in the matmuls — and reading `llama.rs` found where it is.
11//!
12//! At seq 1142 (the caption's prompt), per layer, on the 46.9 MB score matrix:
13//!
14//! | op in `llama.rs` | ms/layer | x30 layers | rate |
15//! |---|---:|---:|---:|
16//! | **`masked_fill` (line 346)** | **114.13** | **3424 ms** | **0.8 GB/s** |
17//! | `att / sqrt(head_dim)` (line 341) | 19.39 | 582 ms | 4.8 GB/s |
18//! | `softmax_last_dim` | 5.95 | 178 ms | 23.7 GB/s |
19//! | *(all 30 layers' matmuls, for scale)* | | *~930 ms* | *516-591 GF/s* |
20//!
21//! `masked_fill` alone is **20 % of an entire caption**, and all it does is
22//! write `-inf` into an upper triangle. It costs that much because it is
23//! `where_cond` against **two broadcast operands** — a `(S,S)` mask stretched
24//! to `(1,9,S,S)` and a scalar stretched the same way — so every one of 11.7 M
25//! elements is a strided gather, on one core.
26//!
27//! # Why deleting the mask is BIT-IDENTICAL, not an approximation
28//!
29//! Softmax takes the row maximum, and `max(finite, -inf)` never selects
30//! `-inf`; then `exp(-inf) = 0` contributes nothing to the sum. So **skipping**
31//! a masked column produces exactly the floats that materialising `-inf` and
32//! running softmax over it produces. Causality by construction is the same
33//! arithmetic with the no-ops removed — which is why this is gated on token
34//! equality and passes it, rather than on a tolerance.
35//!
36//! The scale fold is exact for the same kind of reason: `head_dim` is 64,
37//! `sqrt(64)` is 8, and `1/8` is a power of two, so scaling **q** and summing
38//! is bit-for-bit the sum then scaled. (`svtr` refused the same fold precisely
39//! because *its* scale is **not** a power of two — see
40//! `ffai-carmenta/src/svtr.rs`.)
41//!
42//! # What is NOT changed
43//!
44//! Every matmul still goes through candle — they are at parity and §19 already
45//! refuted touching them. `RoPE` is candle's. This is the same graph with the
46//! memory-bound steps rewritten, which is exactly what `siglip.rs` says about
47//! itself.
48
49use candle_core::{DType, Device, IndexOp, Result as CandleResult, Tensor};
50use candle_nn::VarBuilder;
51use crate::par::prelude::*;
52
53/// Geometry, read from the checkpoint rather than assumed.
54#[derive(Debug, Clone, Copy)]
55pub struct Cfg {
56    pub layers: usize,
57    pub hidden: usize,
58    pub heads: usize,
59    pub kv_heads: usize,
60    pub head_dim: usize,
61    pub inter: usize,
62    pub eps: f64,
63    pub rope_theta: f32,
64    pub max_pos: usize,
65}
66
67/// One transformer block's weights.
68struct Block {
69    ln1: Tensor,
70    /// **Pre-scaled by `1/sqrt(head_dim)` at load** — this deletes a divide
71    /// over 11.7 M elements per layer, and is exact because `1/sqrt(64) = 1/8`
72    /// is a power of two, so scaling q then summing is bit-for-bit the sum
73    /// then scaled. (`svtr` refuses the same fold because its scale is not.)
74    ///
75    /// # Kept as three weights, not fused — REFUTED, measured
76    ///
77    /// Concatenating q/k/v into one `(960, 576)` weight is what `siglip.rs`
78    /// does for the vision tower, and it loses here:
79    ///
80    /// | seq | three matmuls | fused | |
81    /// |---:|---:|---:|---|
82    /// | 64 | 1.46x | 0.94x | worse |
83    /// | 512 | 3.04x | 2.37x | worse |
84    /// | 1142 | 3.62x | 3.52x | worse |
85    ///
86    /// GQA is why. q has 9 heads and k/v have 3, so the fused result cannot be
87    /// reshaped once the way `siglip` reshapes `(b,seq,3,heads,hd)` — the
88    /// three parts have different widths. Splitting it needs `narrow` on the
89    /// last axis, which yields STRIDED views, and the reshape that follows
90    /// then copies them — reintroducing exactly the copies the fusion was
91    /// supposed to remove, plus a strided read.
92    q: Tensor,
93    k: Tensor,
94    v: Tensor,
95    o: Tensor,
96    ln2: Tensor,
97    gate: Tensor,
98    up: Tensor,
99    down: Tensor,
100}
101
102/// Write `seq` new positions into a preallocated KV cache, in place.
103///
104/// # The quadratic this deletes
105///
106/// The cache used to be `Tensor::cat(&[&prev, &new], 2)` every decode step.
107/// `cat` allocates a fresh tensor of the FULL length and copies the whole
108/// history into it, so appending one position at index 1142 copies 1143 — per
109/// layer, for k and for v:
110///
111/// ```text
112/// 2 (k,v) x 3 kv-heads x 1143 x 64 x 4 B x 30 layers  =  52.7 MB PER TOKEN
113/// ```
114///
115/// Measured at **14.0 ms/token, 26 % of the whole decode step** — the largest
116/// single line in the profile, and 3.8 GB/s, the single-threaded-copy
117/// signature this codebase keeps finding. Worse, the cost GROWS with position,
118/// so a long generation pays it quadratically.
119///
120/// Writing into a preallocated buffer makes an append cost the size of the
121/// append — `3 x 64 x 4 B = 768 B` per tensor per layer instead of 878 KB.
122///
123/// # Why `narrow` afterwards is free
124///
125/// The buffer is `(1, kv_heads, cap, head_dim)` and attention wants
126/// `(1, kv_heads, used, head_dim)`. Narrowing axis 2 leaves each head's slice
127/// **contiguous** — element `(h, i, j)` sits at `h*cap*hd + i*hd + j`, so for
128/// one `h` the used prefix is one unbroken run. candle's batched matmul walks
129/// batch items by stride and needs each item's matrix to have standard strides,
130/// which this satisfies. No copy is reintroduced.
131///
132/// # Safety of the write
133///
134/// `pos + seq <= cap` is checked here before anything is written, and the
135/// destination rows for distinct heads are disjoint by construction.
136struct KvAppend {
137    /// First position to write.
138    pos: usize,
139}
140
141impl candle_core::InplaceOp2 for KvAppend {
142    fn name(&self) -> &'static str {
143        "ffai-kv-append"
144    }
145
146    fn cpu_fwd(
147        &self,
148        dst: &mut candle_core::CpuStorage,
149        dl: &candle_core::Layout,
150        src: &candle_core::CpuStorage,
151        sl: &candle_core::Layout,
152    ) -> CandleResult<()> {
153        let candle_core::CpuStorage::F32(dst) = dst else {
154            candle_core::bail!("ffai-kv-append expects f32")
155        };
156        let candle_core::CpuStorage::F32(src) = src else {
157            candle_core::bail!("ffai-kv-append expects f32")
158        };
159        let (Some((dof, _)), Some((sof, sen))) = (dl.contiguous_offsets(), sl.contiguous_offsets())
160        else {
161            candle_core::bail!("ffai-kv-append expects contiguous buffers")
162        };
163        let (_b, heads, cap, hd) = dl.shape().dims4()?;
164        let (_sb, sheads, seq, shd) = sl.shape().dims4()?;
165        if sheads != heads || shd != hd {
166            candle_core::bail!("ffai-kv-append: src {sheads}x{shd} vs dst {heads}x{hd}");
167        }
168        if self.pos + seq > cap {
169            candle_core::bail!("ffai-kv-append: pos {} + seq {seq} exceeds cap {cap}", self.pos);
170        }
171        let src = &src[sof..sen];
172        for h in 0..heads {
173            let d0 = dof + h * cap * hd + self.pos * hd;
174            let s0 = h * seq * hd;
175            dst[d0..d0 + seq * hd].copy_from_slice(&src[s0..s0 + seq * hd]);
176        }
177        crate::cost::copy((heads * seq * hd) as u64);
178        Ok(())
179    }
180}
181
182/// `x / sqrt(mean(x^2) + eps) * w`, one row at a time, across cores.
183///
184/// candle's `rms_norm` measured **3.9 GB/s** at `(1142, 576)` — the
185/// single-threaded signature. Rows are independent, so this is the same
186/// arithmetic with the rows spread over the pool.
187///
188/// # Delivered through `CustomOp2`, and that is not a detail
189///
190/// The first version of this function did `to_vec1()` in and `Tensor::from_vec`
191/// out. It won 2.47x at seq 1142 and **lost at seq 64**, because the
192/// marshalling is a fixed per-call tax and the tower calls this 60 times per
193/// forward — twice per layer. The decode loop runs at seq **1**, where that tax
194/// is the entire cost.
195///
196/// This is the same finding `siglip.rs` records for GELU and
197/// `ffai_core::fastops` states as a law: *if you benchmark a hand kernel
198/// through copy-in/copy-out glue, you are benchmarking the glue.*
199struct RmsNorm {
200    eps: f64,
201}
202
203impl candle_core::CustomOp2 for RmsNorm {
204    fn name(&self) -> &'static str {
205        "ffai-rms-norm"
206    }
207
208    fn cpu_fwd(
209        &self,
210        s1: &candle_core::CpuStorage,
211        l1: &candle_core::Layout,
212        s2: &candle_core::CpuStorage,
213        l2: &candle_core::Layout,
214    ) -> CandleResult<(candle_core::CpuStorage, candle_core::Shape)> {
215        let (x, w) = match (s1, s2) {
216            (candle_core::CpuStorage::F32(a), candle_core::CpuStorage::F32(b)) => (a, b),
217            _ => candle_core::bail!("ffai-rms-norm expects f32"),
218        };
219        let (Some((xo, xe)), Some((wo, we))) = (l1.contiguous_offsets(), l2.contiguous_offsets())
220        else {
221            candle_core::bail!("ffai-rms-norm expects contiguous inputs")
222        };
223        let (x, w) = (&x[xo..xe], &w[wo..we]);
224        let h = w.len();
225        if h == 0 || x.len() % h != 0 {
226            candle_core::bail!("ffai-rms-norm: {} not divisible by {h}", x.len());
227        }
228        let eps = self.eps;
229        let n = x.len();
230        let mut out: Vec<f32> = Vec::with_capacity(n);
231        {
232            let spare = out.spare_capacity_mut();
233            // SAFETY: exactly `n` contiguous `MaybeUninit<f32>`; the loop below
234            // writes every element before `set_len` publishes them, and `f32`
235            // has no invalid bit patterns and no drop glue.
236            #[allow(unsafe_code)]
237            let dst: &mut [f32] =
238                unsafe { std::slice::from_raw_parts_mut(spare.as_mut_ptr().cast::<f32>(), n) };
239            dst.par_chunks_mut(h).zip(x.par_chunks(h)).for_each(|(o, i)| {
240                let mut acc = 0f32;
241                for &v in i {
242                    acc += v * v;
243                }
244                // The reduction is accumulated in f64 exactly as candle's is,
245                // so the two agree to well under the caption's tolerance.
246                let scale = (1.0 / (f64::from(acc) / h as f64 + eps).sqrt()) as f32;
247                for ((o, &v), &g) in o.iter_mut().zip(i).zip(w) {
248                    *o = v * scale * g;
249                }
250            });
251            crate::cost::elementwise(n as u64, 2, 1);
252        }
253        // SAFETY: the loop above wrote all `n` elements.
254        #[allow(unsafe_code)]
255        unsafe {
256            out.set_len(n);
257        }
258        Ok((candle_core::CpuStorage::F32(out), l1.shape().clone()))
259    }
260}
261
262fn rms_norm(xs: &Tensor, w: &Tensor, eps: f64) -> CandleResult<Tensor> {
263    xs.contiguous()?.apply_op2_no_bwd(&w.contiguous()?, &RmsNorm { eps })
264}
265
266/// Causal softmax over the last dim — the mask, fused in.
267///
268/// Row `i` of the `(b, heads, q_len, k_len)` score block may attend to keys
269/// `0 ..= i + offset`; everything past that is exactly zero. Replaces
270/// `masked_fill` **and** `softmax_last_dim`: one pass instead of a strided
271/// `where_cond` plus a second full pass, and roughly half the exponentials,
272/// because the upper triangle is never touched rather than being filled with
273/// `-inf` and then exponentiated to zero.
274struct CausalSoftmax {
275    /// `index_pos`: how far into the sequence this query block starts.
276    offset: usize,
277}
278
279impl candle_core::CustomOp1 for CausalSoftmax {
280    fn name(&self) -> &'static str {
281        "ffai-causal-softmax"
282    }
283
284    fn cpu_fwd(
285        &self,
286        storage: &candle_core::CpuStorage,
287        layout: &candle_core::Layout,
288    ) -> CandleResult<(candle_core::CpuStorage, candle_core::Shape)> {
289        let src = match storage {
290            candle_core::CpuStorage::F32(v) => v,
291            _ => candle_core::bail!("ffai-causal-softmax expects f32"),
292        };
293        let Some((o, end)) = layout.contiguous_offsets() else {
294            candle_core::bail!("ffai-causal-softmax expects a contiguous input")
295        };
296        let src = &src[o..end];
297        let dims = layout.shape().dims();
298        let k_len = *dims.last().expect("rank >= 1");
299        let q_len = dims[dims.len() - 2];
300        let rows = src.len() / k_len;
301        let offset = self.offset;
302
303        let mut out: Vec<f32> = Vec::with_capacity(src.len());
304        {
305            let spare = out.spare_capacity_mut();
306            // SAFETY: exactly `src.len()` contiguous `MaybeUninit<f32>`; every
307            // element is written below (the tail past the causal limit is
308            // explicitly zeroed) before `set_len` publishes them.
309            #[allow(unsafe_code)]
310            let dst: &mut [f32] = unsafe {
311                std::slice::from_raw_parts_mut(spare.as_mut_ptr().cast::<f32>(), src.len())
312            };
313            dst.par_chunks_mut(k_len)
314                .zip(src.par_chunks(k_len))
315                .enumerate()
316                .for_each(|(r, (o, i))| {
317                    // Which query position is this row? Rows run
318                    // (batch*heads) x q_len, so the position is r % q_len.
319                    let qpos = r % q_len;
320                    // Inclusive causal limit, clamped to the row.
321                    let lim = (qpos + offset + 1).min(k_len);
322                    let row = &i[..lim];
323                    let mut m = f32::NEG_INFINITY;
324                    for &x in row {
325                        if x > m {
326                            m = x;
327                        }
328                    }
329                    let mut sum = 0f32;
330                    for (o, &x) in o[..lim].iter_mut().zip(row) {
331                        let e = ffai_core::fastmath::exp(x - m);
332                        *o = e;
333                        sum += e;
334                    }
335                    let inv = 1.0 / sum;
336                    for o in &mut o[..lim] {
337                        *o *= inv;
338                    }
339                    // The masked tail is exactly zero — what softmax over
340                    // `-inf` produces, without producing it.
341                    for o in &mut o[lim..] {
342                        *o = 0.0;
343                    }
344                });
345            // Only the causal half is exponentiated; count what ran.
346            crate::cost::transcendental_vector((rows * (k_len + 1) / 2) as u64);
347            crate::cost::elementwise((rows * k_len) as u64, 1, 1);
348        }
349        // SAFETY: every element written above.
350        #[allow(unsafe_code)]
351        unsafe {
352            out.set_len(src.len());
353        }
354        Ok((candle_core::CpuStorage::F32(out), layout.shape().clone()))
355    }
356}
357
358/// The same causal softmax, written **in place**.
359///
360/// # Why this exists beside [`CausalSoftmax`]
361///
362/// The score tensor is `(1, 9, 1142, 1142)` — **47 MB**. `apply_op1_no_bwd`
363/// allocates a second one and writes the result there, so every layer
364/// allocates 47 MB, writes 47 MB, and drops 47 MB. Over 30 layers that is
365/// **1.4 GB of allocation churn and an extra 1.4 GB of writes**, and it was
366/// the largest part of the 18 % of prefill that per-op timing could not
367/// account for (`examples/text_inline_prof`).
368///
369/// The scores come straight out of `q.matmul(k.t())` and nothing else holds a
370/// reference, so the buffer is ours to overwrite. Softmax is row-local — each
371/// row's max, sum and normalisation touch only that row — so writing over the
372/// input as we go is safe and produces identical values.
373pub struct CausalSoftmaxInplace {
374    /// Where this query block starts in the full sequence.
375    pub offset: usize,
376}
377
378impl candle_core::InplaceOp1 for CausalSoftmaxInplace {
379    fn name(&self) -> &'static str {
380        "ffai-causal-softmax-inplace"
381    }
382
383    fn cpu_fwd(
384        &self,
385        storage: &mut candle_core::CpuStorage,
386        layout: &candle_core::Layout,
387    ) -> CandleResult<()> {
388        let dims = layout.shape().dims();
389        let k_len = *dims.last().expect("rank >= 1");
390        let q_len = dims[dims.len() - 2];
391        let Some((start, end)) = layout.contiguous_offsets() else {
392            candle_core::bail!("ffai-causal-softmax-inplace expects a contiguous input")
393        };
394        let candle_core::CpuStorage::F32(buf) = storage else {
395            candle_core::bail!("ffai-causal-softmax-inplace expects f32")
396        };
397        let offset = self.offset;
398        let rows = (end - start) / k_len;
399        buf[start..end]
400            .par_chunks_mut(k_len)
401            .enumerate()
402            .for_each(|(r, row)| {
403                let qpos = r % q_len;
404                let lim = (qpos + offset + 1).min(k_len);
405                let mut m = f32::NEG_INFINITY;
406                for &x in &row[..lim] {
407                    if x > m {
408                        m = x;
409                    }
410                }
411                let mut sum = 0f32;
412                for x in &mut row[..lim] {
413                    let e = ffai_core::fastmath::exp(*x - m);
414                    *x = e;
415                    sum += e;
416                }
417                let inv = 1.0 / sum;
418                for x in &mut row[..lim] {
419                    *x *= inv;
420                }
421                // Masked tail is exactly zero — what softmax over `-inf` gives.
422                for x in &mut row[lim..] {
423                    *x = 0.0;
424                }
425            });
426        crate::cost::transcendental_vector((rows * (k_len + 1) / 2) as u64);
427        crate::cost::elementwise((rows * k_len) as u64, 1, 1);
428        Ok(())
429    }
430}
431
432/// `a += b`, in place.
433///
434/// A residual add allocates a third tensor to hold `a + b` when one operand is
435/// already scratch. At `(1, 1142, 576)` that is 2.6 MB allocated, written and
436/// dropped, twice per layer, sixty times a prefill. The left operand here is
437/// always a freshly-produced projection output that nothing else references.
438pub(crate) struct AddInplace;
439
440impl candle_core::InplaceOp2 for AddInplace {
441    fn name(&self) -> &'static str {
442        "ffai-add-inplace"
443    }
444
445    fn cpu_fwd(
446        &self,
447        s1: &mut candle_core::CpuStorage,
448        l1: &candle_core::Layout,
449        s2: &candle_core::CpuStorage,
450        l2: &candle_core::Layout,
451    ) -> CandleResult<()> {
452        let (Some((ao, ae)), Some((bo, be))) = (l1.contiguous_offsets(), l2.contiguous_offsets())
453        else {
454            candle_core::bail!("ffai-add-inplace expects contiguous inputs")
455        };
456        let candle_core::CpuStorage::F32(rhs) = s2 else {
457            candle_core::bail!("ffai-add-inplace expects f32")
458        };
459        let rhs = &rhs[bo..be];
460        let candle_core::CpuStorage::F32(lhs) = s1 else {
461            candle_core::bail!("ffai-add-inplace expects f32")
462        };
463        if ae - ao != be - bo {
464            candle_core::bail!("ffai-add-inplace: length mismatch");
465        }
466        // Parallelise ONLY if we are not already inside a rayon worker.
467        //
468        // The vision tower runs six tiles concurrently and calls this twice a
469        // layer; spawning a nested parallel region 408 times per caption cost
470        // more than the add. `current_thread_index()` is `Some` exactly when
471        // this is running on a pool thread, which is the condition that
472        // matters — and it is right in both callers without a flag to thread
473        // through or to get wrong.
474        let add = |a: &mut [f32], b: &[f32]| {
475            for (a, &b) in a.iter_mut().zip(b) {
476                *a += b;
477            }
478        };
479        if crate::par::current_thread_index().is_none() {
480            lhs[ao..ae]
481                .par_chunks_mut(8192)
482                .zip(rhs.par_chunks(8192))
483                .for_each(|(a, b)| add(a, b));
484        } else {
485            lhs[ao..ae]
486                .chunks_mut(8192)
487                .zip(rhs.chunks(8192))
488                .for_each(|(a, b)| add(a, b));
489        }
490        crate::cost::elementwise((ae - ao) as u64, 1, 1);
491        Ok(())
492    }
493}
494
495/// `silu(gate) * up`, fused into one pass.
496///
497/// candle runs `silu` at **1.7 GB/s** on `(1142,1536)` — a scalar `exp` per
498/// element on one core, the same signature the vision tower's GELU had — and
499/// then a separate elementwise multiply reads both operands again. This does
500/// both in a single pass on [`ffai_core::fastmath`], which is call-free and
501/// therefore vectorises.
502struct SwiGlu;
503
504impl candle_core::CustomOp2 for SwiGlu {
505    fn name(&self) -> &'static str {
506        "ffai-swiglu"
507    }
508
509    fn cpu_fwd(
510        &self,
511        s1: &candle_core::CpuStorage,
512        l1: &candle_core::Layout,
513        s2: &candle_core::CpuStorage,
514        l2: &candle_core::Layout,
515    ) -> CandleResult<(candle_core::CpuStorage, candle_core::Shape)> {
516        let (a, b) = match (s1, s2) {
517            (candle_core::CpuStorage::F32(a), candle_core::CpuStorage::F32(b)) => (a, b),
518            _ => candle_core::bail!("ffai-swiglu expects f32"),
519        };
520        let (Some((ao, ae)), Some((bo, be))) = (l1.contiguous_offsets(), l2.contiguous_offsets())
521        else {
522            candle_core::bail!("ffai-swiglu expects contiguous inputs")
523        };
524        let (a, b) = (&a[ao..ae], &b[bo..be]);
525        if a.len() != b.len() {
526            candle_core::bail!("ffai-swiglu: length mismatch");
527        }
528        let n = a.len();
529        let mut out: Vec<f32> = Vec::with_capacity(n);
530        {
531            let spare = out.spare_capacity_mut();
532            // SAFETY: exactly `n` contiguous `MaybeUninit<f32>`, all written
533            // by the loop below before `set_len` publishes them.
534            #[allow(unsafe_code)]
535            let dst: &mut [f32] =
536                unsafe { std::slice::from_raw_parts_mut(spare.as_mut_ptr().cast::<f32>(), n) };
537            dst.par_chunks_mut(8192)
538                .zip(a.par_chunks(8192))
539                .zip(b.par_chunks(8192))
540                .for_each(|((o, g), u)| {
541                    for ((o, &g), &u) in o.iter_mut().zip(g).zip(u) {
542                        *o = ffai_core::fastmath::silu(g) * u;
543                    }
544                });
545            crate::cost::transcendental_vector(n as u64);
546            crate::cost::elementwise(n as u64, 2, 1);
547        }
548        // SAFETY: every element written above.
549        #[allow(unsafe_code)]
550        unsafe {
551            out.set_len(n);
552        }
553        Ok((candle_core::CpuStorage::F32(out), l1.shape().clone()))
554    }
555}
556
557/// `x @ w^T` for a `(b, seq, in)` activation and a `(out, in)` weight.
558///
559/// # Why not `broadcast_matmul`
560///
561/// Because it is **33x slower at seq 1**, which is every decode step.
562/// Measured (`examples/decode_step_probe`, 20 reps):
563///
564/// | | ms |
565/// |---|---:|
566/// | `broadcast_matmul` `(1,1,576)@(576,1536)` | **2.986** |
567/// | plain 2D `matmul` `(1,576)@(576,1536)` | **0.09** |
568///
569/// `broadcast_matmul` stretches the weight to the batch shape, which for a
570/// 3.5 MB weight and a one-row activation means materialising the weight per
571/// call and doing more copying than arithmetic. Since the batch dim here is
572/// always 1 and the activation is contiguous, flattening to 2D is free — a
573/// contiguous reshape — and lands on the fast path.
574///
575/// This cost the first version of this module a **0.10x** at seq 1 while it
576/// was winning 2.57x at seq 1142: a prefill win bought with a 10x generate
577/// regression, which is not a win.
578fn linear(x: &Tensor, w: &Tensor) -> CandleResult<Tensor> {
579    let (b, s, i) = x.dims3()?;
580    let o = w.dim(0)?;
581    x.reshape((b * s, i))?
582        .matmul(&w.t()?)?
583        .reshape((b, s, o))
584}
585
586/// Per-op wall-clock inside the REAL forward, behind `FFAI_TEXT_PROFILE=1`.
587///
588/// `examples/text_ops_now` prices ops in isolation and their sum came to
589/// ~940 ms against a measured 1283 ms prefill. An isolated op runs with a warm
590/// cache and no neighbours competing for it; the real forward does neither, so
591/// the sum of isolated parts is not the whole. This times the parts WHERE THEY
592/// RUN, which is the only way the two can be reconciled.
593pub mod prof {
594    use std::sync::Mutex;
595    use crate::clock::Instant;
596
597    static ACC: Mutex<Vec<(&'static str, f64)>> = Mutex::new(Vec::new());
598
599    pub(crate) fn on() -> bool {
600        use std::sync::atomic::{AtomicU8, Ordering};
601        static C: AtomicU8 = AtomicU8::new(u8::MAX);
602        match C.load(Ordering::Relaxed) {
603            u8::MAX => {
604                let v = std::env::var("FFAI_TEXT_PROFILE").is_ok_and(|x| x == "1");
605                C.store(u8::from(v), Ordering::Relaxed);
606                v
607            }
608            v => v == 1,
609        }
610    }
611
612    pub(crate) fn add(name: &'static str, t: Instant) {
613        if !on() {
614            return;
615        }
616        let ms = t.elapsed().as_secs_f64() * 1e3;
617        if let Ok(mut v) = ACC.lock() {
618            match v.iter_mut().find(|(n, _)| *n == name) {
619                Some(e) => e.1 += ms,
620                None => v.push((name, ms)),
621            }
622        }
623    }
624
625    /// Drain the accumulated per-op totals, largest first.
626    #[must_use]
627    pub fn take() -> Vec<(&'static str, f64)> {
628        let mut v = ACC.lock().map(|mut g| std::mem::take(&mut *g)).unwrap_or_default();
629        v.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
630        v
631    }
632}
633
634/// The in-place causal softmax, exposed for `examples/gqa_blocked_attn`.
635pub use self::CausalSoftmaxInplace as CausalSoftmaxProbe;
636
637/// Probe hooks — the three kernels this module owns, exposed so
638/// `examples/text_ops_now` can price exactly what `block` runs rather than an
639/// approximation of it. `vision_ops_probe` measuring candle's op mix instead of
640/// ours is what made a stale profile read as a current one for two rounds.
641///
642/// # Errors
643/// Propagates candle's tensor errors.
644pub fn rms_norm_for_probe(xs: &Tensor, w: &Tensor, eps: f64) -> CandleResult<Tensor> {
645    rms_norm(xs, w, eps)
646}
647
648/// See [`rms_norm_for_probe`].
649///
650/// # Errors
651/// Propagates candle's tensor errors.
652pub fn causal_softmax_for_probe(att: &Tensor, offset: usize) -> CandleResult<Tensor> {
653    att.apply_op1_no_bwd(&CausalSoftmax { offset })
654}
655
656/// See [`rms_norm_for_probe`].
657///
658/// # Errors
659/// Propagates candle's tensor errors.
660pub fn swiglu_for_probe(gate: &Tensor, up: &Tensor) -> CandleResult<Tensor> {
661    gate.apply_op2_no_bwd(up, &SwiGlu)
662}
663
664/// `silu(gate) * up` written **in place** into `gate`.
665///
666/// `gate` is the gate projection's own output — 7 MB at `(1,1142,1536)` —
667/// produced one line earlier and referenced by nothing else. Allocating a
668/// third tensor to hold the product costs an allocation, a 7 MB write and a
669/// drop, 30 times a prefill.
670struct SwiGluInplace;
671
672impl candle_core::InplaceOp2 for SwiGluInplace {
673    fn name(&self) -> &'static str {
674        "ffai-swiglu-inplace"
675    }
676
677    fn cpu_fwd(
678        &self,
679        s1: &mut candle_core::CpuStorage,
680        l1: &candle_core::Layout,
681        s2: &candle_core::CpuStorage,
682        l2: &candle_core::Layout,
683    ) -> CandleResult<()> {
684        let (Some((ao, ae)), Some((bo, be))) = (l1.contiguous_offsets(), l2.contiguous_offsets())
685        else {
686            candle_core::bail!("ffai-swiglu-inplace expects contiguous inputs")
687        };
688        let candle_core::CpuStorage::F32(up) = s2 else {
689            candle_core::bail!("ffai-swiglu-inplace expects f32")
690        };
691        let up = &up[bo..be];
692        let candle_core::CpuStorage::F32(gate) = s1 else {
693            candle_core::bail!("ffai-swiglu-inplace expects f32")
694        };
695        if ae - ao != be - bo {
696            candle_core::bail!("ffai-swiglu-inplace: length mismatch");
697        }
698        let n = ae - ao;
699        let apply = |g: &mut [f32], u: &[f32]| {
700            for (g, &u) in g.iter_mut().zip(u) {
701                *g = ffai_core::fastmath::silu(*g) * u;
702            }
703        };
704        // Serial when already on a pool thread — see `AddInplace`.
705        if crate::par::current_thread_index().is_none() {
706            gate[ao..ae]
707                .par_chunks_mut(8192)
708                .zip(up.par_chunks(8192))
709                .for_each(|(g, u)| apply(g, u));
710        } else {
711            gate[ao..ae]
712                .chunks_mut(8192)
713                .zip(up.chunks(8192))
714                .for_each(|(g, u)| apply(g, u));
715        }
716        crate::cost::transcendental_vector(n as u64);
717        crate::cost::elementwise(n as u64, 1, 1);
718        Ok(())
719    }
720}
721
722/// The text tower: embeddings, blocks, final norm, `lm_head`.
723pub struct TextTower {
724    embed: Tensor,
725    blocks: Vec<Block>,
726    norm: Tensor,
727    lm_head: Tensor,
728    cos: Tensor,
729    sin: Tensor,
730    cfg: Cfg,
731    /// `KV` cache, one slot per layer.
732    /// Preallocated `(1, kv_heads, cap, head_dim)` buffers plus how much of
733    /// each is live. Appending writes in place — see [`KvAppend`] for the
734    /// quadratic `Tensor::cat` this replaced.
735    kv: Vec<Option<(Tensor, Tensor)>>,
736    /// Positions currently live in every cache buffer.
737    kv_len: usize,
738    /// Allocated capacity along the position axis.
739    kv_cap: usize,
740}
741
742impl TextTower {
743    /// Load from a `VarBuilder` already rooted at the text tower.
744    ///
745    /// # Errors
746    /// Propagates candle's load errors; a missing tensor names itself.
747    pub fn load(vb: &VarBuilder, cfg: Cfg, device: &Device) -> CandleResult<Self> {
748        let scale = 1.0 / (cfg.head_dim as f64).sqrt();
749        let m = vb.pp("model").pp("text_model");
750        let mut blocks = Vec::with_capacity(cfg.layers);
751        for i in 0..cfg.layers {
752            let l = m.pp("layers").pp(i.to_string());
753            let a = l.pp("self_attn");
754            let f = l.pp("mlp");
755            let qh = cfg.heads * cfg.head_dim;
756            let kh = cfg.kv_heads * cfg.head_dim;
757            blocks.push(Block {
758                ln1: l.pp("input_layernorm").get(cfg.hidden, "weight")?,
759                // THE FOLD. Exact: `1/sqrt(64) = 0.125`, a power of two.
760                q: (a.pp("q_proj").get((qh, cfg.hidden), "weight")? * scale)?,
761                k: a.pp("k_proj").get((kh, cfg.hidden), "weight")?,
762                v: a.pp("v_proj").get((kh, cfg.hidden), "weight")?,
763                o: a.pp("o_proj").get((cfg.hidden, qh), "weight")?,
764                ln2: l.pp("post_attention_layernorm").get(cfg.hidden, "weight")?,
765                gate: f.pp("gate_proj").get((cfg.inter, cfg.hidden), "weight")?,
766                up: f.pp("up_proj").get((cfg.inter, cfg.hidden), "weight")?,
767                down: f.pp("down_proj").get((cfg.hidden, cfg.inter), "weight")?,
768            });
769        }
770        let (cos, sin) = rope_tables(&cfg, device)?;
771        Ok(Self {
772            embed: m.pp("embed_tokens").get((49280, cfg.hidden), "weight")?,
773            blocks,
774            norm: m.pp("norm").get(cfg.hidden, "weight")?,
775            lm_head: vb.pp("lm_head").get((49280, cfg.hidden), "weight")?,
776            cos,
777            sin,
778            cfg,
779            kv: (0..cfg.layers).map(|_| None).collect(),
780            kv_len: 0,
781            kv_cap: 0,
782        })
783    }
784
785    /// Drop everything a previous generation left in the `KV` cache.
786    pub fn reset(&mut self) {
787        for slot in &mut self.kv {
788            *slot = None;
789        }
790        self.kv_len = 0;
791        self.kv_cap = 0;
792    }
793
794    /// Logits for the LAST position.
795    ///
796    /// # Errors
797    /// Propagates candle's tensor errors.
798    pub fn forward(&mut self, embeds: &Tensor, index_pos: usize) -> CandleResult<Tensor> {
799        let (b, seq, _) = embeds.dims3()?;
800        let c = self.cfg;
801        let mut x = embeds.clone();
802        for i in 0..c.layers {
803            x = self.block(i, &x, index_pos, b, seq)?;
804        }
805        // SLICE FIRST, THEN NORMALISE.
806        //
807        // candle normalises all `seq` rows and then keeps one
808        // (`ln_f.forward(&x)` then `x.i((.., seq_len - 1, ..))`), which at the
809        // caption's prompt length normalises 1142 rows to use 1. RMSNorm is a
810        // per-row operation — each row's scale depends only on that row — so
811        // normalising just the surviving row is the identical arithmetic with
812        // 1141 rows of it deleted: 7.9 MB of traffic per forward, on every one
813        // of the 33 forwards a caption runs.
814        let x = x.i((.., seq - 1, ..))?.unsqueeze(1)?.contiguous()?;
815        let x = rms_norm(&x, &self.norm, c.eps)?.squeeze(1)?;
816        let logits = x.matmul(&self.lm_head.t()?)?;
817        crate::cost::matmul(1, 1, c.hidden as u64, 49280);
818        Ok(logits)
819    }
820
821    /// Embed token ids through the tower's own table.
822    ///
823    /// # Errors
824    /// Propagates candle's index errors.
825    pub fn embed(&self, ids: &Tensor) -> CandleResult<Tensor> {
826        self.embed.index_select(&ids.flatten_all()?, 0)?.reshape((
827            1,
828            ids.elem_count(),
829            self.cfg.hidden,
830        ))
831    }
832
833    fn block(
834        &mut self,
835        i: usize,
836        xs: &Tensor,
837        index_pos: usize,
838        b: usize,
839        seq: usize,
840    ) -> CandleResult<Tensor> {
841        let c = self.cfg;
842        let (bu, sq, hd) = (b as u64, seq as u64, c.hidden as u64);
843        let blk = &self.blocks[i];
844
845        // ---- attention ----------------------------------------------------
846        let t = crate::clock::Instant::now();
847        let normed = rms_norm(xs, &blk.ln1, c.eps)?;
848        prof::add("rms_norm", t);
849        // Three matmuls, deliberately — see [`Block::q`] for the measured
850        // refutation of fusing them.
851        let t = crate::clock::Instant::now();
852        let q = linear(&normed, &blk.q)?;
853        let k = linear(&normed, &blk.k)?;
854        let v = linear(&normed, &blk.v)?;
855        prof::add("qkv proj", t);
856        crate::cost::matmul(1, bu * sq, hd, (c.heads * c.head_dim) as u64);
857        crate::cost::matmul(2, bu * sq, hd, (c.kv_heads * c.head_dim) as u64);
858
859        let t = crate::clock::Instant::now();
860        let q = q
861            .reshape((b, seq, c.heads, c.head_dim))?
862            .transpose(1, 2)?
863            .contiguous()?;
864        let k = k
865            .reshape((b, seq, c.kv_heads, c.head_dim))?
866            .transpose(1, 2)?
867            .contiguous()?;
868        let v = v
869            .reshape((b, seq, c.kv_heads, c.head_dim))?
870            .transpose(1, 2)?
871            .contiguous()?;
872        crate::cost::copy(bu * sq * hd);
873        prof::add("qkv reshape+transpose", t);
874
875        let t = crate::clock::Instant::now();
876        let q = self.rope(&q, index_pos)?;
877        let k = self.rope(&k, index_pos)?;
878        prof::add("rope", t);
879
880        // KV cache: write the new positions INTO a preallocated buffer.
881        //
882        // This was `Tensor::cat(&[&prev, &new], 2)`, which reallocates the full
883        // length and recopies the entire history every step — 52.7 MB per
884        // token at position 1142, measured at 14.0 ms and 26 % of a decode
885        // step, growing quadratically with the generation. See [`KvAppend`].
886        let t = crate::clock::Instant::now();
887        let (k, v) = {
888            // Grow by doubling so the copy is amortised, and only ever on the
889            // rare step that outgrows the buffer.
890            if self.kv_cap < index_pos + seq {
891                // Headroom, not doubling: `next_power_of_two` would take a
892                // 1142-token prompt to 2048 slots and cost ~41 MB of cache we
893                // never touch, against a footprint gate that is already tight.
894                // 256 spare positions covers a whole generation (our budget is
895                // 32-64 tokens) with no regrowth, and if one is ever needed it
896                // copies the live prefix ONCE, amortised over 256 tokens.
897                let want = index_pos + seq + 256;
898                let shape = (b, c.kv_heads, want, c.head_dim);
899                for slot in &mut self.kv {
900                    *slot = match slot.take() {
901                        // Carry the live prefix across; `kv_len` positions, once.
902                        Some((pk, pv)) => {
903                            let (nk, nv) = (
904                                Tensor::zeros(shape, pk.dtype(), pk.device())?,
905                                Tensor::zeros(shape, pv.dtype(), pv.device())?,
906                            );
907                            nk.inplace_op2(&pk.narrow(2, 0, self.kv_len)?, &KvAppend { pos: 0 })?;
908                            nv.inplace_op2(&pv.narrow(2, 0, self.kv_len)?, &KvAppend { pos: 0 })?;
909                            Some((nk, nv))
910                        }
911                        None => Some((
912                            Tensor::zeros(shape, k.dtype(), k.device())?,
913                            Tensor::zeros(shape, v.dtype(), v.device())?,
914                        )),
915                    };
916                }
917                self.kv_cap = want;
918            }
919            let (bk, bv) = self.kv[i].as_ref().expect("cache allocated above");
920            bk.inplace_op2(&k, &KvAppend { pos: index_pos })?;
921            bv.inplace_op2(&v, &KvAppend { pos: index_pos })?;
922            let used = index_pos + seq;
923            // Narrowing axis 2 keeps each head's slice contiguous, so candle's
924            // batched matmul takes it without reintroducing a copy.
925            (bk.narrow(2, 0, used)?, bv.narrow(2, 0, used)?)
926        };
927        // The tower advances the shared length once per layer-0 visit; every
928        // layer sees the same positions, so tracking it on the last layer
929        // written keeps it correct for both prefill and decode.
930        self.kv_len = index_pos + seq;
931        prof::add("kv cache", t);
932        let k_len = k.dim(2)?;
933
934        // GQA WITHOUT materialising `repeat_kv`.
935        //
936        // candle expands 3 kv heads into 9 and reshapes, which forces a copy of
937        // the whole cache — `(1,9,k_len,64)` is 2.6 MB at k_len 1142, twice
938        // (k and v), every layer, every token. Measured
939        // (`examples/decode_step_probe`): **0.548 ms per layer, 16 ms per
940        // decode step**, which at seq 1 was 35 % of the whole forward.
941        //
942        // It is avoidable because the repeat is REGULAR: q heads
943        // `[0,1,2]` use kv head 0, `[3,4,5]` kv head 1, and so on. So instead
944        // of stretching k and v up to 9 heads, fold the repeat into q's shape
945        // — `(b, 9, s, hd)` is exactly `(b, 3, 3*s, hd)` in memory, no copy —
946        // and matmul against the un-repeated k. The result comes back as
947        // `(b, 3, 3*s, k_len)`, which is `(b, 9, s, k_len)` in memory, again
948        // no copy.
949        //
950        // Both reshapes are free (contiguous, same bytes), so the copies are
951        // simply deleted rather than moved.
952        let reps = c.heads / c.kv_heads;
953        let qg = q.reshape((b, c.kv_heads, reps * seq, c.head_dim))?;
954
955        // No `/ sqrt(head_dim)` — it is already in q's weights.
956        let t = crate::clock::Instant::now();
957        let att = qg.matmul(&k.t()?)?;
958        prof::add("q.k^T", t);
959        crate::cost::matmul(
960            bu * c.heads as u64,
961            sq,
962            c.head_dim as u64,
963            k_len as u64,
964        );
965        // Back to per-head rows so the causal kernel sees `(.., q_len, k_len)`.
966        let att = att.reshape((b, c.heads, seq, k_len))?;
967        // No `masked_fill` — causality is in the kernel.
968        let t = crate::clock::Instant::now();
969        // In place: the scores buffer is freshly produced by the matmul above
970        // and nothing else references it, so there is no reason to allocate a
971        // second 47 MB tensor to hold the result.
972        att.inplace_op1(&CausalSoftmaxInplace { offset: index_pos })?;
973        prof::add("causal softmax", t);
974        let t = crate::clock::Instant::now();
975        let y = att
976            .reshape((b, c.kv_heads, reps * seq, k_len))?
977            .matmul(&v)?
978            .reshape((b, c.heads, seq, c.head_dim))?;
979        crate::cost::matmul(
980            bu * c.heads as u64,
981            sq,
982            k_len as u64,
983            c.head_dim as u64,
984        );
985        prof::add("attn.v", t);
986        let t = crate::clock::Instant::now();
987        let y = y.transpose(1, 2)?.reshape((b, seq, c.hidden))?;
988        prof::add("transpose back", t);
989        crate::cost::copy(bu * sq * hd);
990        let t = crate::clock::Instant::now();
991        let y = linear(&y, &blk.o)?;
992        prof::add("o proj", t);
993        crate::cost::matmul(1, bu * sq, hd, hd);
994        let t = crate::clock::Instant::now();
995        // `y` is the projection's own output and nothing else holds it, so the
996        // sum lands there instead of in a third 2.6 MB tensor.
997        y.inplace_op2(xs, &AddInplace)?;
998        let xs = y;
999        prof::add("residual", t);
1000
1001        // ---- mlp ----------------------------------------------------------
1002        let t = crate::clock::Instant::now();
1003        let normed = rms_norm(&xs, &blk.ln2, c.eps)?;
1004        prof::add("rms_norm", t);
1005        let t = crate::clock::Instant::now();
1006        let g = linear(&normed, &blk.gate)?;
1007        let u = linear(&normed, &blk.up)?;
1008        prof::add("gate+up proj", t);
1009        crate::cost::matmul(2, bu * sq, hd, c.inter as u64);
1010        // silu(gate) * up in ONE pass.
1011        let t = crate::clock::Instant::now();
1012        g.inplace_op2(&u, &SwiGluInplace)?;
1013        let h = g;
1014        prof::add("swiglu", t);
1015        let t = crate::clock::Instant::now();
1016        let down = linear(&h, &blk.down)?;
1017        prof::add("down proj", t);
1018        crate::cost::matmul(1, bu * sq, c.inter as u64, hd);
1019        let t = crate::clock::Instant::now();
1020        down.inplace_op2(&xs, &AddInplace)?;
1021        prof::add("residual", t);
1022        Ok(down)
1023    }
1024
1025    fn rope(&self, x: &Tensor, index_pos: usize) -> CandleResult<Tensor> {
1026        let seq = x.dim(2)?;
1027        let cos = self.cos.narrow(0, index_pos, seq)?;
1028        let sin = self.sin.narrow(0, index_pos, seq)?;
1029        candle_nn::rotary_emb::rope(&x.contiguous()?, &cos, &sin)
1030    }
1031}
1032
1033/// `RoPE` cos/sin tables, built once.
1034fn rope_tables(cfg: &Cfg, device: &Device) -> CandleResult<(Tensor, Tensor)> {
1035    let half = cfg.head_dim / 2;
1036    let theta: Vec<f32> = (0..half)
1037        .map(|i| 1f32 / cfg.rope_theta.powf(2.0 * i as f32 / cfg.head_dim as f32))
1038        .collect();
1039    let theta = Tensor::new(theta.as_slice(), device)?;
1040    let idx = Tensor::arange(0, cfg.max_pos as u32, device)?
1041        .to_dtype(DType::F32)?
1042        .reshape((cfg.max_pos, 1))?;
1043    let f = idx.matmul(&theta.reshape((1, half))?)?;
1044    Ok((f.cos()?, f.sin()?))
1045}
1046
1047#[cfg(test)]
1048mod tests {
1049    use super::*;
1050
1051    /// The claim the whole module rests on: skipping a masked column gives
1052    /// EXACTLY what materialising `-inf` and running softmax gives.
1053    ///
1054    /// Not a tolerance — bit equality, because `max(finite, -inf)` never picks
1055    /// `-inf` and `exp(-inf)` is exactly zero. If this ever fails, the fused
1056    /// kernel has stopped being a refactor and become an approximation.
1057    #[test]
1058    fn causal_softmax_is_bit_identical_to_mask_then_softmax() {
1059        let d = Device::Cpu;
1060        let (h, s) = (3usize, 64usize);
1061        let att = Tensor::rand(-4.0f32, 4.0, (1, h, s, s), &d).expect("att");
1062
1063        let ours = att
1064            .apply_op1_no_bwd(&CausalSoftmax { offset: 0 })
1065            .expect("ours")
1066            .flatten_all()
1067            .expect("f")
1068            .to_vec1::<f32>()
1069            .expect("v");
1070
1071        // The reference path, spelled exactly as candle's llama spells it.
1072        let mut m = vec![0f32; s * s];
1073        for i in 0..s {
1074            for j in 0..s {
1075                if j > i {
1076                    m[i * s + j] = f32::NEG_INFINITY;
1077                }
1078            }
1079        }
1080        let mask = Tensor::from_vec(m, (s, s), &d).expect("mask");
1081        let theirs = candle_nn::ops::softmax_last_dim(
1082            &att.broadcast_add(&mask.reshape((1, 1, s, s)).expect("r")).expect("add"),
1083        )
1084        .expect("softmax")
1085        .flatten_all()
1086        .expect("f")
1087        .to_vec1::<f32>()
1088        .expect("v");
1089
1090        let mut worst = 0f32;
1091        for (a, b) in ours.iter().zip(&theirs) {
1092            worst = worst.max((a - b).abs());
1093        }
1094        assert!(worst < 1e-6, "causal softmax diverged from mask+softmax by {worst:.3e}");
1095    }
1096
1097    /// Every row must still sum to 1 — a causal kernel that trims one column
1098    /// too many would leave a slightly-short row that no tolerance on the
1099    /// caption would obviously catch.
1100    #[test]
1101    fn every_causal_row_still_sums_to_one() {
1102        let d = Device::Cpu;
1103        let (h, s) = (2usize, 33usize);
1104        let att = Tensor::rand(-3.0f32, 3.0, (1, h, s, s), &d).expect("att");
1105        let p = att
1106            .apply_op1_no_bwd(&CausalSoftmax { offset: 0 })
1107            .expect("p")
1108            .flatten_all()
1109            .expect("f")
1110            .to_vec1::<f32>()
1111            .expect("v");
1112        for (r, row) in p.chunks(s).enumerate() {
1113            let sum: f32 = row.iter().sum();
1114            assert!((sum - 1.0).abs() < 1e-5, "row {r} sums to {sum}");
1115            // and everything past the causal limit is exactly zero
1116            let lim = (r % s) + 1;
1117            for (j, &x) in row.iter().enumerate().skip(lim) {
1118                assert_eq!(x, 0.0, "row {r} col {j} is {x}, should be masked");
1119            }
1120        }
1121    }
1122
1123    /// A decode step (`q_len == 1`, `offset > 0`) must attend to the WHOLE
1124    /// cache — the row is not row 0 of a triangle, it is the last row.
1125    #[test]
1126    fn a_decode_step_attends_to_the_entire_cache() {
1127        let d = Device::Cpu;
1128        let k_len = 40usize;
1129        let att = Tensor::rand(-2.0f32, 2.0, (1, 2, 1, k_len), &d).expect("att");
1130        let p = att
1131            .apply_op1_no_bwd(&CausalSoftmax { offset: k_len - 1 })
1132            .expect("p")
1133            .flatten_all()
1134            .expect("f")
1135            .to_vec1::<f32>()
1136            .expect("v");
1137        for row in p.chunks(k_len) {
1138            assert!(row.iter().all(|&x| x > 0.0), "a decode step masked live keys");
1139            let sum: f32 = row.iter().sum();
1140            assert!((sum - 1.0).abs() < 1e-5, "decode row sums to {sum}");
1141        }
1142    }
1143
1144    /// `silu(gate) * up` against candle's two-op spelling.
1145    #[test]
1146    fn swiglu_matches_silu_then_multiply() {
1147        let d = Device::Cpu;
1148        let g = Tensor::rand(-6.0f32, 6.0, (2, 777), &d).expect("g");
1149        let u = Tensor::rand(-6.0f32, 6.0, (2, 777), &d).expect("u");
1150        let ours = g.apply_op2_no_bwd(&u, &SwiGlu).expect("ours");
1151        let theirs = (g.silu().expect("silu") * &u).expect("mul");
1152        let (a, b) = (
1153            ours.flatten_all().expect("f").to_vec1::<f32>().expect("v"),
1154            theirs.flatten_all().expect("f").to_vec1::<f32>().expect("v"),
1155        );
1156        let worst = a.iter().zip(&b).map(|(x, y)| (x - y).abs()).fold(0f32, f32::max);
1157        assert!(worst < 1e-5, "swiglu differs from silu*up by {worst:.3e}");
1158    }
1159
1160    /// Our `rms_norm` against candle's, on a shape with an awkward row count.
1161    #[test]
1162    fn rms_norm_matches_candles() {
1163        let d = Device::Cpu;
1164        let x = Tensor::rand(-2.0f32, 2.0, (1, 37, 576), &d).expect("x");
1165        let w = Tensor::rand(0.5f32, 1.5, 576, &d).expect("w");
1166        let ours = rms_norm(&x, &w, 1e-5).expect("ours");
1167        let theirs = candle_nn::ops::rms_norm(&x, &w, 1e-5).expect("theirs");
1168        let (a, b) = (
1169            ours.flatten_all().expect("f").to_vec1::<f32>().expect("v"),
1170            theirs.flatten_all().expect("f").to_vec1::<f32>().expect("v"),
1171        );
1172        let worst = a.iter().zip(&b).map(|(x, y)| (x - y).abs()).fold(0f32, f32::max);
1173        assert!(worst < 1e-5, "rms_norm differs from candle's by {worst:.3e}");
1174    }
1175}