hosts/linux - Issues keeping up at high refresh rates #9

Open
opened 2026-07-28 14:32:55 +00:00 by enricobuehler · 1 comment
Owner

Multiple users have reported their stream not keeping up the full 120fps - needs further investigation

Multiple users have reported their stream not keeping up the full 120fps - needs further investigation
Author
Owner

Triage notes, so the next report can be resolved in one round-trip instead of three.

Read the ratio first

The client HUD's fps counts received access units, not presented frames — so it is the host encode loop's iteration rate, and the client is exonerated by construction for this symptom. The host loop always encodes and sends a repeat when capture has no new frame (there is no skip-if-unchanged path), so received-AU rate == loop iteration rate.

Under frame-driven pacing (default on) the loop sleeps to t_cap + 0.9×interval, then waits for an arrival with a deadline of next + 0.5×interval. With no fresh arrival that returns at ≈ t_cap + 1.5×intervalrate = fps ÷ 1.5.

So 0.667 × session fps is a signature, not a coincidence: 120 → exactly 80, 60 → 40. That keepalive is by design for a static desktop; seeing it while a game renders is the bug.

Discriminating the causes — they all land on the same HUD number

What the numbers say Cause
repeat_fps high, new fps low Source starvation — the compositor is not publishing into the capture slot at the session rate
new fps ≈ 80, repeat_fps ≈ 0, submit p50 dominating the loop period Capture+encode GPU throughput ceiling
fps at target but the client still feels short Client-side display rate, not the host at all

Easiest ask for a reporter: the web-console stats capture, which already exposes both fps ("genuine NEW frames/s from the source") and repeat_fps ("re-encoded holds/s"). No env var, no log hunting.

For more detail: PUNKTFUNK_PERF=1 gives capture diag: NEW frames from the source vs REPEATS plus the queue/cap/submit/wait stage split.

A/B lever: PUNKTFUNK_FRAME_DRIVEN=0 drops to the legacy fixed-grid tick, which paces at the full interval regardless of arrivals. If fps jumps toward target with it set, the arrival-wait keepalive is confirmed as the limiter.

Two confounds worth knowing before blaming the host

  1. 80 Hz is also a native ProMotion refresh step (120 ÷ 1.5). An iPad reporting 80 may be its display rate, not our keepalive — two mechanisms, one number. Check e2e p50 on the client pill: high (13–16 ms) ⇒ host capture/encode really is the bottleneck; low (~3 ms) ⇒ the host pipeline is healthy and the deficit is pacing or client display rate.
  2. An iOS overlay showing suspiciously round numbers (FPS 80.00 / 12.50 ms) is Apple's Metal performance HUD measuring the app's Metal present rate, not stream arrivals.

Already root-caused and fixed — check the reporter's version first

  • NVENC cursor blend forced the CPU-synced submit path (dff63b2a, in main). The stream-ordered gate forced ordered=false for any cursor-bearing frame, and gamescope re-attaches the live pointer to every frame → every frame paid a blocking copy plus a fence-waited Vulkan blend, ~10 ms CPU-blocked under the game's GPU load. This is the one that produced a measured 79 fps on a 120 fps session with repeat_fps 0.0 in all 73 samples.
  • Depth de-escalation cycling (78fe77b0, in main) — presented as "random lag", not as a steady shortfall.
  • The capture hand-off is a one-deep overwriting slot, not a queue. Any two publishes landing between two consumer grabs collapse; the older is dropped, never encoded. Any bursty or paired present pattern (frame generation, a compositor that batches commits) therefore loses frames for free, with no latency signature, while the source's own counter honestly reports every present. Worth ruling in explicitly when a compositor's overlay and our number disagree.
  • Also note a source's own FPS counter is its composite rate; what it paints into the PipeWire node it hands us is a separate rate, and gamescope ≥3.16.23 changed how that node is painted.

Gap closed

The stats capture's meta recorded id/started/duration/kind/w/h/fps/codec/client/sample_count — but not the encode backend or the GPU. A p50 submit of 10 ms means "GPU CSC+encode throughput is the ceiling" on one backend and something else entirely on another, so every report so far cost a round-trip asking which one it was. encoder_backend and gpu are now recorded, taken from the record the encoder open itself writes (so it names the branch that really opened, rather than a re-derived guess).

What is still needed to close this

Per-reporter, with versions: a stats capture (now self-describing), the compositor and its version, and whether frame generation is on. The remaining open question is whether the reports left over after dff63b2a are a distinct cause or the same one on unpatched builds.

Triage notes, so the next report can be resolved in one round-trip instead of three. ## Read the ratio first The client HUD's `fps` counts **received access units**, not presented frames — so it is the *host encode loop's iteration rate*, and the client is exonerated by construction for this symptom. The host loop always encodes and sends a repeat when capture has no new frame (there is no skip-if-unchanged path), so received-AU rate == loop iteration rate. Under frame-driven pacing (default on) the loop sleeps to `t_cap + 0.9×interval`, then waits for an arrival with a deadline of `next + 0.5×interval`. With **no fresh arrival** that returns at ≈ `t_cap + 1.5×interval` → **rate = fps ÷ 1.5**. So **0.667 × session fps is a signature, not a coincidence**: 120 → exactly 80, 60 → 40. That keepalive is by design for a static desktop; seeing it while a game renders is the bug. ## Discriminating the causes — they all land on the same HUD number | What the numbers say | Cause | |---|---| | `repeat_fps` high, new `fps` low | Source starvation — the compositor is not publishing into the capture slot at the session rate | | new `fps` ≈ 80, `repeat_fps` ≈ 0, `submit` p50 dominating the loop period | Capture+encode GPU throughput ceiling | | `fps` at target but the client still feels short | Client-side display rate, not the host at all | Easiest ask for a reporter: **the web-console stats capture**, which already exposes both `fps` ("genuine NEW frames/s from the source") and `repeat_fps` ("re-encoded holds/s"). No env var, no log hunting. For more detail: `PUNKTFUNK_PERF=1` gives `capture diag: NEW frames from the source vs REPEATS` plus the `queue`/`cap`/`submit`/`wait` stage split. **A/B lever:** `PUNKTFUNK_FRAME_DRIVEN=0` drops to the legacy fixed-grid tick, which paces at the full interval regardless of arrivals. If fps jumps toward target with it set, the arrival-wait keepalive is confirmed as the limiter. ## Two confounds worth knowing before blaming the host 1. **80 Hz is also a native ProMotion refresh step** (120 ÷ 1.5). An iPad reporting 80 may be its *display* rate, not our keepalive — two mechanisms, one number. Check e2e p50 on the client pill: high (13–16 ms) ⇒ host capture/encode really is the bottleneck; low (~3 ms) ⇒ the host pipeline is healthy and the deficit is pacing or client display rate. 2. **An iOS overlay showing suspiciously round numbers** (`FPS 80.00 / 12.50 ms`) is Apple's Metal performance HUD measuring the app's Metal present rate, not stream arrivals. ## Already root-caused and fixed — check the reporter's version first - **NVENC cursor blend forced the CPU-synced submit path** (`dff63b2a`, in main). The stream-ordered gate forced `ordered=false` for any cursor-bearing frame, and gamescope re-attaches the live pointer to *every* frame → every frame paid a blocking copy plus a fence-waited Vulkan blend, ~10 ms CPU-blocked under the game's GPU load. This is the one that produced a measured 79 fps on a 120 fps session with `repeat_fps` 0.0 in all 73 samples. - **Depth de-escalation cycling** (`78fe77b0`, in main) — presented as "random lag", not as a steady shortfall. - **The capture hand-off is a one-deep overwriting slot, not a queue.** Any two publishes landing between two consumer grabs collapse; the older is dropped, never encoded. Any bursty or paired present pattern (frame generation, a compositor that batches commits) therefore loses frames for free, with **no latency signature**, while the source's own counter honestly reports every present. Worth ruling in explicitly when a compositor's overlay and our number disagree. - Also note a source's own FPS counter is its **composite** rate; what it paints into the PipeWire node it hands us is a separate rate, and gamescope ≥3.16.23 changed how that node is painted. ## Gap closed The stats capture's `meta` recorded id/started/duration/kind/w/h/fps/codec/client/sample_count — but **not the encode backend or the GPU**. A p50 `submit` of 10 ms means "GPU CSC+encode throughput is the ceiling" on one backend and something else entirely on another, so every report so far cost a round-trip asking which one it was. `encoder_backend` and `gpu` are now recorded, taken from the record the encoder open itself writes (so it names the branch that really opened, rather than a re-derived guess). ## What is still needed to close this Per-reporter, with versions: a stats capture (now self-describing), the compositor and its version, and whether frame generation is on. The remaining open question is whether the reports left over after `dff63b2a` are a distinct cause or the same one on unpatched builds.
Sign in to join this conversation.
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#9