diff --git a/flake.nix b/flake.nix index 5579cb5..e5005d6 100644 --- a/flake.nix +++ b/flake.nix @@ -172,5 +172,12 @@ formatting = treefmt-eval.config.build.check self; } ); + + devShells = forAllSystems ( + { pkgs, ... }: + { + default = pkgs.mkShellNoCC { packages = [ pkgs.shellcheck ]; }; + } + ); }; } diff --git a/homeConfigurations/muede/cpucap.nix b/homeConfigurations/muede/cpucap.nix new file mode 100644 index 0000000..aaa6d08 --- /dev/null +++ b/homeConfigurations/muede/cpucap.nix @@ -0,0 +1,25 @@ +{ lib, pkgs, ... }: +let + # ./cpucap.sh goes into the store verbatim rather than through readFile or a + # generated wrapper script, because `cpucap --help` sed's the comment header + # back out of "$0" - only an unaltered copy of the file keeps that working. + cpucap = pkgs.runCommandLocal "cpucap" { nativeBuildInputs = [ pkgs.makeWrapper ]; } '' + install -Dm755 ${./cpucap.sh} $out/bin/cpucap + # Only line 1 changes, so the comment header --help prints stays put. + patchShebangs $out/bin/cpucap + wrapProgram $out/bin/cpucap \ + --prefix PATH : ${ + lib.makeBinPath [ + pkgs.gawk + pkgs.gnused + ] + } \ + --set CPUCAP_SELF $out/bin/cpucap + ''; +in +{ + # PATH is prefixed, not set: the script re-execs itself through + # /run/wrappers/bin/sudo for the writing subcommands, and sudo's own env + # handling plus the inherited system PATH have to survive that round trip. + home.packages = [ cpucap ]; +} diff --git a/homeConfigurations/muede/cpucap.sh b/homeConfigurations/muede/cpucap.sh new file mode 100755 index 0000000..0aa0ec8 --- /dev/null +++ b/homeConfigurations/muede/cpucap.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# cpucap - clamp scaling_max_freq on every cpufreq policy, per-policy hw-range safe. +# +# cpucap 1.2GHz cap every policy at 1.2 GHz (clamped into its own hw range) +# cpucap 900MHz +# cpucap 1200000 raw kHz also accepted +# cpucap --reset restore each policy to its cpuinfo_max_freq +# cpucap --show print current state (freq caps, package power, temp) +# cpucap --power 12 also set RAPL PL1+PL2 to 12 W (see note at bottom) +# cpucap --no-turbo pin to base clock (2400 P / 1800 E); --turbo to undo +# +# Anything that writes needs root; it re-runs itself under sudo on its own. + +set -euo pipefail + +POLICIES=(/sys/devices/system/cpu/cpufreq/policy*) +RAPL=/sys/class/powercap/intel-rapl:0 + +die() { printf 'cpucap: %s\n' "$*" >&2; exit 1; } + +# Re-exec under sudo rather than dying with a hint: this lives on the user's +# PATH via home.packages, so plain `sudo cpucap` would not resolve. CPUCAP_SELF +# is set by the nix wrapper to its own $out/bin path; "$0" is the fallback for +# running the file straight out of a checkout. Hardcoded sudo path because only +# /run/wrappers/bin's copy is setuid - the one in the store is not. +need_root() { + if [[ $EUID -ne 0 ]]; then + exec /run/wrappers/bin/sudo "${CPUCAP_SELF:-$0}" "$@" + fi +} + +# Accept 1.2GHz / 1200MHz / 1200000kHz / 1200000 -> kHz integer +to_khz() { + local v="${1,,}" n + case "$v" in + *ghz) n="${v%ghz}"; awk -v n="$n" 'BEGIN{printf "%d", n * 1000000}' ;; + *mhz) n="${v%mhz}"; awk -v n="$n" 'BEGIN{printf "%d", n * 1000}' ;; + *khz) n="${v%khz}"; awk -v n="$n" 'BEGIN{printf "%d", n}' ;; + *[!0-9]*) die "cannot parse frequency '$1' (use e.g. 1.2GHz, 900MHz, or raw kHz)" ;; + *) echo "$v" ;; + esac +} + +fmt_mhz() { awk -v k="$1" 'BEGIN{printf "%.0f MHz", k / 1000}'; } + +show() { + local p hw_min hw_max cur_min cur_max + printf '%-9s %-11s %-11s %s\n' POLICY CAP MIN 'HW RANGE' + for p in "${POLICIES[@]}"; do + hw_min=$(< "$p/cpuinfo_min_freq"); hw_max=$(< "$p/cpuinfo_max_freq") + cur_min=$(< "$p/scaling_min_freq"); cur_max=$(< "$p/scaling_max_freq") + printf '%-9s %-11s %-11s %s - %s\n' \ + "${p##*/}" "$(fmt_mhz "$cur_max")" "$(fmt_mhz "$cur_min")" \ + "$(fmt_mhz "$hw_min")" "$(fmt_mhz "$hw_max")" + done + + if [[ -r /sys/devices/system/cpu/intel_pstate/no_turbo ]]; then + printf '\nintel_pstate: status=%s no_turbo=%s max_perf_pct=%s\n' \ + "$(< /sys/devices/system/cpu/intel_pstate/status)" \ + "$(< /sys/devices/system/cpu/intel_pstate/no_turbo)" \ + "$(< /sys/devices/system/cpu/intel_pstate/max_perf_pct)" + fi + + if [[ -r $RAPL/constraint_0_power_limit_uw ]]; then + printf 'RAPL package: PL1=%sW PL2=%sW\n' \ + "$(( $(< "$RAPL/constraint_0_power_limit_uw") / 1000000 ))" \ + "$(( $(< "$RAPL/constraint_1_power_limit_uw") / 1000000 ))" + fi + + local t + for t in /sys/class/hwmon/hwmon*; do + [[ $(< "$t/name") == coretemp ]] || continue + [[ -r $t/temp1_input ]] && printf 'package temp: %s C\n' "$(( $(< "$t/temp1_input") / 1000 ))" + done +} + +set_cap() { + local target=$1 p hw_min hw_max want cur_min got + printf '%-9s %-11s %s\n' POLICY REQUESTED APPLIED + for p in "${POLICIES[@]}"; do + hw_min=$(< "$p/cpuinfo_min_freq") + hw_max=$(< "$p/cpuinfo_max_freq") + + # Clamp into this policy's own hw range. P-cores and E-cores differ. + want=$target + (( want < hw_min )) && want=$hw_min + (( want > hw_max )) && want=$hw_max + + # scaling_min must not exceed the new max, or the write is rejected. + cur_min=$(< "$p/scaling_min_freq") + (( cur_min > want )) && echo "$want" > "$p/scaling_min_freq" + + echo "$want" > "$p/scaling_max_freq" + + # intel_pstate rounds to the nearest achievable P-state, so read back. + got=$(< "$p/scaling_max_freq") + printf '%-9s %-11s %s%s\n' "${p##*/}" "$(fmt_mhz "$want")" "$(fmt_mhz "$got")" \ + "$( (( got != want )) && echo ' (rounded)' )" + done +} + +reset_cap() { + local p + for p in "${POLICIES[@]}"; do + echo "$(< "$p/cpuinfo_min_freq")" > "$p/scaling_min_freq" + echo "$(< "$p/cpuinfo_max_freq")" > "$p/scaling_max_freq" + done + echo "restored all policies to their hardware maximum" +} + +set_power() { + local watts=$1 uw max + if [[ -z $watts || $watts == *[!0-9]* ]]; then + die "--power takes whole watts, got '$watts'" + fi + uw=$((watts * 1000000)) + [[ -w $RAPL/constraint_0_power_limit_uw ]] || die "RAPL not writable at $RAPL" + max=$(< "$RAPL/constraint_0_max_power_uw") + (( max > 0 && uw > max )) && die "requested ${watts}W exceeds RAPL max $((max / 1000000))W" + echo "$uw" > "$RAPL/constraint_0_power_limit_uw" # PL1, sustained + echo "$uw" > "$RAPL/constraint_1_power_limit_uw" # PL2, burst + printf 'RAPL package: PL1=PL2=%sW\n' \ + "$(( $(< "$RAPL/constraint_0_power_limit_uw") / 1000000 ))" +} + +set_turbo() { + local off=$1 f=/sys/devices/system/cpu/intel_pstate/no_turbo + [[ -w $f ]] || die "no_turbo not writable (intel_pstate not in active mode?)" + echo "$off" > "$f" + # Turbo-off pins each policy to base_frequency; an existing lower cap still wins. + if [[ $off == 1 ]]; then + echo "turbo disabled - ceiling is now base clock, or your cap if it is lower" + else + echo "turbo enabled" + fi +} + +[[ ${#POLICIES[@]} -gt 0 && -d ${POLICIES[0]} ]] || die "no cpufreq policies found" + +case "${1-}" in + ''|--show|-s) show ;; + --reset|-r) need_root "$@"; reset_cap; echo; show ;; + --power|-p) need_root "$@"; [[ -n ${2-} ]] || die "--power needs a wattage" + set_power "$2" ;; + --no-turbo) need_root "$@"; set_turbo 1; echo; show ;; + --turbo) need_root "$@"; set_turbo 0; echo; show ;; + -h|--help) sed -n '2,12p' "$0" | sed 's/^# \?//' ;; + -*) die "unknown option '$1' (see --help)" ;; + *) need_root "$@"; set_cap "$(to_khz "$1")" + if [[ ${2-} == --power || ${2-} == -p ]]; then set_power "$3"; fi + echo; show ;; +esac diff --git a/homeConfigurations/muede/default.nix b/homeConfigurations/muede/default.nix index 93843ac..300d39e 100644 --- a/homeConfigurations/muede/default.nix +++ b/homeConfigurations/muede/default.nix @@ -3,6 +3,7 @@ imports = [ # keep-sorted start ./claude.nix + ./cpucap.nix ./editorconfig.nix ./element.nix ./fonts.nix