Skip to main content

ffai_core/
cost.rs

1//! A **deterministic** cost model — counters, not a stopwatch.
2//!
3//! Lives in `ffai-core` so every engine shares one vocabulary of work. It was
4//! written for `ffai-argus`'s vision tower and moved here unchanged, because
5//! the thing it measures — how much work of which KIND — is not specific to a
6//! vision tower, and a second copy would drift the way three copies of
7//! `exp` did (see [`crate::fastmath`]).
8//!
9//! # Why counters and not a stopwatch
10//!
11//! Wall-clock on this box swings ±12 % run to run, which is larger than most
12//! of the wins worth having. Round 2 opened by chasing exactly that: a change
13//! that must be faster (less work per tile, same threading) measured *slower*,
14//! and three repeats disagreed with each other.
15//!
16//! A number that changes when nothing changed cannot decide anything. So the
17//! unit of account here is not milliseconds but **work**:
18//!
19//! | counter | what it is | why it is the honest unit |
20//! |---|---|---|
21//! | `matmul_flops` | `2*m*k*n` summed | the arithmetic that must happen |
22//! | `matmul_calls` | GEMM invocations | per-call setup and weight packing |
23//! | `elem_ops` | elementwise element-visits | the memory-bound half |
24//! | `transcendental` | `exp`/`tanh` calls | ~40 ns each, scalar, no SIMD |
25//! | `bytes_moved` | read + written | what actually binds a 50 MB tensor |
26//! | `copies` / `copy_bytes` | layout copies | `contiguous`, `to_vec`, `cat` |
27//!
28//! Every one is **exactly reproducible**: same input, same counts, on any
29//! machine, under any load. A win is a counter that went down, and a
30//! regression is one that went up — neither needs a quiet box, an ABBA
31//! interleave, or a z-score.
32//!
33//! This does not replace timing; it replaces timing *as the decision
34//! procedure*. Wall-clock still says whether a counter reduction was worth
35//! having, but it is no longer what tells us whether the change did anything.
36//!
37//! # The rule these counters encode
38//!
39//! Not all work costs the same, and the ratios are stable enough to reason
40//! with. On this box, measured once: a matmul FLOP retires at ~660 GF/s, an
41//! elementwise element-visit at ~10 GB/s (2.5 G elem/s), and a scalar `tanhf`
42//! at ~80 M/s. So **one transcendental costs roughly 30 elementwise visits and
43//! ~8000 FLOPs of matmul time.** That is why replacing `tanhf` with `expf` was
44//! the largest single win of round 1 and why adding threads to a 3 MB
45//! `LayerNorm` was not.
46
47use std::sync::atomic::{AtomicU64, Ordering};
48
49/// One counter set. Global and atomic: the tower runs on several threads, and
50/// a per-thread count would have to be summed by hand at exactly the moment
51/// the answer is wanted.
52#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
53pub struct Costs {
54    pub matmul_flops: u64,
55    pub matmul_calls: u64,
56    pub elem_ops: u64,
57    pub elem_calls: u64,
58    /// Scalar libm calls (`tanhf`, `expf` in a plain loop): ~75 M/s here.
59    pub transcendental: u64,
60    /// Vectorised transcendentals inside a candle kernel: ~2.7 G/s here — 36x
61    /// cheaper each, so they are a different unit of work entirely.
62    pub transcendental_vec: u64,
63    pub bytes_moved: u64,
64    pub copies: u64,
65    pub copy_bytes: u64,
66}
67
68macro_rules! counters {
69    ($($name:ident),* $(,)?) => {
70        $(static $name: AtomicU64 = AtomicU64::new(0);)*
71    };
72}
73counters!(
74    MATMUL_FLOPS,
75    MATMUL_CALLS,
76    ELEM_OPS,
77    ELEM_CALLS,
78    TRANSCENDENTAL,
79    TRANSCENDENTAL_VEC,
80    BYTES_MOVED,
81    COPIES,
82    COPY_BYTES
83);
84
85/// Counting is off unless a probe turns it on, so the shipping path pays
86/// nothing but one relaxed load per instrumented site.
87static ON: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
88
89/// Enable counting and zero the counters. Returns the previous state.
90pub fn start() -> bool {
91    for c in [
92        &MATMUL_FLOPS,
93        &MATMUL_CALLS,
94        &ELEM_OPS,
95        &ELEM_CALLS,
96        &TRANSCENDENTAL,
97        &TRANSCENDENTAL_VEC,
98        &BYTES_MOVED,
99        &COPIES,
100        &COPY_BYTES,
101    ] {
102        c.store(0, Ordering::Relaxed);
103    }
104    ON.swap(true, Ordering::Relaxed)
105}
106
107/// Stop counting and read the totals.
108pub fn stop() -> Costs {
109    ON.store(false, Ordering::Relaxed);
110    Costs {
111        matmul_flops: MATMUL_FLOPS.load(Ordering::Relaxed),
112        matmul_calls: MATMUL_CALLS.load(Ordering::Relaxed),
113        elem_ops: ELEM_OPS.load(Ordering::Relaxed),
114        elem_calls: ELEM_CALLS.load(Ordering::Relaxed),
115        transcendental: TRANSCENDENTAL.load(Ordering::Relaxed),
116        transcendental_vec: TRANSCENDENTAL_VEC.load(Ordering::Relaxed),
117        bytes_moved: BYTES_MOVED.load(Ordering::Relaxed),
118        copies: COPIES.load(Ordering::Relaxed),
119        copy_bytes: COPY_BYTES.load(Ordering::Relaxed),
120    }
121}
122
123#[inline]
124fn on() -> bool {
125    ON.load(Ordering::Relaxed)
126}
127
128/// Record a matmul of `(m,k) x (k,n)`, `batch` times.
129pub fn matmul(batch: u64, m: u64, k: u64, n: u64) {
130    if !on() {
131        return;
132    }
133    MATMUL_CALLS.fetch_add(batch, Ordering::Relaxed);
134    MATMUL_FLOPS.fetch_add(2 * batch * m * k * n, Ordering::Relaxed);
135    // Read both operands, write the result. The output is what makes q·kᵀ
136    // expensive: 64 wide in, 1024 wide out.
137    BYTES_MOVED.fetch_add(4 * batch * (m * k + k * n + m * n), Ordering::Relaxed);
138}
139
140/// Record an elementwise pass over `n` elements: `reads` in, `writes` out.
141pub fn elementwise(n: u64, reads: u64, writes: u64) {
142    if !on() {
143        return;
144    }
145    ELEM_CALLS.fetch_add(1, Ordering::Relaxed);
146    ELEM_OPS.fetch_add(n, Ordering::Relaxed);
147    BYTES_MOVED.fetch_add(4 * n * (reads + writes), Ordering::Relaxed);
148}
149
150/// Record `n` VECTORISED transcendental calls — inside a candle kernel.
151pub fn transcendental_vector(n: u64) {
152    if !on() {
153        return;
154    }
155    TRANSCENDENTAL_VEC.fetch_add(n, Ordering::Relaxed);
156}
157
158/// Record `n` scalar transcendental calls (`exp`, `tanh`, …).
159///
160/// Counted apart from `elem_ops` because they are not the same unit of work:
161/// one is ~30x the other on this box, so folding them together would let a
162/// change that trades 3 M `tanhf` for 3 M `expf` look like it did nothing.
163pub fn transcendental_scalar(n: u64) {
164    if !on() {
165        return;
166    }
167    TRANSCENDENTAL.fetch_add(n, Ordering::Relaxed);
168}
169
170/// Record a layout copy of `n` elements — `contiguous`, `cat`, a `Vec` round
171/// trip. Pure overhead: no arithmetic, and it is the cost `to_vec1` +
172/// `from_vec` hides.
173pub fn copy(n: u64) {
174    if !on() {
175        return;
176    }
177    COPIES.fetch_add(1, Ordering::Relaxed);
178    COPY_BYTES.fetch_add(4 * n * 2, Ordering::Relaxed);
179    BYTES_MOVED.fetch_add(4 * n * 2, Ordering::Relaxed);
180}
181
182impl Costs {
183    /// A weighted total, in units of "matmul-FLOP equivalents".
184    ///
185    /// The weights come from one measurement of this box (see the module
186    /// docs): 660 GF/s matmul, 2.5 G elementwise visits/s, 80 M
187    /// transcendental/s. They are a MODEL, not a timing — their job is to stop
188    /// a change that trades 12 M elementwise visits for 3 M transcendentals
189    /// from reading as an improvement because one counter fell.
190    ///
191    /// Deterministic like everything else here: same input, same number.
192    #[must_use]
193    pub const fn weighted(&self) -> u64 {
194        const ELEM_WEIGHT: u64 = 264; // 660e9 / 2.5e9
195        const TRANS_WEIGHT: u64 = 8800; // 660e9 / 75e6  (scalar libm)
196        const TRANS_VEC_WEIGHT: u64 = 244; // 660e9 / 2.7e9 (vectorised)
197        self.matmul_flops
198            + self.elem_ops * ELEM_WEIGHT
199            + self.transcendental * TRANS_WEIGHT
200            + self.transcendental_vec * TRANS_VEC_WEIGHT
201    }
202
203    /// Human-readable, aligned for diffing two runs by eye.
204    #[must_use]
205    pub fn report(&self, label: &str) -> String {
206        format!(
207            "{label:<22} matmul {:>7.1} GF in {:>4} calls | elem {:>7.1} M in {:>3} calls | \
208             transc {:>6.1} M scalar / {:>6.1} M vec | moved {:>7.1} MB | copies {:>3} ({:>6.1} MB) | weighted {:>8.1} G",
209            self.matmul_flops as f64 / 1e9,
210            self.matmul_calls,
211            self.elem_ops as f64 / 1e6,
212            self.elem_calls,
213            self.transcendental as f64 / 1e6,
214            self.transcendental_vec as f64 / 1e6,
215            self.bytes_moved as f64 / 1e6,
216            self.copies,
217            self.copy_bytes as f64 / 1e6,
218            self.weighted() as f64 / 1e9,
219        )
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    /// The counters are GLOBAL, and `cargo test` runs tests in parallel — so
228    /// two cost tests racing each other read one another's increments. Caught
229    /// exactly that way: `counting_is_off_until_asked` failed because a
230    /// concurrent test had counting enabled.
231    ///
232    /// Global state is the right design for the counters (the tower runs on
233    /// several threads and per-thread totals would have to be summed by hand
234    /// at exactly the wrong moment), so the tests take a lock instead.
235    static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
236
237    fn guard() -> std::sync::MutexGuard<'static, ()> {
238        SERIAL
239            .lock()
240            .unwrap_or_else(std::sync::PoisonError::into_inner)
241    }
242
243    #[test]
244    fn counting_is_off_until_asked() {
245        let _g = guard();
246        // `start()` zeroes; `stop()` only disables. Zero first, or this reads
247        // whatever the last test left behind.
248        start();
249        let _ = stop();
250        matmul(1, 10, 10, 10);
251        transcendental_scalar(99);
252        assert_eq!(stop(), Costs::default(), "counted while disabled");
253    }
254
255    #[test]
256    fn a_matmul_costs_two_flops_per_multiply_add() {
257        let _g = guard();
258        start();
259        matmul(1, 2, 3, 4);
260        let c = stop();
261        assert_eq!(c.matmul_flops, 2 * 2 * 3 * 4);
262        assert_eq!(c.matmul_calls, 1);
263        // reads both operands, writes the result
264        assert_eq!(c.bytes_moved, 4 * (2 * 3 + 3 * 4 + 2 * 4));
265    }
266
267    #[test]
268    fn the_weighting_stops_a_bad_trade_reading_as_a_win() {
269        let _g = guard();
270        // Trading 12 M elementwise visits for 3 M transcendentals lowers
271        // `elem_ops` and would look like progress on any single counter.
272        start();
273        elementwise(12_000_000, 1, 1);
274        let before = stop();
275        start();
276        transcendental_scalar(3_000_000);
277        let after = stop();
278        assert!(
279            after.weighted() > before.weighted(),
280            "3 M transcendentals ({}) should outweigh 12 M elementwise visits ({})",
281            after.weighted(),
282            before.weighted()
283        );
284    }
285
286    #[test]
287    fn counters_are_reproducible_across_runs() {
288        let _g = guard();
289        // The whole premise: same work, same numbers, every time.
290        let run = || {
291            start();
292            matmul(12, 1024, 64, 1024);
293            elementwise(3_145_728, 1, 1);
294            transcendental_scalar(3_145_728);
295            copy(786_432);
296            stop()
297        };
298        assert_eq!(run(), run(), "counters are not deterministic");
299    }
300}