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}