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/// `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}