My side quest measuring input latency with VK_EXT_present_timing – Maister's Graphics Adventures

12 min read Original article ↗

There’s been two use cases I’ve been looking at recently where having an objective and accurate metric for input latency is important. One is the push for AMD_anti_lag support in Mesa (which I need to get around to reviewing) where we need solid objective data that it’s actually helping, and also for my streaming solution PyroFling, I want some hard objective data demonstrating where the milliseconds are going.

I’ve been working on lots of plumbing in this area recently to hopefully help the ecosystem. We shouldn’t need weird hardware solutions to do this stuff. With VK_EXT_present_timing now being plumbed through the Linux driver stack, we have the API we need to do comparative analysis without too much fluff.

The Vulkan layer

I added a new layer to PyroFling repo here. I documented how it works there, but the basic gist is to read back a small region of the swapchain and compare that to a previous frame to compute a Mean Square Error (MSE) metric. When this error spikes significantly compared to the previous N frames, we assume it happened due to input. This input is synthetically generated with /dev/uinput at somewhat random points in time. Using present timing we get accurate metrics for when that present flowed through the system and we can infer latency metrics based on when we generated synthetic input and when the different frame hit the screen.

While the layer is active, the center of screen shows an “error” image. Before starting a capture, this square should be mostly black. TAA jitter on a stable scene can be seen in this view too, and that’s fine for this layer. A small delta input is generated which should show up as large deltas. E.g. if I move the camera while taking a screenshot:

After a run, we can do analysis. This is a CPU bound game on my lopsided system with 9070xt and an old Zen2 CPU.

Typical CPU bound case

Analyzing /tmp/latency-measurement-wine64-preloader-2026-07-01-12-16-26.csv
Average frame time: 9.21 ms (108.58 Hz)

PresentComplete is determined by stage: Dequeued
Dequeued: Used on Xwayland.
Does not exactly represent when image is flipped on screen,
but rather when compositor commits to displaying the image. A few milliseconds are expected.
FirstPixelOut: Used on most compositors.
Represents when GPU flips image on display controller.
FirstPixelVisible: Represents when photons are actually emitted by display.
Not supported by any known implementation.

Gap between input stimulus and PresentComplete:
Represents overall felt latency
Average 22.1 ms (confirms that the game is quite responsive)
Standard Deviation +/- 2.8595 ms
Median 22.4 ms
Range [16.7, 28.3] ms

Gap between input stimulus and GPU idle:
Represents overall felt latency under ideal VRR conditions
Average 21.7 ms
Standard Deviation +/- 2.8383 ms
Median 21.8 ms
Range [16.3, 27.9] ms

Gap between input stimulus and QueuePresent:
If this is large, we are likely CPU bound or application is buffering input a lot
Average 14.8 ms (about 1.5 frames, as expected for CPU bound game)
~0.5 frames for input polling jitter and 1 frame for CPU commands
Standard Deviation +/- 2.7076 ms
Median 15.0 ms
Range [9.49, 20.6] ms

Gap between QueuePresent and GPU idle:
If this is large, we are likely GPU bound and would benefit from anti-lag
Average 6.85 ms (< 1 frame, very unlikely to be GPU bound)
Standard Deviation +/- 0.29939 ms
Median 6.88 ms
Range [6.02, 7.33] ms

Gap between GPU idle to PresentComplete:
On VRR displays, this is ideally close to 0. If this is large, there is FIFO buffering
Average 0.45 ms (confirms VRR is working as intended)
Standard Deviation +/- 0.10954 ms
Median 0.444 ms
Range [0.294, 0.724] ms

All numbers look just like I expect.

Heavily GPU bound case

To stress test anti-lag a bit, Cyberpunk 2077 with RT is a good candidate since it completely slams my GPU at native-res + heavy RT:

Some TAA instability comes through in the delta box. Without anti-lag, we see the culprit right away:

Average frame time: 16.809 ms (59.493 Hz)

Gap between input stimulus and PresentComplete:
Represents overall felt latency
Average 75.9 ms (yikes)
Standard Deviation +/- 6.4658 ms
Median 77.5 ms
Range [65.8, 86.3] ms

Gap between QueuePresent and GPU idle:
If this is large, we are likely GPU bound and would benefit from anti-lag
Average 33.2 ms
Standard Deviation +/- 0.9738 ms
Median 33.1 ms
Range [31.8, 36.1] ms

A full 2 frames of GPU latency, which is bad. We submit work to the GPU long, long before it goes idle from previous frame, oversubscribing it massively. This is what Reflex/AntiLag attacks, adding delays such that we barely keep the GPU fully subscribed, but no more. With anti_lag it looks much better:

Gap between input stimulus and PresentComplete: 
 Represents overall felt latency 
   Average 50.0 ms (clawed back 25 ms latency, nice) 
     Standard Deviation +/- 4.3821 ms 
   Median 50.5 ms 
   Range [43.4, 60.8] ms

Gap between QueuePresent and GPU idle: 
 If this is large, we are likely GPU bound and would benefit from anti-lag 
   Average 16.9 ms (about 1 frame is which what we expect)
     Standard Deviation +/- 1.996 ms 
   Median 16.3 ms 
   Range [13.9, 22.7] ms

The rest of the latency can be explained by latency introduced in the game:

Gap between input stimulus and QueuePresent: 
 If this is large, we are likely CPU bound or application is buffering input a lot 
   Average 32.7 ms (~2 frames, the game might be buffering input or something) 
     Standard Deviation +/- 3.9363 ms 
   Median 32.8 ms 
   Range [25.6, 40.4] ms

The Fixed Refresh FIFO buffering case

For these tests I forced my monitor to 60 Hz FRR. With vsynced output, we usually get issues with too much FIFO buffering causing latency. We can measure that too. For this I used my own Granite test scene, since I know exactly how it should behave. I have a ground truth to compare against.

The default in Granite is that there’s a maximum of 1 outstanding present, giving roughly 2 frames of latency using vkWaitForPresent2KHR.

Gap between QueuePresent and GPU idle:
If this is large, we are likely GPU bound and would benefit from anti-lag
Average 0.642 ms (the scene is trivial)
Standard Deviation +/- 0.014666 ms
Median 0.642 ms
Range [0.594, 0.666] ms

Gap between GPU idle to PresentComplete:
On VRR displays, this is ideally close to 0. If this is large, there is FIFO buffering
Average 32.3 ms (almost all latency is FIFO related, as expected)
Standard Deviation +/- 0.066468 ms
Median 32.3 ms
Range [32.1, 32.4] ms

If I use the mode to block on previous frame completing (no GPU <-> CPU overlap), we get 1 frame of latency as expected with VK_KHR_present_wait2:

Gap between GPU idle to PresentComplete:
On VRR displays, this is ideally close to 0. If this is large, there is FIFO buffering
Average 15.5 ms
Standard Deviation +/- 0.063855 ms
Median 15.5 ms
Range [15.4, 15.6] ms

Latency between input signal and QueuePresentKHR is as expected a little over half a frame:

Gap between input stimulus and QueuePresent: 
 If this is large, we are likely CPU bound or application is buffering input a lot 
   Average 9.74 ms 
     Standard Deviation +/- 5.1905 ms 
   Median 10.4 ms 
   Range [1.08, 16.7] ms

The MAILBOX buffering case

Sometimes, we can spin very hard on MAILBOX, yet there is still some latency since the screen only updates so often. In the above test, I was seeing in windowed mode on KDE Wayland:

Gap between GPU idle to PresentComplete: 
 On VRR displays, this is ideally close to 0. If this is large, there is FIFO buffering 
   Average 6.45 ms 
     Standard Deviation +/- 1.0938 ms 
   Median 6.23 ms 
   Range [4.8, 9.14] ms

We expect to land roughly in the middle of the frame cycle here. For cases where tearing is supported in IMMEDIATE, we’d expect this delay to be effectively 0, or very close to 0.

Measuring streaming latency

Another motivation for this layer was to determine input latency when streaming. The basic idea is simple, which is to run the layer on the client instead, generate synthetic inputs on client (which get sent over to game), and we should be able to measure latency exactly the same way.

Baseline ideal setup

In the baseline, I used the Sponza scene in Granite on a 180 Hz VRR monitor. I frame limited to 60 Hz, which is how I stream. This eliminates FIFO buffering latency and overall latency will depend on polling latency and GPU times since VRR should be working optimally.

Average frame time: 16.666 ms (60.001 Hz)
Gap between input stimulus and GPU idle: 
 Represents overall felt latency under ideal VRR conditions 
   Average 11.8 ms 
     Standard Deviation +/- 5.1819 ms 
   Median 12.4 ms 
   Range [3.48, 19.3] ms

Gap between QueuePresent and GPU idle: 
 If this is large, we are likely GPU bound and would benefit from anti-lag 
   Average 1.69 ms 
     Standard Deviation +/- 0.035862 ms 
   Median 1.68 ms 
   Range [1.6, 1.76] ms

The overwhelming majority of this latency is caused by 0.5 refresh rate delay for polling input.

Local roundtrip streaming with Pyrowave

Here I attempt to measure the added latency for doing IPC, encoding on GPU using the codec I created, sending that data over localhost UDP, decoding that in pyrofling-viewer and getting it on-screen. I tested with 2560x1440p60 at 250mbit with 4:4:4 YCbCr.

Gap between input stimulus and GPU idle: 
 Represents overall felt latency under ideal VRR conditions 
   Average 12.8 ms 
     Standard Deviation +/- 4.9188 ms 
   Median 12.8 ms 
   Range [5.08, 21.3] ms

Adding all these steps account for about 1 ms of extra lag when the client is running on a VRR monitor. Running the timeline trace in pyrofling server, we can see that it’s actually “network” overhead that accounts for the vast majority of this millisecond overhead. My networking code is likely not very optimal since I’m just hammering the plain sendmsg/recvmsg APIs here, but a real world network scenario is probably going to be bandwidth bound taking a few milliseconds to pump through the packets.

H.265 approach

With FFmpeg NVENC 10-bit 4:4:4 at 50 mbit on an RTX 4070, it looks a bit rougher:

Gap between input stimulus and GPU idle: 
 Represents overall felt latency under ideal VRR conditions 
   Average 20.2 ms 
     Standard Deviation +/- 5.4758 ms 
   Median 20.1 ms 
   Range [12.0, 31.9] ms

Now the overhead is mostly concentrated in overhead for encoding (> 6 ms) and decoding (a few ms) instead. Going to 4K, the overhead increases yet again by several milliseconds … Overall, it becomes a question of which overhead is greater, HW encode and decode time, or network bandwidth.

PyroEnc via Vulkan video also shows similar overhead numbers, so not sure if there is room to improve the encoding performance here. One would imagine the FFmpeg path for NVENC to hit the fast paths of the hardware if there is one.

RADV performance for H.265 is much better at least, but this was with 4:2:0 since I haven’t wired 4:4:4 up in PyroEnc at the moment. Still, from what I understand, AMD doesn’t support 4:4:4 encoding anyway, so what you gonna do …

Gap between input stimulus and GPU idle: 
 Represents overall felt latency under ideal VRR conditions 
   Average 14.9 ms 
     Standard Deviation +/- 4.8683 ms 
   Median 15.2 ms 
   Range [6.67, 22.5] ms

Random experiment – anti lag for FRR

While the existing infrastructure for anti-lag is designed for GPU oversubscription, there isn’t much for keeping FIFO latencies in check. On a VRR gaming monitor, these concerns are largely irrelevant, but for e.g. remote streaming or running on less hardcore-gamery setups, 60 Hz FRR is still relevant. I’ve been experimenting a bit with a low latency FRR pacer in Granite. It aims to calibrate the rendering loop such that GPU goes idle with just enough headroom to hit the compositor deadline for a flip. This approach isn’t particularly reliable for a game with a highly variable load, but for e.g. retro emulation or streaming the GPU load is quite small and stable. In my present-timing test app in Granite, there’s a path to test this. It’s not super stable right now, but using EXT_present_timing to slowly tune in a tight loop is a potential use case for the extension.

The basic gist is that we can discover compositor grace periods by checking queue completion times versus expected flip times. If GPU was done before the expected refresh, yet we still missed the cycle, we can estimate that our grace period was too tight, and increase the gap. If we gain confidence on our current gap, try to lower, etc. Stability should improve over time where we ideally land on a stable gap that basically never fails to meet deadline. This is likely similar in spirit to what compositors do internally.

This approach is probably more stable on KHR_display than Wayland or X11 since we’re not fighting against two layers of flip deadlines. For a more practical game application, it might work better to tune the loop so that the GPU goes idle at the half cycle before flip or something conservative like that, instead of trying to race the compositor.

Conclusion

Hopefully the layer will be a useful addition to the ecosystem. It’s also a fairly standalone sample of how to use the present timing extension for feedback purposes.