Skip to main content

kevy_alloc/
stats.rs

1//! The accounting contract, as a type.
2//!
3//! `bench/V5-ACCOUNTING-CONTRACT.md` §1 fixes these fields before this
4//! crate existed, because both v5 RFCs state their ceiling as a
5//! decomposition and a gate that cannot assert *"these terms sum to the
6//! observed gap"* cannot check the only claim that matters.
7//!
8//! # The identity
9//!
10//! ```text
11//! mapped == live + rounding + cache + span_free + virgin
12//!         + hysteresis + segment_overhead
13//! ```
14//!
15//! Exact, with no tolerance. Every mapped byte is in exactly one of
16//! those states by construction, so a mismatch means something is
17//! miscounted — and unexplained bytes are precisely where glibc's 2.24×
18//! was hiding.
19//!
20//! # Two terms the contract did not have at T0
21//!
22//! T0 fixed five terms. Building the geometry showed the partition was
23//! not a partition, so two were added — declared here with the reason,
24//! which is what the contract requires (silently widening it is what is
25//! banned, not changing it):
26//!
27//! - **`virgin`** — spans hand out slots by bumping a cursor, so the
28//!   region above the cursor is *mapped but never touched*, and
29//!   therefore not resident. Folding it into slack would have made the
30//!   slack term look like memory when it is only address space. This
31//!   split is the difference between a number that predicts RSS and one
32//!   that does not.
33//! - **`segment_overhead`** — one span per segment holds the header.
34//!   1.6 % of every segment, structural and knowable, so it is named
35//!   rather than absorbed into a neighbour.
36
37/// A snapshot of where every mapped byte is.
38#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
39pub struct Stats {
40    /// Total bytes mapped from the OS. The anchor.
41    pub mapped: u64,
42    /// Sum of `Layout::size()` over live allocations — what callers
43    /// actually asked for, unrounded.
44    pub live: u64,
45    /// Sum of (slot size − requested size) over live allocations.
46    pub rounding: u64,
47    /// Bytes parked on foreign-free lists, waiting to be drained home.
48    pub cache: u64,
49    /// Free slots in spans that were handed out before and returned to
50    /// the free pool: touched, therefore resident.
51    pub span_free: u64,
52    /// Bytes whose pages have been handed back to the OS — mapped, not
53    /// resident. The v2 term: this is what page-granular reclaim
54    /// produces, and it did not exist while the reclaim unit was the
55    /// whole span.
56    ///
57    /// Two shapes, both genuinely returned: free slots inside a span
58    /// that is still live, and whole spans emptied and retired. The
59    /// second used to be filed under `hysteresis`, so this read 0 while
60    /// most of the map had in fact gone back.
61    pub returned: u64,
62    /// Mapped and never touched, therefore not resident: span bytes at
63    /// or above the bump cursor, and whole spans carved with their
64    /// segment that no class has ever claimed.
65    pub virgin: u64,
66    /// Retained rather than released, and therefore still resident:
67    /// empty spans the per-sweep policy keeps for their class, spans
68    /// whose discard the platform refused, and large mappings parked in
69    /// the process-wide retention pool.
70    ///
71    /// Every byte here is one the allocator chose to keep. A byte that
72    /// went back to the OS belongs in [`Self::returned`] — the two are
73    /// opposites, and for the whole v5 arc they were the same number.
74    pub hysteresis: u64,
75    /// Segment headers.
76    pub segment_overhead: u64,
77    /// Live allocations served by direct mapping rather than a class.
78    pub large_count: u64,
79    /// Spans currently assigned to a size class. Exported because "did
80    /// we keep claiming fresh spans past reusable ones" is not visible
81    /// in any byte count — the identity balances either way.
82    pub spans_assigned: u64,
83}
84
85impl Stats {
86    /// The sum the identity asserts. Kept separate from [`Self::mapped`]
87    /// so a test can compare the two rather than trusting one.
88    /// # Examples
89    ///
90    /// ```
91    /// use kevy_alloc::Stats;
92    /// // The identity this exists to check: every mapped byte is in
93    /// // exactly one bucket, so the sum is comparable to `mapped`
94    /// // rather than derived from it.
95    /// let s = Stats::default();
96    /// assert_eq!(s.accounted(), 0);
97    /// assert_eq!(s.mapped, 0);
98    /// ```
99    #[must_use]
100    pub fn accounted(&self) -> u64 {
101        self.live
102            + self.rounding
103            + self.cache
104            + self.span_free
105            + self.returned
106            + self.virgin
107            + self.hysteresis
108            + self.segment_overhead
109    }
110
111    /// Whether the identity holds exactly.
112    #[must_use]
113    pub fn balanced(&self) -> bool {
114        self.mapped == self.accounted()
115    }
116
117    /// Bytes we expect to be resident: everything mapped except what was
118    /// never touched or has been handed back to the OS.
119    ///
120    /// An estimate by construction — the kernel decides residency, not
121    /// us — so it is named as a prediction and compared against real RSS
122    /// by the gate rather than substituted for it.
123    ///
124    /// `hysteresis` is NOT subtracted, and used to be. That term is what
125    /// the policy is deliberately holding — empty spans kept for their
126    /// class, large mappings parked in the retention pool — and holding
127    /// is the opposite of handing back. Subtracting it made this
128    /// prediction fall by exactly the amount the retention pool grew,
129    /// which is the one direction it cannot be right in.
130    #[must_use]
131    pub fn predicted_resident(&self) -> u64 {
132        self.mapped - self.virgin - self.returned
133    }
134
135    /// Add another heap's snapshot. Shards report separately; a process
136    /// figure is their sum.
137    pub fn merge(&mut self, other: &Stats) {
138        self.mapped += other.mapped;
139        self.live += other.live;
140        self.rounding += other.rounding;
141        self.cache += other.cache;
142        self.span_free += other.span_free;
143        self.returned += other.returned;
144        self.virgin += other.virgin;
145        self.hysteresis += other.hysteresis;
146        self.segment_overhead += other.segment_overhead;
147        self.large_count += other.large_count;
148        self.spans_assigned += other.spans_assigned;
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn an_empty_heap_balances() {
158        assert!(Stats::default().balanced());
159    }
160
161    #[test]
162    fn merge_is_additive_in_every_term() {
163        let a = Stats { mapped: 10, live: 4, virgin: 6, ..Stats::default() };
164        let mut sum = a;
165        sum.merge(&a);
166        assert_eq!(sum.mapped, 20);
167        assert_eq!(sum.live, 8);
168        assert_eq!(sum.virgin, 12);
169        assert!(sum.balanced());
170    }
171
172    #[test]
173    fn an_imbalance_is_visible() {
174        let bad = Stats { mapped: 100, live: 1, ..Stats::default() };
175        assert!(!bad.balanced());
176        assert_eq!(bad.accounted(), 1);
177    }
178}