kswapd and Background Reclaim
kswapdis the kernel’s background page-reclaim daemon — one kernel thread per NUMA node (kswapd0,kswapd1, …), whose entire job is to keep each node’s free memory comfortably above the danger line so that ordinary allocations almost never have to stop and reclaim memory themselves. It sleeps on a per-node wait queue until an allocation pushes a zone’s free pages below its low watermark; it is then woken, and runsbalance_pgdat()to reclaim asynchronously — scanning the LRU lists, writing back dirty pages, swapping out anonymous pages — until every eligible zone is back above its high watermark, at which point it goes back to sleep. Because it runs in its own context rather than the allocating task’s, the work it does is invisible to applications as latency: that is precisely why it exists — to do the reclaim work ahead of demand so that tasks are not forced into synchronous direct reclaim. This note covers the kswapd thread, its watermark-driven life cycle, zone balancing, and its handoff to kcompactd; the shared page-scanning engine it shares with direct reclaim lives in Memory Reclaim Overview and The LRU Lists.
Version pin — Linux 6.12 LTS, verified 2026-09-04
Every function name, constant, and behaviour below was read from the
v6.12tag oftorvalds/linuxviaraw.githubusercontent.com. 6.12 is a maintained longterm branch, not mainline:https://www.kernel.org/releases.jsonon 2026-09-04 lists mainline at 7.3-rc1, latest stable at 7.2.3, and6.12.108among thelongtermentries. Claims were cross-checked against the 6.18 LTS tag (6.18.49), and anything that changed after 6.12 is dated inline to the release that changed it — see What Changed After 6.12 below.Correction to an earlier revision of this note. It claimed
pgdat_balanced()is byte-identical at 6.12 and 6.18 and thatbalance_pgdat()differs “only cosmetically.” Diffing the two tags shows otherwise:kswapd_try_to_sleep()is byte-identical, butpgdat_balanced()gaineddefrag_modehandling (checkingNR_FREE_PAGES_BLOCKSinstead ofNR_FREE_PAGESfor high-order requests) and an explicitpercpu_drift_marksnapshot, andpgdat->kswapd_failureschanged from a plainintto anatomic_t. Those are semantic changes, not refactors.
Scope — where this note sits among its siblings
Reclaim is documented here as a three-way split, and each note is authoritative for its own third:
- Memory Reclaim Overview is the map — what reclaim is, the watermark arithmetic on a measured box, what counts as reclaimable, victim selection via the classic LRU or MGLRU, shrinkers, and a worked
/proc/vmstatdiagnosis.- This note owns the daemon — the per-node kthread, its sleep/wake/balance state machine,
balance_pgdat()and the priority descent, what “balanced” actually means, watermark boosting, thekcompactdhandoff, NUMA behaviour, and why background reclaim is latency-invisible right up until it isn’t.- Direct Reclaim owns the stall — the slowpath gates a task passes through to reach
try_to_free_pages(), the two throttles, PSI accounting,allocstall, and how to recognise a direct-reclaim latency stall in production.The page-scanning engine itself (
shrink_node()→shrink_lruvec()→ the LRU walk) is shared by both reclaimers and is deliberately explained in neither trigger note; see Memory Reclaim Overview and The LRU Lists.
Mental Model — The Daemon Between the Watermarks
Each memory zone has three watermarks (Watermarks and the Allocation Fast Path): min (the hard floor — below it only emergency/PF_MEMALLOC allocations may dip), low (the wake line — crossing it downward signals “getting short”), and high (the satisfaction line — reclaim stops once free memory rises back above it). kswapd lives entirely inside the band between low and high.
flowchart TD subgraph WM["Per-zone free-memory thresholds (one node)"] HIGH["HIGH watermark<br/>(kswapd stops here)"] LOW["LOW watermark<br/>(kswapd woken here)"] MIN["MIN watermark<br/>(hard reserve floor)"] end ALLOC["allocations consume<br/>free pages downward"] -->|"free drops below LOW"| WAKE["wakeup_kswapd()<br/>via wake_all_kswapds in slow path"] WAKE --> KSW["kswapd thread woken<br/>(kswapd_wait queue)"] KSW --> BAL["balance_pgdat()<br/>reclaim node bottom-up<br/>until pgdat_balanced (>= HIGH)"] BAL -->|"all zones >= HIGH"| SLEEP["kswapd_try_to_sleep()<br/>wake kcompactd, then sleep"] BAL -->|"still under pressure,<br/>allocs faster than reclaim"| DR["tasks hit slow path,<br/>do DIRECT RECLAIM themselves"] SLEEP --> KSW
kswapd’s watermark-driven life cycle. What it shows: allocations drive free memory down; crossing low wakes kswapd, which reclaims up to high and then sleeps again. The insight to take: kswapd’s target is the high watermark (it over-reclaims past the wake point on purpose, building a buffer), and the gap between low and high — set by watermark_scale_factor — is the slack that determines whether kswapd can stay ahead. If allocations outrun kswapd despite this buffer, tasks fall through to Direct Reclaim; kswapd existing at all is the mechanism that keeps that fall-through rare.
That flowchart is the threshold view. The other half of the mental model is the lifecycle view — kswapd is a state machine, and almost every operational question about it (“why is it at 100% CPU?”, “why does kswapd_low_wmark_hit_quickly keep climbing?”, “why did it stop reclaiming while memory is still low?”) is really a question about which state it is stuck in.
stateDiagram-v2 [*] --> Spawning Spawning: kswapd_run(nid)<br/>kthread_run(kswapd, pgdat, "kswapd%d", nid)<br/>bind to cpumask_of_node()<br/>set PF_MEMALLOC | PF_KSWAPD Spawning --> FullSleep FullSleep: FULL SLEEP<br/>schedule() on pgdat->kswapd_wait<br/>per-CPU vmstat thresholds relaxed<br/>(calculate_normal_threshold) ShortNap: SHORT NAP<br/>schedule_timeout(HZ/10) = 100 ms<br/>kcompactd already woken<br/>reset_isolation_suitable() done Balancing: BALANCING<br/>balance_pgdat(order, highest_zoneidx)<br/>PAGEOUTRUN++<br/>psi_memstall_enter()<br/>priority 12 down to 1 Checking: prepare_kswapd_sleep()<br/>wake pfmemalloc sleepers,<br/>then test pgdat_balanced() Hopeless: HOPELESS NODE<br/>kswapd_failures >= 16<br/>prepare_kswapd_sleep() returns true<br/>regardless of watermarks FullSleep --> Balancing: wakeup_kswapd()<br/>from wake_all_kswapds()<br/>in the allocator slowpath Balancing --> Checking: pgdat_balanced(), or<br/>priority hit 0, or kthread stop Checking --> ShortNap: balanced —<br/>tighten vmstat thresholds,<br/>wake kcompactd, nap Checking --> Balancing: NOT balanced —<br/>KSWAPD_HIGH_WMARK_HIT_QUICKLY++<br/>go straight back to work ShortNap --> FullSleep: nap completed AND<br/>still balanced on re-check ShortNap --> Balancing: woken during the nap<br/>KSWAPD_LOW_WMARK_HIT_QUICKLY++ Balancing --> Hopeless: balance_pgdat() freed<br/>nothing -> kswapd_failures++ Hopeless --> FullSleep: sleep; direct reclaim<br/>and the OOM killer take over Hopeless --> Balancing: any successful reclaim<br/>resets kswapd_failures = 0
The kswapd state machine, mm/vmscan.c at v6.12. What it shows: there are only five states, and the two vmstat counters everybody misreads are simply labels on two specific edges. kswapd_low_wmark_hit_quickly fires on the ShortNap → Balancing edge (something woke it during the 100 ms nap); kswapd_high_wmark_hit_quickly fires on the Checking → Balancing edge (the nap completed but the re-check found the node unbalanced again). The insight to take: neither counter means what its name suggests, and neither is about a watermark being “hit quickly.” Both mean the same operational thing — kswapd did not get to go properly to sleep — and a machine where they dominate pageoutrun is one where the daemon is oscillating between balancing and napping without ever reaching FullSleep. That is the state to recognise: kswapd at 100% CPU is not a leak, it is this loop.
The defining idea: kswapd decouples reclaim from allocation. Without it, every task that found a zone below its watermark would have to reclaim inline (direct reclaim) — paying the latency itself. kswapd amortizes that work into a background thread that reclaims proactively, asynchronously, and in batch, so the common case is that allocations find free memory waiting for them. The kernel source says it plainly in the kswapd() comment: “This basically trickles out pages so that we have some free memory available even if there is no other activity that frees anything up… If there are applications that are active memory-allocators (most normal use), this basically shouldn’t matter [i.e., they shouldn’t notice].”
Mechanical Walk-through
Birth: one thread per node
At boot (and on memory hotplug), kswapd_run(nid) spawns the daemon for node nid:
pgdat->kswapd = kthread_run(kswapd, pgdat, "kswapd%d", nid);Each thread is bound to the CPUs of its node (set_cpus_allowed_ptr(tsk, cpumask_of_node(pgdat->node_id))) so a node’s reclaim runs on that node’s CPUs — important for NUMA locality. On entry the thread sets two crucial flags:
tsk->flags |= PF_MEMALLOC | PF_KSWAPD;The kernel’s own comment above that line is the clearest statement of why:
“Tell the memory management that we’re a ‘memory allocator’, and that if we need more memory we should get access to it regardless … kswapd normally doesn’t need memory anyway, but sometimes you need a small amount of memory in order to be able to page out something else, and this flag essentially protects us from recursively trying to free more memory as we’re trying to free the first piece of memory in the first place.”
PF_MEMALLOC lets kswapd dip into reserves and — critically — exempts it from re-entering reclaim recursively (the same flag the direct-reclaim path checks as its goto nopage recursion gate). kswapd may need a little memory to write a page out — a bio, a swap-cache entry — and PF_MEMALLOC guarantees it can get that small amount without itself stalling on reclaim, breaking what would otherwise be a deadlock. PF_KSWAPD is how current_is_kswapd() distinguishes kswapd from direct reclaimers throughout mm/vmscan.c.
That one predicate, current_is_kswapd(), is load-bearing in more places than most readers expect. It is worth tabulating, because the entire behavioural difference between the two reclaimers is a handful of tests on this flag:
Call site (mm/vmscan.c, v6.12) | Effect of current_is_kswapd() being true |
|---|---|
reclaimer_offset() | Returns 0, so scans and steals are counted to pgscan_kswapd / pgsteal_kswapd instead of the _direct slots |
too_many_isolated() | Returns false immediately — kswapd is never blocked by other reclaimers holding folios isolated |
reclaim_throttle() | The PF_KTHREAD exemption is explicitly not applied to kswapd, so kswapd can be throttled — but only on WRITEBACK |
consider_reclaim_throttle() | Returns early, so kswapd never sleeps on NOPROGRESS |
shrink_node(), congested-bit block | kswapd sets PGDAT_WRITEBACK, PGDAT_DIRTY and LRUVEC_NODE_CONGESTED; direct reclaimers obey them |
shrink_node(), VMSCAN_THROTTLE_CONGESTED site | Gated on !current_is_kswapd() — kswapd is exempt |
shrink_node(), sc->nr.immediate site | Gated on current_is_kswapd() — this is the one throttle that is kswapd-only |
throttle_direct_reclaim() | Never reached; kswapd is the thing that calls wake_up_all(&pgdat->pfmemalloc_wait) |
Every place the kernel asks “am I kswapd?”, and what changes. What it shows: the daemon is exempted from three of the four back-off mechanisms, subjected to a fourth that direct reclaimers never see, and is the producer of the congestion state that everyone else consumes. The insight to take: kswapd is not “a thread that happens to run the reclaim code.” It is a privileged role. It scans without being throttled by contention, it decides when the node counts as congested, and the one time it does sleep — sc->nr.immediate, meaning it found folios already marked for immediate reclaim and still under writeback — it is because folios are cycling through the LRU faster than storage can write them, which is a condition no amount of extra scanning can fix.
The historical shape, and why it still matters
kswapd predates almost everything else in this note. Mel Gorman’s Understanding the Linux Virtual Memory Manager describes the 2.4 arrangement: “Historically, kswapd used to wake up every 10 seconds but now it is only woken by the physical page allocator when the pages_low number of free pages in a zone is reached”, and “Unlike swapout daemons such as Solaris, which are woken up with increasing frequency as there is memory pressure, kswapd keeps freeing pages until the pages_high watermark is reached” (Ch. 10, Page Frame Reclamation). Both sentences are still exactly true at 6.12 with the names changed (pages_low → WMARK_LOW, pages_high → WMARK_HIGH). The same chapter records the 2.6 change that produced today’s structure: “there is now a kswapd for every memory node in the system … they all execute the same code except their work is confined to their local node.”
The design choice worth extracting is the one Gorman contrasts against Solaris. Linux deliberately does not scale reclaim effort continuously with pressure. It has a binary wake condition and a fixed stop condition, and it over-shoots the wake point every time. Everything about kswapd’s observable behaviour — the bursty CPU pattern, the 100 ms nap, the fact that free memory saw-tooths between low and high rather than sitting at a set point — follows from that single decision.
The main loop: sleep, wake, balance
kswapd() is an infinite loop:
for ( ; ; ) {
alloc_order = reclaim_order = READ_ONCE(pgdat->kswapd_order);
highest_zoneidx = kswapd_highest_zoneidx(pgdat, highest_zoneidx);
kswapd_try_sleep:
kswapd_try_to_sleep(pgdat, alloc_order, reclaim_order, highest_zoneidx);
/* re-read what the waker asked for */
alloc_order = READ_ONCE(pgdat->kswapd_order);
...
reclaim_order = balance_pgdat(pgdat, alloc_order, highest_zoneidx);
if (reclaim_order < alloc_order)
goto kswapd_try_sleep;
}The thread spends most of its life asleep in kswapd_try_to_sleep(). When woken, it reads the order and highest zone index the waker requested (passed via pgdat->kswapd_order / pgdat->kswapd_highest_zoneidx), runs balance_pgdat(), and — if it had to fall back from a high-order request to order-0 (reclaim_order < alloc_order) — loops straight back to sleep rather than spinning.
Who wakes it: wakeup_kswapd
kswapd is woken by wakeup_kswapd(zone, gfp_flags, order, highest_zoneidx), called (via wake_all_kswapds()) from the allocator slow path the moment the fast path fails — before the slow path attempts direct reclaim. wakeup_kswapd() records the requested order and zone index on the pg_data_t, then decides whether to actually wake the thread:
if (pgdat->kswapd_failures >= MAX_RECLAIM_RETRIES ||
(pgdat_balanced(pgdat, order, highest_zoneidx) &&
!pgdat_watermark_boosted(pgdat, highest_zoneidx))) {
/* hopeless or already balanced: maybe just wake kcompactd, then return */
if (!(gfp_flags & __GFP_DIRECT_RECLAIM))
wakeup_kcompactd(pgdat, order, highest_zoneidx);
return;
}
wake_up_interruptible(&pgdat->kswapd_wait);Two early-outs are visible in that snippet, but the full function has five exit points, and the order matters because two of them fire before the “record what the waker wanted” step and two after:
flowchart TD START["wakeup_kswapd(zone, gfp_flags,<br/>order, highest_zoneidx)"] MZ{"managed_zone(zone)?"} X1["return — a zone with zero<br/>managed pages needs nothing"] CS{"cpuset_zone_allowed(zone, gfp_flags)?"} X2["return — this task's cpuset<br/>cannot use this zone anyway"] REC["Record the request on the pgdat:<br/>kswapd_highest_zoneidx = max(...)<br/>kswapd_order = max(...)"] WQ{"waitqueue_active(&pgdat->kswapd_wait)?"} X3["return — kswapd is already<br/>AWAKE and working.<br/>The request is recorded; it<br/>will pick it up next loop."] HOPE{"kswapd_failures >= 16<br/>OR (pgdat_balanced()<br/>AND !pgdat_watermark_boosted())?"} KC{"gfp_flags has<br/>__GFP_DIRECT_RECLAIM?"} X4["return — the caller can<br/>reclaim for itself; don't<br/>even wake kcompactd"] X5["wakeup_kcompactd(pgdat, order,<br/>highest_zoneidx) then return —<br/>the problem is fragmentation,<br/>not shortage"] WAKE["trace_mm_vmscan_wakeup_kswapd()<br/>wake_up_interruptible(&kswapd_wait)"] START --> MZ MZ -->|"no"| X1 MZ -->|"yes"| CS CS -->|"no"| X2 CS -->|"yes"| REC REC --> WQ WQ -->|"no"| X3 WQ -->|"yes"| HOPE HOPE -->|"yes"| KC HOPE -->|"no"| WAKE KC -->|"yes"| X4 KC -->|"no"| X5
Every exit from wakeup_kswapd(), mm/vmscan.c at v6.12. What it shows: the recording of kswapd_order and kswapd_highest_zoneidx happens before the waitqueue_active() check, which is the mechanism by which a request reaches an already-running daemon: the waker does not wake anything, it just raises the daemon’s target, and the daemon re-reads pgdat->kswapd_order at the top of its next loop iteration. The insight to take: the two “hopeless or balanced” outcomes are not the same. When the caller can reclaim for itself (__GFP_DIRECT_RECLAIM set), the function does nothing at all and lets it — deliberately, because a direct reclaimer will handle its own case and waking a daemon that has failed sixteen times would only burn CPU. When the caller cannot (GFP_ATOMIC, GFP_NOWAIT), kcompactd is woken instead, on the theory that the requester may be blocked by fragmentation rather than shortage and compaction is the only remaining lever. This is one of the few places in the kernel where an atomic allocation gets more help than a blocking one.
The pgdat_watermark_boosted() half of the hopeless-or-balanced test deserves a footnote, because getting it wrong caused a real bug. It walks zones top-down (the reverse of pgdat_balanced()), looking for any zone with a non-zero watermark_boost, and its source comment explains the coupling: “Both watermarks and boosts should not be checked at the same time as reclaim would start prematurely when there is no boosting and a lower zone is balanced.” When boosting is compiled out or disabled, that check must fold away cleanly — which it did not, until commit 597c892038e0, “mm: don’t wake kswapd prematurely when watermark boosting is disabled” (Mel Gorman, 2020).
And if kswapd has failed MAX_RECLAIM_RETRIES (16, mm/internal.h line 468) balancing passes in a row, the node is declared “hopeless” and left to direct reclaim and the OOM killer rather than burning CPU. That counter is reset to zero by any pass that reclaims something, so “hopeless” is a sticky-but-recoverable state, not a terminal one.
balance_pgdat: reclaim up to the high watermark
balance_pgdat() is the heart of background reclaim. It wraps the whole pass in PSI memory-stall accounting (psi_memstall_enter/leave) and counts a PAGEOUTRUN vmstat event per invocation — which is why pageoutrun in /proc/vmstat is exactly “the number of times kswapd woke up and did a balancing pass,” and why dividing the two *_wmark_hit_quickly counters by it gives the fraction of wakeups that ended badly.
flowchart TD ENTRY["balance_pgdat(pgdat, order, highest_zoneidx)"] SETUP["set_task_reclaim_state()<br/>psi_memstall_enter()<br/>__fs_reclaim_acquire()<br/>count_vm_event(PAGEOUTRUN)"] BOOST["Snapshot zone->watermark_boost into<br/>zone_boosts[]; nr_boost_reclaim = sum"] RESTART["restart:<br/>set_reclaim_active() — sets ZONE_RECLAIM_ACTIVE,<br/>which LOWERS the per-CPU page-list high mark<br/>sc.priority = DEF_PRIORITY (12)"] BH{"buffer_heads_over_limit?"} BHY["Widen sc.reclaim_idx to the<br/>highest managed zone"] BAL{"pgdat_balanced()?"} BOOSTDROP["Imbalanced while boosting:<br/>nr_boost_reclaim = 0; goto restart<br/>(normal reclaim takes priority<br/>over defragmentation)"] OUT["out: — done"] POLICY["Set the pass policy:<br/>may_writepage = !laptop_mode && !boosting<br/>may_swap = !boosting<br/>(boosted passes do NO I/O)"] AGE["kswapd_age_node()<br/>MGLRU: lru_gen_age_node()<br/>classic: shrink_active_list(ANON)"] SOFT["memcg1_soft_limit_reclaim()"] SHRINK["kswapd_shrink_node()<br/>nr_to_reclaim = SUM over zones of<br/>max(high_wmark_pages(zone), 32)<br/>then shrink_node()"] WAKEPF["If anyone is on pfmemalloc_wait and<br/>allow_direct_reclaim() is now true:<br/>wake_up_all() — release the stalled tasks"] PRIO{"raise_priority<br/>OR nothing reclaimed?"} DEC["sc.priority--"] LOOP{"sc.priority >= 1?"} TRIM{"Nothing reclaimed, priority < 1,<br/>and cache_trim_mode_failed?"} TRIM2["no_cache_trim_mode = 1<br/>goto restart"] FAIL["kswapd_failures++"] DECAY["Decay zone->watermark_boost by what<br/>was reclaimed; wakeup_kcompactd()<br/>at pageblock_order"] RET["return sc.order<br/>(0 if it fell back from high-order)"] ENTRY --> SETUP --> BOOST --> RESTART --> BH BH -->|"yes"| BHY --> BAL BH -->|"no"| BAL BAL -->|"imbalanced & boosting"| BOOSTDROP --> RESTART BAL -->|"balanced & not boosting"| OUT BAL -->|"work to do"| POLICY --> AGE --> SOFT --> SHRINK --> WAKEPF --> PRIO PRIO -->|"yes"| DEC --> LOOP PRIO -->|"no"| LOOP LOOP -->|"yes"| BH LOOP -->|"no"| TRIM TRIM -->|"yes"| TRIM2 --> RESTART TRIM -->|"no"| FAIL --> OUT OUT --> DECAY --> RET
One invocation of balance_pgdat(), mm/vmscan.c at v6.12. What it shows: the loop has two goto restart paths — one that abandons defragmentation when the node turns out to be genuinely imbalanced, and one (no_cache_trim_mode) that retries the whole descent with cache-trimming disabled after a fruitless pass. The insight to take: the very first thing the loop does is set_reclaim_active(), which sets ZONE_RECLAIM_ACTIVE on every eligible zone and thereby lowers the per-CPU page-list high-water mark for the duration of the pass. That is a subtle and important side effect: while kswapd is running, freed pages are returned to the buddy allocator sooner instead of accumulating on per-CPU lists where the watermark checks cannot see them. kswapd does not merely free memory; it changes the allocator’s caching behaviour so that the memory it frees is visible. See Per-CPU Page Lists.
Its loop decreases sc.priority from DEF_PRIORITY (12) toward 1, and on each pass:
-
Checks balance.
pgdat_balanced(pgdat, order, highest_zoneidx)walks the node’s zones bottom-up (lower zones — DMA, DMA32 — are likeliest to satisfy a watermark first) and returns true once any eligible zone meetshigh_wmark_pages(zone)(or the NUMA-tiering promotion watermark). If balanced and no watermark boost is active, it jumps toout:— done.if (sysctl_numa_balancing_mode & NUMA_BALANCING_MEMORY_TIERING) mark = promo_wmark_pages(zone); else mark = high_wmark_pages(zone); if (zone_watermark_ok_safe(zone, order, mark, highest_zoneidx)) return true;Note the target is the high watermark, not low. kswapd was woken at low but deliberately over-reclaims up to high, building a free-memory buffer so it can go back to sleep for a while rather than thrashing awake at every allocation.
-
Ages the LRU.
kswapd_age_node()does background aging — rotating referenced pages — so that pages get a fair chance to be re-referenced before being reclaimed. -
Reclaims.
kswapd_shrink_node()→shrink_node()runs the shared scanning engine (the same code Direct Reclaim uses, documented in Memory Reclaim Overview and The LRU Lists — not repeated here). The pages it scans and steals are attributed topgscan_kswapd/pgsteal_kswapdin/proc/vmstat(becausereclaimer_offset()returns 0 for kswapd). -
Wakes throttled tasks. If any task is parked in the pfmemalloc throttle (see Direct Reclaim) and
allow_direct_reclaim(pgdat)is now true, kswapd wakes them:wake_up_all(&pgdat->pfmemalloc_wait). -
Raises priority. The decrement condition is
if (raise_priority || !nr_reclaimed) sc.priority--;, andraise_prioritystarts each pass true and is cleared only whenkswapd_shrink_node()returns true — meaning it scanned or reclaimed at least its target. So the priority falls on every pass unless the pass was fully productive: this is a descent by default, not a punishment for failure. After the loop, if the wholebalance_pgdat()call freed zero pages,pgdat->kswapd_failures++— and once that counter hitsMAX_RECLAIM_RETRIESthe node is treated as hopeless (perwakeup_kswapdabove).
What “balanced” actually means — and the bug that defined it
pgdat_balanced() is three lines of logic that carry a surprising amount of policy:
for (i = 0; i <= highest_zoneidx; i++) { /* BOTTOM-UP */
zone = pgdat->node_zones + i;
if (!managed_zone(zone))
continue;
mark = (numa tiering) ? promo_wmark_pages(zone) : high_wmark_pages(zone);
if (zone_watermark_ok_safe(zone, order, mark, highest_zoneidx))
return true; /* ANY zone is enough */
}
if (mark == -1)
return true; /* no managed zones at all */
return false;Two decisions are encoded here and both are counter-intuitive. First, the target is the high watermark, not the low one that woke the daemon — kswapd deliberately over-shoots, building a buffer so it can sleep for a while rather than being re-woken by the next allocation. Second, and much more surprising: the function returns true as soon as any one eligible zone meets its high watermark. It does not require all of them.
That second rule is the direct result of a production bug report. Simon Kirby wrote to the list about machines that never used their memory:
“We’re seeing cases on a number of servers where cache never fully grows to use all available memory. Sometimes we see servers with 4 GB of memory that never seem to have less than 1.5 GB free, even with a constantly-active VM. In some cases, these servers also swap out while this happens, even though they are constantly reading the working set into memory.”
The diagnosis, per Mel Gorman’s fix: “On the target machine, there is a small Normal zone in comparison to DMA32. As kswapd tries to balance all zones, it would continually try reclaiming for Normal even though DMA32 was balanced enough for callers” (commit 9950474883e0, merged for 2.6.38). A small, hard-to-satisfy zone was holding the whole node hostage: kswapd would grind forever trying to balance it, throwing away page cache and swapping out anonymous memory that the callers did not need reclaimed at all. The return true on the first satisfied zone is the fix, and the bottom-up walk order is the optimisation that finds it fastest, since lower zones are smaller and more likely to be over their (proportionally tiny) watermark.
The same report exposed a second, related bug: sleeping_prematurely() — today’s prepare_kswapd_sleep() — “does not use the same logic as balance_pgdat() when deciding whether to sleep or not. This keeps kswapd artificially awake.” That class of bug recurred: 333b0a459c0e, “mm, vmscan: fix zone balance check in prepare_kswapd_sleep” (Shantanu Goel, 2017), fixed another divergence between the two. The sleep check and the work check must agree, or the daemon spins. At 6.12 they agree by construction: prepare_kswapd_sleep() calls pgdat_balanced() directly.
Uncertain
Verify: the practical consequences of “any one zone is enough” on a modern single-
Normal-zone x86-64 server. Reason: on the measurement box described in Memory Reclaim Overview,DMAhas 3,840 managed pages andDMA32has 457,117, againstNormal’s 32,326,603 — soDMA32meeting its 1,092-page high watermark would satisfypgdat_balanced()for ahighest_zoneidxthat includes it, even withNormalstarved. Whether that happens in practice depends onhighest_zoneidx, which is derived from the allocation’s GFP flags, and most user allocations carryZONE_MOVABLE/ZONE_NORMALindices. The reasoning was not confirmed by instrumenting a live kernel. To resolve: enablemm_vmscan_kswapd_wake(it reportszid) alongside/proc/zoneinfosampling and check whether balancing ever terminates on a low zone whileNormalis below its high watermark. uncertain
How much kswapd actually tries to reclaim
The single largest quantitative difference between the two reclaimers is hidden in kswapd_shrink_node():
sc->nr_to_reclaim = 0;
for (z = 0; z <= sc->reclaim_idx; z++) {
zone = pgdat->node_zones + z;
if (!managed_zone(zone))
continue;
sc->nr_to_reclaim += max(high_wmark_pages(zone), SWAP_CLUSTER_MAX);
}A direct reclaimer sets nr_to_reclaim = SWAP_CLUSTER_MAX — 32 folios. kswapd sets it to the sum of every eligible zone’s high watermark. Using the live /proc/zoneinfo numbers from the box measured in Memory Reclaim Overview:
| Zone | high_wmark_pages(zone) | max(high, 32) | Running total |
|---|---|---|---|
DMA | 7 | 32 | 32 |
DMA32 | 1,092 | 1,092 | 1,124 |
Normal | 81,317 | 81,317 | 82,441 |
Movable | 32 (unmanaged — skipped) | — | 82,441 |
kswapd target | 82,441 folios ≈ 322 MiB | ||
| Direct-reclaim target | 32 folios = 128 KiB |
The two reclaim targets computed on real watermarks. What it shows: on this machine kswapd aims to free roughly 2,576 times as much per pass as a direct reclaimer does. The insight to take: this is what “background” actually buys. kswapd reclaims in one large, batched, cache-friendly sweep that amortises the LRU-lock traffic and lets the block layer coalesce writeback; a direct reclaimer takes 32 folios and returns to the allocator, then comes back, then comes back again. The efficiency gap measured in /proc/vmstat (92.7% versus 26.1% on the measured box) is partly a consequence of when each runs and partly a consequence of this batch size — a large sweep can afford to skip an expensive folio and find a cheap one, while a 32-folio sweep cannot.
Two guards on that number are worth naming. max(..., SWAP_CLUSTER_MAX) prevents a tiny zone contributing a target of zero. And after shrink_node() returns, kswapd_shrink_node() applies a fragmentation escape:
if (sc->order && sc->nr_reclaimed >= compact_gap(sc->order))
sc->order = 0;compact_gap(order) is 2 << order — twice the allocation size, doubled again because compaction may itself hold 1 << order pages on its free list. Once kswapd has freed twice what a high-order request needed and still cannot satisfy it, it stops treating this as a reclaim problem, drops to order-0, and lets kcompactd deal with the fragmentation. The source comment says exactly that: “Fragmentation may mean that the system cannot be rebalanced for high-order allocations. If twice the allocation size has been reclaimed then recheck watermarks only at order-0 to prevent excessive reclaim.” This is the mechanism behind kswapd()’s if (reclaim_order < alloc_order) goto kswapd_try_sleep; — the daemon noticed it had given up on the high order and goes back to sleep rather than spinning.
Going back to sleep: kswapd_try_to_sleep and the kcompactd handoff
Once balanced, kswapd calls kswapd_try_to_sleep(), which does a careful two-stage sleep to avoid a thundering-herd race:
-
It first checks
prepare_kswapd_sleep()(watermarks met and no task waiting in the pfmemalloc throttle). If OK, it wakes kcompactd for the node —wakeup_kcompactd(pgdat, alloc_order, highest_zoneidx)— on the theory that now that pages are free, it is a good time to compact them into contiguous runs for future high-order allocations (see Memory Compaction). It then does a shortschedule_timeout(HZ/10)(≈100 ms) nap. -
After the short nap, it re-checks
prepare_kswapd_sleep(). If the nap completed (remaining == 0) and the node is still balanced, it goes fully to sleep until explicitly woken — and relaxes the per-CPU vmstat thresholds on the way in (set_pgdat_percpu_threshold(pgdat, calculate_normal_threshold)), tightening them again (calculate_pressure_threshold) when it wakes. Otherwise it counts one of the two*_wmark_hit_quicklyevents and loops back to balancing.
This split exists because the watermarks can be re-breached in the tiny window between “kswapd decided to sleep” and “kswapd actually slept”; the short nap + re-check catches that case cheaply.
The vmstat-threshold adjustment is a genuinely clever detail with an operational consequence. Per-CPU vmstat counters are batched, so NR_FREE_PAGES as read by a watermark check “can deviate from the true value by nr_online_cpus * threshold” — the source’s own words. On a 64-core machine with a threshold of 125, that is up to 8,000 pages, 31 MiB, of error, which on a small zone is larger than the entire low..min band. So kswapd runs with a tight threshold (accurate but more cache-line traffic) while awake and near the watermarks, and relaxes to a loose one (cheap but fuzzy) once it is safely asleep with memory abundant. The cost of accuracy is paid only when accuracy matters.
Here is the same two-stage sleep as a timeline, because the counter semantics only become obvious when you can see which edge each one is on:
sequenceDiagram autonumber participant B as balance_pgdat() participant S as kswapd_try_to_sleep() participant PF as pfmemalloc_wait sleepers participant KC as kcompactd<N> participant A as An allocating task B->>S: node balanced, return S->>S: prepare_to_wait(&kswapd_wait, TASK_INTERRUPTIBLE) S->>PF: prepare_kswapd_sleep(): wake_up_all(&pfmemalloc_wait) Note over PF: stalled direct reclaimers released BEFORE kswapd sleeps —<br/>closes the race where kswapd sleeps with tasks still parked S->>S: pgdat_balanced()? yes S->>S: reset_isolation_suitable(pgdat)<br/>(clear compaction's "skip these blocks" hints) S->>KC: wakeup_kcompactd(pgdat, alloc_order, highest_zoneidx) Note over KC: woken with the ORIGINAL alloc_order,<br/>not the possibly-dropped reclaim_order S->>S: remaining = schedule_timeout(HZ/10) — the 100 ms nap alt woken during the nap (remaining != 0) A->>S: wakeup_kswapd() — free fell below low again S->>S: count_vm_event(KSWAPD_LOW_WMARK_HIT_QUICKLY) S-->>B: back to balancing, no full sleep else nap completed but node no longer balanced S->>S: count_vm_event(KSWAPD_HIGH_WMARK_HIT_QUICKLY) S-->>B: back to balancing, no full sleep else nap completed and still balanced S->>S: trace_mm_vmscan_kswapd_sleep(nid) S->>S: set_pgdat_percpu_threshold(calculate_normal_threshold) S->>S: schedule() — FULL SLEEP, indefinite A->>S: wakeup_kswapd() eventually S->>S: set_pgdat_percpu_threshold(calculate_pressure_threshold) end
The two-stage sleep, kswapd_try_to_sleep() in mm/vmscan.c at v6.12. What it shows: kcompactd is woken before the short nap, not after the full sleep, and the pfmemalloc_wait sleepers are released before kswapd commits to sleeping at all. The insight to take: the ordering of those two wakeups is the answer to two separate bugs. Releasing pfmemalloc_wait first closes a race the source spells out — kswapd could otherwise sleep while direct reclaimers sit parked on a queue only kswapd wakes, which is a hang. And waking kcompactd before the nap rather than after the full sleep is the fix for a regression described below.
That kcompactd-before-the-nap ordering has a documented history. kcompactd was introduced in 4.6 as a per-node compaction daemon, moving compaction out of kswapd’s critical path (Vlastimil Babka, commit 698b1b30642f, followed by accf62422b3a, “mm, kswapd: replace kswapd compaction with waking up kcompactd”). The wakeup was initially placed before the full sleep only, and that turned out to be a regression:
“For higher-order allocations, waking up kcompactd is done only before the full sleep. This turns out to be an issue in case another high-order allocation fails during the initial sleep. It will wake kswapd up, however kswapd considers the zone balanced from the order-0 perspective, and will just quickly try to sleep again. So if there’s a longer stream of high-order allocations hitting the slowpath and waking up kswapd, it might never actually wake up kcompactd … In the worst case, it might be that a single allocation that cannot direct reclaim/compact itself is waking kswapd in the retry loop and preventing kcompactd from being woken up and unblocking it.” — commit
fd901c95388b, “mm: wake kcompactd before kswapd’s short sleep”
That is a livelock in miniature, and it is worth understanding because it is the exact interaction between the two triggers this note and Direct Reclaim describe: a task in the allocator’s retry loop calls wake_all_kswapds() on every iteration, kswapd wakes, finds the node order-0 balanced, tries to nap, gets woken again by the next retry, and never reaches the point where it would have woken the daemon that could actually help. The fix is one line moved.
There is a second kcompactd wakeup, in balance_pgdat()’s exit path, and it uses a different order:
if (boosted) {
/* ... decay each zone's watermark_boost ... */
wakeup_kcompactd(pgdat, pageblock_order, highest_zoneidx);
}pageblock_order (9 on x86-64, i.e. 2 MiB) rather than the caller’s order, because a boosted pass was about fragmentation to begin with: the goal is whole clean pageblocks, not a specific allocation. Note also that kcompactd has been affine to its node via the proper kthread API since 6.13 (Frederic Weisbecker, commit 54880b5a2b5e), which replaced a manual set_cpus_allowed_ptr() and, as a bonus, made it respect CPU isolation and hotplug — relevant if you run isolcpus or nohz_full.
kswapd<N> | kcompactd<N> | |
|---|---|---|
| Job | Frees pages — reclaim | Moves pages — compaction |
| Source file | mm/vmscan.c | mm/compaction.c |
| Woken by | wakeup_kswapd() from the allocator slowpath | wakeup_kcompactd() — from kswapd, or directly from wakeup_kswapd() when the node is balanced |
| Wait queue | pgdat->kswapd_wait | pgdat->kcompactd_wait |
| Success condition | pgdat_balanced() — free pages above a watermark | Contiguous runs of the requested order exist |
| Fixes | Shortage | Fragmentation |
| Counters | pageoutrun, pgscan_kswapd, pgsteal_kswapd | compact_daemon_wake, compact_daemon_migrate_scanned, compact_daemon_free_scanned |
The two per-node daemons compared. The insight to take: they solve genuinely different problems and the handoff is one-directional — kswapd wakes kcompactd, never the reverse. If your allocations are failing while MemFree is large, kswapd is not your problem and reclaim tuning will not help you; see Memory Compaction.
Watermark boosting: defragmentation pressure
There is a second reason kswapd reclaims beyond high: watermark boosting. When the buddy allocator has to steal a pageblock of a different migratetype — a fragmentation event — it temporarily raises that zone’s effective watermarks so kswapd frees a little extra, on the theory that the freed pages will coalesce into whole clean pageblocks and prevent the next fallback. The trigger is in steal_suitable_fallback():
if (boost_watermark(zone) && (alloc_flags & ALLOC_KSWAPD))
set_bit(ZONE_BOOSTED_WATERMARK, &zone->flags);and boost_watermark() computes the amount:
if (!watermark_boost_factor) return false;
if ((pageblock_nr_pages * 4) > zone_managed_pages(zone)) return false; /* tiny zone */
max_boost = mult_frac(zone->_watermark[WMARK_HIGH], watermark_boost_factor, 10000);
if (!max_boost) return false; /* pre-init */
max_boost = max(pageblock_nr_pages, max_boost);
zone->watermark_boost = min(zone->watermark_boost + pageblock_nr_pages, max_boost);Walked symbol by symbol on the measured box: watermark_boost_factor defaults to 15000 (mm/page_alloc.c line 274), and mult_frac(x, 15000, 10000) is 1.5 × x, so the ceiling is 150% of the high watermark — for the Normal zone, 1.5 × 81,317 = 121,975 pages ≈ 476 MiB of extra reclaim headroom. Each individual fragmentation event adds only pageblock_nr_pages (512 pages, 2 MiB on x86-64) to the boost, so the boost is proportional to the recent rate of fragmentation events — it accumulates under sustained fragmentation and decays after each balancing pass. The documentation states the same thing: “The level of reclaim is determined by the number of fragmentation events that occurred in the recent past. If this value is smaller than a pageblock then a pageblocks worth of pages will be reclaimed (e.g. 2MB on 64-bit x86). A boost factor of 0 will disable the feature” (vm.rst). The (pageblock_nr_pages * 4) > zone_managed_pages(zone) guard exists because, per the source comment, “on small machines, including kdump capture kernels running in a small area, boosting the watermark can cause an out of memory situation immediately.”
A boosted pass runs under a different policy than an ordinary one, and this is the part that makes boosting safe:
| Setting during a boosted pass | Value | Why |
|---|---|---|
sc.may_writepage | 0 | Do not issue writeback I/O for a defragmentation goal |
sc.may_swap | 0 | Do not swap anonymous memory for a defragmentation goal |
raise_priority | forced false at DEF_PRIORITY - 2 | Cap the scan depth — never grind for fragmentation |
| Loop exit | if (nr_boost_reclaim && !nr_reclaimed) break; | Abandon immediately if a pass frees nothing |
| If the node is genuinely imbalanced | nr_boost_reclaim = 0; goto restart; | Real shortage always outranks defragmentation |
| On exit | Decay zone->watermark_boost, then wakeup_kcompactd(pgdat, pageblock_order, ...) | Hand the freed pages to the compactor |
How balance_pgdat() restricts a boosted pass, mm/vmscan.c at v6.12. What it shows: boosted reclaim is deliberately crippled — no writeback, no swap, capped priority, and it aborts on the first unproductive pass. The insight to take: the kernel is willing to throw away clean, cheap page cache to reduce fragmentation, and unwilling to spend a single disk write on it. That is the correct trade and it is worth remembering when someone proposes raising watermark_boost_factor “to help THP” — the extra reclaim it buys is only ever the cheapest kind, so the knob’s downside is lost page cache, not I/O.
The feature was added by Mel Gorman for 4.20 with measured justification. His commit describes the problem — “The kernel reduces the probability of such events by increasing the watermark sizes by calling set_recommended_min_free_kbytes early in the lifetime of the system. This works reasonably well in general but if there are enough sparsely populated pageblocks then the problem can still occur as enough memory is free overall and kswapd stays asleep” — and reports, on a THP-heavy fio workload, external-fragmentation events falling from 804,694 to 408,912 (a 49% reduction) with the boost patch alone and to 18,421 (98%) with the full series, while THP allocation success went from 0% to 5.12% (commit 1c30844d2dfe). Set vm.watermark_boost_factor=0 to disable it; the feature has been contentious enough that disabling it is a documented, supported configuration.
Why background reclaim is latency-invisible — until it isn’t
Everything about kswapd is designed so that applications never observe it, and on a healthy machine they do not. It runs on its own thread, on its own node’s CPUs, ahead of demand, in large batches, and it is exempt from the throttles that would make it wait. The measured consequence on the box in Memory Reclaim Overview is stark: nine days of continuous reclaim pressure, kswapd scanning 127 million folios, and 0.0052% of wall-clock time lost to memory stalls.
There are exactly four ways that invisibility breaks, and each has a distinct signature:
| Failure of invisibility | Mechanism | What you observe |
|---|---|---|
| 1. It loses the race | Allocations consume the low..min band faster than one thread can refill it | pgscan_direct rises toward and past pgscan_kswapd; allocstall climbs; PSI some rises. The fix is watermark_scale_factor. |
| 2. It never sleeps | Re-woken inside its 100 ms nap, over and over | kswapd_low_wmark_hit_quickly approaches pageoutrun; one core pinned at 100% by a kswapd<N> thread |
| 3. It becomes the CPU contention | It is bound to cpumask_of_node(), so it competes with your threads on that node’s CPUs | A latency-sensitive thread pinned to the same node loses runqueue time; kswapd appears high in top |
| 4. It sets flags that stall everyone else | PGDAT_WRITEBACK / LRUVEC_NODE_CONGESTED, set by kswapd, are obeyed by every direct reclaimer | PSI full approaches PSI some — the whole machine stalls together rather than one thread at a time |
The four ways kswapd becomes visible. What it shows: only the first is “kswapd is too slow.” The second is an oscillation, the third is a scheduling problem, and the fourth is kswapd making a diagnosis that everyone else is then bound by. The insight to take: case 3 is the one nobody expects and the one that bites hardest on NUMA hosts — the daemon defending node 1’s memory runs only on node 1’s CPUs, so a hot node gets a reclaim thread stealing cycles from exactly the workload that is already struggling. Pinning a latency-sensitive service to a node does not isolate it from that node’s reclaim; it guarantees they share CPUs.
One daemon per node, and what that means on NUMA
kswapd_run(nid) is called per node, so a two-socket machine has kswapd0 and kswapd1, each set_cpus_allowed_ptr(tsk, cpumask_of_node(pgdat->node_id)). Three consequences follow that aggregate metrics hide:
Reclaim is per-node, so pressure is per-node. A node can be at its min watermark and stalling allocations while the machine reports 40% of memory free, because the free memory is on the other node and the allocation’s mempolicy will not let it go there. MemFree in /proc/meminfo is a sum across nodes and is actively misleading under NUMA pressure; /proc/zoneinfo and numastat are not.
A remote wakeup is possible and is handled specially. wake_all_kswapds() walks the allocation’s whole zonelist and calls wakeup_kswapd() once per distinct pgdat, so a task on node 0 whose allocation may fall back to node 1 wakes both daemons. pgdat_balanced() has an explicit case for the resulting oddity: “If a node has no managed zone within highest_zoneidx, it does not need balancing by definition. This can happen if a zone-restricted allocation tries to wake a remote kswapd.”
Fairness between nodes is not a policy — it is an emergent property of zonelist order. There is no cross-node balancer. Each kswapd defends its own node against whatever the allocator points at it, and the allocator’s node preference is set by the task’s mempolicy and zone_reclaim_mode. The one place cross-node behaviour is explicit is memory tiering: when sysctl_numa_balancing_mode has NUMA_BALANCING_MEMORY_TIERING set, pgdat_balanced() targets promo_wmark_pages(zone) — one watermark_scale_factor step above high — instead of high_wmark_pages(zone), so a fast tier keeps extra headroom for pages being promoted up from a slow tier. See NUMA Memory Tiering and Memory Zones and Nodes.
Watching per-node rather than in aggregate is therefore not optional on a multi-socket box:
# Per-node watermarks vs free — the only view that shows node-local pressure.
grep -E 'Node|zone |min|low|high|free|nr_free_pages' /proc/zoneinfo
# Which kswapd is burning CPU, and on which node's CPUs it is allowed to run.
ps -eo pid,comm,psr,pcpu | grep kswapd
for p in $(pgrep '^kswapd'); do echo "$p: $(cat /proc/$p/status | grep Cpus_allowed_list)"; done
# Per-node allocation and fallback behaviour.
numastat -m | head -20Configuration / Tuning
The knobs that govern kswapd (vm sysctl docs):
# How aggressively kswapd reclaims = the distance between watermarks.
# Fraction of 10000 of node memory; default 10 (=0.1%). Larger = kswapd
# wakes earlier (low further from min) AND reclaims more (high further
# from low) -> more headroom before tasks hit direct reclaim.
sysctl vm.watermark_scale_factor=200 # 2%
# Absolute reserve floor -> scales min, and thus low and high, up.
sysctl vm.min_free_kbytes=262144 # 256 MiB
# Fragmentation-driven extra reclaim toward boosted watermarks.
sysctl vm.watermark_boost_factor=15000 # default; 0 disables
# Bias between reclaiming file cache vs swapping anonymous pages.
sysctl vm.swappiness=60 # see Swappiness noteThe watermark math (mm/page_alloc.c:__setup_per_zone_wmarks) shows why watermark_scale_factor is the master knob for kswapd. With gap = max(min/4, managed_pages * watermark_scale_factor / 10000):
zone->_watermark[WMARK_LOW] = min_wmark_pages(zone) + gap;
zone->_watermark[WMARK_HIGH] = low_wmark_pages(zone) + gap;Both the wake distance (low − min) and the reclaim distance (high − low) equal gap, so raising watermark_scale_factor simultaneously makes kswapd start sooner and reclaim more before sleeping. On a 256 GiB host with the default 0.1%, kswapd wakes with only ~256 MiB of slack — easily outrun by a fast allocator — which is exactly why latency-sensitive fleets routinely raise it. The kernel documentation names this exact symptom as the trigger for the knob: “A high rate of threads entering direct reclaim (allocstall) or kswapd going to sleep prematurely (kswapd_low_wmark_hit_quickly) can indicate that the number of free pages kswapd maintains for latency reasons is too small for the allocation bursts occurring in the system” (vm.rst). See Watermarks and the Allocation Fast Path for the full watermark model.
Where your min_free_kbytes actually came from
Before tuning min_free_kbytes it is worth knowing that the value you see was probably not set by the formula everyone quotes. calculate_min_free_kbytes() computes:
lowmem_kbytes = nr_free_buffer_pages() * (PAGE_SIZE >> 10);
new_min_free_kbytes = int_sqrt(lowmem_kbytes * 16);
min_free_kbytes = clamp(new_min_free_kbytes, 128, 262144);so it is sqrt(16 × lowmem_kbytes), clamped to 128 KiB minimum and 256 MiB maximum, with the source’s own worked table alongside it: 128 MiB of RAM → 1,448 KiB; 1 GiB → 4,096 KiB; 16 GiB → 16,384 KiB. It is deliberately sub-linear because, as the comment says, “network bandwidth does not increase linearly with machine size.”
But init_per_zone_wmark_min() then calls khugepaged_min_free_kbytes_update(), and when transparent huge pages are enabled that function can raise the value:
recommended_min = pageblock_nr_pages * nr_zones * 2;
recommended_min += pageblock_nr_pages * nr_zones * MIGRATE_PCPTYPES * MIGRATE_PCPTYPES;
recommended_min = min(recommended_min, nr_free_buffer_pages() / 20); /* never > 5% */
recommended_min <<= (PAGE_SHIFT - 10);
if (recommended_min > min_free_kbytes)
min_free_kbytes = recommended_min;Worked on the box measured in Memory Reclaim Overview — 3 zones at or below ZONE_NORMAL (DMA, DMA32, Normal), pageblock_nr_pages = 512, MIGRATE_PCPTYPES = 3:
| Term | Arithmetic | Pages |
|---|---|---|
| Two free pageblocks per zone | 512 × 3 × 2 | 3,072 |
| Two almost-free blocks per migratetype pair | 512 × 3 × 3 × 3 | 13,824 |
| Subtotal | 16,896 | |
| 5% cap | 32,787,560 / 20 = 1,639,378 | not binding |
| Converted to KiB | 16,896 << (12 − 10) | 67,584 KiB |
Deriving min_free_kbytes from set_recommended_min_free_kbytes() in mm/khugepaged.c at v6.12. What it shows: the computed value, 67,584 KiB, is exactly what /proc/sys/vm/min_free_kbytes reads on that machine — and it is nearly 50% larger than the sqrt(16 × lowmem_kbytes) formula would give for 125 GiB of RAM (~45,800 KiB). The insight to take: on any THP-enabled machine, min_free_kbytes — and therefore min, low, and high for every zone, and therefore kswapd’s entire operating band — is being set by fragmentation-avoidance policy, not by the classic reserve heuristic. If you disable THP you will silently shrink kswapd’s band; if you raise min_free_kbytes by hand, user_min_free_kbytes is recorded and the boot heuristic will stop overriding you.
Reading kswapd’s own health
kswapd gets four counters of its own, and their ratios are the diagnosis:
| Counter | Meaning | Healthy | Unhealthy |
|---|---|---|---|
pageoutrun | balance_pgdat() invocations | Grows steadily | — |
kswapd_low_wmark_hit_quickly | Woken during the 100 ms nap | Small fraction of pageoutrun | Approaching pageoutrun: the daemon never sleeps |
kswapd_high_wmark_hit_quickly | Nap finished, node already unbalanced again | Near zero | Large: reclaim is undone as fast as it is done |
pgsteal_kswapd / pgscan_kswapd | Scan efficiency | >80% | <50%: scanning folios it cannot free — check for pinned/mlocked memory |
pgscan_kswapd / (pgscan_kswapd + pgscan_direct) | Share of scanning done in the background | >80% | <50%: applications are doing the majority of the work — see Direct Reclaim |
kswapd_inodesteal | Pages reclaimed by dropping inodes | Any value | — (informational; see Shrinkers and Slab Reclaim) |
The kswapd-side counters and their ratios. The insight to take: pageoutrun alone is meaningless — a busy file server can do millions of balancing passes and be perfectly healthy. The number that matters is kswapd_low_wmark_hit_quickly / pageoutrun, which is the fraction of wakeups that failed to end in a real sleep. On the measured box that ratio is 93.6% (109,704 of 117,175), meaning the daemon effectively did not sleep for nine days — and yet PSI showed 0.005% stall time, because it was still winning. Both facts are true simultaneously, which is why this ratio is a signal to investigate and never, on its own, a reason to change anything.
Inspecting kswapd’s effect:
# Per-zone watermarks and free pages (look at min/low/high vs 'free'):
cat /proc/zoneinfo | grep -E 'Node|zone|min|low|high|free'
# Background vs synchronous reclaim split (healthy = mostly _kswapd):
grep -E 'pgscan_kswapd|pgsteal_kswapd|pgscan_direct|pageoutrun|kswapd_(low|high)_wmark_hit_quickly' /proc/vmstat
# kswapd threads themselves (one per node):
ps -e | grep kswapd
# Trace kswapd wake/sleep:
echo 1 > /sys/kernel/tracing/events/vmscan/mm_vmscan_kswapd_wake/enable
echo 1 > /sys/kernel/tracing/events/vmscan/mm_vmscan_kswapd_sleep/enableA high pageoutrun with most scanning under pgscan_kswapd and little pgscan_direct is the healthy picture: kswapd is doing the reclaim and applications are not stalling. The inverse — pgscan_direct/allocstall rising — means kswapd is losing the race (see Direct Reclaim).
Failure Modes and Common Misunderstandings
- “kswapd at 100% CPU means a leak.” Not necessarily. kswapd burning a core means it is reclaiming hard and barely keeping up (or failing to). It is a symptom of sustained memory pressure, not the cause; the cause is whatever is consuming memory faster than it can be freed. If kswapd cannot make progress (
kswapd_failuresclimbing), the node is declared hopeless and tasks fall to direct reclaim / OOM. - “There’s one kswapd.” No — there is one per NUMA node (
kswapd0…kswapdN). On a 2-socket box you have two, each bound to its node’s CPUs and balancing its own zones. A single overloaded node can have its kswapd pegged while another node’s is idle. - kswapd targets low, not high. A frequent error. kswapd is woken at the low watermark but reclaims up to the high watermark — it intentionally over-shoots to build a buffer so it can sleep. The reclaim distance is
high − low, set bywatermark_scale_factor. - Confusing kswapd with kcompactd. kswapd frees pages (reclaim); kcompactd moves pages to assemble contiguous runs (compaction). kswapd hands off to kcompactd when it goes to sleep, because free-but-fragmented memory is kcompactd’s problem, not kswapd’s.
- Expecting kswapd to fix an atomic allocation failure.
wakeup_kswapd()is asynchronous; an atomic (GFP_ATOMIC) allocation that fails cannot wait for kswapd to free memory — it fails immediately. kswapd helps the next allocations, not the one that woke it. (It does get a consolation prize: when the node is already balanced, a non-blocking caller is the only one for whomwakeup_kswapd()will wakekcompactdon its behalf.) - “
kswapdbalances all zones.” It stops at the first eligible zone that meets its high watermark, walking bottom-up. This is not a bug or an approximation — it is a deliberate fix for machines where a smallNormalzone next to a largeDMA32causedkswapdto reclaim endlessly and swap out a working set nobody wanted evicted (commit9950474883e0, above). - “
pageoutrunis high, sokswapdis struggling.”pageoutruncounts wakeups, not difficulty. A file-heavy workload wakeskswapdconstantly and is fine. The ratiokswapd_low_wmark_hit_quickly / pageoutrunis the number that distinguishes “busy” from “thrashing,” and even that must be read next to PSI before acting. - Assuming
kswapdis exempt from everything. It is exempt fromtoo_many_isolated(), fromNOPROGRESSandCONGESTEDthrottling, and from the pfmemalloc wait — but there is one throttle it alone is subject to:VMSCAN_THROTTLE_WRITEBACKwhensc->nr.immediateis non-zero, i.e. it encountered folios already flagged for immediate reclaim and still under writeback. The source is blunt about what that means: folios “are cycling through the LRU faster than they are written.” That is a storage-throughput problem, and no reclaim tuning fixes it. - Trying to renice or pin
kswapdaway from your workload. It is bound tocpumask_of_node()by design, because reclaiming a node’s memory from another node’s CPUs means remote memory access on every LRU-list operation. You can change its affinity, but you are trading a latency problem you can see for a throughput problem you cannot. - Reading
MemFreeon a NUMA box. It is a sum. A node can be pinned at itsminwatermark, stalling every allocation with a mempolicy bound to it, whileMemFreelooks comfortable. Use/proc/zoneinfo. - Believing background reclaim is free. It is free of allocation latency, not of cost. It burns CPU on a node-local core, it issues writeback and swap I/O that competes with the application’s own I/O, and it evicts page cache the application may want back — which shows up later as
workingset_refault_file, not as a stall. “Invisible” means “not on the critical path,” not “no impact.”
What changed after the 6.12 pin
Dated to the release that introduced it; none of this is present at the pin. Verified by reading the corresponding tags.
| Change | Landed | Effect on this note |
|---|---|---|
defrag_mode sysctl; pgdat_balanced() uses NR_FREE_PAGES_BLOCKS when defrag_mode && order, so the high watermark must be met in whole pageblocks | 6.15 (absent at v6.14, present at v6.15) | Materially changes when kswapd considers itself done. Off by default. |
pgdat_balanced() takes an explicit percpu_drift_mark snapshot when the per-CPU vmstat error could blur the watermark | by 6.18 | Reduces false “balanced” verdicts on high-core-count machines |
pgdat->kswapd_failures becomes atomic_t | by 6.18 (int at v6.12) | Matters if you read the field from BPF |
kcompactd affine to its node via the kthread API rather than set_cpus_allowed_ptr() | 6.13 (commit 54880b5a2b5e) | kcompactd now respects CPU isolation and hotplug |
for_each_managed_zone_pgdat() iterator macro | by 6.18 | Pure refactor; balance_pgdat() and allow_direct_reclaim() read differently, behave the same |
kswapd_try_to_sleep(), the two *_wmark_hit_quickly counters, the 100 ms nap | — | Byte-identical at v6.12 and v6.18 |
The defrag_mode change is the most consequential for anyone reading this note against a newer kernel, and it comes with published numbers. Johannes Weiner’s commit reports that requiring the high watermark in whole pageblocks moved reclaim work from direct reclaim to kswapd — Pages kswapd scanned +50.63%, Pages direct scanned −50.81% — and cut Alloc stall by 73.62% (commit a211c6550efc, March 2025). Total reclaim work rose ~29% and the workload still got faster, which is the entire thesis of background reclaim stated as a measurement: the quantity to minimise is not reclaim work, it is reclaim work on an application’s thread.
Alternatives and Relationships
- Direct Reclaim — the synchronous fallback. kswapd’s whole purpose is to make direct reclaim rare; when kswapd cannot keep up, allocating tasks reclaim inline and pay the latency. The two share the
shrink_node()engine but differ in who runs it and when. - kcompactd — the per-node compaction daemon kswapd wakes on sleep. Reclaim frees pages; compaction defragments them.
- Per-cgroup Reclaim and Memory Pressure — under cgroup v2, per-memcg limits (
memory.high) trigger targeted reclaim of a cgroup’s pages, scoped rather than node-wide; this is a parallel reclaim path to the global kswapd. - The OOM Killer — when kswapd marks a node hopeless (
kswapd_failures >= MAX_RECLAIM_RETRIES) and direct reclaim also fails, the OOM killer is the terminal recovery.
Production Notes
The operational summary of everything above fits in one sentence: kswapd is not something you tune, it is something you give runway to. There is no knob that makes the daemon scan faster or run more threads; the only levers are the watermarks that define when it starts and when it stops, and the memory it has to work with. Every “kswapd tuning” recommendation reduces to widening the band it operates in.
The canonical kswapd-tuning move on large-memory, latency-sensitive servers is raising vm.watermark_scale_factor (often to 100–500, i.e. 1–5%) so kswapd wakes far earlier and maintains a larger free buffer, keeping tasks off the direct-reclaim path. The default 0.1% was chosen for small systems and is far too tight for hundreds of gigabytes of RAM. Monitoring is PSI-first (/proc/pressure/memory) to detect stalls, then /proc/vmstat’s pgscan_kswapd vs pgscan_direct ratio to confirm whether kswapd is winning or losing the race. On NUMA hosts, watch per-node: a single hot node’s kswapd can saturate while the fleet looks fine in aggregate. In containerized stacks, node-level kswapd coexists with per-cgroup reclaim; setting pod memory.high below memory.max lets a workload self-throttle via proactive reclaim before it ever pressures node-wide kswapd or hits OOM (see Per-cgroup Reclaim and Memory Pressure and, for orchestration, the Kubernetes MOC).
Uncertain
Verify: the behavioural dynamics of watermark boosting across releases. Reason:
MAX_RECLAIM_RETRIES = 16(mm/internal.hline 468 atv6.12) andwatermark_boost_factor = 15000(mm/page_alloc.cline 274 atv6.12) are pinned to source and are not in doubt, but the policy around boosting has been re-litigated repeatedly —24512228b7a3disabled it for DISCONTIGMEM,28360f398778stopped special-casing slab reclaim under a boost, and597c892038e0fixed premature wakeups when boosting is disabled. It is an actively-tuned heuristic, not a stable contract. To resolve: re-readboost_watermark()inmm/page_alloc.cand thenr_boost_reclaimhandling inbalance_pgdat()at the exact kernel you run, and confirm the live value withsysctl vm.watermark_boost_factor, before relying on the precise dynamics. uncertain
Uncertain
Verify: that raising
vm.watermark_scale_factorinto the 100–500 range is a sound default for large-memory hosts. Reason: the direction is endorsed by the kernel documentation, which namesallocstallandkswapd_low_wmark_hit_quicklyas the symptoms this knob addresses, and the arithmetic is unambiguous (0.1% of 256 GiB is only ~256 MiB of runway for one thread to defend). But no primary source consulted here recommends a numeric value for large machines —vm.rstgives only the default (10) and the maximum (3000). The 100–500 figure is folklore, however well-motivated. To resolve: raise it stepwise on one host and measurepgscan_direct/pgscan_kswapd,kswapd_low_wmark_hit_quickly/pageoutrun, and PSIsometogether. The cost of overshooting is a permanently larger free reserve — i.e. less page cache — which will show up as risingworkingset_refault_filebefore it shows up anywhere else. uncertain
Uncertain
Verify: that the LWN articles an earlier revision of this note cited on watermark boosting exist at those URLs. Reason: both were fetched on 2026-09-04 and neither served the cited article —
Articles/776662returns “Mageia alert MGASA-2019-0035 (python-django)” andArticles/783965returns a comment page on “The congestion-notification conflict,” a networking piece. The URLs appear to have been constructed rather than looked up. They have been removed from the body and are recorded insources:as rejected. To resolve: locate the genuine LWN coverage of watermark boosting through LWN’s own search (theSearch/DoSearchGET endpoint used here returned only the empty search form) and re-cite. The kernel commits cited in their place are stronger primary sources regardless. uncertain
See Also
- Direct Reclaim — the synchronous fallback kswapd exists to prevent, and the note that owns the stall: slowpath gates, the two throttles,
allocstall, PSI, and production recognition. - Watermarks and the Allocation Fast Path — the min/low/high lines kswapd lives between.
- Memory Reclaim Overview — the umbrella: what reclaim is, the watermark arithmetic on a measured box, victim selection, MGLRU, shrinkers, and the shared scanning engine.
- Per-CPU Page Lists — lowered by
ZONE_RECLAIM_ACTIVEwhile kswapd runs, so freed pages become visible to watermark checks sooner. - NUMA Memory Tiering — why
pgdat_balanced()sometimes targetspromo_wmark_pages()instead ofhigh_wmark_pages(). - The LRU Lists · Shrinkers and Slab Reclaim — what kswapd’s scanner walks and frees.
- Memory Compaction — kcompactd, which kswapd hands off to on sleep.
- Memory Zones and Nodes · NUMA Memory Model — why kswapd is per-node.
- Pressure Stall Information — PSI brackets balance_pgdat.
- The OOM Killer — the terminal step when kswapd is declared hopeless.
- Linux Memory Management MOC — parent map (§8 Memory Reclaim and the LRU).