memory_budget/policy.rs
1//! Reapportionment policies for [`MemoryBudget`](crate::MemoryBudget).
2//!
3//! The default [`ProportionalPolicy`] implements a hysteresis-based
4//! proportional allocation algorithm:
5//!
6//! - Above the upper hysteresis band, each cache shrinks proportionally so the
7//! projected total fits the target — **unless** the sum of cache bytes is a
8//! small fraction of RSS, in which case shrinking caches cannot meaningfully
9//! reduce RSS (the bulk lives outside the cache subsystem, e.g. in a
10//! transient allocator or working set). In
11//! that case the policy holds with a `SkipShrinkLeanCaches` verdict.
12//! - Below the lower hysteresis band, caches grow back toward their preferred
13//! ceiling. Caches with higher hit rates get more of the slack first.
14//! - Inside the band, the policy does nothing — avoiding the thrashing that a
15//! hysteresis-less algorithm would produce when RSS sits within ε of the
16//! target.
17//!
18//! Every reapportionment produces a [`Verdict`] explaining what the policy
19//! decided and why. The budget logs the verdict on each tick so post-mortem
20//! analysis can distinguish "shrunk because cache N grew" from "skipped
21//! shrink because RSS climbed but caches stayed lean".
22
23use crate::config::{GROW_RATIO, SHRINK_RATIO};
24use crate::resizable::ResizableStats;
25
26/// Cache footprint as a fraction of RSS below which the shrink path is a
27/// no-op. When caches account for less than this share of process RSS, the
28/// growth is coming from somewhere else (transient allocator, leaked
29/// non-cache buffer) and shrinking caches just trades a useful working set
30/// for an unrelated runaway.
31///
32/// `0.25` leaves room for applications whose working set is dominated by
33/// allocations outside their registered caches. Tune this threshold when
34/// application-specific non-cache reporters provide more precise accounting.
35pub(crate) const MIN_CACHE_SHARE_FOR_SHRINK: f64 = 0.25;
36
37/// A single cache's view passed to the policy.
38#[derive(Clone, Debug)]
39pub struct CacheView {
40 /// Human-readable name (forwarded from
41 /// [`Resizable::name`](crate::Resizable::name)).
42 pub name: String,
43 /// Current soft cap, in bytes.
44 pub max_bytes: u64,
45 /// Cache's preferred ceiling — typically its construction-time
46 /// value. The policy refuses to grow caps above this.
47 pub preferred_max_bytes: u64,
48 /// Currently held bytes.
49 pub current_bytes: u64,
50 /// Stats snapshot.
51 pub stats: ResizableStats,
52}
53
54/// Inputs the policy needs to compute a new set of caps.
55#[derive(Clone, Debug)]
56pub struct PolicyInput {
57 /// Effective target after lease deductions, in bytes.
58 pub effective_target_bytes: u64,
59 /// Most recent RSS reading.
60 pub rss_bytes: u64,
61 /// View of every registered cache, in registration order.
62 pub caches: Vec<CacheView>,
63 /// Sum of cache `current_bytes` at the previous tick, or `0`
64 /// if this is the first tick (no history yet). The shrink
65 /// branch uses this to detect "RSS climbed but caches didn't"
66 /// — a signal that the growth came from outside the cache
67 /// subsystem.
68 pub previous_cache_bytes: u64,
69 /// Number of registered caches at the previous tick. `0` on
70 /// first tick.
71 pub previous_cache_count: u32,
72 /// RSS reading at the previous tick. `0` on first tick.
73 /// The flat-caches guard requires RSS to have actually grown
74 /// (`rss_bytes > previous_rss_bytes`) so a lease-induced
75 /// effective-target drop — which raises the rss/target ratio
76 /// without an actual RSS climb — still triggers shrink.
77 pub previous_rss_bytes: u64,
78}
79
80/// The policy returns one new cap per input cache, same order, plus a
81/// [`Verdict`] explaining the decision.
82#[derive(Clone, Debug, Default)]
83pub struct PolicyOutput {
84 /// New `max_bytes` for each cache, parallel to
85 /// [`PolicyInput::caches`].
86 pub new_max_bytes: Vec<u64>,
87 /// What the policy decided this tick and why.
88 pub verdict: Verdict,
89}
90
91/// What the policy decided this tick. Shared across all caches in the
92/// same reapportionment (every cache gets the same regime).
93#[derive(Clone, Debug, Default)]
94pub enum Verdict {
95 /// RSS is inside the hysteresis band — caps held steady.
96 #[default]
97 Hold,
98 /// RSS over target × `SHRINK_RATIO`; every cap multiplied by
99 /// `target / rss`.
100 Shrink {
101 /// `target / rss`, in `(0.0, 1.0)`.
102 factor: f64,
103 },
104 /// RSS over target × `SHRINK_RATIO`, but the sum of cache
105 /// `current_bytes` is < `MIN_CACHE_SHARE_FOR_SHRINK` of RSS —
106 /// shrinking caches would not meaningfully reduce RSS, so the
107 /// policy holds. The growth is coming from a non-cache allocator
108 /// (transient parser/reasoner state, leaked buffer, etc.).
109 SkipShrinkLeanCaches {
110 /// Sum of `current_bytes` across registered caches.
111 cache_bytes: u64,
112 /// Process RSS at the tick.
113 rss_bytes: u64,
114 /// `cache_bytes / rss_bytes`, in `[0.0, 1.0]`.
115 cache_share: f64,
116 },
117 /// RSS over target × `SHRINK_RATIO`, but the cache subsystem did
118 /// not grow between the previous tick and this one (`cache_count`
119 /// is flat AND `cache_bytes` is within
120 /// `FLAT_CACHE_TOLERANCE` of the previous reading). The RSS
121 /// climb came from outside the cache subsystem — shrinking caches
122 /// would trade away a useful working set for an unrelated
123 /// allocator's growth (e.g. parser, query-engine, or reasoner working
124 /// memory).
125 SkipShrinkFlatCaches {
126 /// Sum of `current_bytes` across registered caches.
127 cache_bytes: u64,
128 /// Previous tick's `cache_bytes`.
129 previous_cache_bytes: u64,
130 /// Number of registered caches this tick.
131 cache_count: u32,
132 /// Previous tick's cache count.
133 previous_cache_count: u32,
134 /// Process RSS at the tick.
135 rss_bytes: u64,
136 },
137 /// RSS under target × `GROW_RATIO`; caches grew toward their
138 /// `preferred_max_bytes` ceilings, weighted by hit rate.
139 Grow {
140 /// `target - rss`, distributed across caches.
141 headroom_bytes: u64,
142 },
143}
144
145impl Verdict {
146 /// One-word label for the verdict (used in log emissions).
147 #[must_use]
148 pub fn label(&self) -> &'static str {
149 match self {
150 Verdict::Hold => "hold",
151 Verdict::Shrink { .. } => "shrink",
152 Verdict::SkipShrinkLeanCaches { .. } => "skip-shrink-lean-caches",
153 Verdict::SkipShrinkFlatCaches { .. } => "skip-shrink-flat-caches",
154 Verdict::Grow { .. } => "grow",
155 }
156 }
157}
158
159/// `cache_bytes` is considered "flat" against the previous tick when
160/// it has grown by less than this fraction. Chosen wide enough to
161/// absorb the few-MB churn that LRU eviction + insertion produces on
162/// a quiet cache, but tight enough that real cache fill (typically
163/// 10s of MB per tick during a workload) breaks the threshold.
164pub(crate) const FLAT_CACHE_TOLERANCE: f64 = 0.05;
165
166/// Policy contract.
167pub trait Policy: Send + Sync {
168 /// Compute new caps. Implementations must return one entry per
169 /// input cache, in the same order; the budget then applies each
170 /// via [`Resizable::set_max_bytes`](crate::Resizable::set_max_bytes).
171 fn reapportion(&self, input: &PolicyInput) -> PolicyOutput;
172}
173
174/// Default policy for proportional cache-capacity adjustment.
175///
176/// - **Shrink** (`rss > target × SHRINK_RATIO`): each cache shrinks
177/// proportionally — `new = current_max × target / rss` — so the projected
178/// total fits the target. Skipped if `sum(current_bytes) / rss` is below
179/// `MIN_CACHE_SHARE_FOR_SHRINK` (caches are not the bulk of memory).
180/// - **Grow** (`rss < target × GROW_RATIO`): caches with the highest hit rate
181/// get the first share of the slack, capped at their `preferred_max_bytes`.
182/// - **Hold** (in-band): no change.
183#[derive(Clone, Copy, Debug, Default)]
184pub struct ProportionalPolicy;
185
186impl Policy for ProportionalPolicy {
187 fn reapportion(&self, input: &PolicyInput) -> PolicyOutput {
188 let target = input.effective_target_bytes as f64;
189 let rss = input.rss_bytes as f64;
190 let shrink_threshold = target * SHRINK_RATIO;
191 let grow_threshold = target * GROW_RATIO;
192
193 let mut new_max_bytes = Vec::with_capacity(input.caches.len());
194
195 // Pressure regime: RSS over the upper band (or pathological
196 // case where target == 0). Shrink unless one of two guards
197 // fires (both indicate the bulk of memory lives outside the
198 // cache subsystem and shrinking caches would be ineffective):
199 //
200 // 1. Trend guard — RSS actually climbed since the previous tick but the cache
201 // subsystem didn't (cache_count flat AND cache_bytes within
202 // FLAT_CACHE_TOLERANCE). The new growth came from somewhere else, not the
203 // cache subsystem. Requires `rss_bytes > previous_rss_bytes` so a lease-
204 // induced effective-target drop (which can push the rss/target ratio over
205 // threshold without an actual RSS climb) still triggers shrink.
206 // 2. Static guard — sum(current_bytes) is < 25% of RSS, so even halving every
207 // cap can't meaningfully reduce RSS.
208 if target == 0.0 || rss >= shrink_threshold {
209 let cache_bytes: u64 = input.caches.iter().map(|c| c.current_bytes).sum();
210 let cache_count = input.caches.len() as u32;
211 let have_history = input.previous_rss_bytes > 0;
212 let rss_grew = input.rss_bytes > input.previous_rss_bytes;
213 let count_flat = cache_count <= input.previous_cache_count;
214 // Strictly: current cache_bytes must not exceed the
215 // previous reading times the tolerance. When previous
216 // is zero, any nonzero current is growth (caches just
217 // started filling) — not flat. The expression handles
218 // that correctly: `0 * 1.05 = 0`, so `current > 0`
219 // fails the predicate.
220 let bytes_flat = cache_bytes as f64
221 <= (input.previous_cache_bytes as f64) * (1.0 + FLAT_CACHE_TOLERANCE);
222
223 if have_history && rss_grew && count_flat && bytes_flat {
224 for cache in &input.caches {
225 new_max_bytes.push(cache.max_bytes);
226 }
227 return PolicyOutput {
228 new_max_bytes,
229 verdict: Verdict::SkipShrinkFlatCaches {
230 cache_bytes,
231 previous_cache_bytes: input.previous_cache_bytes,
232 cache_count,
233 previous_cache_count: input.previous_cache_count,
234 rss_bytes: input.rss_bytes,
235 },
236 };
237 }
238
239 let cache_share = if rss > 0.0 {
240 cache_bytes as f64 / rss
241 } else {
242 1.0
243 };
244 if rss > 0.0 && cache_share < MIN_CACHE_SHARE_FOR_SHRINK {
245 for cache in &input.caches {
246 new_max_bytes.push(cache.max_bytes);
247 }
248 return PolicyOutput {
249 new_max_bytes,
250 verdict: Verdict::SkipShrinkLeanCaches {
251 cache_bytes,
252 rss_bytes: input.rss_bytes,
253 cache_share,
254 },
255 };
256 }
257 let factor = if rss > 0.0 { target / rss } else { 1.0 };
258 for cache in &input.caches {
259 let shrunk = ((cache.max_bytes as f64) * factor).max(0.0) as u64;
260 new_max_bytes.push(shrunk);
261 }
262 return PolicyOutput {
263 new_max_bytes,
264 verdict: Verdict::Shrink { factor },
265 };
266 }
267
268 if rss <= grow_threshold {
269 let headroom = ((target - rss).max(0.0)) as u64;
270 grow_under_pressure(input, &mut new_max_bytes);
271 return PolicyOutput {
272 new_max_bytes,
273 verdict: Verdict::Grow {
274 headroom_bytes: headroom,
275 },
276 };
277 }
278
279 // Inside the hysteresis band — hold steady.
280 for cache in &input.caches {
281 new_max_bytes.push(cache.max_bytes);
282 }
283 PolicyOutput {
284 new_max_bytes,
285 verdict: Verdict::Hold,
286 }
287 }
288}
289
290/// Distribute the headroom between current RSS and the target
291/// proportionally to each cache's hit rate, never exceeding the
292/// preferred ceiling. Caches with no observed lookups yet get the
293/// fallback rate of 0.0 (no growth).
294fn grow_under_pressure(input: &PolicyInput, out: &mut Vec<u64>) {
295 let target = input.effective_target_bytes as f64;
296 let rss = input.rss_bytes as f64;
297 let headroom = (target - rss).max(0.0);
298 let weights: Vec<f64> = input
299 .caches
300 .iter()
301 .map(|c| c.stats.hit_rate().unwrap_or(0.0))
302 .collect();
303 let weight_sum: f64 = weights.iter().sum();
304 for (cache, weight) in input.caches.iter().zip(weights.iter()) {
305 let share = if weight_sum > 0.0 {
306 headroom * (weight / weight_sum)
307 } else {
308 // No hit-rate signal yet — distribute evenly.
309 headroom / (input.caches.len().max(1) as f64)
310 };
311 let proposed = (cache.max_bytes as f64) + share;
312 let bounded = proposed.min(cache.preferred_max_bytes as f64);
313 out.push(bounded as u64);
314 }
315}