atagen

monolith

public
Star0
Watch0

Commit a1f375a8b4be

tarball

atagen <boss@atagen.co>2026-10-19 09:49 UTC

8 files changed+258-0
timing: add wp_commit_timing_v1 against each output's vblank timeline

Proton and newer Mesa stamp commits with a target presentation time, and smithay only parks them behind a barrier that the compositor has to signal. Each stamped commit is released so the next render lands on the first vblank at or after its timestamp, using the DRM budget and last vblank or the host's frame callbacks when nested. Surfaces on no output and outputs without a timeline release at once, so a hidden client never waits on a clock nobody keeps.
Modified file: crates/monolith/src/backend.rs+10 additions
@@ -3,6 +3,7 @@
33
44 use crate::drm::Drm;
55 use crate::nested::Host;
6+use crate::timing::Timeline;
67 use monolith_layout::{OutputId, Rect};
78 use smithay::backend::drm::DrmNode;
89 use smithay::output::Output;
@@ -54,6 +55,15 @@ impl Backend {
5455 }
5556 }
5657
58+ /// When `output` presents, if that is known yet.
59+ pub fn timeline(&self, output: &Output) -> Option<Timeline> {
60+ match self {
61+ Backend::Drm(d) => d.timeline(output),
62+ Backend::Nested(h) => (h.output == *output).then(|| h.timeline()).flatten(),
63+ Backend::Starting => None,
64+ }
65+ }
66+
5767 /// The node clients allocate dmabufs on, known on bare metal.
5868 pub fn render_node(&self) -> Option<DrmNode> {
5969 self.drm().map(|d| d.render_node)
Modified file: crates/monolith/src/commit.rs+1 additions
@@ -26,6 +26,7 @@ impl CompositorHandler for State {
2626
2727 fn new_surface(&mut self, surface: &WlSurface) {
2828 add_pre_commit_hook::<Self, _>(surface, |state, _dh, surface| {
29+ crate::timing::watch(state, surface);
2930 hold_until_readable(state, surface);
3031 });
3132 }
Modified file: crates/monolith/src/drm.rs+9 additions
@@ -678,6 +678,15 @@ impl Drm {
678678 .find(|s| s.id == id)
679679 .map(|s| s.output.clone())
680680 }
681+
682+ pub fn timeline(&self, output: &Output) -> Option<crate::timing::Timeline> {
683+ let s = self.surfaces.values().find(|s| s.output == *output)?;
684+ Some(crate::timing::Timeline {
685+ last: s.last_vblank_ns?,
686+ refresh: if s.vrr_on { 0 } else { s.refresh_ns },
687+ budget: s.budget.ns(),
688+ })
689+ }
681690 }
682691
683692 fn on_vblank(state: &mut State, crtc: crtc::Handle, meta: &mut Option<DrmEventMetadata>) {
Modified file: crates/monolith/src/globals.rs+3 additions
@@ -10,6 +10,7 @@ use smithay::backend::renderer::ImportDma;
1010 use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel::WmCapabilities;
1111 use smithay::reexports::wayland_server::DisplayHandle;
1212 use smithay::utils::{ClockSource, Monotonic};
13+use smithay::wayland::commit_timing::CommitTimingManagerState;
1314 use smithay::wayland::compositor::CompositorState;
1415 use smithay::wayland::cursor_shape::CursorShapeManagerState;
1516 use smithay::wayland::dmabuf::{DmabufFeedbackBuilder, DmabufGlobal, DmabufState};
@@ -78,6 +79,7 @@ pub struct Protocols {
7879 _relative_pointer: RelativePointerManagerState,
7980 _pointer_constraints: PointerConstraintsState,
8081 _fifo: FifoManagerState,
82+ _commit_timing: CommitTimingManagerState,
8183 }
8284
8385 impl Protocols {
@@ -146,6 +148,7 @@ impl Protocols {
146148 _relative_pointer: RelativePointerManagerState::new::<State>(dh),
147149 _pointer_constraints: PointerConstraintsState::new::<State>(dh),
148150 _fifo: FifoManagerState::new::<State>(dh),
151+ _commit_timing: CommitTimingManagerState::new::<State>(dh),
149152 }
150153 }
151154 }
Modified file: crates/monolith/src/main.rs+1 additions
@@ -32,6 +32,7 @@ mod seat;
3232 mod signals;
3333 mod state;
3434 mod stats;
35+mod timing;
3536 mod toplevels;
3637 mod windows;
3738 mod wm;
Modified file: crates/monolith/src/nested/mod.rs+14 additions
@@ -59,6 +59,7 @@ pub struct Host {
5959 /// The host clock is CLOCK_MONOTONIC, so its timestamps share our timeline.
6060 monotonic: bool,
6161 last_present_ns: Option<u64>,
62+ last_frame_ns: Option<u64>,
6263 refresh_ns: u64,
6364 feedback: Vec<(u64, OutputPresentationFeedback)>,
6465 next_feedback: u64,
@@ -76,6 +77,17 @@ impl Host {
7677 None => now,
7778 }
7879 }
80+
81+ pub fn timeline(&self) -> Option<crate::timing::Timeline> {
82+ Some(crate::timing::Timeline {
83+ last: self
84+ .last_present_ns
85+ .filter(|_| self.monotonic)
86+ .or(self.last_frame_ns)?,
87+ refresh: self.refresh_ns,
88+ budget: 0,
89+ })
90+ }
7991 }
8092
8193 /// Destroys the host-side buffer when its swapchain slot goes away.
@@ -163,6 +175,7 @@ pub fn init(
163175 presentation,
164176 monotonic: false,
165177 last_present_ns: None,
178+ last_frame_ns: None,
166179 refresh_ns: 16_666_667,
167180 feedback: Vec::new(),
168181 next_feedback: 0,
@@ -556,6 +569,7 @@ impl Dispatch<wl_callback::WlCallback, ()> for State {
556569 Kind::empty(),
557570 );
558571 }
572+ host.last_frame_ns = Some(crate::state::now_ns());
559573 let output = host.output.clone();
560574 crate::fifo::presented(state, &output);
561575 }
Modified file: crates/monolith/src/state.rs+2 additions
@@ -101,6 +101,7 @@ pub struct Services {
101101 pub power: crate::power::Power,
102102 pub commits: crate::stats::CommitStats,
103103 pub fifo: crate::fifo::Fifo,
104+ pub timing: crate::timing::Timing,
104105 }
105106
106107 /// The config and what is derived from it, swapped whole on reload.
@@ -222,6 +223,7 @@ impl State {
222223 power: Default::default(),
223224 commits: Default::default(),
224225 fifo: Default::default(),
226+ timing: Default::default(),
225227 },
226228 })
227229 }
Added file: crates/monolith/src/timing.rs+218 additions
@@ -0,0 +1,218 @@
1+//! wp_commit_timing_v1 timestamps. A commit holds until the first frame
2+//! that can still be rendered and presents at or after its timestamp.
3+
4+use crate::pacing;
5+use crate::state::{State, now_ns};
6+use smithay::output::Output;
7+use smithay::reexports::calloop::RegistrationToken;
8+use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
9+use smithay::reexports::rustix::time::Timespec;
10+use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
11+use smithay::reexports::wayland_server::{Resource, Weak};
12+use smithay::utils::{Monotonic, Time};
13+use smithay::wayland::commit_timing::{
14+ CommitTimerBarrierStateUserData, CommitTimerStateUserData, Timestamp,
15+};
16+use smithay::wayland::compositor::with_states;
17+use std::time::Duration;
18+
19+/// A timestamp may lie this fraction of a refresh past a vblank and still
20+/// count for it. Clients aim at the vblank they computed, which our own
21+/// estimate misses by microseconds.
22+const SLACK_DIVISOR: u64 = 8;
23+/// Past the start of the previous frame's render, so the wake never beats
24+/// that frame's own timer to the commit.
25+const AFTER_START_NS: u64 = 1_000_000;
26+/// Timers for timestamps further out than this run again to recompute.
27+const LONGEST_WAIT: Duration = Duration::from_secs(10);
28+
29+/// What a backend knows of one output's vblank timeline.
30+#[derive(Clone, Copy)]
31+pub struct Timeline {
32+ pub last: u64,
33+ /// Zero when vblanks are not on a fixed grid, as under VRR.
34+ pub refresh: u64,
35+ pub budget: u64,
36+}
37+
38+#[derive(Default)]
39+pub struct Timing {
40+ waiting: Vec<Weak<WlSurface>>,
41+ timer: Option<RegistrationToken>,
42+}
43+
44+/// Notes a surface about to commit a timestamp, ahead of the smithay hook
45+/// that turns it into a blocker.
46+pub fn watch(state: &mut State, surface: &WlSurface) {
47+ let stamped = with_states(surface, |s| {
48+ s.data_map
49+ .get::<CommitTimerStateUserData>()
50+ .is_some_and(|t| t.borrow().timestamp.is_some())
51+ });
52+ if !stamped {
53+ return;
54+ }
55+ let waiting = &mut state.services.timing.waiting;
56+ if !waiting.iter().any(|w| w == surface) {
57+ waiting.push(surface.downgrade());
58+ }
59+ wake(state, Duration::ZERO);
60+}
61+
62+fn wake(state: &mut State, after: Duration) {
63+ if let Some(token) = state.services.timing.timer.take() {
64+ state.core.handle.remove(token);
65+ }
66+ let token = state
67+ .core
68+ .handle
69+ .insert_source(Timer::from_duration(after), |_, _, state| {
70+ state.services.timing.timer = None;
71+ run(state);
72+ TimeoutAction::Drop
73+ });
74+ match token {
75+ Ok(token) => state.services.timing.timer = Some(token),
76+ Err(e) => tracing::warn!("could not arm the commit timing timer: {e}"),
77+ }
78+}
79+
80+fn nanos(t: Timestamp) -> u64 {
81+ let t = Timespec::from(Time::<Monotonic>::from(t));
82+ let secs = u64::try_from(t.tv_sec).unwrap_or(0);
83+ let sub = u64::try_from(t.tv_nsec).unwrap_or(0).min(999_999_999);
84+ secs.saturating_mul(1_000_000_000).saturating_add(sub)
85+}
86+
87+fn stamp(ns: u64) -> Timestamp {
88+ Time::<Monotonic>::from(Timespec {
89+ tv_sec: (ns / 1_000_000_000) as _,
90+ tv_nsec: (ns % 1_000_000_000) as _,
91+ })
92+ .into()
93+}
94+
95+fn output_of(state: &State, surface: &WlSurface) -> Option<Output> {
96+ let root = crate::idle::root_surface(surface);
97+ match state.wm.id_of(&root).and_then(|id| state.wm.window(id)) {
98+ Some(window) => state
99+ .wm
100+ .space
101+ .outputs_for_element(window)
102+ .into_iter()
103+ .next(),
104+ None => state.wm.space.outputs().next().cloned(),
105+ }
106+}
107+
108+fn run(state: &mut State) {
109+ let now = now_ns();
110+ let mut clients = Vec::new();
111+ let mut next: Option<u64> = None;
112+ let mut still = Vec::new();
113+ for weak in std::mem::take(&mut state.services.timing.waiting) {
114+ let Ok(surface) = weak.upgrade() else {
115+ continue;
116+ };
117+ let timeline = output_of(state, &surface).and_then(|o| state.backend.timeline(&o));
118+ let pending = with_states(&surface, |s| {
119+ let mut barriers = s
120+ .data_map
121+ .get::<CommitTimerBarrierStateUserData>()?
122+ .lock()
123+ .unwrap();
124+ let mut signaled = false;
125+ let mut wake = None;
126+ while let Some(t) = barriers.next_deadline() {
127+ match release(timeline, nanos(t), now) {
128+ Release::Now(covers) => signaled |= barriers.signal_until(stamp(covers)),
129+ Release::At(at) => {
130+ wake = Some(at);
131+ break;
132+ }
133+ }
134+ }
135+ if signaled {
136+ clients.extend(surface.client());
137+ }
138+ Some(wake)
139+ });
140+ if let Some(Some(at)) = pending {
141+ next = Some(next.map_or(at, |n| n.min(at)));
142+ still.push(weak);
143+ }
144+ }
145+ state.services.timing.waiting.extend(still);
146+ crate::fifo::unblock(state, clients);
147+ if let Some(at) = next {
148+ let after = Duration::from_nanos(at.saturating_sub(now_ns()).max(1_000_000));
149+ wake(state, after.min(LONGEST_WAIT));
150+ }
151+}
152+
153+enum Release {
154+ /// The commit may apply now, and so may every one stamped up to this time.
155+ Now(u64),
156+ /// The commit must wait until this time.
157+ At(u64),
158+}
159+
160+fn release(timeline: Option<Timeline>, target: u64, now: u64) -> Release {
161+ let Some(tl) = timeline else {
162+ return Release::Now(u64::MAX);
163+ };
164+ if tl.refresh == 0 {
165+ let at = target.saturating_sub(tl.budget);
166+ return if at <= now {
167+ Release::Now(target)
168+ } else {
169+ Release::At(at)
170+ };
171+ }
172+ let slack = tl.refresh / SLACK_DIVISOR;
173+ let Some((reach, _)) = pacing::deadline(tl.last, tl.refresh, tl.budget, now) else {
174+ return Release::Now(u64::MAX);
175+ };
176+ let aimed = target.saturating_sub(slack);
177+ if aimed <= reach {
178+ return Release::Now(reach + slack);
179+ }
180+ let first = (aimed - tl.last).div_ceil(tl.refresh);
181+ let before = tl
182+ .last
183+ .saturating_add((first - 1).saturating_mul(tl.refresh));
184+ Release::At(before.saturating_sub(tl.budget) + AFTER_START_NS)
185+}
186+
187+#[cfg(test)]
188+mod tests {
189+ use super::*;
190+
191+ const TL: Timeline = Timeline {
192+ last: 1_000_000_000,
193+ refresh: 10_000_000,
194+ budget: 2_000_000,
195+ };
196+
197+ #[test]
198+ fn a_timestamp_waits_for_the_frame_after_the_one_being_drawn() {
199+ let now = TL.last + 3_000_000;
200+ let target = TL.last + 30_000_000;
201+ let Release::At(at) = release(Some(TL), target, now) else {
202+ panic!("a timestamp three frames out must wait");
203+ };
204+ assert_eq!(at, TL.last + 20_000_000 - TL.budget + AFTER_START_NS);
205+ let Release::Now(covers) = release(Some(TL), target, at) else {
206+ panic!("the wake must release it");
207+ };
208+ assert!(covers >= target);
209+ }
210+
211+ #[test]
212+ fn a_timestamp_a_hair_past_a_vblank_still_takes_it() {
213+ let now = TL.last + 9_000_000;
214+ let target = TL.last + 20_000_000 + TL.refresh / SLACK_DIVISOR;
215+ assert!(matches!(release(Some(TL), target, now), Release::Now(_)));
216+ assert!(matches!(release(Some(TL), target + 1, now), Release::At(_)));
217+ }
218+}