Skip to main content

concinnity_memory/
detail.rs

1// concinnity-memory/src/detail.rs
2//
3// Where the churn is: live blocks and lifetime allocation counts per size
4// class, behind the `detail` cargo feature.
5//
6// This is the tier a shipped build does not pay for. The global counters and
7// the tagged ledger both stay on everywhere -- a console budget is fixed and the
8// engine's own streaming already makes residency decisions from byte budgets --
9// but a per-allocation histogram is a development instrument, so the feature is
10// off unless a dev binary asks for it.
11//
12// Size classes rather than call sites: a class is one shift on the allocation
13// path, where a call site would mean capturing a stack, and a class already
14// answers the questions that matter at a glance. A live count that climbs and
15// never comes back down is a leak in that class; a lifetime count far above the
16// live one is churn.
17//
18// The reading side is always present and reports `None` when the feature is
19// off, so nothing downstream has to be compiled twice.
20
21// Class `c` above zero holds allocations of `2^(c-1) .. 2^c - 1` bytes. The top
22// class is a catch-all, so a 64-bit size cannot run off the end of the table.
23pub(crate) const CLASS_COUNT: usize = 33;
24
25/// One size class as read at a moment.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27pub struct SizeClass {
28    /// Smallest allocation size in this class, in bytes.
29    pub min_bytes: u64,
30    /// Inclusive. `u64::MAX` in the catch-all top class.
31    pub max_bytes: u64,
32    /// Allocations of this size made since process start.
33    pub allocs: u64,
34    /// Blocks of this size allocated and not yet freed.
35    pub live_blocks: u64,
36}
37
38// Every size class, read in one pass.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct SizeClasses {
41    classes: [SizeClass; CLASS_COUNT],
42}
43
44impl SizeClasses {
45    pub fn classes(&self) -> &[SizeClass; CLASS_COUNT] {
46        &self.classes
47    }
48
49    // The class holding the most live blocks: where the heap's population sits,
50    // and where a leak shows first. `None` when nothing is live.
51    pub fn busiest(&self) -> Option<SizeClass> {
52        self.classes
53            .iter()
54            .copied()
55            .filter(|c| c.live_blocks > 0)
56            .max_by_key(|c| c.live_blocks)
57    }
58
59    pub fn live_blocks(&self) -> u64 {
60        self.classes.iter().map(|c| c.live_blocks).sum()
61    }
62}
63
64// The class an allocation of `size` falls in.
65#[cfg(any(feature = "detail", test))]
66const fn class_of(size: usize) -> usize {
67    let bits = (usize::BITS - size.leading_zeros()) as usize;
68    if bits >= CLASS_COUNT {
69        CLASS_COUNT - 1
70    } else {
71        bits
72    }
73}
74
75// The byte range class `class` covers, inclusive.
76#[cfg(any(feature = "detail", test))]
77const fn class_bounds(class: usize) -> (u64, u64) {
78    match class {
79        0 => (0, 0),
80        c if c == CLASS_COUNT - 1 => (1 << (CLASS_COUNT - 2), u64::MAX),
81        c => (1 << (c - 1), (1 << c) - 1),
82    }
83}
84
85#[cfg(not(feature = "detail"))]
86mod imp {
87    pub(crate) fn record_alloc(_size: usize) {}
88    pub(crate) fn record_free(_size: usize) {}
89    pub(crate) fn record_realloc(_old_size: usize, _new_size: usize) {}
90    pub(crate) fn snapshot() -> Option<super::SizeClasses> {
91        None
92    }
93}
94
95#[cfg(feature = "detail")]
96mod imp {
97    use core::sync::atomic::{AtomicU64, Ordering::Relaxed};
98
99    use super::{CLASS_COUNT, SizeClass, SizeClasses, class_bounds, class_of};
100
101    struct Class {
102        allocs: AtomicU64,
103        live: AtomicU64,
104    }
105
106    impl Class {
107        const fn new() -> Self {
108            Self {
109                allocs: AtomicU64::new(0),
110                live: AtomicU64::new(0),
111            }
112        }
113    }
114
115    static CLASSES: [Class; CLASS_COUNT] = [const { Class::new() }; CLASS_COUNT];
116
117    pub(crate) fn record_alloc(size: usize) {
118        let class = &CLASSES[class_of(size)];
119        class.allocs.fetch_add(1, Relaxed);
120        class.live.fetch_add(1, Relaxed);
121    }
122
123    pub(crate) fn record_free(size: usize) {
124        let live = &CLASSES[class_of(size)].live;
125        let _ = live.fetch_update(Relaxed, Relaxed, |n| Some(n.saturating_sub(1)));
126    }
127
128    // A resize moves a block between classes without being an allocation of its
129    // own, matching how the global counters treat it.
130    pub(crate) fn record_realloc(old_size: usize, new_size: usize) {
131        let (old, new) = (class_of(old_size), class_of(new_size));
132        if old != new {
133            record_free(old_size);
134            CLASSES[new].live.fetch_add(1, Relaxed);
135        }
136    }
137
138    pub(crate) fn snapshot() -> Option<SizeClasses> {
139        Some(SizeClasses {
140            classes: core::array::from_fn(|i| {
141                let (min_bytes, max_bytes) = class_bounds(i);
142                SizeClass {
143                    min_bytes,
144                    max_bytes,
145                    allocs: CLASSES[i].allocs.load(Relaxed),
146                    live_blocks: CLASSES[i].live.load(Relaxed),
147                }
148            }),
149        })
150    }
151}
152
153pub(crate) use imp::{record_alloc, record_free, record_realloc};
154
155/// The heap's size-class histogram, or `None` when the crate was built without
156/// the `detail` feature.
157pub fn size_classes() -> Option<SizeClasses> {
158    imp::snapshot()
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn classes_partition_the_size_range_without_gaps_or_overlap() {
167        let (mut prev_min, mut prev_max) = class_bounds(0);
168        assert_eq!((prev_min, prev_max), (0, 0));
169        for class in 1..CLASS_COUNT {
170            let (min, max) = class_bounds(class);
171            assert_eq!(min, prev_max + 1, "class {class} does not follow the last");
172            assert!(max >= min);
173            (prev_min, prev_max) = (min, max);
174        }
175        assert!(prev_min > 0);
176        assert_eq!(prev_max, u64::MAX, "the top class must catch every size");
177    }
178
179    #[test]
180    fn a_size_lands_in_the_class_that_covers_it() {
181        for size in [0usize, 1, 2, 3, 4, 7, 8, 64, 1023, 1024, 1 << 20] {
182            let class = class_of(size);
183            let (min, max) = class_bounds(class);
184            assert!(
185                (min..=max).contains(&(size as u64)),
186                "{size} landed outside class {class} ({min}..={max})"
187            );
188        }
189    }
190
191    #[test]
192    fn an_enormous_size_lands_in_the_catch_all_class() {
193        assert_eq!(class_of(usize::MAX), CLASS_COUNT - 1);
194        assert_eq!(class_of(1 << 40), CLASS_COUNT - 1);
195    }
196
197    // Without the feature the instrument is absent, not zeroed: a readout must
198    // be able to tell "not measured" from "measured nothing".
199    #[cfg(not(feature = "detail"))]
200    #[test]
201    fn the_histogram_is_absent_without_the_feature() {
202        record_alloc(64);
203        assert_eq!(size_classes(), None);
204    }
205
206    // The counters are process-global, so this asserts on deltas.
207    #[cfg(feature = "detail")]
208    #[test]
209    fn allocations_and_frees_move_their_class() {
210        const SIZE: usize = 1 << 17;
211        let class = class_of(SIZE);
212        let before = size_classes().expect("the feature is on").classes[class];
213
214        record_alloc(SIZE);
215        let during = size_classes().expect("the feature is on").classes[class];
216        assert_eq!(during.allocs, before.allocs + 1);
217        assert_eq!(during.live_blocks, before.live_blocks + 1);
218
219        record_free(SIZE);
220        let after = size_classes().expect("the feature is on").classes[class];
221        assert_eq!(after.live_blocks, before.live_blocks);
222        assert_eq!(after.allocs, before.allocs + 1, "a free is not an alloc");
223    }
224
225    // A resize moves the block's live count between classes and counts as no
226    // new allocation.
227    #[cfg(feature = "detail")]
228    #[test]
229    fn a_resize_moves_the_block_between_classes() {
230        const SMALL: usize = 1 << 13;
231        const LARGE: usize = 1 << 19;
232        let (small, large) = (class_of(SMALL), class_of(LARGE));
233        let before = size_classes().expect("the feature is on");
234
235        record_alloc(SMALL);
236        record_realloc(SMALL, LARGE);
237        let after = size_classes().expect("the feature is on");
238
239        assert_eq!(
240            after.classes[small].live_blocks,
241            before.classes[small].live_blocks
242        );
243        assert_eq!(
244            after.classes[large].live_blocks,
245            before.classes[large].live_blocks + 1
246        );
247        assert_eq!(after.classes[large].allocs, before.classes[large].allocs);
248
249        record_free(LARGE);
250    }
251
252    #[cfg(feature = "detail")]
253    #[test]
254    fn the_busiest_class_is_the_one_holding_the_most_live_blocks() {
255        const SIZE: usize = 1 << 9;
256        // Enough to outweigh whatever the test harness itself is holding.
257        let target = size_classes().expect("the feature is on").live_blocks() + 1;
258        for _ in 0..target {
259            record_alloc(SIZE);
260        }
261
262        let busiest = size_classes()
263            .expect("the feature is on")
264            .busiest()
265            .expect("blocks are live");
266        assert_eq!(busiest.min_bytes, class_bounds(class_of(SIZE)).0);
267
268        for _ in 0..target {
269            record_free(SIZE);
270        }
271    }
272}