in category:Battlestation program:Systemd process:Architect ~ read.

How a Broken Boot Service Led to a Full Laptop Power Management System

My laptop’s boot service had been quietly failing for months, and I hadn’t noticed because the system still worked… it just wasn’t doing anything smart with power. When I finally dug in to optimize battery life, what started as a quick “fix the startup logs” turned into building a full power management system from scratch, a deep dive into NVIDIA runtime D3cold, and a lesson about why you can’t trust your own diagnostic tools.

What Happened

The machine is tuflex, an ASUS TUF laptop with an AMD Ryzen 9 7940HS (8 cores, 16 threads) and an NVIDIA RTX 4050 Max-Q dGPU. It runs Manjaro with a 6.6 kernel and Python 3.14. The startup logs had been showing a failure from cpupower-gui.service every boot:

Jul 07 13:41:14 tuflex cpupower-gui[1223]:         "apply", action="store_true", help="apply cpupower configuration",
Jul 07 13:41:14 tuflex cpupower-gui[1223]:         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Jul 07 13:41:14 tuflex cpupower-gui[1223]:     )
Jul 07 13:41:14 tuflex cpupower-gui[1223]:     ^
Jul 07 13:41:14 tuflex cpupower-gui[1223]:   File "/usr/lib/python3.14/argparse.py", line 1540, in add_argument
Jul 07 13:41:14 tuflex cpupower-gui[1223]:     raise ValueError(f'action {action_name!r} is not valid for positional arguments')
Jul 07 13:41:14 tuflex cpupower-gui[1223]: ValueError: action 'store_true' is not valid for positional arguments

The installed package was cpupower-gui 1.0.0-5 from the AUR, which was the latest upstream release (tagged November 2021). The AUR package had been flagged out-of-date since January 2026.

The root cause was a Python 3.14 change: argparse no longer permits store_true on a positional argument. The offending line in /usr/bin/cpupower-gui at line 269:

"apply", action="store_true", help="apply cpupower configuration",

Python 3.14’s argparse raises ValueError: action 'store_true' is not valid for positional arguments because apply is a positional argument (no -- prefix), and store_true only makes sense for optional flags. The fix upstream exists in the git HEAD (the cpupower-gui-git AUR package has it), but the maintainer hasn’t tagged a new release since 2021.

So the boot service was dead. But the real goal wasn’t just to fix the service… it was to optimize battery life to approach something like a MacBook. That meant building something better than what cpupower-gui was doing anyway.

Root Cause

The cpupower-gui breakage was straightforward: a Python 3.14 incompatibility in a stale AUR package. But the deeper issue was that the existing setup wasn’t doing anything useful for power management even when it worked:

  1. No automatic switching. The cpupower-gui.service applied a single profile at boot. If you unplugged the AC adapter, nothing changed. You’d stay in whatever profile was set at boot.
  2. No dGPU power management. The NVIDIA RTX 4050 was drawing ~2.6W even on battery with no external displays attached. On a 66.88Wh battery, that’s roughly 16Wh wasted over a 6-hour session… about 24% of the battery capacity just keeping an idle GPU awake.
  3. No EPP utilization. The CPU was running with amd-pstate-epp in active mode, but the Energy Performance Preference was set to performance regardless of power state. The amd-pstate-epp driver exposes EPP values like power, balance_power, balance_performance, and performance, but only under the powersave governor.
  4. power-profiles-daemon was fighting us. The system had power-profiles-daemon running and set to performance, which conflicts with manual governor and EPP changes.

The Fix

CPU profile switching

I wrote a custom script at /usr/local/bin/cpu-profile-switch that handles the full power state transition. It reads /sys/class/power_supply/ACAD/online to detect AC state and applies a complete profile:

Setting AC Battery
Governor performance powersave
EPP performance power
Freq range 400-6220 MHz (uncapped) 400-2800 MHz
Cores 16 online 8 online (SMT parked)

The SMT parking is the interesting part. The 7940HS is 8 cores / 16 threads. Cores 8-15 are the SMT (hyperthreading) siblings of cores 0-7. Offlining them is a legitimate power saving technique… the physical cores can enter deeper C-states with only one thread active. The original cpupower-gui Powersave profile was actually onto something by parking cores, but it capped them at 400 MHz instead of actually offlining them, which just starved the scheduler for no real power savings.

A udev rule watches for ACAD state changes:

SUBSYSTEM=="power_supply", ACTION=="change", KERNEL=="ACAD", RUN+="/usr/bin/systemctl start cpu-profile-apply.service"

This triggers a systemd oneshot service that runs the script in auto mode. A separate boot service applies the correct profile at startup based on whatever AC state the system finds itself in.

dGPU power management

This is where the detective work happened. The NVIDIA RTX 4050 was sitting at runtime_status: active drawing 2.6W even on battery with no external displays. The fix was setting NVreg_DynamicPowerManagement=0x02 (fine-grained mode) in /etc/modprobe.d/nvidia-dpm.conf:

options nvidia NVreg_DynamicPowerManagement=0x02

With this parameter, the NVIDIA driver automatically puts the dGPU into D3cold (fully powered off, ~0W) when no displays are attached and no apps hold a CUDA or GL context. On battery with no external monitors, it sleeps on its own. On AC with HDMI plugged in, it wakes up.

But there was a catch. After setting the parameter, I’d unplug the HDMI and the dGPU would stay active. The culprit was Xorg… it was loading the nvidia driver via an OutputClass config and holding the device handle open with 4MiB of memory. Once Xorg holds the device, the driver can’t enter D3cold.

The breakthrough came when I restarted Cinnamon (the desktop environment) after unplugging… Xorg released the nvidia device handle, and the dGPU finally suspended. The NVreg_DynamicPowerManagement=0x02 was working all along; it just couldn’t suspend while Xorg had the device open.

Then I hit the observer effect. I was checking the dGPU state with nvidia-smi --query-gpu=power.draw to confirm it was at ~0W, but nvidia-smi opens the dGPU device to query it, which wakes it up. So the act of checking power draw brought it out of D3cold. The first command would read suspended from sysfs (correct), then nvidia-smi would wake it, and by the time the second command ran, it was already active from the first nvidia-smi call.

The fix for monitoring: use sysfs only, never nvidia-smi:

cat /sys/bus/pci/devices/0000:01:00.0/power/runtime_status

If that says suspended, the dGPU is in D3cold drawing ~0W. Don’t run nvidia-smi to confirm… it’ll wake the dGPU and give you a misleading reading.

Additional power savings

Beyond the CPU and dGPU, I integrated several more subsystems into the switching script:

Subsystem AC Battery
iGPU DPM auto low
iGPU runtime PM on auto
NVMe on auto (runtime PM)
PCIe ASPM default powersupersave
Backlight 60% 40%

PCIe ASPM (Active State Power Management) was the most impactful of these… switching from default to powersupersave on battery puts all PCIe links into the lowest power state. The amdgpu driver power management was already configured, but the script forces power_dpm_force_performance_level=low on battery to keep the iGPU in its lowest power state.

USB autosuspend is enabled for non-input devices on battery. Input devices (the ErgoDox EZ keyboard and Logitech receiver) stay awake… you don’t want your keyboard to suspend. WiFi power save and audio power save were already configured correctly by Manjaro defaults.

The frequency cap reset bug

One bug worth mentioning: the first version of the switching script used "-" to mean “skip writing this value” for frequency limits. So when switching from battery (2800 MHz cap) back to AC, it would skip resetting the frequency limits, leaving the 2800 MHz cap in place. The fix was to explicitly write the hardware min/max values from cpuinfo_min_freq and cpuinfo_max_freq when switching to performance mode, rather than treating - as “don’t touch.”

Disabling power-profiles-daemon

power-profiles-daemon was running and set to performance, which fights manual governor and EPP changes. It also has its own notion of power profiles that overlaps with what we’re doing. I disabled and masked it:

systemctl disable --now power-profiles-daemon.service
systemctl mask power-profiles-daemon.service

What We Learned

  • Check your boot logs. The cpupower-gui service had been failing every boot for months and I hadn’t noticed because the system still worked… it just wasn’t doing anything smart with power. A quick systemctl status would have caught it immediately.
  • nvidia-smi is not a passive observer. Querying the dGPU with nvidia-smi wakes it from D3cold. If you’re debugging dGPU power state, use sysfs (/sys/bus/pci/devices/0000:01:00.0/power/runtime_status) exclusively. The observer effect is real.
  • Xorg holds the dGPU open. Even in Hybrid mode with supergfxctl, Xorg loads the nvidia driver via OutputClass config and keeps the device handle open. The dGPU can’t enter D3cold while Xorg holds it. Restarting the display server releases it, but that’s not practical for automatic switching.
  • SMT parking is legitimate. Offlining SMT siblings (cores 8-15 on a 16-thread CPU) lets the physical cores enter deeper C-states. The original cpupower-gui profile was capping them at 400 MHz instead of offlining them, which just starved the scheduler. Actually offlining them with echo 0 > /sys/devices/system/cpu/cpu8/online is the right approach.
  • "-" is ambiguous in scripts. When I used "-" to mean “skip this value,” it worked for the initial application but broke on state transitions. Explicit is always better than implicit… write the actual hardware limits from cpuinfo_min_freq and cpuinfo_max_freq.
  • You won’t match a MacBook. Apple Silicon is a fundamentally different power architecture with hardware-software integration Linux can’t replicate. But you can get meaningfully closer by stacking CPU governor + EPP + freq cap + SMT parking + dGPU D3cold + PCIe ASPM + NVMe runtime PM. Each one is a small win; together they add up.

Tickets