From 5d1e0aeb29b112c8496c4828518e1c32f38d2f5b Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Tue, 3 Nov 2026 22:14:47 +0530 Subject: [PATCH] shim: a late start keeps its slot's timeline; drop the next-grid-instant release after a late start Reported on app master with payload 20261103.6 (shim 762e559): the FPS limiter set to 20 under gamescope on a 120 Hz panel left MGS V: The Phantom Pain at 40-50 fps; at 60 Hz the cap held. Reproduced on the Retroid Pocket 6 at 120 Hz with DTD at 1920x1080, cap 20, env mailbox: census `declared=mailbox requested=immediate source=env cap=20 gate=vsync`, `gate_holds` frozen at 188 for the rest of the session while `gate_late` grew by one per frame, frame spacing 17.2-20 ms (DTD's own ~53 fps). The cap reached the shim; the gate stopped holding. Cause: GameNative/gamenative-drm#47 (1cc98e1) added, next to the fix for the cap-rate FIFO cell (gate off under that declaration, kept here), a rule that the release after a late start is never more than one grid instant after it. A cap below the display's rate holds the guest for a whole slot (six vblanks at 20 on 120 Hz); after that rule fired once the next release was one vblank after the late start, a guest whose frame outlasts one period plus ON_TIME_NS (0.5 ms) was late for that release too, and so for every release after: the gate never held again. At 120 Hz (8.3 ms period) that is every game; at 60 Hz a guest faster than ~58 fps recovers, which is why the 60 Hz panel hid it. - icd_shim/src/vsync_gate.rs: Schedule::book: a start late by less than a period is booked to its release point as before; a start late by a period or more but still inside its slot (before `ideal + interval`) is booked to the grid instant it is in and keeps the slot's ideal timeline, so the release after it is where the cap puts it, at most the rest of the slot away; a start past the whole slot restarts the timeline from the instant it is in, so a stall carries no debt. The cap-rate cell is unchanged (slot == period). Tests: the late-frame-on-a-divisor-cap test now expects the slot's release (two vblanks at 30 on 60) and covers late by a whole vblank and past the slot; a new test runs a guest with 18 ms frames, 1 ms late wake-ups and one 80 ms hitch under cap 20 on 120 Hz (and 22 ms frames under 20 on 60 Hz) and requires the 6- (3-) vblank cadence everywhere but the three releases after the hitch, which catch the slot's timeline up, and never more releases than the cap over the run. The existing 16 ms guest on a 59.97 Hz grid under cap 60 still gets every vblank. - docs/15: the gate paragraph states the rule and why the next-grid-instant release is gone. --- docs/15-fps-limiter-pacing-and-vsync.md | 13 ++- icd_shim/src/vsync_gate.rs | 109 +++++++++++++++++++----- 2 files changed, 97 insertions(+), 25 deletions(-) diff --git a/docs/15-fps-limiter-pacing-and-vsync.md b/docs/15-fps-limiter-pacing-and-vsync.md index 87e06b1..abc75a7 100644 --- a/docs/15-fps-limiter-pacing-and-vsync.md +++ b/docs/15-fps-limiter-pacing-and-vsync.md @@ -149,10 +149,15 @@ that rate is the display's. gamescope took the previous one and has the period, less gamescope's margin, to reach the next composite. A cap at the display's rate releases every vblank, a divisor cap every `refresh / cap`-th, any other cap below the rate the vblank nearest each instant - of an ideal `1e9 / cap` timeline; a frame late for its point starts at once and the - release after it is never more than one grid instant later, so a guest below the cap - is never quantized to a divisor; a phase - push never lets two releases into one vblank. The gate this replaces ran on the shim's + of an ideal `1e9 / cap` timeline; a frame late for its point starts at once and keeps + its slot's timeline (the release after it is where the cap puts it, at most the rest of + the slot away; only a start past the whole slot restarts the timeline, so a stall + carries no debt), so a guest below the cap is never quantized to a divisor; a phase + push never lets two releases into one vblank. The release after a late start is never + shortened to the next grid instant: that rule (the first shim of payload 20261103.4) + made every guest slower than one period plus 0.5 ms late for every release, and a cap + below the display's rate stopped holding at all (cap 20 on a 120 Hz panel ran at the + guest's own 40-55 fps, `gate_holds` frozen and `gate_late` up by one per frame). The gate this replaces ran on the shim's own clock (exactly 1e9 / N ns) and beat against the display's 59.97-59.99 Hz (57-59 fps at a 60 cap). A cap above the rate runs on the cap's own clock (`gate=clock`, also frame generation's source rate). Without the stamp lead (no diff --git a/icd_shim/src/vsync_gate.rs b/icd_shim/src/vsync_gate.rs index 37c6c55..d479d0a 100644 --- a/icd_shim/src/vsync_gate.rs +++ b/icd_shim/src/vsync_gate.rs @@ -239,15 +239,24 @@ impl Schedule { /// (on time, or late by less than a period) is booked to that release /// point, and the schedule goes on from there: the next release is /// `interval` further along the ideal timeline, on the grid instant - /// nearest it. A start later than that, or the first, starts the timeline - /// again from the grid instant at or before it, so a stall carries no debt - /// and a late frame is never held for a whole slot. The release point is - /// read on the grid as it is now: the daemon re-pushes the phase as it - /// moves, and a point booked on the old phase must not let two releases - /// into one period of the new one. Starts later than [`ON_TIME_NS`] after - /// their point are counted (`gate_late`), with the points passed (`skipped`), - /// and the release after a late start is never more than one grid instant - /// after it. + /// nearest it. A start later than that but still inside its slot (before + /// `interval` has passed on the ideal timeline) is booked to the grid + /// instant it is in and keeps the slot's timeline, so the release after it + /// is at most the rest of the slot away and the cap holds on average. A + /// start past the whole slot, or the first, starts the timeline again from + /// the grid instant at or before it, so a stall carries no debt. The + /// release point is read on the grid as it is now: the daemon re-pushes + /// the phase as it moves, and a point booked on the old phase must not let + /// two releases into one period of the new one. Starts later than + /// [`ON_TIME_NS`] after their point are counted (`gate_late`), with the + /// points passed (`skipped`). + /// + /// The release after a late start is never shortened to the next grid + /// instant: that rule (the first shim of payload 20261103.4) let a guest + /// whose frame outlasts one grid period plus [`ON_TIME_NS`] arrive late for + /// every release, so a cap below the display's rate stopped holding at all + /// (cap 20 on a 120 Hz panel ran at the guest's own 40-55 fps; at 60 Hz + /// only a guest faster than ~58 fps recovered). pub(crate) fn book(&mut self, now_ns: u64, grid: Grid, interval_ns: u64) -> u64 { if grid.period_ns == 0 { return self.book_clock(now_ns, interval_ns); @@ -263,21 +272,17 @@ impl Schedule { } let (slot, ideal) = match due { Some(due) if at <= due => (due, self.ideal_ns), + // Late by a period or more but still inside its slot: the slot's + // timeline goes on, so the frame after this one is due where the + // cap says, not a whole interval after a late start. + Some(_) if now_ns < self.ideal_ns.saturating_add(interval_ns) => (at, self.ideal_ns), _ => (at, at), }; let ideal_next = ideal.saturating_add(interval_ns); // Never two releases on one grid instant. - let mut next = grid + let next = grid .nearest(ideal_next) .max(slot.saturating_add(grid.period_ns)); - let mut ideal_next = ideal_next; - if late.is_some() && next > at.saturating_add(grid.period_ns) { - // A late start already cost the guest time: the release after it - // is the next grid instant after it, never a whole slot further, - // and the timeline goes on from there. - next = at.saturating_add(grid.period_ns); - ideal_next = next; - } self.ideal_ns = ideal_next; self.next_ns = next; next @@ -523,20 +528,82 @@ mod tests { } #[test] - fn a_late_frame_on_a_divisor_cap_waits_for_no_more_than_the_next_grid_instant() { + fn a_late_frame_on_a_divisor_cap_keeps_its_slots_timeline() { // 30 on 60, the guest late by half a vblank: it starts at once, and the - // release after it is the next grid instant, not a whole slot later. + // release after it is where the cap puts it, two vblanks after the + // point this one was released for. let g = grid(P60); let iv = interval(pacing(30, Some(g))); let mut s = Schedule::default(); let due = s.book(g.anchor_ns, g, iv); assert_eq!(due, g.anchor_ns + 2 * P60); - assert_eq!(s.book(due + P60 / 2, g, iv), due + P60); + assert_eq!(s.book(due + P60 / 2, g, iv), due + 2 * P60); + // Late by a whole vblank, still inside the slot: the release after it + // is still the slot's, so the hold is the rest of the slot, not a + // whole slot from the late start. + let next = s.next_ns; + assert_eq!(s.book(next + P60 + 100_000, g, iv), next + 2 * P60); + // Past the whole slot: the timeline restarts from the start, no debt. + let next = s.next_ns; + assert_eq!(s.book(next + 2 * P60 + 100_000, g, iv), next + 4 * P60); // An on-time guest is still held to every second vblank. let next = s.next_ns; assert_eq!(s.book(next + 50_000, g, iv), next + 2 * P60); } + /// Releases of a guest whose acquire-to-acquire time is `frame_ns`, with + /// its wait returning `wake_ns` late, under `cap` on `g`, for `n` frames + /// with one `hitch_ns` stall at frame 100: the gaps, in vblanks, between + /// consecutive releases. + fn gaps_of_a_slow_guest(g: Grid, cap: u32, frame_ns: u64, wake_ns: u64, hitch_ns: u64, n: usize) -> Vec { + let iv = interval(pacing(cap, Some(g))); + let mut s = Schedule::default(); + let mut t = g.anchor_ns; + let mut starts = Vec::new(); + for i in 0..n { + if s.next_ns != 0 && t < s.next_ns { + t = s.next_ns + wake_ns; + } + starts.push(t); + s.book(t, g, iv); + t += frame_ns + if i == 100 { hitch_ns } else { 0 }; + } + vblanks(g, &starts).windows(2).map(|w| w[1] - w[0]).collect() + } + + /// `gaps` holds the cap's cadence `every` vblanks, except the release after + /// the hitch at index 100 and the two after it, which catch the slot's + /// timeline up (a start inside its slot keeps the timeline); over the whole + /// run there are never more releases than the cap allows. + fn assert_cadence(gaps: &[u64], every: u64) { + let off: Vec = gaps + .iter() + .enumerate() + .filter(|&(i, &gap)| gap != every && !(100..103).contains(&i)) + .map(|(i, _)| i) + .collect(); + assert!(off.is_empty(), "releases off the {every}-vblank cadence at {off:?}: {gaps:?}"); + assert!(gaps[100] > every, "the hitch frame was not late: {:?}", &gaps[98..104]); + let total: u64 = gaps.iter().sum(); + assert!(total >= every * gaps.len() as u64, "more releases than the cap: {:?}", &gaps[98..104]); + } + + #[test] + fn a_guest_slower_than_a_vblank_is_held_to_a_cap_below_the_rate() { + // Cap 20 on a 120 Hz grid, 18 ms frames (the guest alone would run at + // 55 fps), every wake-up 1 ms late (late for its point) and one 80 ms + // hitch: the releases stay on every sixth vblank, and the cadence is + // back within three releases of the hitch. On the first shim of + // payload 20261103.4 the first late start was followed at the next + // vblank, the 18 ms frame was late for that too, and the cap never + // held again (the guest ran at its own rate). + let gaps = gaps_of_a_slow_guest(grid(P120), 20, 18_000_000, 1_000_000, 80_000_000, 600); + assert_cadence(&gaps, 6); + // 22 ms frames at 60 Hz under cap 20: every third vblank. + let gaps = gaps_of_a_slow_guest(grid(P60), 20, 22_000_000, 1_000_000, 80_000_000, 600); + assert_cadence(&gaps, 3); + } + #[test] fn a_guest_with_16_ms_of_work_is_never_skipped() { // 16 ms from acquire to the next acquire on a 59.97 Hz grid, and every