Memory reclaim is the Linux kernel’s machinery for taking physical pages that are already in use and freeing them for reuse, so that the system can keep handing out memory after physical RAM has filled up. The kernel never fails an ordinary GFP_KERNEL allocation just because RAM is full; instead, when the free-page count drops below a threshold, the page reclaimer in mm/vmscan.c walks the least-recently-used (LRU) lists, throws away clean cached file pages, writes dirty pages back to their files, and pushes anonymous (heap/stack) pages out to swap — only invoking the out-of-memory (OOM) killer when reclaim genuinely cannot keep up (kernel.org Concepts overview). Reclaim is the defensive half of memory management: the fault path hands out the illusion of abundant memory, and reclaim defends the scarce physical reality underneath. This note is the hub for the reclaim subsystem; each mechanism it sketches has its own deep-dive sibling.
Version pin — Linux 6.12 LTS
Source citations in this note are read from the v6.12 tag of torvalds/linux. 6.12 (released 2024-11-17) is a maintained long-term-support branch, not mainline: it is still receiving stable updates in 2026, which is why it is the pin rather than the newest release. Where a mechanism changed after 6.12, the change is called out inline with the release that made it, verified against the corresponding tag. Runtime numbers labelled “on this box” were measured on a Fedora 44 workstation running 7.1.8 with 125 GiB of RAM — they illustrate the shapes and the arithmetic, and where the running kernel differs from 6.12 that is stated.
Scope — this note is the map; the two triggers have their own notes
Reclaim is documented in this vault as a three-way split, and each note is authoritative for its own third. Read this one first; go to the siblings for mechanism depth.
This note (the map) — what reclaim is, where it is entered from the allocation slowpath, the watermark arithmetic walked with real numbers, what counts as reclaimable, how victims are chosen by the classic LRU and by MGLRU, shrinkers and slab reclaim, a worked /proc/vmstat diagnosis of one measured machine, and the taxonomy of failure modes.
kswapd and Background Reclaim (the daemon) — the per-node kthread and its PF_MEMALLOC/PF_KSWAPD privileges, the sleep/wake/balance state machine, balance_pgdat() and the priority descent, what pgdat_balanced() really tests and the 2.6.38 bug that defined it, kswapd’s reclaim-target arithmetic, watermark boosting, the kcompactd handoff, per-node NUMA behaviour, and why background reclaim is latency-invisible until it isn’t.
Direct Reclaim (the stall) — the gates in __alloc_pages_slowpath() a task must pass to reach try_to_free_pages(), the four-way retry structure and why allocstall over-counts, the two distinct throttles (throttle_direct_reclaim() versus reclaim_throttle()), PSI accounting, and how to recognise a direct-reclaim latency stall in production.
The shared page-scanning engine (shrink_node() → shrink_lruvec() → the LRU walk) is explained here and in The LRU Lists, and deliberately not duplicated in either trigger note. Where the sections below sketch a trigger mechanism, the sibling note is the authority; two refinements are worth flagging explicitly, both verified against v6.12 source by the trigger notes: the pfmemalloc_wait sleep is unbounded for __GFP_FS callers (wait_event_killable) and bounded at one second only for !__GFP_FS callers, and pgdat_balanced() is not byte-identical between 6.12 and 6.18.
Mental Model — Two Triggers, Four Kinds of Reclaimable Memory
The whole subsystem fits in one sentence: when free pages fall past a watermark, reclaim selects victims off the LRU and frees them, choosing among four kinds of reclaimable memory by cost.
flowchart TB
ALLOC["Allocation request<br/>(buddy allocator fast path)"]
WM{"Free pages vs<br/>watermarks?"}
ALLOC --> WM
WM -->|"above low"| FAST["Succeed immediately<br/>(no reclaim)"]
WM -->|"below low,<br/>above min"| KSWAPD["Wake kswapd<br/>(async, background)"]
WM -->|"below min"| DIRECT["Direct reclaim<br/>(sync, in caller's context)"]
KSWAPD --> SCAN["Scan LRU lists<br/>(shrink_node)"]
DIRECT --> SCAN
SCAN --> SHR["Shrinkers<br/>(slab: dentry/inode caches)"]
SCAN --> PAGES{"Page type?"}
PAGES -->|"clean file page"| DROP["Drop it<br/>(re-read from disk later)"]
PAGES -->|"dirty file page"| WB["Writeback<br/>(flush to filesystem)"]
PAGES -->|"anonymous page"| SWAP["Swap out<br/>(write to swap device)"]
DROP --> FREE["Freed pages<br/>back to buddy allocator"]
WB --> FREE
SWAP --> FREE
FREE -->|"still not enough"| OOM["OOM killer<br/>(last resort)"]
The reclaim subsystem at a glance. What it shows: an allocation that finds free memory below the low watermark wakes the background daemon kswapd; one that finds memory below the min watermark must reclaim synchronously in its own context (direct reclaim). Both paths funnel into the same node-scanning core (shrink_node), which calls shrinkers for non-page caches and walks the LRU lists for pages. A page’s fate depends on its type: a clean file page is simply dropped (its data is still on disk), a dirty file page must be written back first, and an anonymous page — which has no file behind it — must be written to swap. The insight to take: there is no single “free memory” button; reclaim is a graded escalation (background → synchronous → OOM kill) layered on a cost-ranked menu of victims, and the cheapest victim, a clean file page, is always preferred over the most expensive, an anonymous page that requires a swap write.
The two questions that organize everything are “who reclaims?” (background kswapd vs synchronous direct reclaim) and “what gets reclaimed?” (file pages, dropped or written back; anonymous pages, swapped; slab, via shrinkers).
Where Reclaim Is Entered — The Allocation Slowpath
Reclaim is not a service anyone calls directly. It is a step inside the page allocator’s slow path, and reading __alloc_pages_slowpath() in mm/page_alloc.c is the only way to see how many things must fail before a task ends up doing reclaim itself.
An allocation begins in __alloc_pages_noprof(), which tries get_page_from_freelist() against the low watermark. If that succeeds — the overwhelmingly common case — nothing else happens. If it fails, control passes to __alloc_pages_slowpath() (mm/page_alloc.c line 4204 at v6.12), which runs the following escalation.
The allocation slow path of __alloc_pages_slowpath(), mm/page_alloc.c at v6.12. What it shows: reclaim (__alloc_pages_direct_reclaim) sits deep inside a much longer escalation. Before a task blocks, the allocator has already woken kswapd, retried the free lists with relaxed flags, optionally tried direct compaction, and possibly dipped into the emergency reserves. After reclaim fails, it tries compaction again, then loops back up to sixteen times (MAX_RECLAIM_RETRIES and MAX_COMPACT_RETRIES are both 16, mm/internal.h line 468 and mm/page_alloc.c line 3663) before the OOM killer is even considered. The insight to take: “the system OOM-killed something” is the end of a long chain, not a single decision — and the ordering explains a common confusion, namely that compaction is attempted before reclaim for high-order requests (a fragmented zone may already have enough free pages), while reclaim comes first for order-0 requests, which cannot be helped by rearranging anything.
Two conditions in that flow deserve naming because they change behaviour dramatically:
costly_order means order > PAGE_ALLOC_COSTLY_ORDER, and PAGE_ALLOC_COSTLY_ORDER is 3 (mm/internal.h line 46) — so any request for more than 8 contiguous pages (32 KiB on a 4 KiB-page machine) is “costly”. The kernel is deliberately unwilling to grind for these: if (costly_order && (!can_compact || !(gfp_mask & __GFP_RETRY_MAYFAIL))) goto nopage;. A costly allocation that does not carry __GFP_RETRY_MAYFAIL gets one pass of reclaim and compaction and then fails, rather than looping. This is why a driver asking for an order-5 buffer sees -ENOMEM on a busy machine while a malloc() of the same size never does — malloc() is built out of order-0 pages.
can_direct_reclaim is derived from __GFP_DIRECT_RECLAIM. GFP_ATOMIC and GFP_NOWAIT clear it, so those allocations skip the entire lower half of the diagram: they may wake kswapd, they may dip into reserves via ALLOC_HIGH, but they will never block. An interrupt handler cannot stall on writeback, so it does not.
There is also a recursion guard worth knowing about: if (current->flags & PF_MEMALLOC) goto nopage;. A task already inside reclaim (reclaim sets PF_MEMALLOC on itself) that needs to allocate — a swap-out path allocating a bio, say — is not allowed to re-enter reclaim. Without that check, reclaim would recurse until the stack overflowed.
GFP modifier
Effect on the slowpath
__GFP_DIRECT_RECLAIM
Permits the synchronous stall at all. Cleared by GFP_ATOMIC / GFP_NOWAIT.
__GFP_KSWAPD_RECLAIM
Permits waking kswapd (ALLOC_KSWAPD). Set in GFP_KERNEL and GFP_ATOMIC.
__GFP_NORETRY
One attempt only; goto nopage rather than looping. Used by THP fault allocations.
__GFP_RETRY_MAYFAIL
Try hard (including the retry loops) but return NULL rather than OOM-killing.
__GFP_NOFAIL
Loop forever; never return NULL. Reserved for callers that genuinely cannot handle failure.
__GFP_FS / __GFP_IO
Cleared, they forbid reclaim from entering the filesystem or block layer — the escape hatch that stops filesystem code deadlocking on itself.
Allocation-context flags and what they do to the escalation above. The insight: the same reclaim engine behaves completely differently depending on who called it; the GFP flags are the caller’s declaration of what it is willing to pay. Full treatment in GFP Flags and Allocation Contexts.
Watermarks, Walked With Real Numbers
The trigger thresholds are the three per-zone watermarks, and it is worth doing the arithmetic once with real numbers rather than treating min/low/high as abstractions. Here is the live /proc/zoneinfo state from the measurement box (values in pages, 4 KiB each):
Zone
managed
min
low
high
free (at sample time)
DMA
3,840
1
4
7
2,816
DMA32
457,117
224
658
1,092
332,601
Normal
32,326,603
16,669
48,993
81,317
5,329,742
Movable
0
32
32
32
0
Live per-zone watermarks, read from /proc/zoneinfo. What it shows: watermarks are per zone, proportional to that zone’s managed page count, and tiny compared to it — the Normal zone’s min is 16,669 pages ≈ 65 MiB out of 123 GiB, roughly 0.05%. The insight: the reclaim triggers are a thin sliver at the bottom of memory, not a large reserve. The system runs with essentially all of RAM occupied by page cache and anonymous memory, and the watermarks are the trip-wire in the last fraction of a percent.
The values come from __setup_per_zone_wmarks() (mm/page_alloc.c line 6008). The min watermark divides the global min_free_kbytes budget across zones in proportion to size:
Symbol by symbol: min_free_kbytes is the sysctl (67,584 KiB on this box, auto-sized at boot from total RAM); PAGE_SHIFT is 12 on x86-64, so >> (12 - 10) is a divide by 4, converting kilobytes to 4 KiB pages — 67,584 / 4 = 16,896 pages to distribute. lowmem_pages is the sum of managed pages over all non-highmem, non-movable zones (3,840 + 457,117 + 32,326,603 = 32,787,560 here). The Normal zone therefore gets 16,896 × 32,326,603 / 32,787,560 ≈ 16,658 pages, matching the observed 16,669 to within a rounding-and-drift margin (see the callout below).
The gaps between the watermarks come from a separate knob:
watermark_scale_factor is “in fractions of 10,000” with a default of 10, i.e. 0.1% of the zone, and a maximum of 3000 (30%) (vm.rst). mult_frac(managed, 10, 10000) on the Normal zone is 32,326,603 / 1000 ≈ 32,326 pages; tmp >> 2 (a quarter of min) is only 4,167, so the scale factor wins. Observed: low − min = 48,993 − 16,669 = 32,324, and high − low = 81,317 − 48,993 = 32,324. The formula reproduces the kernel’s own numbers.
Uncertain
Verify: the two-page discrepancy between the computed gap (32,326) and the observed gap (32,324), and the 11-page discrepancy on min. Reason: watermarks are computed once by setup_per_zone_wmarks() and recomputed only when min_free_kbytes, watermark_scale_factor, or memory hotplug change them, while the managed count printed by /proc/zoneinfo drifts afterwards (late memblock frees, driver reservations). The mismatch is ~0.006% and consistent with that explanation, but it was not proven by instrumenting the boot. To resolve: write min_free_kbytes back to itself (sysctl -w vm.min_free_kbytes=$(sysctl -n vm.min_free_kbytes)) to force recomputation and re-read /proc/zoneinfo. uncertain
There is a fourth watermark in the list — WMARK_PROMO, one tmp above high — which exists for NUMA memory tiering: when sysctl_numa_balancing_mode has NUMA_BALANCING_MEMORY_TIERING set, pgdat_balanced() uses promo_wmark_pages() instead of high_wmark_pages() so that a fast tier keeps extra headroom for pages being promoted up from a slow tier (mm/vmscan.c line 6659). On this box it shows as promo 10 for the DMA zone. See NUMA Memory Tiering.
Here is the ladder those numbers describe. Mermaid has no good primitive for “a bar with labelled thresholds”, so this one is an ASCII box diagram in a fenced block:
Normal zone: 32,326,603 managed pages (123.3 GiB)
free pages
^
| ................................................... plenty free: fast path only
|
81,317 +--- WMARK_HIGH ------------------------------------- kswapd stops here and sleeps
| ^
| | kswapd's working band (32,324 pages = 126 MiB)
| v
48,993 +--- WMARK_LOW -------------------------------------- allocation wakes kswapd here
| ^
| | the reserve kswapd is racing to defend (32,324 pages)
| v
16,669 +--- WMARK_MIN -------------------------------------- allocations now do DIRECT RECLAIM
| ^
| | emergency reserve: only ALLOC_HIGH / ALLOC_HARDER /
| | ALLOC_NO_WATERMARKS (PF_MEMALLOC) callers may enter
| v
0 +--- exhausted --------------------------------------- OOM
The free-page ladder for one zone, with the measured watermarks. What it shows: three thresholds carving free memory into four bands, and which actor is responsible in each. The insight to take: the distance between low and min is the entire latency budget — it is the amount of memory kswapd gets to burn through before an application is forced to stall. That distance is exactly watermark_scale_factor × zone size, which is why raising watermark_scale_factor is the standard fix for a workload that allocates in bursts faster than kswapd can refill: it widens the band, buying kswapd more time. The kernel documentation says so directly — “a high rate of threads entering direct reclaim (allocstall) … can indicate that the number of free pages kswapd maintains for latency reasons is too small for the allocation bursts occurring in the system.”
What “Reclaimable” Means
The kernel’s own definition is blunt: “The process of freeing the reclaimable physical memory pages and repurposing them is called (surprise!) reclaim” (Concepts overview). A page is reclaimable if its current contents can be reconstructed elsewhere — either because the data already lives on a backing store, or because the kernel can write it there first.
flowchart TB
ALL["All physical pages"]
ALL --> EV["Evictable<br/>(on an LRU)"]
ALL --> SLAB["Kernel slab caches<br/>(not on any LRU)"]
ALL --> UNEV["Unevictable LRU<br/>mlock(), ramfs, SHM_LOCK"]
ALL --> PINNED["Structurally unreclaimable<br/>page tables, kernel stacks,<br/>get_user_pages pins, DMA buffers"]
EV --> FILE["File-backed<br/>(page cache)"]
EV --> ANON["Anonymous<br/>(heap, stack, MAP_ANONYMOUS)"]
FILE --> CLEAN["Clean: cost = 0 I/O<br/>just unhook and free"]
FILE --> DIRTY["Dirty: cost = 1 writeback<br/>then free"]
ANON --> SWAPPED["Swap configured:<br/>cost = 1 swap write"]
ANON --> NOSWAP["No swap:<br/>UNRECLAIMABLE"]
SLAB --> SHRINK["Shrinkers<br/>dentry, inode, per-fs caches"]
CLEAN -.->|"cheapest"| RANK["Cost ranking<br/>drives get_scan_count()"]
DIRTY -.-> RANK
SWAPPED -.->|"most expensive"| RANK
The reclaimability taxonomy. What it shows: the four-way split of physical memory by how (and whether) it can be reclaimed, and the cost ordering among the reclaimable classes. The insight to take: the two branches that terminate in “unreclaimable” are the ones that cause incidents. A box with no swap has an entire class of memory it structurally cannot free, so growth in anonymous memory marches directly toward the OOM killer with the page cache shrinking to nothing on the way; and long-term get_user_pages() pins (RDMA, GPU, io_uring registered buffers) make pages that are neither reclaimable nor migratable, which also breaks compaction.
File-backed page cache. Pages read from a file are “put into the page cache to avoid expensive disk access on the subsequent reads” (Concepts overview). These are the easy case. A clean page-cache page (one that matches what is on disk) can be freed instantly — reclaim just unhooks it from the page cache and returns the frame to the buddy allocator; if the file is read again later, the page is simply re-fetched. A dirty page-cache page (modified since it was read) must first be written back to its filesystem, which is slower and depends on the block layer; reclaim either kicks the writeback threads and moves on, or in tight situations waits. See Dirty Pages and Writeback for the writeback machinery and The Page Cache for the cache itself.
Anonymous memory. Anonymous memory “represent[s] memory that is not backed by a filesystem” (Concepts overview) — a process’s heap, stack, and MAP_ANONYMOUS mappings. There is no file to drop it back to, so the only way to reclaim an anonymous page is to write it to a swap area first (see Linux Swap Subsystem). If there is no swap configured, anonymous pages are effectively unreclaimable — they pin RAM until the process frees them or is killed. The code says this explicitly: get_scan_count() opens with if (!sc->may_swap || !can_reclaim_anon_pages(memcg, pgdat->node_id, sc)) { scan_balance = SCAN_FILE; goto out; } (mm/vmscan.c line 2383) — with no swap, the anon lists are never scanned at all. This asymmetry is the single most important fact about reclaim balance and is the subject of Swappiness and Reclaim Balance and Anonymous vs File-Backed Memory.
Slab and other kernel caches. Much kernel memory is not in page form at all but in slab caches of small objects — directory-entry caches (dentry), inode caches, and dozens of others. These cannot sit on the page LRU, so the kernel reclaims them through shrinkers, covered in its own section below.
A fourth category is explicitly not reclaimable: pages locked into RAM with mlock(), and certain kernel allocations, live on the unevictable LRU and are skipped by the scanner entirely — see The Unevictable LRU and mlock. On the measurement box, nr_unevictable is 245,557 pages (≈960 MiB), most of it locked by the display server and the browser sandbox.
The Two Triggers — Background vs Direct
Reclaim is driven by those watermarks. The kernel documentation states the two triggers precisely:
“an allocation request will awaken the kswapd daemon. It will asynchronously scan memory pages and either just free them if the data they contain is available elsewhere, or evict to the backing storage device” — this is the low-watermark, background path. And: “an allocation will trigger direct reclaim. In this case allocation is stalled until enough memory pages are reclaimed to satisfy the request” — the min-watermark, synchronous path (Concepts overview).
sequenceDiagram
participant App as Allocating task
participant Alloc as Page allocator
participant KS as kswapd0 (per NUMA node)
participant Scan as balance_pgdat / shrink_node
participant IO as Writeback + swap
App->>Alloc: alloc_pages(GFP_KERNEL, 0)
Alloc->>Alloc: get_page_from_freelist(WMARK_LOW)
Note over Alloc: free < low
Alloc->>KS: wake_all_kswapds()
Alloc-->>App: page from the low..min reserve (no stall)
KS->>Scan: balance_pgdat(order, highest_zoneidx)
loop priority 12 down to 0, until pgdat_balanced()
Scan->>Scan: shrink_node(): shrinkers, then LRU
Scan->>IO: writeback dirty file pages / swap out anon
Scan->>Scan: kswapd_shrink_node(): reclaim SWAP_CLUSTER_MAX at a time
end
Note over Scan: pgdat_balanced() -> any zone at WMARK_HIGH
Scan-->>KS: done
KS->>KS: kswapd_try_to_sleep() -> back on kswapd_wait
rect rgb(255, 235, 235)
Note over App,IO: If allocation outruns kswapd
App->>Alloc: alloc_pages(...) again
Alloc->>Alloc: free < min, no reserve left
Alloc->>Scan: __alloc_pages_direct_reclaim() IN THE TASK'S OWN CONTEXT
Scan->>IO: may block on writeback / swap I/O
Note over App: PSI "some" and possibly "full" memory stall accrues here
Scan-->>Alloc: nr_reclaimed
Alloc-->>App: page (finally) — the latency spike
end
The kswapd wakeup → scan → shrink cycle, and its failure mode. What it shows: in the healthy case (top) the allocating task never waits: it takes a page from the low..min reserve while kswapd refills that reserve behind it. In the unhealthy case (red) the reserve is gone and the allocating task performs the same scanning work itself, inline, with the writeback and swap I/O on its own critical path. The insight to take: the two paths run the same code — shrink_node() — so the difference is not in what gets reclaimed but in who pays for it in wall-clock time. That is why pgscan_direct vs pgscan_kswapd is the single most diagnostic ratio in /proc/vmstat.
kswapd — background reclaim. There is one kswapd kernel thread per NUMA node (kswapd0, kswapd1, …). When an allocation drops free memory below a zone’s low watermark, the buddy allocator wakes the node’s kswapd, which loops in balance_pgdat() (mm/vmscan.c line 6832) until pgdat_balanced() returns true. pgdat_balanced() is more forgiving than it sounds: it walks zones bottom-up and returns true as soon as any managed zone up to highest_zoneidx meets its high watermark — not all of them. Full mechanism in kswapd and Background Reclaim.
Direct reclaim — synchronous reclaim. If allocation pressure outruns kswapd and free memory falls to the min watermark, the allocating task performs reclaim itself, in its own context, before the allocation returns. This stalls the application — the malloc() or page fault that triggered it blocks while the kernel frees pages. Direct reclaim is the proximate cause of the latency spikes people describe as “the box went unresponsive under memory pressure.” The escalation rules, the priority loop, and how direct reclaim decides to give up are covered in Direct Reclaim.
There is a further brake that is easy to miss. throttle_direct_reclaim() calls allow_direct_reclaim() (mm/vmscan.c line 6349), which computes pfmemalloc_reserve as the sum of min_wmark_pages() over the node’s zones up to ZONE_NORMAL, and throttles the caller if free_pages <= pfmemalloc_reserve / 2. A throttled direct reclaimer sleeps on pgdat->pfmemalloc_wait for up to HZ and lets kswapd work instead — the rationale in the source comment being network-backed storage, where a swap write over NFS itself needs an allocation. The escape valve is the first line of the function: if (pgdat->kswapd_failures >= MAX_RECLAIM_RETRIES) return true; — after 16 consecutive failed kswapd passes the kernel stops throttling, because kswapd is evidently not going to help and blocking everyone would just deadlock.
Proactive / user-driven reclaim. Writing a byte count to a cgroup’s memory.reclaim file asks the kernel to reclaim from that group on demand, independent of watermarks (cgroup-v2.rst). At 6.12 the file is a nested-keyed file accepting a swappiness= key — echo "1G swappiness=0" > memory.reclaim reclaims a gigabyte without touching anonymous memory. The kernel plumbs this through sc->proactive_swappiness, which sc_swappiness() prefers over the cgroup or global value (mm/vmscan.c line 246). The documentation is careful to note that “the proactive reclaim (triggered by this interface) is not meant to indicate memory pressure on the memory cgroup,” so socket-memory balancing is deliberately not exercised — a subtlety that matters if you build a userspace memory manager on it. See Per-cgroup Reclaim and Memory Pressure.
How Victims Are Selected — The Classic Active/Inactive LRU
Reclaim does not pick pages at random; it approximates least-recently-used eviction so that the pages it frees are the ones least likely to be needed soon. The classic Linux implementation maintains a set of LRU lists per memory cgroup per node (a struct lruvec): an active and an inactive list, split again into anonymous and file halves, plus one unevictable list. That is five lists per lruvec.
stateDiagram-v2
[*] --> InactiveFile: page-cache read (read-ahead,<br/>first touch of a file page)
[*] --> InactiveAnon: anonymous fault (heap/stack,<br/>MAP_ANONYMOUS) — inactive since v5.9
InactiveFile --> ActiveFile: second reference while inactive<br/>(PG_referenced already set)
InactiveFile --> InactiveFile: first reference<br/>(set PG_referenced, stay put)
ActiveFile --> InactiveFile: shrink_active_list()<br/>demotion under pressure
InactiveFile --> Freed: shrink_inactive_list()<br/>clean -> drop, dirty -> writeback
InactiveAnon --> ActiveAnon: soft fault / second reference<br/>while still inactive
ActiveAnon --> InactiveAnon: shrink_active_list()<br/>demotion under pressure
InactiveAnon --> Freed: swap slot allocated,<br/>page written to swap
InactiveFile --> Unevictable: mlock() / SHM_LOCK
InactiveAnon --> Unevictable: mlock()
Unevictable --> InactiveFile: munlock()
Freed --> [*]: back to the buddy allocator
Freed --> Refault: faulted back in soon after<br/>(workingset_refault_* counter)
Refault --> ActiveFile: shadow entry says it was<br/>recently evicted -> activate directly
Refault --> ActiveAnon: anon shadow entries too,<br/>also since v5.9
Folio state in the classic two-list LRU, as of v6.12. What it shows: the lifecycle of a page across the active/inactive split — both entry points land on an inactive list, the demotion done by shrink_active_list(), and the refault edge on the bottom right. The insight to take: the shared entry point encodes a policy of universal suspicion — every newly arrived page, file or anonymous, is assumed to be a streaming one-shot until it proves otherwise by being referenced a second time. And the Refault state is not decoration: when a page is evicted the kernel leaves a shadow entry in the page cache recording its eviction timestamp, so that if the page comes back quickly the kernel knows the inactive list is too small and reactivates it immediately. That feedback is what the workingset_refault_file / workingset_refault_anon counters measure — and the existence of the _anon half of that pair is itself evidence of the change described next.
Correction — anonymous pages have started on the inactive list since Linux 5.9
An earlier revision of this note (and a great deal of still-circulating documentation, including textbooks and blog posts) states that a freshly faulted anonymous page is placed on the active anon list while file pages start inactive. That was true for eighteen years and is no longer true. Joonsoo Kim’s “workingset protection for anonymous pages” series changed it: anonymous pages are now faulted onto the inactive list like file pages, and refault (shadow-entry) tracking was extended to cover them (LWN, “Working-set protection for anonymous pages”, 2020-03-19).
Pinned by existence check against the source tree rather than trusted from the article, which predicted the merge only vaguely: mm/swap.c defines lru_cache_add_active_or_unevictable() — whose body calls SetPageActive(page) — at the v5.7 and v5.8 tags, and defines lru_cache_add_inactive_or_unevictable() with no such call at v5.9 and v5.10. The change landed in 5.9. At v6.12 the only folio_set_active() in folio_add_lru() is guarded by if (lru_gen_enabled() && ... lru_gen_in_fault() ...) — i.e. it applies to MGLRU only, which is a genuinely different policy and is discussed below. Corbet quotes Andrew Morton’s reaction to the 400% improvement on a virtual-memory scalability test — “One wonders why on earth we weren’t doing these things in the first place?” — to which Kim replied with the 2002 patches that introduced the old behaviour, written by Morton himself.
The motivation is worth internalising because it is the same argument as the file case: a process that faults in a large anonymous region it will touch only once — a one-shot buffer, a calloc() that is written and dropped — used to shove genuinely hot pages off the active list on its way past. Starting anonymous pages inactive makes that traffic cheap to undo.
The second-chance rule (“referenced while inactive → promote”) is the classic CLOCK approximation of true LRU; the abstract policy it implements is the subject of Least Recently Used Cache, and the data structure itself of The LRU Lists.
Reclaim’s own aggressiveness is controlled by a priority counter that starts at DEF_PRIORITY and counts down:
/* include/linux/mmzone.h, v6.12 *//* ... A value of 12 for DEF_PRIORITY implies that we will scan 1/4096th of the ... */#define DEF_PRIORITY 12
Priority
Fraction of each LRU list scanned per pass
Meaning
12 (DEF_PRIORITY)
1/4096
First, gentlest pass. kswapd starts here every wakeup.
8
1/256
Pressure is real; sc->may_writepage may now be allowed.
4
1/16
sc->priority < DEF_PRIORITY - 2 unlocks writeback from reclaim context.
1
1/2
Nearly desperate.
0
whole list
if (!sc->priority && swappiness) scan_balance = SCAN_EQUAL — stop being clever, scan anon and file equally. Note the second conjunct: at swappiness = 0 even priority 0 does not unlock equal scanning.
The reclaim priority ladder. What it shows: each retry halves-then-halves again the denominator, so the scan target grows exponentially — lruvec_size >> sc->priority is the literal expression in get_scan_count(). The insight to take: reclaim’s escalation is not a boolean “trying harder”, it is twelve doublings of scan aggression, and several behavioural switches are keyed to specific priority thresholds. When you see reclaim doing something it “shouldn’t” — swapping despite swappiness=0, writing back from the reclaim thread — check whether the priority has fallen far enough to unlock it.
MGLRU — The Modern Default, and Why the Two-List Story Is Incomplete
If you only know the active/inactive LRU, you are describing a code path that may not be running on your machine.
Multi-Gen LRU (MGLRU) is a from-scratch replacement for the scanner described above. It was merged in Linux 6.1 and is present in 6.12. Verified by existence check against the source tree: mm/vmscan.c contains zero occurrences of lru_gen at the v5.19 and v6.0 tags and 161 at v6.1. When it is enabled, shrink_lruvec() short-circuits — if (lru_gen_enabled() && !root_reclaim(sc)) { lru_gen_shrink_lruvec(lruvec, sc); return; } (mm/vmscan.c line 5680) — and get_scan_count(), the active/inactive lists, and the whole two-hand clock above are simply not used.
Is it on?
This is the question that actually matters, and it has a precise answer.
Upstream Kconfig, both 6.12 and 6.18:CONFIG_LRU_GEN (“Multi-Gen LRU”) has no default y, and CONFIG_LRU_GEN_ENABLED (“Enable by default”) likewise has no default y (mm/Kconfig lines 1236–1250 at v6.12; lines 1296–1310 at v6.18). Upstream, MGLRU is opt-in at build time and off by default.
Distribution kernels are a different story. On the Fedora 44 measurement box, /boot/config-7.1.8-200.fc44.x86_64 contains CONFIG_LRU_GEN=y, CONFIG_LRU_GEN_ENABLED=y, and CONFIG_LRU_GEN_WALKS_MMU=y. cat /sys/kernel/mm/lru_gen/enabled returns 0x0007. On that machine MGLRU is the reclaim algorithm, and the classic LRU is dead code.
To check any box:
# Is MGLRU compiled in and running?cat /sys/kernel/mm/lru_gen/enabled # missing file -> CONFIG_LRU_GEN=n # 0x0000 -> compiled in, switched off # 0x0007 -> fully on (this box)zgrep -E 'CONFIG_LRU_GEN' /proc/config.gz 2>/dev/null \ || grep -E 'CONFIG_LRU_GEN' /boot/config-$(uname -r)
The value is a bitmask, not a boolean, and the bits are a stable ABI (multigen_lru admin guide):
Bit
Enum (include/linux/mmzone.h)
Component
0x0001
LRU_GEN_CORE
The main switch. Off ⇒ the classic active/inactive LRU runs.
0x0002
LRU_GEN_MM_WALK
Clear the accessed bit in leaf PTEs in large batches (needs arch_has_hw_pte_young()). Off ⇒ minor degradation on contiguously-mapped hot pages.
0x0004
LRU_GEN_NONLEAF_YOUNG
Clear the accessed bit in non-leaf (PMD) entries too (needs arch_has_hw_nonleaf_pmd_young()). Documented as verified only on Intel and AMD x86.
The lru_gen/enabled bitmask. What it shows: MGLRU is three independently-switchable capabilities, implemented as a DEFINE_STATIC_KEY_ARRAY of static branches (mm/vmscan.c line 2584) so that a disabled component costs a patched-out nop, not a test. The insight:0x0007 is “all three”; a value like 0x0005 means the leaf-batching optimisation was deliberately switched off, most likely to investigate mmap_lock contention, which the documentation names as the theoretical downside.
Uncertain
Verify: whether distributions other than Fedora 44 (Debian, Ubuntu, RHEL, SUSE, Arch) ship CONFIG_LRU_GEN_ENABLED=y. Reason: the Debian (salsa.debian.org) and Arch (gitlab.archlinux.org) packaging config paths both returned HTTP 404 during this task, so only the local Fedora config was verified. Do not generalise “modern distros default to MGLRU” from a single data point. To resolve: read /boot/config-$(uname -r) on each target distribution, or the distro’s packaging repository once the correct path is found. uncertain
The generation model
MGLRU replaces “active vs inactive” with a sliding window of numbered generations. The design is documented upstream in Documentation/mm/multigen_lru.rst, which is the primary source for everything in this subsection.
stateDiagram-v2
direction LR
state "Generation window (min_seq .. max_seq)" as W {
G3: max_seq<br/>YOUNGEST<br/>= newly faulted +<br/>promoted-by-aging
G2: max_seq-1<br/>not fully aged<br/>(counts as ACTIVE)
G1: min_seq+1<br/>cooling
G0: min_seq<br/>OLDEST<br/>-> eviction candidates
G3 --> G2: aging increments max_seq<br/>(all pages shift one bucket older)
G2 --> G1: aging increments max_seq
G1 --> G0: aging increments max_seq
}
[*] --> G3: page faulted in
G0 --> [*]: evict_folios()<br/>clean file -> drop<br/>dirty -> writeback<br/>anon -> swap
G0 --> G3: aging found a young PTE<br/>(accessed via page table)
G0 --> G1: accessed via file descriptor<br/>(promote ONE generation only)
G0 --> G0: min_seq incremented when<br/>this bucket empties
The MGLRU generation window. What it shows: at most MAX_NR_GENS = 4 and at least MIN_NR_GENS = 2 generations exist at once (include/linux/mmzone.h lines 359–360), indexed by two monotonically-increasing counters: lrugen->max_seq (the youngest, shared by anon and file “as they are aged on an equal footing”) and lrugen->min_seq[] (the oldest, tracked separately for anon and file because clean file pages can be evicted regardless of swap constraints). The insight to take: ageing happens by incrementing max_seq, which reclassifies every page at once without touching any of them — a generation is a bucket, and “getting older” is the bucket label moving, not the page moving. Compare the classic LRU, where demotion means physically splicing folios between lists under the LRU lock. Note the two different promotion edges: a page found young via a page-table walk jumps straight to max_seq, whereas one referenced through a file descriptor advances only one generation. Jonathan Corbet’s LSFMM report records Yu Zhao’s reason: file-descriptor accesses are all visible to the kernel, while page-table accesses “can only be observed once on every scan,” so the latter deserve stronger protection (LWN, “Merging the multi-generational LRU”, 2022-05-12).
Two further mechanics complete the picture.
Tiers. Within a generation, pages are further ranked by tier: “a page accessed N times through file descriptors is in tier order_base_2(N)”, with MAX_NR_TIERS = 4. Crucially, tiers have no dedicated lists — tier membership is encoded in folio->flags (PG_referenced, plus PG_workingset for tier ≥ 2, plus MAX_NR_TIERS-2 spare bits). The source comment states the payoff exactly: “in contrast to moving across generations which requires the LRU lock, moving across tiers only involves atomic operations on folio->flags and therefore has a negligible cost in the buffered access path.” That is the whole reason tiers exist: read() on a cached file must not take a lock.
The PID controller. MGLRU decides which type (anon or file) and which tiers to evict using “a feedback loop modeled after the Proportional-Integral-Derivative (PID) controller [that] monitors refaults over all the tiers from anon and file types.” It compares refaulted / (evicted + protected) for the first tier against the rest, using the first tier — “single-use unmapped clean pages, which are the best bet” — as the baseline. The controller uses generations rather than the wall clock as its time domain, because “a CPU can scan pages at different rates under varying memory pressure.” The per-tier counters live in struct lru_gen_folio as atomic_long_t evicted[NR_HIST_GENS][ANON_AND_FILE][MAX_NR_TIERS] and the matching refaulted[].
flowchart LR
subgraph AGING["AGING (producer) — try_to_inc_max_seq()"]
MML["Walk lruvec_memcg()->mm_list<br/>via walk_page_range()"]
BF["Bloom filter:<br/>which PMDs are worth walking?"]
YOUNG["Found a young PTE:<br/>clear accessed bit,<br/>set gen = (max_seq "]
SCAN["scan_folios() from<br/>folios[min_seq ` in the diagram nodes above: that is a literal percent sign doubled to survive mermaid's label parsing; in the source the expression is `max_seq % MAX_NR_GENS`.
### Why page-table walking instead of rmap scanning
This is MGLRU's central design bet and worth stating plainly. To decide whether a page is hot, the kernel must read the hardware **accessed bit**, which lives in the page table entry (PTE), not in the page. The classic LRU works from a physical page and must therefore run a **reverse-map (rmap) walk** to find every PTE mapping it. The upstream design document is blunt about the cost: "searching the rmap for PTEs mapping each page on an LRU list (to test and clear the accessed bit) can be expensive because pages from different VMAs (PA space) are not cache friendly to the rmap (VA space). For workloads mostly using mapped pages, searching the rmap can incur the highest CPU cost in the reclaim path."
MGLRU inverts the direction: it iterates each memcg's `mm_struct` list and calls `walk_page_range()`, sweeping *all* the young PTEs in one address space in one cache-friendly pass. Corbet's LSFMM write-up records the same argument from Zhao: "the LRU walk in current kernels is constantly having to switch between different process's page tables, which creates cache misses and slows things down."
The two methods are not mutually exclusive — the design document says "the key is to optimize both methods and use them in combination," and `lru_gen_look_around()` is exactly that combination: an rmap-driven eviction that opportunistically scans neighbouring PTEs.
### Thrashing prevention: `min_ttl_ms`
MGLRU ships one tunable aimed squarely at desktop responsiveness. Writing `N` to `/sys/kernel/mm/lru_gen/min_ttl_ms` "prevent[s] the working set of `N` milliseconds from getting evicted. The OOM killer is triggered if this working set cannot be kept in memory." Each generation is timestamped at birth (`lrugen->timestamps[MAX_NR_GENS]`), and an `lruvec` whose oldest generation was born within `min_ttl_ms` is protected from eviction.
The documentation's framing is unusually candid about who it is for: "personal computers are more sensitive to thrashing because it can cause janks (lags when rendering UI) ... The multi-gen LRU offers thrashing prevention to the majority of laptop and desktop users who do not have `oomd`." It suggests `N=1000` based on "the average human detectable lag (~100ms)", and warns that "larger values like `N=3000` make janks less noticeable at the risk of premature OOM kills." The default is `0` (disabled) — confirmed on the measurement box, where `cat /sys/kernel/mm/lru_gen/min_ttl_ms` returns `0`.
This is a genuinely different philosophy from the classic LRU, which has no notion of "protect the last N milliseconds of working set" and no wire to the OOM killer other than reclaim failure. It is a **deliberate trade of availability for latency**: MGLRU will kill something rather than let the UI stutter.
### Working-set estimation and generation-granular proactive reclaim
Because generations are time-stamped buckets, reading them gives a **working-set histogram** for free. `/sys/kernel/debug/lru_gen` (requires `CONFIG_DEBUG_FS` and root) returns, per memcg and node, one line per generation with `age_in_ms`, `nr_anon_pages`, and `nr_file_pages`. The intended consumer is a datacentre job scheduler doing bin packing: it can rank servers by "how much memory here is colder than 30 seconds" before placing a job.
The same file accepts commands. `- memcg_id node_id min_gen_nr [swappiness [nr_to_reclaim]]` evicts generations up to `min_gen_nr` — proactive reclaim aimed at a *specific coldness*, which `memory.reclaim` (a byte count) cannot express. The documentation notes the constraint that `min_gen_nr` must be less than `max_gen_nr - 1`, "since `max_gen_nr` and `max_gen_nr-1` are not fully aged (equivalent to the active list) and therefore cannot be evicted" — which is precisely why `MIN_NR_GENS` is 2.
### The merge debate, and what actually happened
MGLRU's status is not an accident; it is the outcome of a documented argument. At the 2022 Linux Storage, Filesystem, Memory-Management and BPF Summit, a full session was spent on whether to merge it and whether to enable it by default ([LWN](https://lwn.net/Articles/894859/)). The positions on record:
- **Michal Hocko** proposed merging it alongside the existing LRU, opt-in: "merging is the only way to find out how well MGLRU really works across workloads, he said, but he was nervous about switching over to it by default." He also warned that "maintaining two LRUs will have a huge cost for as long as it lasts."
- **Mel Gorman** argued the opposite — "if this code is merged, it should be enabled by default" — reasoning from the transparent-huge-pages precedent: THP "was enabled by default and 'set everything on fire'. It took three years to sort it all out, but without having been enabled for all users, it would never have been fixed."
- **Matthew Wilcox** took the counter-lesson from the same precedent: "enabling transparent huge pages by default was actually the wrong thing to do. Documentation lives forever, he said, and vendors are still telling users to disable transparent huge pages even though the problems have long since been fixed."
- **Andrew Morton** worried about maintainability rather than correctness: adding MGLRU "takes developers who have worked on memory management for decades and 'turns them into new hires'". He asked for internal documentation — which is why `Documentation/mm/multigen_lru.rst` exists and is unusually good.
- **Johannes Weiner** suggested the compromise that in fact happened: "some distributors would turn MGLRU on even if it were disabled by default; he mentioned Arch in particular. That might be a good way to avoid a 'total flag day'." He asked for "a set time frame to enable it — a maximum of a couple of development cycles."
The verifiable outcome, four years on: MGLRU merged in 6.1, and `CONFIG_LRU_GEN_ENABLED` still carries **no `default y`** in either 6.12 or 6.18. Weiner's "couple of development cycles" did not happen upstream; his prediction about distributions did. The Fedora 44 config above is that prediction confirmed. The consequence for anyone reading this note is the one stated at the top of this section: *upstream default* and *what is running on your machine* are different questions, and only the second one matters when you are debugging.
Full treatment in [[Multi-Generational LRU]].
---
## Shrinkers — Reclaiming Memory That Is Not On Any LRU
Everything above concerns **pages**: things with a `struct folio`, a place on an LRU list, and a well-defined backing store. But a large fraction of kernel memory is not shaped like that. Directory-entry caches (`dentry`), inode caches, filesystem extent maps, the [[The Slab Allocator and SLUB|slab]] caches behind dozens of subsystems, GPU shrinkable buffers, and even the [[eBPF Maps|BPF]] and networking caches hold reclaimable data in *objects*, not pages. On the measurement box `nr_slab_reclaimable` is **407,047 pages ≈ 1.55 GiB** — memory that the LRU scanner cannot see at all.
The kernel's answer is the **shrinker**: a callback interface by which any subsystem holding a reclaimable cache registers a pair of functions and lets the reclaim core apply proportional pressure to it. The contract is defined in `include/linux/shrinker.h` at `v6.12`, and it is deliberately minimal:
```c
struct shrinker {
unsigned long (*count_objects)(struct shrinker *, struct shrink_control *sc);
unsigned long (*scan_objects)(struct shrinker *, struct shrink_control *sc);
long batch; /* reclaim batch size, 0 = default */
int seeks; /* seeks to recreate an obj */
unsigned flags;
/* ... */
};
#define DEFAULT_SEEKS 2 /* A good number if you don't know better. */
Symbol by symbol: count_objects answers “how many freeable items do you hold right now?” and must not block or take contended locks — the header is explicit that “no deadlock checks should be done during the count callback.” scan_objects is the one that actually frees, and it is “only called if count_objects returned a non-zero value.” seeks is the cache’s declared cost of regeneration, denominated in disk seeks: how much I/O would be needed to rebuild one object if it were thrown away. batch is the granularity of one scan_objects call, defaulting to SHRINK_BATCH, which is 128 (mm/shrinker.c line 369).
Two sentinel return values carry meaning that a plain count cannot. SHRINK_EMPTY (~0UL - 1) from count_objects means “I genuinely hold nothing”, as distinct from 0, which means “I cannot tell you, or please skip me this round.” SHRINK_STOP (~0UL) from scan_objects means “progress is impossible right now due to potential deadlocks” — and, per the header, “if SHRINK_STOP is returned, then no further attempts to call the scan_objects will be made from the current reclaim context.”
flowchart TB
SN["shrink_node()<br/>mm/vmscan.c"]
SS["shrink_slab(gfp_mask, nid, memcg, sc->priority)<br/>mm/vmscan.c line 4824"]
SN --> SS
SS --> MEMCG{"memcg-aware<br/>shrinkers?"}
MEMCG -->|"yes, via shrinker_info bitmap"| SSM["shrink_slab_memcg()<br/>walk only shrinkers with<br/>objects charged to this memcg"]
MEMCG -->|"global / root reclaim"| SSG["walk the global shrinker_list"]
SSM --> DSS
SSG --> DSS["do_shrink_slab()<br/>mm/shrinker.c line 371"]
DSS --> C["freeable = count_objects()"]
C --> Z{"freeable == 0<br/>or SHRINK_EMPTY?"}
Z -->|"yes"| SKIP["return, touch nothing"]
Z -->|"no"| DEF["nr = xchg_nr_deferred()<br/>claim the deferred backlog"]
DEF --> DELTA["delta = (freeable >> priority) * 4 / seeks<br/>total_scan = (nr >> priority) + delta<br/>capped at 2 * freeable"]
DELTA --> LOOP["while total_scan >= batch_size:<br/>scan_objects(min(batch_size, total_scan))"]
LOOP -->|"SHRINK_STOP"| BAIL["break out early"]
LOOP --> ACC["freed += ret<br/>count_vm_events(SLABS_SCANNED)"]
ACC --> LOOP
BAIL --> CARRY
LOOP --> CARRY["next_deferred = max(nr + delta - scanned, 0)<br/>capped at 2 * freeable<br/>add_nr_deferred()"]
The shrinker invocation path, shrink_node() → shrink_slab() → do_shrink_slab() at v6.12. What it shows: shrinkers are not asked to “free N bytes”; they are asked to scan a number of objects derived from the same sc->priority counter that drives LRU scanning, and they report back how many they actually freed. The nr_deferred accumulator on the left and right of the loop is the memory of work that could not be done — a shrinker that returned SHRINK_STOP because it could not take a lock does not simply lose that pressure, it banks it for the next caller. The insight to take: slab reclaim and page reclaim are pressure-matched by construction. shrink_slab() is passed sc->priority, so as reclaim escalates through the twelve priority levels described above, the fraction of every registered cache that gets scanned escalates in lockstep with the fraction of every LRU list. Nobody has to tune the balance between “shrink the dentry cache” and “evict page cache” — they are the same exponent.
The scan-count formula, walked
The single most consequential line in do_shrink_slab() is the one that turns a priority into a scan target:
if (shrinker->seeks) { delta = freeable >> priority; delta *= 4; do_div(delta, shrinker->seeks);} else { /* These objects don't require any IO to create. Trim * them aggressively under memory pressure to keep * them from causing refetches in the IO caches. */ delta = freeable / 2;}total_scan = nr >> priority;total_scan += delta;total_scan = min(total_scan, (2 * freeable));
freeable is what count_objects() just reported. priority is the reclaim priority, 12 down to 0. freeable >> priority is therefore “the same fraction of this cache that the LRU scanner is taking of its lists” — at DEF_PRIORITY that is 1/4096. The multiply by 4 and divide by seeks is the cost weighting: with DEFAULT_SEEKS == 2 the factor is exactly 2, so a default shrinker is scanned at 1/2048 of its contents per pass — twice as hard as an LRU list. That is the kernel’s standing judgement that a dentry is cheaper to lose than a page of cached file data.
A shrinker that sets seeks = 0 opts into the branch on the right: delta = freeable / 2, i.e. half the entire cache in one pass, at every priority. The comment explains it — these are objects “[that] don’t require any IO to create”, so keeping them is nearly worthless under pressure. This is not a hypothetical; it is how caches that are pure CPU-time memoisation declare themselves.
total_scan then adds nr >> priority, the deferred backlog, and the whole thing is clamped to 2 * freeable. That clamp is what stops a shrinker that has been repeatedly unable to make progress from eventually being asked to scan its own cache a hundred times over.
Finally, the loop condition is worth reading closely, because it encodes an escape from a real failure:
while (total_scan >= batch_size || total_scan >= freeable) {
The || total_scan >= freeable clause exists, per the comment above it, to “detect the ‘tight on memory’ situations”: normally a shrinker holding fewer than batch_size objects would never be scanned at all, and “we can end up failing allocations although there are plenty of reclaimable objects spread over several slabs with usage less than the batch_size.” A thousand filesystems each holding 40 cached dentries add up; without that clause none of them would ever be touched.
vfs_cache_pressure acts on the count, not the scan
The sysctl everyone reaches for when the dentry cache grows large is vm.vfs_cache_pressure, documented as a “percentage value [that] controls the tendency of the kernel to reclaim the memory which is used for caching of directory and inode objects”, defaulting to 100 (vm.rst). What is not documented, and matters, is where it is applied. It is not a term in the formula above. It is applied inside the superblock shrinker’s count callback:
and vfs_pressure_ratio(val) is simply mult_frac(val, sysctl_vfs_cache_pressure, 100) (include/linux/dcache.h line 513). The superblock shrinker lies about its size in proportion to the sysctl. Setting vfs_cache_pressure=1000 does not make reclaim try ten times harder in any direct sense; it makes the shrinker claim to hold ten times as many objects, and every downstream computation — delta, total_scan, the 2 * freeable clamp — inflates accordingly. This is exactly why the documentation warns that “increasing vfs_cache_pressure significantly beyond 100 may have negative performance impact … With vfs_cache_pressure=1000, it will look for ten times more freeable objects than there are.” The scan loop will genuinely walk a list that has already run out, taking locks the whole way.
The symmetric hazard is at the other end: at vfs_cache_pressure=0 the count is always 0, so count_objects returns before scan_objects is ever called, and “the kernel will never reclaim dentries and inodes due to memory pressure and this can easily lead to out-of-memory conditions.” A zero here is not “be gentle”, it is “this cache is exempt from reclaim forever.”
Field
Default
Meaning
Practical consequence
seeks
DEFAULT_SEEKS = 2
Declared I/O cost of regenerating one object
Scanned at 2× the LRU rate for the same priority
seeks = 0
—
“Free to rebuild” opt-out
delta = freeable / 2 — half the cache per pass
batch
SHRINK_BATCH = 128
Objects per scan_objects() call
Superblocks override it to 1024 (fs/super.c line 385)
SHRINKER_NUMA_AWARE
off
Cache is per-node; sc->nid is meaningful
Scanned once per node, not once globally
SHRINKER_MEMCG_AWARE
off
Objects are charged to cgroups
Reachable from shrink_slab_memcg(), so per-cgroup reclaim can hit it
SHRINKER_NONSLAB
off
Memcg-aware but not slab-backed
Only meaningful together with MEMCG_AWARE
The registration knobs a shrinker author actually sets. The insight:seeks is the only tuning dial the cache owner gets, and it is a statement about the cost of being wrong, not about how much memory the cache holds. Everything about how much is negotiated at runtime through count_objects and the priority.
Registration itself changed shape in Linux 6.7, and the old idiom is still widespread in tutorials and in out-of-tree drivers. At v6.12 the interface is a three-call lifecycle — shrinker_alloc(flags, "name-fmt", ...), fill in the callbacks, shrinker_register(shrinker), and later shrinker_free(shrinker) — with a refcount_t, a struct completion done, and an RCU-deferred free, so that unregistration can safely race with an in-flight do_shrink_slab() on another CPU.
The release was pinned by existence check rather than assumed: include/linux/shrinker.h contains register_shrinker( and noshrinker_alloc at the v6.5 and v6.6 tags, and the reverse — shrinker_alloc present, register_shrinker( absent, plus the new refcount_t refcount field — from v6.7 onward through v6.8, v6.9 and v6.12. Code that declares a static struct shrinker and calls register_shrinker() therefore predates 6.7 and will not compile against a current tree.
A fetch that lied about succeeding
The first attempt at the v6.8 header returned HTTP 200 with a 313-byte body reading “429: This request was rate-limited due to too many requests from your network.” Had the check been made on status code alone it would have recorded “shrinker_alloc absent at 6.8” and produced a wrong, confidently-stated regression. Always grep the extracted text, never the exit status — and retry, because the same URL returned the real header seconds later.
Reading Reclaim From /proc/vmstat — A Worked Diagnosis
Everything above is mechanism. This section is the part you actually use at 3 a.m. All the counters below were read from the measurement box in a single sample, and the arithmetic on them is done in full, because the individual numbers mean very little and the ratios mean almost everything.
Context for the sample: Fedora 44, kernel 7.1.8, 125 GiB RAM, 8 GiB of swap, vm.swappiness=10, MGLRU enabled (lru_gen/enabled = 0x0007), uptime 765,394 s ≈ 8.86 days. Every counter in /proc/vmstat is a monotonic since-boot total, so all of these are 8.86-day cumulative figures.
The counters, and exactly where each is incremented
Reading a counter without knowing its increment site is how people talk themselves into wrong conclusions, so here are the sites, all at v6.12.
Counter
Incremented at
What it really counts
pgscan_kswapd / pgscan_direct / pgscan_khugepaged
PGSCAN_KSWAPD + reclaimer_offset(), mm/vmscan.c lines 1949 and 4445
Folios isolated for examination. reclaimer_offset() returns 0 in kswapd, a khugepaged offset in khugepaged, and the direct offset otherwise — so the three are one array indexed by who is running, not by what is scanned.
The same events re-bucketed by page type. The two decompositions cover the same events, which is a free consistency check: on this box pgscan_anon + pgscan_file = 272,900,691 = pgscan_kswapd + pgscan_direct, exactly.
allocstall_<zone>
__count_zid_vm_events(ALLOCSTALL, sc->reclaim_idx, 1) at the top of do_try_to_free_pages(), line 6256
Entries into direct reclaim, bucketed by the allocation’s preferred zone index, not by the zone reclaimed from. Guarded by if (!cgroup_reclaim(sc)), so cgroup-internal reclaim is excluded. The retry: label sits above it, so a reclaim that restarts at initial_priority counts twice.
pgscan_direct_throttle
line 6453
Times a direct reclaimer was put to sleep on pgdat->pfmemalloc_wait instead of being allowed to scan.
pageoutrun
count_vm_event(PAGEOUTRUN) in balance_pgdat(), line 6852
Invocations of balance_pgdat() — i.e. how many times kswapd woke up and did a balancing pass.
Not what the names suggest. After balancing, kswapd naps for schedule_timeout(HZ/10) = 100 ms. If it is woken during that nap (remaining != 0) the low counter fires; if the nap completed but prepare_kswapd_sleep() then said the node is no longer balanced, the high counter fires. Both mean “kswapd did not get to go properly to sleep.”
workingset_refault_file / _anon
mm/workingset.c
A page was faulted back in while its shadow entry was still present — the kernel evicted something it needed again.
Increment sites for the reclaim counters. The insight to take:pgscan_* and pgsteal_* are not two independent measurements, they are the numerator and denominator of an efficiency, and the counters are bucketed twice over — once by who reclaimed and once by what was reclaimed — which lets you cross-check a sample for consistency before you trust it.
Ratio 1 — who is doing the reclaiming, and how well
That is the whole diagnosis in two lines, and it is worse than it first looks. Direct reclaim did 53.4% of all the scanning on this machine but produced only 24.4% of the freed pages.kswapd freed better than nine pages for every ten it looked at; direct reclaim freed barely one in four.
scanned stolen
|=================|===================| |==================|======|
kswapd 127.3M direct 145.6M kswapd 118.0M direct 38.0M
(46.6%) (53.4%) (75.6%) (24.4%)
efficiency: kswapd 92.7% ############################ (9.3 of 10 pages)
direct 26.1% ####### (2.6 of 10 pages)
Scan effort versus yield, split by reclaimer, over 8.86 days on the measurement box. Mermaid has no bar-comparison primitive that reads well at this aspect ratio, so this is an ASCII bar chart in a fenced block. What it shows: the two bars are near-inverses of one another — the majority of the work was done on the side that produced the minority of the result. The insight to take: direct reclaim is not merely slower for the application that pays for it, it is less effective per unit of CPU burned. The reason is structural: kswapd starts every pass at DEF_PRIORITY against a node that is merely below low, so the easy victims — clean file pages — are still plentiful. Direct reclaim runs against a node already picked over by kswapd, so the cheap pages are gone and it spends its scanning on folios that are dirty, mapped, or under writeback and cannot be freed on this pass. A high pgscan_direct therefore compounds: the more you stall, the less each stall achieves.
Ratio 2 — how often the stall happens, and what one stall buys
allocstall_normal + allocstall_movable = 178,618 + 731,738 = 910,356 entries
/ 765,394 s uptime = 1.19 direct-reclaim entries per second
pgsteal_direct / allocstall = 37,981,882 / 910,356 = 41.7 pages ≈ 167 KiB per entry
pgscan_direct / allocstall = 145,622,150 / 910,356 = 160 pages scanned per entry
An average of 1.19 stalls per second, sustained for nearly nine days. Each one scanned about 160 folios to free about 42 — roughly SWAP_CLUSTER_MAX (32) worth of progress, which is exactly what one would expect since that is the batch shrink_node() works in.
The zone split is the part people misread. allocstall_movable is 80.4% of the total, yet this machine’s ZONE_MOVABLE has zero managed pages. There is no contradiction: the counter is indexed by sc->reclaim_idx, which is derived from the allocation’s GFP flags, and GFP_HIGHUSER_MOVABLE — the flag set used for page-cache and anonymous user pages — carries reclaim_idx = ZONE_MOVABLE. So allocstall_movable reads “user-memory allocations that stalled” and allocstall_normal reads “kernel allocations that stalled.” The four-fifths/one-fifth split is a statement about who stalled, not about where memory came from.
pgscan_direct_throttle is 0: no direct reclaimer on this box was ever put to sleep on pfmemalloc_wait. Combined with oom_kill = 1, the picture is a machine under continuous, survivable pressure rather than one repeatedly falling off a cliff.
Ratio 3 — anon versus file, and the swappiness fingerprint
Anonymous memory received 1.39% of all scanning — a direct consequence of vm.swappiness=10, well below the documented default of 60 (vm.rst). The interesting part is the last line: when the kernel did scan anonymous pages it freed 74.7% of them, against only 56.9% for file pages. The anon list was the more productive place to look, and policy sent the scanner to the less productive one 71 times more often.
That is not automatically a misconfiguration — swappiness encodes I/O cost, not yield, and the documentation is explicit that it “define[s] the rough relative IO cost of swapping and filesystem paging.” Freeing an anonymous page costs a random write to swap; freeing a clean file page costs nothing. But it does mean the tuning is doing something, and the corroborating evidence says it is doing too much of it: SwapFree is 139 MiB of 8,191 MiB — the swap device is 98.3% full, while workingset_refault_file is 38,246,455, a quarter of all the file pages ever stolen. The machine is repeatedly evicting file pages it then needs back, while holding anonymous memory it has no room left to swap.
93.6% of kswapd wakeups ended with kswapd being re-woken inside its 100 ms nap. It essentially never reached a proper sleep in nine days: balance, doze, get woken, balance again. That, plus pgscan_direct > pgscan_kswapd, is the textbook signature the documentation describes — allocation bursts outrunning the low..min band — and it is precisely the case vm.watermark_scale_factor exists to fix by widening that band.
The two-command triage
# 1. Is anyone stalling, and is kswapd losing?grep -E 'pgscan_(kswapd|direct)|pgsteal_(kswapd|direct)|allocstall' /proc/vmstat# 2. How much wall-clock time did it actually cost?cat /proc/pressure/memory
The first says whether reclaim is unhealthy. The second says whether you should care — and they disagree more often than you would expect.
The counters MGLRU changes underneath you
One trap deserves calling out, because it makes a monitoring dashboard silently wrong. On this box:
pgdeactivate is exactly zero after nine days of heavy reclaim. It is not broken. PGDEACTIVATE is incremented in only three places: shrink_active_list() (mm/vmscan.c line 2117), which is classic-LRU-only code that MGLRU never executes, and two explicit-deactivation helpers in mm/swap.c reached from madvise(MADV_COLD) and friends, which nothing on this box calls. Meanwhile pgrefill is not zero — but under MGLRU it is incremented at mm/vmscan.c line 4448 counting folios sorted between tiers, which is a different quantity from the classic meaning of “active-list folios examined.”
So a threshold alert written against pgdeactivate on a classic-LRU fleet goes permanently silent the day a distribution flips CONFIG_LRU_GEN_ENABLED=y, and a pgrefill alert keeps firing while measuring something else. This is not a hypothetical annoyance: Kalesh Singh raised exactly this at the 2026 LSFMM+BPF planning discussion, saying the metrics “differ significantly between the two LRU implementations, and that makes life difficult for components like the Android user-space out-of-memory daemon” (LWN, “Reconsidering the multi-generational LRU”, 2026-03-05).
PSI — the counter that measures the thing you actually care about
Every counter above measures work. None of them measures time lost, and that is the number that maps to a latency SLO. Pressure Stall Information (PSI) supplies it (psi.rst):
$ cat /proc/pressure/memory
some avg10=0.00 avg60=0.00 avg300=0.00 total=39566201
full avg10=0.00 avg60=0.00 avg300=0.00 total=36882751
total is in microseconds, cumulative since boot. So this machine lost 39.57 seconds to memory stalls in 8.86 days — 0.0052% of wall-clock time. Nine days of 1.19 stalls per second added up to under forty seconds of actual delay.
The some/full distinction is what makes PSI worth reading. some is time during which at least one runnable task was stalled on memory; full is time during which no task could make progress because they were all stalled. Here full is 36.88 s, or 93.2% of some — when this box stalls, it tends to stall everybody at once, which is the shape of a short, sharp, system-wide event rather than one unlucky process being slow.
This is why PSI belongs next to the vmstat ratios rather than instead of them. The vmstat numbers looked alarming — direct reclaim outscanning kswapd, a stall every second, kswapd never sleeping. PSI says it cost 0.005% of the machine’s time. Both are true: the reclaim subsystem is running hot, and it is also absorbing the load successfully, which is exactly what a healthy machine under a page-cache-heavy workload looks like. Reaching for vm.watermark_scale_factor on the strength of the vmstat numbers alone would have been tuning a system that is not actually hurting. Full treatment in Pressure Stall Information.
Failure Modes and Gotchas
Reclaim fails in a small number of recognisable shapes. Each one below is a real mechanism with a real symptom, and in most cases the mechanism is visible in the source comment that anticipates it.
flowchart TB
P["Memory pressure rises"]
P --> A{"Is anonymous memory<br/>reclaimable at all?"}
A -->|"no swap, or swap full"| F1["FAILURE 1<br/>Page cache collapses to nothing,<br/>then straight to OOM"]
A -->|"yes"| B{"Is the file LRU<br/>large enough to<br/>keep absorbing?"}
B -->|"no: file + free <= high wmark"| F2["FAILURE 2 — the cache trap<br/>tiny thrashing file LRU looks<br/>infinitely attractive<br/>(guarded by sc->file_is_tiny)"]
B -->|"yes"| C{"Are the pages<br/>the scanner finds<br/>actually freeable?"}
C -->|"pinned: GUP, DMA, mlock"| F3["FAILURE 3<br/>Scanner burns CPU on folios<br/>it can never free.<br/>pgscan high, pgsteal flat"]
C -->|"dirty, writeback backlog"| F4["FAILURE 4<br/>Reclaim waits on the block layer.<br/>PSI full spikes"]
C -->|"yes"| D{"Is kswapd keeping<br/>ahead of the burst?"}
D -->|"no"| F5["FAILURE 5<br/>Direct reclaim storm.<br/>pgscan_direct > pgscan_kswapd,<br/>efficiency collapses"]
D -->|"yes"| OK["Healthy: kswapd absorbs it,<br/>PSI stays near zero"]
The five failure shapes, arranged as the questions reclaim must answer in order. What it shows: each failure is a different answer to a different question, and they need different fixes — adding swap, raising watermark_scale_factor, unpinning memory, and tuning writeback are not interchangeable. The insight to take: the symptoms overlap heavily (everything looks like “the machine is slow and memory is full”), so the diagnosis has to come from which counters moved, not from the subjective experience. Failures 1 and 2 show up as a collapsed nr_file_pages; failure 3 as pgscan climbing with pgsteal flat; failure 4 as PSI full dominating PSI some; failure 5 as the pgscan_direct/pgscan_kswapd inversion.
1. No swap makes an entire class of memory unreclaimable
This is the most common and the most consequential. get_scan_count() opens with a hard early-out:
/* If we have no swap space, do not bother scanning anon folios. */if (!sc->may_swap || !can_reclaim_anon_pages(memcg, pgdat->node_id, sc)) { scan_balance = SCAN_FILE; goto out;}
With no swap, scan_balance is pinned to SCAN_FILEat every priority, forever. There is no escalation that unlocks anonymous reclaim, because there is nowhere to put the pages. The consequence is a very specific and often-misdiagnosed trajectory: as anonymous memory grows, the only thing reclaim can shrink is the page cache, so the cache is squeezed toward zero, every file access starts hitting disk, the machine gets dramatically slower while free still reports gigabytes “available”, and then the OOM killer fires with no warning from the free-memory graph. Operators who ran the box swapless “for predictable latency” get the least predictable latency available.
The measurement box shows the adjacent version of the same problem: swap exists but is 98.3% full (139 MiB free of 8,191 MiB). Once SwapFree reaches zero, can_reclaim_anon_pages() starts returning false and the machine silently converts itself into the swapless case above — with no configuration change and no log line.
2. The cache trap — a runaway feedback loop, and the guard against it
The source comment in prepare_scan_control() describes this one better than a paraphrase could:
“Prevent the reclaimer from falling into the cache trap: as cache pages start out inactive, every cache fault will tip the scan balance towards the file LRU. And as the file LRU shrinks, so does the window for rotation from references. This means we have a runaway feedback loop where a tiny thrashing file LRU becomes infinitely more attractive than anon pages.”
The loop is worth tracing because it is genuinely counter-intuitive. Reclaim prefers file pages. Reclaiming file pages shrinks the file LRU. A smaller inactive file list means a page has less time on it before being evicted, so fewer pages survive long enough to be referenced twice and promoted. Fewer promotions makes the file LRU look even colder and even more attractive. The system converges on a state where it thrashes a few thousand file pages at enormous cost while gigabytes of genuinely cold anonymous memory sit untouched.
Symbol by symbol: file is NR_ACTIVE_FILE + NR_INACTIVE_FILE for the node; free is the node’s free pages; total_high_wmark is the sum of high_wmark_pages() over all managed zones. So the first clause asks “would every file page plus every free page still not reach the high watermark?” — i.e. the page cache has already been reduced to irrelevance. The third clause, anon >> sc->priority, checks there is actually enough anonymous memory to be worth attacking at the current scan fraction. When all three hold, scan_balance is forced to SCAN_ANON and the scanner is pointed at anonymous memory regardless of swappiness. That, and not any swappiness arithmetic, is what the comment in the same function means by “Global reclaim will swap to prevent OOM even with no swappiness.”
3. swappiness=0 does not mean “never swap” — and swappiness behaves differently in a cgroup
Three distinct behaviours hide behind one knob, and conflating them causes real incidents:
Global reclaim, swappiness=0. Anonymous memory is still reclaimed when sc->file_is_tiny fires, per the mechanism above. The kernel will swap to avoid an OOM kill.
Cgroup reclaim, swappiness=0. A different early-out applies: if (cgroup_reclaim(sc) && !swappiness) { scan_balance = SCAN_FILE; goto out; }. The comment states the intent — “memcg users want to use this knob to disable swapping for individual groups completely.” Inside a cgroup, zero really does mean never.
Priority 0. The desperation override is if (!sc->priority && swappiness) scan_balance = SCAN_EQUAL;. The && swappiness conjunct is easy to miss: at swappiness = 0, even the most desperate reclaim pass does not fall back to scanning both lists equally.
The valid range is 0 to 200, not 0 to 100, and the documentation gives the reasoning for values above 100 explicitly: “if the random IO against the swap device is on average 2x faster than IO from the filesystem, swappiness should be 133 (x + 2x = 200, 2x = 133.33)”. For zram or zswap backing, where “swap” is a memcpy and a compression pass rather than a disk write, a value well above 100 is the correct setting and the reflexive swappiness=1 is actively harmful.
4. MGLRU retains anonymous memory in the youngest two generations
This one is a live, unresolved upstream problem and it is visible directly in the placement code. lru_gen_add_folio() in include/linux/mm_inline.h decides which generation a folio joins:
An anonymous folio that is not in the swap cache is placed at max_seq - 1. Now recall lru_gen_is_active(): return gen == lru_gen_from_seq(max_seq) || gen == lru_gen_from_seq(max_seq - 1); — bothmax_seq and max_seq - 1 count as active, and the admin guide notes that generations max_gen_nr and max_gen_nr - 1 “are not fully aged … and therefore cannot be evicted.” Anonymous memory therefore lands, by default, in the two generations that are structurally ineligible for eviction, and only ages out if max_seq advances past it.
That is precisely the behaviour reported from production. At the 2026 LSFMM+BPF planning discussion, Zicheng Wang of Honor reported that with MGLRU “anonymous pages tend to stay within the youngest two generations, causing them to never be reclaimed (and file-backed pages to be reclaimed overly aggressively). Adjusting the swappiness knob does not fix the problem.” Wang’s employer works around it by “explicitly using memory control groups to force reclaim of anonymous pages from non-foreground apps, but there is no general solution in the mainline kernel” (LWN, 2026-03-05). Kairui Song raised the same anon-reclaim complaint independently.
A second MGLRU placement problem from the same discussion: Barry Song noted that readahead pages go into the youngest generation “even though there is no guarantee that those pages will ever be used at all”, where the classic LRU puts them on the inactive list to be reclaimed quickly if untouched. Both are consequences of the same design choice — MGLRU’s folio_add_lru() sets PG_active on fault (if (lru_gen_enabled() && ... lru_gen_in_fault() ...) folio_set_active(folio);), which is the opposite of the classic-LRU policy corrected in 5.9 and described earlier in this note.
Uncertain
Verify: whether the anon-retention and readahead-placement behaviours described above have been fixed in a release later than v6.12. Reason: the LWN report is dated 2026-03-05 and describes an LSFMM+BPF session scheduled for May 2026 — the outcome of that session was not retrievable during this task, and the placement code was read at the v6.12 LTS tag, which by construction would not carry a post-6.12 fix. To resolve: re-read lru_gen_add_folio() in include/linux/mm_inline.h at the current mainline tag and check mm/vmscan.c for readahead-specific generation placement. uncertain
5. Pinned pages: the scanner burns CPU on folios it can never free
Long-term get_user_pages() pins — RDMA registrations, GPU buffers, io_uring registered buffers, vfio device assignments — make pages that are on an LRU list, look reclaimable to the scanner, and cannot be freed. mlock()ed pages at least get moved to the unevictable LRU and skipped; GUP pins do not, so the scanner isolates them, discovers the elevated refcount, and puts them back. The signature is unmistakable in the ratios of the previous section: pgscan_* climbing steadily while pgsteal_* stays flat, i.e. reclaim efficiency falling toward zero while CPU time in kswapd rises.
The same pins also defeat compaction, because an unmovable page in the middle of a pageblock is exactly what compaction cannot work around. A machine with heavy RDMA or GPU usage can therefore be simultaneously unable to reclaim and unable to defragment, which is how order-4 allocation failures appear on a box with 40 GiB free.
6. Reclaim triggered by fragmentation, not by shortage
vm.watermark_boost_factor is a trap for anyone reasoning from free-memory graphs alone. It “defines the percentage of the high watermark of a zone that will be reclaimed if pages of different mobility are being mixed within pageblocks”, defaulting to 15,000 in fractions of 10,000 — that is, up to 150% of the high watermark (vm.rst). The measurement box carries that default.
So kswapd can wake and reclaim aggressively on a machine that is nowhere near any watermark, because a fragmentation event occurred — an unmovable allocation landed in a movable pageblock. The intent, per the documentation, “is that compaction has less work to do in the future and to increase the success rate of future high-order allocations such as SLUB allocations, THP and hugetlbfs pages.” The gotcha is that a reclaim spike with no corresponding drop in free memory is not a bug and not a monitoring artefact; it is boost-driven reclaim, and the knob that turns it off is watermark_boost_factor=0. This is the seam where reclaim and compaction meet, treated fully in Memory Compaction.
7. vfs_cache_pressure = 0 is not “be gentle”, it is “never”
Covered mechanically above: at zero, super_cache_count() returns zero via vfs_pressure_ratio(), do_shrink_slab() returns before calling scan_objects, and the dentry and inode caches become permanently exempt. The documentation says it plainly — “the kernel will never reclaim dentries and inodes due to memory pressure and this can easily lead to out-of-memory conditions.” On a workload that stats millions of paths, the dentry cache will grow until something dies.
8. Recursion and deadlock guards you can trip from userspace
Two guards exist that surprise people writing kernel or filesystem code. if (current->flags & PF_MEMALLOC) goto nopage; in the slowpath stops a task already inside reclaim from re-entering it. And clearing __GFP_FS / __GFP_IO — which GFP_NOFS and GFP_NOIO do — forbids reclaim from calling back into the filesystem or block layer, preventing a filesystem from deadlocking on its own lock while reclaiming its own pages. The cost is that a GFP_NOFS allocation has a much smaller pool of reclaimable memory available to it and can fail on a machine with plenty of freeable page cache. See GFP Flags and Allocation Contexts.
9. drop_caches is a debugging tool, not a fix
Writing to /proc/sys/vm/drop_caches calls drop_slab() and frees clean caches immediately. It is genuinely useful for making a benchmark reproducible. It is not a remedy for memory pressure: it discards the page cache the machine spent hours warming, so every subsequent access takes a major fault, and pgmajfault and PSI both spike. A cron job that drops caches nightly is a cron job that guarantees a latency cliff every night.
Alternatives and When to Choose Them
“Alternative” means three different things for reclaim, and conflating them is a common source of bad advice. There is a choice of scanner (which algorithm picks victims), a choice of backing store (where evicted anonymous pages go), and a choice of driver (what decides when to reclaim, and how much). They are orthogonal — you pick one from each column.
flowchart LR
subgraph SCAN["1. WHICH SCANNER"]
S1["Classic active/inactive LRU<br/>upstream default"]
S2["MGLRU<br/>CONFIG_LRU_GEN_ENABLED"]
end
subgraph STORE["2. WHERE ANON PAGES GO"]
B1["No swap<br/>anon unreclaimable"]
B2["Disk / SSD swap partition"]
B3["zram<br/>compressed block device"]
B4["zswap<br/>compressed writeback cache<br/>in front of a real swap device"]
B5["Slower tier / CXL<br/>NUMA demotion"]
end
subgraph DRIVE["3. WHAT DRIVES IT"]
D1["Watermarks only<br/>kswapd + direct reclaim"]
D2["cgroup memory.high<br/>throttle + reclaim"]
D3["memory.reclaim<br/>byte count, on demand"]
D4["lru_gen debugfs<br/>reclaim by generation age"]
D5["DAMON_RECLAIM<br/>access-frequency driven"]
D6["Userspace: systemd-oomd,<br/>oomd, earlyoom, LMKD<br/>PSI-driven, kills instead"]
end
SCAN --> STORE --> DRIVE
The three independent axes of reclaim configuration. What it shows: the decisions people argue about — “should we enable MGLRU”, “should we use zram”, “should we run systemd-oomd” — are not competing answers to one question; they are answers to three different questions, and a coherent configuration picks one from each column. The insight to take: most “reclaim tuning” advice found online silently assumes a particular combination. swappiness=1, for instance, is reasonable advice for column 2 = disk swap and actively wrong for column 2 = zram, where the documented guidance is a value above 100.
Choosing a scanner
Classic active/inactive LRU
MGLRU
Upstream default at 6.12 / 6.18
Yes — CONFIG_LRU_GEN_ENABLED has no default y
No; opt-in at build time
How hotness is read
rmap walk from each physical page to its PTEs
walk_page_range() over each mm_struct, Bloom-filtered
Cost profile
Worst on mapped pages; the design doc says rmap search “can incur the highest CPU cost in the reclaim path”
Cheaper on mapped, heavily-shared workloads
Anon/file balance
Mature; swappiness works as documented
Known problem — anon retained in the youngest two generations; swappiness does not fix it
Readahead pages
Land on the inactive list, reclaimed quickly if untouched
Land in the youngest generation
Working-set introspection
Refault counters only
Per-generation age histogram via /sys/kernel/debug/lru_gen
Latency protection
None
min_ttl_ms — protect the last N ms of working set, OOM rather than thrash
Metrics compatibility
The baseline every tool assumes
pgdeactivate goes to zero, pgrefill changes meaning
Maintenance status (2026)
Actively maintained
Contested — see below
Scanner comparison at v6.12. The insight to take: MGLRU is not strictly better, and the honest summary is that it trades a measured CPU win on mapped-page-heavy workloads for a reported regression in anon/file balance. Choose it when your workload is dominated by mapped, shared memory and you are in a position to watch for anonymous-memory growth; stay on the classic LRU when swappiness behaviour matters or when your monitoring is built on the classic counters.
The maintenance question is now explicit. In February and March 2026, ahead of that year’s LSFMM+BPF, Matthew Wilcox argued for deletion outright: “To my mind, the biggest problem with MGLRU is that Google dumped it on us and ran away. Commit 44958000bada claimed that it was now maintained and added three people as maintainers. In the six months since that commit, none of those three people have any commits in mm/! This is a shameful state of affairs. I say rip it out.” Axel Rasmussen, one of the named maintainers, “seemed to agree with this assessment, but said that the situation would soon change.” Barry Song argued for keeping it — “It just needs more work. MGLRU has many strong design aspects, including using more generations to differentiate cold from hot, the look-around mechanism to reduce scanning overhead by leveraging cache locality, and data structure designs that minimize lock holding” — and Corbet’s assessment was that “there was little support expressed for the idea of removing it” (LWN, “Reconsidering the multi-generational LRU”, 2026-03-05).
The practical reading for 2026: MGLRU is staying, it is not the upstream default, its known problems are in anon/file balance and metrics, and David Rientjes’s framing — “what needs to be addressed so that MGLRU can be on a path to becoming the default implementation and we can eliminate two separate implementations” — is the open question, not a settled plan.
Uncertain
Verify: the outcome of the MGLRU session at LSFMM+BPF 2026 (May 2026) and whether any of the listed problems were fixed in 6.13–7.2. Reason: the LWN article is a preview of a scheduled session, written 2026-03-05; no report of the session itself was retrieved during this task, and the source pin for this note is the 6.12 LTS. To resolve: search lwn.net for LSFMM+BPF 2026 memory-management coverage, and check git log --oneline mm/vmscan.c between v6.13 and current mainline for lru_gen commits. uncertain
Choosing a backing store
Reclaim of anonymous memory is only as good as the place it puts the pages. The choice changes the cost term that swappiness is supposed to express, which is why the two decisions must be made together.
No swap. Choose this only when you genuinely prefer an OOM kill to any paging, and understand that you are also giving up the ability to reclaim cold anonymous memory that will never be touched again. Failure mode 1 above.
Disk or SSD swap. The classic. Costs a random write on eviction and a random read on refault; swappiness below 60 is defensible on spinning media, less so on NVMe.
zram. A compressed RAM-backed block device used as swap. “Swapping” becomes a compression pass, so the I/O cost term collapses and the documentation’s own arithmetic recommends swappiness above 100. The trade is CPU and the fact that the compressed pages still occupy RAM — you are buying an effective capacity multiplier, not free memory.
zswap. A compressed cache in front of a real swap device: pages are compressed into a RAM pool first and only written to disk when that pool fills. Gives most of zram’s latency win while retaining a real overflow. Disabled on the measurement box (/sys/module/zswap/parameters/enabled returns N).
A slower memory tier. With CXL or persistent memory, reclaim can demote rather than evict — the WMARK_PROMO watermark described earlier exists for exactly this. Visible as the pgdemote_kswapd / pgdemote_direct counters, both 0 on the measurement box since no tiering is configured. See NUMA Memory Tiering.
Choosing a driver — and the case for reclaiming before the watermark
Watermark-driven reclaim is reactive by construction: it starts when memory is nearly gone, which is the worst moment to start. Every proactive mechanism below exists to move that work earlier, where it is cheap.
Mechanism
Interface
Expresses
Good for
memory.high
cgroup v2
“throttle this group above N bytes” — “processes of the cgroup are throttled and put under heavy reclaim pressure”; “never invokes the OOM killer”
Soft-limiting a noisy neighbour
memory.low / memory.min
cgroup v2
Protection, not pressure. low is “best-effort”; min is “won’t be reclaimed under any conditions” and will invoke the OOM killer instead
Guaranteeing a latency-critical service its working set
memory.reclaim
cgroup v2
“free N bytes from this group now”, plus a swappiness= key at 6.12
A userspace manager that knows a job is idle
/sys/kernel/debug/lru_gen
MGLRU debugfs
“free everything colder than generation G”
Reclaiming by coldness rather than by quantity
DAMON_RECLAIM
mm/damon/, CONFIG_DAMON_RECLAIM
Access-frequency monitoring drives reclaim of pages “not accessed for a long time (cold)”; the Kconfig calls it “proactive and lightweight”
Whole-system proactive reclaim with a bounded CPU budget
systemd-oomd / oomd / earlyoom / Android LMKD
userspace, PSI-driven
“kill something before the kernel has to”
Desktops and containers where a fast kill beats a long thrash
Reclaim drivers and what each can express. The insight to take: the interfaces differ in the unit of the request, and that is the whole reason several exist. memory.reclaim speaks in bytes, which is the wrong unit if what you know is “this job has been idle for five minutes.” lru_gen debugfs speaks in generations, i.e. coldness, which is exactly that unit. memory.min speaks in protection, the inverse. Picking the interface whose unit matches the fact you actually possess is most of the design work.
Note also the important negative in the cgroup documentation: “the proactive reclaim (triggered by this interface) is not meant to indicate memory pressure on the memory cgroup”, so socket-memory balancing is deliberately not exercised on that path. A userspace memory manager built on memory.reclaim will not see the same secondary effects that natural pressure produces.
The userspace-killer row deserves its own caveat. min_ttl_ms in MGLRU exists precisely because, in the documentation’s own words, “the multi-gen LRU offers thrashing prevention to the majority of laptop and desktop users who do not have oomd.” If you do run a PSI-driven userspace killer, you have chosen the same trade in userspace and should probably leave min_ttl_ms at its default of 0 rather than arming two independent mechanisms to kill on latency.
Production Notes
The industry converged on proactive reclaim, then disagreed about how
The clearest public record of how large operators actually handle this is Shakeel Butt’s 2019 LSFMM session on Google’s approach (LWN, “Proactively reclaiming idle memory”, 2019-05-07). The framing is a cost argument, not a technical one: “memory makes up a big part of the total cost of equipping a data center”, so operators overcommit, and overcommitment means reclaim. Butt’s objection to relying on kswapd is worth quoting because it is the standard critique: it “kind of works”, but “is based on watermarks (keeping a certain percentage of memory free) rather than on idleness.”
Two numbers from that session are the most useful published figures on how much memory is actually cold in a large fleet:
About 32% of memory can be deemed idle at any given time across the Google data center.
If that memory is reclaimed after two minutes of idle time, about 14% of it will be refaulted back in — the other 86% was genuinely dead and is better used by somebody else.
The cost side is equally concrete, and it is the reason MGLRU’s page-table-walking design exists at all. Google’s kstaled / kreclaimd pair had a CPU cost that “increases linearly with the amount of memory that must be tracked and with the scan frequency. On a system with 512GB of installed memory, one full CPU must be dedicated to this task.” Crucially, “most of this time is spent walking through the reverse-map entries to find page mappings”, and eliminating the rmap walk “in favor of … a linked list of mid-level (PMD) page tables … reduced CPU usage by a factor of 3.5.” That is the same insight, with a measured multiplier attached, that MGLRU later shipped upstream: rmap walking is the bottleneck, and iterating page tables instead is worth several times the CPU.
Meta took the other route. In the same session Johannes Weiner described Facebook’s approach: every workload is containerized and users declare their memory needs, but “nobody actually knows how much memory their task will require, so they all ask for too much.” Rather than tracking idleness directly, they “use pressure-stall information to learn when memory is starting to get tight, then chop[] the oldest pages off the LRU list. If the refault rate goes up, pages are reclaimed less aggressively.” Weiner’s claim was that this “yields reasonable results at a much smaller CPU cost” — a closed loop on observed refaults rather than an open loop on measured idleness.
That disagreement is the same one embedded in the kernel today. MGLRU’s PID controller is Weiner’s design pattern — feedback on refault rates — implemented in-kernel, while DAMON_RECLAIM and the lru_gen debugfs interface are Butt’s, exposing measured coldness to a policy that lives elsewhere. Rik van Riel’s objection at the time (“even with the performance improvements … this system has scalability problems”) and Weiner’s (“why [is] Google reimplementing the tracking that is already done by the … LRU lists … it is ‘crazy expensive’”) have not been settled so much as split into two shipped mechanisms.
What the measured box actually teaches
The nine-day sample analysed earlier is a useful calibration for how alarming these counters should look before you act:
pgscan_direct exceeding pgscan_kswapd is survivable. On this box direct reclaim did 53.4% of the scanning, kswapd failed to sleep properly 93.6% of the time, and the total cost was 39.57 seconds of PSI stall in 8.86 days — 0.005% of wall-clock time. The counters screamed; the machine was fine.
Reclaim efficiency is the number that ages badly, not reclaim volume. 92.7% for kswapd versus 26.1% for direct reclaim is the ratio to alert on, because it degrades smoothly as the machine gets sicker, whereas raw scan counts grow with legitimate load too.
oom_kill = 1 in nine days on a 98.3%-full swap device is the shape of a system operating right at its designed limit. The single kill is the system working, not the system failing.
Corroborate before tuning. The vmstat ratios alone would have justified raising watermark_scale_factor; PSI showed there was nothing to fix. Any reclaim change should be argued from a PSI delta, because PSI is the only one of these numbers denominated in the unit users experience.
Operational checklist
Confirm which scanner is running before trusting any documentation, including this note: cat /sys/kernel/mm/lru_gen/enabled. Missing file means classic LRU; 0x0007 means MGLRU. Upstream default and distribution default disagree.
Check that anonymous memory is reclaimable at all — SwapTotal non-zero andSwapFree not near zero. A full swap device is functionally a swapless machine.
Alert on the efficiency ratio and on PSI, not on pgscan volume or on free. MemAvailable in /proc/meminfo is the right free-memory number; “used” from free is not.
Never set vfs_cache_pressure=0 and be very cautious above ~200; the scan loop takes real locks on a list that has already run out.
Set swappiness from the backing store, not from folklore: low for spinning disks, near the default of 60 for NVMe, above 100 for zram or zswap.
If kswapd cannot keep up with bursts, widen its working band with vm.watermark_scale_factor before reaching for anything else — it directly buys the low..min distance that is the entire latency budget.
Expect reclaim spikes with no free-memory drop.watermark_boost_factor defaults to 15,000, and fragmentation alone can drive kswapd.