Skip to main content

mulciber_runtime/
pacing.rs

1//! Presentation pacing diagnostics and scheduling policy.
2//!
3//! This module holds both halves of the pacing vocabulary. [`PacingDiagnostics`] observes the
4//! presented cadence a graphics backend reports and summarizes it. [`FramePacer`] consumes the
5//! same presented timestamps and schedules frame work onto the observed presentation grid, so
6//! simulation advances by whole display intervals instead of by jittery wall-clock gaps between
7//! render starts. Timestamps arrive as plain [`Instant`]s so both halves stay independent of any
8//! particular graphics crate or feedback mechanism, including estimated timestamps where native
9//! feedback is absent.
10
11use std::collections::VecDeque;
12use std::fmt;
13use std::time::{Duration, Instant};
14
15const DEFAULT_INTERVAL_WINDOW: usize = 240;
16/// Intervals required before cadence estimation and missed-interval detection begin.
17const MIN_ESTIMATION_INTERVALS: usize = 10;
18/// An interval longer than the estimated cadence by this factor counts as missed.
19const MISSED_INTERVAL_FACTOR: f64 = 1.5;
20
21/// Accumulates presented-frame timestamps into cadence diagnostics.
22///
23/// Feed every presented frame in presentation order through [`Self::record_presented`] (or
24/// [`Self::record_untimed_presented`] when presentation completed without a display time), then
25/// read summaries with [`Self::report`].
26#[derive(Debug)]
27pub struct PacingDiagnostics {
28    intervals: VecDeque<Duration>,
29    window: usize,
30    last_presented_at: Option<Instant>,
31    presented_frames: u64,
32    untimed_frames: u64,
33    missed_intervals: u64,
34}
35
36impl Default for PacingDiagnostics {
37    fn default() -> Self {
38        Self::new()
39    }
40}
41
42impl PacingDiagnostics {
43    /// Creates diagnostics summarizing the most recent 240 presented intervals.
44    #[must_use]
45    pub fn new() -> Self {
46        Self::with_window(DEFAULT_INTERVAL_WINDOW)
47    }
48
49    /// Creates diagnostics summarizing at most `window` recent presented intervals.
50    ///
51    /// A zero window is treated as a window of one interval.
52    #[must_use]
53    pub fn with_window(window: usize) -> Self {
54        Self {
55            intervals: VecDeque::new(),
56            window: window.max(1),
57            last_presented_at: None,
58            presented_frames: 0,
59            untimed_frames: 0,
60            missed_intervals: 0,
61        }
62    }
63
64    /// Records one presented frame with the display time the backend reported for it.
65    ///
66    /// A timestamp not later than its predecessor contributes no interval; the frame is still
67    /// counted.
68    pub fn record_presented(&mut self, presented_at: Instant) {
69        self.presented_frames += 1;
70        if let Some(previous) = self.last_presented_at
71            && let Some(interval) = presented_at.checked_duration_since(previous)
72            && !interval.is_zero()
73        {
74            if let Some(cadence) = self.estimated_cadence()
75                && interval.as_secs_f64() > cadence.as_secs_f64() * MISSED_INTERVAL_FACTOR
76            {
77                self.missed_intervals += 1;
78            }
79            if self.intervals.len() >= self.window {
80                self.intervals.pop_front();
81            }
82            self.intervals.push_back(interval);
83        }
84        self.last_presented_at = Some(presented_at);
85    }
86
87    /// Records one frame whose presentation completed without a reported display time, such as
88    /// while the window is off screen.
89    pub fn record_untimed_presented(&mut self) {
90        self.presented_frames += 1;
91        self.untimed_frames += 1;
92    }
93
94    /// Estimates the display cadence as the median of the recent presented intervals.
95    ///
96    /// Returns `None` until enough intervals have been observed to estimate responsibly.
97    #[must_use]
98    pub fn estimated_cadence(&self) -> Option<Duration> {
99        if self.intervals.len() < MIN_ESTIMATION_INTERVALS {
100            return None;
101        }
102        let mut sorted: Vec<Duration> = self.intervals.iter().copied().collect();
103        sorted.sort_unstable();
104        Some(sorted[(sorted.len() - 1) / 2])
105    }
106
107    /// Summarizes everything recorded so far.
108    #[must_use]
109    pub fn report(&self) -> PacingReport {
110        let mut sorted: Vec<Duration> = self.intervals.iter().copied().collect();
111        sorted.sort_unstable();
112        let recent_intervals = (!sorted.is_empty()).then(|| IntervalSummary {
113            samples: sorted.len(),
114            min: sorted[0],
115            median: sorted[(sorted.len() - 1) / 2],
116            p95: sorted[percentile_rank(sorted.len(), 95)],
117            max: sorted[sorted.len() - 1],
118        });
119        PacingReport {
120            presented_frames: self.presented_frames,
121            untimed_frames: self.untimed_frames,
122            estimated_cadence: self.estimated_cadence(),
123            recent_intervals,
124            missed_intervals: self.missed_intervals,
125        }
126    }
127}
128
129/// Nearest-rank index for a percentile over `length` ascending samples.
130fn percentile_rank(length: usize, percent: usize) -> usize {
131    (percent * length).div_ceil(100).max(1) - 1
132}
133
134/// Presented feedback older than this no longer anchors the presentation grid; scheduling falls
135/// back to wall-clock timing until fresh feedback arrives, such as after occlusion or resume.
136const PACING_STALENESS_LIMIT: Duration = Duration::from_millis(250);
137
138/// Derives display-cadence frame deltas from presented-frame feedback.
139///
140/// Feed every presented frame through [`Self::record_presented`] (or
141/// [`Self::record_untimed_presented`] when presentation completed without a display time), then
142/// ask [`Self::schedule`] when a frame is about to be built. While a cadence estimate and fresh
143/// feedback exist, each frame delta is a whole number of display intervals: one interval
144/// normally, more when the wall-clock gap since the previous schedule shows the display consumed
145/// extra intervals. Without an estimate, or when feedback goes stale, deltas observably fall back
146/// to raw wall-clock gaps.
147///
148/// Deltas quantize to the cadence instead of following the wall clock because a FIFO-presented
149/// backend displays exactly one frame per display interval even when frame building starts at
150/// irregular times; physically measured on the Wayland/KWin tier, presented intervals stayed
151/// within ±0.4 ms of the cadence while build starts jittered by ±7 ms, so wall-clock deltas
152/// animate that jitter onto a steady display. The same measurements showed presented feedback
153/// arrives about two frames late with drain-latency bias in its absolute placement, so this
154/// policy deliberately owns no absolute frame-start scheduling: an earlier revision that slept
155/// toward grid instants extrapolated from feedback timestamps paired whole-interval delta errors
156/// on the same tier.
157#[derive(Debug)]
158pub struct FramePacer {
159    diagnostics: PacingDiagnostics,
160    last_presented: Option<Instant>,
161    last_schedule_at: Option<Instant>,
162}
163
164impl Default for FramePacer {
165    fn default() -> Self {
166        Self::new()
167    }
168}
169
170impl FramePacer {
171    /// Creates a pacer with no observed presentation feedback.
172    #[must_use]
173    pub fn new() -> Self {
174        Self {
175            diagnostics: PacingDiagnostics::new(),
176            last_presented: None,
177            last_schedule_at: None,
178        }
179    }
180
181    /// Records one presented frame with the display time the backend reported for it.
182    pub fn record_presented(&mut self, presented_at: Instant) {
183        self.diagnostics.record_presented(presented_at);
184        self.last_presented = Some(match self.last_presented {
185            Some(previous) => previous.max(presented_at),
186            None => presented_at,
187        });
188    }
189
190    /// Records one frame whose presentation completed without a reported display time.
191    pub fn record_untimed_presented(&mut self) {
192        self.diagnostics.record_untimed_presented();
193    }
194
195    /// Re-anchors schedule gap measurement at `now`, discarding the time since the previous
196    /// [`Self::schedule`] call.
197    ///
198    /// Call when frame production resumes after a pause so the paused interval does not enter the
199    /// next frame delta as elapsed time.
200    pub const fn resume(&mut self, now: Instant) {
201        self.last_schedule_at = Some(now);
202    }
203
204    /// Summarizes everything the underlying diagnostics recorded so far.
205    #[must_use]
206    pub fn report(&self) -> PacingReport {
207        self.diagnostics.report()
208    }
209
210    /// Schedules the frame about to be built, given the current wall-clock instant.
211    pub fn schedule(&mut self, now: Instant) -> FrameSchedule {
212        let elapsed = self.last_schedule_at.map_or(Duration::ZERO, |previous| {
213            now.saturating_duration_since(previous)
214        });
215        self.last_schedule_at = Some(now);
216        match self.display_intervals(now, elapsed) {
217            Some(frame_delta) => FrameSchedule {
218                frame_delta,
219                paced: true,
220            },
221            None => FrameSchedule {
222                frame_delta: elapsed,
223                paced: false,
224            },
225        }
226    }
227
228    /// Quantizes `elapsed` to whole display intervals, or `None` when cadence or fresh feedback
229    /// is missing.
230    fn display_intervals(&self, now: Instant, elapsed: Duration) -> Option<Duration> {
231        let cadence = self.diagnostics.estimated_cadence()?;
232        let presented = self.last_presented?;
233        if now.saturating_duration_since(presented) > PACING_STALENESS_LIMIT {
234            return None;
235        }
236        // Whole interval count with a one-interval floor and a quarter-interval of slack: the
237        // display consumed at least one interval no matter how quickly this schedule followed
238        // the previous one, and measured build-start spikes reach 1.7 intervals on frames that
239        // still made their display slot while genuinely missed slots start at two intervals, so
240        // gaps count an extra interval only past one and three-quarters.
241        let slack = cadence / 4;
242        let intervals = (elapsed + slack).as_nanos() / cadence.as_nanos();
243        u32::try_from(intervals.max(1))
244            .ok()
245            .map(|intervals| cadence * intervals)
246    }
247}
248
249/// One frame's pacing decision: how much time the frame advances and how that was derived.
250#[derive(Clone, Copy, Debug, Eq, PartialEq)]
251#[must_use = "a schedule carries the frame delta the simulation should consume"]
252pub struct FrameSchedule {
253    frame_delta: Duration,
254    paced: bool,
255}
256
257impl FrameSchedule {
258    /// A zero-delta unpaced schedule for frames that advance no time, such as while suspended.
259    pub(crate) const fn idle() -> Self {
260        Self {
261            frame_delta: Duration::ZERO,
262            paced: false,
263        }
264    }
265
266    /// Returns how much time this frame advances relative to the previous schedule.
267    #[must_use]
268    pub const fn frame_delta(self) -> Duration {
269        self.frame_delta
270    }
271
272    /// Returns whether the delta is a whole number of observed display intervals rather than a
273    /// wall-clock fallback.
274    #[must_use]
275    pub const fn paced(self) -> bool {
276        self.paced
277    }
278}
279
280/// Distribution of the retained recent presented intervals.
281#[derive(Clone, Copy, Debug, Eq, PartialEq)]
282pub struct IntervalSummary {
283    /// Number of intervals summarized.
284    pub samples: usize,
285    /// Shortest retained interval.
286    pub min: Duration,
287    /// Median retained interval.
288    pub median: Duration,
289    /// Nearest-rank 95th-percentile interval.
290    pub p95: Duration,
291    /// Longest retained interval.
292    pub max: Duration,
293}
294
295/// A point-in-time summary of presentation pacing.
296#[derive(Clone, Copy, Debug, Eq, PartialEq)]
297pub struct PacingReport {
298    /// Presented frames recorded, timed and untimed.
299    pub presented_frames: u64,
300    /// Presented frames that carried no display time.
301    pub untimed_frames: u64,
302    /// Median-of-window cadence estimate, once enough intervals exist.
303    pub estimated_cadence: Option<Duration>,
304    /// Distribution of the retained recent intervals, once any interval exists.
305    pub recent_intervals: Option<IntervalSummary>,
306    /// Intervals that exceeded 1.5 times the running cadence estimate.
307    pub missed_intervals: u64,
308}
309
310impl fmt::Display for PacingReport {
311    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
312        write!(
313            formatter,
314            "{} presented frames ({} without a display time)",
315            self.presented_frames, self.untimed_frames
316        )?;
317        if let Some(cadence) = self.estimated_cadence {
318            write!(
319                formatter,
320                ", estimated cadence {:.3} ms",
321                cadence.as_secs_f64() * 1_000.0
322            )?;
323        }
324        if let Some(intervals) = self.recent_intervals {
325            write!(
326                formatter,
327                ", recent intervals (n={}) min {:.3} ms, median {:.3} ms, p95 {:.3} ms, max {:.3} ms",
328                intervals.samples,
329                intervals.min.as_secs_f64() * 1_000.0,
330                intervals.median.as_secs_f64() * 1_000.0,
331                intervals.p95.as_secs_f64() * 1_000.0,
332                intervals.max.as_secs_f64() * 1_000.0,
333            )?;
334        }
335        write!(formatter, ", {} missed intervals", self.missed_intervals)
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use std::time::{Duration, Instant};
342
343    use super::{FramePacer, MIN_ESTIMATION_INTERVALS, PACING_STALENESS_LIMIT, PacingDiagnostics};
344
345    const STEP: Duration = Duration::from_micros(16_667);
346
347    /// Feeds `count` steady presents and returns the pacer with the last presented instant.
348    fn pacer_after_steady_presents(count: u32) -> (FramePacer, Instant) {
349        let mut pacer = FramePacer::new();
350        let base = Instant::now();
351        let mut at = base;
352        for _ in 1..count {
353            pacer.record_presented(at);
354            at += STEP;
355        }
356        pacer.record_presented(at);
357        (pacer, at)
358    }
359
360    fn diagnostics_after_steady_frames(count: usize) -> (PacingDiagnostics, Instant) {
361        let mut diagnostics = PacingDiagnostics::new();
362        let base = Instant::now();
363        let mut at = base;
364        for _ in 0..count {
365            diagnostics.record_presented(at);
366            at += STEP;
367        }
368        (diagnostics, at)
369    }
370
371    #[test]
372    fn steady_frames_estimate_their_cadence() {
373        let (diagnostics, _) = diagnostics_after_steady_frames(60);
374        let report = diagnostics.report();
375        assert_eq!(report.presented_frames, 60);
376        assert_eq!(report.untimed_frames, 0);
377        assert_eq!(report.estimated_cadence, Some(STEP));
378        assert_eq!(report.missed_intervals, 0);
379        let intervals = report.recent_intervals.unwrap();
380        assert_eq!(intervals.samples, 59);
381        assert_eq!(intervals.min, STEP);
382        assert_eq!(intervals.max, STEP);
383    }
384
385    #[test]
386    fn a_skipped_vsync_counts_as_missed_once_estimation_exists() {
387        let (mut diagnostics, mut at) = diagnostics_after_steady_frames(30);
388        at += STEP;
389        diagnostics.record_presented(at);
390        assert_eq!(diagnostics.report().missed_intervals, 1);
391        assert_eq!(diagnostics.report().estimated_cadence, Some(STEP));
392    }
393
394    #[test]
395    fn estimation_requires_enough_intervals() {
396        let (diagnostics, _) = diagnostics_after_steady_frames(MIN_ESTIMATION_INTERVALS);
397        assert_eq!(diagnostics.report().estimated_cadence, None);
398        let (diagnostics, _) = diagnostics_after_steady_frames(MIN_ESTIMATION_INTERVALS + 1);
399        assert!(diagnostics.report().estimated_cadence.is_some());
400    }
401
402    #[test]
403    fn untimed_and_non_monotonic_frames_count_without_intervals() {
404        let mut diagnostics = PacingDiagnostics::new();
405        let base = Instant::now();
406        diagnostics.record_presented(base);
407        diagnostics.record_presented(base);
408        diagnostics.record_untimed_presented();
409        let report = diagnostics.report();
410        assert_eq!(report.presented_frames, 3);
411        assert_eq!(report.untimed_frames, 1);
412        assert!(report.recent_intervals.is_none());
413    }
414
415    #[test]
416    fn jittered_schedule_gaps_still_advance_one_display_interval() {
417        // The measured pathology: build starts alternately arrive ~3 ms and ~18 ms apart while
418        // the display consumes one frame per ~13.3 ms interval. Deltas must stay one interval.
419        let (mut pacer, mut presented) = pacer_after_steady_presents(30);
420        let mut now = presented + Duration::from_millis(1);
421        for jitter_ms in [3_u64, 18, 2, 17, 4, 16] {
422            let schedule = pacer.schedule(now);
423            assert!(schedule.paced());
424            assert_eq!(schedule.frame_delta(), STEP, "jitter {jitter_ms} ms");
425            presented += STEP;
426            pacer.record_presented(presented);
427            now += Duration::from_millis(jitter_ms);
428        }
429    }
430
431    #[test]
432    fn a_late_schedule_advances_by_whole_missed_intervals() {
433        let (mut pacer, last_presented) = pacer_after_steady_presents(30);
434        let first_at = last_presented + Duration::from_millis(1);
435        let _ = pacer.schedule(first_at);
436        let second = pacer.schedule(first_at + STEP * 2 + Duration::from_millis(2));
437        assert!(second.paced());
438        assert_eq!(second.frame_delta(), STEP * 2);
439    }
440
441    #[test]
442    fn a_build_start_spike_short_of_two_intervals_stays_one_interval() {
443        let (mut pacer, last_presented) = pacer_after_steady_presents(30);
444        let first_at = last_presented + Duration::from_millis(1);
445        let _ = pacer.schedule(first_at);
446        let spike = pacer.schedule(first_at + STEP + STEP * 7 / 10);
447        assert!(spike.paced());
448        assert_eq!(spike.frame_delta(), STEP);
449    }
450
451    #[test]
452    fn back_to_back_schedules_keep_the_one_interval_floor() {
453        let (mut pacer, last_presented) = pacer_after_steady_presents(30);
454        let now = last_presented + Duration::from_millis(1);
455        let first = pacer.schedule(now);
456        let second = pacer.schedule(now + Duration::from_millis(1));
457        assert_eq!(first.frame_delta(), STEP);
458        assert_eq!(second.frame_delta(), STEP);
459    }
460
461    #[test]
462    fn missing_and_stale_feedback_fall_back_to_wall_clock() {
463        let mut pacer = FramePacer::new();
464        let base = Instant::now();
465        let unestimated = pacer.schedule(base);
466        assert!(!unestimated.paced());
467        assert_eq!(unestimated.frame_delta(), Duration::ZERO);
468        let next = pacer.schedule(base + Duration::from_millis(7));
469        assert!(!next.paced());
470        assert_eq!(next.frame_delta(), Duration::from_millis(7));
471
472        let (mut pacer, last_presented) = pacer_after_steady_presents(30);
473        let _ = pacer.schedule(last_presented + Duration::from_millis(1));
474        let stale_at = last_presented + Duration::from_millis(1) + PACING_STALENESS_LIMIT + STEP;
475        let stale = pacer.schedule(stale_at);
476        assert!(!stale.paced());
477        assert_eq!(stale.frame_delta(), PACING_STALENESS_LIMIT + STEP);
478    }
479
480    #[test]
481    fn the_interval_window_is_bounded() {
482        let mut diagnostics = PacingDiagnostics::with_window(4);
483        let mut at = Instant::now();
484        for _ in 0..20 {
485            diagnostics.record_presented(at);
486            at += STEP;
487        }
488        assert_eq!(diagnostics.report().recent_intervals.unwrap().samples, 4);
489    }
490}