inillucent_alloc/lib.rs
1//! A size-classed free list over the system allocator.
2//!
3//! Invariant: **it recycles rather than accumulating.** A bump arena that never
4//! frees is the shortest thing to write and the wrong thing to ship: thirty
5//! rounds of the gate's plan would grow it without bound, and what it measured
6//! would be page faults rather than allocation. This keeps one intrusive list
7//! per size class, capped, and hands a block back to the system allocator when
8//! the class is full.
9//!
10//! ## Why the engine has one at all
11//!
12//! Because allocation is where a compile goes. A trivial compile was measured
13//! at **25 heap allocations, with the Windows C runtime heap at 59% of the
14//! time**, and a size-classed free list at **17% overall** on the same
15//! plan - which is why Phase 3's Part E names it the cheapest first move rather
16//! than one of the several structural changes beside it. The same shape is
17//! visible outside compilation: `CREATE INDEX` over a hundred thousand rows
18//! builds two allocations per row just to hold the key and the rowid, and the
19//! gate's `schema.index` spends more time in its scan than SQLite spends on the
20//! whole statement.
21//!
22//! What it removes is exactly what was in question: the size lookup, the
23//! locking and the per-call bookkeeping the system allocator does. It does not
24//! try to be a better allocator in general - above [`LARGEST`] and for any
25//! alignment the system's own guarantee does not cover, the request is
26//! forwarded unchanged.
27//!
28//! ## Why it never allocates
29//!
30//! An allocator that allocates re-enters itself, and a re-entrant allocator is
31//! a deadlock or a stack overflow waiting for the right allocation pattern. So
32//! the free lists are **intrusive**: a freed block holds the pointer to the
33//! next free block of its class in its own first eight bytes, and the heads
34//! live in a fixed-size array of `Cell`s in thread-local storage. Nothing here
35//! calls `Vec`, `Box`, or anything that could.
36//!
37//! ## Why it is a crate of its own
38//!
39//! Because every other production crate in this workspace carries
40//! `#![forbid(unsafe_code)]`, and an allocator cannot. Putting it here keeps
41//! that true everywhere it is true today and confines the unsafe to one file
42//! that has nothing else in it - no dependencies, first-party or otherwise, so
43//! there is nothing it could re-enter itself through.
44//!
45//! ## Why it is per thread
46//!
47//! Because a shared list needs a lock, and the lock is most of what this exists
48//! to remove. A block allocated on one thread and freed on another goes onto
49//! the freeing thread's list, which is safe - the block is memory of a known
50//! class, and the class is derived from the layout the caller hands back - and
51//! at worst moves a block between threads. The per-class cap bounds what that
52//! can cost.
53
54#![deny(missing_docs)]
55// **The one production crate in this workspace allowed to write `unsafe`, and
56// it was outside every check until task-1932 (H9).** It was not in `GOVERNED`
57// and not in `UNSAFE_CRATES`, which is not the same as being permitted: it
58// means nothing read it. A `GlobalAlloc` is an unsafe trait and this crate is
59// the boundary, so the four lints below are what say that the *rest* of it -
60// the size-class arithmetic, the caps, the thread-local lists - is ordinary
61// safe code held to the same standard as the engine.
62#![deny(clippy::indexing_slicing)]
63#![deny(clippy::unwrap_used)]
64#![deny(clippy::expect_used)]
65#![deny(clippy::panic)]
66#![cfg_attr(
67 test,
68 allow(
69 clippy::expect_used,
70 clippy::indexing_slicing,
71 clippy::panic,
72 clippy::unwrap_used
73 )
74)]
75
76use std::alloc::{GlobalAlloc, Layout, System};
77use std::cell::Cell;
78
79/// The largest allocation the free list handles itself.
80///
81/// Above this the system allocator is asked directly: a big block is rare, and
82/// keeping a free list of them would be a cache of things nothing asks for
83/// twice.
84pub const LARGEST: usize = 4_096;
85
86/// The granularity of a size class, which is also the alignment every pooled
87/// block is made with.
88const GRAIN: usize = 16;
89
90/// How many size classes the free list holds.
91///
92/// One per sixteen bytes up to [`LARGEST`], which is the granularity a `Vec<u8>`
93/// of a row, a key or a name actually lands on.
94const CLASSES: usize = LARGEST / GRAIN + 1;
95
96/// How many **bytes** one class keeps before handing the rest back.
97///
98/// The cap is what makes this a recycler rather than a leak: a workload that
99/// allocates a million blocks of one class and frees them all keeps a bounded
100/// amount and returns the rest, so the process's footprint is bounded by the
101/// classes rather than by the workload.
102///
103/// **Bytes rather than blocks.** The cap was a thousand and
104/// twenty-four *blocks* per class - a fixed count over classes whose sizes
105/// differ by two hundred and fifty-six times, so the same number meant sixteen
106/// kilobytes in the smallest class and four megabytes in the largest. The
107/// gate's write family paid for it: one round left 5.2 MiB of heap standing
108/// that nothing live was using, and the process's high-water mark is exactly
109/// what this ticket's memory bar reads.
110///
111/// Sixteen kilobytes per class keeps the small classes as deep as they were -
112/// the sixteen-byte class still holds its thousand and twenty-four blocks,
113/// which is where the free list's measured speed comes from - and bounds the
114/// largest at four. The whole cache is at most `CLASSES * 16 KiB`, about
115/// 4 MiB, rather than an unbounded function of which classes a workload
116/// happened to touch.
117const PER_CLASS_BYTES: usize = 64 << 10;
118
119/// The block cap that was here before, kept as a ceiling.
120///
121/// **The byte cap only ever takes retention away.** Applying it alone would
122/// have made the smallest class keep four thousand blocks where it used to keep
123/// a thousand - more, not less - and the small classes are exactly where the
124/// free list's measured speed comes from. Keeping the old count as a ceiling
125/// means every class holds *at most* what it held before, and the large ones
126/// hold far less.
127const PER_CLASS_BLOCKS: usize = 1_024;
128
129/// How many blocks of each class the free list keeps, worked out once.
130///
131/// **A table rather than the arithmetic, because `dealloc` is the hot path.**
132/// The cap is a division and a pair of clamps, and computing it per free put a
133/// divide on every deallocation the program makes. It is a constant of the
134/// class, so it is a constant of the build.
135const CAPS: [usize; CLASSES] = caps();
136
137/// Builds [`CAPS`] at compile time.
138///
139/// The lower of the two caps, and at least one so no class is barred from
140/// recycling by arithmetic. At 64 KiB and a 1,024-block ceiling: the 16-byte
141/// class keeps its full thousand and twenty-four, unchanged, and the
142/// 4,096-byte class keeps sixteen where it used to keep a thousand - which is
143/// four megabytes of one class's free list that a write workload was leaving
144/// standing.
145#[allow(clippy::indexing_slicing)]
146const fn caps() -> [usize; CLASSES] {
147 let mut caps = [1usize; CLASSES];
148 let mut class = 0usize;
149 while class < CLASSES {
150 let size = if class * GRAIN > GRAIN {
151 class * GRAIN
152 } else {
153 GRAIN
154 };
155 let mut cap = PER_CLASS_BYTES / size;
156 if cap > PER_CLASS_BLOCKS {
157 cap = PER_CLASS_BLOCKS;
158 }
159 if cap < 1 {
160 cap = 1;
161 }
162 caps[class] = cap;
163 class += 1;
164 }
165 caps
166}
167
168/// Returns how many blocks of one class the free list keeps.
169///
170/// @param class - the size class
171#[inline]
172fn per_class(class: usize) -> usize {
173 CAPS.get(class).copied().unwrap_or(1)
174}
175
176thread_local! {
177 /// The head of each class's intrusive free list, or null.
178 ///
179 /// `const` on the whole initialiser, not only on the elements: it removes
180 /// the lazy-initialisation check from every access, and an allocator's
181 /// per-call cost is the thing this crate exists to keep small.
182 static HEADS: [Cell<*mut u8>; CLASSES] =
183 const { [const { Cell::new(std::ptr::null_mut()) }; CLASSES] };
184 /// How many blocks each class is holding.
185 static HELD: [Cell<usize>; CLASSES] = const { [const { Cell::new(0) }; CLASSES] };
186}
187
188/// Returns the size class an allocation falls in, if any.
189///
190/// `None` means "not ours": too large, too aligned, or too small to hold the
191/// link a freed block stores in itself.
192///
193/// @param layout - the allocation's layout
194#[inline]
195fn class_of(layout: Layout) -> Option<usize> {
196 if layout.size() > LARGEST
197 || layout.align() > GRAIN
198 || layout.size() < core::mem::size_of::<*mut u8>()
199 {
200 return None;
201 }
202 Some(layout.size().div_ceil(GRAIN))
203}
204
205/// Returns the layout a size class's blocks are allocated with.
206///
207/// @param class - the size class
208#[inline]
209fn layout_of(class: usize) -> Layout {
210 // Every class is a multiple of the grain and aligned to it, so a block is
211 // always at least as large and as aligned as any request in its class.
212 Layout::from_size_align(class.saturating_mul(GRAIN).max(GRAIN), GRAIN)
213 .unwrap_or_else(|_| Layout::new::<u128>())
214}
215
216/// A size-classed free list over the system allocator.
217///
218/// Install it in a binary with
219///
220/// ```ignore
221/// #[global_allocator]
222/// static ALLOCATOR: inillucent_base::alloc::Pooled = inillucent_base::alloc::Pooled;
223/// ```
224///
225/// It is per binary rather than per library because only a binary can name a
226/// global allocator, and because the choice belongs to whoever is running the
227/// program.
228pub struct Pooled;
229
230// SAFETY: every path either forwards to the system allocator unchanged, or
231// hands back a block this allocator obtained from `System` with its class's own
232// layout and has not handed out since. The class is derived from the layout on
233// both sides, so a block is only ever reused for a request it is large enough
234// and aligned enough for.
235unsafe impl GlobalAlloc for Pooled {
236 // SAFETY: a pooled block was allocated by `System.alloc` with the class's
237 // layout, which is at least this request's size and alignment. The link
238 // read out of the block was written by `dealloc` below and nothing has
239 // touched the block since - it is not reachable by any other path while it
240 // is on the list.
241 #[inline]
242 unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
243 let Some(class) = class_of(layout) else {
244 // SAFETY: forwarded unchanged to the system allocator.
245 return unsafe { System.alloc(layout) };
246 };
247 // `try_with`, because a thread tearing down has already dropped its
248 // storage and a panic inside an allocator aborts the process. A request
249 // that finds no list is simply a fresh block.
250 let taken = HEADS
251 .try_with(|heads| {
252 let Some(head) = heads.get(class) else {
253 return std::ptr::null_mut();
254 };
255 let block = head.get();
256 if block.is_null() {
257 return std::ptr::null_mut();
258 }
259 // SAFETY: the block is one this allocator made and put on the
260 // list, and its first word is the link `dealloc` wrote.
261 let next = unsafe { block.cast::<*mut u8>().read() };
262 head.set(next);
263 let _ = HELD.try_with(|held| {
264 if let Some(count) = held.get(class) {
265 count.set(count.get().saturating_sub(1));
266 }
267 });
268 block
269 })
270 .unwrap_or(std::ptr::null_mut());
271 if !taken.is_null() {
272 return taken;
273 }
274 // SAFETY: a fresh block of the class's own layout, which is at least as
275 // large and as aligned as the request. It is freed with the same
276 // layout, in `dealloc` below.
277 unsafe { System.alloc(layout_of(class)) }
278 }
279
280 // SAFETY: the pointer and layout are the ones handed out above, so a
281 // pointer with a pooled class is a block of at least `GRAIN` bytes and can
282 // hold the link. Anything else goes back to the system allocator with the
283 // layout it was made with.
284 #[inline]
285 unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) {
286 let Some(class) = class_of(layout) else {
287 // SAFETY: forwarded unchanged to the allocator that made it.
288 return unsafe { System.dealloc(pointer, layout) };
289 };
290 let kept = HELD
291 .try_with(|held| {
292 let Some(count) = held.get(class) else {
293 return false;
294 };
295 if count.get() >= per_class(class) {
296 return false;
297 }
298 HEADS
299 .try_with(|heads| {
300 let Some(head) = heads.get(class) else {
301 return false;
302 };
303 // SAFETY: the block is at least eight bytes and is not
304 // reachable by anything else once the caller has freed
305 // it, so its first word is ours to use as the link.
306 unsafe { pointer.cast::<*mut u8>().write(head.get()) };
307 head.set(pointer);
308 count.set(count.get().saturating_add(1));
309 true
310 })
311 .unwrap_or(false)
312 })
313 .unwrap_or(false);
314 if kept {
315 return;
316 }
317 // SAFETY: freed with the layout it was allocated with in `alloc`.
318 unsafe { System.dealloc(pointer, layout_of(class)) };
319 }
320
321 // SAFETY: a realloc that stays inside one class is the same block, because
322 // every block in a class is the full class size. Anything else goes through
323 // the default alloc-copy-free, which is what `GlobalAlloc` does when this
324 // is not overridden.
325 #[inline]
326 unsafe fn realloc(&self, pointer: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
327 let old = class_of(layout);
328 let new = Layout::from_size_align(new_size, layout.align())
329 .ok()
330 .and_then(class_of);
331 if let (Some(old), Some(new)) = (old, new) {
332 if old == new {
333 return pointer;
334 }
335 }
336 // SAFETY: the default behaviour, spelled out: a fresh block, the old
337 // bytes copied into it, and the old block freed. Every argument is the
338 // caller's or derived from it.
339 unsafe {
340 let Ok(wanted) = Layout::from_size_align(new_size, layout.align()) else {
341 return std::ptr::null_mut();
342 };
343 let fresh = self.alloc(wanted);
344 if !fresh.is_null() {
345 std::ptr::copy_nonoverlapping(pointer, fresh, layout.size().min(new_size));
346 self.dealloc(pointer, layout);
347 }
348 fresh
349 }
350 }
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356
357 /// The classes cover what they claim to and refuse what they do not.
358 #[test]
359 fn the_classes_are_the_ones_documented() {
360 assert_eq!(class_of(Layout::from_size_align(8, 8).unwrap()), Some(1));
361 assert_eq!(class_of(Layout::from_size_align(16, 16).unwrap()), Some(1));
362 assert_eq!(class_of(Layout::from_size_align(17, 8).unwrap()), Some(2));
363 assert_eq!(
364 class_of(Layout::from_size_align(LARGEST, 16).unwrap()),
365 Some(CLASSES - 1)
366 );
367 // Too large, too aligned, and too small to hold the link.
368 assert_eq!(
369 class_of(Layout::from_size_align(LARGEST + 1, 8).unwrap()),
370 None
371 );
372 assert_eq!(class_of(Layout::from_size_align(32, 32).unwrap()), None);
373 assert_eq!(class_of(Layout::from_size_align(4, 4).unwrap()), None);
374 }
375
376 /// Every class's block is at least as large and as aligned as any request
377 /// in it, which is the whole of why reuse is safe.
378 #[test]
379 fn a_class_block_covers_every_request_in_it() {
380 for size in 8..=LARGEST {
381 let Some(layout) = Layout::from_size_align(size, 8).ok() else {
382 continue;
383 };
384 let Some(class) = class_of(layout) else {
385 continue;
386 };
387 let block = layout_of(class);
388 assert!(block.size() >= layout.size(), "size {size}");
389 assert!(block.align() >= layout.align(), "size {size}");
390 }
391 }
392
393 /// A block goes onto its class's list and comes back off it.
394 ///
395 /// Driven through the allocator itself rather than through the lists,
396 /// because what has to hold is that a pointer handed back is one that was
397 /// handed out - the intrusive link is an implementation detail and asserting
398 /// on it would pin the implementation rather than the behaviour.
399 #[test]
400 fn a_freed_block_is_the_one_handed_back() {
401 let layout = Layout::from_size_align(64, 8).expect("a layout");
402 // SAFETY: every pointer here comes from this allocator and is freed
403 // with the layout it was allocated with.
404 unsafe {
405 let first = Pooled.alloc(layout);
406 assert!(!first.is_null());
407 Pooled.dealloc(first, layout);
408 let second = Pooled.alloc(layout);
409 assert_eq!(first, second, "the freed block was not recycled");
410 Pooled.dealloc(second, layout);
411 }
412 }
413
414 /// A block that is written to and recycled does not carry its old bytes
415 /// into a caller's hands as anything but uninitialised memory.
416 ///
417 /// The link is written over the first word of a freed block, so a caller
418 /// that assumed a fresh allocation was zeroed would be reading it. Nothing
419 /// may assume that of `alloc` - `alloc_zeroed` is the one that promises -
420 /// and this pins that the link is confined to the block itself and does not
421 /// run past its end.
422 #[test]
423 fn recycling_stays_inside_the_block() {
424 let layout = Layout::from_size_align(16, 8).expect("a layout");
425 // SAFETY: as above; the guard bytes are a second allocation this test
426 // owns for the length of the check.
427 unsafe {
428 let guard = Pooled.alloc(layout);
429 let block = Pooled.alloc(layout);
430 std::ptr::write_bytes(guard, 0xAB, layout.size());
431 Pooled.dealloc(block, layout);
432 let again = Pooled.alloc(layout);
433 assert_eq!(block, again);
434 for at in 0..layout.size() {
435 assert_eq!(guard.add(at).read(), 0xAB, "the link ran past its block");
436 }
437 Pooled.dealloc(again, layout);
438 Pooled.dealloc(guard, layout);
439 }
440 }
441
442 /// A realloc inside one class is the same block, and one that crosses a
443 /// class keeps the bytes.
444 #[test]
445 fn realloc_keeps_the_bytes() {
446 // SAFETY: every pointer comes from this allocator, and every layout is
447 // the one the block was made with.
448 unsafe {
449 let small = Layout::from_size_align(16, 8).expect("a layout");
450 let block = Pooled.alloc(small);
451 std::ptr::write_bytes(block, 0x5A, small.size());
452 let inside = Pooled.realloc(block, small, 12);
453 assert_eq!(inside, block, "a realloc inside one class moved the block");
454 let across = Pooled.realloc(inside, small, 200);
455 assert!(!across.is_null());
456 for at in 0..small.size() {
457 assert_eq!(across.add(at).read(), 0x5A, "realloc lost a byte");
458 }
459 Pooled.dealloc(across, Layout::from_size_align(200, 8).expect("a layout"));
460 }
461 }
462}