Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ softbuffer = { version = "0.4", optional = true }
winit = { version = "0.30", optional = true }
wasmer-types = { version = "7.4", optional = true }

# libdispatch, for a timer that macOS fires on time (src/macos_deadline.rs).
[target.'cfg(target_os = "macos")'.dependencies]
dispatch2 = { version = "0.3.1", default-features = false, features = ["alloc"] }

[target.'cfg(target_arch = "wasm32")'.dependencies]
js-sys = { version = "0.3", optional = true }
wasm-bindgen = { version = "0.2.120", optional = true }
Expand Down
28 changes: 27 additions & 1 deletion src/headless.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ impl Default for HeadlessOptions {
/// The id of the one display.
const DISPLAY: u64 = 1;

/// Makes the display threads started from now on wait for each refresh with
/// a plain sleep, as where the strict timer cannot be made: for the
/// `frame_pacing` measurement's comparison.
#[cfg(all(test, target_os = "macos"))]
pub(crate) static SLEEP_FOR_REFRESH: AtomicBool = AtomicBool::new(false);

#[derive(Default)]
struct Clipboard {
items: Vec<ClipboardData>,
Expand Down Expand Up @@ -180,6 +186,15 @@ impl Inner {
}

fn tick(system: Weak<Inner>) {
// macOS can wake a thread that sleeps milliseconds late: there a
// strict timer ends the waits for the display.
#[cfg(target_os = "macos")]
let timer = crate::macos_deadline::Deadline::new({
let thread = std::thread::current();
move || thread.unpark()
});
#[cfg(all(test, target_os = "macos"))]
let timer = timer.filter(|_| !SLEEP_FOR_REFRESH.load(Ordering::Relaxed));
loop {
let Some(inner) = system.upgrade() else {
return;
Expand All @@ -201,8 +216,19 @@ impl Inner {
}
let interval = inner.frame_interval();
let elapsed = inner.started.elapsed().as_nanos() as u64;
let delay = Duration::from_nanos(interval - elapsed % interval);
// The refresh, on the system's clock.
#[cfg(target_os = "macos")]
let refresh = inner.started + Duration::from_nanos(elapsed) + delay;
drop(inner);
std::thread::sleep(Duration::from_nanos(interval - elapsed % interval));
#[cfg(target_os = "macos")]
if let Some(timer) = &timer {
timer.wait_until(refresh);
} else {
std::thread::sleep(refresh.saturating_duration_since(Instant::now()));
}
#[cfg(not(target_os = "macos"))]
std::thread::sleep(delay);
if let Some(inner) = system.upgrade() {
inner.refresh();
}
Expand Down
102 changes: 102 additions & 0 deletions src/host/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1856,3 +1856,105 @@ fn a_kill_shows_on_the_guest_s_memory() {
shared.disable_atomics().expect("supported");
assert!(super::killed(&probe));
}

// -- frame pacing --------------------------------------------------------------

/// How a headless display paces a guest that draws every frame, at 60 Hz on
/// a real clock: the distribution of the intervals between its frames, and
/// of how late each refresh came past its time. On macOS it is measured
/// twice: with the strict timer the display thread waits on, and with the
/// plain sleep it falls back on.
///
/// A stand-in guest asks for a redraw, polls until `WindowRedrawRequested`
/// arrives, and takes the record's timestamp, 600 times over. Timestamps
/// and refreshes count from the system's start, so how late a refresh came
/// is exact.
///
/// It prints what it measured and checks nothing of it: a strict timer is
/// a request the system does its best with, and a busy machine is not a
/// failure. Run it on its own, on a machine that is otherwise idle:
///
/// ```text
/// cargo test --release --features cli --lib frame_pacing -- --ignored --nocapture
/// ```
#[test]
#[ignore = "measures for seconds and checks nothing of it: run by hand, as its documentation says"]
fn frame_pacing() {
#[cfg(target_os = "macos")]
{
pace("strict timer");
crate::headless::SLEEP_FOR_REFRESH.store(true, Ordering::Relaxed);
pace("thread::sleep");
crate::headless::SLEEP_FOR_REFRESH.store(false, Ordering::Relaxed);
}
#[cfg(not(target_os = "macos"))]
pace("thread::sleep");
}

/// One run of `frame_pacing`, printed as waiting with `wait`.
fn pace(wait: &str) {
use std::time::{Duration, Instant};

const FRAMES: usize = 600;
/// A frame at 60 Hz, in nanoseconds.
const INTERVAL: u64 = 1_000_000_000 / 60;
let rig = Rig::with(
HeadlessOptions {
clock: HeadlessClock::Real,
redraws: crate::headless::HeadlessRedraws::Paced,
refresh_rate: 60_000,
..HeadlessOptions::default()
},
GuiPolicy::default(),
GuiLimits::default(),
);
let (window, _user) = rig.window();
rig.events();
let mut stamps = Vec::with_capacity(FRAMES + 1);
while stamps.len() <= FRAMES {
assert_eq!(
rig.call(|host, cx| host.window_request_redraw(cx, window)),
Ok(Status::Success)
);
let asked = Instant::now();
let stamp = loop {
let redrawn = rig.records().into_iter().find_map(|(header, event)| {
matches!(event, Event::WindowRedrawRequested).then_some(header.timestamp)
});
if let Some(stamp) = redrawn {
break stamp;
}
assert!(asked.elapsed() < Duration::from_secs(10), "no refresh");
std::thread::yield_now();
};
stamps.push(stamp);
}
assert_eq!(stamps.len(), FRAMES + 1);

let ms = |nanos: u64| nanos as f64 / 1e6;
let mut intervals: Vec<u64> = stamps.windows(2).map(|pair| pair[1] - pair[0]).collect();
// Past the refresh before it: the one it waited for, unless it came a
// whole frame late, which shows as a frame skipped.
let mut late: Vec<u64> = stamps[1..].iter().map(|stamp| stamp % INTERVAL).collect();
let skipped = intervals
.iter()
.filter(|&&interval| interval > INTERVAL * 3 / 2)
.count();
intervals.sort_unstable();
late.sort_unstable();
let at = |sorted: &[u64], quantile: f64| {
ms(sorted[((sorted.len() - 1) as f64 * quantile).round() as usize])
};
println!(
"frame_pacing, {wait}: {FRAMES} frames at 60 Hz; \
interval ms p50 {:.2} p95 {:.2} p99 {:.2} max {:.2}; \
late ms p50 {:.3} p95 {:.3} max {:.3}; skipped {skipped}",
at(&intervals, 0.5),
at(&intervals, 0.95),
at(&intervals, 0.99),
at(&intervals, 1.0),
at(&late, 0.5),
at(&late, 0.95),
at(&late, 1.0),
);
}
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ pub mod cli;
mod clock;
mod ctx;
mod env;
#[cfg(target_os = "macos")]
mod macos_deadline;
// Windows nobody sees are for tests, servers and streams: not for a page.
#[cfg(not(target_arch = "wasm32"))]
pub mod headless;
Expand Down
Loading
Loading