The cgroup v2 CPU Controller
The
cpucontroller is the cgroup v2 subsystem that decides how the kernel’s CPU-time scheduler distributes processor cycles among the tasks in a control-group hierarchy. It exposes a small set of files in each non-root cgroup directory:cpu.weight(and its nice-valued aliascpu.weight.nice) for proportional sharing under contention,cpu.maxfor an absolute bandwidth cap,cpu.statfor usage and throttling accounting,cpu.pressurefor Pressure Stall Information, and thecpu.uclamp.*files for frequency hints. Mechanically, the controller does not implement a scheduler of its own — it maps each cgroup to a task group (struct task_group) that the fair scheduler (EEVDF, formerly CFS) already knows how to schedule hierarchically, so a cgroup’scpu.weightbecomes a scheduler load weight andcpu.maxbecomes a CFS bandwidth quota/period pair (CFS = Completely Fair Scheduler; the current fair scheduler is EEVDF, Earliest Eligible Virtual Deadline First, which inherited this machinery) (cgroup-v2.rst, “CPU” section, v6.12). This note covers the weight/proportional side and the controller’s overall shape; the bandwidth/throttling side is detailed in CPU Bandwidth Control cpu.max and Throttling.
This note is pinned to Linux 6.12 LTS (released 2024-11-17, per kernel.org/releases); the same interface holds on 6.18 LTS (2025-11-30) save where noted. The broad cgroup hierarchy mechanics (the unified tree, cgroup.subtree_control, the no-internal-process rule) live in cgroups Integration and are not re-derived here — this note is the scheduler’s view of cgroups.
Mental Model
The right way to think about the cpu controller is as a thin translation layer sitting between a userspace-facing filesystem interface (the cpu.* files under /sys/fs/cgroup/...) and the scheduler’s pre-existing hierarchical machinery. Linux’s fair scheduler has, since the CFS era, been able to schedule groups of tasks as first-class entities: instead of a single flat run queue of tasks per CPU, each CPU has a tree of run queues, where a group is itself a schedulable entity (struct sched_entity) that competes for time against its siblings, and only once a group “wins” does the scheduler descend into that group’s own sub-run-queue to pick an actual task. That machinery is Hierarchical Group Scheduling and Task Groups. The cpu controller’s entire job is to keep a cgroup directory and a struct task_group in lock-step: create a cgroup, a task group is created; write cpu.weight, the group’s scheduler weight changes; write cpu.max, the group’s bandwidth limit changes.
flowchart TB subgraph FS["cgroupfs (userspace-visible)"] DIR["/sys/fs/cgroup/myslice/<br/>cpu.weight = 200<br/>cpu.max = '50000 100000'<br/>cpu.stat (read-only)"] end subgraph KERN["Kernel scheduler state"] TG["struct task_group<br/>(one per cgroup)"] SHARES["tg->shares<br/>(load weight, scaled)"] BW["tg->cfs_bandwidth<br/>(quota, period, runtime pool)"] SE["per-CPU sched_entity[]<br/>+ cfs_rq[] (group run queues)"] end DIR -- "cpu_weight_write_u64()" --> SHARES DIR -- "cpu_max_write()" --> BW SHARES --> TG BW --> TG TG --> SE SE -- "competes as a group<br/>in EEVDF/CFS tree" --> SCHED["fair_sched_class<br/>(EEVDF)"]
How a cpu cgroup maps to scheduler state. What it shows: each cgroup directory owns exactly one struct task_group; writing cpu.weight calls cpu_weight_write_u64() which sets the group’s load weight (tg->shares), and writing cpu.max calls cpu_max_write() which sets the group’s bandwidth pool. The task group materializes as a per-CPU array of group sched_entitys and child run queues that the fair class schedules recursively. The insight to take: the controller is plumbing, not policy — all the actual scheduling is done by EEVDF/CFS group scheduling; the cpu files are just the knobs that configure pre-existing task-group state.
Mechanical Walk-through
One cgroup, one task group
When the cpu controller is enabled on a subtree (by writing +cpu to the parent’s cgroup.subtree_control, the standard cgroup v2 enablement path described in cgroups Integration) and a child cgroup directory is created, the scheduler’s cgroup hooks allocate a struct task_group for it, parented to the enclosing group’s task group. The root cgroup corresponds to root_task_group, initialized at boot with the default weight (root_task_group.scx_weight = CGROUP_WEIGHT_DFL, core.c v6.12). A task group is not a single object but a per-CPU one: it holds an array tg->se[cpu] of group scheduling entities and tg->cfs_rq[cpu] of group run queues, one pair per online CPU, because the fair scheduler is fundamentally per-CPU (see Per-CPU Run Queues and struct rq). The mapping from a cgroup CSS (the cgroup_subsys_state) to its task group is the helper css_tg(), used by every cpu.* file handler.
cpu.weight — proportional sharing
cpu.weight is the proportional-sharing knob. It is a read-write single value on every non-root cgroup, default 100, with the documented range [1, 10000] (cgroup-v2.rst, v6.12). The semantics are relative among siblings: a cgroup with weight 200 gets twice the CPU time of a sibling with weight 100 when both are contending for the same CPU. Weights are work-conserving — if a sibling is idle, the busy cgroup is free to use the slack; the weight only bites under contention. The 100-as-default, [1, 10000] range is a deliberate cgroup-v2 design choice so that the same weight semantics apply uniformly across the weight-model controllers cpu and io (the io.weight knob; the memory controller uses protections/limits, not weights) — the “Weights” resource-distribution model, cgroup-v2.rst: “All weights are in the range [1, 10000] with the default at 100. This allows symmetric multiplicative biases in both directions at fine enough granularity while staying in the intuitive range.”
The write handler is small and worth reading exactly:
static int cpu_weight_write_u64(struct cgroup_subsys_state *css,
struct cftype *cft, u64 cgrp_weight)
{
unsigned long weight;
int ret;
if (cgrp_weight < CGROUP_WEIGHT_MIN || cgrp_weight > CGROUP_WEIGHT_MAX)
return -ERANGE; /* reject < 1 or > 10000 */
weight = sched_weight_from_cgroup(cgrp_weight); /* cgroup units -> scheduler load weight */
ret = sched_group_set_shares(css_tg(css), scale_load(weight));
if (!ret)
scx_group_set_weight(css_tg(css), cgrp_weight); /* mirror into sched_ext, 6.12+ */
return ret;
}(core.c, v6.12). Line by line: the input is range-checked against CGROUP_WEIGHT_MIN/CGROUP_WEIGHT_MAX (1 and 10000, defined in include/linux/cgroup.h); sched_weight_from_cgroup() converts the cgroup-scale weight into the scheduler’s internal load weight; sched_group_set_shares() pushes that into tg->shares (the value the fair class actually uses); and finally scx_group_set_weight() mirrors the cgroup-scale value into the sched_ext view, so a BPF scheduler sees the same weight — this mirroring line is new in the 6.12 sched_ext era.
The conversion is the crux of the v2 weight model:
#define CGROUP_WEIGHT_MIN 1
#define CGROUP_WEIGHT_DFL 100
#define CGROUP_WEIGHT_MAX 10000
static inline unsigned long sched_weight_from_cgroup(unsigned long cgrp_weight)
{
return DIV_ROUND_CLOSEST_ULL(cgrp_weight * 1024, CGROUP_WEIGHT_DFL);
}(include/linux/cgroup.h and sched.h, v6.12). Walking the arithmetic: the scheduler’s internal “nice 0” load weight is 1024 (this is NICE_0_LOAD, the reference weight a default-priority task carries). The formula maps a cgroup weight w to round(w * 1024 / 100). So the default cpu.weight = 100 maps to 100 * 1024 / 100 = 1024 — exactly a single nice-0 task’s worth of weight. cpu.weight = 200 maps to 2048, twice the load, hence twice the CPU under contention. cpu.weight = 10000 maps to 102400, and cpu.weight = 1 maps to round(1024/100) = 10. The kernel comment is explicit that this round-trips: “it maps pretty well onto the shares value used by scheduler and the round-trip conversions preserve the original value over the entire range” (sched.h).
cpu.weight.nice — the same knob in nice units
cpu.weight.nice is an alternative interface to the same underlying weight, expressed in nice(2) units. It is read-write, default 0, range [-20, 19] (cgroup-v2.rst, v6.12). Writing it converts a nice value into a load weight via the kernel’s standard sched_prio_to_weight[] table — the same table that gives an individual task its weight from its nice value (see Nice Values Weights and Priority Scaling):
static int cpu_weight_nice_write_s64(struct cgroup_subsys_state *css,
struct cftype *cft, s64 nice)
{
unsigned long weight;
int idx, ret;
if (nice < MIN_NICE || nice > MAX_NICE) /* [-20, 19] */
return -ERANGE;
idx = NICE_TO_PRIO(nice) - MAX_RT_PRIO; /* nice -> 0..39 index */
idx = array_index_nospec(idx, 40);
weight = sched_prio_to_weight[idx]; /* table lookup */
ret = sched_group_set_shares(css_tg(css), scale_load(weight));
...
}(core.c, v6.12). Because the nice scale has only 40 discrete steps while cpu.weight has 10000, the two interfaces are not bijective: reading cpu.weight.nice after setting cpu.weight returns “the closest approximation of the current weight” (cgroup-v2.rst). The read handler literally scans sched_prio_to_weight[] for the entry nearest the current tg_weight() and returns its nice value. The key fact to internalize: cpu.weight.nice and cpu.weight are two faces of one number — they set the same tg->shares. Use whichever scale fits your mental model; the [-20, 19] nice scale spans cpu.weight values from ~8700 (nice -20: sched_prio_to_weight[0] = 88761, so 88761*100/1024 ≈ 8668) down to ~1 (nice 19: sched_prio_to_weight[39] = 15, so 15*100/1024 ≈ 1, also the minimum clamp), following the classic ~1.25-per-step geometric ratio of the nice table (the table is in core.c, v6.12).
cpu.idle and SCHED_IDLE groups
A related knob, cpu.idle, marks a whole cgroup as SCHED_IDLE (the lowest-priority fair policy): tasks in such a group only run when nothing else wants the CPU. When cpu.idle = 1, reading cpu.weight shows 0 (a sentinel, since an idle group has no meaningful proportional weight), per the interface docs (cgroup-v2.rst, v6.12). This is the cgroup-scoped analogue of running a task under SCHED_IDLE.
cpu.stat — always-on accounting
cpu.stat is a read-only flat-keyed file that exists whether or not the controller is enabled. It always reports three cumulative usage counters — usage_usec (total CPU time), user_usec, and system_usec — and, only when the controller is enabled, five throttling/burst counters: nr_periods, nr_throttled, throttled_usec, nr_bursts, burst_usec (cgroup-v2.rst, v6.12). The first three let you do basic CPU accounting on any cgroup; the latter five are how you measure bandwidth throttling and are the primary diagnostic for the throttling problems discussed in CPU Bandwidth Control cpu.max and Throttling. Note the unit difference from cgroup v1: v2 reports throttled_usec (microseconds), whereas the v1 cpu.stat field was throttled_time in nanoseconds (sched-bwc.rst, v6.12).
cpu.pressure — PSI for CPU
cpu.pressure is a read-write nested-keyed file exposing per-cgroup Pressure Stall Information for CPU — the share of wall-clock time tasks in this cgroup were runnable but waiting on a CPU (cgroup-v2.rst). The mechanism, the some/full distinction, and how to set pressure triggers are owned by Pressure Stall Information; here it suffices to know the cpu controller surfaces a PSI file per cgroup so you can attribute CPU starvation to a specific slice of the hierarchy.
cpu.uclamp.min / cpu.uclamp.max — frequency hints, not time shares
The two cpu.uclamp.* files are a different axis entirely: they are utilization clamping hints to the schedutil cpufreq governor, expressed as a percentage (e.g. 12.34 for 12.34%), telling the governor the minimum frequency this cgroup’s tasks should be guaranteed (cpu.uclamp.min, default 0) and the maximum they should provoke (cpu.uclamp.max, default max) (cgroup-v2.rst, v6.12). They affect clock frequency, not time allocation — orthogonal to cpu.weight/cpu.max. The controller docs are explicit that cycle distribution “is defined only on a temporal base and it does not account for the frequency at which tasks are executed”; uclamp is the optional bridge to frequency.
The v1 → v2 change: cpu.shares → cpu.weight
In cgroup v1, proportional sharing was configured through cpu.shares, an unbounded-ish value whose conventional default was 1024 and which mapped directly onto the scheduler’s internal load weight (1024 = one nice-0 task). cgroup v2 renamed and rescaled this to cpu.weight, default 100, range [1, 10000], to harmonize with the other controllers’ weight model. The kernel still implements both: the v1 handler cpu_shares_write_u64() calls sched_group_set_shares() with scale_load(shareval) directly, while the v2 handler runs the input through sched_weight_from_cgroup() first (core.c, v6.12). Concretely, the equivalences are:
- v1
cpu.shares = 1024≡ v2cpu.weight = 100(both → scheduler weight 1024) - v1
cpu.shares = 2048≡ v2cpu.weight = 200 - v1
cpu.shares = 512≡ v2cpu.weight = 50
The conversion is cpu.weight = cpu.shares / 1024 * 100, i.e. cpu.weight ≈ shares * 100 / 1024. The bandwidth knobs were similarly consolidated: v1’s two files cpu.cfs_quota_us + cpu.cfs_period_us became v2’s single cpu.max = "$quota $period" (sched-bwc.rst notes the v1 files are v1-only, pointing v2 readers to cgroup-v2.rst). This is part of cgroup v2’s general philosophy of fewer, more orthogonal, cross-controller-consistent files.
Uncertain
Verify: the precise
cpu.shares ↔ cpu.weightnumeric mapping as a supported user-facing conversion (vs. an implementation detail ofsched_weight_from_cgroup). Reason: the v6.12 source confirmscpu.weight=100 → weight 1024andcpu.sharesdefaults to 1024 mapping to the same internal weight, soweight = round(shares*100/1024)follows arithmetically; but I did not find an official doc table stating the v1↔v2 equivalence explicitly — it is derived from the two code paths. To resolve: checksystemd’sCPUWeight=/CPUShares=compatibility documentation or a kernel changelog that states the intended mapping. uncertain
Configuration / Code — worked examples
Assume a unified hierarchy mounted at /sys/fs/cgroup (the standard cgroup v2 layout). First enable the controller on the parent and create two competing children:
# Enable the cpu controller on the root's children
echo "+cpu" > /sys/fs/cgroup/cgroup.subtree_control
mkdir /sys/fs/cgroup/batch # background work
mkdir /sys/fs/cgroup/latency # interactive work
# Give the latency group 4x the CPU share of batch under contention
echo 100 > /sys/fs/cgroup/batch/cpu.weight # default
echo 400 > /sys/fs/cgroup/latency/cpu.weight # -> internal weight 4096
# Move shells/processes in by writing their PIDs
echo $PID_BATCH > /sys/fs/cgroup/batch/cgroup.procs
echo $PID_LATENCY > /sys/fs/cgroup/latency/cgroup.procsWith both groups CPU-bound on a single contended CPU, latency receives 400/(400+100) = 80% and batch 20%. If batch goes idle, latency gets 100% — weights are work-conserving. To express the same intent in nice units:
echo -8 > /sys/fs/cgroup/latency/cpu.weight.nice # boost (lower nice = more weight)
cat /sys/fs/cgroup/latency/cpu.weight # reads back ~ the closest weight, e.g. 590-ishReading the always-on accounting and (once cpu.max is set) the throttling counters:
cat /sys/fs/cgroup/latency/cpu.stat
# usage_usec 1234567 <- total CPU time used by this group (always present)
# user_usec 900000
# system_usec 334567
# nr_periods 0 <- the next five appear only when the controller is enabled
# nr_throttled 0
# throttled_usec 0
# nr_bursts 0
# burst_usec 0To combine proportional sharing with a hard cap — give latency a high weight and a ceiling so it can never use more than half a CPU even when alone (the bandwidth side, covered in the sibling note):
echo 400 > /sys/fs/cgroup/latency/cpu.weight
echo "50000 100000" > /sys/fs/cgroup/latency/cpu.max # 50ms quota per 100ms period = 0.5 CPUFailure Modes and Common Misunderstandings
“cpu.weight is a percentage / a guaranteed share.” No. It is a relative weight among active siblings under contention. A weight of 1000 guarantees nothing on its own; it only means “ten times a default sibling, if that sibling is also runnable.” If you want an absolute guarantee or cap, you need cpu.max (a ceiling) — there is no cpu.min floor in the CPU controller (unlike memory.min). The proportional model deliberately cannot reserve idle CPU.
Confusing weight with bandwidth. cpu.weight is work-conserving (slack is shared); cpu.max is not (unused quota is forfeited at the period boundary and the group is throttled when exhausted). They are independent and frequently combined. A common production error is reaching for cpu.max (a hard cap that causes throttling and its latency tax) when cpu.weight (soft prioritization with no idle waste) would have met the actual goal.
Forgetting to enable the controller. Until +cpu is written into the parent’s cgroup.subtree_control, the cpu.weight/cpu.max files do not even appear in child cgroups (only the always-on cpu.stat shows up). The cgroup v2 “no internal processes” rule and subtree-control delegation interact here — see cgroups Integration.
RT tasks and the controller. With CONFIG_RT_GROUP_SCHED enabled, the cpu controller can only be enabled when all real-time (SCHED_FIFO/SCHED_RR) tasks are in the root cgroup; system management software may have already placed RT tasks in non-root cgroups during boot, blocking enablement (cgroup-v2.rst warning, v6.12). cgroup v2 does not yet support bandwidth control of RT processes through cpu.max; RT bandwidth is a separate, system-wide mechanism (Real-Time Throttling and the RT Bandwidth Limit).
Alternatives and When to Choose Them
The cpu controller is the grouped, hierarchical way to apportion CPU. The alternatives operate at different granularities:
- Per-task
nice/sched_setattr(Nice Values Weights and Priority Scaling) — adjusts a single task’s weight, not a group’s. Use when you control individual processes and don’t need a hierarchy. cpusetcontroller (Cpusets and CPU Partitioning) — confines tasks to a set of CPUs rather than apportioning time on shared CPUs. Orthogonal tocpu: you can pin a cgroup to CPUs 0–3 withcpuset.cpusand weight it withcpu.weight.SCHED_DEADLINE(SCHED_DEADLINE and Earliest Deadline First) — for tasks needing EDF guarantees, not proportional fairness. Bypasses the fair class entirely.sched_extBPF schedulers (sched_ext and BPF-Defined Schedulers) — when you want custom group-scheduling policy; the controller mirrorscpu.weightinto the BPF view (thescx_group_set_weight()call above) so a BPF scheduler can honor cgroup weights.
Choose the cpu controller when you need to divide CPU among hierarchies of processes (containers, service slices) under a single host — which is exactly what container runtimes and orchestrators do.
Production Notes
In practice the cpu controller is driven almost entirely by higher layers, not by humans writing to cgroupfs. systemd maps unit properties CPUWeight= → cpu.weight and CPUQuota= → cpu.max, organizing the host into a slice/scope/service tree. Container runtimes (runc and friends) translate OCI resources.cpu.shares/cpuShares and cpu.quota/cpu.period into the controller files. Kubernetes translates pod CPU requests into cpu.weight (proportional, soft) and CPU limits into cpu.max (hard cap) — the request/limit-to-cgroup translation and QoS classes live in Kubernetes MOC and cgroups Integration, not here; this note owns only the kernel mechanism those layers configure.
The single most consequential production lesson is the request-vs-limit / weight-vs-max distinction: setting a CPU limit (cpu.max) on a latency-sensitive containerized service invites throttling-induced tail latency even when the host has spare CPU, whereas a CPU request (cpu.weight) gives prioritization without the throttling tax. This is the subject of the sibling note CPU Bandwidth Control cpu.max and Throttling and a recurring source of production incidents.
See Also
- CPU Bandwidth Control cpu.max and Throttling — the
cpu.maxquota/period side and the throttling mechanism - Hierarchical Group Scheduling and Task Groups — how task groups are scheduled recursively (the machinery this controller configures)
- The EEVDF Scheduler / The Completely Fair Scheduler and Its History — the fair scheduler that actually does the work
- Nice Values Weights and Priority Scaling —
sched_prio_to_weight[], the nice→weight tablecpu.weight.nicereuses - Pressure Stall Information — the
cpu.pressurePSI file - Cpusets and CPU Partitioning — the
cpusetcontroller (where, not how much) - cgroups Integration — the broad cgroup v2 hierarchy and enablement machinery (don’t duplicate; cross-link)
- sched_ext and BPF-Defined Schedulers — the BPF scheduler view the controller now mirrors weights into
- Linux Process Scheduling MOC — parent map