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:
_STAbit 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_BIFon older firmware) gives the power unit (mWh vs mAh), the design capacity, and the last full charge capacity._BSTgives the charge/discharge state, the present rate, the remaining capacity and the present voltage._PSRon 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.