Skip to main content

concinnity_core/memory/
counters.rs

1// The tracked heap's global counters, sharded so concurrent allocation does not
2// serialize on one cache line.
3//
4// A job system has every worker allocating at once, and a single atomic block
5// turns each allocation into a contended read-modify-write on the same line. The
6// counters are split across cache-line-sized shards picked by the calling
7// thread, and summed only when something reads them.
8//
9// Threads are told apart by a stack address: every thread has its own stack, and
10// reading one costs nothing. Thread-local storage would say it directly but is
11// not available in a `no_std` crate, and is unwelcome underneath a global
12// allocator in any case -- its lazy initialization allocates, which re-enters
13// the allocator. The mapping is approximate: a deep call stack can land a thread
14// in a neighbouring shard, and a block allocated on one thread is often freed on
15// another. `live` is therefore a wrapping counter, which leaves the individual
16// shards free to disagree and their sum exact -- and the sum is the only figure
17// anyone reads.
18
19use core::sync::atomic::{AtomicUsize, Ordering::Relaxed};
20
21// Enough shards to keep a workstation's workers off each other's lines without
22// making a read walk a large table.
23const SHARDS: usize = 16;
24const SHARD_BITS: u32 = SHARDS.trailing_zeros();
25
26// Stack addresses separate at 64 KiB granularity: coarse enough that a thread
27// keeps its shard as its stack grows and shrinks, fine enough that threads whose
28// stacks sit close together still land apart.
29const STACK_SHIFT: u32 = 16;
30
31// How often an allocating thread re-sums the shards to refresh the peak.
32// Amortized over a thousand allocations the walk costs nothing, and a rise built
33// from small allocations always carries enough of them to be sampled.
34const PEAK_SAMPLE_ALLOCS: usize = 1024;
35
36// A single allocation this large moves the total by itself, so it refreshes the
37// peak on the spot rather than waiting for the count to come round.
38const PEAK_SAMPLE_BYTES: usize = 1 << 20;
39
40/// A snapshot of the tracked heap. Counters are relaxed, so the fields are
41/// individually accurate but need not agree with each other to the byte under
42/// concurrent allocation.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
44pub struct MemStats {
45    /// Bytes currently allocated and not yet freed.
46    pub live_bytes: u64,
47    /// High-water mark of `live_bytes`, sampled as the shards are re-summed.
48    pub peak_bytes: u64,
49    /// Allocations made since process start. With `free_count`, this is the
50    /// churn rate; a reallocation resizes an existing block and counts as
51    /// neither.
52    pub alloc_count: u64,
53    /// Frees made since process start.
54    pub free_count: u64,
55}
56
57// One shard's counters, padded to a cache line so a thread updating its own
58// shard never invalidates another's.
59#[repr(align(64))]
60struct Shard {
61    live: AtomicUsize,
62    allocs: AtomicUsize,
63    frees: AtomicUsize,
64}
65
66impl Shard {
67    const fn new() -> Self {
68        Self {
69            live: AtomicUsize::new(0),
70            allocs: AtomicUsize::new(0),
71            frees: AtomicUsize::new(0),
72        }
73    }
74}
75
76// The counter block behind the global allocator. Split out from `TrackingAlloc`
77// so the accounting is testable on its own instance: the allocator itself can
78// only ever drive the one process-global block.
79pub(crate) struct Counters {
80    shards: [Shard; SHARDS],
81    peak: AtomicUsize,
82}
83
84impl Counters {
85    pub(crate) const fn new() -> Self {
86        Self {
87            shards: [const { Shard::new() }; SHARDS],
88            peak: AtomicUsize::new(0),
89        }
90    }
91
92    fn shard(&self) -> &Shard {
93        &self.shards[current_shard()]
94    }
95
96    pub(crate) fn record_alloc(&self, size: usize) {
97        let shard = self.shard();
98        shard.live.fetch_add(size, Relaxed);
99        let allocs = shard.allocs.fetch_add(1, Relaxed).wrapping_add(1);
100        if size >= PEAK_SAMPLE_BYTES || allocs.is_multiple_of(PEAK_SAMPLE_ALLOCS) {
101            self.refresh_peak();
102        }
103    }
104
105    pub(crate) fn record_free(&self, size: usize) {
106        let shard = self.shard();
107        shard.live.fetch_sub(size, Relaxed);
108        shard.frees.fetch_add(1, Relaxed);
109    }
110
111    // A resize moves `live` by the delta only: the block was already counted at
112    // `old_size` and no alloc/free pair reaches the counters.
113    pub(crate) fn record_realloc(&self, old_size: usize, new_size: usize) {
114        let shard = self.shard();
115        if new_size >= old_size {
116            let grew = new_size - old_size;
117            shard.live.fetch_add(grew, Relaxed);
118            if grew >= PEAK_SAMPLE_BYTES {
119                self.refresh_peak();
120            }
121        } else {
122            shard.live.fetch_sub(old_size - new_size, Relaxed);
123        }
124    }
125
126    // Live bytes across every shard. Wrapping, because a block freed on a
127    // different thread than it was allocated on decrements a shard that never
128    // counted it; only the total is meaningful.
129    fn live(&self) -> usize {
130        self.shards
131            .iter()
132            .fold(0usize, |sum, s| sum.wrapping_add(s.live.load(Relaxed)))
133    }
134
135    fn refresh_peak(&self) -> usize {
136        let live = self.live();
137        self.peak.fetch_max(live, Relaxed);
138        live
139    }
140
141    // How many shards have counted anything. The claim a stack probe makes is
142    // that real threads land apart; this is what lets a test check it.
143    #[cfg(test)]
144    fn touched_shards(&self) -> usize {
145        self.shards
146            .iter()
147            .filter(|s| s.allocs.load(Relaxed) > 0)
148            .count()
149    }
150
151    // Allocations counted by this block since process start, without the peak
152    // refresh a full `snapshot` pays. Cheap enough to sample around every
153    // system step; `None` under the same condition as `snapshot`.
154    pub(crate) fn alloc_count(&self) -> Option<u64> {
155        let count = self
156            .shards
157            .iter()
158            .fold(0u64, |sum, s| sum + s.allocs.load(Relaxed) as u64);
159        (count > 0).then_some(count)
160    }
161
162    // `None` until something allocates through this block, which for the global
163    // block means "no binary installed the allocator".
164    pub(crate) fn snapshot(&self) -> Option<MemStats> {
165        let (alloc_count, free_count) = self.shards.iter().fold((0u64, 0u64), |(a, f), s| {
166            (
167                a + s.allocs.load(Relaxed) as u64,
168                f + s.frees.load(Relaxed) as u64,
169            )
170        });
171        if alloc_count == 0 {
172            return None;
173        }
174        let live = self.refresh_peak();
175        Some(MemStats {
176            live_bytes: live as u64,
177            peak_bytes: self.peak.load(Relaxed) as u64,
178            alloc_count,
179            free_count,
180        })
181    }
182}
183
184impl Default for Counters {
185    fn default() -> Self {
186        Self::new()
187    }
188}
189
190// Which shard an address on the calling thread's stack belongs to.
191//
192// Hashed rather than masked. Thread stacks are laid out at a fixed stride, and
193// a stride that is a multiple of the table size would put every thread on one
194// shard -- which is the failure this whole file exists to avoid. Multiplying by
195// the golden ratio and keeping the high bits spreads any stride.
196const fn shard_of(stack_addr: usize) -> usize {
197    const GOLDEN: u64 = 0x9E37_79B9_7F4A_7C15;
198    let key = (stack_addr >> STACK_SHIFT) as u64;
199    (key.wrapping_mul(GOLDEN) >> (u64::BITS - SHARD_BITS)) as usize
200}
201
202// The calling thread's shard. `black_box` keeps the probe a real stack slot
203// rather than something the optimizer folds away, and the probe is
204// uninitialized because only its address is ever read.
205fn current_shard() -> usize {
206    let probe = core::mem::MaybeUninit::<u8>::uninit();
207    shard_of(core::hint::black_box(probe.as_ptr()) as usize)
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use std::vec::Vec;
214
215    // A fresh block has never seen an allocation, which is how a readout tells
216    // "not installed" from "installed and holding nothing".
217    #[test]
218    fn snapshot_is_none_until_something_allocates() {
219        let counters = Counters::new();
220        assert_eq!(counters.snapshot(), None);
221
222        counters.record_alloc(64);
223        assert!(counters.snapshot().is_some());
224    }
225
226    // The cheap count agrees with the full snapshot and shares its
227    // None-until-installed probe, so a delta reader can rely on either.
228    #[test]
229    fn alloc_count_matches_the_snapshot() {
230        let counters = Counters::new();
231        assert_eq!(counters.alloc_count(), None);
232
233        counters.record_alloc(64);
234        counters.record_alloc(32);
235        counters.record_free(64);
236        let stats = counters.snapshot().expect("block has seen allocations");
237        assert_eq!(counters.alloc_count(), Some(stats.alloc_count));
238        assert_eq!(counters.alloc_count(), Some(2));
239    }
240
241    #[test]
242    fn alloc_and_free_balance_back_to_zero() {
243        let counters = Counters::new();
244        counters.record_alloc(1024);
245        counters.record_alloc(512);
246        counters.record_free(1024);
247        counters.record_free(512);
248
249        let stats = counters.snapshot().expect("block has seen allocations");
250        assert_eq!(stats.live_bytes, 0);
251        assert_eq!(stats.alloc_count, 2);
252        assert_eq!(stats.free_count, 2);
253    }
254
255    // Peak is a high-water mark: it holds the largest live total seen, not the
256    // current one. Reading is one of the moments it is sampled, so a readout
257    // never reports a peak below the live bytes beside it.
258    #[test]
259    fn peak_holds_the_high_water_mark() {
260        let counters = Counters::new();
261        counters.record_alloc(1000);
262        counters.record_alloc(500);
263        assert_eq!(counters.snapshot().unwrap().live_bytes, 1500);
264
265        counters.record_free(1200);
266        let stats = counters.snapshot().expect("block has seen allocations");
267        assert_eq!(stats.live_bytes, 300);
268        assert_eq!(stats.peak_bytes, 1500);
269    }
270
271    // A single large allocation refreshes the peak where it happens, so a block
272    // taken and dropped between two reads is still seen.
273    #[test]
274    fn a_large_allocation_refreshes_the_peak_where_it_happens() {
275        let counters = Counters::new();
276        counters.record_alloc(PEAK_SAMPLE_BYTES);
277        counters.record_free(PEAK_SAMPLE_BYTES);
278
279        let stats = counters.snapshot().expect("block has seen allocations");
280        assert_eq!(stats.live_bytes, 0);
281        assert_eq!(stats.peak_bytes as usize, PEAK_SAMPLE_BYTES);
282    }
283
284    #[test]
285    fn realloc_moves_live_bytes_by_the_delta_only() {
286        let counters = Counters::new();
287        counters.record_alloc(100);
288
289        counters.record_realloc(100, 400);
290        assert_eq!(counters.snapshot().unwrap().live_bytes, 400);
291
292        counters.record_realloc(400, 250);
293        let stats = counters.snapshot().unwrap();
294        assert_eq!(stats.live_bytes, 250);
295        assert_eq!(stats.peak_bytes, 400);
296        // The resizes were neither allocations nor frees.
297        assert_eq!(stats.alloc_count, 1);
298        assert_eq!(stats.free_count, 0);
299    }
300
301    // Threads must spread across the table whatever stride the platform lays
302    // their stacks out at. Perfect separation is not the claim -- shards are a
303    // hash, and collisions only cost contention -- but collapsing a whole
304    // process onto one shard is the failure this file exists to avoid, and a
305    // masked index does exactly that at any stride that divides the table.
306    #[test]
307    fn threads_spread_across_the_table_at_every_plausible_stack_stride() {
308        const KIB: usize = 1024;
309        for stride in [64 * KIB, 512 * KIB, 1 << 20, 2 << 20, 8 << 20] {
310            let shards: std::collections::BTreeSet<usize> = (0..SHARDS)
311                .map(|t| shard_of(0x7000_0000_0000 + t * stride))
312                .collect();
313            assert!(
314                shards.len() >= SHARDS / 2,
315                "{SHARDS} stacks {stride} bytes apart used only {} of {SHARDS} shards",
316                shards.len()
317            );
318        }
319    }
320
321    // A thread keeps its shard as its stack grows and shrinks, so its counters
322    // stay on the line its own core already holds.
323    #[test]
324    fn one_stack_keeps_its_shard_as_it_grows() {
325        let base = 0x7000_0000_0000usize;
326        for depth in [0, 1, 64, 4096, (1 << STACK_SHIFT) - 1] {
327            assert_eq!(shard_of(base), shard_of(base + depth));
328        }
329    }
330
331    #[test]
332    fn every_address_maps_into_the_shard_table() {
333        for addr in [0usize, 1, usize::MAX, 0x7fff_ffff_ffff, 1 << 47] {
334            assert!(shard_of(addr) < SHARDS);
335        }
336    }
337
338    // The whole point of sharding: threads counting at once must still sum to
339    // the exact total, including blocks freed on a thread other than the one
340    // that allocated them.
341    #[test]
342    fn concurrent_threads_sum_to_the_exact_total() {
343        use std::sync::Arc;
344        use std::thread;
345
346        const THREADS: usize = 8;
347        const PER_THREAD: usize = 4096;
348        const SIZE: usize = 128;
349
350        let counters = Arc::new(Counters::new());
351        let handles: Vec<_> = (0..THREADS)
352            .map(|_| {
353                let counters = Arc::clone(&counters);
354                thread::spawn(move || {
355                    for _ in 0..PER_THREAD {
356                        counters.record_alloc(SIZE);
357                    }
358                })
359            })
360            .collect();
361        for h in handles {
362            h.join().expect("counting thread");
363        }
364
365        let stats = counters.snapshot().expect("threads allocated");
366        assert_eq!(stats.alloc_count as usize, THREADS * PER_THREAD);
367        assert_eq!(stats.live_bytes as usize, THREADS * PER_THREAD * SIZE);
368        assert!(
369            counters.touched_shards() > 1,
370            "every thread landed on one shard, which is the contention sharding removes"
371        );
372
373        // Free every block from threads that never allocated one, which is what
374        // makes the per-shard counters disagree and the sum still hold.
375        let handles: Vec<_> = (0..THREADS)
376            .map(|_| {
377                let counters = Arc::clone(&counters);
378                thread::spawn(move || {
379                    for _ in 0..PER_THREAD {
380                        counters.record_free(SIZE);
381                    }
382                })
383            })
384            .collect();
385        for h in handles {
386            h.join().expect("freeing thread");
387        }
388
389        let stats = counters.snapshot().expect("threads allocated");
390        assert_eq!(stats.live_bytes, 0);
391        assert_eq!(stats.free_count as usize, THREADS * PER_THREAD);
392        assert_eq!(stats.peak_bytes as usize, THREADS * PER_THREAD * SIZE);
393    }
394}