Hold fan speed unless the change is worth making

The setpoint was a continuous function of instantaneous temperature and
was pushed to the BMC every pass, so ~1% of fan per degree of CPU noise
meant the fans never settled. Measured on iz-pve0: stock iDRAC held one
speed for 5 minutes through 57-59c jitter while this script made 7
changes in 16 minutes off the same signal.

Adds a 5% deadband around the applied speed, with full-speed requests
never held back and a periodic re-assert in case the BMC forgets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Zeb Hering
2026-08-28 20:07:33 -07:00
parent 64e6e03dba
commit 56ae7542a2
3 changed files with 69 additions and 3 deletions

View File

@@ -24,6 +24,19 @@ speed = max( interpolate(hottest of CPU/GPU, 45 -> 75),
Sources are never compared as raw temperatures — an 85c CPU and a 45c disk are both Sources are never compared as raw temperatures — an 85c CPU and a 45c disk are both
"fine", and a single `max()` over the numbers would be meaningless. "fine", and a single `max()` over the numbers would be meaningless.
The result is then **held unless it moves by `FAN_SPEED_DEADBAND` (5%)**. This matters
more than it sounds. The CPU curve is roughly 1% of fan per degree and idle CPU noise is
±2c, so without a deadband the setpoint changes on almost every pass and the fans never
settle — measurably worse than stock. Measured on iz-pve0: stock iDRAC held one speed for
5 minutes straight through 57→59c jitter, while this script made 7 distinct changes in 16
minutes off the same signal. A request for full speed is never held back, and the held
value is re-pushed every `FAN_REASSERT_INTERVAL` in case the BMC forgets it.
Note that iDRAC's own profile regulates to a target *RPM* (closed loop, rock steady),
while manual control sets a *PWM* percentage (open loop, so measured RPM still wanders a
percent or two at a fixed setpoint). That residual is inherent to manual control, not
something the deadband can remove.
### Why disks are measured against their own limits ### Why disks are measured against their own limits
Every drive reports its own maximum operating temperature (SATA: `Min/Max Temperature Every drive reports its own maximum operating temperature (SATA: `Min/Max Temperature
@@ -118,6 +131,8 @@ systemctl enable --now fan_speed.service
|---|---|---| |---|---|---|
| `CHECK_INTERVAL` | `10` | Seconds between passes | | `CHECK_INTERVAL` | `10` | Seconds between passes |
| `LOW_FAN_SPEED` / `HIGH_FAN_SPEED` | `18` / `50` | Percent | | `LOW_FAN_SPEED` / `HIGH_FAN_SPEED` | `18` / `50` | Percent |
| `FAN_SPEED_DEADBAND` | `5` | Hold the current speed until the request moves this far |
| `FAN_REASSERT_INTERVAL` | `300` | Re-push the held speed this often regardless |
| `LOW_TEMPERATURE_THRESHOLD` | `45` | CPU/GPU ramp start | | `LOW_TEMPERATURE_THRESHOLD` | `45` | CPU/GPU ramp start |
| `CPU_TEMPERATURE_THRESHOLD` | `90` | Above this, Dell's profile takes over | | `CPU_TEMPERATURE_THRESHOLD` | `90` | Above this, Dell's profile takes over |
| `GPU_TEMPERATURE_THRESHOLD` | `75` | | | `GPU_TEMPERATURE_THRESHOLD` | `75` | |

View File

@@ -82,6 +82,17 @@ selftest() {
echo "0,50,0,38,20" >> "$TREND_FILE" echo "0,50,0,38,20" >> "$TREND_FILE"
check "partial window stays silent" 0 "$(rise_over_window 4)" check "partial window stays silent" 0 "$(rise_over_window 4)"
echo "fan speed deadband (hold unless the change is worth making):"
local now; now=$(date +%s)
HIGH_FAN_SPEED=50
should_apply_fan_speed 21 20 "$now" && check "1% drift holds" hold apply || check "1% drift holds" hold hold
should_apply_fan_speed 23 20 "$now" && check "3% drift holds" hold apply || check "3% drift holds" hold hold
should_apply_fan_speed 25 20 "$now" && check "5% rise applies" apply apply || check "5% rise applies" apply hold
should_apply_fan_speed 15 20 "$now" && check "5% fall applies" apply apply || check "5% fall applies" apply hold
should_apply_fan_speed 50 20 "$now" && check "full speed always applies" apply apply || check "full speed always applies" apply hold
should_apply_fan_speed 21 "" "$now" && check "first pass always applies" apply apply || check "first pass always applies" apply hold
should_apply_fan_speed 21 20 $((now - 400)) && check "stale setpoint is reasserted" apply apply || check "stale setpoint is reasserted" apply hold
echo "alert rate limiting:" echo "alert rate limiting:"
local out local out
out=$( { raise_alert testkey "first" "body"; raise_alert testkey "second" "body"; } | grep -c '^MAIL\[' ) out=$( { raise_alert testkey "first" "body"; raise_alert testkey "second" "body"; } | grep -c '^MAIL\[' )
@@ -180,6 +191,8 @@ fi
cache_disk_limits cache_disk_limits
echo "Monitoring $DISK_COUNT_EXPECTED disk(s)." echo "Monitoring $DISK_COUNT_EXPECTED disk(s)."
LAST_SMART_CHECK=0 LAST_SMART_CHECK=0
APPLIED_FAN_SPEED=""
APPLIED_FAN_SPEED_AT=0
####################################### #######################################
####################################### #######################################
@@ -232,11 +245,21 @@ one_pass() {
FAN_SPEED_DRIVER="disk" FAN_SPEED_DRIVER="disk"
fi fi
# Hold the current speed unless the change is worth making - see the deadband
# note in monitor.sh. Chasing every degree is what made this louder than stock.
local held=""
if should_apply_fan_speed "$DECIMAL_CURRENT_FAN_SPEED" "$APPLIED_FAN_SPEED" "$APPLIED_FAN_SPEED_AT"; then
apply_user_fan_control "$DECIMAL_CURRENT_FAN_SPEED" apply_user_fan_control "$DECIMAL_CURRENT_FAN_SPEED"
APPLIED_FAN_SPEED=$DECIMAL_CURRENT_FAN_SPEED
APPLIED_FAN_SPEED_AT=$(date +%s)
else
held=" held, want $DECIMAL_CURRENT_FAN_SPEED"
fi
COMMENT="CPU1:$CPU1_TEMPERATURE | CPU2:$CPU2_TEMPERATURE | GPU:$GPU_TEMPERATURE | Inlet:$INLET_TEMPERATURE | Exhaust:$EXHAUST_TEMPERATURE | Disk:$HOTTEST_DISK_TEMPERATURE/$HOTTEST_DISK_LIMIT($(basename $HOTTEST_DISK_DEVICE)) | Fan Speed:$DECIMAL_CURRENT_FAN_SPEED($FAN_SPEED_DRIVER)" COMMENT="CPU1:$CPU1_TEMPERATURE | CPU2:$CPU2_TEMPERATURE | GPU:$GPU_TEMPERATURE | Inlet:$INLET_TEMPERATURE | Exhaust:$EXHAUST_TEMPERATURE | Disk:$HOTTEST_DISK_TEMPERATURE/$HOTTEST_DISK_LIMIT($(basename $HOTTEST_DISK_DEVICE)) | Fan Speed:$APPLIED_FAN_SPEED($FAN_SPEED_DRIVER$held)"
record_sample "$HIGHEST_CPU_TEMPERATURE" "$GPU_TEMPERATURE" "$HOTTEST_DISK_TEMPERATURE" "$DECIMAL_CURRENT_FAN_SPEED" # Record what the fans are actually doing, not what was merely requested.
record_sample "$HIGHEST_CPU_TEMPERATURE" "$GPU_TEMPERATURE" "$HOTTEST_DISK_TEMPERATURE" "$APPLIED_FAN_SPEED"
check_alarms check_alarms
} }

View File

@@ -38,6 +38,34 @@ DISK_ALARM_OFFSET=${DISK_ALARM_OFFSET:-5}
HDD_LIMIT_CAP=${HDD_LIMIT_CAP:-60} HDD_LIMIT_CAP=${HDD_LIMIT_CAP:-60}
SSD_LIMIT_CAP=${SSD_LIMIT_CAP:-70} SSD_LIMIT_CAP=${SSD_LIMIT_CAP:-70}
# The fan speed is only pushed to the BMC when it moves by at least this much.
#
# Without it the setpoint is a continuous function of instantaneous temperature:
# the CPU curve is ~1% of fan per degree, idle CPU noise is +/-2c, so the fan
# gets a new speed every single pass and never settles. Measured on iz-pve0,
# stock iDRAC held one speed for 5 minutes straight through the same jitter
# while this script made 7 changes in 16 minutes.
FAN_SPEED_DEADBAND=${FAN_SPEED_DEADBAND:-5}
# Re-push the held speed this often anyway, in case the BMC forgets it. Not
# measured - the MD1200 EMM does forget, iDRAC is believed not to, and one
# ipmitool call every 5 minutes is cheaper than finding out the hard way.
FAN_REASSERT_INTERVAL=${FAN_REASSERT_INTERVAL:-300}
# should_apply_fan_speed <wanted> <currently applied> <epoch it was applied>
# True when the change is worth making. A request for full speed is never held
# back, and the first pass always applies.
should_apply_fan_speed() {
local wanted=$1 applied=$2 applied_at=$3 delta now
[ -n "$applied" ] || return 0
[ "$wanted" -ge "$HIGH_FAN_SPEED" ] && return 0
now=$(date +%s)
[ $((now - applied_at)) -ge "$FAN_REASSERT_INTERVAL" ] && return 0
delta=$((wanted - applied))
[ "$delta" -lt 0 ] && delta=$((-delta))
[ "$delta" -ge "$FAN_SPEED_DEADBAND" ]
}
ALERT_EMAIL=${ALERT_EMAIL:-Servers@ntfy1.izebra.xyz} ALERT_EMAIL=${ALERT_EMAIL:-Servers@ntfy1.izebra.xyz}
ALERT_COOLDOWN=${ALERT_COOLDOWN:-3600} ALERT_COOLDOWN=${ALERT_COOLDOWN:-3600}
STATE_DIR=${STATE_DIR:-/root/fan_speed/state} STATE_DIR=${STATE_DIR:-/root/fan_speed/state}