kevy_alloc/pagemap.rs
1//! Per-span occupancy — a bitmap in the segment header, one bit per slot.
2//!
3//! v1 tracked free slots as a LIFO list threaded *through* the slots
4//! themselves. That was the structure M3 killed: a page can only be
5//! returned to the OS if no metadata lives inside it, and the free
6//! list's next-pointers sat in the exact pages `MADV_DONTNEED` would
7//! zero — so reclaim could only ever work on whole spans, and a span
8//! returns nothing until all of its (up to 157) slots die together.
9//!
10//! The bitmap moves every trace of occupancy into the header, which buys
11//! three properties at once (RFC §5.1 v2):
12//!
13//! - **pages are pure data**, so any page whose overlapping slots are
14//! all free is returnable while its neighbours stay live;
15//! - **lowest-first allocation densifies** — `alloc_slot` takes the
16//! lowest free bit, so live slots pack low and churn migrates free
17//! space upward into whole pages, *manufacturing* returnable pages
18//! rather than waiting for coincident deaths;
19//! - **`free` writes nothing into the slot**, one line fewer touched.
20//!
21//! Worst case (16 B class) is 4096 slots → 512 B of bitmap; 64 spans of
22//! metadata ≈ 34 KB, comfortably inside the 64 KiB header span.
23
24use crate::class::{self, SPAN_BYTES};
25use crate::os::PAGE;
26
27/// 4 KiB pages per 64 KiB span.
28pub const PAGES_PER_SPAN: usize = SPAN_BYTES / PAGE;
29
30/// `discarded` with every page set — a whole span handed back at once,
31/// which is what retiring an emptied span does.
32///
33/// # Examples
34///
35/// One bit per page of the span, and no more — the field is a `u16` and
36/// a span is 16 pages, so an off-by-one here would either lose a page or
37/// set a bit that names nothing.
38///
39/// ```
40/// use kevy_alloc::pagemap::{ALL_PAGES_DISCARDED, PAGES_PER_SPAN};
41///
42/// assert_eq!(ALL_PAGES_DISCARDED.count_ones() as usize, PAGES_PER_SPAN);
43/// assert_eq!(ALL_PAGES_DISCARDED.trailing_ones() as usize, PAGES_PER_SPAN);
44/// ```
45pub const ALL_PAGES_DISCARDED: u16 = ((1u32 << PAGES_PER_SPAN) - 1) as u16;
46
47/// Bitmap words: enough for the smallest class (16 B → 4096 slots).
48pub const BITMAP_WORDS: usize = SPAN_BYTES / 16 / 64;
49
50/// No class assigned — the span is free for any class to take.
51pub const NO_CLASS: u8 = 0xFF;
52
53/// Per-span bookkeeping. Deliberately *not* small: the bitmap is the
54/// price of page-granular reclaim, and it lives in the header span,
55/// which exists to be spent on exactly this.
56#[derive(Debug, Clone, Copy)]
57pub struct SpanMeta {
58 /// Size class this span serves, or [`NO_CLASS`].
59 pub class: u8,
60 /// Lowest bitmap word that may hold a zero bit — a scan cursor,
61 /// maintained so lowest-first allocation is O(words-with-no-hole)
62 /// rather than O(words).
63 hint: u8,
64 /// Slots handed out and not yet freed.
65 pub live: u16,
66 /// Slots at or above this index have never been handed out; their
67 /// pages were never touched and are not resident.
68 pub high_water: u16,
69 /// Pages returned to the OS (`MADV_DONTNEED`). Cleared per page when
70 /// an allocation lands back in one; set wholesale by
71 /// [`Heap::retire_empty_span`](crate::Heap) when the span is emptied
72 /// and its pages go back together.
73 pub discarded: u16,
74 /// Set when this span was emptied and handed back to the free pool,
75 /// as opposed to never having been assigned at all.
76 ///
77 /// Both are `class == NO_CLASS`, and they are opposite kinds of
78 /// unassigned: one was never touched, the other was touched and then
79 /// either discarded or deliberately kept. Without this bit all three
80 /// collapse into one bucket, which is what they did — the identity
81 /// balances the same whichever way they fall, so nothing caught it.
82 ///
83 /// # Examples
84 ///
85 /// The three states a span with no class can be in, and the two
86 /// fields that tell them apart:
87 ///
88 /// ```
89 /// use kevy_alloc::pagemap::{ALL_PAGES_DISCARDED, NO_CLASS};
90 ///
91 /// // (class, retired, discarded) -> what it is
92 /// let never_claimed = (NO_CLASS, false, 0u16);
93 /// let given_back = (NO_CLASS, true, ALL_PAGES_DISCARDED);
94 /// let held = (NO_CLASS, true, 0u16);
95 ///
96 /// // All three read as "unassigned", and only `retired` plus the
97 /// // discard bitmap separate the one that was never touched from the
98 /// // one whose pages went back and the one still resident.
99 /// for (class, _, _) in [never_claimed, given_back, held] {
100 /// assert_eq!(class, NO_CLASS);
101 /// }
102 /// assert!(!never_claimed.1);
103 /// assert_ne!(given_back.2, held.2);
104 /// ```
105 pub retired: bool,
106 /// One bit per slot; set = live (or parked on a foreign list, which
107 /// pins the page exactly as a live slot does).
108 bitmap: [u64; BITMAP_WORDS],
109}
110
111impl SpanMeta {
112 pub(crate) const fn new() -> Self {
113 Self {
114 class: NO_CLASS,
115 hint: 0,
116 live: 0,
117 high_water: 0,
118 discarded: 0,
119 retired: false,
120 bitmap: [0; BITMAP_WORDS],
121 }
122 }
123
124 /// Assign this span to a class, forgetting everything it held.
125 /// Only legal when nothing in it is live.
126 pub fn reset(&mut self, class: u8) {
127 debug_assert_eq!(self.live, 0, "resetting a span with live slots");
128 *self = Self { class, ..Self::new() };
129 }
130
131 /// Slots this span can hold, given its class.
132 #[must_use]
133 pub fn capacity(&self) -> u32 {
134 if self.class == NO_CLASS {
135 return 0;
136 }
137 class::slots_per_span(self.class as usize) as u32
138 }
139
140 /// Take the lowest free slot, or `None` when the span is full.
141 pub fn alloc_slot(&mut self) -> Option<u32> {
142 let n = self.capacity();
143 let words = (n as usize).div_ceil(64);
144 for w in (self.hint as usize)..words {
145 let holes = !self.bitmap[w];
146 if holes == 0 {
147 continue;
148 }
149 let i = (w as u32) * 64 + holes.trailing_zeros();
150 if i >= n {
151 // Only reachable in the last word: the free bits there
152 // are past the slot count, so the span is full.
153 return None;
154 }
155 self.bitmap[w] |= 1u64 << (i % 64);
156 self.hint = w as u8;
157 self.live += 1;
158 if i as u16 >= self.high_water {
159 self.high_water = i as u16 + 1;
160 }
161 return Some(i);
162 }
163 None
164 }
165
166 /// Claim every free bit of the lowest holed word for local
167 /// handout: the bits are marked live in the bitmap (a claimed bit
168 /// pins its pages exactly as a live slot does, which is what makes
169 /// the claim invisible to reclaim), and the caller hands them out
170 /// from its own copy without touching this header again. Returns
171 /// `(word_index, claimed_mask)`, or `None` when the span is full.
172 ///
173 /// The far-line arithmetic this exists for: one header round-trip
174 /// claims up to 64 slots, so the per-allocation touch that
175 /// profiled at 17.3% of collection-write self time amortizes
176 /// 64:1. Position-awareness coarsens from bit to word — the claim
177 /// still takes the LOWEST holed word, so densification's
178 /// lowest-first semantics survive at word granularity.
179 pub fn claim_word(&mut self) -> Option<(u8, u64)> {
180 let n = self.capacity();
181 let words = (n as usize).div_ceil(64);
182 for w in (self.hint as usize)..words {
183 let valid = if (w + 1) * 64 <= n as usize {
184 !0u64
185 } else {
186 (1u64 << (n as usize - w * 64)) - 1
187 };
188 let holes = !self.bitmap[w] & valid;
189 if holes == 0 {
190 continue;
191 }
192 self.bitmap[w] |= holes;
193 self.live += holes.count_ones() as u16;
194 let hi = (w as u32) * 64 + (63 - holes.leading_zeros());
195 if hi as u16 >= self.high_water {
196 self.high_water = hi as u16 + 1;
197 }
198 self.hint = w as u8;
199 return Some((w as u8, holes));
200 }
201 None
202 }
203
204 /// Return the bits of a claimed word that were never handed out
205 /// (or were handed out and locally freed). The exact inverse of
206 /// the claim's marking; the hint walks back so lowest-first
207 /// allocation sees the holes again.
208 pub fn retire_word(&mut self, w: u8, unused: u64) {
209 debug_assert_eq!(
210 self.bitmap[w as usize] & unused,
211 unused,
212 "retiring bits that were not claimed"
213 );
214 self.bitmap[w as usize] &= !unused;
215 self.live -= unused.count_ones() as u16;
216 if w < self.hint {
217 self.hint = w;
218 }
219 }
220
221 /// Mark slot `i` free.
222 pub fn free_slot(&mut self, i: u32) {
223 let w = (i / 64) as usize;
224 let m = 1u64 << (i % 64);
225 debug_assert!(self.bitmap[w] & m != 0, "double free of slot {i}");
226 self.bitmap[w] &= !m;
227 self.live -= 1;
228 if (w as u8) < self.hint {
229 self.hint = w as u8;
230 }
231 }
232
233 /// Whether slot `i` is live (or parked foreign, which pins pages
234 /// identically).
235 #[must_use]
236 pub fn is_live(&self, i: u32) -> bool {
237 self.bitmap[(i / 64) as usize] & (1u64 << (i % 64)) != 0
238 }
239
240 /// Whether any slot in `first..=last` is live.
241 #[must_use]
242 pub fn range_has_live(&self, first: u32, last: u32) -> bool {
243 let (fw, lw) = ((first / 64) as usize, (last / 64) as usize);
244 for w in fw..=lw {
245 let mut mask = !0u64;
246 if w == fw {
247 mask &= !0u64 << (first % 64);
248 }
249 if w == lw {
250 mask &= !0u64 >> (63 - (last % 64));
251 }
252 if self.bitmap[w] & mask != 0 {
253 return true;
254 }
255 }
256 false
257 }
258}
259
260/// The pages slot `i` of a `slot_size` class overlaps, inclusive.
261#[must_use]
262pub fn pages_of_slot(i: u32, slot_size: usize) -> (usize, usize) {
263 let start = i as usize * slot_size;
264 let end = start + slot_size - 1;
265 (start / PAGE, end / PAGE)
266}
267
268/// The slots of a `slot_size` class overlapping page `p`, inclusive,
269/// clamped to `nslots`.
270#[must_use]
271pub fn slots_of_page(p: usize, slot_size: usize, nslots: u32) -> (u32, u32) {
272 let first = (p * PAGE / slot_size) as u32;
273 let last = (((p + 1) * PAGE - 1) / slot_size) as u32;
274 (first.min(nslots - 1), last.min(nslots - 1))
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280
281 fn meta_for(size: usize) -> SpanMeta {
282 let mut m = SpanMeta::new();
283 m.reset(class::index_of(size, 8).unwrap() as u8);
284 m
285 }
286
287 #[test]
288 fn allocation_is_lowest_first_and_exhausts_exactly() {
289 let mut m = meta_for(8192);
290 let cap = m.capacity();
291 for expect in 0..cap {
292 assert_eq!(m.alloc_slot(), Some(expect), "not lowest-first");
293 }
294 assert_eq!(m.alloc_slot(), None, "over-handed past capacity");
295 assert_eq!(m.live as u32, cap);
296 }
297
298 #[test]
299 fn a_freed_low_slot_is_taken_before_a_higher_hole() {
300 let mut m = meta_for(400);
301 for _ in 0..100 {
302 m.alloc_slot();
303 }
304 m.free_slot(3);
305 m.free_slot(97);
306 assert_eq!(m.alloc_slot(), Some(3), "densification broken");
307 assert_eq!(m.alloc_slot(), Some(97));
308 }
309
310 #[test]
311 fn range_has_live_sees_across_word_boundaries() {
312 let mut m = meta_for(16); // 4096 slots, many words
313 for _ in 0..=130 {
314 m.alloc_slot();
315 }
316 for i in 0..=129 {
317 m.free_slot(i);
318 }
319 // Slot 130 is the only survivor, sitting in word 2.
320 assert!(m.range_has_live(0, 200));
321 assert!(m.range_has_live(130, 130));
322 assert!(!m.range_has_live(0, 129));
323 assert!(!m.range_has_live(131, 300));
324 }
325
326 #[test]
327 fn claim_takes_the_lowest_holed_word_and_retire_reverses_it() {
328 let mut m = meta_for(400); // 157 slots -> 3 words, last partial
329 for _ in 0..64 {
330 m.alloc_slot(); // word 0 full
331 }
332 let (w, mask) = m.claim_word().expect("word 1 has holes");
333 assert_eq!(w, 1, "lowest holed word");
334 assert_eq!(mask, !0u64, "all 64 bits were free");
335 assert_eq!(m.live, 128);
336 // The span-side view: word 1 is now full, allocation skips it.
337 assert_eq!(m.alloc_slot(), Some(128), "next span alloc lands in word 2");
338 m.free_slot(128);
339 // Retire half the claim; those bits become allocatable again.
340 m.retire_word(w, 0xFFFF_FFFF);
341 assert_eq!(m.live, 96);
342 assert_eq!(m.alloc_slot(), Some(64), "retired bit is the lowest hole");
343 }
344
345 #[test]
346 fn claim_respects_the_capacity_edge() {
347 let mut m = meta_for(400); // 157 slots: word 2 has 29 valid bits
348 for _ in 0..128 {
349 m.alloc_slot();
350 }
351 let (w, mask) = m.claim_word().expect("partial last word");
352 assert_eq!(w, 2);
353 assert_eq!(mask.count_ones(), 157 - 128, "only valid bits claimed");
354 assert_eq!(m.claim_word(), None, "span exhausted");
355 assert_eq!(m.live as u32, m.capacity());
356 }
357
358 #[test]
359 fn page_and_slot_maps_are_inverses() {
360 for size in [16usize, 400, 416, 4096, 8192] {
361 let slot = class::size_of(class::index_of(size, 8).unwrap());
362 let n = (SPAN_BYTES / slot) as u32;
363 for p in 0..PAGES_PER_SPAN {
364 let (a, b) = slots_of_page(p, slot, n);
365 for i in a..=b {
366 let (pa, pb) = pages_of_slot(i, slot);
367 assert!(
368 pa <= p && p <= pb,
369 "slot {i} of {slot}B claims pages {pa}..={pb}, not {p}"
370 );
371 }
372 }
373 }
374 }
375}