1
Fork 0
mirror of https://github.com/RGBCube/superfreq synced 2025-07-27 17:07:44 +00:00

Merge pull request #24 from NotAShelf/better-polling

daemon: improve adapting polling; more precise math
This commit is contained in:
raf 2025-05-17 06:30:55 +03:00 committed by GitHub
commit 506151ac79
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 449 additions and 53 deletions

View file

@ -10,7 +10,7 @@
<br/>
<a href="#what-is-superfreq">Synopsis</a><br/>
<a href="#features">Features</a> | <a href="#usage">Usage</a><br/>
<a href="#Contributing">Contributing</a>
<a href="#contributing">Contributing</a>
<br/>
</div>
@ -176,7 +176,8 @@ max_freq_mhz = 2500
# Global battery charging thresholds (applied to both profiles unless overridden)
# Start charging at 40%, stop at 80% - extends battery lifespan
# NOTE: Profile-specific thresholds (in [charger] or [battery] sections) take precedence over this global setting
# NOTE: Profile-specific thresholds (in [charger] or [battery] sections)
# take precedence over this global setting
battery_charge_thresholds = [40, 80]
# Daemon configuration
@ -210,11 +211,28 @@ create an issue.
### Adaptive Polling
The daemon mode uses adaptive polling to balance responsiveness with efficiency:
Superfreq includes a "sophisticated" (euphemism for complicated) adaptive
polling system to try and maximize power efficiency
- Increases polling frequency during system changes
- Decreases polling frequency during stable periods
- Reduces polling when on battery to save power
- **Battery Discharge Analysis** - Automatically adjusts polling frequency based
on the battery discharge rate, reducing system activity when battery is
draining quickly
- **System Activity Pattern Recognition** - Monitors CPU usage and temperature
patterns to identify system stability
- **Dynamic Interval Calculation** - Uses multiple factors to determine optimal
polling intervals - up to 3x longer on battery with minimal user impact
- **Idle Detection** - Significantly reduces polling frequency during extended
idle periods to minimize power consumption
- **Gradual Transition** - Smooth transitions between polling rates to avoid
performance spikes
- **Progressive Back-off** - Implements logarithmic back-off during idle periods
(1min -> 1.5x, 2min -> 2x, 4min -> 3x, 8min -> 4x, 16min -> 5x)
- **Battery Discharge Protection** - Includes safeguards against measurement
noise to prevent erratic polling behavior
When enabled, this intelligent polling system provides substantial power savings
over conventional fixed-interval approaches, especially during low-activity or
idle periods, while maintaining responsiveness when needed.
### Power Supply Filtering
@ -257,11 +275,25 @@ the codebase as they stand.
### Setup
You will need Cargo and Rust installed on your system. For Nix users, using
Direnv is encouraged.
You will need Cargo and Rust installed on your system. Rust 1.80 or later is required.
Non-Nix users may get the appropriate Cargo andn Rust versions from their
package manager.
A `.envrc` is provided, and it's usage is encouraged for Nix users.
Alternatively, you may use Nix for a reproducible developer environment
```bash
nix develop
```
Non-Nix users may get the appropriate Cargo and Rust versions from their package
manager.
### Formatting
Please make sure to run _at least_ `cargo fmt` inside the repository to make
sure all of your code is properly formatted. For Nix code, please use Alejandra.
Clippy lints are not _required_ as of now, but a good rule of thumb to run them
before committing to catch possible code smell early.
## License

View file

@ -4,12 +4,343 @@ use crate::core::SystemReport;
use crate::engine;
use crate::monitor;
use log::{LevelFilter, debug, error, info, warn};
use std::collections::VecDeque;
use std::fs::File;
use std::io::Write;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
/// Parameters for computing optimal polling interval
struct IntervalParams {
/// Base polling interval in seconds
base_interval: u64,
/// Minimum allowed polling interval in seconds
min_interval: u64,
/// Maximum allowed polling interval in seconds
max_interval: u64,
/// How rapidly CPU usage is changing
cpu_volatility: f32,
/// How rapidly temperature is changing
temp_volatility: f32,
/// Battery discharge rate in %/hour if available
battery_discharge_rate: Option<f32>,
/// Time since last detected user activity
last_user_activity: Duration,
/// Whether the system appears to be idle
is_system_idle: bool,
/// Whether the system is running on battery power
on_battery: bool,
}
/// Calculate the idle time multiplier based on system idle duration
///
/// Returns a multiplier between 1.0 and 5.0 (capped):
/// - For idle times < 2 minutes: Linear interpolation from 1.0 to 2.0
/// - For idle times >= 2 minutes: Logarithmic scaling (1.0 + log2(minutes))
fn idle_multiplier(idle_secs: u64) -> f32 {
if idle_secs == 0 {
return 1.0; // No idle time, no multiplier effect
}
let idle_factor = if idle_secs < 120 {
// Less than 2 minutes (0 to 119 seconds)
// Linear interpolation from 1.0 (at 0s) to 2.0 (at 120s)
1.0 + (idle_secs as f32) / 120.0
} else {
// 2 minutes (120 seconds) or more
let idle_time_minutes = idle_secs / 60;
// Logarithmic scaling: 1.0 + log2(minutes)
1.0 + (idle_time_minutes as f32).log2().max(0.5)
};
// Cap the multiplier to avoid excessive intervals
idle_factor.min(5.0) // max factor of 5x
}
/// Calculate optimal polling interval based on system conditions and history
fn compute_new(params: &IntervalParams) -> u64 {
// Start with base interval
let mut adjusted_interval = params.base_interval;
// If we're on battery, we want to be more aggressive about saving power
if params.on_battery {
// Apply a multiplier based on battery discharge rate
if let Some(discharge_rate) = params.battery_discharge_rate {
if discharge_rate > 20.0 {
// High discharge rate - increase polling interval significantly (3x)
adjusted_interval = adjusted_interval.saturating_mul(3);
} else if discharge_rate > 10.0 {
// Moderate discharge - double polling interval (2x)
adjusted_interval = adjusted_interval.saturating_mul(2);
} else {
// Low discharge rate - increase by 50% (multiply by 3/2)
adjusted_interval = adjusted_interval.saturating_mul(3).saturating_div(2);
}
} else {
// If we don't know discharge rate, use a conservative multiplier (2x)
adjusted_interval = adjusted_interval.saturating_mul(2);
}
}
// Adjust for system idleness
if params.is_system_idle {
let idle_time_seconds = params.last_user_activity.as_secs();
// Apply adjustment only if the system has been idle for a non-zero duration
if idle_time_seconds > 0 {
let idle_factor = idle_multiplier(idle_time_seconds);
debug!(
"System idle for {} seconds (approx. {} minutes), applying idle factor: {:.2}x",
idle_time_seconds,
(idle_time_seconds as f32 / 60.0).round(),
idle_factor
);
// Convert f32 multiplier to integer-safe math
// Multiply by a large number first, then divide to maintain precision
// Use 1000 as the scaling factor to preserve up to 3 decimal places
let scaling_factor = 1000;
let scaled_factor = (idle_factor * scaling_factor as f32) as u64;
adjusted_interval = adjusted_interval
.saturating_mul(scaled_factor)
.saturating_div(scaling_factor);
}
// If idle_time_seconds is 0, no factor is applied by this block
}
// Adjust for CPU/temperature volatility
if params.cpu_volatility > 10.0 || params.temp_volatility > 2.0 {
// For division by 2 (halving the interval), we can safely use integer division
adjusted_interval = (adjusted_interval / 2).max(1);
}
// Ensure interval stays within configured bounds
adjusted_interval.clamp(params.min_interval, params.max_interval)
}
/// Tracks historical system data for "advanced" adaptive polling
#[derive(Debug)]
struct SystemHistory {
/// Last several CPU usage measurements
cpu_usage_history: VecDeque<f32>,
/// Last several temperature readings
temperature_history: VecDeque<f32>,
/// Time of last detected user activity
last_user_activity: Instant,
/// Previous battery percentage (to calculate discharge rate)
last_battery_percentage: Option<f32>,
/// Timestamp of last battery reading
last_battery_timestamp: Option<Instant>,
/// Battery discharge rate (%/hour)
battery_discharge_rate: Option<f32>,
/// Time spent in each system state
state_durations: std::collections::HashMap<SystemState, Duration>,
/// Last time a state transition happened
last_state_change: Instant,
/// Current system state
current_state: SystemState,
}
impl Default for SystemHistory {
fn default() -> Self {
Self {
cpu_usage_history: VecDeque::new(),
temperature_history: VecDeque::new(),
last_user_activity: Instant::now(),
last_battery_percentage: None,
last_battery_timestamp: None,
battery_discharge_rate: None,
state_durations: std::collections::HashMap::new(),
last_state_change: Instant::now(),
current_state: SystemState::default(),
}
}
}
impl SystemHistory {
/// Update system history with new report data
fn update(&mut self, report: &SystemReport) {
// Update CPU usage history
if !report.cpu_cores.is_empty() {
let mut total_usage: f32 = 0.0;
let mut core_count: usize = 0;
for core in &report.cpu_cores {
if let Some(usage) = core.usage_percent {
total_usage += usage;
core_count += 1;
}
}
if core_count > 0 {
let avg_usage = total_usage / core_count as f32;
// Keep only the last 5 measurements
if self.cpu_usage_history.len() >= 5 {
self.cpu_usage_history.pop_front();
}
self.cpu_usage_history.push_back(avg_usage);
// Update last_user_activity if CPU usage indicates activity
// Consider significant CPU usage or sudden change as user activity
if avg_usage > 20.0
|| (self.cpu_usage_history.len() > 1
&& (avg_usage - self.cpu_usage_history[self.cpu_usage_history.len() - 2])
.abs()
> 15.0)
{
self.last_user_activity = Instant::now();
debug!("User activity detected based on CPU usage");
}
}
}
// Update temperature history
if let Some(temp) = report.cpu_global.average_temperature_celsius {
if self.temperature_history.len() >= 5 {
self.temperature_history.pop_front();
}
self.temperature_history.push_back(temp);
// Significant temperature increase can indicate user activity
if self.temperature_history.len() > 1 {
let temp_change =
temp - self.temperature_history[self.temperature_history.len() - 2];
if temp_change > 5.0 {
// 5°C rise in temperature
self.last_user_activity = Instant::now();
debug!("User activity detected based on temperature change");
}
}
}
// Update battery discharge rate
if let Some(battery) = report.batteries.first() {
// Reset when we are charging or have just connected AC
if battery.ac_connected {
// Reset discharge tracking but continue updating the rest of
// the history so we still detect activity/load changes on AC.
self.battery_discharge_rate = None;
self.last_battery_percentage = None;
self.last_battery_timestamp = None;
}
if let Some(current_percentage) = battery.capacity_percent {
let current_percent = f32::from(current_percentage);
if let (Some(last_percentage), Some(last_timestamp)) =
(self.last_battery_percentage, self.last_battery_timestamp)
{
let elapsed_hours = last_timestamp.elapsed().as_secs_f32() / 3600.0;
// Only calculate discharge rate if at least 30 seconds have passed
// and we're not on AC power
if elapsed_hours > 0.0083 && !battery.ac_connected {
// 0.0083 hours = 30 seconds
// Calculate discharge rate in percent per hour
let percent_change = last_percentage - current_percent;
if percent_change > 0.0 {
// Only if battery is discharging
let hourly_rate = percent_change / elapsed_hours;
// Clamp the discharge rate to a reasonable maximum value (100%/hour)
let clamped_rate = hourly_rate.min(100.0);
self.battery_discharge_rate = Some(clamped_rate);
}
}
}
self.last_battery_percentage = Some(current_percent);
self.last_battery_timestamp = Some(Instant::now());
}
}
// Update system state tracking
let new_state = determine_system_state(report, self);
if new_state != self.current_state {
// Record time spent in previous state
let time_in_state = self.last_state_change.elapsed();
*self
.state_durations
.entry(self.current_state.clone())
.or_insert(Duration::ZERO) += time_in_state;
// State changes (except to Idle) likely indicate user activity
if new_state != SystemState::Idle && new_state != SystemState::LowLoad {
self.last_user_activity = Instant::now();
debug!("User activity detected based on system state change to {new_state:?}");
}
// Update state
self.current_state = new_state;
self.last_state_change = Instant::now();
}
// Check for significant load changes
if report.system_load.load_avg_1min > 1.0 {
self.last_user_activity = Instant::now();
debug!("User activity detected based on system load");
}
}
/// Calculate CPU usage volatility (how much it's changing)
fn get_cpu_volatility(&self) -> f32 {
if self.cpu_usage_history.len() < 2 {
return 0.0;
}
let mut sum_of_changes = 0.0;
for i in 1..self.cpu_usage_history.len() {
sum_of_changes += (self.cpu_usage_history[i] - self.cpu_usage_history[i - 1]).abs();
}
sum_of_changes / (self.cpu_usage_history.len() - 1) as f32
}
/// Calculate temperature volatility
fn get_temperature_volatility(&self) -> f32 {
if self.temperature_history.len() < 2 {
return 0.0;
}
let mut sum_of_changes = 0.0;
for i in 1..self.temperature_history.len() {
sum_of_changes += (self.temperature_history[i] - self.temperature_history[i - 1]).abs();
}
sum_of_changes / (self.temperature_history.len() - 1) as f32
}
/// Determine if the system appears to be idle
fn is_system_idle(&self) -> bool {
if self.cpu_usage_history.is_empty() {
return false;
}
// System considered idle if the average CPU usage of last readings is below 10%
let recent_avg =
self.cpu_usage_history.iter().sum::<f32>() / self.cpu_usage_history.len() as f32;
recent_avg < 10.0 && self.get_cpu_volatility() < 5.0
}
/// Calculate optimal polling interval based on system conditions
fn calculate_optimal_interval(&self, config: &AppConfig, on_battery: bool) -> u64 {
let params = IntervalParams {
base_interval: config.daemon.poll_interval_sec,
min_interval: config.daemon.min_poll_interval_sec,
max_interval: config.daemon.max_poll_interval_sec,
cpu_volatility: self.get_cpu_volatility(),
temp_volatility: self.get_temperature_volatility(),
battery_discharge_rate: self.battery_discharge_rate,
last_user_activity: self.last_user_activity.elapsed(),
is_system_idle: self.is_system_idle(),
on_battery,
};
compute_new(&params)
}
}
/// Run the daemon
pub fn run_daemon(mut config: AppConfig, verbose: bool) -> Result<(), Box<dyn std::error::Error>> {
// Set effective log level based on config and verbose flag
@ -83,9 +414,12 @@ pub fn run_daemon(mut config: AppConfig, verbose: bool) -> Result<(), Box<dyn st
};
// Variables for adaptive polling
let mut current_poll_interval = config.daemon.poll_interval_sec;
let mut last_settings_change = Instant::now();
let mut last_system_state = SystemState::Unknown;
// Make sure that the poll interval is *never* zero to prevent a busy loop
let mut current_poll_interval = config.daemon.poll_interval_sec.max(1);
if config.daemon.poll_interval_sec == 0 {
warn!("Poll interval is set to zero in config, using 1s minimum to prevent a busy loop");
}
let mut system_history = SystemHistory::default();
// Main loop
while running.load(Ordering::SeqCst) {
@ -99,9 +433,14 @@ pub fn run_daemon(mut config: AppConfig, verbose: bool) -> Result<(), Box<dyn st
info!("Config file changed, updating configuration");
config = new_config;
// Reset polling interval after config change
current_poll_interval = config.daemon.poll_interval_sec;
// Record this as a settings change for adaptive polling purposes
last_settings_change = Instant::now();
current_poll_interval = config.daemon.poll_interval_sec.max(1);
if config.daemon.poll_interval_sec == 0 {
warn!(
"Poll interval is set to zero in updated config, using 1s minimum to prevent a busy loop"
);
}
// Mark this as a system event for adaptive polling
system_history.last_user_activity = Instant::now();
}
Err(e) => {
error!("Error loading new configuration: {e}");
@ -115,8 +454,11 @@ pub fn run_daemon(mut config: AppConfig, verbose: bool) -> Result<(), Box<dyn st
Ok(report) => {
debug!("Collected system report, applying settings...");
// Determine current system state
let current_state = determine_system_state(&report);
// Store the current state before updating history
let previous_state = system_history.current_state.clone();
// Update system history with new data
system_history.update(&report);
// Update the stats file if configured
if let Some(stats_path) = &config.daemon.stats_file_path {
@ -129,12 +471,12 @@ pub fn run_daemon(mut config: AppConfig, verbose: bool) -> Result<(), Box<dyn st
Ok(()) => {
debug!("Successfully applied system settings");
// If system state changed or settings were applied differently, record the time
if current_state != last_system_state {
last_settings_change = Instant::now();
last_system_state = current_state.clone();
info!("System state changed to: {current_state:?}");
// If system state changed, log the new state
if system_history.current_state != previous_state {
info!(
"System state changed to: {:?}",
system_history.current_state
);
}
}
Err(e) => {
@ -142,38 +484,51 @@ pub fn run_daemon(mut config: AppConfig, verbose: bool) -> Result<(), Box<dyn st
}
}
// Adjust poll interval if adaptive polling is enabled
// Check if we're on battery
let on_battery = !report.batteries.is_empty()
&& report.batteries.first().is_some_and(|b| !b.ac_connected);
// Calculate optimal polling interval if adaptive polling is enabled
if config.daemon.adaptive_interval {
let time_since_change = last_settings_change.elapsed().as_secs();
let optimal_interval =
system_history.calculate_optimal_interval(&config, on_battery);
// If we've been stable for a while, increase the interval (up to max)
if time_since_change > 60 {
current_poll_interval =
(current_poll_interval * 2).min(config.daemon.max_poll_interval_sec);
debug!("Adaptive polling: increasing interval to {current_poll_interval}s");
} else if time_since_change < 10 {
// If we've had recent changes, decrease the interval (down to min)
current_poll_interval =
(current_poll_interval / 2).max(config.daemon.min_poll_interval_sec);
debug!("Adaptive polling: decreasing interval to {current_poll_interval}s");
// Don't change the interval too dramatically at once
if optimal_interval > current_poll_interval {
current_poll_interval = (current_poll_interval + optimal_interval) / 2;
} else if optimal_interval < current_poll_interval {
current_poll_interval = current_poll_interval
- ((current_poll_interval - optimal_interval) / 2).max(1);
}
// Make sure that we respect the (user) configured min and max limits
current_poll_interval = current_poll_interval.clamp(
config.daemon.min_poll_interval_sec,
config.daemon.max_poll_interval_sec,
);
debug!("Adaptive polling: set interval to {current_poll_interval}s");
} else {
// If not adaptive, use the configured poll interval
current_poll_interval = config.daemon.poll_interval_sec;
}
// If adaptive polling is disabled, still apply battery-saving adjustment
if config.daemon.throttle_on_battery && on_battery {
let battery_multiplier = 2; // poll half as often on battery
// If on battery and throttling is enabled, lengthen the poll interval to save power
if config.daemon.throttle_on_battery
&& !report.batteries.is_empty()
&& report.batteries.first().is_some_and(|b| !b.ac_connected)
{
let battery_multiplier = 2; // Poll half as often on battery
current_poll_interval = (current_poll_interval * battery_multiplier)
.min(config.daemon.max_poll_interval_sec);
// We need to make sure `poll_interval_sec` is *at least* 1
// before multiplying.
let safe_interval = config.daemon.poll_interval_sec.max(1);
current_poll_interval = (safe_interval * battery_multiplier)
.min(config.daemon.max_poll_interval_sec);
debug!("On battery power, increasing poll interval to save energy");
debug!(
"On battery power, increased poll interval to {current_poll_interval}s"
);
} else {
// Use the configured poll interval
current_poll_interval = config.daemon.poll_interval_sec.max(1);
if config.daemon.poll_interval_sec == 0 {
debug!("Using minimum poll interval of 1s instead of configured 0s");
}
}
}
}
Err(e) => {
@ -227,18 +582,20 @@ fn write_stats_file(path: &str, report: &SystemReport) -> Result<(), std::io::Er
}
/// Simplified system state used for determining when to adjust polling interval
#[derive(Debug, PartialEq, Eq, Clone)]
#[derive(Debug, PartialEq, Eq, Clone, Hash, Default)]
enum SystemState {
#[default]
Unknown,
OnAC,
OnBattery,
HighLoad,
LowLoad,
HighTemp,
Idle,
}
/// Determine the current system state for adaptive polling
fn determine_system_state(report: &SystemReport) -> SystemState {
fn determine_system_state(report: &SystemReport, history: &SystemHistory) -> SystemState {
// Check power state first
if !report.batteries.is_empty() {
if let Some(battery) = report.batteries.first() {
@ -261,11 +618,18 @@ fn determine_system_state(report: &SystemReport) -> SystemState {
}
}
// Check load
// Check load first, as high load should take precedence over idle state
let avg_load = report.system_load.load_avg_1min;
if avg_load > 3.0 {
return SystemState::HighLoad;
}
// Check idle state only if we don't have high load
if history.is_system_idle() {
return SystemState::Idle;
}
// Check for low load
if avg_load < 0.5 {
return SystemState::LowLoad;
}