Floor the forced-keyframe coalesce window so a 120 fps session can't IDR-storm #377

Merged
enricobuehler merged 1 commits from worktree-hevc-idr-storm-coalesce into main 2026-08-22 22:00:59 +00:00
Owner

From a user report: HEVC "stutters with stable latency" on Bazzite 44 / AMD RX 7800 XT, H.264 is fine. Their host log is the whole story.

What the log shows

Seven back-to-back sessions on one box, which makes an unusually clean experiment:

Session Codec Backend Bitrate IDR requests
1 H.264 libav VAAPI 100 Mbps 2
2 HEVC Vulkan Video 100 Mbps 26
3 HEVC Vulkan Video 100 Mbps 1118
4 HEVC Vulkan Video 100 Mbps 277
5–7 HEVC Vulkan Video 500 kbps 2

All 1429 requests are plain 0x0302 request-IDR — the client sent zero 0x0301 RFI-with-range messages, so the Vulkan backend's reference-invalidation never gets exercised and every request lands on force_idr.

Session 3 is the pathology: 1118 requests, and only 3 keyframe request coalesced lines. So ~1115 became full IDRs — about one every tenth frame — while the host itself was healthy throughout (fps=121, dropped_batches=1).

Why the gate did nothing

keyframe_coalesce = frame_interval * 2 is 16.7 ms at 120 fps. The client re-asks about every 30 ms (32–33/s in the log). 30 ms > 16.7 ms, so the gate is open on arrival every single time.

The existing comment already names the failure mode it was meant to prevent — "an un-coalesced request stream turns EVERY frame into a full IDR, saturates the send path, and collapses the session instead of recovering". That is what happened; the window was just sized in the wrong unit. Those IDRs saturate the send path, which causes the loss that prompts the next request, so the storm sustains itself. Frames are lost rather than queued, which is exactly why the user sees stutter at a stable latency.

The change

The window is a round-trip bound — how long until the client can receive and decode the IDR it already asked for — so it needs an absolute floor rather than a frame count:

(frame_interval * 2).max(Duration::from_millis(100))

100 ms matches the encoder-reset backoff already used in the same loop. This is not a 120-only fix: 60 fps was under the floor too (33.3 ms).

Simulated against the logged 30 ms cadence over a 91 s session, honoured IDRs drop from every request to roughly a quarter, while a genuine loss event still recovers within 100 ms.

Scope / what this does not claim

This bounds the severity, not the trigger. What starts the initial loss on this box looks like the Vulkan HEVC path at a 100 Mbps target (the 500 kbps HEVC sessions are clean, and H.264 at the same 100 Mbps is clean) — worth a separate look, since average_bitrate == max_bitrate == target is well-formed and the RC comment already notes the VBV window "does NOT bound complex frames" on RADV. Fixing the gate is correct regardless: no client's recovery spam should be able to convert a hiccup into a session-long collapse.

Verification

  • New unit test in mod tests covering the 120 fps case, the 60 fps case, and that a slow stream keeps the frame-scaled window.
  • Logic also verified natively via a standalone rustc --test copy replaying the logged cadence: old gate honours 3034/3034, new honours 759.
  • ci / rust is green — all 18 steps, including Clippy (deny warnings), Build, Test (unit + loopback + proptest + C ABI harness), and the feature-gated Linux encode backends. (punktfunk-host does not compile on macOS, so CI was the real judge here.)

Field workaround for the reporting user

PUNKTFUNK_VULKAN_ENCODE=0 pins HEVC to the same libav VAAPI path H.264 already used, and keeps HDR (VAAPI pins the main10 profile). Note for the docs: users find PUNKTFUNK_FORCE_SHM=1 via the zero-copy pool WARN that names it, but that WARN fires on healthy sessions too — and FORCE_SHM silently downgrades HDR to SDR, which is usually the whole reason they wanted HEVC.

From a user report: HEVC "stutters with stable latency" on Bazzite 44 / AMD RX 7800 XT, H.264 is fine. Their host log is the whole story. ## What the log shows Seven back-to-back sessions on one box, which makes an unusually clean experiment: | Session | Codec | Backend | Bitrate | IDR requests | |---|---|---|---|---| | 1 | H.264 | libav VAAPI | 100 Mbps | **2** | | 2 | HEVC | Vulkan Video | 100 Mbps | 26 | | 3 | HEVC | Vulkan Video | 100 Mbps | **1118** | | 4 | HEVC | Vulkan Video | 100 Mbps | 277 | | 5–7 | HEVC | Vulkan Video | 500 kbps | 2 | All 1429 requests are plain `0x0302` request-IDR — the client sent **zero** `0x0301` RFI-with-range messages, so the Vulkan backend's reference-invalidation never gets exercised and every request lands on `force_idr`. Session 3 is the pathology: 1118 requests, and only **3** `keyframe request coalesced` lines. So ~1115 became full IDRs — about one every tenth frame — while the host itself was healthy throughout (`fps=121`, `dropped_batches=1`). ## Why the gate did nothing `keyframe_coalesce = frame_interval * 2` is **16.7 ms** at 120 fps. The client re-asks about every 30 ms (32–33/s in the log). 30 ms > 16.7 ms, so the gate is open on arrival every single time. The existing comment already names the failure mode it was meant to prevent — "an un-coalesced request stream turns EVERY frame into a full IDR, saturates the send path, and collapses the session instead of recovering". That is what happened; the window was just sized in the wrong unit. Those IDRs saturate the send path, which causes the loss that prompts the next request, so the storm sustains itself. Frames are lost rather than queued, which is exactly why the user sees stutter at a *stable* latency. ## The change The window is a round-trip bound — how long until the client can receive and decode the IDR it already asked for — so it needs an absolute floor rather than a frame count: ```rust (frame_interval * 2).max(Duration::from_millis(100)) ``` 100 ms matches the encoder-reset backoff already used in the same loop. This is not a 120-only fix: 60 fps was under the floor too (33.3 ms). Simulated against the logged 30 ms cadence over a 91 s session, honoured IDRs drop from *every* request to roughly a quarter, while a genuine loss event still recovers within 100 ms. ## Scope / what this does not claim This bounds the **severity**, not the trigger. What starts the initial loss on this box looks like the Vulkan HEVC path at a 100 Mbps target (the 500 kbps HEVC sessions are clean, and H.264 at the same 100 Mbps is clean) — worth a separate look, since `average_bitrate == max_bitrate == target` is well-formed and the RC comment already notes the VBV window "does NOT bound complex frames" on RADV. Fixing the gate is correct regardless: no client's recovery spam should be able to convert a hiccup into a session-long collapse. ## Verification - New unit test in `mod tests` covering the 120 fps case, the 60 fps case, and that a slow stream keeps the frame-scaled window. - Logic also verified natively via a standalone `rustc --test` copy replaying the logged cadence: old gate honours 3034/3034, new honours 759. - **`ci / rust` is green** — all 18 steps, including `Clippy (deny warnings)`, `Build`, `Test (unit + loopback + proptest + C ABI harness)`, and the feature-gated Linux encode backends. (`punktfunk-host` does not compile on macOS, so CI was the real judge here.) ## Field workaround for the reporting user `PUNKTFUNK_VULKAN_ENCODE=0` pins HEVC to the same libav VAAPI path H.264 already used, and keeps HDR (VAAPI pins the `main10` profile). Note for the docs: users find `PUNKTFUNK_FORCE_SHM=1` via the zero-copy pool WARN that names it, but that WARN fires on healthy sessions too — and `FORCE_SHM` silently downgrades HDR to SDR, which is usually the whole reason they wanted HEVC.
enricobuehler added 1 commit 2026-08-22 21:50:14 +00:00
fix(host): floor the forced-keyframe coalesce window so a 120 fps session can't IDR-storm
ci / rust-arm64 (pull_request) Successful in 2m24s
ci / web (pull_request) Successful in 2m2s
ci / docs-drift (pull_request) Successful in 27s
ci / bun-nix (pull_request) Successful in 26s
ci / rust (pull_request) Successful in 7m33s
ci / docs-site (pull_request) Successful in 1m57s
android / android (pull_request) Successful in 8m14s
5d91176500
The window was `frame_interval * 2`, which is 16.7 ms at 120 fps. A Moonlight
client that has lost decode sync re-asks for an IDR roughly every 30 ms, so the
gate never closed between requests and effectively every request became a full
keyframe.

Field log (AMD RX 7800 XT, Bazzite 44, 1080p120 HEVC over the GameStream plane):
1118 IDR requests in one 91 s session, 1115 honoured, only 3 coalesced — about
one full IDR every tenth frame at a 100 Mbps target. IDRs that size saturate the
send path, which causes the loss that prompts the next request, so the storm
sustains itself. It reads as stutter at a flat latency, because frames are being
lost rather than queued. The same session's H.264 leg (libav VAAPI, same
bitrate) took 2 IDR requests and was clean.

The window is a round-trip bound — how long until the client can receive and
decode the IDR it already asked for — so it needs an absolute floor rather than
a frame count. 100 ms matches the encoder-reset backoff in the same loop.

Simulated against the logged 30 ms request cadence, this cuts honoured IDRs over
a 91 s session from every request to roughly a quarter, while still recovering
promptly from a genuine loss event.
enricobuehler marked the pull request as ready for review 2026-08-22 21:59:08 +00:00
enricobuehler merged commit ec278c0478 into main 2026-08-22 22:00:59 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#377