auto_suspend: binary GPU idle counter replaces EMA for GPU

This commit is contained in:
2026-06-09 22:13:45 +02:00
parent 4825ad1495
commit df5a98853b
2 changed files with 186 additions and 141 deletions
@@ -72,13 +72,11 @@ class TestFreshState(unittest.TestCase):
from auto_suspend import fresh_state
state = fresh_state()
self.assertEqual(state["idle_counter"], 0)
self.assertEqual(state["gpu_idle_counter"], 0)
self.assertEqual(state["sample_count"], 0)
self.assertIsNone(state["cpu_idle_n"])
self.assertIsNone(state["cpu_idle_n1"])
self.assertIsNone(state["gpu_util_n"])
self.assertIsNone(state["gpu_util_n1"])
self.assertIsNone(state["cpu_avg"])
self.assertIsNone(state["gpu_avg"])
class TestLoadState(unittest.TestCase):
@@ -347,77 +345,116 @@ class TestUpdateAverages(unittest.TestCase):
from auto_suspend import fresh_state, update_averages
logger = MagicMock()
state = fresh_state()
new_state = update_averages(state, cpu_n=95.0, gpu_n=2.0, alpha=0.3, logger=logger)
new_state = update_averages(state, cpu_n=95.0, gpu_n=2.0, gpu_threshold=5.0, alpha=0.3, logger=logger)
self.assertEqual(new_state["sample_count"], 1)
self.assertAlmostEqual(new_state["cpu_avg"], 95.0)
self.assertAlmostEqual(new_state["gpu_avg"], 2.0)
self.assertEqual(new_state["gpu_idle_counter"], 1) # idle → +1
self.assertEqual(new_state["cpu_idle_n1"], None)
self.assertEqual(new_state["gpu_util_n1"], None)
def test_ema_update(self):
def test_ema_update_cpu_only(self):
"""CPU EMA updates, GPU idle counter increments."""
from auto_suspend import fresh_state, update_averages
logger = MagicMock()
state = fresh_state()
state = update_averages(state, cpu_n=90.0, gpu_n=10.0, alpha=0.3, logger=logger)
state = update_averages(state, cpu_n=95.0, gpu_n=5.0, alpha=0.3, logger=logger)
state = update_averages(state, cpu_n=90.0, gpu_n=1.0, gpu_threshold=5.0, alpha=0.3, logger=logger)
state = update_averages(state, cpu_n=95.0, gpu_n=2.0, gpu_threshold=5.0, alpha=0.3, logger=logger)
# EMA = 0.3 * 95 + 0.7 * 90 = 91.5
self.assertAlmostEqual(state["cpu_avg"], 91.5)
# EMA = 0.3 * 5 + 0.7 * 10 = 8.5
self.assertAlmostEqual(state["gpu_avg"], 8.5)
# GPU idle both cycles → counter = 2
self.assertEqual(state["gpu_idle_counter"], 2)
self.assertEqual(state["sample_count"], 2)
def test_gpu_busy_resets_counter(self):
"""GPU above threshold resets idle counter to 0."""
from auto_suspend import fresh_state, update_averages
logger = MagicMock()
state = fresh_state()
# Cycle 1: idle GPU → counter = 1
state = update_averages(state, cpu_n=90.0, gpu_n=1.0, gpu_threshold=5.0, alpha=0.3, logger=logger)
self.assertEqual(state["gpu_idle_counter"], 1)
# Cycle 2: busy GPU → counter resets to 0
state = update_averages(state, cpu_n=90.0, gpu_n=50.0, gpu_threshold=5.0, alpha=0.3, logger=logger)
self.assertEqual(state["gpu_idle_counter"], 0)
# Cycle 3: idle GPU → counter = 1
state = update_averages(state, cpu_n=90.0, gpu_n=1.0, gpu_threshold=5.0, alpha=0.3, logger=logger)
self.assertEqual(state["gpu_idle_counter"], 1)
def test_gpu_at_threshold_is_idle(self):
"""GPU exactly at threshold counts as idle (strictly greater = busy)."""
from auto_suspend import fresh_state, update_averages
logger = MagicMock()
state = fresh_state()
# GPU at exactly 5.0 with threshold 5.0 → idle (not > threshold)
state = update_averages(state, cpu_n=90.0, gpu_n=5.0, gpu_threshold=5.0, alpha=0.3, logger=logger)
self.assertEqual(state["gpu_idle_counter"], 1)
# GPU at 5.1 → busy
state = update_averages(state, cpu_n=90.0, gpu_n=5.1, gpu_threshold=5.0, alpha=0.3, logger=logger)
self.assertEqual(state["gpu_idle_counter"], 0)
def test_gpu_none_is_idle(self):
"""None GPU (no GPU) counts as idle."""
from auto_suspend import fresh_state, update_averages
logger = MagicMock()
state = fresh_state()
state = update_averages(state, cpu_n=90.0, gpu_n=None, gpu_threshold=5.0, alpha=0.3, logger=logger)
self.assertEqual(state["gpu_idle_counter"], 1)
def test_shift_n_to_n1(self):
from auto_suspend import fresh_state, update_averages
logger = MagicMock()
state = fresh_state()
state = update_averages(state, cpu_n=80.0, gpu_n=50.0, alpha=0.3, logger=logger)
state = update_averages(state, cpu_n=85.0, gpu_n=30.0, alpha=0.3, logger=logger)
state = update_averages(state, cpu_n=80.0, gpu_n=50.0, gpu_threshold=5.0, alpha=0.3, logger=logger)
state = update_averages(state, cpu_n=85.0, gpu_n=30.0, gpu_threshold=5.0, alpha=0.3, logger=logger)
self.assertEqual(state["cpu_idle_n"], 85.0)
self.assertEqual(state["cpu_idle_n1"], 80.0)
self.assertEqual(state["gpu_util_n"], 30.0)
self.assertEqual(state["gpu_util_n1"], 50.0)
def test_none_measurements_preserve_avg(self):
def test_none_cpu_preserves_avg(self):
from auto_suspend import fresh_state, update_averages
logger = MagicMock()
state = fresh_state()
state = update_averages(state, cpu_n=90.0, gpu_n=5.0, alpha=0.3, logger=logger)
state = update_averages(state, cpu_n=None, gpu_n=None, alpha=0.3, logger=logger)
state = update_averages(state, cpu_n=90.0, gpu_n=5.0, gpu_threshold=5.0, alpha=0.3, logger=logger)
state = update_averages(state, cpu_n=None, gpu_n=None, gpu_threshold=5.0, alpha=0.3, logger=logger)
self.assertAlmostEqual(state["cpu_avg"], 90.0)
self.assertAlmostEqual(state["gpu_avg"], 5.0)
self.assertEqual(state["sample_count"], 2)
def test_ema_decay_idle_gpu(self):
"""
Simulate GPU going from 100% to 0% idle and verify EMA decays.
def test_gpu_idle_counter_survives_cpu_none(self):
"""GPU counter increments even when CPU measurement is None."""
from auto_suspend import fresh_state, update_averages
logger = MagicMock()
state = fresh_state()
state = update_averages(state, cpu_n=90.0, gpu_n=1.0, gpu_threshold=5.0, alpha=0.3, logger=logger)
state = update_averages(state, cpu_n=None, gpu_n=0.5, gpu_threshold=5.0, alpha=0.3, logger=logger)
self.assertEqual(state["gpu_idle_counter"], 2)
self.assertAlmostEqual(state["cpu_avg"], 90.0)
This validates that the EMA calculation correctly handles the scenario
where a busy GPU becomes idle — the average should converge toward
idle values after enough consecutive idle samples.
def test_consecutive_idle_then_spike(self):
"""
Simulate the real data pattern: idle GPU with periodic 100% spikes.
Each spike resets the counter. This is the key scenario that the
old EMA approach handled poorly (spikes lingered in the average).
"""
from auto_suspend import fresh_state, update_averages
logger = MagicMock()
state = fresh_state()
# Phase 1: GPU was busy (simulate 10 consecutive 100% samples)
for _ in range(10):
state = update_averages(state, cpu_n=95.0, gpu_n=100.0, alpha=0.3, logger=logger)
# 3 idle cycles → counter = 3
for _ in range(3):
state = update_averages(state, cpu_n=99.0, gpu_n=1.0, gpu_threshold=5.0, alpha=0.3, logger=logger)
self.assertEqual(state["gpu_idle_counter"], 3)
self.assertAlmostEqual(state["gpu_avg"], 100.0, places=1)
self.assertEqual(state["sample_count"], 10)
# 1 spike → counter resets to 0
state = update_averages(state, cpu_n=99.0, gpu_n=100.0, gpu_threshold=5.0, alpha=0.3, logger=logger)
self.assertEqual(state["gpu_idle_counter"], 0)
# Phase 2: GPU goes idle (0%) for 20 consecutive samples
for _ in range(20):
state = update_averages(state, cpu_n=99.0, gpu_n=0.0, alpha=0.3, logger=logger)
# 3 idle cycles after spike → counter = 3
for _ in range(3):
state = update_averages(state, cpu_n=99.0, gpu_n=1.0, gpu_threshold=5.0, alpha=0.3, logger=logger)
self.assertEqual(state["gpu_idle_counter"], 3)
# After 20 idle samples from 100%, EMA should be very close to 0
# EMA = 0.7^20 * 100 ≈ 0.08%
self.assertAlmostEqual(state["gpu_avg"], 0.08, places=1)
self.assertEqual(state["sample_count"], 30)
def test_ema_convergence_multiple_values(self):
def test_ema_cpu_convergence(self):
"""
Verify EMA converges to the steady-state value regardless of start.
Verify CPU EMA converges to the steady-state value.
With alpha=0.3 and 100 identical samples, the average should equal
the sample value (since EMA_new = 0.3*x + 0.7*x = x).
@@ -426,37 +463,11 @@ class TestUpdateAverages(unittest.TestCase):
logger = MagicMock()
state = fresh_state()
gpu_idle = 2.0
cpu_idle = 97.0
for _ in range(50):
state = update_averages(state, cpu_n=95.0, gpu_n=gpu_idle, alpha=0.3, logger=logger)
state = update_averages(state, cpu_n=cpu_idle, gpu_n=1.0, gpu_threshold=5.0, alpha=0.3, logger=logger)
self.assertAlmostEqual(state["gpu_avg"], gpu_idle, places=2)
self.assertAlmostEqual(state["cpu_avg"], 95.0, places=2)
def test_ema_with_missing_gpu_sample(self):
"""
Verify None GPU sample preserves the average.
When measure_gpu_amd returns None (e.g., tool unavailable), the
average should NOT be updated — it should keep the last value.
"""
from auto_suspend import fresh_state, update_averages
logger = MagicMock()
state = fresh_state()
# Seed with a GPU average
state = update_averages(state, cpu_n=95.0, gpu_n=10.0, alpha=0.3, logger=logger)
self.assertAlmostEqual(state["gpu_avg"], 10.0)
# Now GPU measurement fails (None) — average should be preserved
state = update_averages(state, cpu_n=95.0, gpu_n=None, alpha=0.3, logger=logger)
self.assertAlmostEqual(state["gpu_avg"], 10.0)
self.assertEqual(state["sample_count"], 2)
# After GPU returns, EMA should continue from the preserved value
state = update_averages(state, cpu_n=95.0, gpu_n=0.0, alpha=0.3, logger=logger)
# EMA = 0.3 * 0 + 0.7 * 10 = 7.0
self.assertAlmostEqual(state["gpu_avg"], 7.0)
self.assertAlmostEqual(state["cpu_avg"], cpu_idle, places=2)
class TestEvaluate(unittest.TestCase):
@@ -466,106 +477,133 @@ class TestEvaluate(unittest.TestCase):
logger = MagicMock()
state = fresh_state()
for _ in range(3):
state = update_averages(state, cpu_n=95.0, gpu_n=1.0, alpha=0.3, logger=logger)
self.assertTrue(evaluate(state, has_users=False, idle_threshold=90.0, gpu_threshold=5.0, min_samples=3, logger=logger))
state = update_averages(state, cpu_n=95.0, gpu_n=1.0, gpu_threshold=5.0, alpha=0.3, logger=logger)
self.assertTrue(evaluate(state, has_users=False, gpu_idle_counter=3, idle_threshold=90.0, gpu_threshold=5.0, min_samples=3, logger=logger))
def test_warmup_skips(self):
from auto_suspend import fresh_state, evaluate
logger = MagicMock()
state = fresh_state()
self.assertFalse(evaluate(state, has_users=False, idle_threshold=90.0, gpu_threshold=5.0, min_samples=3, logger=logger))
self.assertFalse(evaluate(state, has_users=False, gpu_idle_counter=0, idle_threshold=90.0, gpu_threshold=5.0, min_samples=3, logger=logger))
def test_users_logged_in(self):
from auto_suspend import fresh_state, evaluate, update_averages
logger = MagicMock()
state = fresh_state()
for _ in range(3):
state = update_averages(state, cpu_n=95.0, gpu_n=1.0, alpha=0.3, logger=logger)
self.assertFalse(evaluate(state, has_users=True, idle_threshold=90.0, gpu_threshold=5.0, min_samples=3, logger=logger))
state = update_averages(state, cpu_n=95.0, gpu_n=1.0, gpu_threshold=5.0, alpha=0.3, logger=logger)
self.assertFalse(evaluate(state, has_users=True, gpu_idle_counter=3, idle_threshold=90.0, gpu_threshold=5.0, min_samples=3, logger=logger))
def test_cpu_not_idle(self):
from auto_suspend import fresh_state, evaluate, update_averages
logger = MagicMock()
state = fresh_state()
for _ in range(3):
state = update_averages(state, cpu_n=40.0, gpu_n=1.0, alpha=0.3, logger=logger)
self.assertFalse(evaluate(state, has_users=False, idle_threshold=90.0, gpu_threshold=5.0, min_samples=3, logger=logger))
state = update_averages(state, cpu_n=40.0, gpu_n=1.0, gpu_threshold=5.0, alpha=0.3, logger=logger)
self.assertFalse(evaluate(state, has_users=False, gpu_idle_counter=3, idle_threshold=90.0, gpu_threshold=5.0, min_samples=3, logger=logger))
def test_gpu_busy(self):
from auto_suspend import fresh_state, evaluate, update_averages
def test_gpu_busy_resets_evaluation(self):
"""GPU busy this cycle (counter=0) blocks evaluation even if CPU is idle."""
from auto_suspend import fresh_state, update_averages, evaluate
logger = MagicMock()
state = fresh_state()
for _ in range(3):
state = update_averages(state, cpu_n=95.0, gpu_n=50.0, alpha=0.3, logger=logger)
self.assertFalse(evaluate(state, has_users=False, idle_threshold=90.0, gpu_threshold=5.0, min_samples=3, logger=logger))
state = update_averages(state, cpu_n=95.0, gpu_n=1.0, gpu_threshold=5.0, alpha=0.3, logger=logger)
# Force GPU busy by resetting counter
self.assertFalse(evaluate(state, has_users=False, gpu_idle_counter=0, idle_threshold=90.0, gpu_threshold=5.0, min_samples=3, logger=logger))
def test_gpu_none_is_idle(self):
from auto_suspend import fresh_state, evaluate, update_averages
logger = MagicMock()
state = fresh_state()
for _ in range(3):
state = update_averages(state, cpu_n=95.0, gpu_n=None, alpha=0.3, logger=logger)
self.assertTrue(evaluate(state, has_users=False, idle_threshold=90.0, gpu_threshold=5.0, min_samples=3, logger=logger))
state = update_averages(state, cpu_n=95.0, gpu_n=None, gpu_threshold=5.0, alpha=0.3, logger=logger)
self.assertTrue(evaluate(state, has_users=False, gpu_idle_counter=3, idle_threshold=90.0, gpu_threshold=5.0, min_samples=3, logger=logger))
def test_at_threshold_cpu_below(self):
from auto_suspend import fresh_state, update_averages, evaluate
logger = MagicMock()
state = fresh_state()
for _ in range(3):
state = update_averages(state, cpu_n=90.0, gpu_n=1.0, alpha=0.3, logger=logger)
self.assertFalse(evaluate(state, has_users=False, idle_threshold=90.0, gpu_threshold=5.0, min_samples=3, logger=logger))
state = update_averages(state, cpu_n=90.0, gpu_n=1.0, gpu_threshold=5.0, alpha=0.3, logger=logger)
self.assertFalse(evaluate(state, has_users=False, gpu_idle_counter=3, idle_threshold=90.0, gpu_threshold=5.0, min_samples=3, logger=logger))
def test_at_threshold_gpu_above(self):
def test_gpu_at_threshold_is_idle(self):
"""GPU at exactly threshold should NOT block evaluation."""
from auto_suspend import fresh_state, update_averages, evaluate
logger = MagicMock()
state = fresh_state()
for _ in range(3):
state = update_averages(state, cpu_n=95.0, gpu_n=5.0, alpha=0.3, logger=logger)
self.assertFalse(evaluate(state, has_users=False, idle_threshold=90.0, gpu_threshold=5.0, min_samples=3, logger=logger))
state = update_averages(state, cpu_n=95.0, gpu_n=5.0, gpu_threshold=5.0, alpha=0.3, logger=logger)
self.assertTrue(evaluate(state, has_users=False, gpu_idle_counter=3, idle_threshold=90.0, gpu_threshold=5.0, min_samples=3, logger=logger))
def test_gpu_above_threshold_blocks(self):
"""GPU above threshold should block evaluation."""
from auto_suspend import fresh_state, update_averages, evaluate
logger = MagicMock()
state = fresh_state()
state = update_averages(state, cpu_n=95.0, gpu_n=50.0, gpu_threshold=5.0, alpha=0.3, logger=logger)
self.assertFalse(evaluate(state, has_users=False, gpu_idle_counter=0, idle_threshold=90.0, gpu_threshold=5.0, min_samples=1, logger=logger))
def test_custom_thresholds(self):
from auto_suspend import fresh_state, update_averages, evaluate
logger = MagicMock()
state = fresh_state()
for _ in range(3):
state = update_averages(state, cpu_n=95.0, gpu_n=3.0, alpha=0.3, logger=logger)
self.assertTrue(evaluate(state, has_users=False, idle_threshold=90.0, gpu_threshold=5.0, min_samples=3, logger=logger))
self.assertFalse(evaluate(state, has_users=False, idle_threshold=95.0, gpu_threshold=5.0, min_samples=3, logger=logger))
state = update_averages(state, cpu_n=95.0, gpu_n=3.0, gpu_threshold=5.0, alpha=0.3, logger=logger)
self.assertTrue(evaluate(state, has_users=False, gpu_idle_counter=3, idle_threshold=90.0, gpu_threshold=5.0, min_samples=3, logger=logger))
self.assertFalse(evaluate(state, has_users=False, gpu_idle_counter=3, idle_threshold=95.0, gpu_threshold=5.0, min_samples=3, logger=logger))
def test_gpu_busy_during_warmup(self):
"""GPU busy during warmup: counter=0 but still in warmup."""
from auto_suspend import fresh_state, evaluate, update_averages
logger = MagicMock()
state = fresh_state()
# Only 1 sample, counter=0 because GPU was busy
state = update_averages(state, cpu_n=95.0, gpu_n=100.0, gpu_threshold=5.0, alpha=0.3, logger=logger)
self.assertFalse(evaluate(state, has_users=False, gpu_idle_counter=0, idle_threshold=90.0, gpu_threshold=5.0, min_samples=3, logger=logger))
class TestGetWhyNotIdle(unittest.TestCase):
def test_users(self):
from auto_suspend import get_why_not_idle
state = {"sample_count": 5, "cpu_avg": 95.0, "gpu_avg": 1.0}
state = {"sample_count": 5, "cpu_avg": 95.0, "gpu_idle_counter": 3}
reason = get_why_not_idle(state, has_users=True, logger=MagicMock())
self.assertIn("users logged in", reason)
def test_cpu_low(self):
from auto_suspend import get_why_not_idle
state = {"sample_count": 5, "cpu_avg": 40.0, "gpu_avg": 1.0}
state = {"sample_count": 5, "cpu_avg": 40.0, "gpu_idle_counter": 3}
reason = get_why_not_idle(state, has_users=False, logger=MagicMock())
self.assertIn("CPU idle avg", reason)
def test_gpu_high(self):
def test_gpu_busy(self):
from auto_suspend import get_why_not_idle
state = {"sample_count": 5, "cpu_avg": 95.0, "gpu_avg": 50.0}
reason = get_why_not_idle(state, has_users=False, logger=MagicMock())
self.assertIn("GPU util avg", reason)
state = {"sample_count": 5, "cpu_avg": 95.0, "gpu_idle_counter": 0}
reason = get_why_not_idle(state, has_users=False, gpu_idle_counter=0, logger=MagicMock())
self.assertIn("GPU busy", reason)
def test_gpu_idle_no_reason(self):
"""GPU idle counter > 0 should not add a reason."""
from auto_suspend import get_why_not_idle
state = {"sample_count": 5, "cpu_avg": 95.0, "gpu_idle_counter": 3}
reason = get_why_not_idle(state, has_users=False, gpu_idle_counter=3, logger=MagicMock())
self.assertNotIn("GPU busy", reason)
def test_warming_up(self):
from auto_suspend import get_why_not_idle
state = {"sample_count": 2, "cpu_avg": None, "gpu_avg": None}
reason = get_why_not_idle(state, has_users=False, logger=MagicMock())
state = {"sample_count": 2, "cpu_avg": None, "gpu_idle_counter": 0}
reason = get_why_not_idle(state, has_users=False, gpu_idle_counter=0, logger=MagicMock())
self.assertIn("warming up", reason)
def test_multiple_reasons(self):
from auto_suspend import get_why_not_idle
state = {"sample_count": 5, "cpu_avg": 40.0, "gpu_avg": 50.0}
reason = get_why_not_idle(state, has_users=True, logger=MagicMock())
state = {"sample_count": 5, "cpu_avg": 40.0, "gpu_idle_counter": 0}
reason = get_why_not_idle(state, has_users=True, gpu_idle_counter=0, logger=MagicMock())
self.assertIn("users logged in", reason)
self.assertIn("CPU idle avg", reason)
self.assertIn("GPU util avg", reason)
self.assertIn("GPU busy", reason)
class TestSetupLogging(unittest.TestCase):