Skip to main content

mnemosyne_backend/recorders/
stats.rs

1//! Telemetry and stats tracking for OS virtual memory mappings.
2//!
3//! Pure recorder counters and the per-concern unit tests for `record_*`.
4//! The recorder is exposed as `pub(crate)` so sibling concern modules
5//! ([`crate::mapping`], [`crate::guard`], [`crate::reset`]) can update
6//! counters on confirmed OS outcomes; external consumers reach the
7//! snapshot through [`backend_memory_stats`] and [`BackendMemoryStats`].
8
9use core::sync::atomic::{AtomicUsize, Ordering};
10
11static CURRENT_MAPPED_BYTES: AtomicUsize = AtomicUsize::new(0);
12static PEAK_MAPPED_BYTES: AtomicUsize = AtomicUsize::new(0);
13static MAP_CALLS: AtomicUsize = AtomicUsize::new(0);
14static UNMAP_CALLS: AtomicUsize = AtomicUsize::new(0);
15static PAGE_RESET_CALLS: AtomicUsize = AtomicUsize::new(0);
16static PAGE_RESET_BYTES: AtomicUsize = AtomicUsize::new(0);
17static GUARD_INSTALL_CALLS: AtomicUsize = AtomicUsize::new(0);
18static GUARD_INSTALL_BYTES: AtomicUsize = AtomicUsize::new(0);
19static DECOMMIT_CALLS: AtomicUsize = AtomicUsize::new(0);
20static DECOMMIT_BYTES: AtomicUsize = AtomicUsize::new(0);
21/// Bytes decommitted with MADV_FREE (lazy, no IPI) — subset of DECOMMIT_BYTES.
22static PURGE_BYTES: AtomicUsize = AtomicUsize::new(0);
23static HUGEPAGE_HINT_CALLS: AtomicUsize = AtomicUsize::new(0);
24
25/// Snapshot of OS mappings requested by Mnemosyne.
26#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
27pub struct BackendMemoryStats {
28    /// Address space currently mapped from the OS, in bytes.
29    ///
30    /// Reserved address space, not resident memory: a range stays counted
31    /// here after `page_reset` or `decommit` releases its physical
32    /// backing, because the mapping itself is still held.
33    pub current_mapped_bytes: usize,
34    /// High-water mark of [`Self::current_mapped_bytes`].
35    ///
36    /// Retained because the instantaneous value hides transient peaks: a
37    /// workload that maps and unmaps in bursts can sit near zero while
38    /// having demanded far more at once.
39    pub peak_mapped_bytes: usize,
40    /// Number of successful map requests to the OS.
41    ///
42    /// Against [`Self::unmap_calls`], a persistent gap is expected rather
43    /// than a leak: decay deliberately retains cold segments for reuse.
44    pub map_calls: usize,
45    /// Number of successful unmap requests to the OS.
46    pub unmap_calls: usize,
47    /// Number of `page_reset` calls that the OS confirmed.
48    ///
49    /// A reset releases the physical backing of an addressed range while
50    /// keeping the virtual mapping intact, so this counter is independent
51    /// of `unmap_calls` and `current_mapped_bytes` is not decremented.
52    pub page_reset_calls: usize,
53    /// Cumulative byte count passed to confirmed `page_reset` calls.
54    pub page_reset_bytes: usize,
55    /// Number of `make_guard` calls the OS confirmed.
56    ///
57    /// A guard install changes only the protection bits of an addressed
58    /// range; the mapping remains reserved, so this counter is
59    /// independent of `unmap_calls` and `current_mapped_bytes` is not
60    /// decremented.
61    pub guard_install_calls: usize,
62    /// Cumulative byte count passed to confirmed `make_guard` calls.
63    pub guard_install_bytes: usize,
64    /// Number of `decommit` calls the OS confirmed.
65    ///
66    /// A decommit releases the commit charge / resident backing of an addressed
67    /// range while keeping the reservation, so this counter is independent of
68    /// `unmap_calls` and `current_mapped_bytes` is not decremented (the address
69    /// space remains reserved until `deallocate`).
70    pub decommit_calls: usize,
71    /// Cumulative byte count passed to confirmed `decommit` calls (the commit
72    /// charge / resident backing returned to the OS).
73    pub decommit_bytes: usize,
74    /// Subset of `decommit_bytes` that used `MADV_FREE` (lazy purge).
75    ///
76    /// On Linux ≥ 4.5, `decommit` prefers `MADV_FREE` over `MADV_DONTNEED`
77    /// to avoid IPI broadcast storms during segment release. This counter
78    /// tracks the lazy purge path separately from eager resets, matching the
79    /// mimalloc `purged` / `reset` counter split. Zero on non-Linux targets.
80    pub purged_bytes: usize,
81    /// Number of huge-page hints issued to the OS for freshly mapped regions.
82    ///
83    /// Counts the decision to advise, not a confirmed kernel outcome: the
84    /// hint is advisory, its return value is deliberately discarded, and a
85    /// kernel that ignores it still produces a valid mapping — so the
86    /// decision is the only thing the hint makes observable at all. Stays
87    /// zero on every target that issues no hint (everything but Linux, and
88    /// Linux under Miri), the same way `page_reset_calls` stays zero where
89    /// `page_reset` is unsupported.
90    pub hugepage_hint_calls: usize,
91}
92
93/// Non-generic SSOT for the common (calls += 1, bytes += size) recorder shape.
94///
95/// `record_page_reset`, `record_guard_install`, and `record_decommit` all
96/// perform exactly these two relaxed increments; sharing the body removes the
97/// duplication while keeping each public recorder a clearly-named function.
98#[inline(always)]
99fn record_counter_and_bytes(calls: &AtomicUsize, bytes: &AtomicUsize, size: usize) {
100    calls.fetch_add(1, Ordering::Relaxed);
101    bytes.fetch_add(size, Ordering::Relaxed);
102}
103
104#[inline]
105pub(crate) fn record_map(size: usize) {
106    MAP_CALLS.fetch_add(1, Ordering::Relaxed);
107    let current = CURRENT_MAPPED_BYTES.fetch_add(size, Ordering::Relaxed) + size;
108    if current > PEAK_MAPPED_BYTES.load(Ordering::Relaxed) {
109        PEAK_MAPPED_BYTES.fetch_max(current, Ordering::Relaxed);
110    }
111}
112
113#[inline]
114pub(crate) fn record_unmap(size: usize) {
115    UNMAP_CALLS.fetch_add(1, Ordering::Relaxed);
116    CURRENT_MAPPED_BYTES.fetch_sub(size, Ordering::Relaxed);
117}
118
119/// Records an attempted but failed OS release.
120///
121/// Increments only the call counter so `current_mapped_bytes` stays consistent
122/// with the OS-side mapping set when the release call itself failed.
123#[inline]
124pub(crate) fn record_unmap_failure() {
125    UNMAP_CALLS.fetch_add(1, Ordering::Relaxed);
126}
127
128#[inline]
129pub(crate) fn record_page_reset(size: usize) {
130    record_counter_and_bytes(&PAGE_RESET_CALLS, &PAGE_RESET_BYTES, size);
131}
132
133/// Records a confirmed guard-region install.
134#[inline]
135pub(crate) fn record_guard_install(size: usize) {
136    record_counter_and_bytes(&GUARD_INSTALL_CALLS, &GUARD_INSTALL_BYTES, size);
137}
138
139/// Records a confirmed decommit.
140#[inline]
141pub(crate) fn record_decommit(size: usize) {
142    record_counter_and_bytes(&DECOMMIT_CALLS, &DECOMMIT_BYTES, size);
143}
144
145/// Records a huge-page hint issued for a freshly mapped region.
146///
147/// Unlike the other recorders this takes no size and moves no byte counter:
148/// the advice changes neither the mapping's extent nor its backing, so the
149/// call count is the whole observable. Defined only where a hint is actually
150/// issued, so a target that cannot advise has no unreachable recorder.
151#[cfg(all(target_os = "linux", not(miri)))]
152#[inline]
153pub(crate) fn record_hugepage_hint() {
154    HUGEPAGE_HINT_CALLS.fetch_add(1, Ordering::Relaxed);
155}
156
157/// Marks `size` bytes as having been decommitted via MADV_FREE (lazy purge).
158///
159/// Called by the Linux decommit path when it successfully uses MADV_FREE
160/// rather than MADV_DONTNEED. Does NOT call `record_decommit` — the caller
161/// (`reset::do_decommit`) handles the decommit_calls/decommit_bytes counters
162/// centrally for all backends. This only tracks the subset that used the
163/// lazy IPI-free path.
164#[cfg(all(target_os = "linux", not(miri)))]
165#[inline]
166pub(crate) fn record_purge_only(size: usize) {
167    PURGE_BYTES.fetch_add(size, Ordering::Relaxed);
168}
169
170/// Returns the current backend memory mapping counters.
171///
172/// The snapshot uses relaxed atomics because these counters are telemetry only:
173/// allocator correctness never depends on cross-counter synchronization.
174pub fn backend_memory_stats() -> BackendMemoryStats {
175    BackendMemoryStats {
176        current_mapped_bytes: CURRENT_MAPPED_BYTES.load(Ordering::Relaxed),
177        peak_mapped_bytes: PEAK_MAPPED_BYTES.load(Ordering::Relaxed),
178        map_calls: MAP_CALLS.load(Ordering::Relaxed),
179        unmap_calls: UNMAP_CALLS.load(Ordering::Relaxed),
180        page_reset_calls: PAGE_RESET_CALLS.load(Ordering::Relaxed),
181        page_reset_bytes: PAGE_RESET_BYTES.load(Ordering::Relaxed),
182        guard_install_calls: GUARD_INSTALL_CALLS.load(Ordering::Relaxed),
183        guard_install_bytes: GUARD_INSTALL_BYTES.load(Ordering::Relaxed),
184        decommit_calls: DECOMMIT_CALLS.load(Ordering::Relaxed),
185        decommit_bytes: DECOMMIT_BYTES.load(Ordering::Relaxed),
186        purged_bytes: PURGE_BYTES.load(Ordering::Relaxed),
187        hugepage_hint_calls: HUGEPAGE_HINT_CALLS.load(Ordering::Relaxed),
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    extern crate std;
194
195    use super::*;
196    use crate::test_support::lock_test;
197
198    #[test]
199    fn mapping_telemetry_tracks_deltas_and_peak() {
200        let _guard = lock_test();
201        let before = backend_memory_stats();
202        let size = 4096;
203
204        record_map(size);
205        let during = backend_memory_stats();
206
207        assert_eq!(
208            during.current_mapped_bytes,
209            before.current_mapped_bytes + size
210        );
211        assert_eq!(during.map_calls, before.map_calls + 1);
212        assert_eq!(during.unmap_calls, before.unmap_calls);
213        assert!(
214            during.peak_mapped_bytes >= during.current_mapped_bytes,
215            "peak {} below current {} after record_map",
216            during.peak_mapped_bytes,
217            during.current_mapped_bytes
218        );
219        assert!(
220            during.peak_mapped_bytes >= before.peak_mapped_bytes,
221            "peak {} below pre-map peak {}",
222            during.peak_mapped_bytes,
223            before.peak_mapped_bytes
224        );
225
226        record_unmap(size);
227        let after = backend_memory_stats();
228
229        assert_eq!(after.current_mapped_bytes, before.current_mapped_bytes);
230        assert_eq!(after.map_calls, before.map_calls + 1);
231        assert_eq!(after.unmap_calls, before.unmap_calls + 1);
232        assert!(
233            after.peak_mapped_bytes >= during.peak_mapped_bytes,
234            "peak {} regressed below mid-cycle peak {}",
235            after.peak_mapped_bytes,
236            during.peak_mapped_bytes
237        );
238    }
239
240    #[test]
241    fn failed_release_increments_call_count_without_byte_delta() {
242        let _guard = lock_test();
243        let before = backend_memory_stats();
244        let size = 4096;
245
246        record_map(size);
247        let mapped = backend_memory_stats();
248        assert_eq!(
249            mapped.current_mapped_bytes,
250            before.current_mapped_bytes + size
251        );
252
253        // Simulate a failed OS release: the wrapper increments the call counter
254        // but must not subtract bytes that remain mapped from the OS perspective.
255        record_unmap_failure();
256        let failed = backend_memory_stats();
257        assert_eq!(failed.current_mapped_bytes, mapped.current_mapped_bytes);
258        assert_eq!(failed.unmap_calls, mapped.unmap_calls + 1);
259        assert_eq!(failed.map_calls, mapped.map_calls);
260
261        record_unmap(size);
262        let cleared = backend_memory_stats();
263        assert_eq!(cleared.current_mapped_bytes, before.current_mapped_bytes);
264    }
265
266    #[test]
267    fn page_reset_telemetry_increments_call_and_byte_counters_only() {
268        let _guard = lock_test();
269        // record_page_reset must increment both call and byte counters
270        // without touching current_mapped_bytes, because a reset releases
271        // physical backing while leaving the virtual mapping committed.
272        let before = backend_memory_stats();
273        let size = 8192;
274
275        record_page_reset(size);
276        let after = backend_memory_stats();
277
278        assert_eq!(
279            after.page_reset_calls,
280            before.page_reset_calls + 1,
281            "page_reset_calls counter did not advance"
282        );
283        assert_eq!(
284            after.page_reset_bytes,
285            before.page_reset_bytes + size,
286            "page_reset_bytes counter did not advance by the reset size"
287        );
288        assert_eq!(
289            after.current_mapped_bytes, before.current_mapped_bytes,
290            "page_reset must not decrement current_mapped_bytes"
291        );
292        assert_eq!(
293            after.unmap_calls, before.unmap_calls,
294            "page_reset must not increment unmap_calls"
295        );
296    }
297
298    #[test]
299    fn decommit_telemetry_increments_call_and_byte_counters_only() {
300        let _guard = lock_test();
301        // record_decommit must increment both counters without touching
302        // current_mapped_bytes (the reservation persists) or the unmap/reset
303        // counters.
304        let before = backend_memory_stats();
305        let size = 64 * 1024;
306
307        record_decommit(size);
308        let after = backend_memory_stats();
309
310        assert_eq!(
311            after.decommit_calls,
312            before.decommit_calls + 1,
313            "decommit_calls counter did not advance"
314        );
315        assert_eq!(
316            after.decommit_bytes,
317            before.decommit_bytes + size,
318            "decommit_bytes counter did not advance by the decommit size"
319        );
320        assert_eq!(
321            after.current_mapped_bytes, before.current_mapped_bytes,
322            "decommit must not decrement current_mapped_bytes (reservation persists)"
323        );
324        assert_eq!(after.unmap_calls, before.unmap_calls);
325        assert_eq!(after.page_reset_calls, before.page_reset_calls);
326    }
327
328    /// The recorder is defined only where a hint is issued, so the test is
329    /// scoped to the same targets as the function it covers.
330    #[cfg(all(target_os = "linux", not(miri)))]
331    #[test]
332    fn hugepage_hint_telemetry_increments_only_its_own_call_counter() {
333        let _guard = lock_test();
334        // The hint carries no size and changes no mapping extent, so
335        // record_hugepage_hint must move its own counter and nothing else.
336        let before = backend_memory_stats();
337
338        record_hugepage_hint();
339        let after = backend_memory_stats();
340
341        assert_eq!(
342            after.hugepage_hint_calls,
343            before.hugepage_hint_calls + 1,
344            "hugepage_hint_calls counter did not advance"
345        );
346        assert_eq!(
347            after.current_mapped_bytes, before.current_mapped_bytes,
348            "an advisory hint must not move the mapped-byte accounting"
349        );
350        assert_eq!(after.map_calls, before.map_calls);
351        assert_eq!(after.page_reset_calls, before.page_reset_calls);
352        assert_eq!(after.decommit_calls, before.decommit_calls);
353    }
354
355    #[test]
356    fn guard_telemetry_increments_call_and_byte_counters_only() {
357        let _guard = lock_test();
358        // record_guard_install must increment both counters without
359        // perturbing current_mapped_bytes, page_reset, or unmap counters.
360        let before = backend_memory_stats();
361        let size = 4096;
362        record_guard_install(size);
363        let after = backend_memory_stats();
364        assert_eq!(
365            after.guard_install_calls,
366            before.guard_install_calls + 1,
367            "guard_install_calls counter did not advance"
368        );
369        assert_eq!(
370            after.guard_install_bytes,
371            before.guard_install_bytes + size,
372            "guard_install_bytes counter did not advance by the guard size"
373        );
374        assert_eq!(
375            after.current_mapped_bytes, before.current_mapped_bytes,
376            "make_guard must not decrement current_mapped_bytes"
377        );
378        assert_eq!(after.page_reset_calls, before.page_reset_calls);
379        assert_eq!(after.unmap_calls, before.unmap_calls);
380    }
381}