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!(called.load(Ordering::Relaxed), "emit should be called with zero interval");
75    }
76
77    #[test]
78    fn tick_accumulates_progress_across_calls() {
79        let lp = LoopProgress::new(0);
80        let last_seen = AtomicUsize::new(0);
81        lp.tick(5, |progress, _| {
82            last_seen.store(progress, Ordering::Relaxed);
83        });
84        assert_eq!(last_seen.load(Ordering::Relaxed), 5);
85    }
86
87    #[test]
88    fn tick_with_large_interval_does_not_emit_on_first_call() {
89        // With a 1-hour interval the first tick will have elapsed < interval,
90        // so emit should NOT be called (elapsed ≥ 0 but < 3600 seconds).
91        // Use an intermediate small value: 3600 seconds is definitely not elapsed
92        // in a unit test.
93        let lp = LoopProgress::new(3600);
94        let called = AtomicBool::new(false);
95        lp.tick(1, |_, _| {
96            called.store(true, Ordering::Relaxed);
97        });
98        // The first tick starts with last=0; elapsed is a small positive number;
99        // 3_600_000_000_000 ns >> any realistic elapsed, so emit is skipped.
100        assert!(!called.load(Ordering::Relaxed), "emit should not fire with 1-hour interval");
101    }
102
103    #[test]
104    fn tick_delta_zero_still_works() {
105        let lp = LoopProgress::new(0);
106        let seen = AtomicUsize::new(usize::MAX);
107        lp.tick(0, |progress, _elapsed| {
108            seen.store(progress, Ordering::Relaxed);
109        });
110        // A zero-delta tick must not panic and leaves the counter at 0; the
111        // zero interval still lets the single emit fire with progress 0.
112        assert_eq!(
113            seen.load(Ordering::Relaxed),
114            0,
115            "zero-delta tick must emit progress 0"
116        );
117    }
118}