Skip to main content

aster_forge_alloc/
lib.rs

1//! Shared allocator tracking and memory statistics helpers.
2//!
3//! Applications still own their `#[global_allocator]` selection and any
4//! platform-specific allocator configuration. This crate provides the reusable
5//! pieces used by Aster services: a debug tracking allocator for system-allocator
6//! builds, and a single `stats` API that reports either tracked system
7//! allocation counters or jemalloc counters depending on enabled features.
8#![cfg_attr(
9    not(test),
10    deny(
11        clippy::unwrap_used,
12        clippy::unreachable,
13        clippy::expect_used,
14        clippy::panic,
15        clippy::unimplemented,
16        clippy::todo
17    )
18)]
19
20#[cfg(not(feature = "jemalloc"))]
21use std::alloc::{GlobalAlloc, Layout, System};
22#[cfg(not(feature = "jemalloc"))]
23use std::sync::atomic::{AtomicUsize, Ordering};
24
25/// Current tracked heap allocation in bytes for system-allocator builds.
26#[cfg(not(feature = "jemalloc"))]
27pub static ALLOCATED: AtomicUsize = AtomicUsize::new(0);
28/// Peak tracked heap allocation in bytes for system-allocator builds.
29#[cfg(not(feature = "jemalloc"))]
30pub static PEAK: AtomicUsize = AtomicUsize::new(0);
31
32/// Global allocator wrapper that records current and peak allocation sizes.
33#[cfg(not(feature = "jemalloc"))]
34pub struct TrackingAlloc;
35
36#[cfg(not(feature = "jemalloc"))]
37#[inline]
38fn record_alloc(size: usize) {
39    let current = ALLOCATED.fetch_add(size, Ordering::Relaxed) + size;
40    PEAK.fetch_max(current, Ordering::Relaxed);
41}
42
43#[cfg(not(feature = "jemalloc"))]
44#[inline]
45fn record_dealloc(size: usize) {
46    ALLOCATED.fetch_sub(size, Ordering::Relaxed);
47}
48
49#[cfg(not(feature = "jemalloc"))]
50// SAFETY: `TrackingAlloc` delegates all allocation operations to `System` with
51// caller-provided layouts and pointers unchanged, and only updates independent
52// atomic counters after successful allocation-size changes.
53unsafe impl GlobalAlloc for TrackingAlloc {
54    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
55        // SAFETY: `TrackingAlloc` preserves the caller's `GlobalAlloc::alloc`
56        // contract and forwards the exact layout to the system allocator.
57        let ptr = unsafe { System.alloc(layout) };
58        if !ptr.is_null() {
59            record_alloc(layout.size());
60        }
61        ptr
62    }
63
64    unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
65        // SAFETY: `TrackingAlloc` preserves the caller's `GlobalAlloc::alloc_zeroed`
66        // contract and forwards the exact layout to the system allocator.
67        let ptr = unsafe { System.alloc_zeroed(layout) };
68        if !ptr.is_null() {
69            record_alloc(layout.size());
70        }
71        ptr
72    }
73
74    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
75        record_dealloc(layout.size());
76        // SAFETY: `TrackingAlloc` preserves the caller's `GlobalAlloc::dealloc`
77        // contract and forwards the original pointer and layout unchanged.
78        unsafe { System.dealloc(ptr, layout) };
79    }
80
81    unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
82        // SAFETY: `TrackingAlloc` preserves the caller's `GlobalAlloc::realloc`
83        // contract and forwards the original pointer, layout, and requested size.
84        let new_ptr = unsafe { System.realloc(ptr, layout, new_size) };
85        if !new_ptr.is_null() {
86            match new_size.cmp(&layout.size()) {
87                std::cmp::Ordering::Greater => record_alloc(new_size - layout.size()),
88                std::cmp::Ordering::Less => record_dealloc(layout.size() - new_size),
89                std::cmp::Ordering::Equal => {}
90            }
91        }
92        new_ptr
93    }
94}
95
96/// Returns current and peak tracked allocations in MiB for system-allocator builds.
97#[cfg(not(feature = "jemalloc"))]
98#[expect(
99    clippy::cast_precision_loss,
100    reason = "Allocator metrics are approximate diagnostics reported as f64 MiB values."
101)]
102#[must_use]
103pub fn stats() -> (f64, f64) {
104    let allocated = ALLOCATED.load(Ordering::Relaxed) as f64 / 1_048_576.0;
105    let peak = PEAK.load(Ordering::Relaxed) as f64 / 1_048_576.0;
106    (allocated, peak)
107}
108
109/// Returns current allocated and resident memory in MiB for jemalloc stats builds.
110#[cfg(feature = "jemalloc-stats")]
111#[expect(
112    clippy::cast_precision_loss,
113    reason = "Allocator metrics are approximate diagnostics reported as f64 MiB values."
114)]
115#[must_use]
116pub fn stats() -> (f64, f64) {
117    if let Err(error) = tikv_jemalloc_ctl::epoch::advance() {
118        tracing::warn!(error = %error, "failed to refresh jemalloc stats epoch");
119    }
120
121    let allocated = tikv_jemalloc_ctl::stats::allocated::read().unwrap_or(0) as f64 / 1_048_576.0;
122    let resident = tikv_jemalloc_ctl::stats::resident::read().unwrap_or(0) as f64 / 1_048_576.0;
123    (allocated, resident)
124}
125
126/// Returns zeroed counters for jemalloc builds without the stats feature.
127#[cfg(all(feature = "jemalloc", not(feature = "jemalloc-stats")))]
128pub fn stats() -> (f64, f64) {
129    (0.0, 0.0)
130}
131
132#[cfg(test)]
133mod tests {
134    #[cfg(not(feature = "jemalloc"))]
135    use std::{
136        alloc::{GlobalAlloc, Layout},
137        ptr,
138        sync::{Mutex, MutexGuard},
139    };
140
141    #[cfg(not(feature = "jemalloc"))]
142    static TEST_ALLOC_LOCK: Mutex<()> = Mutex::new(());
143
144    #[cfg(not(feature = "jemalloc"))]
145    fn reset_tracking_counters() -> MutexGuard<'static, ()> {
146        let guard = TEST_ALLOC_LOCK.lock().unwrap();
147        super::ALLOCATED.store(0, std::sync::atomic::Ordering::Relaxed);
148        super::PEAK.store(0, std::sync::atomic::Ordering::Relaxed);
149        guard
150    }
151
152    #[cfg(not(feature = "jemalloc"))]
153    fn assert_stats_bytes(allocated: usize, peak: usize) {
154        let (allocated_mib, peak_mib) = super::stats();
155        let mib_per_byte = 1.0 / 1_048_576.0;
156        let expected_allocated_mib =
157            f64::from(u32::try_from(allocated).expect("test allocation fits in u32"))
158                * mib_per_byte;
159        let expected_peak_mib =
160            f64::from(u32::try_from(peak).expect("test allocation fits in u32")) * mib_per_byte;
161
162        assert!((allocated_mib - expected_allocated_mib).abs() < f64::EPSILON);
163        assert!((peak_mib - expected_peak_mib).abs() < f64::EPSILON);
164    }
165
166    #[cfg(not(feature = "jemalloc"))]
167    #[test]
168    fn stats_returns_non_negative_counters() {
169        let (allocated, peak) = super::stats();
170
171        assert!(allocated >= 0.0);
172        assert!(peak >= 0.0);
173    }
174
175    #[cfg(not(feature = "jemalloc"))]
176    #[test]
177    fn record_alloc_updates_current_and_peak_bytes() {
178        let _guard = reset_tracking_counters();
179
180        super::record_alloc(256);
181        assert_stats_bytes(256, 256);
182
183        super::record_alloc(128);
184        assert_stats_bytes(384, 384);
185    }
186
187    #[cfg(not(feature = "jemalloc"))]
188    #[test]
189    fn record_dealloc_reduces_current_without_lowering_peak() {
190        let _guard = reset_tracking_counters();
191
192        super::record_alloc(512);
193        super::record_dealloc(128);
194
195        assert_stats_bytes(384, 512);
196    }
197
198    #[cfg(not(feature = "jemalloc"))]
199    #[test]
200    fn tracking_alloc_records_alloc_and_dealloc() {
201        let _guard = reset_tracking_counters();
202        let allocator = super::TrackingAlloc;
203        let layout = Layout::from_size_align(64, 8).unwrap();
204
205        // SAFETY: `layout` is non-zero and valid. The returned pointer is checked
206        // for null before use and released once with the same allocator and layout.
207        let ptr = unsafe { allocator.alloc(layout) };
208        assert!(!ptr.is_null());
209        assert_stats_bytes(64, 64);
210
211        // SAFETY: `ptr` was allocated by `allocator.alloc(layout)` above and has
212        // not been deallocated yet.
213        unsafe { allocator.dealloc(ptr, layout) };
214        assert_stats_bytes(0, 64);
215    }
216
217    #[cfg(not(feature = "jemalloc"))]
218    #[test]
219    fn tracking_alloc_zeroed_returns_zeroed_memory_and_records_size() {
220        let _guard = reset_tracking_counters();
221        let allocator = super::TrackingAlloc;
222        let layout = Layout::from_size_align(32, 8).unwrap();
223
224        // SAFETY: `layout` is non-zero and valid. The returned pointer is checked
225        // for null before reading and released once with the same allocator/layout.
226        let ptr = unsafe { allocator.alloc_zeroed(layout) };
227        assert!(!ptr.is_null());
228
229        // SAFETY: `ptr` references `layout.size()` initialized bytes because
230        // `alloc_zeroed` succeeded.
231        let bytes = unsafe { std::slice::from_raw_parts(ptr, layout.size()) };
232        assert!(bytes.iter().all(|byte| *byte == 0));
233        assert_stats_bytes(32, 32);
234
235        // SAFETY: `ptr` was allocated by `allocator.alloc_zeroed(layout)` above
236        // and has not been deallocated yet.
237        unsafe { allocator.dealloc(ptr, layout) };
238        assert_stats_bytes(0, 32);
239    }
240
241    #[cfg(not(feature = "jemalloc"))]
242    #[test]
243    fn tracking_alloc_realloc_grow_and_shrink_adjusts_counters() {
244        let _guard = reset_tracking_counters();
245        let allocator = super::TrackingAlloc;
246        let initial_layout = Layout::from_size_align(16, 8).unwrap();
247
248        // SAFETY: `initial_layout` is non-zero and valid. The returned pointer is
249        // checked for null before use and remains owned until the final dealloc.
250        let ptr = unsafe { allocator.alloc(initial_layout) };
251        assert!(!ptr.is_null());
252
253        // SAFETY: `ptr` references at least 16 writable bytes from the allocation above.
254        unsafe { ptr::write_bytes(ptr, 0xAB, initial_layout.size()) };
255
256        // SAFETY: `ptr` was allocated with `initial_layout` and has not been freed.
257        let grown_ptr = unsafe { allocator.realloc(ptr, initial_layout, 64) };
258        assert!(!grown_ptr.is_null());
259        assert_stats_bytes(64, 64);
260
261        // SAFETY: The first 16 bytes must remain valid after successful `realloc`.
262        let preserved = unsafe { std::slice::from_raw_parts(grown_ptr, initial_layout.size()) };
263        assert!(preserved.iter().all(|byte| *byte == 0xAB));
264
265        let grown_layout = Layout::from_size_align(64, 8).unwrap();
266        // SAFETY: `grown_ptr` was allocated by the successful realloc above with
267        // `grown_layout.size()` bytes and has not been freed.
268        let shrunk_ptr = unsafe { allocator.realloc(grown_ptr, grown_layout, 24) };
269        assert!(!shrunk_ptr.is_null());
270        assert_stats_bytes(24, 64);
271
272        let shrunk_layout = Layout::from_size_align(24, 8).unwrap();
273        // SAFETY: `shrunk_ptr` is the live pointer from the successful shrink and
274        // `shrunk_layout` matches the new allocation size and alignment.
275        unsafe { allocator.dealloc(shrunk_ptr, shrunk_layout) };
276        assert_stats_bytes(0, 64);
277    }
278
279    #[cfg(not(feature = "jemalloc"))]
280    #[test]
281    fn tracking_alloc_realloc_same_size_leaves_counters_unchanged() {
282        let _guard = reset_tracking_counters();
283        let allocator = super::TrackingAlloc;
284        let layout = Layout::from_size_align(40, 8).unwrap();
285
286        // SAFETY: `layout` is non-zero and valid. The returned pointer is checked
287        // for null before use and remains owned until the final dealloc.
288        let ptr = unsafe { allocator.alloc(layout) };
289        assert!(!ptr.is_null());
290        assert_stats_bytes(40, 40);
291
292        // SAFETY: `ptr` was allocated with `layout` and has not been freed. Passing
293        // the existing allocation size exercises the equal-size realloc path.
294        let same_size_ptr = unsafe { allocator.realloc(ptr, layout, layout.size()) };
295        assert!(!same_size_ptr.is_null());
296        assert_stats_bytes(40, 40);
297
298        // SAFETY: `same_size_ptr` is the live pointer returned by realloc and
299        // `layout` still matches the allocation size and alignment.
300        unsafe { allocator.dealloc(same_size_ptr, layout) };
301        assert_stats_bytes(0, 40);
302    }
303
304    #[cfg(all(feature = "jemalloc", not(feature = "jemalloc-stats")))]
305    #[test]
306    fn jemalloc_without_stats_returns_zeroes() {
307        assert_eq!(super::stats(), (0.0, 0.0));
308    }
309}