From a40b899b8cd41ccd6744e21fa8cdc4896c2cf78d Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Thu, 1 Oct 2026 17:22:34 +0100 Subject: [PATCH] Schedule DRM frames against per-output presentation deadlines --- compositor/src/backends/direct.rs | 327 +++++++++++----- compositor/src/backends/direct/scheduling.rs | 141 +++++++ compositor/src/dimming.rs | 13 +- compositor/src/frame_scheduler.rs | 368 +++++++++++++++++++ compositor/src/main.rs | 1 + compositor/src/overview.rs | 30 ++ compositor/src/presentation.rs | 48 +-- compositor/src/state.rs | 3 + compositor/src/state/prediction.rs | 345 +++++++++++++++++ docs/performance-baseline.md | 21 ++ 10 files changed, 1168 insertions(+), 129 deletions(-) create mode 100644 compositor/src/backends/direct/scheduling.rs create mode 100644 compositor/src/frame_scheduler.rs create mode 100644 compositor/src/state/prediction.rs diff --git a/compositor/src/backends/direct.rs b/compositor/src/backends/direct.rs index 20207ef..3320f93 100644 --- a/compositor/src/backends/direct.rs +++ b/compositor/src/backends/direct.rs @@ -4,6 +4,8 @@ use std::io; use std::path::Path; use std::time::{Duration, Instant}; +mod scheduling; +use scheduling::*; use smithay::backend::allocator::Fourcc; use smithay::backend::allocator::gbm::{GbmAllocator, GbmBufferFlags, GbmDevice}; use smithay::backend::drm::{DrmDevice, DrmDeviceFd, DrmEvent, DrmEventTime, DrmNode, GbmBufferedSurface, NodeType}; @@ -163,8 +165,9 @@ struct DirectOutput { damage_tracker: OutputDamageTracker, render_metrics: RenderMetrics, frame_pending: bool, + scheduler: crate::frame_scheduler::FrameScheduler, + render_timer: Option, power_off: bool, - callback_clock: crate::presentation::CallbackClock, callback_timer: Option, lock_frame_pending: bool, } @@ -211,6 +214,7 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() animation_timer: None, animation_active: false, }); + open_primary_device(event_loop, state, &primary_path)?; event_loop .handle() @@ -228,17 +232,20 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() backend.input_devices.push(device); } } + InputEvent::DeviceRemoved { device } => { if let Some(backend) = state.direct_backend.as_mut() { backend.input_devices.retain(|candidate| candidate != &device); } } + event => state.process_input_event(event), })?; event_loop .handle() .insert_source(notifier, move |event, _, state| match event { SessionEvent::PauseSession => { + let handle = state.loop_handle.clone(); if let Some(backend) = state.direct_backend.as_mut() { backend.active = false; backend @@ -248,6 +255,7 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() backend.devices.values_mut().for_each(|device| { device.drm.pause(); device.outputs.values_mut().for_each(|output| { + cancel_output_timers(&handle, output); output.surface.reset_buffers(); output.damage_tracker = OutputDamageTracker::from_output(&output.output); output.frame_pending = false; @@ -255,13 +263,16 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() }); }); } + libinput_context.suspend(); tracing::info!("direct session paused"); } + SessionEvent::ActivateSession => { if libinput_context.resume().is_err() { tracing::error!("failed to resume libinput"); } + if let Some(backend) = state.direct_backend.as_mut() { backend.active = true; for device in backend.devices.values_mut() { @@ -270,6 +281,7 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() } } } + update_power_policy(state); state.reset_animation_clock(); tracing::info!("direct session activated"); @@ -287,11 +299,13 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() tracing::info!(?device_id, ?path, "additional DRM device discovered"); } } + UdevEvent::Changed { device_id } => { if let Some(node) = direct_node_for_device(state, device_id) { rescan_device(state, node); } } + UdevEvent::Removed { device_id } => { if let Some(node) = direct_node_for_device(state, device_id) { remove_device(state, node); @@ -447,6 +461,7 @@ fn open_primary_device( ) .into()); } + state .direct_backend .as_mut() @@ -484,11 +499,13 @@ fn open_primary_device( retired = true; feedback } + Err(error) => { tracing::error!(?node, ?crtc, %error, "failed to retire DRM frame"); None } }); + if let (Some(mut feedback), DrmEventTime::Monotonic(time)) = (feedback, metadata.time) { feedback.presented( Time::::from(time), @@ -497,6 +514,7 @@ fn open_primary_device( Kind::Vsync | Kind::HwClock | Kind::HwCompletion, ); } + let lock_output = state .direct_backend .as_mut() @@ -509,34 +527,59 @@ fn open_primary_device( None } }); + if let Some(output) = lock_output { state.lock_frame_presented(&output); } + if let Some(output) = state .direct_backend .as_mut() .and_then(|backend| backend.devices.get_mut(&node)) .and_then(|device| device.outputs.get_mut(&crtc)) + && retired { output.frame_pending = false; + if let DrmEventTime::Monotonic(timestamp) = metadata.time { + let now = monotonic_now(); + if timestamp <= now + refresh_duration_from_mode(output.mode) { + if let Some(plan) = output.scheduler.presented(timestamp) + && std::env::var_os("FERESE_TRACE_PERFORMANCE").is_some() + { + tracing::debug!(target: "ferese::render", output = %output.output.name(), + target_us = plan.present_at.as_micros(), actual_us = timestamp.as_micros(), + request_to_present_us = timestamp.saturating_sub(plan.requested_at).as_micros(), + missed_deadlines = output.scheduler.missed_deadlines, + "DRM presentation schedule"); + } + } else { + output.scheduler.retire_unknown(); + } + } else { + output.scheduler.retire_unknown(); + } } + state.record_drm_presentation(node, crtc, metadata.time, metadata.sequence); tracing::trace!(?node, ?crtc, sequence = metadata.sequence, "page flip"); let active = state.direct_backend.as_ref().is_some_and(|backend| backend.active); - if active { - let was_animating = state + if active && retired { + schedule_frame_callbacks(state, node, crtc, monotonic_now()); + let identity = state .direct_backend .as_ref() - .is_some_and(|backend| backend.animation_active); - let animating = state.advance_animations(Instant::now()); - if animating || was_animating { - render_outputs(state, animating); + .and_then(|backend| backend.devices.get(&node)) + .and_then(|device| device.outputs.get(&crtc)) + .map(|output| output.output.clone()); + if identity.is_some_and(|output| state.output_has_animations(&output)) { + request_frame(state, node, crtc); } else { - render_output(state, node, crtc); + arm_frame(state, node, crtc); } } } } + DrmEvent::Error(error) => { tracing::error!(?node, %error, "DRM event error"); } @@ -566,16 +609,23 @@ pub(crate) fn sleep_locked_outputs(state: &mut Ferese) { if !state.session_lock.active || !state.session_lock.sleeping { return; } + let Some(backend) = state.direct_backend.as_mut() else { return; }; + for device in backend.devices.values_mut().filter(|device| device.drm.is_active()) { for output in device.outputs.values_mut() { if output.power_off || output.frame_pending { continue; } + match output.surface.surface().clear() { - Ok(()) => output.power_off = true, + Ok(()) => { + cancel_output_timers(&state.loop_handle, output); + output.power_off = true; + } + Err(error) => { tracing::warn!(%error, output = %output.output.name(), "could not sleep locked display") } @@ -609,6 +659,7 @@ fn render_outputs(state: &mut Ferese, animating: bool) { if let Some(backend) = state.direct_backend.as_mut() { backend.animation_active = animating && !state.session_lock.sleeping; } + arm_animation_timer(state); let outputs = state .direct_backend @@ -623,7 +674,7 @@ fn render_outputs(state: &mut Ferese, animating: bool) { .unwrap_or_default(); for (node, crtc) in outputs { - render_output(state, node, crtc); + request_frame(state, node, crtc); } } @@ -697,8 +748,7 @@ fn lid_hides_panel(closed: bool, external_available: bool) -> bool { closed && external_available } -fn render_output(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle) { - schedule_frame_callbacks(state, node, crtc); +fn render_output(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle, plan: crate::frame_scheduler::FramePlan) { if state.session_lock.active && state.session_lock.sleeping { sleep_locked_outputs(state); let asleep = state @@ -711,11 +761,13 @@ fn render_output(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle) { return; } } + let missed_deadlines = state .direct_backend .as_ref() - .and_then(|backend| backend.presentation.get(&(node, crtc))) - .map_or(0, |clock| clock.missed_deadlines); + .and_then(|backend| backend.devices.get(&node)) + .and_then(|device| device.outputs.get(&crtc)) + .map_or(0, |output| output.scheduler.missed_deadlines); let Some(mut device) = state .direct_backend .as_mut() @@ -723,10 +775,12 @@ fn render_output(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle) { else { return; }; + let Some(mut output) = device.outputs.remove(&crtc) else { restore_device(state, node, device); return; }; + if !device.drm.is_active() || output.frame_pending { device.outputs.insert(crtc, output); restore_device(state, node, device); @@ -734,73 +788,92 @@ fn render_output(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle) { } let render_started = Instant::now(); - let rendered = (|| -> Result> { - state.process_dmabuf_imports(&mut device.renderer); - let (mut buffer, age) = output.surface.next_buffer()?; - let elements = animated_window_elements(state, &mut device.renderer, &output.output); - let effects = frame_effect_metrics(&elements, output.output.current_scale().fractional_scale()); - let mut framebuffer = device.renderer.bind(&mut buffer)?; - let result = output.damage_tracker.render_output( - &mut device.renderer, - &mut framebuffer, - usize::from(age), - &elements, - [0.035, 0.04, 0.055, 1.0], - )?; - let cursorless_capture = state.has_pending_screencopy(&output.output, false); - let captured_with_cursor = state.process_screencopies(&mut device.renderer, &framebuffer, &output.output, true); - - if cursorless_capture { - let cursorless_elements = cursorless_window_elements(state, &mut device.renderer, &output.output); - redraw_output( + let now = monotonic_now(); + let horizon = output + .scheduler + .forecast_time(now, plan) + .map_or(Duration::ZERO, |target| target.saturating_sub(now)); + let mut forecast = state.forecast_render(&output.output, horizon); + let rendered = { + let state = &mut *forecast; + (|| -> Result> { + state.process_dmabuf_imports(&mut device.renderer); + let (mut buffer, age) = output.surface.next_buffer()?; + let elements = animated_window_elements(state, &mut device.renderer, &output.output); + let effects = frame_effect_metrics(&elements, output.output.current_scale().fractional_scale()); + let mut framebuffer = device.renderer.bind(&mut buffer)?; + let result = output.damage_tracker.render_output( &mut device.renderer, &mut framebuffer, - &output.output, - &cursorless_elements, + usize::from(age), + &elements, + [0.035, 0.04, 0.055, 1.0], )?; - state.process_screencopies(&mut device.renderer, &framebuffer, &output.output, false); - redraw_output(&mut device.renderer, &mut framebuffer, &output.output, &elements)?; - } else if captured_with_cursor { - let _ = device - .renderer - .render( + let cursorless_capture = state.has_pending_screencopy(&output.output, false); + let captured_with_cursor = + state.process_screencopies(&mut device.renderer, &framebuffer, &output.output, true); + + if cursorless_capture { + let cursorless_elements = cursorless_window_elements(state, &mut device.renderer, &output.output); + redraw_output( + &mut device.renderer, &mut framebuffer, - output.output.current_mode().expect("output has a mode").size, - output.output.current_transform(), - )? - .finish()?; - } - let Some(damage) = result.damage.cloned() else { + &output.output, + &cursorless_elements, + )?; + state.process_screencopies(&mut device.renderer, &framebuffer, &output.output, false); + redraw_output(&mut device.renderer, &mut framebuffer, &output.output, &elements)?; + } else if captured_with_cursor { + let _ = device + .renderer + .render( + &mut framebuffer, + output.output.current_mode().expect("output has a mode").size, + output.output.current_transform(), + )? + .finish()?; + } + + let Some(damage) = result.damage.cloned() else { + output + .render_metrics + .record_no_damage(render_started.elapsed(), missed_deadlines); + return Ok(false); + }; + + let presentation = crate::presentation::take_output_feedback( + state, + &output.output, + &result.states, + Kind::Vsync | Kind::HwClock | Kind::HwCompletion, + ); + output + .surface + .queue_buffer(Some(result.sync), Some(damage.clone()), presentation)?; output .render_metrics - .record_no_damage(render_started.elapsed(), missed_deadlines); - return Ok(false); - }; + .record_frame(render_started.elapsed(), &damage, missed_deadlines, effects); + output.frame_pending = true; + output.lock_frame_pending = state.session_lock.active; + Ok(true) + })() + }; - let presentation = crate::presentation::take_output_feedback( - state, - &output.output, - &result.states, - Kind::Vsync | Kind::HwClock | Kind::HwCompletion, - ); - output - .surface - .queue_buffer(Some(result.sync), Some(damage.clone()), presentation)?; - output - .render_metrics - .record_frame(render_started.elapsed(), &damage, missed_deadlines, effects); - output.frame_pending = true; - output.lock_frame_pending = state.session_lock.active; - Ok(true) - })(); + let submitted = rendered.as_ref().is_ok_and(|submitted| *submitted); + record_frame_schedule(&mut output, plan, render_started.elapsed(), submitted); match rendered { Ok(true) => tracing::trace!(?node, ?crtc, "queued DRM frame"), Ok(false) => (), Err(error) => tracing::error!(?node, ?crtc, %error, "failed to render DRM frame"), } + device.outputs.insert(crtc, output); - restore_device(state, node, device); + restore_device(&mut forecast, node, device); + drop(forecast); + if !submitted { + schedule_frame_callbacks(state, node, crtc, plan.present_at); + } } fn restore_device(state: &mut Ferese, node: DrmNode, device: DirectDevice) { @@ -840,11 +913,13 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) { return; } }; + let lid_closed = state.direct_backend.as_ref().is_some_and(|backend| backend.lid_closed); apply_lid_policy(&mut scan, lid_closed); if let Some(backend) = state.direct_backend.as_mut() { backend.connected_outputs = scan.connected_outputs; } + let mut selections = scan .selections .into_iter() @@ -866,10 +941,12 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) { } else { Ok(()) }; + match result { Ok(()) => { let old_geometry = state.space.output_geometry(&output.output); output.mode = selection.mode; + cancel_output_timers(&state.loop_handle, output); output.settings = selection.settings.clone(); let position = selection.settings.position.unwrap_or_else(|| { state @@ -878,6 +955,7 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) { .map(|g| [g.loc.x, g.loc.y]) .unwrap_or([0, 0]) }); + output.output.change_current_state( Some(OutputMode::from(output.mode)), Some(output_transform(output.settings.transform)), @@ -910,6 +988,7 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) { ); } } + output.damage_tracker = OutputDamageTracker::from_output(&output.output); if let Some(backend) = state.direct_backend.as_mut() { let clock = backend.presentation.entry((node, crtc)).or_default(); @@ -917,13 +996,16 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) { clock.reset_timing(); } } + Err(error) => { tracing::warn!(?crtc, %error, "display change failed; retaining current output") } } + selections.remove(&crtc); continue; } + let unchanged = device.outputs.get(&crtc).is_some_and(|output| { selections.get(&crtc).is_some_and(|selection| { selection.connector.handle() == output.connector @@ -931,6 +1013,7 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) { && selection.settings == output.settings }) }); + if unchanged { selections.remove(&crtc); continue; @@ -944,12 +1027,14 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) { continue; } - if let Some(output) = device.outputs.remove(&crtc) { + if let Some(mut output) = device.outputs.remove(&crtc) { + cancel_output_timers(&state.loop_handle, &mut output); state.display_handle.disable_global::(output.global); state.unregister_output(&output.output); if let Some(backend) = state.direct_backend.as_mut() { backend.presentation.remove(&(node, crtc)); } + tracing::info!(?node, ?crtc, "removed DRM output"); } } @@ -969,6 +1054,7 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) { .set_refresh(OutputMode::from(mode).refresh); device.outputs.insert(crtc, output); } + Err(error) => tracing::error!(?node, ?crtc, %error, "failed to add DRM output"), } } @@ -997,9 +1083,12 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) { info.enabled = true; info.current_mode = Some(connected_mode_info(output.mode)); } + continue; } - if let Some(output) = device.outputs.remove(&crtc) { + + if let Some(mut output) = device.outputs.remove(&crtc) { + cancel_output_timers(&state.loop_handle, &mut output); state.display_handle.disable_global::(output.global); state.unregister_output(&output.output); if let Some(backend) = state.direct_backend.as_mut() { @@ -1007,6 +1096,7 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) { } } } + if let Some(backend) = state.direct_backend.as_mut() { for output in device.outputs.values() { if let Some(info) = backend @@ -1023,6 +1113,7 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) { } } } + restore_device(state, node, device); state.restore_output_focus(); state.relayout(); @@ -1041,16 +1132,19 @@ fn remove_device(state: &mut Ferese, node: DrmNode) { let context = device.renderer.context_id().erased(); state.wallpaper.forget_context(&context); state.resize_snapshots.retain(|_, snapshot| snapshot.context != context); - for (crtc, output) in device.outputs { + for (crtc, mut output) in device.outputs { + cancel_output_timers(&state.loop_handle, &mut output); state.display_handle.disable_global::(output.global); state.unregister_output(&output.output); if let Some(backend) = state.direct_backend.as_mut() { backend.presentation.remove(&(node, crtc)); } } + if let Some(backend) = state.direct_backend.as_mut() { backend.connected_outputs.clear(); } + tracing::info!(?node, "removed DRM device"); } @@ -1066,6 +1160,10 @@ fn refresh_duration(state: &Ferese, node: DrmNode, crtc: crtc::Handle) -> Durati .unwrap_or_else(|| Duration::from_millis(16)) } +fn refresh_duration_from_mode(mode: DrmMode) -> Duration { + Duration::from_nanos(1_000_000_000_000 / OutputMode::from(mode).refresh.max(1) as u64) +} + fn animation_interval(state: &Ferese) -> Duration { state .direct_backend @@ -1088,6 +1186,7 @@ fn arm_animation_timer(state: &mut Ferese) { if !needed { return; } + let deadline = state.animation_fallback_deadline(animation_interval(state)); match state .loop_handle @@ -1101,7 +1200,30 @@ fn arm_animation_timer(state: &mut Ferese) { if Instant::now() < deadline { return TimeoutAction::ToInstant(deadline); } - render_all(state); + + let animating = state.advance_animations(Instant::now()); + if let Some(backend) = state.direct_backend.as_mut() { + backend.animation_active = animating; + } + + let outputs = state + .direct_backend + .as_ref() + .into_iter() + .flat_map(|backend| backend.devices.iter()) + .flat_map(|(node, device)| { + device + .outputs + .iter() + .map(|(crtc, output)| (*node, *crtc, output.output.clone())) + }) + .collect::>(); + for (node, crtc, output) in outputs { + if state.output_has_animations(&output) { + request_frame(state, node, crtc); + } + } + if state .direct_backend .as_ref() @@ -1110,9 +1232,11 @@ fn arm_animation_timer(state: &mut Ferese) { return TimeoutAction::ToInstant(state.animation_fallback_deadline(animation_interval(state))); } } + if let Some(backend) = state.direct_backend.as_mut() { backend.animation_timer = None; } + TimeoutAction::Drop }) { Ok(token) => state.direct_backend.as_mut().unwrap().animation_timer = Some(token), @@ -1120,12 +1244,13 @@ fn arm_animation_timer(state: &mut Ferese) { } } -fn schedule_frame_callbacks(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle) { +fn schedule_frame_callbacks(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle, target: Duration) { if state.session_lock.sleeping { return; } + let now = Instant::now(); - let interval = refresh_duration(state, node, crtc); + let timestamp = monotonic_now(); let Some(output) = state .direct_backend .as_ref() @@ -1136,15 +1261,19 @@ fn schedule_frame_callbacks(state: &mut Ferese, node: DrmNode, crtc: crtc::Handl else { return; }; + if output.callback_timer.is_some() { return; } + let identity = output.output.clone(); - let deadline = output.callback_clock.deadline(now, interval); - if deadline <= now { + let callback_at = output.scheduler.callback_deadline(timestamp, target); + if callback_at <= timestamp { deliver_frame_callbacks(state, node, crtc, &identity); return; } + + let deadline = now + callback_at.saturating_sub(timestamp); match state .loop_handle .insert_source(Timer::from_deadline(deadline), move |_, _, state| { @@ -1161,6 +1290,7 @@ fn schedule_frame_callbacks(state: &mut Ferese, node: DrmNode, crtc: crtc::Handl output.callback_timer = Some(token); } } + Err(error) => tracing::warn!(%error, "could not schedule DRM frame callbacks"), } } @@ -1169,9 +1299,11 @@ fn deliver_frame_callbacks(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle let Some(backend) = state.direct_backend.as_mut() else { return; }; + let Some(device) = backend.devices.get_mut(&node) else { return; }; + let Some(output) = device .outputs .get_mut(&crtc) @@ -1179,11 +1311,20 @@ fn deliver_frame_callbacks(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle else { return; }; + output.callback_timer = None; if !backend.active || !device.drm.is_active() || state.session_lock.sleeping { return; } - output.callback_clock.sent(Instant::now()); + + let timestamp = monotonic_now(); + let next = output.scheduler.callback_deadline(timestamp, timestamp); + if next > timestamp { + schedule_frame_callbacks(state, node, crtc, next); + return; + } + + output.scheduler.callback_sent(timestamp); send_frame_callbacks(state, identity); } @@ -1438,7 +1579,8 @@ fn create_direct_output( render_metrics, frame_pending: false, power_off: false, - callback_clock: Default::default(), + scheduler: crate::frame_scheduler::FrameScheduler::new(refresh_duration_from_mode(mode)), + render_timer: None, callback_timer: None, lock_frame_pending: false, }) @@ -1540,9 +1682,15 @@ impl DirectBackendState { time: DrmEventTime, sequence: u32, ) -> Option { - self.presentation - .get_mut(&(node, crtc)) - .and_then(|clock| clock.record(time, sequence)) + let missed = self + .devices + .get(&node) + .and_then(|device| device.outputs.get(&crtc)) + .map_or(0, |output| output.scheduler.missed_deadlines); + self.presentation.get_mut(&(node, crtc)).and_then(|clock| { + clock.missed_deadlines = missed; + clock.record(time, sequence) + }) } } @@ -1566,21 +1714,6 @@ impl PresentationClock { self.last_presentation = Some(time); self.presented_frames = self.presented_frames.saturating_add(1); - if let (Some(delta), Some(refresh)) = (delta, self.refresh_interval) - && delta > refresh + refresh / 2 - { - let elapsed_frames = (delta.as_nanos() / refresh.as_nanos()).max(1) as u64; - let missed = elapsed_frames.saturating_sub(1); - self.missed_deadlines = self.missed_deadlines.saturating_add(missed); - tracing::warn!( - sequence, - ?delta, - ?refresh, - missed, - total = self.missed_deadlines, - "missed DRM presentation deadline" - ); - } delta } } @@ -1639,14 +1772,14 @@ mod tests { } #[test] - fn presentation_clock_counts_missed_deadlines() { + fn presentation_clock_does_not_count_idle_gaps_as_missed_deadlines() { let mut clock = PresentationClock::default(); clock.set_refresh(60_000); clock.record(DrmEventTime::Monotonic(Duration::ZERO), 1); clock.record(DrmEventTime::Monotonic(Duration::from_millis(50)), 2); - assert_eq!(clock.missed_deadlines, 2); + assert_eq!(clock.missed_deadlines, 0); } #[test] diff --git a/compositor/src/backends/direct/scheduling.rs b/compositor/src/backends/direct/scheduling.rs new file mode 100644 index 0000000..cb1f0fa --- /dev/null +++ b/compositor/src/backends/direct/scheduling.rs @@ -0,0 +1,141 @@ +use super::*; +use crate::frame_scheduler::FramePlan; + +pub(super) fn monotonic_now() -> Duration { + smithay::utils::Clock::::new().now().into() +} + +pub(super) fn cancel_output_timers( + handle: &smithay::reexports::calloop::LoopHandle<'static, Ferese>, + output: &mut DirectOutput, +) { + for token in [output.render_timer.take(), output.callback_timer.take()] + .into_iter() + .flatten() + { + handle.remove(token); + } + + output.scheduler.reset(Duration::from_nanos( + 1_000_000_000_000 / OutputMode::from(output.mode).refresh.max(1) as u64, + )); +} + +pub(super) fn request_frame(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle) { + if state.session_lock.sleeping { + sleep_locked_outputs(state); + return; + } + + let now = monotonic_now(); + let Some(output) = state + .direct_backend + .as_mut() + .filter(|backend| backend.active) + .and_then(|backend| backend.devices.get_mut(&node)) + .filter(|device| device.drm.is_active()) + .and_then(|device| device.outputs.get_mut(&crtc)) + else { + return; + }; + + if output.power_off { + return; + } + + output.scheduler.request(now); + arm_frame(state, node, crtc); +} + +pub(super) fn arm_frame(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle) { + let now = monotonic_now(); + let Some(output) = state + .direct_backend + .as_mut() + .filter(|backend| backend.active) + .and_then(|backend| backend.devices.get_mut(&node)) + .filter(|device| device.drm.is_active()) + .and_then(|device| device.outputs.get_mut(&crtc)) + else { + return; + }; + + if output.render_timer.is_some() || output.frame_pending || output.power_off || state.session_lock.sleeping { + return; + } + + let Some(plan) = output.scheduler.plan(now) else { + return; + }; + + let identity = output.output.clone(); + // Even an immediate repaint is queued. Bursts of input and surface commits + // in the same event-loop dispatch are folded into one scene build. + let deadline = Instant::now() + plan.render_at.saturating_sub(now); + match state + .loop_handle + .insert_source(Timer::from_deadline(deadline), move |_, _, state| { + dispatch_frame(state, node, crtc, &identity); + TimeoutAction::Drop + }) { + Ok(token) => { + if let Some(output) = state + .direct_backend + .as_mut() + .and_then(|backend| backend.devices.get_mut(&node)) + .and_then(|device| device.outputs.get_mut(&crtc)) + { + output.render_timer = Some(token); + } + } + + Err(error) => tracing::warn!(%error, "could not schedule DRM frame"), + } +} + +fn dispatch_frame(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle, identity: &Output) { + let Some(output) = state + .direct_backend + .as_mut() + .filter(|backend| backend.active) + .and_then(|backend| backend.devices.get_mut(&node)) + .filter(|device| device.drm.is_active()) + .and_then(|device| device.outputs.get_mut(&crtc)) + .filter(|output| &output.output == identity) + else { + return; + }; + + output.render_timer = None; + if output.frame_pending || output.power_off || state.session_lock.sleeping { + return; + } + + let Some(plan) = output.scheduler.begin() else { + return; + }; + + let animating = state.advance_animations(Instant::now()); + if let Some(backend) = state.direct_backend.as_mut() { + backend.animation_active = animating; + } + + render_output(state, node, crtc, plan); + if state.output_has_animations(identity) { + request_frame(state, node, crtc); + } + + arm_animation_timer(state); +} + +pub(super) fn record_frame_schedule(output: &mut DirectOutput, plan: FramePlan, cost: Duration, submitted: bool) { + output.scheduler.rendered(plan, monotonic_now(), cost, submitted); + if std::env::var_os("FERESE_TRACE_PERFORMANCE").is_some() { + let started = monotonic_now().saturating_sub(cost); + tracing::debug!(target: "ferese::render", output = %output.output.name(), + render_target_us = plan.render_at.as_micros(), presentation_target_us = plan.present_at.as_micros(), + target_phase_known = plan.phase_known, + render_started_us = started.as_micros(), timer_lateness_us = started.saturating_sub(plan.render_at).as_micros(), + render_us = cost.as_micros(), submitted, "DRM frame schedule"); + } +} diff --git a/compositor/src/dimming.rs b/compositor/src/dimming.rs index 2e8f906..382a79e 100644 --- a/compositor/src/dimming.rs +++ b/compositor/src/dimming.rs @@ -18,7 +18,7 @@ pub(crate) fn target( } /// A bounded, interruption-safe opacity transition; it never overshoots. -#[derive(Debug)] +#[derive(Clone, Debug)] pub(crate) struct DimAnimation { pub current: f64, start: f64, @@ -27,6 +27,14 @@ pub(crate) struct DimAnimation { } impl DimAnimation { + pub(crate) fn is_animating(&self) -> bool { + self.current != self.target + } + + pub(crate) fn predict(&mut self, delta: Duration, duration_ms: f64) { + self.advance(self.target, delta, duration_ms); + } + pub fn new(value: f64) -> Self { Self { current: value, @@ -42,16 +50,19 @@ impl DimAnimation { self.target = target; self.elapsed = 0.0; } + if duration_ms == 0.0 { self.current = target; return false; } + self.elapsed += delta.as_secs_f64() * 1000.0; let progress = (self.elapsed / duration_ms).min(1.0); if progress == 1.0 { self.current = target; return false; } + let eased = progress * progress * (3.0 - 2.0 * progress); self.current = self.start + (target - self.start) * eased; self.current != target diff --git a/compositor/src/frame_scheduler.rs b/compositor/src/frame_scheduler.rs new file mode 100644 index 0000000..c4a19e7 --- /dev/null +++ b/compositor/src/frame_scheduler.rs @@ -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, + last_target: Option, + requested_at: Option, + scheduled: Option, + submitted: Option, + last_callback: Option, + render_estimate: Duration, + render_variance: Duration, + last_sample: Option, + 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 { + 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 { + 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 { + // 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 { + // 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" + ); + } +} diff --git a/compositor/src/main.rs b/compositor/src/main.rs index 5c322fe..02e34fd 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -7,6 +7,7 @@ mod daemon; mod dimming; mod effects; mod floating; +mod frame_scheduler; mod gestures; mod grabs; mod handlers; diff --git a/compositor/src/overview.rs b/compositor/src/overview.rs index c0ef305..475b10c 100644 --- a/compositor/src/overview.rs +++ b/compositor/src/overview.rs @@ -102,6 +102,12 @@ pub(crate) struct OverviewState { labels: OverviewStateLabel, } +pub(crate) struct OverviewAnimationSnapshot { + presentations: HashMap, + opacity: AnimatedValue, + exit_transition: Option<(f64, f64)>, +} + impl Default for OverviewState { fn default() -> Self { Self { @@ -160,6 +166,30 @@ fn centered_row_start(strip: Rect, count: usize, card_width: f64) -> f64 { } impl OverviewState { + pub(crate) fn is_animating(&self, spring: SpringConfig) -> bool { + self.exit_transition.is_some() + || (self.opacity.current - self.opacity.target).abs() > 0.001 + || self.opacity.velocity.abs() > 0.005 + || self.presentations.values().any(|rect| !rect.is_settled(spring)) + } + + pub(crate) fn predict(&mut self, delta: Duration, spring: SpringConfig) -> OverviewAnimationSnapshot { + let snapshot = OverviewAnimationSnapshot { + presentations: self.presentations.clone(), + opacity: self.opacity, + exit_transition: self.exit_transition, + }; + + self.advance(delta, spring, true); + snapshot + } + + pub(crate) fn restore_prediction(&mut self, snapshot: OverviewAnimationSnapshot) { + self.presentations = snapshot.presentations; + self.opacity = snapshot.opacity; + self.exit_transition = snapshot.exit_transition; + } + pub(crate) fn set_font_family(&mut self, family: String) { if self.font_family != family { self.font_family = family; diff --git a/compositor/src/presentation.rs b/compositor/src/presentation.rs index b8642c6..34a8bec 100644 --- a/compositor/src/presentation.rs +++ b/compositor/src/presentation.rs @@ -59,21 +59,6 @@ fn rendered_feedback_output(output: &Output, id: Id, rendered: &RenderElementSta .then(|| output.clone()) } -#[derive(Default)] -pub(crate) struct CallbackClock { - last_sent: Option, -} - -impl CallbackClock { - pub(crate) fn deadline(&self, now: Instant, interval: Duration) -> Instant { - self.last_sent.map_or(now, |last| (last + interval).max(now)) - } - - pub(crate) fn sent(&mut self, now: Instant) { - self.last_sent = Some(now); - } -} - pub(crate) fn frame_delta(last: &mut Instant, now: Instant) -> Duration { let delta = now.saturating_duration_since(*last); *last = (*last).max(now); @@ -442,37 +427,38 @@ mod tests { #[test] fn no_damage_requests_are_paced_without_buffer_submission() { - let start = Instant::now(); + let start = Duration::ZERO; let refresh = Duration::from_millis(16); - let mut clock = CallbackClock::default(); - assert_eq!(clock.deadline(start, refresh), start); - clock.sent(start); + let mut clock = crate::frame_scheduler::FrameScheduler::new(refresh); + assert_eq!(clock.callback_deadline(start, start), start); + clock.callback_sent(start); for millisecond in 1..16 { assert_eq!( - clock.deadline(start + Duration::from_millis(millisecond), refresh), + clock.callback_deadline(start + Duration::from_millis(millisecond), start), start + refresh ); } + let late = start + Duration::from_millis(50); - assert_eq!(clock.deadline(late, refresh), late); - clock.sent(late); - assert_eq!(clock.deadline(late, refresh), late + refresh); + assert_eq!(clock.callback_deadline(late, late), late); + clock.callback_sent(late); + assert_eq!(clock.callback_deadline(late, late), late + refresh); } #[test] fn independent_output_callback_clocks_do_not_sum_their_rates() { - let start = Instant::now(); - let mut slow = CallbackClock::default(); - let mut fast = CallbackClock::default(); - slow.sent(start); - fast.sent(start); - fast.sent(start + Duration::from_millis(8)); + let start = Duration::ZERO; + let mut slow = crate::frame_scheduler::FrameScheduler::new(Duration::from_millis(16)); + let mut fast = crate::frame_scheduler::FrameScheduler::new(Duration::from_millis(8)); + slow.callback_sent(start); + fast.callback_sent(start); + fast.callback_sent(start + Duration::from_millis(8)); assert_eq!( - slow.deadline(start + Duration::from_millis(8), Duration::from_millis(16)), + slow.callback_deadline(start + Duration::from_millis(8), start), start + Duration::from_millis(16) ); assert_eq!( - fast.deadline(start + Duration::from_millis(8), Duration::from_millis(8)), + fast.callback_deadline(start + Duration::from_millis(8), start), start + Duration::from_millis(16) ); } diff --git a/compositor/src/state.rs b/compositor/src/state.rs index c415773..1a5863b 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -5,6 +5,7 @@ mod hit_testing; mod layout; mod navigation; mod outputs; +mod prediction; mod windows; use std::collections::hash_map::Entry; @@ -169,12 +170,14 @@ impl FocusSwipe { } } +#[derive(Clone)] struct WorkspaceSlideItem { workspace: WorkspaceId, start: SlideOffset, target: SlideOffset, } +#[derive(Clone)] struct WorkspaceSlide { items: Vec, elapsed: Duration, diff --git a/compositor/src/state/prediction.rs b/compositor/src/state/prediction.rs new file mode 100644 index 0000000..2772682 --- /dev/null +++ b/compositor/src/state/prediction.rs @@ -0,0 +1,345 @@ +use super::*; + +/// Rendering samples an isolated forecast. Restore authoritative state before +/// returning to input, client commits, or another monitor's render deadline. +pub(crate) struct RenderForecast<'a> { + state: &'a mut Ferese, + geometry: Vec<(WindowId, WindowGeometry)>, + slides: HashMap, + closing: HashMap, + focus: HashMap, + dimming: HashMap, + overview: Option, +} + +impl std::ops::Deref for RenderForecast<'_> { + type Target = Ferese; + + fn deref(&self) -> &Ferese { + self.state + } +} + +impl std::ops::DerefMut for RenderForecast<'_> { + fn deref_mut(&mut self) -> &mut Ferese { + self.state + } +} + +impl Drop for RenderForecast<'_> { + fn drop(&mut self) { + if self.overview.is_none() { + return; + } + + for (id, geometry) in self.geometry.drain(..) { + self.state.window_geometry.insert(id, geometry); + } + + self.state.workspace_slides = std::mem::take(&mut self.slides); + self.state.closing_windows = std::mem::take(&mut self.closing); + self.state.window_focus = std::mem::take(&mut self.focus); + self.state.window_dimming = std::mem::take(&mut self.dimming); + if let Some(overview) = self.overview.take() { + self.state.overview.restore_prediction(overview); + } + } +} + +impl Ferese { + pub(crate) fn output_has_animations(&self, output: &Output) -> bool { + if self.overview.is_animating(self.spring_config) || !self.dismissing_popups.is_empty() { + return true; + } + + if self + .output_id(output) + .and_then(|id| self.workspace_slides.get(&id)) + .is_some_and(|slide| slide.held_progress.is_none() && slide.elapsed < WORKSPACE_SLIDE_DURATION) + { + return true; + } + + self.window_geometry.iter().any(|(id, geometry)| { + if !self.window_belongs_to_output(*id, output) { + return false; + } + + if self.resize_transactions.contains_key(id) + || self.resize_snapshots.contains_key(id) + || self.closing_windows.get(id).is_some_and(|close| !close.close_sent) + || self.window_focus.get(id).is_some_and(|focus| focus.is_animating()) + || self.window_dimming.get(id).is_some_and(|dim| dim.is_animating()) + { + return true; + } + + let mut geometry = *geometry; + if geometry.advance(Duration::ZERO, self.spring_config, self.animations_enabled) { + return true; + } + + let Some(workspace) = self.workspaces.workspace_for_window(*id) else { + return false; + }; + + self.viewport_animations.get(&workspace).is_some_and(|viewport| { + let mut viewport = *viewport; + let held = self + .focus_swipe + .as_ref() + .is_some_and(|swipe| swipe.workspace == workspace); + !held && viewport.advance(Duration::ZERO, self.viewport_spring_config) + }) || self.viewport_coupled_widths.get(id).is_some_and(|(_, width)| { + let mut width = *width; + width.advance(Duration::ZERO, self.viewport_spring_config) + }) + }) + } + + pub(crate) fn forecast_render(&mut self, output: &Output, horizon: Duration) -> RenderForecast<'_> { + if horizon.is_zero() || !self.animations_enabled || !self.output_has_animations(output) { + return RenderForecast { + state: self, + geometry: Vec::new(), + slides: HashMap::new(), + closing: HashMap::new(), + focus: HashMap::new(), + dimming: HashMap::new(), + overview: None, + }; + } + + let delta = if self.animations_enabled { + horizon.min(Duration::from_millis(100)).mul_f64(self.animation_speed) + } else { + Duration::ZERO + }; + + let geometry = self + .window_geometry + .iter() + .filter(|(id, _)| self.window_belongs_to_output(**id, output)) + .map(|(id, geometry)| (*id, *geometry)) + .collect::>(); + let slides = self.workspace_slides.clone(); + let closing = self.closing_windows.clone(); + let focus = self.window_focus.clone(); + let dimming = self.window_dimming.clone(); + let overview = self.overview.predict(delta, self.spring_config); + let blocked = self + .resize_transactions + .keys() + .filter_map(|id| self.workspaces.workspace_for_window(*id)) + .collect::>(); + for (id, original) in &geometry { + let workspace = self.workspaces.workspace_for_window(*id); + if workspace.is_some_and(|workspace| blocked.contains(&workspace)) { + continue; + } + + let world = self.scrolling_world_x.get(id).and_then(|(workspace, world)| { + let viewport = self.viewport_animations.get(workspace)?; + let held = self + .focus_swipe + .as_ref() + .is_some_and(|swipe| swipe.workspace == *workspace); + Some((*world, *viewport, held)) + }); + + let width = self.viewport_coupled_widths.get(id).map(|(_, width)| *width); + let predicted = predict_geometry( + *original, + delta, + self.spring_config, + self.viewport_spring_config, + world, + width, + ); + self.window_geometry.insert(*id, predicted); + } + + for slide in self + .workspace_slides + .values_mut() + .filter(|slide| slide.held_progress.is_none()) + { + slide.advance(delta); + } + + for close in self.closing_windows.values_mut() { + close.advance(delta, self.animations_enabled); + } + + for focus in self.window_focus.values_mut() { + focus.predict(delta, self.inactive_dim.duration_ms); + } + + for dim in self.window_dimming.values_mut() { + dim.predict(delta, self.inactive_dim.duration_ms); + } + + RenderForecast { + state: self, + geometry, + slides, + closing, + focus, + dimming, + overview: Some(overview), + } + } +} + +fn predict_geometry( + original: WindowGeometry, + delta: Duration, + spring: SpringConfig, + viewport_spring: SpringConfig, + world: Option<(AnimatedValue, AnimatedValue, bool)>, + width: Option, +) -> WindowGeometry { + let mut predicted = original; + let zooming = predicted.is_zooming(); + if width.is_some() { + predicted.visual.target.width = predicted.visual.current.width; + predicted.visual.velocity.width = 0.0; + } + + predicted.advance(delta, spring, true); + predicted.visual.target.width = original.visual.target.width; + if let Some((mut world, mut viewport, held)) = world + && !zooming + { + world.advance(delta, spring); + if !held { + viewport.advance(delta, viewport_spring); + } + + predicted.visual.current.x = world.current - viewport.current; + predicted.visual.velocity.x = world.velocity - viewport.velocity; + } + + if let Some(mut width) = width { + width.advance(delta, viewport_spring); + predicted.visual.current.width = width.current; + predicted.visual.velocity.width = width.velocity; + } + + predicted +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[ignore = "requires a private XDG_RUNTIME_DIR and permission to bind test sockets"] + fn render_forecast_restores_compositor_state_between_output_deadlines() { + let runtime = std::path::PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR").unwrap()); + assert!( + runtime.starts_with(std::env::temp_dir()), + "use a disposable runtime directory" + ); + let mut event_loop = EventLoop::try_new().unwrap(); + let display = Display::new().unwrap(); + let config = crate::config::Config::default().runtime_config().unwrap(); + let mut state = Ferese::new(&mut event_loop, display, config).unwrap(); + let output = Output::new( + "forecast-test".into(), + smithay::output::PhysicalProperties { + size: (0, 0).into(), + subpixel: smithay::output::Subpixel::Unknown, + make: "test".into(), + model: "test".into(), + }, + ); + output.change_current_state( + Some(smithay::output::Mode { + size: (1920, 1080).into(), + refresh: 60_000, + }), + Some(smithay::utils::Transform::Normal), + None, + Some((0, 0).into()), + ); + state.space.map_output(&output, (0, 0)); + state.register_output(&output, "forecast-test".into()); + let id = WindowId(1); + state.workspaces.insert_window(id, Axis::Horizontal, 0.5).unwrap(); + let mut geometry = WindowGeometry::new(Rect::new(0., 0., 400., 300.), None); + geometry.visual.set_target(Rect::new(600., 0., 400., 300.)); + state.window_geometry.insert(id, geometry); + let tick = state.last_animation_tick; + for horizon in [ + Duration::from_millis(16), + Duration::from_millis(4), + Duration::from_millis(8), + ] { + { + let forecast = state.forecast_render(&output, horizon); + assert!(forecast.window_geometry[&id].visual.current.x > geometry.visual.current.x); + } + + assert_eq!(state.window_geometry[&id], geometry); + assert_eq!(state.last_animation_tick, tick); + } + + state.animations_enabled = false; + let forecast = state.forecast_render(&output, Duration::from_millis(16)); + assert_eq!(forecast.window_geometry[&id], geometry); + } + + #[test] + fn mixed_refresh_forecasts_do_not_change_authoritative_geometry_or_client_state() { + let mut geometry = WindowGeometry::new(Rect::new(0., 0., 400., 300.), None); + geometry.visual.set_target(Rect::new(600., 0., 500., 300.)); + let original = geometry; + let sample = |delta| { + predict_geometry( + geometry, + delta, + SpringConfig::default(), + SpringConfig::default(), + None, + None, + ) + }; + + let fast = sample(Duration::from_nanos(1_000_000_000 / 240)); + let slow = sample(Duration::from_nanos(1_000_000_000 / 60)); + assert!(fast.visual.current.x > 0.); + assert!(slow.visual.current.x > fast.visual.current.x); + assert_eq!(sample(Duration::from_nanos(1_000_000_000 / 240)), fast); + assert_eq!(geometry, original); + assert_eq!(slow.client, original.client); + assert_eq!(slow.logical, original.logical); + } + + #[test] + fn scrolling_coordinates_and_coupled_width_use_the_same_forecast_time() { + let geometry = WindowGeometry::new(Rect::new(500., 0., 400., 300.), None); + let mut world = AnimatedValue::new(500.); + world.set_target(600.); + let mut viewport = AnimatedValue::new(0.); + viewport.set_target(200.); + let mut width = AnimatedValue::new(400.); + width.set_target(500.); + let spring = SpringConfig::default(); + let delta = Duration::from_millis(10); + let predicted = predict_geometry( + geometry, + delta, + spring, + spring, + Some((world, viewport, false)), + Some(width), + ); + world.advance(delta, spring); + viewport.advance(delta, spring); + width.advance(delta, spring); + assert_eq!(predicted.visual.current.x, world.current - viewport.current); + assert_eq!(predicted.visual.current.width, width.current); + assert_eq!(predicted.visual.target, geometry.visual.target); + } +} diff --git a/docs/performance-baseline.md b/docs/performance-baseline.md index d96064c..95f36eb 100644 --- a/docs/performance-baseline.md +++ b/docs/performance-baseline.md @@ -21,6 +21,27 @@ frames. Keep build work and unrelated activity outside the sampling interval. CPU percentages below use one core as 100%. Short samples are observations, not guarantees or hardware-session benchmarks. +## Direct frame timing + +The DRM backend schedules each output against its own predicted presentation +time. Render cost and a safety margin determine when compositing starts. Idle +outputs wake on demand; callback-only updates are paced to the output refresh +cycle without submitting unchanged buffers. Animation forecasts are restored +before processing input or rendering another output. + +For a direct session, enable `FERESE_TRACE_PERFORMANCE=1` and +`RUST_LOG=ferese::render=debug` before starting Ferese. The timing records include +the planned render start, actual render start, timer lateness, presentation +target, DRM presentation timestamp, and request-to-presentation duration. +Missed deadlines compare submitted frames with their targets; idle gaps do not +count. Request-to-presentation starts at the compositor's redraw request, so it +does not include device input latency, client rendering before the request, or +the display's pixel response. Render cost measures CPU preparation and queueing; +asynchronous GPU work is covered conservatively by feedback-driven margins. + +Check sustained animation, idle wakeup, callback-only clients, and mixed-refresh +outputs separately. Nested sessions do not exercise the DRM scheduler. + ## Historical results On 2026-09-27 at `08a5866`, two nested runs used a 1422 × 1696 output at 2× scale,