Skip to main content

span_timing/
lib.rs

1#![doc = include_str!("../README.md")]
2#![cfg_attr(not(feature = "std"), no_std)]
3
4#[cfg(all(
5    not(feature = "std"),
6    not(any(target_arch = "aarch64", target_arch = "x86", target_arch = "x86_64"))
7))]
8compile_error!(
9    "span-timing without the `std` feature supports only x86, x86_64, and aarch64 targets"
10);
11
12/// Receives measurements collected by [`timed_span!`].
13///
14/// The macro calls [`Self::increment_count`] when a timed scope begins and
15/// [`Self::add_elapsed_ticks`] when it ends. Implementations may store those
16/// values directly, aggregate them differently, or update additional metrics.
17/// [`timing_entries!`] uses [`Self::INITIAL`] to create each element of a
18/// generated static counter array.
19///
20/// # Example
21///
22/// This counter computes a running average on demand from its sample count and
23/// total ticks:
24///
25/// ```
26/// use std::sync::atomic::{AtomicU64, Ordering};
27/// use span_timing::TimingCounter;
28///
29/// struct AverageCounter {
30///     samples: AtomicU64,
31///     total_ticks: AtomicU64,
32/// }
33///
34/// impl AverageCounter {
35///     fn average_ticks(&self) -> Option<u64> {
36///         let samples = self.samples.load(Ordering::Relaxed);
37///         (samples != 0).then(|| self.total_ticks.load(Ordering::Relaxed) / samples)
38///     }
39/// }
40///
41/// impl TimingCounter for AverageCounter {
42///     const INITIAL: Self = Self {
43///         samples: AtomicU64::new(0),
44///         total_ticks: AtomicU64::new(0),
45///     };
46///
47///     fn increment_count(&self) {
48///         self.samples.fetch_add(1, Ordering::Relaxed);
49///     }
50///
51///     fn add_elapsed_ticks(&self, ticks: u64) {
52///         self.total_ticks.fetch_add(ticks, Ordering::Relaxed);
53///     }
54/// }
55/// ```
56pub trait TimingCounter {
57    /// The const value used to initialize each element of a static counter collection.
58    const INITIAL: Self;
59
60    /// Records one invocation of the timed operation, before the timed scope runs.
61    fn increment_count(&self);
62
63    /// Records the elapsed processor-counter ticks or nanoseconds when the scope ends.
64    fn add_elapsed_ticks(&self, elapsed_ticks: u64);
65}
66
67/// The standard atomic counter implementation for [`timed_span!`].
68///
69/// `count` records the number of timed spans, while `ticks` accumulates their
70/// elapsed processor-counter ticks (or nanoseconds on unsupported architectures).
71/// Use this type when a total, a count, and their derived average are sufficient.
72#[cfg(target_has_atomic = "64")]
73#[derive(Debug, Default)]
74pub struct Counter {
75    pub count: core::sync::atomic::AtomicU64,
76    pub ticks: core::sync::atomic::AtomicU64,
77}
78
79#[cfg(target_has_atomic = "64")]
80impl Counter {
81    /// Creates a counter with both measurements set to zero.
82    pub const fn new() -> Self {
83        Self {
84            count: core::sync::atomic::AtomicU64::new(0),
85            ticks: core::sync::atomic::AtomicU64::new(0),
86        }
87    }
88
89    /// Resets both measurements to zero.
90    pub fn reset(&self) {
91        self.count.store(0, core::sync::atomic::Ordering::Relaxed);
92        self.ticks.store(0, core::sync::atomic::Ordering::Relaxed);
93    }
94}
95
96#[cfg(target_has_atomic = "64")]
97impl TimingCounter for Counter {
98    const INITIAL: Self = Self::new();
99
100    fn increment_count(&self) {
101        self.count
102            .fetch_add(1, core::sync::atomic::Ordering::Relaxed);
103    }
104
105    fn add_elapsed_ticks(&self, ticks: u64) {
106        self.ticks
107            .fetch_add(ticks, core::sync::atomic::Ordering::Relaxed);
108    }
109}
110
111/// Declares an enum and optionally a static counter collection for timing entries.
112///
113/// The generated enum has `ALL`, `COUNT`, and `to_str` associated items. Adding
114/// `static COUNTERS: [Counter];` after the enum generates a `[Counter; COUNT]` static,
115/// initialized from [`TimingCounter::INITIAL`]. This keeps entry names, array length,
116/// and reporting order in one declaration.
117///
118/// Without the `static` declaration, the macro only generates the enum and its helpers.
119#[macro_export]
120macro_rules! timing_entries {
121    (
122        $visibility:vis enum $name:ident {
123            $($entry:ident $(= $value:expr)?),*
124            $(,)?
125        }
126        $counter_visibility:vis static $counters:ident: [$counter_type:ty];
127    ) => {
128        $crate::timing_entries! {
129            @entries
130            $visibility enum $name {
131                $($entry $(= $value)?),*
132            }
133        }
134
135        $counter_visibility static $counters: [$counter_type; $name::COUNT] =
136            [const { <$counter_type as $crate::TimingCounter>::INITIAL }; $name::COUNT];
137    };
138    (
139        $visibility:vis enum $name:ident {
140            $($entry:ident $(= $value:expr)?),*
141            $(,)?
142        }
143    ) => {
144        $crate::timing_entries! {
145            @entries
146            $visibility enum $name {
147                $($entry $(= $value)?),*
148            }
149        }
150    };
151    (
152        @entries
153        $visibility:vis enum $name:ident {
154            $($entry:ident $(= $value:expr)?),*
155            $(,)?
156        }
157    ) => {
158        #[derive(Clone, Copy)]
159        $visibility enum $name {
160            $($entry $(= $value)?),*
161        }
162
163        impl $name {
164            pub const ALL: &[$name] = &{
165                let declared: [$name; 0 $(+ { let _ = $name::$entry; 1 })*] =
166                    [$($name::$entry),*];
167                let mut ordered = declared;
168                let mut index = 0;
169
170                while index < declared.len() {
171                    let entry = declared[index];
172                    ordered[entry as usize] = entry;
173                    index += 1;
174                }
175
176                ordered
177            };
178            pub const COUNT: usize = $name::ALL.len();
179
180            pub const fn to_str(&self) -> &'static str {
181                match self {
182                    $(
183                        $name::$entry => stringify!($entry),
184                    )*
185                }
186            }
187        }
188
189        $(
190            const _: $name = $name::ALL[$name::$entry as usize];
191        )*
192    };
193}
194
195/// Starts a timed span and returns its guard.
196///
197/// The first argument is an enum variant that can be cast to an index. The second is an
198/// indexable collection whose entries implement [`TimingCounter`]. Bind the returned
199/// guard for the scope to measure. When dropped, it adds the elapsed measurement, even
200/// if the scope returns early or unwinds. On x86, x86_64, and aarch64 the elapsed value
201/// is a processor-counter tick count; on other architectures it is elapsed nanoseconds.
202#[macro_export]
203macro_rules! timed_span {
204    ($entry:expr, $counters:expr $(,)?) => {{
205        let counter = &($counters)[($entry) as usize];
206        $crate::TimingCounter::increment_count(counter);
207        $crate::TimedSpanGuard::new(counter)
208    }};
209}
210
211#[cfg(any(target_arch = "aarch64", target_arch = "x86", target_arch = "x86_64"))]
212mod clock {
213    use super::TimingCounter;
214    use core::arch::asm;
215
216    #[cfg(target_arch = "aarch64")]
217    #[inline]
218    fn read_counter() -> u64 {
219        let value: u64;
220        unsafe {
221            asm!("mrs {}, CNTVCT_EL0", out(reg) value, options(nostack, nomem));
222        }
223        value
224    }
225
226    #[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
227    #[inline]
228    fn read_counter() -> u64 {
229        let low: u32;
230        let high: u32;
231        unsafe {
232            asm!(
233                "rdtsc",
234                out("eax") low,
235                out("edx") high,
236                options(nostack, nomem)
237            );
238        }
239        ((high as u64) << 32) | low as u64
240    }
241
242    /// A scope guard that adds elapsed processor-counter ticks to an atomic counter.
243    pub struct TimedSpanGuard<'a, C: TimingCounter + ?Sized> {
244        start: u64,
245        counter: &'a C,
246    }
247
248    impl<'a, C: TimingCounter + ?Sized> TimedSpanGuard<'a, C> {
249        /// Starts timing and records elapsed ticks in `counter` when dropped.
250        pub fn new(counter: &'a C) -> Self {
251            Self {
252                start: read_counter(),
253                counter,
254            }
255        }
256    }
257
258    impl<C: TimingCounter + ?Sized> Drop for TimedSpanGuard<'_, C> {
259        fn drop(&mut self) {
260            self.counter.add_elapsed_ticks(read_counter() - self.start);
261        }
262    }
263}
264
265#[cfg(all(
266    feature = "std",
267    not(any(target_arch = "aarch64", target_arch = "x86", target_arch = "x86_64"))
268))]
269mod clock {
270    use super::TimingCounter;
271    use std::time::Instant;
272
273    /// A scope guard that adds elapsed nanoseconds to an atomic counter.
274    pub struct TimedSpanGuard<'a, C: TimingCounter + ?Sized> {
275        start: Instant,
276        counter: &'a C,
277    }
278
279    impl<'a, C: TimingCounter + ?Sized> TimedSpanGuard<'a, C> {
280        /// Starts timing and records elapsed nanoseconds in `counter` when dropped.
281        pub fn new(counter: &'a C) -> Self {
282            Self {
283                start: Instant::now(),
284                counter,
285            }
286        }
287    }
288
289    impl<C: TimingCounter + ?Sized> Drop for TimedSpanGuard<'_, C> {
290        fn drop(&mut self) {
291            self.counter
292                .add_elapsed_ticks(self.start.elapsed().as_nanos() as u64);
293        }
294    }
295}
296
297pub use clock::TimedSpanGuard;
298
299#[cfg(all(test, target_has_atomic = "64"))]
300mod tests {
301    use crate::{Counter as StandardCounter, TimingCounter};
302    use core::sync::atomic::{AtomicU64, Ordering};
303
304    timing_entries! {
305        pub enum Entry {
306            First,
307            Second,
308        }
309        static COUNTERS: [Counter];
310    }
311
312    struct Counter {
313        invocations: AtomicU64,
314        elapsed: AtomicU64,
315    }
316
317    impl TimingCounter for Counter {
318        const INITIAL: Self = Self {
319            invocations: AtomicU64::new(0),
320            elapsed: AtomicU64::new(0),
321        };
322
323        fn increment_count(&self) {
324            self.invocations.fetch_add(1, Ordering::Relaxed);
325        }
326
327        fn add_elapsed_ticks(&self, elapsed_ticks: u64) {
328            self.elapsed.fetch_add(elapsed_ticks, Ordering::Relaxed);
329        }
330    }
331
332    #[test]
333    fn standard_counter_records_and_resets_measurements() {
334        let counter = StandardCounter::default();
335        counter.increment_count();
336        counter.add_elapsed_ticks(42);
337
338        assert_eq!(counter.count.load(Ordering::Relaxed), 1);
339        assert_eq!(counter.ticks.load(Ordering::Relaxed), 42);
340
341        counter.reset();
342        assert_eq!(counter.count.load(Ordering::Relaxed), 0);
343        assert_eq!(counter.ticks.load(Ordering::Relaxed), 0);
344    }
345
346    #[test]
347    fn declares_entries_and_records_a_span() {
348        assert_eq!(Entry::ALL.len(), 2);
349        assert_eq!(Entry::Second.to_str(), "Second");
350
351        {
352            let _timed_span_guard = timed_span!(Entry::First, COUNTERS);
353            for value in 0..100_000 {
354                core::hint::black_box(value);
355            }
356        }
357        assert_eq!(
358            COUNTERS[Entry::First as usize]
359                .invocations
360                .load(Ordering::Relaxed),
361            1
362        );
363        assert_ne!(
364            COUNTERS[Entry::First as usize]
365                .elapsed
366                .load(Ordering::Relaxed),
367            0
368        );
369    }
370
371    #[test]
372    fn declares_entries_with_in_bounds_explicit_discriminants() {
373        timing_entries! {
374            enum ExplicitEntry {
375                Second = 1,
376                First = 0,
377            }
378        }
379
380        assert_eq!(ExplicitEntry::COUNT, 2);
381        assert_eq!(ExplicitEntry::First as usize, 0);
382        assert_eq!(ExplicitEntry::Second as usize, 1);
383        assert_eq!(
384            ExplicitEntry::ALL[ExplicitEntry::First as usize].to_str(),
385            "First"
386        );
387        assert_eq!(
388            ExplicitEntry::ALL[ExplicitEntry::Second as usize].to_str(),
389            "Second"
390        );
391    }
392}
393
394/// These tests use cargo directly to validate a compile failure.  Other alternatives are
395/// 1. a doc test, but I didn't want to pollute the docs or,
396/// 2. the `trybuild` crate, but I didn't want to rely on the exact compiler error message.
397#[cfg(all(test, feature = "std"))]
398mod compile_fail_tests {
399    use std::{
400        env, fs,
401        process::{self, Command},
402        time::{SystemTime, UNIX_EPOCH},
403    };
404
405    #[test]
406    fn rejects_entries_with_out_of_bounds_discriminants() {
407        let unique = SystemTime::now()
408            .duration_since(UNIX_EPOCH)
409            .expect("system clock is before the Unix epoch")
410            .as_nanos();
411        let test_dir = env::temp_dir().join(format!("span-timing-oob-{unique}-{}", process::id()));
412        let manifest_dir =
413            env::var("CARGO_MANIFEST_DIR").expect("Cargo did not set CARGO_MANIFEST_DIR");
414
415        fs::create_dir_all(test_dir.join("src")).expect("failed to create temporary test crate");
416        fs::write(
417            test_dir.join("Cargo.toml"),
418            format!(
419                "[package]\nname = \"timing-entries-oob\"\nversion = \"0.0.0\"\nedition = \"2024\"\n\n[dependencies]\nspan-timing = {{ path = {manifest_dir:?} }}\n"
420            ),
421        )
422        .expect("failed to write temporary manifest");
423        fs::write(
424            test_dir.join("src/main.rs"),
425            "use span_timing::timing_entries;\n\ntiming_entries! {\n    enum Oob {\n        Foo = 42,\n    }\n}\n\nfn main() {}\n",
426        )
427        .expect("failed to write temporary source");
428
429        let output = Command::new(env::var("CARGO").unwrap_or_else(|_| "cargo".into()))
430            .args(["check", "--offline"])
431            .current_dir(&test_dir)
432            .output()
433            .expect("failed to run cargo check");
434        let _ = fs::remove_dir_all(&test_dir);
435
436        assert!(
437            !output.status.success(),
438            "out-of-bounds discriminant unexpectedly compiled:\n{}",
439            String::from_utf8_lossy(&output.stdout)
440        );
441    }
442}