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
156        assert_eq!(allocated_mib, allocated as f64 / 1_048_576.0);
157        assert_eq!(peak_mib, peak as f64 / 1_048_576.0);
158    }
159
160    #[cfg(not(feature = "jemalloc"))]
161    #[test]
162    fn stats_returns_non_negative_counters() {
163        let (allocated, peak) = super::stats();
164
165        assert!(allocated >= 0.0);
166        assert!(peak >= 0.0);
167    }
168
169    #[cfg(not(feature = "jemalloc"))]
170    #[test]
171    fn record_alloc_updates_current_and_peak_bytes() {
172        let _guard = reset_tracking_counters();
173
174        super::record_alloc(256);
175        assert_stats_bytes(256, 256);
176
177        super::record_alloc(128);
178        assert_stats_bytes(384, 384);
179    }
180
181    #[cfg(not(feature = "jemalloc"))]
182    #[test]
183    fn record_dealloc_reduces_current_without_lowering_peak() {
184        let _guard = reset_tracking_counters();
185
186        super::record_alloc(512);
187        super::record_dealloc(128);
188
189        assert_stats_bytes(384, 512);
190    }
191
192    #[cfg(not(feature = "jemalloc"))]
193    #[test]
194    fn tracking_alloc_records_alloc_and_dealloc() {
195        let _guard = reset_tracking_counters();
196        let allocator = super::TrackingAlloc;
197        let layout = Layout::from_size_align(64, 8).unwrap();
198
199        // SAFETY: `layout` is non-zero and valid. The returned pointer is checked
200        // for null before use and released once with the same allocator and layout.
201        let ptr = unsafe { allocator.alloc(layout) };
202        assert!(!ptr.is_null());
203        assert_stats_bytes(64, 64);
204
205        // SAFETY: `ptr` was allocated by `allocator.alloc(layout)` above and has
206        // not been deallocated yet.
207        unsafe { allocator.dealloc(ptr, layout) };
208        assert_stats_bytes(0, 64);
209    }
210
211    #[cfg(not(feature = "jemalloc"))]
212    #[test]
213    fn tracking_alloc_zeroed_returns_zeroed_memory_and_records_size() {
214        let _guard = reset_tracking_counters();
215        let allocator = super::TrackingAlloc;
216        let layout = Layout::from_size_align(32, 8).unwrap();
217
218        // SAFETY: `layout` is non-zero and valid. The returned pointer is checked
219        // for null before reading and released once with the same allocator/layout.
220        let ptr = unsafe { allocator.alloc_zeroed(layout) };
221        assert!(!ptr.is_null());
222
223        // SAFETY: `ptr` references `layout.size()` initialized bytes because
224        // `alloc_zeroed` succeeded.
225        let bytes = unsafe { std::slice::from_raw_parts(ptr, layout.size()) };
226        assert!(bytes.iter().all(|byte| *byte == 0));
227        assert_stats_bytes(32, 32);
228
229        // SAFETY: `ptr` was allocated by `allocator.alloc_zeroed(layout)` above
230        // and has not been deallocated yet.
231        unsafe { allocator.dealloc(ptr, layout) };
232        assert_stats_bytes(0, 32);
233    }
234
235    #[cfg(not(feature = "jemalloc"))]
236    #[test]
237    fn tracking_alloc_realloc_grow_and_shrink_adjusts_counters() {
238        let _guard = reset_tracking_counters();
239        let allocator = super::TrackingAlloc;
240        let initial_layout = Layout::from_size_align(16, 8).unwrap();
241
242        // SAFETY: `initial_layout` is non-zero and valid. The returned pointer is
243        // checked for null before use and remains owned until the final dealloc.
244        let ptr = unsafe { allocator.alloc(initial_layout) };
245        assert!(!ptr.is_null());
246
247        // SAFETY: `ptr` references at least 16 writable bytes from the allocation above.
248        unsafe { ptr::write_bytes(ptr, 0xAB, initial_layout.size()) };
249
250        // SAFETY: `ptr` was allocated with `initial_layout` and has not been freed.
251        let grown_ptr = unsafe { allocator.realloc(ptr, initial_layout, 64) };
252        assert!(!grown_ptr.is_null());
253        assert_stats_bytes(64, 64);
254
255        // SAFETY: The first 16 bytes must remain valid after successful `realloc`.
256        let preserved = unsafe { std::slice::from_raw_parts(grown_ptr, initial_layout.size()) };
257        assert!(preserved.iter().all(|byte| *byte == 0xAB));
258
259        let grown_layout = Layout::from_size_align(64, 8).unwrap();
260        // SAFETY: `grown_ptr` was allocated by the successful realloc above with
261        // `grown_layout.size()` bytes and has not been freed.
262        let shrunk_ptr = unsafe { allocator.realloc(grown_ptr, grown_layout, 24) };
263        assert!(!shrunk_ptr.is_null());
264        assert_stats_bytes(24, 64);
265
266        let shrunk_layout = Layout::from_size_align(24, 8).unwrap();
267        // SAFETY: `shrunk_ptr` is the live pointer from the successful shrink and
268        // `shrunk_layout` matches the new allocation size and alignment.
269        unsafe { allocator.dealloc(shrunk_ptr, shrunk_layout) };
270        assert_stats_bytes(0, 64);
271    }
272
273    #[cfg(not(feature = "jemalloc"))]
274    #[test]
275    fn tracking_alloc_realloc_same_size_leaves_counters_unchanged() {
276        let _guard = reset_tracking_counters();
277        let allocator = super::TrackingAlloc;
278        let layout = Layout::from_size_align(40, 8).unwrap();
279
280        // SAFETY: `layout` is non-zero and valid. The returned pointer is checked
281        // for null before use and remains owned until the final dealloc.
282        let ptr = unsafe { allocator.alloc(layout) };
283        assert!(!ptr.is_null());
284        assert_stats_bytes(40, 40);
285
286        // SAFETY: `ptr` was allocated with `layout` and has not been freed. Passing
287        // the existing allocation size exercises the equal-size realloc path.
288        let same_size_ptr = unsafe { allocator.realloc(ptr, layout, layout.size()) };
289        assert!(!same_size_ptr.is_null());
290        assert_stats_bytes(40, 40);
291
292        // SAFETY: `same_size_ptr` is the live pointer returned by realloc and
293        // `layout` still matches the allocation size and alignment.
294        unsafe { allocator.dealloc(same_size_ptr, layout) };
295        assert_stats_bytes(0, 40);
296    }
297
298    #[cfg(all(feature = "jemalloc", not(feature = "jemalloc-stats")))]
299    #[test]
300    fn jemalloc_without_stats_returns_zeroes() {
301        assert_eq!(super::stats(), (0.0, 0.0));
302    }
303}