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 =
77    PoolInner { entries: [(0, 0, 0); POOL_SLOTS], len: 0, generation: 0 };
78
79/// Run `f` holding the pool lock.
80fn with_pool<R>(f: impl FnOnce(&mut PoolInner) -> R) -> R {
81    use core::sync::atomic::Ordering;
82    while POOL_LOCK
83        .compare_exchange_weak(false, true, Ordering::Acquire, Ordering::Relaxed)
84        .is_err()
85    {
86        core::hint::spin_loop();
87    }
88    // SAFETY: the spinlock gives exclusive access for `f`'s duration,
89    // and `f` cannot re-enter — nothing inside it allocates.
90    let out = f(unsafe { &mut *core::ptr::addr_of_mut!(POOL) });
91    POOL_LOCK.store(false, Ordering::Release);
92    out
93}
94
95/// Take a parked mapping of exactly `mapped` bytes.
96fn pool_take(mapped: usize) -> Option<NonNull<u8>> {
97    with_pool(|p| {
98        for i in 0..p.len as usize {
99            if p.entries[i].1 == mapped {
100                let (addr, _, _) = p.entries[i];
101                p.len -= 1;
102                p.entries[i] = p.entries[p.len as usize];
103                POOLED.fetch_sub(mapped as u64, Relaxed);
104                return NonNull::new(addr as *mut u8);
105            }
106        }
107        None
108    })
109}
110
111/// Largest mapping the pool will retain. The pool exists for the reply
112/// and dispatch buffers' 36 KB–600 KB growth ladder (~17k births a
113/// second); anything bigger churns on the cadence of a table resize —
114/// the B6 probe counted exactly one such event, a 64 MiB mapping, per
115/// whole run. Parking a giant buys one mmap and prices tens of MB of
116/// hysteresis-term retention for the whole aging window: a bad trade
117/// at any measured frequency, so it passes straight through to munmap.
118const POOL_MAX_LEN: usize = 1 << 20;
119
120/// Park a mapping; refuses when full or oversized (the caller unmaps).
121fn pool_park(ptr: NonNull<u8>, mapped: usize) -> bool {
122    with_pool(|p| {
123        if p.len as usize == POOL_SLOTS || mapped > POOL_MAX_LEN {
124            return false;
125        }
126        p.entries[p.len as usize] = (ptr.as_ptr() as usize, mapped, p.generation);
127        p.len += 1;
128        POOLED.fetch_add(mapped as u64, Relaxed);
129        true
130    })
131}
132
133/// Unmap parked mappings that have aged past the pacing bound; young
134/// entries stay parked so a burst cycle re-takes them instead of
135/// paying mmap + kernel zero-fill again
136/// (the reclaim-pacing design round). Ages are in drain calls and
137/// every entry still leaves within POOL_AGE_DRAINS of them — the same
138/// unconditional liveness bound as the span sweep's.
139pub(crate) fn pool_drain() {
140    /// Drain calls an entry may sit parked. With N shards each
141    /// ticking, wall-clock retention is this / N ticks.
142    const POOL_AGE_DRAINS: u8 = 64;
143    // Collected under the lock, unmapped outside it: munmap takes the
144    // process mmap_lock, and holding a spinlock across that invites
145    // exactly the convoy this pool exists to prevent.
146    let mut held: [(usize, usize); POOL_SLOTS] = [(0, 0); POOL_SLOTS];
147    let n = with_pool(|p| {
148        p.generation = p.generation.wrapping_add(1);
149        let mut n = 0usize;
150        let mut i = 0usize;
151        while i < p.len as usize {
152            let (addr, mapped, born) = p.entries[i];
153            if p.generation.wrapping_sub(born) >= POOL_AGE_DRAINS {
154                held[n] = (addr, mapped);
155                n += 1;
156                p.len -= 1;
157                p.entries[i] = p.entries[p.len as usize];
158            } else {
159                i += 1;
160            }
161        }
162        n
163    });
164    for &(addr, mapped) in &held[..n] {
165        POOLED.fetch_sub(mapped as u64, Relaxed);
166        counters::sub_mapped_only(mapped as u64);
167        // SAFETY: parked mappings are live, exactly `mapped` bytes, and
168        // referenced by nobody once off the pool.
169        unsafe {
170            os::unmap(NonNull::new_unchecked(addr as *mut u8), mapped);
171        }
172    }
173}
174
175/// The counters themselves. See the module docs for why they live here.
176mod counters {
177    use core::sync::atomic::{AtomicU64, Ordering::Relaxed};
178
179    pub(super) static MAPPED: AtomicU64 = AtomicU64::new(0);
180    pub(super) static LIVE: AtomicU64 = AtomicU64::new(0);
181    pub(super) static ROUNDING: AtomicU64 = AtomicU64::new(0);
182    pub(super) static COUNT: AtomicU64 = AtomicU64::new(0);
183
184    pub(super) fn add(mapped: u64, requested: u64) {
185        MAPPED.fetch_add(mapped, Relaxed);
186        add_live_only(mapped, requested);
187    }
188
189    /// A pooled reuse: the mapping was already counted, only its
190    /// occupancy changes.
191    pub(super) fn add_live_only(mapped: u64, requested: u64) {
192        LIVE.fetch_add(requested, Relaxed);
193        ROUNDING.fetch_add(mapped - requested, Relaxed);
194        COUNT.fetch_add(1, Relaxed);
195    }
196
197    /// A park: occupancy ends, the mapping stays counted (it is still
198    /// mapped — the pool holds it).
199    pub(super) fn sub_live_only(mapped: u64, requested: u64) {
200        LIVE.fetch_sub(requested, Relaxed);
201        ROUNDING.fetch_sub(mapped - requested, Relaxed);
202        COUNT.fetch_sub(1, Relaxed);
203    }
204
205    /// The pool released a mapping to the OS.
206    pub(super) fn sub_mapped_only(mapped: u64) {
207        MAPPED.fetch_sub(mapped, Relaxed);
208    }
209}
210
211/// Direct-mapping figures for the whole process.
212///
213/// Kept apart from [`Heap::snapshot`] rather than folded in, because
214/// summing per-shard snapshots would then count them once per shard.
215/// Each balances on its own, and so does their sum.
216#[must_use]
217pub fn large_stats() -> Stats {
218    use core::sync::atomic::Ordering::Relaxed;
219    Stats {
220        mapped: counters::MAPPED.load(Relaxed),
221        live: counters::LIVE.load(Relaxed),
222        rounding: counters::ROUNDING.load(Relaxed),
223        // Parked mappings: retained-rather-than-released, the same
224        // policy the empty-span rule applies at span scale, so the same
225        // term prices them (contract §1, widened with this reason).
226        hysteresis: POOLED.load(Relaxed),
227        large_count: counters::COUNT.load(Relaxed),
228        ..Stats::default()
229    }
230}
231
232/// Map `size` bytes directly, reusing a parked mapping when one of
233/// exactly the right length is waiting. `None` when the OS refuses or
234/// the alignment is stricter than a fresh mapping provides.
235pub(crate) fn alloc(size: usize, align: usize) -> Option<NonNull<u8>> {
236    if align > os::PAGE {
237        return None;
238    }
239    let mapped = os::round_up(size, os::PAGE);
240    if let Some(p) = pool_take(mapped) {
241        counters::add_live_only(mapped as u64, size as u64);
242        return Some(p);
243    }
244    let p = os::map_aligned(mapped, os::PAGE)?;
245    counters::add(mapped as u64, size as u64);
246    Some(p)
247}
248
249/// # Safety
250/// `ptr`/`size` must come from [`alloc`] and not be used afterwards.
251pub(crate) unsafe fn dealloc(ptr: NonNull<u8>, size: usize) {
252    let mapped = os::round_up(size, os::PAGE);
253    counters::sub_live_only(mapped as u64, size as u64);
254    if pool_park(ptr, mapped) {
255        return;
256    }
257    counters::sub_mapped_only(mapped as u64);
258    // SAFETY: delegated to the caller's contract.
259    unsafe { os::unmap(ptr, mapped) };
260}
261
262/// How many pool slots hold exactly `mapped` bytes right now. Tests
263/// key on lengths nobody else allocates, which makes this exact even
264/// while parallel tests churn the shared pool.
265#[cfg(test)]
266fn parked_count(mapped: usize) -> usize {
267    with_pool(|p| (0..p.len as usize).filter(|&i| p.entries[i].1 == mapped).count())
268}
269
270#[cfg(test)]
271mod pool_tests {
272    use super::*;
273
274    /// What the pub/sub and B6 probes bought (pacing round 3): a
275    /// teardown-scale burst of one length parks whole and serves the
276    /// next wave of births, and an oversized mapping is never parked at
277    /// all. Everything moves through the public alloc/dealloc lifecycle
278    /// so the accounting identity holds at every step even under
279    /// parallel tests.
280    #[test]
281    fn the_pool_holds_a_teardown_burst_and_refuses_any_giant() {
282        let size = crate::class::MAX_SMALL + 98_765;
283        let mapped = os::round_up(size, os::PAGE);
284        let blocks: Vec<NonNull<u8>> =
285            (0..8).map(|_| alloc(size, 8).expect("direct mapping")).collect();
286        for p in blocks {
287            // SAFETY: allocated just above with exactly this size.
288            unsafe { dealloc(p, size) };
289        }
290        assert_eq!(parked_count(mapped), 8, "the burst should park whole");
291        // The parked ones actually serve the next births of this length.
292        let again: Vec<NonNull<u8>> =
293            (0..8).map(|_| alloc(size, 8).expect("retake")).collect();
294        assert_eq!(parked_count(mapped), 0, "a parked mapping was not re-taken");
295        for p in again {
296            // SAFETY: allocated just above with exactly this size.
297            unsafe { dealloc(p, size) };
298        }
299
300        let giant = POOL_MAX_LEN + 1;
301        let p = alloc(giant, 8).expect("map 1 MiB + a byte");
302        // SAFETY: allocated just above with exactly this size.
303        unsafe { dealloc(p, giant) };
304        assert_eq!(
305            parked_count(os::round_up(giant, os::PAGE)),
306            0,
307            "an oversized mapping must never park"
308        );
309    }
310}