Skip to main content

kevy_alloc/
heap.rs

1//! The per-shard heap.
2//!
3//! # Why there is no thread-local cache in front of this
4//!
5//! tcmalloc and mimalloc put a thread cache ahead of a shared central
6//! heap because they cannot know how threads relate to memory, and
7//! torajs-mmalloc's finding doc records what happens without one: its
8//! first cutover cost 10–30 ns per allocation and reversed alloc-heavy
9//! benchmarks by up to 4×, until a TLAB went in front.
10//!
11//! kevy pins a shard per core and routes every key to its owner, so the
12//! heap **is** the thread-local structure. The fast path pops from the
13//! current span's free list with no atomics — which is what a thread
14//! cache exists to achieve. Adding one here would put a cache in front
15//! of a cache. This is the divergence from the references that ROADMAP
16//! rule ② asks to be stated rather than assumed.
17//!
18//! Cross-shard frees are real (values travel on the shared read lane),
19//! and they are handled by [`segment::push_foreign`] — push-only, so
20//! there is no ABA hazard to inherit.
21
22use core::ptr::NonNull;
23
24use crate::class::{self, NCLASSES};
25use crate::os;
26use crate::outbound::Outbound;
27use crate::partials::PartialRing;
28use crate::segment::{self, FIRST_DATA_SPAN, NO_CLASS, SEGMENT_BYTES, SPANS_PER_SEGMENT, Segment};
29
30/// Spans one class may hold at once, per heap — a runaway guard, not a
31/// policy. At 64 KiB a span, this bounds one class at roughly 4 GiB per
32/// shard, which no correct program reaches by accident.
33///
34/// torajs-mmalloc shipped without any cap and paid for it (`c2970b6d`):
35/// a legal program exhausted a class, the allocator returned `None`, and
36/// the null propagated into a write — a SIGSEGV on correct code. What
37/// actually protects a Rust program is that a null from `alloc` becomes
38/// `handle_alloc_error` and a clean abort; the cap only makes runaway
39/// growth arrive there sooner.
40///
41/// **The inherited value was 64 spans — 4 MiB a class — and it was wrong
42/// by three orders of magnitude for this engine.** The standard library
43/// found it on the first run that put real work through the allocator: a
44/// test holding tens of thousands of buffers of one size hit the ceiling
45/// and aborted with "memory allocation of 6152 bytes failed" while the
46/// machine had gigabytes free. A number that is right for a JavaScript
47/// runtime's object churn is not right for a data engine, and copying it
48/// across was the mistake.
49///
50/// [`Heap::with_class_cap`] takes a tighter bound where one is wanted —
51/// which is how the exhaustion path stays testable now that the default
52/// is out of reach.
53///
54/// **The same lesson, second verse:** the raise above was silently
55/// pinned by its own `u16` — 65,535 spans × 64 KiB is a hidden 4 GiB
56/// ceiling *per class*, and the first hour-long soak found it: a
57/// 3-byte-value storm filled the 16 B class and the process aborted
58/// with "memory allocation of 3 bytes failed" on a box with 48 GiB
59/// free. The counter is now `u32` and the guard sits at 1 TiB per
60/// class — memory governance belongs to maxmemory and the tier
61/// budget, never to an invisible allocator constant.
62pub const PER_CLASS_CAP: u32 = 16_777_216;
63
64/// Empty spans a heap keeps mapped-but-discarded before releasing the
65/// whole segment. Decay-style hysteresis, after jemalloc: releasing
66/// eagerly turns a churny workload into an mmap/munmap storm.
67pub const EMPTY_SPAN_HYSTERESIS: u16 = 4;
68
69/// One shard's heap. Not `Sync`: exactly one thread owns it, which is
70/// what removes the atomics from the fast path.
71#[derive(Debug)]
72pub struct Heap {
73    id: usize,
74    pub(crate) segments: *mut Segment,
75    /// Current span per class, as (segment, span index).
76    pub(crate) partial: [Option<(NonNull<Segment>, u8)>; NCLASSES],
77    pub(crate) spans_in_class: [u32; NCLASSES],
78    pub(crate) live_bytes: u64,
79    pub(crate) rounding_bytes: u64,
80    /// Foreign frees awaiting batched shipment home. The free fast path
81    /// only ever appends here — the cross-core traffic all lives in the
82    /// flush. See `outbound.rs` for why this shape and not tcache-style
83    /// local reuse.
84    pub(crate) outbound: Outbound,
85    /// Per-class ring of spans believed to have room — pushed when a
86    /// free makes a full span partial, popped by the slow path before
87    /// it falls back to scanning.
88    ///
89    /// The legacy profile forced this (finding
90    /// the mmap-lock finding's follow-up): with the
91    /// 16–32 KiB classes a span holds 2–8 slots, so churn exhausts one
92    /// every few allocations, and the slow path's two O(segments)
93    /// scans put `Heap::alloc` at 6 % of server self time. This is
94    /// mimalloc's page-queue-per-class, sized as a ring because entries
95    /// may go stale (a span can be reassigned after its entry is
96    /// pushed) — the pop validates and simply discards liars.
97    partials: [PartialRing; NCLASSES],
98    /// Per-class claimed bitmap word (the far-line amortizer): up to 64
99    /// slots of the current span's lowest holed word, handed out and
100    /// locally recycled without touching the segment header. One header
101    /// round-trip per 64 slots instead of per slot — the collection-write
102    /// residual this shape exists for. `claimed & !taken` are the bits
103    /// owed back to the span on retire.
104    claims: [Option<Claim>; NCLASSES],
105    class_cap: u32,
106}
107
108impl Heap {
109    /// A heap owning nothing. `id` identifies the shard in stats and in
110    /// segment headers.
111    #[must_use]
112    pub const fn new(id: usize) -> Self {
113        Self::with_class_cap(id, PER_CLASS_CAP)
114    }
115
116    /// A heap with a tighter per-class ceiling than [`PER_CLASS_CAP`].
117    ///
118    /// The default is a runaway guard set beyond any real workload,
119    /// which leaves the refusal path unreachable in a test. This makes
120    /// it reachable without pretending the default is smaller than it is.
121    #[must_use]
122    pub const fn with_class_cap(id: usize, class_cap: u32) -> Self {
123        Self {
124            id,
125            segments: core::ptr::null_mut(),
126            partial: [None; NCLASSES],
127            spans_in_class: [0; NCLASSES],
128            live_bytes: 0,
129            rounding_bytes: 0,
130            outbound: Outbound::new(),
131            partials: [PartialRing::EMPTY; NCLASSES],
132            claims: [None; NCLASSES],
133            class_cap,
134        }
135    }
136
137    /// Adopt this heap's address as its identity, once.
138    ///
139    /// Segments record their owner so a free arriving on the wrong
140    /// thread can be routed home. The address of the heap itself is a
141    /// ready-made unique identifier — no counter, no registry, and it
142    /// cannot collide while the heap is alive. `0` means "not yet set",
143    /// which is why [`Heap::new`] can stay `const`.
144    pub fn ensure_identity(&mut self) {
145        if self.id == 0 {
146            self.id = core::ptr::from_mut(self) as usize;
147        }
148    }
149
150    /// Allocate `size` bytes aligned to `align`, or `None` if the OS or
151    /// a class cap says no.
152    ///
153    /// Alignment up to [`class::MAX_NATIVE_ALIGN`] is served by choosing
154    /// a suitable class. Stricter requests fall to the direct-mapping
155    /// path, which returns page-aligned memory; anything beyond a page
156    /// belongs to the `GlobalAlloc` shim's over-aligned path.
157    pub fn alloc(&mut self, size: usize, align: usize) -> Option<NonNull<u8>> {
158        match class::index_of(size, align) {
159            Some(c) => self.alloc_small(c, size),
160            None => self.alloc_large(size, align),
161        }
162    }
163
164    /// Grow or shrink in place when the block's size class does not
165    /// change, reporting whether it worked.
166    ///
167    /// This is the capability a general-purpose allocator gets from its
168    /// chunk headers: glibc can often extend a block where it lies
169    /// instead of moving it. Without it, `GlobalAlloc`'s default
170    /// `realloc` allocates, copies and frees on every growth — and a
171    /// profile of pub/sub showed exactly that, with `libc realloc`
172    /// visible and cheap on the system side and nothing corresponding
173    /// on ours.
174    ///
175    /// Refuses when the block belongs to another thread. Adjusting the
176    /// owner's counters from here is precisely what this design does not
177    /// do, and the caller falls back to allocate-copy-free, which routes
178    /// the release home correctly.
179    ///
180    /// # Safety
181    /// `ptr` must be a live allocation from this allocator made with
182    /// `old_size` and `align`.
183    pub unsafe fn try_resize_in_place(
184        &mut self,
185        ptr: NonNull<u8>,
186        old_size: usize,
187        new_size: usize,
188        align: usize,
189    ) -> bool {
190        let (Some(a), Some(b)) =
191            (class::index_of(old_size, align), class::index_of(new_size, align))
192        else {
193            return false;
194        };
195        if a != b {
196            return false;
197        }
198        // SAFETY: a small allocation always lies inside a segment.
199        let seg = unsafe { segment::segment_of(ptr) };
200        // SAFETY: the mask lands on a live header for our own pointers.
201        if unsafe { seg.as_ref() }.owner != self.id {
202            return false;
203        }
204        let slot = class::size_of(a) as u64;
205        self.live_bytes = self.live_bytes - old_size as u64 + new_size as u64;
206        self.rounding_bytes =
207            self.rounding_bytes - (slot - old_size as u64) + (slot - new_size as u64);
208        true
209    }
210
211    /// Return an allocation. `size` must be the one it was made with —
212    /// the sized-dealloc contract is what lets us store no headers.
213    ///
214    /// # Safety
215    /// `ptr` must come from [`Self::alloc`] on this heap with this
216    /// `size`, and must not be used afterwards.
217    pub unsafe fn dealloc(&mut self, ptr: NonNull<u8>, size: usize, align: usize) {
218        match class::index_of(size, align) {
219            // SAFETY: this fn is `unsafe`; its contract already requires that `ptr` came
220            // from this heap for this `size`/`align` and is not used again. `class::index_of`
221            // returning `Some(c)` means the block was served from the small path, so
222            // `dealloc_small` is the matching return path.
223            Some(c) => unsafe { self.dealloc_small(ptr, c, size) },
224            // SAFETY: same caller contract; `None` means the size/align pair has no size
225            // class, so the block came from the large path and returns to it.
226            None => unsafe { self.dealloc_large(ptr, size) },
227        }
228    }
229
230    /// Every allocation goes through the bitmap, lowest-first — there
231    /// is deliberately no free-slot cache in front of it.
232    ///
233    /// One existed. Its premise (keeping the hot free list in
234    /// heap-local memory) died with the locality hypothesis, it never
235    /// won a measurable point of throughput anywhere (0.826 → 0.844,
236    /// inside the band), and the M3 re-measurement convicted it of
237    /// costing 137 MB of the memory result: LIFO reuse hands back the
238    /// most recently freed slot regardless of position, which undoes
239    /// the lowest-first densification that page-granular reclaim feeds
240    /// on — resident went 1.98× → 2.38× with the cache in place. The
241    /// allocator's reason to exist outranks a cache that pays nothing.
242    fn alloc_small(&mut self, c: usize, size: usize) -> Option<NonNull<u8>> {
243        let slot = self.pop_slot(c).or_else(|| self.slow_path(c))?;
244        self.live_bytes += size as u64;
245        self.rounding_bytes += (class::size_of(c) - size) as u64;
246        Some(slot)
247    }
248
249    /// The current span had nothing. Look wider before asking the OS.
250    ///
251    /// The order matters, and one step here was missing at first: slots
252    /// freed into a span that is *not* the current one land on that
253    /// span's own free list, so without [`Self::adopt_partial`] those
254    /// spans are never revisited. Allocation would keep claiming fresh
255    /// spans past perfectly reusable ones until `PER_CLASS_CAP` refused
256    /// — looking exactly like a leak while every byte was accounted for.
257    fn slow_path(&mut self, c: usize) -> Option<NonNull<u8>> {
258        // O(1) first: spans the free path registered as having room.
259        // Entries can be stale — validate, discard liars.
260        while let Some((seg, ix)) = self.partials[c].pop() {
261            // SAFETY: rings only hold segments from this heap's list,
262            // which live as long as the heap.
263            let m = unsafe { &(*seg).spans[ix] };
264            if m.class as usize == c && u32::from(m.live) < m.capacity() {
265                // SAFETY: non-null by construction of the ring.
266                self.partial[c] = Some((unsafe { NonNull::new_unchecked(seg) }, ix as u8));
267                if let Some(p) = self.pop_slot(c) {
268                    return Some(p);
269                }
270            }
271        }
272        self.drain_foreign();
273        if self.adopt_partial(c)
274            && let Some(p) = self.pop_slot(c)
275        {
276            return Some(p);
277        }
278        self.claim_span(c)?;
279        self.pop_slot(c)
280    }
281
282    /// Make some span of class `c` that still has room the current one.
283    fn adopt_partial(&mut self, c: usize) -> bool {
284        let mut seg = self.segments;
285        while !seg.is_null() {
286            // SAFETY: the list holds live segment headers only.
287            let s = unsafe { &*seg };
288            for ix in FIRST_DATA_SPAN..SPANS_PER_SEGMENT {
289                let m = &s.spans[ix];
290                if m.class as usize == c && u32::from(m.live) < m.capacity() {
291                    // SAFETY: `seg` is non-null in this branch.
292                    self.partial[c] = Some((unsafe { NonNull::new_unchecked(seg) }, ix as u8));
293                    return true;
294                }
295            }
296            seg = s.next;
297        }
298        false
299    }
300
301    /// Take the lowest free slot from the class's current span, without
302    /// falling back. Lowest-first is the densification property: live
303    /// slots pack toward a span's low pages, so churn migrates free
304    /// space upward into whole pages the reclaim sweep can return.
305    ///
306    /// The handout comes from the class's claimed word; only when it
307    /// runs dry does the span header get touched again (one claim per
308    /// 64 slots — the far-line amortizer).
309    fn pop_slot(&mut self, c: usize) -> Option<NonNull<u8>> {
310        if let Some(p) = self.pop_claimed(c) {
311            return Some(p);
312        }
313        self.refill_claim(c)?;
314        self.pop_claimed(c)
315    }
316
317    /// Assign a span to class `c` and make it current, mapping a new
318    /// segment if no free span exists. `None` means the cap or the OS
319    /// refused.
320    fn claim_span(&mut self, c: usize) -> Option<()> {
321        if self.spans_in_class[c] >= self.class_cap {
322            return None;
323        }
324        let (seg, ix) = self.find_free_span().or_else(|| {
325            self.map_segment()?;
326            self.find_free_span()
327        })?;
328        // SAFETY: `find_free_span` returns a span of a live segment.
329        let meta = unsafe { &mut (*seg.as_ptr()).spans[ix] };
330        meta.reset(c as u8);
331        self.spans_in_class[c] += 1;
332        self.partial[c] = Some((seg, ix as u8));
333        Some(())
334    }
335
336    /// First span not assigned to a class, across this heap's segments.
337    fn find_free_span(&self) -> Option<(NonNull<Segment>, usize)> {
338        let mut seg = self.segments;
339        while !seg.is_null() {
340            // SAFETY: the list holds live segment headers only.
341            let s = unsafe { &*seg };
342            for ix in FIRST_DATA_SPAN..SPANS_PER_SEGMENT {
343                if s.spans[ix].class == NO_CLASS {
344                    // SAFETY: `seg` is non-null in this branch.
345                    return Some((unsafe { NonNull::new_unchecked(seg) }, ix));
346                }
347            }
348            seg = s.next;
349        }
350        None
351    }
352
353    /// Map a new segment and link it in. `None` when the OS refuses.
354    fn map_segment(&mut self) -> Option<()> {
355        let base = os::map_aligned(SEGMENT_BYTES, SEGMENT_BYTES)?;
356        // SAFETY: a fresh exclusive mapping of exactly one segment.
357        let seg = unsafe { Segment::init(base, self.id) };
358        // SAFETY: just initialised and owned solely by this heap.
359        unsafe { (*seg.as_ptr()).next = self.segments };
360        self.segments = seg.as_ptr();
361        Some(())
362    }
363
364    fn alloc_large(&mut self, size: usize, align: usize) -> Option<NonNull<u8>> {
365        crate::large::alloc(size, align)
366    }
367
368    /// # Safety
369    /// See [`Self::dealloc`].
370    unsafe fn dealloc_large(&mut self, ptr: NonNull<u8>, size: usize) {
371        // SAFETY: delegated to the caller's contract.
372        unsafe { crate::large::dealloc(ptr, size) };
373    }
374}
375
376impl Drop for Heap {
377    fn drop(&mut self) {
378        // Claims hold no memory of their own — the segments they point
379        // into are unmapped below — but retiring them keeps the
380        // debug-assert bookkeeping (live counts) honest for any
381        // instrumented teardown that walks spans first.
382        self.flush_claims();
383        // The retention pool is process-wide and bounded, so a heap's
384        // death owes it nothing — but the fuzzer's tight RSS limit
385        // watches every iteration, and draining here keeps single-heap
386        // lifecycles (tests, fuzz) at zero retained bytes. Its per-heap
387        // ancestor forgot the equivalent and leaked a mapping per heap.
388        crate::large::pool_drain();
389        let mut seg = self.segments;
390        while !seg.is_null() {
391            // SAFETY: live header from our own list; read `next` before
392            // the mapping goes away.
393            let next = unsafe { (*seg).next };
394            // SAFETY: this heap mapped it and is the only owner.
395            unsafe {
396                os::unmap(NonNull::new_unchecked(seg.cast::<u8>()), SEGMENT_BYTES);
397            }
398            seg = next;
399        }
400    }
401}
402
403#[path = "heap_claims.rs"]
404mod heap_claims;
405#[path = "heap_free.rs"]
406mod heap_free;
407pub(crate) use heap_claims::Claim;