Skip to main content

kevy_alloc/
large.rs

1//! The direct-mapping path.
2//!
3//! Requests past the largest size class get their own mapping and give
4//! it straight back on release: no pooling, no span, and therefore no
5//! slack — a direct mapping is exactly as big as it needs to be, page
6//! rounding aside.
7//!
8//! # Why these counters are per process and not per heap
9//!
10//! A large block has no segment, so nothing records which heap it came
11//! from, so a free arriving on another thread has nowhere to be routed.
12//! Whichever thread frees it is the one that unmaps it. Per-heap
13//! counters would therefore drift negative the first time a large
14//! allocation crossed a thread — the same defect the small path settles
15//! through the foreign list, but with nowhere to settle it. The process
16//! figure is the one that is meaningful, so it is the one kept, and
17//! [`large_stats`] stays out of `Heap::snapshot` so that summing shards
18//! cannot count it once per shard.
19
20use core::ptr::NonNull;
21use core::sync::atomic::{AtomicU64, Ordering::Relaxed};
22
23use crate::os;
24use crate::stats::Stats;
25
26/// Mappings retained for reuse instead of unmapping, process-wide.
27/// Thirty-two is a measured ceiling, not a guess at demand: connection
28/// teardown would park ~100 buffers if the slots existed, but widening
29/// to 128 let dead entries of *distinct* lengths (which exact-length
30/// matching can never serve again) pile up through the aging window,
31/// and one B6 leg answered with an RSS peak of 883 MB against glibc's
32/// 818 — worse than no allocator. Fewer slots forfeit part of a burst;
33/// surplus slots hoard corpses. The burst loss is bounded and the
34/// hoard is not, so the small number wins.
35pub(crate) const POOL_SLOTS: usize = 32;
36
37/// Bytes currently parked in retention pools, process-wide — reported
38/// inside the `hysteresis` term ("retained rather than released", the
39/// same policy the empty-span rule applies at span scale).
40static POOLED: AtomicU64 = AtomicU64::new(0);
41
42/// The retention pool, keyed by exact mapped length — and process-wide,
43/// which the syscall counter decided, not taste.
44///
45/// Its existence: after the class table reached its 64 KiB-span
46/// ceiling, the legacy shape still ran ~17k direct allocations a second
47/// — dispatch and reply buffers growing through a 36 KB–300 KB ladder —
48/// each paying an mmap on birth and a munmap on death while glibc paid
49/// zero syscalls (the mmap-lock finding's follow-up measurement).
50///
51/// Its scope: the first version was per-heap and moved the count by
52/// **nothing**, because these buffers are born on one shard and die on
53/// another — the freeing side's pool filled once and stayed useless
54/// while the allocating side's stayed empty. A large mapping has no
55/// segment, so no owner is recoverable from its address and no route
56/// home exists; the pool must be shared. One spinlock guards it: at
57/// ~17k operations a second against 8.5M ops served this is a cold
58/// path, and an uncontended spinlock costs two orders of magnitude less
59/// than the syscall it replaces.
60///
61/// Growth ladders repeat the same page-rounded lengths
62/// deterministically, so exact-length matching is both trivial and
63/// sufficient; a miss just maps, and anything unusual falls through.
64struct PoolInner {
65    entries: [(usize, usize, u8); POOL_SLOTS], // (addr, mapped_len, parked_gen)
66    len: u8,
67    /// Drain-call generation. Ages are measured in drain calls — one
68    /// per shard per tick — because the pool is process-wide and has
69    /// no tick of its own. With N shards the wall-clock bound is
70    /// POOL_AGE_DRAINS / N ticks; the bound existing at all is what
71    /// matters (the no-reclaim wedge's lesson), not its exact length.
72    generation: u8,
73}
74
75static POOL_LOCK: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false);
76static mut POOL: PoolInner = PoolInner { entries: [(0, 0, 0); POOL_SLOTS], len: 0, generation: 0 };
77
78/// Run `f` holding the pool lock.
79fn with_pool<R>(f: impl FnOnce(&mut PoolInner) -> R) -> R {
80    use core::sync::atomic::Ordering;
81    while POOL_LOCK
82        .compare_exchange_weak(false, true, Ordering::Acquire, Ordering::Relaxed)
83        .is_err()
84    {
85        core::hint::spin_loop();
86    }
87    // SAFETY: the spinlock gives exclusive access for `f`'s duration,
88    // and `f` cannot re-enter — nothing inside it allocates.
89    let out = f(unsafe { &mut *core::ptr::addr_of_mut!(POOL) });
90    POOL_LOCK.store(false, Ordering::Release);
91    out
92}
93
94/// Take a parked mapping of exactly `mapped` bytes.
95fn pool_take(mapped: usize) -> Option<NonNull<u8>> {
96    with_pool(|p| {
97        for i in 0..p.len as usize {
98            if p.entries[i].1 == mapped {
99                let (addr, _, _) = p.entries[i];
100                p.len -= 1;
101                p.entries[i] = p.entries[p.len as usize];
102                POOLED.fetch_sub(mapped as u64, Relaxed);
103                return NonNull::new(addr as *mut u8);
104            }
105        }
106        None
107    })
108}
109
110/// Largest mapping the pool will retain. The pool exists for the reply
111/// and dispatch buffers' 36 KB–600 KB growth ladder (~17k births a
112/// second); anything bigger churns on the cadence of a table resize —
113/// the B6 probe counted exactly one such event, a 64 MiB mapping, per
114/// whole run. Parking a giant buys one mmap and prices tens of MB of
115/// hysteresis-term retention for the whole aging window: a bad trade
116/// at any measured frequency, so it passes straight through to munmap.
117const POOL_MAX_LEN: usize = 1 << 20;
118
119/// Park a mapping; refuses when full or oversized (the caller unmaps).
120fn pool_park(ptr: NonNull<u8>, mapped: usize) -> bool {
121    with_pool(|p| {
122        if p.len as usize == POOL_SLOTS || mapped > POOL_MAX_LEN {
123            return false;
124        }
125        p.entries[p.len as usize] = (ptr.as_ptr() as usize, mapped, p.generation);
126        p.len += 1;
127        POOLED.fetch_add(mapped as u64, Relaxed);
128        true
129    })
130}
131
132/// Unmap parked mappings that have aged past the pacing bound; young
133/// entries stay parked so a burst cycle re-takes them instead of
134/// paying mmap + kernel zero-fill again
135/// (the reclaim-pacing design round). Ages are in drain calls and
136/// every entry still leaves within POOL_AGE_DRAINS of them — the same
137/// unconditional liveness bound as the span sweep's.
138pub(crate) fn pool_drain() {
139    /// Drain calls an entry may sit parked. With N shards each
140    /// ticking, wall-clock retention is this / N ticks.
141    const POOL_AGE_DRAINS: u8 = 64;
142    // Collected under the lock, unmapped outside it: munmap takes the
143    // process mmap_lock, and holding a spinlock across that invites
144    // exactly the convoy this pool exists to prevent.
145    let mut held: [(usize, usize); POOL_SLOTS] = [(0, 0); POOL_SLOTS];
146    let n = with_pool(|p| {
147        p.generation = p.generation.wrapping_add(1);
148        let mut n = 0usize;
149        let mut i = 0usize;
150        while i < p.len as usize {
151            let (addr, mapped, born) = p.entries[i];
152            if p.generation.wrapping_sub(born) >= POOL_AGE_DRAINS {
153                held[n] = (addr, mapped);
154                n += 1;
155                p.len -= 1;
156                p.entries[i] = p.entries[p.len as usize];
157            } else {
158                i += 1;
159            }
160        }
161        n
162    });
163    for &(addr, mapped) in &held[..n] {
164        POOLED.fetch_sub(mapped as u64, Relaxed);
165        counters::sub_mapped_only(mapped as u64);
166        // SAFETY: parked mappings are live, exactly `mapped` bytes, and
167        // referenced by nobody once off the pool.
168        unsafe {
169            os::unmap(NonNull::new_unchecked(addr as *mut u8), mapped);
170        }
171    }
172}
173
174/// The counters themselves. See the module docs for why they live here.
175mod counters {
176    use core::sync::atomic::{AtomicU64, Ordering::Relaxed};
177
178    pub(super) static MAPPED: AtomicU64 = AtomicU64::new(0);
179    pub(super) static LIVE: AtomicU64 = AtomicU64::new(0);
180    pub(super) static ROUNDING: AtomicU64 = AtomicU64::new(0);
181    pub(super) static COUNT: AtomicU64 = AtomicU64::new(0);
182
183    pub(super) fn add(mapped: u64, requested: u64) {
184        MAPPED.fetch_add(mapped, Relaxed);
185        add_live_only(mapped, requested);
186    }
187
188    /// A pooled reuse: the mapping was already counted, only its
189    /// occupancy changes.
190    pub(super) fn add_live_only(mapped: u64, requested: u64) {
191        LIVE.fetch_add(requested, Relaxed);
192        ROUNDING.fetch_add(mapped - requested, Relaxed);
193        COUNT.fetch_add(1, Relaxed);
194    }
195
196    /// A park: occupancy ends, the mapping stays counted (it is still
197    /// mapped — the pool holds it).
198    pub(super) fn sub_live_only(mapped: u64, requested: u64) {
199        LIVE.fetch_sub(requested, Relaxed);
200        ROUNDING.fetch_sub(mapped - requested, Relaxed);
201        COUNT.fetch_sub(1, Relaxed);
202    }
203
204    /// The pool released a mapping to the OS.
205    pub(super) fn sub_mapped_only(mapped: u64) {
206        MAPPED.fetch_sub(mapped, Relaxed);
207    }
208}
209
210/// Direct-mapping figures for the whole process.
211///
212/// Kept apart from [`Heap::snapshot`] rather than folded in, because
213/// summing per-shard snapshots would then count them once per shard.
214/// Each balances on its own, and so does their sum.
215#[must_use]
216pub fn large_stats() -> Stats {
217    use core::sync::atomic::Ordering::Relaxed;
218    Stats {
219        mapped: counters::MAPPED.load(Relaxed),
220        live: counters::LIVE.load(Relaxed),
221        rounding: counters::ROUNDING.load(Relaxed),
222        // Parked mappings: retained-rather-than-released, the same
223        // policy the empty-span rule applies at span scale, so the same
224        // term prices them (contract §1, widened with this reason).
225        hysteresis: POOLED.load(Relaxed),
226        large_count: counters::COUNT.load(Relaxed),
227        ..Stats::default()
228    }
229}
230
231/// Map `size` bytes directly, reusing a parked mapping when one of
232/// exactly the right length is waiting. `None` when the OS refuses or
233/// the alignment is stricter than a fresh mapping provides.
234pub(crate) fn alloc(size: usize, align: usize) -> Option<NonNull<u8>> {
235    if align > os::PAGE {
236        return None;
237    }
238    let mapped = os::round_up(size, os::PAGE);
239    if let Some(p) = pool_take(mapped) {
240        counters::add_live_only(mapped as u64, size as u64);
241        return Some(p);
242    }
243    let p = os::map_aligned(mapped, os::PAGE)?;
244    counters::add(mapped as u64, size as u64);
245    Some(p)
246}
247
248/// # Safety
249/// `ptr`/`size` must come from [`alloc`] and not be used afterwards.
250pub(crate) unsafe fn dealloc(ptr: NonNull<u8>, size: usize) {
251    let mapped = os::round_up(size, os::PAGE);
252    counters::sub_live_only(mapped as u64, size as u64);
253    if pool_park(ptr, mapped) {
254        return;
255    }
256    counters::sub_mapped_only(mapped as u64);
257    // SAFETY: delegated to the caller's contract.
258    unsafe { os::unmap(ptr, mapped) };
259}
260
261/// How many pool slots hold exactly `mapped` bytes right now. Tests
262/// key on lengths nobody else allocates, which makes this exact even
263/// while parallel tests churn the shared pool.
264#[cfg(test)]
265fn parked_count(mapped: usize) -> usize {
266    with_pool(|p| (0..p.len as usize).filter(|&i| p.entries[i].1 == mapped).count())
267}
268
269#[cfg(test)]
270mod pool_tests {
271    use super::*;
272
273    /// What the pub/sub and B6 probes bought (pacing round 3): a
274    /// teardown-scale burst of one length parks whole and serves the
275    /// next wave of births, and an oversized mapping is never parked at
276    /// all. Everything moves through the public alloc/dealloc lifecycle
277    /// so the accounting identity holds at every step even under
278    /// parallel tests.
279    #[test]
280    fn the_pool_holds_a_teardown_burst_and_refuses_any_giant() {
281        let size = crate::class::MAX_SMALL + 98_765;
282        let mapped = os::round_up(size, os::PAGE);
283        let blocks: Vec<NonNull<u8>> =
284            (0..8).map(|_| alloc(size, 8).expect("direct mapping")).collect();
285        for p in blocks {
286            // SAFETY: allocated just above with exactly this size.
287            unsafe { dealloc(p, size) };
288        }
289        assert_eq!(parked_count(mapped), 8, "the burst should park whole");
290        // The parked ones actually serve the next births of this length.
291        let again: Vec<NonNull<u8>> = (0..8).map(|_| alloc(size, 8).expect("retake")).collect();
292        assert_eq!(parked_count(mapped), 0, "a parked mapping was not re-taken");
293        for p in again {
294            // SAFETY: allocated just above with exactly this size.
295            unsafe { dealloc(p, size) };
296        }
297
298        let giant = POOL_MAX_LEN + 1;
299        let p = alloc(giant, 8).expect("map 1 MiB + a byte");
300        // SAFETY: allocated just above with exactly this size.
301        unsafe { dealloc(p, giant) };
302        assert_eq!(
303            parked_count(os::round_up(giant, os::PAGE)),
304            0,
305            "an oversized mapping must never park"
306        );
307    }
308}