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#[inline]
141#[must_use]
142pub fn index_of(size: usize, align: usize) -> Option<usize> {
143 if size > MAX_SMALL || align > MAX_NATIVE_ALIGN {
144 return None;
145 }
146 let base = LOOKUP[size.div_ceil(GRAIN)] as usize;
147 if align <= MIN_ALIGN || CLASSES[base].is_multiple_of(align as u32) {
148 return Some(base);
149 }
150 // Only the 8-stepped region can miss, and there the next class up is
151 // always a multiple of 16.
152 let next = base + 1;
153 debug_assert!(next < NCLASSES && CLASSES[next].is_multiple_of(align as u32));
154 Some(next)
155}
156
157/// Slot size for a class index.
158#[inline]
159#[must_use]
160pub fn size_of(index: usize) -> usize {
161 CLASSES[index] as usize
162}
163
164/// `ceil(2^32 / size)` per class — the reciprocal that turns the free
165/// path's slot-index division into a multiply-shift.
166///
167/// Exactness (Granlund–Montgomery): with `m = ceil(2^32 / d)`,
168/// `(n * m) >> 32 == n / d` for every `n` where
169/// `n * (m*d − 2^32) < 2^32`. Here `m*d − 2^32 < d ≤ 2^15` and
170/// `n < SPAN_BYTES = 2^16`, so the error product stays below `2^31`.
171/// The unit test still checks every class at every span offset —
172/// exhaustively, because a proof in a comment has no CI.
173const RECIP: [u32; NCLASSES] = {
174 let mut t = [0u32; NCLASSES];
175 let mut i = 0;
176 while i < NCLASSES {
177 t[i] = ((1u64 << 32).div_ceil(CLASSES[i] as u64)) as u32;
178 i += 1;
179 }
180 t
181};
182
183/// Divide a span offset by a class's slot size via the reciprocal
184/// table. `off` must be below [`SPAN_BYTES`].
185#[inline]
186#[must_use]
187pub fn slot_of_offset(off: usize, index: usize) -> u32 {
188 ((off as u64 * RECIP[index] as u64) >> 32) as u32
189}
190
191/// Slots that fit in a span of this class.
192#[must_use]
193pub const fn slots_per_span(index: usize) -> usize {
194 SPAN_BYTES / CLASSES[index] as usize
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 #[test]
202 fn classes_ascend_and_are_natively_aligned() {
203 let mut prev = 0u32;
204 for (i, &c) in CLASSES.iter().enumerate() {
205 assert!(c > prev, "class {i} = {c} does not exceed {prev}");
206 assert!(
207 (c as usize).is_multiple_of(MIN_ALIGN),
208 "class {c} is not {MIN_ALIGN}-byte aligned"
209 );
210 prev = c;
211 }
212 }
213
214 #[test]
215 fn the_rounding_bound_is_relative_above_128_and_absolute_below() {
216 // The octave decision claims a relative bound; the 8-stepped
217 // region cannot honour one (8 bytes is 33 % of a 24-byte class)
218 // and is bounded absolutely instead. Both halves are asserted so
219 // neither claim can quietly weaken.
220 for w in CLASSES.windows(2) {
221 let (prev, cur) = (w[0], w[1]);
222 let worst = cur - (prev + 1);
223 if cur >= 128 {
224 let rel = f64::from(worst) / f64::from(cur);
225 assert!(rel < 0.125, "class {prev} -> {cur} wastes {:.1}%", rel * 100.0);
226 } else {
227 assert!(worst < GRAIN as u32, "class {prev} -> {cur} wastes {worst} bytes");
228 }
229 }
230 }
231
232 #[test]
233 fn lookup_picks_the_smallest_class_that_fits() {
234 for size in 1..=MAX_SMALL {
235 let idx = index_of(size, 1).expect("within the small range");
236 let picked = size_of(idx);
237 assert!(picked >= size, "class {picked} too small for {size}");
238 if idx > 0 {
239 assert!(
240 size_of(idx - 1) < size,
241 "class {} would also have fit {size}",
242 size_of(idx - 1)
243 );
244 }
245 }
246 }
247
248 #[test]
249 fn sixteen_byte_alignment_is_served_by_class_choice() {
250 for size in 1..=MAX_SMALL {
251 let idx = index_of(size, 16).expect("16 is served natively");
252 let picked = size_of(idx);
253 assert!(picked >= size, "class {picked} too small for {size}");
254 assert!(picked.is_multiple_of(16), "class {picked} cannot align {size} to 16");
255 }
256 }
257
258 #[test]
259 fn requests_off_the_class_path_have_no_class() {
260 assert!(index_of(MAX_SMALL + 1, 1).is_none());
261 assert!(index_of(usize::MAX, 1).is_none());
262 assert!(index_of(64, 32).is_none(), "over-alignment belongs to the shim");
263 }
264
265 #[test]
266 fn every_span_holds_at_least_a_few_slots() {
267 for i in 0..NCLASSES {
268 let slots = slots_per_span(i);
269 // The 16-32 KiB classes get 2-4 slots per span — few, but a
270 // span with two slots still reclaims page-granularly, and
271 // the alternative was an mmap/munmap pair per buffer.
272 assert!(
273 slots >= 2,
274 "class {} gets only {slots} slots per span",
275 size_of(i)
276 );
277 }
278 assert_eq!(SPAN_BYTES % crate::os::PAGE, 0, "a span must be a whole number of pages");
279 assert!(SPAN_BYTES.is_power_of_two(), "masking needs a power-of-two span");
280 }
281
282 /// The reciprocal shortcut must equal the division at every span
283 /// offset of every class — exhaustive, not sampled: 64 Ki offsets
284 /// x 79 classes is five million cheap checks, and the proof in the
285 /// table's comment has no CI without this.
286 #[test]
287 fn the_reciprocal_agrees_with_division_everywhere() {
288 for c in 0..NCLASSES {
289 let size = size_of(c);
290 for off in 0..SPAN_BYTES {
291 assert_eq!(
292 slot_of_offset(off, c) as usize,
293 off / size,
294 "class {c} (size {size}) at offset {off}"
295 );
296 }
297 }
298 }
299}