When memory starts filling up on your server, the kernel makes a decision: which page gets thrown away, which one stays? For years that decision was made with two lists — "active" and "inactive" — and most of us never argued with it. It worked well enough.
That algorithm has changed. And on your server it's most likely already on.
Here's the state on mine:
$ cat /sys/kernel/mm/lru_gen/enabled
0x0007
$ grep CONFIG_LRU_GEN /boot/config-$(uname -r)
CONFIG_LRU_GEN=y
CONFIG_LRU_GEN_ENABLED=y
# CONFIG_LRU_GEN_STATS is not set
CONFIG_LRU_GEN_WALKS_MMU=y
0x0007 means the multi-generational LRU (MGLRU) is active with all of its components. I didn't make that decision; the distribution did, it arrived with a kernel package, and no release note put it on my agenda.
Don't generalise that into "it's on everywhere", though: Amazon Linux 2023's official documentation states that CONFIG_LRU_GEN is compiled in but not enabled by default, and that you enable it through the same sysfs file. So the same application may be running on two cloud servers with different reclaim algorithms. Read it rather than assume it.
If it's off, there's no kernel command line parameter to make it permanent either; you need a tmpfiles.d rule or a unit that writes to sysfs at boot.
The most important changes in systems software are usually the quiet ones. Knowing MGLRU means updating your assumptions about how your server behaves under memory pressure.
The old model and the new one
In classic LRU, pages live in two lists: active and inactive. A page that gets accessed is promoted to active, an unused one falls to inactive, and under pressure pages are dropped from the tail of the inactive list. Simple and legible.
MGLRU does the same job with generations. Pages sit in numbered generations ordered by access recency; younger generations hold hot pages, older ones hold cold pages. The kernel moves pages between generations through "aging" and drops them from the oldest generation through "eviction".
The documentation's opening line sums up why this matters: page reclaim decides the kernel's caching policy and its ability to overcommit memory, and it directly impacts kswapd CPU usage and RAM efficiency.
Three bits in the enabled file
The kill switch, enabled, is a bitmask that turns three components on and off separately.
0x0001 is the main switch: multi-gen LRU itself. 0x0002 clears the accessed bit — the one the MMU sets — in leaf page table entries in large batches; the documentation warns this can theoretically worsen lock contention (mmap_lock), and that disabling it costs a minor performance degradation for workloads that contiguously map hot pages. 0x0004 does the same clearing for non-leaf entries; the docs note this behaviour wasn't verified on x86 varieties other than Intel and AMD, and call the cost of disabling it negligible.
Writing y or n applies to all components at once. The 0x0007 in my output means all three are on.
The fourth line in my output (CONFIG_LRU_GEN_WALKS_MMU=y) explains why those two bits exist: it isn't a user setting but a dependency enabled automatically on architectures where the hardware sets the accessed bit itself. These are the knobs that manage the cost of walking page tables.
One detail: if the hardware doesn't support a component, writing has no effect — and valid values are accepted even when the main switch is off. So don't write to the file and assume; read it back.
Thrashing prevention: min_ttl_ms
MGLRU has a little-known knob aimed directly at the "the system freezes" complaint.
Write N to min_ttl_ms and the working set of the last N milliseconds is protected from eviction. If that working set can't be kept in memory, the OOM killer fires. In the documentation's own words this works as an adjustable pressure relief valve which, when open, terminates applications that are hopefully not being used.
The docs even give numbers: since the average human-detectable lag is ~100 ms, N=1000 usually eliminates intolerable janks; larger values like N=3000 make janks less noticeable at the risk of premature OOM kills. The default 0 means disabled.
It's 0 on my server too — and that's the right place for it. This knob was added with desktop and laptop users in mind, the ones who don't have a userspace policy like oomd. On the server side, the better way to manage memory pressure is a pressure-based policy like the one in my systemd-oomd cgroup v2 memory pressure runbook. MGLRU's valve doesn't replace that policy.
Working set estimation
MGLRU's most valuable part on a server is the debugfs interface most people never open.
Reading /sys/kernel/debug/lru_gen returns a histogram of the number of pages accessed over different time intervals, per memcg and node. The format is documented: the leading number is the generation number, the second column is the age in milliseconds, and the next two are the estimated anon and file pages in that generation. The lowest-numbered generation holds the coldest pages and the highest-numbered one the hottest, because the age ordering runs the other way. The histograms are noncumulative.
Let me admit the first trap I fell into: looking at the memcg 1 / line at the top of the file and drawing conclusions about the machine. The root cgroup comes back nearly empty, because on a systemd system almost every page is charged to a child cgroup. The meaningful table is below, from real services:
# grep -A2 "actions.runner\|containerd" /sys/kernel/debug/lru_gen
memcg 53 /system.slice/actions.runner.merbay-erp-kopru.kopru-vps3.service
1 23283281 0 378128
3 23283281 17418 65909
memcg 57 /system.slice/containerd.service
1 23283243 0 29931
3 23283243 73949 12960
These values are in pages, so multiply by 4 KB. The runner service holds ~378k file pages in its cold generation — roughly 1.5 GB of page cache — while its hot generation holds ~65k file and ~17k anon pages. On the containerd side the table inverts: ~74k anon pages in the hot generation, about 290 MB of genuinely working memory.
The second trap is subtler and visible in my own output: the age values within each memcg are identical. The kernel source explains why — when an lruvec is first initialised, all generation timestamps are set to the same moment. So aging hasn't advanced at all on this machine; the bins aren't "different time intervals", they only show the current distribution. A real working set measurement needs aging to be triggered first.
You can also trigger aging by hand, which is the cure for the gap above: the command + memcg_id node_id max_gen_nr [can_swap [force_scan]] creates a new generation. A scheduler can run it at intervals and rank its servers by how many cold pages that interval reveals. If you're measuring, the flow is: create a generation, wait, read the histogram — a single read doesn't give you a working set.
The number of generations is fixed too: the kernel keeps at most four, and the two youngest count as "not fully aged". The documentation maps those two onto the classic LRU's active list, so there's a direct equivalent for anyone who knows the old model.
The same interface can do proactive reclaim. This command evicts generations less than or equal to the given generation number:
- memcg_id node_id min_gen_nr [swappiness [nr_to_reclaim]]
The details matter: min_gen_nr must be less than max_gen_nr-1, because the two youngest generations aren't fully aged and therefore can't be evicted. Here swappiness overrides the value in /proc/sys/vm/swappiness, with a valid range of 0-200 plus max, which is used exclusively for reclaiming anonymous memory. nr_to_reclaim caps how many pages get evicted.
This is the official way to say "clear cold pages while there's no memory pressure" — tailor-made for a scheduler preparing a server before placing a new job, and, as the docs stress, with limited impact on the jobs already running.
A note: this interface sits under "experimental features" and needs root access to debugfs. If you're going to wire production automation to it, do so accepting that behaviour may change across kernel upgrades.
If all you want is proactive reclaim, there's already a stable, non-experimental interface: cgroup v2's memory.reclaim. It triggers reclaim in the target cgroup and accepts a swappiness nested key:
echo "1G" > /sys/fs/cgroup/system.slice/backup.service/memory.reclaim
Note the two caveats from the docs: the kernel can over- or under-reclaim (under-reclaim returns -EAGAIN), and reclaim triggered this way is not meant to indicate memory pressure — so the networking layer's socket memory balancing isn't exercised. For cold-page cleanup in production, start here; MGLRU's debugfs interface is for measuring the working set.
Working set per container
Note the first line of that output: memcg 1 /. The histogram is per cgroup, meaning every container on the machine gets its own table. That makes it possible to separate two questions people routinely conflate in capacity planning.
First question: "how much memory has this container allocated?" memory.current answers it, and it usually looks larger than it should, because page cache counts there too. Second question: "how many pages did this container actually touch in the last N milliseconds?" Only the generation histogram answers that.
When we set limits we look at the first number and try to guess the second; MGLRU makes the second directly measurable. On a dense container host, revisiting memory.max values against this table is far cheaper than the habit of "give it twice as much, just in case".
One caveat: the table is a snapshot and aging runs continuously. Don't decide from a single read; look during both busy and quiet hours.
Where MGLRU stands in 2026
Ending this with "it settled in quietly, case closed" would be wrong, because the upstream discussion is open.
MGLRU was merged in 6.1, but progress stalled and it still isn't enabled on many systems. In the 2026 memory-management discussions, some developers looked for ways to improve it while another argued for removing it entirely — the objection being less technical than about maintenance: that the feature wasn't owned and that the assigned maintainers weren't contributing. In the same period the LSFMM+BPF summit devoted three separate sessions to it, covering both better integration and problems seen on Android.
The operational translation: classic LRU wasn't removed, MGLRU didn't become mandatory, both implementations sit side by side, and distributions have landed on different sides. This isn't a "migration to the new standard" story; it's still a choice.
So what did I gain?
The honest answer: nothing measurable changed on my own server, because there's no memory pressure there. While writing this, the machine was using 11 GB of its 96 GB and the averages in /proc/pressure/memory were zero.
MGLRU's difference shows up under pressure. The generation-based model aims to make scanning cheaper and to protect hot pages better; on paper the gain lands in the CPU kswapd burns and in the amount of work you can carry on the same RAM.
It isn't free, though: aging walks page tables, and the two bits in the enabled mask exist precisely to manage that cost. The criticism that it's expensive on low-end hardware comes from the same place.
But be careful here: I can't tell you "it got X% faster", because I didn't measure it. If you want to, the right method is to put your workload under pressure and compare kswapd CPU and application latency while flipping enabled between y and n. On one machine, with one workload.
If you use compressed swap, the two work together: MGLRU decides which page gets evicted, and zram or zswap decides where the evicted page goes. One makes the selection, the other handles the storage.
Checklist
-
cat /sys/kernel/mm/lru_gen/enabled→ is it on, and which components are active? -
grep CONFIG_LRU_GEN /boot/config-$(uname -r)→ what's your distribution's default, isCONFIG_LRU_GEN_ENABLEDset? - When you hit trouble, turn the main switch off and compare:
echo n > /sys/kernel/mm/lru_gen/enabled. Reversible, but not free: during the switch the kernel moves pages across every cgroup between two list layouts. On a large-memory machine with hundreds of cgroups, do it in a maintenance window rather than under pressure. - On desktop/laptop setups with jank complaints, try
min_ttl_msstarting at 1000; on servers, set up a pressure-based policy first. - If you're curious about working sets, look at the debugfs histogram — but remember the "experimental" label before wiring it into production automation.
- Additional statistics live in
/sys/kernel/debug/lru_gen_full;CONFIG_LRU_GEN_STATSonly keeps historical stats from evicted generations there, at a memory cost. On a kernel like mine, where it's off, that history isn't available. - When measuring, look at
pgscan_kswapd,pgsteal_kswapdandworkingset_refault_*in/proc/vmstat— but account for these metrics behaving differently under MGLRU than under classic LRU; your dashboards may not be calibrated for it.
Defaults change quietly
More than a technical topic, MGLRU reminded me of a habit: the things we trust most in infrastructure are the things we question least. Page reclaim sat inside the "the kernel handles it" box for years; one day the algorithm changed, and so did the contents of the box.
I'm not saying that's bad — quite the opposite, distributions manage these transitions well. But knowing when the mental model you use to explain a server's behaviour was last updated makes a difference during diagnosis. When you say "under memory pressure it behaves like this", you should know which kernel's algorithm you're talking about.
So the question for your own setup is simple: which of your kernel's defaults changed in the past year, and how many of them did you notice?
Top comments (0)