From 20d757d2b41c0932ef91c41afd97e3ddfdbbf64b Mon Sep 17 00:00:00 2001 From: Erik Bourget Date: Sun, 25 Oct 2026 17:50:29 -0400 Subject: [PATCH 1/2] thermal: apple: Accept idle rails under 0.1 W The power work treats a reading of less than 0.1 W across the P-CPU, E-CPU and GPU rails as a firmware reset image: it reports -ENODATA, retains the minimum CPU and GPU ceilings and fails the hwmon rails with -EAGAIN. An idle J713 reads below that once its GPU idles at a low performance state (P-CPU 40-55 mW, E-CPU 9 mW, GPU 27 mW): the ceilings then sat at the minimum while idle, the CPUs ran at 900 MHz, and the GPU budget climbed back one state at a time once work arrived. A reset image reads zero on both CPU rails, which the running reader cannot. Reject only that. Signed-off-by: Erik Bourget --- drivers/thermal/apple-pmp-thermal.c | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/drivers/thermal/apple-pmp-thermal.c b/drivers/thermal/apple-pmp-thermal.c index 81b7bb6093ade..203ea9f5608bd 100644 --- a/drivers/thermal/apple-pmp-thermal.c +++ b/drivers/thermal/apple-pmp-thermal.c @@ -174,10 +174,11 @@ static void apple_pmp_power_work(struct work_struct *work) u64 epoch = apple_pmp_gpu_epoch(pmp_thermal.sensors); u64 now; int ret = apple_pmp_power_read_rails(mw); - /* The running CPUs/reader cannot plausibly consume <0.1 W in total. - * A firmware reset/all-zero image must not refill the energy bucket. + /* The running reader cannot read zero on both CPU rails. A firmware + * reset/all-zero image must not refill the energy bucket; an idle + * system may still draw well under 0.1 W on all three rails. */ - if (!ret && mw[0] + mw[1] + mw[2] < 100) + if (!ret && !mw[0] && !mw[1]) ret = -ENODATA; /* Clock under the lock: a raw sample published while the SMC reads ran * must not make the unsigned age below wrap into a false -EAGAIN. From 26fd98392fc3fc58bd50b9b97b1db7e5b53ba83f Mon Sep 17 00:00:00 2001 From: Erik Bourget Date: Sun, 25 Oct 2026 17:50:29 -0400 Subject: [PATCH 2/2] drm/asahi: g16: Govern the published performance state by utilization The firmware runs at the state the three Globals selectors publish, busy or idle, and the host published the thermal ceiling: on the J713 the GPU sat at S9 (1470 MHz, 1000 mV) with nothing to draw. Its rail drew 1.06 W idle and 1.25 W for a 60 fps desktop, against 68-90 mW for the same desktop at S1-S3. Request a state from the GPU's busy share of each 20 ms window, between S3 and the thermal and user ceiling, which still bound it: the top state when a window was at least 70% busy, one step up at 40%, and down on a utilization averaged over about four windows, one step under 25% and to S3 under 10%. Busy time runs from publication to drain. An idle engine polls once more per window until it is back at S3. S3 draws no more than S1 on a desktop load and runs at 2.4 times its clock; S4 and up cost several times as much. The accounting is policy::Governor, which the host policy test runs directly: publication to drain, busy spans carried across windows, flights dropped by recovery without a drain, the idle re-arm back to S3, and thermal and user ceilings, including those below S3. Paired on the J713, on battery with the backlight at half (idle 5 x 60 s, three 60 Hz scenes 3 x 30 s each, the glmark2 suite 3 times): idle draw 7.32 -> 6.06 W, the GPU rail 1.09 W -> 27 mW. The 60 Hz build scene stays at S3 with no missed frame, and the glmark2 score is 574 -> 573. Signed-off-by: Erik Bourget [eryk: don't re-arm the idle poll on a failed engine: its poll returns before service_power(), so the demand never decays, and an engine that failed while idle above S3 re-armed its poll every 20 ms until reboot once anything kicked it; the host test's call-site check follows] Signed-off-by: Eryk Wieliczko Co-Authored-By: Claude Opus 5.5 --- drivers/gpu/drm/asahi/g16/engine.rs | 2 + drivers/gpu/drm/asahi/g16/poll.rs | 21 +- drivers/gpu/drm/asahi/g16/power.rs | 18 +- drivers/gpu/drm/asahi/g16/power/policy.rs | 280 +++++++++++++++++- .../gpu/asahi-m4/test-thermal-policy.py | 16 + 5 files changed, 329 insertions(+), 8 deletions(-) diff --git a/drivers/gpu/drm/asahi/g16/engine.rs b/drivers/gpu/drm/asahi/g16/engine.rs index f53293578316f..7112066baf33f 100644 --- a/drivers/gpu/drm/asahi/g16/engine.rs +++ b/drivers/gpu/drm/asahi/g16/engine.rs @@ -1358,5 +1358,7 @@ impl Firmware { s.starved_ns += s.pub_ns.wrapping_sub(s.starve_from_ns); } s.starve_from_ns = 0; + let now = s.pub_ns; + self.power.utilization.busy(now); } } diff --git a/drivers/gpu/drm/asahi/g16/poll.rs b/drivers/gpu/drm/asahi/g16/poll.rs index b94fb3a4cd36f..eab58fcac8afd 100644 --- a/drivers/gpu/drm/asahi/g16/poll.rs +++ b/drivers/gpu/drm/asahi/g16/poll.rs @@ -79,6 +79,16 @@ impl Shared { let evented = self.evented.swap(false, Ordering::AcqRel); match firmware.poll(evented, &mut done) { Ok(true) => self.rearm(firmware.engine.chan_wait), + // Idle: one more pass after a window gives the state back. A failed + // engine's poll no longer services power, so it has nothing to give back. + Ok(false) + if firmware.recovery.failed().is_none() + && firmware.power.utilization.decaying() => + { + self.mod_poll(kernel::time::msecs_to_jiffies( + (super::power::policy::WINDOW_NS / 1_000_000) as u32, + )) + } Ok(false) => {} Err(error) => { firmware.fail_stop(error, &mut done); @@ -535,6 +545,7 @@ impl Firmware { if self.engine.flights.is_empty() { // Drained: a gap until the next publication. self.engine.stats.idle_from_ns = now_ns(); + self.power.utilization.idle(self.engine.stats.idle_from_ns); } // The next prefix starts executing about now. self.renew_deadlines(); @@ -861,9 +872,13 @@ impl Firmware { super::recovery::logic::FIRMWARE_STAMP_PERIOD, self.engine.ordinal, self.engine.draw)?; writeln!( f, - "power: governor {} budget {} effective S{} temperature_mc {} updates {} sensor_errors {}", - self.power.governor, self.power.budget, self.power.effective, - self.power.temperature_mc, self.power.updates, self.power.sensor_errors + "power: governor {} budget {} demand S{} utilization {} effective S{} temperature_mc {} updates {} sensor_errors {}", + self.power.governor, + self.power.budget, + self.power.utilization.demand, + self.power.utilization.average, + self.power.effective, self.power.temperature_mc, self.power.updates, + self.power.sensor_errors )?; writeln!( f, diff --git a/drivers/gpu/drm/asahi/g16/power.rs b/drivers/gpu/drm/asahi/g16/power.rs index c6738f4bc4ac2..3b16fa04f7d7a 100644 --- a/drivers/gpu/drm/asahi/g16/power.rs +++ b/drivers/gpu/drm/asahi/g16/power.rs @@ -1,11 +1,15 @@ // SPDX-License-Identifier: GPL-2.0-only OR MIT -//! PMP-owned cooling requests and retained-session performance ceiling. +//! PMP-owned cooling requests, retained-session performance ceiling and the +//! utilization governor. //! //! The firmware owns voltage and clock transitions through PMP. The host //! consumes PMP-owner telemetry and publishes only three Globals selectors, //! in hundredths of a performance state. Firmware power tables and policy //! words from other GPU generations are never modified here. +//! +//! The firmware runs at the published state, busy or not, so the host requests +//! one from the GPU's busy share of each window, bounded by the ceiling. pub(crate) mod policy; @@ -113,6 +117,7 @@ pub(crate) struct Power { pub(crate) sensor_errors: u64, pub(crate) decisions: u64, last_decision: Option<(u32, u32, u32, u32, Option)>, + pub(crate) utilization: policy::Governor, } impl Power { @@ -139,6 +144,7 @@ impl Power { sensor_errors: 0, decisions: 0, last_decision: None, + utilization: policy::Governor::new(), }) } @@ -184,6 +190,10 @@ impl Firmware { } let now = now_ns(); let (limit, decision) = self.power.ceiling(now); + let target = self + .power + .utilization + .govern(now, limit, !self.engine.flights.is_empty()); if decision { dev_info!( self.dev(), @@ -197,15 +207,15 @@ impl Firmware { limit ); } - if limit == self.power.effective { + if target == self.power.effective { return Ok(()); } let globals = self.init.globals.as_ref().ok_or(ENODEV)?; for offset in globals::PSTATE_SELECTORS { - globals.w32(offset, limit * 100)?; + globals.w32(offset, target * 100)?; } wmb(); - self.power.effective = limit; + self.power.effective = target; self.power.updates += 1; Ok(()) } diff --git a/drivers/gpu/drm/asahi/g16/power/policy.rs b/drivers/gpu/drm/asahi/g16/power/policy.rs index 598ce43fa65dc..0d127b8a5f817 100644 --- a/drivers/gpu/drm/asahi/g16/power/policy.rs +++ b/drivers/gpu/drm/asahi/g16/power/policy.rs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-2.0-only OR MIT -//! Composition and sampling cadence for the retained firmware ceiling. +//! Composition and sampling cadence for the retained firmware ceiling, and the +//! utilization governor below it. //! //! Temperature decoding and regulation belong to the PMP provider and the //! shared C cooling policy. This module defines no temperature thresholds. @@ -8,6 +9,11 @@ pub(crate) const PSTATE_MAX: u32 = 9; pub(crate) const COOLING_MAX: u32 = 8; pub(crate) const SAMPLE_NS: u64 = 5_000_000; +/// Utilization window of the governor. +pub(crate) const WINDOW_NS: u64 = 20_000_000; +/// Lowest governed state. On the J713 S1-S3 draw under 0.1 W for a desktop +/// load and S4 several times that; S3 runs at 2.4 times the clock of S1. +pub(crate) const FLOOR: u32 = 3; /// Completion polling must not turn into a PMP MMIO read on every pass. pub(crate) fn read_due(last: Option, now: u64) -> bool { @@ -20,6 +26,111 @@ pub(crate) fn limit(configured: u32, ceiling: u32, user: u32) -> u32 { ceiling.min(PSTATE_MAX).min(configured).min(user).max(1) } +/// Percentage of `window` ns the GPU was busy for. +pub(crate) fn utilization(busy: u64, window: u64) -> u32 { + if window == 0 { + 100 + } else { + let busy = busy.min(window); + // No 128-bit division in the kernel; a window near u64::MAX loses nothing. + let util = match busy.checked_mul(100) { + Some(scaled) => scaled / window, + None => busy / (window / 100), + }; + util.min(100) as u32 + } +} + +/// Utilization averaged over about four windows; a longer gap restarts it. +pub(crate) fn smooth(average: u32, util: u32, window: u64) -> u32 { + if window >= 4 * WINDOW_NS { + util + } else { + (3 * average.min(100) + util.min(100)) / 4 + } +} + +/// The state to request from the `current` published one: up on the last +/// window's utilization `util`, to the top state under heavy load, and down +/// on the smoothed `average`, to the floor when idle. +pub(crate) fn demand(current: u32, util: u32, average: u32) -> u32 { + let next = if util >= 70 { + PSTATE_MAX + } else if util >= 40 { + current.saturating_add(1) + } else if average < 10 { + FLOOR + } else if average < 25 { + current.saturating_sub(1) + } else { + current + }; + next.clamp(FLOOR, PSTATE_MAX) +} + +/// Busy-time accounting over the governor's windows. The engine reports each +/// publication and drain; each power service closes the window once it is due. +pub(crate) struct Governor { + /// The requested state and the smoothed utilization. + pub(crate) demand: u32, + pub(crate) average: u32, + window_ns: u64, + busy_since: Option, + busy_ns: u64, +} + +impl Governor { + pub(crate) const fn new() -> Self { + Self { + demand: FLOOR, + average: 0, + window_ns: 0, + busy_since: None, + busy_ns: 0, + } + } + + /// The engine published work; it stays busy until [`Governor::idle`]. + pub(crate) fn busy(&mut self, now: u64) { + self.busy_since.get_or_insert(now); + } + + /// The engine drained. + pub(crate) fn idle(&mut self, now: u64) { + if let Some(since) = self.busy_since.take() { + self.busy_ns += now.saturating_sub(since); + } + } + + /// Whether an idle engine still has a state above the floor to give back. + /// Its poll comes back once a window while this holds. + pub(crate) fn decaying(&self) -> bool { + self.busy_since.is_none() && self.demand > FLOOR + } + + /// Closes the window once it is due and returns the state to publish + /// under the ceiling `limit`; `busy` is whether flights are outstanding. + pub(crate) fn govern(&mut self, now: u64, limit: u32, busy: bool) -> u32 { + // Recovery can drop flights without a drain. + if !busy { + self.idle(now); + } + let window = now.wrapping_sub(self.window_ns); + if window >= WINDOW_NS { + if let Some(since) = self.busy_since.as_mut() { + self.busy_ns += now.saturating_sub(*since); + *since = now; + } + let util = utilization(self.busy_ns, window); + self.average = smooth(self.average, util, window); + self.demand = demand(self.demand.min(limit), util, self.average); + self.window_ns = now; + self.busy_ns = 0; + } + self.demand.min(limit) + } +} + #[cfg(test)] mod tests { use super::*; @@ -48,4 +159,171 @@ mod tests { } } } + + #[test] + fn utilization_is_a_bounded_share_of_the_window() { + let w = WINDOW_NS; + assert_eq!(utilization(0, w), 0); + assert_eq!(utilization(w / 4, w), 25); + assert_eq!(utilization(w, w), 100); + assert_eq!(utilization(2 * w, w), 100); + assert_eq!(utilization(0, 0), 100); + assert_eq!(utilization(u64::MAX, u64::MAX), 100); + } + + #[test] + fn the_average_decays_and_restarts_after_a_gap() { + let w = WINDOW_NS; + assert_eq!(smooth(100, 0, w), 75); + assert_eq!(smooth(0, 100, w), 25); + assert_eq!(smooth(100, 0, 4 * w), 0); + assert_eq!(smooth(u32::MAX, u32::MAX, w), 100); + let mut average = 100; + let mut windows = 0; + while demand(PSTATE_MAX, 0, average) != FLOOR { + average = smooth(average, 0, w); + windows += 1; + } + assert!(windows <= 10); + } + + #[test] + fn demand_rises_fast_and_falls_on_the_average() { + assert_eq!(demand(FLOOR, 100, 0), PSTATE_MAX); + assert_eq!(demand(FLOOR, 70, 0), PSTATE_MAX); + assert_eq!(demand(4, 50, 0), 5); + assert_eq!(demand(PSTATE_MAX, 50, 0), PSTATE_MAX); + // A short dip under a busy average holds the state. + assert_eq!(demand(PSTATE_MAX, 5, 80), PSTATE_MAX); + assert_eq!(demand(6, 30, 30), 6); + assert_eq!(demand(6, 20, 20), 5); + assert_eq!(demand(PSTATE_MAX, 0, 5), FLOOR); + // A light 60 fps load near 25% busy settles on the floor. + assert_eq!(demand(FLOOR, 25, 24), FLOOR); + assert_eq!(demand(1, 30, 30), FLOOR); + for current in [0, 1, FLOOR, 7, PSTATE_MAX, u32::MAX] { + for util in [0, 20, 50, 70, 100, u32::MAX] { + for average in [0, 9, 20, 30, 100, u32::MAX] { + let next = demand(current, util, average); + assert!((FLOOR..=PSTATE_MAX).contains(&next)); + } + } + } + } + + const T: u64 = 1_000_000_000; + + /// A governor whose first window closed idle at `T`. + fn started() -> Governor { + let mut governor = Governor::new(); + assert_eq!(governor.govern(T, PSTATE_MAX, false), FLOOR); + assert_eq!(governor.average, 0); + governor + } + + #[test] + fn busy_time_runs_from_publication_to_drain() { + let w = WINDOW_NS; + let mut g = started(); + // A second publication before the drain does not restart the span. + g.busy(T + 1_000_000); + g.busy(T + 3_000_000); + g.idle(T + 6_000_000); + // A drain without a publication adds nothing. + g.idle(T + 9_000_000); + // A service before the window is due changes nothing. + assert_eq!(g.govern(T + w - 1, PSTATE_MAX, false), FLOOR); + assert_eq!(g.average, 0); + // 5 ms of 20 ms: 25%, a quarter of it in the average. + assert_eq!(g.govern(T + w, PSTATE_MAX, false), FLOOR); + assert_eq!(g.average, 6); + } + + #[test] + fn a_busy_engine_counts_to_each_window_end() { + let w = WINDOW_NS; + let mut g = started(); + g.busy(T); + assert!(!g.decaying()); + // Busy through the window: all of it counts, and the load gets the top state. + assert_eq!(g.govern(T + w, PSTATE_MAX, true), PSTATE_MAX); + assert_eq!(g.average, 25); + // Busy above the floor: the engine's own polls govern it, not the idle re-arm. + assert!(!g.decaying()); + // The span carries on from the window's end, not from its publication. + g.idle(T + w + w / 2); + assert_eq!(g.govern(T + 2 * w, PSTATE_MAX, false), PSTATE_MAX); + assert_eq!(g.average, (3 * 25 + 50) / 4); + } + + #[test] + fn flights_dropped_without_a_drain_end_the_busy_span() { + let w = WINDOW_NS; + let mut g = started(); + g.busy(T); + // Recovery dropped the flights and reported no drain: the next service, + // finding none outstanding, ends the span there. + assert_eq!(g.govern(T + w / 2, PSTATE_MAX, false), FLOOR); + assert!(!g.decaying()); + assert_eq!(g.govern(T + w, PSTATE_MAX, false), FLOOR + 1); + assert_eq!(g.average, 50 / 4); + // Idle above the floor: the poll keeps coming back. + assert!(g.decaying()); + // A late drain report counts nothing more. + g.idle(T + w + 1); + assert_eq!(g.govern(T + 2 * w, PSTATE_MAX, false), FLOOR); + assert!(!g.decaying()); + } + + #[test] + fn an_idle_engine_is_polled_back_to_the_floor_and_then_left_alone() { + let w = WINDOW_NS; + let mut g = started(); + g.busy(T); + for n in 1..=8 { + assert_eq!(g.govern(T + n * w, PSTATE_MAX, true), PSTATE_MAX); + } + let mut now = T + 8 * w; + g.idle(now); + // As in run_poll: an idle pass re-arms one window later while decaying. + let mut passes = 0; + while g.decaying() { + now += w; + g.govern(now, PSTATE_MAX, false); + passes += 1; + assert!(passes <= 10); + } + assert_eq!(g.demand, FLOOR); + assert!(passes > 1); + // At the floor nothing re-arms, and a later service holds it there. + assert_eq!(g.govern(now + 10 * w, PSTATE_MAX, false), FLOOR); + assert!(!g.decaying()); + // A long sleep between passes restarts the average and drops at once. + let mut g = started(); + g.busy(T); + assert_eq!(g.govern(T + w, PSTATE_MAX, true), PSTATE_MAX); + g.idle(T + w); + assert_eq!(g.govern(T + 6 * w, PSTATE_MAX, false), FLOOR); + assert!(!g.decaying()); + } + + #[test] + fn ceilings_bound_the_published_state_even_below_the_floor() { + let w = WINDOW_NS; + let mut g = started(); + g.busy(T); + // A thermal or user ceiling under load holds the request to it. + assert_eq!(g.govern(T + w, 5, true), 5); + // A ceiling below the floor is published as it is. + assert_eq!(g.govern(T + 2 * w, 1, true), 1); + assert_eq!(g.govern(T + 2 * w + 1, 2, true), 2); + // Released, the load's request applies at once. + assert_eq!(g.govern(T + 2 * w + 2, PSTATE_MAX, true), PSTATE_MAX); + // Idle under a ceiling below the floor: one window gives it all back. + g.idle(T + 3 * w); + assert!(g.decaying()); + assert_eq!(g.govern(T + 4 * w, 1, false), 1); + assert_eq!(g.demand, FLOOR); + assert!(!g.decaying()); + } } diff --git a/tools/testing/selftests/drivers/gpu/asahi-m4/test-thermal-policy.py b/tools/testing/selftests/drivers/gpu/asahi-m4/test-thermal-policy.py index af6e1ad7c3976..2df317430ea52 100644 --- a/tools/testing/selftests/drivers/gpu/asahi-m4/test-thermal-policy.py +++ b/tools/testing/selftests/drivers/gpu/asahi-m4/test-thermal-policy.py @@ -189,6 +189,22 @@ def operation(op, now=0, mc=90000, error=0, gov=0, power=0, configured=9, cap=9, report={'passed':True,'scope':'Shared production C cooling policy against frozen regression traces, plus production Rust scheduling/selector units; no hardware qualification.', 'rows':len(cases), 'production_sha256':sha(ROOT/'include/linux/soc/apple/pmp-cooling.h'), 'rust_composition_sha256':sha(ROOT/'drivers/gpu/drm/asahi/g16/power/policy.rs'), 'oracle_sha256':sha(FIXTURES/'thermal-policy-oracle.json')} (OUT/'thermal-policy-host.json').write_text(json.dumps(report,indent=2)+'\n') print(unit) + +# The governor's accounting is policy::Governor, run by the units above. These +# are the calls that connect it: publication, drain, the power service with +# whether flights are outstanding, and the idle poll's re-arm, which a failed +# engine does not take. +G16 = ROOT/'drivers/gpu/drm/asahi/g16' +for path, call in [ + ('engine.rs', 'self.power.utilization.busy(now);'), + ('poll.rs', 'self.power.utilization.idle(self.engine.stats.idle_from_ns);'), + ('power.rs', '.govern(now, limit, !self.engine.flights.is_empty());'), + ('poll.rs', 'Ok(false)\n if firmware.recovery.failed().is_none()\n' + ' && firmware.power.utilization.decaying() =>\n' + ' {\n self.mod_poll('), +]: + assert (G16/path).read_text().count(call) == 1, (path, call) +print('Governor connected at publication, drain, power service and idle re-arm') print(json.dumps(report,indent=2)) # Read/reattach interleavings exercise the provider's actual epoch state helpers.