Skip to main content

rlx_runtime/
telemetry.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Telemetry primitives (plan #65).
6//!
7//! Borrowed from MAX's `serve/telemetry/` module shape (counters,
8//! histograms, stopwatches) but stripped to a pure data layer.
9//! Today this is a single in-process `MetricsRegistry`; a future
10//! serving crate can route the registry to a sidecar process /
11//! Prometheus / OTel exporter without changing the call sites.
12//!
13//! Why now (without a serving crate to consume it)?
14//!   - Lets the autotuner / fusion passes record decisions while
15//!     they're being made, viewable later via [`MetricsRegistry::
16//!     snapshot`].
17//!   - Establishes the "metrics live in their own type, not
18//!     scattered through hot paths" pattern before we accumulate
19//!     a hundred ad-hoc counters.
20
21use std::collections::BTreeMap;
22use std::sync::{
23    Mutex, OnceLock,
24    atomic::{AtomicU64, Ordering},
25};
26
27/// Monotonic 64-bit counter. Cheap (atomic add); safe to call from
28/// any thread.
29#[derive(Debug, Default)]
30pub struct Counter {
31    value: AtomicU64,
32}
33
34impl Counter {
35    pub const fn new() -> Self {
36        Self {
37            value: AtomicU64::new(0),
38        }
39    }
40    // #[inline(always)] (plan #76) — these are the per-event hot
41    // paths; we want callers to see one atomic instruction, not a
42    // function-call indirection through the Counter type.
43    #[inline(always)]
44    pub fn inc(&self) {
45        self.value.fetch_add(1, Ordering::Relaxed);
46    }
47    #[inline(always)]
48    pub fn add(&self, delta: u64) {
49        self.value.fetch_add(delta, Ordering::Relaxed);
50    }
51    #[inline(always)]
52    pub fn get(&self) -> u64 {
53        self.value.load(Ordering::Relaxed)
54    }
55    pub fn reset(&self) {
56        self.value.store(0, Ordering::Relaxed);
57    }
58}
59
60/// Fixed-bucket exponential histogram — 16 buckets covering up to
61/// `2^16 ≈ 65k` of the chosen unit. Right for ns-to-ms latency
62/// distributions or 1-100k sample-count distributions.
63#[derive(Debug)]
64pub struct Histogram {
65    buckets: [AtomicU64; 16],
66    sum: AtomicU64,
67    count: AtomicU64,
68}
69
70impl Default for Histogram {
71    fn default() -> Self {
72        Self::new()
73    }
74}
75
76impl Histogram {
77    pub const fn new() -> Self {
78        // `[Z; N]` array-init produces N independent AtomicU64s, not
79        // shared interior-mutable const aliases — same idiom std uses
80        // in `[AtomicUsize::new(0); N]`.
81        #[allow(clippy::declare_interior_mutable_const)]
82        const Z: AtomicU64 = AtomicU64::new(0);
83        Self {
84            buckets: [Z; 16],
85            sum: Z,
86            count: Z,
87        }
88    }
89
90    pub fn record(&self, value: u64) {
91        let bucket = (value.checked_ilog2().unwrap_or(0) as usize).min(15);
92        self.buckets[bucket].fetch_add(1, Ordering::Relaxed);
93        self.sum.fetch_add(value, Ordering::Relaxed);
94        self.count.fetch_add(1, Ordering::Relaxed);
95    }
96
97    pub fn count(&self) -> u64 {
98        self.count.load(Ordering::Relaxed)
99    }
100    pub fn sum(&self) -> u64 {
101        self.sum.load(Ordering::Relaxed)
102    }
103    pub fn mean(&self) -> Option<f64> {
104        let c = self.count();
105        if c == 0 {
106            None
107        } else {
108            Some(self.sum() as f64 / c as f64)
109        }
110    }
111    pub fn bucket_counts(&self) -> [u64; 16] {
112        let mut out = [0u64; 16];
113        for (i, b) in self.buckets.iter().enumerate() {
114            out[i] = b.load(Ordering::Relaxed);
115        }
116        out
117    }
118}
119
120/// Global registry of named counters and histograms. Indexed by
121/// static string keys; lookups are O(log N) on a BTreeMap. Lock
122/// is only held during register/lookup, not during increment.
123pub struct MetricsRegistry {
124    counters: Mutex<BTreeMap<&'static str, &'static Counter>>,
125    histograms: Mutex<BTreeMap<&'static str, &'static Histogram>>,
126}
127
128impl MetricsRegistry {
129    /// Process-wide registry. First access lazily initializes.
130    pub fn global() -> &'static Self {
131        static R: OnceLock<MetricsRegistry> = OnceLock::new();
132        R.get_or_init(|| MetricsRegistry {
133            counters: Mutex::new(BTreeMap::new()),
134            histograms: Mutex::new(BTreeMap::new()),
135        })
136    }
137
138    /// Register a `'static` counter so it shows up in
139    /// `snapshot()`. Idempotent — re-registering with the same
140    /// pointer is a no-op.
141    pub fn register_counter(&self, name: &'static str, c: &'static Counter) {
142        self.counters
143            .lock()
144            .expect("registry poisoned")
145            .insert(name, c);
146    }
147
148    pub fn register_histogram(&self, name: &'static str, h: &'static Histogram) {
149        self.histograms
150            .lock()
151            .expect("registry poisoned")
152            .insert(name, h);
153    }
154
155    /// Snapshot all metrics into a serializable map. Useful for
156    /// dumping to a log / exporting to Prometheus.
157    pub fn snapshot(&self) -> Snapshot {
158        let counters = self
159            .counters
160            .lock()
161            .unwrap()
162            .iter()
163            .map(|(&n, c)| (n.to_string(), c.get()))
164            .collect();
165        let histograms = self
166            .histograms
167            .lock()
168            .unwrap()
169            .iter()
170            .map(|(&n, h)| {
171                (
172                    n.to_string(),
173                    HistogramSnapshot {
174                        count: h.count(),
175                        sum: h.sum(),
176                        buckets: h.bucket_counts(),
177                    },
178                )
179            })
180            .collect();
181        Snapshot {
182            counters,
183            histograms,
184        }
185    }
186}
187
188#[derive(Debug)]
189pub struct HistogramSnapshot {
190    pub count: u64,
191    pub sum: u64,
192    pub buckets: [u64; 16],
193}
194
195#[derive(Debug, Default)]
196pub struct Snapshot {
197    pub counters: BTreeMap<String, u64>,
198    pub histograms: BTreeMap<String, HistogramSnapshot>,
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn counter_basic() {
207        let c = Counter::new();
208        c.inc();
209        c.inc();
210        c.add(10);
211        assert_eq!(c.get(), 12);
212    }
213
214    #[test]
215    fn histogram_records_in_bucket() {
216        let h = Histogram::new();
217        h.record(0); // bucket 0 (ilog2(0).unwrap_or(0) = 0)
218        h.record(1); // bucket 0
219        h.record(7); // bucket 2 (ilog2(7) = 2)
220        h.record(1024); // bucket 10
221        let b = h.bucket_counts();
222        assert_eq!(h.count(), 4);
223        assert_eq!(b[0] + b[2] + b[10], 4);
224    }
225
226    #[test]
227    fn registry_round_trip() {
228        static C: Counter = Counter::new();
229        static H: Histogram = Histogram::new();
230        let r = MetricsRegistry::global();
231        r.register_counter("test_count", &C);
232        r.register_histogram("test_hist", &H);
233        C.inc();
234        H.record(42);
235        let snap = r.snapshot();
236        assert!(snap.counters.contains_key("test_count"));
237        assert!(snap.histograms.contains_key("test_hist"));
238    }
239}