Skip to content

perf(editor): pace Windows playback on a high-resolution waitable timer instead of spinning 2 ms per frame - #3068

Merged
richiemcilroy merged 3 commits into
mainfrom
perf/win-playback-timer
Oct 24, 2026
Merged

richiemcilroy merged 3 commits into
mainfrom
perf/win-playback-timer

Conversation

@richiemcilroy

@richiemcilroy richiemcilroy commented Oct 24, 2026 •

Copy link
Copy Markdown
Member

Problem

On Windows, precision_sleep_sync (crates/editor/src/playback.rs:107-130 on main) sleeps until 2 ms before each frame deadline and then busy-spins. The playback loop calls it once per frame (playback.rs:1498), so the playback thread burns about 5% of a core at 30 fps and 10% at 60 fps for the whole of playback, and keeps that core from clocking down.

Fix

  • Windows 10 1803+: wait on a CreateWaitableTimerExW(CREATE_WAITABLE_TIMER_HIGH_RESOLUTION) timer (one per playback thread, closed when the thread exits) until 500 us before the deadline, then spin the rest. The timer fires up to about 0.5 ms late on Blacksmith (a 250 us spin left the wake p95 at 255 us late, no spin at 505 us), so 500 us is the shortest spin that keeps the wake on time.
  • The playback thread runs at THREAD_PRIORITY_ABOVE_NORMAL. Without it, a timer wake on a busy machine waits for a core behind decode and render threads and lands up to 10 ms late. The current spin has the same problem (wake p99 7.6 ms late with WARP rendering in the same process), so this improves pacing under load on both paths. The thread uses under 2% of a core, so it cannot starve anything.
  • If the high-resolution timer can't be created (older Windows) or the wait fails, the existing thread::sleep plus 2 ms spin runs unchanged. The 1 ms timeBeginPeriod guard stays.
  • macOS and Linux are unchanged.

Measurements

Blacksmith blacksmith-8vcpu-windows-2025. Scratch workflow builds base (main) and head on the same runner and interleaves them, 5 rounds, median [min, max]. Playback-thread CPU comes from QueryThreadCycleTime, calibrated against a busy spin on the same machine. "Wake late" is how far after the frame deadline the playback thread resumed, the time the frame is submitted to the renderer.

Real playback loop (editor-playback-benchmark --headless --resolution quarter, 1280x720 60 fps fixture, 20 s per run):

base head
playback thread CPU, 30 fps 5.32% [5.29, 5.35] 0.84% [0.83, 0.88]
playback thread CPU, 60 fps 10.58% [10.56, 10.62] 1.69% [1.65, 1.75]
wake late p50 / p95 / p99, 60 fps 0.00 / 0.00 / 0.01 ms 0.00 / 0.05 / 0.13 ms
wake late max, 60 fps 0.22 ms 0.30 ms
presented frame interval minus target, p50 / p95 / p99, 60 fps 0.15 / 1.18 / 2.04 ms 0.13 / 1.14 / 1.97 ms
presented frame interval minus target, p50 / p95 / p99, 30 fps 0.17 / 1.30 / 2.15 ms 0.17 / 1.15 / 1.97 ms
process CPU, 60 fps 136% [116, 139] 129% [115, 136]

Same loop on a runner where WARP rendering saturated the CPU (earlier run, head without the priority vs head with it):

60 fps base timer, normal priority timer + above normal (this PR)
wake late p95 3.16 ms 4.67 ms 0.02 ms
wake late p99 7.64 ms 9.06 ms 0.11 ms
playback thread CPU 9.77% 1.58% 1.74%

Sleep alone (precision_sleep_sync back to back for 10 s; "loaded" adds 8 busy threads on the 8 vCPU runner):

base head
CPU, 30 / 60 fps, idle 5.1% / 10.3% 0.7% / 1.4%
wake late p99 / max, 60 fps, idle 3 us / 8 us 25 us / 125 us
wake late p99 / max, 60 fps, loaded 21.6 ms / 25.7 ms 61 us / 139 us
wake late p99 / max, 30 fps, loaded 24.9 ms / 29.0 ms 78 us / 104 us

On an idle machine the wake is up to about 0.1 ms later than the spin gives, which is under 1% of a 60 fps frame, and the presented frame intervals don't change. Under load, main wakes up to 25 ms late (missing frames); head's p99 stays under 0.15 ms.

Tests

  • playback::tests::precision_sleep_returns_at_its_deadline (all platforms): the sleep never returns before its deadline and returns immediately when the deadline has passed.
  • playback::tests::windows_has_a_high_resolution_waitable_timer (Windows): CI takes the timer path, and the timer sleeps for at least the requested time.
  • playback::tests::precision_sleep_pacing (Windows, ignored): prints CPU and wake percentiles at 30 and 60 fps.

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge, with the previous finding fully addressed.

Summary

Windows playback uses a high-resolution timer with a shorter final spin and raises the playback thread’s priority.

  • Windows playback uses a high-resolution timer to pace each frame.

Reviews (3) · Last reviewed commit: "style: use saturating_sub for Duration s..." · Reviewed by Greptile

@vercel

vercel Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
cap-status Ready Ready Preview Oct 24, 2026 7:47am UTC

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Capy found 1 potential issue (1 medium).

Open in Capy

Comment thread crates/editor/src/playback.rs Resolved
Comment thread crates/editor/src/playback.rs Outdated
let timed = WAITABLE_TIMER.with(|timer| {
timer.as_ref().is_some_and(|timer| {
remaining <= WindowsWaitableTimer::SPIN
|| timer.sleep(remaining - WindowsWaitableTimer::SPIN)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Time subtraction breaks repository rules

AGENTS.md requires saturating_sub when subtracting Duration values. Use remaining.saturating_sub(WindowsWaitableTimer::SPIN) here, and replace thread_cpu() - cpu_start in the new pacing test too. The short-circuit check protects this call from underflow, but both expressions must follow the repository requirement before merging.

Context Used: AGENTS.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/editor/src/playback.rs
Line: 191

Comment:
**Time subtraction breaks repository rules**

`AGENTS.md` requires `saturating_sub` when subtracting `Duration` values. Use `remaining.saturating_sub(WindowsWaitableTimer::SPIN)` here, and replace `thread_cpu() - cpu_start` in the new pacing test too. The short-circuit check protects this call from underflow, but both expressions must follow the repository requirement before merging.

**Context Used:** AGENTS.md ([source](https://github.com/capsoftware/cap/blob/main/AGENTS.md))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@richiemcilroy

Copy link
Copy Markdown
Member Author

@greptileai review

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Capy found no issues.

View 2 other findings in Capy.

Open in Capy

@richiemcilroy

Copy link
Copy Markdown
Member Author

@greptileai review

@richiemcilroy
richiemcilroy merged commit 8f98224 into main Oct 24, 2026
56 checks passed

This branch was successfully deployed

2 active deployments
Preview – cap-web — a5c39222 Deployed Oct 24, 2026 by vercel[bot]
Preview – cap-status — a5c39222 Deployed Oct 24, 2026 by vercel[bot]
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