feat(perf): wire frame-rate limiter + adaptive chunking low-motion mode
- Wire 120 FPS FrameRateLimiter into run_event_loop via time_until_next_draw + mark_emitted - Add low_motion support: 30 FPS cap via LOW_MOTION_MIN_FRAME_INTERVAL - Add AdaptiveChunkingPolicy::set_low_motion() to force Smooth mode - Add StreamingState::set_low_motion() to propagate to all block policies - Tool spinner already freezes on first frame when low_motion is set TODO_BACKEND.md §3, TODO_FIXES.md #4
This commit is contained in:
@@ -35,12 +35,17 @@ use std::time::Instant;
|
||||
/// 120 FPS minimum frame interval (≈8.33ms).
|
||||
pub const MIN_FRAME_INTERVAL: Duration = Duration::from_nanos(8_333_334);
|
||||
|
||||
/// 30 FPS minimum frame interval (≈33.33ms) used in low-motion mode.
|
||||
pub const LOW_MOTION_MIN_FRAME_INTERVAL: Duration = Duration::from_nanos(33_333_333);
|
||||
|
||||
/// Remembers the most recent emitted draw, allowing deadlines to be clamped
|
||||
/// forward so the next draw never lands sooner than `MIN_FRAME_INTERVAL`
|
||||
/// after the last one.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct FrameRateLimiter {
|
||||
last_emitted_at: Option<Instant>,
|
||||
/// When true, use the 30 FPS cap instead of 120 FPS.
|
||||
low_motion: bool,
|
||||
}
|
||||
|
||||
impl FrameRateLimiter {
|
||||
@@ -52,7 +57,7 @@ impl FrameRateLimiter {
|
||||
return requested;
|
||||
};
|
||||
let min_allowed = last_emitted_at
|
||||
.checked_add(MIN_FRAME_INTERVAL)
|
||||
.checked_add(self.interval())
|
||||
.unwrap_or(last_emitted_at);
|
||||
requested.max(min_allowed)
|
||||
}
|
||||
@@ -74,6 +79,19 @@ impl FrameRateLimiter {
|
||||
Some(clamped - now)
|
||||
}
|
||||
}
|
||||
|
||||
/// Set low-motion mode: caps frame rate at 30 FPS instead of 120 FPS.
|
||||
pub fn set_low_motion(&mut self, low_motion: bool) {
|
||||
self.low_motion = low_motion;
|
||||
}
|
||||
|
||||
fn interval(&self) -> Duration {
|
||||
if self.low_motion {
|
||||
LOW_MOTION_MIN_FRAME_INTERVAL
|
||||
} else {
|
||||
MIN_FRAME_INTERVAL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -124,4 +142,45 @@ mod tests {
|
||||
let well_past = t0 + Duration::from_millis(50);
|
||||
assert!(limiter.time_until_next_draw(well_past).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_motion_clamps_to_30fps_interval() {
|
||||
let t0 = Instant::now();
|
||||
let mut limiter = FrameRateLimiter::default();
|
||||
limiter.set_low_motion(true);
|
||||
limiter.mark_emitted(t0);
|
||||
|
||||
let too_soon = t0 + Duration::from_millis(5);
|
||||
// Under 30 FPS (~33.33 ms), a draw 5 ms after last emit is clamped.
|
||||
assert_eq!(
|
||||
limiter.clamp_deadline(too_soon),
|
||||
t0 + LOW_MOTION_MIN_FRAME_INTERVAL
|
||||
);
|
||||
|
||||
// After 34 ms, draw is allowed.
|
||||
let after_34 = t0 + Duration::from_millis(34);
|
||||
assert!(limiter.time_until_next_draw(after_34).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_motion_switching_respects_current_mode() {
|
||||
let t0 = Instant::now();
|
||||
let mut limiter = FrameRateLimiter::default();
|
||||
|
||||
// Default (120 FPS): mark at t0, 10 ms later is clamped to ~8.33ms
|
||||
limiter.mark_emitted(t0);
|
||||
let t10 = t0 + Duration::from_millis(10);
|
||||
assert!(limiter.time_until_next_draw(t10).is_none()); // 10ms > 8.33ms
|
||||
|
||||
// Switch to low_motion; mark again
|
||||
limiter.set_low_motion(true);
|
||||
limiter.mark_emitted(t10);
|
||||
let t20 = t10 + Duration::from_millis(10);
|
||||
let remaining = limiter.time_until_next_draw(t20).unwrap();
|
||||
// 30 FPS = 33.33 ms interval; 10ms elapsed → ~23.33 remaining
|
||||
assert!(
|
||||
remaining > Duration::from_millis(20) && remaining < Duration::from_millis(25),
|
||||
"expected ~23.33ms remaining, got {remaining:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,10 @@ pub struct AdaptiveChunkingPolicy {
|
||||
mode: ChunkingMode,
|
||||
below_exit_threshold_since: Option<Instant>,
|
||||
last_catch_up_exit_at: Option<Instant>,
|
||||
/// When true, the policy never enters `CatchUp` — it stays in `Smooth`
|
||||
/// regardless of queue pressure, keeping the display calm for users who
|
||||
/// prefer reduced visual churn.
|
||||
low_motion: bool,
|
||||
}
|
||||
|
||||
impl AdaptiveChunkingPolicy {
|
||||
@@ -109,8 +113,31 @@ impl AdaptiveChunkingPolicy {
|
||||
self.last_catch_up_exit_at = None;
|
||||
}
|
||||
|
||||
/// When true, the policy never enters `CatchUp` — it stays in `Smooth`
|
||||
/// regardless of queue pressure.
|
||||
pub fn set_low_motion(&mut self, low_motion: bool) {
|
||||
self.low_motion = low_motion;
|
||||
if low_motion {
|
||||
self.mode = ChunkingMode::Smooth;
|
||||
self.below_exit_threshold_since = None;
|
||||
self.last_catch_up_exit_at = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes a drain decision from the current queue snapshot.
|
||||
pub fn decide(&mut self, snapshot: QueueSnapshot, now: Instant) -> ChunkingDecision {
|
||||
// In low-motion mode, always use Smooth pacing regardless of queue
|
||||
// pressure — the user asked for a calm, steady display.
|
||||
if self.low_motion {
|
||||
self.mode = ChunkingMode::Smooth;
|
||||
self.below_exit_threshold_since = None;
|
||||
return ChunkingDecision {
|
||||
mode: self.mode,
|
||||
entered_catch_up: false,
|
||||
drain_plan: DrainPlan::Single,
|
||||
};
|
||||
}
|
||||
|
||||
if snapshot.queued_lines == 0 {
|
||||
self.note_catch_up_exit(now);
|
||||
self.mode = ChunkingMode::Smooth;
|
||||
@@ -351,4 +378,46 @@ mod tests {
|
||||
assert_eq!(severe.mode, ChunkingMode::CatchUp);
|
||||
assert_eq!(severe.drain_plan, DrainPlan::Batch(64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_motion_always_smooth_regardless_of_pressure() {
|
||||
let mut policy = AdaptiveChunkingPolicy::new();
|
||||
policy.set_low_motion(true);
|
||||
let t0 = Instant::now();
|
||||
|
||||
// Queue depth far above ENTER threshold.
|
||||
let d1 = policy.decide(snap(20, 10), t0);
|
||||
assert_eq!(d1.mode, ChunkingMode::Smooth);
|
||||
assert!(!d1.entered_catch_up);
|
||||
assert_eq!(d1.drain_plan, DrainPlan::Single);
|
||||
|
||||
// Oldest age far above ENTER threshold.
|
||||
let d2 = policy.decide(snap(5, 500), t0 + Duration::from_millis(100));
|
||||
assert_eq!(d2.mode, ChunkingMode::Smooth);
|
||||
assert!(!d2.entered_catch_up);
|
||||
assert_eq!(d2.drain_plan, DrainPlan::Single);
|
||||
|
||||
// Severe backlog — still Smooth.
|
||||
let d3 = policy.decide(snap(80, 500), t0 + Duration::from_millis(200));
|
||||
assert_eq!(d3.mode, ChunkingMode::Smooth);
|
||||
assert_eq!(d3.drain_plan, DrainPlan::Single);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_motion_reset_resumes_normal_operation() {
|
||||
let mut policy = AdaptiveChunkingPolicy::new();
|
||||
policy.set_low_motion(true);
|
||||
let t0 = Instant::now();
|
||||
|
||||
// Low motion blocks catch-up.
|
||||
let d1 = policy.decide(snap(20, 10), t0);
|
||||
assert_eq!(d1.mode, ChunkingMode::Smooth);
|
||||
|
||||
// Turn off low motion — next burst should enter CatchUp.
|
||||
policy.set_low_motion(false);
|
||||
let d2 = policy.decide(snap(20, 10), t0 + Duration::from_millis(10));
|
||||
assert_eq!(d2.mode, ChunkingMode::CatchUp);
|
||||
assert!(d2.entered_catch_up);
|
||||
assert_eq!(d2.drain_plan, DrainPlan::Batch(20));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,6 +491,15 @@ impl StreamingState {
|
||||
result
|
||||
}
|
||||
|
||||
/// Propagate the low-motion flag to every block's chunking policy.
|
||||
/// When true, all policies stay in `Smooth` regardless of queue pressure,
|
||||
/// preventing CatchUp burst drains that would create sudden visual jumps.
|
||||
pub fn set_low_motion(&mut self, low_motion: bool) {
|
||||
for block in self.blocks.iter_mut().flatten() {
|
||||
block.policy.set_low_motion(low_motion);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if any stream is still active
|
||||
fn check_active(&mut self) {
|
||||
self.is_active = self.blocks.iter().any(|b| {
|
||||
|
||||
@@ -954,6 +954,13 @@ async fn run_event_loop(
|
||||
// redraw is needed but the limiter says we're inside the cooldown
|
||||
// window, leave `needs_redraw = true` and shorten the poll timeout
|
||||
// so the loop wakes up exactly when drawing is allowed.
|
||||
|
||||
// Sync low-motion flag into the frame-rate limiter and streaming
|
||||
// chunking policy. Low-motion mode drops the frame cap to 30 FPS
|
||||
// and forces Smooth-only chunking so the display stays calm.
|
||||
frame_rate_limiter.set_low_motion(app.low_motion);
|
||||
app.streaming_state.set_low_motion(app.low_motion);
|
||||
|
||||
let draw_wait = if app.needs_redraw {
|
||||
frame_rate_limiter.time_until_next_draw(now)
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user