Skip to main content

concinnity_memory/
counters.rs

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