vyre_reference/execution/op_count.rs
1//! Thread-local counting of arithmetic IR operations executed by the reference
2//! interpreter, a backend-agnostic dynamic operation count for roofline / complexity
3//! analysis.
4//!
5//! The reference interpreter executes the *same* vyre IR with the *same* data-dependent
6//! control flow that any backend (GPU or CPU) would, so the arithmetic-op count it
7//! reports for a `(program, inputs)` pair equals the dynamic IR-op count the GPU would
8//! execute for the same inputs, at the **vyre-IR** granularity, which is distinct from
9//! (and coarser than) hardware SASS instructions. This gives an honest, non-root
10//! operational-intensity measurement (`ops / bytes`) for the roofline without
11//! Nsight-Compute; the SASS-level dynamic count remains the ncu refinement.
12//!
13//! Counting is a no-op unless a [`count_ops`] scope is active on the current thread, so
14//! ordinary reference evaluation (the vast majority of interpreter use, all in tests)
15//! pays only one thread-local read per arithmetic op and no allocation.
16
17use std::cell::Cell;
18
19thread_local! {
20 /// `Some(n)` while a [`count_ops`] scope is active on this thread; `None` otherwise.
21 static OP_COUNTER: Cell<Option<u64>> = const { Cell::new(None) };
22}
23
24/// Record one arithmetic IR op if counting is active on this thread (a cheap
25/// thread-local read otherwise). Called by the interpreter's `BinOp` / `UnOp` / `Fma`
26/// evaluation arms (the arithmetic operations that make up the roofline's compute term).
27#[inline]
28pub(crate) fn record_op() {
29 OP_COUNTER.with(|counter| {
30 if let Some(count) = counter.get() {
31 counter.set(Some(count.saturating_add(1)));
32 }
33 });
34}
35
36/// Run `f` with arithmetic-op counting active and return `(f's result, arithmetic IR
37/// ops executed)`. The count is every `BinOp` / `UnOp` / `Fma` the reference interpreter
38/// evaluates during `f`: a backend-agnostic dynamic operation count.
39///
40/// Re-entrant-safe: a nested `count_ops` reports its own inner ops AND propagates them to
41/// the enclosing scope, so an outer scope's total still includes work done inside inner
42/// scopes.
43pub fn count_ops<R>(f: impl FnOnce() -> R) -> (R, u64) {
44 let saved = OP_COUNTER.with(|counter| counter.replace(Some(0)));
45 let result = f();
46 let count = OP_COUNTER
47 .with(|counter| counter.replace(saved))
48 .unwrap_or(0);
49 // Propagate our ops to an enclosing scope, if any, so nesting is additive.
50 if saved.is_some() {
51 OP_COUNTER.with(|counter| {
52 if let Some(outer) = counter.get() {
53 counter.set(Some(outer.saturating_add(count)));
54 }
55 });
56 }
57 (result, count)
58}
59
60#[cfg(test)]
61mod tests {
62 use super::*;
63
64 #[test]
65 fn count_ops_is_zero_when_no_ops_run() {
66 let (value, ops) = count_ops(|| 42u32);
67 assert_eq!(value, 42);
68 assert_eq!(ops, 0, "a closure that runs no interpreter ops counts zero");
69 }
70
71 #[test]
72 fn record_op_is_inert_outside_a_count_scope() {
73 // No active scope: record_op must not panic and must not accumulate anywhere.
74 record_op();
75 record_op();
76 let (_, ops) = count_ops(|| {
77 record_op();
78 record_op();
79 record_op();
80 });
81 assert_eq!(ops, 3, "only ops inside the scope are counted");
82 }
83
84 #[test]
85 fn nested_scopes_are_additive() {
86 let ((_, inner_ops), outer_ops) = count_ops(|| {
87 record_op(); // outer: 1
88 let inner = count_ops(|| {
89 record_op();
90 record_op(); // inner: 2
91 });
92 record_op(); // outer: another 1
93 inner
94 });
95 assert_eq!(inner_ops, 2, "inner scope counts only its own ops");
96 assert_eq!(
97 outer_ops, 4,
98 "outer scope counts its own 2 ops plus the 2 propagated from the inner scope"
99 );
100 }
101}