Skip to main content

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/// Bitmap words: enough for the smallest class (16 B → 4096 slots).
31pub const BITMAP_WORDS: usize = SPAN_BYTES / 16 / 64;
32
33/// No class assigned — the span is free for any class to take.
34pub const NO_CLASS: u8 = 0xFF;
35
36/// Per-span bookkeeping. Deliberately *not* small: the bitmap is the
37/// price of page-granular reclaim, and it lives in the header span,
38/// which exists to be spent on exactly this.
39#[derive(Clone, Copy)]
40pub struct SpanMeta {
41    /// Size class this span serves, or [`NO_CLASS`].
42    pub class: u8,
43    /// Lowest bitmap word that may hold a zero bit — a scan cursor,
44    /// maintained so lowest-first allocation is O(words-with-no-hole)
45    /// rather than O(words).
46    hint: u8,
47    /// Slots handed out and not yet freed.
48    pub live: u16,
49    /// Slots at or above this index have never been handed out; their
50    /// pages were never touched and are not resident.
51    pub high_water: u16,
52    /// Pages returned to the OS (`MADV_DONTNEED`) while the span stays
53    /// assigned. Cleared per page when an allocation lands back in one.
54    pub discarded: u16,
55    /// One bit per slot; set = live (or parked on a foreign list, which
56    /// pins the page exactly as a live slot does).
57    bitmap: [u64; BITMAP_WORDS],
58}
59
60impl SpanMeta {
61    pub(crate) const fn new() -> Self {
62        Self {
63            class: NO_CLASS,
64            hint: 0,
65            live: 0,
66            high_water: 0,
67            discarded: 0,
68            bitmap: [0; BITMAP_WORDS],
69        }
70    }
71
72    /// Assign this span to a class, forgetting everything it held.
73    /// Only legal when nothing in it is live.
74    pub fn reset(&mut self, class: u8) {
75        debug_assert_eq!(self.live, 0, "resetting a span with live slots");
76        *self = Self { class, ..Self::new() };
77    }
78
79    /// Slots this span can hold, given its class.
80    #[must_use]
81    pub fn capacity(&self) -> u32 {
82        if self.class == NO_CLASS {
83            return 0;
84        }
85        class::slots_per_span(self.class as usize) as u32
86    }
87
88    /// Take the lowest free slot, or `None` when the span is full.
89    pub fn alloc_slot(&mut self) -> Option<u32> {
90        let n = self.capacity();
91        let words = (n as usize).div_ceil(64);
92        for w in (self.hint as usize)..words {
93            let holes = !self.bitmap[w];
94            if holes == 0 {
95                continue;
96            }
97            let i = (w as u32) * 64 + holes.trailing_zeros();
98            if i >= n {
99                // Only reachable in the last word: the free bits there
100                // are past the slot count, so the span is full.
101                return None;
102            }
103            self.bitmap[w] |= 1u64 << (i % 64);
104            self.hint = w as u8;
105            self.live += 1;
106            if i as u16 >= self.high_water {
107                self.high_water = i as u16 + 1;
108            }
109            return Some(i);
110        }
111        None
112    }
113
114    /// Claim every free bit of the lowest holed word for local
115    /// handout: the bits are marked live in the bitmap (a claimed bit
116    /// pins its pages exactly as a live slot does, which is what makes
117    /// the claim invisible to reclaim), and the caller hands them out
118    /// from its own copy without touching this header again. Returns
119    /// `(word_index, claimed_mask)`, or `None` when the span is full.
120    ///
121    /// The far-line arithmetic this exists for: one header round-trip
122    /// claims up to 64 slots, so the per-allocation touch that
123    /// profiled at 17.3% of collection-write self time amortizes
124    /// 64:1. Position-awareness coarsens from bit to word — the claim
125    /// still takes the LOWEST holed word, so densification's
126    /// lowest-first semantics survive at word granularity.
127    pub fn claim_word(&mut self) -> Option<(u8, u64)> {
128        let n = self.capacity();
129        let words = (n as usize).div_ceil(64);
130        for w in (self.hint as usize)..words {
131            let valid = if (w + 1) * 64 <= n as usize {
132                !0u64
133            } else {
134                (1u64 << (n as usize - w * 64)) - 1
135            };
136            let holes = !self.bitmap[w] & valid;
137            if holes == 0 {
138                continue;
139            }
140            self.bitmap[w] |= holes;
141            self.live += holes.count_ones() as u16;
142            let hi = (w as u32) * 64 + (63 - holes.leading_zeros());
143            if hi as u16 >= self.high_water {
144                self.high_water = hi as u16 + 1;
145            }
146            self.hint = w as u8;
147            return Some((w as u8, holes));
148        }
149        None
150    }
151
152    /// Return the bits of a claimed word that were never handed out
153    /// (or were handed out and locally freed). The exact inverse of
154    /// the claim's marking; the hint walks back so lowest-first
155    /// allocation sees the holes again.
156    pub fn retire_word(&mut self, w: u8, unused: u64) {
157        debug_assert_eq!(
158            self.bitmap[w as usize] & unused,
159            unused,
160            "retiring bits that were not claimed"
161        );
162        self.bitmap[w as usize] &= !unused;
163        self.live -= unused.count_ones() as u16;
164        if w < self.hint {
165            self.hint = w;
166        }
167    }
168
169    /// Mark slot `i` free.
170    pub fn free_slot(&mut self, i: u32) {
171        let w = (i / 64) as usize;
172        let m = 1u64 << (i % 64);
173        debug_assert!(self.bitmap[w] & m != 0, "double free of slot {i}");
174        self.bitmap[w] &= !m;
175        self.live -= 1;
176        if (w as u8) < self.hint {
177            self.hint = w as u8;
178        }
179    }
180
181    /// Whether slot `i` is live (or parked foreign, which pins pages
182    /// identically).
183    #[must_use]
184    pub fn is_live(&self, i: u32) -> bool {
185        self.bitmap[(i / 64) as usize] & (1u64 << (i % 64)) != 0
186    }
187
188    /// Whether any slot in `first..=last` is live.
189    #[must_use]
190    pub fn range_has_live(&self, first: u32, last: u32) -> bool {
191        let (fw, lw) = ((first / 64) as usize, (last / 64) as usize);
192        for w in fw..=lw {
193            let mut mask = !0u64;
194            if w == fw {
195                mask &= !0u64 << (first % 64);
196            }
197            if w == lw {
198                mask &= !0u64 >> (63 - (last % 64));
199            }
200            if self.bitmap[w] & mask != 0 {
201                return true;
202            }
203        }
204        false
205    }
206}
207
208/// The pages slot `i` of a `slot_size` class overlaps, inclusive.
209#[must_use]
210pub fn pages_of_slot(i: u32, slot_size: usize) -> (usize, usize) {
211    let start = i as usize * slot_size;
212    let end = start + slot_size - 1;
213    (start / PAGE, end / PAGE)
214}
215
216/// The slots of a `slot_size` class overlapping page `p`, inclusive,
217/// clamped to `nslots`.
218#[must_use]
219pub fn slots_of_page(p: usize, slot_size: usize, nslots: u32) -> (u32, u32) {
220    let first = (p * PAGE / slot_size) as u32;
221    let last = (((p + 1) * PAGE - 1) / slot_size) as u32;
222    (first.min(nslots - 1), last.min(nslots - 1))
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    fn meta_for(size: usize) -> SpanMeta {
230        let mut m = SpanMeta::new();
231        m.reset(class::index_of(size, 8).unwrap() as u8);
232        m
233    }
234
235    #[test]
236    fn allocation_is_lowest_first_and_exhausts_exactly() {
237        let mut m = meta_for(8192);
238        let cap = m.capacity();
239        for expect in 0..cap {
240            assert_eq!(m.alloc_slot(), Some(expect), "not lowest-first");
241        }
242        assert_eq!(m.alloc_slot(), None, "over-handed past capacity");
243        assert_eq!(m.live as u32, cap);
244    }
245
246    #[test]
247    fn a_freed_low_slot_is_taken_before_a_higher_hole() {
248        let mut m = meta_for(400);
249        for _ in 0..100 {
250            m.alloc_slot();
251        }
252        m.free_slot(3);
253        m.free_slot(97);
254        assert_eq!(m.alloc_slot(), Some(3), "densification broken");
255        assert_eq!(m.alloc_slot(), Some(97));
256    }
257
258    #[test]
259    fn range_has_live_sees_across_word_boundaries() {
260        let mut m = meta_for(16); // 4096 slots, many words
261        for _ in 0..=130 {
262            m.alloc_slot();
263        }
264        for i in 0..=129 {
265            m.free_slot(i);
266        }
267        // Slot 130 is the only survivor, sitting in word 2.
268        assert!(m.range_has_live(0, 200));
269        assert!(m.range_has_live(130, 130));
270        assert!(!m.range_has_live(0, 129));
271        assert!(!m.range_has_live(131, 300));
272    }
273
274    #[test]
275    fn claim_takes_the_lowest_holed_word_and_retire_reverses_it() {
276        let mut m = meta_for(400); // 157 slots -> 3 words, last partial
277        for _ in 0..64 {
278            m.alloc_slot(); // word 0 full
279        }
280        let (w, mask) = m.claim_word().expect("word 1 has holes");
281        assert_eq!(w, 1, "lowest holed word");
282        assert_eq!(mask, !0u64, "all 64 bits were free");
283        assert_eq!(m.live, 128);
284        // The span-side view: word 1 is now full, allocation skips it.
285        assert_eq!(m.alloc_slot(), Some(128), "next span alloc lands in word 2");
286        m.free_slot(128);
287        // Retire half the claim; those bits become allocatable again.
288        m.retire_word(w, 0xFFFF_FFFF);
289        assert_eq!(m.live, 96);
290        assert_eq!(m.alloc_slot(), Some(64), "retired bit is the lowest hole");
291    }
292
293    #[test]
294    fn claim_respects_the_capacity_edge() {
295        let mut m = meta_for(400); // 157 slots: word 2 has 29 valid bits
296        for _ in 0..128 {
297            m.alloc_slot();
298        }
299        let (w, mask) = m.claim_word().expect("partial last word");
300        assert_eq!(w, 2);
301        assert_eq!(mask.count_ones(), 157 - 128, "only valid bits claimed");
302        assert_eq!(m.claim_word(), None, "span exhausted");
303        assert_eq!(m.live as u32, m.capacity());
304    }
305
306    #[test]
307    fn page_and_slot_maps_are_inverses() {
308        for size in [16usize, 400, 416, 4096, 8192] {
309            let slot = class::size_of(class::index_of(size, 8).unwrap());
310            let n = (SPAN_BYTES / slot) as u32;
311            for p in 0..PAGES_PER_SPAN {
312                let (a, b) = slots_of_page(p, slot, n);
313                for i in a..=b {
314                    let (pa, pb) = pages_of_slot(i, slot);
315                    assert!(
316                        pa <= p && p <= pb,
317                        "slot {i} of {slot}B claims pages {pa}..={pb}, not {p}"
318                    );
319                }
320            }
321        }
322    }
323}