Files
MontaukOS/docs/power.md
T

7.7 KiB

CPU Power and Thermal Management

MontaukOS manages CPU frequency, idle states, and package temperature on Intel hardware. Without this, a modern many-core laptop part (the reference target is a TUXEDO Gemini Gen2 with an i9-13900HX, 24 cores / 32 threads) runs at whatever P-state the firmware left it, never drops below C1 when idle, and heats until the hardware throttles at TjMax - or until the embedded controller intervenes (on Clevo boards: forced full-volume beeping, then a hard power-off).

Everything below is feature-gated by CPUID, so virtual machines (KVM masks the power bits in CPUID.06H) and non-Intel CPUs skip it safely.

Components

HWP frequency scaling (Hal/CpuPower.cpp)

Hal::CpuPower::InitializeBsp() enables Hardware P-states (IA32_PM_ENABLE), reads the performance range from IA32_HWP_CAPABILITIES, and programs IA32_HWP_REQUEST with:

  • min = lowest performance level, max = highest
  • desired = 0 (fully autonomous hardware frequency selection)
  • EPP = 0x80 (balanced; same as Linux "balance_performance")

This is the same mechanism intel_pstate uses on Linux and is the single biggest thermal win: the CPU idles near its minimum frequency and ramps only while there is work. APs apply the same request in ApEntry via InitializeAp() (HWP request MSRs are per-logical-CPU).

CPUs with EPB but no HWP get IA32_ENERGY_PERF_BIAS = 6 (balanced) instead.

C1E promotion

MSR_POWER_CTL bit 1 lets HLT-idle drop core voltage/frequency to minimum (C1E) instead of parking at the current ratio. Set per-core on the BSP and every AP. Gated on the DTS CPUID bit as a real-hardware heuristic, because MSR_POWER_CTL is not architectural and hypervisors may not emulate it.

Thermal governor (Hal/CpuPower.cpp)

A closed-loop passive-cooling controller on the BSP, stepped from Sched::RunBspMaintenance() (reached from both the idle loop and the timer tick, so it keeps running under full load). Every 500 ms it reads the package digital thermal sensor (IA32_PACKAGE_THERM_STATUS readout relative to TjMax from MSR_TEMPERATURE_TARGET) and adjusts the HWP performance ceiling:

  • >= 85 C (passive): step the ceiling down 2 ratio units (~200 MHz)
  • >= 94 C (hot): step down 8 units per interval
  • <= 78 C (resume): step back up 1 unit per interval
  • between resume and passive: hold (hysteresis)

The ceiling propagates through a policy epoch counter: each CPU compares the epoch in Sched::Tick() (and the AP idle loop) against the last one it applied and rewrites its own IA32_HWP_REQUEST when it changed, since MSR writes only affect the executing core. APs tick every 10 ms, so a throttle decision reaches all cores within one tick.

Throttle engage/release transitions are logged (WARNING/OK, "CpuPower").

Deep AP idle (ACPI/CpuIdle.cpp)

APs use deep MWAIT C-states while idle; C6 power-gates the core entirely, which is where most of the idle-heat win lives on many-core parts.

The hint comes from ACPI _CST FFH entries (the GAS address low byte is the MWAIT hint, same convention as Linux), validated against the CPUID.05H sub-state enumeration and capped at the C6 family (0x2F). With no usable _CST, the deepest CPUID-enumerated hint (up to C6) is used.

The BSP stays in C1 (HLT). On some laptops deep MWAIT states freeze the LAPIC timer even though ARAT is advertised, and the BSP's periodic 1 ms tick is the system timekeeper (see the comment in Timekeeping::IdleOnce). APs have no timekeeping duty: an idle AP is woken by the reschedule IPI, and its coarse tick resumes on wake.

S3 wake

IA32_PM_ENABLE, HWP requests, and MSR_POWER_CTL do not survive suspend. Hal::CpuPower::ReapplyAfterWake() runs in the S3 resume path, redoes the BSP setup, and bumps the policy epoch so every AP re-applies its HWP request (and re-checks C1E) on its next tick.

Battery (ACPI/Battery.cpp, ACPI/EmbeddedController.cpp)

Battery state comes from the ACPI Control Method Battery (_HID PNP0C0A) and, for the AC adapter, ACPI0003:

  • _STA bit 4 says whether a pack is installed. A laptop with the pack removed still has the device, which is why "has a battery device" and "has a battery" are separate answers.
  • _BIX (or _BIF on older firmware) gives the power unit (mWh vs mAh), the design capacity, and the last full charge capacity.
  • _BST gives the charge/discharge state, the present rate, the remaining capacity and the present voltage.
  • _PSR on the adapter says whether it is plugged in.

Percentage is remaining / last-full, clamped to 100 and falling back to the design capacity when firmware reports last-full as zero. Results are cached for two seconds so that a panel repaint does not turn into EC traffic.

Almost all of this reaches the CPU through the embedded controller. Hal::EmbeddedController finds the EC's command/data port pair from the ECDT table when one exists, and otherwise from _CRS on the PNP0C09 device, then drives the polled read/write protocol (0x80/0x81 with OBF/IBF handshaking). It is wired into the AML interpreter as the EmbeddedControl region space, so any OperationRegion in the DSDT that targets the EC now works -- battery methods being the first user.

Supporting _BST also needed real package element storage in the AML interpreter. Firmware overwhelmingly builds its result package with Store(x, Index(BST0, n)), which requires Index() to name a writable element rather than yield a value. AML::PackageStore provides that: a persistent region for packages declared with Name(), and a transient ring rewound at each top-level evaluation for packages built inside a method, so polling _BST cannot exhaust it.

Diagnostics

SYS_POWERINFO (149) fills a PowerInfo struct: package temperature, TjMax, HWP state and performance range, the governor's current ceiling, CPUID base/turbo frequency, the measured average active frequency (APERF/MPERF over the 500 ms governor window), and the AP idle MWAIT hint. Returns -1 when the CPU exposes no power features (e.g. under QEMU).

SYS_BATTERYINFO (188) fills a BatteryInfo struct: percentage, charge state, AC presence, capacities, present rate, voltage and an estimated time remaining. It returns -1 on a machine with no battery device at all, which is how the desktop panel decides not to draw an indicator. Like SYS_POWERINFO it is ungated read-only telemetry: the panel indicator has to work in an unprivileged session, which holds no device capability to spend on it.

The power program (programs/src/power) prints both:

power             one-shot snapshot
power watch       1 Hz samples for 60 s (temp, avg freq, ceiling)
power watch 300   same, for 300 s

Boot log lines to look for (Kernel Log app):

[CpuPower] HWP enabled: perf range 4-54, EPP=0x80
[CpuPower] Thermal governor armed: TjMax=100C, passive=85C, hot=94C, ...
[CpuIdle]  AP deep idle: MWAIT hint 0x20
[EC]       Embedded Controller at cmd=66 data=62
[Battery]  ACPI battery at \_SB.PCI0.LPCB.EC0.BAT0 (AC adapter present)

The desktop panel shows a battery icon left of the Wi-Fi and network icons, using the icon pack's battery-level-N[-charging]-symbolic glyphs (one per 10% step, reloaded only when the step changes). Clicking it opens a popout with the percentage in numbers, the charge state, time remaining and raw capacity. The icon is absent entirely when SYS_BATTERYINFO fails, so a desktop machine shows nothing there.

Tuning

The governor constants live at the top of Hal/CpuPower.cpp (PassiveTempC, HotTempC, ResumeTempC, GovernorIntervalMs, step sizes) and were chosen to keep the package well below both TjMax (100 C on the i9-13900HX) and the Clevo EC's emergency thresholds. If the fans still spin up aggressively under sustained load, lower PassiveTempC first; if builds feel throttled while cool, raise the recovery rate instead of the trip points.