Re: [PATCH v4 4/5] mm/memcontrol: convert memcg to use page_counter_stock

From: Oliver Sang

Date: Thu Jul 30 2026 - 03:18:02 EST


hi, Joshua Hahn,

we are currently piloting AI integrations to help enhance 0-Day's performance
analysis and regression-detection capabilities. Since this is in an early trial
phase, we want to ensure we build features that genuinely support your workflow.
We would love to hear what you would like to see from AI-assisted reports, whether
it's specific bottleneck explanations, a cleaner format, or filtering ideas.

Please share your thoughts and suggestions with us to help guide our development!


0-day Regression Report: memcg page_counter_stock conversion adds an
unbatched hierarchical uncharge to the socket free path
(73.9% netperf SCTP_RR loss)
======================================================================

Data source: Call-graph profile embedded in the 0-day report (per-frame
cycle attribution over full call stacks). The raw per-run
artifacts have since aged out; the profile quoted below is
the one carried in the report itself.
Evidence tier: callgraph

Factual Summary
---------------
Regression (restated for standalone reading):
Test: netperf, SCTP_RR, ipv4, cs-localhost, 200% nr_threads,
300s runtime, performance governor
Metric: netperf.Throughput_tps: 26124 -> 6815 (-73.9%)
Machine: 2 sockets, 224 threads, Intel Xeon (Sapphire Rapids), 128G
Config: x86_64-rhel-9.4, gcc-14
Commit: 1e8017bb42 ("[PATCH v4 4/5] mm/memcontrol: convert memcg to
use page_counter_stock")
Baseline: 35587f026a ("mm/page_counter: introduce
page_counter_try_charge_stock()")

This is an unmerged patch under review (v4, posted 2026-06-23), not a
commit in mainline. The regression was measured on the posted series.

What this commit does:

The series moves the memcg per-CPU charge cache (the shared, 7-slot
memcg_stock) down into each struct page_counter as a per-counter percpu
stock. Patch 1 adds the stock and its drain; patch 3 adds a stock-aware
charge, page_counter_try_charge_stock(). This patch (4/5) switches
try_charge_memcg() over to the stocked charge and repoints the uncharge
callers off the old stock. The stock is charge-only: there is no
stock-returning uncharge helper, so the converted uncharge callers now
call the hierarchical page_counter_uncharge() directly.

Code changes (verbatim, mm/memcontrol.c):

mem_cgroup_sk_uncharge() -- the socket free path:
mod_memcg_state(memcg, MEMCG_SOCK, -nr_pages);
- refill_stock(memcg, nr_pages);
+ page_counter_uncharge(&memcg->memory, nr_pages);

obj_cgroup_uncharge_pages() -- the kmem page free path:
if (!mem_cgroup_is_root(memcg))
- refill_stock(memcg, nr_pages);
+ memcg_uncharge(memcg, nr_pages);

For contrast, the charge side in try_charge_memcg() KEEPS batching via
the new per-counter stock:
+ if (page_counter_try_charge_stock(&memcg->memory, nr_pages,
+ &counter, &nr_charged)) {

What page_counter_uncharge() costs, per call (mm/page_counter.c):
page_counter_uncharge(): for (c = counter; c; c = c->parent)
page_counter_cancel(c, nr_pages);
page_counter_cancel(): atomic_long_sub_return(nr_pages,
&counter->usage); [+ protection
propagation up the tree]
i.e. an atomic RMW on the shared per-memcg counter->usage (and one per
ancestor) on every call. refill_stock() previously accumulated frees in
a percpu slot and ran this walk only when the slot exceeded
MEMCG_CHARGE_BATCH, amortising it ~1/batch.

Measured impact (callgraph):

The profile places page_counter_uncharge() directly on the hot chain
and attributes the majority of sampled cycles to it. Its self-time rises
from ~0 to 61.28% in the SCTP_RR run:

0.00 +61.3 61.28 self.cycles-pp.page_counter_uncharge
0.80 +60.9 61.69 children.cycles-pp.__sk_mem_reduce_allocated

Entering through the two socket-buffer free paths that
mem_cgroup_sk_uncharge() feeds via __sk_mem_reduce_allocated():

0.00 +30.4 30.38 page_counter_uncharge.__sk_mem_reduce_allocated.
skb_release_head_state.sk_skb_reason_drop.
sctp_recvmsg (RX free)
0.00 +30.8 30.84 page_counter_uncharge.__sk_mem_reduce_allocated.
sctp_wfree.skb_release_head_state.consume_skb
(TX completion free)

The nature of the cost -- stalls, not extra work. The machine did LESS
work yet went slower:
perf-stat.ps.instructions: 5.837e11 -> 1.519e11 (-74.0%)
perf-stat.i.cpu-cycles: 5.567e11 -> 5.884e11 (+5.7%, ~flat)
perf-stat.overall.cpi: 0.97 -> 3.85 (+298.7%)
perf-stat.overall.ipc: 1.04 -> 0.26 (-75.0%)
Cycles retired roughly the same while instructions collapsed: the CPUs
are spending their time stalled inside the uncharge, consistent with
atomic_long_sub_return() on a memcg->memory.usage cacheline that all
224 threads' skb frees now contend on every free, instead of ~once per
MEMCG_CHARGE_BATCH frees as under refill_stock().

The remaining top-level counters are symptoms of the throughput drop in
this request/response workload, not independent corroboration:
netperf.workload, netperf.time.voluntary_context_switches and
vmstat.system.cs (all ~-74/-75%) simply reflect that fewer round-trips
completed in the fixed 300s window.

Other impacts:

lmbench3.AF_UNIX.sock.stream.bandwidth.MB/sec -12.1%
lmbench3.TCP.socket.bandwidth.64B.MB/sec -1.9%

The AF_UNIX profile shows the same page_counter_uncharge() signature
from a different free path, confirming the cost is generic to the
uncharge conversion rather than SCTP-specific:
18.21 +12.7 30.90 self.cycles-pp.page_counter_uncharge
19.04 +4.6 23.67 page_counter_uncharge.uncharge_batch.
__mem_cgroup_uncharge.__folio_put.
skb_release_data
0.00 +7.5 7.47 page_counter_uncharge.__refill_obj_stock.
__memcg_slab_free_hook.kfree.skb_release_data

Contextual Analysis
-------------------
(analytical / speculative -- labeled; not part of the verified record)

Patch series context:
The series (RFC 2026-04-10, iterated to v4 2026-06-23) is a performance
change: its stated aim (patch 1 changelog) is to "avoid expensive
hierarchy walks on every memcg charge" by replacing the shared 7-slot
memcg_stock with a per-page_counter stock, curing victim-eviction
thrashing between memcgs. The design carried a charge-side fast path
(page_counter_try_charge_stock) but no matching uncharge-side stock.
The regression appears to be the direct consequence of that asymmetry:
converting mem_cgroup_sk_uncharge()/obj_cgroup_uncharge_pages() from the
batched refill_stock() to the unbatched page_counter_uncharge()/
memcg_uncharge() removed the only batching those hot free paths had. A
socket ping-pong workload frees an skb on every packet, so it hammers
exactly the path that lost its batch. This reading is inferred from the
recorded stacks and the code; it is not a statement from the author.

During v4 review Usama Arif raised a separate, correctness concern on
the same patch: try_charge_memcg()'s memory-failure path can uncharge
nr_pages from memsw even when the memsw charge was satisfied from stock
(msg-id 120367a5-0a3c-40ba-a821-f46f8494ef85@xxxxxxxxx). He later partly
walked it back in the same thread. That is about transactional rollback,
not this throughput regression.

Related issues:
The two lmbench3 socket-bandwidth regressions reported alongside netperf
(see Other impacts) share the identical page_counter_uncharge mechanism
and are the same root cause, not separate findings.

Subsystem status:
No reply to the 0-day report exists in the thread and no v5 has been
posted, so the regression is unacknowledged as of this writing. The
natural fix is symmetry: give the uncharge side a stock-returning helper
(mirroring page_counter_try_charge_stock) so the socket/kmem free paths
refill a percpu stock instead of walking the hierarchy per free, drained
in batches -- restoring the amortisation the baseline's refill_stock()
provided.

Assessment:
High confidence in the mechanism. The embedded call-graph profile puts
page_counter_uncharge() directly on the hot chain (~61% of cycles in
SCTP_RR), the responsible source line is quoted verbatim from the patch,
and the flat-cycles / collapsed-instructions signature identifies the
cost as stalls inside the counter's atomic RMW rather than added
instruction volume -- most likely cross-CPU contention on the shared
memcg->memory.usage cacheline, though that specific cause is inferred,
not directly measured here. The charge-vs-uncharge asymmetry in the series
makes the free path the obvious locus. This looks like an unintended
side-effect of the conversion, fixable within the series before merge,
not an accepted trade-off.

Add page_counter_uncharge_stock(), mirroring the credit/consume logic
already used by page_counter_try_charge_stock(): fold small, bounded
uncharges into the local per-CPU stock when there is room (capped at
counter->batch, exactly like the charge-side surplus), and only fall
back to the exact hierarchical page_counter_uncharge() when the stock
is unavailable or already full. Route mem_cgroup_sk_uncharge()
through it.

Sample Fix:
Fix commit: bbb5964710b863d6f03c73fe8806b6d61af231cc on linux-devel/fixup-1e8017bb42651bb1dc84917b7864ae6159866b1d
Validation [PARTIAL]: shows -7.1% vs. parent on netperf.Throughput_tps (reduced from -73.9%; further investigation
may be needed).