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//! # Example
20//!
21//! ```
22//! use mnemosyne::Mnemosyne;
23//! use mnemosyne::counting::{CountingAllocator, measure};
24//!
25//! #[global_allocator]
26//! static ALLOCATOR: CountingAllocator<Mnemosyne> = CountingAllocator::new(Mnemosyne);
27//!
28//! fn main() {
29//!     let (boxed, delta) = measure(|| std::hint::black_box(Box::new(7_u64)));
30//!     assert_eq!(*boxed, 7);
31//!     assert_eq!(delta.allocations, 1);
32//!     assert_eq!(delta.bytes_allocated, 8);
33//!     assert_eq!(delta.deallocations, 0);
34//! }
35//! ```
36
37use core::alloc::{GlobalAlloc, Layout};
38use core::cell::Cell;
39
40/// Counts of allocator calls and the bytes they moved.
41///
42/// Every field is an exact tally of what the calling thread did, modulo
43/// `usize::MAX + 1` (the counters wrap rather than panic, because a panic
44/// inside a global allocator aborts the process). A window that moves fewer
45/// than `usize::MAX` bytes reads exactly.
46///
47/// `Default` is the empty delta, the value a window that never touched the
48/// heap reads.
49#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
50pub struct AllocationDelta {
51    /// Successful `alloc` and `alloc_zeroed` calls.
52    pub allocations: usize,
53    /// `dealloc` calls.
54    pub deallocations: usize,
55    /// Successful `realloc` calls.
56    pub reallocations: usize,
57    /// Bytes acquired: the size of every successful allocation, plus the growth
58    /// of every successful `realloc` to a larger size.
59    pub bytes_allocated: usize,
60    /// Bytes released: the size of every deallocated block, plus the shrinkage
61    /// of every successful `realloc` to a smaller size.
62    pub bytes_deallocated: usize,
63}
64
65impl AllocationDelta {
66    /// Net heap growth over the window: [`Self::bytes_allocated`] minus
67    /// [`Self::bytes_deallocated`], signed.
68    ///
69    /// Positive when the window retained memory, negative when it released
70    /// memory acquired before the window began, zero when it was balanced.
71    /// Exact while the magnitude is below `isize::MAX`.
72    #[must_use]
73    pub const fn bytes_retained(&self) -> isize {
74        self.bytes_allocated
75            .wrapping_sub(self.bytes_deallocated)
76            .cast_signed()
77    }
78
79    /// Counts accumulated since `earlier`, a snapshot taken on the same thread.
80    const fn since(&self, earlier: &Self) -> Self {
81        Self {
82            allocations: self.allocations.wrapping_sub(earlier.allocations),
83            deallocations: self.deallocations.wrapping_sub(earlier.deallocations),
84            reallocations: self.reallocations.wrapping_sub(earlier.reallocations),
85            bytes_allocated: self.bytes_allocated.wrapping_sub(earlier.bytes_allocated),
86            bytes_deallocated: self
87                .bytes_deallocated
88                .wrapping_sub(earlier.bytes_deallocated),
89        }
90    }
91}
92
93/// One thread's running totals. Plain `Cell`s: only the owning thread touches
94/// them, so no synchronization exists to cost anything or to recurse.
95struct Counters {
96    allocations: Cell<usize>,
97    deallocations: Cell<usize>,
98    reallocations: Cell<usize>,
99    bytes_allocated: Cell<usize>,
100    bytes_deallocated: Cell<usize>,
101}
102
103impl Counters {
104    const fn new() -> Self {
105        Self {
106            allocations: Cell::new(0),
107            deallocations: Cell::new(0),
108            reallocations: Cell::new(0),
109            bytes_allocated: Cell::new(0),
110            bytes_deallocated: Cell::new(0),
111        }
112    }
113
114    fn totals(&self) -> AllocationDelta {
115        AllocationDelta {
116            allocations: self.allocations.get(),
117            deallocations: self.deallocations.get(),
118            reallocations: self.reallocations.get(),
119            bytes_allocated: self.bytes_allocated.get(),
120            bytes_deallocated: self.bytes_deallocated.get(),
121        }
122    }
123}
124
125// The slot is const-initialized and its type has no drop glue, so reading it
126// runs no user code and never allocates through the global allocator, which
127// would re-enter the allocator being instrumented. It stays readable for the
128// whole life of the thread, for a different reason on each storage kind in the
129// standard library (`sys/thread_local`, Rust 1.97):
130//
131// - Native thread-local storage registers no destructor for a type without
132//   drop glue, so nothing ever marks the slot unreadable.
133// - The OS-key fallback registers a destructor for every thread-local and
134//   boxes the slot through `System`, not the global allocator, so first access
135//   does not re-enter it. The destructor marks the slot unreadable only while
136//   it drops the value, which has no glue and calls no method of this
137//   allocator, and resets the slot afterwards, so a read after teardown
138//   reinitializes it instead of failing.
139std::thread_local! {
140    static COUNTERS: Counters = const { Counters::new() };
141}
142
143fn bump(cell: &Cell<usize>, by: usize) {
144    cell.set(cell.get().wrapping_add(by));
145}
146
147fn record_alloc(size: usize) {
148    COUNTERS.with(|counters| {
149        bump(&counters.allocations, 1);
150        bump(&counters.bytes_allocated, size);
151    });
152}
153
154fn record_dealloc(size: usize) {
155    COUNTERS.with(|counters| {
156        bump(&counters.deallocations, 1);
157        bump(&counters.bytes_deallocated, size);
158    });
159}
160
161fn record_realloc(old_size: usize, new_size: usize) {
162    COUNTERS.with(|counters| {
163        bump(&counters.reallocations, 1);
164        if new_size >= old_size {
165            bump(&counters.bytes_allocated, new_size - old_size);
166        } else {
167            bump(&counters.bytes_deallocated, old_size - new_size);
168        }
169    });
170}
171
172/// A [`GlobalAlloc`] that forwards to `A` and counts, per calling thread, what
173/// it forwarded.
174///
175/// Install it as the `#[global_allocator]` of the test binary whose
176/// allocation budget is under test, then read the counts with [`measure`].
177/// Only calls that succeed are counted: a null return from the inner
178/// allocator acquired nothing and is not an allocation.
179///
180/// Counting costs two `Cell` updates on thread-local counters per call. It is
181/// a test instrument; production binaries install their allocator unwrapped.
182#[derive(Debug, Default)]
183pub struct CountingAllocator<A> {
184    inner: A,
185}
186
187impl<A> CountingAllocator<A> {
188    /// Wraps `inner`. `const`, so the wrapper can initialize a `static`.
189    #[must_use]
190    pub const fn new(inner: A) -> Self {
191        Self { inner }
192    }
193}
194
195// SAFETY: every method forwards to `A`, which upholds the `GlobalAlloc`
196// contract, with the caller's pointer and layout unmodified and returns its
197// result unmodified. The added effect is a `Cell` update on a const-initialized
198// thread-local, which neither allocates nor panics, so it cannot re-enter the
199// allocator or unwind out of it.
200unsafe impl<A: GlobalAlloc> GlobalAlloc for CountingAllocator<A> {
201    #[inline]
202    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
203        // SAFETY: the caller upholds `alloc`'s contract for `layout`, which is
204        // forwarded verbatim.
205        let ptr = unsafe { self.inner.alloc(layout) };
206        if !ptr.is_null() {
207            record_alloc(layout.size());
208        }
209        ptr
210    }
211
212    #[inline]
213    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
214        // SAFETY: the caller upholds `dealloc`'s contract: `ptr` was returned
215        // by this allocator for `layout`, and both are forwarded verbatim.
216        unsafe { self.inner.dealloc(ptr, layout) };
217        record_dealloc(layout.size());
218    }
219
220    #[inline]
221    unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
222        // SAFETY: the caller upholds `alloc_zeroed`'s contract for `layout`,
223        // which is forwarded verbatim so the inner allocator keeps its own
224        // zeroing path.
225        let ptr = unsafe { self.inner.alloc_zeroed(layout) };
226        if !ptr.is_null() {
227            record_alloc(layout.size());
228        }
229        ptr
230    }
231
232    #[inline]
233    unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
234        // SAFETY: the caller upholds `realloc`'s contract: `ptr` was returned
235        // by this allocator for `layout` and `new_size` is valid for its
236        // alignment, all forwarded verbatim so the inner allocator keeps its
237        // own in-place path.
238        let new_ptr = unsafe { self.inner.realloc(ptr, layout, new_size) };
239        if !new_ptr.is_null() {
240            record_realloc(layout.size(), new_size);
241        }
242        new_ptr
243    }
244}
245
246/// Runs `body` and returns its value with the allocator traffic the calling
247/// thread produced while it ran.
248///
249/// Allocations made by other threads are not counted, including threads
250/// `body` waits on. Allocations `body` makes to start a thread (its closure,
251/// its handle) are made by the calling thread and are counted.
252///
253/// Counts are read by difference, so windows nest: an outer window includes
254/// the traffic of an inner one. Counting happens only where a
255/// [`CountingAllocator`] is installed as the global allocator; without one
256/// every window reads empty.
257///
258/// # Example
259///
260/// ```
261/// use mnemosyne::counting::{AllocationDelta, CountingAllocator, measure};
262///
263/// #[global_allocator]
264/// static ALLOCATOR: CountingAllocator<std::alloc::System> =
265///     CountingAllocator::new(std::alloc::System);
266///
267/// fn main() {
268///     let mut buffer = Vec::<u8>::with_capacity(16);
269///     let ((), delta) = measure(|| buffer.extend_from_slice(&[0; 16]));
270///     assert_eq!(delta, AllocationDelta::default());
271///
272///     let ((), delta) = measure(|| buffer.extend_from_slice(&[0; 16]));
273///     assert_eq!(delta.reallocations, 1);
274///     assert_eq!(delta.bytes_allocated, buffer.capacity() - 16);
275/// }
276/// ```
277pub fn measure<R>(body: impl FnOnce() -> R) -> (R, AllocationDelta) {
278    let before = COUNTERS.with(Counters::totals);
279    let value = body();
280    let after = COUNTERS.with(Counters::totals);
281    (value, after.since(&before))
282}