feat(stack,runtime): pool-recycle DONTNEED above the retained entry end (RFC 019 §6)
- stack::retain_range: pure checked span fn (retain page-up = zap less; None when retain covers the reserve, so the 64 KiB default config never pays a syscall) + 6 adversarial units mirroring shrink_range's. - Stack::recycle_zap: advisory MADV_DONTNEED of [usable_base, top-RETAIN); stack is unowned at the call site, synchronous eager zap races nothing. - recycle_stack: zap OFF-LOCK before pool admission (acquire_stack's no-syscall-under-the-pool-lock invariant); rare cap-overflow pays a wasted zap ahead of munmap, accepted over a second lock round-trip. - pub const RECYCLE_RETAIN = 64 KiB beside the shrink knobs, ratified-as- constant rationale in doc. - tests/stack_recycle.rs: mincore-based exact-zero-resident assert over the zap span. smaps was tried first and over-counts: a neighboring rw anon VMA can merge flush against the stack top (observed once under the full-suite run); the PROT_NONE guard pins the usable base exactly. FLAGGED (Claude-solo calls): - RFC §6 'above the bottom RETAIN' is direction-ambiguous in address terms; implemented as retain the ENTRY end (highest addresses, the pages the next actor faults first), zap the cold deep span below. - Const named RECYCLE_RETAIN (RFC says RETAIN) to sit beside SHRINK_*.
This commit is contained in:
@@ -441,6 +441,15 @@ pub const SHRINK_THRESHOLD: usize = 256 * 1024;
|
||||
/// rationale as [`SHRINK_THRESHOLD`]).
|
||||
pub const SHRINK_COOLDOWN: u32 = 64;
|
||||
|
||||
/// RFC 019 §6: the entry-end span (highest addresses — the frames the next
|
||||
/// actor faults first) a recycled stack keeps resident; everything below it
|
||||
/// is `MADV_DONTNEED`ed before the stack re-enters the pool. Ratified as a
|
||||
/// constant, not Config, alongside the shrink knobs; the 64 KiB value was a
|
||||
/// flagged Claude-solo call at ratification — it equals the default reserve,
|
||||
/// so with an unraised Config the zap is a no-op and only Configs that raise
|
||||
/// the default reserve pay it.
|
||||
pub const RECYCLE_RETAIN: usize = 64 * 1024;
|
||||
|
||||
pub(crate) type Closure = Box<dyn FnOnce() + Send>;
|
||||
|
||||
/// Lifecycle data, mutated only under the slot's cold [`RawMutex`]. Everything
|
||||
@@ -1472,6 +1481,12 @@ pub(crate) fn acquire_stack(
|
||||
/// otherwise dropped here → munmap (custom shapes and cap overflow alike).
|
||||
pub(crate) fn recycle_stack(inner: &RuntimeInner, stack: crate::stack::Stack) {
|
||||
if stack.shape() == (inner.stack_reserve, inner.stack_guard) {
|
||||
// RFC 019 §6: zap the dead spike before pooling, BEFORE taking the
|
||||
// pool lock — acquire_stack's invariant is that no syscall ever
|
||||
// stalls another spawner under it. On the rare cap-overflow the zap
|
||||
// is wasted work ahead of the munmap; harmless, and cheaper than a
|
||||
// second lock round-trip to find out.
|
||||
stack.recycle_zap(RECYCLE_RETAIN);
|
||||
let mut pool = inner.stack_pool.lock();
|
||||
if pool.len() < inner.stack_pool_cap {
|
||||
pool.push(stack);
|
||||
|
||||
+88
-1
@@ -94,6 +94,29 @@ impl Stack {
|
||||
pub fn shape(&self) -> (usize, usize) {
|
||||
(self.stack_size, self.guard_size)
|
||||
}
|
||||
|
||||
/// Pool-recycle zap (RFC 019 §6): `MADV_DONTNEED` everything below the
|
||||
/// retained entry end `[top − retain, top)` — the span the next actor's
|
||||
/// shallow frames land in stays resident, the dead spike below it is
|
||||
/// released. The stack is unowned at the call site (its actor is dead),
|
||||
/// so a synchronous eager zap races nothing and the RSS drop is
|
||||
/// immediate — a museum of worst-case spikes is exactly what a pool must
|
||||
/// not be; DONTNEED's ~8× per-page cost vs FREE is irrelevant off the
|
||||
/// hot path. Advisory like the park-path shrink: a failure degrades to
|
||||
/// "the pool keeps RSS", never to incorrectness. No-op (no syscall) when
|
||||
/// `retain` covers the whole usable region — i.e. always, at the 64 KiB
|
||||
/// default reserve.
|
||||
pub(crate) fn recycle_zap(&self, retain: usize) {
|
||||
if let Some((off, len)) = retain_range(self.stack_size, retain, page_size()) {
|
||||
unsafe {
|
||||
libc::madvise(
|
||||
self.usable_base().add(off) as *mut libc::c_void,
|
||||
len,
|
||||
libc::MADV_DONTNEED,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Round `n` up to whole pages — the same rounding `Stack::new` applies, so
|
||||
@@ -144,12 +167,76 @@ pub(crate) fn shrink_range(hwm: usize, sp: usize, page: usize) -> Option<(usize,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `(offset_from_usable_base, len)` span the pool recycle DONTNEEDs
|
||||
/// (RFC 019 §6): everything below the retained entry end. "Bottom RETAIN of
|
||||
/// the stack" is read stack-wise (entry frames = highest addresses of a
|
||||
/// downward stack): the retained span is `[top − page_up(retain), top)`, the
|
||||
/// zapped span is the rest — retaining the low-address deep end instead
|
||||
/// would keep the coldest pages and release the ones the next actor faults
|
||||
/// first. `retain` rounds *up* to whole pages (retain more, zap less), so
|
||||
/// with `stack_size` page-rounded by `Stack::new` the result is always
|
||||
/// page-aligned. Checked math: `retain ≥ stack_size` (notably the default
|
||||
/// 64 KiB reserve with the 64 KiB RETAIN) and overflow collapse to `None`.
|
||||
pub(crate) fn retain_range(stack_size: usize, retain: usize, page: usize) -> Option<(usize, usize)> {
|
||||
debug_assert!(page.is_power_of_two());
|
||||
let retain = retain.checked_add(page - 1)? & !(page - 1); // page_up(retain)
|
||||
let len = stack_size.checked_sub(retain)?;
|
||||
if len == 0 {
|
||||
return None;
|
||||
}
|
||||
Some((0, len))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::shrink_range;
|
||||
use super::{retain_range, shrink_range};
|
||||
|
||||
const PG: usize = 4096;
|
||||
|
||||
#[test]
|
||||
fn retain_covers_whole_stack_is_a_noop() {
|
||||
// The default config: reserve == RETAIN == 64 KiB. No zap, no syscall.
|
||||
assert_eq!(retain_range(16 * PG, 16 * PG, PG), None);
|
||||
assert_eq!(retain_range(PG, PG, PG), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retain_larger_than_stack_is_a_noop() {
|
||||
assert_eq!(retain_range(16 * PG, 17 * PG, PG), None);
|
||||
assert_eq!(retain_range(PG, usize::MAX, PG), None); // page_up overflows
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retain_zero_zaps_everything() {
|
||||
assert_eq!(retain_range(16 * PG, 0, PG), Some((0, 16 * PG)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retain_rounds_up_zapping_less() {
|
||||
// 1 byte of retain keeps a whole page.
|
||||
assert_eq!(retain_range(16 * PG, 1, PG), Some((0, 15 * PG)));
|
||||
assert_eq!(retain_range(16 * PG, PG + 1, PG), Some((0, 14 * PG)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retain_one_page_short_of_stack() {
|
||||
assert_eq!(retain_range(2 * PG, PG, PG), Some((0, PG)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retain_range_is_page_aligned() {
|
||||
for size_pg in [1usize, 2, 3, 16, 1024] {
|
||||
for retain in [0usize, 1, PG - 1, PG, PG + 1, 4 * PG, size_pg * PG] {
|
||||
if let Some((off, len)) = retain_range(size_pg * PG, retain, PG) {
|
||||
assert_eq!(off, 0);
|
||||
assert_eq!(len % PG, 0);
|
||||
assert!(len <= size_pg * PG);
|
||||
assert!(len > 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_inverted_spans_are_none() {
|
||||
assert_eq!(shrink_range(0x8000_0000, 0x8000_0000, PG), None); // hwm == sp
|
||||
|
||||
Reference in New Issue
Block a user