Skip to content

mm: cache heap page blocks per CPU in front of the heap buddy - #114

Open
JiTianYu391 wants to merge 2 commits into
ViudiraTech:developfrom
JiTianYu391:perf/heap-page-cache
Open

JiTianYu391 wants to merge 2 commits into
ViudiraTech:developfrom
JiTianYu391:perf/heap-page-cache

Conversation

@JiTianYu391

@JiTianYu391 JiTianYu391 commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #112 — please merge #112 first. Both PRs rewrite the same lines of mem/alloc.c (the page-tagging loop in page_alloc() and the counter block of heap_get_stats()), so each merged cleanly into develop on its own but conflicted with the other. This branch is now rebased onto #112: the first commit is #112 unchanged, the second is this PR. After #112 lands, only the second commit remains in the diff.

Problem

Every large malloc(), every slab growth/shrink, and (via pointer_owner()) every free() take heap.page_lock, the one lock of the kernel heap's buddy allocator. It does not scale: with the in-kernel benchmark from #110 (kbench, 5 vCPUs, KVM) a 64 KiB malloc()+free() loop costs 144 ns/op on one CPU but 2364 ns/op when every CPU runs it, i.e. ~16x slower. A temporary lock-stat counter (not included) showed ~45% of acquisitions of this lock finding it already held; no other lock (scheduler.lock, frame_allocator.lock, printk_lock, pid_hash_lock) was contended in any kbench test.

Fix (same idea as Linux's per-CPU pagesets)

  • Freed blocks of orders 0..5 (up to 128 KiB) are parked in a small per-CPU cache and handed back from there; the cache exchanges blocks with the buddy in batches (half the limit), so page_lock is only taken on a miss or when the cache is full. Limits per order are 8/8/6/4/3/2 blocks.
  • A cached block stays allocated in the buddy (it cannot coalesce) but has its tags cleared, so pointer_owner() and the double-free detection reject it exactly like a free block. page_free() claims the head tag with a compare-and-swap, so a repeated or racing free of one block fails instead of being cached twice.
  • If the buddy cannot satisfy an allocation, all CPU caches are drained once and the allocation retried, so cached memory cannot cause a spurious out-of-memory. heap_get_stats() counts cached pages as free.
  • pointer_owner() reads the page tag without page_lock: the tag of a valid pointer is stable while its block is allocated, and an invalid pointer is still rejected by the owner's magic/cookie checks (the same change as in mm: scale kmalloc with bounded per-CPU magazines #112, which this PR is built on).
  • The cache is bypassed until the scheduler has started (CPU topology is not usable before that), like the frame allocator's PCP lists.

Measurement

kbench, 5 vCPUs, A/B alternating, 5 boots per side, 3 rounds per boot, median of per-boot medians (>20% is the only effect I claim). Measured on codex/perf-validation (develop + #108/#109/#111/#112 + #110) with this patch applied, because plain develop misbehaves under kbench without the scheduler fixes.

test before after ratio
malloc/65536/all-cpus (ns/op) 2364.0 780.4 0.33
malloc/65536/solo 143.9 143.3 1.00
malloc/4096/all-cpus 181.6 187.5 1.03
malloc/64/solo, all-cpus 99.7, 105.4 100.4, 104.0 1.01, 0.99
yield, wq-pingpong, pipe-*, kthread/create-exit, wq-prepare-cancel all within ±10% (noise)

The in-kernel heap self-check used during testing (17 size classes, batch overflow, realloc, exactly one expected rejected double free, heap_validate()) reported 0 failures in all 10 boots.

Why not solo-level yet

With the global statistics atomics (allocation_calls, live_allocations, allocated_bytes, free_calls) temporarily disabled in the large-allocation path, malloc/65536/all-cpus measured 123 ns (= solo). The remaining 780 ns is those 6 shared-cacheline atomic operations per malloc/free pair, not a lock. Making them per-CPU belongs on top of #112 (which already has per-CPU counters for the slab path), so it is not part of this PR.

Notes

🤖 Generated with Claude Code

JiTianYu391 and others added 2 commits October 21, 2026 06:41
On a 5-vCPU KVM guest, malloc(64)+free scales from about 115 ns alone to
2,130 ns with five workers. Prior contention sampling found 70% of slab
cache lock acquisitions contended; pointer_owner also took heap.page_lock
on every free. Reproduce with kbench=3 and five alternating A/B boot pairs.

Give each of the 17 kmalloc size classes a per-CPU magazine capped at
32 objects / 16 KiB. Refill and spill in batches under the slab cache lock,
keep cached objects counted in slab->inuse so shrink cannot reclaim them,
and retain a CAS transition from ALLOCATED to CACHED to reject double free.
Typed/dynamic caches keep their existing constructor/destructor path.
Use stable buddy ownership tags for valid live pointers without taking the
page lock, and aggregate usage counters from the per-CPU front ends.

The new early malloc caller also exposed smp_init publishing cpu_count
before allocating cpus. GDB found smp_init -> aligned_alloc -> malloc ->
heap_cpu_cache -> get_current_cpu_id with cpu_count=5 and cpus=NULL, faulting
at cpus[0].lapic_id (address 8); panic then faults in send_ipi_all. Extend
the BSP fallback until the CPU table exists.

Validation: five alternating A/B boot pairs, three rounds per boot,
median within each boot then median across boots (ns/op):
                     A         B
  malloc/64/solo      115.4      99.4
  malloc/64/all-cpus 2130.3     107.8
  malloc/4096/solo    171.5     159.0
  malloc/4096/all    2992.1     184.1
  malloc/65536/solo   167.5     145.1
  malloc/65536/all   2793.1    2382.0
  yield/solo          92.0      92.5
Only the >20% changes (64 B and 4 KiB multi-CPU) establish a performance
gain; smaller differences are treated as noise. All ten boots complete.
Every boot passes heap_validate, all size classes with batch spills,
realloc data checks, dynamic slab busy/shrink/destroy, free poisoning and
exactly one expected duplicate-free rejection, with zero failures.

The comparison images use the same scheduler/kbench/pinning/strcpy test
overlays and SMP bootstrap guard; only the allocator differs. Temporary
integrity checks remain outside this PR. Standalone origin/develop build
uses CONFIG_VIRTIO_GPU off and -Wl,-u,strcpy for the existing LTO defect.
The rejected two-task steal minimum and idle polling are not included.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Every large allocation and every slab growth/shrink takes heap.page_lock,
the single lock of the kernel heap's buddy allocator, and pointer_owner()
took the same lock on every free just to read a page tag. With kbench on
5 vCPUs a 64 KiB malloc()/free() loop costs 144 ns on one CPU but 2364 ns
per op when every CPU runs it (a lock-stat run showed ~45% of acquisitions
of this lock finding it held).

As in Linux's per-CPU pagesets, park freed blocks of orders 0..5 on the
freeing CPU and hand them back from there, exchanging blocks with the buddy
in batches. Cached blocks stay allocated in the buddy (they cannot
coalesce) but have their tags cleared, so pointer_owner() and the
double-free check reject them exactly like free blocks; page_free() claims
the head tag with a compare-and-swap so a racing or repeated free of one
block fails. When the buddy cannot satisfy an allocation all caches are
drained once before giving up, and heap_get_stats() counts cached pages as
free. pointer_owner() now reads the tag without page_lock (the tag of a
valid pointer is stable while the block is allocated; invalid pointers are
still rejected by the owner's magic and cookie checks).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
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