Skip to main content

concinnity_core/memory/
detail.rs

1// Where the churn is: live blocks and lifetime allocation counts per size
2// class, behind the `detail` cargo feature.
3//
4// This is the tier a shipped build does not pay for. The global counters and
5// the tagged ledger both stay on everywhere -- a console budget is fixed and the
6// engine's own streaming already makes residency decisions from byte budgets --
7// but a per-allocation histogram is a development instrument, so the feature is
8// off unless a dev binary asks for it.
9//
10// Size classes rather than call sites: a class is one shift on the allocation
11// path, where a call site would mean capturing a stack, and a class already
12// answers the questions that matter at a glance. A live count that climbs and
13// never comes back down is a leak in that class; a lifetime count far above the
14// live one is churn.
15//
16// The reading side is always present and reports `None` when the feature is
17// off, so nothing downstream has to be compiled twice.
18
19// Class `c` above zero holds allocations of `2^(c-1) .. 2^c - 1` bytes. The top
20// class is a catch-all, so a 64-bit size cannot run off the end of the table.
21pub(crate) const CLASS_COUNT: usize = 33;
22
23/// One size class as read at a moment.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub struct SizeClass {
26    /// Smallest allocation size in this class, in bytes.
27    pub min_bytes: u64,
28    /// Inclusive. `u64::MAX` in the catch-all top class.
29    pub max_bytes: u64,
30    /// Allocations of this size made since process start.
31    pub allocs: u64,
32    /// Blocks of this size allocated and not yet freed.
33    pub live_blocks: u64,
34}
35
36// Every size class, read in one pass.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct SizeClasses {
39    classes: [SizeClass; CLASS_COUNT],
40}
41
42impl SizeClasses {
43    pub fn classes(&self) -> &[SizeClass; CLASS_COUNT] {
44        &self.classes
45    }
46
47    // The class holding the most live blocks: where the heap's population sits,
48    // and where a leak shows first. `None` when nothing is live.
49    pub fn busiest(&self) -> Option<SizeClass> {
50        self.classes
51            .iter()
52            .copied()
53            .filter(|c| c.live_blocks > 0)
54            .max_by_key(|c| c.live_blocks)
55    }
56
57    pub fn live_blocks(&self) -> u64 {
58        self.classes.iter().map(|c| c.live_blocks).sum()
59    }
60}
61
62// The class an allocation of `size` falls in.
63#[cfg(any(feature = "detail", test))]
64const fn class_of(size: usize) -> usize {
65    let bits = (usize::BITS - size.leading_zeros()) as usize;
66    if bits >= CLASS_COUNT {
67        CLASS_COUNT - 1
68    } else {
69        bits
70    }
71}
72
73// The byte range class `class` covers, inclusive.
74#[cfg(any(feature = "detail", test))]
75const fn class_bounds(class: usize) -> (u64, u64) {
76    match class {
77        0 => (0, 0),
78        c if c == CLASS_COUNT - 1 => (1 << (CLASS_COUNT - 2), u64::MAX),
79        c => (1 << (c - 1), (1 << c) - 1),
80    }
81}
82
83#[cfg(not(feature = "detail"))]
84mod imp {
85    pub(crate) fn record_alloc(_size: usize) {}
86    pub(crate) fn record_free(_size: usize) {}
87    pub(crate) fn record_realloc(_old_size: usize, _new_size: usize) {}
88    pub(crate) fn snapshot() -> Option<super::SizeClasses> {
89        None
90    }
91}
92
93#[cfg(feature = "detail")]
94mod imp {
95    use core::sync::atomic::{AtomicU64, Ordering::Relaxed};
96
97    use super::{CLASS_COUNT, SizeClass, SizeClasses, class_bounds, class_of};
98
99    struct Class {
100        allocs: AtomicU64,
101        live: AtomicU64,
102    }
103
104    impl Class {
105        const fn new() -> Self {
106            Self {
107                allocs: AtomicU64::new(0),
108                live: AtomicU64::new(0),
109            }
110        }
111    }
112
113    // The histogram behind the global allocator. Split out from the free
114    // functions so the bookkeeping is testable on its own instance: the
115    // process-global one is fed by every thread at once, so a delta read across
116    // it is a race rather than a measurement.
117    pub(crate) struct Histogram {
118        classes: [Class; CLASS_COUNT],
119    }
120
121    impl Histogram {
122        pub(crate) const fn new() -> Self {
123            Self {
124                classes: [const { Class::new() }; CLASS_COUNT],
125            }
126        }
127
128        pub(crate) fn record_alloc(&self, size: usize) {
129            let class = &self.classes[class_of(size)];
130            class.allocs.fetch_add(1, Relaxed);
131            class.live.fetch_add(1, Relaxed);
132        }
133
134        pub(crate) fn record_free(&self, size: usize) {
135            let live = &self.classes[class_of(size)].live;
136            let _ = live.fetch_update(Relaxed, Relaxed, |n| Some(n.saturating_sub(1)));
137        }
138
139        // A resize moves a block between classes without being an allocation of
140        // its own, matching how the global counters treat it.
141        pub(crate) fn record_realloc(&self, old_size: usize, new_size: usize) {
142            let (old, new) = (class_of(old_size), class_of(new_size));
143            if old != new {
144                self.record_free(old_size);
145                self.classes[new].live.fetch_add(1, Relaxed);
146            }
147        }
148
149        pub(crate) fn snapshot(&self) -> SizeClasses {
150            SizeClasses {
151                classes: core::array::from_fn(|i| {
152                    let (min_bytes, max_bytes) = class_bounds(i);
153                    SizeClass {
154                        min_bytes,
155                        max_bytes,
156                        allocs: self.classes[i].allocs.load(Relaxed),
157                        live_blocks: self.classes[i].live.load(Relaxed),
158                    }
159                }),
160            }
161        }
162    }
163
164    static CLASSES: Histogram = Histogram::new();
165
166    pub(crate) fn record_alloc(size: usize) {
167        CLASSES.record_alloc(size);
168    }
169
170    pub(crate) fn record_free(size: usize) {
171        CLASSES.record_free(size);
172    }
173
174    pub(crate) fn record_realloc(old_size: usize, new_size: usize) {
175        CLASSES.record_realloc(old_size, new_size);
176    }
177
178    pub(crate) fn snapshot() -> Option<SizeClasses> {
179        Some(CLASSES.snapshot())
180    }
181}
182
183pub(crate) use imp::{record_alloc, record_free, record_realloc};
184
185/// The heap's size-class histogram, or `None` when the crate was built without
186/// the `detail` feature.
187pub fn size_classes() -> Option<SizeClasses> {
188    imp::snapshot()
189}
190
191#[cfg(test)]
192mod tests {
193    #[cfg(feature = "detail")]
194    use super::imp::Histogram;
195    use super::*;
196
197    #[test]
198    fn classes_partition_the_size_range_without_gaps_or_overlap() {
199        let (mut prev_min, mut prev_max) = class_bounds(0);
200        assert_eq!((prev_min, prev_max), (0, 0));
201        for class in 1..CLASS_COUNT {
202            let (min, max) = class_bounds(class);
203            assert_eq!(min, prev_max + 1, "class {class} does not follow the last");
204            assert!(max >= min);
205            (prev_min, prev_max) = (min, max);
206        }
207        assert!(prev_min > 0);
208        assert_eq!(prev_max, u64::MAX, "the top class must catch every size");
209    }
210
211    #[test]
212    fn a_size_lands_in_the_class_that_covers_it() {
213        for size in [0usize, 1, 2, 3, 4, 7, 8, 64, 1023, 1024, 1 << 20] {
214            let class = class_of(size);
215            let (min, max) = class_bounds(class);
216            assert!(
217                (min..=max).contains(&(size as u64)),
218                "{size} landed outside class {class} ({min}..={max})"
219            );
220        }
221    }
222
223    #[test]
224    fn an_enormous_size_lands_in_the_catch_all_class() {
225        assert_eq!(class_of(usize::MAX), CLASS_COUNT - 1);
226        assert_eq!(class_of(1 << 40), CLASS_COUNT - 1);
227    }
228
229    // Without the feature the instrument is absent, not zeroed: a readout must
230    // be able to tell "not measured" from "measured nothing".
231    #[cfg(not(feature = "detail"))]
232    #[test]
233    fn the_histogram_is_absent_without_the_feature() {
234        record_alloc(64);
235        assert_eq!(size_classes(), None);
236    }
237
238    #[cfg(feature = "detail")]
239    #[test]
240    fn allocations_and_frees_move_their_class() {
241        const SIZE: usize = 1 << 17;
242        let class = class_of(SIZE);
243        let histogram = Histogram::new();
244
245        histogram.record_alloc(SIZE);
246        let during = histogram.snapshot().classes[class];
247        assert_eq!(during.allocs, 1);
248        assert_eq!(during.live_blocks, 1);
249
250        histogram.record_free(SIZE);
251        let after = histogram.snapshot().classes[class];
252        assert_eq!(after.live_blocks, 0);
253        assert_eq!(after.allocs, 1, "a free is not an alloc");
254    }
255
256    // A free of a class that never allocated cannot take the live count below
257    // zero: the allocator sees frees of blocks older than the histogram.
258    #[cfg(feature = "detail")]
259    #[test]
260    fn a_free_without_a_matching_alloc_leaves_the_class_empty() {
261        const SIZE: usize = 1 << 11;
262        let histogram = Histogram::new();
263
264        histogram.record_free(SIZE);
265
266        let class = histogram.snapshot().classes[class_of(SIZE)];
267        assert_eq!(class.live_blocks, 0);
268        assert_eq!(class.allocs, 0);
269    }
270
271    // A resize moves the block's live count between classes and counts as no
272    // new allocation.
273    #[cfg(feature = "detail")]
274    #[test]
275    fn a_resize_moves_the_block_between_classes() {
276        const SMALL: usize = 1 << 13;
277        const LARGE: usize = 1 << 19;
278        let (small, large) = (class_of(SMALL), class_of(LARGE));
279        let histogram = Histogram::new();
280
281        histogram.record_alloc(SMALL);
282        histogram.record_realloc(SMALL, LARGE);
283        let after = histogram.snapshot();
284
285        assert_eq!(after.classes[small].live_blocks, 0);
286        assert_eq!(after.classes[large].live_blocks, 1);
287        assert_eq!(after.classes[large].allocs, 0, "a resize is not an alloc");
288        assert_eq!(after.classes[small].allocs, 1);
289    }
290
291    // A resize inside one class is not a move at all.
292    #[cfg(feature = "detail")]
293    #[test]
294    fn a_resize_within_a_class_leaves_the_counts_alone() {
295        const SMALL: usize = 1 << 13;
296        const LARGER: usize = (1 << 13) + 64;
297        let class = class_of(SMALL);
298        let histogram = Histogram::new();
299
300        histogram.record_alloc(SMALL);
301        histogram.record_realloc(SMALL, LARGER);
302
303        let after = histogram.snapshot().classes[class];
304        assert_eq!(after.live_blocks, 1);
305        assert_eq!(after.allocs, 1);
306    }
307
308    #[cfg(feature = "detail")]
309    #[test]
310    fn the_busiest_class_is_the_one_holding_the_most_live_blocks() {
311        const BUSY: usize = 1 << 9;
312        const QUIET: usize = 1 << 3;
313        let histogram = Histogram::new();
314
315        histogram.record_alloc(QUIET);
316        for _ in 0..3 {
317            histogram.record_alloc(BUSY);
318        }
319
320        let snapshot = histogram.snapshot();
321        let busiest = snapshot.busiest().expect("blocks are live");
322        assert_eq!(busiest.min_bytes, class_bounds(class_of(BUSY)).0);
323        assert_eq!(busiest.live_blocks, 3);
324        assert_eq!(snapshot.live_blocks(), 4);
325    }
326
327    #[cfg(feature = "detail")]
328    #[test]
329    fn an_empty_histogram_has_no_busiest_class() {
330        let histogram = Histogram::new();
331        assert_eq!(histogram.snapshot().busiest(), None);
332        assert_eq!(histogram.snapshot().live_blocks(), 0);
333    }
334
335    // The global histogram is the one the allocator feeds, and every other test
336    // thread feeds it too, so this asserts only what stays true under that
337    // traffic: a recorded allocation reaches it, and the lifetime count only
338    // ever climbs.
339    #[cfg(feature = "detail")]
340    #[test]
341    fn the_global_histogram_takes_what_the_allocator_records() {
342        const SIZE: usize = 1 << 17;
343        let class = class_of(SIZE);
344        let before = size_classes().expect("the feature is on").classes[class];
345
346        record_alloc(SIZE);
347        let after = size_classes().expect("the feature is on").classes[class];
348        assert!(after.allocs > before.allocs);
349
350        record_free(SIZE);
351    }
352
353    // The whole partition is readable as one array, which is what a readout
354    // walks: every class in ascending order, covering the range without a gap.
355    // Only a build carrying the histogram has one to read.
356    #[cfg(feature = "detail")]
357    #[test]
358    fn the_classes_are_readable_as_one_ascending_array() {
359        let snapshot = size_classes().expect("this build tracks size classes");
360        let classes = snapshot.classes();
361        assert_eq!(classes.len(), CLASS_COUNT);
362        for pair in classes.windows(2) {
363            assert!(
364                pair[0].max_bytes < pair[1].min_bytes || pair[0].max_bytes + 1 == pair[1].min_bytes,
365                "{:?} then {:?} do not meet",
366                pair[0],
367                pair[1]
368            );
369        }
370        assert_eq!(classes[CLASS_COUNT - 1].max_bytes, u64::MAX);
371    }
372}