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}