Per-cgroup Reclaim and Memory Pressure
Per-cgroup (memcg) reclaim is the kernel reclaim machinery re-scoped to a single memory cgroup subtree instead of the whole machine. The exact same LRU-scanning code that runs under global memory pressure —
shrink_node,shrink_lruvec, the LRU lists — is pointed at one cgroup by settingscan_control.target_mem_cgroup. It runs in four distinct situations: target reclaim when a cgroup breaches itsmemory.high(throttle) ormemory.max(hard limit); proactive reclaim triggered from userspace by writing tomemory.reclaim; thememory.min/memory.lowprotection that deflects global reclaim away from a cgroup’s pages; and the per-cgroup pressure signal (PSI, exposed atmemory.pressure) that tells a management agent how much a cgroup is stalling on memory. This note traces those paths in Linux v6.12 — the vault’s reference pin, not “the LTS”: as of 2026-09-12kernel.org/releases.jsonlists mainline7.3-rc2, stable7.2.5, and six longterm series including both6.18.51and6.12.109. Everything is verified againstmm/memcontrol.c,mm/vmscan.c,kernel/sched/psi.candDocumentation/admin-guide/cgroup-v2.rstat thev6.12tag, with volatile interfaces spot-checked againstv6.18. This is the kernel mechanism that a Kubernetes pod memory limit ultimately bottoms out in (orchestration policy lives in the Kubernetes MOC; this note owns the kernel side).
The single most consequential thing in this note is in its title, and it is a distinction, not a conjunction. “Per-cgroup reclaim” and “memory pressure” are not two names for one phenomenon, and
memory.pressureis not a reclaim meter. Measured on the machine this note was written on, a cgroup withmemory.high=32Mand swap disabled accumulated 431 seconds of cumulative PSI memory stall and 4,353memory.eventshightransitions whilememory.statreportedpgscan 0andpgsteal 0— not one page scanned, not one page freed. The entire stall wasschedule_timeout_killable()atmm/memcontrol.c:2149, a deliberate punitive sleep with no reclaim in it. In the same experiment, the run that did reclaim — 21,695 pages scanned and 16,968 stolen — registered 36 milliseconds of pressure, four orders of magnitude less. And a full memcg OOM kill registered 39 microseconds. A dashboard that readsmemory.pressureas “how hard is this cgroup reclaiming” gets all three of those backwards. The section Pressure is not reclaim below has the full table and thepsi_memstall_enter()call-site map that explains why.
Context
This note sits in section 14 of the Linux Memory Management MOC. It is the reclaim-path counterpart to its siblings: The Memory Cgroup memcg covers what a memcg is and the controller’s structure; memcg Charging and Limits covers how pages get charged to a cgroup and how the page_counters for memory.current/memory.high/memory.max work; this note covers what happens on the reclaim path when those limits are hit and how global reclaim respects per-cgroup protection. Pressure Stall Information covers the PSI mechanism in general; here we cover only its per-cgroup memory.pressure facet. The generic reclaim flow it builds on is Memory Reclaim Overview, and the lists it scans are The LRU Lists (or Multi-Generational LRU when enabled).
The one idea: reclaim with a target_mem_cgroup
Global reclaim and per-cgroup reclaim are the same code. The pivot is one field in struct scan_control (v6.12, mm/vmscan.c):
struct scan_control {
...
struct mem_cgroup *target_mem_cgroup; /* NULL => global; set => scoped */
...
};
static bool cgroup_reclaim(struct scan_control *sc) { return sc->target_mem_cgroup; }
static bool root_reclaim(struct scan_control *sc) { return !sc->target_mem_cgroup || mem_cgroup_is_root(sc->target_mem_cgroup); }When target_mem_cgroup is NULL, reclaim walks every memcg’s lruvec on the node, freeing pages system-wide. When it is set to a cgroup, reclaim iterates only that cgroup and its descendants. The entry point for the scoped case is try_to_free_mem_cgroup_pages() (v6.12, mm/vmscan.c), which builds a scan_control with target_mem_cgroup = memcg and calls the ordinary do_try_to_free_pages → shrink_node → shrink_node_memcgs → shrink_lruvec chain. Everything below shrink_node is shared; only the scope differs.
flowchart TB CHARGE["page charge in try_charge()<br/>usage vs limits"] HIGH{"usage > memory.high?"} MAX{"usage > memory.max?"} USER["userspace writes<br/>memory.reclaim"] GLOBAL["global reclaim<br/>(kswapd / direct)"] CHARGE --> HIGH CHARGE --> MAX HIGH -->|"yes (batched)"| OVERHIGH["mem_cgroup_handle_over_high()<br/>reclaim + throttle penalty"] MAX -->|"yes"| TRYCHARGE["try_charge() reclaim loop<br/>then memcg OOM if stuck"] USER --> PROACTIVE["memory_reclaim handler<br/>MEMCG_RECLAIM_PROACTIVE"] OVERHIGH --> T2F["try_to_free_mem_cgroup_pages(memcg)"] TRYCHARGE --> T2F PROACTIVE --> T2F T2F --> SNM["shrink_node_memcgs(): iterate subtree"] GLOBAL --> SNM SNM --> PROT{"memory.min / memory.low<br/>protection check"} PROT -->|"below min"| SKIP1["skip (hard protect)"] PROT -->|"below low"| SKIP2["skip unless 2nd pass"] PROT -->|"above"| SL["shrink_lruvec(): scan LRU,<br/>scaled by overage"]
The four entry points into per-cgroup reclaim and where they converge. What it shows: memory.high (throttle, batched), memory.max (hard limit, can OOM), and memory.reclaim (proactive) all funnel into try_to_free_mem_cgroup_pages with a target memcg; global reclaim enters the same shrink_node_memcgs from the other side. The single shared chokepoint is the protection check, where memory.min/memory.low can deflect reclaim. The insight: the limits differ only in what they do when reclaim fails — high throttles, max OOM-kills, reclaim returns -EAGAIN — but the reclaim work itself is one code path scoped by target_mem_cgroup.
shrink_node_memcgs: the subtree iteration
The hinge between “reclaim a node” and “reclaim a cgroup” is shrink_node_memcgs() (v6.12, mm/vmscan.c). It walks the cgroup subtree rooted at target_mem_cgroup and reclaims each member’s lruvec in turn:
static void shrink_node_memcgs(pg_data_t *pgdat, struct scan_control *sc)
{
struct mem_cgroup *target_memcg = sc->target_mem_cgroup;
struct mem_cgroup *memcg;
...
memcg = mem_cgroup_iter(target_memcg, NULL, partial);
do {
struct lruvec *lruvec = mem_cgroup_lruvec(memcg, pgdat);
...
mem_cgroup_calculate_protection(target_memcg, memcg);
if (mem_cgroup_below_min(target_memcg, memcg)) {
continue; /* HARD protect: never reclaim */
} else if (mem_cgroup_below_low(target_memcg, memcg)) {
if (!sc->memcg_low_reclaim) {
sc->memcg_low_skipped = 1; /* SOFT protect: skip 1st pass */
continue;
}
memcg_memory_event(memcg, MEMCG_LOW);
}
shrink_lruvec(lruvec, sc); /* the actual LRU scan */
shrink_slab(sc->gfp_mask, pgdat->node_id, memcg, sc->priority);
if (!sc->proactive)
vmpressure(...); /* feed legacy vmpressure */
if (partial && sc->nr_reclaimed >= sc->nr_to_reclaim) {
mem_cgroup_iter_break(target_memcg, memcg);
break;
}
} while ((memcg = mem_cgroup_iter(target_memcg, memcg, partial)));
}Symbol by symbol:
mem_cgroup_iter(target_memcg, ...)walkstarget_memcgand all its descendants. For global reclaimtarget_memcgis the root, so it visits every cgroup; for scoped reclaim it visits only the named subtree. The same loop, two scopes.mem_cgroup_lruvec(memcg, pgdat)resolves the per-cgroup-per-node lruvec — the five LRU lists belonging to this cgroup on this node. That is the entire trick: because every memcg has its ownlruvec, reclaim of one cgroup never touches another’s pages.mem_cgroup_calculate_protectionthen thebelow_min/below_lowchecks implement protection (next section).shrink_lruvecdoes the real scanning (active→inactive demotion and inactive eviction — see The LRU Lists), andshrink_slabreclaims the cgroup’s slab objects (dentries, inodes — see Shrinkers and Slab Reclaim), which are memcg-aware so a cgroup’s slab is reclaimed alongside its pages.- Partial walks. For direct reclaimers the iterator keeps a cookie (
mem_cgroup_reclaim_cookie) so successive invocations resume where they left off, balancing fairness against latency; forkswapd(andmemcg_full_walk) it always does a full walk because “reliable forward progress is more important than a quick return to idle” (source comment).
memory.min and memory.low: protection during global reclaim
This is the most important and most misunderstood per-cgroup reclaim behavior. memory.min and memory.low do not trigger reclaim — they deflect it. They tell global reclaim “leave this cgroup’s pages alone.” Per Documentation/admin-guide/cgroup-v2.rst (v6.12):
memory.min— hard protection. “If the memory usage of a cgroup is within its effective min boundary, the cgroup’s memory won’t be reclaimed under any conditions. If there is no unprotected reclaimable memory available, OOM killer is invoked.” This is themem_cgroup_below_min→continuebranch above: such a cgroup is skipped entirely, and if that starves reclaim of candidates, the OOM killer fires rather than violatemin.memory.low— best-effort protection. “If the memory usage of a cgroup is within its effective low boundary, the cgroup’s memory won’t be reclaimed unless there is no reclaimable memory available in unprotected cgroups.” This is themem_cgroup_below_lowbranch: on the first reclaim pass the cgroup is skipped andsc->memcg_low_skippedis set; if that pass cannot meet the goal, reclaim retries withsc->memcg_low_reclaim = 1and overrides low protection, emitting aMEMCG_LOWevent so the breach is observable.
Both boundaries are hierarchical and proportional, not binary. The cgroup-v2.rst design essay (v6.12) is explicit that this fixes the old soft_limit_in_bytes design, which had “no hierarchical meaning” and was “so aggressive that it… impacts system performance due to overreclaim, to the point where the feature becomes self-defeating.” memory.low is instead “a top-down allocated reserve… A cgroup enjoys reclaim protection when it’s within its effective low… It also enjoys having reclaim pressure proportional to its overage when above its effective low.” The “effective” qualifier matters: a child’s protection is capped by its ancestors’ (memory.min of all ancestors limits the effective min), and if siblings overcommit the parent’s protection, each child gets a share proportional to its usage below the boundary.
flowchart TB START["global reclaim reaches this memcg<br/>in shrink_node_memcgs()"] CALC["mem_cgroup_calculate_protection()<br/>computes EFFECTIVE min/low:<br/>capped by every ancestor,<br/>split proportionally among siblings"] MIN{"usage < effective<br/>memory.min ?"} LOW{"usage < effective<br/>memory.low ?"} P1{"first pass?<br/>(sc->memcg_low_reclaim == 0)"} SKIPMIN["continue — NEVER reclaimed.<br/>If this starves reclaim of all<br/>candidates, the OOM killer fires<br/>rather than violate min."] SKIPLOW["sc->memcg_low_skipped = 1<br/>continue — skip this pass"] BREACH["memcg_memory_event(MEMCG_LOW)<br/>→ memory.events 'low' increments<br/>protection is OVERRIDDEN"] SCAN["get_scan_count():<br/>scan = lruvec_size<br/>− lruvec_size × protection ÷ (cgroup_size+1)<br/>floor of SWAP_CLUSTER_MAX"] SHRINK["shrink_lruvec() + shrink_slab()"] RETRY["pass 1 missed its goal →<br/>retry with sc->memcg_low_reclaim = 1"] START --> CALC --> MIN MIN -->|yes| SKIPMIN MIN -->|no| LOW LOW -->|no| SCAN LOW -->|yes| P1 P1 -->|yes| SKIPLOW P1 -->|"no (2nd pass)"| BREACH BREACH --> SCAN SKIPLOW -.-> RETRY -.-> P1 SCAN --> SHRINK
The two-pass memory.min / memory.low protection decision, as executed once per memcg per reclaim pass. What it shows: min is an unconditional continue — reclaim will invoke the OOM killer before touching a cgroup inside its effective min. low is skipped only on the first pass; if that pass falls short, reclaim comes back with sc->memcg_low_reclaim set, records a MEMCG_LOW event so the breach is observable, and reclaims anyway. Above the boundary the cgroup is not simply unprotected — get_scan_count() scales its scan target by how much of its usage sits under protection. The insight to take: neither knob ever causes reclaim; both only redirect it. And the memory.events low counter exists precisely because low is a promise the kernel is allowed to break, so you need to know when it did.
The proportional part is implemented in get_scan_count (v6.12, mm/vmscan.c), which scales the per-list scan target nr[] down by how much of the cgroup’s usage sits under protection:
mem_cgroup_protection(sc->target_mem_cgroup, memcg, &min, &low);
if (min || low) {
unsigned long cgroup_size = mem_cgroup_size(memcg);
unsigned long protection;
if (!sc->memcg_low_reclaim && low > min) {
protection = low;
sc->memcg_low_skipped = 1;
} else {
protection = min;
}
cgroup_size = max(cgroup_size, protection);
scan = lruvec_size - lruvec_size * protection / (cgroup_size + 1);
scan = max(scan, SWAP_CLUSTER_MAX);
}Walk the key line scan = lruvec_size - lruvec_size * protection / (cgroup_size + 1): if a cgroup uses exactly its protection amount, protection ≈ cgroup_size so scan ≈ 0 (fully protected); as usage grows above protection, the fraction protection/cgroup_size shrinks and scan rises toward lruvec_size (full pressure). The comment explains why this gradient exists: without it, “scanning aggression becomes extremely binary — from nothing as we approach the memory protection threshold, to totally nominal as we exceed it,” forcing users to set absurdly liberal thresholds. The max(scan, SWAP_CLUSTER_MAX) floor keeps reclaim inching forward so a protected cgroup can still be the OOM victim rather than livelocking reclaim.
Pressure is not reclaim
Every PSI dashboard the author has seen labels the memory panel something like “memory pressure / reclaim activity,” and every one of them is conflating two quantities that the kernel keeps deliberately separate. This section establishes the distinction by measurement first and by source second, because the measurement is startling enough that the source reads differently afterwards.
The experiment
A 96 MiB anonymous working set is touched once, page by page, inside a cgroup created by an unprivileged user (uid 1000) under systemd’s delegated user@1000.service subtree. The only variables are memory.high, memory.max and memory.swap.max. Counters are read from memory.events, memory.stat and memory.pressure immediately after the run.
$ cat > hog.c <<'EOF'
size_t n = mb << 20; char *p = malloc(n);
for (size_t i = 0; i < n; i += 4096) p[i] = i; /* touch every page once */
EOF
$ gcc -O2 -o hog hog.c
$ cat > run.sh <<'EOF'
#!/bin/sh
d=$1; shift; echo $$ > $d/cgroup.procs || exit 1; exec "$@"
EOF
$ d=/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/t1.slice/exp
$ mkdir -p $d && echo 32M > $d/memory.high && echo 0 > $d/memory.swap.max
$ ./run.sh $d ./hog 96 1| Run | memory.high | memory.max | memory.swap.max | wall time | events.high | events.max | pgscan | pgsteal | PSI some total | outcome |
|---|---|---|---|---|---|---|---|---|---|---|
| A baseline | max | max | 0 | 0.04 s | 0 | 0 | 0 | 0 | 0 µs | completes instantly |
| B throttled, nothing reclaimable | 32M | max | 0 | >466 s, killed incomplete | 4,353 | 0 | 0 | 0 | 430,908,703 µs ≈ 431 s | pure penalty sleep |
| C throttled, swap available | 32M | max | max | 0.08 s | 165 | 0 | 21,695 | 16,968 | 35,512 µs ≈ 36 ms | real reclaim, no perceptible stall |
| D hard limit, swap available | max | 32M | max | 0.08 s | 0 | 289 | 16,525 | 16,524 | 29,231 µs ≈ 29 ms | try_charge reclaim loop |
| E hard limit, nothing reclaimable | max | 32M | 0 | 0.02 s | 0 | 36 | 0 | 0 | 39 µs | oom 1, oom_kill 1, SIGKILL |
The same workload under five memory-control regimes, measured on Fedora 44 / kernel 7.1.13 as uid 1000. What it shows: the two columns that a monitoring system treats as synonyms move in opposite directions. Run B has the maximum possible pressure reading and zero reclaim. Run C has essentially all of the reclaim and 1/12,000th of the pressure. Run E — a process being OOM-killed, the most severe memory outcome there is — produces 39 microseconds of pressure, which on a 10-second average rounds to 0.00. The insight to take: PSI memory pressure measures time tasks spent stalled, and the biggest single contributor to that time is a sleep that exists to slow the workload down, not reclaim. Reclaim that succeeds is fast and nearly invisible to PSI; reclaim that is impossible produces no PSI at all, because there is nothing to wait for; and the throttle in between produces almost all of it.
Three of those rows deserve unpacking.
Why B reclaims nothing. memory.swap.max = 0 plus an all-anonymous working set means can_reclaim_anon_pages() is false for this cgroup, so get_scan_count() assigns zero scan targets to the anon LRUs, and the file LRUs hold almost nothing (a statically linked 16 KiB binary). try_to_free_mem_cgroup_pages() is called, walks the lruvec, finds nothing eligible, and returns having scanned zero pages. reclaim_high() therefore returns nr_reclaimed == 0 every time, which is precisely the condition that drops mem_cgroup_handle_over_high() out of its retry loop and into the sleep. The throttle is not a supplement to reclaim; it is what happens when reclaim fails. Adding swap (run C) converts the same configuration into 21,695 pages scanned and 0.08 s wall time.
Why C is almost free. Reclaiming 16,968 pages to a zram-backed swap device is fast — tens of milliseconds — and PSI only accrues while a task is stalled. try_to_free_mem_cgroup_pages() is bracketed by psi_memstall_enter/leave in reclaim_high() (mm/memcontrol.c:1902), so those tens of milliseconds are counted: 36 ms of some total. That is the honest signal for “this cgroup did real reclaim work,” and it is four orders of magnitude below run B’s.
Why E is invisible. An OOM kill is not a stall. try_charge_memcg() attempts reclaim (bracketed at mm/memcontrol.c:2210), finds nothing, exhausts MAX_RECLAIM_RETRIES, and invokes the memcg OOM killer. The whole sequence takes microseconds because there is nothing to scan. memory.events records it unambiguously — max 36, oom 1, oom_kill 1 — but memory.pressure reports 39 µs and a 10-second average of 0.00. An alerting rule built on memory.pressure alone will not fire for an OOM kill. This is the single most important operational consequence in this note, and it is the reason oomd polls PSI and watches memory.events, rather than PSI alone.
Where PSI memory stall actually comes from
psi_memstall_enter() (v6.12 kernel/sched/psi.c:1047) is not subtle. It sets one flag on the current task and tells the scheduler:
void psi_memstall_enter(unsigned long *flags)
{
if (static_branch_likely(&psi_disabled))
return;
*flags = current->in_memstall; /* nesting guard: inner calls are no-ops */
if (*flags)
return;
rq = this_rq_lock_irq(&rf);
current->in_memstall = 1;
psi_task_change(current, 0, TSK_MEMSTALL | TSK_MEMSTALL_RUNNING);
rq_unlock_irq(rq, &rf);
}Note the two-line nesting guard: *flags snapshots current->in_memstall, and if it was already set the function returns immediately, so nested brackets do not double-count. psi_task_change sets both TSK_MEMSTALL (which feeds the some line) and TSK_MEMSTALL_RUNNING (which is what lets the full line distinguish “stalled but the CPU is still doing productive work elsewhere” from genuine thrashing). The accounting then propagates up the cgroup PSI hierarchy from the task’s cgroup to the root.
So “memory pressure” is, exactly and only, the wall-clock time that tasks spend inside one of these brackets. There are eleven such regions in v6.12, and enumerating them is the whole explanation:
| # | Site (v6.12) | Enclosing function | What is being waited for | Reclaim involved? |
|---|---|---|---|---|
| 1 | mm/memcontrol.c:1902 | reclaim_high() | try_to_free_mem_cgroup_pages() for a cgroup over memory.high — and every ancestor over its own high | yes |
| 2 | mm/memcontrol.c:2149 | mem_cgroup_handle_over_high() | schedule_timeout_killable(penalty_jiffies) — a punitive sleep, reached only after reclaim failed to make progress | no — this is the throttle |
| 3 | mm/memcontrol.c:2210 | try_charge_memcg() | try_to_free_mem_cgroup_pages() for a cgroup at memory.max, before the memcg OOM killer | yes |
| 4 | mm/vmscan.c:6849 | balance_pgdat() | the whole of kswapd’s background reclaim pass | yes — but see the caveat below |
| 5 | mm/vmscan.c:7480 | __node_reclaim() | node-local reclaim (vm.zone_reclaim_mode) | yes |
| 6 | mm/page_alloc.c:3679 | __alloc_pages_direct_compact() | synchronous compaction in the allocator slow path | no — compaction, not reclaim |
| 7 | mm/page_alloc.c:3948 | __alloc_pages_direct_reclaim() | global direct reclaim from the page allocator | yes |
| 8 | mm/filemap.c:1244 | folio_wait_bit_common() | waiting on PG_locked for a non-uptodate folio with PG_workingset set — i.e. a refault of a page that was recently evicted | no — thrashing |
| 9 | mm/filemap.c:1397 | migration_entry_wait_on_locked() | same refault condition, during page migration | no — thrashing |
| 10 | mm/filemap.c:2366 | filemap_read_folio() | a synchronous read of a workingset folio | no — thrashing |
| 11 | mm/page_io.c:627 | swap_read_folio() | swap-in of a workingset folio | no — thrashing |
| 12 | mm/readahead.c:156, :768, :797 | read_pages(), readahead_expand() | readahead that touched a workingset folio (rac->_workingset) | no — thrashing |
| 13 | mm/compaction.c:3185 | kcompactd() | background compaction work | no — compaction |
| 14 | block/blk-cgroup.c:1924 | blkcg_maybe_throttle_blkg() | the io.latency / io.cost throttle sleep, but only when use_memdelay is set — i.e. the IO being throttled is swap or writeback on reclaim’s behalf | no — IO throttle |
Every psi_memstall_enter() region in Linux v6.12, found by grepping the whole mm/ and block/ trees. What it shows: exactly three of fourteen regions contain an actual LRU scan (1, 3, 4, 5, 7 — five if you count the two global ones a cgroup’s tasks also pass through). The rest are refault waits, swap-in waits, compaction, and two explicit punitive sleeps. The insight to take: “memory pressure” was designed to measure lost productive time attributable to memory, which deliberately includes waiting for a page you should not have had to fetch again (thrashing) and being deliberately slowed down (throttling). Reading it as a reclaim counter is not a small imprecision; the two largest contributors in practice — site 2 and sites 8–12 — contain no reclaim at all.
Two entries in that table carry caveats worth stating.
- Site 4,
balance_pgdat, is kswapd — a kernel thread. Its stall is attributed to kswapd’s own cgroup (the root), not to the cgroup whose pages it is freeing. So a cgroup can be the cause of heavy system-wide reclaim and see nothing in its ownmemory.pressure. Pressure is attributed to who waits, never to who is responsible. - Sites 8–12 fire only when
folio_test_workingset()is true. That bit is set when a page is evicted and its shadow entry says it was recently in the working set — the refault machinery. An ordinary cold cache miss produces no pressure at all; only a refault does. This is the design’s central idea, and it is why PSI is a better signal than raw page-fault counts.
The counters that do mean reclaim
Since memory.pressure does not answer “is this cgroup reclaiming,” here is what does. All of these are per-cgroup and recursive (they include descendants).
| File and field | Meaning | Increments on |
|---|---|---|
memory.stat pgscan | pages examined on the LRU lists | any reclaim pass that scans, including failed ones |
memory.stat pgsteal | pages actually freed | successful eviction only |
memory.stat pgscan_direct / pgsteal_direct | the subset done in the allocating task’s own context | direct reclaim, the latency-visible kind |
memory.stat pgscan_kswapd / pgsteal_kswapd | the subset done by kswapd | background reclaim |
memory.stat pgscan_proactive / pgsteal_proactive | the subset driven by memory.reclaim | proactive reclaim only — separated precisely so it does not pollute the other two |
memory.stat pgrefill | pages moved active → inactive | reclaim under real pressure (deactivation), a leading indicator |
memory.stat workingset_refault_anon / _file | pages faulted back in that had been evicted while in the working set | thrashing, and the trigger for sites 8–12 above |
memory.events low | times the cgroup’s memory.low protection was breached by global reclaim | second-pass reclaim overrode soft protection |
memory.events high | times the cgroup was throttled at memory.high | the throttle, not reclaim — run B scored 4,353 of these with zero reclaim |
memory.events max | times an allocation hit memory.max and had to reclaim or fail | the hard limit binding |
memory.events oom | times reclaim failed and the memcg OOM path was entered | the signal memory.pressure misses |
memory.events oom_kill | processes actually killed | OOM kills, including ones PSI reports as 39 µs |
memory.events oom_group_kill | times a whole cgroup was killed atomically | memory.oom.group = 1 fired |
The per-cgroup reclaim and limit counters, with what each one actually observes. The insight to take: the honest “is it reclaiming?” question is answered by the pgscan/pgsteal pair and nothing else; the honest “is it in trouble?” question needs memory.events oom/oom_kill, which PSI cannot see; and memory.events high measures the throttle, which is why it tracked run B’s pressure so closely while pgscan stayed at zero.
memory.stat’s exact field set is version-dependent, and the note’s pin matters here: the pgscan_proactive / pgsteal_proactive pair does not exist in every kernel that has memory.reclaim, and the host these numbers came from (7.1.13) additionally exposes a sock_throttled counter in memory.events that is absent at v6.12. Check memory.stat on the kernel you are actually running before writing a dashboard query against a field name.
Uncertain
The measurements in this section were taken on Fedora 44, kernel 7.1.13-200.fc44, with zram-backed swap (
/dev/zram0, 8 GiB, priority 100) — not on a v6.12 kernel and not on a disk-backed swap device. Compressed in-memory swap makes run C’s reclaim unusually cheap; the same experiment against an NVMe swap partition would show a larger PSI figure for C, though the ordering (B ≫ C ≫ E) follows from the mechanism rather than from the device, since B contains no IO at all. The zero-pgscanresult in B and E is device-independent: withmemory.swap.max = 0there is nothing to scan regardless. To resolve: re-run under a v6.12 kernel with a disk-backed swap partition and record C’ssome total. uncertain
Target reclaim: memory.high (throttle) vs memory.max (hard limit)
Both limits live in the charge path (try_charge, memcg Charging and Limits), but they invoke reclaim very differently.
The four knobs, side by side
Before tracing the code, the whole memory.* limit family in one place. All four are hierarchical: a cgroup’s effective value is constrained by its ancestors’, and none of them can be escaped by writing a looser value further from the root.
| Knob | Kind | Enforced where | What reclaim it causes | On failure | Events emitted | Default |
|---|---|---|---|---|---|---|
memory.min | hard protection | shrink_node_memcgs() → mem_cgroup_below_min() | none — it deflects global reclaim | if no unprotected memory remains, the OOM killer fires rather than violate min | — | 0 |
memory.low | best-effort protection | shrink_node_memcgs() → mem_cgroup_below_low(), plus proportional scaling in get_scan_count() | none; it deflects first-pass global reclaim only | second-pass reclaim sets sc->memcg_low_reclaim and overrides it | memory.events low | 0 |
memory.high | throttle | recorded in try_charge_memcg(), acted on in mem_cgroup_handle_over_high() on the return-to-userspace path | reclaim_high() on the cgroup and every ancestor over its own high | penalty sleep up to 2 s per batch; never OOM-kills; the limit can be breached | memory.events high | max |
memory.max | hard limit | synchronously inside try_charge_memcg() | try_to_free_mem_cgroup_pages() in the allocating task’s context, up to MAX_RECLAIM_RETRIES | memcg OOM killer, scoped to the cgroup (or -ENOMEM for allocations that may not OOM) | memory.events max, then oom, oom_kill | max |
The four limits by enforcement mechanism rather than by name. The insight to take: the top two and the bottom two are opposite kinds of object. min/low are reserves consumed by global reclaim deciding what to skip; high/max are ceilings consumed by the charge path deciding what to do about this cgroup. Confusing a reserve for a ceiling — “I set memory.low so it will stay under that” — is the most common memcg configuration error, and it fails silently because nothing enforces it.
The memory.high throttle, mechanically
The docs describe memory.high as putting a cgroup “under heavy reclaim pressure.” The code shows that the reclaim comes first and the pressure is what happens when the reclaim does not work. try_charge_memcg() (v6.12 mm/memcontrol.c:2296) does no work at charge time at all; it walks the ancestor chain, and for every level over its own high, accumulates the batch onto the task and sets a flag:
do {
mem_high = page_counter_read(&memcg->memory) > READ_ONCE(memcg->memory.high);
swap_high = page_counter_read(&memcg->swap) > READ_ONCE(memcg->swap.high);
if (!in_task()) { /* don't punish a random interrupted task */
if (mem_high) { schedule_work(&memcg->high_work); break; }
continue;
}
if (mem_high || swap_high) {
current->memcg_nr_pages_over_high += batch;
set_notify_resume(current); /* run the work on return to userspace */
break;
}
} while ((memcg = parent_mem_cgroup(memcg)));Two details in that loop matter. The !in_task() branch is a fairness rule: if the charge happened in interrupt context, the interrupted task is innocent, so the work is punted to a workqueue (memcg->high_work) instead of being billed to whoever happened to be running. And set_notify_resume() is what defers everything to the return-to-userspace path, where the task is guaranteed to be able to block — which is also why memory.high enforcement is batched rather than per-allocation, and therefore why usage can overshoot high before anything happens.
The deferred work is mem_cgroup_handle_over_high() (mm/memcontrol.c:2064), and its structure is the point:
retry_reclaim:
if (task_is_dying())
goto out; /* don't trap a dying task in reclaim */
nr_reclaimed = reclaim_high(memcg,
in_retry ? SWAP_CLUSTER_MAX : nr_pages,
gfp_mask); /* (A) TRY RECLAIM FIRST */
penalty_jiffies = calculate_high_delay(memcg, nr_pages, mem_find_max_overage(memcg));
penalty_jiffies += calculate_high_delay(memcg, nr_pages, swap_find_max_overage(memcg));
penalty_jiffies = min(penalty_jiffies, MEMCG_MAX_HIGH_DELAY_JIFFIES); /* 2 s */
if (penalty_jiffies <= HZ / 100)
goto out; /* (B) < 10 ms owed: not worth it */
if (nr_reclaimed || nr_retries--) {
in_retry = true;
goto retry_reclaim; /* (C) PROGRESS? KEEP RECLAIMING */
}
psi_memstall_enter(&pflags); /* (D) LAST RESORT: THE SLEEP */
schedule_timeout_killable(penalty_jiffies);
psi_memstall_leave(&pflags);
out:
css_put(&memcg->css);The order is the whole design. (A) reclaim is attempted every iteration. (B) if the computed debt is under 10 ms the task is let go entirely — the comment says this is “an attempt to be nice to those who go only a small amount over their memory.high value.” (C) is the crucial branch: as long as reclaim freed even one page, or retries remain, the loop goes back to reclaiming rather than sleeping, with the comment “if reclaim is making forward progress but we’re still over memory.high, we want to encourage that rather than doing allocator throttling.” Only when reclaim has returned zero and MAX_RECLAIM_RETRIES is exhausted does control reach (D), the psi_memstall_enter bracket from row 2 of the call-site table. That is why run B in the experiment above logged 431 seconds of pressure with pgscan 0: reclaim returned zero on every attempt, so every batch fell through to the sleep.
The delay curve itself is calculate_high_delay() (mm/memcontrol.c:2027):
penalty_jiffies = max_overage * max_overage * HZ; /* overage is a ratio << 20 */
penalty_jiffies >>= MEMCG_DELAY_PRECISION_SHIFT; /* 20 */
penalty_jiffies >>= MEMCG_DELAY_SCALING_SHIFT; /* 14 */
return penalty_jiffies * nr_pages / MEMCG_CHARGE_BATCH; /* batch is 64 pages */Walking it symbol by symbol: max_overage is not a byte count but a fixed-point ratio, ((usage - high) << 20) / high, computed by calculate_overage() and maximised over the cgroup and all its non-root ancestors by mem_find_max_overage() — so a child that is within its own high still gets throttled if a parent is over. Squaring that ratio and then shifting right by 20 + 14 = 34 bits converts it back out of fixed point and scales it to a sane number of jiffies. The final multiplication by nr_pages / MEMCG_CHARGE_BATCH (64 pages) bills the task in proportion to how much it actually allocated, so that “four N-sized allocations are throttled approximately the same as one 4N-sized allocation” (source comment). MEMCG_MAX_HIGH_DELAY_JIFFIES is 2UL * HZ — two seconds, per usermode return, not per allocation, and the comment explains the clamp is there “to still keep the application moving forwards and also permit diagnostics, albeit extremely slowly.”
Uncertain
The source comments describe this curve as increasing the delay “exponentially” — twice. The arithmetic is quadratic:
penalty ∝ overage². The kernel’s own worked table, reproduced in the comment block aboveMEMCG_DELAY_PRECISION_SHIFTfor a 100 MBhigh, confirms it — 6, 25, 57, 102, 159, 230, 313 ms at 101M…107M, whose second differences are a near-constant ≈13 ms, the signature of a quadratic and not an exponential. I have not found a commit message or list posting explaining the word choice, andlore.kernel.orgreturns HTTP 403 tocurlso I could not read the original review thread. Treat “exponential” in the source comments as loose language for “superlinear,” and the formula as the authority. uncertain
That kernel-supplied table is worth quoting directly, because it is the only place the throttle’s real magnitude is written down. With memory.high = 100M:
| usage | delay | usage | delay | usage | delay |
|---|---|---|---|---|---|
| 100M | 0 ms | 107M | 313 ms | 114M | 1254 ms |
| 101M | 6 ms | 108M | 409 ms | 115M | 1439 ms |
| 102M | 25 ms | 109M | 518 ms | 116M | 1638 ms |
| 103M | 57 ms | 110M | 639 ms | 117M | 1849 ms |
| 104M | 102 ms | 111M | 774 ms | 118M | 2000 ms (clamped) |
| 105M | 159 ms | 112M | 921 ms | 119M | 2000 ms |
| 106M | 230 ms | 113M | 1081 ms | 120M | 2000 ms |
The memory.high penalty curve, reproduced from the comment block in mm/memcontrol.c (v6.12, above MEMCG_DELAY_PRECISION_SHIFT). What it shows: a 1 % overage costs 6 ms; a 10 % overage costs 639 ms; an 18 % overage saturates the 2-second clamp. The insight to take: the curve is deliberately forgiving near the limit and brutal beyond it, and it saturates quickly — past ~18 % over, the throttle stops getting worse and simply pins the workload at 2 seconds per usermode return. A cgroup parked at the clamp is not “slightly slow”; it is running at a few hundred allocations per minute, which is what produced the >466-second run B above.
sequenceDiagram autonumber participant T as allocating task participant TC as try_charge_memcg() participant PC as page_counter (memcg->memory) participant OH as mem_cgroup_handle_over_high() participant R as reclaim_high() → try_to_free_mem_cgroup_pages() participant PSI as PSI (memory.pressure) T->>TC: charge 64-page batch TC->>PC: page_counter_try_charge() PC-->>TC: ok, but usage > memory.high TC->>TC: current->memcg_nr_pages_over_high += batch TC->>TC: set_notify_resume(current) Note over TC: nothing blocks here — high is BATCHED TC-->>T: charge succeeds, syscall continues T->>OH: on return to userspace loop until progress stops or retries exhausted OH->>R: reclaim_high(memcg + every ancestor over its high) OH->>PSI: psi_memstall_enter (site 1) R-->>OH: nr_reclaimed OH->>PSI: psi_memstall_leave alt nr_reclaimed > 0 or retries left OH->>OH: in_retry = true, loop end end alt penalty_jiffies <= 10 ms OH-->>T: return, no sleep at all else reclaim made no progress OH->>PSI: psi_memstall_enter (site 2) OH->>OH: schedule_timeout_killable(penalty_jiffies) Note over OH,PSI: THE THROTTLE. No reclaim here.<br/>This is where run B spent 431 s. OH->>PSI: psi_memstall_leave OH-->>T: resume, ~2 s later end
The memory.high path from charge to throttle, showing exactly where PSI is charged. What it shows: the charge itself never blocks — memory.high is recorded and deferred to the return-to-userspace path. Reclaim is attempted in a loop, and PSI site 1 covers it. Only after reclaim stops making progress does the sequence fall through to PSI site 2, the penalty sleep. The insight to take: both branches feed the same memory.pressure file, and nothing in that file distinguishes them. pgscan/pgsteal is the only way to tell which one your workload is in — and the answer determines the fix. Site 1 dominating means “give it more memory or accept the IO”; site 2 dominating means “this cgroup has nothing reclaimable and memory.high is simply strangling it.”
memory.max is the hard limit, enforced synchronously inside try_charge() (v6.12, mm/memcontrol.c). When page_counter_try_charge(&memcg->memory, ...) fails because usage would exceed max, try_charge raises a MEMCG_MAX event and calls try_to_free_mem_cgroup_pages(mem_over_limit, ...) in the allocating task’s own context — wrapped in psi_memstall_enter()/psi_memstall_leave() so the stall is attributed to memory pressure (this is the PSI hook, below). It retries up to MAX_RECLAIM_RETRIES times; if reclaim cannot make room, it invokes the memcg OOM killer scoped to that cgroup. The docs: “If a cgroup’s memory usage reaches this limit and can’t be reduced, the OOM killer is invoked in the cgroup.” max is the backstop that cannot be exceeded for normal allocations (though some allocations bypass it and return -ENOMEM instead).
memory.high is a throttle, not a wall. Per the docs: “Going over the high limit never invokes the OOM killer and under extreme conditions the limit may be breached.” Its enforcement is batched and deferred, and the full mechanism is traced above: try_charge_memcg records the overage in current->memcg_nr_pages_over_high, and the work happens later in mem_cgroup_handle_over_high(), which reclaims first and sleeps only if reclaiming failed. The penalty sleep is quadratic in the overage ratio, not proportional to it, and is capped at MEMCG_MAX_HIGH_DELAY_JIFFIES = 2UL * HZ — two seconds per usermode return. The deliberate design (docs): “the processes of the cgroup are throttled and put under heavy reclaim pressure” but never killed, so “a management agent has ample opportunities to monitor and take appropriate actions such as granting more memory or terminating the workload.” The throttle slows the workload down gracefully instead of killing it — which is exactly why cgroup-v2.rst calls memory.high “the main mechanism to control memory usage” and recommends memory.max only as the buggy/malicious-app backstop.
There is a real cost to the batched design, and it was the subject of a 2020 patch series that is worth knowing about: because the throttle is applied on the return-to-userspace path rather than at charge time, a task can overshoot high substantially before anything slows it down, and the sleep then lands on whichever task happens to be returning. Johannes Weiner proposed moving memory.high reclaim into a workqueue so the allocator would not have to do it synchronously — “attempt reclaim before putting the task to sleep when async reclaim cannot keep up with the allocation rate” (LWN 813044, mm: memcontrol: asynchronous reclaim for memory.high). The v6.12 code above is the outcome of that line of work: schedule_work(&memcg->high_work) for the interrupt-context case, and a reclaim-before-sleep loop for everything else.
Proactive reclaim: memory.reclaim
memory.reclaim (v6.12, Documentation/admin-guide/cgroup-v2.rst and mm/memcontrol.c) is a write-only file letting userspace trigger reclaim on a cgroup with no pressure at all: echo "1G" > memory.reclaim. Its handler memory_reclaim() parses a byte count (and an optional swappiness= nested key), then loops calling try_to_free_mem_cgroup_pages() with reclaim_options = MEMCG_RECLAIM_MAY_SWAP | MEMCG_RECLAIM_PROACTIVE until the target is met or it gives up with -EAGAIN. Two details matter:
- It converges in quarters. Each iteration requests
(nr_to_reclaim - nr_reclaimed) / 4pages, so it homes in on the target rather than overshooting in one giant pass. MEMCG_RECLAIM_PROACTIVEsuppresses pressure side-effects. The docs are explicit: “the proactive reclaim (triggered by this interface) is not meant to indicate memory pressure on the memory cgroup,” so socket-memory balancing andvmpressureare not exercised (note theif (!sc->proactive)guard aroundvmpressureinshrink_node_memcgs). Proactive reclaim must not look like real pressure or it would poison the very signals an agent uses to make decisions.
The swappiness= nested key lets the caller override the anon/file bias for this one reclaim. The two LTS releases differ here, and the difference is verified against source: in 6.12 the token is exactly "swappiness=%d", parsed with match_int and bounded by MIN_SWAPPINESS(0)–MAX_SWAPPINESS(200) (per v6.12 mm/memcontrol.c memory_reclaim and include/linux/swap.h). The swappiness=max form — documented in 6.18 as range [0-200, max] where max “exclusively reclaims anonymous memory” (v6.18 cgroup-v2.rst) — is a post-6.12 addition and is not accepted in 6.12 LTS. memory.reclaim is the canonical tool for userspace-driven memory tiering / offloading: a userspace agent writes to it to keep a workload at its minimal footprint, watching memory.pressure to know how hard it can push before the workload starts stalling.
The named tool for this pattern is Meta’s senpai, and its own README (fetched 2026-09-12) states the mechanism directly: “Using Linux psi metrics and cgroup2 memory limits, senpai applies just enough memory pressure on a container to page out the cold and unused memory pages that aren’t necessary for nominal workload performance” (facebookincubator/senpai). The README’s framing of the problem is the best one-line statement of why this interface exists: “Allocated memory is not a good proxy for required memory.” Its worked example is a kernel compile whose peak memory.current with no limit is 803,934,208 bytes (~767 MiB), but which finishes in the same wall time — 3m58s vs 4m0s vs 4m3s — under memory.high of 600M and then 400M, with memory.current tracking the limit down to 419,368,960 bytes. That is nearly half the “required” memory turning out to be slack, discoverable only by applying pressure and watching whether the workload actually slows. Note that senpai drives this through memory.high, not memory.reclaim; memory.reclaim is the later, purpose-built interface for the same idea without the throttle side-effect.
Uncertain
Earlier drafts of this note flagged the senpai attribution as unverified. It is now verified against the project’s own README, fetched during this task. What remains unverified is whether senpai as shipped today still uses
memory.highrather than having migrated tomemory.reclaim— the README’s examples predatememory.reclaim’s addition. To resolve: read the currentsenpai.pysource rather than the README. uncertain
Per-cgroup pressure: memory.pressure (PSI)
Every non-root cgroup exposes memory.pressure — a read-only file showing Pressure Stall Information scoped to that cgroup (the system-wide mechanism is Pressure Stall Information). It reports two lines:
some avg10=0.00 avg60=0.00 avg300=0.00 total=12345
full avg10=0.00 avg60=0.00 avg300=0.00 total=6789
Per Documentation/accounting/psi.rst (v6.12) and the original PSI proposal (LWN 759781, “Tracking pressure-stall information”): the some line is the share of wall-clock time in which at least one task in the cgroup was stalled waiting on memory (e.g. blocked in direct reclaim, swap-in, or page-cache refault); the full line is the share in which all non-idle tasks were stalled simultaneously — the CPU does no productive work. The avg10/60/300 are decaying averages over 10/60/300 seconds; total is the cumulative microseconds stalled. The same proposal added the per-cgroup cpu.pressure/memory.pressure/io.pressure files alongside the system-wide /proc/pressure/*.
The plumbing that makes per-cgroup PSI accurate is the psi_memstall_enter()/psi_memstall_leave() brackets you can see wrapping try_to_free_mem_cgroup_pages in both reclaim_high and try_charge (shown above). Whenever a task enters memcg reclaim, that interval is accounted as a memory stall and propagated up the cgroup PSI hierarchy. This is the early-warning signal that the The Memory Cgroup memcg design intends agents to act on: rather than waiting for an OOM kill, an orchestrator watches memory.pressure, and when some/full rise it grants more memory or sheds load. PSI also supports triggers — a process can poll() on memory.pressure for a threshold like “stalled > 150 ms in any 1 s window” and get woken to react; per Meta’s PSI documentation, this is the basis of oomd, “a userspace tool that uses PSI thresholds as triggers to carry out specified OOM actions when memory pressure increases.”
flowchart LR subgraph SITES["the 14 psi_memstall_enter regions"] direction TB S2["memory.high PENALTY SLEEP<br/>memcontrol.c:2149<br/>no reclaim at all"]:::throt S1["reclaim_high()<br/>memcontrol.c:1902"]:::rec S3["try_charge_memcg()<br/>memcontrol.c:2210"]:::rec S7["direct reclaim<br/>page_alloc.c:3948"]:::rec S4["kswapd balance_pgdat()<br/>vmscan.c:6849"]:::rec S8["refault / swap-in waits<br/>filemap.c, page_io.c,<br/>readahead.c"]:::thrash S6["compaction<br/>page_alloc.c:3679<br/>compaction.c:3185"]:::other S14["blkcg throttle w/ use_memdelay<br/>blk-cgroup.c:1924"]:::throt end T["the stalling TASK<br/>current->in_memstall = 1"] S1 --> T S2 --> T S3 --> T S7 --> T S8 --> T S6 --> T S14 --> T T --> CG["task's own cgroup<br/>memory.pressure"] CG --> P1["parent .../memory.pressure"] P1 --> P2["... up to /sys/fs/cgroup/memory.pressure<br/>== /proc/pressure/memory"] S4 -.->|"kswapd is a KERNEL THREAD"| ROOTONLY["charged to kswapd's own cgroup (root).<br/>The cgroup whose pages are being freed<br/>sees NOTHING in its memory.pressure."] classDef rec fill:#e8f4e8,stroke:#4a4; classDef throt fill:#fde8e8,stroke:#c44; classDef thrash fill:#fff4e0,stroke:#c84; classDef other fill:#eef,stroke:#66a;
Where each psi_memstall_enter() region’s time ends up, colour-coded by whether it contains reclaim. What it shows: every bracket charges the currently running task, whose cgroup and every ancestor then accrue the interval — so memory.pressure at any level is the union of its descendants’ stalls. Green regions do real LRU scanning; red regions are deliberate sleeps with no reclaim in them; amber regions are refault and swap-in waits, which are thrashing rather than reclaim. The insight to take: two of the three colours are not reclaim, and kswapd — the biggest reclaimer on most systems — is charged to the root, because PSI attributes stall to whoever waits and kswapd is its own kernel thread. A cgroup can drive the machine into continuous background reclaim and show a flat memory.pressure throughout.
What memory.pressure looks like on a real cgroup, and what it does not tell you
Read on this machine’s own login session, which had been running the throttle experiments above:
$ U=/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service
$ cat $U/memory.pressure
some avg10=0.00 avg60=0.00 avg300=8.89 total=1152808663
full avg10=0.00 avg60=0.00 avg300=8.36 total=1044641934Note full nearly equalling some. That is characteristic of a small cgroup: full requires that all non-idle tasks in the group be stalled simultaneously, which is easy when the group contains one busy task and trivially satisfied when it contains one throttled one. At the system level the same condition is hard to meet, so full and some diverge sharply. A high full in a small cgroup is much weaker evidence of thrashing than a high full system-wide, and dashboards that apply the same threshold to both will page on nothing.
Walking the same reading up the hierarchy produced a result I did not expect and cannot fully account for:
$ for c in /sys/fs/cgroup /sys/fs/cgroup/user.slice \
/sys/fs/cgroup/user.slice/user-1000.slice \
/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service; do
printf "%-58s %s\n" "$c" "$(awk '/^some/{print $5}' $c/memory.pressure)"; done
/sys/fs/cgroup total=1046496133
/sys/fs/cgroup/user.slice total=1070371225
/sys/fs/cgroup/user.slice/user-1000.slice total=1074949058
/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service total=1152808663total decreases monotonically as you move towards the root — the leaf-most cgroup reports 1,152.8 seconds of cumulative some stall while the root cgroup reports 1,046.5 s, about 9 % less. (The root cgroup’s memory.pressure and /proc/pressure/memory are the same object; both read identically.) Whatever the cause, the operational consequence is concrete and worth stating: PSI totals do not sum across siblings and are not monotonic up the tree, so do not build a “share of system pressure” ratio out of them. Compare a cgroup’s pressure against its own history, not against its parent’s.
Uncertain
I cannot explain the non-monotonic
totalabove from the source I read. Naively,psi_group_change()walks a task’s cgroup ancestry updating every level’s per-CPU task counters, andtest_state()forPSI_MEM_SOMEis justtasks[NR_MEMSTALL] != 0, which should make an ancestor’s stall intervals a superset of a descendant’s and hence itstotallarger, not smaller. The gap was ~9 % and reproduced across repeated reads, so it is not sampling noise. Candidate explanations I did not verify: per-CPUstate_startsampling skew across levels; thePSI_ONCPU/TSK_MEMSTALL_RUNNINGinteraction introduced for thefullfix; or accounting differences betweenpsi_systemand cgroup PSI groups. To resolve: instrumentrecord_times()for a known synthetic stall, or readpsi_group_change()andrecord_times()in full atkernel/sched/psi.c— I read onlypsi_memstall_enter/leaveand the trigger path. uncertain
PSI triggers: what the documentation says and what the code does
The other half of the interface is poll()-able thresholds. A process writes <some|full> <stall_us> <window_us> to memory.pressure and is woken when the threshold is crossed. Here the shipped documentation and the shipped code disagree, and it matters for anyone writing an agent. Documentation/accounting/psi.rst (v6.12) states:
“The kernel accepts window sizes ranging from 500ms to 10s, therefore min monitoring update interval is 50ms and max is 1s.”
The code (v6.12 kernel/sched/psi.c:1313) has no lower bound at all:
#define WINDOW_MAX_US 10000000 /* Max window size is 10s */
...
privileged = cap_raised(file->f_cred->cap_effective, CAP_SYS_RESOURCE);
...
if (window_us == 0 || window_us > WINDOW_MAX_US)
return ERR_PTR(-EINVAL);
/* Unprivileged users can only use 2s windows ... */
if (!privileged && window_us % 2000000)
return ERR_PTR(-EINVAL);
if (threshold_us == 0 || threshold_us > window_us)
return ERR_PTR(-EINVAL);
...
t->aggregator = privileged ? PSI_POLL : PSI_AVGS;There is a WINDOW_MAX_US but no WINDOW_MIN_US — the documented 500 ms floor no longer exists in the code. What does exist is a privilege split introduced for the same reason: a caller with CAP_SYS_RESOURCE may use any window from 1 µs to 10 s and gets a real high-resolution poll worker (PSI_POLL); a caller without it must use a window that is a whole multiple of 2 seconds and is served by the ordinary 2-second averaging work (PSI_AVGS, where PSI_FREQ is 2*HZ+1). Verified empirically, as uid 1000 with no capabilities, writing to a memory.pressure file in a cgroup the user created:
as uid 1000 (no CAP_SYS_RESOURCE)
write "some 150000 1000000" -> Invalid argument # 1s: not a multiple of 2s
write "some 150000 2000000" -> ACCEPTED # 2s: the unprivileged minimum
write "some 150000 500000" -> Invalid argument # 500ms: the DOCUMENTED minimum, rejected
write "some 150000 1000" -> Invalid argument # 1ms: no code floor, but not a 2s multiple
write "some 150000 20000000" -> Invalid argument # 20s: exceeds WINDOW_MAX_US
write "some 3000000 2000000" -> Invalid argument # threshold > windowThe documented 500 ms minimum is rejected, and the value the docs do not mention — a plain 2 s window — is the only one that works. An agent running unprivileged in a container therefore cannot get sub-2-second PSI notifications at all, no matter what the man pages suggest; it must either acquire CAP_SYS_RESOURCE or fall back to polling memory.pressure on its own schedule.
One more thing an unprivileged delegatee cannot do: turn its own PSI accounting off. cgroup.pressure (a per-cgroup on/off switch for PSI accounting, so the scheduler hot-path cost can be avoided where it is not wanted) is present but is not in /sys/kernel/cgroup/delegate, and on this host it is root:root even inside the delegated subtree:
$ ls -l $U/cgroup.pressure; cat $U/cgroup.pressure
-rw-r--r--. 1 root root 0 Sep 11 23:39 .../user@1000.service/cgroup.pressure
1That is the correct call: switching PSI off is not an operation that can only shrink your own resource use, so it does not meet the delegation criterion.
Uncertain
An older “Usage Guidelines” paragraph in
cgroup-v2.rststill says “memory pressure monitoring mechanism isn’t implemented yet.” That sentence predates PSI and is stale — the implemented mechanism is preciselymemory.pressure/PSI documented elsewhere in the same file and inpsi.rst. Treatmemory.pressureas the real, shipped per-cgroup pressure signal; the stale line should not be cited as current. uncertain
Pitfalls / Misconceptions
- “
memory.min/memory.lowcause reclaim.” They do the opposite — they protect a cgroup from global reclaim. The reclaim-triggering limits arehighandmax. Confusing protection with limiting is the single most common memcg mistake. - “
memory.highis a hard cap.” No.highthrottles (penalty sleep + reclaim) and can be breached under extreme pressure; it never OOM-kills. Onlymemory.maxis a hard cap that can invoke the (cgroup-scoped) OOM killer. Usehighfor graceful control,maxas the backstop. - “
memory.reclaimshows up as memory pressure.” It is flaggedMEMCG_RECLAIM_PROACTIVEspecifically so it does not register as pressure (novmpressure, no socket-memory balancing). Proactive reclaim is invisible to the pressure signals by design. - “Per-cgroup reclaim is different code from global reclaim.” It is the same
shrink_node/shrink_lruveccode, distinguished only byscan_control.target_mem_cgroup. Global reclaim is justshrink_node_memcgswith the root as the target. - “PSI
fullincludes the CPU controller.” For memory,fullmeans every non-idle task stalled on memory; it is per-resource. But be careful with the corollary: in a small cgroupfullis trivially satisfied whenever its one busy task stalls, so it is a much weaker signal there than system-wide. Measured on this host, a login session’smemory.pressureshowedfullat 8.36 % againstsomeat 8.89 % — nearly identical, because there was effectively one stalling task. - “High
memory.pressuremeans the cgroup is reclaiming.” The single biggest misconception, and the reason this note has a whole section on it. Measured: 431 seconds of PSI withpgscan 0.memory.pressurecounts time insidepsi_memstall_enter()brackets, and thememory.highpenalty sleep is one of them. Confirm withpgscan/pgstealbefore concluding anything about reclaim. - “Low
memory.pressuremeans the cgroup is healthy.” Also measured false: a cgroup whose process was OOM-killed atmemory.maxregistered 39 microseconds of PSI — a rounded0.00on every average. If nothing is reclaimable there is nothing to wait for, so the most severe outcome produces the least pressure. Alert onmemory.eventsoom/oom_killseparately; PSI will not cover you. - “A cgroup’s pressure is a share of its parent’s.” Measured false on this host:
totaldecreased going up the hierarchy (1,152.8 s at the leaf vs 1,046.5 s at the root). PSI totals do not sum across siblings and are not reliably monotonic up the tree — see the uncertainty callout in the PSI section. Compare a cgroup against its own history only. - “PSI triggers support 500 ms windows.” That is what
psi.rstsays; the code has no minimum window at all, and an unprivileged caller is restricted to whole multiples of 2 seconds. Verified by writing every boundary case to a realmemory.pressurefile as uid 1000. An unprivileged agent cannot get sub-2-second notifications withoutCAP_SYS_RESOURCE. - “The
memory.highpenalty grows exponentially.” The source comment says so twice; the arithmetic isoverage². It is superlinear but quadratic, and it clamps hard at 2 seconds per usermode return once you are ~18 % over the limit. - “Charging follows the process to its new cgroup.” No. Per
cgroup-v2.rst“Memory Ownership,” a page stays charged to the cgroup that first instantiated it; migrating the task does not move already-charged memory (see memcg Charging and Limits).
Alternatives and when they apply
The legacy pressure signal is vmpressure (the cgroup-v1-era notification mechanism, still fed by shrink_node_memcgs), but PSI’s memory.pressure superseded it for v2 workloads because it measures time stalled (actionable) rather than reclaim efficiency (a derived, noisier proxy). The cgroup-v1 controller exposed memory.limit_in_bytes, memory.soft_limit_in_bytes, and memory.pressure_level; the v2 redesign replaced the global-rbtree soft limit with the hierarchical min/low reserves described above precisely because the soft limit was non-hierarchical and over-aggressive. For triggering reclaim from outside the kernel, the alternatives to memory.reclaim are crude (echo 1 > /proc/sys/vm/drop_caches is system-wide and unscoped; setting a low memory.high then raising it works but throttles the workload meanwhile) — memory.reclaim is the purpose-built, scoped, pressure-silent tool.
See Also
- The Memory Cgroup memcg — what a memcg is and the controller structure; this note is its reclaim path.
- memcg Charging and Limits — how pages are charged and the
page_counters behindcurrent/high/max. - Memory Reclaim Overview — the generic reclaim flow that per-cgroup reclaim reuses.
- The LRU Lists — the per-
lruveclistsshrink_lruvecscans; the per-memcg lruvec is what scopes reclaim. - Pressure Stall Information — the PSI mechanism in full;
memory.pressureis its per-cgroup facet. - Swappiness and Reclaim Balance — the anon/file bias
memory.reclaim’sswappiness=key overrides. - cgroup OOM and memory.oom.group — what
memory.maxreclaim falls through to when it cannot make room. - Shrinkers and Slab Reclaim — the memcg-aware slab reclaim
shrink_node_memcgsruns alongside LRU reclaim. - Kubernetes MOC — pod memory limits/requests/QoS map onto these knobs (orchestration policy, not kernel mechanism).
- cgroup Delegation and the No Internal Process Rule — why
memoryis a domain controller and cannot be enabled on a populated internal cgroup, and which of thesememory.*files a delegated owner is allowed to write. - Workingset Detection and Refaults — the
PG_workingsetbit that gates PSI sites 8–12; an ordinary cache miss produces no pressure, only a refault does. - Linux Memory Management MOC — parent map.
Sources
- Linux v6.12
mm/memcontrol.c—try_charge,reclaim_high,mem_cgroup_handle_over_high,memory_high_write,memory_max_write,memory_reclaim,psi_memstall_enter/leavebrackets. - Linux v6.12
mm/vmscan.c—try_to_free_mem_cgroup_pages,shrink_node_memcgs,get_scan_countprotection scaling,cgroup_reclaim/root_reclaim. - Linux v6.12
Documentation/admin-guide/cgroup-v2.rst—memory.min/low/high/max/reclaim/pressuresemantics, hierarchical protection design essay, memory ownership. - Linux v6.12
Documentation/accounting/psi.rst—some/fulldefinitions. - Linux v6.18
mm/memcontrol.candcgroup-v2.rst— spot-check thatmemory_reclaimand theswappinessnested key persist (range documented as[0-200, max]in 6.18;swappiness=maxis a post-6.12 addition). - Linux v6.12
kernel/sched/psi.c—psi_memstall_enter/psi_memstall_leave(:1047, :1078),psi_trigger_createwindow validation and theCAP_SYS_RESOURCEprivilege split (:1286–1346),WINDOW_MAX_US(:163),PSI_FREQ(:157). - Linux v6.12
include/linux/memcontrol.h—MEMCG_CHARGE_BATCH= 64 (:334). - Linux v6.12
mm/memcontrol.cin detail —MEMCG_MAX_HIGH_DELAY_JIFFIES=2UL*HZ(:1927), the delay-curve comment table (:1929–1971),MEMCG_DELAY_PRECISION_SHIFT/MEMCG_DELAY_SCALING_SHIFT= 20/14 (:1972–1973),calculate_overage(:1975),mem_find_max_overage/swap_find_max_overage(:1993, :2007),calculate_high_delay(:2027),mem_cgroup_handle_over_high(:2064) with thepsi_memstall_enterpenalty-sleep bracket (:2149),try_charge_memcg(:2153) and its deferred-highloop (:2296–2350). - The complete
psi_memstall_enter()call-site map was produced by grepping the v6.12mm/andblock/trees:mm/memcontrol.c(:1902, :2149, :2210),mm/vmscan.c(:6849balance_pgdat, :7480__node_reclaim),mm/filemap.c(:1244folio_wait_bit_common, :1397migration_entry_wait_on_locked, :2366filemap_read_folio),mm/page_alloc.c(:3679__alloc_pages_direct_compact, :3948__alloc_pages_direct_reclaim),mm/compaction.c(:3185kcompactd),mm/page_io.c(:627swap_read_folio),mm/readahead.c(:156, :768, :797),block/blk-cgroup.c(:1924blkcg_maybe_throttle_blkg). - LWN 759781 (“Tracking pressure-stall information” — PSI
some/fulland per-cgroup*.pressurefiles); LWN 813044 (“mm: memcontrol: asynchronous reclaim for memory.high” — why reclaim is attempted before the penalty sleep); Meta PSI overview (oomduses PSI triggers); facebookincubator/senpai README (the workingset-sizing use case, with its kernel-compile numbers). kernel.org/releases.json, read 2026-09-12 — mainline7.3-rc2, stable7.2.5, longterm6.18.51 / 6.12.109 / 6.6.156 / 6.1.187 / 5.15.220 / 5.10.269.
Measurement environment. All live output in this note was produced on Fedora 44, kernel 7.1.13-200.fc44, 32 CPU, 125 GiB RAM, zram-backed swap (/dev/zram0, 8 GiB, priority 100, zswap disabled), systemd 259, as uid 1000 with no privilege escalation, inside systemd’s delegated user@1000.service subtree (Delegate=yes, DelegateControllers=cpu io memory pids). Test cgroups were created with mkdir and torn down with cgroup.kill. The workload was a 60-line C program that mallocs N MiB and writes one byte per 4 KiB page. Because the host kernel is 7.1 rather than v6.12, every numeric claim was cross-checked against the v6.12 source paths listed above; the divergences found are called out in place (memory.events gains a sock_throttled field on 7.1 that v6.12 lacks).