Files
feresaria/compositor/src/frame_scheduler.rs
T
Samuel Onoja 6f2951c6b0 Scope redraw requests and avoid redundant animation fallback wakeups
Instrument redraw sources, per-output request coalescing, and animation fallback scheduling. Route local visual changes to their affected outputs while preserving deferred grab and gesture updates.

Schedule fallback recovery using each orphaned animation output's refresh interval, suppress it while a normal frame chain exists, and retain requested final frames after scheduling failures. Add mixed-refresh, cursor footprint, frame-chain, and disconnect regressions and document measurement limits.
2026-10-02 01:21:11 +01:00

407 lines
15 KiB
Rust

//! Fixed-refresh scheduling policy, independent of DRM and the event loop.
use std::time::Duration;
const MIN_RENDER: Duration = Duration::from_millis(2);
const TIMER_MARGIN: Duration = Duration::from_millis(1);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct FramePlan {
pub render_at: Duration,
pub present_at: Duration,
pub requested_at: Duration,
pub phase_known: bool,
}
#[derive(Debug)]
pub(crate) struct FrameScheduler {
interval: Duration,
last_presented: Option<Duration>,
last_target: Option<Duration>,
requested_at: Option<Duration>,
scheduled: Option<FramePlan>,
submitted: Option<FramePlan>,
last_callback: Option<Duration>,
render_estimate: Duration,
render_variance: Duration,
last_sample: Option<Duration>,
extra_margin: Duration,
pub missed_deadlines: u64,
}
impl FrameScheduler {
pub fn new(interval: Duration) -> Self {
Self {
interval: interval.max(Duration::from_micros(1)),
last_presented: None,
last_target: None,
requested_at: None,
scheduled: None,
submitted: None,
last_callback: None,
render_estimate: MIN_RENDER,
render_variance: Duration::ZERO,
last_sample: None,
extra_margin: Duration::ZERO,
missed_deadlines: 0,
}
}
pub fn reset(&mut self, interval: Duration) {
let missed = self.missed_deadlines;
*self = Self::new(interval);
self.missed_deadlines = missed;
}
pub fn request(&mut self, now: Duration) {
self.requested_at.get_or_insert(now);
}
pub fn has_pending_request(&self) -> bool {
self.requested_at.is_some() || self.scheduled.is_some()
}
fn budget(&self, now: Duration) -> Duration {
let idle = self
.last_presented
.is_none_or(|last| now.saturating_sub(last) >= self.interval * 100);
if idle {
// 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)
}
}
fn phase_at_or_after(&self, time: Duration) -> Duration {
let Some(last) = self.last_presented else {
return time;
};
let count = time
.saturating_sub(last)
.as_nanos()
.div_ceil(self.interval.as_nanos())
.max(1);
last + self.interval.mul_f64(count as f64)
}
pub fn plan(&mut self, now: Duration) -> Option<FramePlan> {
if self.submitted.is_some() {
return None;
}
if let Some(plan) = self.scheduled {
return Some(plan);
}
let requested_at = self.requested_at?;
if self.last_presented.is_some_and(|last| last > now + self.interval) {
self.last_presented = None;
self.last_target = None;
}
let budget = self.budget(now);
let idle = self
.last_presented
.is_none_or(|last| now.saturating_sub(last) >= self.interval * 100);
// With one in-flight GBM frame, submitting immediately can hit the
// nearest vblank even when the conservative budget is larger than
// the remaining time. Do not deliberately skip that presentation.
let next = if self.last_presented.is_some() {
now + Duration::from_nanos(1)
} else {
now + self.interval
};
let earliest = next.max(self.last_target.map_or(next, |last| last + self.interval));
let present_at = self.phase_at_or_after(earliest);
let plan = FramePlan {
render_at: if idle {
now
} else {
present_at.saturating_sub(budget).max(now)
},
present_at,
requested_at,
phase_known: self.last_presented.is_some(),
};
self.scheduled = Some(plan);
Some(plan)
}
pub fn begin(&mut self) -> Option<FramePlan> {
let plan = self.scheduled.take()?;
self.requested_at = None;
self.last_target = Some(plan.present_at);
Some(plan)
}
pub fn rendered(&mut self, plan: FramePlan, now: Duration, cost: Duration, submitted: bool) {
if submitted {
self.submitted = Some(plan);
let elapsed = self
.last_sample
.map_or(Duration::from_millis(500), |last| now.saturating_sub(last));
self.last_sample = Some(now);
let sample = cost.max(MIN_RENDER);
let ratio = (elapsed.as_secs_f64() / 0.5).clamp(0.01, 1.0);
let deviation = sample.saturating_sub(self.render_estimate);
let variance_ratio = (elapsed.as_secs_f64() / 6.0).clamp(0.001, 0.1);
self.render_variance = self.render_variance.mul_f64(1.0 - variance_ratio).max(deviation);
self.render_estimate = self.render_estimate.mul_f64(1.0 - ratio) + sample.mul_f64(ratio);
}
}
pub fn presented(&mut self, timestamp: Duration) -> Option<FramePlan> {
// Regressive or implausibly early events must not shift the phase backward.
if self.last_presented.is_some_and(|last| timestamp < last) {
self.last_presented = None;
self.last_target = None;
return self.submitted.take();
}
self.last_presented = Some(timestamp);
let plan = self.submitted.take()?;
// The first submission has only an estimated phase. Retiring it on
// the actual refresh must not carry that estimate into the next frame.
self.last_target = Some(timestamp);
let late = timestamp.saturating_sub(plan.present_at);
if plan.phase_known && late > self.interval / 2 {
let missed = (late.as_nanos() + self.interval.as_nanos() / 2) / self.interval.as_nanos();
self.missed_deadlines = self.missed_deadlines.saturating_add(missed as u64);
// CPU submission timings may omit asynchronous GPU work. Increase
// the margin promptly after a real miss; relax it slowly afterward.
self.extra_margin = (self.extra_margin + TIMER_MARGIN).min(self.interval / 2);
} else {
self.extra_margin = self.extra_margin.mul_f64(0.98);
}
Some(plan)
}
pub fn retire_unknown(&mut self) {
self.submitted = None;
self.last_presented = None;
self.last_target = None;
}
pub fn forecast_time(&self, now: Duration, plan: FramePlan) -> Option<Duration> {
// Before the first real page flip there is no reliable refresh phase.
// A late timer still samples the next physical presentation, while
// deadline metrics retain the original scheduled target.
plan.phase_known.then(|| {
plan.present_at
.max(self.phase_at_or_after(now + Duration::from_nanos(1)))
})
}
pub fn callback_deadline(&self, now: Duration, target: Duration) -> Option<Duration> {
// A timer from a callback-only update must yield to a later submission.
// Its page flip will release callbacks using the observed refresh phase.
if self.submitted.is_some() {
return None;
}
let tolerance = Duration::from_micros(500).min(self.interval / 8);
if target <= now
&& self
.last_callback
.is_none_or(|last| last + self.interval <= now + tolerance)
{
return Some(now);
}
let earliest = self
.last_callback
.map_or(now, |last| last + self.interval)
.max(target)
.max(now);
Some(self.phase_at_or_after(earliest))
}
pub fn callback_sent(&mut self, now: Duration) {
// Record the refresh slot, not delayed event-loop dispatch time.
// Otherwise a callback delivered a few microseconds late can make
// the next refresh look too early and halve a client's frame rate.
self.last_callback = Some(self.last_presented.map_or(now, |last| {
let tolerance = Duration::from_micros(500).min(self.interval / 8);
let slots = (now.saturating_sub(last) + tolerance).as_nanos() / self.interval.as_nanos();
last + self.interval.mul_f64(slots as f64)
}));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ms(value: u64) -> Duration {
Duration::from_millis(value)
}
#[test]
fn requests_coalesce_without_postponing_the_frame_and_wait_for_retirement() {
let mut scheduler = FrameScheduler::new(ms(10));
scheduler.presented(ms(100));
scheduler.request(ms(101));
let plan = scheduler.plan(ms(101)).unwrap();
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));
scheduler.rendered(plan, ms(108), ms(1), true);
scheduler.request(ms(109));
assert_eq!(scheduler.plan(ms(109)), None);
scheduler.presented(ms(110));
assert_eq!(scheduler.plan(ms(110)).unwrap().present_at, ms(120));
}
#[test]
fn idle_wakeup_starts_early_and_is_not_a_missed_deadline() {
let mut scheduler = FrameScheduler::new(ms(10));
scheduler.presented(ms(100));
scheduler.request(ms(10_100));
let plan = scheduler.plan(ms(10_100)).unwrap();
assert_eq!(plan.render_at, ms(10_100));
scheduler.begin();
scheduler.rendered(plan, ms(10_101), ms(1), true);
scheduler.presented(ms(10_110));
assert_eq!(scheduler.missed_deadlines, 0);
}
#[test]
fn misses_measure_the_submitted_target_and_expand_the_budget() {
let mut scheduler = FrameScheduler::new(ms(10));
scheduler.presented(ms(100));
scheduler.request(ms(101));
let plan = scheduler.plan(ms(101)).unwrap();
scheduler.begin();
scheduler.rendered(plan, ms(108), ms(1), true);
assert_eq!(scheduler.presented(ms(130)), Some(plan));
assert_eq!(scheduler.missed_deadlines, 2);
assert_eq!(scheduler.budget(ms(131)), ms(4));
assert_eq!(scheduler.presented(ms(129)), None);
}
#[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.rendered(plan, ms(102), ms(2), true);
scheduler.presented(ms(103));
scheduler.request(ms(104));
assert_eq!(scheduler.plan(ms(104)).unwrap().present_at, ms(113));
}
#[test]
fn no_damage_attempts_and_callbacks_do_not_spin_within_a_refresh() {
let mut scheduler = FrameScheduler::new(ms(10));
scheduler.presented(ms(100));
scheduler.request(ms(101));
let plan = scheduler.plan(ms(101)).unwrap();
scheduler.begin();
scheduler.rendered(plan, ms(108), ms(1), false);
scheduler.callback_sent(ms(110));
scheduler.request(ms(108));
assert_eq!(scheduler.plan(ms(108)).unwrap().present_at, ms(120));
assert_eq!(scheduler.callback_deadline(ms(111), ms(110)).unwrap(), ms(120));
}
#[test]
fn callback_only_timer_waits_for_a_later_submission_even_when_it_misses_its_target() {
let mut scheduler = FrameScheduler::new(ms(10));
scheduler.presented(ms(100));
scheduler.request(ms(101));
let unchanged = scheduler.plan(ms(101)).unwrap();
scheduler.begin();
scheduler.rendered(unchanged, ms(108), ms(1), false);
let callback_at = scheduler.callback_deadline(ms(108), unchanged.present_at).unwrap();
assert_eq!(callback_at, ms(110));
scheduler.request(ms(109));
let changed = scheduler.plan(ms(109)).unwrap();
scheduler.begin();
scheduler.rendered(changed, ms(118), ms(1), true);
// The old timer is dispatched late, after a new frame was queued.
// Neither that timer nor the predicted refresh can release callbacks.
assert_eq!(scheduler.callback_deadline(ms(118), callback_at), None);
assert_eq!(scheduler.callback_deadline(ms(120), changed.present_at), None);
assert_eq!(scheduler.callback_deadline(ms(129), callback_at), None);
scheduler.presented(ms(130));
assert_eq!(scheduler.callback_deadline(ms(130), ms(130)), Some(ms(130)));
scheduler.callback_sent(ms(130));
assert_eq!(scheduler.callback_deadline(ms(130), callback_at), Some(ms(140)));
}
#[test]
fn outputs_keep_independent_phases_and_reset_cancels_obsolete_work() {
let mut fast = FrameScheduler::new(ms(4));
let mut slow = FrameScheduler::new(ms(16));
for scheduler in [&mut fast, &mut slow] {
scheduler.presented(ms(100));
scheduler.request(ms(101));
}
assert_eq!(fast.plan(ms(101)).unwrap().present_at, ms(104));
assert_eq!(slow.plan(ms(101)).unwrap().present_at, ms(116));
fast.reset(ms(8));
assert_eq!(fast.plan(ms(102)), None);
}
#[test]
fn callback_dispatch_jitter_does_not_skip_a_refresh_or_duplicate_a_callback() {
let mut scheduler = FrameScheduler::new(ms(10));
scheduler.presented(ms(100));
scheduler.callback_sent(ms(100) + Duration::from_micros(80));
scheduler.presented(ms(110));
let now = ms(110) + Duration::from_micros(10);
assert_eq!(scheduler.callback_deadline(now, now).unwrap(), now);
scheduler.callback_sent(now);
assert_eq!(scheduler.callback_deadline(now, now).unwrap(), ms(120));
}
#[test]
fn unknown_retirement_and_regressive_timestamps_do_not_strand_pending_work() {
let mut scheduler = FrameScheduler::new(ms(10));
scheduler.presented(ms(100));
scheduler.request(ms(101));
let plan = scheduler.plan(ms(101)).unwrap();
scheduler.begin();
scheduler.rendered(plan, ms(108), ms(1), true);
scheduler.request(ms(109));
assert_eq!(scheduler.presented(ms(90)), Some(plan));
assert!(scheduler.plan(ms(110)).is_some());
scheduler.retire_unknown();
assert!(scheduler.plan(ms(111)).is_some());
}
#[test]
fn cold_gpu_and_late_dispatch_do_not_predict_a_past_or_deliberately_skipped_frame() {
let mut scheduler = FrameScheduler::new(ms(10));
scheduler.presented(ms(100));
scheduler.request(ms(10_101));
let plan = scheduler.plan(ms(10_101)).unwrap();
assert_eq!(plan.render_at, ms(10_101));
assert_eq!(plan.present_at, ms(10_110));
assert_eq!(scheduler.forecast_time(ms(10_111), plan), Some(ms(10_120)));
let mut unknown = FrameScheduler::new(ms(10));
unknown.request(ms(100));
let plan = unknown.plan(ms(100)).unwrap();
assert_eq!(unknown.forecast_time(ms(101), plan), None);
unknown.begin();
unknown.rendered(plan, ms(102), ms(2), true);
unknown.presented(ms(130));
assert_eq!(
unknown.missed_deadlines, 0,
"unknown phase is not a measurable missed deadline"
);
}
}