Skip to content

cpufreq: apple: verify T8140 transitions on the next request - #443

Merged
DjDeveloperr merged 4 commits into
integration/aurorafrom
fix/t8140-cpufreq-lazy-verify-20261025
Oct 26, 2026
Merged

DjDeveloperr merged 4 commits into
integration/aurorafrom
fix/t8140-cpufreq-lazy-verify-20261025

Conversation

@DjDeveloperr

@DjDeveloperr DjDeveloperr commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Every T8140 CPU frequency request spun until the cluster reported the new state, about 50 µs per transition. Because of that verification, the T8140 also never used schedutil's fast-switch path. Every change therefore went through the governor kthread, which runs at deadline priority and busy-waited on whichever CPU it landed on.

Under light GPU work the P-cluster changes state roughly a thousand times a second. Each GPU completion is handled by the mailbox interrupt thread, a real-time task that raises the cluster to its top state, and the cluster drops back once the thread sleeps. The spin cost about 7% of a P-core, and it landed between a completion and the next submission.

This change checks each request when the next one is issued. That request still waits for BUSY to clear and still stops all requests if the accepted state differs from the last one written, so an unexpected owner is still caught, one request later. With the wait gone, T8140 enables fast switch and the governor thread disappears. Requests through ->target_index, from suspend and the static governors, still wait for their own transition. The fast-switch callback resolves its table index from the frequency it is given rather than reading policy->cached_resolved_idx, which a concurrent limits update can rewrite.

Measurements

Neo (A18 Pro); same boot, alternating with the previous module; gpubench, µs per submission:

row before after
compute, depth 2 58.8–59.7 46.4–47.3
compute, depth 1 107.8–108.4 102.6–102.7
render, depth 2 47.5–47.8 45.8–45.9
render depth 32 / compute depth 64 unchanged unchanged

Checks on Neo

  • Governor thread. It is gone with this change (sugov count 0). schedutil runs fast switch on both policies.
  • Errors. No DVFS busy or completion errors were logged through about 50k transitions in the latency runs, nor during all-core load.
  • Thermal behaviour is unchanged. Under 20 s of load on all six cores, alternating with the stock module, both step the P-cluster down through 2196/1620/1260/744 MHz at the same temperatures (77–106 °C). The fast die-temperature limit still applies through its QoS request.

Scope

T8140 only, behind a new lazy_verify flag. T8142 and T8152 keep blocking verification until they are measured.

@RyanTheTide

RyanTheTide commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Current head 6f5d208 adds direct fast die-temperature cap delivery and suspend draining after the tested e2a2d8e head. Those changes address the area covered by the earlier QoS dispatch finding; that finding is historical and is not a verdict on this new implementation. The earlier final-request issue was already addressed by e2a2d8e's timer.

The completed results below are for e2a2d8e only. On integration/aurora at 421e314 with shared #438 and #456, DCP is PASS (63/63). KUnit has 905 passes and 1 policy skips. Universal WERROR is PASS; Fedora-base WERROR is PASS. The actual-function timer and QoS controls also target e2a2d8e. These passes do not qualify 6f5d208.

The new head needs fresh DCP, KUnit, universal and Fedora-base builds, plus controls for direct cap delivery without a scheduler update, cap relaxation, BUSY pending requests, suspend and teardown. Hardware qualification remains with the orchestrator. No current-head failure or hardware result is claimed.

Every T8140 frequency request spun until the cluster reported the new
state, about 50 us per transition, and the same verification kept
schedutil off the fast-switch path. Each change therefore woke the
governor kthread, which runs above every normal task and then busy-waited
on whichever CPU it landed on.

Under light GPU work the P-cluster changes state about a thousand times a
second: each GPU completion runs the mailbox interrupt thread, a real-time
task that raises the cluster to its top state, and the cluster drops back
once the thread sleeps. The spin cost 7% of a P-core and landed between a
completion and the next submission.

On the fast-switch path, check each request when the next one is issued
instead. That request still waits for BUSY to clear and still stops all
requests if the accepted state is not the one last written, so an
unexpected owner is caught as before, one request later. With the wait
gone the driver can switch from the scheduler path, and the governor
thread is no longer used. Requests through ->target_index, from suspend
and the static governors, still wait for their own transition.

The fast-switch callback now resolves its table index from the frequency
it is given instead of reading policy->cached_resolved_idx, which a
concurrent limits update can rewrite between programming the cluster and
reporting the frequency.

Measured on Neo, same boot, alternating with the previous module
(gpubench, us per submission, two rounds):

  compute, depth 2      58.8-59.7 -> 46.4-47.3
  compute, depth 1     107.8-108.4 -> 102.6-102.7
  render, depth 2       47.5-47.8 -> 45.8-45.9
  render, depth 32 and compute, depth 64: unchanged
@DjDeveloperr
DjDeveloperr force-pushed the fix/t8140-cpufreq-lazy-verify-20261025 branch from 9aada77 to b11a5e9 Compare October 25, 2026 23:02
@DjDeveloperr

Copy link
Copy Markdown
Contributor Author

Thanks, agreed: a final deferred request with no successor must be verified too. Since your comment I've split the paths: ->target_index (suspend, static governors) now waits for its own transition again, and fast switch resolves its own index rather than cached_resolved_idx (b11a5e9). The remaining fast-switch work is under way:

  • a per-policy hard hrtimer verifies every deferred write, including the last, within the existing transition timeout, and latches a refused write or stuck BUSY as today;
  • a request arriving while BUSY is set is held as pending instead of spinning in scheduler context.

The Neo smoke covers boot, load and thermal transitions, an injected refused transition, and a final request without a successor. I don't have an M1 Pro, and the M2 here is the build host, so I'll say explicitly what that regression coverage relies on.

The previous change verified a fast-switch request only when the next one
was issued. That request still spun on BUSY in the scheduler for up to the
transition timeout, the last request of a burst was never verified, and a
request dropped on BUSY was never retried: schedutil does not ask again
for the frequency it last asked for.

Verify from a hard-irq hrtimer instead, serialised with the scheduler's
requests and ->target_index by a per-policy raw spinlock:

- A request that finds the cluster idle checks that the accepted state is
  the one last written, and is written at once. The timer checks the last
  request written the same way within 1 ms, with 0.5 ms of slack so that
  it can share an interrupt, so every request is verified, the last of a
  burst included.
- A request that finds the cluster busy is kept as pending; it is neither
  spun on nor dropped. The timer writes it once the transition has
  finished, from about when it should have, and verifies it in turn.
- A transition still busy past the transition timeout, or an accepted
  state other than the one written, latches transition_failed as before.
  The timer reports it, outside the scheduler.
- fast_switch returns the requested frequency once a request is committed.
  ->target_index cancels the timer, drops any pending request and keeps
  its blocking verification; ->exit cancels the timer.

Only lazy_verify SoCs (T8140) use the timer.

Measured on T8140 against the previous change, in the same boot,
alternating, three rounds (us per submit, medians):

  compute.light queue d1    102.67 -> 102.38
  compute.light queue d2     46.70 ->  46.61
  render.light queue d2      45.81 ->  45.76

with 5-6% more timer interrupts over those rows. Under mixed GPU and CPU
load the policies made about 570 transitions/s with no DVFS errors;
all-core load throttled to the same frequencies; the static governors and
a pm_test=devices suspend/resume cycle ran without errors. A diagnostic
build, not part of this change, showed the timer alone finding a refused
write when no further request was issued, and a stuck BUSY on both
policies.
@DjDeveloperr

Copy link
Copy Markdown
Contributor Author

Your point is addressed in e2a2d8e. A per-policy hard hrtimer now verifies the last written fast-switch request within 1 ms, so a final request with no successor is checked, and a refused write or stuck BUSY latches transition_failed as before. A request that finds BUSY is held as pending (not spun on in scheduler context, not dropped) and written by the timer once the transition finishes. ->target_index keeps blocking verification.

On Neo: the light-GPU rows match the previous head (compute d2 46.70 → 46.61 µs), with 0 DVFS errors over about 570 transitions/s. A diag build showed the timer alone catching both a refused write with no later request and a stuck BUSY. Governor switches and a pm_test suspend/resume ran clean, and thermal throttling is unchanged. M1 Pro / M2 aren't touched by the timer path (T8140 lazy_verify only); the shared fast-switch index fix does reach them.

A fast-switch request returns its frequency once committed, and the core
records it in policy->cur, but on a lazy_verify SoC it may still be
pending behind a busy transition, or be dropped when the verify timer
finds that transition failed. cpufreq_generic_suspend() skips a suspend
frequency equal to policy->cur, so suspend could leave the cluster at an
older state, or miss a failure, without calling the driver.

Give the driver its own ->suspend: on lazy_verify SoCs it cancels the
verify timer, reports a failure the timer had yet to report, writes the
pending request and waits for it, verifying it and the transition before
it, and only then does the generic suspend. A latched failure fails the
driver suspend, which the core logs.

Waited-for requests share the draining step. They never run alongside
fast-switch requests, so ->target_index no longer holds the raw spinlock,
with interrupts off, across its two polls; a failure report the timer
lost to the cancel is now printed; ->exit drains as well.

Measured on T8140 against the previous change's base in the same boot,
alternating, three rounds (us per submit, medians):

  compute.light queue d1    102.25 -> 102.12
  compute.light queue d2     36.93 ->  36.47
  render.light queue d2      48.24 ->  48.45

A pm_test=devices suspend cycle ran without DVFS or driver suspend
errors. With a diagnostic build, not part of this change: a request left
pending for the lowest frequency, equal to policy->cur and suspend_freq,
was written and verified at suspend; a stuck BUSY that dropped the
pending request, with the timer's report withheld, was reported at
suspend and failed the driver suspend.
@DjDeveloperr

Copy link
Copy Markdown
Contributor Author

Follow-up commit b7ea3ca, "cpufreq: apple: settle T8140 lazy requests before suspend", is the same change as omacom#227. A fast-switch request can still be pending when the system suspends. cpufreq_generic_suspend() skips a suspend frequency equal to policy->cur, so it could leave that request unwritten, or miss a failure. The driver's own ->suspend now writes and verifies the pending request, or reports the failure, before the generic suspend runs.

It passed a pm_test=devices cycle, plus two cases with a diagnostic build: a request pending at suspend, and a failed request whose report was held back. Light-submit rows are unchanged.

@DjDeveloperr

Copy link
Copy Markdown
Contributor Author

Thanks, agreed. Before lazy verification, T8140 used ->target_index, where the core applies a lowered FREQ_QOS_MAX at once. Fast switching introduced the delay, so it belongs to this PR.

I have a fix that is built but not yet tested on hardware: "cpufreq: apple: apply a lowered T8140 fast die limit at once".

  • Lowering. When a fast die-temperature sample changes the limit on a lazy_verify cluster, the driver records the fastest P-state at or below it. Under the same lock as fast-switch requests, it lowers any request in flight or pending above that state. Delivery is then bounded by the sampling: a tick on a busy CPU, and the 5 ms hrtimer on a cluster with nohz_full CPUs, which already samples from the hrtimer all the time.
  • Clamping. Later fast-switch requests are clamped to the cap while the limit holds, and return the frequency actually requested.
  • Waited-for requests. These own the cluster from the drain to the next fast-switch request, and the core applies the limit to them. During that window the samples only record the cap. The drain marks this before it cancels the verify timer, so no sample can queue the timer again.
  • Other SoCs. None of this runs anywhere else: apple_fast_die_cap() returns early unless the SoC is lazy_verify. The non-lazy fast-switch path is behaviourally the same, so M1 Pro and M2 run the code they ran before.

Hardware qualification on Neo is next: forced-limit delivery with and without the fix under a pinned spinner, the same on a nohz_full boot, a real thermal case, timer-only refusal, static governor switches, module teardown, and light-row cost. I'll push the commit with those results.

The fast die-temperature limit is a FREQ_QOS_MAX request. T8140 now
switches frequency from the scheduler, where schedutil only records a
lower limit and applies it at its next frequency update, which a
nohz_full CPU running a task with its tick stopped may not have for a
long time. Through ->target_index, which T8140 used before lazy
verification, the core applies the limit at once.

When a sample changes the limit of a lazy_verify cluster, record the
fastest P-state at or below it and move the cluster to the P-state the
scheduler last asked for, at most that one, through the same locked
path as fast-switch requests. Later fast-switch requests are clamped to
it while the limit holds, and return the frequency actually requested.
Going back to the scheduler's request also covers a limit that rises
again before the QoS request was updated: the request is then left as
it was, and schedutil does not ask again.

Waited-for requests own the cluster from the drain to the next
fast-switch request, and the core applies the limit to them, so the
samples leave the cluster alone then. The drain now marks this before it
cancels the verify timer, so that no sample queues the timer again.

Measured on T8140 with a diagnostic build that forces a 2196 MHz limit
while a spinner runs on a P-core: the limit is written inside the sample
that sets it, where the scheduler path took about 1 ms, and 0.02 to
2.5 ms with nohz_full on that CPU. A drop and restore within one sample,
with the QoS request unchanged, returns the cluster to the scheduler's
request. Under a real thermal load the frequency stayed at or below the
limit in every sample. Static governor switches, unbinding and a
pm_test=devices cycle were clean, and light GPU submission costs are
unchanged.

Other SoCs are unchanged.
@DjDeveloperr

Copy link
Copy Markdown
Contributor Author

Cap delivery fixed in 6f5d208, "cpufreq: apple: apply a lowered T8140 fast die limit at once", and qualified on Neo. Setup: 7.3 perf kernel; "before" is e2a2d8e + b7ea3ca. A diagnostic build, not part of the change, forced the limit and timed the gap from the sample that changes it to the first command write at or below it.

check before after
forced 2196 MHz limit, spinner on P-core 5, normal boot (10×) 996–999 µs (one tick plus the QoS work) written inside the sample, 10/10
same with nohz_full=5 isolcpus=5, one spinner on cpu5, its tick stopped (0 timer IRQs in 2 s) (5×) 23 µs to 2.5 ms, median about 530 µs written inside the sample, 5/5
drop and restore within one sample, QoS unchanged, steady spinner — returns to the scheduler's request every time
real thermal load, both P-cores busy for 30 s frequency above the limit in 0/120 samples, max 99.2 °C 0/120, max 93.9 °C
static governors (performance, powersave, schedutil, twice), unbind and rebind ×3, pm_test=devices — no DVFS lines, no driver suspend errors
compute.light d1 / d2, render.light d2, µs per submit (3 alternating rounds) 101.92 / 36.33 / 48.48 102.12 / 36.80 / 48.32

The nohz_full boot didn't produce an unbounded delay before the fix. cpu0 shares the policy with cpu5 and stays the housekeeping CPU, and the QoS work's kworker on cpu0 triggers a schedutil update. The fully unbounded case needs every CPU of the policy tickless, which this boot can't do. The fix doesn't depend on any of that: it delivers inside the sample, which runs at most every 5 ms while the limit is below the maximum and once a tick otherwise, whatever the governor does.

The drop-and-restore row covers a case I found while reviewing my first version. If the limit falls and recovers before the QoS work runs, the QoS request never changes, so schedutil never asks again. The driver therefore moves the cluster to the scheduler's last request, at most the cap, and raises it again too.

M1 Pro and M2: apple_fast_die_cap() returns early unless the SoC is lazy_verify, and the non-lazy fast-switch path does what it did before. They run the same code as before this commit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants