The schedutil Governor

schedutil (internally sugov) is the modern Linux cpufreq governor that lives inside the scheduler rather than beside it. Where the old load-sampling governors woke on a timer, computed a CPU’s busy fraction, and inferred a frequency, schedutil instead consumes the scheduler’s own first-class per-runqueue utilization signal — the same Per-Entity Load Tracking (PELT) util_avg aggregate the scheduler already maintains for load balancing — and recomputes the target frequency synchronously, on the very code path that updates that signal. Its source is the file kernel/sched/cpufreq_schedutil.c (note: it lives under kernel/sched/, not drivers/cpufreq/, precisely because it is part of the scheduler) (kernel/sched/cpufreq_schedutil.c v6.12). The frequency it picks is, to first order, simply proportional to utilization with a margin: next_freq = 1.25 × max_freq × util / max. Because it reuses the scheduler’s signal, it reacts immediately to scheduling events instead of lagging a sample period behind, and it shares that signal with energy-aware scheduling — which is why schedutil is the in-kernel default on ARM/ARM64 and on x86 systems using intel_pstate/amd-pstate with SMP (drivers/cpufreq/Kconfig v6.12, lines 39–42).

Mental Model

The old governors were pull systems: a periodic sampler pulled load statistics out of the CPU and guessed. schedutil is a push system: every time the scheduler updates a CPU’s utilization — a task enqueues, dequeues, ticks, or migrates — it pushes a notification, and schedutil recomputes a frequency right then. The connection point is one function pointer, the cpufreq_update_util hook: the scheduler calls it on the local runqueue, and if a governor has registered an update_util_data callback for that CPU, the callback runs (kernel/sched/sched.h v6.12, cpufreq_update_util(), lines 3261–3269).

flowchart TB
  TASK["task enqueues / dequeues /<br/>tick / migrates"]
  PELT["scheduler updates PELT util_avg<br/>(update_load_avg in fair.c)"]
  HOOK["cfs_rq_util_change()<br/>-> cpufreq_update_util(rq, flags)"]
  CB["per-CPU update_util_data->func<br/>= sugov_update_single/shared"]
  GET["sugov_get_util():<br/>effective_cpu_util() -> util, min, max"]
  FREQ["get_next_freq():<br/>1.25 * ref_freq * util / cap"]
  FAST{"fast_switch_enabled?"}
  DIRECT["cpufreq_driver_fast_switch()<br/>(set freq right here)"]
  KTH["sugov kthread (SCHED_DEADLINE)<br/>__cpufreq_driver_target()"]
  TASK --> PELT --> HOOK --> CB --> GET --> FREQ --> FAST
  FAST -->|"yes (e.g. intel/amd pstate)"| DIRECT
  FAST -->|"no (slow, sleeping driver)"| KTH

The schedutil push path. What it shows: a scheduling event updates PELT utilization, which fires the cpufreq_update_util hook, which calls sugov’s callback; sugov derives an effective utilization, maps it to a frequency, and either programs the hardware inline (fast-switch drivers) or defers to a dedicated kthread (drivers whose set-frequency operation may sleep). The insight to take: there is no timer in the hot path — frequency selection is a side effect of the scheduler already doing its accounting, which is what makes schedutil both fast and “free.”

Why “Frequency-Invariant” Utilization Is Essential

PELT tracks how much of the available CPU time an entity actually used, decayed over a geometric window. But “available CPU time” depends on how fast the CPU was running. If a task is busy 50% of the time at 1 GHz, and the CPU then jumps to 2 GHz, the same work now occupies only ~25% of wall-clock time — the raw utilization drops purely because the clock went up. A naive proportional governor would then lower the frequency, which raises utilization again: an oscillation.

The fix is frequency-invariant utilization: the scheduler scales each PELT window by the ratio of the running frequency to the maximum frequency, so the recorded util_avg represents “fraction of maximum-frequency capacity used” and stays stable across DVFS changes. This scaling is arch_scale_freq_capacity(), an architecture hook; the comment in the scheduler is explicit that without it the util number is not frequency-invariant (kernel/sched/fair.c v6.12, cfs_rq_util_change(), lines 4080–4101). schedutil’s frequency formula assumes this invariance — see the derivation in the source comment (cpufreq_schedutil.c v6.12, get_next_freq(), lines 143–179):

If the utilization is frequency-invariant, choose the new frequency to be proportional to it: next_freq = C * max_freq * util / max. Otherwise approximate the would-be frequency-invariant utilization by util_raw * (curr_freq / max_freq), leading to next_freq = C * curr_freq * util_raw / max.

So schedutil has a fallback for platforms lacking the invariance hook: it substitutes the current frequency as the reference and applies a 25% margin to anticipate the climb before the CPU is fully busy (get_capacity_ref_freq(), lines 117–141). The constant C = 1.25 places the tipping point — where the governor requests maximum frequency — at util/max = 0.8, i.e. it ramps to full speed once a CPU is 80% utilized, leaving 25% headroom so frequency rises before the CPU saturates.

The cpufreq_update_util Hook

The hook is the contract between the scheduler and any “scheduler-aware” governor. Its definition is a small inline (sched.h v6.12, lines 3261–3269):

static inline void cpufreq_update_util(struct rq *rq, unsigned int flags)
{
	struct update_util_data *data;
	data = rcu_dereference_sched(*per_cpu_ptr(&cpufreq_update_util_data, cpu_of(rq)));
	if (data)
		data->func(data, rq_clock(rq), flags);
}

Line by line: it reads the per-CPU cpufreq_update_util_data pointer under RCU-sched (so registration/teardown is lockless on the hot path); if a governor registered a callback, it invokes data->func(data, rq_clock(rq), flags) — passing the current runqueue clock as the timestamp and a flags word. The single defined flag is SCHED_CPUFREQ_IOWAIT, set when a task wakes from I/O wait (include/linux/sched/cpufreq.h v6.12, line 11). The governor registers and unregisters via cpufreq_add_update_util_hook()/cpufreq_remove_update_util_hook() in kernel/sched/cpufreq.c, which simply rcu_assign_pointer() the per-CPU slot (kernel/sched/cpufreq.c v6.12, lines 29–58).

Where does the scheduler call it? Mainly from the fair (CFS) class. cfs_rq_util_change() — invoked whenever update_load_avg() recomputes a runqueue’s PELT averages — calls cpufreq_update_util(rq, flags) for the top-level runqueue (fair.c v6.12, lines 4080–4101). It is also called with SCHED_CPUFREQ_IOWAIT on I/O-wait wakeups (fair.c v6.12, line 6984), and the RT and deadline classes call it too — RT does so periodically as a band-aid so an all-RT CPU does not get stuck at a stale low frequency, as the kernel comment candidly admits (sched.h v6.12, lines 3239–3260). Because the callback runs holding rq->lock for the target CPU, schedutil’s per-CPU data is fully serialized without extra locking on the fast path (cpufreq_schedutil.c v6.12, lines 66–82, 418–429).

The sugov Update Path

schedutil registers one of three callbacks per CPU at sugov_start(), chosen by policy shape (cpufreq_schedutil.c v6.12, sugov_start(), lines 833–864):

if (policy_is_shared(policy))
	uu = sugov_update_shared;            // several CPUs share one clock
else if (policy->fast_switch_enabled && cpufreq_driver_has_adjust_perf())
	uu = sugov_update_single_perf;       // single CPU, performance-level API
else
	uu = sugov_update_single_freq;       // single CPU, frequency API

Single-CPU path

sugov_update_single_freq() is the canonical path (lines 391–430). It first runs sugov_update_single_common(), which (a) updates I/O-wait boost state, (b) records the update time, and (c) checks the rate limit via sugov_should_update_freq() — bailing out if less than freq_update_delay_ns has elapsed since the last change, unless limits changed (lines 62–93, 371–389). If the rate limit allows, it derives the effective utilization with sugov_get_util(), maps it to a frequency with get_next_freq(), applies a “don’t drop too eagerly” hold heuristic (sugov_hold_freq(), which keeps the frequency up if the CPU has not actually gone idle recently), and finally commits. Committal forks on the driver:

if (sg_policy->policy->fast_switch_enabled)
	cpufreq_driver_fast_switch(sg_policy->policy, next_f);   // inline, atomic
else {
	raw_spin_lock(&sg_policy->update_lock);
	sugov_deferred_update(sg_policy);                        // hand to kthread
	raw_spin_unlock(&sg_policy->update_lock);
}

Shared path

sugov_update_shared() handles the common case where a cluster of CPUs shares one clock domain. It takes the policy’s update_lock, then in sugov_next_freq_shared() iterates every CPU in policy->cpus, recomputes each one’s utilization, and takes the maximum — the whole domain must run fast enough for its busiest member (lines 463–512). The committal logic mirrors the single path.

The performance-level path

The third callback, sugov_update_single_perf(), is used when the driver both supports fast switching and exposes the newer “adjust performance” API (cpufreq_driver_has_adjust_perf()) — chiefly amd-pstate and intel_pstate in passive mode with the performance interface (cpufreq_schedutil.c v6.12, lines 432–461). Instead of resolving utilization to a discrete frequency and calling cpufreq_driver_fast_switch(), it passes the performance level directly to cpufreq_driver_adjust_perf(cpu, bw_min, util, max_cap), letting the driver/hardware map abstract “performance” onto its own P-state granularity. Crucially this path requires frequency invariance — if arch_scale_freq_invariant() is false it falls back to sugov_update_single_freq(), because the direct util-to-performance mapping only holds when the util number is frequency-invariant (lines 438–447). This is the mechanism behind the “passive” amd-pstate/intel_pstate mode where schedutil still chooses, but the hardware does the fine-grained mapping — bridging the gap between pure governor-driven and pure hardware-managed P-state selection.

The commit gate: need_freq_update and limits_changed

Two flags guard the commit. sugov_update_next_freq() short-circuits a no-op: if the newly computed next_freq equals the last one, it returns false and nothing is written — except when need_freq_update is set, which forces a write even to the same frequency for drivers that set CPUFREQ_NEED_UPDATE_LIMITS (they must be re-poked when limits move even if the numeric frequency is unchanged) (lines 95–107). The companion limits_changed flag is set by sugov_limits() whenever the core fires the limits callback (a min/max change) and by ignore_dl_rate_limit(); sugov_should_update_freq() treats a set limits_changed as license to ignore the rate limit and update immediately (lines 84–93, 882–893). On slow-switch drivers sugov_limits() also synchronously re-applies the clamped limits under work_lock before setting the flag, so a tightened scaling_max_freq takes effect at once rather than waiting for the next scheduling event.

Deriving the utilization

sugov_get_util() is where the scheduler signal becomes a number schedutil can use (lines 198–208). It calls effective_cpu_util() (in fair.c), which combines the CFS utilization with RT and deadline (DL) utilization, factors in IRQ pressure, and returns the busy estimate plus the min/max performance bounds (the latter shaped by uclamp) (fair.c v6.12, effective_cpu_util(), lines 8128–8200). It then folds in any I/O-wait boost and a DVFS headroom margin (sugov_effective_cpu_perf() / map_util_perf(), which adds 25%) so a CPU that is fully busy at the current point still has room to climb (lines 181–207).

Uncertain

Verify: that this note’s account of the origin of util_avg/PELT (the geometric-decay windows, how util_avg is computed and aggregated per-runqueue) matches the scheduler implementation exactly. Reason: this note deliberately treats PELT as an input it consumes and does not derive it — there is no standalone PELT leaf in this vault yet, and effective_cpu_util() blends CFS/RT/DL/IRQ contributions in ways whose details belong to the scheduler subsystem, not here. To resolve: read kernel/sched/pelt.c and kernel/sched/fair.c’s update_load_avg() at the v6.12 tag, or see Linux Process Scheduling MOC when a PELT leaf is written. uncertain

The sugov Kthread for Slow-Switching Drivers

Some scaling drivers can change frequency from any context, atomically and without sleeping — these set fast_switch_enabled, and schedutil programs them inline from the scheduler callback. But many drivers (notably I²C/SPI-attached PMIC regulators, or anything that must wait on a clock to stabilize) have a ->target operation that may sleep, which is forbidden under rq->lock in interrupt-disabled context. For those, schedutil defers the actual frequency change to a dedicated per-policy kernel thread.

The mechanism is a two-stage handoff (cpufreq_schedutil.c v6.12, lines 109–115, 514–547). sugov_deferred_update() queues an irq_work (if one is not already in flight). The IRQ work handler sugov_irq_work() queues a kthread_work onto the policy’s worker, and sugov_work() finally calls __cpufreq_driver_target(policy, freq, CPUFREQ_RELATION_L) in normal, sleepable thread context. The irq_work indirection exists because the callback runs in scheduler context where you cannot directly wake a thread safely; bouncing through irq_work reaches a context where kthread_queue_work() is allowed.

The kthread itself is created in sugov_kthread_create() and is striking in one respect: it runs as a SCHED_DEADLINE task (lines 652–701):

struct sched_attr attr = {
	.sched_policy	= SCHED_DEADLINE,
	.sched_flags	= SCHED_FLAG_SUGOV,   // special "fake bandwidth" sugov flag
	.sched_runtime	= NSEC_PER_MSEC,
	.sched_deadline = 10 * NSEC_PER_MSEC,
	.sched_period	= 10 * NSEC_PER_MSEC,
};

It is named sugov:%d (the first CPU of the domain) and bound to the policy’s related_cpus. The reason it is a deadline task rather than a normal or RT thread is that the frequency-raising work must itself run promptly even when the system is busy — if the thread that raises the clock were starved, the CPU would stay slow exactly when it most needs to speed up, a deadlock-of-intent. SCHED_DEADLINE with the special SCHED_FLAG_SUGOV gives it guaranteed, prioritized service without participating in normal bandwidth accounting. On fast_switch_enabled policies no kthread is created at all (lines 672–674).

Rate Limiting and the One Tunable

schedutil exposes exactly one sysfs tunable: rate_limit_us, the minimum interval between frequency changes (lines 559–584). Its default is the policy’s transition delay — cpufreq_policy_transition_delay_us(), which is 1.5× the driver’s reported transition_latency, or 1000 µs (1 ms) if the driver reports no latency (drivers/cpufreq/cpufreq.c v6.12, lines 570–583; set into tunables->rate_limit_us at cpufreq_schedutil.c line 775). Raising it makes schedutil change frequency less often (lower overhead, slower reaction); lowering it makes it twitchier. Whether the tunable is per-policy or global depends on have_governor_per_policy() (lines 714–731).

One subtlety: the rate limit is ignored when deadline-class bandwidth demand rises. ignore_dl_rate_limit() forces an immediate update if the runqueue’s DL bandwidth exceeds the cached minimum, so a freshly-admitted real-time deadline task gets its frequency bump without waiting out the rate-limit window (lines 361–369, 380, 497).

I/O-Wait Boosting

A task blocked on disk or network I/O shows low CPU utilization, so a naive governor would drop the frequency — penalizing throughput-bound I/O workloads that alternate short CPU bursts with waits. schedutil counters this with I/O-wait boosting: when a task wakes with SCHED_CPUFREQ_IOWAIT, the boost starts at IOWAIT_BOOST_MIN (one-eighth of full capacity) and doubles on each successive I/O wakeup up to full capacity, decaying by half when no boost is requested (lines 9, 210–327). The doubling-per-wakeup is deliberate: a workload doing frequent, successive I/O gets ramped up quickly, while sporadic one-off I/O is treated conservatively. The boost resets entirely if the CPU has been idle for at least a tick (sugov_iowait_reset(), lines 210–234).

Relationship to Energy-Aware Scheduling

schedutil and energy-aware scheduling (EAS) are two consumers of the same utilization signal, and EAS depends on schedutil being active. When schedutil is initialized or torn down on a policy, it triggers a rebuild of the scheduler domains so EAS knows whether it can run — sugov_eas_rebuild_sd() schedules rebuild_sched_domains_energy() (cpufreq_schedutil.c v6.12, lines 607–630, 786, 830). The kernel scheduler documentation states the coupling plainly: EAS uses the same signal and assumes a governor that follows utilization (sched-energy docs). The division of labor: EAS decides which CPU a task should wake on for least energy; schedutil decides how fast the chosen CPU then runs. See Energy-Aware Scheduling EAS and Capacity-Aware and Energy-Aware Scheduling.

Failure Modes and Common Misunderstandings

  • schedutil is not in scaling_available_governors on my Intel laptop.” You are on intel_pstate active mode, a ->setpolicy driver that bypasses the governor layer entirely (see cpufreq Governors). Switch to intel_pstate=passive to expose schedutil. schedutil itself is unrelated to the absence — the driver shape is.
  • “Frequencies oscillate / never settle.” Almost always a missing or broken arch_scale_freq_capacity() — without frequency invariance the proportional formula chases its own tail (see the invariance section). Check whether the arch provides the hook; on x86 it depends on APERF/MPERF feedback, on ARM on the cpufreq driver supplying it.
  • “Real-time audio glitches under schedutil.” RT tasks only trigger frequency updates via the periodic band-aid path, and the rate limit may delay a needed bump. uclamp (util_clamp_min) on the latency-sensitive task forces a performance floor; this is the recommended fix rather than abandoning schedutil.
  • “The sugov:N kthread shows up as SCHED_DEADLINE and worries my RT auditing.” That is by design — it carries SCHED_FLAG_SUGOV with fake bandwidth and does not consume real deadline budget (lines 655–668). It exists only on slow-switch drivers; fast-switch systems have no such thread.
  • “Frequency is slow to drop after a burst ends.” The sugov_hold_freq() heuristic intentionally resists premature downward changes when the CPU has not actually gone idle recently — it trades a little extra energy for fewer ramp-down/ramp-up cycles (lines 329–359, 407–413).

Production Notes

schedutil is the default on Android (ARM big.LITTLE/DynamIQ), on most ARM64 servers, and is increasingly used on x86 via intel_pstate=passive/amd-pstate passive where teams want the scheduler and frequency selection tightly coupled. The historical motivation, articulated by author Rafael Wysocki (Intel) when the governor merged in v4.7 (2016), was exactly to eliminate the redundant, lagging load estimation of ondemand/conservative by reading the signal the scheduler already computes. The v6.18 source is structurally identical to v6.12 — same three update callbacks, same SCHED_DEADLINE kthread, same rate_limit_us tunable, same CPUFREQ_GOV_DYNAMIC_SWITCHING flag — with one cosmetic change: schedutil_gov is declared static at v6.18 versus externally visible at v6.12 (cpufreq_schedutil.c v6.18, lines 914–925). For any default or version claim pin to the Kconfig and source at the LTS tag, as done here; the rendered kernel docs track mainline/7.x, not the 6.12/6.18 lines.

See Also