Schedule DRM frames against per-output presentation deadlines

This commit is contained in:
Samuel Onoja committed 2026-10-01 17:22:34 +01:00
1 parent 9666e4ff6c
commit a40b899b8c
10 files changed
+1168 -129

No files matched your search

+368
View File
@@ -0,0 +1,368 @@
//! 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);
}
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) -> Duration {
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 now;
}
let earliest = self
.last_callback
.map_or(now, |last| last + self.interval)
.max(target)
.max(now);
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)), ms(120));
}
#[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), now);
scheduler.callback_sent(now);
assert_eq!(scheduler.callback_deadline(now, now), 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"
);
}
}