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 setting scan_control.target_mem_cgroup. It runs in four distinct situations: target reclaim when a cgroup breaches its memory.high (throttle) or memory.max (hard limit); proactive reclaim triggered from userspace by writing to memory.reclaim; the memory.min/memory.low protection that deflects global reclaim away from a cgroup’s pages; and the per-cgroup pressure signal (PSI, exposed at memory.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-12 kernel.org/releases.json lists mainline 7.3-rc2, stable 7.2.5, and six longterm series including both 6.18.51 and 6.12.109. Everything is verified against mm/memcontrol.c, mm/vmscan.c, kernel/sched/psi.c and Documentation/admin-guide/cgroup-v2.rst at the v6.12 tag, with volatile interfaces spot-checked against v6.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.pressure is not a reclaim meter. Measured on the machine this note was written on, a cgroup with memory.high=32M and swap disabled accumulated 431 seconds of cumulative PSI memory stall and 4,353 memory.events high transitions while memory.stat reported pgscan 0 and pgsteal 0 — not one page scanned, not one page freed. The entire stall was schedule_timeout_killable() at mm/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 reads memory.pressure as “how hard is this cgroup reclaiming” gets all three of those backwards. The section Pressure is not reclaim below has the full table and the psi_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, ...) walks target_memcg and all its descendants. For global reclaim target_memcg is 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 own lruvec, reclaim of one cgroup never touches another’s pages.
  • mem_cgroup_calculate_protection then the below_min/below_low checks implement protection (next section).
  • shrink_lruvec does the real scanning (active→inactive demotion and inactive eviction — see The LRU Lists), and shrink_slab reclaims 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; for kswapd (and memcg_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 the mem_cgroup_below_min → continue branch above: such a cgroup is skipped entirely, and if that starves reclaim of candidates, the OOM killer fires rather than violate min.
  • 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 the mem_cgroup_below_low branch: on the first reclaim pass the cgroup is skipped and sc->memcg_low_skipped is set; if that pass cannot meet the goal, reclaim retries with sc->memcg_low_reclaim = 1 and overrides low protection, emitting a MEMCG_LOW event 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 &lt; effective<br/>memory.min ?"}
  LOW{"usage &lt; effective<br/>memory.low ?"}
  P1{"first pass?<br/>(sc-&gt;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-&gt;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-&gt;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
Runmemory.highmemory.maxmemory.swap.maxwall timeevents.highevents.maxpgscanpgstealPSI some totaloutcome
A baselinemaxmax00.04 s00000 µscompletes instantly
B throttled, nothing reclaimable32Mmax0>466 s, killed incomplete4,353000430,908,703 µs ≈ 431 spure penalty sleep
C throttled, swap available32Mmaxmax0.08 s165021,69516,96835,512 µs ≈ 36 msreal reclaim, no perceptible stall
D hard limit, swap availablemax32Mmax0.08 s028916,52516,52429,231 µs ≈ 29 mstry_charge reclaim loop
E hard limit, nothing reclaimablemax32M00.02 s0360039 µsoom 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 functionWhat is being waited forReclaim involved?
1mm/memcontrol.c:1902reclaim_high()try_to_free_mem_cgroup_pages() for a cgroup over memory.high — and every ancestor over its own highyes
2mm/memcontrol.c:2149mem_cgroup_handle_over_high()schedule_timeout_killable(penalty_jiffies) — a punitive sleep, reached only after reclaim failed to make progressno — this is the throttle
3mm/memcontrol.c:2210try_charge_memcg()try_to_free_mem_cgroup_pages() for a cgroup at memory.max, before the memcg OOM killeryes
4mm/vmscan.c:6849balance_pgdat()the whole of kswapd’s background reclaim passyes — but see the caveat below
5mm/vmscan.c:7480__node_reclaim()node-local reclaim (vm.zone_reclaim_mode)yes
6mm/page_alloc.c:3679__alloc_pages_direct_compact()synchronous compaction in the allocator slow pathno — compaction, not reclaim
7mm/page_alloc.c:3948__alloc_pages_direct_reclaim()global direct reclaim from the page allocatoryes
8mm/filemap.c:1244folio_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 evictedno — thrashing
9mm/filemap.c:1397migration_entry_wait_on_locked()same refault condition, during page migrationno — thrashing
10mm/filemap.c:2366filemap_read_folio()a synchronous read of a workingset foliono — thrashing
11mm/page_io.c:627swap_read_folio()swap-in of a workingset foliono — thrashing
12mm/readahead.c:156, :768, :797read_pages(), readahead_expand()readahead that touched a workingset folio (rac->_workingset)no — thrashing
13mm/compaction.c:3185kcompactd()background compaction workno — compaction
14block/blk-cgroup.c:1924blkcg_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 behalfno — 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 own memory.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 fieldMeaningIncrements on
memory.stat pgscanpages examined on the LRU listsany reclaim pass that scans, including failed ones
memory.stat pgstealpages actually freedsuccessful eviction only
memory.stat pgscan_direct / pgsteal_directthe subset done in the allocating task’s own contextdirect reclaim, the latency-visible kind
memory.stat pgscan_kswapd / pgsteal_kswapdthe subset done by kswapdbackground reclaim
memory.stat pgscan_proactive / pgsteal_proactivethe subset driven by memory.reclaimproactive reclaim only — separated precisely so it does not pollute the other two
memory.stat pgrefillpages moved active → inactivereclaim under real pressure (deactivation), a leading indicator
memory.stat workingset_refault_anon / _filepages faulted back in that had been evicted while in the working setthrashing, and the trigger for sites 8–12 above
memory.events lowtimes the cgroup’s memory.low protection was breached by global reclaimsecond-pass reclaim overrode soft protection
memory.events hightimes the cgroup was throttled at memory.highthe throttle, not reclaim — run B scored 4,353 of these with zero reclaim
memory.events maxtimes an allocation hit memory.max and had to reclaim or failthe hard limit binding
memory.events oomtimes reclaim failed and the memcg OOM path was enteredthe signal memory.pressure misses
memory.events oom_killprocesses actually killedOOM kills, including ones PSI reports as 39 µs
memory.events oom_group_killtimes a whole cgroup was killed atomicallymemory.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-pgscan result in B and E is device-independent: with memory.swap.max = 0 there is nothing to scan regardless. To resolve: re-run under a v6.12 kernel with a disk-backed swap partition and record C’s some 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.

KnobKindEnforced whereWhat reclaim it causesOn failureEvents emittedDefault
memory.minhard protectionshrink_node_memcgs() → mem_cgroup_below_min()none — it deflects global reclaimif no unprotected memory remains, the OOM killer fires rather than violate min—0
memory.lowbest-effort protectionshrink_node_memcgs() → mem_cgroup_below_low(), plus proportional scaling in get_scan_count()none; it deflects first-pass global reclaim onlysecond-pass reclaim sets sc->memcg_low_reclaim and overrides itmemory.events low0
memory.highthrottlerecorded in try_charge_memcg(), acted on in mem_cgroup_handle_over_high() on the return-to-userspace pathreclaim_high() on the cgroup and every ancestor over its own highpenalty sleep up to 2 s per batch; never OOM-kills; the limit can be breachedmemory.events highmax
memory.maxhard limitsynchronously inside try_charge_memcg()try_to_free_mem_cgroup_pages() in the allocating task’s context, up to MAX_RECLAIM_RETRIESmemcg OOM killer, scoped to the cgroup (or -ENOMEM for allocations that may not OOM)memory.events max, then oom, oom_killmax

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 above MEMCG_DELAY_PRECISION_SHIFT for a 100 MB high, 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, and lore.kernel.org returns HTTP 403 to curl so 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:

usagedelayusagedelayusagedelay
100M0 ms107M313 ms114M1254 ms
101M6 ms108M409 ms115M1439 ms
102M25 ms109M518 ms116M1638 ms
103M57 ms110M639 ms117M1849 ms
104M102 ms111M774 ms118M2000 ms (clamped)
105M159 ms112M921 ms119M2000 ms
106M230 ms113M1081 ms120M2000 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:

  1. It converges in quarters. Each iteration requests (nr_to_reclaim - nr_reclaimed) / 4 pages, so it homes in on the target rather than overshooting in one giant pass.
  2. MEMCG_RECLAIM_PROACTIVE suppresses 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 and vmpressure are not exercised (note the if (!sc->proactive) guard around vmpressure in shrink_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.high rather than having migrated to memory.reclaim — the README’s examples predate memory.reclaim’s addition. To resolve: read the current senpai.py source 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=1044641934

Note 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=1152808663

total 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 total above from the source I read. Naively, psi_group_change() walks a task’s cgroup ancestry updating every level’s per-CPU task counters, and test_state() for PSI_MEM_SOME is just tasks[NR_MEMSTALL] != 0, which should make an ancestor’s stall intervals a superset of a descendant’s and hence its total larger, not smaller. The gap was ~9 % and reproduced across repeated reads, so it is not sampling noise. Candidate explanations I did not verify: per-CPU state_start sampling skew across levels; the PSI_ONCPU/TSK_MEMSTALL_RUNNING interaction introduced for the full fix; or accounting differences between psi_system and cgroup PSI groups. To resolve: instrument record_times() for a known synthetic stall, or read psi_group_change() and record_times() in full at kernel/sched/psi.c — I read only psi_memstall_enter/leave and 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 > window

The 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
1

That 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.rst still says “memory pressure monitoring mechanism isn’t implemented yet.” That sentence predates PSI and is stale — the implemented mechanism is precisely memory.pressure/PSI documented elsewhere in the same file and in psi.rst. Treat memory.pressure as the real, shipped per-cgroup pressure signal; the stale line should not be cited as current. uncertain

Pitfalls / Misconceptions

  • “memory.min/memory.low cause reclaim.” They do the opposite — they protect a cgroup from global reclaim. The reclaim-triggering limits are high and max. Confusing protection with limiting is the single most common memcg mistake.
  • “memory.high is a hard cap.” No. high throttles (penalty sleep + reclaim) and can be breached under extreme pressure; it never OOM-kills. Only memory.max is a hard cap that can invoke the (cgroup-scoped) OOM killer. Use high for graceful control, max as the backstop.
  • “memory.reclaim shows up as memory pressure.” It is flagged MEMCG_RECLAIM_PROACTIVE specifically so it does not register as pressure (no vmpressure, 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_lruvec code, distinguished only by scan_control.target_mem_cgroup. Global reclaim is just shrink_node_memcgs with the root as the target.
  • “PSI full includes the CPU controller.” For memory, full means every non-idle task stalled on memory; it is per-resource. But be careful with the corollary: in a small cgroup full is 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’s memory.pressure showed full at 8.36 % against some at 8.89 % — nearly identical, because there was effectively one stalling task.
  • “High memory.pressure means the cgroup is reclaiming.” The single biggest misconception, and the reason this note has a whole section on it. Measured: 431 seconds of PSI with pgscan 0. memory.pressure counts time inside psi_memstall_enter() brackets, and the memory.high penalty sleep is one of them. Confirm with pgscan/pgsteal before concluding anything about reclaim.
  • “Low memory.pressure means the cgroup is healthy.” Also measured false: a cgroup whose process was OOM-killed at memory.max registered 39 microseconds of PSI — a rounded 0.00 on every average. If nothing is reclaimable there is nothing to wait for, so the most severe outcome produces the least pressure. Alert on memory.events oom/oom_kill separately; PSI will not cover you.
  • “A cgroup’s pressure is a share of its parent’s.” Measured false on this host: total decreased 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.rst says; 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 real memory.pressure file as uid 1000. An unprivileged agent cannot get sub-2-second notifications without CAP_SYS_RESOURCE.
  • “The memory.high penalty grows exponentially.” The source comment says so twice; the arithmetic is overage². 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

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/leave brackets.
  • Linux v6.12 mm/vmscan.c — try_to_free_mem_cgroup_pages, shrink_node_memcgs, get_scan_count protection scaling, cgroup_reclaim/root_reclaim.
  • Linux v6.12 Documentation/admin-guide/cgroup-v2.rst — memory.min/low/high/max/reclaim/pressure semantics, hierarchical protection design essay, memory ownership.
  • Linux v6.12 Documentation/accounting/psi.rst — some/full definitions.
  • Linux v6.18 mm/memcontrol.c and cgroup-v2.rst — spot-check that memory_reclaim and the swappiness nested key persist (range documented as [0-200, max] in 6.18; swappiness=max is a post-6.12 addition).
  • Linux v6.12 kernel/sched/psi.c — psi_memstall_enter/psi_memstall_leave (:1047, :1078), psi_trigger_create window validation and the CAP_SYS_RESOURCE privilege 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.c in 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 the psi_memstall_enter penalty-sleep bracket (:2149), try_charge_memcg (:2153) and its deferred-high loop (:2296–2350).
  • The complete psi_memstall_enter() call-site map was produced by grepping the v6.12 mm/ and block/ trees: mm/memcontrol.c (:1902, :2149, :2210), mm/vmscan.c (:6849 balance_pgdat, :7480 __node_reclaim), mm/filemap.c (:1244 folio_wait_bit_common, :1397 migration_entry_wait_on_locked, :2366 filemap_read_folio), mm/page_alloc.c (:3679 __alloc_pages_direct_compact, :3948 __alloc_pages_direct_reclaim), mm/compaction.c (:3185 kcompactd), mm/page_io.c (:627 swap_read_folio), mm/readahead.c (:156, :768, :797), block/blk-cgroup.c (:1924 blkcg_maybe_throttle_blkg).
  • LWN 759781 (“Tracking pressure-stall information” — PSI some/full and per-cgroup *.pressure files); LWN 813044 (“mm: memcontrol: asynchronous reclaim for memory.high” — why reclaim is attempted before the penalty sleep); Meta PSI overview (oomd uses PSI triggers); facebookincubator/senpai README (the workingset-sizing use case, with its kernel-compile numbers).
  • kernel.org/releases.json, read 2026-09-12 — mainline 7.3-rc2, stable 7.2.5, longterm 6.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).