Skip to content

Implement topology-aware CPU autoscaling for Linux SoCs (big.LITTLE & multi-cluster) #117

Description

@nchapman

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

  1. 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.
  2. Virtual “performance steps”:

    • Replace the current “frequency index” table with a PerfState table.

    • Each PerfState represents a combination of:

      • per-cluster min frequency (and optional max),
      • emulation thread CPU affinity.
  3. Reuse existing control logic:

    • Start at highest PerfState index, step down as long as:

      • frame timing is good, and
      • audio buffer is stable.
    • Jump back up to top state on audio underrun.

  4. Automatic device support:

    • No SoC-specific code (no explicit “if SM8250” / “if A523”).

    • Must handle:

      • Single-cluster (legacy).
      • Allwinner A523 (TG5050): 8× A55 in 2 clusters (cpu0–3, cpu4–7).
      • Typical big.LITTLE with 2 or 3 clusters (LITTLE, BIG, optional PRIME).

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

  • Cluster: one cpufreq policy (group of CPUs that share a frequency).

  • PerfState: one “virtual frequency step” in our autoscaler:

    • per-cluster min frequency (scaling_min_freq),
    • optional per-cluster max frequency (scaling_max_freq),
    • CPU affinity mask for the emulation thread.

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):

  1. 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).
  2. 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).

  3. 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
    };
  4. Sort all ClusterInfo by max_khz ascending. This gives an ordered list from “smallest/slowest” to “largest/fastest” cluster.

  5. Classify them logically:

    • LITTLE: clusters[0]

    • BIG: clusters[1 .. N-2] (if any)

    • PRIME: clusters[N-1] IF:

      • It has only 1 CPU, or
      • Its max_khz is significantly higher than the second-highest cluster (e.g. > ~5–10% higher).

    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:

  1. LITTLE only, low/mid frequencies
  2. LITTLE max only
  3. LITTLE max + BIG at increasing frequencies
  4. 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:

  • If scaling_available_frequencies exists:

    • Use 3 points: low (freqs[0]), mid (freqs[freqs.size()/2]), high (freqs.back()).
  • If not:

    • Generate 2–3 steps between min_khz and max_khz (e.g. 0%, ~50%, 100%).

Then:

  1. Single-cluster case (legacy SoCs)

    • Build PerfStates:

      • state0: min
      • state1: mid (if available)
      • state2: max
    • emu_cpus = all CPUs in that single cluster.

    • This mimics our existing “frequency table” behavior.

  2. 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).
  3. 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:

  1. 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.

  2. 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);
  3. 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:

  • On init, try to set:

    echo schedutil > /sys/devices/system/cpu/cpufreq/policyX/scaling_governor
    
  • If schedutil is not available, fall back to current/legacy behavior or ondemand.

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)

  • sun55iw3 kernel, 8× Cortex-A55.

  • Expect 2 policies:

    • policy0 → cpus 0–3
    • policy4 → cpus 4–7
  • perf_table should include:

    • LITTLE-only states (policy0 at various freqs, policy4 at minimum),
    • LITTLE-max + BIG-step states.
  • Emu affinity:

    • LITTLE states: cpus 0–3.
    • BIG states: cpus 4–7 (optionally +0–3).

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:

    • LITTLE
    • BIG
    • PRIME+BIG

Testing & Acceptance Criteria

  1. Detection correctness

    • Log detected clusters (policy_id, CPUs, min/max_khz).

    • Confirm:

      • Single-cluster systems produce 1 cluster.
      • TG5050 produces 2 clusters with correct CPUs.
      • Big.LITTLE test device produces 2–3 clusters as expected.
  2. PerfState table sanity

    • Log all PerfStates at startup (in debug builds):

      • For each state: cluster min_khz values and emu_cpus mask.
    • Ensure:

      • States are monotonically increasing in “capacity” (higher idx → higher combined cluster freqs / more clusters).
      • No duplicate states.
  3. Runtime behavior

    • On light workloads (8-bit, simple 2D):

      • Autoscaler should stabilize in a LITTLE-only state (low/mid min_khz).
    • On heavier workloads:

      • Autoscaler should eventually climb to BIG / PRIME states as needed to maintain target FPS and avoid audio underruns.
    • On forced audio underrun (test harness):

      • Autoscaler must immediately jump to highest PerfState and apply it.
  4. Regression

    • On legacy single-cluster devices:

      • Performance and battery behavior should be comparable to, or better than, the existing frequency-based autoscaler.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions