The OSD stage line stays a partition — an async decode figure is not one of its terms #211

Merged
enricobuehler merged 1 commits from worktree-decode-stat-overlap into main 2026-08-13 21:56:47 +00:00
Owner

Follow-up to #205, from the same 2026-08-13 field report. The reporter read the OSD's stage line as a breakdown of e2e and asked why the parts did not add up:

e2e 8.1/9.1 ms (p50/p95) · host 5.4 · net 0.3 · decode 6.6 · display 1.4 ms (pace 0.2 + latch 1.2)

Fair question. Every number there is individually true. They add up without decode5.4 + 0.3 + 1.4 ≈ 8.1.

Why

The stages really are a per-frame partition of e2e:

pts ──host+net──▶ received ──decode──▶ decoded ──display──▶ displayed

That holds for as long as the decoded stamp is a completion stamp. On the synchronous rungs it is.

On the native-Vulkan rung it is not. receive_frame returns at submission (~0.1 ms, the decode is still on the GPU) and decoded_ns is stamped there (pf-client-core/src/session.rs), while the presenter computes display = displayed_ns − decoded_ns (pf-presenter/src/run.rs). So:

  • host+net covers pts → received
  • display covers submit → displayedthe GPU decode is inside it
  • the two already tile e2e bar the ~0.1 ms received → submit gap

decode, measured received → fence-complete, therefore re-counts the GPU work display already contains. Two figures, one overlap, printed side by side as though they tiled.

What changed

On that rung decode leaves the stage line — which stays a true partition — and gets its own, carrying the two caveats a reader needs before the number means anything:

e2e 8.1/9.1 ms (p50/p95) · host 5.4 · net 0.3 · display 1.4 ms (pace 0.2 + latch 1.2)
decode 6.6 ms (1 sample, inside display — not additive)

Synchronous rungs are untouched: decode is a genuine term there and stays inline. The new Stats::decode_overlaps_display latches per window from the sampling site itself rather than sniffing the rung name, so a mid-window demote moves the flag and the samples together. A window where every fence wait timed out reports 0 — an absence of measurement, not an instant decode — and renders nothing.

Deliberately not changed

The one-sample-per-window design. pf-client-core/src/session.rs argues it at length and the argument is good: a per-frame fence wait serialises the decode pipeline (measured — a 19 ms APU decode capping a 5120×1440 stream at ~51 fps), and M4 already re-examined and rejected polling, which quantises every sample up by a frame interval (8.3 ms at 120 Hz against decodes of ~0.1–2 ms) — "not a cheaper measurement, a wrong one". The reporting around that design was the defect, not the design.

For the record, GPU timestamp queries would be the wrong fix and were considered: they measure the GPU op alone, but the gap in the decomposition is received → complete including queueing — a tidier number that still would not tile, minus exactly the queueing time that makes a slow decoder hurt. The route to a real per-frame term is a waiter thread on the timeline (the in-code comment names it), which is a bigger change than this.

Not answered

Why the sampled frame read 6.6 ms when the sampling comment expects 0.1–2 ms. It is a tail frame by construction — a frame that took 6.6 ms to decode also took ≥6.6 ms to display, against a 1.4 ms display p50 — but whether the first frame of a window is systematically a tail frame needs instrumenting rather than guessing. This PR stops the number misleading readers; it does not tell you whether that decode was genuinely slow.

Verification

Rebased onto 1b167f8e and re-run there (#209 touched console UI, which pf-presenter depends on):

  • pf-presenter 47/47 incl. the new case, which pins both shapes and the timed-out-window zero
  • pf-client-core 188/188
  • clippy --all-targets -D warnings clean on both, cargo fmt --all --check clean

All in the linux/amd64 container. The pf-client-core leg finished suspiciously fast, so it was proven non-vacuous with a planted compile_error! before the clean run was trusted.

No on-glass: this is OSD text, verified by unit test rather than by reading it off a screen.

Follow-up to #205, from the same 2026-08-13 field report. The reporter read the OSD's stage line as a breakdown of `e2e` and asked why the parts did not add up: ``` e2e 8.1/9.1 ms (p50/p95) · host 5.4 · net 0.3 · decode 6.6 · display 1.4 ms (pace 0.2 + latch 1.2) ``` Fair question. Every number there is individually true. **They add up without `decode`** — `5.4 + 0.3 + 1.4 ≈ 8.1`. ## Why The stages really are a per-frame partition of `e2e`: ``` pts ──host+net──▶ received ──decode──▶ decoded ──display──▶ displayed ``` That holds for as long as the `decoded` stamp is a **completion** stamp. On the synchronous rungs it is. On the native-Vulkan rung it is not. `receive_frame` returns at **submission** (~0.1 ms, the decode is still on the GPU) and `decoded_ns` is stamped there (`pf-client-core/src/session.rs`), while the presenter computes `display = displayed_ns − decoded_ns` (`pf-presenter/src/run.rs`). So: - `host+net` covers `pts → received` - `display` covers `submit → displayed` — **the GPU decode is inside it** - the two already tile `e2e` bar the ~0.1 ms `received → submit` gap `decode`, measured `received → fence-complete`, therefore **re-counts** the GPU work `display` already contains. Two figures, one overlap, printed side by side as though they tiled. ## What changed On that rung `decode` leaves the stage line — which stays a true partition — and gets its own, carrying the two caveats a reader needs before the number means anything: ``` e2e 8.1/9.1 ms (p50/p95) · host 5.4 · net 0.3 · display 1.4 ms (pace 0.2 + latch 1.2) decode 6.6 ms (1 sample, inside display — not additive) ``` Synchronous rungs are untouched: `decode` is a genuine term there and stays inline. The new `Stats::decode_overlaps_display` latches per window **from the sampling site itself** rather than sniffing the rung name, so a mid-window demote moves the flag and the samples together. A window where every fence wait timed out reports `0` — an absence of measurement, not an instant decode — and renders nothing. ## Deliberately not changed **The one-sample-per-window design.** `pf-client-core/src/session.rs` argues it at length and the argument is good: a per-frame fence wait serialises the decode pipeline (measured — a 19 ms APU decode capping a 5120×1440 stream at ~51 fps), and M4 already re-examined and rejected polling, which quantises every sample up by a frame interval (8.3 ms at 120 Hz against decodes of ~0.1–2 ms) — "not a cheaper measurement, a wrong one". The reporting around that design was the defect, not the design. For the record, **GPU timestamp queries would be the wrong fix** and were considered: they measure the GPU op alone, but the gap in the decomposition is `received → complete` *including queueing* — a tidier number that still would not tile, minus exactly the queueing time that makes a slow decoder hurt. The route to a real per-frame term is a waiter thread on the timeline (the in-code comment names it), which is a bigger change than this. ## Not answered Why the sampled frame read **6.6 ms** when the sampling comment expects 0.1–2 ms. It is a tail frame by construction — a frame that took 6.6 ms to decode also took ≥6.6 ms to display, against a 1.4 ms display p50 — but whether the first frame of a window is *systematically* a tail frame needs instrumenting rather than guessing. This PR stops the number misleading readers; it does not tell you whether that decode was genuinely slow. ## Verification Rebased onto `1b167f8e` and re-run there (#209 touched console UI, which `pf-presenter` depends on): - `pf-presenter` **47/47** incl. the new case, which pins both shapes and the timed-out-window zero - `pf-client-core` **188/188** - `clippy --all-targets -D warnings` clean on both, `cargo fmt --all --check` clean All in the linux/amd64 container. The `pf-client-core` leg finished suspiciously fast, so it was proven non-vacuous with a planted `compile_error!` before the clean run was trusted. No on-glass: this is OSD text, verified by unit test rather than by reading it off a screen.
enricobuehler added 1 commit 2026-08-13 21:50:55 +00:00
fix(client/stats): keep the stage line a partition — an async decode figure is not one of its terms
ci / web (pull_request) Successful in 2m11s
ci / bun-nix (pull_request) Successful in 3m25s
ci / rust-arm64 (pull_request) Successful in 4m39s
android / android (pull_request) Successful in 6m20s
ci / docs-site (pull_request) Successful in 5m37s
ci / rust (pull_request) Successful in 6m40s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m49s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m34s
81022bcc80
A 2026-08-13 field report read the OSD's stage line as a breakdown of e2e and
asked why the parts did not add up: `host 5.4 · net 0.3 · decode 6.6 ·
display 1.4` against `e2e 8.1/9.1`. Fair question, and the numbers are all
individually true. They add up without `decode`: 5.4 + 0.3 + 1.4 ≈ 8.1.

The stages ARE a per-frame partition of e2e — pts →(host+net)→ received
→(decode)→ decoded →(display)→ displayed — and that holds for as long as the
`decoded` stamp is a COMPLETION stamp. On the synchronous rungs it is. On the
native-Vulkan rung `receive_frame` returns at SUBMISSION (~0.1 ms) and the
stamp shipped to the presenter is taken there, so `display` is measured from
submit and the GPU decode happens INSIDE it. `host+net` and `display` already
tile e2e between them; the `decode` figure, measured received → fence-complete,
re-counts the GPU work `display` contains. Two figures, one overlap, printed
side by side as though they tiled.

So on that rung `decode` leaves the stage line and gets its own, carrying the
two caveats a reader needs before the number means anything: it is ONE sample
per window there, not the p50 every other figure on that line is, and it is
already inside `display` so adding it double-counts. The synchronous rungs are
untouched — `decode` is a real term there and stays inline.

Deliberately NOT changed: the one-sample-per-window design. `pf_client_core::
session` argues it at length — a per-frame fence wait serialises the decode
pipeline (an APU's 19 ms decode capping a 5120×1440 stream at ~51 fps), and M4
already re-examined and rejected polling, which quantises every sample up by a
frame interval (8.3 ms at 120 Hz against decodes of ~0.1-2 ms). That reasoning
still holds; the reporting around it was the defect. Making `decode` a genuine
per-frame term would need a completion stamp off the hot path — a waiter thread
on the timeline, which that comment already names as the remaining option — and
is a bigger change than this one.

Also not answered here: why the sampled frame read 6.6 ms when the sampling
comment expects 0.1-2 ms. It is a tail frame by construction (a frame that took
6.6 ms to decode also took ≥ 6.6 ms to display, against a 1.4 ms display p50),
but whether the first frame of a window is SYSTEMATICALLY a tail frame needs
instrumenting rather than guessing.

Verified in the linux/amd64 container: pf-presenter 47/47 (incl. the new case,
which pins both shapes and the timed-out-window zero), pf-client-core 188/188,
`clippy --all-targets -D warnings` clean on both, fmt clean. The pf-client-core
leg was proven non-vacuous with a planted compile_error! first.
enricobuehler merged commit 8fe5acf7f2 into main 2026-08-13 21:56:47 +00:00
enricobuehler deleted branch worktree-decode-stat-overlap 2026-08-13 21:56:50 +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#211