use super::adapter::GpuDeviceType;
use crate::compat::{format, Instant, MiniToString, String};
use alloc::collections::VecDeque;
use core::time::Duration;
const MAX_DURATION_SECS_F32: f32 = 1.0e18;
fn secs_from_fps(fps: f32) -> f32 {
if !fps.is_finite() || fps <= 0.0 {
return 1.0 / 30.0;
}
let secs = 1.0 / fps;
safe_duration_secs(secs)
}
fn safe_duration_secs(secs: f32) -> f32 {
const MIN_POSITIVE_SECS: f32 = 1e-9;
if !secs.is_finite() || secs < 0.0 {
0.0
} else {
secs.clamp(MIN_POSITIVE_SECS, MAX_DURATION_SECS_F32)
}
}
const ADJUST_INTERVAL_FRAMES: usize = 60;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FrameQuality {
Good,
Bad,
Neutral,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PerformanceMonitorStrategy {
GpuTimestamp,
FrameTime,
CpuUtilization,
}
impl PerformanceMonitorStrategy {
pub fn for_device_type(device_type: GpuDeviceType) -> Self {
match device_type {
GpuDeviceType::DiscreteGpu => Self::GpuTimestamp,
GpuDeviceType::IntegratedGpu => Self::FrameTime,
_ => Self::CpuUtilization,
}
}
}
#[derive(Debug, Clone)]
pub struct AdaptivePerformanceThresholds {
pub target_fps: f32,
pub degrade_threshold: f32,
pub upgrade_threshold: f32,
pub degrade_frame_count: usize,
pub upgrade_frame_count: usize,
pub memory_pressure_threshold: f32,
pub cpu_utilization_threshold: f32,
}
impl AdaptivePerformanceThresholds {
pub fn discrete() -> Self {
Self {
target_fps: 60.0,
degrade_threshold: 1.5, upgrade_threshold: 0.7, degrade_frame_count: 5, upgrade_frame_count: 10, memory_pressure_threshold: 0.9,
cpu_utilization_threshold: 0.8,
}
}
pub fn integrated() -> Self {
Self {
target_fps: 60.0,
degrade_threshold: 1.3, upgrade_threshold: 0.75,
degrade_frame_count: 3, upgrade_frame_count: 15, memory_pressure_threshold: 0.75, cpu_utilization_threshold: 0.7,
}
}
pub fn cpu() -> Self {
Self {
target_fps: 30.0, degrade_threshold: 1.2, upgrade_threshold: 0.8,
degrade_frame_count: 2, upgrade_frame_count: 20, memory_pressure_threshold: 0.6,
cpu_utilization_threshold: 0.5, }
}
pub fn for_device_type(device_type: GpuDeviceType) -> Self {
match device_type {
GpuDeviceType::DiscreteGpu => Self::discrete(),
GpuDeviceType::IntegratedGpu => Self::integrated(),
_ => Self::cpu(),
}
}
pub fn target_frame_duration(&self) -> Duration {
Duration::from_secs_f32(secs_from_fps(self.target_fps))
}
pub fn degrade_duration(&self) -> Duration {
Duration::from_secs_f32(safe_duration_secs(
self.target_frame_duration().as_secs_f32() * self.degrade_threshold,
))
}
pub fn upgrade_duration(&self) -> Duration {
Duration::from_secs_f32(safe_duration_secs(
self.target_frame_duration().as_secs_f32() * self.upgrade_threshold,
))
}
pub fn adjust_based_on_performance(&mut self, avg_frame_time: Duration, stability: f32) {
const DEGRADE_FLOOR: f32 = 1.1;
const UPGRADE_CEILING: f32 = 0.9;
const TARGET_FPS_FLOOR: f32 = 10.0;
if stability < 0.5 {
self.degrade_threshold = (self.degrade_threshold * 0.9).max(DEGRADE_FLOOR);
self.upgrade_threshold = (self.upgrade_threshold * 1.1).min(UPGRADE_CEILING);
}
let avg_fps = 1.0 / avg_frame_time.as_secs_f32();
if avg_fps < self.target_fps * 0.5 {
self.target_fps = (self.target_fps * 0.9).max(TARGET_FPS_FLOOR);
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct PerformanceSample {
pub frame_index: u64,
pub frame_duration: Duration,
pub gpu_time: Option<Duration>,
pub cpu_time: Duration,
pub memory_utilization: Option<f32>,
pub cpu_utilization: Option<f32>,
pub timestamp: Instant,
}
pub struct AdaptivePerformanceMonitor {
strategy: PerformanceMonitorStrategy,
thresholds: AdaptivePerformanceThresholds,
samples: VecDeque<PerformanceSample>,
max_samples: usize,
current_frame: u64,
frame_start: Instant,
consecutive_bad_frames: usize,
consecutive_good_frames: usize,
last_quality_change: Instant,
frames_since_adjust: usize,
}
impl AdaptivePerformanceMonitor {
pub fn for_device_type(device_type: GpuDeviceType) -> Self {
let strategy = PerformanceMonitorStrategy::for_device_type(device_type);
let thresholds = AdaptivePerformanceThresholds::for_device_type(device_type);
Self::new(strategy, thresholds)
}
pub fn new(
strategy: PerformanceMonitorStrategy,
thresholds: AdaptivePerformanceThresholds,
) -> Self {
Self {
strategy,
thresholds,
samples: VecDeque::with_capacity(120),
max_samples: 120,
current_frame: 0,
frame_start: Instant::now(),
consecutive_bad_frames: 0,
consecutive_good_frames: 0,
last_quality_change: Instant::now() - Duration::from_secs(60),
frames_since_adjust: 0,
}
}
pub fn begin_frame(&mut self) {
self.frame_start = Instant::now();
self.current_frame += 1;
}
pub fn end_frame(&mut self) -> PerformanceSample {
let frame_duration = self.frame_start.elapsed();
let sample = PerformanceSample {
frame_index: self.current_frame,
frame_duration,
gpu_time: self.measure_gpu_time(),
cpu_time: frame_duration, memory_utilization: self.measure_memory_utilization(),
cpu_utilization: self.measure_cpu_utilization(),
timestamp: Instant::now(),
};
self.record_sample(sample);
sample
}
fn measure_gpu_time(&self) -> Option<Duration> {
if let Ok(val) = std::env::var("RUST_WIDGETS_GPU_TIME_MS") {
match val.trim().parse::<f64>() {
Ok(ms) => {
let secs = ms / 1000.0;
if secs.is_finite() && secs >= 0.0 && secs <= u64::MAX as f64 {
return Some(Duration::from_secs_f64(secs));
}
log::warn!(
"[performance] RUST_WIDGETS_GPU_TIME_MS value '{val}' is out of range for a \
Duration; ignoring it"
);
}
Err(_) => log::warn!(
"[performance] RUST_WIDGETS_GPU_TIME_MS value '{val}' is not a valid f64"
),
}
}
log::debug!(
"[performance] measure_gpu_time: no GPU query backend available (strategy={:?})",
self.strategy
);
None
}
fn measure_memory_utilization(&self) -> Option<f32> {
if let Ok(val) = std::env::var("RUST_WIDGETS_MEM_UTIL") {
if let Ok(v) = val.trim().parse::<f32>() {
return Some(v.clamp(0.0, 1.0));
}
log::warn!("[performance] RUST_WIDGETS_MEM_UTIL value '{val}' is not a valid f32");
}
if let Some(ratio) = crate::platform::platform_facts().process_memory_utilization() {
log::debug!("[performance] memory utilization from platform backend: {ratio:.3}");
return Some(ratio.clamp(0.0, 1.0));
}
log::debug!("[performance] measure_memory_utilization: no backend available");
None
}
fn measure_cpu_utilization(&self) -> Option<f32> {
if let Ok(val) = std::env::var("RUST_WIDGETS_CPU_UTIL") {
if let Ok(v) = val.trim().parse::<f32>() {
return Some(v.clamp(0.0, 1.0));
}
log::warn!("[performance] RUST_WIDGETS_CPU_UTIL value '{val}' is not a valid f32");
}
if let Some(ratio) = crate::platform::platform_facts().process_cpu_utilization() {
log::debug!("[performance] CPU utilization from platform backend: {ratio:.3}");
return Some(ratio.clamp(0.0, 1.0));
}
log::debug!("[performance] measure_cpu_utilization: no backend available");
None
}
fn record_sample(&mut self, sample: PerformanceSample) {
if self.samples.len() >= self.max_samples {
self.samples.pop_front();
}
self.samples.push_back(sample);
match self.classify_frame(&sample) {
FrameQuality::Bad => {
self.consecutive_bad_frames += 1;
self.consecutive_good_frames = 0;
}
FrameQuality::Good => {
self.consecutive_good_frames += 1;
self.consecutive_bad_frames = 0;
}
FrameQuality::Neutral => {
self.consecutive_bad_frames = 0;
self.consecutive_good_frames = 0;
}
}
}
fn classify_frame(&self, sample: &PerformanceSample) -> FrameQuality {
if sample.frame_duration > self.thresholds.degrade_duration() {
FrameQuality::Bad
} else if sample.frame_duration <= self.thresholds.upgrade_duration() {
FrameQuality::Good
} else {
FrameQuality::Neutral
}
}
#[allow(dead_code)]
fn is_frame_bad(&self, sample: &PerformanceSample) -> bool {
sample.frame_duration > self.thresholds.degrade_duration()
}
pub fn should_degrade(&self) -> bool {
if self.consecutive_bad_frames >= self.thresholds.degrade_frame_count {
if self.last_quality_change.elapsed() > Duration::from_secs(2) {
return true;
}
}
false
}
pub fn should_upgrade(&self) -> bool {
if self.consecutive_good_frames >= self.thresholds.upgrade_frame_count {
if self.last_quality_change.elapsed() > Duration::from_secs(5) {
return true;
}
}
false
}
pub fn notify_quality_changed(&mut self) {
self.last_quality_change = Instant::now();
self.consecutive_bad_frames = 0;
self.consecutive_good_frames = 0;
}
pub fn average_frame_time(&self) -> Duration {
if self.samples.is_empty() {
return Duration::from_secs(0);
}
let total: Duration = self.samples.iter().map(|s| s.frame_duration).sum();
total / self.samples.len() as u32
}
pub fn current_fps(&self) -> f32 {
let avg = self.average_frame_time();
if avg.as_secs_f32() > 0.0 {
1.0 / avg.as_secs_f32()
} else {
0.0
}
}
pub fn stability(&self) -> f32 {
if self.samples.len() < 10 {
return 1.0;
}
let avg = self.average_frame_time();
let variance: f32 = self
.samples
.iter()
.map(|s| {
let diff = s.frame_duration.as_secs_f32() - avg.as_secs_f32();
diff * diff
})
.sum::<f32>()
/ self.samples.len() as f32;
let std_dev = variance.sqrt();
let stability = 1.0 - (std_dev / avg.as_secs_f32()).min(1.0);
stability.max(0.0)
}
pub fn is_memory_pressure(&self) -> bool {
if let Some(sample) = self.samples.back() {
sample
.memory_utilization
.is_some_and(|util| util > self.thresholds.memory_pressure_threshold)
} else {
false
}
}
pub fn is_cpu_overloaded(&self) -> bool {
if let Some(sample) = self.samples.back() {
sample
.cpu_utilization
.is_some_and(|util| util > self.thresholds.cpu_utilization_threshold)
} else {
false
}
}
pub fn stats(&self) -> PerformanceStats {
PerformanceStats {
current_fps: self.current_fps(),
average_frame_time: self.average_frame_time(),
stability: self.stability(),
consecutive_bad_frames: self.consecutive_bad_frames,
consecutive_good_frames: self.consecutive_good_frames,
is_memory_pressure: self.is_memory_pressure(),
is_cpu_overloaded: self.is_cpu_overloaded(),
}
}
pub fn auto_adjust_thresholds(&mut self) {
if self.samples.len() < 60 {
return; }
self.frames_since_adjust = self.frames_since_adjust.saturating_add(1);
if self.frames_since_adjust < ADJUST_INTERVAL_FRAMES {
return;
}
self.frames_since_adjust = 0;
let avg = self.average_frame_time();
let stability = self.stability();
self.thresholds.adjust_based_on_performance(avg, stability);
}
pub fn thresholds(&self) -> &AdaptivePerformanceThresholds {
&self.thresholds
}
pub fn set_thresholds(&mut self, thresholds: AdaptivePerformanceThresholds) {
self.thresholds = thresholds;
}
}
#[derive(Debug, Clone, Copy)]
pub struct PerformanceStats {
pub current_fps: f32,
pub average_frame_time: Duration,
pub stability: f32,
pub consecutive_bad_frames: usize,
pub consecutive_good_frames: usize,
pub is_memory_pressure: bool,
pub is_cpu_overloaded: bool,
}
pub struct PerformanceTrapDetector {
low_fps_threshold: f32,
sustained_low_fps_frames: usize,
low_fps_counter: usize,
last_warning: Instant,
}
impl PerformanceTrapDetector {
pub fn new(low_fps_threshold: f32, sustained_frames: usize) -> Self {
Self {
low_fps_threshold,
sustained_low_fps_frames: sustained_frames,
low_fps_counter: 0,
last_warning: Instant::now() - Duration::from_secs(300),
}
}
pub fn check(&mut self, fps: f32) -> Option<PerformanceTrap> {
if fps < self.low_fps_threshold {
self.low_fps_counter += 1;
if self.low_fps_counter >= self.sustained_low_fps_frames
&& self.last_warning.elapsed() > Duration::from_secs(30)
{
self.last_warning = Instant::now();
return Some(PerformanceTrap::LowFrameRate {
current_fps: fps,
threshold: self.low_fps_threshold,
});
}
} else {
self.low_fps_counter = 0;
}
None
}
}
#[derive(Debug, Clone)]
pub enum PerformanceTrap {
LowFrameRate {
current_fps: f32,
threshold: f32,
},
MemoryPressure {
utilization: f32,
},
CpuOverload {
utilization: f32,
},
BrowserForcedIntegratedGpu,
}
impl PerformanceTrap {
pub fn message(&self) -> String {
match self {
Self::LowFrameRate { current_fps, threshold } => {
format!(
"Performance warning: Frame rate is {current_fps:.1} FPS (below {threshold:.1} FPS). Consider lowering graphics quality or closing other applications."
)
}
Self::MemoryPressure { utilization } => {
format!(
"Memory warning: GPU memory is {:.0}% full. Consider reducing texture quality or closing other applications.",
utilization * 100.0
)
}
Self::CpuOverload { utilization } => {
format!(
"CPU warning: CPU usage is {:.0}%. Software rendering is CPU-intensive. Consider using a GPU if available.",
utilization * 100.0
)
}
Self::BrowserForcedIntegratedGpu => {
"Browser is forcing integrated GPU. For best performance, try running outside browser".to_string()
}
}
}
pub fn suggests_cpu_mode(&self) -> bool {
matches!(self, Self::BrowserForcedIntegratedGpu)
}
pub fn suggests_restart(&self) -> bool {
matches!(self, Self::BrowserForcedIntegratedGpu | Self::CpuOverload { .. })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_thresholds_for_device_type() {
let discrete = AdaptivePerformanceThresholds::for_device_type(GpuDeviceType::DiscreteGpu);
let integrated =
AdaptivePerformanceThresholds::for_device_type(GpuDeviceType::IntegratedGpu);
let cpu = AdaptivePerformanceThresholds::for_device_type(GpuDeviceType::Cpu);
assert!(discrete.degrade_threshold > integrated.degrade_threshold);
assert!(integrated.degrade_threshold > cpu.degrade_threshold);
assert_eq!(discrete.target_fps, 60.0);
assert_eq!(cpu.target_fps, 30.0);
}
#[test]
fn test_performance_monitor() {
let mut monitor = AdaptivePerformanceMonitor::for_device_type(GpuDeviceType::DiscreteGpu);
monitor.begin_frame();
std::thread::sleep(Duration::from_millis(10));
let sample = monitor.end_frame();
assert_eq!(sample.frame_index, 1);
assert!(sample.frame_duration >= Duration::from_millis(10));
}
#[test]
fn test_trap_detector() {
let mut detector = PerformanceTrapDetector::new(30.0, 5);
for _ in 0..4 {
assert!(detector.check(20.0).is_none());
}
let trap = detector.check(20.0);
assert!(trap.is_some());
if let Some(PerformanceTrap::LowFrameRate { current_fps, .. }) = trap {
assert_eq!(current_fps, 20.0);
}
}
#[test]
fn test_trap_messages() {
let trap = PerformanceTrap::LowFrameRate { current_fps: 15.0, threshold: 30.0 };
let msg = trap.message();
assert!(msg.contains("15.0"));
assert!(msg.contains("30.0"));
}
fn sample_of(frame_duration: Duration) -> PerformanceSample {
PerformanceSample {
frame_index: 0,
frame_duration,
gpu_time: None,
cpu_time: frame_duration,
memory_utilization: None,
cpu_utilization: None,
timestamp: Instant::now(),
}
}
fn discrete_monitor() -> AdaptivePerformanceMonitor {
AdaptivePerformanceMonitor::for_device_type(GpuDeviceType::DiscreteGpu)
}
#[test]
fn record_sample_classifies_good_neutral_and_bad() {
let monitor = discrete_monitor();
let t = monitor.thresholds();
let upgrade = t.upgrade_duration();
let degrade = t.degrade_duration();
assert!(upgrade < degrade, "the two thresholds must be ordered");
assert_eq!(monitor.classify_frame(&sample_of(upgrade)), FrameQuality::Good);
assert_eq!(monitor.classify_frame(&sample_of(upgrade / 2)), FrameQuality::Good);
assert_eq!(monitor.classify_frame(&sample_of(degrade)), FrameQuality::Neutral);
let mid = upgrade + (degrade - upgrade) / 2;
assert_eq!(monitor.classify_frame(&sample_of(mid)), FrameQuality::Neutral);
assert_eq!(
monitor.classify_frame(&sample_of(degrade + Duration::from_millis(1))),
FrameQuality::Bad
);
}
#[test]
fn neutral_frame_resets_both_streaks() {
let mut monitor = discrete_monitor();
let t = monitor.thresholds();
let good = t.upgrade_duration();
let degrade = t.degrade_duration();
let neutral = t.upgrade_duration() + (degrade - t.upgrade_duration()) / 2;
monitor.record_sample(sample_of(good));
monitor.record_sample(sample_of(good));
assert_eq!(monitor.stats().consecutive_good_frames, 2);
monitor.record_sample(sample_of(neutral));
let stats = monitor.stats();
assert_eq!(stats.consecutive_good_frames, 0, "a neutral frame is not good evidence");
assert_eq!(stats.consecutive_bad_frames, 0, "a neutral frame is not bad evidence");
}
#[test]
fn should_upgrade_uses_upgrade_threshold_and_cooldown() {
let mut monitor = discrete_monitor();
monitor.last_quality_change = Instant::now() - Duration::from_secs(10);
let good = monitor.thresholds().upgrade_duration();
let needed = monitor.thresholds().upgrade_frame_count;
for _ in 0..needed {
monitor.record_sample(sample_of(good));
}
assert!(monitor.should_upgrade(), "enough consecutive good frames must allow an upgrade");
let neutral = monitor.thresholds().upgrade_duration() + Duration::from_micros(1);
monitor.record_sample(sample_of(neutral));
assert!(!monitor.should_upgrade(), "a non-good frame must reset the upgrade streak");
monitor.notify_quality_changed();
for _ in 0..needed {
monitor.record_sample(sample_of(good));
}
assert!(!monitor.should_upgrade(), "the cooldown must still apply after a change");
}
#[test]
fn thresholds_survive_pathological_values() {
let mut t = AdaptivePerformanceThresholds::discrete();
for fps in [0.0f32, -1.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MAX] {
t.target_fps = fps;
let d = t.target_frame_duration();
assert!(d > Duration::ZERO, "fps {fps} must yield a positive duration");
}
for mult in [-1.0f32, f32::NAN, f32::INFINITY, f32::MAX] {
t.target_fps = 60.0;
t.degrade_threshold = mult;
t.upgrade_threshold = mult;
let _ = t.degrade_duration();
let _ = t.upgrade_duration();
}
}
#[test]
fn gpu_time_env_override_is_range_checked() {
let monitor = discrete_monitor();
std::env::set_var("RUST_WIDGETS_GPU_TIME_MS", "-5");
assert_eq!(monitor.measure_gpu_time(), None, "a negative duration is rejected");
std::env::set_var("RUST_WIDGETS_GPU_TIME_MS", "nan");
assert_eq!(monitor.measure_gpu_time(), None, "NaN is rejected");
std::env::set_var("RUST_WIDGETS_GPU_TIME_MS", "12.5");
assert_eq!(
monitor.measure_gpu_time(),
Some(Duration::from_secs_f64(0.0125)),
"a normal value is converted"
);
std::env::remove_var("RUST_WIDGETS_GPU_TIME_MS");
}
#[test]
fn repeated_adjustment_stays_bounded_and_ordered() {
let mut t = AdaptivePerformanceThresholds::discrete();
for _ in 0..1000 {
t.adjust_based_on_performance(Duration::from_millis(200), 0.0);
}
assert!(
t.degrade_threshold >= 1.1,
"degrade must not collapse to the upgrade side: {}",
t.degrade_threshold
);
assert!(t.upgrade_threshold <= 0.9, "upgrade must stay bounded: {}", t.upgrade_threshold);
assert!(
t.degrade_threshold > t.upgrade_threshold,
"the thresholds must never invert (degrade {} vs upgrade {})",
t.degrade_threshold,
t.upgrade_threshold
);
assert!(t.target_fps >= 10.0, "target_fps must stay usable: {}", t.target_fps);
assert!(t.target_frame_duration() > Duration::ZERO);
assert!(t.degrade_duration() > t.upgrade_duration());
}
#[test]
fn auto_adjust_thresholds_is_rate_limited() {
let mut monitor = discrete_monitor();
let before = monitor.thresholds().degrade_threshold;
for i in 0..60 {
let ms = if i % 2 == 0 { 4 } else { 200 };
monitor.record_sample(sample_of(Duration::from_millis(ms)));
}
assert!(monitor.stability() < 0.5, "the fixture must be unstable");
for _ in 0..(ADJUST_INTERVAL_FRAMES - 1) {
monitor.auto_adjust_thresholds();
}
assert_eq!(
monitor.thresholds().degrade_threshold,
before,
"an adjustment before the cadence elapses must not change the thresholds"
);
monitor.auto_adjust_thresholds();
assert!(
monitor.thresholds().degrade_threshold < before,
"once the cadence elapses, an unstable run widens the degrade window"
);
let wider = monitor.thresholds().degrade_threshold;
monitor.auto_adjust_thresholds();
assert_eq!(monitor.thresholds().degrade_threshold, wider, "the cadence must hold");
}
}