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 ©_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}