Skip to main content

ffai_core/
fastops.rs

1//! Tensor-level activations on [`crate::fastmath`] — the drop-in replacements
2//! for candle's, which compute a scalar libm call per element.
3//!
4//! # What this is worth
5//!
6//! Measured in `ffai-argus` on `(1, 1024, 3072)`, the shape a `SigLIP` MLP
7//! actually runs: candle's `.gelu()` took **44.01 ms**, this shape of kernel
8//! **1.22 ms** — 32x, with the caption it feeds byte-identical to the reference
9//! implementation's. candle's activations are elementwise ops in a backend that
10//! uses rayon for `conv2d` and nothing else, evaluating `tanhf`/`erf` per
11//! element on one core.
12//!
13//! # Two things that are NOT interchangeable
14//!
15//! `gelu_erf` is `0.5x(1 + erf(x/sqrt 2))`; `gelu_tanh` is the tanh
16//! approximation of it. **They differ by up to ~1e-3** — far above the
17//! tolerance these engines gate at — so a site calling one must be given that
18//! one. Six of the seventeen activation sites in this workspace are `gelu_erf`
19//! and three are `gelu_tanh`; swapping them silently would be a quality
20//! regression that no test here would catch, because both are "a GELU".
21//!
22//! # Delivery
23//!
24//! Each op is a [`candle_core::CustomOp1`], which hands the kernel candle's own
25//! `CpuStorage`. That matters more than the arithmetic: routing a tensor
26//! through `to_vec1()` and `Tensor::from_vec` is a fixed per-call tax, and in
27//! the Argus campaign more than half the total win came from removing the glue
28//! rather than from the polynomial (3.03 ms -> 2.30 -> 1.08 across three
29//! delivery fixes with no change to the inner loop).
30
31use candle_core::{CpuStorage, CustomOp1, Layout, Result, Shape, Tensor};
32use rayon::prelude::*;
33
34/// Elements per rayon task.
35///
36/// Large enough that scheduling is noise against the work, small enough that a
37/// typical activation tensor still makes hundreds of tasks for the pool to
38/// balance.
39const CHUNK: usize = 8192;
40
41/// One elementwise activation, applied through candle's zero-copy hook.
42struct ElemOp {
43    name: &'static str,
44    f: fn(f32) -> f32,
45}
46
47impl CustomOp1 for ElemOp {
48    fn name(&self) -> &'static str {
49        self.name
50    }
51
52    fn cpu_fwd(&self, storage: &CpuStorage, layout: &Layout) -> Result<(CpuStorage, Shape)> {
53        let CpuStorage::F32(src) = storage else {
54            candle_core::bail!("{}: expects f32", self.name)
55        };
56        // `contiguous_offsets` is `Some` only for a genuinely flat run. A
57        // strided view read as if it were dense is silent corruption, not an
58        // error, so this refuses rather than guesses.
59        let Some((start, end)) = layout.contiguous_offsets() else {
60            candle_core::bail!("{}: expects a contiguous input", self.name)
61        };
62        let src = &src[start..end];
63        let n = src.len();
64
65        let mut out: Vec<f32> = Vec::with_capacity(n);
66        {
67            // Written through the SPARE capacity, not `vec![0.0; n]`.
68            //
69            // Every element is overwritten below, so zero-initialising first is
70            // an entire discarded pass over the buffer — 2.6 GB per image in
71            // the Argus vision tower. `set_len` before filling would be the
72            // other way to avoid it and is UB-adjacent (clippy's `uninit_vec`
73            // is right); this creates the `&mut [f32]` over the spare region
74            // and publishes it only after every element is written.
75            let spare = out.spare_capacity_mut();
76            // SAFETY: `spare` is exactly `n` contiguous `MaybeUninit<f32>`.
77            // `f32` has no invalid bit patterns and no drop glue, and the
78            // partitioned loop below writes all n before `set_len` publishes
79            // them, so nothing observes an uninitialised value and nothing is
80            // dropped on unwind.
81            #[allow(unsafe_code)]
82            let dst: &mut [f32] =
83                unsafe { std::slice::from_raw_parts_mut(spare.as_mut_ptr().cast::<f32>(), n) };
84            let f = self.f;
85            dst.par_chunks_mut(CHUNK)
86                .zip(src.par_chunks(CHUNK))
87                .for_each(|(d, s)| {
88                    for (o, &i) in d.iter_mut().zip(s) {
89                        *o = f(i);
90                    }
91                });
92        }
93        // SAFETY: the loop above wrote all n elements.
94        #[allow(unsafe_code)]
95        unsafe {
96            out.set_len(n);
97        }
98        Ok((CpuStorage::F32(out), layout.shape().clone()))
99    }
100}
101
102macro_rules! tensor_op {
103    ($fn_name:ident, $kernel:path, $tag:literal, $doc:literal) => {
104        #[doc = $doc]
105        ///
106        /// # Errors
107        /// If the input is not contiguous `f32`.
108        pub fn $fn_name(xs: &Tensor) -> Result<Tensor> {
109            // Materialise a strided view before handing it to the kernel.
110            //
111            // The kernel itself REFUSES non-contiguous storage, and that
112            // strictness is right — reading a strided tensor as if it were
113            // dense is silent corruption, not an error. But refusing at the
114            // API boundary makes every caller responsible for a detail candle's
115            // own ops handle, and `wav2vec2`'s `pos_conv` passes exactly such a
116            // view. Its test caught this as "expects a contiguous input" the
117            // moment the migration landed.
118            //
119            // `contiguous()` on an already-contiguous tensor is a cheap clone,
120            // so the common path pays nothing.
121            xs.contiguous()?.apply_op1_no_bwd(&ElemOp {
122                name: $tag,
123                f: $kernel,
124            })
125        }
126    };
127}
128
129tensor_op!(
130    gelu_erf,
131    crate::fastmath::gelu_erf,
132    "ffai-gelu-erf",
133    "`0.5x(1 + erf(x/sqrt 2))` — the drop-in for candle's `.gelu_erf()`."
134);
135tensor_op!(
136    gelu_tanh,
137    crate::fastmath::gelu_tanh,
138    "ffai-gelu-tanh",
139    "`gelu_pytorch_tanh` — the drop-in for candle's `.gelu()`."
140);
141tensor_op!(
142    tanh,
143    crate::fastmath::tanh,
144    "ffai-tanh",
145    "`tanh(x)` — the drop-in for candle's `.tanh()`."
146);
147tensor_op!(
148    silu,
149    crate::fastmath::silu,
150    "ffai-silu",
151    "`x * sigmoid(x)` — the drop-in for candle's `.silu()`."
152);
153tensor_op!(
154    erf,
155    crate::fastmath::erf,
156    "ffai-erf",
157    "`erf(x)` — the drop-in for candle's `.erf()`."
158);
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use candle_core::{DType, Device};
164
165    /// Every op against the candle op it replaces, on a shape with an awkward
166    /// tail so the chunked loop's remainder is exercised rather than assumed.
167    #[test]
168    fn every_op_tracks_candles() {
169        let d = Device::Cpu;
170        let xs = Tensor::rand(-8.0f32, 8.0, (3, 4099), &d).expect("xs");
171        assert_ne!(4099 % CHUNK, 0, "the tail must not be a whole chunk");
172
173        let cases: Vec<(&str, Tensor, Tensor)> = vec![
174            (
175                "gelu_erf",
176                gelu_erf(&xs).expect("ours"),
177                xs.gelu_erf().expect("candle"),
178            ),
179            (
180                "gelu_tanh",
181                gelu_tanh(&xs).expect("ours"),
182                xs.gelu().expect("candle"),
183            ),
184            ("tanh", tanh(&xs).expect("ours"), xs.tanh().expect("candle")),
185            ("silu", silu(&xs).expect("ours"), xs.silu().expect("candle")),
186            ("erf", erf(&xs).expect("ours"), xs.erf().expect("candle")),
187        ];
188        for (name, a, b) in cases {
189            assert_eq!(a.dims(), b.dims(), "{name}: shape");
190            let (av, bv) = (
191                a.flatten_all().expect("a").to_vec1::<f32>().expect("av"),
192                b.flatten_all().expect("b").to_vec1::<f32>().expect("bv"),
193            );
194            let worst = av
195                .iter()
196                .zip(&bv)
197                .map(|(x, y)| (x - y).abs())
198                .fold(0.0f32, f32::max);
199            eprintln!("  {name:<10} worst abs {worst:.3e}");
200            assert!(worst < 1e-4, "{name} differs from candle by {worst:.3e}");
201        }
202    }
203
204    /// The distinction that a "both are a GELU" mistake would erase.
205    #[test]
206    fn the_two_gelus_stay_different() {
207        let d = Device::Cpu;
208        let xs = Tensor::rand(-4.0f32, 4.0, 2048, &d).expect("xs");
209        let a = gelu_erf(&xs)
210            .expect("erf")
211            .flatten_all()
212            .expect("f")
213            .to_vec1::<f32>()
214            .expect("v");
215        let b = gelu_tanh(&xs)
216            .expect("tanh")
217            .flatten_all()
218            .expect("f")
219            .to_vec1::<f32>()
220            .expect("v");
221        let worst = a
222            .iter()
223            .zip(&b)
224            .map(|(x, y)| (x - y).abs())
225            .fold(0.0f32, f32::max);
226        assert!(
227            worst > 1e-4,
228            "gelu_erf and gelu_tanh differ by only {worst:.3e} — one has been aliased to the other"
229        );
230    }
231
232    /// A strided view must give the RIGHT answer, not a plausible wrong one.
233    ///
234    /// The kernel refuses non-contiguous storage — reading it as dense would
235    /// silently transpose the data — so the wrapper materialises first. This
236    /// pins the outcome rather than the mechanism: whatever the wrapper does,
237    /// a transposed input must produce the transposed result.
238    #[test]
239    fn a_strided_view_is_handled_not_misread() {
240        let d = Device::Cpu;
241        let xs = Tensor::rand(-3.0f32, 3.0, (7, 5), &d).expect("xs");
242        let strided = xs.t().expect("transpose");
243        assert!(!strided.is_contiguous(), "the test needs a strided view");
244
245        let ours = tanh(&strided).expect("strided must be handled");
246        let theirs = strided.tanh().expect("candle");
247        assert_eq!(ours.dims(), theirs.dims());
248        let (a, b) = (
249            ours.flatten_all().expect("a").to_vec1::<f32>().expect("av"),
250            theirs
251                .flatten_all()
252                .expect("b")
253                .to_vec1::<f32>()
254                .expect("bv"),
255        );
256        let worst = a
257            .iter()
258            .zip(&b)
259            .map(|(x, y)| (x - y).abs())
260            .fold(0.0f32, f32::max);
261        assert!(
262            worst < 1e-5,
263            "strided result differs by {worst:.3e} — misread as dense?"
264        );
265    }
266
267    #[test]
268    fn an_empty_tensor_does_not_panic() {
269        let d = Device::Cpu;
270        let xs = Tensor::zeros((0, 8), DType::F32, &d).expect("xs");
271        assert_eq!(gelu_erf(&xs).expect("gelu").elem_count(), 0);
272        assert_eq!(tanh(&xs).expect("tanh").elem_count(), 0);
273    }
274}