Background
Our current autoscaler assumes a single, linear CPU frequency curve:
-
At startup we fetch all available frequencies for “the CPU”.
-
We start at highest frequency and step down while:
- frame timing stays within budget, and
- audio buffer does not underrun.
-
If audio underruns, we jump back to the top frequency.
This works well on simpler SoCs where performance ≈ linear with MHz.
Newer devices (e.g. Snapdragon-class big.LITTLE, Allwinner A523) expose multiple CPU clusters (cpufreq policies) with different capacities and per-cluster frequencies. Our current model (single global MHz) is no longer correct or optimal.
We want to extend the autoscaler so it:
-
Works automatically across:
- single-cluster SoCs,
- dual-cluster (e.g. Allwinner A523: 2× A55 clusters),
- tri-cluster+ SoCs (LITTLE + BIG + PRIME).
-
Uses Linux topology and cpufreq data at runtime (no SoC special-casing).
-
Keeps the existing frame-time + audio underrun driven control loop as much as possible.
Goals
-
Topology-aware autoscaling on Linux:
- Detect clusters (cpufreq policies, CPUs per policy, min/max freq).
- Use per-cluster frequency configurations instead of a single global frequency.
-
Virtual “performance steps”:
-
Reuse existing control logic:
-
Automatic device support:
Non-Goals (for this initial implementation)
- No Android/ADPF or vendor HAL integration (Linux only).
- No user UI changes beyond any existing “auto CPU scaling” toggle.
- No per-game overrides for now (all games share the same autoscaling logic).
High-Level Design
1. Terminology
The outer control loop (frame timing + audio underrun) only sees:
std::vector<PerfState> perf_table;
int current_index;
and manipulates current_index exactly like it does with the old frequency table.
Implementation Details
2. Cluster Detection (Linux sysfs)
At process startup (once):
-
Enumerate cpufreq policies:
- Base path:
/sys/devices/system/cpu/cpufreq/
- Policies: directories named
policy0, policy1, … (DO NOT assume contiguous numbers; probe until a directory doesn’t exist).
-
For each policyX, read:
-
related_cpus
- Example:
"0-3" or "0-3,5,7-8"
- Parse into a vector of CPU IDs.
-
cpuinfo_min_freq (kHz, integer)
-
cpuinfo_max_freq (kHz, integer)
-
scaling_available_frequencies (optional; fall back to min/max if missing).
-
Build an internal ClusterInfo:
struct ClusterInfo {
int policy_id; // X from policyX
std::vector<int> cpus; // logical CPU IDs
int min_khz;
int max_khz;
std::vector<int> freqs; // ascending order, from scaling_available_frequencies if present
};
-
Sort all ClusterInfo by max_khz ascending. This gives an ordered list from “smallest/slowest” to “largest/fastest” cluster.
-
Classify them logically:
On Allwinner A523:
- policy0: cpu0–3, lower max_khz → LITTLE
- policy4: cpu4–7, higher max_khz → BIG
- No PRIME (big cluster is just “bigger LITTLE”).
On a single-cluster SoC:
- only
clusters[0] exists → treat as LITTLE/BIG combined and build a simpler PerfState table (see below).
3. Building the PerfState Table
We synthesize a ladder of PerfStates based on available frequencies and cluster roles.
3.1 PerfState definition
struct PerfState {
struct ClusterFreq {
int policy_id; // cpufreq policy
int min_khz; // frequency to write to scaling_min_freq (0 = don't touch)
int max_khz; // frequency to write to scaling_max_freq (0 = don't touch)
};
std::vector<ClusterFreq> clusters; // one entry per cluster we touch
std::vector<int> emu_cpus; // CPUs where the emulation thread is allowed to run
};
3.2 Ladder shape (conceptual)
We want a small, ordered set of states, from lowest to highest:
- LITTLE only, low/mid frequencies
- LITTLE max only
- LITTLE max + BIG at increasing frequencies
- Optional PRIME states (if PRIME cluster exists)
On a 2-cluster SoC like A523, we stop at #3. On a single-cluster SoC, we only have #1–2.
3.3 Concrete rules
For each cluster, pick ~2–3 frequencies:
Then:
-
Single-cluster case (legacy SoCs)
-
Two-cluster case (e.g. Allwinner A523)
Assume:
little = clusters[0]
big = clusters[1]
Build states roughly as:
- LITTLE low/mid/max (BIG at hw minimum)
- LITTLE max + BIG low/mid/max
Emu affinity:
- For LITTLE-only states: emu_cpus =
little.cpus.
- For BIG states: emu_cpus =
big.cpus (plus optionally little.cpus if we want spillover).
-
Three-cluster case (LITTLE + BIG + PRIME)
Assume:
little = clusters[0]
big = clusters[1] (or clusters[1..N-2] merged logically)
prime = clusters[N-1] (single CPU or highest capacity)
Build states:
- LITTLE low/mid/max
- LITTLE max + BIG low/mid/max
- LITTLE max + BIG max + PRIME mid/max
Emu affinity:
- LITTLE states:
little.cpus
- BIG states:
big.cpus (+ optionally little)
- PRIME states:
prime.cpus + big.cpus
Important:
The table must be sorted ascending by “overall performance”. We will start at perf_table.back() (highest) and step down.
4. Applying a PerfState
Implement:
void apply_perf_state(const PerfState& s, pthread_t emu_thread);
Behavior:
-
For each ClusterFreq entry:
- Base path:
/sys/devices/system/cpu/cpufreq/policy<policy_id>/
- If
min_khz > 0: write to scaling_min_freq.
- If
max_khz > 0: write to scaling_max_freq.
We require root / appropriate permissions to write these.
-
Set the emulation thread’s CPU affinity:
cpu_set_t set;
CPU_ZERO(&set);
for (int cpu : s.emu_cpus) CPU_SET(cpu, &set);
pthread_setaffinity_np(emu_thread, sizeof(set), &set);
-
We should not spam sysfs every frame:
- Only call
apply_perf_state when the PerfState index actually changes.
5. Governor Choice
Before using PerfStates, ensure each cpufreq policy uses a DVFS governor that cooperates well with this scheme, ideally schedutil:
We do not use scaling_setspeed in this design; we let the governor handle fine-grained frequency changes within the min/max bounds we set.
Integration with Existing Autoscaler Logic
The existing loop currently does:
-
maintain a freq_table and current_freq_index,
-
watch:
- frame times vs target (FPS),
- audio buffer level / underrun,
-
step index down on sustained headroom,
-
step index up on timing trouble,
-
jump to max index on audio underrun.
We keep that logic but replace freq_table with perf_table:
std::vector<PerfState> perf_table;
int current_idx = (int)perf_table.size() - 1; // start at highest
void autoscaler_on_frame(double frame_ms, bool audio_underrun) {
static int cool_frames = 0;
if (audio_underrun) {
current_idx = (int)perf_table.size() - 1;
apply_perf_state(perf_table[current_idx], emu_thread);
cool_frames = 0;
return;
}
if (frame_ms > target_ms * 1.05) {
// frame over budget → step up if we can
if (current_idx + 1 < (int)perf_table.size()) {
++current_idx;
apply_perf_state(perf_table[current_idx], emu_thread);
cool_frames = 0;
}
} else if (frame_ms < target_ms * 0.7) {
// plenty of headroom → consider stepping down
if (++cool_frames > COOL_FRAMES_THRESHOLD && current_idx > 0) {
--current_idx;
apply_perf_state(perf_table[current_idx], emu_thread);
cool_frames = 0;
}
} else {
// in the sweet spot, reset cool_frames but don't change state
cool_frames = 0;
}
}
Notes:
COOL_FRAMES_THRESHOLD should be non-trivial (e.g. 300 frames) to avoid flapping.
- We may want to add slight hysteresis on the over-budget condition too if needed.
Device Examples to Validate
A. Single-cluster SoC (legacy)
- Only one
policyX.
perf_table should have 2–3 states with different min_khz.
- Emu affinity always includes all CPUs.
- Behavior should closely match current frequency-based autoscaling.
B. TG5050 (Trimui Smart Pro S, Allwinner A523)
C. Tri-cluster big.LITTLE SoC (e.g. Snapdragon-class)
-
3 policies with increasing max_khz.
-
PRIME detection should treat the highest-max cluster (usually 1 core) as PRIME.
-
perf_table should progress:
- LITTLE-only → LITTLE+BIG → LITTLE+BIG+PRIME.
-
Emu affinity:
Testing & Acceptance Criteria
-
Detection correctness
-
PerfState table sanity
-
Runtime behavior
-
Regression
Background
Our current autoscaler assumes a single, linear CPU frequency curve:
At startup we fetch all available frequencies for “the CPU”.
We start at highest frequency and step down while:
If audio underruns, we jump back to the top frequency.
This works well on simpler SoCs where performance ≈ linear with MHz.
Newer devices (e.g. Snapdragon-class big.LITTLE, Allwinner A523) expose multiple CPU clusters (cpufreq policies) with different capacities and per-cluster frequencies. Our current model (single global MHz) is no longer correct or optimal.
We want to extend the autoscaler so it:
Works automatically across:
Uses Linux topology and cpufreq data at runtime (no SoC special-casing).
Keeps the existing frame-time + audio underrun driven control loop as much as possible.
Goals
Topology-aware autoscaling on Linux:
Virtual “performance steps”:
Replace the current “frequency index” table with a PerfState table.
Each PerfState represents a combination of:
Reuse existing control logic:
Start at highest PerfState index, step down as long as:
Jump back up to top state on audio underrun.
Automatic device support:
No SoC-specific code (no explicit “if SM8250” / “if A523”).
Must handle:
Non-Goals (for this initial implementation)
High-Level Design
1. Terminology
Cluster: one cpufreq policy (group of CPUs that share a frequency).
PerfState: one “virtual frequency step” in our autoscaler:
The outer control loop (frame timing + audio underrun) only sees:
std::vector<PerfState> perf_table; int current_index;and manipulates
current_indexexactly like it does with the old frequency table.Implementation Details
2. Cluster Detection (Linux sysfs)
At process startup (once):
Enumerate cpufreq policies:
/sys/devices/system/cpu/cpufreq/policy0,policy1, … (DO NOT assume contiguous numbers; probe until a directory doesn’t exist).For each
policyX, read:related_cpus"0-3"or"0-3,5,7-8"cpuinfo_min_freq(kHz, integer)cpuinfo_max_freq(kHz, integer)scaling_available_frequencies(optional; fall back tomin/maxif missing).Build an internal
ClusterInfo:Sort all ClusterInfo by
max_khzascending. This gives an ordered list from “smallest/slowest” to “largest/fastest” cluster.Classify them logically:
LITTLE:
clusters[0]BIG:
clusters[1 .. N-2](if any)PRIME:
clusters[N-1]IF:max_khzis significantly higher than the second-highest cluster (e.g. > ~5–10% higher).On Allwinner A523:
On a single-cluster SoC:
clusters[0]exists → treat as LITTLE/BIG combined and build a simpler PerfState table (see below).3. Building the PerfState Table
We synthesize a ladder of PerfStates based on available frequencies and cluster roles.
3.1 PerfState definition
3.2 Ladder shape (conceptual)
We want a small, ordered set of states, from lowest to highest:
On a 2-cluster SoC like A523, we stop at #3. On a single-cluster SoC, we only have #1–2.
3.3 Concrete rules
For each cluster, pick ~2–3 frequencies:
If
scaling_available_frequenciesexists:freqs[0]), mid (freqs[freqs.size()/2]), high (freqs.back()).If not:
min_khzandmax_khz(e.g. 0%, ~50%, 100%).Then:
Single-cluster case (legacy SoCs)
Build PerfStates:
state0: minstate1: mid (if available)state2: maxemu_cpus= all CPUs in that single cluster.This mimics our existing “frequency table” behavior.
Two-cluster case (e.g. Allwinner A523)
Assume:
little = clusters[0]big = clusters[1]Build states roughly as:
Emu affinity:
little.cpus.big.cpus(plus optionallylittle.cpusif we want spillover).Three-cluster case (LITTLE + BIG + PRIME)
Assume:
little = clusters[0]big = clusters[1](orclusters[1..N-2]merged logically)prime = clusters[N-1](single CPU or highest capacity)Build states:
Emu affinity:
little.cpusbig.cpus(+ optionally little)prime.cpus + big.cpusImportant:
The table must be sorted ascending by “overall performance”. We will start at
perf_table.back()(highest) and step down.4. Applying a PerfState
Implement:
Behavior:
For each
ClusterFreqentry:/sys/devices/system/cpu/cpufreq/policy<policy_id>/min_khz > 0: write toscaling_min_freq.max_khz > 0: write toscaling_max_freq.We require root / appropriate permissions to write these.
Set the emulation thread’s CPU affinity:
We should not spam sysfs every frame:
apply_perf_statewhen the PerfState index actually changes.5. Governor Choice
Before using PerfStates, ensure each cpufreq policy uses a DVFS governor that cooperates well with this scheme, ideally
schedutil:On init, try to set:
If
schedutilis not available, fall back to current/legacy behavior orondemand.We do not use
scaling_setspeedin this design; we let the governor handle fine-grained frequency changes within the min/max bounds we set.Integration with Existing Autoscaler Logic
The existing loop currently does:
maintain a
freq_tableandcurrent_freq_index,watch:
step index down on sustained headroom,
step index up on timing trouble,
jump to max index on audio underrun.
We keep that logic but replace
freq_tablewithperf_table:Notes:
COOL_FRAMES_THRESHOLDshould be non-trivial (e.g. 300 frames) to avoid flapping.Device Examples to Validate
A. Single-cluster SoC (legacy)
policyX.perf_tableshould have 2–3 states with different min_khz.B. TG5050 (Trimui Smart Pro S, Allwinner A523)
sun55iw3kernel, 8× Cortex-A55.Expect 2 policies:
policy0→ cpus 0–3policy4→ cpus 4–7perf_tableshould include:Emu affinity:
C. Tri-cluster big.LITTLE SoC (e.g. Snapdragon-class)
3 policies with increasing max_khz.
PRIME detection should treat the highest-max cluster (usually 1 core) as PRIME.
perf_tableshould progress:Emu affinity:
Testing & Acceptance Criteria
Detection correctness
Log detected clusters (policy_id, CPUs, min/max_khz).
Confirm:
PerfState table sanity
Log all PerfStates at startup (in debug builds):
Ensure:
Runtime behavior
On light workloads (8-bit, simple 2D):
On heavier workloads:
On forced audio underrun (test harness):
Regression
On legacy single-cluster devices: