Skip to content

Bucketed page pool, memory reclaim + threat watermarks - #224

Open
TurkeyMan wants to merge 5 commits into
masterfrom
ow/page-pool
Open

Bucketed page pool, memory reclaim + threat watermarks#224
TurkeyMan wants to merge 5 commits into
masterfrom
ow/page-pool

Conversation

@TurkeyMan

@TurkeyMan TurkeyMan commented Aug 14, 2026

Copy link
Copy Markdown
Member

Memory-pressure toolkit for shared-heap targets, in three mechanisms plus groundwork:

Page pool (urt.mem.pagepool): fixed-size pages in configurable size categories, carved from slabs with per-slab freelists. Allocation always pops from the most-occupied slab, so lightly-used slabs drain to fully-idle and trim can return them to the heap whole. Hard per-category caps (failure = caller drops + counter, never heap growth), low-water preallocation, stats with size histogram. Oversize requests get a heap-backed page behind the same header so page_free is uniform; in-range requests never fall through to the heap, keeping the caps meaningful under flood. Pop/push run under a Critical; slab alloc/release always happens outside it.

Reclaimer registry (urt.mem.reclaim): subsystems holding reclaimable cache register a handler with a willingness value (reflecting rebuild cost). On allocation failure the allocator walks handlers most-willing-first and retries; !thread_safe handlers only run from the main thread; reentry from a handler that allocates returns 0 instead of recursing. Handlers are stored as a decomposed function/context pair (delegates recomposed at the call), so function-pointer and delegate registration share one entry shape. The page pool registers its trim at high willingness.

Memory-threat watermarks (same module): allocated bytes are accounted at the alloc/free/realloc/expand choke points (one atomic + one compare on the fast path); crossing a registered high watermark fires the handler once, re-arming only below the low watermark, so a pressure episode costs one callback rather than one per allocation. Handler contract is post-an-event-and-return. This is the proactive half: it buys time to convert expensive-to-reclaim state to cheap (e.g. flushing dirty history so its blocks become droppable) before the reclaim walk needs it.

Groundwork: Critical is now zero-init valid on every platform -- the Windows backing becomes SRWLOCK (zero-init by design, waiters block) with the same owner/count reentrancy layer the Posix owner-spin branch uses -- so __gshared Critical needs no init hook; urt.thread gains current_thread_id/set_main_thread/is_main_thread, with urt's own main() stamping the main thread during bootstrap; and the Bouffalo TLSF heap is now locked (it had no locking while vendor C enters via the extern(C) malloc overrides) -- a prerequisite once anything above it takes locks.

Unittests cover pool round-trip/exhaustion/packing/trim/jumbo, reclaim ordering/early-exit/reentrancy, watermark fire-once/re-arm, and thread identity. openwatt consumer: open-watt/openwatt#518.

@TurkeyMan
TurkeyMan force-pushed the ow/page-pool branch 3 times, most recently from 8c712c1 to 6255f40 Compare August 14, 2026 16:30
…n-thread notion

Critical's Windows backing becomes SRWLOCK (zero-init by design) with the
same owner/count reentrancy layer the Posix owner-spin branch uses, so
zero-init is the valid initial state on every platform and a __gshared
Critical needs no init() hook. Windows waiters block instead of spinning.

thread gains current_thread_id/set_main_thread/is_main_thread; urt's own
main() stamps the main thread during bootstrap, so is_main_thread() is
trustworthy in every consumer with no setup call.
Subsystems holding reclaimable cache (freelists, caches) register a
handler with a willingness value reflecting rebuild cost; on allocation
failure the allocator walks handlers most-willing-first and retries once
anything was freed. Handlers registered !thread_safe only run from the
main thread; reentry from a handler that allocates returns 0 instead of
recursing. Both delegate and function-pointer registration are supported
since module-level trim handlers have no context to close over.
Fixed-size pages in a few size categories, carved from slabs. Allocation
prefers the most-occupied slab (per-slab freelists, not a master list) so
lightly-used slabs drain to fully-idle and trim can return them to the
heap; the pool registers itself as a high-willingness reclaimer. Requests
beyond the largest category get a heap-backed page with the same header
so page_free is uniform; in-range requests never fall through to the
heap, keeping the per-category caps meaningful under flood. Pop/push run
under a Critical; slab allocation and release happen outside it.
TLSF has no internal locking and vendor C enters through the extern(C)
malloc overrides from other tasks. All pool access now runs under a
Critical; log calls stay outside it since they format strings.
query_pool_stats walks the free-list so it takes the lock too.
Proactive counterpart to the reclaim walk: allocated bytes are accounted
at the alloc/free/realloc/expand choke points (one atomic + one compare
on the fast path), and when usage crosses a registered high watermark the
handler fires ONCE, re-arming only after usage falls below the low
watermark -- a pressure episode costs one callback, not one per alloc.
Handlers run inside the allocator on whatever thread allocated, so their
contract is post-an-event-and-return; the scheduled job does the real
work (e.g. flushing dirty cache so its reclaimer becomes cheap).

Usage counts requested bytes, excluding allocator headers and
fragmentation: watermarks are a proxy for footprint, not a measure.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant