Luigit
repositories / smith

smith

There are many coding harnesses - but this one is fast

owned by admin

smith-alloc/src/lib.rs

Raw
//! Scope-tagged counting allocator (`SMH-SPEC-SPEC0001`, Memory accounting).
//!
//! [`Counting`] wraps [`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`].

use std::alloc::{GlobalAlloc, Layout, System};
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 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<Stack> = const { Cell::new(Stack { len: 0, items: [Scope::Process; DEPTH] }) };
}

fn current() -> Scope {
    STACK
        .try_with(|stack| {
            let stack = stack.get();
            stack.items.get(stack.len.wrapping_sub(1)).copied()
        })
        .ok()
        .flatten()
        .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 _ = 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);
            cell.set(stack);
        });
    }
}

/// 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 = 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
        })
        .unwrap_or(0);
    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),
    }
}

/// Run `work` charged to `scope` and report what it cost: `allocations`
/// and `bytes` are the deltas across the run, `live` and `peak` the scope's
/// values right after it.
///
/// Counters are process-wide; concurrent measurements of one scope mix.
pub fn measure<T>(scope: Scope, work: impl FnOnce() -> T) -> (T, Stats) {
    let before = snapshot(scope);
    let guard = enter(scope);
    let out = work();
    drop(guard);
    let after = snapshot(scope);
    (
        out,
        Stats {
            allocations: after.allocations - before.allocations,
            bytes: after.bytes - before.bytes,
            live: after.live,
            peak: after.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);
}

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);
    }
}

/// Header of `align` bytes in front of each block; its last byte is the scope tag.
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))
}

/// Counting wrapper around [`System`]; install with `#[global_allocator]`.
pub struct Counting;

impl Counting {
    /// # Safety
    /// `raw` must allocate a block for the layout it is given, like [`GlobalAlloc::alloc`].
    unsafe fn tagged(layout: Layout, raw: impl FnOnce(Layout) -> *mut u8) -> *mut u8 {
        let Some((outer, pad)) = padded(layout) else {
            return std::ptr::null_mut();
        };
        // SAFETY: `raw` returned null or a block of `outer`.
        unsafe { Self::stamp(raw(outer), 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 block spanning `pad + size` bytes, `pad >= 1`.
    unsafe fn stamp(base: *mut u8, pad: usize, size: usize) -> *mut u8 {
        if base.is_null() {
            return base;
        }
        let scope = current();
        // SAFETY: the user pointer and its tag byte lie inside the block;
        // `pad` is the alignment, so the user pointer stays aligned.
        let user = unsafe { base.add(pad) };
        unsafe { user.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.
unsafe impl GlobalAlloc for Counting {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        // SAFETY: forwards the caller's non-zero layout, grown by the header.
        unsafe { Self::tagged(layout, |outer| System.alloc(outer)) }
    }

    unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
        // SAFETY: as `alloc`.
        unsafe { Self::tagged(layout, |outer| System.alloc_zeroed(outer)) }
    }

    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
        let Some((outer, pad)) = padded(layout) else {
            return;
        };
        // SAFETY: `ptr` came from `tagged` with this layout, so the tag byte
        // and the `System` base lie `1` and `pad` bytes before it.
        unsafe {
            record_free(ptr.sub(1).read(), layout.size());
            System.dealloc(ptr.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 `dealloc`; on success `System` moved the header with the
        // data and the old block is gone, so its charge is released first.
        unsafe {
            let tag = ptr.sub(1).read();
            let base = System.realloc(ptr.sub(pad), outer, new_outer.size());
            if !base.is_null() {
                record_free(tag, layout.size());
            }
            Self::stamp(base, pad, new_size)
        }
    }
}

#[cfg(test)]
mod tests {
    use super::{Counting, Scope, enter, snapshot};

    #[global_allocator]
    static ALLOC: Counting = Counting;

    // Each test owns a distinct scope: counters are process-wide and libtest runs tests in parallel.

    #[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 nested_scope_restores_outer_and_realloc_retags() {
        let outer = enter(Scope::KernelCall);
        let mut grown: Vec<u8> = 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 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]
    fn realloc_past_header_capacity_fails_and_keeps_block() {
        use std::alloc::{GlobalAlloc, Layout};
        let layout = Layout::new::<u64>();
        // Valid for the caller, but no room left for the header in front.
        let new_size = isize::MAX.cast_unsigned() - 7;
        // SAFETY: `layout` is non-zero; `new_size` rounded to its alignment
        // fits `isize`; the block is freed with the layout it was made with.
        unsafe {
            let block = Counting.alloc(layout);
            assert!(!block.is_null());
            assert!(Counting.realloc(block, layout, new_size).is_null());
            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);
    }
}