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