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}