Skip to content

Wake for the next frame on time on macOS - #5

Merged
syrusakbary merged 3 commits into
mainfrom
perf/macos-deadline
Oct 28, 2026
Merged

syrusakbary merged 3 commits into
mainfrom
perf/macos-deadline

Conversation

@syrusakbary

@syrusakbary syrusakbary commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

On macOS, the timers that pace frames are coalesced and often fire milliseconds late. Two of ours are affected: the headless window system's display thread sleeps until each refresh with std::thread::sleep, and a native window waits for a redraw it held back with winit's ControlFlow::WaitUntil. At 60 Hz the headless refreshes arrived up to 8 ms late, so a guest that draws every frame saw a frame-interval p95 of 20–23 ms. This PR wakes both from a one-shot libdispatch timer made with DISPATCH_TIMER_STRICT and zero leeway. With it, refreshes arrive within 0.1 ms of their deadline and the p95 is 16.7 ms. A browser engine running as a guest (WebKit compiled to WASIX) measured the same change: its 60 Hz frame-interval p95 went from 23–25 ms to 16.7 ms.

Change

  • src/macos_deadline.rs (macOS only) adds Deadline, a one-shot timer:
    • It is built from DISPATCH_SOURCE_TYPE_TIMER with DISPATCH_TIMER_STRICT, zero leeway and interval DISPATCH_TIME_FOREVER, on a private serial queue.
    • It uses dispatch2's typed bindings (queues, sources, dispatch_time, self-releasing references). dispatch2 0.3.1 was already in Cargo.lock for winit; it is now a direct macOS dependency with only its alloc feature, and the lock gains no package. The one declaration kept is dispatch_source_create, typed as dispatch2 types it: DispatchSource::new panics where libdispatch makes no source, and Deadline::new answers None there instead, so its owners fall back to sleeping.
    • arm(Some(t)) replaces the pending time and arm(None) disarms. The event handler calls the owner's notify only once Instant::now() >= t. Instant (CLOCK_UPTIME_RAW) and dispatch_time(DISPATCH_TIME_NOW, …) (mach_absolute_time) count the same uptime.
    • A fire that comes before the stored time is not dropped: it sets the timer again. That covers a fire for a time that was since replaced, and a fire one tick early from rounding nanoseconds to ticks.
    • The handler state is leaked into the source's context and freed by its cancel handler. libdispatch runs that once, after the last event handler has returned.
    • Dropping a Deadline cancels the source without waiting for a notification in flight, since that notification may be blocked on a lock the dropper holds.
    • No panic unwinds into libdispatch.
  • src/headless.rs: the display thread waits for each refresh boundary with Deadline::wait_until. The timer unparks the thread, and park_timeout remains as a fallback. If the timer cannot be created, it falls back to a plain sleep. The code for other systems is unchanged.
  • src/native/app.rs:
    • about_to_wait arms the timer for the earliest held-back redraw of a window that is shown and in sight, and suspended disarms it.
    • When the timer fires, it upgrades a Weak<Shared> and, if the loop is running, calls proxy.send_event(()), the same wake-up a guest's command sends. No guest code runs on libdispatch's thread.
    • ControlFlow::WaitUntil is set as before, so the loop still wakes if the timer does not.

Apple warns that strict timers cost power. This one is armed only while a frame is awaited, fires once, and nothing stays armed while idle.

Measurements

All runs were on an Apple M5 Max running macOS 27.0, on AC power, with debug builds.

Headless, 60 Hz. The #[ignore] test frame_pacing on the crate's test rig (src/host/tests.rs). It prints the distributions and asserts nothing about them, since a strict timer is a request the system does its best with:

cargo test --release --features cli --lib frame_pacing -- --ignored --nocapture
  • Setup: a Paced headless system with a real clock. A stand-in guest asks for a redraw, polls until WindowRedrawRequested arrives, records the record's timestamp, and repeats 600 times.
  • "Late" means time past the refresh boundary. Timestamps and refreshes count from the same start, so this is exact.
  • For the comparison, the same run then forces the fallback, std::thread::sleep until the boundary, through a switch that exists only in test builds.
Wait Frame interval p50 / p95 / p99 / max (ms) Late p50 / p95 / max (ms)
thread::sleep (3 runs) 16.60–16.66 / 22.5–23.2 / 24.2–24.4 / 24.6–24.9 5.4–5.5 / 7.3–7.7 / 8.2–8.3
strict timer (3 runs) 16.67 / 16.67 / 16.68 / 16.68–16.71 0.025 / 0.031–0.032 / 0.04–0.07
thread::sleep, poll with 1 ms sleeps (2 runs) 16.35–16.49 / 22.1–22.5 / 23.6 / 24.7–24.8 4.1–4.2 / 6.9 / 8.2–9.1
strict timer, poll with 1 ms sleeps (2 runs) 16.67 / 16.69–16.70 / 16.71–16.72 / 16.73–16.75 0.05 / 0.08 / 0.12–0.14

The sleep's lateness depends on the machine's state. In an earlier session the same day it was 3.3 ms at the median and 4.2 ms at most, with a frame-interval p95 of 20.0–20.1 ms. With the strict timer, results stayed the same across sessions.

A later release-build run of the kept test, on a busy machine (load 9–16): the strict timer kept a p95 interval of 16.68–16.69 ms and lateness under 0.07 ms at p95 in 4 of 5 runs, while the sleep gave a p95 of 19.1–19.9 ms and 2.9–3.3 ms late at the median in all 5.

Native window, built-in 120 Hz display. This harness is not part of this PR.

  • Setup: a small C guest run with gui_wasmer --window --inactive and the pixels feature. It asks for a redraw as soon as it is told to draw, presents a 160×120 frame, and runs 1200 frames. Intervals are between the WindowRedrawRequested timestamps.
  • The comparison is the same build with the timer left out, which leaves winit's WaitUntil alone.
  • Result: the frame-interval p95 is 8.45 ms with the timer and 9.6–9.9 ms without; p99 is 8.6 ms with it and 9.9–10.1 ms without.
  • In an earlier session the two were within 0.15 ms of each other at p95. The loop's own timer is not always late.
  • During a 600-frame run, the timer fired for 595 frames.

Tests

All run locally on macOS arm64 (M5 Max), with Rust 1.95 unless noted.

What Command Result
Formatting cargo fmt --check pass
Lints make lint (clippy -D warnings, cli and cli,winit,clipboard,webgpu,pixels, --all-targets) pass
Lints, newer toolchain nightly 1.100 (2026-08-31): the same two clippy commands pass
CI's check job make check pass
Headless host make test pass: 93 unit (frame_pacing ignored), 2 ABI sweep, 4 embedder, 1 manifest test (2 ignored, as on main)
Timer tests, repeated the macos_deadline tests 15× in a row, 30× six at a time, and 10× next to 36 busy loops on 18 cores all pass
CI's macOS build cargo build --features cli,winit,clipboard,webgpu,pixels --all-targets pass
Real windows, no WebGPU WGUI_TEST_WINDOWS=1 cargo test --features cli,winit,pixels --test native_windows pass (5 programs)

The timer tests are written for slow CI runners:

  • Waits for something that must happen time out after 10 s, and end as soon as it happens.
  • Where a test needs two lines to run before a deadline (300 ms or more), it checks afterwards. If the thread was held up past the deadline, the test returns without asserting.

CI lints only on Linux, where this module is compiled out. On macOS-15, CI builds it and runs its tests but does not lint it.

🤖 Generated with Claude Code

macOS coalesces timers. The headless window system's display thread slept
until each refresh with `std::thread::sleep`, and a native window waits for
a redraw it held back with winit's `WaitUntil`: either can be woken
milliseconds late, up to half a frame at 60 Hz. On an M5 Max the headless
refreshes at 60 Hz came 3 to 5.5 ms late at the median and up to 8 ms, and
a guest that drew every frame saw 20 to 23 ms between frames at p95, not
16.7.

src/macos_deadline.rs is a one-shot libdispatch timer made with
`DISPATCH_TIMER_STRICT` and no leeway, which fires within a fraction of a
millisecond of its time. The headless display thread waits for each
refresh on it; a plain sleep remains where the timer cannot be made, and
on other systems. The native loop arms it for the earliest redraw held back
for a window that is shown and in sight, and disarms it when the platform
suspends. When it fires it only wakes the loop, as a guest's command does:
no guest code runs on libdispatch's thread. The loop's own `WaitUntil` is
as it was. Nothing stays armed while nothing waits.

The timer's state is freed by its cancel handler, which libdispatch runs
once, after the last event handler has returned: dropping the timer does
not wait for a notification on its way, which may be waiting for a lock
its owner holds. A fire before the stored time, for a time since replaced
or a tick early from rounding nanoseconds to ticks, sets the timer again
instead of being lost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@syrusakbary syrusakbary left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deep review of adb93c5, including the full diff, headless scheduling, winit deadline selection, and libdispatch timer/cancellation semantics. I found no blocking correctness issue. The timer only wakes the existing scheduler, preserving the distinction between a host wake and a presented frame; keeping the existing frame policy and WaitUntil fallback is appropriate for the native Wasmer adaptation.

The lifecycle looks sound: timer updates are serialized with the stored deadline, stale/early callbacks recheck that deadline, callbacks run after releasing the mutex, and the context is freed by the cancellation handler after in-flight event handling completes. The headless callback owns a thread handle, and the winit callback uses a weak reference rather than borrowing the app. Idle/disarmed paths were also checked.

Validation: all 93 native library tests passed with --features winit, including the eight timer tests. I did not independently run the real-window or latency benchmarks reported in the PR. It would be useful to retain the benchmark harness as an ignored/manual test so future changes can reproduce the distribution; avoid a tight latency threshold in ordinary CI because DISPATCH_TIMER_STRICT remains a best-effort scheduling request (Apple's timer-source contract). One nonblocking simplification suggestion is inline.

Comment thread src/macos_deadline.rs

// libdispatch is part of libSystem, which every program on macOS links.
#[link(name = "System")]
unsafe extern "C" {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Consider using the existing dispatch2 bindings

Cargo.lock already contains dispatch2 0.3.1 for the native window stack. Making it a direct macOS dependency would let this module use its typed DispatchQueue/DispatchSource bindings and DispatchRetained instead of maintaining the hand-written ABI declarations, type-erased Object pointers, constants, and manual reference releases. The crate exposes the timer and C callback APIs needed here (DispatchSource, DispatchRetained). Keep the explicit cancel-handler ownership of Context, since source reference counting alone does not replace that lifecycle guarantee. This is a maintainability suggestion, not a finding that the current cancellation code is incorrect.

syrusakbary and others added 2 commits October 28, 2026 09:05
The timer declared libdispatch's functions, constants and types itself, and
passed its objects around as untyped pointers that it released by hand.
dispatch2, already in Cargo.lock for the native window stack, has typed
bindings for all of it: queues, timer sources, `dispatch_time`, and
references that release themselves. It is now a dependency on macOS, without
its default features, at the version the lock has.

The context its handlers share now holds a reference to the source of its
own, so setting the timer takes no unsafe code. The cancel handler still
owns the context and frees it: reference counting alone does not say when
the last event handler has returned.

One declaration stays: `dispatch_source_create`, typed as dispatch2 types
it. dispatch2's `DispatchSource::new` panics where libdispatch makes no
source, which it does for arguments it rejects; declared here, the timer
answers `None` instead, and its owners wait with a sleep as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The numbers that showed macOS waking the headless display late came from a
measurement that was not kept. `frame_pacing`, an ignored test on the host's
test rig, takes it again: a guest that draws every frame on a 60 Hz display
with a real clock, 600 frames, with the strict timer and then with the sleep
it falls back on, which a switch that exists only in tests forces. It prints
the distribution of frame intervals and of how late each refresh came, and
checks none of it: a strict timer is a request the system does its best
with, and a busy machine is not a failure.

    cargo test --release --features cli --lib frame_pacing -- --ignored --nocapture

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@syrusakbary
syrusakbary merged commit 25c38af into main Oct 28, 2026
6 checks passed
syrusakbary added a commit that referenced this pull request Oct 28, 2026
Keeps both sides where they met: the wait counter from #1 next to the
stretched test wait, and the frame pacing measurement from #5 after the
external wake tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant