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}