Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 122 additions & 20 deletions docs/10-fps-limit.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,31 +104,133 @@ divisor cap reads its target exactly.
took the slot; a subscriber whose peer is gone is dropped there, and the
connection currently being served stays its own loop's to close. This is what
makes a cap change made mid-session reach an already-connected shim at all.
- **gamescope's limiter enforces it; the shim declares FIFO while it is set.** On a
PC, gamescope's WSI layer declares FIFO to gamescope (`gamescope_swapchain.
set_present_mode`) while its limiter is engaged, gamescope latches one FIFO commit per
limited vblank (`steamcompmgr.cpp` `handle_done_commits_xwayland`), and the client is
paced by `wl_buffer.release`. The shim is that layer here: on gamescope's override
route it declares FIFO on every commit while the daemon's `FrameCapNote` carries a
cap, the guest's own mode otherwise, and mailbox while frame generation runs
(`icd_shim/src/proxy.rs` `live_present_mode`). `MESA_VK_WSI_PRESENT_MODE`, a container
default for the X11 path, no longer decides that route: honouring it declared every
commit mailbox, which the limiter never holds, and that is why an acquire gate on the
shim's own clock used to stand in for the cap. That time gate
(`scanout::next_acquire_after`) now runs only while frame generation sets the source
rate, and on the opt-in X11 Present route, which has no compositor declaration.
gamescope's fixed-rate limiter passes every `refresh / target`-th vblank with an integer
divisor, so a cap that does not divide the display's rate (40 or 45 on 60 Hz, 45 or 50
on 120 Hz) runs at the next divisor rate above it; that is upstream gamescope's
behaviour and is left to it for now.
- **Who enforces it follows the present mode, and the mode follows the container.**
On gamescope's override route the shim declares to gamescope, per commit
(`gamescope_swapchain.set_present_mode`), the container's
`MESA_VK_WSI_PRESENT_MODE` (mailbox is the container default; the user may set
fifo, relaxed or immediate), or the guest's own mode when the env is absent
(`icd_shim/src/proxy.rs` `effective_present_mode`, `live_present_mode`).
gamescope's limiter holds only commits declared FIFO (`wlserver_surface_is_fifo`):
it latches one per limited vblank (`steamcompmgr.cpp` `handle_done_commits_xwayland`)
and the client is paced by `wl_buffer.release`. Any other mode is taken at the
next composite, so there the shim's acquire gate is the cap:

| container mode | cap | declared | what limits the rate |
|---|---|---|---|
| mailbox (default) | none, guest asked for FIFO (in-game VSync on) | fifo (`source=guest`) | the display (FIFO) |
| mailbox (default) | none, guest asked for mailbox or immediate | mailbox | nobody |
| mailbox | at the display's rate | fifo (`source=cap-rate`) | the shim's acquire gate, every vblank; gamescope latches one FIFO commit per vblank |
| mailbox | below the display's rate | mailbox | the shim's acquire gate, on the display's vblank grid |
| fifo | none | fifo | the display (FIFO) |
| fifo | set | fifo | gamescope's divisor limiter |
| any, frame generation on | any | mailbox | the source-rate gate (`docs/17`) |

FIFO-relaxed (`relaxed`) and immediate behave as mailbox in this table. No env: the guest's
own mode takes the env's place. "At the display's rate" is a cap within 0.5 %
of the measured rate (60 on a 59.97 Hz panel); a cap above it runs on the
gate's own clock under the container's mode.

**With no cap a guest's FIFO request wins over the container's default.** Every
container carries `MESA_VK_WSI_PRESENT_MODE=mailbox` as a stamped default the user
did not choose. With no cap, honouring it turned a game's own VSync (a FIFO
request) into an uncapped mailbox session, which is neither master's behaviour nor
what the user asked for. A FIFO request (FIFO-relaxed too) is a specific wish and
is declared as asked (`source=guest`); a mailbox or immediate request means "do not
make me wait", and the container's mailbox is the substitute, as on the X11 route
(an uncapped immediate guest gets mailbox, not tearing). An env of `fifo` is an
explicit wish and wins in every case. Under a cap the table above applies whatever
the guest asked for.

**The user's control is `MESA_VK_WSI_PRESENT_MODE`.** It sits in the container's
environment, written from the driver's present-mode setting, and takes Mesa's
names: `fifo`, `relaxed` (FIFO-relaxed), `mailbox`, `immediate`. Anything else,
`fifo_relaxed` included, is rejected as Mesa rejects it, the guest's own mode is
used, and the shim logs `Invalid MESA_VK_WSI_PRESENT_MODE value!` whether or not
`GNDRM_LOG_VKSHIM` is set, so a typo shows in a shipped session. It is
overridden in one case: a non-FIFO value under a cap at the display's rate is
declared FIFO (`source=cap-rate`, below). Mesa reads the same variable, so it
also sets the mode a zink guest's own swapchain asks for below the shim.

**Why mailbox is not declared at the display's rate.** At that cap the panel
shows one frame per vblank under either mode, and under mailbox the shim has to
keep two commits out of one gamescope composite. It cannot see when gamescope
latches: gamescope takes commits at its wakeup, the target vblank minus its
rolling draw time and red zone (`vblankmanager.cpp` 142-199), and that draw time
is a peak hold that jumps on any slow draw (164-165) and decays per wakeup
(167); draw time runs to the page flip queued (`DRMBackend.cpp` 4465), on this
stack through the daemon's commit. The client sees only `presented` (stamped
with the wakeup's target vblank, `steamcompmgr.cpp` 8140, 8168) and `discarded`,
one inequality per frame. Measured on a Retroid Pocket 6 (DTD, cap 60, in-game
VSync off, a guest pacing itself at ~59.3 fps on a 59.98 Hz panel): mailbox with
the gate gave 58.3 fps on `SurfaceFlinger` with ~1 commit a second replaced in
gamescope's mailbox; a one-commit-per-window queue in the shim at a guessed
2.5 ms boundary gave 59.0 with ~0.8 a second still replaced; FIFO on master
gives 59.22 on the same scene. Under FIFO gamescope latches one commit per
vblank at its own wakeup (`steamcompmgr.cpp` 8028-8049), its limiter's divisor
at this cap is 1, and the gate keeps the guest from getting ahead, so the FIFO
queue stays about one frame deep. Below the display's rate the gate's releases
are at least two vblanks apart and two frames in one composite are rare, so
mailbox stays (FIFO with gamescope's limiter quantized a 32 fps game to 30 at a
60 cap on 120 Hz; mailbox with the gate did not).

The gate (`icd_shim/src/vsync_gate.rs`) releases acquires on the vblank grid
gamescope composites from, not on a clock of its own: the daemon's `VsyncNote`
latch grid (measured period and phase) moved earlier by the lead the daemon
stamps flip events with (`stamp_lead_ns`). A cap at the display's rate releases
every vblank (60 on a 59.97 Hz panel is 59.97, one frame per latch); a cap that
divides the rate, every `refresh / cap`-th vblank (30 on 60, 60 on 120); any
other cap below the rate, the vblank nearest each instant of an ideal
`1e9 / cap` timeline, which averages the cap on vblank boundaries (45 on 60
releases 3 of 4 vblanks); a cap above the rate, the cap's own clock. A frame
that arrives after its release point starts at once, and the next release is
the next point of the schedule, so a game below the cap keeps its own rate. A
gate on the shim's own `1e9 / N` clock, as before 7bdcbae, beat against the
panel: 57-59 fps on `SurfaceFlinger` at a 60 cap on a 59.97 Hz panel.

The gate needs the stamp lead. Until a note carrying one has arrived (no note
yet, or a daemon that predates the field and sends 0), a capped swapchain whose
mode is not FIFO is declared FIFO and gamescope's limiter is the cap, as before
the container's mode was honoured here; the census reads `source=cap-compat`.
Released on the latch points themselves, frames start ~2 ms after gamescope's
wakeup and straddle its composite (Retroid Pocket 6, cap 60: 58.3 fps and 102
frames replaced in gamescope's mailbox per minute at 60 Hz, 13 % of publishes
late at 120 Hz). A rollout that ships only the payload (the shim) therefore keeps
today's pacing until the app ships the daemon that sends the lead. A swapchain
created before the lead is known keeps the slot count of the guest's own request
until it is recreated.

The census says which path ran. Ungated, once when frames first flow and again
whenever the declared mode, its source, the cap or the gate changes:
`present mode: declared=… requested=… source=env|guest|cap-rate|cap-compat
route=gamescope cap=… gate=vsync|clock|off lsfg=…` (`source=guest`: the guest's
own mode, no env or a FIFO request with no cap; `source=cap-rate`: declared FIFO
because the cap is the display's rate, with `gate=vsync`; `source=cap-compat`:
declared FIFO for want of the stamp lead, with `gate=off`; `gate=clock` is frame
generation's source rate or a cap above the display's rate). With
`GNDRM_LOG_VKSHIM=1` the same line repeats every 10 s with the counters a device
run checks the pacing against: `acquires= commits= declared_fifo=
declared_other= releases= feedback= presented= discarded= fallback=
gate_holds= gate_late= skipped= slot_waits= commit_lat_us= spacing=[…]`. They
are cumulative except the latency and the spacing: `gate_holds`, acquires the
gate held; `gate_late`, acquires that started after their release point (not
the daemon's `late=` on its `vsync: check` line, which counts publishes that
missed their latch); `skipped`, release points moved past for them;
`slot_waits`, acquires that passed the gate and then waited on a compositor
release for a free image; `commit_lat_us`, the smoothed acquire-to-commit time;
`spacing=[<15:… 15-16.2:… 16.2-17.2:… 17.2-20:… >20:…]`, the spacing of
consecutive commits in ms since the last line, the game's own cadence as it
reaches gamescope. Off the override route (the opt-in X11
Present route) the env decides the mode as in Mesa and the gate runs on the
cap's own clock, as before.
- **`-r` is not part of it, and no launch argument carries the limit.** The app
sends the cap to the shim's gate; `--framerate-limit` is never built.

Open points:
- **Divisor rounding bites the gamescope route only.** `nVblankDivisor =
- **Divisor rounding bites the FIFO route only.** `nVblankDivisor =
nRefreshHz / nTargetFPS` (`steamcompmgr.cpp:7040`, and `:10621` for the window's
limited cycle) lands on the nearest divisor of the grid; the shim's floor is
exact (measured: 60.00 commits/s at a 60 cap).
limited cycle) lands on the nearest divisor of the grid, so with the container
set to fifo a 40 or 45 cap on 60 Hz runs at 60. Under mailbox the shim's gate
averages a non-divisor cap on vblanks instead.
- **The client-visible refresh is the mode's, and the mode is the display's.**
`-r` reaches `g_nOutputRefresh` only transiently (`drm_unset_mode`, `:3680`),
before `drm_set_mode` (`:3728`) overwrites it with the mode's rate —
Expand Down
Loading