From 417f78aa943260f16090bcfa561c5cf901a230fa Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Sat, 24 Oct 2026 22:44:06 +0100 Subject: [PATCH] perf(compositor): account for late frame dispatch Include observed timer dispatch delay in the render budget, capped at one refresh and decayed by elapsed time. Preserve the scheduled presentation target and output-local state. Existing scheduling assertions remain intact; a negative control verifies the new deadline regression. --- compositor/src/backends/direct/redraw.rs | 4 +- compositor/src/backends/direct/scheduling.rs | 2 +- compositor/src/frame_scheduler.rs | 121 +++++++++++++++++-- compositor/src/state/prediction.rs | 2 +- 4 files changed, 113 insertions(+), 16 deletions(-) diff --git a/compositor/src/backends/direct/redraw.rs b/compositor/src/backends/direct/redraw.rs index 0f1d285..a484946 100644 --- a/compositor/src/backends/direct/redraw.rs +++ b/compositor/src/backends/direct/redraw.rs @@ -246,7 +246,7 @@ mod tests { scheduler.request(Duration::ZERO); let scheduled = scheduler.plan(Duration::ZERO).unwrap(); assert_eq!(fallback.deadline(start, interval, true, true), None); - let plan = scheduler.begin().unwrap(); + let plan = scheduler.begin(scheduled.render_at).unwrap(); assert_eq!(plan, scheduled); scheduler.rendered( plan, @@ -297,7 +297,7 @@ mod tests { fallback.deadline(deadline, interval, needs_final_frame, true), None ); - scheduler.begin().unwrap(); + scheduler.begin(deadline.duration_since(start)).unwrap(); assert!(!scheduler.has_pending_request()); } diff --git a/compositor/src/backends/direct/scheduling.rs b/compositor/src/backends/direct/scheduling.rs index 7663bd5..8ce2456 100644 --- a/compositor/src/backends/direct/scheduling.rs +++ b/compositor/src/backends/direct/scheduling.rs @@ -126,7 +126,7 @@ fn dispatch_frame(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle, identit return; } - let Some(plan) = output.scheduler.begin() else { + let Some(plan) = output.scheduler.begin(monotonic_now()) else { return; }; diff --git a/compositor/src/frame_scheduler.rs b/compositor/src/frame_scheduler.rs index cdff45c..9ba301b 100644 --- a/compositor/src/frame_scheduler.rs +++ b/compositor/src/frame_scheduler.rs @@ -3,6 +3,7 @@ use std::time::Duration; const MIN_RENDER: Duration = Duration::from_millis(2); const TIMER_MARGIN: Duration = Duration::from_millis(1); +const WAKEUP_DECAY: Duration = Duration::from_secs(1); #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) struct FramePlan { @@ -25,6 +26,8 @@ pub(crate) struct FrameScheduler { render_variance: Duration, last_sample: Option, extra_margin: Duration, + wakeup_delay: Duration, + last_wakeup: Option, pub missed_deadlines: u64, } @@ -42,6 +45,8 @@ impl FrameScheduler { render_variance: Duration::ZERO, last_sample: None, extra_margin: Duration::ZERO, + wakeup_delay: Duration::ZERO, + last_wakeup: None, missed_deadlines: 0, } } @@ -68,11 +73,27 @@ impl FrameScheduler { // A cold GPU is less predictable. Start immediately on idle wakeup. self.interval } else { - (self.render_estimate + self.render_variance * 2 + TIMER_MARGIN + self.extra_margin) - .min(self.interval) + (self.render_estimate + + self.render_variance * 2 + + TIMER_MARGIN + + self.extra_margin + + self.wakeup_margin(now)) + .min(self.interval) } } + fn wakeup_margin(&self, now: Duration) -> Duration { + if self.wakeup_delay.is_zero() { + return Duration::ZERO; + } + let elapsed = self + .last_wakeup + .map_or(Duration::ZERO, |last| now.saturating_sub(last)); + // Forget transient contention by elapsed time, independent of refresh rate. + self.wakeup_delay + .mul_f64((-elapsed.as_secs_f64() / WAKEUP_DECAY.as_secs_f64()).exp()) + } + fn phase_at_or_after(&self, time: Duration) -> Duration { let Some(last) = self.last_presented else { return time; @@ -134,8 +155,15 @@ impl FrameScheduler { Some(plan) } - pub fn begin(&mut self) -> Option { + pub fn begin(&mut self, now: Duration) -> Option { let plan = self.scheduled.take()?; + if plan.phase_known { + // Rendering cost starts after dispatch. Account separately for time + // spent waiting for a CPU or for earlier event-loop callbacks. + let late = now.saturating_sub(plan.render_at).min(self.interval); + self.wakeup_delay = self.wakeup_margin(now).max(late); + self.last_wakeup = Some(now); + } self.requested_at = None; self.last_target = Some(plan.present_at); Some(plan) @@ -259,7 +287,7 @@ mod tests { assert_eq!((plan.render_at, plan.present_at), (ms(107), ms(110))); scheduler.request(ms(102)); assert_eq!(scheduler.plan(ms(102)), Some(plan)); - assert_eq!(scheduler.begin(), Some(plan)); + assert_eq!(scheduler.begin(plan.render_at), Some(plan)); scheduler.rendered(plan, ms(108), ms(1), true); scheduler.request(ms(109)); assert_eq!(scheduler.plan(ms(109)), None); @@ -274,7 +302,7 @@ mod tests { scheduler.request(ms(10_100)); let plan = scheduler.plan(ms(10_100)).unwrap(); assert_eq!(plan.render_at, ms(10_100)); - scheduler.begin(); + scheduler.begin(plan.render_at); scheduler.rendered(plan, ms(10_101), ms(1), true); scheduler.presented(ms(10_110)); assert_eq!(scheduler.missed_deadlines, 0); @@ -286,7 +314,7 @@ mod tests { scheduler.presented(ms(100)); scheduler.request(ms(101)); let plan = scheduler.plan(ms(101)).unwrap(); - scheduler.begin(); + scheduler.begin(plan.render_at); scheduler.rendered(plan, ms(108), ms(1), true); assert_eq!(scheduler.presented(ms(130)), Some(plan)); assert_eq!(scheduler.missed_deadlines, 2); @@ -294,12 +322,81 @@ mod tests { assert_eq!(scheduler.presented(ms(129)), None); } + #[test] + fn late_timer_dispatch_moves_the_next_render_earlier_without_skipping_a_refresh() { + let mut scheduler = FrameScheduler::new(ms(10)); + scheduler.presented(ms(100)); + scheduler.request(ms(101)); + let first = scheduler.plan(ms(101)).unwrap(); + assert_eq!(first.render_at, ms(107)); + let dispatch = first.render_at + ms(3); + assert_eq!(scheduler.begin(dispatch), Some(first)); + scheduler.rendered(first, dispatch + ms(2), ms(2), true); + scheduler.presented(ms(120)); + assert_eq!(scheduler.missed_deadlines, 1); + + scheduler.request(ms(120)); + let next = scheduler.plan(ms(120)).unwrap(); + assert_eq!(next.present_at, ms(130), "retain the next physical refresh"); + assert!( + next.render_at + ms(3) + ms(2) <= next.present_at - TIMER_MARGIN, + "the next frame must budget both the observed wakeup delay and rendering" + ); + assert_eq!( + scheduler.plan(ms(121)), + Some(next), + "new input must not postpone the plan" + ); + } + + #[test] + fn wakeup_history_decays_by_time_is_bounded_and_resets_with_the_output() { + let mut fast = FrameScheduler::new(ms(5)); + let mut slow = FrameScheduler::new(ms(20)); + let mut margins = Vec::new(); + for scheduler in [&mut fast, &mut slow] { + scheduler.presented(ms(100)); + scheduler.request(ms(101)); + let plan = scheduler.plan(ms(101)).unwrap(); + let now = plan.render_at + ms(3); + scheduler.begin(now); + assert_eq!(scheduler.wakeup_margin(now), ms(3)); + margins.push(scheduler.wakeup_margin(now + ms(1000))); + assert!(scheduler.wakeup_margin(now + ms(5000)) < Duration::from_micros(21)); + scheduler.reset(ms(10)); + assert_eq!(scheduler.wakeup_margin(now), Duration::ZERO); + } + assert_eq!( + margins[0], margins[1], + "decay must not depend on display refresh rate" + ); + let mut scheduler = FrameScheduler::new(ms(10)); + scheduler.presented(ms(100)); + scheduler.request(ms(101)); + let plan = scheduler.plan(ms(101)).unwrap(); + let now = plan.render_at + ms(200); + scheduler.begin(now); + assert_eq!(scheduler.wakeup_margin(now), ms(10)); + assert!(scheduler.budget(now) <= ms(10)); + } + + #[test] + fn unknown_phase_does_not_train_wakeup_history_or_move_a_pending_plan() { + let mut scheduler = FrameScheduler::new(ms(10)); + scheduler.request(ms(100)); + let plan = scheduler.plan(ms(100)).unwrap(); + assert!(!plan.phase_known); + assert_eq!(scheduler.plan(ms(120)), Some(plan)); + scheduler.begin(ms(130)); + assert_eq!(scheduler.wakeup_margin(ms(130)), Duration::ZERO); + } + #[test] fn first_retirement_aligns_the_next_frame_to_the_observed_refresh() { let mut scheduler = FrameScheduler::new(ms(10)); scheduler.request(ms(100)); let plan = scheduler.plan(ms(100)).unwrap(); - scheduler.begin(); + scheduler.begin(plan.render_at); scheduler.rendered(plan, ms(102), ms(2), true); scheduler.presented(ms(103)); @@ -313,7 +410,7 @@ mod tests { scheduler.presented(ms(100)); scheduler.request(ms(101)); let plan = scheduler.plan(ms(101)).unwrap(); - scheduler.begin(); + scheduler.begin(plan.render_at); scheduler.rendered(plan, ms(108), ms(1), false); scheduler.callback_sent(ms(110)); scheduler.request(ms(108)); @@ -330,7 +427,7 @@ mod tests { scheduler.presented(ms(100)); scheduler.request(ms(101)); let unchanged = scheduler.plan(ms(101)).unwrap(); - scheduler.begin(); + scheduler.begin(unchanged.render_at); scheduler.rendered(unchanged, ms(108), ms(1), false); let callback_at = scheduler .callback_deadline(ms(108), unchanged.present_at) @@ -339,7 +436,7 @@ mod tests { scheduler.request(ms(109)); let changed = scheduler.plan(ms(109)).unwrap(); - scheduler.begin(); + scheduler.begin(changed.render_at); scheduler.rendered(changed, ms(118), ms(1), true); // The old timer is dispatched late, after a new frame was queued. @@ -393,7 +490,7 @@ mod tests { scheduler.presented(ms(100)); scheduler.request(ms(101)); let plan = scheduler.plan(ms(101)).unwrap(); - scheduler.begin(); + scheduler.begin(plan.render_at); scheduler.rendered(plan, ms(108), ms(1), true); scheduler.request(ms(109)); assert_eq!(scheduler.presented(ms(90)), Some(plan)); @@ -416,7 +513,7 @@ mod tests { unknown.request(ms(100)); let plan = unknown.plan(ms(100)).unwrap(); assert_eq!(unknown.forecast_time(ms(101), plan), None); - unknown.begin(); + unknown.begin(plan.render_at); unknown.rendered(plan, ms(102), ms(2), true); unknown.presented(ms(130)); assert_eq!( diff --git a/compositor/src/state/prediction.rs b/compositor/src/state/prediction.rs index 64298a6..427cefd 100644 --- a/compositor/src/state/prediction.rs +++ b/compositor/src/state/prediction.rs @@ -543,7 +543,7 @@ mod tests { for tick in 0..(hz * 3 / 10) { let now = interval * tick as u32; if scheduler.plan(now).is_some() { - let plan = scheduler.begin().unwrap(); + let plan = scheduler.begin(now).unwrap(); attempts += 1; scheduler.rendered(plan, now, Duration::ZERO, false); if state.sample_frame(&output, Duration::ZERO).animating {