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}