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    static CLASSES: [Class; CLASS_COUNT] = [const { Class::new() }; CLASS_COUNT];
114
115    pub(crate) fn record_alloc(size: usize) {
116        let class = &CLASSES[class_of(size)];
117        class.allocs.fetch_add(1, Relaxed);
118        class.live.fetch_add(1, Relaxed);
119    }
120
121    pub(crate) fn record_free(size: usize) {
122        let live = &CLASSES[class_of(size)].live;
123        let _ = live.fetch_update(Relaxed, Relaxed, |n| Some(n.saturating_sub(1)));
124    }
125
126    // A resize moves a block between classes without being an allocation of its
127    // own, matching how the global counters treat it.
128    pub(crate) fn record_realloc(old_size: usize, new_size: usize) {
129        let (old, new) = (class_of(old_size), class_of(new_size));
130        if old != new {
131            record_free(old_size);
132            CLASSES[new].live.fetch_add(1, Relaxed);
133        }
134    }
135
136    pub(crate) fn snapshot() -> Option<SizeClasses> {
137        Some(SizeClasses {
138            classes: core::array::from_fn(|i| {
139                let (min_bytes, max_bytes) = class_bounds(i);
140                SizeClass {
141                    min_bytes,
142                    max_bytes,
143                    allocs: CLASSES[i].allocs.load(Relaxed),
144                    live_blocks: CLASSES[i].live.load(Relaxed),
145                }
146            }),
147        })
148    }
149}
150
151pub(crate) use imp::{record_alloc, record_free, record_realloc};
152
153/// The heap's size-class histogram, or `None` when the crate was built without
154/// the `detail` feature.
155pub fn size_classes() -> Option<SizeClasses> {
156    imp::snapshot()
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn classes_partition_the_size_range_without_gaps_or_overlap() {
165        let (mut prev_min, mut prev_max) = class_bounds(0);
166        assert_eq!((prev_min, prev_max), (0, 0));
167        for class in 1..CLASS_COUNT {
168            let (min, max) = class_bounds(class);
169            assert_eq!(min, prev_max + 1, "class {class} does not follow the last");
170            assert!(max >= min);
171            (prev_min, prev_max) = (min, max);
172        }
173        assert!(prev_min > 0);
174        assert_eq!(prev_max, u64::MAX, "the top class must catch every size");
175    }
176
177    #[test]
178    fn a_size_lands_in_the_class_that_covers_it() {
179        for size in [0usize, 1, 2, 3, 4, 7, 8, 64, 1023, 1024, 1 << 20] {
180            let class = class_of(size);
181            let (min, max) = class_bounds(class);
182            assert!(
183                (min..=max).contains(&(size as u64)),
184                "{size} landed outside class {class} ({min}..={max})"
185            );
186        }
187    }
188
189    #[test]
190    fn an_enormous_size_lands_in_the_catch_all_class() {
191        assert_eq!(class_of(usize::MAX), CLASS_COUNT - 1);
192        assert_eq!(class_of(1 << 40), CLASS_COUNT - 1);
193    }
194
195    // Without the feature the instrument is absent, not zeroed: a readout must
196    // be able to tell "not measured" from "measured nothing".
197    #[cfg(not(feature = "detail"))]
198    #[test]
199    fn the_histogram_is_absent_without_the_feature() {
200        record_alloc(64);
201        assert_eq!(size_classes(), None);
202    }
203
204    // The counters are process-global, so this asserts on deltas.
205    #[cfg(feature = "detail")]
206    #[test]
207    fn allocations_and_frees_move_their_class() {
208        const SIZE: usize = 1 << 17;
209        let class = class_of(SIZE);
210        let before = size_classes().expect("the feature is on").classes[class];
211
212        record_alloc(SIZE);
213        let during = size_classes().expect("the feature is on").classes[class];
214        assert_eq!(during.allocs, before.allocs + 1);
215        assert_eq!(during.live_blocks, before.live_blocks + 1);
216
217        record_free(SIZE);
218        let after = size_classes().expect("the feature is on").classes[class];
219        assert_eq!(after.live_blocks, before.live_blocks);
220        assert_eq!(after.allocs, before.allocs + 1, "a free is not an alloc");
221    }
222
223    // A resize moves the block's live count between classes and counts as no
224    // new allocation.
225    #[cfg(feature = "detail")]
226    #[test]
227    fn a_resize_moves_the_block_between_classes() {
228        const SMALL: usize = 1 << 13;
229        const LARGE: usize = 1 << 19;
230        let (small, large) = (class_of(SMALL), class_of(LARGE));
231        let before = size_classes().expect("the feature is on");
232
233        record_alloc(SMALL);
234        record_realloc(SMALL, LARGE);
235        let after = size_classes().expect("the feature is on");
236
237        assert_eq!(
238            after.classes[small].live_blocks,
239            before.classes[small].live_blocks
240        );
241        assert_eq!(
242            after.classes[large].live_blocks,
243            before.classes[large].live_blocks + 1
244        );
245        assert_eq!(after.classes[large].allocs, before.classes[large].allocs);
246
247        record_free(LARGE);
248    }
249
250    #[cfg(feature = "detail")]
251    #[test]
252    fn the_busiest_class_is_the_one_holding_the_most_live_blocks() {
253        const SIZE: usize = 1 << 9;
254        // Enough to outweigh whatever the test harness itself is holding.
255        let target = size_classes().expect("the feature is on").live_blocks() + 1;
256        for _ in 0..target {
257            record_alloc(SIZE);
258        }
259
260        let busiest = size_classes()
261            .expect("the feature is on")
262            .busiest()
263            .expect("blocks are live");
264        assert_eq!(busiest.min_bytes, class_bounds(class_of(SIZE)).0);
265
266        for _ in 0..target {
267            record_free(SIZE);
268        }
269    }
270}