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}