Skip to main content

mnemosyne/
counting.rs

1//! Per-thread allocation counting for allocator-budget tests.
2//!
3//! [`CountingAllocator`] wraps any [`GlobalAlloc`] and records, per thread, how
4//! many allocations, reallocations and deallocations that thread performed and
5//! how many bytes they moved. [`measure`] runs a closure and returns the
6//! counts the calling thread produced while it ran.
7//!
8//! A process-wide counter is the wrong instrument for a test that asserts an
9//! exact allocation budget: the test harness runs the test body on a spawned
10//! thread while its main thread keeps allocating for its own bookkeeping, and
11//! parallel tests allocate concurrently. Both land inside a process-wide
12//! window and fail it for reasons unrelated to the code under test. Counting
13//! per thread removes both sources without serializing the test run.
14//!
15//! The counters are not part of the wrapped allocator, so wrapping
16//! [`Mnemosyne`](crate::Mnemosyne) measures exactly the allocator a program
17//! ships with.
18//!
19//! # Semantics
20//!
21//! - **Per-thread.** Each thread has its own counters and its own set of open
22//!   windows. [`measure`] reads only the calling thread's counters, so an
23//!   allocation made by any other thread — including one the measured body
24//!   waits on — never enters the window. There is no cross-thread total.
25//! - **Reentrancy.** Recording touches only const-initialized thread-local
26//!   `Cell`s: it allocates nothing, locks nothing, and cannot re-enter the
27//!   allocator it instruments, so it is safe on every allocator call path,
28//!   including one reached from inside another allocation. Windows nest: an
29//!   inner [`measure`] composes into the outer window's counts, and if the
30//!   measured body panics, the window is closed as the panic unwinds past
31//!   it, so no later window on that thread is affected.
32//! - **Thread exit.** The counters live in thread-local storage with no drop
33//!   glue and are discarded with the thread. A thread that exits with a
34//!   window still open (only possible by unwinding out of it) takes its
35//!   counts with it; nothing is reported anywhere else, and a later thread
36//!   starts from zero.
37//! - **When not measuring.** Recording is gated on the calling thread having
38//!   a [`measure`] window open. Outside one, an allocator call through the
39//!   wrapper adds one thread-local load and one branch and no stores, so
40//!   unmeasured traffic pays no counter traffic. A binary that does not
41//!   install the wrapper at all pays literally nothing: the counter type is
42//!   the size of the allocator it wraps, and production binaries install
43//!   their allocator unwrapped.
44//!
45//! # Example
46//!
47//! ```
48//! use mnemosyne::Mnemosyne;
49//! use mnemosyne::counting::{CountingAllocator, measure};
50//!
51//! #[global_allocator]
52//! static ALLOCATOR: CountingAllocator<Mnemosyne> = CountingAllocator::new(Mnemosyne);
53//!
54//! fn main() {
55//!     let (boxed, delta) = measure(|| std::hint::black_box(Box::new(7_u64)));
56//!     assert_eq!(*boxed, 7);
57//!     assert_eq!(delta.allocations, 1);
58//!     assert_eq!(delta.bytes_allocated, 8);
59//!     assert_eq!(delta.deallocations, 0);
60//! }
61//! ```
62
63use core::alloc::{GlobalAlloc, Layout};
64use core::cell::Cell;
65
66/// Counts of allocator calls and the bytes they moved.
67///
68/// Every field is an exact tally of what the calling thread did, modulo
69/// `usize::MAX + 1` (the counters wrap rather than panic, because a panic
70/// inside a global allocator aborts the process). A window that moves fewer
71/// than `usize::MAX` bytes reads exactly.
72///
73/// `Default` is the empty delta, the value a window that never touched the
74/// heap reads.
75#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
76pub struct AllocationDelta {
77    /// Successful `alloc` and `alloc_zeroed` calls.
78    pub allocations: usize,
79    /// `dealloc` calls.
80    pub deallocations: usize,
81    /// Successful `realloc` calls.
82    pub reallocations: usize,
83    /// Bytes acquired: the size of every successful allocation, plus the growth
84    /// of every successful `realloc` to a larger size.
85    pub bytes_allocated: usize,
86    /// Bytes released: the size of every deallocated block, plus the shrinkage
87    /// of every successful `realloc` to a smaller size.
88    pub bytes_deallocated: usize,
89}
90
91impl AllocationDelta {
92    /// Net heap growth over the window: [`Self::bytes_allocated`] minus
93    /// [`Self::bytes_deallocated`], signed.
94    ///
95    /// Positive when the window retained memory, negative when it released
96    /// memory acquired before the window began, zero when it was balanced.
97    /// Exact while the magnitude is below `isize::MAX`.
98    #[must_use]
99    pub const fn bytes_retained(&self) -> isize {
100        self.bytes_allocated
101            .wrapping_sub(self.bytes_deallocated)
102            .cast_signed()
103    }
104
105    /// Counts accumulated since `earlier`, a snapshot taken on the same thread.
106    const fn since(&self, earlier: &Self) -> Self {
107        Self {
108            allocations: self.allocations.wrapping_sub(earlier.allocations),
109            deallocations: self.deallocations.wrapping_sub(earlier.deallocations),
110            reallocations: self.reallocations.wrapping_sub(earlier.reallocations),
111            bytes_allocated: self.bytes_allocated.wrapping_sub(earlier.bytes_allocated),
112            bytes_deallocated: self
113                .bytes_deallocated
114                .wrapping_sub(earlier.bytes_deallocated),
115        }
116    }
117}
118
119/// One thread's running totals. Plain `Cell`s: only the owning thread touches
120/// them, so no synchronization exists to cost anything or to recurse.
121///
122/// `open_windows` is the recording gate: [`measure`] raises it for the
123/// duration of its window (nested windows raise it further), and the
124/// `record_*` functions touch the tallies only while it is non-zero. Traffic
125/// outside any window therefore costs one load and one branch per allocator
126/// call and no stores.
127struct Counters {
128    allocations: Cell<usize>,
129    deallocations: Cell<usize>,
130    reallocations: Cell<usize>,
131    bytes_allocated: Cell<usize>,
132    bytes_deallocated: Cell<usize>,
133    open_windows: Cell<usize>,
134}
135
136impl Counters {
137    const fn new() -> Self {
138        Self {
139            allocations: Cell::new(0),
140            deallocations: Cell::new(0),
141            reallocations: Cell::new(0),
142            bytes_allocated: Cell::new(0),
143            bytes_deallocated: Cell::new(0),
144            open_windows: Cell::new(0),
145        }
146    }
147
148    fn totals(&self) -> AllocationDelta {
149        AllocationDelta {
150            allocations: self.allocations.get(),
151            deallocations: self.deallocations.get(),
152            reallocations: self.reallocations.get(),
153            bytes_allocated: self.bytes_allocated.get(),
154            bytes_deallocated: self.bytes_deallocated.get(),
155        }
156    }
157}
158
159// The slot is const-initialized and its type has no drop glue, so reading it
160// runs no user code and never allocates through the global allocator, which
161// would re-enter the allocator being instrumented. It stays readable for the
162// whole life of the thread, for a different reason on each storage kind in the
163// standard library (`sys/thread_local`, Rust 1.97):
164//
165// - Native thread-local storage registers no destructor for a type without
166//   drop glue, so nothing ever marks the slot unreadable.
167// - The OS-key fallback registers a destructor for every thread-local and
168//   boxes the slot through `System`, not the global allocator, so first access
169//   does not re-enter it. The destructor marks the slot unreadable only while
170//   it drops the value, which has no glue and calls no method of this
171//   allocator, and resets the slot afterwards, so a read after teardown
172//   reinitializes it instead of failing.
173std::thread_local! {
174    static COUNTERS: Counters = const { Counters::new() };
175}
176
177fn bump(cell: &Cell<usize>, by: usize) {
178    cell.set(cell.get().wrapping_add(by));
179}
180
181fn record_alloc(size: usize) {
182    COUNTERS.with(|counters| {
183        if counters.open_windows.get() == 0 {
184            return;
185        }
186        bump(&counters.allocations, 1);
187        bump(&counters.bytes_allocated, size);
188    });
189}
190
191fn record_dealloc(size: usize) {
192    COUNTERS.with(|counters| {
193        if counters.open_windows.get() == 0 {
194            return;
195        }
196        bump(&counters.deallocations, 1);
197        bump(&counters.bytes_deallocated, size);
198    });
199}
200
201fn record_realloc(old_size: usize, new_size: usize) {
202    COUNTERS.with(|counters| {
203        if counters.open_windows.get() == 0 {
204            return;
205        }
206        bump(&counters.reallocations, 1);
207        if new_size >= old_size {
208            bump(&counters.bytes_allocated, new_size - old_size);
209        } else {
210            bump(&counters.bytes_deallocated, old_size - new_size);
211        }
212    });
213}
214
215/// One open [`measure`] window on the current thread. Dropping it closes the
216/// window, so a body that panics still restores the gate as it unwinds.
217struct OpenWindow;
218
219impl OpenWindow {
220    fn enter() -> Self {
221        COUNTERS.with(|counters| bump(&counters.open_windows, 1));
222        Self
223    }
224}
225
226impl Drop for OpenWindow {
227    fn drop(&mut self) {
228        COUNTERS.with(|counters| {
229            counters
230                .open_windows
231                .set(counters.open_windows.get().saturating_sub(1));
232        });
233    }
234}
235
236/// A [`GlobalAlloc`] that forwards to `A` and counts, per calling thread, what
237/// it forwarded.
238///
239/// Install it as the `#[global_allocator]` of the test binary whose
240/// allocation budget is under test, then read the counts with [`measure`].
241/// Only calls that succeed are counted: a null return from the inner
242/// allocator acquired nothing and is not an allocation.
243///
244/// Inside a [`measure`] window, counting costs two `Cell` updates on
245/// thread-local counters per call; outside one it costs one thread-local
246/// load and one branch, with no stores. The wrapper is exactly the size of
247/// `A`. It is a test instrument; production binaries install their allocator
248/// unwrapped and pay nothing.
249#[derive(Debug, Default)]
250pub struct CountingAllocator<A> {
251    inner: A,
252}
253
254impl<A> CountingAllocator<A> {
255    /// Wraps `inner`. `const`, so the wrapper can initialize a `static`.
256    #[must_use]
257    pub const fn new(inner: A) -> Self {
258        Self { inner }
259    }
260}
261
262// SAFETY: every method forwards to `A`, which upholds the `GlobalAlloc`
263// contract, with the caller's pointer and layout unmodified and returns its
264// result unmodified. The added effect is a `Cell` update on a const-initialized
265// thread-local, which neither allocates nor panics, so it cannot re-enter the
266// allocator or unwind out of it.
267unsafe impl<A: GlobalAlloc> GlobalAlloc for CountingAllocator<A> {
268    #[inline]
269    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
270        // SAFETY: the caller upholds `alloc`'s contract for `layout`, which is
271        // forwarded verbatim.
272        let ptr = unsafe { self.inner.alloc(layout) };
273        if !ptr.is_null() {
274            record_alloc(layout.size());
275        }
276        ptr
277    }
278
279    #[inline]
280    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
281        // SAFETY: the caller upholds `dealloc`'s contract: `ptr` was returned
282        // by this allocator for `layout`, and both are forwarded verbatim.
283        unsafe { self.inner.dealloc(ptr, layout) };
284        record_dealloc(layout.size());
285    }
286
287    #[inline]
288    unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
289        // SAFETY: the caller upholds `alloc_zeroed`'s contract for `layout`,
290        // which is forwarded verbatim so the inner allocator keeps its own
291        // zeroing path.
292        let ptr = unsafe { self.inner.alloc_zeroed(layout) };
293        if !ptr.is_null() {
294            record_alloc(layout.size());
295        }
296        ptr
297    }
298
299    #[inline]
300    unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
301        // SAFETY: the caller upholds `realloc`'s contract: `ptr` was returned
302        // by this allocator for `layout` and `new_size` is valid for its
303        // alignment, all forwarded verbatim so the inner allocator keeps its
304        // own in-place path.
305        let new_ptr = unsafe { self.inner.realloc(ptr, layout, new_size) };
306        if !new_ptr.is_null() {
307            record_realloc(layout.size(), new_size);
308        }
309        new_ptr
310    }
311}
312
313/// Runs `body` and returns its value with the allocator traffic the calling
314/// thread produced while it ran.
315///
316/// Allocations made by other threads are not counted, including threads
317/// `body` waits on. Allocations `body` makes to start a thread (its closure,
318/// its handle) are made by the calling thread and are counted.
319///
320/// Counts are read by difference, so windows nest: an outer window includes
321/// the traffic of an inner one. Counting happens only where a
322/// [`CountingAllocator`] is installed as the global allocator; without one
323/// every window reads empty.
324///
325/// # Example
326///
327/// ```
328/// use mnemosyne::counting::{AllocationDelta, CountingAllocator, measure};
329///
330/// #[global_allocator]
331/// static ALLOCATOR: CountingAllocator<std::alloc::System> =
332///     CountingAllocator::new(std::alloc::System);
333///
334/// fn main() {
335///     let mut buffer = Vec::<u8>::with_capacity(16);
336///     let ((), delta) = measure(|| buffer.extend_from_slice(&[0; 16]));
337///     assert_eq!(delta, AllocationDelta::default());
338///
339///     let ((), delta) = measure(|| buffer.extend_from_slice(&[0; 16]));
340///     assert_eq!(delta.reallocations, 1);
341///     assert_eq!(delta.bytes_allocated, buffer.capacity() - 16);
342/// }
343/// ```
344pub fn measure<R>(body: impl FnOnce() -> R) -> (R, AllocationDelta) {
345    // Open the window before the baseline snapshot so the snapshot itself is
346    // the first counted instant, and hold it in a guard so a panicking body
347    // closes it on unwind rather than leaving recording on for this thread.
348    let _window = OpenWindow::enter();
349    let before = COUNTERS.with(Counters::totals);
350    let value = body();
351    let after = COUNTERS.with(Counters::totals);
352    (value, after.since(&before))
353}
354
355#[cfg(test)]
356mod tests {
357    use super::{AllocationDelta, COUNTERS, Counters, measure, record_alloc};
358
359    fn totals() -> AllocationDelta {
360        COUNTERS.with(Counters::totals)
361    }
362
363    #[test]
364    fn recording_is_inert_outside_a_window() {
365        let before = totals();
366        record_alloc(24);
367        assert_eq!(totals(), before);
368
369        let ((), delta) = measure(|| record_alloc(24));
370        assert_eq!(
371            delta,
372            AllocationDelta {
373                allocations: 1,
374                bytes_allocated: 24,
375                ..AllocationDelta::default()
376            }
377        );
378
379        record_alloc(24);
380        assert_eq!(
381            totals(),
382            AllocationDelta {
383                allocations: before.allocations + 1,
384                bytes_allocated: before.bytes_allocated + 24,
385                ..before
386            }
387        );
388    }
389
390    #[test]
391    fn closing_an_inner_window_keeps_the_outer_one_recording() {
392        let ((), outer) = measure(|| {
393            let ((), inner) = measure(|| record_alloc(8));
394            assert_eq!(inner.allocations, 1);
395            record_alloc(8);
396        });
397        assert_eq!(outer.allocations, 2);
398        assert_eq!(outer.bytes_allocated, 16);
399    }
400
401    #[test]
402    fn a_panicking_body_closes_its_window() {
403        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
404            let ((), _) = measure(|| -> () { panic!("invariant: test body") });
405        }));
406        let Err(payload) = outcome else {
407            panic!("invariant: the panicking body unwinds out of measure");
408        };
409        assert_eq!(
410            payload.downcast_ref::<&str>(),
411            Some(&"invariant: test body")
412        );
413
414        let before = totals();
415        record_alloc(24);
416        assert_eq!(totals(), before);
417    }
418}