Skip to main content

gam_runtime/
loop_progress.rs

1//! Low-overhead progress ticker for long parallel loops.
2//!
3//! `LoopProgress::tick` advances a shared counter and lets exactly one
4//! worker emit after each wall-clock interval. Callers own the log message
5//! so units and totals stay local to the loop.
6
7use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
8use std::time::Instant;
9
10pub const DEFAULT_LOOP_PROGRESS_INTERVAL_SECS: u64 = 25;
11
12pub struct LoopProgress {
13    started: Instant,
14    last_emit_nanos: AtomicU64,
15    interval_nanos: u64,
16    progress: AtomicUsize,
17}
18
19impl LoopProgress {
20    pub fn new(interval_secs: u64) -> Self {
21        Self {
22            started: Instant::now(),
23            last_emit_nanos: AtomicU64::new(0),
24            interval_nanos: interval_secs.saturating_mul(1_000_000_000),
25            progress: AtomicUsize::new(0),
26        }
27    }
28
29    pub fn default_interval() -> Self {
30        Self::new(DEFAULT_LOOP_PROGRESS_INTERVAL_SECS)
31    }
32
33    /// Advance the progress counter by `delta` and, if at least
34    /// `interval` of wall time has passed since the last claimed print,
35    /// invoke `emit(progress, elapsed_secs)` exactly once across all
36    /// threads. The closure typically issues a `log::info!`.
37    pub fn tick(&self, delta: usize, emit: impl FnOnce(usize, f64)) {
38        let progress = self
39            .progress
40            .fetch_add(delta, Ordering::Relaxed)
41            .saturating_add(delta);
42        let elapsed = self.started.elapsed().as_nanos() as u64;
43        let last = self.last_emit_nanos.load(Ordering::Relaxed);
44        if elapsed < last.saturating_add(self.interval_nanos) {
45            return;
46        }
47        if self
48            .last_emit_nanos
49            .compare_exchange(last, elapsed, Ordering::Relaxed, Ordering::Relaxed)
50            .is_ok()
51        {
52            emit(progress, elapsed as f64 / 1.0e9);
53        }
54    }
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60    use std::sync::atomic::{AtomicBool, AtomicUsize};
61
62    #[test]
63    fn default_interval_constant_matches_expectation() {
64        assert_eq!(DEFAULT_LOOP_PROGRESS_INTERVAL_SECS, 25);
65    }
66
67    #[test]
68    fn new_with_zero_interval_emits_on_first_tick() {
69        let lp = LoopProgress::new(0);
70        let called = AtomicBool::new(false);
71        lp.tick(1, |_progress, _elapsed| {
72            called.store(true, Ordering::Relaxed);
73        });
74        assert!(
75            called.load(Ordering::Relaxed),
76            "emit should be called with zero interval"
77        );
78    }
79
80    #[test]
81    fn tick_accumulates_progress_across_calls() {
82        let lp = LoopProgress::new(0);
83        let last_seen = AtomicUsize::new(0);
84        lp.tick(5, |progress, _| {
85            last_seen.store(progress, Ordering::Relaxed);
86        });
87        assert_eq!(last_seen.load(Ordering::Relaxed), 5);
88    }
89
90    #[test]
91    fn tick_with_large_interval_does_not_emit_on_first_call() {
92        // With a 1-hour interval the first tick will have elapsed < interval,
93        // so emit should NOT be called (elapsed ≥ 0 but < 3600 seconds).
94        // Use an intermediate small value: 3600 seconds is definitely not elapsed
95        // in a unit test.
96        let lp = LoopProgress::new(3600);
97        let called = AtomicBool::new(false);
98        lp.tick(1, |_, _| {
99            called.store(true, Ordering::Relaxed);
100        });
101        // The first tick starts with last=0; elapsed is a small positive number;
102        // 3_600_000_000_000 ns >> any realistic elapsed, so emit is skipped.
103        assert!(
104            !called.load(Ordering::Relaxed),
105            "emit should not fire with 1-hour interval"
106        );
107    }
108
109    #[test]
110    fn tick_delta_zero_still_works() {
111        let lp = LoopProgress::new(0);
112        let seen = AtomicUsize::new(usize::MAX);
113        lp.tick(0, |progress, _elapsed| {
114            seen.store(progress, Ordering::Relaxed);
115        });
116        // A zero-delta tick must not panic and leaves the counter at 0; the
117        // zero interval still lets the single emit fire with progress 0.
118        assert_eq!(
119            seen.load(Ordering::Relaxed),
120            0,
121            "zero-delta tick must emit progress 0"
122        );
123    }
124}