Files
MobileGL/docs/Disaggregated/devices/pin-verification-2026-09-07.md
T
swung0x48 2d690754dd [Tooling] (DeviceBench): verify the two campaign devices' pin profiles and add a pin helper that works on them
- bench.sh's pin_freqs writes /proc/ppm and /proc/gpufreq, which exist on neither 35d0befa
  (SM8750) nor 3B159D009VZ00000 (MT6993 dropped both for /proc/gpufreqv2); the guard the
  PROFILE_VERIFIED key exists for was about to be defeated on the device its own comment
  guessed was safe
- pin_device.sh pins big/little/GPU through the nodes each device actually has (Xiaomi:
  policy6 1958400 / policy0 1555200 / kgsl pwrlevel 0; Oppo: policy4 2000000 + policy7
  2000000 / policy0 1600000 / gpufreqv2 fix_target_opp_index 0), checks only against its own
  pins because ColorOS moves stock maxima by itself, and refuses to call a zero-key read a verdict
- both profiles are PROFILE_VERIFIED=1 on 30 s pinned windows with zero drift; the evidence and
  the two things not verified (Oppo GPU held at DVFS-request level only, no full bench window)
  are in docs/Disaggregated/devices/pin-verification-2026-09-07.md
2026-09-07 23:43:11 -04:00

22 KiB

P2 frequency-pin profiles: what was read, what was written, what was proven

Date: 2026-09-07. Devices: 35d0befa (Xiaomi 24129PN74C, Snapdragon 8 Elite / SM8750, Adreno 830v2) and 3B159D009VZ00000 (Oppo PLG110 / ColorOS, MediaTek MT6993 "Dimensity 9500", Mali). Both rooted via Magisk (su returns uid 0 in context u:r:magisk:s0). Host: Windows, Git Bash, MSYS_NO_PATHCONV=1 on every adb invocation.

Outputs, all in this directory:

file what it is
xiaomi-adreno830.env completed profile, PROFILE_VERIFIED=1
oppo-mali.env completed profile, PROFILE_VERIFIED=1
pin_device.sh standalone <serial> pin|unpin|check helper, pure adb + su
REPORT.md this file

Both devices were left unpinned, confirmed by pin_device.sh <serial> check at the end (transcripts at the bottom). Every node written was restored.


Headline: bench.sh cannot pin either of these devices

This is the finding that matters most, because it inverts an assumption written into the existing profiles.

The pin_freqs() / unpin_freqs() pair in bench.sh writes /proc/ppm/policy/hard_userlimit_{min,max}_cpu_freq and /proc/gpufreq/gpufreq_opp_freq. On these devices:

path 35d0befa (Qualcomm) 3B159D009VZ00000 (MediaTek)
/proc/ppm/policy/ absent absent
/proc/gpufreq/ absent absent (superseded by /proc/gpufreqv2/)

The Qualcomm case was already anticipated - xiaomi-adreno830.env said so. The MediaTek case was not: the old oppo-mali.env reasoned "this part is a MediaTek SoC, so unlike the Adreno profile the harness's existing /proc/ppm + /proc/gpufreq pin path is probably the right one" and set PIN_STYLE=ppm. It is not right. MT6993 is new enough to have dropped both legacy interfaces. So the exact failure mode the PROFILE_VERIFIED guard was written to prevent - a root echo redirect into a missing /proc path exiting 0, and the run then reporting itself as pinned - was waiting on the device the guard's own comment guessed was safe.

Consequence for P2: run bench.sh / session.sh with --no-pin, drive the pin with pin_device.sh, and use pin_device.sh check in place of the big_cur/little_cur/gpu_cur_khz integrity fields. PROFILE_VERIFIED=1 in the two profiles certifies the nodes and pins in those files, not that bench.sh can drive them; both files say so in a header block. Teaching bench.sh a PIN_STYLE switch is the real fix and is not done here.


Device 1 - 35d0befa, Xiaomi 24129PN74C / SM8750 / Adreno 830v2

CPU: which policy is "big", which is "little"

ro.board.platform=sun, ro.soc.model=SM8750. Two policies, and no true little cluster - SM8750 is a 2+6 part:

policy0  cpus 0 1 2 3 4 5   gov=walt  cpuinfo 384000..3532800   (6x performance)
  avail: 384000 556800 748800 960000 1152000 1363200 1555200 1785600 1996800 2227200
         2400000 2745600 2918400 3072000 3321600 3532800
policy6  cpus 6 7           gov=walt  cpuinfo 1017600..4320000  (2x prime)
  avail: 1017600 1209600 1401600 1689600 1958400 2246400 2438400 2649600 2841600 3072000
         3283200 3513600 3801600 4089600 4204800 4320000

So big = policy6, little = policy0, and both protocol targets are exact members of scaling_available_frequencies - no rounding:

role policy target chosen rounding
big policy6 1.96 GHz 1958400 exact
little policy0 1.55 GHz 1555200 exact

CPU: how to pin, and does it hold

Method: scaling_min_freq = scaling_max_freq = target, stock walt governor left alone. No performance or userspace governor is needed - once min == max the governor has no room, and not switching it means nothing extra to restore. (migov walt conservative powersave performance schedutil are all offered.)

Writes returned exit 0 and took effect immediately. Held for the whole window under load (8 concurrent yes > /dev/null), sampled every 5 s:

t=1 p0_cur=1555200 p0_min=1555200 p0_max=1555200  p6_cur=1958400 p6_min=1958400 p6_max=1958400  gpuclk=1100000000  cpuss-0-0=47100
t=2 p0_cur=1555200 (min/max same)                 p6_cur=1958400 (min/max same)                 gpuclk=1100000000  cpuss-0-0=49400
t=3 p0_cur=1555200 (min/max same)                 p6_cur=1958400 (min/max same)                 gpuclk=1100000000  cpuss-0-0=49800
t=4 p0_cur=1555200 (min/max same)                 p6_cur=1958400 (min/max same)                 gpuclk=1100000000  cpuss-0-0=50200
t=5 p0_cur=1555200 (min/max same)                 p6_cur=1958400 (min/max same)                 gpuclk=1100000000  cpuss-0-0=51300
t=6 p0_cur=1555200 (min/max same)                 p6_cur=1958400 (min/max same)                 gpuclk=1100000000  cpuss-0-0=52100
t=7 p0_cur=1555200 (min/max same)                 p6_cur=1958400 (min/max same)                 gpuclk=1100000000  cpuss-0-0=52100

7 samples over 30 s, zero drift on either cluster or the GPU, while the SoC heated 47 -> 52 C.

Restore sequence (verified back to the stock snapshot):

policy6: scaling_min_freq=1017600  scaling_max_freq=2841600
policy0: scaling_min_freq=556800   scaling_max_freq=2745600

pin_device.sh writes min -> cpuinfo_min_freq first, then max, then min, so the order works regardless of where stock sits relative to the target (writing min above the current max is clamped by cpufreq).

GPU: kgsl pwrlevels

gpu_model=Adreno830v2   num_pwrlevels=14   default_pwrlevel=12
gpu_available_frequencies: 1100000000 1050000000 967000000 900000000 832000000 734000000
                           660000000 607000000 525000000 443000000 389000000 342000000
                           222000000 160000000
stock: min_pwrlevel=12  max_pwrlevel=0  gpuclk=222000000
       devfreq/governor=msm-adreno-tz  devfreq/min_freq=160000000  devfreq/max_freq=1050000000

Pin: echo 0 > /sys/class/kgsl/kgsl-3d0/min_pwrlevel and the same to max_pwrlevel, collapsing the range onto level 0. gpuclk read 1100000000 immediately and for the whole 30 s window.

Worth noting: the devfreq route cannot reach the top step. devfreq/max_freq is 1050000000, one OPP below level 0, so a devfreq/min_freq + max_freq pin tops out at 1050 MHz. The pwrlevel pin unlocks 1100 MHz. Restore: min_pwrlevel=12, max_pwrlevel=0.

Unit trap. bench.sh reports gpu_cur_khz from $GPU_CURFREQ_NODE via awk taking the last field. The kgsl gpuclk node is in Hz; the MediaTek current_freqency node is in kHz. So on this device the field bench.sh calls gpu_cur_khz is actually Hz. The profile carries GPU_PIN_HZ=1100000000 and GPU_CURFREQ_UNIT=hz alongside a schema-compatible GPU_PIN_KHZ=1100000; compare against the former.

GPU util node corrected. The draft profile had GPU_UTIL_NODE=/sys/class/kgsl/kgsl-3d0/gpubusy. Reading gpubusy prints 263821 1008410 - busy and total cycles - and bench.sh takes the first field, so it would have logged raw cycle counts as a busy percentage. Changed to gpu_busy_percentage, which prints 26 % and whose first field is what bench.sh means.

Thermal

84 zones; type strings are unique, so the match-first-zone-by-type loop in bench.sh is unambiguous. Chosen: cpuss-0-0 (thermal_zone13), the CPU-subsystem sensor for the 6-core cluster that carries most of the load, and the hottest of the candidates under the load test:

zone idle end of 30 s load
cpuss-0-0 (13) 34700 52100
cpuss-1-0 (20) - 49000
quiet_therm (71) 29835 32668 (skin sensor, not SoC)
gpuss-0 (23) 31200 -

The 40 C gate is reachable: it idles at 34.7 C, and was observed between 36.6 and 40.1 C across the session.


Device 2 - 3B159D009VZ00000, Oppo PLG110 / MT6993 / Mali

CPU: three policies, and the one that would have silently ruined the pin

ro.soc.model=MT6993. MT6993 is a 4+3+1 part:

policy0  cpus 0 1 2 3   gov=sugov_ext  cpuinfo 300000..2700000   stock max 2100000
policy4  cpus 4 5 6     gov=sugov_ext  cpuinfo 300000..3500000   stock max 3500000
policy7  cpu  7         gov=sugov_ext  cpuinfo 300000..4210000   stock max 3200000

Decision: big = policy4, little = policy0, and policy7 is pinned to the big target as well. policy4 rather than policy7 is the structural analogue of the 2-core policy6 on the Xiaomi - a multi-core cluster the render and worker threads can share, rather than a single core they would contend for. But policy7 cannot be left alone: an unpinned 4.21 GHz core defeats the entire pin the moment the scheduler lands a hot thread on it, and nothing in the result JSON would show it. Pinned, the device reads as 4 little + 4 big.

Neither target is an exact OPP here - both are the nearest available step:

role policy target candidates chosen offset
big policy4 1958400 1900000 (-58400), 2000000 (+41600) 2000000 +2.1%
little policy0 1555200 1500000 (-55200), 1600000 (+44800) 1600000 +2.9%
extra policy7 1958400 2000000 is an exact member 2000000 +2.1%

Carry that 2-3% offset when comparing absolute per-thread CPU cost against the Xiaomi.

CPU: how to pin, and does it hold

Same method (min = max = target, stock sugov_ext left alone). All six writes returned 0. Held under the same 8-way load, sampled every 5 s, in cur/min-max form:

t=1 p0=1600000/1600000-1600000  p4=2000000/2000000-2000000  p7=2000000/2000000-2000000  gpu="0 1716000"  soc_max=52833
t=2 p0=1600000/1600000-1600000  p4=2000000/2000000-2000000  p7=2000000/2000000-2000000  gpu="0 1716000"  soc_max=56859
t=3 p0=1600000/1600000-1600000  p4=2000000/2000000-2000000  p7=2000000/2000000-2000000  gpu="0 1716000"  soc_max=58063
t=4 p0=1600000/1600000-1600000  p4=2000000/2000000-2000000  p7=2000000/2000000-2000000  gpu="0 1716000"  soc_max=59661
t=5 p0=1600000/1600000-1600000  p4=2000000/2000000-2000000  p7=2000000/2000000-2000000  gpu="0 1716000"  soc_max=60353
t=6 p0=1600000/1600000-1600000  p4=2000000/2000000-2000000  p7=2000000/2000000-2000000  gpu="0 1716000"  soc_max=61354
t=7 p0=1600000/1600000-1600000  p4=2000000/2000000-2000000  p7=2000000/2000000-2000000  gpu="0 1716000"  soc_max=62025

7 samples over 30 s, zero drift on all three policies, SoC 52.8 -> 62.0 C. The ColorOS performance daemons did not contend for the cpufreq nodes during the window.

Read permissions. scaling_governor, scaling_min_freq and scaling_max_freq are 0660 system:system here. A plain adb shell cat .../scaling_governor answers Permission denied, which a careless parser reads as an empty governor rather than a failure. Every read goes through su. Note scaling_cur_freq and scaling_available_frequencies are world-readable, which makes the trap worse - a script can look like it is working.

GPU: gpufreqv2, not legacy gpufreq

57 working OPPs, index 0 fastest:

/proc/gpufreqv2/gpu_working_opp_table
  [00] freq: 1716000  [01] 1690000  [02] 1664000 ... [56] 390000     (kHz)

Pin: echo 0 > /proc/gpufreqv2/fix_target_opp_index; restore: echo -1. Evidence:

before  : [GPUFREQ-DEBUG] fix GPU/STACK OPP index is disabled
after 0 : [GPUFREQ-DEBUG] fix GPU/STACK OPP index: 0/0
          /sys/kernel/ged/hal/current_freqency = "0 1716000"      (index 0, 1716000 kHz)
after -1: [GPUFREQ-DEBUG] fix GPU/STACK OPP index is disabled

Deliberately not the ged custom_boost_gpu_freq / custom_upbound_gpu_freq pair, the usual MTK route, for two reasons both visible in the nodes:

  1. On this kernel they take an OPP index, not kHz. Stock reads boost=56, upbound=0 - a floor at the slowest OPP and a ceiling at the fastest. Porting the kHz-valued GPU_PIN_KHZ=902000 from odinlite.env into them would write garbage.
  2. Reading them prints a request log showing /odm/bin/hw/vendor.oplus.hardware.urcc-service (pid 1534) writing the same nodes. A pin there is one voter among several. fix_target_opp_index has no such contention.

Thermal

87 zones, unique types. Chosen: soc_max (thermal_zone14) - the SoC-wide max aggregate, which is what "SoC temperature" means for a gate, and the strictest candidate.

Cooldown series after the load test (screen awake, idle), 15 s apart:

soc_max  39094  37177  36806  35745  36954  36312
soc-top0 36361  35082  34641  34226  34079  34031
ap_ntc   36414  35034  34342  33965  33746  33566

It settles to 36-37 C, so THERMAL_START_MAX_MC=40000 is reachable - but only just, and it was still reading 42.4 C several minutes after ordinary use. Expect this gate to actually wait, unlike the one on the Xiaomi. Under load it went 52.8 -> 62.0 C in 30 s.


Every node read or written

Read-only (both devices): /sys/devices/system/cpu/cpufreq/policy*/{affected_cpus, scaling_governor,scaling_available_governors,scaling_available_frequencies,cpuinfo_min_freq, cpuinfo_max_freq,scaling_cur_freq}, /sys/class/thermal/thermal_zone*/{type,temp}, getprop, id.

Read-only, Xiaomi: /sys/class/kgsl/kgsl-3d0/{gpu_model,num_pwrlevels,default_pwrlevel, thermal_pwrlevel,max_gpuclk,gpuclk,clock_mhz,max_clock_mhz,min_clock_mhz, gpu_available_frequencies,freq_table_mhz,gpubusy,gpu_busy_percentage,throttling,temp}, /sys/class/kgsl/kgsl-3d0/devfreq/{name,governor,available_governors,available_frequencies, cur_freq,min_freq,max_freq,target_freq,gpu_load,mod_percent}, /sys/class/devfreq/.

Read-only, Oppo: /proc/gpufreqv2/{gpufreq_status,gpu_working_opp_table,fix_target_opp_index}, /sys/kernel/ged/hal/{current_freqency,custom_boost_gpu_freq,custom_upbound_gpu_freq, gpu_utilization,total_gpu_freq_level_count}.

Written (all restored):

device node stock pinned to restored to
Xiaomi policy6/scaling_min_freq 1017600 1958400 1017600
Xiaomi policy6/scaling_max_freq 2841600 1958400 2841600
Xiaomi policy0/scaling_min_freq 556800 1555200 556800
Xiaomi policy0/scaling_max_freq 2745600 1555200 2745600
Xiaomi kgsl-3d0/min_pwrlevel 12 0 12
Xiaomi kgsl-3d0/max_pwrlevel 0 0 0
Oppo policy0/scaling_min_freq 300000 1600000 300000
Oppo policy0/scaling_max_freq 2100000 1600000 2100000
Oppo policy4/scaling_min_freq 300000 2000000 300000
Oppo policy4/scaling_max_freq 3500000 2000000 3500000, then 3200000 by the vendor daemon - see below
Oppo policy7/scaling_min_freq 300000 2000000 300000
Oppo policy7/scaling_max_freq 3200000 2000000 3200000
Oppo /proc/gpufreqv2/fix_target_opp_index disabled 0 -1 (disabled)

No governor was ever written on either device; both stayed on their vendor governor (walt, sugov_ext) throughout. Nothing else was written - no settings put, no thermal-engine tampering, no app installs.

The one restore that does not settle at its stock value

On the Oppo, policy4/scaling_max_freq was restored to its snapshotted 3500000 and a ColorOS daemon lowered it to 3200000 by itself within seconds. This is correct behaviour - the range is handed back and the vendor takes over - but it means stock maxima on this device are not constants.

It caught a real bug in the first version of pin_device.sh check, which classified each node as at-pin / at-stock / neither and reported DRIFT for anything else: it declared DRIFT on a device that had been released perfectly. The classifier now asserts only against our pins (pinned means min == max == pin; anything else is not pinned, with a min == max at some other value flagged as an external clamp, which is equally fatal to comparability). The hardcoded stock values still drive the restore path, where handing the range back is all they have to do.


pin_device.sh

Usage: pin_device.sh <serial> pin|unpin|check. Pure adb + su; no profile sourcing, no bench.sh. The device table (policies, pins, stock values, GPU style, thermal zone type) is embedded so it has no inputs to get wrong. All three actions print the live big/little/GPU frequencies, governors and gate temperature, so the output of pin and of unpin is itself the before/after evidence.

Exit codes: 0 PINNED (every pinned node at its pin and every live frequency equal to it), 1 DRIFT (partly pinned, externally clamped, or a live frequency has left its pin - discard any overlapping run), 2 UNPINNED (no node is at a pin this script set). UNPINNED is non-zero on purpose, so that pin_device.sh X check && measure cannot silently measure an unpinned device.

Three things it does that are load-bearing:

  • Write order is min -> cpuinfo_min_freq, then max -> target, then min -> target. Setting min above the current max is clamped by cpufreq, so a naive two-write pin half-applies depending on where stock sits relative to the target.
  • Stock values are hardcoded, not sampled at pin time. A restore that read "stock" off an already-pinned device would make the pin permanent.
  • The device-side read emits raw node contents and parses locally. The read is delivered as a single-quoted su -c argument, so a single quote inside it (an awk program, a sed expression) closes that quoting and the whole read returns nothing. That bug was hit during this work: the Oppo check printed <unread> for every field and reported DRIFT on a correctly pinned device. A zero-key read is now a hard error (exit 66), not a verdict.

Final check on both devices, unpinned state

$ pin_device.sh 35d0befa check
== check ==
  device : Xiaomi 24129PN74C / SM8750 / Adreno 830v2 (35d0befa)
  big     policy6  gov=walt       cur=2841600   min=1017600   max=2841600    (pin 1958400 / stock 1017600-2841600)
  little  policy0  gov=walt       cur=2400000   min=556800    max=2745600    (pin 1555200 / stock 556800-2745600)
  gpu     kgsl-3d0 pwrlevel=0..12  freq=222000000 Hz  busy=7%   (pin lvl 0 = 1100000000 Hz / stock lvl 0..12)
  thermal cpuss-0-0 38200 mC = 38.2 C   (/sys/class/thermal/thermal_zone13)

  VERDICT: UNPINNED - none of the 3 nodes is at a pin this script set; the vendor
           governors have their range back and nothing this script writes is in effect.
rc=2
$ pin_device.sh 3B159D009VZ00000 check
== check ==
  device : Oppo PLG110 / MT6993 / Mali (gpufreqv2) (3B159D009VZ00000)
  big     policy4  gov=sugov_ext  cur=300000    min=300000    max=3200000    (pin 2000000 / stock 300000-3500000)
  little  policy0  gov=sugov_ext  cur=2100000   min=300000    max=2100000    (pin 1600000 / stock 300000-2100000)
  extra   policy7  gov=sugov_ext  cur=2750000   min=300000    max=3200000    (pin 2000000 / stock 300000-3200000)
  gpu     gpufreqv2 fix_opp=off  freq=1508000 kHz  busy=0%   (pin idx 0 = 1716000 kHz / stock off)
  thermal soc_max  45292 mC = 45.3 C   (/sys/class/thermal/thermal_zone14)

  VERDICT: UNPINNED - none of the 4 nodes is at a pin this script set; the vendor
           governors have their range back and nothing this script writes is in effect.
    note: big(policy4) unpinned, range 300000-3200000 (recorded stock 300000-3500000, which vendor daemons move)
rc=2

Both report their stock governors (walt, sugov_ext), the GPU back on DVFS (pwrlevel 0..12 / fix_opp=off), and cpufreq ranges wide open. Nothing written is still in effect.

For the record, the pinned half of the same cycle on each device:

$ pin_device.sh 35d0befa pin      (after pin)
  big     policy6  gov=walt       cur=1958400   min=1958400   max=1958400
  little  policy0  gov=walt       cur=1555200   min=1555200   max=1555200
  gpu     kgsl-3d0 pwrlevel=0..0  freq=1100000000 Hz
  VERDICT: PINNED - all 3 pinned nodes at their pins, live frequencies match.   rc=0

$ pin_device.sh 3B159D009VZ00000 pin      (after pin)
  big     policy4  gov=sugov_ext  cur=2000000   min=2000000   max=2000000
  little  policy0  gov=sugov_ext  cur=1600000   min=1600000   max=1600000
  extra   policy7  gov=sugov_ext  cur=2000000   min=2000000   max=2000000
  gpu     gpufreqv2 fix_opp=0     freq=1716000 kHz
  VERDICT: PINNED - all 4 pinned nodes at their pins, live frequencies match.   rc=0

What is NOT verified

  1. bench.sh cannot drive these pins. Not fixed here. The profiles are verified; the harness integration is not. Use --no-pin plus pin_device.sh until a PIN_STYLE switch exists.
  2. The Oppo GPU pin was confirmed at the DVFS-request level, not under graphics load. The pinned window was a CPU load, so the GPU was power-collapsed throughout (gpufreq_status showed PowerCount: 0). current_freqency reported OPP index 0 / 1716000 kHz for the whole window - which is exactly the field bench.sh samples as gpu_cur_khz, so the check bench.sh would perform does pass - but nobody has watched this GPU actually clock 1716 MHz while rendering. Related readout quirk: while powered down, /proc/gpufreqv2/gpufreq_status keeps printing Freq: 26000 for the fixed OPP entry. That is a parked rail, not the pin failing; current_freqency is the node to believe.
  3. Pin durability over a full bench window is untested. The verification window is 30 s of synthetic CPU load, which is what the README defines. Neither device was held pinned through a 180 s warmup plus a 30-sample measurement with the game running, where a vendor game-boost daemon has far more reason to intervene than it did here. Running pin_device.sh check after each run is what closes that gap; treat rc=1 as "discard the run".
  4. Thermal gate behaviour under the real protocol is untested. 40 C is reachable on both, but on the Oppo it idles only 3-4 C below the gate, so back-to-back paired runs will spend real time waiting. Nobody has measured how long.
  5. No reboot test. All pins were applied and released within one session. Nothing here persists across a reboot by construction (every node written is volatile sysfs or procfs), but that was not demonstrated.
  6. The walt governor and the Xiaomi thermal daemon were never provoked. 30 s at 52 C is well short of the thermal-limit regime where thermal_pwrlevel (read 0 throughout) would start capping the GPU.