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