//! Scope-tagged counting allocator (`SMH-SPEC-SPEC0001`, Memory accounting). //! //! [`Counting`] wraps [`System`](std::alloc::System) and attributes every //! allocation, including third-party ones, to the innermost [`Scope`] //! entered on the allocating thread. Each block carries its scope tag in a //! header, so a free is charged to the scope that allocated it and live //! bytes stay exact per scope. //! Scopes do not propagate to spawned threads; those start at //! [`Scope::Process`]. //! //! The only unsafe code is the [`GlobalAlloc`](std::alloc::GlobalAlloc) //! implementation and its header arithmetic, kept in one private module; //! the public surface is safe. #![deny(unsafe_code)] use std::cell::Cell; use std::marker::PhantomData; use std::sync::atomic::{AtomicU64, Ordering::Relaxed}; /// Logical lifetime an allocation is charged to. #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[repr(u8)] pub enum Scope { /// Everything outside an explicit scope, including pre-main. Process, /// One session. Session, /// One agent turn. Turn, /// One kernel call. KernelCall, /// One rendered frame. Frame, /// One I/O request. IoRequest, } const SCOPES: [Scope; 6] = [ Scope::Process, Scope::Session, Scope::Turn, Scope::KernelCall, Scope::Frame, Scope::IoRequest, ]; /// Counters recorded for one scope since process start. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct Stats { /// Allocations, reallocations included. pub allocations: u64, /// Bytes requested by those allocations. pub bytes: u64, /// Bytes allocated in this scope and not yet freed. pub live: u64, /// Highest `live` observed. pub peak: u64, } struct Counters { allocations: AtomicU64, bytes: AtomicU64, live: AtomicU64, peak: AtomicU64, } static COUNTERS: [Counters; SCOPES.len()] = [const { Counters { allocations: AtomicU64::new(0), bytes: AtomicU64::new(0), live: AtomicU64::new(0), peak: AtomicU64::new(0), } }; SCOPES.len()]; const _: () = assert!( SCOPES.len() <= u8::MAX as usize, "scope tags must fit in one byte" ); const DEPTH: usize = 16; #[derive(Clone, Copy)] struct Stack { len: usize, items: [Scope; DEPTH], } thread_local! { // Const-initialized and drop-free: access never allocates or registers a destructor. static STACK: Cell = const { Cell::new(Stack { len: 0, items: [Scope::Process; DEPTH] }) }; // Kept separately so allocation hot paths do not copy `Stack` from its `Cell`. static CURRENT_SCOPE: Cell = const { Cell::new(Scope::Process) }; // Zero means inactive; scopes use their discriminant plus one. static ACTIVE_MEASUREMENT: Cell = const { Cell::new(0) }; static MEASUREMENT: Cell = const { Cell::new(Measurement::EMPTY) }; } #[derive(Clone, Copy)] struct Measurement { live: u64, peak: u64, } impl Measurement { const EMPTY: Self = Self { live: 0, peak: 0 }; } const _: () = assert!( std::mem::size_of::() == 16, "measurement state must remain two u64 words" ); #[inline] fn measures(scope: u8) -> bool { ACTIVE_MEASUREMENT .try_with(|active| active.get() == scope + 1) .unwrap_or(false) } #[inline] fn measure_allocation(size: u64) { // TLS teardown must not make allocator accounting panic or abort allocation. let _measurement_update: std::result::Result<(), std::thread::AccessError> = MEASUREMENT .try_with(|cell| { let mut measurement = cell.get(); measurement.live += size; measurement.peak = measurement.peak.max(measurement.live); cell.set(measurement); }); } #[inline] fn measure_free(size: u64) { // TLS teardown must not make allocator accounting panic or abort deallocation. let _measurement_update: std::result::Result<(), std::thread::AccessError> = MEASUREMENT .try_with(|cell| { let mut measurement = cell.get(); measurement.live = measurement.live.saturating_sub(size); cell.set(measurement); }); } #[inline] fn current() -> Scope { CURRENT_SCOPE.try_with(Cell::get).unwrap_or(Scope::Process) } /// Restores the enclosing scope when dropped; bound to its thread. #[must_use = "the scope ends when the guard drops"] pub struct Guard { previous: usize, _thread_bound: PhantomData<*const ()>, } impl Drop for Guard { fn drop(&mut self) { let current = STACK .try_with(|cell| { let mut stack = cell.get(); // `min` tolerates non-LIFO drops, such as a `Vec` of guards. stack.len = stack.len.min(self.previous); let current = stack .items .get(stack.len.wrapping_sub(1)) .copied() .unwrap_or(Scope::Process); cell.set(stack); current }) .unwrap_or(Scope::Process); // Scope restoration is best-effort during thread-local destruction. let _scope_update: std::result::Result<(), std::thread::AccessError> = CURRENT_SCOPE.try_with(|cell| cell.set(current)); } } /// Charge this thread's allocations to `scope` until the guard drops. /// /// Nesting deeper than 16 keeps charging the outer scope (soft limit). pub fn enter(scope: Scope) -> Guard { let (previous, current) = STACK .try_with(|cell| { let mut stack = cell.get(); let previous = stack.len; if let Some(slot) = stack.items.get_mut(previous) { *slot = scope; stack.len += 1; cell.set(stack); } ( previous, stack .items .get(stack.len.wrapping_sub(1)) .copied() .unwrap_or(Scope::Process), ) }) .unwrap_or((0, Scope::Process)); // Scope setup is best-effort if thread-local state is already being torn down. let _scope_update: std::result::Result<(), std::thread::AccessError> = CURRENT_SCOPE.try_with(|cell| cell.set(current)); Guard { previous, _thread_bound: PhantomData, } } /// Counters recorded for `scope`. pub fn snapshot(scope: Scope) -> Stats { let counters = &COUNTERS[scope as usize]; Stats { allocations: counters.allocations.load(Relaxed), bytes: counters.bytes.load(Relaxed), live: counters.live.load(Relaxed), peak: counters.peak.load(Relaxed), } } struct MeasurementGuard { scope: Option, active: u8, measurement: Measurement, } impl Drop for MeasurementGuard { fn drop(&mut self) { drop(self.scope.take()); ACTIVE_MEASUREMENT.set(self.active); MEASUREMENT.set(self.measurement); } } /// Run `work` charged to `scope` and report what it cost. /// /// `allocations`, `bytes`, and `peak` describe this invocation only. /// `live` is the scope's process-wide value after it returns. /// Measurements on the same scope must not overlap on one thread. pub fn measure(scope: Scope, work: impl FnOnce() -> T) -> (T, Stats) { let before = snapshot(scope); let guard = MeasurementGuard { scope: Some(enter(scope)), active: ACTIVE_MEASUREMENT.replace(scope as u8 + 1), measurement: MEASUREMENT.replace(Measurement::EMPTY), }; let out = work(); let measurement = MEASUREMENT.get(); drop(guard); let after = snapshot(scope); ( out, Stats { allocations: after.allocations - before.allocations, bytes: after.bytes - before.bytes, live: after.live, peak: measurement.peak, }, ) } /// Every scope with its counters. pub fn snapshot_all() -> [(Scope, Stats); SCOPES.len()] { SCOPES.map(|scope| (scope, snapshot(scope))) } fn record_alloc(scope: Scope, size: usize) { let counters = &COUNTERS[scope as usize]; counters.allocations.fetch_add(1, Relaxed); counters.bytes.fetch_add(size as u64, Relaxed); let now = counters.live.fetch_add(size as u64, Relaxed) + size as u64; counters.peak.fetch_max(now, Relaxed); if measures(scope as u8) { measure_allocation(size as u64); } } fn record_free(tag: u8, size: usize) { // A tag outside the scope range means a foreign block; nothing to charge. if let Some(counters) = COUNTERS.get(usize::from(tag)) { counters.live.fetch_sub(size as u64, Relaxed); if measures(tag) { measure_free(size as u64); } } } /// Counting wrapper around [`System`](std::alloc::System); install with /// `#[global_allocator]`. pub struct Counting; /// Block layout: `System` holds `pad = align` header bytes in front of /// each user block, the last header byte being the scope tag. #[expect( unsafe_code, reason = "GlobalAlloc has no safe interface; implementing it is the only way to attribute every allocation, third-party ones included, to a scope" )] mod raw { use super::{Counting, current, record_alloc, record_free}; use std::alloc::{GlobalAlloc, Layout, System}; /// The `System` layout for `layout` and its header size; `pad >= 1` /// because every alignment is at least one. fn padded(layout: Layout) -> Option<(Layout, usize)> { let pad = layout.align(); let size = layout.size().checked_add(pad)?; Layout::from_size_align(size, pad) .ok() .map(|outer| (outer, pad)) } /// A fresh tagged block for `layout`, or null. fn tagged(layout: Layout, zeroed: bool) -> *mut u8 { let Some((outer, pad)) = padded(layout) else { return std::ptr::null_mut(); }; let base = if zeroed { // SAFETY: `outer` is `layout` grown by `pad >= 1`, so its size is non-zero. unsafe { System.alloc_zeroed(outer) } } else { // SAFETY: `outer` is `layout` grown by `pad >= 1`, so its size is non-zero. unsafe { System.alloc(outer) } }; // SAFETY: `base` is null or a fresh `System` block of `outer`, // which spans `pad + layout.size()` bytes. unsafe { stamp(base, pad, layout.size()) } } /// Tag a fresh `System` block with the current scope and charge `size` /// to it; null passes through. /// /// # Safety /// `base` is null or a live, writable block spanning `pad + size` /// bytes, aligned to `pad`, with `pad >= 1`. unsafe fn stamp(base: *mut u8, pad: usize, size: usize) -> *mut u8 { if base.is_null() { return base; } let scope = current(); // `pad` is the alignment, so the user pointer stays aligned. let user = base.wrapping_add(pad); // SAFETY: `1 <= pad`, so the tag byte `user - 1` is the last header // byte, inside the block the caller hands over. unsafe { user.wrapping_sub(1).write(scope as u8) }; record_alloc(scope, size); user } // SAFETY: every block is a `System` block of `padded(layout)`; the user // pointer sits `pad` bytes in, so `System` receives back exactly what it // gave, and the user block keeps the size and alignment of `layout`. // Header and base are reached from the caller's pointer, keeping its // provenance: a `Box` protects the pointer it frees, so any other // pointer to the block would be a foreign access (Tree Borrows). unsafe impl GlobalAlloc for Counting { unsafe fn alloc(&self, layout: Layout) -> *mut u8 { tagged(layout, false) } unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { tagged(layout, true) } unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { let Some((outer, pad)) = padded(layout) else { return; }; // SAFETY: the caller passes a block this allocator made with // `layout`, so its tag byte lies one byte before `ptr`. let tag = unsafe { ptr.wrapping_sub(1).read() }; record_free(tag, layout.size()); // SAFETY: the `System` base lies `pad` bytes before `ptr` and was // allocated with `outer = padded(layout)`. unsafe { System.dealloc(ptr.wrapping_sub(pad), outer) }; } unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { // The grown outer layout must itself be valid, as `System.realloc` requires. let new_layout = Layout::from_size_align(new_size, layout.align()).ok(); let (Some((outer, pad)), Some((new_outer, _))) = (padded(layout), new_layout.and_then(padded)) else { return std::ptr::null_mut(); }; // SAFETY: as in `dealloc`, the tag byte lies one byte before `ptr`. let tag = unsafe { ptr.wrapping_sub(1).read() }; // SAFETY: the base `pad` bytes before `ptr` is a live `System` // block of `outer`; `new_outer` is a valid layout of the same // alignment and non-zero size. let base = unsafe { System.realloc(ptr.wrapping_sub(pad), outer, new_outer.size()) }; // On success `System` moved the header with the data and the old // block is gone, so its charge is released first. if !base.is_null() { record_free(tag, layout.size()); } // SAFETY: `base` is null or a `System` block of `new_outer`, // which spans `pad + new_size` bytes aligned to `pad`. unsafe { stamp(base, pad, new_size) } } } } #[cfg(test)] mod tests { use super::{Counting, Scope, enter, measure, snapshot}; #[global_allocator] static ALLOC: Counting = Counting; // Each test owns a distinct scope: counters are process-wide and libtest runs tests in parallel. #[cfg(target_pointer_width = "64")] #[test] fn active_measurement_state_fits_two_words() { assert_eq!(std::mem::size_of::(), 16); } #[test] fn foreign_free_tags_are_ignored_during_and_outside_measurement() { let active = super::ACTIVE_MEASUREMENT.replace(0); let measurement = super::MEASUREMENT.replace(super::Measurement { live: 7, peak: 11 }); let before = snapshot(Scope::Session); for tag in 6..=u8::MAX { super::record_free(tag, 13); } assert_eq!(snapshot(Scope::Session), before); assert_eq!( (super::MEASUREMENT.get().live, super::MEASUREMENT.get().peak), (7, 11) ); super::ACTIVE_MEASUREMENT.set(Scope::IoRequest as u8 + 1); for tag in 6..=u8::MAX { super::record_free(tag, 13); } assert_eq!(snapshot(Scope::Session), before); assert_eq!( (super::MEASUREMENT.get().live, super::MEASUREMENT.get().peak), (7, 11) ); super::ACTIVE_MEASUREMENT.set(active); super::MEASUREMENT.set(measurement); } #[test] fn free_outside_scope_is_charged_to_allocating_scope() { let guard = enter(Scope::Frame); let block = std::hint::black_box(vec![0u8; 1000]); drop(guard); let inside = snapshot(Scope::Frame); assert_eq!( (inside.allocations, inside.bytes, inside.live), (1, 1000, 1000) ); drop(block); let after = snapshot(Scope::Frame); assert_eq!((after.live, after.peak), (0, 1000)); } #[test] fn measure_nested_scope_restores_outer_peak() { let (_, outer) = measure(Scope::Turn, || { let first = std::hint::black_box(vec![0u8; 4]); let (_, inner) = measure(Scope::Process, || std::hint::black_box(vec![0u8; 8])); assert_eq!(inner.peak, 8); drop(first); std::hint::black_box(vec![0u8; 16]) }); assert_eq!((outer.allocations, outer.bytes, outer.peak), (2, 20, 16)); } #[test] fn panicking_measure_restores_enclosing_measurement() { let (_, outer) = measure(Scope::Turn, || { let result = std::panic::catch_unwind(|| { let _ = measure(Scope::Process, || { let _block = std::hint::black_box(vec![0u8; 8]); std::panic::resume_unwind(Box::new(())); }); }); assert!(result.is_err()); std::hint::black_box(vec![0u8; 16]) }); assert_eq!(outer.peak, 16); } #[test] fn nested_scope_restores_outer_and_realloc_retags() { let outer = enter(Scope::KernelCall); let mut grown: Vec = Vec::with_capacity(8); { let _inner = enter(Scope::IoRequest); grown.reserve_exact(64); } let tail = std::hint::black_box(vec![1u8; 16]); drop(outer); let kernel = snapshot(Scope::KernelCall); let io = snapshot(Scope::IoRequest); assert_eq!((kernel.allocations, kernel.bytes, kernel.live), (2, 24, 16)); assert_eq!((io.allocations, io.bytes, io.live), (1, 64, 64)); drop((grown, tail)); assert_eq!(snapshot(Scope::IoRequest).live, 0); } #[test] fn zeroed_blocks_are_zero_after_a_dirty_free() { drop(std::hint::black_box(vec![0xAAu8; 64])); let zeroed = std::hint::black_box(vec![0u8; 64]); assert!(zeroed.iter().all(|byte| *byte == 0)); } #[test] fn overaligned_blocks_stay_aligned() { #[repr(align(4096))] struct Page([u8; 4096]); let page = Box::new(Page([0; 4096])); assert_eq!(std::ptr::from_ref(&*page).addr() % 4096, 0); assert_eq!(page.0[4095], 0); } #[test] #[expect( unsafe_code, reason = "drives GlobalAlloc directly: no safe caller can request a size past the header capacity" )] fn realloc_past_header_capacity_fails_and_keeps_block() { use std::alloc::{GlobalAlloc, Layout}; let layout = Layout::new::(); // Valid for the caller, but no room left for the header in front. let new_size = isize::MAX.cast_unsigned() - 7; // SAFETY: `layout` has non-zero size. let block = unsafe { Counting.alloc(layout) }; assert!(!block.is_null()); // SAFETY: `block` is live with `layout`; `new_size` is non-zero and, // rounded to the alignment, fits `isize`. assert!(unsafe { Counting.realloc(block, layout, new_size) }.is_null()); // SAFETY: the failed `realloc` kept `block`, still live with `layout`. unsafe { Counting.dealloc(block, layout) }; } #[test] fn spawned_threads_start_at_process_scope() { let _turn = enter(Scope::Turn); let seen = std::thread::spawn(super::current) .join() .map_err(|_| "join"); assert_eq!(seen, Ok(Scope::Process)); } #[test] fn overflowing_depth_keeps_outer_scope() { let _session = enter(Scope::Session); let guards: Vec<_> = (0..20).map(|_| enter(Scope::Process)).collect(); assert_eq!(super::current(), Scope::Process); drop(guards); assert_eq!(super::current(), Scope::Session); } }