* [RFC PATCH 0/7] cgroup: charge kernel work to the cgroup it is done for
@ 2026-09-24 18:45 Shakeel Butt
2026-09-24 18:50 ` Shakeel Butt
0 siblings, 1 reply; 5+ messages in thread
From: Shakeel Butt @ 2026-09-24 18:45 UTC (permalink / raw)
To: Tejun Heo, Johannes Weiner, Peter Zijlstra
Cc: Michal Koutný,
Michal Hocko, Roman Gushchin, Muchun Song, Andrew Morton,
Ingo Molnar, Juri Lelli, Vincent Guittot, Dietmar Eggemann,
Steven Rostedt, Ben Segall, Mel Gorman, Valentin Schneider,
K Prateek Nayak, Suren Baghdasaryan, Kumar Kartikeya Dwivedi,
David Dai, JP Kobryn, Frederic Weisbecker, Aaron Lu,
Daniel Jordan, Hao Lee, cgroups, bpf, linux-mm, linux-kselftest,
linux-kernel
Sometimes a kernel thread does work for a cgroup. The CPU time of that
work is charged to the thread's own cgroup, which is usually the root.
The cgroup that caused the work never sees it, and never pays for it.
This series lets a kernel thread say which cgroup it is working for.
That cgroup then sees the CPU time in its cpu.stat and the stalls in
its memory.pressure, and the CPU time comes out of its cpu.max quota.
The first user is the memcg reclaim that runs from high_work.
The problem
===========
When a memcg goes over memory.high, the task that charged the memory
is made to reclaim on its way back to user space. That does not work
when the charge happens in IRQ context, because the interrupted task
may have nothing to do with the memcg. For that case the kernel
queues memcg->high_work, and a kworker does the reclaim later.
The kworker runs in the root cgroup. So for that reclaim:
- its CPU time does not show up in the memcg's cpu.stat;
- its reclaim work does not show up in the memcg's memory.pressure;
- its CPU time does not count against the memcg's cpu.max limit;
- the rest of the system sees it as system overhead.
It is one case of a wider gap. For memory, a kernel thread can charge
an allocation to another memcg with set_active_memcg(). For IO there
is kthread_associate_blkcg(). For CPU time and pressure there is
nothing like it. And a kworker cannot just be moved into the cgroup:
kworkers are bound kthreads (PF_NO_SETAFFINITY), and cgroup refuses to
move those.
For our lock isolation work [5, 6], we plan to use async reclaim more
extensively, not just for charge requests from IRQ context, and thus
we want to make the CPU accounting of this work accurate.
Earlier attempts
================
This has come up before, with memory reclaim as one of the main
examples.
In 2019, Daniel Jordan posted "cgroup-aware unbound workqueues" [1].
It moved the worker into the work item's cgroup before running it, and
back to the root after. A move took about 1 us, but every move takes
cgroup_mutex and cgroup_threadgroup_rwsem, so moves cannot run in
parallel. The workers also showed up in cgroup.procs and blocked
rmdir, and cpuset did not work. It was not merged. In his reply [2],
Tejun said the direction for memory and IO is "remote charging, where
a kthread explicitly says who the specific io or allocation is for",
along with back-charging, and that "CPU doesn't have a backcharging
mechanism yet".
In 2021, Hao Lee asked for remote charging of CPU time [3]. His case
is ours: memcg reclaim from a workqueue "will steal cpu time from the
system level, which breaks the resource isolation". Tejun agreed [4]
that something like this is needed for CPU time spent in common code
for a specific cgroup, "e.g. memory reclaim, net packet rx", and said
he knew of no patches for it. Daniel said he was working on one.
Nothing has been merged since.
Options we looked at
====================
A. Keep the shared kworker, and only charge the cgroup.
A1. Measure how long the reclaim ran, then charge that time to the
memcg's cgroup.
A2. Let the kworker say "charge this cgroup" while it works, the
same way set_active_memcg() works for memory.
B. Also make the cgroup's CPU limits apply.
B1. Run the kworker in the cgroup's scheduling group while it works,
so cpu.weight and cpu.max apply to it.
B2. Run the reclaim at full speed, then take its CPU time out of the
cgroup's cpu.max quota afterwards (back-charging).
C. Do the reclaim inside the cgroup.
C1. Record the overage as a debt on the memcg, and let the memcg's
own tasks pay it the next time they charge memory or return to
user space.
C2. Give each memcg its own reclaim thread that lives in the cgroup.
C3. Use a cgroup-aware workqueue, as in [1], or per-cgroup worker
pools.
What we chose and why
=====================
We chose A2, together with B2 for cpu.max.
We do not throttle the reclaim itself. Adding limits to it is more
complicated and most probably unneeded, as we envision that we will
need concurrent background reclaimers instead of throttling in a
real-world environment. We are working on developing a system to
balance the rate of allocations/charges with the rate of reclaim, to
keep the system always running effectively.
In addition, reclaim takes sleeping locks, like i_mmap_rwsem and the
anon_vma lock in rmap walks, and fs locks in shrinkers. With a low
cgroup weight, the kworker can be preempted while it holds them, and
then tasks in other cgroups wait on it. It also hurts the workqueue. A
kworker that is runnable but not running still counts as running, and
the workqueue only spots CPU hogs by the CPU time they use. So other
work queued on that CPU's system_wq waits too.
Still, the reclaim should not be free CPU time on top of the cgroup's
limit. With A2 alone, the reclaim shows up in cpu.stat, but the
cgroup's own tasks still get their full cpu.max quota. B2 fixes that
without slowing the reclaim down. The kworker runs at full speed, and
afterwards its time is taken out of the cgroup's cpu.max quota, so the
cgroup's own tasks get less CPU time instead. This is the
back-charging Tejun described [2]. It only covers cpu.max.
Back-charging cpu.weight is future work.
We did not take C2 or C3. A high_work run asks for only 64 pages,
which is far too little to pay for moving a thread into a cgroup
through the global cgroup locks. A thread per memcg means thousands of
threads. cgroup v2 does not allow tasks in a non-leaf domain cgroup.
And a kernel thread in a cgroup shows up in cgroup.procs and blocks
rmdir.
C1 changes behaviour. memory.high would start slowing tasks down for
socket memory that arrived in softirq, and it would still need a
fallback for when no task in the memcg runs. That deserves its own
discussion.
Between A1 and A2: A1 is a few lines and easy to backport, but it only
fixes cpu.stat, it charges the time in one lump at the end, and it
only helps this one user. A2 charges the time as it is used, PSI can
follow it, and any kernel thread that works for a cgroup can use it.
How it works
============
task_struct gets an active_cgroup pointer. set_active_cgroup() sets it
and returns the old value, which the caller puts back when done:
old = set_active_cgroup(memcg->css.cgroup);
reclaim_high(memcg, MEMCG_CHARGE_BATCH, GFP_KERNEL);
set_active_cgroup(old);
Three important details:
- Before switching, set_active_cgroup() charges the time used so far
to the old target, under the task's rq lock. A high_work run is
often shorter than a tick. Without this, its time goes to whatever
is set the next time the scheduler adds up run time, which is
usually root again.
- Code run under set_active_cgroup() is always kernel code. So its
time is charged as system time right away, and the tick and vtime
paths skip it. Without this, on nohz_full CPUs vtime posts kernel
time late, after the old value is back, and system_usec comes out
as 0. Forced idle time from core scheduling comes through the same
path. It is not run time, so it goes to the active cgroup there.
- PSI follows active_cgroup too. When it changes, the task's pressure
state moves from the old groups to the new ones under the same rq
lock, the same way cgroup_move_task() does for a real move.
For cpu.max, set_active_cgroup() also notes the task's run time at
each switch. When it switches away from a cgroup, it passes the time
used under that cgroup to cfs_bandwidth_charge():
- The time is taken from the cpu.max pool of the cgroup and of each
ancestor that has a limit. These are the same pools the cgroup's
own tasks draw from.
- The kworker is never throttled, because the work has already run.
If a pool runs dry, the rest becomes debt, which is paid first out
of the next periods' quota. So the cgroup's own tasks get less CPU
time for a while.
- The debt is capped at one period's quota, so one long piece of
work cannot starve the cgroup for long. Time over the cap still
shows up in cpu.stat. Writing cpu.max or cpu.max.burst clears the
debt.
- If neither the cgroup nor any ancestor has a limit, it returns
right away.
The caller must keep the cgroup alive until it puts back the old
value. For high_work this holds: the memcg's css pins its cgroup, and
high_work is cancelled in mem_cgroup_css_free(). If a task exits with
active_cgroup still set, it gets a warning and the field is cleared.
The caller must be in the root cgroup, as kworkers are. The scheduler
still runs the caller in its own cgroup, so from any other cgroup the
time would also be taken out of that cgroup's cpu.max, and on nohz_full
CPUs vtime could post some system time to the wrong cgroup at a switch.
For such a caller, set_active_cgroup() warns and does nothing.
Which cgroup pays for high_work? The work belongs to the memcg that is
over memory.high. A charge in IRQ context walks up from the charging
memcg and queues the high_work of the first memcg it finds over
memory.high. So if a child makes the charge but only its parent is
over memory.high, the reclaim is charged to the parent: its cpu.stat,
its memory.pressure, and the cpu.max of the parent and of each
ancestor above it that has a limit. The child is not charged. Reclaim
in task context is different: the charging task reclaims on its way
back to user space, so the child and every ancestor above it are
charged.
The patches
===========
1: A helper that charges kernel CPU time to a cgroup.
2: set_active_cgroup().
3: PSI following it.
4: A helper that takes kernel CPU time out of a cgroup's cpu.max.
5: set_active_cgroup() using it.
6: high_work using set_active_cgroup().
7: Selftests for high_work.
Testing
=======
- End to end, with the setup above. Without the series, the memcg's
cpu.stat and memory.pressure did not move. With it, the memcg's
usage_usec matched the CPU time that high_work used, measured with
bpftrace, to within 1%, all of it as system time, also on nohz_full.
memory.pressure grew by the reclaim's stall time.
- A test module that calls set_active_cgroup() directly. The charge
matches the CPU time used to within 20 us in runs of 200 to 500 ms.
It also covers nesting, use from a user task in the root cgroup,
over 30,000 switches while userspace moves the thread between
cgroups, a task that exits without putting the old value back, and
rmdir of a cgroup that is still in use. A caller outside the root
cgroup gets one warning, and its time stays with its own cgroup. It
passes on normal, psi=0, cgroup_disable=pressure, nohz_full,
lockdep+KASAN+UBSAN and KCSAN kernels. No pressure state is left
behind, and KCSAN reports nothing in the new code.
- Forced idle, on a core scheduling kernel with SMT siblings. The
module burns 500 ms under set_active_cgroup() next to a busy loop
with a different core scheduling cookie, so each forces the other's
CPU idle. The active cgroup's core_sched.force_idle_usec grows by
exactly the forced idle time charged to the module's thread, about
250 ms per run.
- The same module against cpu.max, with a CPU hog in a cgroup whose
cpu.max is "20000 100000":
- 100 pieces of 2 ms of work for the cgroup cost the hog 214 to
233 ms of CPU time for 199 ms of work. The same holds with the
limit on the parent and the hog in a sibling cgroup.
- Five 300 ms pieces cost the hog 89 to 104 ms, about five
periods' quota. When cpu.max is written after each piece, the
debt is cleared and the hog loses nothing.
- Without a limit, the hog loses nothing.
- rmdir of a cgroup that has debt works.
It passes on normal, lockdep+KASAN+UBSAN and KCSAN kernels.
- A caller inside a limited cgroup, doing 200 ms of work for that same
cgroup. set_active_cgroup() refuses it, so the work is charged once:
a hog there loses 227 to 233 ms, the same as when the caller does
not use set_active_cgroup() at all (211 to 232 ms). Without the
root cgroup rule, it loses 412 to 423 ms, as the time is charged
twice.
- A stress test that creates, fills, kills and removes memcgs while
high_work runs, with and without cpu.max limits on the memcgs and
their parent, on normal and debug kernels. With limits, each run
takes 4 to 5 seconds of high_work time out of cpu.max, in about
2,100 pieces. All the dying memcgs get freed within 2 seconds.
- The two new selftests. Without the series, the first fails and the
second skips, because no reclaim time shows up at all. With only
patches 1-3 and 6, the second fails. With the whole series, both
pass, on normal and debug kernels.
- Every patch builds, with and without cgroups, and with core
scheduling. The whole series builds cleanly without PSI, without
memcg, without cgroups, without CFS bandwidth control, without
CGROUP_SCHED, on UP, with PREEMPT_RT, with SCHED_CORE, with
nohz_full, on i386, with clang, on arm64, with allmodconfig, and
with W=1 on the touched files.
- No measurable change in perf bench sched pipe or messaging. The hot
path gains one load and one branch.
Open questions
==============
- When the memcg's own tasks are idle, the kworker is its only busy
member while it reclaims. So memory.pressure "full" equals "some"
for that time. Is that what we want? Tools like oomd will see it.
- Is set_active_cgroup() the right name and place? It is limited to
callers in the root cgroup. Other callers would need the vtime state
split at each switch, and their own cpu.max left alone while the
override is set. Is that worth doing?
- Is one period's quota the right cap for the cpu.max debt? A larger
cap makes the cgroup pay for more of a long run, but can stall its
tasks for longer.
- Should high_work charge the memcg that made the charge instead, as
reclaim in task context does? That would mean queuing the charging
memcg's own high_work, so several children of an over-high parent
could reclaim at the same time, instead of one shared work item.
- Other kernel threads that work for one cgroup could use
set_active_cgroup() too. Network receive itself is harder, because
the cgroup is only known after the work starts, as Daniel pointed
out in the thread at [3].
[1] https://lore.kernel.org/20190605133650.28545-1-daniel.m.jordan@oracle.com/
[2] https://lore.kernel.org/20190605135319.GK374014@devbig004.ftw2.facebook.com/
[3] https://lore.kernel.org/60decdb6.1c69fb81.6130e.7642@mx.google.com/
[4] https://lore.kernel.org/YN+Sne76dhKBzV%2FR@mtj.duckdns.org/
[5] https://lore.kernel.org/20260921192559.2619635-1-shakeel.butt@linux.dev/
[6] https://lore.kernel.org/20260924155025.949998-1-shakeel.butt@linux.dev/
Shakeel Butt (7):
cgroup: add cgroup_account_system_time()
cgroup: add set_active_cgroup() to charge CPU time to a cgroup
psi: charge pressure to the task's active cgroup
sched/fair: add cfs_bandwidth_charge() for kernel work done for a
cgroup
cgroup: take set_active_cgroup() time out of the cgroup's cpu.max
memcg: charge high_work reclaim to the memcg
selftests: cgroup: check that high_work reclaim is charged to the
memcg
include/linux/cgroup.h | 39 ++
include/linux/sched.h | 4 +
kernel/cgroup/cgroup.c | 4 +
kernel/fork.c | 4 +
kernel/sched/core.c | 52 +++
kernel/sched/fair.c | 43 +++
kernel/sched/psi.c | 22 +-
kernel/sched/sched.h | 8 +
kernel/sched/stats.h | 10 +
mm/memcontrol.c | 5 +
.../selftests/cgroup/test_memcontrol.c | 332 ++++++++++++++++++
11 files changed, 522 insertions(+), 1 deletion(-)
base-commit: a8c591ed6b672915e0be57843f943a2a723aff40
--
2.53.0-Meta
^ permalink raw reply [flat|nested] 5+ messages in thread* Re: [RFC PATCH 0/7] cgroup: charge kernel work to the cgroup it is done for
2026-09-24 18:45 [RFC PATCH 0/7] cgroup: charge kernel work to the cgroup it is done for Shakeel Butt
@ 2026-09-24 18:50 ` Shakeel Butt
0 siblings, 0 replies; 5+ messages in thread
From: Shakeel Butt @ 2026-09-24 18:50 UTC (permalink / raw)
To: Tejun Heo, Johannes Weiner, Peter Zijlstra
Cc: Michal Koutný,
Michal Hocko, Roman Gushchin, Muchun Song, Andrew Morton,
Ingo Molnar, Juri Lelli, Vincent Guittot, Dietmar Eggemann,
Steven Rostedt, Ben Segall, Mel Gorman, Valentin Schneider,
K Prateek Nayak, Suren Baghdasaryan, Kumar Kartikeya Dwivedi,
David Dai, JP Kobryn, Frederic Weisbecker, Aaron Lu,
Daniel Jordan, Hao Lee, cgroups, bpf, linux-mm, linux-kselftest,
linux-kernel
Somehow the git send-email got killed in between. Please ignore this.
The full RFC is at
https://lore.kernel.org/20260924184714.912181-1-shakeel.butt@linux.dev
^ permalink raw reply [flat|nested] 5+ messages in thread
* [RFC PATCH 0/7] cgroup: charge kernel work to the cgroup it is done for
@ 2026-09-24 18:47 Shakeel Butt
2026-09-24 20:28 ` Tejun Heo
0 siblings, 1 reply; 5+ messages in thread
From: Shakeel Butt @ 2026-09-24 18:47 UTC (permalink / raw)
To: Tejun Heo, Johannes Weiner, Peter Zijlstra
Cc: Michal Koutný,
Michal Hocko, Roman Gushchin, Muchun Song, Andrew Morton,
Ingo Molnar, Juri Lelli, Vincent Guittot, Dietmar Eggemann,
Steven Rostedt, Ben Segall, Mel Gorman, Valentin Schneider,
K Prateek Nayak, Suren Baghdasaryan, Kumar Kartikeya Dwivedi,
David Dai, JP Kobryn, Frederic Weisbecker, Aaron Lu,
Daniel Jordan, Hao Lee, kernel-team, cgroups, bpf, linux-mm,
linux-kselftest, linux-kernel
Sometimes a kernel thread does work for a cgroup. The CPU time of that
work is charged to the thread's own cgroup, which is usually the root.
The cgroup that caused the work never sees it, and never pays for it.
This series lets a kernel thread say which cgroup it is working for.
That cgroup then sees the CPU time in its cpu.stat and the stalls in
its memory.pressure, and the CPU time comes out of its cpu.max quota.
The first user is the memcg reclaim that runs from high_work.
The problem
===========
When a memcg goes over memory.high, the task that charged the memory
is made to reclaim on its way back to user space. That does not work
when the charge happens in IRQ context, because the interrupted task
may have nothing to do with the memcg. For that case the kernel
queues memcg->high_work, and a kworker does the reclaim later.
The kworker runs in the root cgroup. So for that reclaim:
- its CPU time does not show up in the memcg's cpu.stat;
- its reclaim work does not show up in the memcg's memory.pressure;
- its CPU time does not count against the memcg's cpu.max limit;
- the rest of the system sees it as system overhead.
It is one case of a wider gap. For memory, a kernel thread can charge
an allocation to another memcg with set_active_memcg(). For IO there
is kthread_associate_blkcg(). For CPU time and pressure there is
nothing like it. And a kworker cannot just be moved into the cgroup:
kworkers are bound kthreads (PF_NO_SETAFFINITY), and cgroup refuses to
move those.
For our lock isolation work [5, 6], we plan to use async reclaim more
extensively, not just for charge requests from IRQ context, and thus
we want to make the CPU accounting of this work accurate.
Earlier attempts
================
This has come up before, with memory reclaim as one of the main
examples.
In 2019, Daniel Jordan posted "cgroup-aware unbound workqueues" [1].
It moved the worker into the work item's cgroup before running it, and
back to the root after. A move took about 1 us, but every move takes
cgroup_mutex and cgroup_threadgroup_rwsem, so moves cannot run in
parallel. The workers also showed up in cgroup.procs and blocked
rmdir, and cpuset did not work. It was not merged. In his reply [2],
Tejun said the direction for memory and IO is "remote charging, where
a kthread explicitly says who the specific io or allocation is for",
along with back-charging, and that "CPU doesn't have a backcharging
mechanism yet".
In 2021, Hao Lee asked for remote charging of CPU time [3]. His case
is ours: memcg reclaim from a workqueue "will steal cpu time from the
system level, which breaks the resource isolation". Tejun agreed [4]
that something like this is needed for CPU time spent in common code
for a specific cgroup, "e.g. memory reclaim, net packet rx", and said
he knew of no patches for it. Daniel said he was working on one.
Nothing has been merged since.
Options we looked at
====================
A. Keep the shared kworker, and only charge the cgroup.
A1. Measure how long the reclaim ran, then charge that time to the
memcg's cgroup.
A2. Let the kworker say "charge this cgroup" while it works, the
same way set_active_memcg() works for memory.
B. Also make the cgroup's CPU limits apply.
B1. Run the kworker in the cgroup's scheduling group while it works,
so cpu.weight and cpu.max apply to it.
B2. Run the reclaim at full speed, then take its CPU time out of the
cgroup's cpu.max quota afterwards (back-charging).
C. Do the reclaim inside the cgroup.
C1. Record the overage as a debt on the memcg, and let the memcg's
own tasks pay it the next time they charge memory or return to
user space.
C2. Give each memcg its own reclaim thread that lives in the cgroup.
C3. Use a cgroup-aware workqueue, as in [1], or per-cgroup worker
pools.
What we chose and why
=====================
We chose A2, together with B2 for cpu.max.
We do not throttle the reclaim itself. Adding limits to it is more
complicated and most probably unneeded, as we envision that we will
need concurrent background reclaimers instead of throttling in a
real-world environment. We are working on developing a system to
balance the rate of allocations/charges with the rate of reclaim, to
keep the system always running effectively.
In addition, reclaim takes sleeping locks, like i_mmap_rwsem and the
anon_vma lock in rmap walks, and fs locks in shrinkers. With a low
cgroup weight, the kworker can be preempted while it holds them, and
then tasks in other cgroups wait on it. It also hurts the workqueue. A
kworker that is runnable but not running still counts as running, and
the workqueue only spots CPU hogs by the CPU time they use. So other
work queued on that CPU's system_wq waits too.
Still, the reclaim should not be free CPU time on top of the cgroup's
limit. With A2 alone, the reclaim shows up in cpu.stat, but the
cgroup's own tasks still get their full cpu.max quota. B2 fixes that
without slowing the reclaim down. The kworker runs at full speed, and
afterwards its time is taken out of the cgroup's cpu.max quota, so the
cgroup's own tasks get less CPU time instead. This is the
back-charging Tejun described [2]. It only covers cpu.max.
Back-charging cpu.weight is future work.
We did not take C2 or C3. A high_work run asks for only 64 pages,
which is far too little to pay for moving a thread into a cgroup
through the global cgroup locks. A thread per memcg means thousands of
threads. cgroup v2 does not allow tasks in a non-leaf domain cgroup.
And a kernel thread in a cgroup shows up in cgroup.procs and blocks
rmdir.
C1 changes behaviour. memory.high would start slowing tasks down for
socket memory that arrived in softirq, and it would still need a
fallback for when no task in the memcg runs. That deserves its own
discussion.
Between A1 and A2: A1 is a few lines and easy to backport, but it only
fixes cpu.stat, it charges the time in one lump at the end, and it
only helps this one user. A2 charges the time as it is used, PSI can
follow it, and any kernel thread that works for a cgroup can use it.
How it works
============
task_struct gets an active_cgroup pointer. set_active_cgroup() sets it
and returns the old value, which the caller puts back when done:
old = set_active_cgroup(memcg->css.cgroup);
reclaim_high(memcg, MEMCG_CHARGE_BATCH, GFP_KERNEL);
set_active_cgroup(old);
Three important details:
- Before switching, set_active_cgroup() charges the time used so far
to the old target, under the task's rq lock. A high_work run is
often shorter than a tick. Without this, its time goes to whatever
is set the next time the scheduler adds up run time, which is
usually root again.
- Code run under set_active_cgroup() is always kernel code. So its
time is charged as system time right away, and the tick and vtime
paths skip it. Without this, on nohz_full CPUs vtime posts kernel
time late, after the old value is back, and system_usec comes out
as 0. Forced idle time from core scheduling comes through the same
path. It is not run time, so it goes to the active cgroup there.
- PSI follows active_cgroup too. When it changes, the task's pressure
state moves from the old groups to the new ones under the same rq
lock, the same way cgroup_move_task() does for a real move.
For cpu.max, set_active_cgroup() also notes the task's run time at
each switch. When it switches away from a cgroup, it passes the time
used under that cgroup to cfs_bandwidth_charge():
- The time is taken from the cpu.max pool of the cgroup and of each
ancestor that has a limit. These are the same pools the cgroup's
own tasks draw from.
- The kworker is never throttled, because the work has already run.
If a pool runs dry, the rest becomes debt, which is paid first out
of the next periods' quota. So the cgroup's own tasks get less CPU
time for a while.
- The debt is capped at one period's quota, so one long piece of
work cannot starve the cgroup for long. Time over the cap still
shows up in cpu.stat. Writing cpu.max or cpu.max.burst clears the
debt.
- If neither the cgroup nor any ancestor has a limit, it returns
right away.
The caller must keep the cgroup alive until it puts back the old
value. For high_work this holds: the memcg's css pins its cgroup, and
high_work is cancelled in mem_cgroup_css_free(). If a task exits with
active_cgroup still set, it gets a warning and the field is cleared.
The caller must be in the root cgroup, as kworkers are. The scheduler
still runs the caller in its own cgroup, so from any other cgroup the
time would also be taken out of that cgroup's cpu.max, and on nohz_full
CPUs vtime could post some system time to the wrong cgroup at a switch.
For such a caller, set_active_cgroup() warns and does nothing.
Which cgroup pays for high_work? The work belongs to the memcg that is
over memory.high. A charge in IRQ context walks up from the charging
memcg and queues the high_work of the first memcg it finds over
memory.high. So if a child makes the charge but only its parent is
over memory.high, the reclaim is charged to the parent: its cpu.stat,
its memory.pressure, and the cpu.max of the parent and of each
ancestor above it that has a limit. The child is not charged. Reclaim
in task context is different: the charging task reclaims on its way
back to user space, so the child and every ancestor above it are
charged.
The patches
===========
1: A helper that charges kernel CPU time to a cgroup.
2: set_active_cgroup().
3: PSI following it.
4: A helper that takes kernel CPU time out of a cgroup's cpu.max.
5: set_active_cgroup() using it.
6: high_work using set_active_cgroup().
7: Selftests for high_work.
Testing
=======
- End to end, with the setup above. Without the series, the memcg's
cpu.stat and memory.pressure did not move. With it, the memcg's
usage_usec matched the CPU time that high_work used, measured with
bpftrace, to within 1%, all of it as system time, also on nohz_full.
memory.pressure grew by the reclaim's stall time.
- A test module that calls set_active_cgroup() directly. The charge
matches the CPU time used to within 20 us in runs of 200 to 500 ms.
It also covers nesting, use from a user task in the root cgroup,
over 30,000 switches while userspace moves the thread between
cgroups, a task that exits without putting the old value back, and
rmdir of a cgroup that is still in use. A caller outside the root
cgroup gets one warning, and its time stays with its own cgroup. It
passes on normal, psi=0, cgroup_disable=pressure, nohz_full,
lockdep+KASAN+UBSAN and KCSAN kernels. No pressure state is left
behind, and KCSAN reports nothing in the new code.
- Forced idle, on a core scheduling kernel with SMT siblings. The
module burns 500 ms under set_active_cgroup() next to a busy loop
with a different core scheduling cookie, so each forces the other's
CPU idle. The active cgroup's core_sched.force_idle_usec grows by
exactly the forced idle time charged to the module's thread, about
250 ms per run.
- The same module against cpu.max, with a CPU hog in a cgroup whose
cpu.max is "20000 100000":
- 100 pieces of 2 ms of work for the cgroup cost the hog 214 to
233 ms of CPU time for 199 ms of work. The same holds with the
limit on the parent and the hog in a sibling cgroup.
- Five 300 ms pieces cost the hog 89 to 104 ms, about five
periods' quota. When cpu.max is written after each piece, the
debt is cleared and the hog loses nothing.
- Without a limit, the hog loses nothing.
- rmdir of a cgroup that has debt works.
It passes on normal, lockdep+KASAN+UBSAN and KCSAN kernels.
- A caller inside a limited cgroup, doing 200 ms of work for that same
cgroup. set_active_cgroup() refuses it, so the work is charged once:
a hog there loses 227 to 233 ms, the same as when the caller does
not use set_active_cgroup() at all (211 to 232 ms). Without the
root cgroup rule, it loses 412 to 423 ms, as the time is charged
twice.
- A stress test that creates, fills, kills and removes memcgs while
high_work runs, with and without cpu.max limits on the memcgs and
their parent, on normal and debug kernels. With limits, each run
takes 4 to 5 seconds of high_work time out of cpu.max, in about
2,100 pieces. All the dying memcgs get freed within 2 seconds.
- The two new selftests. Without the series, the first fails and the
second skips, because no reclaim time shows up at all. With only
patches 1-3 and 6, the second fails. With the whole series, both
pass, on normal and debug kernels.
- Every patch builds, with and without cgroups, and with core
scheduling. The whole series builds cleanly without PSI, without
memcg, without cgroups, without CFS bandwidth control, without
CGROUP_SCHED, on UP, with PREEMPT_RT, with SCHED_CORE, with
nohz_full, on i386, with clang, on arm64, with allmodconfig, and
with W=1 on the touched files.
- No measurable change in perf bench sched pipe or messaging. The hot
path gains one load and one branch.
Open questions
==============
- When the memcg's own tasks are idle, the kworker is its only busy
member while it reclaims. So memory.pressure "full" equals "some"
for that time. Is that what we want? Tools like oomd will see it.
- Is set_active_cgroup() the right name and place? It is limited to
callers in the root cgroup. Other callers would need the vtime state
split at each switch, and their own cpu.max left alone while the
override is set. Is that worth doing?
- Is one period's quota the right cap for the cpu.max debt? A larger
cap makes the cgroup pay for more of a long run, but can stall its
tasks for longer.
- Should high_work charge the memcg that made the charge instead, as
reclaim in task context does? That would mean queuing the charging
memcg's own high_work, so several children of an over-high parent
could reclaim at the same time, instead of one shared work item.
- Other kernel threads that work for one cgroup could use
set_active_cgroup() too. Network receive itself is harder, because
the cgroup is only known after the work starts, as Daniel pointed
out in the thread at [3].
[1] https://lore.kernel.org/20190605133650.28545-1-daniel.m.jordan@oracle.com/
[2] https://lore.kernel.org/20190605135319.GK374014@devbig004.ftw2.facebook.com/
[3] https://lore.kernel.org/60decdb6.1c69fb81.6130e.7642@mx.google.com/
[4] https://lore.kernel.org/YN+Sne76dhKBzV%2FR@mtj.duckdns.org/
[5] https://lore.kernel.org/20260921192559.2619635-1-shakeel.butt@linux.dev/
[6] https://lore.kernel.org/20260924155025.949998-1-shakeel.butt@linux.dev/
Shakeel Butt (7):
cgroup: add cgroup_account_system_time()
cgroup: add set_active_cgroup() to charge CPU time to a cgroup
psi: charge pressure to the task's active cgroup
sched/fair: add cfs_bandwidth_charge() for kernel work done for a
cgroup
cgroup: take set_active_cgroup() time out of the cgroup's cpu.max
memcg: charge high_work reclaim to the memcg
selftests: cgroup: check that high_work reclaim is charged to the
memcg
include/linux/cgroup.h | 39 ++
include/linux/sched.h | 4 +
kernel/cgroup/cgroup.c | 4 +
kernel/fork.c | 4 +
kernel/sched/core.c | 52 +++
kernel/sched/fair.c | 43 +++
kernel/sched/psi.c | 22 +-
kernel/sched/sched.h | 8 +
kernel/sched/stats.h | 10 +
mm/memcontrol.c | 5 +
.../selftests/cgroup/test_memcontrol.c | 332 ++++++++++++++++++
11 files changed, 522 insertions(+), 1 deletion(-)
base-commit: a8c591ed6b672915e0be57843f943a2a723aff40
--
2.53.0-Meta
^ permalink raw reply [flat|nested] 5+ messages in thread* Re: [RFC PATCH 0/7] cgroup: charge kernel work to the cgroup it is done for
2026-09-24 18:47 Shakeel Butt
@ 2026-09-24 20:28 ` Tejun Heo
2026-09-24 21:11 ` Shakeel Butt
0 siblings, 1 reply; 5+ messages in thread
From: Tejun Heo @ 2026-09-24 20:28 UTC (permalink / raw)
To: Shakeel Butt
Cc: Johannes Weiner, Peter Zijlstra, Michal Koutný,
Michal Hocko, Roman Gushchin, Muchun Song, Andrew Morton,
Ingo Molnar, Juri Lelli, Vincent Guittot, Dietmar Eggemann,
Steven Rostedt, Ben Segall, Mel Gorman, Valentin Schneider,
K Prateek Nayak, Suren Baghdasaryan, Kumar Kartikeya Dwivedi,
David Dai, JP Kobryn, Frederic Weisbecker, Aaron Lu,
Daniel Jordan, Hao Lee, kernel-team, cgroups, bpf, linux-mm,
linux-kselftest, linux-kernel
Hello, Shakeel.
On Thu, Sep 24, 2026 at 11:47:04AM -0700, Shakeel Butt wrote:
> This series lets a kernel thread say which cgroup it is working for.
> That cgroup then sees the CPU time in its cpu.stat and the stalls in
> its memory.pressure, and the CPU time comes out of its cpu.max quota.
> The first user is the memcg reclaim that runs from high_work.
This doesn't translate to net rx, which is another major source of
displaced CPU usage. Switching membership on each packet isn't going to
work there. Attribution can't happen that way. We'd much rather count
per-cgroup received packets and prorate the CPU consumption. If at all
possible, I think it'd be better to adopt an approach which can cover
both use cases.
> - The debt is capped at one period's quota, so one long piece of
> work cannot starve the cgroup for long. Time over the cap still
> shows up in cpu.stat. Writing cpu.max or cpu.max.burst clears the
> debt.
I don't like the debt capping. Having debt doesn't have to mean that
the cgroup doesn't get any bandwidth at all. The cgroup just needs to
be slowed down enough that the generation of new work is throttled and
the whole thing doesn't go out of control. IO control already does
this: when IO debt is accumulated, userspace is heavily throttled, but
not completely stalled, until the whole cgroup's consumption comes
under control. I don't see why the debts would need to be forgiven
unconditionally. The cgroup can keep paying them while running at a
minimal rate to avoid triggering stall failures, and if the situation
doesn't resolve quickly, that will most likely trigger pressure based
kills in any reasonable setup anyway.
Thanks.
--
tejun
^ permalink raw reply [flat|nested] 5+ messages in thread
* Re: [RFC PATCH 0/7] cgroup: charge kernel work to the cgroup it is done for
2026-09-24 20:28 ` Tejun Heo
@ 2026-09-24 21:11 ` Shakeel Butt
0 siblings, 0 replies; 5+ messages in thread
From: Shakeel Butt @ 2026-09-24 21:11 UTC (permalink / raw)
To: Tejun Heo
Cc: Johannes Weiner, Peter Zijlstra, Michal Koutný,
Michal Hocko, Roman Gushchin, Muchun Song, Andrew Morton,
Ingo Molnar, Juri Lelli, Vincent Guittot, Dietmar Eggemann,
Steven Rostedt, Ben Segall, Mel Gorman, Valentin Schneider,
K Prateek Nayak, Suren Baghdasaryan, Kumar Kartikeya Dwivedi,
David Dai, JP Kobryn, Frederic Weisbecker, Aaron Lu,
Daniel Jordan, Hao Lee, kernel-team, cgroups, bpf, linux-mm,
linux-kselftest, linux-kernel
On Thu, Sep 24, 2026 at 10:28:10AM -1000, Tejun Heo wrote:
> Hello, Shakeel.
>
> On Thu, Sep 24, 2026 at 11:47:04AM -0700, Shakeel Butt wrote:
> > This series lets a kernel thread say which cgroup it is working for.
> > That cgroup then sees the CPU time in its cpu.stat and the stalls in
> > its memory.pressure, and the CPU time comes out of its cpu.max quota.
> > The first user is the memcg reclaim that runs from high_work.
>
> This doesn't translate to net rx, which is another major source of
> displaced CPU usage. Switching membership on each packet isn't going to
> work there. Attribution can't happen that way. We'd much rather count
> per-cgroup received packets and prorate the CPU consumption. If at all
> possible, I think it'd be better to adopt an approach which can cover
> both use cases.
Very good point. I think we need to think for net rx for two scenarios. First,
the modern NICs with rx steering support and second, old NIC with shared queues.
On the modern NICs where the workloads get their own rx queues, I think the
proposed mechanism can help to do the accurate accounting (I have to extend this
to softirqs as it is limited to kthreads atm) very easily.
The challenge you mentioned is for the old NICs where we get to know the cgroup
assosiation of the rx packets very late (or deep) in the stack. Also CPU spent
on each packet is not necessarily uniform (due to out-of-order or drops or
checksum errors or window shrinking) but for simplicity we can assume uniform
CPU. Maybe the right place to charge for such scenario might be at application
receiving those packets into the memory. I feel like we might need a very
special way to account for this case (maybe through BPF or something). At
the moment to me it seems very hard to have a universal solution which helps
this case and the reclaim case I am targetted.
If you don't mind, I think having solution for modern NICs i.e. dedicated rx
queues, should suffice for now (unless you want the solution for old NICs as
well). Let me know what you think.
>
> > - The debt is capped at one period's quota, so one long piece of
> > work cannot starve the cgroup for long. Time over the cap still
> > shows up in cpu.stat. Writing cpu.max or cpu.max.burst clears the
> > debt.
>
> I don't like the debt capping. Having debt doesn't have to mean that
> the cgroup doesn't get any bandwidth at all. The cgroup just needs to
> be slowed down enough that the generation of new work is throttled and
> the whole thing doesn't go out of control. IO control already does
> this: when IO debt is accumulated, userspace is heavily throttled, but
> not completely stalled, until the whole cgroup's consumption comes
> under control. I don't see why the debts would need to be forgiven
> unconditionally. The cgroup can keep paying them while running at a
> minimal rate to avoid triggering stall failures, and if the situation
> doesn't resolve quickly, that will most likely trigger pressure based
> kills in any reasonable setup anyway.
Sounds good, I will remove this capping in the next version.
Thanks for taking a look.
^ permalink raw reply [flat|nested] 5+ messages in thread
end of thread, other threads:[~2026-09-24 21:11 UTC | newest]
Thread overview: 5+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2026-09-24 18:45 [RFC PATCH 0/7] cgroup: charge kernel work to the cgroup it is done for Shakeel Butt
2026-09-24 18:50 ` Shakeel Butt
2026-09-24 18:47 Shakeel Butt
2026-09-24 20:28 ` Tejun Heo
2026-09-24 21:11 ` Shakeel Butt
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
all inboxes | Powered by JetHome®