Skip to main content

concinnity_engine/app/
mem_drift.rs

1// src/app/mem_drift.rs
2//
3// Long-session memory drift: whether the process's growth came from the Rust
4// heap or from somewhere the Rust heap cannot see.
5//
6// Resident set size alone cannot tell a leak from a fragmenting allocator, and
7// the tracked heap alone cannot either, yet the two failures have opposite
8// remedies. Read together across a session they separate: a heap that grows
9// while the rest holds steady is ours to fix, and a resident set that grows
10// while the heap holds steady is not.
11//
12// The instantaneous pair says nothing worth reading. A healthy process holds
13// most of its resident set outside the Rust heap -- the binary image, thread
14// stacks, driver allocations, mapped asset blobs -- so the ratio between them
15// has no value to threshold against. Only its movement over a long session
16// does, which is why this tracks growth from a baseline rather than a ratio.
17//
18// What the growth outside the heap does not do is name its own cause. It is
19// fragmentation, driver growth and newly mapped assets together; separating
20// those further is the ledger's job, not this module's.
21
22use std::time::Instant;
23
24// How far a term must move, as a percentage of the memory budget, before it
25// counts as drift, and how far back it must fall before it stops counting. Any
26// long session jitters by a few megabytes and none of it means anything, and a
27// figure sitting on a single threshold would alternate its reading twice a
28// second; the gap between the two is the hysteresis band that stops it, the
29// same way the streaming valve's engage and release marks do.
30const SIGNIFICANT_PCT: u64 = 2;
31const RELEASE_PCT: u64 = 1;
32
33// Per-sample RSS growth, as a percentage of the previous sample, under which the
34// session counts as flat, and how many flat samples in a row settle it. Startup
35// climbs steeply while blobs load and pipelines build, and the driver keeps
36// allocating for seconds after the first frame; one flat sample lands in the
37// gaps of that, so the baseline waits for a run of them.
38const SETTLE_GROWTH_PCT: u64 = 1;
39const SETTLE_STREAK: u32 = 4;
40
41// Samples after which the baseline is captured whether or not RSS ever settled.
42// A world that streams continuously may never settle, and drift measured from a
43// busy baseline still beats no drift at all.
44const SETTLE_DEADLINE_SAMPLES: u32 = 240;
45
46/// Which terms moved, once both are read against the budget.
47#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
48pub enum DriftVerdict {
49    /// Neither term has moved against the budget.
50    #[default]
51    Settled,
52    /// The tracked heap grew: the engine is holding more than it was.
53    Heap,
54    /// The resident set grew and the tracked heap did not, so the growth is
55    /// memory Rust never allocated.
56    OutsideHeap,
57    /// Both terms grew.
58    Both,
59}
60
61impl DriftVerdict {
62    /// How a readout names the verdict.
63    pub fn label(self) -> &'static str {
64        match self {
65            DriftVerdict::Settled => "settled",
66            DriftVerdict::Heap => "heap",
67            DriftVerdict::OutsideHeap => "outside-heap",
68            DriftVerdict::Both => "heap and outside-heap",
69        }
70    }
71}
72
73/// Process memory movement since the session settled. Growth is signed: a term
74/// that shrank reads negative.
75#[derive(Clone, Copy, PartialEq, Eq, Debug)]
76pub struct MemoryDrift {
77    /// Bytes the tracked heap has moved since the baseline.
78    pub heap_growth_bytes: i64,
79    /// Bytes of resident-set movement the tracked heap does not account for.
80    pub outside_heap_growth_bytes: i64,
81    /// Seconds the movement is measured over, so a growth figure has a rate
82    /// behind it: 400 MB over three hours and over three minutes are different
83    /// problems.
84    pub window_secs: u64,
85    /// Which term (if any) is still growing.
86    pub verdict: DriftVerdict,
87}
88
89// The baseline the drift is measured from, captured once the session settles.
90#[derive(Clone, Copy, Debug)]
91struct Baseline {
92    at: Instant,
93    rss_bytes: u64,
94    heap_live_bytes: u64,
95}
96
97// Holds the baseline and turns each sample into a `MemoryDrift`. Reports
98// nothing until the session settles, because a drift measured from a startup
99// figure is noise wearing a number's clothes.
100#[derive(Debug, Default)]
101pub(crate) struct DriftTracker {
102    baseline: Option<Baseline>,
103    // Previous RSS, for the settle test that runs before a baseline exists.
104    last_rss: Option<u64>,
105    // Consecutive flat samples so far, and every sample seen before the
106    // baseline was captured (which the deadline is measured against).
107    flat_streak: u32,
108    samples_before_baseline: u32,
109    // Whether each term currently counts as drifting. Latched, so a figure
110    // hovering at the threshold holds its reading instead of alternating.
111    heap_moved: bool,
112    outside_heap_moved: bool,
113}
114
115impl DriftTracker {
116    // Fold one sample in, returning the drift once a baseline exists.
117    pub(crate) fn sample(
118        &mut self,
119        rss_bytes: u64,
120        heap_live_bytes: u64,
121        budget_bytes: u64,
122    ) -> Option<MemoryDrift> {
123        self.sample_at(rss_bytes, heap_live_bytes, budget_bytes, Instant::now())
124    }
125
126    // The clock split out so a test can drive the window without waiting on it.
127    fn sample_at(
128        &mut self,
129        rss_bytes: u64,
130        heap_live_bytes: u64,
131        budget_bytes: u64,
132        now: Instant,
133    ) -> Option<MemoryDrift> {
134        let Some(base) = self.baseline else {
135            self.settle(rss_bytes, heap_live_bytes, now);
136            return None;
137        };
138        let heap_growth = growth(heap_live_bytes, base.heap_live_bytes);
139        let outside_heap_growth = growth(rss_bytes, base.rss_bytes) - heap_growth;
140        self.heap_moved = moved(heap_growth, budget_bytes, self.heap_moved);
141        self.outside_heap_moved = moved(outside_heap_growth, budget_bytes, self.outside_heap_moved);
142        Some(MemoryDrift {
143            heap_growth_bytes: heap_growth,
144            outside_heap_growth_bytes: outside_heap_growth,
145            window_secs: now.saturating_duration_since(base.at).as_secs(),
146            verdict: verdict(self.heap_moved, self.outside_heap_moved),
147        })
148    }
149
150    // Capture the baseline once RSS has held flat for a run of samples, or once
151    // the deadline passes for a world that never stops loading.
152    fn settle(&mut self, rss_bytes: u64, heap_live_bytes: u64, now: Instant) {
153        self.samples_before_baseline = self.samples_before_baseline.saturating_add(1);
154        let flat = self.last_rss.is_some_and(|prev| {
155            rss_bytes.saturating_sub(prev) <= prev.saturating_mul(SETTLE_GROWTH_PCT) / 100
156        });
157        self.flat_streak = if flat { self.flat_streak + 1 } else { 0 };
158        self.last_rss = Some(rss_bytes);
159        if self.flat_streak >= SETTLE_STREAK
160            || self.samples_before_baseline >= SETTLE_DEADLINE_SAMPLES
161        {
162            self.baseline = Some(Baseline {
163                at: now,
164                rss_bytes,
165                heap_live_bytes,
166            });
167        }
168    }
169}
170
171// Signed movement from `base` to `now`, saturating rather than wrapping at the
172// extremes a bogus platform reading could produce.
173fn growth(now: u64, base: u64) -> i64 {
174    (now as i128 - base as i128).clamp(i64::MIN as i128, i64::MAX as i128) as i64
175}
176
177// Whether one term counts as drifting, given whether it already did. A term
178// that has not moved must clear the significance mark to start counting, and one
179// that has must fall back under the release mark to stop; between them it holds
180// whatever it was. Shrinkage never counts, and a zero budget has no scale to
181// judge against so nothing counts.
182fn moved(growth: i64, budget_bytes: u64, was_moved: bool) -> bool {
183    let pct = if was_moved {
184        RELEASE_PCT
185    } else {
186        SIGNIFICANT_PCT
187    };
188    let threshold = budget_bytes.saturating_mul(pct) / 100;
189    threshold > 0 && growth > 0 && growth as u64 > threshold
190}
191
192fn verdict(heap_moved: bool, outside_heap_moved: bool) -> DriftVerdict {
193    match (heap_moved, outside_heap_moved) {
194        (true, true) => DriftVerdict::Both,
195        (true, false) => DriftVerdict::Heap,
196        (false, true) => DriftVerdict::OutsideHeap,
197        (false, false) => DriftVerdict::Settled,
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use std::time::Duration;
205
206    const MIB: u64 = 1024 * 1024;
207    const BUDGET: u64 = 1000 * MIB;
208    // 2% of the budget: the smallest movement that counts as drift.
209    const SIGNIFICANT: u64 = 20 * MIB;
210
211    // A tracker already past its baseline, so a test can drive drift directly.
212    // One sample to compare against plus a full flat run settles it.
213    fn settled(rss: u64, heap: u64, at: Instant) -> DriftTracker {
214        let mut t = DriftTracker::default();
215        for _ in 0..=SETTLE_STREAK {
216            assert_eq!(t.sample_at(rss, heap, BUDGET, at), None);
217        }
218        assert!(
219            t.baseline.is_some(),
220            "a run of steady samples settles the baseline"
221        );
222        t
223    }
224
225    // The reading the whole module exists for: RSS climbing while the heap holds
226    // steady is growth Rust never made, and the remedy is not a leak hunt.
227    #[test]
228    fn a_climbing_rss_against_a_flat_heap_reads_as_outside_the_heap() {
229        let start = Instant::now();
230        let mut t = settled(2000 * MIB, 400 * MIB, start);
231
232        let d = t
233            .sample_at(
234                2400 * MIB,
235                400 * MIB,
236                BUDGET,
237                start + Duration::from_secs(3600),
238            )
239            .expect("the baseline is captured");
240        assert_eq!(d.verdict, DriftVerdict::OutsideHeap);
241        assert_eq!(d.heap_growth_bytes, 0);
242        assert_eq!(d.outside_heap_growth_bytes, (400 * MIB) as i64);
243        assert_eq!(d.window_secs, 3600);
244    }
245
246    // The opposite reading, which is the one that *is* a leak hunt: the heap
247    // took every byte of the growth.
248    #[test]
249    fn a_climbing_heap_carrying_the_rss_reads_as_ours() {
250        let start = Instant::now();
251        let mut t = settled(2000 * MIB, 400 * MIB, start);
252
253        let d = t
254            .sample_at(
255                2400 * MIB,
256                800 * MIB,
257                BUDGET,
258                start + Duration::from_secs(60),
259            )
260            .expect("the baseline is captured");
261        assert_eq!(d.verdict, DriftVerdict::Heap);
262        assert_eq!(d.heap_growth_bytes, (400 * MIB) as i64);
263        // The heap explains all of it, so nothing is left outside it.
264        assert_eq!(d.outside_heap_growth_bytes, 0);
265    }
266
267    #[test]
268    fn both_terms_growing_are_reported_as_both() {
269        let start = Instant::now();
270        let mut t = settled(2000 * MIB, 400 * MIB, start);
271        let d = t
272            .sample_at(2600 * MIB, 700 * MIB, BUDGET, start)
273            .expect("the baseline is captured");
274        assert_eq!(d.verdict, DriftVerdict::Both);
275        assert_eq!(d.heap_growth_bytes, (300 * MIB) as i64);
276        assert_eq!(d.outside_heap_growth_bytes, (300 * MIB) as i64);
277    }
278
279    // A few megabytes either way is what every long session does; the verdict
280    // must not call that drift.
281    #[test]
282    fn movement_under_the_threshold_stays_settled() {
283        let start = Instant::now();
284        let mut t = settled(2000 * MIB, 400 * MIB, start);
285        let d = t
286            .sample_at(
287                2000 * MIB + SIGNIFICANT - 1,
288                400 * MIB + SIGNIFICANT - 1,
289                BUDGET,
290                start,
291            )
292            .expect("the baseline is captured");
293        assert_eq!(d.verdict, DriftVerdict::Settled);
294    }
295
296    // A figure sitting on the threshold must not alternate its reading. A live
297    // run flapped outside-heap / settled in 250 ms across a growth of 350 then
298    // 317 MiB against a 328 MiB mark, which is a log line twice a second saying
299    // nothing. Once a term counts as drifting it holds until it falls back to
300    // the release mark.
301    #[test]
302    fn a_growth_hovering_at_the_threshold_holds_its_reading() {
303        let start = Instant::now();
304        let mut t = settled(2000 * MIB, 400 * MIB, start);
305        let rss_at = |outside: u64| 2000 * MIB + outside;
306
307        // Just over the significance mark: the term starts counting.
308        let d = t
309            .sample_at(rss_at(SIGNIFICANT + MIB), 400 * MIB, BUDGET, start)
310            .expect("the baseline is captured");
311        assert_eq!(d.verdict, DriftVerdict::OutsideHeap);
312
313        // Back under it, but nowhere near the release mark: the reading holds
314        // rather than flipping back.
315        for outside in [SIGNIFICANT - MIB, SIGNIFICANT + MIB, SIGNIFICANT - MIB] {
316            let d = t
317                .sample_at(rss_at(outside), 400 * MIB, BUDGET, start)
318                .expect("the baseline is captured");
319            assert_eq!(
320                d.verdict,
321                DriftVerdict::OutsideHeap,
322                "a term at {outside} bytes flipped inside the hysteresis band"
323            );
324        }
325
326        // All the way back under the release mark: it stops counting.
327        let released = BUDGET * RELEASE_PCT / 100 - MIB;
328        let d = t
329            .sample_at(rss_at(released), 400 * MIB, BUDGET, start)
330            .expect("the baseline is captured");
331        assert_eq!(d.verdict, DriftVerdict::Settled);
332    }
333
334    // Shrinking is not drift. Growth is signed so the numbers stay honest, but
335    // a process handing memory back is not a fault to report.
336    #[test]
337    fn shrinking_is_reported_but_never_read_as_drift() {
338        let start = Instant::now();
339        let mut t = settled(2000 * MIB, 400 * MIB, start);
340        let d = t
341            .sample_at(1500 * MIB, 300 * MIB, BUDGET, start)
342            .expect("the baseline is captured");
343        assert_eq!(d.verdict, DriftVerdict::Settled);
344        assert_eq!(d.heap_growth_bytes, -((100 * MIB) as i64));
345        assert_eq!(d.outside_heap_growth_bytes, -((400 * MIB) as i64));
346    }
347
348    // The failure a fixed warm-up timer produces: a baseline captured while
349    // assets are still loading makes every later reading a measurement against
350    // a number that was never steady.
351    #[test]
352    fn a_climbing_startup_does_not_become_the_baseline() {
353        let start = Instant::now();
354        let mut t = DriftTracker::default();
355        // Each sample adds a tenth of the last, far above the settle threshold.
356        let mut rss = 100 * MIB;
357        for _ in 0..20 {
358            assert_eq!(t.sample_at(rss, 50 * MIB, BUDGET, start), None);
359            rss += rss / 10;
360        }
361        assert!(t.baseline.is_none(), "a climbing session has not settled");
362
363        // Levelling off is recognised over a run of flat samples. The first
364        // sample here still carries the last climb, so it takes one more than
365        // the streak, and the baseline is the sample that completes it.
366        for _ in 0..=SETTLE_STREAK {
367            assert_eq!(t.sample_at(rss, 50 * MIB, BUDGET, start), None);
368        }
369        assert!(t.baseline.is_some(), "a flat run settles the baseline");
370        assert!(t.sample_at(rss, 50 * MIB, BUDGET, start).is_some());
371    }
372
373    // A single flat sample in the middle of a climb is not a settled session.
374    // The driver keeps allocating for seconds after the first frame and lands a
375    // flat sample in the gaps; a baseline captured on one of those measures
376    // every later reading against a number that was still moving.
377    #[test]
378    fn one_flat_sample_inside_a_climb_does_not_settle_it() {
379        let start = Instant::now();
380        let mut t = DriftTracker::default();
381        let mut rss = 100 * MIB;
382        for _ in 0..10 {
383            // A flat pair, then a jump: the streak restarts every time.
384            t.sample_at(rss, 50 * MIB, BUDGET, start);
385            t.sample_at(rss, 50 * MIB, BUDGET, start);
386            rss += rss / 4;
387            t.sample_at(rss, 50 * MIB, BUDGET, start);
388        }
389        assert!(
390            t.baseline.is_none(),
391            "a climb interrupted by single flat samples is not settled"
392        );
393    }
394
395    // A world that streams forever never settles, and no baseline at all would
396    // mean no signal for exactly the long sessions this is built for.
397    #[test]
398    fn a_session_that_never_settles_captures_a_baseline_at_the_deadline() {
399        let start = Instant::now();
400        let mut t = DriftTracker::default();
401        let mut rss = 100 * MIB;
402        for _ in 0..SETTLE_DEADLINE_SAMPLES {
403            t.sample_at(rss, 50 * MIB, BUDGET, start);
404            rss += rss / 10;
405        }
406        assert!(
407            t.baseline.is_some(),
408            "the deadline captures a baseline even while RSS climbs"
409        );
410    }
411
412    // Without a budget there is no scale to call a movement significant against,
413    // so the numbers are still reported and the verdict withholds judgement.
414    #[test]
415    fn a_zero_budget_reports_movement_without_a_verdict() {
416        let start = Instant::now();
417        let mut t = DriftTracker::default();
418        for _ in 0..=SETTLE_STREAK {
419            t.sample_at(2000 * MIB, 400 * MIB, 0, start);
420        }
421        let d = t
422            .sample_at(9000 * MIB, 400 * MIB, 0, start)
423            .expect("the baseline is captured");
424        assert_eq!(d.verdict, DriftVerdict::Settled);
425        assert_eq!(d.outside_heap_growth_bytes, (7000 * MIB) as i64);
426    }
427
428    #[test]
429    fn every_verdict_has_a_label() {
430        for v in [
431            DriftVerdict::Settled,
432            DriftVerdict::Heap,
433            DriftVerdict::OutsideHeap,
434            DriftVerdict::Both,
435        ] {
436            assert!(!v.label().is_empty());
437        }
438    }
439}