memory_budget/jemalloc.rs
1//! jemalloc stats split for the [`crate::BudgetSnapshot`] non-cache bucket.
2//!
3//! `rss − cache_bytes − lease_bytes` lumps every byte the budget cannot
4//! resize into a single number. When that number grows monotonically
5//! across a long-running workload it is impossible to tell whether the
6//! application is retaining live objects or jemalloc is sitting on
7//! retained-but-decommitted pages
8//! (work for allocator tuning). This module surfaces jemalloc's own
9//! introspection so the tick log can name the bucket directly.
10//!
11//! [`SystemJemallocStats`] is feature-gated behind `jemalloc` so the
12//! memory-budget crate stays allocator-agnostic; binaries that install
13//! `tikv-jemallocator` as `#[global_allocator]` enable the feature and
14//! wire it into the budget via [`crate::MemoryBudget::with_jemalloc_stats`].
15//! Everyone else falls back to [`NoJemallocStats`] and the budget tick
16//! reports zeros for these columns.
17
18/// Snapshot of jemalloc's process-wide accounting at one tick. All
19/// values are bytes. Definitions match the `stats.*` mallctl namespace
20/// (see [jemalloc(3)](https://jemalloc.net/jemalloc.3.html#stats)).
21#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
22pub struct JemallocStats {
23 /// Bytes currently held by live application allocations
24 /// (`stats.allocated`). The closest thing to "what is the live heap
25 /// from the program's point of view". If this grows monotonically
26 /// across runs while caches stay flat, the program is holding more
27 /// memory.
28 pub allocated_bytes: u64,
29 /// Bytes in active jemalloc extents (`stats.active`). Always
30 /// `>= allocated_bytes`; the delta is fragmentation within active
31 /// extents.
32 pub active_bytes: u64,
33 /// Bytes used by jemalloc's own metadata structures
34 /// (`stats.metadata`).
35 pub metadata_bytes: u64,
36 /// jemalloc's resident-set estimate (`stats.resident`): active +
37 /// retained-but-resident + metadata, in pages.
38 pub resident_bytes: u64,
39 /// Total virtual memory mapped by jemalloc (`stats.mapped`).
40 pub mapped_bytes: u64,
41 /// Virtual memory retained by jemalloc after decommit
42 /// (`stats.retained`). This is the bucket that explains "process
43 /// RSS stays high after a workload finishes" — pages were
44 /// returned to jemalloc but jemalloc has not yet returned them to
45 /// the kernel. Controlled by `dirty_decay_ms` / `muzzy_decay_ms`
46 /// in `MALLOC_CONF`.
47 pub retained_bytes: u64,
48}
49
50impl JemallocStats {
51 /// Conservatively reconcile process-wide and allocator measurements.
52 ///
53 /// These are overlapping views, so take their maximum rather than adding
54 /// them or allowing a smaller allocator reading to hide other memory.
55 #[must_use]
56 pub fn observed_usage_bytes(self, process_bytes: u64) -> u64 {
57 process_bytes
58 .max(self.resident_bytes)
59 .max(self.allocated_bytes)
60 }
61}
62
63/// "What does jemalloc say about our memory usage right now?"
64///
65/// Implementations must be cheap (the budget calls
66/// [`JemallocStatsSource::read`] once per tick). Returning all-zeros
67/// is a valid answer for builds that do not use jemalloc.
68pub trait JemallocStatsSource: Send + Sync {
69 /// Read the current stats. Implementations are free to cache the
70 /// epoch handle, but should call `epoch.advance` (or equivalent)
71 /// before reading so the returned values reflect the current
72 /// state and not the values from the previous epoch.
73 fn read(&self) -> JemallocStats;
74}
75
76/// No-op source used when the binary does not install jemalloc as
77/// the global allocator. All readings are zero, which the tick log
78/// distinguishes from real-but-tiny readings via the surrounding
79/// `cache_bytes_current` / `rss_bytes` context.
80pub struct NoJemallocStats;
81
82impl JemallocStatsSource for NoJemallocStats {
83 fn read(&self) -> JemallocStats {
84 JemallocStats::default()
85 }
86}
87
88#[cfg(feature = "jemalloc")]
89pub use system::SystemJemallocStats;
90
91#[cfg(feature = "jemalloc")]
92mod system {
93 use tikv_jemalloc_ctl::{epoch, epoch_mib, stats};
94
95 use super::{JemallocStats, JemallocStatsSource};
96
97 /// Production stats source backed by `tikv-jemalloc-ctl`'s mallctl
98 /// wrapper. Caches the per-stat MIB handles so each tick is just
99 /// six word-sized reads after an `epoch.advance`.
100 ///
101 /// Construct via [`SystemJemallocStats::new`]. The constructor
102 /// resolves the mallctl handles once; a failure here means jemalloc
103 /// was compiled without stats support and the budget has no useful
104 /// information to surface, so the caller should fall back to
105 /// [`super::NoJemallocStats`] instead.
106 pub struct SystemJemallocStats {
107 epoch: epoch_mib,
108 allocated: stats::allocated_mib,
109 active: stats::active_mib,
110 metadata: stats::metadata_mib,
111 resident: stats::resident_mib,
112 mapped: stats::mapped_mib,
113 retained: stats::retained_mib,
114 }
115
116 impl SystemJemallocStats {
117 /// Resolve the mallctl MIB handles. Returns `None` if jemalloc
118 /// was built without stats support — caller should fall back
119 /// to [`super::NoJemallocStats`].
120 #[must_use]
121 pub fn new() -> Option<Self> {
122 Some(Self {
123 epoch: epoch::mib().ok()?,
124 allocated: stats::allocated::mib().ok()?,
125 active: stats::active::mib().ok()?,
126 metadata: stats::metadata::mib().ok()?,
127 resident: stats::resident::mib().ok()?,
128 mapped: stats::mapped::mib().ok()?,
129 retained: stats::retained::mib().ok()?,
130 })
131 }
132 }
133
134 impl JemallocStatsSource for SystemJemallocStats {
135 fn read(&self) -> JemallocStats {
136 // jemalloc's stats are sampled into a snapshot on each
137 // `epoch.advance` call; without this every read returns the
138 // values frozen at process start.
139 if self.epoch.advance().is_err() {
140 return JemallocStats::default();
141 }
142 JemallocStats {
143 allocated_bytes: self.allocated.read().unwrap_or(0) as u64,
144 active_bytes: self.active.read().unwrap_or(0) as u64,
145 metadata_bytes: self.metadata.read().unwrap_or(0) as u64,
146 resident_bytes: self.resident.read().unwrap_or(0) as u64,
147 mapped_bytes: self.mapped.read().unwrap_or(0) as u64,
148 retained_bytes: self.retained.read().unwrap_or(0) as u64,
149 }
150 }
151 }
152}