Skip to main content

rns_net/
profiling.rs

1//! Lightweight live profiling compatible with Reticulum's profiler results.
2//!
3//! Create a guard with [`profile`] or [`profile_with_parent`]. A capture is
4//! registered immediately and completed when the guard is dropped, which makes
5//! nested and reentrant calls independently visible without reporting partial
6//! durations. Results are process-global and bounded per tag so the daemon can
7//! expose them indefinitely over local and remote status APIs.
8
9use std::collections::{BTreeMap, HashSet, VecDeque};
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::sync::{Mutex, OnceLock};
12use std::thread::ThreadId;
13use std::time::Instant;
14
15use rns_core::msgpack::Value;
16
17use crate::pickle::PickleValue;
18
19/// Default maximum retained samples for each tag across all threads.
20pub const MAX_CAPTURES: usize = 10_000;
21
22#[derive(Debug, Clone, Copy)]
23struct Capture {
24    id: u64,
25    started: f64,
26    ended: Option<f64>,
27    thread_id: ThreadId,
28}
29
30impl Capture {
31    fn duration(self) -> Option<f64> {
32        self.ended.map(|ended| ended - self.started)
33    }
34}
35
36#[derive(Debug)]
37struct TagCaptures {
38    parent: Option<String>,
39    max_captures: usize,
40    captures: VecDeque<Capture>,
41}
42
43#[derive(Debug, Clone, PartialEq)]
44pub struct ProfilingStats {
45    pub count: usize,
46    pub mean: f64,
47    pub median: f64,
48    pub min: f64,
49    pub max: f64,
50    pub stdev: Option<f64>,
51    pub sum: f64,
52    pub threads: Option<usize>,
53}
54
55#[derive(Debug, Clone, PartialEq)]
56pub struct ProfilingResult {
57    pub name: String,
58    pub parent: Option<String>,
59    pub stats_all: ProfilingStats,
60    pub stats_1m: Option<ProfilingStats>,
61    pub stats_5m: Option<ProfilingStats>,
62    pub stats_30m: Option<ProfilingStats>,
63    pub stats_60m: Option<ProfilingStats>,
64}
65
66static EPOCH: OnceLock<Instant> = OnceLock::new();
67static NEXT_CAPTURE_ID: AtomicU64 = AtomicU64::new(1);
68static CAPTURES: OnceLock<Mutex<BTreeMap<String, TagCaptures>>> = OnceLock::new();
69
70fn elapsed_now() -> f64 {
71    EPOCH.get_or_init(Instant::now).elapsed().as_secs_f64()
72}
73
74fn captures() -> &'static Mutex<BTreeMap<String, TagCaptures>> {
75    CAPTURES.get_or_init(|| Mutex::new(BTreeMap::new()))
76}
77
78/// A timing sample that is completed when the guard is dropped.
79#[must_use = "the guard must be retained for the duration being profiled"]
80pub struct ProfileGuard {
81    tag: String,
82    capture_id: Option<u64>,
83}
84
85impl Drop for ProfileGuard {
86    fn drop(&mut self) {
87        if let Some(capture_id) = self.capture_id {
88            complete_capture(&self.tag, capture_id, elapsed_now());
89        }
90    }
91}
92
93fn begin_capture(
94    tag: &str,
95    parent: Option<&str>,
96    max_captures: usize,
97    started: f64,
98    thread_id: ThreadId,
99) -> Option<u64> {
100    if max_captures == 0 {
101        return None;
102    }
103    let capture_id = NEXT_CAPTURE_ID.fetch_add(1, Ordering::Relaxed);
104    let mut registry = captures()
105        .lock()
106        .unwrap_or_else(|poisoned| poisoned.into_inner());
107    let entry = registry
108        .entry(tag.to_owned())
109        .or_insert_with(|| TagCaptures {
110            parent: parent.map(str::to_owned),
111            max_captures,
112            captures: VecDeque::with_capacity(max_captures.min(1024)),
113        });
114    if entry.parent.is_none() {
115        entry.parent = parent.map(str::to_owned);
116    }
117    while entry.captures.len() >= entry.max_captures {
118        entry.captures.pop_front();
119    }
120    entry.captures.push_back(Capture {
121        id: capture_id,
122        started,
123        ended: None,
124        thread_id,
125    });
126    Some(capture_id)
127}
128
129fn complete_capture(tag: &str, capture_id: u64, ended: f64) {
130    let mut registry = captures()
131        .lock()
132        .unwrap_or_else(|poisoned| poisoned.into_inner());
133    let Some(entry) = registry.get_mut(tag) else {
134        return;
135    };
136    if let Some(capture) = entry
137        .captures
138        .iter_mut()
139        .find(|capture| capture.id == capture_id)
140    {
141        capture.ended = Some(ended);
142    }
143}
144
145fn start_profile(tag: String, parent: Option<String>, max_captures: usize) -> ProfileGuard {
146    let capture_id = begin_capture(
147        &tag,
148        parent.as_deref(),
149        max_captures,
150        elapsed_now(),
151        std::thread::current().id(),
152    );
153    ProfileGuard { tag, capture_id }
154}
155
156/// Start profiling a top-level tag with the default retention limit.
157pub fn profile(tag: impl Into<String>) -> ProfileGuard {
158    start_profile(tag.into(), None, MAX_CAPTURES)
159}
160
161/// Start profiling a top-level tag with a custom per-tag retention limit.
162pub fn profile_with_limit(tag: impl Into<String>, max_captures: usize) -> ProfileGuard {
163    start_profile(tag.into(), None, max_captures)
164}
165
166/// Start profiling a tag nested under `parent` in formatted results.
167pub fn profile_with_parent(tag: impl Into<String>, parent: impl Into<String>) -> ProfileGuard {
168    start_profile(tag.into(), Some(parent.into()), MAX_CAPTURES)
169}
170
171/// Start nested profiling with a custom per-tag retention limit.
172pub fn profile_with_parent_and_limit(
173    tag: impl Into<String>,
174    parent: impl Into<String>,
175    max_captures: usize,
176) -> ProfileGuard {
177    start_profile(tag.into(), Some(parent.into()), max_captures)
178}
179
180/// Run a function or closure under a profiling tag and return its result.
181///
182/// This is the Rust counterpart to Reticulum's profiling decorator. Rust does
183/// not expose a stable runtime qualified function name, so the tag is explicit.
184/// The guard is also dropped during unwinding, preserving completed timing for
185/// functions that panic when the panic is caught by their caller.
186pub fn profile_function<T>(tag: impl Into<String>, function: impl FnOnce() -> T) -> T {
187    let _profile = profile(tag);
188    function()
189}
190
191/// Run a function or closure under a profiling tag with a custom retention
192/// limit and return its result.
193pub fn profile_function_with_limit<T>(
194    tag: impl Into<String>,
195    max_captures: usize,
196    function: impl FnOnce() -> T,
197) -> T {
198    let _profile = profile_with_limit(tag, max_captures);
199    function()
200}
201
202/// Return whether at least one timing sample has completed.
203pub fn ran() -> bool {
204    captures()
205        .lock()
206        .unwrap_or_else(|poisoned| poisoned.into_inner())
207        .values()
208        .any(|entry| entry.captures.iter().any(|capture| capture.ended.is_some()))
209}
210
211fn calculate_stats(captures: &[Capture], include_threads: bool) -> Option<ProfilingStats> {
212    let completed: Vec<(f64, ThreadId)> = captures
213        .iter()
214        .filter_map(|capture| {
215            capture
216                .duration()
217                .map(|duration| (duration, capture.thread_id))
218        })
219        .collect();
220    if completed.is_empty() {
221        return None;
222    }
223    let mut durations: Vec<f64> = completed.iter().map(|(duration, _)| *duration).collect();
224    durations.sort_by(f64::total_cmp);
225    let count = durations.len();
226    let sum = durations.iter().sum::<f64>();
227    let mean = sum / count as f64;
228    let median = if count.is_multiple_of(2) {
229        (durations[count / 2 - 1] + durations[count / 2]) / 2.0
230    } else {
231        durations[count / 2]
232    };
233    let stdev = (count > 1).then(|| {
234        let variance = durations
235            .iter()
236            .map(|duration| (duration - mean).powi(2))
237            .sum::<f64>()
238            / (count - 1) as f64;
239        variance.sqrt()
240    });
241    let threads = include_threads.then(|| {
242        completed
243            .iter()
244            .map(|(_, thread_id)| *thread_id)
245            .collect::<HashSet<_>>()
246            .len()
247    });
248    Some(ProfilingStats {
249        count,
250        mean,
251        median,
252        min: durations[0],
253        max: durations[count - 1],
254        stdev,
255        sum,
256        threads,
257    })
258}
259
260fn window_stats(
261    captures: &[Capture],
262    now: f64,
263    youngest_age: f64,
264    oldest_age: f64,
265) -> Option<ProfilingStats> {
266    let window: Vec<Capture> = captures
267        .iter()
268        .copied()
269        .filter(|capture| {
270            let age = now - capture.started;
271            age >= youngest_age && age < oldest_age
272        })
273        .collect();
274    calculate_stats(&window, false)
275}
276
277/// Return a stable, tag-sorted snapshot of all retained completed samples.
278pub fn results() -> BTreeMap<String, ProfilingResult> {
279    let now = elapsed_now();
280    captures()
281        .lock()
282        .unwrap_or_else(|poisoned| poisoned.into_inner())
283        .iter()
284        .filter_map(|(tag, entry)| {
285            let mut tag_captures: Vec<Capture> = entry.captures.iter().copied().collect();
286            tag_captures.sort_by(|left, right| left.started.total_cmp(&right.started));
287            let stats_all = calculate_stats(&tag_captures, true)?;
288            Some((
289                tag.clone(),
290                ProfilingResult {
291                    name: tag.clone(),
292                    parent: entry.parent.clone(),
293                    stats_all,
294                    stats_1m: window_stats(&tag_captures, now, 0.0, 60.0),
295                    stats_5m: window_stats(&tag_captures, now, 60.0, 5.0 * 60.0),
296                    stats_30m: window_stats(&tag_captures, now, 5.0 * 60.0, 30.0 * 60.0),
297                    stats_60m: window_stats(&tag_captures, now, 30.0 * 60.0, 60.0 * 60.0),
298                },
299            ))
300        })
301        .collect()
302}
303
304fn pickle_key(key: &str) -> PickleValue {
305    PickleValue::String(key.into())
306}
307
308fn stats_to_pickle(stats: Option<&ProfilingStats>) -> PickleValue {
309    let Some(stats) = stats else {
310        return PickleValue::None;
311    };
312    PickleValue::Dict(vec![
313        (pickle_key("count"), PickleValue::Int(stats.count as i64)),
314        (pickle_key("mean"), PickleValue::Float(stats.mean)),
315        (pickle_key("median"), PickleValue::Float(stats.median)),
316        (pickle_key("min"), PickleValue::Float(stats.min)),
317        (pickle_key("max"), PickleValue::Float(stats.max)),
318        (
319            pickle_key("stdev"),
320            stats.stdev.map_or(PickleValue::None, PickleValue::Float),
321        ),
322        (pickle_key("sum"), PickleValue::Float(stats.sum)),
323        (
324            pickle_key("threads"),
325            stats.threads.map_or(PickleValue::None, |threads| {
326                PickleValue::Int(threads as i64)
327            }),
328        ),
329    ])
330}
331
332fn result_to_pickle(result: &ProfilingResult) -> PickleValue {
333    PickleValue::Dict(vec![
334        (pickle_key("name"), PickleValue::String(result.name.clone())),
335        (
336            pickle_key("super"),
337            result.parent.as_ref().map_or(PickleValue::None, |parent| {
338                PickleValue::String(parent.clone())
339            }),
340        ),
341        (
342            pickle_key("stats_all"),
343            stats_to_pickle(Some(&result.stats_all)),
344        ),
345        (
346            pickle_key("stats_1m"),
347            stats_to_pickle(result.stats_1m.as_ref()),
348        ),
349        (
350            pickle_key("stats_5m"),
351            stats_to_pickle(result.stats_5m.as_ref()),
352        ),
353        (
354            pickle_key("stats_30m"),
355            stats_to_pickle(result.stats_30m.as_ref()),
356        ),
357        (
358            pickle_key("stats_60m"),
359            stats_to_pickle(result.stats_60m.as_ref()),
360        ),
361    ])
362}
363
364/// Snapshot results in the dictionary shape used by Python's local RPC.
365pub fn results_pickle() -> Option<PickleValue> {
366    let results = results();
367    (!results.is_empty()).then(|| {
368        PickleValue::Dict(
369            results
370                .iter()
371                .map(|(tag, result)| (PickleValue::String(tag.clone()), result_to_pickle(result)))
372                .collect(),
373        )
374    })
375}
376
377fn stats_to_msgpack(stats: Option<&ProfilingStats>) -> Value {
378    let Some(stats) = stats else {
379        return Value::Nil;
380    };
381    Value::Map(vec![
382        (Value::Str("count".into()), Value::UInt(stats.count as u64)),
383        (Value::Str("mean".into()), Value::Float(stats.mean)),
384        (Value::Str("median".into()), Value::Float(stats.median)),
385        (Value::Str("min".into()), Value::Float(stats.min)),
386        (Value::Str("max".into()), Value::Float(stats.max)),
387        (
388            Value::Str("stdev".into()),
389            stats.stdev.map_or(Value::Nil, Value::Float),
390        ),
391        (Value::Str("sum".into()), Value::Float(stats.sum)),
392        (
393            Value::Str("threads".into()),
394            stats
395                .threads
396                .map_or(Value::Nil, |threads| Value::UInt(threads as u64)),
397        ),
398    ])
399}
400
401fn result_to_msgpack(result: &ProfilingResult) -> Value {
402    Value::Map(vec![
403        (Value::Str("name".into()), Value::Str(result.name.clone())),
404        (
405            Value::Str("super".into()),
406            result
407                .parent
408                .as_ref()
409                .map_or(Value::Nil, |parent| Value::Str(parent.clone())),
410        ),
411        (
412            Value::Str("stats_all".into()),
413            stats_to_msgpack(Some(&result.stats_all)),
414        ),
415        (
416            Value::Str("stats_1m".into()),
417            stats_to_msgpack(result.stats_1m.as_ref()),
418        ),
419        (
420            Value::Str("stats_5m".into()),
421            stats_to_msgpack(result.stats_5m.as_ref()),
422        ),
423        (
424            Value::Str("stats_30m".into()),
425            stats_to_msgpack(result.stats_30m.as_ref()),
426        ),
427        (
428            Value::Str("stats_60m".into()),
429            stats_to_msgpack(result.stats_60m.as_ref()),
430        ),
431    ])
432}
433
434/// Snapshot results in the dictionary shape used by remote management.
435pub fn results_msgpack() -> Value {
436    let results = results();
437    if results.is_empty() {
438        Value::Nil
439    } else {
440        Value::Map(
441            results
442                .iter()
443                .map(|(tag, result)| (Value::Str(tag.clone()), result_to_msgpack(result)))
444                .collect(),
445        )
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452
453    fn record(tag: &str, started: f64, duration: f64, limit: usize) {
454        let id = begin_capture(tag, None, limit, started, std::thread::current().id()).unwrap();
455        complete_capture(tag, id, started + duration);
456    }
457
458    #[test]
459    fn retention_limit_is_shared_across_threads() {
460        let tag = "entry113.shared-limit";
461        let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
462        let handles: Vec<_> = (0..2)
463            .map(|thread_index| {
464                let barrier = barrier.clone();
465                std::thread::spawn(move || {
466                    for sample in 0..3 {
467                        barrier.wait();
468                        record(
469                            tag,
470                            elapsed_now(),
471                            (thread_index * 10 + sample + 1) as f64,
472                            3,
473                        );
474                        barrier.wait();
475                    }
476                })
477            })
478            .collect();
479        for handle in handles {
480            handle.join().unwrap();
481        }
482
483        let result = &results()[tag];
484        assert_eq!(result.stats_all.count, 3);
485        assert_eq!(result.stats_all.threads, Some(2));
486    }
487
488    #[test]
489    fn reentrant_guards_complete_independent_captures() {
490        let outer = profile("entry113.reentrant");
491        let inner = profile("entry113.reentrant");
492        drop(inner);
493        assert_eq!(results()["entry113.reentrant"].stats_all.count, 1);
494        drop(outer);
495        assert_eq!(results()["entry113.reentrant"].stats_all.count, 2);
496    }
497
498    #[test]
499    fn live_windows_are_non_overlapping() {
500        let tag = "entry113.windows";
501        let now = elapsed_now();
502        record(tag, now - 30.0, 0.001, 10);
503        record(tag, now - 120.0, 0.002, 10);
504        record(tag, now - 600.0, 0.003, 10);
505        record(tag, now - 2_400.0, 0.004, 10);
506
507        let result = &results()[tag];
508        assert_eq!(result.stats_all.count, 4);
509        assert!((result.stats_all.sum - 0.010).abs() < 1e-9);
510        assert_eq!(result.stats_1m.as_ref().unwrap().count, 1);
511        assert!((result.stats_1m.as_ref().unwrap().sum - 0.001).abs() < 1e-9);
512        assert!((result.stats_5m.as_ref().unwrap().sum - 0.002).abs() < 1e-9);
513        assert!((result.stats_30m.as_ref().unwrap().sum - 0.003).abs() < 1e-9);
514        assert!((result.stats_60m.as_ref().unwrap().sum - 0.004).abs() < 1e-9);
515    }
516
517    #[test]
518    fn rpc_shape_moves_counts_into_statistics() {
519        record("entry113.shape", elapsed_now(), 0.005, 10);
520        let pickle = results_pickle().unwrap();
521        let entry = pickle.get("entry113.shape").unwrap();
522        assert!(entry.get("count").is_none());
523        assert!(entry.get("threads").is_none());
524        let all = entry.get("stats_all").unwrap();
525        assert_eq!(all.get("count").and_then(PickleValue::as_int), Some(1));
526        assert_eq!(all.get("threads").and_then(PickleValue::as_int), Some(1));
527        let sum = all.get("sum").and_then(PickleValue::as_float).unwrap();
528        assert!((sum - 0.005).abs() < 1e-9);
529    }
530
531    #[test]
532    fn function_adapter_preserves_result_and_records_unwind() {
533        assert_eq!(profile_function("entry113.function", || 42), 42);
534        let unwind = std::panic::catch_unwind(|| {
535            profile_function("entry113.unwind", || panic!("profiled failure"));
536        });
537        assert!(unwind.is_err());
538        assert_eq!(results()["entry113.unwind"].stats_all.count, 1);
539    }
540
541    #[test]
542    fn merged_live_profiling_pipeline_retains_final_schema() {
543        profile_function("entry114.merge", || ());
544        let Value::Map(tags) = results_msgpack() else {
545            panic!("merged profiler results must be a MessagePack map");
546        };
547        let Value::Map(entry) = tags
548            .iter()
549            .find(|(key, _)| *key == Value::Str("entry114.merge".into()))
550            .map(|(_, value)| value)
551            .unwrap()
552        else {
553            panic!("merged profiler entry must be a map");
554        };
555        for key in [
556            "name",
557            "super",
558            "stats_all",
559            "stats_1m",
560            "stats_5m",
561            "stats_30m",
562            "stats_60m",
563        ] {
564            assert!(entry
565                .iter()
566                .any(|(candidate, _)| *candidate == Value::Str(key.into())));
567        }
568    }
569}