Per-CPU Page Lists
The buddy allocator hands out physically-contiguous runs of pages, but every operation on it must take the per-zone lock — and the overwhelmingly common case is a single CPU allocating or freeing one page (an order-0 page) on a hot path like a page fault. Funnelling every such single-page operation through a shared zone lock would serialize all CPUs against each other and trash cache locality. The kernel’s answer is the per-CPU page list (the
struct per_cpu_pages, universally abbreviated pcp or “pageset”): a small cache of order-0 (and, optionally, transparent-huge-page-order) pages held per CPU per zone, refilled and drained from the buddy allocator in batches under the zone lock, so that the steady-state fast path — alloc one page, free one page — touches only CPU-local data and takes no zone lock at all (it takes only a lightweight per-pcp spinlock). The structure is defined ininclude/linux/mmzone.h(Linux v6.12 source).
This is one of the kernel’s most important scalability optimizations: it converts the page allocator’s hottest path from a globally-contended lock into a CPU-local, cache-hot operation, paying the zone lock only once per batch of pages rather than once per page. It is the per-CPU caching idea — also seen in the SLUB allocator’s per-CPU slabs — applied at the page granularity.
Mental Model
Picture each CPU as having a small private “bucket” of loose order-0 pages for each memory zone. When code wants one page, the CPU grabs it from its own bucket — no coordination with other CPUs. When code frees a page, it drops it back into the bucket. Only when the bucket runs empty (alloc) or overflows a high-water mark (free) does the CPU walk over to the big shared warehouse (the buddy allocator), unlock it, and either scoop up a batch of fresh pages or pour a batch of excess pages back — one lock acquisition amortized across many pages. Because the bucket is CPU-local and recently-touched, the pages it hands out are usually still warm in that CPU’s cache, which also speeds up the code that then uses the page.
flowchart TD subgraph CPU0["CPU 0 (local, lock-free fast path)"] A0["alloc 1 page →<br/>pop from pcp->lists[mt]"] F0["free 1 page →<br/>push to pcp->lists[mt]"] end subgraph PCP["struct per_cpu_pages (per CPU, per zone)"] L["lists[NR_PCP_LISTS]<br/>(one per migratetype + THP order)"] H["high / batch / count<br/>watermarks"] end A0 --> L F0 --> L L -- "empty on alloc:<br/>rmqueue_bulk(batch)" --> ZONE L -- "count >= high on free:<br/>free_pcppages_bulk(batch)" --> ZONE ZONE["Buddy allocator<br/>(zone->lock, shared by all CPUs)"]
The per-CPU page list as a write-back cache in front of the buddy allocator. What it shows: single-page alloc/free hits only the CPU-local pcp->lists[] (cheap); the expensive shared zone->lock is touched only on the refill (list empty) and drain (list over the high watermark) transitions, and then it moves a whole batch of pages at once. The insight to take: the design trades a little memory (pages parked in per-CPU buckets, invisible to other CPUs until drained) for an enormous reduction in zone-lock contention — the lock cost is divided by the batch size on the steady-state path.
The struct per_cpu_pages (v6.12)
Every struct zone has, per online CPU, one of these (it is ____cacheline_aligned_in_smp, so each CPU’s pageset sits in its own cache line to avoid false sharing):
struct per_cpu_pages {
spinlock_t lock; /* Protects lists field */
int count; /* number of pages in the list */
int high; /* high watermark, emptying needed */
int high_min; /* min high watermark */
int high_max; /* max high watermark */
int batch; /* chunk size for buddy add/remove */
u8 flags; /* protected by pcp->lock */
u8 alloc_factor; /* batch scaling factor during allocate */
#ifdef CONFIG_NUMA
u8 expire; /* When 0, remote pagesets are drained */
#endif
short free_count; /* consecutive free count */
/* Lists of pages, one per migrate type stored on the pcp-lists */
struct list_head lists[NR_PCP_LISTS];
} ____cacheline_aligned_in_smp;Walking the fields:
lock— a per-pcp spinlock, not the zone lock. It was added (in the 5.19-era rework) so the pcp could be manipulated without disabling preemption/IRQs the old way; it is cheap because it is almost never contended (only this CPU normally touches its own pageset, the exception being remote draining, below).count— how many pages are currently parked in this pageset across all its lists.high— the high watermark: whencountreacheshighafter a free, the pageset is over-full and abatchof pages is drained back to the buddy allocator. This is the knob that bounds how much memory the pageset can hoard.high_min/high_max— the floor and ceiling for the adaptivehighvalue.highis not constant: it floats betweenhigh_minandhigh_maxdepending on recent free activity, so a CPU doing a burst of frees can temporarily hold more pages (fewer drains), while an idle CPU’shighdecays back towardhigh_minto release memory.batch— the chunk size: how many pages are moved to/from the buddy allocator in one refill or drain. This is the amortization factor — the zone lock is taken once perbatchpages.alloc_factor— scales the effective batch up during sustained allocation, so a CPU on a heavy alloc streak refills more pages per zone-lock acquisition.free_count— a running count of consecutive frees, used to drive the adaptive growth ofhigh(a long free streak raiseshightowardhigh_max).expire(NUMA builds only) — a countdown for remote pageset draining: a pageset holding pages from a remote NUMA node is periodically aged, and whenexpirehits zero those remote pages are drained so they can return to their home node. This keeps a CPU from indefinitely hoarding another node’s memory.lists[NR_PCP_LISTS]— the actual page lists, one per migratetype (so that, e.g., MOVABLE and UNMOVABLE pages stay segregated to fight fragmentation), plus extra lists for transparent-huge-page-order pages.
The list count
#define NR_PCP_THP 2 /* or 0 if !CONFIG_TRANSPARENT_HUGEPAGE */
#define NR_LOWORDER_PCP_LISTS (MIGRATE_PCPTYPES * (PAGE_ALLOC_COSTLY_ORDER + 1))
#define NR_PCP_LISTS (NR_LOWORDER_PCP_LISTS + NR_PCP_THP)MIGRATE_PCPTYPES is the number of migratetypes cached on the pcp; PAGE_ALLOC_COSTLY_ORDER is 3. So the “low-order” lists cover migratetypes × orders 0..3 — i.e. the pcp caches not just order-0 single pages but small contiguous orders up to order 3, segregated by migratetype, with two extra lists for THP-order pages (when transparent huge pages are configured). This per-order, per-migratetype segregation lets the pcp serve a small higher-order allocation locally too, not only single pages.
Uncertain
Verify: that the pcp caches orders 0 through
PAGE_ALLOC_COSTLY_ORDER(3) and not only order-0. TheNR_LOWORDER_PCP_LISTSmacro arithmetic (MIGRATE_PCPTYPES * (PAGE_ALLOC_COSTLY_ORDER + 1)) strongly implies orders 0..3 are each cached per migratetype, and the multi-order pcp work landed around 5.x, but I did not fetch the alloc/free code (__rmqueue_pcplist,free_unref_page_commit) at v6.12 to confirm the order indexing and that all four orders are actually populated. Reason: source fetch ofmm/page_alloc.cwas blocked (backend overload). To resolve: readorder_to_pindex()/pindex_to_order()and__rmqueue_pcplistinmm/page_alloc.cat thev6.12tag. uncertain
Mechanical Walk-through: Alloc, Free, Refill, Drain
The two sibling per-CPU stats structures the zone also carries are distinct from the page lists and hold statistics, not pages:
struct per_cpu_zonestat { /* per-CPU per-zone counters */
s8 vm_stat_diff[NR_VM_ZONE_STAT_ITEMS];
s8 stat_threshold;
unsigned long vm_numa_event[NR_VM_NUMA_EVENT_ITEMS]; /* CONFIG_NUMA */
};
struct per_cpu_nodestat { /* per-CPU per-node counters */
s8 stat_threshold;
s8 vm_node_stat_diff[NR_VM_NODE_STAT_ITEMS];
};These batch up VM statistics (e.g. NR_FREE_PAGES) the same way the page lists batch pages — folding per-CPU deltas into the global counters only when a threshold is crossed, so that hot counters don’t bounce a shared cache line between CPUs.
The page-movement paths themselves work as follows (the function names below are the v6.12 mm/page_alloc.c entry points):
Allocation (rmqueue_pcplist → __rmqueue_pcplist). An order-0 (or small-order) allocation that does not demand special placement is routed to the local pageset. The allocator takes pcp->lock, pops a page off the appropriate lists[] entry for the requested migratetype/order, decrements count, and returns — no zone lock. If the relevant list is empty, it first calls into the buddy allocator (rmqueue_bulk) under the zone lock to pull a batch of pages (scaled by alloc_factor) onto the list, then satisfies the allocation from the freshly-filled list.
Free (free_unref_page → free_unref_page_commit). Freeing a single page that is eligible for pcp caching pushes it onto the local lists[] for its migratetype/order, increments count and free_count. If count now meets or exceeds pcp->high, the pageset is over its watermark and free_pcppages_bulk() drains a batch of pages back to the buddy allocator under the zone lock — coalescing them with their buddies as it goes.
Uncertain
Verify: the exact control flow and function names above (
rmqueue_pcplist,__rmqueue_pcplist,rmqueue_bulk,free_unref_page,free_unref_page_commit,free_pcppages_bulk) and the precise condition that triggers a drain (count >= highvscount >= high + batch), plus howalloc_factor/free_countadjust the batch andhighadaptively. These are reconstructed from the field comments and prior kernel knowledge, NOT quoted from the v6.12 source (themm/page_alloc.cfetch was blocked during research). Reason: source fetch blocked (backend overload). To resolve: read these functions andnr_pcp_high()/nr_pcp_free()inmm/page_alloc.cat thev6.12tag. uncertain
Sizing high and batch
The default high and batch are computed at zone init from the zone’s size and the number of online CPUs (functions zone_batchsize() and zone_highsize() in mm/page_alloc.c). The intent is: batch large enough to amortize the zone lock but small enough that draining doesn’t cause a latency spike; high large enough to absorb bursts but bounded so the per-CPU pagesets across all CPUs don’t lock away a large fraction of the zone’s free memory.
Uncertain
Verify: the actual
zone_batchsize()andzone_highsize()formulas in v6.12 (historicallybatchwas derived as roughlymin(zone_pages / 1024, some_cap)rounded to a power-of-two-minus-one, andhighas a multiple ofbatchscaled bypercpu_pagelist_high_fractionand CPU count — but the multi-order/adaptive-high rework changed this). I could NOT fetchmm/page_alloc.cat v6.12 to quote the exact arithmetic. Reason: source fetch blocked (backend overload). To resolve: readzone_batchsize,zone_highsize, andnr_pcp_highat thev6.12tag and reproduce the formulas symbol-by-symbol. uncertain
Tuning: percpu_pagelist_high_fraction
The one user-facing tunable is vm.percpu_pagelist_high_fraction, documented in the kernel admin guide. It bounds how large pcp->high may grow: it sets the fraction of a zone’s pages that may be stored across the per-CPU page lists, expressed as a divisor and further divided by the number of online CPUs. The default value is 0, which means “use the kernel’s default sizing” (derived from zone size and CPU count as above) rather than an operator-imposed cap. Raising the fraction (a smaller effective divisor caps high higher) lets each CPU hoard more pages — fewer zone-lock trips, but more free memory parked per-CPU and invisible to allocators on other CPUs; lowering it does the reverse. Operators rarely touch this; it matters mainly on machines with very many CPUs and small zones, where the sum of all per-CPU pagesets can lock away a meaningful slice of free memory.
Draining: When Pages Must Come Back
Pages parked in a pcp are free but unavailable to any other CPU and to the buddy allocator’s coalescing — so several events force a drain (drain_pages, drain_all_pages, drain_zone_pages):
- Over the high watermark on free — the steady-state batch drain described above.
- Memory pressure / low watermarks — when the allocation slow path cannot meet a watermark, it drains the pcp lists (
drain_all_pages) to return those free pages to the buddy allocator where they can coalesce into higher orders and be seen system-wide. This is why a system under pressure flushes per-CPU caches: hoarded pcp pages would otherwise block compaction and high-order allocation. - CPU hotplug / offline — a CPU going offline drains its pagesets so its hoarded pages aren’t orphaned.
- Remote NUMA expiry — the
expirecountdown drains a pageset’s remote-node pages so they migrate back toward their home node. - Explicit —
echoto/proc/sys/vm/...paths anddrain_all_pages()from various subsystems.
Draining is the counterweight to caching: the more aggressively pages are held per-CPU (high high), the more must be reclaimed at drain time, and the more high-order/contiguous allocations can be temporarily starved.
Failure Modes and Diagnosis
- “Free” memory that isn’t allocatable. Pages sitting in per-CPU pagesets count as free (
NR_FREE_PAGES) but cannot satisfy a higher-order allocation on another CPU until drained. On many-CPU machines this can surface as high-order allocation failures despite plenty of reported free memory — the fix is the drain that the slow path already performs, but it explains transientorder-Nallocation stalls. - Fragmentation pressure. Because pcp pages skip buddy coalescing while parked, a workload with huge per-CPU caches can degrade the buddy allocator’s ability to form high-order blocks. This is one reason compaction and the slow path drain the pcp lists.
- NUMA hoarding. Without the
expire-driven remote drain, a CPU that allocated remote-node pages and then went quiet would hold that remote memory indefinitely; the expiry mechanism bounds this. - Diagnosis:
/proc/zoneinforeports per-CPU pagesetcount/high/batchper zone per CPU;/proc/vmstatand the per-CPU stat counters show alloc/free rates;perfonzone->lockcontention reveals whether the pcp is effectively absorbing the hot path (it should: heavyzone->lockcontention usually means batch/high are mis-sized or draining is too frequent).
Alternatives and Relationship to Sibling Mechanisms
The per-CPU page list is the page-granularity analogue of the SLUB allocator’s per-CPU freelist (kmem_cache_cpu): both keep a CPU-local cache in front of a globally-locked structure, both refill/drain in batches, and both trade a little hoarded memory for a lot of avoided contention. They compose: SLUB sits on top of the buddy allocator, so a SLUB cache refilling its slab ultimately pulls order-N pages — often through the pcp lists. The pcp is not a replacement for the buddy allocator (it caches only small orders and depends on the buddy for refill, coalescing, and all large/contiguous allocations); it is a front-end cache. It is also distinct from zone/node structure — there is one pageset per (CPU, zone), so a 64-CPU box with 4 zones has 256 pagesets.
Production Notes
The pcp lists are largely invisible in production precisely because they work: the steady-state page allocator path on a busy server is dominated by order-0 fault-in and free, and the pcp keeps that path off the contended zone lock. The visible artifacts are (1) the per-CPU pageset rows in /proc/zoneinfo, which operators occasionally inspect when chasing high-order allocation failures, and (2) the rare need to drop percpu_pagelist_high_fraction on extreme-core-count, small-zone systems where the summed per-CPU hoard becomes non-trivial. The 5.19-era addition of the per-pcp spinlock (replacing the older local_irq_save/local_lock discipline) and the adaptive high/high_min/high_max + alloc_factor/free_count machinery were both contention/latency optimizations: the adaptive high lets a CPU on a free-heavy burst hold more pages (fewer drains) while still releasing memory when idle, smoothing the worst-case zone-lock traffic without permanently over-provisioning every pageset.
Uncertain
Verify: the “5.19-era” attribution for the per-pcp
spinlockintroduction and the adaptive-high (high_min/high_max/alloc_factor/free_count) rework — these are from memory, not pinned to a merge-window LWN summary or commit during this task. Reason: LWN/source confirmation fetch was blocked (backend overload). To resolve: check the git history ofstruct per_cpu_pagesininclude/linux/mmzone.hand the associated LWN merge-window summaries. uncertain
See Also
- The Buddy Allocator — the shared, zone-locked allocator the pcp caches in front of; pcp refills/drains go here.
- Watermarks and the Allocation Fast Path — the fast path serves order-0 from the pcp; the slow path drains the pcp under pressure.
- Memory Zones and Nodes — there is one
per_cpu_pagesper (CPU, zone). - Page Order and Fragmentation — per-migratetype pcp lists keep page types segregated; parked pcp pages skip coalescing.
- The Slab Allocator and SLUB — the per-CPU-cache idea applied to kernel objects, layered on top of the buddy allocator.
- Memory Compaction — drains the pcp to free contiguous runs.
- NUMA Memory Model — the
expirefield drains remote-node pcp pages. - UP: Linux Memory Management MOC (§4 The Page Allocator).