memory_budget/reporter.rs
1//! Read-only attribution of non-cache memory to named subsystems.
2//!
3//! The budget tick's `non_cache_live_bytes` (jemalloc-allocated minus
4//! registered caches and leases) is the program's live heap outside
5//! the resizable caches — typically dominated by the reasoner working
6//! set. It is one opaque number. A [`NonCacheReporter`] lets a
7//! subsystem publish "how many bytes am I holding right now" so the
8//! tick can *name* slices of that bucket and leave the rest as a
9//! `non_cache_other` residual.
10//!
11//! Unlike [`Resizable`](crate::Resizable), reporters are **read-only**:
12//! the budget observes but cannot shrink them (the reasoner's working
13//! set isn't evictable mid-pass). The budget holds `Weak` handles, so
14//! a dropped reporter prunes itself from the next tick.
15
16use std::sync::Arc;
17use std::sync::atomic::{AtomicU64, Ordering};
18
19/// A subsystem that reports its current non-cache memory footprint.
20pub trait NonCacheReporter: Send + Sync {
21 /// Short label surfaced on the budget tick (e.g. `reasoner`).
22 fn name(&self) -> &str;
23
24 /// Bytes this subsystem is currently holding. Best-effort — an
25 /// estimate is fine; the budget uses it for attribution, not
26 /// eviction.
27 fn current_bytes(&self) -> u64;
28}
29
30/// A [`NonCacheReporter`] backed by a shared atomic gauge.
31///
32/// The budget registers the `AtomicReporter`; the producing subsystem
33/// holds a [`MemoryGauge`] clone (via [`AtomicReporter::gauge`]) and
34/// updates it as its footprint changes. Construct one per subsystem
35/// instance (e.g. a parser or reasoner), register it, and thread
36/// the gauge into the subsystem.
37pub struct AtomicReporter {
38 name: String,
39 gauge: Arc<AtomicU64>,
40}
41
42impl AtomicReporter {
43 /// Create a reporter with a zeroed gauge.
44 #[must_use]
45 pub fn new(name: impl Into<String>) -> Self {
46 Self {
47 name: name.into(),
48 gauge: Arc::new(AtomicU64::new(0)),
49 }
50 }
51
52 /// Producer-side handle used to update the reported byte count.
53 #[must_use]
54 pub fn gauge(&self) -> MemoryGauge {
55 MemoryGauge {
56 inner: Arc::clone(&self.gauge),
57 }
58 }
59}
60
61impl NonCacheReporter for AtomicReporter {
62 fn name(&self) -> &str {
63 &self.name
64 }
65
66 fn current_bytes(&self) -> u64 {
67 self.gauge.load(Ordering::Relaxed)
68 }
69}
70
71/// Producer-side handle to an [`AtomicReporter`]'s gauge.
72///
73/// Cheap to clone (one `Arc`). Threaded into a subsystem so it can
74/// publish its live footprint. A [`MemoryGauge::disconnected`] gauge
75/// writes to a private atomic nobody reads — handy so subsystem code
76/// always has a gauge to write to even when not running under a
77/// registered budget (tests or applications without a budget).
78#[derive(Clone, Debug)]
79pub struct MemoryGauge {
80 inner: Arc<AtomicU64>,
81}
82
83impl MemoryGauge {
84 /// A gauge not wired to any reporter; updates go nowhere.
85 #[must_use]
86 pub fn disconnected() -> Self {
87 Self {
88 inner: Arc::new(AtomicU64::new(0)),
89 }
90 }
91
92 /// Replace the reported byte count.
93 pub fn set(&self, bytes: u64) {
94 self.inner.store(bytes, Ordering::Relaxed);
95 }
96
97 /// Add to the reported byte count.
98 pub fn add(&self, bytes: u64) {
99 self.inner.fetch_add(bytes, Ordering::Relaxed);
100 }
101
102 /// Subtract from the reported byte count, saturating at zero.
103 pub fn sub(&self, bytes: u64) {
104 let mut current = self.inner.load(Ordering::Relaxed);
105 loop {
106 let next = current.saturating_sub(bytes);
107 match self.inner.compare_exchange_weak(
108 current,
109 next,
110 Ordering::Relaxed,
111 Ordering::Relaxed,
112 ) {
113 Ok(_) => return,
114 Err(observed) => current = observed,
115 }
116 }
117 }
118
119 /// Current reported byte count.
120 #[must_use]
121 pub fn current(&self) -> u64 {
122 self.inner.load(Ordering::Relaxed)
123 }
124
125 /// RAII attribution: add `bytes` to the gauge now and subtract the
126 /// same amount when the returned [`GaugeGuard`] drops. Hold one
127 /// alongside a resident allocation to attribute it to this gauge's
128 /// reporter for exactly that allocation's lifetime. Uses `add`/`sub`
129 /// so concurrent guards on the same reporter compose into one total.
130 #[must_use]
131 pub fn guard(&self, bytes: u64) -> GaugeGuard {
132 self.add(bytes);
133 GaugeGuard {
134 gauge: self.clone(),
135 bytes,
136 }
137 }
138}
139
140/// RAII guard that publishes a fixed byte count to a [`MemoryGauge`]
141/// for its lifetime. Created by [`MemoryGauge::guard`]; `Drop` removes
142/// the contribution again.
143#[derive(Debug)]
144pub struct GaugeGuard {
145 gauge: MemoryGauge,
146 bytes: u64,
147}
148
149impl Drop for GaugeGuard {
150 fn drop(&mut self) {
151 self.gauge.sub(self.bytes);
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use std::sync::Arc;
158
159 use super::{AtomicReporter, NonCacheReporter};
160
161 #[test]
162 fn gauge_updates_are_visible_through_reporter() {
163 let reporter = AtomicReporter::new("reasoner");
164 let gauge = reporter.gauge();
165 assert_eq!(reporter.current_bytes(), 0);
166 gauge.add(1000);
167 assert_eq!(reporter.current_bytes(), 1000);
168 gauge.set(4096);
169 assert_eq!(reporter.current_bytes(), 4096);
170 gauge.sub(100);
171 assert_eq!(reporter.current_bytes(), 3996);
172 // Saturating subtraction never underflows.
173 gauge.sub(u64::MAX);
174 assert_eq!(reporter.current_bytes(), 0);
175 }
176
177 #[test]
178 fn gauge_guard_adds_then_subtracts_on_drop() {
179 let reporter = AtomicReporter::new("executor");
180 let gauge = reporter.gauge();
181 {
182 let _g = gauge.guard(1024);
183 assert_eq!(reporter.current_bytes(), 1024);
184 // Concurrent guards on the same reporter compose additively.
185 let _g2 = gauge.guard(512);
186 assert_eq!(reporter.current_bytes(), 1536);
187 }
188 assert_eq!(reporter.current_bytes(), 0);
189 }
190
191 #[test]
192 fn reporter_name_is_exposed() {
193 let reporter = AtomicReporter::new("reasoner");
194 let dyn_reporter: Arc<dyn NonCacheReporter> = Arc::new(reporter);
195 assert_eq!(dyn_reporter.name(), "reasoner");
196 }
197}