PyroWave on Linux: the GPU-priority knob never fired, a dmabuf timeout condemned the host, and the jumbo grow was dead code #132

Merged
enricobuehler merged 22 commits from worktree-wave2-pyrowave into main 2026-08-08 23:23:13 +00:00
Owner

Wave 2 of the Linux host performance program — the PyroWave packages (PW1–PW8), plus the
instruments that had to exist before any of it could be measured.

Design doc: punktfunk-planning/design/linux-host-performance-wave2-pyrowave.md (its per-package
status blocks are updated with every number below, and are the place to read the detail).

Everything here is default-neutral or default-off except PW1's capability grant and PW3's latch
behaviour. Nothing changes what a client negotiates, and no wire format moves.

What each package did, and what it measured

PW2 — a PyroWave session could drop to CPU capture and log nothing at all.
The CPU-fallback warning was gated on backend_is_vaapi, which reads the host-global encoder
pref. A PyroWave session is negotiated per session, so on an NVIDIA/auto host that gate is false
and the session fell out of every arm of the log chain — paying a full-resolution CPU pixel touch
per frame while logging nothing. A degraded host and a healthy one produced identical logs.
Measured on the exact silent configuration: 0 → 2 lines. All three capture arms now announce
themselves (cuda-import → nvenc, dmabuf-passthrough → pyrowave, cpu → pyrowave).

PW1 — the GPU-priority knob had never done anything on Linux.
patches/0005 requests an elevated global-priority queue, but it is gated if (!inherit_info) and
only Windows leaves that null; Linux passes its own create-infos and takes the inherit branch, so
the block was skipped. Wired natively, with the C patch's grammar and a REALTIME→HIGH→none ladder
that can never fail an open.

Then the measurement changed the package: the lever is inert without CAP_SYS_NICE, on every
vendor
— not the RADV-only downgrade the plan predicted. Without it every class is refused; with
it REALTIME is granted first try. So the capability now ships from all six packaging paths (Arch,
deb, RPM %caps, NixOS security.wrappers, Deck installer, Bazzite sysext at image-build time).

Measured under a GRID 2 benchmark loop at 54–87 % GPU, same binary both arms:

arm p50 p99 worst frame
default priority (refused) ~2.6 ms ~6.4 ms 9.5 ms
REALTIME granted ~3.2 ms ~4.4 ms 5.4 ms

p99 down ~30 %, worst frame roughly halved, for ~0.6 ms on the median — the right trade for a
streaming encoder, where the tail is what becomes a visible hitch. This contradicts patch 0005's
only prior datum (RTX 4090 / Windows / WDDM: "did not reduce the spikes"), and its header now records
both results side by side with an explicit "do not delete this patch — the two stacks disagree".

PW3 — one dmabuf timeout condemned every later capture on the host, forever.
The raw-dmabuf latch had two causes sharing one AtomicBool: an encoder that cannot import what the
compositor allocates (unrecoverable) and an offer that never negotiated (often just a compositor
mid-restart). One timeout meant CPU capture for the rest of the process — including sessions against
a completely different compositor and node. Now: import failures stay sticky, negotiation timeouts
get one retry, a negotiated capture credits the budget back, and both are keyed to a capture
identity so a new node earns a fresh attempt while the same one keeps its verdict. The session-open
line carries the latch state.

PW4 — retired on evidence, exactly as pre-registered.
The package proposed moving the producer-fence wait off the PipeWire loop thread. It is already
free: no producer in the fleet attaches an implicit fence.

producer × vendor result
gamescope × NVIDIA no fence
Mutter × NVIDIA no fence
gamescope × RADV (Deck) 300/300, mean 23 µs, p99 ≤100 µs
KWin × RADV (Deck desktop) no fence

Shipped instead: the histogram (kept — it is how anyone re-checks) and a comment correction. The
100 ms budget stays as a guard for a producer that does fence.

PW5 — stages 1–5, overlap deliberately OFF (max_inflight = 1).
The prerequisite analysis found pyrowave's encoder structurally cannot hold two frames in flight
(single wavelet/scratch buffers, and Impl::encode opens by discarding them), so "double patch
0004's pool" was the wrong fix — the design is two alternated handles. That carries a landmine:
the 3-bit wire sequence counter is per-encoder, so alternating emits 1,1,2,2,3,3… and the decoder
(restart = diff != 0) reads a repeat as more blocks of the same frame — every second frame
silently swallowed. patches/0007 exposes a sequence setter; the gate has a negative control
that reproduces the failure exactly when the patch is removed.

Landed: pool census, buffer headroom request, submit/poll split, doubled per-slot resources
(six single-slot resources, not the two the plan named), second handle. Depth 2 was run on hardware
(bit-identical output vs depth 1) but is not shipped — the capture-side tear-hunt needs a
live client and is not done.

PW6 — PyroWave can stream its AU to the wire. Off by default.
Also establishes that newest-wins draining was never the blocker: host STREAMED_AU chunks still
complete as one Frame; it is client prefix delivery that the entry-counting drain cannot
survive. So PW6 buys host send-side pipelining, not decode-while-arriving — the "~7 ms" framing
in the original plan is not reachable as scoped.

Loss behaviour, 20 000 frames per cell at 2 % loss with FEC off: streamed and whole-AU are an
exact tie, zero vanished frames either way. But a probe dropping precisely the final block
separates them completely — whole-AU 200 partials / 0 losses, streamed 0 partials / 200 total
losses
. Hence the default stays off. On-glass: busy test cards, luma PSNR 40.16 dB, and
byte-identical AU streams across chunk sizes.

PW7a — the shipped jumbo grow was dead code.
quinn caps MTU discovery at the peer's advertised max_udp_payload_size, which defaults to 1472,
and nothing in the repo had ever touched EndpointConfig. Proven by A/B: server-only opt-in settles
at 1472 B, both ends opted in reaches 8972 B in 5 ms. Fixed, and the session-start grow is
authorised not by memory but by a live re-proof on the connection being welcomed — quinn's
current_mtu only rises to a probe size that was acked, so a moved laptop cannot inherit a jumbo
verdict. ⚠ Jumbo now needs the opt-in on both ends; Android/iOS would need a client setting.

PW8 — closed, premise stale. KWin does not cap virtual outputs at 60 Hz; it creates them at 60
and kwin.rs already does what PW8 proposed to investigate. The 60 Hz is the rejection fallback.

Instruments added (none existed before)

  • PyroWave PUNKTFUNK_PERF encode split (mean/p50/p99/max) — every other backend had one; the
    encoder this program exists to protect was the one you could not measure.
  • Fence-wait histogram on the PipeWire loop thread.
  • PipeWire buffer-pool census — the number every zero-copy safety argument rests on, never counted.
  • spike --codec pyrowave + --wire-chunk, and tools/loss-harness.

Review notes

  • Four branches merged here, and two conflicts were semantic rather than textual. The important
    one: PW6 guards poll() while a chunk cursor is open, PW5 moved the fence wait into poll().
    Either side alone silently breaks the other; both are kept, guard first.
  • Vendored discipline: patches/0007 re-applies clean to a pristine tree; git diff crates/pyrowave-sys/vendor/ touches only intended files.
  • "Byte-identical stream" is not an achievable gate — three runs of the same unmodified binary
    produce three different AU hashes (the 4:2:0 encoder emits run-varying bytes the decoder ignores).
    The real gate is decode identity, and it holds.
  • Knobs documented in docs-site/content/docs/configuration.md + running-as-a-service.md.

Gates

Full CI parity on a Linux box, on the merged result, not just per branch: cargo fmt --check;
workspace clippy -D warnings; pf-encode clippy with nvenc,vulkan-encode,pyrowave and
without; workspace build; workspace tests. All green.

Still owed (all hardware-gated)

  1. PW5's capture-side tear-hunt — the blocker for depth 2.
  2. Producer pool depths — the instrument ships, nobody has run a session with it.
  3. PW7a's A/B on a real 9000-MTU segment.
  4. A millisecond number for PW6's send-side win before its default could ever flip.
  5. A boosted-GPU sweep at 1440p/4K to firm up PW5's scoping. (Note: on this card idle is the
    worst case — it downclocks to 180 MHz of 3090, so idle timings are ~3× slow, not fast.)
Wave 2 of the Linux host performance program — the PyroWave packages (PW1–PW8), plus the instruments that had to exist before any of it could be measured. Design doc: `punktfunk-planning/design/linux-host-performance-wave2-pyrowave.md` (its per-package status blocks are updated with every number below, and are the place to read the detail). Everything here is **default-neutral or default-off** except PW1's capability grant and PW3's latch behaviour. Nothing changes what a client negotiates, and no wire format moves. ## What each package did, and what it measured **PW2 — a PyroWave session could drop to CPU capture and log nothing at all.** The CPU-fallback warning was gated on `backend_is_vaapi`, which reads the *host-global* encoder pref. A PyroWave session is negotiated *per session*, so on an NVIDIA/auto host that gate is false and the session fell out of every arm of the log chain — paying a full-resolution CPU pixel touch per frame while logging nothing. A degraded host and a healthy one produced identical logs. Measured on the exact silent configuration: **0 → 2 lines**. All three capture arms now announce themselves (`cuda-import → nvenc`, `dmabuf-passthrough → pyrowave`, `cpu → pyrowave`). **PW1 — the GPU-priority knob had never done anything on Linux.** `patches/0005` requests an elevated global-priority queue, but it is gated `if (!inherit_info)` and only Windows leaves that null; Linux passes its own create-infos and takes the inherit branch, so the block was skipped. Wired natively, with the C patch's grammar and a REALTIME→HIGH→none ladder that can never fail an open. Then the measurement changed the package: the lever is **inert without `CAP_SYS_NICE`, on every vendor** — not the RADV-only downgrade the plan predicted. Without it *every* class is refused; with it REALTIME is granted first try. So the capability now ships from all six packaging paths (Arch, deb, RPM `%caps`, NixOS `security.wrappers`, Deck installer, Bazzite sysext at image-build time). Measured under a GRID 2 benchmark loop at 54–87 % GPU, same binary both arms: | arm | p50 | p99 | worst frame | |---|---|---|---| | default priority (refused) | ~2.6 ms | **~6.4 ms** | 9.5 ms | | REALTIME granted | ~3.2 ms | **~4.4 ms** | 5.4 ms | p99 down ~30 %, worst frame roughly halved, for ~0.6 ms on the median — the right trade for a streaming encoder, where the tail is what becomes a visible hitch. This **contradicts** patch 0005's only prior datum (RTX 4090 / Windows / WDDM: "did not reduce the spikes"), and its header now records both results side by side with an explicit "do not delete this patch — the two stacks disagree". **PW3 — one dmabuf timeout condemned every later capture on the host, forever.** The raw-dmabuf latch had two causes sharing one `AtomicBool`: an encoder that cannot import what the compositor allocates (unrecoverable) and an offer that never negotiated (often just a compositor mid-restart). One timeout meant CPU capture for the rest of the process — including sessions against a completely different compositor and node. Now: import failures stay sticky, negotiation timeouts get one retry, a negotiated capture credits the budget back, and both are keyed to a capture identity so a new node earns a fresh attempt while the same one keeps its verdict. The session-open line carries the latch state. **PW4 — retired on evidence, exactly as pre-registered.** The package proposed moving the producer-fence wait off the PipeWire loop thread. It is already free: **no producer in the fleet attaches an implicit fence.** | producer × vendor | result | |---|---| | gamescope × NVIDIA | no fence | | Mutter × NVIDIA | no fence | | gamescope × RADV (Deck) | 300/300, mean 23 µs, p99 ≤100 µs | | KWin × RADV (Deck desktop) | no fence | Shipped instead: the histogram (kept — it is how anyone re-checks) and a comment correction. The 100 ms budget stays as a guard for a producer that *does* fence. **PW5 — stages 1–5, overlap deliberately OFF (`max_inflight` = 1).** The prerequisite analysis found pyrowave's encoder **structurally cannot** hold two frames in flight (single wavelet/scratch buffers, and `Impl::encode` opens by discarding them), so "double patch 0004's pool" was the wrong fix — the design is two alternated handles. That carries a landmine: the 3-bit wire sequence counter is per-encoder, so alternating emits `1,1,2,2,3,3…` and the decoder (`restart = diff != 0`) reads a repeat as *more blocks of the same frame* — every second frame silently swallowed. `patches/0007` exposes a sequence setter; the gate has a **negative control** that reproduces the failure exactly when the patch is removed. Landed: pool census, buffer headroom request, `submit`/`poll` split, doubled per-slot resources (six single-slot resources, not the two the plan named), second handle. Depth 2 was run on hardware (**bit-identical** output vs depth 1) but is **not shipped** — the capture-side tear-hunt needs a live client and is not done. **PW6 — PyroWave can stream its AU to the wire. Off by default.** Also establishes that newest-wins draining was never the blocker: host STREAMED_AU chunks still complete as one `Frame`; it is *client prefix delivery* that the entry-counting drain cannot survive. So PW6 buys host send-side pipelining, **not** decode-while-arriving — the "~7 ms" framing in the original plan is not reachable as scoped. Loss behaviour, 20 000 frames per cell at 2 % loss with FEC off: streamed and whole-AU are an **exact tie**, zero vanished frames either way. But a probe dropping *precisely the final block* separates them completely — whole-AU 200 partials / 0 losses, streamed 0 partials / **200 total losses**. Hence the default stays off. On-glass: busy test cards, **luma PSNR 40.16 dB**, and byte-identical AU streams across chunk sizes. **PW7a — the shipped jumbo grow was dead code.** quinn caps MTU discovery at the *peer's* advertised `max_udp_payload_size`, which defaults to 1472, and nothing in the repo had ever touched `EndpointConfig`. Proven by A/B: server-only opt-in settles at **1472 B**, both ends opted in reaches **8972 B in 5 ms**. Fixed, and the session-start grow is authorised not by memory but by a **live re-proof on the connection being welcomed** — quinn's `current_mtu` only rises to a probe size that was *acked*, so a moved laptop cannot inherit a jumbo verdict. ⚠ Jumbo now needs the opt-in on **both** ends; Android/iOS would need a client setting. **PW8 — closed, premise stale.** KWin does not cap virtual outputs at 60 Hz; it creates them at 60 and `kwin.rs` already does what PW8 proposed to investigate. The 60 Hz is the *rejection* fallback. ## Instruments added (none existed before) - PyroWave `PUNKTFUNK_PERF` encode split (mean/p50/**p99**/max) — every other backend had one; the encoder this program exists to protect was the one you could not measure. - Fence-wait histogram on the PipeWire loop thread. - PipeWire buffer-pool census — the number every zero-copy safety argument rests on, never counted. - `spike --codec pyrowave` + `--wire-chunk`, and `tools/loss-harness`. ## Review notes - **Four branches merged here**, and two conflicts were semantic rather than textual. The important one: PW6 guards `poll()` while a chunk cursor is open, PW5 moved the fence wait *into* `poll()`. Either side alone silently breaks the other; both are kept, guard first. - **Vendored discipline**: `patches/0007` re-applies clean to a pristine tree; `git diff crates/pyrowave-sys/vendor/` touches only intended files. - **"Byte-identical stream" is not an achievable gate** — three runs of the same unmodified binary produce three different AU hashes (the 4:2:0 encoder emits run-varying bytes the decoder ignores). The real gate is decode identity, and it holds. - Knobs documented in `docs-site/content/docs/configuration.md` + `running-as-a-service.md`. ## Gates Full CI parity on a Linux box, **on the merged result**, not just per branch: `cargo fmt --check`; workspace `clippy -D warnings`; `pf-encode` clippy with `nvenc,vulkan-encode,pyrowave` **and** without; workspace build; workspace tests. All green. ## Still owed (all hardware-gated) 1. PW5's capture-side tear-hunt — the blocker for depth 2. 2. Producer pool depths — the instrument ships, nobody has run a session with it. 3. PW7a's A/B on a real 9000-MTU segment. 4. A millisecond number for PW6's send-side win before its default could ever flip. 5. A boosted-GPU sweep at 1440p/4K to firm up PW5's scoping. (Note: on this card idle is the *worst* case — it downclocks to 180 MHz of 3090, so idle timings are ~3× slow, not fast.)
enricobuehler added 22 commits 2026-08-08 23:22:11 +00:00
Wave-2 PW2 (design/linux-host-performance-wave2-pyrowave.md). Observability only — no
behaviour change to any capture decision — and it lands first because every later package
in the program is measured by an A/B whose "before" is currently unreadable.

The defect: the capture path's CPU-fallback warning was gated on `backend_is_vaapi`, which
reads the HOST-GLOBAL encoder pref. A PyroWave session is negotiated PER SESSION, so on an
NVIDIA/auto host that gate is false — and the session then fell out of every arm of the
negotiation log chain, emitting nothing whatsoever while paying a full-resolution CPU pixel
touch on every frame. A degraded host and a healthy one produced identical logs.

Four sites, matching PW2.1-2.4:

1. The CPU-path warning now asks the per-session question (`consumer_kind`) instead of the
   pref, and names the consumer. Its gate widened to every GPU consumer and excludes only
   the software encoder, whose native input IS CPU frames — an NVENC session silently on
   the CPU path is the same defect, not a different one. `pyrowave_session` deliberately
   outranks `backend_is_vaapi`, because a PyroWave pref flips `backend_is_vaapi` on too
   (`linux_zero_copy_is_vaapi_for`'s `Pyrowave` arm), so testing vaapi first would swallow
   every PyroWave session.

2. The raw-passthrough block in `consume_frame` had four silent exits — no format, an
   SHM/MemFd buffer, no DRM fourcc, a failed `F_DUPFD_CLOEXEC` — each falling out of three
   nested `if`s into the CPU de-pad path. It is now a labeled block that breaks with a named
   `PassthroughFallback`, logged once per distinct reason per session with a running count,
   so a persistent downgrade is distinguishable from a hiccup at renegotiation. `.process`
   runs per frame, so the rate limit is the shippable part and is what the tests pin.

   Note `NoFormat` does NOT fall back — the CPU path needs `ud.format` too and returns — so
   the line says DROPPED for that one. Three of four downgrade; one loses the frame.

3. `force_cpu_for_nvenc_444` told a 4:4:4 PyroWave session it was "on the NVENC path", which
   is false in every particular: the wavelet encoder never touches NVENC, never swscales to
   YUV444P, and what it actually loses is the raw-dmabuf passthrough its design assumes.

4. One INFO line at pipeline build states the resolved arm and consumer
   (`capture pipeline resolved: dmabuf-passthrough → pyrowave`). Nothing stated it before;
   the 2026-08-08 triage reconstructed it from four files, and for the arm that matters most
   there was no detail line to reconstruct it from.

Also: `spike --codec pyrowave`, so a PyroWave capture→encode pass can be driven without a
client. That is the harness the rest of this program measures on, and it did not exist.

Gates on .21 at CI parity: fmt, workspace clippy -D warnings, pf-encode clippy with
nvenc,vulkan-encode,pyrowave and without, workspace tests.
Found while taking PW2's on-glass measurement, and it is what made the measurement possible.

`spike` built its capture request from `OutputFormat::resolve`, the constructor shared with the
GameStream path, which hard-codes `pyrowave: false` ("GameStream never negotiates PyroWave").
On Linux that flag is not cosmetic: `capture_virtual_output` feeds it to `zero_copy_policy` as
`ZeroCopyPolicy::pyrowave_session`, which is what puts the capture on the raw-dmabuf passthrough.
So `--codec pyrowave` opened a PyroWave encoder over a capture negotiated for a different
consumer, and the only way to exercise the real path was the host-global
`PUNKTFUNK_ENCODER=pyrowave` lever.

That lever cannot stand in for the per-session flag, which is the part that matters here: it
resolves the backend to `Pyrowave`, and `linux_zero_copy_is_vaapi_for` returns true for that —
so it ALSO flips `backend_is_vaapi` on. A per-session PyroWave negotiation on an auto/NVENC host,
where `backend_is_vaapi` is false, was therefore unreachable from the CLI — and that is exactly
the configuration whose CPU downgrade logged nothing at all.

The spike now sets the flag from its own codec, the same comparison `session_plan::output_format`
makes for a real session. With it, the before/after on .21 is unambiguous: origin/main logs zero
capture-path lines on that configuration, this branch logs two (the resolved arm, and the named
downgrade with its cause and fix).
Wave-2 PW1, first half = Wave-1 WP14 step 4, executed as specced.

PyroWave encodes on the same GPU shader cores a game saturates, and that is measured to hurt:
patch 0005's header records `encode_gpu_synchronous` going from ~2 ms to 15-18 ms at 95 % game
load, with the stream frame rate collapsing. NVENC is immune because it has its own ASIC. The
lever for a compute workload is an elevated global-priority QUEUE — a process-priority raise only
reorders submission, not hardware preemption.

The vendored patch requests exactly that. It is gated `if (!inherit_info)`, and only Windows
leaves `inherit_info` null (`pyrowave_create_device_by_compat`, where Granite builds the device
itself). Linux passes its own create-infos into `pyrowave_device_create_info`, Granite's
`get_existing_create_info()` hands them back, `create_device` takes the inherit branch — and the
whole block is skipped. On Linux the knob has never done anything at all. Meanwhile pf-zerocopy's
VkBridge has shipped the identical ladder on Linux for some time and calls it "the actual NVIDIA
compute-preemption lever"; the encoder that needs it most did not have it.

This wires it natively in `open_inner`'s `DeviceHold`:

- The extension probe reuses the `dev_ext_props` already fetched for queue_family_foreign, and
  takes KHR or the EXT alias — the same spelling pf-zerocopy probes, so the two cannot disagree.
- `queue_priority_candidates` is a pure fn with the grammar copied from the C patch: unset →
  realtime, ASCII-lowercased, `off` alone disables, `high` asks for HIGH only, junk falls back to
  the ladder rather than to off. One env var must not mean two things on two platforms — that is
  the documentation trap this package exists to close — so the grammar is unit-tested against the
  patch's, including where they are both deliberately un-clever (neither trims).
- The create ladder is REALTIME → HIGH → no-priority, stepping only on a refusal. A refused class
  can never fail the open, which matters more here than on Windows: this path is reached only by a
  NEGOTIATED PyroWave session, so a hard error is a dead stream, not a fallback to another encoder.

The subtle part is the write-back. `pyrowave_create_device` RETAINS `device_create_info` for the
device's lifetime and Granite reads the chain back. If the ladder ends on the no-priority attempt
while `_queue_ci[0].p_next` still points at the global-priority struct, Granite is handed a chain
the device was not created with. The `None` arm therefore nulls `p_next` before the final create,
and the field's doc says why. The enabled extension deliberately STAYS in the list: it really is
enabled on the device, it just carries no request.

One deviation from the plan, stated because it is a deviation: the ladder also steps down on
`ERROR_INITIALIZATION_FAILED`, not only `ERROR_NOT_PERMITTED_KHR`. The plan and the C patch handle
only the latter; pf-zerocopy's shipped ladder accepts both. Given a hard error here kills a
negotiated session, treating one extra driver-specific refusal as a downgrade is the cheap side of
that asymmetry.

Also corrects the two vendored notes, which claimed a Linux behaviour the gate made impossible,
and records that patch 0005's negative RTX-4090 result is Windows/WDDM and does not transfer to a
different driver stack. Patch hunks are byte-identical (header prose only) and
`git diff crates/pyrowave-sys/vendor/` is PUNKTFUNK-VENDOR.txt alone.

`PYROWAVE_QUEUE_PRIORITY` is now reachable on Linux, so it is documented in the same PR.

MEASURED ON GLASS, and it changes what this package is worth on its own — .21, RTX 5070 Ti,
NVIDIA 610.43.02, same binary in both arms:

  as packaged (no capability)     every class refused, REALTIME *and* HIGH -> default priority
  same binary, cap_sys_nice+ep    granted REALTIME on the FIRST attempt, no downgrade

So the lever is INERT on an unprivileged host, and that is not the RADV-specific downgrade the
plan predicted — on NVIDIA it is a downgrade to nothing at all. The ladder itself is proven good
across all three legs (unset / high / off): a refused class never fails the open, and `off`
enables no extension and logs nothing. It simply has nothing to grant yet.

The privilege needed is CAP_SYS_NICE on the host binary, which is NOT what Wave-1 WP3 ships
(RLIMIT_NICE, PAM limits, CPUWeight — all different things). That grant is a security-posture
change on a network-facing daemon, so it is deliberately NOT in this commit; the warn line now
names the capability so an operator is not left guessing, and the docs row says the setting has no
effect on most hosts today rather than implying it works.

The loaded-GPU encode_us p99 A/B is therefore not run: it needs a GPU-saturating game (hence a
desktop session the box does not currently have) and it is pointless before the capability lands,
since the unprivileged arm has no priority to measure.

NO unit test is possible for the device-create ladder itself — it needs a real Vulkan device. Its
coverage is the clippy pass, the grammar tests, and the on-glass log line. Stated here rather than
left for a reviewer to wonder about.
Wave-2 PW1, second half. The companion commit wires `PYROWAVE_QUEUE_PRIORITY` into the Linux
PyroWave device; this is what makes it work on a packaged host.

Measured on .21 (RTX 5070 Ti, NVIDIA 610.43.02), same binary in both arms:

  as packaged (no capability)     every class refused, REALTIME *and* HIGH -> default priority
  same binary, cap_sys_nice+ep    granted REALTIME on the FIRST attempt, no downgrade

RADV behaves the same way. So this is not the RADV-specific "expect one downgrade to HIGH" the
plan predicted — without the capability there is no elevated priority at all, on any vendor, and
the knob is decoration.

Worth being precise about what is being granted, because it is a network-facing daemon.
CAP_SYS_NICE permits raising scheduling priority (nice, ioprio, affinity, RT class) and nothing
else: no filesystem access, no network privilege, no user switching, and it is NOT setuid. The
repo already ships exactly this capability on its gamescope binary for the same reason. Two side
effects that will otherwise confuse someone debugging: a capability-carrying binary is AT_SECURE,
so the loader ignores LD_LIBRARY_PATH/LD_PRELOAD for it (note this box was propped up by exactly
such a shim during the ffmpeg-9 soname break — that workaround would now be silently ignored), and
core dumps are suppressed by default.

Per packaging path, because none of them are the same:

- Arch: a `_grant_sched_capability` in the scriptlet, called from post_install AND post_upgrade —
  a replaced binary is a new inode, so the capability does not survive an upgrade by itself.
- Debian: the same setcap in the postinst `configure` branch.
- RPM: `%caps(cap_sys_nice=ep)` on the binary in `%files`, which is the rpm-native form — rpm then
  applies it on install, restores it on upgrade, and verifies it. A `%post setcap` does none of
  those.
- NixOS: `security.wrappers`, because a store path is read-only and shared and cannot be setcap'd.
  The unit's ExecStart moves to `config.security.wrapperDir` — without that the wrapper exists and
  the service still runs the uncapped store path, which is the whole failure this fixes.
- Steam Deck: setcap in the installer's sudo block. That box needs it most (one small Van Gogh GPU
  shared between the game and the encode). The binary lives under $HOME, so unlike the /etc
  drop-ins it survives a SteamOS A/B update on its own and needs no atomic-keep entry — but it
  does need re-applying after each rebuild, which re-running the installer does.
- Bazzite sysext: at IMAGE BUILD time, before mksquashfs. It cannot be done in the merge hook (a
  merged sysext's /usr is read-only squashfs) and it cannot ride in from the RPM either — rpm keeps
  capabilities in its own header and `rpm2cpio | cpio` carries only the payload, so the staged file
  arrives with none. mksquashfs does record security.capability (only security.selinux is
  excluded), so a setcap on the staging tree is what lands in the image. Needs root/CAP_SETFCAP;
  a plain-user CI build warns and ships without it rather than failing a release over a
  performance lever.

Every one of them is best-effort and cannot fail an install: a box without libcap, or a filesystem
that cannot store capabilities, simply runs at default priority exactly as it does today.

Documented in the same PR — the configuration row now says the packages grant it, and
running-as-a-service gets a section explaining what it is, how to check it (`getcap`), and how to
remove it (`setcap -r`, or just `PYROWAVE_QUEUE_PRIORITY=off`), including the two debugging side
effects.

Verified: the Arch scriptlet grants the capability from a fake package root exactly as pacman
would invoke it, and the resulting binary reaches REALTIME end to end on the RTX 5070 Ti; the RPM
spec's %caps line parses under rpmspec in a Fedora 41 container; the NixOS module parses under
nix-instantiate; all five edited shell scripts pass `bash -n`. No Rust file changed in this
commit, so the CI-parity Rust gates from the companion commit still stand.
Wave-2 PW3.

The raw-dmabuf passthrough has two very different reasons to switch itself off, and they shared one
`AtomicBool`:

  * the encoder repeatedly failed to import what this compositor allocates — unrecoverable, a
    driver fact, and the reason this latch was written (it stops the encode-stall recovery
    rebuilding the same doomed encoder five times and then ending the session, on every connection,
    forever);
  * the dmabuf-only capture offer never negotiated — which can simply mean the compositor was
    mid-restart.

Sharing the flag made the second as permanent as the first. One timeout, and EVERY later session on
the host captured CPU frames until the process was restarted — including sessions against a
completely different compositor and a different node, which had never failed at anything. Nothing
said so; the arm line PW2 added would have shown `cpu` with no explanation.

Now the two causes have the lifetimes they should have, in a `RawDmabufLatch` that owns both:

  * Import failures stay sticky. Unchanged threshold (3 consecutive), unchanged hazard coverage.
  * Negotiation timeouts get a retry budget of 2 — one retry, deliberately small: each failure
    costs a ~10 s stall, so a larger budget is paid by the user in dead air. One retry survives the
    mid-restart transient; a compositor that genuinely never accepts keeps the same identity, so it
    latches on the second try, one extra stall per host lifetime versus the old behaviour.
  * A capture that negotiates credits the budget back, so an evening of reconnects against a
    compositor that failed once cannot accumulate its way into a latch.
  * BOTH are keyed to a capture identity (node id + portal bit). A new node — fresh virtual output,
    compositor restart, the Bazzite Gaming↔Desktop switch — is a genuinely different question and
    earns a fresh dmabuf attempt instead of inheriting a verdict about something else. The SAME
    capture keeps its verdict, which is what preserves the 10 s-stall protection the latch exists
    for.

The session-open line now carries the latch state, so `cpu` is no longer ambiguous between "this
host was never going to do dmabuf" and "something failed earlier and we are still living with the
verdict" — only the second is a bug worth chasing, and only the second is now visible as one.

Atomics rather than a lock because `note_import_ok` is on the per-frame import path; everything
else runs at pipeline build or on failure. The state machine is tested against a local instance
rather than the process-wide static — seven tests covering both lifetimes, the identity clear, the
same-identity hold, the budget credit, and the cause naming.

One honest note on the identity: it is the PipeWire node id, not the "(compositor-id, modifier
list)" pair the design sketched. Node id is what capture actually has at that point, and it changes
on exactly the events that matter here (new virtual output, compositor restart, session switch).
Keying on the modifier list too would need the list before the importer is built, which is the
wrong order.
Wave-2 PW4, step one of one-so-far. The package's own first line is "investigation step first
(measure, then decide)", and it is pre-registered to be ABANDONED if the wait's p99 is ~0 — so the
instrument ships before the change, not after.

A per-session histogram of `wait_read_ready`, taken on the PipeWire loop thread, which is exactly
where the wait is expensive: that thread is the compositor's consumer, so time blocked there delays
buffer recycling for the NEXT frame. Logged under PUNKTFUNK_PERF at the same cadence and gate the
encode backends use for their submit splits, so a perf run reads as one instrument: samples, mean,
max, p50/p99 bucket, and the Signaled/NoFence/TimedOut/failed split.

Buckets are coarse on purpose (100us -> 10ms, plus overflow). The decision this feeds is binary —
a p99 in the first bucket means the wait is already free and PW4 becomes a comment correction; a
p99 past 1ms is a real stall against a 16.6ms frame budget. Edges are placed so those two worlds
cannot be confused, and anything past the last edge reports as overflow rather than clamping into
the top bucket ("worse than 10ms" is a distinct finding).

The outcome split sits next to the timings because "the wait is short" and "there is nothing to
wait for" are different results with different consequences, and one data point already shows the
second: on gamescope/NVIDIA the probe reports NoFence, i.e. that producer attaches no implicit
fence at all.

5 tests pin the arithmetic, including that an empty histogram reports "no answer" rather than a
decisive-looking zero — the failure mode that would retire the package on no evidence.

No behaviour change: the wait still happens where it always did. Gates green at CI parity.
Wave-2 PW4's outcome. The package proposed moving the producer-fence wait off the PipeWire loop
thread, and was pre-registered to be ABANDONED if the wait turned out to already be free. It is,
on every producer and vendor measured — including the one where implicit sync actually exists.

Steam Deck, RADV VANGOGH, gamescope producer (built in distrobox pf2, run on the host):

  samples=300  mean_us=23  max_us=48  p50=<=100us  p99=<=100us
  signaled=0   no_fence=300  timed_out=0  failed=0

p99 in the first bucket is the plan's own abandonment condition, and the outcome split explains
why: 300 of 300 buffers reported NoFence. Same on both NVIDIA producers (gamescope and Mutter's
virtual output — the exact no-explicit-sync case the comment cites as the reason the wait exists).

So `wait_read_ready` here is one ioctl and a return, not a block. Moving it to the consumer side
would buy nothing measurable and would take on the hazard the package itself names — a slot holding
a not-yet-ready dmabuf, and `repeat_last` re-waiting a fence it already consumed. Not a trade worth
making for 23 microseconds.

The 100 ms budget stays: it guards a producer that DOES fence, which is a real thing even if
nothing in this fleet does it. KWin/AMD is the one combination still unmeasured, and the histogram
from the previous commit is deliberately kept as the way to re-check — run with PUNKTFUNK_PERF=1
and read the p99 bucket.

Comment-only; no behaviour change. Gates green at CI parity.
Wave-2 PW1's exit criterion, and the instrument it needed.

VAAPI and direct NVENC both log a PUNKTFUNK_PERF submit split. PyroWave did not — which meant the
single encoder the GPU-priority work exists to defend was the one you could not put a number on.
Adds per-frame timing of the synchronous encode (whole `submit`: CSC + encode + fence wait +
packetize, which for this backend IS the encode), summarised every 2 s as mean/p50/p99/max.

p99 rather than mean-only on purpose. The failure patch 0005 describes is a TAIL event — frames
going ~2 ms to 15-18 ms at 95 % game load while the mean barely moves — so a mean-only readout
would report "fine" straight through the thing being measured.

WHAT IT MEASURED — .21, RTX 5070 Ti (610.57.04), GRID 2 benchmark loop saturating the GPU at
54-87 %, PyroWave 1080p, same binary both arms (only CAP_SYS_NICE differs), 30-frame windows with
the warm-up window dropped:

  arm                        p50        p99        worst frame
  default priority (refused) ~2.6 ms    ~6.4 ms    9.5 ms
  REALTIME granted           ~3.2 ms    ~4.4 ms    5.4 ms
  REALTIME granted (repeat)  ~3.35 ms   ~4.8 ms    5.1 ms

p99 down ~30 %, worst frame roughly halved, for ~0.6 ms on the median. For a streaming encoder
that is the right side of the trade — the tail is what becomes a visible hitch.

This CONTRADICTS the patch's only prior datum (RTX 4090 / Windows / WDDM: "did not reduce the
spikes"), so patch 0005's header now records the Linux/NVIDIA result beside it, with an explicit
"do NOT delete this patch on the strength of the WDDM result — the two stacks disagree". Header
prose only; the diff hunks stay byte-identical and `git diff crates/pyrowave-sys/vendor/` is
untouched by this commit.

Caveats recorded rather than buried: the arms were not interleaved and the game load drifted
between them, capture was frame-starved (~2.5 fps) so this is encode latency under contention and
not a full-rate stream, and it is two granted runs against one refused run. The direction held
across all 25 windows.

Also worth knowing for anyone repeating this: `encode_fps` is a VACUOUS metric on this rig. A
headless gamescope with no real content emits ~12 fps, so both arms simply report the capture rate.
Measure latency, not throughput.

Gates green at CI parity.
PW6 was gated on one question: what happens to the client's newest-wins
draining when a PyroWave AU arrives in pieces, given that
`Session::set_deliver_frame_parts` refuses to combine with an all-intra
stream. The answer is that the doc and the plan conflated two different
axes, and the question never applied to this package.

Host STREAMED_AU chunks change only the WIRE shape. The reassembler
completes such a frame exactly like a whole one (`block_count != 0 &&
blocks_ok == block_count`) and hands up ONE Frame, so the frame channel
still sees one entry per AU and the drain is untouched.

What newest-wins genuinely cannot survive is the client's SEPARATE prefix
delivery, and the mechanism is sharper than "assumes whole AUs" said:
`FrameChannel::pop` counts QUEUE ENTRIES and takes one entry to be one AU.
With parts on, one AU pushes several, so `len > 1` stops meaning "the
consumer is behind" — the drain fires mid-AU, returns a SUFFIX and clears
that same AU's prefixes. For PyroWave that is fatal rather than lossy: the
sequence header lives in window 0 of every AU (`au_dims` reads it there), so
every frame would arrive headerless, and `FramePart`'s own orphan contract
would have a correct consumer abandon essentially all of them. Written into
`pop`, `set_deliver_frame_parts` and the handshake, together with what a fix
would take (skip whole SUPERSEDED AUs, never split one).

That answer shrinks what this package may claim, so the code says so
plainly. `encode_frame` is synchronous: the whole AU exists before the first
chunk can be polled, so `poll_chunk` is not "emit as produced" and there is
no encode/send overlap here (PW6 ⟂ PW5, confirmed). And with the client
still receiving one whole Frame there is no decode-while-arriving either —
the "~7 ms, decouple e2e latency from AU size" framing needs client work
this commit does not do. What IS left is real and host-side: the whole-AU
path FEC-protects, packetizes and seals the entire ~830 KB AU before its
first datagram may leave the socket, while the streamed path seals and paces
each FEC block as it completes.

All of the cutting lives in the shared `pyrowave_wire` helper, which
compiles and unit-tests on every platform, so both backends' `poll_chunk` /
`supports_chunked_poll` are thin delegations — the Windows backend cannot be
compiled from a Linux box, and logic written into it directly would ship
unverified. Chunks are whole numbers of framing windows because `build_au`
gives each window exactly ONE kind; that also makes them shard-aligned for
free, which is what the sealer's sentinel bases require. Dense mode never
streams (no window framing to cut on). `poll()` now errors while a chunk
cursor is live — the trait's one-drain-method-per-AU contract, where
double-emitting would put the same bytes on the wire twice under one frame
index — and `reset()` drops the cursor so a rebuild cannot splice a dead
AU's tail onto a fresh one. No new Encoder trait method, so neither the
TrackedEncoder forwarding trap nor the EncoderCaps default trap is in play.

Shipped OFF: `PUNKTFUNK_PYROWAVE_STREAMED_AU=1` arms it,
`PUNKTFUNK_PYROWAVE_CHUNK_KIB` tunes the 256 KiB target. The pre-registered
partial-delivery trap is real and now has a named cost — an unpinned
streamed frame (final block lost) is excluded from partial delivery, where
the whole-AU path still hands the consumer a usable blur, and PyroWave
clients opt into partials unconditionally. The netem loss-harness leg is the
prerequisite for default-on and has not been run.
PW6 shipped behind a knob because one pre-registered risk was unmeasured: a
streamed frame whose FINAL block is lost has no totals, so where the whole-AU
path hands the consumer a usable blurred partial, a streamed frame may deliver
nothing. PyroWave clients opt into partial delivery unconditionally, so this
would have been a live behaviour change for every one of them. Measured now,
three ways, instead of reasoned about.

`tools/loss-harness` gains a partial-delivery leg: FEC pinned OFF, chunk-aligned
AUs, deliver_partial ON, realistic 1408/200 geometry, and AU sizes swept across
the whole 1..=200-shard range of FINAL-block sizes — because the final block's
size is what bounds the exposure. Loss is injected per packet from a seeded
xorshift rather than through `loopback_drop_period`, whose deterministic 1-in-N
would systematically always-or-never hit the final block, which is the entire
question. `tc netem` on `lo` was deliberately not used: the in-process model
gives exact per-frame attribution, needs no sudo, cannot disturb a box running a
live desktop session, and — decisively — can drop precisely the final block.

Leg 1, deterministic (drop exactly the last block, 200 frames): whole-AU
delivers 200 partials and 0 losses; streamed delivers 0 partials and 200 total
losses. The trap is real and, when it fires, total.

Leg 2, random loss, 20 000 frames per cell, same seed and sizes for both shapes.
At 2 % the two are indistinguishable — 20000/20000 partials and ZERO vanished
frames on both, matching the analytic bound E[loss^k] over final-block sizes k
(~1e-4). The gap only appears at 30 % (99.94 % vs 100 % rescue) and 50 %
(99.79 %). `complete` is 0 throughout by construction: with FEC off and ~500
packets per AU, essentially every frame is damaged — which is the regime the
partial path exists for.

The spike gains `--wire-chunk` and a streamed loopback path, so the wire shape
is reachable end to end outside a real client: `poll_chunk` drains the AU,
`begin_streamed_frame_at`/`seal_streamed_chunk`/`seal_streamed_finish` seal each
piece, and the client byte-compares the reassembly. On 120 real PyroWave AUs the
streamed legs (56.5 and 2.0 chunks/AU) and the whole-AU control emit a
byte-identical 47 373 568-byte stream with 0 mismatches — the cut changes the
wire shape and not one byte of content, and with the knob unset it does not
engage at all.

A new `#[ignore]`d GPU test closes the picture question on real hardware with a
BUSY card (gradients + checker + noise), never a flat fill: chunks are whole
windows, exactly one `first` and one `last`, the AU decodes through the client's
own window walk, and luma PSNR lands at 40.2 dB. Unset the knob and the test
refuses to run, which is the default-off claim verified rather than asserted.

Verdict recorded in the plan: KEEP IT OFF. The 2 % tie is an argument about
typical loss, but the failure is not graceful when it fires and the measured win
is host send-side pipelining that nobody has yet put a millisecond number on.
Wave-2 PW7a: a PyroWave session on a proven-jumbo LAN should START at the big shard, because it
is the one codec that can never be re-keyed mid-stream (its client parses chunk-aligned AUs in
windows of the `Welcome` value, read once over the C ABI). At an 8908-byte shard that is ~6×
fewer datagrams per frame — ~49k → ~8k pps at 550 Mb/s — and proportionally less window-tail
padding.

THE BLOCKER FOUND FIRST: the whole jumbo leg was dead code, not just the missing half. quinn
caps a peer's MTU-discovery search at `min(MtuDiscoveryConfig::upper_bound, the OTHER side's
advertised max_udp_payload_size)` (`quinn_proto::connection::mtud::SearchState::new`), and
`EndpointConfig::max_udp_payload_size` defaults to 1472. Nothing in the repo had ever touched
`EndpointConfig`, so raising the host's PROBE ceiling — all `stream_transport_idle` did — could
never make discovery settle above 1472, and the shipped mid-session grow's
`settled >= sealed_datagram_bytes(target)` gate was unreachable on every path that has ever
existed. Two smaller contributors, fixed here too: the watcher stopped sampling the moment
`settled >= 1472`, discarding the very climb the proof needs, and a session sealed ABOVE the
1500-byte default was never checked against the path at all.

The advertisement is raised on the CLIENT endpoint, under the same `jumbo_wire_mtu()` opt-in as
the probe ceiling, because it is not free: quinn sizes its endpoint receive buffer
`max_udp_payload_size × max_receive_segments × BATCH_SIZE`, so on a GRO-capable Linux/Android
client that is ~2.9 MiB at the default and ~18 MiB at jumbo (47 KiB → 288 KiB on Apple/Windows).
Consequence: jumbo now needs the opt-in on BOTH ends. Without it, every byte on the wire and
every byte of buffer is exactly what it was.

WHY THE GROW IS AS SAFE AS THE CLAMP, which is not obvious — the failure modes are opposite. A
stale clamp only makes datagrams smaller than they had to be; a stale grow seals an oversized
datagram onto a 1500-byte path, where it is silently dropped, and a PyroWave session cannot
recover from that for its whole life. Mirroring the clamp's keying is therefore NOT sufficient.
So the memory is demoted: the persisted verdict only decides whether it is worth WAITING for a
proof, and what authorises the grow is a LIVE re-proof on the very connection being welcomed —
`conn.stats().path.current_mtu` ≥ the sealed target, i.e. a datagram of exactly that size acked
by this client, on this connection, seconds ago. The moved laptop cannot inherit anything: its
new path's live MTU is 1472 and the grow does not happen, whatever the memory says.

The remembered half is keyed strictly anyway — `(local_ip, peer_ip)`, so a verdict earned over
the host's 10 GbE NIC does not apply to the same peer over Wi-Fi or a VPN — and carries the
operator target it was proven under plus a 6 h TTL. It is erased by any contrary evidence: a
lower settle, a session that ended before the window closed (what a client staring at black
does), a changed opt-in, or a constrained-path clamp that disagrees.

The proof-wait is on the bring-up critical path (`handshake.rs` sends the `Welcome` and only
then kicks the display prep), so it is bounded at 300 ms, exits the instant the proof lands, and
is entered ONLY for a path a previous session already proved. Its worst case is the moved
laptop, and that is self-limiting: that session's watcher erases the verdict.

MEASURED, NOT ARGUED: `mtu_discovery_climbs_only_as_high_as_the_peer_advertises` (`#[ignore]`d,
loopback — whose own MTU is 64 KiB, so configuration is the only thing that can stop the search),
on .21:

  leg A (server opted in, client NOT): settled at 1472 B UDP payload   <- the dead-code proof
  leg B (both opted in):               reached 8972 B in 5 ms          <- the fix, and its speed

Leg A is the finding restated as an experiment. Leg B says the climb costs ~5 ms once both sides
advertise it, so the 300 ms proof-wait is ~60x the loopback convergence time — enough headroom
for a real LAN's RTT and per-probe ack delay across the ~11 probes the search takes.

Still owed: the A/B on a real jumbo LAN segment (9000-MTU NIC + switch on both ends) — pps per
frame, wire/pin ratio, and a PyroWave session observed starting at 8908. Not runnable without
the hardware.
Closes PW4's one remaining gap. The Steam Deck switched to Desktop Mode gives KWin on RADV, the
combination none of the earlier legs covered, and it reports no implicit fence like every other:

  gamescope + NVIDIA (RTX 5070 Ti)   NoFence
  Mutter    + NVIDIA (RTX 5070 Ti)   NoFence
  gamescope + RADV   (Deck VANGOGH)  300/300 NoFence, mean 23us, p99 <=100us
  KWin      + RADV   (Deck desktop)  no fence  (older build's wording: waited=false)

That is every compositor x vendor this fleet has. PW4 retires with no outstanding doubt rather
than "probably fine except one box we never tried".

Measured with the Deck's OWN already-authorized binary rather than a scratch build, because KWin
grants zkde_screencast_unstable_v1 per EXECUTABLE PATH: it resolves /proc/<pid>/exe against a
.desktop's Exec= and caches the grant on first connect, so an unregistered path is refused outright
and registering one needs a re-login. The fence probe is pre-existing capture-path code, so a build
from July answers the outcome question perfectly well — and nothing of the user's was modified to
get it.

Comment-only; no behaviour change. fmt + pf-capture clippy -D warnings green.
Wave-2 PW5 stage 1, and the one stage with no risk at all.

The zero-copy capture path dups the dmabuf fd, publishes the frame, and hands the SPA buffer
straight back to the producer at `.process` return — while the encode thread has not yet imported
it, let alone read it. The code says so itself ("content stability across the brief import/encode
window relies on the compositor's buffer-pool depth, like any zero-copy capture"). That depth is
therefore load-bearing: it is the ONLY thing standing between us and the producer overwriting a
buffer mid-read.

And it had never been measured. Not logged, not asserted, not even requested — `build_dmabuf_buffers`
set `SPA_PARAM_BUFFERS_dataType` and nothing else, so whatever the producer picked is what we got,
silently.

This adds the `add_buffer`/`remove_buffer` stream callbacks PipeWire has always offered and logs the
count once per distinct depth: `pool_depth`, `high_water`, and the latest-frame-only `drained`
count beside it. One line per session on a stable pool (`.process` runs at the capture rate — an
unconditional log would be 240 lines a second of the same number), a second line if a
renegotiation changes the depth.

`high_water` is tracked separately from `live` because a renegotiation frees the pool before
re-allocating it: any decision keyed on the live count would read that dip as "the pool shrank".
`remove` saturates at zero rather than wrapping, so an unmatched remove cannot report `u32::MAX`
buffers.

Measurement only — no behaviour change, and no consumer of the number yet. PW5's later stages need
it (a deeper encode pipeline widens the overwrite window by a full frame period), but the number is
worth having regardless of whether those stages ever land: it is the answer to "is our zero-copy
capture actually safe on this compositor", and until now the honest answer was "nobody knows".

3 tests pin the once-per-depth logging, the renegotiation dip, and the saturating remove.

Gates green at CI parity.
Wave-2 PW5 stage 2, on the number stage 1 just made visible.

`build_dmabuf_buffers` set `SPA_PARAM_BUFFERS_dataType` and stopped there — no
`SPA_PARAM_BUFFERS_buffers` at all, so the pool depth the whole zero-copy safety argument rests on
was entirely the producer's choice, and we never even expressed a preference. This asks for 8
(min 2, max 16).

A **Choice Range**, deliberately, not a fixed count. SPA intersects the consumer's and producer's
Buffers params, so a fixed 8 against a producer that can only afford 4 empties the intersection and
the link stalls in "negotiating" with no error anywhere — the exact trap that cost this codebase
the entire Linux cursor channel once, when a 256^2 cursor-meta max failed to intersect Mutter's
fixed 384^2 offer. With a range the producer clamps into it and negotiation still succeeds; the
min stays at 2 so nothing that works today stops working.

The numbers, and what they are not: 8 buffers is ~133 ms of pool at 60 Hz and ~33 ms at 240 Hz,
well past the ~3-4 ms capture-to-fence latency PW3/PW4 measured, with room for a second frame in
flight. 16 is a ceiling rather than a request — a 4K 4:4:4 buffer is ~25 MB, so 16 of them is
~400 MB of compositor allocation. These are the values we ASK for; what a producer actually
allocates is what stage 1's census line reports, and that line is the one to trust.

Scoped to the dmabuf pod only. The mappable and SHM-only builders are untouched: their consumers
copy out of the buffer inside `.process`, so pool depth is not part of their correctness argument.

A test pins the pod SHAPE — Choice, Range, Int children, values default-first — so a later
simplification cannot quietly turn the range back into a number and take the negotiation down with
it.

Gates green at CI parity; on-glass negotiation on each producer is stage 2's own gate and is
reported with the stage-1 census numbers.
Wave-2 PW5 stage 3. Depth is STILL 1; this is the shape change alone.

`encode_frame` recorded CSC+encode, queue-submitted, waited the fence and packetized, all inside
`Encoder::submit`. Every other backend in this crate puts the wait on the POLL side. That
difference is the whole reason the host loop's cadence folds around this encoder: with the wait
inline, `submit` returns only after the GPU is done, so the arrival-anchored floor absorbs the
encode only while it stays under 0.9x the frame interval.

Split into `submit_frame` (ingest -> CSC -> pyrowave encode -> queue-submit -> return) and
`wait_and_packetize` (fence wait -> packetize -> AU), with an `InFlight` deque between them capped
by `max_inflight`, which is 1. **One is the only value the resources can support today** — `cmd`,
`fence`, `csc_set` and the y/uv images are one each, so a second concurrent frame would record into
a PENDING command buffer and storage-write images pyrowave is still sampling. `submit` therefore
drains to `max_inflight - 1` before recording, which states that invariant in one place instead of
leaving it implicit in "the encode is synchronous".

The subtle part is the command-buffer state machine, and it is unchanged: the record-and-submit
closure still resets `cmd` on every PRE-submit failure (RECORDING/INVALID/EXECUTABLE, never
PENDING), and the fence wait still does NOT reset on failure, because a timeout leaves the buffer
PENDING where a reset violates VUID-vkResetCommandBuffer-commandBuffer-00045. What changed is that
a failed wait now also leaves the entry IN FLIGHT — which is precisely what tells `reset()` there
is live GPU work to re-wait before the pyrowave encoder object may be destroyed. `gpu_pending` is
gone; `!inflight.is_empty()` is the same fact, and cannot drift from it.

The split opened two windows that did not exist when everything ran inline, both closed here:
`reconfigure_bitrate` and `set_wire_chunking` can now land BETWEEN a submit and its poll, so the
packetize boundary and the bitstream cap are snapshotted into `InFlight` at submit time. Reading
the live fields would have let a mid-flight bitrate drop turn a perfectly good frame into
"unexpected packet count", and a mid-flight chunking change into an AU with the wrong
`chunk_aligned` flag.

`flush()` is no longer a no-op — it drains the in-flight frame, so the trait's poll-until-None
contract still returns every AU (the `spike` subcommand and the hardware smoke tests are the real
users).

The perf instrument still measures submit->AU, stamped at submit and taken when the AU becomes
readable, so `92326312`'s numbers stay directly comparable; the log line now carries `depth` and
says plainly that above depth 1 the number legitimately grows by about one loop period.

VERIFIED ON GLASS (.21, RTX 5070 Ti, GPU idle at 180 MHz of 3090 — so these are slow-clock runs,
which is the worst case on this card, not the best): all 6 `#[ignore]`d GPU tests pass — the
4:2:0, 4:4:4 and 24-bpp PSNR smokes, the mode-mismatch refusal, the fd-leak check and the golden
dump.

Byte-identity, honestly: the AU bytes are NOT reproducible, and were not before this commit
either. Three runs of the SAME unmodified binary produced three different `au-dense.bin` hashes
(ab7ecaf6 / 8735700e / 933b3d40) — the vendored 4:2:0 encoder emits run-varying bytes that the
decoder ignores. So the meaningful gate is DECODE identity, and that holds exactly: every decoded
plane (`ref-dense-{y,cb,cr}`, `ref-chunked-{y,cb,cr}`, `ref-dense444-{y,cb,cr}`) is bit-identical
between the pre-split base and this commit, across four runs. 4:4:4 AUs are additionally
bit-stable and match the checked-in Apple fixture exactly.

Gates green at CI parity.
Wave-2 PW5 stage 4. Pure capacity — `max_inflight` is STILL 1, nothing overlaps yet.

The plan named the y/uv images as the thing to double. Reading the backend found five more, and
each is a correctness problem under overlap rather than a performance one:

  * `csc_set` — ONE descriptor set, rewritten every frame by `bind_rgb`. Updating a set still bound
    by a PENDING command buffer violates VUID-vkUpdateDescriptorSets-None-03047, and on most
    drivers that is a wrong picture rather than an error.
  * `y_img`/`uv_img` — the CSC of N+1 storage-writes exactly the images pyrowave is still sampling
    for N. The barrier comment ("the previous frame's encode already completed under our
    synchronous fence") was load-bearing and said so.
  * `cursor_img` + `cursor_stage` — the struct comment stated the assumption outright: *"Single
    (not ring) because PyroWave encodes one frame synchronously — no in-flight overlap to race."*
  * `cmd` + `fence` — you cannot record into a PENDING command buffer at all.
  * `cpu_img`/`cpu_stage` (software capture / tests) — the host writes staging while the previous
    frame's copy is still pending.

All of it moves into a `Slot`, and the encoder now owns `SLOTS` of them. Two, because Granite caps
the overlap at two for us: the pyrowave device defaults to `init_frame_contexts(2)` and
`next_frame_context()` — called at the top of every `encode_gpu_synchronous` — waits the context it
rotates into. A third slot would need a vendored `init_frame_contexts(3)` that is not exposed.

`bitstream` and `import_cache` are deliberately NOT per-slot, and the `Slot` doc says why so a
later sweep does not "fix" it: `bitstream` is only touched during packetize, i.e. only on the poll
side one frame at a time, and `import_cache` retaining the VkImage/VkDeviceMemory per dmabuf inode
is precisely what makes it safe for two slots to sample the same imported buffer. `cpu_expand` is
shared for the same reason — it is copied into staging before `submit_frame` returns, so no GPU
work ever reads it.

Each frame carries its slot index in `InFlight` rather than recomputing it, so `wait_and_packetize`
cannot wait the wrong fence — the failure that would look like corruption rather than an error.
`reset()` now waits EVERY in-flight fence, not just one, which matters the moment depth rises.

WHAT IT COSTS, measured from the driver's own memory requirements rather than estimated (.21,
RTX 5070 Ti, and there is now an `#[ignore]`d test that prints it on any GPU):

  1080p 4:2:0   3872 KiB per slot    7744 KiB for both
  4K    4:2:0  12992 KiB per slot   25984 KiB for both
  4K    4:4:4  24992 KiB per slot   49984 KiB for both

So the extra slot costs ~3.8 MiB at 1080p and ~24 MiB at 4K 4:4:4 — an order of magnitude under
the plan's ~25-35 MB / 100-150 MB estimate, because that estimate included pyrowave's internal
wavelet and scratch buffers, which stage 5's second encoder handle will add and this stage does
not. Affordable on an iGPU. The open line now logs `slots`, `slot_kib` and `slots_kib` so this is
visible per session and not only in a test.

VERIFIED ON GLASS (.21, GPU idle at 195 MHz of 3090 — slow-clock, the worst case on this card):
all 6 `#[ignore]`d GPU tests pass, and all NINE decoded-plane hashes (`ref-dense-{y,cb,cr}`,
`ref-chunked-*`, `ref-dense444-*`) are bit-identical to the pre-PW5 base. Decode identity is the
meaningful gate here — the raw AU bytes are not reproducible run-to-run even from an unmodified
binary, which stage 3's message documents.

Gates green at CI parity.
Wave-2 PW5 stage 5. Depth is STILL 1 — the handles alternate per frame, one in flight.

PyroWave's `Encoder` cannot hold two frames. Not "probably not" — structurally not. `Encoder::Impl`
owns ONE each of `wavelet_img_high_res`, `bucket_buffer`, `meta_buffer`, `block_stat_buffer`,
`payload_data` and `quant_buffer`, and `Impl::encode` OPENS by discarding them: an image barrier
with `VK_IMAGE_LAYOUT_UNDEFINED` as the old layout — a written promise that nothing else is reading
it — plus three `fill_buffer` clears. Two encodes recorded into two command buffers and submitted
to one queue have no execution dependency in Vulkan (submission order orders the START, not the
completion), so N+1's DWT would overwrite the wavelet bands and zero the RDO buckets while N's
block packing still reads them. Content-dependent, silent.

So overlap means TWO handles on one device, alternated — one per slot. Every resource above is
then private per handle, and within a handle the encodes stay strictly serialized (a slot's next
frame is recorded only after that slot's previous one retired), which leaves patch 0004's
scratch-pool invariant intact without touching it.

THE LANDMINE, and it is the reason this stage is its own commit: `sequence_count` ALSO lives on
`Impl`, and it is the 3-bit counter stamped into every block header. Two handles each count
1,2,3... alone, so the wire sees 1,1,2,2,3,3.... The decoder restarts a frame only when the value
CHANGES (`diff = (hdr.sequence - last_seq) & 0x7; restart = diff != 0`), so a repeat reads as MORE
BLOCKS OF THE SAME FRAME: `clear()` never runs, `decoded_frame_for_current_sequence` stays true,
and the second frame of each pair is swallowed. Half frame rate, occasional mixed-frame blocks, no
error anywhere — on every client, since pf-client-core and the Apple Metal hand-port parse the same
field.

`patches/0007-encoder-sequence-override.patch` (new, ~38 lines) exposes
`Encoder::set_next_sequence` + a `pyrowave_encoder_set_next_sequence` C entry + a
`PYROWAVE_SEQUENCE_MASK` define, so ONE monotonic counter on the Rust side is stamped regardless of
which handle encodes. The setter stores `(seq - 1) & mask` because `Impl::encode` pre-increments —
its contract is about the next ENCODE, not the next store. Inert when unused, so the whole Windows
backend is untouched. No `.def` change: the C API is a static archive.

PREDICTED, THEN OBSERVED. A negative control on .21 (the override call removed, nothing else) reads
the wire out at exactly:

  [1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 0, 0, 1, 1, 2, 2]

which is the analysis's prediction character for character, and with the override:

  +1 mod 8, all 20 frames, through the 3-bit wrap.

THE GATE, `wire_sequence_increments_across_alternating_handles`, checks three things over 20 frames
because any one alone could pass while the stream is broken: the wire counter advances by 1 mod 8;
ONE persistent decoder (its `last_seq` carried across every push, exactly like a client's) reports
every AU decodable; and consecutive decoded pictures DIFFER. Content moves every frame — and the
first run caught a trap in the harness itself rather than the encoder: `test_card` starts its LCG
at `seed | 1`, so seeds 2 and 3 build a byte-identical card and the test faked the very repeat it
hunts. Odd seeds only now, with the reason written down.

A runtime self-check backs the test up where the test cannot reach: after packetize, the stamped
sequence is compared against what we asked for, and a mismatch logs once per process naming patch
0007. A re-vendor that loses the patch would not fail to build — it would fail on glass, subtly,
and this makes it loud instead. Two byte reads per frame.

`reset()` rebuilds both handles and `Drop` destroys both, each with the same null-immediately
discipline the single handle had (`pyrowave_encoder_destroy` is a bare `delete` with no null
check, so a stale pointer left in the field is a double free).

Vendored-patch discipline: patch 0007 re-applies clean to a pristine vendor checkout (verified by
stashing the vendor tree and re-applying), and `git diff crates/pyrowave-sys/vendor/` touches
exactly the four intended files.

VERIFIED ON GLASS (.21, RTX 5070 Ti, GPU idle at 180 MHz of 3090): all 8 `#[ignore]`d GPU tests
pass, including the new gate and the 4:2:0 / 4:4:4 / 24-bpp PSNR smokes.

Gates green at CI parity.
Wave-2 PW5, the stage-6 experiment. Shipped behaviour is UNCHANGED: `max_inflight` is still 1.

Stage 6 is the frame-corruption stage, and its gate is an on-glass tear-hunt with a live compositor,
a real client and ten minutes of moving content. That is not runnable from here. But the depth-2
risk has two halves, and one of them lives entirely in this crate — the per-slot resources
(`cmd`/`fence`/`csc_set`/y/uv/cursor) and the alternating encoder handles — so that half can be
answered now, on the GPU, and the answer is worth having before anyone attempts the other.

The experiment drives the backend with two frames genuinely in flight (submit N+1, then poll N) and
compares the result against the encoder's OWN synchronous output over the same 16 moving frames.
Its own depth-1 decode is the honest reference: pyrowave's raw AU bytes are not reproducible
run-to-run (see the stage-3 commit), but its decoded planes are.

RESULT, .21 / RTX 5070 Ti (GPU idle at 180 MHz of 3090 — the slow-clock worst case on this card):

  depth-2 vs depth-1 over 16 frames: worst-case PSNR identical (inf)

Bit-identical luma, every frame, in order. So stages 4 and 5 between them are sufficient for the
encoder side: doubling the six single-slot resources and alternating two `pyrowave_encoder` handles
under one monotonic wire sequence really does make overlap invisible to the decoder.

The test is built to fail rather than to pass. Content MOVES every frame (flat fills are the
documented false-green trap — a torn frame stitched from two halves of a static card is invisible),
it asserts two frames were ACTUALLY in flight rather than silently proving nothing, it asserts the
AU count is unchanged, and it carries an off-by-one discriminator that raw PSNR would miss: each
overlapped frame must match its own reference BETTER than it matches the previous one, so a
pipeline delivering frames one position late fails even though every individual PSNR looks fine.

It reaches `max_inflight` directly instead of through a shipped knob, precisely so the shipped
value stays 1.

⚠ WHAT THIS DOES NOT COVER, stated here so the next person does not read it as a green light for
stage 6: the CAPTURE side. `.process` hands the SPA buffer back to the compositor at callback
return while the encode thread holds only a dup of its dmabuf fd, so a second frame in flight
widens the window in which the producer may overwrite a buffer we are still reading by a full frame
period. Nothing in this crate can test that — it needs a live producer. Stages 1 and 2 are what
make it answerable (the pool census says how deep the producer's ring is; the Choice range asks for
headroom), and the on-glass hunt is what would settle it.

Gates green at CI parity.
# Conflicts:
#	packaging/arch/punktfunk-host.install
#	scripts/steamdeck/install.sh
Merge branch 'worktree-wave2-pw5-encode-overlap' into worktree-wave2-pyrowave
apple / swift (pull_request) Successful in 1m34s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m9s
ci / bun-nix (pull_request) Successful in 1m19s
ci / docs-site (pull_request) Successful in 1m57s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m13s
ci / web (pull_request) Successful in 3m25s
android / android (pull_request) Successful in 5m4s
ci / rust-arm64 (pull_request) Successful in 6m29s
ci / rust (pull_request) Successful in 11m37s
nix / flake (pull_request) Successful in 18m17s
ebf61cb448
# Conflicts:
#	crates/pf-encode/src/enc/linux/pyrowave.rs
enricobuehler merged commit 4070d043d6 into main 2026-08-08 23:23:13 +00:00
enricobuehler deleted branch worktree-wave2-pyrowave 2026-08-08 23:23:16 +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#132