mnemosyne_local/local_alloc/stats.rs
1use crate::local_alloc::{CROSS_THREAD_RECLAIMED_BLOCKS, ThreadAllocator};
2use core::ptr::NonNull;
3use core::sync::atomic::Ordering;
4use mnemosyne_arena::HasSegmentPool;
5use mnemosyne_core::constants::NUM_SIZE_CLASSES;
6use mnemosyne_core::types::Page;
7
8/// Occupancy counters for a single size class in the current thread allocator.
9#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
10pub struct SizeClassOccupancy {
11 /// Pages holding at least one live allocation.
12 pub active_pages: usize,
13 /// Pages with no live allocation, retained for reuse rather than
14 /// returned, so a refill of this size class costs no syscall.
15 pub empty_pages: usize,
16 /// Allocations currently handed out from this size class.
17 pub live_allocations: usize,
18 /// Slots across all pages of this size class, live or free.
19 ///
20 /// Against [`Self::live_allocations`] this gives the class's internal
21 /// fragmentation: a large gap means pages are pinned by a few
22 /// scattered survivors.
23 pub total_slots: usize,
24}
25
26/// Snapshot of the current thread-local allocator state.
27#[derive(Clone, Copy, Debug, Eq, PartialEq)]
28pub struct ThreadAllocatorStats {
29 /// Allocations currently handed out by this thread across every size
30 /// class.
31 pub current_thread_live_allocations: usize,
32 /// Segments this thread owns and allocates from without coordination.
33 pub current_thread_owned_segments: usize,
34 /// Blocks freed by another thread and returned to this thread's pages.
35 ///
36 /// Cross-thread frees are queued to the owning thread rather than
37 /// mutating its pages directly, so this counts the deferred returns
38 /// that have been drained.
39 pub cross_thread_reclaimed_blocks: usize,
40 /// Times a size class exhausted its page and had to acquire another.
41 ///
42 /// The sum of the three sources below, and the headline number for how
43 /// often the fast path fell through.
44 pub page_refills: usize,
45 /// Refills served from an empty page this thread already held — the
46 /// cheapest outcome, no segment or OS work.
47 pub recycled_pages: usize,
48 /// Refills that carved a new page from an owned segment.
49 pub fresh_pages: usize,
50 /// Refills that required a new segment, the only source that can reach
51 /// the OS backend.
52 pub fresh_segments: usize,
53 /// Segments inherited from threads that exited while still owning them.
54 ///
55 /// Adoption is what keeps a terminated thread's memory reusable
56 /// instead of stranded until process exit.
57 pub orphan_segments_adopted: usize,
58 /// Sweeps over pages looking for empties to recycle.
59 ///
60 /// Against [`Self::recycled_pages`] this shows whether sweeping is
61 /// paying for itself or scanning without finding reusable pages.
62 pub recycle_sweeps: usize,
63 /// Per-size-class occupancy, indexed by size class.
64 pub size_class_occupancy: [SizeClassOccupancy; NUM_SIZE_CLASSES],
65}
66
67impl Default for ThreadAllocatorStats {
68 fn default() -> Self {
69 Self {
70 current_thread_live_allocations: 0,
71 current_thread_owned_segments: 0,
72 cross_thread_reclaimed_blocks: 0,
73 page_refills: 0,
74 recycled_pages: 0,
75 fresh_pages: 0,
76 fresh_segments: 0,
77 orphan_segments_adopted: 0,
78 recycle_sweeps: 0,
79 size_class_occupancy: [SizeClassOccupancy::default(); NUM_SIZE_CLASSES],
80 }
81 }
82}
83
84impl<B: HasSegmentPool> ThreadAllocator<B> {
85 /// Returns a statistics snapshot for this thread allocator.
86 ///
87 /// The snapshot walks the allocator's active/full/empty page lists instead
88 /// of every page in every owned segment. The page lists are the
89 /// authoritative membership structure for initialized pages, so diagnostic
90 /// work scales with pages that carry allocator state rather than
91 /// `owned_segment_count * PAGES_PER_SEGMENT`.
92 pub fn stats(&self) -> ThreadAllocatorStats {
93 let mut snapshot = ThreadAllocatorStats {
94 current_thread_owned_segments: self.owned_segment_count,
95 // Process-wide total: the global fold point (contributions from
96 // already-terminated threads) plus this live thread's own
97 // not-yet-folded count. This reproduces the pre-split observable
98 // for the calling thread exactly.
99 cross_thread_reclaimed_blocks: CROSS_THREAD_RECLAIMED_BLOCKS.load(Ordering::Relaxed)
100 + self.cross_thread_reclaimed,
101 page_refills: self.page_refills,
102 recycled_pages: self.recycled_pages,
103 fresh_pages: self.fresh_pages,
104 fresh_segments: self.fresh_segments,
105 orphan_segments_adopted: self.orphan_segments_adopted,
106 recycle_sweeps: self.recycle_sweeps,
107 ..ThreadAllocatorStats::default()
108 };
109
110 for class in 0..NUM_SIZE_CLASSES {
111 // SAFETY: `active_pages[class]`/`full_pages[class]` are the heads of
112 // this allocator's own intrusive page lists; every linked `Page` is
113 // live and owned by this thread, satisfying the read-only walk's
114 // precondition.
115 unsafe { accumulate_active_list(&mut snapshot, self.active_pages[class]) };
116 unsafe { accumulate_active_list(&mut snapshot, self.full_pages[class]) };
117 }
118 // Empty pages are tracked separately: they retain stale size_class/block_size
119 // from their last use, so they must not be counted as live active pages.
120 // SAFETY: `empty_pages` is the head of this allocator's own empty-page
121 // list; every linked `Page` is live and owned by this thread.
122 unsafe { accumulate_empty_list(&mut snapshot, self.empty_pages) };
123
124 snapshot
125 }
126}
127
128/// Accumulates stats for pages in an active or full list.
129/// Empty pages must not pass through this function — use `accumulate_empty_list`.
130///
131/// # Safety
132///
133/// `current` must be the head of an intrusive page list owned by the calling
134/// thread's allocator; every `Page` reachable via `next_page` must be live for
135/// the duration of the walk and not mutably aliased elsewhere.
136unsafe fn accumulate_active_list(
137 snapshot: &mut ThreadAllocatorStats,
138 mut current: Option<NonNull<Page>>,
139) {
140 while let Some(page_ptr) = current {
141 // SAFETY: `page_ptr` is a live, non-null `Page` from the caller-owned
142 // list (its head, then each `next_page`); the shared `&` is sound
143 // because no mutable borrow of the page is live during this read-only
144 // diagnostic walk.
145 let page = unsafe { page_ptr.as_ref() };
146 if page.block_size > 0 {
147 let class = page.size_class as usize;
148 debug_assert!(class < NUM_SIZE_CLASSES);
149 let occupancy = &mut snapshot.size_class_occupancy[class];
150 occupancy.active_pages += 1;
151 if page.alloc_count == 0 {
152 occupancy.empty_pages += 1;
153 }
154 occupancy.live_allocations += page.alloc_count as usize;
155 occupancy.total_slots += page.max_blocks();
156 snapshot.current_thread_live_allocations += page.alloc_count as usize;
157 }
158 current = page.next_page;
159 }
160}
161
162/// Accumulates stats for pages in the empty recycle list.
163///
164/// Empty pages retain stale `size_class`/`block_size` from their last active
165/// use, so they must not be counted as live active pages or add to total_slots.
166///
167/// # Safety
168///
169/// `current` must be the head of the calling thread's allocator's empty-page
170/// list; every `Page` reachable via `next_page` must be live for the duration
171/// of the walk and not mutably aliased elsewhere.
172unsafe fn accumulate_empty_list(
173 snapshot: &mut ThreadAllocatorStats,
174 mut current: Option<NonNull<Page>>,
175) {
176 while let Some(page_ptr) = current {
177 // SAFETY: `page_ptr` is a live, non-null `Page` from the caller-owned
178 // empty list; the shared `&` is sound because no mutable borrow of the
179 // page is live during this read-only diagnostic walk.
180 let page = unsafe { page_ptr.as_ref() };
181 if page.block_size > 0 {
182 let class = page.size_class as usize;
183 debug_assert!(class < NUM_SIZE_CLASSES);
184 snapshot.size_class_occupancy[class].empty_pages += 1;
185 }
186 current = page.next_page;
187 }
188}