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 /// Free slots whose pages have been handed back to the OS while
53 /// their span stays live — mapped, not resident. The v2 term: this
54 /// is what page-granular reclaim produces, and it did not exist
55 /// while the reclaim unit was the whole span.
56 pub returned: u64,
57 /// Span bytes at or above the bump cursor — mapped, never touched,
58 /// not resident.
59 pub virgin: u64,
60 /// Whole spans with nothing live, retained rather than released.
61 pub hysteresis: u64,
62 /// Segment headers.
63 pub segment_overhead: u64,
64 /// Live allocations served by direct mapping rather than a class.
65 pub large_count: u64,
66 /// Spans currently assigned to a size class. Exported because "did
67 /// we keep claiming fresh spans past reusable ones" is not visible
68 /// in any byte count — the identity balances either way.
69 pub spans_assigned: u64,
70}
71
72impl Stats {
73 /// The sum the identity asserts. Kept separate from [`Self::mapped`]
74 /// so a test can compare the two rather than trusting one.
75 /// # Examples
76 ///
77 /// ```
78 /// use kevy_alloc::Stats;
79 /// // The identity this exists to check: every mapped byte is in
80 /// // exactly one bucket, so the sum is comparable to `mapped`
81 /// // rather than derived from it.
82 /// let s = Stats::default();
83 /// assert_eq!(s.accounted(), 0);
84 /// assert_eq!(s.mapped, 0);
85 /// ```
86 #[must_use]
87 pub fn accounted(&self) -> u64 {
88 self.live
89 + self.rounding
90 + self.cache
91 + self.span_free
92 + self.returned
93 + self.virgin
94 + self.hysteresis
95 + self.segment_overhead
96 }
97
98 /// Whether the identity holds exactly.
99 #[must_use]
100 pub fn balanced(&self) -> bool {
101 self.mapped == self.accounted()
102 }
103
104 /// Bytes we expect to be resident: everything mapped except what was
105 /// never touched or has been handed back to the OS.
106 ///
107 /// An estimate by construction — the kernel decides residency, not
108 /// us — so it is named as a prediction and compared against real RSS
109 /// by the gate rather than substituted for it.
110 #[must_use]
111 pub fn predicted_resident(&self) -> u64 {
112 self.mapped - self.virgin - self.hysteresis - self.returned
113 }
114
115 /// Add another heap's snapshot. Shards report separately; a process
116 /// figure is their sum.
117 pub fn merge(&mut self, other: &Stats) {
118 self.mapped += other.mapped;
119 self.live += other.live;
120 self.rounding += other.rounding;
121 self.cache += other.cache;
122 self.span_free += other.span_free;
123 self.returned += other.returned;
124 self.virgin += other.virgin;
125 self.hysteresis += other.hysteresis;
126 self.segment_overhead += other.segment_overhead;
127 self.large_count += other.large_count;
128 self.spans_assigned += other.spans_assigned;
129 }
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135
136 #[test]
137 fn an_empty_heap_balances() {
138 assert!(Stats::default().balanced());
139 }
140
141 #[test]
142 fn merge_is_additive_in_every_term() {
143 let a = Stats { mapped: 10, live: 4, virgin: 6, ..Stats::default() };
144 let mut sum = a;
145 sum.merge(&a);
146 assert_eq!(sum.mapped, 20);
147 assert_eq!(sum.live, 8);
148 assert_eq!(sum.virgin, 12);
149 assert!(sum.balanced());
150 }
151
152 #[test]
153 fn an_imbalance_is_visible() {
154 let bad = Stats { mapped: 100, live: 1, ..Stats::default() };
155 assert!(!bad.balanced());
156 assert_eq!(bad.accounted(), 1);
157 }
158}