Files
feresaria/compositor/src/backends/direct/redraw.rs
T
Samuel Onoja 417f78aa94 perf(compositor): account for late frame dispatch
Include observed timer dispatch delay in the render budget, capped at one refresh and decayed by elapsed time. Preserve the scheduled presentation target and output-local state. Existing scheduling assertions remain intact; a negative control verifies the new deadline regression.
2026-10-24 22:44:06 +01:00

449 lines
16 KiB
Rust

//! Redraw/fallback measurements and the orphaned-animation watchdog policy.
use std::collections::HashMap;
use std::panic::Location;
use std::time::{Duration, Instant};
#[derive(Default)]
pub(super) struct AnimationFallback {
orphaned_since: Option<Instant>,
}
impl AnimationFallback {
pub fn waiting_for_frame(&self) -> bool {
self.orphaned_since.is_some()
}
pub fn deadline(
&mut self,
now: Instant,
interval: Duration,
active: bool,
chained: bool,
) -> Option<Instant> {
if !active || chained {
self.orphaned_since = None;
return None;
}
Some(*self.orphaned_since.get_or_insert(now) + interval.saturating_mul(2))
}
}
#[derive(Debug, Default)]
struct RedrawCount {
calls: u64,
requested_outputs: u64,
connected_outputs: u64,
}
pub(super) struct SchedulingMetrics {
enabled: bool,
started: Instant,
redraws: HashMap<(&'static str, u32, bool), RedrawCount>,
pub fallback_arms: u64,
pub fallback_cancels: u64,
pub fallback_wakes: u64,
pub fallback_recoveries: u64,
pub chained_animation_observations: u64,
}
impl SchedulingMetrics {
pub fn new() -> Self {
Self {
enabled: std::env::var_os("FERESE_TRACE_PERFORMANCE").is_some(),
started: Instant::now(),
redraws: HashMap::new(),
fallback_arms: 0,
fallback_cancels: 0,
fallback_wakes: 0,
fallback_recoveries: 0,
chained_animation_observations: 0,
}
}
pub fn redraw(
&mut self,
source: &'static Location<'static>,
global: bool,
requested: usize,
connected: usize,
) {
if !self.enabled {
return;
}
let count = self
.redraws
.entry((source.file(), source.line(), global))
.or_default();
count.calls += 1;
count.requested_outputs += requested as u64;
count.connected_outputs += connected as u64;
if self.started.elapsed() >= Duration::from_secs(5) {
self.dump();
self.started = Instant::now();
self.redraws.clear();
self.fallback_arms = 0;
self.fallback_cancels = 0;
self.fallback_wakes = 0;
self.fallback_recoveries = 0;
self.chained_animation_observations = 0;
}
}
pub fn dump(&self) {
if !self.enabled {
return;
}
for ((file, line, global), count) in &self.redraws {
tracing::info!(target: "ferese::render", file, line, global,
calls = count.calls, requested_outputs = count.requested_outputs,
connected_outputs = count.connected_outputs, "redraw requests");
}
tracing::info!(target: "ferese::render", fallback_arms = self.fallback_arms,
fallback_cancels = self.fallback_cancels, fallback_wakes = self.fallback_wakes,
fallback_recoveries = self.fallback_recoveries,
chained_animation_observations = self.chained_animation_observations,
"animation fallback scheduling");
}
}
pub(super) fn cursor_on_output(state: &crate::Ferese, output: &smithay::output::Output) -> bool {
use smithay::input::pointer::{CursorImageStatus, CursorImageSurfaceData};
use smithay::utils::{Logical, Rectangle};
use smithay::wayland::compositor::with_states;
if state.input_capture.active() {
return false;
}
let Some(pointer) = state.seat.get_pointer() else {
return false;
};
let Some(bounds) = state.space.output_geometry(output) else {
return false;
};
let location = pointer.current_location();
let rect = match &state.cursor_status {
CursorImageStatus::Surface(surface) => {
let hotspot = with_states(surface, |states| {
states
.data_map
.get::<CursorImageSurfaceData>()
.map(|attributes| {
attributes
.lock()
.expect(
"cursor surface attributes must remain consistent across callbacks",
)
.hotspot
})
.unwrap_or_default()
});
smithay::desktop::utils::bbox_from_surface_tree(
surface,
(location - hotspot.to_f64()).to_i32_round(),
)
.to_f64()
}
CursorImageStatus::Named(_) => {
let Some(cursor) = state.named_cursor_frame() else {
return false;
};
cursor.logical_rect(location)
}
CursorImageStatus::Hidden => return false,
};
// One logical pixel covers physical rounding at fractional scales.
let bounds = Rectangle::<f64, Logical>::new(
(f64::from(bounds.loc.x) - 1.0, f64::from(bounds.loc.y) - 1.0).into(),
(
f64::from(bounds.size.w) + 2.0,
f64::from(bounds.size.h) + 2.0,
)
.into(),
);
rect.overlaps(bounds)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mixed_refresh_only_orphaned_animations_need_a_watchdog() {
let now = Instant::now();
let fast = Duration::from_nanos(1_000_000_000 / 240);
let slow = Duration::from_nanos(1_000_000_000 / 60);
let mut idle_fast = AnimationFallback::default();
let mut moving_slow = AnimationFallback::default();
assert_eq!(idle_fast.deadline(now, fast, false, false), None);
assert_eq!(
moving_slow.deadline(now, slow, true, false),
Some(now + slow * 2)
);
assert_eq!(
moving_slow.deadline(now + slow, slow, true, false),
Some(now + slow * 2)
);
assert_eq!(moving_slow.deadline(now + slow, slow, true, true), None);
}
#[test]
fn disconnect_and_reconnect_do_not_keep_an_old_deadline() {
let now = Instant::now();
let interval = Duration::from_millis(16);
let mut output = AnimationFallback::default();
output.deadline(now, interval, true, false);
assert_eq!(
output.deadline(now + interval, interval, false, false),
None
);
let reconnected = now + Duration::from_secs(1);
assert_eq!(
output.deadline(reconnected, interval, true, false),
Some(reconnected + interval * 2)
);
}
#[test]
fn measured_legacy_fast_monitor_watchdog_wakes_with_an_intact_slow_chain() {
// The old watchdog wakes at last_animation_tick + 2*fast_interval,
// then advances the shared clock, even with a slow render in flight.
let mut last_tick_us = 0;
let mut old_wakes = 0;
let mut policy = AnimationFallback::default();
let start = Instant::now();
for now_us in 0..1_000_000 {
if now_us % 16_667 == 0 {
last_tick_us = now_us;
}
if now_us >= last_tick_us + 8_334 {
old_wakes += 1;
last_tick_us = now_us;
}
assert_eq!(
policy.deadline(start, Duration::from_micros(16_667), true, true),
None
);
}
assert_eq!(old_wakes, 60);
eprintln!("watchdog replay: legacy={old_wakes} unnecessary wakes/s, intact-chain policy=0");
}
#[test]
fn scheduled_and_in_flight_frames_suppress_fallback_until_the_chain_is_lost() {
use crate::frame_scheduler::FrameScheduler;
let start = Instant::now();
let interval = Duration::from_millis(16);
let mut scheduler = FrameScheduler::new(interval);
let mut fallback = AnimationFallback::default();
scheduler.request(Duration::ZERO);
let scheduled = scheduler.plan(Duration::ZERO).unwrap();
assert_eq!(fallback.deadline(start, interval, true, true), None);
let plan = scheduler.begin(scheduled.render_at).unwrap();
assert_eq!(plan, scheduled);
scheduler.rendered(
plan,
Duration::from_millis(2),
Duration::from_millis(2),
true,
);
assert_eq!(scheduler.plan(interval), None);
assert_eq!(
fallback.deadline(start + interval, interval, true, true),
None
);
scheduler.presented(interval);
assert_eq!(
fallback.deadline(start + interval, interval, true, false),
Some(start + interval * 3)
);
scheduler.request(interval);
assert!(scheduler.plan(interval).is_some());
assert_eq!(
fallback.deadline(start + interval, interval, true, true),
None
);
}
#[test]
fn settled_animation_still_recovers_its_requested_final_frame_after_scheduling_failure() {
use crate::frame_scheduler::FrameScheduler;
let start = Instant::now();
let interval = Duration::from_millis(16);
let mut scheduler = FrameScheduler::new(interval);
let mut fallback = AnimationFallback::default();
scheduler.request(Duration::ZERO);
scheduler.plan(Duration::ZERO).unwrap();
// Timer insertion failed: a scheduler plan without an event-loop
// source is not a live chain. Another output can settle the spring.
assert!(scheduler.has_pending_request());
let deadline = fallback.deadline(start, interval, true, false).unwrap();
let animation_active = false;
let needs_final_frame =
animation_active || (fallback.waiting_for_frame() && scheduler.has_pending_request());
assert_eq!(
fallback.deadline(deadline, interval, needs_final_frame, false),
Some(deadline)
);
// A successful retry installs the normal timer and cancels fallback.
assert_eq!(
fallback.deadline(deadline, interval, needs_final_frame, true),
None
);
scheduler.begin(deadline.duration_since(start)).unwrap();
assert!(!scheduler.has_pending_request());
}
#[test]
#[ignore = "requires private sockets; subprocess supplies its own runtime directory"]
fn cursor_footprints_limit_output_local_redraws_and_clear_both_sides_of_a_crossing() {
if std::env::var_os("FERESE_CURSOR_REDRAW_TEST_CHILD").is_none() {
let runtime = tempfile::tempdir().unwrap();
let result = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "backends::direct::redraw::tests::cursor_footprints_limit_output_local_redraws_and_clear_both_sides_of_a_crossing", "--include-ignored", "--nocapture"])
.env("XDG_RUNTIME_DIR", runtime.path())
.env("FERESE_CURSOR_REDRAW_TEST_CHILD", "1")
.output().unwrap();
assert!(
result.status.success(),
"{}{}",
String::from_utf8_lossy(&result.stdout),
String::from_utf8_lossy(&result.stderr)
);
return;
}
use smithay::input::pointer::{CursorImageStatus, MotionEvent};
use smithay::output::{Mode, Output, PhysicalProperties, Subpixel};
use smithay::utils::SERIAL_COUNTER;
let mut event_loop = smithay::reexports::calloop::EventLoop::try_new().unwrap();
let display = smithay::reexports::wayland_server::Display::new().unwrap();
let config = crate::config::Config::default().runtime_config().unwrap();
let mut state = crate::Ferese::new(&mut event_loop, display, config).unwrap();
let mut outputs = Vec::new();
for (index, refresh) in [60_000, 240_000].into_iter().enumerate() {
let output = Output::new(
format!("cursor-test-{index}"),
PhysicalProperties {
serial_number: String::new(),
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
);
output.change_current_state(
Some(Mode {
size: if index == 0 {
(3840, 2160).into()
} else {
(1920, 1080).into()
},
refresh,
}),
None,
Some(smithay::output::Scale::Fractional(if index == 0 {
2.0
} else {
1.0
})),
Some(((index as i32) * 1920, 0).into()),
);
state.space.map_output(&output, ((index as i32) * 1920, 0));
state.register_output(&output, output.name());
outputs.push(output);
}
let pointer = state.seat.get_pointer().unwrap();
let mut old = [false; 2];
let mut local_requests = 0;
for x in [500.0, 501.0, 502.0] {
pointer.motion(
&mut state,
None,
&MotionEvent {
location: (x, 500.0).into(),
serial: SERIAL_COUNTER.next_serial(),
time: smithay::backend::input::InputTime::from_millis(0),
},
);
let current = [
cursor_on_output(&state, &outputs[0]),
cursor_on_output(&state, &outputs[1]),
];
assert_eq!(current, [true, false]);
local_requests += old
.into_iter()
.zip(current)
.filter(|(before, after)| *before || *after)
.count();
old = current;
}
assert_eq!(local_requests, 3);
eprintln!("cursor replay: legacy=6 output requests, footprint policy={local_requests}");
pointer.motion(
&mut state,
None,
&MotionEvent {
location: (1919.0, 500.0).into(),
serial: SERIAL_COUNTER.next_serial(),
time: smithay::backend::input::InputTime::from_millis(0),
},
);
assert!(cursor_on_output(&state, &outputs[0]));
assert!(
cursor_on_output(&state, &outputs[1]),
"cursor image straddles the seam"
);
if let Some(cursor) = state.named_cursor_frame() {
let x = 1920.0 - f64::from(cursor.size.w) / f64::from(cursor.buffer_scale) * 0.75
+ f64::from(cursor.hotspot.x) / f64::from(cursor.buffer_scale);
pointer.motion(
&mut state,
None,
&MotionEvent {
location: (x, 500.0).into(),
serial: SERIAL_COUNTER.next_serial(),
time: smithay::backend::input::InputTime::from_millis(0),
},
);
assert!(
cursor_on_output(&state, &outputs[1]),
"fractional-scale footprint matches rendered cursor size"
);
}
old = [true, true];
state.cursor_status = CursorImageStatus::Hidden;
let current = [
cursor_on_output(&state, &outputs[0]),
cursor_on_output(&state, &outputs[1]),
];
assert_eq!(current, [false, false]);
assert_eq!(
old.into_iter()
.zip(current)
.filter(|(before, after)| *before || *after)
.count(),
2
);
// Grabs and held gestures defer window/output work independently of
// the cursor, which may be on another display or hidden entirely.
state.defer_output_redraw(outputs[0].clone());
state.defer_output_redraw(outputs[1].clone());
state.defer_output_redraw(outputs[1].clone());
assert_eq!(state.output_redraw_pending, outputs);
state.unregister_output(&outputs[1]);
assert_eq!(state.output_redraw_pending, vec![outputs[0].clone()]);
assert!(!cursor_on_output(&state, &outputs[1]));
}
}