Skip to main content

kevy_alloc/
class.rs

1//! Size classes.
2//!
3//! # Why eight subdivisions per octave, not four
4//!
5//! Two terms of the accounting contract pull against each other: finer
6//! classes cut **rounding**, coarser classes cut **span slack** (fewer
7//! classes means fewer partial spans sitting around). Both are real, so
8//! the choice is not taste.
9//!
10//! §8.1 of the RFC settles it. Rounding is the only term that scales
11//! with the dataset; slack is O(classes × shards) and constant in the
12//! data. **Spend the constant to shrink the term that scales.** Hence
13//! eight subdivisions per octave — worst-case rounding ≈ 11.1 %, against
14//! the ~20 % that four subdivisions would give.
15//!
16//! Below 128 bytes the classes step by 8, and the bound there is
17//! **absolute rather than relative**: at most 7 bytes wasted, but that
18//! is 29 % of a 24-byte class. The relative bound cannot be rescued at
19//! that end — 8 bytes is the granularity floor, since finer classes
20//! would not keep slots 8-byte aligned. Saying "the step is finer so it
21//! costs nothing" would have been wrong, and the class table's own test
22//! said so before this comment was written.
23//!
24//! # Why spans are one uniform size after all
25//!
26//! The first draft sized spans per class, reasoning that a fixed span
27//! size makes slack proportional to the class count — which the decision
28//! above deliberately increases. Two things overturned it.
29//!
30//! Geometry: `dealloc` finds a pointer's span by masking, which needs
31//! uniform span geometry. Variable spans would need a lookup structure
32//! on the free path, paid on every deallocation, to save address space.
33//!
34//! And the worry was misplaced. Spans hand out slots by bumping a
35//! cursor, so the untouched tail of a span is **mapped but never
36//! resident** — it costs address space, not memory. That is why the
37//! accounting splits slack into touched (`span_free`) and untouched
38//! (`virgin`): only the first is RSS. A large uniform span whose tail is
39//! never reached is close to free in the metric that matters.
40
41
42/// The largest allocation served by a size class. Above this, requests
43/// are mapped directly and returned with `unmap`.
44///
45/// Raised 8 KiB → 32 KiB by the M1 decomposition. The old cap assumed
46/// large allocations were infrequent — and under a cross-shard load the
47/// dispatch and reply buffers sit just past 8 KiB, so every one paid an
48/// mmap on birth and a munmap on death, eight shards serialised on the
49/// process-wide mmap_lock: **40 % of server self time** was
50/// `__x64_sys_munmap` + `vm_mmap_pgoff` (finding
51/// measured (the mmap-lock convoy finding). glibc recycles those
52/// buffers from its arena with zero syscalls, which is the entire
53/// cross-shard gap. A 64 KiB span still holds 2–8 slots at these sizes.
54pub const MAX_SMALL: usize = 32_768;
55
56/// Alignment every class satisfies natively, because every class is a
57/// multiple of it and spans are aligned far beyond it.
58pub const MIN_ALIGN: usize = 8;
59
60/// Strongest alignment served by picking a suitable class rather than by
61/// over-allocating. Requests above this go to the `GlobalAlloc` shim's
62/// over-aligned path.
63///
64/// 16 matters enough to be worth serving directly — `u128`, `AtomicU64`
65/// pairs and most SIMD vectors ask for it — and it costs only skipping
66/// to the next class when the natural one is not a multiple of 16.
67pub const MAX_NATIVE_ALIGN: usize = 16;
68
69/// Every span is this many bytes, whatever class it serves. Uniform
70/// geometry is what lets `dealloc` find a span by masking; see the
71/// module docs for why the variable-size draft lost. 64 KiB gives the
72/// largest class eight slots and the smallest four thousand.
73pub const SPAN_BYTES: usize = 64 * 1024;
74
75/// The class table: every size a slot may have, ascending.
76///
77/// Written out rather than generated so it can be read and checked. A
78/// stone's most important property is that a reviewer can see what it
79/// does; 79 numbers are cheaper to audit than the loop that would emit
80/// them.
81pub const CLASSES: [u32; 79] = [
82    // 16..=128 step 8 — finer than the octave rule, and free.
83    16, 24, 32, 40, 48, 56, 64, 72, 80, 88, 96, 104, 112, 120, 128,
84    // 128..=256 step 16
85    144, 160, 176, 192, 208, 224, 240, 256,
86    // 256..=512 step 32
87    288, 320, 352, 384, 416, 448, 480, 512,
88    // 512..=1024 step 64
89    576, 640, 704, 768, 832, 896, 960, 1024,
90    // 1024..=2048 step 128
91    1152, 1280, 1408, 1536, 1664, 1792, 1920, 2048,
92    // 2048..=4096 step 256
93    2304, 2560, 2816, 3072, 3328, 3584, 3840, 4096,
94    // 4096..=8192 step 512
95    4608, 5120, 5632, 6144, 6656, 7168, 7680, 8192,
96    // 8192..=16384 step 1024
97    9216, 10240, 11264, 12288, 13312, 14336, 15360, 16384,
98    // 16384..=32768 step 2048
99    18432, 20480, 22528, 24576, 26624, 28672, 30720, 32768,
100];
101
102/// Number of size classes.
103pub const NCLASSES: usize = CLASSES.len();
104
105/// Lookup granularity: one table entry per 8 bytes of request size.
106const GRAIN: usize = 8;
107const LOOKUP_LEN: usize = MAX_SMALL / GRAIN + 1;
108
109/// `size -> class index`, resolved by table rather than by arithmetic so
110/// the hot path is one load and no branching over the octave structure.
111static LOOKUP: [u8; LOOKUP_LEN] = build_lookup();
112
113const fn build_lookup() -> [u8; LOOKUP_LEN] {
114    let mut table = [0u8; LOOKUP_LEN];
115    let mut i = 0;
116    while i < LOOKUP_LEN {
117        let size = i * GRAIN;
118        let mut c = 0;
119        while c < NCLASSES {
120            if CLASSES[c] as usize >= size {
121                break;
122            }
123            c += 1;
124        }
125        table[i] = c as u8;
126        i += 1;
127    }
128    table
129}
130
131/// The class index serving `size` at `align`, or `None` when the request
132/// belongs on the direct-mapping path (too large, or too strictly
133/// aligned to serve from a class).
134///
135/// Slots sit at multiples of the class size inside a span, and span
136/// bases are 64 KiB-aligned, so a slot's alignment is exactly the
137/// alignment of its class size. Serving a 16-byte alignment therefore
138/// means choosing a class that is a multiple of 16 — which, in the
139/// 8-stepped region below 128, is always the very next one.
140/// # Examples
141///
142/// ```
143/// use kevy_alloc::class::{index_of, size_of};
144///
145/// // Every served size rounds UP to its class, never down.
146/// let i = index_of(1, 1).unwrap();
147/// assert!(size_of(i) >= 1);
148/// let i = index_of(100, 1).unwrap();
149/// assert!(size_of(i) >= 100);
150///
151/// // A strict alignment picks a class that is a multiple of it.
152/// let i = index_of(24, 16).unwrap();
153/// assert_eq!(size_of(i) % 16, 0);
154///
155/// // Too large, or too strictly aligned, is None — the direct-mapping
156/// // path, not a wrong class.
157/// assert_eq!(index_of(1 << 30, 1), None);
158/// ```
159#[inline]
160#[must_use]
161pub fn index_of(size: usize, align: usize) -> Option<usize> {
162    if size > MAX_SMALL || align > MAX_NATIVE_ALIGN {
163        return None;
164    }
165    let base = LOOKUP[size.div_ceil(GRAIN)] as usize;
166    if align <= MIN_ALIGN || CLASSES[base].is_multiple_of(align as u32) {
167        return Some(base);
168    }
169    // Only the 8-stepped region can miss, and there the next class up is
170    // always a multiple of 16.
171    let next = base + 1;
172    debug_assert!(next < NCLASSES && CLASSES[next].is_multiple_of(align as u32));
173    Some(next)
174}
175
176/// Slot size for a class index.
177/// # Examples
178///
179/// ```
180/// use kevy_alloc::class::{index_of, size_of};
181/// // Classes ascend, so a bigger request never lands in a smaller slot.
182/// let small = size_of(index_of(8, 1).unwrap());
183/// let big = size_of(index_of(200, 1).unwrap());
184/// assert!(small <= big);
185/// ```
186#[inline]
187#[must_use]
188pub fn size_of(index: usize) -> usize {
189    CLASSES[index] as usize
190}
191
192/// `ceil(2^32 / size)` per class — the reciprocal that turns the free
193/// path's slot-index division into a multiply-shift.
194///
195/// Exactness (Granlund–Montgomery): with `m = ceil(2^32 / d)`,
196/// `(n * m) >> 32 == n / d` for every `n` where
197/// `n * (m*d − 2^32) < 2^32`. Here `m*d − 2^32 < d ≤ 2^15` and
198/// `n < SPAN_BYTES = 2^16`, so the error product stays below `2^31`.
199/// The unit test still checks every class at every span offset —
200/// exhaustively, because a proof in a comment has no CI.
201const RECIP: [u32; NCLASSES] = {
202    let mut t = [0u32; NCLASSES];
203    let mut i = 0;
204    while i < NCLASSES {
205        t[i] = ((1u64 << 32).div_ceil(CLASSES[i] as u64)) as u32;
206        i += 1;
207    }
208    t
209};
210
211/// Divide a span offset by a class's slot size via the reciprocal
212/// table. `off` must be below [`SPAN_BYTES`].
213#[inline]
214#[must_use]
215pub fn slot_of_offset(off: usize, index: usize) -> u32 {
216    ((off as u64 * RECIP[index] as u64) >> 32) as u32
217}
218
219/// Slots that fit in a span of this class.
220#[must_use]
221pub const fn slots_per_span(index: usize) -> usize {
222    SPAN_BYTES / CLASSES[index] as usize
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn classes_ascend_and_are_natively_aligned() {
231        let mut prev = 0u32;
232        for (i, &c) in CLASSES.iter().enumerate() {
233            assert!(c > prev, "class {i} = {c} does not exceed {prev}");
234            assert!(
235                (c as usize).is_multiple_of(MIN_ALIGN),
236                "class {c} is not {MIN_ALIGN}-byte aligned"
237            );
238            prev = c;
239        }
240    }
241
242    #[test]
243    fn the_rounding_bound_is_relative_above_128_and_absolute_below() {
244        // The octave decision claims a relative bound; the 8-stepped
245        // region cannot honour one (8 bytes is 33 % of a 24-byte class)
246        // and is bounded absolutely instead. Both halves are asserted so
247        // neither claim can quietly weaken.
248        for w in CLASSES.windows(2) {
249            let (prev, cur) = (w[0], w[1]);
250            let worst = cur - (prev + 1);
251            if cur >= 128 {
252                let rel = f64::from(worst) / f64::from(cur);
253                assert!(rel < 0.125, "class {prev} -> {cur} wastes {:.1}%", rel * 100.0);
254            } else {
255                assert!(worst < GRAIN as u32, "class {prev} -> {cur} wastes {worst} bytes");
256            }
257        }
258    }
259
260    #[test]
261    fn lookup_picks_the_smallest_class_that_fits() {
262        for size in 1..=MAX_SMALL {
263            let idx = index_of(size, 1).expect("within the small range");
264            let picked = size_of(idx);
265            assert!(picked >= size, "class {picked} too small for {size}");
266            if idx > 0 {
267                assert!(
268                    size_of(idx - 1) < size,
269                    "class {} would also have fit {size}",
270                    size_of(idx - 1)
271                );
272            }
273        }
274    }
275
276    #[test]
277    fn sixteen_byte_alignment_is_served_by_class_choice() {
278        for size in 1..=MAX_SMALL {
279            let idx = index_of(size, 16).expect("16 is served natively");
280            let picked = size_of(idx);
281            assert!(picked >= size, "class {picked} too small for {size}");
282            assert!(picked.is_multiple_of(16), "class {picked} cannot align {size} to 16");
283        }
284    }
285
286    #[test]
287    fn requests_off_the_class_path_have_no_class() {
288        assert!(index_of(MAX_SMALL + 1, 1).is_none());
289        assert!(index_of(usize::MAX, 1).is_none());
290        assert!(index_of(64, 32).is_none(), "over-alignment belongs to the shim");
291    }
292
293    #[test]
294    fn every_span_holds_at_least_a_few_slots() {
295        for i in 0..NCLASSES {
296            let slots = slots_per_span(i);
297            // The 16-32 KiB classes get 2-4 slots per span — few, but a
298            // span with two slots still reclaims page-granularly, and
299            // the alternative was an mmap/munmap pair per buffer.
300            assert!(
301                slots >= 2,
302                "class {} gets only {slots} slots per span",
303                size_of(i)
304            );
305        }
306        assert_eq!(SPAN_BYTES % crate::os::PAGE, 0, "a span must be a whole number of pages");
307        assert!(SPAN_BYTES.is_power_of_two(), "masking needs a power-of-two span");
308    }
309
310    /// The reciprocal shortcut must equal the division at every span
311    /// offset of every class — exhaustive, not sampled: 64 Ki offsets
312    /// x 79 classes is five million cheap checks, and the proof in the
313    /// table's comment has no CI without this.
314    #[test]
315    fn the_reciprocal_agrees_with_division_everywhere() {
316        for c in 0..NCLASSES {
317            let size = size_of(c);
318            for off in 0..SPAN_BYTES {
319                assert_eq!(
320                    slot_of_offset(off, c) as usize,
321                    off / size,
322                    "class {c} (size {size}) at offset {off}"
323                );
324            }
325        }
326    }
327}