blad_mem/lib.rs
1//! Memory instrumentation.
2//!
3//! Two numbers, deliberately, because they answer different questions and their
4//! *difference* is itself informative:
5//!
6//! - **Heap** — bytes we allocated, counted exactly by wrapping the global allocator.
7//! Resettable, so it can be attributed per phase.
8//! - **RSS** — what the OS considers resident, a monotonic high-water mark. Includes
9//! things the heap counter cannot see: mapped libraries, allocator overhead, and any
10//! memory allocated inside vendored C++ rather than through Rust's global allocator.
11//!
12//! The gap between them is the cost of everything that is not our data structures. For
13//! blad that is dominated by libjxl's own working set, which the heap counter cannot
14//! see at all — exactly the kind of thing that stays invisible if you only measure one.
15//!
16//! Both numbers are for **this process only**. `/usr/bin/time -l` includes children and
17//! will attribute a subprocess's peak to us; that mistake was made once here already.
18//!
19//! # Usage
20//!
21//! The binary installs the allocator:
22//!
23//! ```ignore
24//! #[global_allocator]
25//! static ALLOC: blad_mem::Tracking<std::alloc::System> =
26//! blad_mem::Tracking(std::alloc::System);
27//! ```
28//!
29//! Without that, [`heap_peak`] reports zero and everything still works — instrumentation
30//! must never be load-bearing.
31
32use std::alloc::{GlobalAlloc, Layout};
33use std::sync::atomic::{AtomicUsize, Ordering};
34
35static CURRENT: AtomicUsize = AtomicUsize::new(0);
36static PEAK: AtomicUsize = AtomicUsize::new(0);
37
38/// Global allocator wrapper that tracks live bytes and a resettable high-water mark.
39///
40/// Counters use relaxed ordering: they are diagnostics, not synchronisation, and the
41/// cost must stay near zero on the allocation path.
42pub struct Tracking<A>(pub A);
43
44unsafe impl<A: GlobalAlloc> GlobalAlloc for Tracking<A> {
45 unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
46 let p = unsafe { self.0.alloc(layout) };
47 if !p.is_null() {
48 bump(layout.size());
49 }
50 p
51 }
52
53 unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
54 CURRENT.fetch_sub(layout.size(), Ordering::Relaxed);
55 unsafe { self.0.dealloc(ptr, layout) }
56 }
57
58 unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
59 let p = unsafe { self.0.realloc(ptr, layout, new_size) };
60 if !p.is_null() {
61 if new_size >= layout.size() {
62 bump(new_size - layout.size());
63 } else {
64 CURRENT.fetch_sub(layout.size() - new_size, Ordering::Relaxed);
65 }
66 }
67 p
68 }
69}
70
71fn bump(n: usize) {
72 let now = CURRENT.fetch_add(n, Ordering::Relaxed) + n;
73 // Not atomic with the add, so under contention the recorded peak can lag slightly.
74 // Acceptable for a diagnostic; a lock here would be far more costly than the error.
75 PEAK.fetch_max(now, Ordering::Relaxed);
76}
77
78/// Bytes currently allocated through the tracking allocator.
79pub fn heap_current() -> u64 {
80 CURRENT.load(Ordering::Relaxed) as u64
81}
82
83/// Highest [`heap_current`] seen since the last [`reset_heap_peak`].
84pub fn heap_peak() -> u64 {
85 PEAK.load(Ordering::Relaxed) as u64
86}
87
88/// Drop the high-water mark to the current level, so the next phase is measured on its
89/// own rather than inheriting the previous one's peak.
90pub fn reset_heap_peak() {
91 PEAK.store(CURRENT.load(Ordering::Relaxed), Ordering::Relaxed);
92}
93
94/// Process peak resident set size, in bytes.
95///
96/// Monotonic — it never falls — so sampling it at phase boundaries tells you *which*
97/// phase pushed the peak, which is the question worth answering.
98///
99/// `ru_maxrss` is bytes on macOS and kilobytes on Linux. Getting that wrong yields
100/// silently 1024x-wrong numbers, which is worse than no measurement.
101#[cfg(unix)]
102pub fn rss_highwater() -> u64 {
103 unsafe {
104 let mut ru: libc::rusage = std::mem::zeroed();
105 if libc::getrusage(libc::RUSAGE_SELF, &mut ru) != 0 {
106 return 0;
107 }
108 let v = ru.ru_maxrss as u64;
109 if cfg!(target_os = "macos") {
110 v
111 } else {
112 v * 1024
113 }
114 }
115}
116
117/// Process peak resident set size, in bytes.
118///
119/// Windows has no `getrusage`; `PeakWorkingSetSize` is the equivalent high-water mark,
120/// and is already in bytes, so there is no unit trap on this side.
121#[cfg(windows)]
122pub fn rss_highwater() -> u64 {
123 use windows_sys::Win32::System::ProcessStatus::{
124 K32GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS,
125 };
126 use windows_sys::Win32::System::Threading::GetCurrentProcess;
127
128 unsafe {
129 let mut pmc: PROCESS_MEMORY_COUNTERS = std::mem::zeroed();
130 let size = std::mem::size_of::<PROCESS_MEMORY_COUNTERS>() as u32;
131 if K32GetProcessMemoryInfo(GetCurrentProcess(), &mut pmc, size) == 0 {
132 return 0;
133 }
134 pmc.PeakWorkingSetSize as u64
135 }
136}
137
138/// Process peak resident set size, in bytes.
139///
140/// No implementation for this target. Instrumentation is never load-bearing, so
141/// reporting zero degrades `--stats` rather than breaking the build.
142#[cfg(not(any(unix, windows)))]
143pub fn rss_highwater() -> u64 {
144 0
145}
146
147/// Time, heap, and RSS for one phase of work.
148#[derive(Debug, Clone, Copy, Default)]
149pub struct Phase {
150 pub time: std::time::Duration,
151 /// Peak bytes allocated *during this phase*.
152 pub heap_peak: u64,
153 /// Process RSS high-water after this phase. Monotonic across phases: a phase that
154 /// does not raise it did not drive peak memory.
155 pub rss_after: u64,
156}
157
158/// Run `f`, timing it and measuring its heap peak in isolation.
159pub fn measure<T>(f: impl FnOnce() -> T) -> (T, Phase) {
160 reset_heap_peak();
161 let start = std::time::Instant::now();
162 let out = f();
163 let phase = Phase {
164 time: start.elapsed(),
165 heap_peak: heap_peak(),
166 rss_after: rss_highwater(),
167 };
168 (out, phase)
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 /// Without the allocator installed the heap counters read zero and nothing panics —
176 /// instrumentation must never be load-bearing.
177 ///
178 /// Deliberately no assertion on elapsed time: that would be testing the clock, and
179 /// a zero-duration reading is legitimate rather than a bug.
180 #[test]
181 fn works_without_allocator_installed() {
182 reset_heap_peak();
183 let (v, p) = measure(|| {
184 let big: Vec<u8> = vec![7u8; 4 << 20];
185 std::hint::black_box(&big);
186 big.len()
187 });
188 assert_eq!(v, 4 << 20);
189 assert!(p.rss_after > 0, "rss should always be readable");
190 }
191
192 #[test]
193 fn rss_is_plausible() {
194 // Any live process has some resident memory; zero would mean getrusage failed.
195 assert!(rss_highwater() > 0);
196 }
197
198 #[test]
199 fn reset_lowers_the_peak() {
200 {
201 let _big: Vec<u8> = vec![0u8; 1 << 20];
202 }
203 reset_heap_peak();
204 assert!(heap_peak() >= heap_current());
205 }
206}