Skip to content

perf: replace the frame bitmap scan with a buddy allocator + per-CPU cache - #2

Merged
MAliffadlan merged 1 commit into
mainfrom
perf/frame-allocator-buddy-percpu
Oct 24, 2026
Merged

MAliffadlan merged 1 commit into
mainfrom
perf/frame-allocator-buddy-percpu

Conversation

@MAliffadlan

Copy link
Copy Markdown
Owner

What this changes

frame_alloc() was a linear scan of a 131k-bit map under one global vmm_lock, with no coalescing, and every fork() paid dozens of those scans. It is now a buddy allocator (orders 0–9, per-order free lists, node pointers stored in the free frame itself) fronted by a 32-frame per-CPU cache.

frame_alloc() / frame_free() keep their signatures, so every caller — COW faults (idt.c), vmm_clone_address_space, vmm_free_address_space, SysV shm, the loader, task teardown — is untouched. frame_ref_count[] semantics (COW/shm sharing, 255 = pinned zero page) are preserved.

Measured effect

On a live guest under a desktop + terminal workload:

FrameCached: 12 frames
FrameHit: 234 cache, 0 buddy, 272 refilled
FrameFree: 127 cached, 160 spilled, 173 coalesced, 0 oom, 1 over-free

234 of 234 allocations were served from the per-CPU cache, with zero descents into the buddy free lists and no vmm_lock acquisition on the fast path — which was the bottleneck. The counter ledger reconciles exactly (inflows = hits + spill outflow + frames still parked).

Reviewing this? These two constraints are load-bearing

Both were caught in review as regressions against the original bitmap allocator, so they are the places worth your attention:

1. The bitmap is authoritative for "this frame is on a free list" — not just init state. phys_reserve_region() runs after the free lists exist (the boot self-test forces the first allocation, still inside phys_init), and a reservation can start in the middle of a coalesced block — which frame_order[] cannot describe, since only a block's aligned head carries its order. So it rebuilds the lists from the bitmap rather than unlinking frame by frame, and pins reserved frames at refcount 255 so a stray frame_free() cannot release MMIO back into the allocator. Without this, framebuffer/MMIO frames stay allocatable and get handed to a process as ordinary writable memory. (Invisible on QEMU, where the fb sits above RAM; real on hardware where it lands inside RAM — the reason mem.c already had a comment about it.)

2. A frame parked in a cache carries BUDDY_CACHED (0xFE), and frame_free() claims a frame by writing that marker under vmm_lock before parking it. That is what keeps a double free inert — including one racing on another core — without putting vmm_lock back on the cache's fast path. The bitmap allocator got the same protection from a refcount > 0 test; the first cut of this PR dropped it.

Verification

  • make check-quick: 17/17 PASS on the exact committed tree.

  • The boot self-test no longer just checks that allocations succeed. It proves the free lists are a partition of the frames the bitmap calls free — every free frame on exactly one list, at an aligned head carrying the right order, none twice, none overlapping a live/cached frame. One scan catches a bad merge, a leaked frame, and a frame left in a cache and a free list at once. It also probes over-free and compares the used-page count exactly rather than within a cache's worth of slack.

    Current output: selftest PASS (partition ok, max order 0x6, 0xF merges, 0x1 over-free caught) — max order 0x6 means a 64-frame (256 KB) block genuinely reappeared after an aligned run was freed, so merging is exercised rather than assumed.

  • Frame-allocator counters (cache_hits, buddy_allocs, refill_frames, cached_frees, spilled_frees, coalesces, oom, overfree, cache_frames) are exposed in /proc/meminfo.

Known gaps

  • No per-AP SMP test. The battery runs -smp 4 and the fork/thread/cond suites drive the per-CPU cache across 4 cores, so there is empirical coverage — but nothing deliberately proves the partition invariant on an AP. Flagging rather than overstating.
  • Accepted trade-off: a cache is never drained, so a CPU that has ever allocated holds up to 32 frames until asked again (≤2 MB system-wide). A producer/consumer split (free on CPU A, allocate on CPU B) can therefore report OOM with frames parked on A. Documented in the code; a drain-at-idle hook or a global cached-frame cap is the fix if it ever bites.
  • phys_reserve_region() after the lists exist costs an O(frames) rebuild, once, at boot. The alternative — deferring the first build until after paging_init — would mean the self-test cannot run in phys_init.
  • Two pre-existing /proc/meminfo bugs found nearby and deliberately left alone: VFS_TMPFS_MAX_BYTES renders empty (TmpfsUsed: 0 B of B), and the tmpfs line is spliced into the middle of the HeapFree parenthetical.

Files

File Change
src/sys/vmm.c the allocator: buddy core, per-CPU cache, stats, boot self-test
src/include/vmm.h frame_alloc_stats_t + phys_get_stats(); header comment corrected
src/sys/vfs.c the /proc/meminfo lines for the frame counters
src/sys/mem.c note at the phys_reserve_region call site about reserving after the build
docs/kernel-locking.md lock-ordering note + shared-state rows for the new state

🤖 Generated with Claude Code

…cache

frame_alloc() was a linear scan of a 131k-bit map under one global
vmm_lock, with no coalescing. It is now a buddy allocator (orders 0-9,
per-order free lists, node pointers stored in the free frame itself)
fronted by a 32-frame per-CPU cache. frame_alloc()/frame_free() keep
their signatures, so every caller — COW faults, clone, munmap, shm,
loader, task teardown — is untouched.

Measured on a live guest under a desktop+terminal workload: 234 of 234
allocations served from the per-CPU cache, zero descents into the buddy
free lists, no vmm_lock on the fast path.

Two constraints here are load-bearing and easy to break:

- The bitmap is authoritative for "this frame is on a free list", not
  just init state. phys_reserve_region() runs after the lists exist
  (the boot self-test forces the first allocation, still inside
  phys_init), and a reservation can start in the MIDDLE of a coalesced
  block — which frame_order[] cannot describe, since only a block's
  aligned head carries its order. So it rebuilds the lists from the
  bitmap instead of unlinking frame by frame. Dropping that rebuild
  hands framebuffer/MMIO frames to a process as ordinary writable
  memory, and reserved frames are pinned at refcount 255 so a stray
  frame_free() cannot release them either.
- A frame parked in a cache carries BUDDY_CACHED (0xFE), and
  frame_free() claims a frame by writing that marker *under vmm_lock*
  before the frame is parked. That marker is what keeps a double free
  inert, including one racing on another core, without putting
  vmm_lock back on the cache's fast path. The bitmap allocator it
  replaces got the same protection from a refcount>0 test.

The boot self-test no longer just checks that allocations succeed: it
proves the free lists are a PARTITION of the frames the bitmap calls
free — every free frame on exactly one list, at an aligned head with
the right order, none twice, none overlapping a live/cached frame.
That single scan catches a bad merge, a leaked frame and a frame left
in a cache and a free list at once. It also probes over-free and
compares the used-page count exactly rather than within a cache's worth
of slack. Frame-allocator counters (cache hits, refills, spills,
merges, over-free) are exposed in /proc/meminfo.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@MAliffadlan
MAliffadlan merged commit 54535fc into main Oct 24, 2026
34 checks passed
@MAliffadlan
MAliffadlan deleted the perf/frame-allocator-buddy-percpu branch October 24, 2026 01:28
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