Schedule DRM frames against per-output presentation deadlines

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

No files matched your search

+181 -48
View File
@@ -4,6 +4,8 @@ use std::io;
use std::path::Path; use std::path::Path;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
mod scheduling;
use scheduling::*;
use smithay::backend::allocator::Fourcc; use smithay::backend::allocator::Fourcc;
use smithay::backend::allocator::gbm::{GbmAllocator, GbmBufferFlags, GbmDevice}; use smithay::backend::allocator::gbm::{GbmAllocator, GbmBufferFlags, GbmDevice};
use smithay::backend::drm::{DrmDevice, DrmDeviceFd, DrmEvent, DrmEventTime, DrmNode, GbmBufferedSurface, NodeType}; use smithay::backend::drm::{DrmDevice, DrmDeviceFd, DrmEvent, DrmEventTime, DrmNode, GbmBufferedSurface, NodeType};
@@ -163,8 +165,9 @@ struct DirectOutput {
damage_tracker: OutputDamageTracker, damage_tracker: OutputDamageTracker,
render_metrics: RenderMetrics, render_metrics: RenderMetrics,
frame_pending: bool, frame_pending: bool,
scheduler: crate::frame_scheduler::FrameScheduler,
render_timer: Option<RegistrationToken>,
power_off: bool, power_off: bool,
callback_clock: crate::presentation::CallbackClock,
callback_timer: Option<RegistrationToken>, callback_timer: Option<RegistrationToken>,
lock_frame_pending: bool, lock_frame_pending: bool,
} }
@@ -211,6 +214,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
animation_timer: None, animation_timer: None,
animation_active: false, animation_active: false,
}); });
open_primary_device(event_loop, state, &primary_path)?; open_primary_device(event_loop, state, &primary_path)?;
event_loop event_loop
.handle() .handle()
@@ -228,17 +232,20 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
backend.input_devices.push(device); backend.input_devices.push(device);
} }
} }
InputEvent::DeviceRemoved { device } => { InputEvent::DeviceRemoved { device } => {
if let Some(backend) = state.direct_backend.as_mut() { if let Some(backend) = state.direct_backend.as_mut() {
backend.input_devices.retain(|candidate| candidate != &device); backend.input_devices.retain(|candidate| candidate != &device);
} }
} }
event => state.process_input_event(event), event => state.process_input_event(event),
})?; })?;
event_loop event_loop
.handle() .handle()
.insert_source(notifier, move |event, _, state| match event { .insert_source(notifier, move |event, _, state| match event {
SessionEvent::PauseSession => { SessionEvent::PauseSession => {
let handle = state.loop_handle.clone();
if let Some(backend) = state.direct_backend.as_mut() { if let Some(backend) = state.direct_backend.as_mut() {
backend.active = false; backend.active = false;
backend backend
@@ -248,6 +255,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
backend.devices.values_mut().for_each(|device| { backend.devices.values_mut().for_each(|device| {
device.drm.pause(); device.drm.pause();
device.outputs.values_mut().for_each(|output| { device.outputs.values_mut().for_each(|output| {
cancel_output_timers(&handle, output);
output.surface.reset_buffers(); output.surface.reset_buffers();
output.damage_tracker = OutputDamageTracker::from_output(&output.output); output.damage_tracker = OutputDamageTracker::from_output(&output.output);
output.frame_pending = false; output.frame_pending = false;
@@ -255,13 +263,16 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
}); });
}); });
} }
libinput_context.suspend(); libinput_context.suspend();
tracing::info!("direct session paused"); tracing::info!("direct session paused");
} }
SessionEvent::ActivateSession => { SessionEvent::ActivateSession => {
if libinput_context.resume().is_err() { if libinput_context.resume().is_err() {
tracing::error!("failed to resume libinput"); tracing::error!("failed to resume libinput");
} }
if let Some(backend) = state.direct_backend.as_mut() { if let Some(backend) = state.direct_backend.as_mut() {
backend.active = true; backend.active = true;
for device in backend.devices.values_mut() { for device in backend.devices.values_mut() {
@@ -270,6 +281,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
} }
} }
} }
update_power_policy(state); update_power_policy(state);
state.reset_animation_clock(); state.reset_animation_clock();
tracing::info!("direct session activated"); tracing::info!("direct session activated");
@@ -287,11 +299,13 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
tracing::info!(?device_id, ?path, "additional DRM device discovered"); tracing::info!(?device_id, ?path, "additional DRM device discovered");
} }
} }
UdevEvent::Changed { device_id } => { UdevEvent::Changed { device_id } => {
if let Some(node) = direct_node_for_device(state, device_id) { if let Some(node) = direct_node_for_device(state, device_id) {
rescan_device(state, node); rescan_device(state, node);
} }
} }
UdevEvent::Removed { device_id } => { UdevEvent::Removed { device_id } => {
if let Some(node) = direct_node_for_device(state, device_id) { if let Some(node) = direct_node_for_device(state, device_id) {
remove_device(state, node); remove_device(state, node);
@@ -447,6 +461,7 @@ fn open_primary_device(
) )
.into()); .into());
} }
state state
.direct_backend .direct_backend
.as_mut() .as_mut()
@@ -484,11 +499,13 @@ fn open_primary_device(
retired = true; retired = true;
feedback feedback
} }
Err(error) => { Err(error) => {
tracing::error!(?node, ?crtc, %error, "failed to retire DRM frame"); tracing::error!(?node, ?crtc, %error, "failed to retire DRM frame");
None None
} }
}); });
if let (Some(mut feedback), DrmEventTime::Monotonic(time)) = (feedback, metadata.time) { if let (Some(mut feedback), DrmEventTime::Monotonic(time)) = (feedback, metadata.time) {
feedback.presented( feedback.presented(
Time::<Monotonic>::from(time), Time::<Monotonic>::from(time),
@@ -497,6 +514,7 @@ fn open_primary_device(
Kind::Vsync | Kind::HwClock | Kind::HwCompletion, Kind::Vsync | Kind::HwClock | Kind::HwCompletion,
); );
} }
let lock_output = state let lock_output = state
.direct_backend .direct_backend
.as_mut() .as_mut()
@@ -509,34 +527,59 @@ fn open_primary_device(
None None
} }
}); });
if let Some(output) = lock_output { if let Some(output) = lock_output {
state.lock_frame_presented(&output); state.lock_frame_presented(&output);
} }
if let Some(output) = state if let Some(output) = state
.direct_backend .direct_backend
.as_mut() .as_mut()
.and_then(|backend| backend.devices.get_mut(&node)) .and_then(|backend| backend.devices.get_mut(&node))
.and_then(|device| device.outputs.get_mut(&crtc)) .and_then(|device| device.outputs.get_mut(&crtc))
&& retired
{ {
output.frame_pending = false; 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); state.record_drm_presentation(node, crtc, metadata.time, metadata.sequence);
tracing::trace!(?node, ?crtc, sequence = metadata.sequence, "page flip"); tracing::trace!(?node, ?crtc, sequence = metadata.sequence, "page flip");
let active = state.direct_backend.as_ref().is_some_and(|backend| backend.active); let active = state.direct_backend.as_ref().is_some_and(|backend| backend.active);
if active { if active && retired {
let was_animating = state schedule_frame_callbacks(state, node, crtc, monotonic_now());
let identity = state
.direct_backend .direct_backend
.as_ref() .as_ref()
.is_some_and(|backend| backend.animation_active); .and_then(|backend| backend.devices.get(&node))
let animating = state.advance_animations(Instant::now()); .and_then(|device| device.outputs.get(&crtc))
if animating || was_animating { .map(|output| output.output.clone());
render_outputs(state, animating); if identity.is_some_and(|output| state.output_has_animations(&output)) {
request_frame(state, node, crtc);
} else { } else {
render_output(state, node, crtc); arm_frame(state, node, crtc);
} }
} }
} }
} }
DrmEvent::Error(error) => { DrmEvent::Error(error) => {
tracing::error!(?node, %error, "DRM event 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 { if !state.session_lock.active || !state.session_lock.sleeping {
return; return;
} }
let Some(backend) = state.direct_backend.as_mut() else { let Some(backend) = state.direct_backend.as_mut() else {
return; return;
}; };
for device in backend.devices.values_mut().filter(|device| device.drm.is_active()) { for device in backend.devices.values_mut().filter(|device| device.drm.is_active()) {
for output in device.outputs.values_mut() { for output in device.outputs.values_mut() {
if output.power_off || output.frame_pending { if output.power_off || output.frame_pending {
continue; continue;
} }
match output.surface.surface().clear() { match output.surface.surface().clear() {
Ok(()) => output.power_off = true, Ok(()) => {
cancel_output_timers(&state.loop_handle, output);
output.power_off = true;
}
Err(error) => { Err(error) => {
tracing::warn!(%error, output = %output.output.name(), "could not sleep locked display") 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() { if let Some(backend) = state.direct_backend.as_mut() {
backend.animation_active = animating && !state.session_lock.sleeping; backend.animation_active = animating && !state.session_lock.sleeping;
} }
arm_animation_timer(state); arm_animation_timer(state);
let outputs = state let outputs = state
.direct_backend .direct_backend
@@ -623,7 +674,7 @@ fn render_outputs(state: &mut Ferese, animating: bool) {
.unwrap_or_default(); .unwrap_or_default();
for (node, crtc) in outputs { 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 closed && external_available
} }
fn render_output(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle) { fn render_output(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle, plan: crate::frame_scheduler::FramePlan) {
schedule_frame_callbacks(state, node, crtc);
if state.session_lock.active && state.session_lock.sleeping { if state.session_lock.active && state.session_lock.sleeping {
sleep_locked_outputs(state); sleep_locked_outputs(state);
let asleep = state let asleep = state
@@ -711,11 +761,13 @@ fn render_output(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle) {
return; return;
} }
} }
let missed_deadlines = state let missed_deadlines = state
.direct_backend .direct_backend
.as_ref() .as_ref()
.and_then(|backend| backend.presentation.get(&(node, crtc))) .and_then(|backend| backend.devices.get(&node))
.map_or(0, |clock| clock.missed_deadlines); .and_then(|device| device.outputs.get(&crtc))
.map_or(0, |output| output.scheduler.missed_deadlines);
let Some(mut device) = state let Some(mut device) = state
.direct_backend .direct_backend
.as_mut() .as_mut()
@@ -723,10 +775,12 @@ fn render_output(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle) {
else { else {
return; return;
}; };
let Some(mut output) = device.outputs.remove(&crtc) else { let Some(mut output) = device.outputs.remove(&crtc) else {
restore_device(state, node, device); restore_device(state, node, device);
return; return;
}; };
if !device.drm.is_active() || output.frame_pending { if !device.drm.is_active() || output.frame_pending {
device.outputs.insert(crtc, output); device.outputs.insert(crtc, output);
restore_device(state, node, device); restore_device(state, node, device);
@@ -734,7 +788,15 @@ fn render_output(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle) {
} }
let render_started = Instant::now(); let render_started = Instant::now();
let rendered = (|| -> Result<bool, Box<dyn Error>> { 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<bool, Box<dyn Error>> {
state.process_dmabuf_imports(&mut device.renderer); state.process_dmabuf_imports(&mut device.renderer);
let (mut buffer, age) = output.surface.next_buffer()?; let (mut buffer, age) = output.surface.next_buffer()?;
let elements = animated_window_elements(state, &mut device.renderer, &output.output); let elements = animated_window_elements(state, &mut device.renderer, &output.output);
@@ -748,7 +810,8 @@ fn render_output(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle) {
[0.035, 0.04, 0.055, 1.0], [0.035, 0.04, 0.055, 1.0],
)?; )?;
let cursorless_capture = state.has_pending_screencopy(&output.output, false); let cursorless_capture = state.has_pending_screencopy(&output.output, false);
let captured_with_cursor = state.process_screencopies(&mut device.renderer, &framebuffer, &output.output, true); let captured_with_cursor =
state.process_screencopies(&mut device.renderer, &framebuffer, &output.output, true);
if cursorless_capture { if cursorless_capture {
let cursorless_elements = cursorless_window_elements(state, &mut device.renderer, &output.output); let cursorless_elements = cursorless_window_elements(state, &mut device.renderer, &output.output);
@@ -770,6 +833,7 @@ fn render_output(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle) {
)? )?
.finish()?; .finish()?;
} }
let Some(damage) = result.damage.cloned() else { let Some(damage) = result.damage.cloned() else {
output output
.render_metrics .render_metrics
@@ -792,15 +856,24 @@ fn render_output(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle) {
output.frame_pending = true; output.frame_pending = true;
output.lock_frame_pending = state.session_lock.active; output.lock_frame_pending = state.session_lock.active;
Ok(true) Ok(true)
})(); })()
};
let submitted = rendered.as_ref().is_ok_and(|submitted| *submitted);
record_frame_schedule(&mut output, plan, render_started.elapsed(), submitted);
match rendered { match rendered {
Ok(true) => tracing::trace!(?node, ?crtc, "queued DRM frame"), Ok(true) => tracing::trace!(?node, ?crtc, "queued DRM frame"),
Ok(false) => (), Ok(false) => (),
Err(error) => tracing::error!(?node, ?crtc, %error, "failed to render DRM frame"), Err(error) => tracing::error!(?node, ?crtc, %error, "failed to render DRM frame"),
} }
device.outputs.insert(crtc, output); 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) { fn restore_device(state: &mut Ferese, node: DrmNode, device: DirectDevice) {
@@ -840,11 +913,13 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) {
return; return;
} }
}; };
let lid_closed = state.direct_backend.as_ref().is_some_and(|backend| backend.lid_closed); let lid_closed = state.direct_backend.as_ref().is_some_and(|backend| backend.lid_closed);
apply_lid_policy(&mut scan, lid_closed); apply_lid_policy(&mut scan, lid_closed);
if let Some(backend) = state.direct_backend.as_mut() { if let Some(backend) = state.direct_backend.as_mut() {
backend.connected_outputs = scan.connected_outputs; backend.connected_outputs = scan.connected_outputs;
} }
let mut selections = scan let mut selections = scan
.selections .selections
.into_iter() .into_iter()
@@ -866,10 +941,12 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) {
} else { } else {
Ok(()) Ok(())
}; };
match result { match result {
Ok(()) => { Ok(()) => {
let old_geometry = state.space.output_geometry(&output.output); let old_geometry = state.space.output_geometry(&output.output);
output.mode = selection.mode; output.mode = selection.mode;
cancel_output_timers(&state.loop_handle, output);
output.settings = selection.settings.clone(); output.settings = selection.settings.clone();
let position = selection.settings.position.unwrap_or_else(|| { let position = selection.settings.position.unwrap_or_else(|| {
state state
@@ -878,6 +955,7 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) {
.map(|g| [g.loc.x, g.loc.y]) .map(|g| [g.loc.x, g.loc.y])
.unwrap_or([0, 0]) .unwrap_or([0, 0])
}); });
output.output.change_current_state( output.output.change_current_state(
Some(OutputMode::from(output.mode)), Some(OutputMode::from(output.mode)),
Some(output_transform(output.settings.transform)), 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); output.damage_tracker = OutputDamageTracker::from_output(&output.output);
if let Some(backend) = state.direct_backend.as_mut() { if let Some(backend) = state.direct_backend.as_mut() {
let clock = backend.presentation.entry((node, crtc)).or_default(); let clock = backend.presentation.entry((node, crtc)).or_default();
@@ -917,13 +996,16 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) {
clock.reset_timing(); clock.reset_timing();
} }
} }
Err(error) => { Err(error) => {
tracing::warn!(?crtc, %error, "display change failed; retaining current output") tracing::warn!(?crtc, %error, "display change failed; retaining current output")
} }
} }
selections.remove(&crtc); selections.remove(&crtc);
continue; continue;
} }
let unchanged = device.outputs.get(&crtc).is_some_and(|output| { let unchanged = device.outputs.get(&crtc).is_some_and(|output| {
selections.get(&crtc).is_some_and(|selection| { selections.get(&crtc).is_some_and(|selection| {
selection.connector.handle() == output.connector selection.connector.handle() == output.connector
@@ -931,6 +1013,7 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) {
&& selection.settings == output.settings && selection.settings == output.settings
}) })
}); });
if unchanged { if unchanged {
selections.remove(&crtc); selections.remove(&crtc);
continue; continue;
@@ -944,12 +1027,14 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) {
continue; 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::<Ferese>(output.global); state.display_handle.disable_global::<Ferese>(output.global);
state.unregister_output(&output.output); state.unregister_output(&output.output);
if let Some(backend) = state.direct_backend.as_mut() { if let Some(backend) = state.direct_backend.as_mut() {
backend.presentation.remove(&(node, crtc)); backend.presentation.remove(&(node, crtc));
} }
tracing::info!(?node, ?crtc, "removed DRM output"); 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); .set_refresh(OutputMode::from(mode).refresh);
device.outputs.insert(crtc, output); device.outputs.insert(crtc, output);
} }
Err(error) => tracing::error!(?node, ?crtc, %error, "failed to add DRM 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.enabled = true;
info.current_mode = Some(connected_mode_info(output.mode)); info.current_mode = Some(connected_mode_info(output.mode));
} }
continue; 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::<Ferese>(output.global); state.display_handle.disable_global::<Ferese>(output.global);
state.unregister_output(&output.output); state.unregister_output(&output.output);
if let Some(backend) = state.direct_backend.as_mut() { 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() { if let Some(backend) = state.direct_backend.as_mut() {
for output in device.outputs.values() { for output in device.outputs.values() {
if let Some(info) = backend if let Some(info) = backend
@@ -1023,6 +1113,7 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) {
} }
} }
} }
restore_device(state, node, device); restore_device(state, node, device);
state.restore_output_focus(); state.restore_output_focus();
state.relayout(); state.relayout();
@@ -1041,16 +1132,19 @@ fn remove_device(state: &mut Ferese, node: DrmNode) {
let context = device.renderer.context_id().erased(); let context = device.renderer.context_id().erased();
state.wallpaper.forget_context(&context); state.wallpaper.forget_context(&context);
state.resize_snapshots.retain(|_, snapshot| snapshot.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::<Ferese>(output.global); state.display_handle.disable_global::<Ferese>(output.global);
state.unregister_output(&output.output); state.unregister_output(&output.output);
if let Some(backend) = state.direct_backend.as_mut() { if let Some(backend) = state.direct_backend.as_mut() {
backend.presentation.remove(&(node, crtc)); backend.presentation.remove(&(node, crtc));
} }
} }
if let Some(backend) = state.direct_backend.as_mut() { if let Some(backend) = state.direct_backend.as_mut() {
backend.connected_outputs.clear(); backend.connected_outputs.clear();
} }
tracing::info!(?node, "removed DRM device"); 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)) .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 { fn animation_interval(state: &Ferese) -> Duration {
state state
.direct_backend .direct_backend
@@ -1088,6 +1186,7 @@ fn arm_animation_timer(state: &mut Ferese) {
if !needed { if !needed {
return; return;
} }
let deadline = state.animation_fallback_deadline(animation_interval(state)); let deadline = state.animation_fallback_deadline(animation_interval(state));
match state match state
.loop_handle .loop_handle
@@ -1101,7 +1200,30 @@ fn arm_animation_timer(state: &mut Ferese) {
if Instant::now() < deadline { if Instant::now() < deadline {
return TimeoutAction::ToInstant(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::<Vec<_>>();
for (node, crtc, output) in outputs {
if state.output_has_animations(&output) {
request_frame(state, node, crtc);
}
}
if state if state
.direct_backend .direct_backend
.as_ref() .as_ref()
@@ -1110,9 +1232,11 @@ fn arm_animation_timer(state: &mut Ferese) {
return TimeoutAction::ToInstant(state.animation_fallback_deadline(animation_interval(state))); return TimeoutAction::ToInstant(state.animation_fallback_deadline(animation_interval(state)));
} }
} }
if let Some(backend) = state.direct_backend.as_mut() { if let Some(backend) = state.direct_backend.as_mut() {
backend.animation_timer = None; backend.animation_timer = None;
} }
TimeoutAction::Drop TimeoutAction::Drop
}) { }) {
Ok(token) => state.direct_backend.as_mut().unwrap().animation_timer = Some(token), 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 { if state.session_lock.sleeping {
return; return;
} }
let now = Instant::now(); let now = Instant::now();
let interval = refresh_duration(state, node, crtc); let timestamp = monotonic_now();
let Some(output) = state let Some(output) = state
.direct_backend .direct_backend
.as_ref() .as_ref()
@@ -1136,15 +1261,19 @@ fn schedule_frame_callbacks(state: &mut Ferese, node: DrmNode, crtc: crtc::Handl
else { else {
return; return;
}; };
if output.callback_timer.is_some() { if output.callback_timer.is_some() {
return; return;
} }
let identity = output.output.clone(); let identity = output.output.clone();
let deadline = output.callback_clock.deadline(now, interval); let callback_at = output.scheduler.callback_deadline(timestamp, target);
if deadline <= now { if callback_at <= timestamp {
deliver_frame_callbacks(state, node, crtc, &identity); deliver_frame_callbacks(state, node, crtc, &identity);
return; return;
} }
let deadline = now + callback_at.saturating_sub(timestamp);
match state match state
.loop_handle .loop_handle
.insert_source(Timer::from_deadline(deadline), move |_, _, state| { .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); output.callback_timer = Some(token);
} }
} }
Err(error) => tracing::warn!(%error, "could not schedule DRM frame callbacks"), 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 { let Some(backend) = state.direct_backend.as_mut() else {
return; return;
}; };
let Some(device) = backend.devices.get_mut(&node) else { let Some(device) = backend.devices.get_mut(&node) else {
return; return;
}; };
let Some(output) = device let Some(output) = device
.outputs .outputs
.get_mut(&crtc) .get_mut(&crtc)
@@ -1179,11 +1311,20 @@ fn deliver_frame_callbacks(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle
else { else {
return; return;
}; };
output.callback_timer = None; output.callback_timer = None;
if !backend.active || !device.drm.is_active() || state.session_lock.sleeping { if !backend.active || !device.drm.is_active() || state.session_lock.sleeping {
return; 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); send_frame_callbacks(state, identity);
} }
@@ -1438,7 +1579,8 @@ fn create_direct_output(
render_metrics, render_metrics,
frame_pending: false, frame_pending: false,
power_off: 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, callback_timer: None,
lock_frame_pending: false, lock_frame_pending: false,
}) })
@@ -1540,9 +1682,15 @@ impl DirectBackendState {
time: DrmEventTime, time: DrmEventTime,
sequence: u32, sequence: u32,
) -> Option<Duration> { ) -> Option<Duration> {
self.presentation let missed = self
.get_mut(&(node, crtc)) .devices
.and_then(|clock| clock.record(time, sequence)) .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.last_presentation = Some(time);
self.presented_frames = self.presented_frames.saturating_add(1); 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 delta
} }
} }
@@ -1639,14 +1772,14 @@ mod tests {
} }
#[test] #[test]
fn presentation_clock_counts_missed_deadlines() { fn presentation_clock_does_not_count_idle_gaps_as_missed_deadlines() {
let mut clock = PresentationClock::default(); let mut clock = PresentationClock::default();
clock.set_refresh(60_000); clock.set_refresh(60_000);
clock.record(DrmEventTime::Monotonic(Duration::ZERO), 1); clock.record(DrmEventTime::Monotonic(Duration::ZERO), 1);
clock.record(DrmEventTime::Monotonic(Duration::from_millis(50)), 2); clock.record(DrmEventTime::Monotonic(Duration::from_millis(50)), 2);
assert_eq!(clock.missed_deadlines, 2); assert_eq!(clock.missed_deadlines, 0);
} }
#[test] #[test]
@@ -0,0 +1,141 @@
use super::*;
use crate::frame_scheduler::FramePlan;
pub(super) fn monotonic_now() -> Duration {
smithay::utils::Clock::<Monotonic>::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");
}
}
+12 -1
View File
@@ -18,7 +18,7 @@ pub(crate) fn target(
} }
/// A bounded, interruption-safe opacity transition; it never overshoots. /// A bounded, interruption-safe opacity transition; it never overshoots.
#[derive(Debug)] #[derive(Clone, Debug)]
pub(crate) struct DimAnimation { pub(crate) struct DimAnimation {
pub current: f64, pub current: f64,
start: f64, start: f64,
@@ -27,6 +27,14 @@ pub(crate) struct DimAnimation {
} }
impl 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 { pub fn new(value: f64) -> Self {
Self { Self {
current: value, current: value,
@@ -42,16 +50,19 @@ impl DimAnimation {
self.target = target; self.target = target;
self.elapsed = 0.0; self.elapsed = 0.0;
} }
if duration_ms == 0.0 { if duration_ms == 0.0 {
self.current = target; self.current = target;
return false; return false;
} }
self.elapsed += delta.as_secs_f64() * 1000.0; self.elapsed += delta.as_secs_f64() * 1000.0;
let progress = (self.elapsed / duration_ms).min(1.0); let progress = (self.elapsed / duration_ms).min(1.0);
if progress == 1.0 { if progress == 1.0 {
self.current = target; self.current = target;
return false; return false;
} }
let eased = progress * progress * (3.0 - 2.0 * progress); let eased = progress * progress * (3.0 - 2.0 * progress);
self.current = self.start + (target - self.start) * eased; self.current = self.start + (target - self.start) * eased;
self.current != target self.current != target
+368
View File
@@ -0,0 +1,368 @@
//! Fixed-refresh scheduling policy, independent of DRM and the event loop.
use std::time::Duration;
const MIN_RENDER: Duration = Duration::from_millis(2);
const TIMER_MARGIN: Duration = Duration::from_millis(1);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct FramePlan {
pub render_at: Duration,
pub present_at: Duration,
pub requested_at: Duration,
pub phase_known: bool,
}
#[derive(Debug)]
pub(crate) struct FrameScheduler {
interval: Duration,
last_presented: Option<Duration>,
last_target: Option<Duration>,
requested_at: Option<Duration>,
scheduled: Option<FramePlan>,
submitted: Option<FramePlan>,
last_callback: Option<Duration>,
render_estimate: Duration,
render_variance: Duration,
last_sample: Option<Duration>,
extra_margin: Duration,
pub missed_deadlines: u64,
}
impl FrameScheduler {
pub fn new(interval: Duration) -> Self {
Self {
interval: interval.max(Duration::from_micros(1)),
last_presented: None,
last_target: None,
requested_at: None,
scheduled: None,
submitted: None,
last_callback: None,
render_estimate: MIN_RENDER,
render_variance: Duration::ZERO,
last_sample: None,
extra_margin: Duration::ZERO,
missed_deadlines: 0,
}
}
pub fn reset(&mut self, interval: Duration) {
let missed = self.missed_deadlines;
*self = Self::new(interval);
self.missed_deadlines = missed;
}
pub fn request(&mut self, now: Duration) {
self.requested_at.get_or_insert(now);
}
fn budget(&self, now: Duration) -> Duration {
let idle = self
.last_presented
.is_none_or(|last| now.saturating_sub(last) >= self.interval * 100);
if idle {
// A cold GPU is less predictable. Start immediately on idle wakeup.
self.interval
} else {
(self.render_estimate + self.render_variance * 2 + TIMER_MARGIN + self.extra_margin).min(self.interval)
}
}
fn phase_at_or_after(&self, time: Duration) -> Duration {
let Some(last) = self.last_presented else {
return time;
};
let count = time
.saturating_sub(last)
.as_nanos()
.div_ceil(self.interval.as_nanos())
.max(1);
last + self.interval.mul_f64(count as f64)
}
pub fn plan(&mut self, now: Duration) -> Option<FramePlan> {
if self.submitted.is_some() {
return None;
}
if let Some(plan) = self.scheduled {
return Some(plan);
}
let requested_at = self.requested_at?;
if self.last_presented.is_some_and(|last| last > now + self.interval) {
self.last_presented = None;
self.last_target = None;
}
let budget = self.budget(now);
let idle = self
.last_presented
.is_none_or(|last| now.saturating_sub(last) >= self.interval * 100);
// With one in-flight GBM frame, submitting immediately can hit the
// nearest vblank even when the conservative budget is larger than
// the remaining time. Do not deliberately skip that presentation.
let next = if self.last_presented.is_some() {
now + Duration::from_nanos(1)
} else {
now + self.interval
};
let earliest = next.max(self.last_target.map_or(next, |last| last + self.interval));
let present_at = self.phase_at_or_after(earliest);
let plan = FramePlan {
render_at: if idle {
now
} else {
present_at.saturating_sub(budget).max(now)
},
present_at,
requested_at,
phase_known: self.last_presented.is_some(),
};
self.scheduled = Some(plan);
Some(plan)
}
pub fn begin(&mut self) -> Option<FramePlan> {
let plan = self.scheduled.take()?;
self.requested_at = None;
self.last_target = Some(plan.present_at);
Some(plan)
}
pub fn rendered(&mut self, plan: FramePlan, now: Duration, cost: Duration, submitted: bool) {
if submitted {
self.submitted = Some(plan);
let elapsed = self
.last_sample
.map_or(Duration::from_millis(500), |last| now.saturating_sub(last));
self.last_sample = Some(now);
let sample = cost.max(MIN_RENDER);
let ratio = (elapsed.as_secs_f64() / 0.5).clamp(0.01, 1.0);
let deviation = sample.saturating_sub(self.render_estimate);
let variance_ratio = (elapsed.as_secs_f64() / 6.0).clamp(0.001, 0.1);
self.render_variance = self.render_variance.mul_f64(1.0 - variance_ratio).max(deviation);
self.render_estimate = self.render_estimate.mul_f64(1.0 - ratio) + sample.mul_f64(ratio);
}
}
pub fn presented(&mut self, timestamp: Duration) -> Option<FramePlan> {
// Regressive or implausibly early events must not shift the phase backward.
if self.last_presented.is_some_and(|last| timestamp < last) {
self.last_presented = None;
self.last_target = None;
return self.submitted.take();
}
self.last_presented = Some(timestamp);
let plan = self.submitted.take()?;
// The first submission has only an estimated phase. Retiring it on
// the actual refresh must not carry that estimate into the next frame.
self.last_target = Some(timestamp);
let late = timestamp.saturating_sub(plan.present_at);
if plan.phase_known && late > self.interval / 2 {
let missed = (late.as_nanos() + self.interval.as_nanos() / 2) / self.interval.as_nanos();
self.missed_deadlines = self.missed_deadlines.saturating_add(missed as u64);
// CPU submission timings may omit asynchronous GPU work. Increase
// the margin promptly after a real miss; relax it slowly afterward.
self.extra_margin = (self.extra_margin + TIMER_MARGIN).min(self.interval / 2);
} else {
self.extra_margin = self.extra_margin.mul_f64(0.98);
}
Some(plan)
}
pub fn retire_unknown(&mut self) {
self.submitted = None;
self.last_presented = None;
self.last_target = None;
}
pub fn forecast_time(&self, now: Duration, plan: FramePlan) -> Option<Duration> {
// Before the first real page flip there is no reliable refresh phase.
// A late timer still samples the next physical presentation, while
// deadline metrics retain the original scheduled target.
plan.phase_known.then(|| {
plan.present_at
.max(self.phase_at_or_after(now + Duration::from_nanos(1)))
})
}
pub fn callback_deadline(&self, now: Duration, target: Duration) -> Duration {
let tolerance = Duration::from_micros(500).min(self.interval / 8);
if target <= now
&& self
.last_callback
.is_none_or(|last| last + self.interval <= now + tolerance)
{
return now;
}
let earliest = self
.last_callback
.map_or(now, |last| last + self.interval)
.max(target)
.max(now);
self.phase_at_or_after(earliest)
}
pub fn callback_sent(&mut self, now: Duration) {
// Record the refresh slot, not delayed event-loop dispatch time.
// Otherwise a callback delivered a few microseconds late can make
// the next refresh look too early and halve a client's frame rate.
self.last_callback = Some(self.last_presented.map_or(now, |last| {
let tolerance = Duration::from_micros(500).min(self.interval / 8);
let slots = (now.saturating_sub(last) + tolerance).as_nanos() / self.interval.as_nanos();
last + self.interval.mul_f64(slots as f64)
}));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ms(value: u64) -> Duration {
Duration::from_millis(value)
}
#[test]
fn requests_coalesce_without_postponing_the_frame_and_wait_for_retirement() {
let mut scheduler = FrameScheduler::new(ms(10));
scheduler.presented(ms(100));
scheduler.request(ms(101));
let plan = scheduler.plan(ms(101)).unwrap();
assert_eq!((plan.render_at, plan.present_at), (ms(107), ms(110)));
scheduler.request(ms(102));
assert_eq!(scheduler.plan(ms(102)), Some(plan));
assert_eq!(scheduler.begin(), Some(plan));
scheduler.rendered(plan, ms(108), ms(1), true);
scheduler.request(ms(109));
assert_eq!(scheduler.plan(ms(109)), None);
scheduler.presented(ms(110));
assert_eq!(scheduler.plan(ms(110)).unwrap().present_at, ms(120));
}
#[test]
fn idle_wakeup_starts_early_and_is_not_a_missed_deadline() {
let mut scheduler = FrameScheduler::new(ms(10));
scheduler.presented(ms(100));
scheduler.request(ms(10_100));
let plan = scheduler.plan(ms(10_100)).unwrap();
assert_eq!(plan.render_at, ms(10_100));
scheduler.begin();
scheduler.rendered(plan, ms(10_101), ms(1), true);
scheduler.presented(ms(10_110));
assert_eq!(scheduler.missed_deadlines, 0);
}
#[test]
fn misses_measure_the_submitted_target_and_expand_the_budget() {
let mut scheduler = FrameScheduler::new(ms(10));
scheduler.presented(ms(100));
scheduler.request(ms(101));
let plan = scheduler.plan(ms(101)).unwrap();
scheduler.begin();
scheduler.rendered(plan, ms(108), ms(1), true);
assert_eq!(scheduler.presented(ms(130)), Some(plan));
assert_eq!(scheduler.missed_deadlines, 2);
assert_eq!(scheduler.budget(ms(131)), ms(4));
assert_eq!(scheduler.presented(ms(129)), None);
}
#[test]
fn first_retirement_aligns_the_next_frame_to_the_observed_refresh() {
let mut scheduler = FrameScheduler::new(ms(10));
scheduler.request(ms(100));
let plan = scheduler.plan(ms(100)).unwrap();
scheduler.begin();
scheduler.rendered(plan, ms(102), ms(2), true);
scheduler.presented(ms(103));
scheduler.request(ms(104));
assert_eq!(scheduler.plan(ms(104)).unwrap().present_at, ms(113));
}
#[test]
fn no_damage_attempts_and_callbacks_do_not_spin_within_a_refresh() {
let mut scheduler = FrameScheduler::new(ms(10));
scheduler.presented(ms(100));
scheduler.request(ms(101));
let plan = scheduler.plan(ms(101)).unwrap();
scheduler.begin();
scheduler.rendered(plan, ms(108), ms(1), false);
scheduler.callback_sent(ms(110));
scheduler.request(ms(108));
assert_eq!(scheduler.plan(ms(108)).unwrap().present_at, ms(120));
assert_eq!(scheduler.callback_deadline(ms(111), ms(110)), ms(120));
}
#[test]
fn outputs_keep_independent_phases_and_reset_cancels_obsolete_work() {
let mut fast = FrameScheduler::new(ms(4));
let mut slow = FrameScheduler::new(ms(16));
for scheduler in [&mut fast, &mut slow] {
scheduler.presented(ms(100));
scheduler.request(ms(101));
}
assert_eq!(fast.plan(ms(101)).unwrap().present_at, ms(104));
assert_eq!(slow.plan(ms(101)).unwrap().present_at, ms(116));
fast.reset(ms(8));
assert_eq!(fast.plan(ms(102)), None);
}
#[test]
fn callback_dispatch_jitter_does_not_skip_a_refresh_or_duplicate_a_callback() {
let mut scheduler = FrameScheduler::new(ms(10));
scheduler.presented(ms(100));
scheduler.callback_sent(ms(100) + Duration::from_micros(80));
scheduler.presented(ms(110));
let now = ms(110) + Duration::from_micros(10);
assert_eq!(scheduler.callback_deadline(now, now), now);
scheduler.callback_sent(now);
assert_eq!(scheduler.callback_deadline(now, now), ms(120));
}
#[test]
fn unknown_retirement_and_regressive_timestamps_do_not_strand_pending_work() {
let mut scheduler = FrameScheduler::new(ms(10));
scheduler.presented(ms(100));
scheduler.request(ms(101));
let plan = scheduler.plan(ms(101)).unwrap();
scheduler.begin();
scheduler.rendered(plan, ms(108), ms(1), true);
scheduler.request(ms(109));
assert_eq!(scheduler.presented(ms(90)), Some(plan));
assert!(scheduler.plan(ms(110)).is_some());
scheduler.retire_unknown();
assert!(scheduler.plan(ms(111)).is_some());
}
#[test]
fn cold_gpu_and_late_dispatch_do_not_predict_a_past_or_deliberately_skipped_frame() {
let mut scheduler = FrameScheduler::new(ms(10));
scheduler.presented(ms(100));
scheduler.request(ms(10_101));
let plan = scheduler.plan(ms(10_101)).unwrap();
assert_eq!(plan.render_at, ms(10_101));
assert_eq!(plan.present_at, ms(10_110));
assert_eq!(scheduler.forecast_time(ms(10_111), plan), Some(ms(10_120)));
let mut unknown = FrameScheduler::new(ms(10));
unknown.request(ms(100));
let plan = unknown.plan(ms(100)).unwrap();
assert_eq!(unknown.forecast_time(ms(101), plan), None);
unknown.begin();
unknown.rendered(plan, ms(102), ms(2), true);
unknown.presented(ms(130));
assert_eq!(
unknown.missed_deadlines, 0,
"unknown phase is not a measurable missed deadline"
);
}
}
+1
View File
@@ -7,6 +7,7 @@ mod daemon;
mod dimming; mod dimming;
mod effects; mod effects;
mod floating; mod floating;
mod frame_scheduler;
mod gestures; mod gestures;
mod grabs; mod grabs;
mod handlers; mod handlers;
+30
View File
@@ -102,6 +102,12 @@ pub(crate) struct OverviewState {
labels: OverviewStateLabel, labels: OverviewStateLabel,
} }
pub(crate) struct OverviewAnimationSnapshot {
presentations: HashMap<WindowId, AnimatedRect>,
opacity: AnimatedValue,
exit_transition: Option<(f64, f64)>,
}
impl Default for OverviewState { impl Default for OverviewState {
fn default() -> Self { fn default() -> Self {
Self { Self {
@@ -160,6 +166,30 @@ fn centered_row_start(strip: Rect, count: usize, card_width: f64) -> f64 {
} }
impl OverviewState { 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) { pub(crate) fn set_font_family(&mut self, family: String) {
if self.font_family != family { if self.font_family != family {
self.font_family = family; self.font_family = family;
+17 -31
View File
@@ -59,21 +59,6 @@ fn rendered_feedback_output(output: &Output, id: Id, rendered: &RenderElementSta
.then(|| output.clone()) .then(|| output.clone())
} }
#[derive(Default)]
pub(crate) struct CallbackClock {
last_sent: Option<Instant>,
}
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 { pub(crate) fn frame_delta(last: &mut Instant, now: Instant) -> Duration {
let delta = now.saturating_duration_since(*last); let delta = now.saturating_duration_since(*last);
*last = (*last).max(now); *last = (*last).max(now);
@@ -442,37 +427,38 @@ mod tests {
#[test] #[test]
fn no_damage_requests_are_paced_without_buffer_submission() { fn no_damage_requests_are_paced_without_buffer_submission() {
let start = Instant::now(); let start = Duration::ZERO;
let refresh = Duration::from_millis(16); let refresh = Duration::from_millis(16);
let mut clock = CallbackClock::default(); let mut clock = crate::frame_scheduler::FrameScheduler::new(refresh);
assert_eq!(clock.deadline(start, refresh), start); assert_eq!(clock.callback_deadline(start, start), start);
clock.sent(start); clock.callback_sent(start);
for millisecond in 1..16 { for millisecond in 1..16 {
assert_eq!( assert_eq!(
clock.deadline(start + Duration::from_millis(millisecond), refresh), clock.callback_deadline(start + Duration::from_millis(millisecond), start),
start + refresh start + refresh
); );
} }
let late = start + Duration::from_millis(50); let late = start + Duration::from_millis(50);
assert_eq!(clock.deadline(late, refresh), late); assert_eq!(clock.callback_deadline(late, late), late);
clock.sent(late); clock.callback_sent(late);
assert_eq!(clock.deadline(late, refresh), late + refresh); assert_eq!(clock.callback_deadline(late, late), late + refresh);
} }
#[test] #[test]
fn independent_output_callback_clocks_do_not_sum_their_rates() { fn independent_output_callback_clocks_do_not_sum_their_rates() {
let start = Instant::now(); let start = Duration::ZERO;
let mut slow = CallbackClock::default(); let mut slow = crate::frame_scheduler::FrameScheduler::new(Duration::from_millis(16));
let mut fast = CallbackClock::default(); let mut fast = crate::frame_scheduler::FrameScheduler::new(Duration::from_millis(8));
slow.sent(start); slow.callback_sent(start);
fast.sent(start); fast.callback_sent(start);
fast.sent(start + Duration::from_millis(8)); fast.callback_sent(start + Duration::from_millis(8));
assert_eq!( 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) start + Duration::from_millis(16)
); );
assert_eq!( 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) start + Duration::from_millis(16)
); );
} }
+3
View File
@@ -5,6 +5,7 @@ mod hit_testing;
mod layout; mod layout;
mod navigation; mod navigation;
mod outputs; mod outputs;
mod prediction;
mod windows; mod windows;
use std::collections::hash_map::Entry; use std::collections::hash_map::Entry;
@@ -169,12 +170,14 @@ impl FocusSwipe {
} }
} }
#[derive(Clone)]
struct WorkspaceSlideItem { struct WorkspaceSlideItem {
workspace: WorkspaceId, workspace: WorkspaceId,
start: SlideOffset, start: SlideOffset,
target: SlideOffset, target: SlideOffset,
} }
#[derive(Clone)]
struct WorkspaceSlide { struct WorkspaceSlide {
items: Vec<WorkspaceSlideItem>, items: Vec<WorkspaceSlideItem>,
elapsed: Duration, elapsed: Duration,
+345
View File
@@ -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<OutputId, WorkspaceSlide>,
closing: HashMap<WindowId, ClosingAnimation>,
focus: HashMap<WindowId, crate::dimming::DimAnimation>,
dimming: HashMap<WindowId, crate::dimming::DimAnimation>,
overview: Option<crate::overview::OverviewAnimationSnapshot>,
}
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::<Vec<_>>();
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::<HashSet<_>>();
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<AnimatedValue>,
) -> 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);
}
}
+21
View File
@@ -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, CPU percentages below use one core as 100%. Short samples are observations,
not guarantees or hardware-session benchmarks. 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 ## Historical results
On 2026-09-27 at `08a5866`, two nested runs used a 1422 × 1696 output at 2× scale, On 2026-09-27 at `08a5866`, two nested runs used a 1422 × 1696 output at 2× scale,