Skip to main content

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}