Skip to main content

whycodes_core/
process_memory.rs

1//! Keep resident memory close to what the process actually uses.
2//!
3//! glibc keeps freed heap pages in its arenas, so after a burst (a long
4//! history loaded, a big request built, a turn finished) the resident set
5//! stays high even though the program no longer needs the memory. This module
6//! limits that and gives the pages back:
7//!
8//! - [`configure_system_allocator`] caps the number of malloc arenas and pins
9//!   the mmap threshold, so large transient buffers are mmapped and returned
10//!   to the OS as soon as they are freed (glibc otherwise raises the threshold
11//!   dynamically and keeps them in the heap).
12//! - [`release_retained_heap`] runs `malloc_trim(0)`.
13//! - [`RetentionWatch`] trims when memory that is resident but not allocated
14//!   grows past the lowest level seen since the last trim.
15//!
16//! Everything is a no-op outside Linux/glibc.
17
18use std::sync::Mutex;
19use std::time::{Duration, Instant};
20
21/// Default cap on glibc malloc arenas (glibc's own default is 8 × cores).
22pub const DEFAULT_ARENA_MAX: i32 = 4;
23/// Default mmap threshold: allocations at least this big bypass the arenas.
24pub const DEFAULT_MMAP_THRESHOLD: i32 = 256 * 1024;
25
26static LAST_TRIM: Mutex<Option<Instant>> = Mutex::new(None);
27
28/// Apply the allocator limits once at startup. `WHYCODES_GLIBC_ARENA_MAX` and
29/// `WHYCODES_GLIBC_MMAP_THRESHOLD` override the defaults; `0` keeps glibc's
30/// own behaviour for that setting.
31pub fn configure_system_allocator() {
32    let arena_max = env_int("WHYCODES_GLIBC_ARENA_MAX", DEFAULT_ARENA_MAX);
33    let mmap_threshold = env_int("WHYCODES_GLIBC_MMAP_THRESHOLD", DEFAULT_MMAP_THRESHOLD);
34    glibc::set_limits(arena_max, mmap_threshold);
35}
36
37fn env_int(key: &str, default: i32) -> i32 {
38    parse_int(std::env::var(key).as_deref().unwrap_or(""), default)
39}
40
41fn parse_int(raw: &str, default: i32) -> i32 {
42    raw.trim().parse().unwrap_or(default)
43}
44
45/// Return freed-but-retained heap pages to the OS.
46pub fn release_retained_heap(reason: &'static str) {
47    let trimmed = glibc::trim();
48    tracing::debug!(reason, trimmed, "released retained heap");
49    *crate::file_claims::recover_lock(LAST_TRIM.lock()) = Some(Instant::now());
50}
51
52/// [`release_retained_heap`] unless one already ran within `min_interval`.
53/// Returns whether it ran.
54pub fn release_retained_heap_debounced(reason: &'static str, min_interval: Duration) -> bool {
55    let last = *crate::file_claims::recover_lock(LAST_TRIM.lock());
56    let recent = last.is_some_and(|t| t.elapsed() < min_interval);
57    if recent {
58        return false;
59    }
60    release_retained_heap(reason);
61    true
62}
63
64/// Resident anonymous memory not held by live allocations: what a trim could
65/// give back. `None` where it cannot be measured.
66pub fn retained_bytes() -> Option<u64> {
67    let rss_anon = rss_anon_bytes()?;
68    let live = glibc::live_bytes()?;
69    Some(rss_anon.saturating_sub(live))
70}
71
72/// `RssAnon` of this process in bytes (Linux).
73#[cfg(target_os = "linux")]
74pub fn rss_anon_bytes() -> Option<u64> {
75    let status = std::fs::read_to_string("/proc/self/status");
76    status.ok().and_then(|s| parse_rss_anon(&s))
77}
78
79/// `RssAnon` of this process in bytes (Linux).
80#[cfg(not(target_os = "linux"))]
81pub fn rss_anon_bytes() -> Option<u64> {
82    None
83}
84
85/// `RssAnon:` from a `/proc/<pid>/status` body, in bytes.
86pub fn parse_rss_anon(status: &str) -> Option<u64> {
87    let line = status.lines().find(|l| l.starts_with("RssAnon:"))?;
88    let kb: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
89    Some(kb * 1024)
90}
91
92/// Trims when retained memory grows `threshold` bytes past a baseline. The
93/// baseline is the level measured after the last trim and only moves down
94/// between trims: glibc's free-chunk counters do not drop after a trim, so a
95/// plain threshold would fire again on every check.
96#[derive(Debug)]
97pub struct RetentionWatch {
98    threshold: u64,
99    baseline: Option<u64>,
100}
101
102impl RetentionWatch {
103    pub fn new(threshold_bytes: u64) -> Self {
104        Self {
105            threshold: threshold_bytes,
106            baseline: None,
107        }
108    }
109
110    /// Measure and trim if retention grew too much. Returns whether it trimmed.
111    pub fn check(&mut self, reason: &'static str) -> bool {
112        retained_bytes().is_some_and(|r| self.observe(r, reason, retained_bytes))
113    }
114
115    /// Decide on one measurement; `after_trim` re-measures once trimmed.
116    pub fn observe(
117        &mut self,
118        retained: u64,
119        reason: &'static str,
120        after_trim: fn() -> Option<u64>,
121    ) -> bool {
122        let baseline = *self.baseline.get_or_insert(retained);
123        if retained < baseline {
124            self.baseline = Some(retained);
125        }
126        if retained.saturating_sub(baseline) < self.threshold {
127            return false;
128        }
129        release_retained_heap(reason);
130        self.baseline = Some(after_trim().unwrap_or(retained));
131        true
132    }
133
134    /// The level new growth is measured against.
135    pub fn baseline(&self) -> Option<u64> {
136        self.baseline
137    }
138}
139
140#[cfg(all(target_os = "linux", target_env = "gnu"))]
141mod glibc {
142    use std::ffi::{c_char, c_int, c_void};
143
144    // <malloc.h> mallopt parameters.
145    const M_MMAP_THRESHOLD: c_int = -3;
146    const M_ARENA_MAX: c_int = -8;
147
148    // Field layout of `struct mallinfo2`; only two fields are read.
149    #[repr(C)]
150    #[allow(dead_code)]
151    struct Mallinfo2 {
152        arena: usize,
153        ordblks: usize,
154        smblks: usize,
155        hblks: usize,
156        hblkhd: usize,
157        usmblks: usize,
158        fsmblks: usize,
159        uordblks: usize,
160        fordblks: usize,
161        keepcost: usize,
162    }
163
164    unsafe extern "C" {
165        fn mallopt(param: c_int, value: c_int) -> c_int;
166        fn malloc_trim(pad: usize) -> c_int;
167        fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void;
168    }
169
170    pub(super) fn set_limits(arena_max: i32, mmap_threshold: i32) {
171        let limits = [(M_ARENA_MAX, arena_max), (M_MMAP_THRESHOLD, mmap_threshold)];
172        // A value of 0 (or less) leaves glibc's own setting.
173        for (param, value) in limits.into_iter().filter(|&(_, v)| v > 0) {
174            // SAFETY: mallopt only adjusts allocator tunables; invalid values
175            // are rejected by glibc with a 0 return.
176            unsafe { mallopt(param, value) };
177        }
178    }
179
180    pub(super) fn trim() -> bool {
181        // SAFETY: malloc_trim(0) walks the arenas and releases free pages.
182        unsafe { malloc_trim(0) == 1 }
183    }
184
185    /// Bytes in live allocations (`uordblks` + mmapped `hblkhd`). Resolved at
186    /// run time: `mallinfo2` only exists since glibc 2.33.
187    pub(super) fn live_bytes() -> Option<u64> {
188        // SAFETY: RTLD_DEFAULT (null) searches the global scope; the symbol,
189        // when present, has the documented `struct mallinfo2 (void)` signature.
190        let sym = unsafe { dlsym(std::ptr::null_mut(), c"mallinfo2".as_ptr()) };
191        (!sym.is_null()).then(|| {
192            let mallinfo2: extern "C" fn() -> Mallinfo2 = unsafe { std::mem::transmute(sym) };
193            let info = mallinfo2();
194            (info.uordblks + info.hblkhd) as u64
195        })
196    }
197}
198
199#[cfg(not(all(target_os = "linux", target_env = "gnu")))]
200mod glibc {
201    pub(super) fn set_limits(_arena_max: i32, _mmap_threshold: i32) {}
202
203    pub(super) fn trim() -> bool {
204        false
205    }
206
207    pub(super) fn live_bytes() -> Option<u64> {
208        None
209    }
210}
211
212#[cfg(test)]
213#[path = "process_memory_tests.rs"]
214mod tests;