Skip to main content

keyhog_profile/
allocation.rs

1//! Allocation counting and per-stage ownership behind the
2//! `allocation-tracking` feature, following the `process-metrics` and
3//! `hardware-counters` capability pattern.
4//!
5//! [`TrackingAllocator`] wraps the system allocator. Binaries install it with
6//! `#[global_allocator]`; when the feature is disabled it compiles to a
7//! transparent pass-through with no counters. When installed, every allocation
8//! carries a 16-byte header recording the profiling stage active at allocation
9//! time, so per-stage live bytes stay exact even when memory is freed from a
10//! different stage or thread. All counters are process-wide atomics; a session
11//! diffs snapshots taken at its boundaries. Nothing allocates on the recording
12//! path.
13
14use crate::collector::{CollectorAvailability, CollectorCapability, CollectorId};
15use std::alloc::{GlobalAlloc, Layout, System};
16
17/// Stage attribution slots: one per [`crate::Stage`] plus one root slot for
18/// allocations made outside any recorded span.
19pub const STAGE_SLOTS: usize = crate::runtime::STAGE_COUNT + 1;
20/// Slot index for allocations made outside any recorded span.
21pub const ROOT_SLOT: usize = crate::runtime::STAGE_COUNT;
22
23#[cfg(feature = "allocation-tracking")]
24mod tracked {
25    use super::*;
26    use std::cell::{Cell, UnsafeCell};
27    use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
28
29    const MAX_STAGE_DEPTH: usize = 64;
30    const HEADER_BYTES: usize = 16;
31    const HEADER_MAGIC: u8 = 0xA5;
32
33    pub(super) static INSTALLED: AtomicBool = AtomicBool::new(false);
34    /// Process-wide count of SystemSessions currently sampling allocation totals.
35    static ACTIVE_ALLOC_SESSIONS: AtomicUsize = AtomicUsize::new(0);
36    /// Sticky while any overlapping allocation sessions share the global counters.
37    static ALLOC_SESSION_OVERLAP: AtomicBool = AtomicBool::new(false);
38    static ALLOCATIONS: AtomicU64 = AtomicU64::new(0);
39    static DEALLOCATIONS: AtomicU64 = AtomicU64::new(0);
40    static ALLOCATION_BYTES: AtomicU64 = AtomicU64::new(0);
41    static DEALLOCATION_BYTES: AtomicU64 = AtomicU64::new(0);
42    static LIVE_BYTES: AtomicU64 = AtomicU64::new(0);
43    static PEAK_LIVE_BYTES: AtomicU64 = AtomicU64::new(0);
44    static SLOT_ALLOCATIONS: [AtomicU64; STAGE_SLOTS] = [const { AtomicU64::new(0) }; STAGE_SLOTS];
45    static SLOT_ALLOCATION_BYTES: [AtomicU64; STAGE_SLOTS] =
46        [const { AtomicU64::new(0) }; STAGE_SLOTS];
47    static SLOT_DEALLOCATION_BYTES: [AtomicU64; STAGE_SLOTS] =
48        [const { AtomicU64::new(0) }; STAGE_SLOTS];
49    static SLOT_LIVE_BYTES: [AtomicU64; STAGE_SLOTS] = [const { AtomicU64::new(0) }; STAGE_SLOTS];
50    static SLOT_PEAK_LIVE_BYTES: [AtomicU64; STAGE_SLOTS] =
51        [const { AtomicU64::new(0) }; STAGE_SLOTS];
52
53    thread_local! {
54        static STAGE_STACK_DEPTH: Cell<u16> = const { Cell::new(0) };
55        static STAGE_STACK: UnsafeCell<[u8; MAX_STAGE_DEPTH]> =
56            const { UnsafeCell::new([0; MAX_STAGE_DEPTH]) };
57    }
58
59    /// Push one stage onto this thread's allocation-attribution stack.
60    pub(crate) fn stage_context_push(stage: crate::Stage) {
61        STAGE_STACK_DEPTH.with(|depth| {
62            let current = depth.get();
63            if (current as usize) < MAX_STAGE_DEPTH {
64                STAGE_STACK.with(|stack| {
65                    // SAFETY: thread-local stack slot below the depth bound;
66                    // only this thread reads or writes it.
67                    unsafe { (*stack.get())[current as usize] = stage.index() as u8 };
68                });
69            }
70            depth.set(current.saturating_add(1));
71        });
72    }
73
74    /// Pop the stage this thread most recently pushed; saturates on mismatch.
75    pub(crate) fn stage_context_pop() {
76        STAGE_STACK_DEPTH.with(|depth| depth.set(depth.get().saturating_sub(1)));
77    }
78
79    fn current_slot() -> usize {
80        STAGE_STACK_DEPTH.with(|depth| {
81            let current = depth.get();
82            if current == 0 || current as usize > MAX_STAGE_DEPTH {
83                return ROOT_SLOT;
84            }
85            STAGE_STACK.with(|stack| {
86                // SAFETY: current - 1 is below the depth bound and was written
87                // by this thread when the frame was pushed.
88                usize::from(unsafe { (*stack.get())[current as usize - 1] })
89            })
90        })
91    }
92
93    #[inline]
94    fn record_alloc(slot: usize, bytes: u64) {
95        INSTALLED.store(true, Ordering::Relaxed);
96        ALLOCATIONS.fetch_add(1, Ordering::Relaxed);
97        ALLOCATION_BYTES.fetch_add(bytes, Ordering::Relaxed);
98        let live = LIVE_BYTES.fetch_add(bytes, Ordering::Relaxed) + bytes;
99        PEAK_LIVE_BYTES.fetch_max(live, Ordering::Relaxed);
100        SLOT_ALLOCATIONS[slot].fetch_add(1, Ordering::Relaxed);
101        SLOT_ALLOCATION_BYTES[slot].fetch_add(bytes, Ordering::Relaxed);
102        let slot_live = SLOT_LIVE_BYTES[slot].fetch_add(bytes, Ordering::Relaxed) + bytes;
103        SLOT_PEAK_LIVE_BYTES[slot].fetch_max(slot_live, Ordering::Relaxed);
104    }
105
106    #[inline]
107    fn record_dealloc(slot: usize, bytes: u64) {
108        if slot >= STAGE_SLOTS {
109            // Fail closed: a corrupt header must not index SLOT_* or wrap live
110            // counters. Callers that already validated the header never hit this.
111            return;
112        }
113        DEALLOCATIONS.fetch_add(1, Ordering::Relaxed);
114        DEALLOCATION_BYTES.fetch_add(bytes, Ordering::Relaxed);
115        saturating_fetch_sub(&LIVE_BYTES, bytes);
116        SLOT_DEALLOCATION_BYTES[slot].fetch_add(bytes, Ordering::Relaxed);
117        saturating_fetch_sub(&SLOT_LIVE_BYTES[slot], bytes);
118    }
119
120    #[inline]
121    fn saturating_fetch_sub(cell: &AtomicU64, bytes: u64) {
122        let mut current = cell.load(Ordering::Relaxed);
123        loop {
124            let next = current.saturating_sub(bytes);
125            match cell.compare_exchange_weak(current, next, Ordering::Relaxed, Ordering::Relaxed) {
126                Ok(_) => break,
127                Err(observed) => current = observed,
128            }
129        }
130    }
131
132    pub(super) fn snapshot_totals() -> (u64, u64, u64, u64, u64, u64) {
133        (
134            ALLOCATIONS.load(Ordering::Relaxed),
135            DEALLOCATIONS.load(Ordering::Relaxed),
136            ALLOCATION_BYTES.load(Ordering::Relaxed),
137            DEALLOCATION_BYTES.load(Ordering::Relaxed),
138            LIVE_BYTES.load(Ordering::Relaxed),
139            PEAK_LIVE_BYTES.load(Ordering::Relaxed),
140        )
141    }
142
143    pub(super) fn snapshot_slot(slot: usize) -> super::AllocationSlotV2 {
144        let allocated = SLOT_ALLOCATION_BYTES[slot].load(Ordering::Relaxed);
145        let deallocated = SLOT_DEALLOCATION_BYTES[slot].load(Ordering::Relaxed);
146        super::AllocationSlotV2 {
147            allocations: SLOT_ALLOCATIONS[slot].load(Ordering::Relaxed),
148            allocated_bytes: allocated,
149            live_bytes: allocated.saturating_sub(deallocated),
150            peak_live_bytes: SLOT_PEAK_LIVE_BYTES[slot].load(Ordering::Relaxed),
151        }
152    }
153
154    pub(super) fn reset_peaks() {
155        PEAK_LIVE_BYTES.store(LIVE_BYTES.load(Ordering::Relaxed), Ordering::Relaxed);
156        for slot in 0..STAGE_SLOTS {
157            SLOT_PEAK_LIVE_BYTES[slot].store(
158                SLOT_LIVE_BYTES[slot].load(Ordering::Relaxed),
159                Ordering::Relaxed,
160            );
161        }
162    }
163
164    /// Enter a session window over the process-global allocation counters.
165    ///
166    /// Only the sole active session may reset peaks. A second concurrent
167    /// session marks the process contaminated so every overlapping window
168    /// fail-closes instead of publishing misattributed peaks/deltas.
169    pub(super) fn enter_session() -> (bool, bool) {
170        let prev = ACTIVE_ALLOC_SESSIONS.fetch_add(1, Ordering::AcqRel);
171        if prev == 0 {
172            ALLOC_SESSION_OVERLAP.store(false, Ordering::Release);
173            reset_peaks();
174            (true, false)
175        } else {
176            ALLOC_SESSION_OVERLAP.store(true, Ordering::Release);
177            (true, true)
178        }
179    }
180
181    pub(super) fn leave_session() {
182        ACTIVE_ALLOC_SESSIONS.fetch_sub(1, Ordering::AcqRel);
183    }
184
185    pub(super) fn session_evidence_reliable(joined_overlapped: bool) -> bool {
186        if joined_overlapped {
187            return false;
188        }
189        if ALLOC_SESSION_OVERLAP.load(Ordering::Acquire) {
190            return false;
191        }
192        ACTIVE_ALLOC_SESSIONS.load(Ordering::Acquire) == 1
193    }
194
195    #[repr(C)]
196    struct AllocationHeader {
197        stage: u8,
198        magic: u8,
199        reserved: [u8; 6],
200        bytes: u64,
201    }
202
203    const _: () = assert!(std::mem::size_of::<AllocationHeader>() == HEADER_BYTES);
204
205    /// Tracked allocation: header records the stage slot and requested bytes.
206    pub(super) unsafe fn tracked_alloc(layout: Layout) -> *mut u8 {
207        let offset = layout.align().max(HEADER_BYTES);
208        let Some(total) = layout.size().checked_add(offset) else {
209            return std::ptr::null_mut();
210        };
211        let Ok(real) = Layout::from_size_align(total, offset) else {
212            return std::ptr::null_mut();
213        };
214        // SAFETY: real is a valid layout; the caller honors GlobalAlloc rules.
215        let base = unsafe { System.alloc(real) };
216        if base.is_null() {
217            return base;
218        }
219        let slot = current_slot();
220        let header = AllocationHeader {
221            stage: slot as u8,
222            magic: HEADER_MAGIC,
223            reserved: [0; 6],
224            bytes: layout.size() as u64,
225        };
226        // SAFETY: base is 16-aligned and owns at least HEADER_BYTES before the
227        // user region at base + offset.
228        unsafe { base.cast::<AllocationHeader>().write(header) };
229        record_alloc(slot, layout.size() as u64);
230        // SAFETY: offset lies inside the allocated block.
231        unsafe { base.add(offset) }
232    }
233
234    /// Tracked deallocation: ownership returns to the allocating stage slot.
235    ///
236    /// Release builds previously trusted `AllocationHeader` with only
237    /// `debug_assert`s. A corrupt `stage` indexed `SLOT_*` out of bounds
238    /// (panic in the global allocator); a corrupt `bytes` fed
239    /// `from_size_align_unchecked` and wrapping `fetch_sub`. Validate the
240    /// header; on failure, free with the caller layout and skip counters.
241    pub(super) unsafe fn tracked_dealloc(ptr: *mut u8, layout: Layout) {
242        let offset = layout.align().max(HEADER_BYTES);
243        // SAFETY: ptr came from tracked_alloc with the same layout, so the
244        // header sits exactly offset bytes before it.
245        let base = unsafe { ptr.sub(offset) };
246        // SAFETY: base points at the header written by tracked_alloc (or at
247        // whatever bytes sit there if the block was corrupted).
248        let header = unsafe { base.cast::<AllocationHeader>().read() };
249        let stage = usize::from(header.stage);
250        let header_ok = header.magic == HEADER_MAGIC
251            && header.bytes == layout.size() as u64
252            && stage < STAGE_SLOTS;
253        let user_bytes = if header_ok {
254            record_dealloc(stage, header.bytes);
255            header.bytes as usize
256        } else {
257            // Skip counter updates; free with the caller-provided layout size.
258            layout.size()
259        };
260        let real = Layout::from_size_align(user_bytes.saturating_add(offset), offset)
261            .unwrap_or_else(|_| {
262                // offset is align.max(16) so power-of-two and nonzero; size was
263                // accepted at alloc time. Fall back only if saturating wrap
264                // produced an impossible pair.
265                unsafe { Layout::from_size_align_unchecked(layout.size() + offset, offset) }
266            });
267        // SAFETY: base/real match the allocation that produced ptr when the
268        // header is valid; on corruption we free using the caller layout that
269        // GlobalAlloc requires the client to pass.
270        unsafe { System.dealloc(base, real) };
271    }
272}
273
274#[cfg(not(feature = "allocation-tracking"))]
275mod untracked {
276    pub(super) fn snapshot_totals() -> (u64, u64, u64, u64, u64, u64) {
277        (0, 0, 0, 0, 0, 0)
278    }
279
280    pub(super) fn snapshot_slot(_slot: usize) -> super::AllocationSlotV2 {
281        super::AllocationSlotV2 {
282            allocations: 0,
283            allocated_bytes: 0,
284            live_bytes: 0,
285            peak_live_bytes: 0,
286        }
287    }
288
289    pub(super) fn reset_peaks() {}
290}
291
292#[cfg(feature = "allocation-tracking")]
293use tracked as backend;
294#[cfg(not(feature = "allocation-tracking"))]
295use untracked as backend;
296
297#[cfg(feature = "allocation-tracking")]
298pub(crate) use backend::{stage_context_pop, stage_context_push};
299
300/// Push one stage onto this thread's attribution stack; no-op without the
301/// `allocation-tracking` feature. Called by span guards only.
302#[cfg(not(feature = "allocation-tracking"))]
303#[inline(always)]
304pub(crate) fn stage_context_push(_stage: crate::Stage) {}
305
306/// Pop one stage from this thread's attribution stack; no-op without the
307/// `allocation-tracking` feature. Called by span guards only.
308#[cfg(not(feature = "allocation-tracking"))]
309#[inline(always)]
310pub(crate) fn stage_context_pop() {}
311
312/// Global allocator that counts allocations, bytes, and live memory with
313/// per-stage ownership. Install with `#[global_allocator]`. Without the
314/// `allocation-tracking` feature every method inlines to the system allocator.
315pub struct TrackingAllocator;
316
317impl TrackingAllocator {
318    pub const fn new() -> Self {
319        Self
320    }
321}
322
323impl Default for TrackingAllocator {
324    fn default() -> Self {
325        Self::new()
326    }
327}
328
329// SAFETY: every method forwards a valid layout to the system allocator; the
330// tracked variant keeps the GlobalAlloc contract for the returned pointers.
331unsafe impl GlobalAlloc for TrackingAllocator {
332    #[cfg(feature = "allocation-tracking")]
333    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
334        // SAFETY: forwarded from the caller.
335        unsafe { tracked::tracked_alloc(layout) }
336    }
337
338    #[cfg(feature = "allocation-tracking")]
339    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
340        // SAFETY: forwarded from the caller with the matching layout.
341        unsafe { tracked::tracked_dealloc(ptr, layout) }
342    }
343
344    #[cfg(not(feature = "allocation-tracking"))]
345    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
346        // SAFETY: forwarded from the caller.
347        unsafe { System.alloc(layout) }
348    }
349
350    #[cfg(not(feature = "allocation-tracking"))]
351    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
352        // SAFETY: forwarded from the caller with the matching layout.
353        unsafe { System.dealloc(ptr, layout) }
354    }
355}
356
357/// Whether any tracked allocation has run through a [`TrackingAllocator`].
358pub fn allocation_tracking_installed() -> bool {
359    #[cfg(feature = "allocation-tracking")]
360    {
361        tracked::INSTALLED.load(std::sync::atomic::Ordering::Relaxed)
362    }
363    #[cfg(not(feature = "allocation-tracking"))]
364    {
365        false
366    }
367}
368
369/// Per-slot allocation counters at one instant.
370#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
371pub struct AllocationSlotV2 {
372    pub allocations: u64,
373    pub allocated_bytes: u64,
374    pub live_bytes: u64,
375    pub peak_live_bytes: u64,
376}
377
378/// Process-wide allocation counters at one instant, split by owning stage.
379#[derive(Clone, Debug, Eq, PartialEq)]
380pub struct AllocationSnapshotV2 {
381    pub allocations: u64,
382    pub deallocations: u64,
383    pub allocated_bytes: u64,
384    pub deallocated_bytes: u64,
385    pub live_bytes: u64,
386    pub peak_live_bytes: u64,
387    /// One slot per [`crate::Stage`] in wire order plus the root slot.
388    pub slots: [AllocationSlotV2; STAGE_SLOTS],
389}
390
391impl AllocationSnapshotV2 {
392    /// Counters owned by one stage, or the root slot for unattributed work.
393    pub fn slot(&self, stage: crate::Stage) -> &AllocationSlotV2 {
394        &self.slots[stage.index()]
395    }
396
397    /// Counters for allocations made outside any recorded span.
398    pub fn root(&self) -> &AllocationSlotV2 {
399        &self.slots[ROOT_SLOT]
400    }
401
402    /// Live bytes delta between two snapshots.
403    pub fn live_delta_since(&self, start: &Self) -> u64 {
404        self.live_bytes.saturating_sub(start.live_bytes)
405    }
406}
407
408/// Snapshot the process-wide allocation counters; all zeros when the
409/// `allocation-tracking` feature is disabled.
410pub fn allocation_snapshot() -> AllocationSnapshotV2 {
411    let (allocations, deallocations, allocated_bytes, deallocated_bytes, live_bytes, peak) =
412        backend::snapshot_totals();
413    AllocationSnapshotV2 {
414        allocations,
415        deallocations,
416        allocated_bytes,
417        deallocated_bytes,
418        live_bytes,
419        peak_live_bytes: peak,
420        slots: std::array::from_fn(backend::snapshot_slot),
421    }
422}
423
424/// Restart peak-live tracking from the current live levels.
425///
426/// A sole session calls this at start so its reported peak covers exactly its
427/// own window. The tracker is process-wide: overlapping sessions must not reset
428/// each other's peaks — `SystemSession` enforces that fail-closed.
429pub fn reset_allocation_peaks() {
430    backend::reset_peaks();
431}
432
433/// RAII participation in the process-global allocation session window.
434///
435/// Dropping the token leaves the active-session count. Evidence is reliable
436/// only while this token is the sole uncontaminated participant.
437pub(crate) struct AllocationSessionToken {
438    active: bool,
439    overlapped: bool,
440}
441
442impl AllocationSessionToken {
443    pub(crate) const fn inactive() -> Self {
444        Self {
445            active: false,
446            overlapped: false,
447        }
448    }
449
450    pub(crate) fn evidence_is_reliable(&self) -> bool {
451        if !self.active {
452            return true;
453        }
454        #[cfg(feature = "allocation-tracking")]
455        {
456            backend::session_evidence_reliable(self.overlapped)
457        }
458        #[cfg(not(feature = "allocation-tracking"))]
459        {
460            true
461        }
462    }
463}
464
465impl Drop for AllocationSessionToken {
466    fn drop(&mut self) {
467        if !self.active {
468            return;
469        }
470        self.active = false;
471        #[cfg(feature = "allocation-tracking")]
472        {
473            backend::leave_session();
474        }
475    }
476}
477
478/// Snapshot-then-enter helper used by [`crate::system::SystemSession`].
479///
480/// Peaks reset only when this session is the sole active participant.
481pub(crate) fn enter_allocation_session() -> AllocationSessionToken {
482    #[cfg(feature = "allocation-tracking")]
483    {
484        let (active, overlapped) = backend::enter_session();
485        AllocationSessionToken { active, overlapped }
486    }
487    #[cfg(not(feature = "allocation-tracking"))]
488    {
489        AllocationSessionToken::inactive()
490    }
491}
492
493pub(crate) fn allocation_capability() -> CollectorCapability {
494    #[cfg(not(feature = "allocation-tracking"))]
495    {
496        CollectorCapability::unavailable(
497            CollectorId::AllocationTracking,
498            CollectorAvailability::Disabled,
499            "enable the keyhog-profile allocation-tracking feature",
500        )
501    }
502    #[cfg(feature = "allocation-tracking")]
503    {
504        if allocation_tracking_installed() {
505            CollectorCapability::available(CollectorId::AllocationTracking)
506        } else {
507            CollectorCapability::unavailable(
508                CollectorId::AllocationTracking,
509                CollectorAvailability::Unavailable,
510                "install keyhog_profile::TrackingAllocator as the global allocator to count allocations",
511            )
512        }
513    }
514}