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 → completeincluding 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-presenter47/47 incl. the new case, which pins both shapes and the timed-out-window zero
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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Follow-up to #205, from the same 2026-08-13 field report. The reporter read the OSD's stage line as a breakdown of
e2eand asked why the parts did not add up: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:That holds for as long as the
decodedstamp is a completion stamp. On the synchronous rungs it is.On the native-Vulkan rung it is not.
receive_framereturns at submission (~0.1 ms, the decode is still on the GPU) anddecoded_nsis stamped there (pf-client-core/src/session.rs), while the presenter computesdisplay = displayed_ns − decoded_ns(pf-presenter/src/run.rs). So:host+netcoverspts → receiveddisplaycoverssubmit → displayed— the GPU decode is inside ite2ebar the ~0.1 msreceived → submitgapdecode, measuredreceived → fence-complete, therefore re-counts the GPU workdisplayalready contains. Two figures, one overlap, printed side by side as though they tiled.What changed
On that rung
decodeleaves the stage line — which stays a true partition — and gets its own, carrying the two caveats a reader needs before the number means anything:Synchronous rungs are untouched:
decodeis a genuine term there and stays inline. The newStats::decode_overlaps_displaylatches 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 reports0— 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.rsargues 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 → completeincluding 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
1b167f8eand re-run there (#209 touched console UI, whichpf-presenterdepends on):pf-presenter47/47 incl. the new case, which pins both shapes and the timed-out-window zeropf-client-core188/188clippy --all-targets -D warningsclean on both,cargo fmt --all --checkcleanAll in the linux/amd64 container. The
pf-client-coreleg finished suspiciously fast, so it was proven non-vacuous with a plantedcompile_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.