diff --git a/crates/pf-capture/src/linux/mod.rs b/crates/pf-capture/src/linux/mod.rs index 65e7c5b1..60e43bd8 100644 --- a/crates/pf-capture/src/linux/mod.rs +++ b/crates/pf-capture/src/linux/mod.rs @@ -168,6 +168,10 @@ pub struct PortalCapturer { /// downgrade ([`pf_zerocopy::note_raw_dmabuf_negotiation_failed`]) so the pipeline rebuild /// retries on the CPU offer instead of failing identically forever. vaapi_dmabuf: bool, + /// PW3: this capture's dmabuf offer has been confirmed to negotiate (a frame arrived), so the + /// negotiation retry budget has already been credited back. One-shot — the credit is per + /// capture, not per frame. + negotiation_confirmed: bool, /// This capture ran the HDR (10-bit PQ/BT.2020 dmabuf) offer — see [`Self::open`]'s /// `want_hdr`. Read by the negotiation-timeout diagnosis (a failed HDR offer latches the /// process-wide SDR downgrade) and by [`hdr_meta`](Capturer::hdr_meta). @@ -412,6 +416,7 @@ impl PwHandles { signals: self.signals, stall_since: None, vaapi_dmabuf: self.vaapi_dmabuf, + negotiation_confirmed: false, hdr_offer: self.hdr_offer, hdr_source, node_id, @@ -468,6 +473,13 @@ fn spawn_pipewire( } else { want_hdr }; + // PW3: tell the raw-dmabuf latch which capture this is BEFORE reading its verdict below. A + // different node id is a different question — a fresh virtual output, a compositor restart, + // the Bazzite Gaming↔Desktop switch — and inheriting "dmabuf does not work here" from an + // unrelated capture is how one transient timeout used to cost a host CPU capture until it was + // restarted. The portal bit is in the key because a portal-fd capture and a virtual-output + // capture with the same node number are genuinely different sources. + pf_zerocopy::note_raw_dmabuf_capture(u64::from(node_id) | (u64::from(fd.is_some()) << 32)); // THE negotiation decision, resolved once here and handed to the thread — no mirror (L3/F1). // Every environment/latch read the decision depends on happens at this single point. let plan = pipewire::negotiation_plan(pipewire::NegotiationInputs { @@ -705,6 +717,7 @@ impl PortalCapturer { // The slot before the wakeup: a publish that coalesced its edge (or landed while we were // not waiting) is still visible here. if let Some(f) = self.take_frame() { + self.note_negotiation_confirmed(); return Ok(f); } let slice = Duration::from_millis(500) @@ -728,6 +741,16 @@ impl PortalCapturer { self.slot.lock().ok().and_then(|mut s| s.take()) } + /// PW3: a frame arrived, so this capture's dmabuf-only offer DID negotiate — credit the + /// negotiation retry budget back. Only meaningful for a capture that actually made that offer, + /// and only once per capture (the budget counts consecutive failed BUILDS, not frames). + fn note_negotiation_confirmed(&mut self) { + if self.vaapi_dmabuf && !self.negotiation_confirmed { + self.negotiation_confirmed = true; + pf_zerocopy::note_raw_dmabuf_negotiation_ok(); + } + } + /// The [`frame_within`](Self::frame_within) budget expired (or the thread ended) — turn it /// into the diagnosis-bearing error. Split out of the slicing loop above; behavior unchanged. fn next_frame_timed_out( diff --git a/crates/pf-capture/src/linux/pipewire.rs b/crates/pf-capture/src/linux/pipewire.rs index 37b17757..2c7b8899 100644 --- a/crates/pf-capture/src/linux/pipewire.rs +++ b/crates/pf-capture/src/linux/pipewire.rs @@ -70,6 +70,13 @@ struct UserData { linear_nv12_failed: bool, /// Rate-limit counter for the latest-frame-only diagnostic log (see `.process`). dbg_log_n: u64, + /// PW4 step 1: the producer-fence wait distribution, measured on this (the PipeWire loop) + /// thread. Per-session, like the fall-through tally. + fence_wait: FenceWaitStats, + /// Raw-passthrough frames that silently fell through to the CPU de-pad path, by reason — see + /// [`PassthroughFallbacks`]. Per-session: a fresh `UserData` is built per pipeline, so a + /// compositor that starts serving dmabufs again after a rebuild gets a fresh log budget. + passthrough_fallbacks: PassthroughFallbacks, /// Cursor-as-metadata state, composited into the CPU de-pad path (see `consume_frame`). cursor: CursorState, /// `Some((w, h))` while the producer's negotiated size is a sacrificial birth mode and a @@ -239,6 +246,290 @@ impl NegotiationPlan { } } +/// Which capture arm a negotiated pipeline actually resolved to. +/// +/// The 2026-08-08 PyroWave triage had to reconstruct this from four files, because no single line +/// ever states it: the arm is the product of a policy, a latch, an importer that may or may not +/// have constructed, and a modifier list. A degraded host and a healthy one logged the same +/// thing. [`resolved_capture_arm`] plus the one INFO line at pipeline build is the whole fix. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum CaptureArm { + /// Raw dmabufs handed straight to the encoder, which imports them itself — libva (VAAPI) or + /// the PyroWave encoder's own Vulkan device. No host pixel touch. + DmabufPassthrough, + /// dmabufs imported to CUDA device buffers by the EGL→CUDA worker, for NVENC. No host pixel + /// touch either, but a different failure surface (the worker, the modifier negotiation). + CudaImport, + /// CPU frames: an mmap de-pad of every frame, then whatever CSC + upload the encoder needs. + /// The slow path — always a downgrade when the consumer could have taken a dmabuf. + Cpu, +} + +impl CaptureArm { + pub(super) fn as_str(self) -> &'static str { + match self { + CaptureArm::DmabufPassthrough => "dmabuf-passthrough", + CaptureArm::CudaImport => "cuda-import", + CaptureArm::Cpu => "cpu", + } + } +} + +/// Resolve the arm this pipeline ended up on. **Pure** — `have_importer` and `want_dmabuf` are the +/// two runtime facts `negotiation_plan` cannot know (whether the importer constructed, and what +/// modifier list that yielded); everything else is already in the plan. +pub(super) fn resolved_capture_arm( + plan: &NegotiationPlan, + have_importer: bool, + want_dmabuf: bool, +) -> CaptureArm { + if !want_dmabuf { + // No dmabuf offer at all: SHM/CPU frames, whatever the plan wanted. + CaptureArm::Cpu + } else if plan.vaapi_passthrough { + CaptureArm::DmabufPassthrough + } else if have_importer { + CaptureArm::CudaImport + } else { + // Unreachable via `want_dmabuf` (it requires `have_importer || vaapi_passthrough`), but + // stated rather than `unreachable!()`: a logging helper must never be the thing that + // panics a capture thread. + CaptureArm::Cpu + } +} + +/// Who consumes the captured frames — the fact that decides whether a CPU arm is a *downgrade* +/// worth warning about, and what to call it in the log. +/// +/// Derived from the resolved [`ZeroCopyPolicy`](crate::ZeroCopyPolicy), **not** from the encoder +/// pref: `pyrowave_session` is per-session (the negotiated codec), so a PyroWave session on an +/// otherwise-NVENC host reads as PyroWave here. Naming the pref instead is exactly how a PyroWave +/// session's CPU downgrade came to be reported as an NVENC one — or, on an NVIDIA host, not +/// reported at all. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ConsumerKind { + /// This session encodes PyroWave: the wavelet encoder's own Vulkan device imports dmabufs on + /// any vendor, so a CPU arm costs it the passthrough it was designed around. + PyroWave, + /// The VAAPI backend (AMD/Intel): libva imports the dmabuf and CSCs on the GPU. + Vaapi, + /// NVENC, fed by the EGL→CUDA importer. + Nvenc, + /// The software encoder — CPU frames are its native input, so a CPU arm is no downgrade. + Software, +} + +impl ConsumerKind { + pub(super) fn as_str(self) -> &'static str { + match self { + ConsumerKind::PyroWave => "pyrowave", + ConsumerKind::Vaapi => "vaapi", + ConsumerKind::Nvenc => "nvenc", + ConsumerKind::Software => "software", + } + } + + /// Whether landing on [`CaptureArm::Cpu`] is a performance downgrade for this consumer (i.e. + /// worth a `warn!`). True for every GPU consumer; false for the software encoder, which wants + /// CPU frames anyway. + pub(super) fn cpu_is_downgrade(self) -> bool { + !matches!(self, ConsumerKind::Software) + } +} + +/// Classify the frames' consumer. **Pure.** `pyrowave_session` wins over `backend_is_vaapi` +/// because it is the per-session truth and the pref is host-global (a PyroWave session also flips +/// `backend_is_vaapi` on, via `linux_zero_copy_is_vaapi`'s `Pyrowave` arm — so testing vaapi first +/// would swallow every PyroWave session). +pub(super) fn consumer_kind( + pyrowave_session: bool, + backend_is_vaapi: bool, + backend_is_gpu: bool, +) -> ConsumerKind { + if pyrowave_session { + ConsumerKind::PyroWave + } else if !backend_is_gpu { + ConsumerKind::Software + } else if backend_is_vaapi { + ConsumerKind::Vaapi + } else { + ConsumerKind::Nvenc + } +} + +/// Why a frame on the raw-dmabuf passthrough could not be handed to the encoder and fell through +/// to the CPU de-pad path instead. +/// +/// Each variant is a *different* diagnosis with a different fix, and all four were silent: the +/// passthrough block simply fell out of its `if` and the frame took the slow path, so a session +/// that had negotiated zero-copy could pay CPU costs on every frame while logging a healthy +/// "advertising DMA-BUF modifiers" line at open. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum PassthroughFallback { + /// No format negotiated yet — transient, and expected around a renegotiation. + NoFormat, + /// The producer delivered an SHM/MemFd buffer for this frame, not a dmabuf. + NotDmabuf, + /// The negotiated pixel format has no DRM fourcc, so it cannot be described to the encoder. + NoFourcc, + /// `F_DUPFD_CLOEXEC` failed — the fd could not be duplicated to outlive the buffer recycle + /// (an fd-limit symptom, not a graphics one). + DupFailed, +} + +impl PassthroughFallback { + fn bit(self) -> u8 { + match self { + PassthroughFallback::NoFormat => 1 << 0, + PassthroughFallback::NotDmabuf => 1 << 1, + PassthroughFallback::NoFourcc => 1 << 2, + PassthroughFallback::DupFailed => 1 << 3, + } + } + + pub(super) fn as_str(self) -> &'static str { + match self { + PassthroughFallback::NoFormat => "no format negotiated yet", + PassthroughFallback::NotDmabuf => "the producer delivered an SHM/MemFd buffer", + PassthroughFallback::NoFourcc => "the negotiated format has no DRM fourcc", + PassthroughFallback::DupFailed => "F_DUPFD_CLOEXEC failed on the dmabuf fd", + } + } + + /// What actually happens to the frame. Three of the four reasons downgrade it to the CPU + /// de-pad path; `NoFormat` does not — the CPU path needs `ud.format` too and returns without + /// de-padding, so that frame is DROPPED. Worth the distinction: "slower" and "gone" are + /// different faults, and a diagnostic that conflates them is the thing PW2 exists to remove. + pub(super) fn falls_back_to_cpu(self) -> bool { + !matches!(self, PassthroughFallback::NoFormat) + } + + /// What to do about it — the half a log line is useless without. + pub(super) fn hint(self) -> &'static str { + match self { + PassthroughFallback::NoFormat => { + "harmless if it stops: the first buffers can arrive before param_changed" + } + PassthroughFallback::NotDmabuf => { + "the compositor accepted the dmabuf offer and is serving memory anyway — check \ + PUNKTFUNK_FORCE_SHM and the compositor's allocator" + } + PassthroughFallback::NoFourcc => { + "a capture format the encoder path cannot describe — file it, the negotiation \ + should not have accepted it" + } + PassthroughFallback::DupFailed => "out of file descriptors — raise the host's NOFILE", + } + } +} + +/// Upper bounds (µs) of the fence-wait histogram's buckets; the last bucket is everything above. +/// +/// Deliberately coarse and log-ish. The question this instrument exists to answer is not "what is +/// the wait, to the microsecond" but "**is the tail ~0, or is it milliseconds?**" — the first +/// answer retires PW4 as a comment correction, the second justifies moving the wait off the +/// PipeWire loop thread. Bucket edges are placed so those two worlds cannot be confused: anything +/// at or below 100 µs is noise, anything past 1 ms is a real stall on a 60 Hz budget of 16.6 ms. +const FENCE_WAIT_BUCKETS_US: [u64; 6] = [100, 500, 1_000, 2_000, 5_000, 10_000]; + +/// PW4 step 1: the distribution of the producer's implicit-fence wait, measured **on the PipeWire +/// loop thread**, which is exactly where it is expensive — that thread is the compositor's +/// consumer, so time spent blocked here delays buffer recycling for the NEXT frame. +/// +/// PW4 is pre-registered to be **abandoned on evidence**: if the p99 sits in the first bucket the +/// wait is already ~free and the package becomes a comment correction. Shipping the instrument +/// before the change is the whole point — the alternative is moving load-bearing synchronisation +/// off a thread on a hunch. +/// +/// One caveat the reader needs: measure this AFTER the priority levers land. The win case for +/// moving the wait is a loaded GPU, which is the same scenario PW1 targets, so a measurement taken +/// before PW1 would hand PW4 credit for PW1's problem. +#[derive(Debug, Default, Clone, Copy)] +pub(super) struct FenceWaitStats { + samples: u64, + total_us: u64, + max_us: u64, + /// One more than the bucket bounds: the overflow bucket. + buckets: [u64; FENCE_WAIT_BUCKETS_US.len() + 1], + /// Outcome split — a `NoFence` majority means the wait is structurally free on this producer + /// (nothing to wait for), which is a different finding from "the wait is short". + signaled: u64, + no_fence: u64, + timed_out: u64, + failed: u64, +} + +impl FenceWaitStats { + /// Record one wait. `bucket_of` is inlined here rather than exposed: the histogram is only + /// ever read through [`summary`](Self::summary). + pub(super) fn record(&mut self, us: u64) { + self.samples += 1; + self.total_us += us; + self.max_us = self.max_us.max(us); + let idx = FENCE_WAIT_BUCKETS_US + .iter() + .position(|&b| us <= b) + .unwrap_or(FENCE_WAIT_BUCKETS_US.len()); + self.buckets[idx] += 1; + } + + /// The bucket the `q`-quantile falls in, as its upper bound in µs — `None` for the overflow + /// bucket (i.e. "worse than the last edge"). Counting up to the quantile rather than + /// interpolating keeps this honest about what a histogram can actually say. + pub(super) fn quantile_bucket_us(&self, q: f64) -> Option> { + if self.samples == 0 { + return None; + } + // The index of the sample at `q`, 0-based, so q=1.0 picks the last sample. + let target = ((self.samples as f64) * q).ceil().max(1.0) as u64; + let mut seen = 0u64; + for (i, &count) in self.buckets.iter().enumerate() { + seen += count; + if seen >= target { + return Some(FENCE_WAIT_BUCKETS_US.get(i).copied()); + } + } + Some(None) + } + + pub(super) fn mean_us(&self) -> u64 { + self.total_us.checked_div(self.samples).unwrap_or(0) + } + + /// Whether enough has been seen for the p99 to mean anything. 100 frames is under two seconds + /// at 60 fps and is the point where one outlier stops dominating the answer. + pub(super) fn is_meaningful(&self) -> bool { + self.samples >= 100 + } +} + +/// Per-session tally of raw-passthrough frames that fell through to the CPU path, with a one-line +/// budget per distinct reason. +/// +/// Rate-limiting is what makes this shippable: `.process` runs per frame, so an unconditional log +/// would flood at the capture rate. Per *reason* rather than per session, because the four reasons +/// diagnose different faults and a transient `NoFormat` at open must not spend the budget a +/// persistent `NotDmabuf` needs. +#[derive(Debug, Default, Clone, Copy)] +pub(super) struct PassthroughFallbacks { + frames: u64, + logged: u8, +} + +impl PassthroughFallbacks { + /// Record one fall-through. Returns `Some(frames_so_far)` the FIRST time each distinct reason + /// is seen this session — the caller logs then and only then, so at most four lines per + /// session regardless of frame rate. + pub(super) fn note(&mut self, reason: PassthroughFallback) -> Option { + self.frames += 1; + let bit = reason.bit(); + (self.logged & bit == 0).then(|| { + self.logged |= bit; + self.frames + }) + } +} + /// Consecutive tiled-import failures (worker alive, e.g. a per-buffer `EGL_BAD_MATCH`) before /// the stream is poisoned for rebuild. A tiled import failure must NEVER fall through to the /// CPU mmap path — de-padding tiled bytes as linear produces a scrambled image — so after a @@ -345,9 +636,34 @@ fn consume_frame(ud: &mut UserData, spa_buf: *mut spa::sys::spa_buffer) { // the buffer's implicit fence and wait the producer's render before sampling — // closing the stale/old-frame race on NVIDIA. No-op for shm buffers or drivers that // attach no fence. Covers both the GPU import and the CPU mmap read below. + // + // MEASURED 2026-08-08 (Wave-2 PW4, which proposed moving this off the loop thread and was + // pre-registered to be abandoned if the wait was already free — it is): + // gamescope + NVIDIA (RTX 5070 Ti) outcome=NoFence + // Mutter + NVIDIA (RTX 5070 Ti) outcome=NoFence + // gamescope + RADV (Deck VANGOGH) 300 samples, ALL NoFence, mean 23us, max 48us, + // p50 and p99 both in the <=100us bucket + // KWin + RADV (Deck desktop) no implicit fence either + // That is EVERY compositor × vendor combination this fleet has, and not one of them attaches an + // implicit fence — so this is one ioctl and a return, not a block: there is nothing to wait on, + // and moving it to the consumer side buys nothing. The 100 ms budget stays as a guard for a + // producer that DOES fence, which is a real thing even if nothing here does it. The histogram + // below is how to re-check if that ever changes: run with PUNKTFUNK_PERF=1 and read the p99 + // bucket. if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf { - match pf_zerocopy::dmabuf_fence::wait_read_ready(datas[0].fd(), 100) { + // PW4 step 1: time the wait. Two `Instant::now()` per frame on a path that is already + // making a syscall — and this is the measurement that decides whether PW4 ships at all. + let t0 = std::time::Instant::now(); + let waited = pf_zerocopy::dmabuf_fence::wait_read_ready(datas[0].fd(), 100); + ud.fence_wait.record(t0.elapsed().as_micros() as u64); + match waited { Ok(outcome) => { + use pf_zerocopy::dmabuf_fence::WaitOutcome; + match outcome { + WaitOutcome::Signaled => ud.fence_wait.signaled += 1, + WaitOutcome::NoFence => ud.fence_wait.no_fence += 1, + WaitOutcome::TimedOut => ud.fence_wait.timed_out += 1, + } static F1: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true); if F1.swap(false, Ordering::Relaxed) { tracing::info!( @@ -360,6 +676,7 @@ fn consume_frame(ud: &mut UserData, spa_buf: *mut spa::sys::spa_buffer) { } } Err(e) => { + ud.fence_wait.failed += 1; static F2: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true); if F2.swap(false, Ordering::Relaxed) { tracing::warn!( @@ -370,106 +687,161 @@ fn consume_frame(ud: &mut UserData, spa_buf: *mut spa::sys::spa_buffer) { } } } + // One line per ~5 s at 60 fps, under PUNKTFUNK_PERF only — the same gate and cadence the + // encode backends use for their submit splits, so a perf run reads as one instrument. + if pf_host_config::config().perf + && ud.fence_wait.is_meaningful() + && ud.fence_wait.samples % 300 == 0 + { + let q = |p: f64| match ud.fence_wait.quantile_bucket_us(p) { + Some(Some(us)) => format!("<={us}us"), + Some(None) => format!( + ">{}us", + FENCE_WAIT_BUCKETS_US[FENCE_WAIT_BUCKETS_US.len() - 1] + ), + None => "n/a".to_string(), + }; + tracing::info!( + samples = ud.fence_wait.samples, + mean_us = ud.fence_wait.mean_us(), + max_us = ud.fence_wait.max_us, + p50 = %q(0.50), + p99 = %q(0.99), + signaled = ud.fence_wait.signaled, + no_fence = ud.fence_wait.no_fence, + timed_out = ud.fence_wait.timed_out, + failed = ud.fence_wait.failed, + "dmabuf implicit-fence wait on the PipeWire loop thread (PW4: a p99 in the first \ + bucket means this wait is already free and moving it off-thread buys nothing)" + ); + } } // Raw DMA-BUF passthrough: packed RGB is imported for GPU CSC; producer-native NV12 can // be consumed by the Vulkan Video encoder without another color conversion. + // + // The block below either publishes a dmabuf and RETURNS, or breaks with the reason it could + // not — so every non-success exit is named and counted instead of silently falling out of + // three nested `if`s into the CPU path, which is how a session that had negotiated zero-copy + // could pay a full CPU pixel touch per frame while logging nothing but a healthy open. if ud.vaapi_passthrough { - if let Some(fmt) = ud.format { - if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf { - if let Some(fourcc) = pf_frame::drm_fourcc(fmt) { - let chunk = datas[0].chunk(); - let offset = chunk.offset(); - let stride = chunk.stride().max(0) as u32; - // Native NV12 usually arrives as a two-plane SPA buffer over ONE buffer - // object; plane 1's chunk carries the REAL UV offset/stride (compositors - // may align the Y plane before UV). Pass it through instead of assuming - // contiguity. Each spa_data holds its own (dup'd) fd, so BO identity is - // by inode, not fd number; a genuinely two-BO frame cannot travel through - // the single-fd import — drop it with a diagnosis instead of streaming - // garbage chroma. - let plane1 = - if fmt == PixelFormat::Nv12 && datas.len() >= 2 && datas[1].fd() > 0 { - // SAFETY: zeroed `libc::stat` is a valid POD initializer; both fds are - // owned by the live PipeWire buffer for this callback, and `fstat` - // only writes the out-param structs, whose fields are read only after - // the `== 0` success checks. - let same_bo = unsafe { - let mut s0: libc::stat = std::mem::zeroed(); - let mut s1: libc::stat = std::mem::zeroed(); - libc::fstat(datas[0].fd() as i32, &mut s0) == 0 - && libc::fstat(datas[1].fd() as i32, &mut s1) == 0 - && (s0.st_dev, s0.st_ino) == (s1.st_dev, s1.st_ino) - }; - if !same_bo { - warn_once( - "NV12 planes live in different buffer objects — frames \ - dropped (single-fd import only)", - ); - return; - } - let c1 = datas[1].chunk(); - Some((c1.offset(), c1.stride().max(0) as u32)) - } else { - None - }; - // dup the fd so it survives the SPA buffer recycle — the encode thread - // imports it. Content stability across the brief import/encode window relies - // on the compositor's buffer-pool depth, like any zero-copy capture. - // SAFETY: `datas[0].fd()` is the dmabuf fd owned by the live PipeWire buffer (valid - // for this callback). `fcntl(fd, F_DUPFD_CLOEXEC, 0)` reads only the integer fd, - // touches no Rust memory, and returns a fresh independent CLOEXEC duplicate (or -1). - // The original stays owned by PipeWire; the dup is a new fd we own (checked >= 0). - let dup = - unsafe { libc::fcntl(datas[0].fd() as i32, libc::F_DUPFD_CLOEXEC, 0) }; - if dup >= 0 { - let pts_ns = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos() as u64) - .unwrap_or(0); - ud.publish(CapturedFrame { - width: w as u32, - height: h as u32, - pts_ns, - format: fmt, - payload: FramePayload::Dmabuf(DmabufFrame { - // SAFETY: `dup` is the fresh fd `fcntl(F_DUPFD_CLOEXEC)` just returned - // (checked `dup >= 0`); nothing else owns it, so `OwnedFd` takes sole - // ownership and closes it exactly once on drop — no alias, no - // double-close. - fd: unsafe { OwnedFd::from_raw_fd(dup) }, - fourcc, - modifier: ud.modifier, - offset, - stride, - plane1, - }), - // Cursor-as-metadata is blended only by RGB→NV12 backends. Gamescope - // embeds its pointer in the produced pixels, so native NV12 has none. - cursor: ud.cursor.overlay(), - }); - static ONCE: std::sync::atomic::AtomicBool = - std::sync::atomic::AtomicBool::new(true); - if ONCE.swap(false, Ordering::Relaxed) { - tracing::info!( - w, - h, - modifier = ud.modifier, - fourcc = format_args!("{:#010x}", fourcc), - source = if fmt == PixelFormat::Nv12 { - "producer-native NV12" - } else { - "packed RGB (encoder GPU CSC)" - }, - "zero-copy: handing the raw DMA-BUF to the encoder" - ); - } - return; - } - } + let reason = 'passthrough: { + let Some(fmt) = ud.format else { + break 'passthrough PassthroughFallback::NoFormat; + }; + if datas[0].type_() != pw::spa::buffer::DataType::DmaBuf { + break 'passthrough PassthroughFallback::NotDmabuf; } + let Some(fourcc) = pf_frame::drm_fourcc(fmt) else { + break 'passthrough PassthroughFallback::NoFourcc; + }; + let chunk = datas[0].chunk(); + let offset = chunk.offset(); + let stride = chunk.stride().max(0) as u32; + // Native NV12 usually arrives as a two-plane SPA buffer over ONE buffer + // object; plane 1's chunk carries the REAL UV offset/stride (compositors + // may align the Y plane before UV). Pass it through instead of assuming + // contiguity. Each spa_data holds its own (dup'd) fd, so BO identity is + // by inode, not fd number; a genuinely two-BO frame cannot travel through + // the single-fd import — drop it with a diagnosis instead of streaming + // garbage chroma. + let plane1 = if fmt == PixelFormat::Nv12 && datas.len() >= 2 && datas[1].fd() > 0 { + // SAFETY: zeroed `libc::stat` is a valid POD initializer; both fds are + // owned by the live PipeWire buffer for this callback, and `fstat` + // only writes the out-param structs, whose fields are read only after + // the `== 0` success checks. + let same_bo = unsafe { + let mut s0: libc::stat = std::mem::zeroed(); + let mut s1: libc::stat = std::mem::zeroed(); + libc::fstat(datas[0].fd() as i32, &mut s0) == 0 + && libc::fstat(datas[1].fd() as i32, &mut s1) == 0 + && (s0.st_dev, s0.st_ino) == (s1.st_dev, s1.st_ino) + }; + if !same_bo { + warn_once( + "NV12 planes live in different buffer objects — frames \ + dropped (single-fd import only)", + ); + // Not a fall-through: this frame is DROPPED, not downgraded (de-padding it as + // linear would stream scrambled chroma), so it is not counted below. + return; + } + let c1 = datas[1].chunk(); + Some((c1.offset(), c1.stride().max(0) as u32)) + } else { + None + }; + // dup the fd so it survives the SPA buffer recycle — the encode thread + // imports it. Content stability across the brief import/encode window relies + // on the compositor's buffer-pool depth, like any zero-copy capture. + // SAFETY: `datas[0].fd()` is the dmabuf fd owned by the live PipeWire buffer (valid + // for this callback). `fcntl(fd, F_DUPFD_CLOEXEC, 0)` reads only the integer fd, + // touches no Rust memory, and returns a fresh independent CLOEXEC duplicate (or -1). + // The original stays owned by PipeWire; the dup is a new fd we own (checked >= 0). + let dup = unsafe { libc::fcntl(datas[0].fd() as i32, libc::F_DUPFD_CLOEXEC, 0) }; + if dup < 0 { + break 'passthrough PassthroughFallback::DupFailed; + } + let pts_ns = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0); + ud.publish(CapturedFrame { + width: w as u32, + height: h as u32, + pts_ns, + format: fmt, + payload: FramePayload::Dmabuf(DmabufFrame { + // SAFETY: `dup` is the fresh fd `fcntl(F_DUPFD_CLOEXEC)` just returned + // (checked `dup >= 0`); nothing else owns it, so `OwnedFd` takes sole + // ownership and closes it exactly once on drop — no alias, no + // double-close. + fd: unsafe { OwnedFd::from_raw_fd(dup) }, + fourcc, + modifier: ud.modifier, + offset, + stride, + plane1, + }), + // Cursor-as-metadata is blended only by RGB→NV12 backends. Gamescope + // embeds its pointer in the produced pixels, so native NV12 has none. + cursor: ud.cursor.overlay(), + }); + static ONCE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true); + if ONCE.swap(false, Ordering::Relaxed) { + tracing::info!( + w, + h, + modifier = ud.modifier, + fourcc = format_args!("{:#010x}", fourcc), + source = if fmt == PixelFormat::Nv12 { + "producer-native NV12" + } else { + "packed RGB (encoder GPU CSC)" + }, + "zero-copy: handing the raw DMA-BUF to the encoder" + ); + } + return; + }; + // The passthrough declined this frame. Say so ONCE per distinct reason (`.process` runs + // per frame — see `PassthroughFallbacks`), carrying the running count so a persistent + // downgrade is distinguishable from a one-frame hiccup at renegotiation. + if let Some(frames) = ud.passthrough_fallbacks.note(reason) { + tracing::warn!( + frames, + "zero-copy raw-dmabuf passthrough did not take this frame: {} — {} ({})", + reason.as_str(), + if reason.falls_back_to_cpu() { + "it falls back to the CPU capture path, costing a full-resolution mmap \ + de-pad plus the encoder's own upload on every such frame" + } else { + "the frame is DROPPED — the CPU de-pad path needs the negotiated format \ + too, so nothing streams while this persists" + }, + reason.hint() + ); } - // Not a dmabuf (or unmappable format) — fall through to the CPU de-pad path. } // Zero-copy path: if the buffer is a dmabuf and we have an importer, import it @@ -912,6 +1284,31 @@ pub fn pipewire_thread( want_dmabuf && !vaapi_passthrough && !want_hdr, Ordering::Relaxed, ); + // The ONE line that says which arm this session actually got, and who consumes it. Everything + // below explains a particular arm; nothing below states the answer. Reconstructing it from the + // detail lines cost the 2026-08-08 PyroWave triage four files, and on the one arm that matters + // most — a PyroWave session downgraded to CPU on an NVIDIA host — there was no detail line to + // reconstruct it from at all (see the final `else if` of this chain). + let consumer = consumer_kind( + policy.pyrowave_session, + backend_is_vaapi, + policy.backend_is_gpu, + ); + let arm = resolved_capture_arm(&plan, importer.is_some(), want_dmabuf); + tracing::info!( + capture_arm = arm.as_str(), + consumer = consumer.as_str(), + modifier_count = modifiers.len(), + // PW3(c): the latch state belongs on the same line as the arm. A `cpu` arm has two very + // different explanations — "this host was never going to do dmabuf" and "something failed + // earlier and we are still living with the verdict" — and only the second is a bug worth + // chasing. Reading it here also means the retry/clear behaviour is observable rather than + // inferred. + raw_dmabuf_latch = pf_zerocopy::raw_dmabuf_latch_state(), + "capture pipeline resolved: {} → {}", + arm.as_str(), + consumer.as_str() + ); if force_shm { tracing::info!( "capture: PUNKTFUNK_FORCE_SHM — race-free SHM download path (no dmabuf, no zero-copy)" @@ -942,19 +1339,34 @@ pub fn pipewire_thread( sample = ?&modifiers[..modifiers.len().min(6)], "zero-copy: advertising EGL-importable dmabuf modifiers" ); - } else if backend_is_vaapi && policy.backend_is_gpu { + } else if consumer.cpu_is_downgrade() { // Reached only when no dmabuf is advertised at all (every arm above rules out a - // zero-copy path), so this genuinely IS the CPU capture path: a VAAPI session then pays - // three full-frame CPU touches (mmap de-pad + swscale RGB→NV12 + surface upload) — + // zero-copy path), so this genuinely IS the CPU capture path: the consumer then pays + // full-frame CPU touches (mmap de-pad + whatever CSC/upload it needs) — // make the silent fallback visible. // The `raw_dmabuf_latched` arm above catches the latched downgrade, so by here zero-copy // is off at the source: the env var, or the session's own output format. + // + // The gate used to be `backend_is_vaapi && backend_is_gpu`, which is why a PyroWave + // session's CPU downgrade was invisible on an NVIDIA/auto host: `backend_is_vaapi` reads + // the host-global encoder pref, so a per-session PyroWave negotiation there is `false` and + // fell out of the chain logging NOTHING. `consumer_kind` asks the per-session question + // instead, and excludes only the software encoder (which wants CPU frames). tracing::warn!( - "VAAPI encode with the CPU capture path (per-frame de-pad + swscale CSC + \ - upload) — zero-copy is off for this capture ({}); clear PUNKTFUNK_ZEROCOPY to \ - restore the dmabuf default", + consumer = consumer.as_str(), + "{} encode with the CPU capture path (per-frame de-pad + CSC + upload) — \ + zero-copy is off for this capture ({}); set PUNKTFUNK_ZEROCOPY=1 to restore the \ + dmabuf default", + consumer.as_str(), if std::env::var_os("PUNKTFUNK_ZEROCOPY").is_some() { "PUNKTFUNK_ZEROCOPY is set falsy" + } else if want_hdr && !policy.hdr_cuda_ok { + // Reachable and NOT the output format's doing: `build_importer` drops an HDR + // capture whose encoder cannot take a packed 10-bit CUDA payload (libav's HDR + // route swscales into a P010 hardware frame). Naming the output format here + // would send the reader hunting the wrong knob. + "this HDR session's encoder cannot ingest a 10-bit CUDA payload, so the capture \ + stays on CPU frames" } else { "this session's output format asked for CPU frames" } @@ -986,6 +1398,8 @@ pub fn pipewire_thread( yuv444: want_444, linear_nv12_failed: false, dbg_log_n: 0, + fence_wait: FenceWaitStats::default(), + passthrough_fallbacks: PassthroughFallbacks::default(), cursor: CursorState::new(cursor_id0_hides), expect_dims: if expect_exact_dims { preferred.map(|(w, h, _)| (w, h)) @@ -1708,4 +2122,226 @@ mod tests { assert!(!p.want_dmabuf(false, &[0])); } } + + // ---- PW2: capture-arm observability (pure halves) ------------------------------------- + // + // Env-var reads race under a shared test process, so these assert against the pure functions + // the logging sites call — the same rule `negotiation_plan_invariants` follows. + + use super::{ + consumer_kind, resolved_capture_arm, CaptureArm, ConsumerKind, FenceWaitStats, + PassthroughFallback, PassthroughFallbacks, FENCE_WAIT_BUCKETS_US, + }; + + /// A PyroWave session is PyroWave even though it also flips `backend_is_vaapi` on (the + /// `linux_zero_copy_is_vaapi` `Pyrowave` arm). Getting this precedence backwards is the exact + /// shape of the bug PW2 fixes: the session gets reported as somebody else's backend. + #[test] + fn pyrowave_outranks_the_host_global_backend_pref() { + assert_eq!(consumer_kind(true, true, true), ConsumerKind::PyroWave); + // ...and on an NVIDIA/auto host, where `backend_is_vaapi` is false, it is still PyroWave — + // the case that previously logged nothing at all. + assert_eq!(consumer_kind(true, false, true), ConsumerKind::PyroWave); + } + + /// The non-PyroWave consumers, and the one that must NOT warn on a CPU arm. + #[test] + fn consumer_kinds_and_which_ones_a_cpu_arm_degrades() { + assert_eq!(consumer_kind(false, true, true), ConsumerKind::Vaapi); + assert_eq!(consumer_kind(false, false, true), ConsumerKind::Nvenc); + // No GPU backend ⇒ the software encoder, whose native input IS CPU frames. + assert_eq!(consumer_kind(false, false, false), ConsumerKind::Software); + assert!(ConsumerKind::PyroWave.cpu_is_downgrade()); + assert!(ConsumerKind::Vaapi.cpu_is_downgrade()); + assert!(ConsumerKind::Nvenc.cpu_is_downgrade()); + assert!(!ConsumerKind::Software.cpu_is_downgrade()); + } + + /// The arm is a function of the plan plus the two runtime facts. Pinned against every plan the + /// resolver can produce, so the headline line can never claim an arm the session did not take. + #[test] + fn resolved_arm_matches_the_plan_that_produced_it() { + // PyroWave/VAAPI raw passthrough. + let p = negotiation_plan(NegotiationInputs { + pyrowave_session: true, + ..nvenc() + }); + assert!(p.vaapi_passthrough); + assert_eq!( + resolved_capture_arm(&p, false, p.want_dmabuf(false, &[0])), + CaptureArm::DmabufPassthrough + ); + // NVENC via the EGL→CUDA importer. + let p = negotiation_plan(nvenc()); + assert!(p.build_importer); + assert_eq!( + resolved_capture_arm(&p, true, p.want_dmabuf(true, &[0])), + CaptureArm::CudaImport + ); + // The importer was meant to be built but did not construct (no driver): CPU, not a + // cuda-import the session never got. + assert_eq!( + resolved_capture_arm(&p, false, p.want_dmabuf(false, &[0])), + CaptureArm::Cpu + ); + // An empty modifier list is a CPU arm even under a live passthrough plan. + let p = negotiation_plan(NegotiationInputs { + pyrowave_session: true, + ..nvenc() + }); + assert_eq!( + resolved_capture_arm(&p, false, p.want_dmabuf(false, &[])), + CaptureArm::Cpu + ); + // Forced SHM: CPU regardless of everything else. + let p = negotiation_plan(NegotiationInputs { + force_shm: true, + ..nvenc() + }); + assert_eq!( + resolved_capture_arm(&p, true, p.want_dmabuf(true, &[0])), + CaptureArm::Cpu + ); + } + + /// The rate limiter: ONE line per distinct reason per session, counting every fall-through. + /// `.process` runs per frame, so an off-by-one here is a log flood at the capture rate. + #[test] + fn fallback_log_budget_is_one_line_per_reason() { + let mut f = PassthroughFallbacks::default(); + // First of a reason logs, and reports the running total (not a per-reason count). + assert_eq!(f.note(PassthroughFallback::NotDmabuf), Some(1)); + // Repeats of the SAME reason never log again, but are still counted. + for _ in 0..1_000 { + assert_eq!(f.note(PassthroughFallback::NotDmabuf), None); + } + // A DIFFERENT reason is a different diagnosis and gets its own line, carrying the + // now-large total — which is what distinguishes a persistent downgrade from a hiccup. + assert_eq!(f.note(PassthroughFallback::DupFailed), Some(1002)); + assert_eq!(f.note(PassthroughFallback::DupFailed), None); + // All four reasons fit the budget independently; the tally counts every frame. + assert_eq!(f.note(PassthroughFallback::NoFormat), Some(1004)); + assert_eq!(f.note(PassthroughFallback::NoFourcc), Some(1005)); + // Budget spent: every reason has logged once, so nothing logs again however long the + // session runs. + for r in [ + PassthroughFallback::NoFormat, + PassthroughFallback::NotDmabuf, + PassthroughFallback::NoFourcc, + PassthroughFallback::DupFailed, + ] { + assert_eq!(f.note(r), None); + } + } + + /// Every reason is distinguishable (a shared bit would silence one of them) and carries an + /// actionable hint — a reason with no fix is a line the reader cannot use. + #[test] + fn every_fallback_reason_is_distinct_and_actionable() { + let all = [ + PassthroughFallback::NoFormat, + PassthroughFallback::NotDmabuf, + PassthroughFallback::NoFourcc, + PassthroughFallback::DupFailed, + ]; + let mut f = PassthroughFallbacks::default(); + for r in all { + assert!( + f.note(r).is_some(), + "{r:?} shares a bit with an earlier reason" + ); + assert!(!r.as_str().is_empty()); + assert!(!r.hint().is_empty()); + } + // Only `NoFormat` drops the frame; the other three downgrade it. The log line picks its + // consequence clause off this, so an inverted answer would print the opposite of the truth. + assert!(!PassthroughFallback::NoFormat.falls_back_to_cpu()); + assert!(PassthroughFallback::NotDmabuf.falls_back_to_cpu()); + assert!(PassthroughFallback::NoFourcc.falls_back_to_cpu()); + assert!(PassthroughFallback::DupFailed.falls_back_to_cpu()); + } + + // ---- PW4 step 1: the fence-wait histogram ------------------------------------------------ + // + // This instrument decides whether PW4 ships, so its arithmetic has to be right: a p99 that + // reads one bucket low would retire a package that was worth doing, and one that reads high + // would justify moving load-bearing synchronisation off a thread for nothing. + + /// An empty histogram must say "no answer", not "zero" — those are different claims, and the + /// second one would look like a decisive p99 ≈ 0 result. + #[test] + fn an_empty_histogram_has_no_quantile() { + let s = FenceWaitStats::default(); + assert_eq!(s.quantile_bucket_us(0.99), None); + assert_eq!(s.mean_us(), 0); + assert!(!s.is_meaningful(), "it must not be trusted yet either"); + } + + /// The all-fast case — the one that RETIRES PW4. Every sample in the first bucket must put the + /// p99 there too. + #[test] + fn an_all_fast_distribution_puts_p99_in_the_first_bucket() { + let mut s = FenceWaitStats::default(); + for _ in 0..1000 { + s.record(3); + } + assert_eq!( + s.quantile_bucket_us(0.50), + Some(Some(FENCE_WAIT_BUCKETS_US[0])) + ); + assert_eq!( + s.quantile_bucket_us(0.99), + Some(Some(FENCE_WAIT_BUCKETS_US[0])) + ); + assert_eq!(s.mean_us(), 3); + } + + /// The case PW4 exists for: a fast median with a heavy tail. The p50 must stay low AND the p99 + /// must find the tail — a histogram that smeared them together could not tell the two worlds + /// apart, which is the entire decision. + #[test] + fn a_heavy_tail_moves_p99_without_moving_p50() { + let mut s = FenceWaitStats::default(); + for _ in 0..980 { + s.record(10); // fast majority + } + for _ in 0..20 { + s.record(6_000); // 2 % of frames stall milliseconds + } + assert_eq!( + s.quantile_bucket_us(0.50), + Some(Some(FENCE_WAIT_BUCKETS_US[0])), + "the median is still free" + ); + assert_eq!( + s.quantile_bucket_us(0.99), + Some(Some(10_000)), + "...but the p99 must land in the 5-10ms bucket, not with the median" + ); + assert_eq!(s.max_us, 6_000); + } + + /// Anything past the last edge reports as overflow rather than being clamped into the last + /// bucket — "worse than 10 ms" is a distinct finding and must not read as "10 ms". + #[test] + fn waits_past_the_last_edge_report_as_overflow() { + let mut s = FenceWaitStats::default(); + s.record(99_000); + assert_eq!(s.quantile_bucket_us(0.99), Some(None)); + } + + /// Bucket edges are inclusive upper bounds, so a sample exactly ON an edge belongs to that + /// bucket and not the next one up. + #[test] + fn bucket_edges_are_inclusive() { + for (i, &edge) in FENCE_WAIT_BUCKETS_US.iter().enumerate() { + let mut s = FenceWaitStats::default(); + s.record(edge); + assert_eq!( + s.quantile_bucket_us(1.0), + Some(Some(edge)), + "a sample of exactly {edge}us belongs in bucket {i}" + ); + } + } } diff --git a/crates/pf-encode/src/enc/linux/pyrowave.rs b/crates/pf-encode/src/enc/linux/pyrowave.rs index 1d9f0aac..dbd8a267 100644 --- a/crates/pf-encode/src/enc/linux/pyrowave.rs +++ b/crates/pf-encode/src/enc/linux/pyrowave.rs @@ -321,6 +321,15 @@ struct DeviceHold { instance_ci: Box>, _queue_prio: Box<[f32; 1]>, _queue_ci: Box<[vk::DeviceQueueCreateInfo<'static>; 1]>, + /// The elevated global-priority request chained into `_queue_ci[0].p_next` + /// (`PYROWAVE_QUEUE_PRIORITY`, see `queue_priority_candidates`). A `Box` for the same + /// move-stability reason as its siblings: `pyrowave_create_device` RETAINS `device_ci` for + /// the device's lifetime and Granite reads the chain back through + /// `get_existing_create_info()`, so this must stay put and — critically — must describe the + /// device that actually got created. The create ladder below is therefore required to write + /// its FINAL state back here (null the `p_next` if the no-priority attempt is the one that + /// succeeded); a chain the device was not created with is a lie Granite would believe. + _queue_gp: Box<[vk::DeviceQueueGlobalPriorityCreateInfoKHR<'static>; 1]>, // A plain Vec (not Box<[_; N]> like its siblings): Phase 8 pushes queue_family_foreign // conditionally. The heap buffer as_ptr() feeds device_ci is move-stable like the Boxes. _dev_exts: Vec<*const c_char>, @@ -330,6 +339,60 @@ struct DeviceHold { device_ci: Box>, } +/// Percentile of a sorted sample slice, by nearest-rank. **Pure.** Used for the `PUNKTFUNK_PERF` +/// encode split: a p99 is the whole point here (the game-load spike this codec suffers is a TAIL +/// event — the mean barely moves while individual frames go 2 ms → 18 ms), so a mean-only readout +/// would report "fine" through exactly the failure the priority lever exists to fix. +fn pct(sorted: &[u32], q: f64) -> u32 { + if sorted.is_empty() { + return 0; + } + let rank = ((sorted.len() as f64) * q).ceil() as usize; + sorted[rank.clamp(1, sorted.len()) - 1] +} + +/// The global-priority classes to try, in order, for `PYROWAVE_QUEUE_PRIORITY`. +/// +/// **Pure, and character-identical to the vendored C patch's grammar** +/// (`patches/0005-global-priority-queue.patch`, `context.cpp` ~2136-2152): unset → `realtime`; +/// ASCII-lowercased; `off` → no candidates at all; `high` → `[HIGH]`; anything else, including +/// junk → `[REALTIME, HIGH]`. Keeping the two grammars identical is the whole point — the same +/// env var drives the Windows path (where the patch is live, because Granite builds its own +/// device there) and this Linux path (where the patch is inert, because we pass our own +/// create-infos and Granite takes its `inherit_info` branch). One knob that meant two different +/// things per platform is exactly the documentation trap this wiring exists to close. +/// +/// Note `off` is the ONLY spelling that disables it; `0` is not, because the C side does not +/// accept `0` either. Do not "improve" that here without changing the patch in the same commit. +fn queue_priority_candidates(raw: Option<&str>) -> Vec { + let want = raw.map(|s| s.to_ascii_lowercase()); + match want.as_deref() { + Some("off") => Vec::new(), + Some("high") => vec![vk::QueueGlobalPriorityKHR::HIGH], + _ => vec![ + vk::QueueGlobalPriorityKHR::REALTIME, + vk::QueueGlobalPriorityKHR::HIGH, + ], + } +} + +/// Whether a `create_device` error means "this priority class was refused" — i.e. walk the ladder +/// down rather than failing the encoder open. +/// +/// `ERROR_NOT_PERMITTED_KHR` is the specified answer and the only one the C patch handles. +/// `ERROR_INITIALIZATION_FAILED` is here because the in-tree precedent that already ships this +/// ladder on Linux — pf-zerocopy's VkBridge — accepts it too, and because the failure mode this +/// guards against is severe and asymmetric: a PyroWave open is reached only by a NEGOTIATED +/// PyroWave session, so a hard error here is a dead stream, not a fallback to another encoder. +/// Treating one extra driver-specific refusal as a downgrade costs nothing; treating it as fatal +/// costs the session. +fn priority_refused(e: vk::Result) -> bool { + matches!( + e, + vk::Result::ERROR_NOT_PERMITTED_KHR | vk::Result::ERROR_INITIALIZATION_FAILED + ) +} + pub struct PyroWaveEncoder { // --- vulkan core (owned; private to this encoder) --- _entry: ash::Entry, @@ -398,6 +461,13 @@ pub struct PyroWaveEncoder { chroma444: bool, /// Per-frame bitstream budget (hard CBR): `bitrate / (8 * fps)`. frame_budget: usize, + /// `PUNKTFUNK_PERF`: the synchronous encode's own duration, which is the quantity the + /// GPU-priority work is about — patch 0005's header records it going ~2 ms → 15-18 ms at + /// 95 % game load. Every other backend (VAAPI, direct NVENC) already logs a submit split; + /// this one did not, so the one encoder whose cost the priority lever exists to protect was + /// the one you could not measure. Reservoir of recent samples, summarised on a slow cadence. + perf_us: Vec, + perf_logged_at: Option, /// Datagram-aligned mode (plan §4.4): packetize at this boundary and pad every codec /// packet to it, so each wire shard carries whole self-delimiting packets. `None` = /// one packet per AU (the dense MVP shape). @@ -420,6 +490,43 @@ fn budget_for(bitrate_bps: u64, fps: u32) -> usize { } impl PyroWaveEncoder { + /// `PUNKTFUNK_PERF`: record one synchronous-encode duration and summarise on a slow cadence. + /// + /// Whole-`submit` timing on purpose: for this backend that IS the encode — `encode_frame` + /// records CSC+encode, submits, waits the fence and packetizes, all inline (which is also + /// why the loop's period folds to `interval + encode`, the thing PW5 exists to unfold). + fn note_encode_us(&mut self, us: u32) { + if !pf_host_config::config().perf { + return; + } + self.perf_us.push(us); + let now = std::time::Instant::now(); + let since = self.perf_logged_at.map(|t| now.duration_since(t)); + // Every 2 s, matching the other backends' submit-split cadence, and never before there + // are enough samples for a p99 to mean anything. + if self.perf_us.len() < 30 || since.is_some_and(|d| d.as_secs() < 2) { + if self.perf_logged_at.is_none() { + self.perf_logged_at = Some(now); + } + return; + } + self.perf_logged_at = Some(now); + let mut s = std::mem::take(&mut self.perf_us); + s.sort_unstable(); + let n = s.len() as u64; + let mean = s.iter().map(|&v| u64::from(v)).sum::() / n.max(1); + tracing::info!( + frames = n, + mean_us = mean, + p50_us = pct(&s, 0.50), + p99_us = pct(&s, 0.99), + max_us = *s.last().unwrap_or(&0), + "pyrowave encode (synchronous: CSC + encode + fence wait + packetize). Under a \ + GPU-bound game this is the number the global-priority queue exists to protect — \ + watch p99, not the mean" + ); + } + pub fn open( width: u32, height: u32, @@ -467,6 +574,7 @@ impl PyroWaveEncoder { instance_ci: Box::new(vk::InstanceCreateInfo::default()), _queue_prio: Box::new([1.0f32]), _queue_ci: Box::new([vk::DeviceQueueCreateInfo::default()]), + _queue_gp: Box::new([vk::DeviceQueueGlobalPriorityCreateInfoKHR::default()]), _dev_exts: vec![ ash::khr::external_memory_fd::NAME.as_ptr(), ash::ext::external_memory_dma_buf::NAME.as_ptr(), @@ -487,8 +595,14 @@ impl PyroWaveEncoder { // error arm. From the device on, a partially-constructed `Self` (below) makes the // existing `Drop` the sole unwind path — these used to be a dozen `?`s that each leaked // everything created before them. - // SAFETY: plain physical-device queries on the live instance just created, and a - // `create_device` whose create-infos are pinned in `hold` for the call's duration. + // SAFETY: plain physical-device queries on the live instance just created, and + // `create_device` calls whose create-infos are pinned in `hold` for each call's duration. + // The global-priority ladder (WP14 step 4) may call `create_device` several times; every + // attempt reads the SAME pinned `hold`, and the only thing that varies between attempts is + // `hold._queue_gp[0].global_priority` (a plain enum field) and, for the final attempt, + // `hold._queue_ci[0].p_next` being nulled. Both live in `Box`es owned by `hold`, so the + // pointers `device_ci` holds stay valid across the retries; a failed `create_device` does + // not consume or invalidate its create-info, so re-passing it is sound. let selected = (|| unsafe { // The SAME selector `capture_modifiers` uses, so the two can never disagree about // the device (see `select_physical_device` — including why the selection itself is @@ -585,18 +699,117 @@ impl PyroWaveEncoder { ); vk::QUEUE_FAMILY_EXTERNAL }; + // VK_KHR_global_priority (WP14 step 4): PyroWave encodes on the SAME shader cores a + // game saturates, and `encode_gpu_synchronous` measurably collapses under that load + // (patch 0005's header: ~2 ms → 15-18 ms at 95 % game load on an RTX 4090). An + // elevated global-priority queue is the actual compute-PREEMPTION lever — unlike a + // process-priority raise, which only orders submission. The vendored patch requests + // it, but it is gated `if (!inherit_info)` and Linux passes its OWN create-infos, so + // Granite takes the inherit branch and the patch has never done anything here. This + // is the Linux half. Must be pushed BEFORE the count/as_ptr wiring below, exactly + // like queue_family_foreign above. + let gp_candidates = + queue_priority_candidates(std::env::var("PYROWAVE_QUEUE_PRIORITY").ok().as_deref()); + // Enable whichever alias the driver advertises (KHR = the promoted name), mirroring + // pf-zerocopy's VkBridge probe so the two can never disagree about the spelling. + let gp_ext = + if crate::vk_util::ext_advertised(&dev_ext_props, vk::KHR_GLOBAL_PRIORITY_NAME) { + Some(vk::KHR_GLOBAL_PRIORITY_NAME) + } else if crate::vk_util::ext_advertised( + &dev_ext_props, + vk::EXT_GLOBAL_PRIORITY_NAME, + ) { + Some(vk::EXT_GLOBAL_PRIORITY_NAME) + } else { + None + }; + let gp = gp_ext.filter(|_| !gp_candidates.is_empty()); + if let Some(name) = gp { + hold._dev_exts.push(name.as_ptr()); + } + hold._queue_ci[0] = vk::DeviceQueueCreateInfo::default().queue_family_index(family); hold._queue_ci[0].queue_count = 1; hold._queue_ci[0].p_queue_priorities = hold._queue_prio.as_ptr(); + if gp.is_some() { + hold._queue_ci[0].p_next = &*hold._queue_gp as *const _ as *const std::ffi::c_void; + } hold.device_ci.p_next = &*hold._feat2 as *const _ as *const std::ffi::c_void; hold.device_ci.queue_create_info_count = 1; hold.device_ci.p_queue_create_infos = hold._queue_ci.as_ptr(); hold.device_ci.enabled_extension_count = hold._dev_exts.len() as u32; hold.device_ci.pp_enabled_extension_names = hold._dev_exts.as_ptr(); - let device = instance - .create_device(pd, &hold.device_ci, None) - .context("create device")?; + // The downgrade ladder, mirroring the C patch: try each class in turn, step down only + // on a REFUSAL, and if every class is refused create with no global priority at all. + // A refused class must NEVER fail the open — that graceful property is the entire + // reason patch 0005 was kept despite its negative RTX/WDDM measurement, and it matters + // more here: this path is reached only by a negotiated PyroWave session, so a hard + // error is a dead stream rather than a fallback to another encoder. + let mut chosen = None; + let mut device = None; + for want in &gp_candidates { + if gp.is_none() { + break; + } + hold._queue_gp[0].global_priority = *want; + match instance.create_device(pd, &hold.device_ci, None) { + Ok(d) => { + chosen = Some(*want); + device = Some(d); + break; + } + Err(e) if priority_refused(e) => { + tracing::debug!( + priority = ?want, + error = ?e, + "pyrowave: global queue priority not permitted — downgrading" + ); + } + Err(e) => { + return Err(e).context("create device"); + } + } + } + let device = match device { + Some(d) => { + tracing::info!( + priority = ?chosen, + ext = ?gp, + "pyrowave: elevated global queue priority (the encode dispatch preempts a \ + GPU-bound game where the driver honors it)" + ); + d + } + None => { + // Either nothing was requested (`off`, or no extension), or every class was + // refused. EITHER WAY the retained create-info must now describe a device + // created WITHOUT a priority chain — `pyrowave_create_device` keeps + // `device_ci` for the device's lifetime and Granite reads it back through + // `get_existing_create_info()`. Leaving `p_next` pointing at the + // global-priority struct here would hand Granite a chain the device was not + // created with. (The extension itself stays enabled and that is correct: it + // IS enabled on the device, it just carries no request.) + hold._queue_ci[0].p_next = std::ptr::null(); + if !gp_candidates.is_empty() && gp.is_some() { + // MEASURED on .21 (RTX 5070 Ti, NVIDIA 610.43.02, 2026-08-08), and it is + // not a vendor quirk: an unprivileged host is refused EVERY class, and the + // same binary with `cap_sys_nice+ep` is granted REALTIME on the first + // attempt. So this arm is the normal state of a packaged host today, the + // lever is inert until the capability ships, and the message has to say + // which capability rather than leave an operator guessing. + tracing::warn!( + "pyrowave: every global queue priority class was refused — encoding \ + at default priority. The GPU-preemption lever is INERT without \ + CAP_SYS_NICE on the host binary (measured on both NVIDIA and RADV); \ + PYROWAVE_QUEUE_PRIORITY=off silences this" + ); + } + instance + .create_device(pd, &hold.device_ci, None) + .context("create device")? + } + }; Ok((pd, family, device, foreign_qfi)) })(); let (pd, family, device, foreign_qfi) = match selected { @@ -662,6 +875,8 @@ impl PyroWaveEncoder { fps, chroma444, frame_budget: budget_for(bitrate, fps), + perf_us: Vec::new(), + perf_logged_at: None, wire_chunk: None, wire_budget: crate::pyrowave_wire::WireBudget::new(), bitstream: Vec::new(), @@ -1519,6 +1734,9 @@ impl PyroWaveEncoder { impl Encoder for PyroWaveEncoder { fn submit(&mut self, frame: &CapturedFrame) -> Result<()> { + // `PUNKTFUNK_PERF` encode split (kept above the SAFETY comment so that comment stays + // attached to the block it proves — the crate denies undocumented unsafe blocks). + let t0 = std::time::Instant::now(); // SAFETY: single-threaded encoder; `encode_frame` records/submits on handles this // struct owns and waits its own fence before touching results. Command-buffer state on // failure is `encode_frame`'s own business now: its record-and-submit closure resets the @@ -1527,7 +1745,9 @@ impl Encoder for PyroWaveEncoder { // VUID-vkResetCommandBuffer-commandBuffer-00045; the blanket reset that used to live // here fired on exactly that path. Recovery (`reset()`/`Drop`) waits the device idle // before anything touches `cmd` again. - unsafe { self.encode_frame(frame) } + let r = unsafe { self.encode_frame(frame) }; + self.note_encode_us(t0.elapsed().as_micros() as u32); + r } fn caps(&self) -> EncoderCaps { @@ -2262,4 +2482,81 @@ mod tests { dump("ref-dense444-cb.bin", &cb); dump("ref-dense444-cr.bin", &cr); } + + // ---- WP14 step 4: the global-priority grammar -------------------------------------------- + // + // The device-create ladder itself has NO unit test and cannot have one — it needs a real + // Vulkan device. Its coverage is clippy plus the on-glass log line. What IS testable, and what + // actually drifts, is the grammar: it must stay character-identical to the vendored C patch, + // because the SAME env var drives the Windows path (where the patch is live) and this one. + // These are device-free by construction — that is why `queue_priority_candidates` takes the + // raw string instead of reading the environment itself (env-var tests race). + + /// Unset means `realtime`, which means "try REALTIME, then fall back to HIGH" — the ladder, + /// not a single class. Windows parity: the C patch defaults the same way. + #[test] + fn unset_requests_the_realtime_ladder() { + assert_eq!( + queue_priority_candidates(None), + vec![ + vk::QueueGlobalPriorityKHR::REALTIME, + vk::QueueGlobalPriorityKHR::HIGH + ] + ); + } + + /// `off` is the ONLY spelling that disables it, and it is case-insensitive because the C patch + /// lowercases first. Note `0` is deliberately NOT off — the C side does not accept it either, + /// and two grammars for one variable is the trap this wiring closes. + #[test] + fn only_off_disables_and_it_is_case_insensitive() { + assert!(queue_priority_candidates(Some("off")).is_empty()); + assert!(queue_priority_candidates(Some("OFF")).is_empty()); + assert!(queue_priority_candidates(Some("Off")).is_empty()); + assert!(!queue_priority_candidates(Some("0")).is_empty()); + } + + /// `high` asks for HIGH ONLY — it must not silently try REALTIME first, or the knob would be + /// unable to express "elevated, but not realtime" (the thing an operator reaches for after a + /// compositor-jank report). + #[test] + fn high_asks_for_high_alone() { + assert_eq!( + queue_priority_candidates(Some("high")), + vec![vk::QueueGlobalPriorityKHR::HIGH] + ); + assert_eq!( + queue_priority_candidates(Some("HIGH")), + vec![vk::QueueGlobalPriorityKHR::HIGH] + ); + } + + /// Junk falls back to the default ladder rather than to "off": an unparseable value must never + /// silently disable a performance lever the operator was trying to tune. + #[test] + fn junk_falls_back_to_the_default_ladder() { + for raw in ["", "realtime", "REALTIME", "yes", "1", "medium", " high"] { + assert!( + !queue_priority_candidates(Some(raw)).is_empty(), + "{raw:?} must not disable the priority request" + ); + } + // ...and specifically the full ladder, not HIGH alone. `" high"` is in the list above on + // purpose: the C patch does NOT trim, so neither do we — a space-padded value is junk to + // both, and the two must agree even in how they are wrong. + assert_eq!(queue_priority_candidates(Some(" high")).len(), 2); + } + + /// A refused class must walk the ladder down, never fail the open. `NOT_PERMITTED` is the + /// specified refusal; `INITIALIZATION_FAILED` is accepted too, matching pf-zerocopy's shipped + /// VkBridge ladder. Anything else is a real error and must propagate — a driver that is out of + /// memory should not be silently retried at a lower priority and reported as a success. + #[test] + fn only_refusals_walk_the_ladder_down() { + assert!(priority_refused(vk::Result::ERROR_NOT_PERMITTED_KHR)); + assert!(priority_refused(vk::Result::ERROR_INITIALIZATION_FAILED)); + assert!(!priority_refused(vk::Result::ERROR_OUT_OF_DEVICE_MEMORY)); + assert!(!priority_refused(vk::Result::ERROR_EXTENSION_NOT_PRESENT)); + assert!(!priority_refused(vk::Result::SUCCESS)); + } } diff --git a/crates/pf-zerocopy/src/imp/mod.rs b/crates/pf-zerocopy/src/imp/mod.rs index f3a4a4d9..f6d38174 100644 --- a/crates/pf-zerocopy/src/imp/mod.rs +++ b/crates/pf-zerocopy/src/imp/mod.rs @@ -19,7 +19,7 @@ pub mod vkslot; pub mod vulkan; pub mod worker; -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; pub use cuda::DeviceBuffer; pub use egl::{DmabufPlane, EglImporter}; @@ -261,56 +261,223 @@ pub fn gpu_import_disabled() -> bool { /// operator found `PUNKTFUNK_ZEROCOPY=0` by hand. The host already knows how to encode that /// machine — capture just has to stop handing it dmabufs. Latching here is what makes the next /// session negotiate CPU frames on its own. -static RAW_DMABUF_FAILURE_STREAK: AtomicU32 = AtomicU32::new(0); -static RAW_DMABUF_DISABLED: AtomicBool = AtomicBool::new(false); /// Below the encoder's own rebuild budget, so the latch is set before the session it doomed ends. const RAW_DMABUF_FAILURE_LATCH: u32 = 3; -/// Record an encoder-side raw-dmabuf import failure. Latches the process-wide disable after -/// `RAW_DMABUF_FAILURE_LATCH` consecutive failures. +/// Consecutive capture rebuilds whose dmabuf-only offer never negotiated before the passthrough is +/// latched off. **2 = one retry**, deliberately: each failed negotiation costs a ~10 s stall, so a +/// larger budget is paid by the user in dead air. One retry is enough to survive a compositor +/// caught mid-restart, which is the transient this exists for; a compositor that genuinely never +/// accepts keeps the same capture identity, so its streak accumulates and it latches on the second +/// try — one extra stall versus the old behaviour, once per host lifetime. +const RAW_DMABUF_NEGOTIATION_LATCH: u32 = 2; + +/// The raw-dmabuf passthrough's off-switch — **two causes with two different lifetimes**, which is +/// the whole point of this type. +/// +/// They used to share one `AtomicBool`, so the cheap recoverable cause (a negotiation that timed +/// out, possibly because the compositor was mid-restart) was as permanent as the expensive +/// unrecoverable one (an encoder that cannot import what this compositor allocates). Once either +/// fired, EVERY later session on the host captured CPU frames until the process was restarted — +/// including sessions against a completely different compositor and node, which had never failed +/// at anything. +/// +/// * **Import failures stay sticky.** A driver that will not take what the compositor allocates +/// refuses identically on every retry, and the encode-stall recovery above cannot tell that from +/// a transient — it rebuilt the same failing encoder five times and then ended the session, on +/// every connection, forever. That is what this latch was born to stop, and it must keep +/// stopping it. +/// * **Negotiation timeouts get a retry budget** ([`RAW_DMABUF_NEGOTIATION_LATCH`]). +/// * **Both are keyed to a capture identity.** A new node id — a fresh virtual output, the +/// Bazzite Gaming↔Desktop switch, a compositor restart — is a genuinely different question, so +/// it earns a fresh dmabuf attempt instead of inheriting a verdict about something else. +/// +/// Atomics rather than a lock because [`note_import_ok`](Self::note_import_ok) is on the per-frame +/// import path; everything else here runs at pipeline build or on failure. +#[derive(Debug)] +pub struct RawDmabufLatch { + import_streak: AtomicU32, + import_latched: AtomicBool, + negotiation_streak: AtomicU32, + negotiation_latched: AtomicBool, + /// The capture identity the counters above describe. `u64::MAX` = nothing observed yet (a real + /// identity is a node id, so it can never collide with the sentinel). + identity: AtomicU64, +} + +/// Nothing observed yet — distinct from any real capture identity. +const NO_IDENTITY: u64 = u64::MAX; + +impl RawDmabufLatch { + pub const fn new() -> Self { + RawDmabufLatch { + import_streak: AtomicU32::new(0), + import_latched: AtomicBool::new(false), + negotiation_streak: AtomicU32::new(0), + negotiation_latched: AtomicBool::new(false), + identity: AtomicU64::new(NO_IDENTITY), + } + } + + /// Whether the raw-dmabuf passthrough is currently off, for either cause. + pub fn disabled(&self) -> bool { + self.import_latched.load(Ordering::Relaxed) + || self.negotiation_latched.load(Ordering::Relaxed) + } + + /// Tell the latch which capture is about to be built. A DIFFERENT capture from the one the + /// current verdict was formed against clears every counter and both latches, so the new + /// pipeline earns a fresh dmabuf attempt. + /// + /// Returns `true` only when that clear actually **re-armed something** — i.e. the identity + /// changed *and* a latch was set. Deliberately not "the identity changed": every session on a + /// fresh virtual output changes it, and a caller that logged on that would print a re-arm line + /// on every healthy session open, which is noise. `true` means "this capture would have been + /// forced to CPU by an earlier capture's verdict, and no longer is". + /// + /// Call this BEFORE reading [`disabled`](Self::disabled) for a negotiation decision, or the + /// decision is made against the previous capture's verdict. + pub fn observe_capture(&self, identity: u64) -> bool { + if self.identity.swap(identity, Ordering::Relaxed) == identity { + return false; + } + let was_latched = self.disabled(); + self.import_streak.store(0, Ordering::Relaxed); + self.import_latched.store(false, Ordering::Relaxed); + self.negotiation_streak.store(0, Ordering::Relaxed); + self.negotiation_latched.store(false, Ordering::Relaxed); + was_latched + } + + /// Record an encoder-side raw-dmabuf import failure. Returns `true` if this failure is the one + /// that latched the passthrough off. + pub fn note_import_failure(&self) -> Option { + let streak = self.import_streak.fetch_add(1, Ordering::Relaxed) + 1; + (streak >= RAW_DMABUF_FAILURE_LATCH && !self.import_latched.swap(true, Ordering::Relaxed)) + .then_some(streak) + } + + /// Record a raw dmabuf that imported and encoded — resets the failure streak. The per-frame + /// hot path, hence a single relaxed store. + /// + /// Deliberately does NOT clear `import_latched`: once the latch fires, capture has already + /// moved to CPU frames, so there are no more dmabuf imports to succeed. Only a new capture + /// identity clears it. + pub fn note_import_ok(&self) { + self.import_streak.store(0, Ordering::Relaxed); + } + + /// Record a capture rebuild whose dmabuf-only offer never negotiated. Returns `Some(streak)` + /// if this is the failure that latched the passthrough off, `None` while retries remain. + pub fn note_negotiation_timeout(&self) -> Option { + let streak = self.negotiation_streak.fetch_add(1, Ordering::Relaxed) + 1; + (streak >= RAW_DMABUF_NEGOTIATION_LATCH + && !self.negotiation_latched.swap(true, Ordering::Relaxed)) + .then_some(streak) + } + + /// Record a capture whose dmabuf offer DID negotiate — the retry budget is per consecutive + /// run of failures, so a success spends none of it. + pub fn note_negotiation_ok(&self) { + self.negotiation_streak.store(0, Ordering::Relaxed); + } + + /// Diagnostic for the session-open line: which cause (if any) currently holds it off. + pub fn state(&self) -> &'static str { + match ( + self.import_latched.load(Ordering::Relaxed), + self.negotiation_latched.load(Ordering::Relaxed), + ) { + (true, true) => "latched: encoder-import + negotiation", + (true, false) => "latched: encoder-import failures", + (false, true) => "latched: negotiation timeouts", + (false, false) => "live", + } + } +} + +impl Default for RawDmabufLatch { + fn default() -> Self { + Self::new() + } +} + +static RAW_DMABUF: RawDmabufLatch = RawDmabufLatch::new(); + +/// Record an encoder-side raw-dmabuf import failure. Latches the passthrough off after +/// `RAW_DMABUF_FAILURE_LATCH` consecutive failures, until the capture identity changes. pub fn note_raw_dmabuf_import_failure(reason: &str) { - let streak = RAW_DMABUF_FAILURE_STREAK.fetch_add(1, Ordering::Relaxed) + 1; - if streak >= RAW_DMABUF_FAILURE_LATCH && !RAW_DMABUF_DISABLED.swap(true, Ordering::Relaxed) { + if let Some(streak) = RAW_DMABUF.note_import_failure() { tracing::error!( streak, reason, - "zero-copy raw-dmabuf passthrough disabled for this host process: the encoder failed \ - to import the compositor's dmabuf {streak} times in a row — captures fall back to the \ - CPU path (slower, but this host could not stream at all otherwise)" + "zero-copy raw-dmabuf passthrough disabled: the encoder failed to import the \ + compositor's dmabuf {streak} times in a row — captures fall back to the CPU path \ + (slower, but this host could not stream at all otherwise). A new capture (different \ + node / compositor) clears this." ); } } /// Record a raw dmabuf that imported and encoded — resets the failure streak. pub fn note_raw_dmabuf_import_ok() { - RAW_DMABUF_FAILURE_STREAK.store(0, Ordering::Relaxed); + RAW_DMABUF.note_import_ok(); } /// Latch the raw-dmabuf passthrough off because its dmabuf-only *offer never negotiated* — the -/// CAPTURE-side counterpart to [`note_raw_dmabuf_import_failure`]'s encoder-side streak. One -/// timeout is conclusive for this offer (a compositor that cannot allocate the requested -/// LINEAR/modifier BGRx dmabuf refuses it identically on every retry), so there is no streak to -/// count: the next capture skips the passthrough and negotiates SHM/CPU instead of re-running the -/// same 10 s timeout on every reconnect. +/// CAPTURE-side counterpart to [`note_raw_dmabuf_import_failure`]'s encoder-side streak. +/// +/// Unlike the import streak this gets a retry budget: the offer can time out because the +/// compositor was mid-restart rather than because it will never accept, and the old behaviour +/// (one timeout = CPU capture for the rest of the host's life, for every compositor and every +/// node) turned a transient into a permanent downgrade nobody could see. /// /// Scoped deliberately. This used to be `note_vaapi_dmabuf_failed`, which fed [`enabled`] and so -/// disabled ALL zero-copy host-wide — see [`enabled`]. `RAW_DMABUF_DISABLED` gates only the -/// raw-passthrough decision, so the EGL→CUDA importer that a later NVENC session builds is -/// untouched. +/// disabled ALL zero-copy host-wide — see [`enabled`]. It gates only the raw-passthrough decision, +/// so the EGL→CUDA importer that a later NVENC session builds is untouched. pub fn note_raw_dmabuf_negotiation_failed() { - if !RAW_DMABUF_DISABLED.swap(true, Ordering::Relaxed) { - tracing::warn!( - "zero-copy raw-dmabuf passthrough disabled for this host process: the compositor never \ - accepted the dmabuf-only capture offer, so later captures negotiate the CPU path \ - instead of repeating that timeout (the EGL→CUDA import path is NOT affected)" - ); + match RAW_DMABUF.note_negotiation_timeout() { + Some(streak) => tracing::warn!( + streak, + "zero-copy raw-dmabuf passthrough disabled: the compositor did not accept the \ + dmabuf-only capture offer {streak} builds in a row, so later captures negotiate the \ + CPU path instead of repeating that timeout (the EGL→CUDA import path is NOT \ + affected). A new capture (different node / compositor) clears this." + ), + None => tracing::warn!( + "the compositor did not accept the dmabuf-only capture offer — retrying dmabuf on the \ + next capture build before giving up on it" + ), } } -/// True once repeated encoder import failures latched the raw-dmabuf passthrough off (see -/// [`note_raw_dmabuf_import_failure`]). +/// Record a capture whose dmabuf offer negotiated — spends none of the retry budget. +pub fn note_raw_dmabuf_negotiation_ok() { + RAW_DMABUF.note_negotiation_ok(); +} + +/// Tell the latch which capture is about to be built, so a verdict formed against a DIFFERENT +/// compositor/node is not inherited. Returns `true` if a latch was cleared by the change. +pub fn note_raw_dmabuf_capture(identity: u64) -> bool { + let cleared = RAW_DMABUF.observe_capture(identity); + if cleared { + tracing::info!( + identity, + "zero-copy raw-dmabuf passthrough re-armed: this is a different capture from the one \ + that failed, so it gets a fresh dmabuf attempt" + ); + } + cleared +} + +/// True while either cause holds the raw-dmabuf passthrough off (see [`RawDmabufLatch`]). pub fn raw_dmabuf_import_disabled() -> bool { - RAW_DMABUF_DISABLED.load(Ordering::Relaxed) + RAW_DMABUF.disabled() +} + +/// Which cause holds the passthrough off, for the session-open diagnostic line. +pub fn raw_dmabuf_latch_state() -> &'static str { + RAW_DMABUF.state() } /// The EGL→CUDA twin of the raw-passthrough negotiation latch: the capture advertised the GPU @@ -564,4 +731,131 @@ mod tests { note_gpu_import_death(); // third consecutive death assert!(gpu_import_disabled()); } + + // ---- PW3: the raw-dmabuf latch's two lifetimes ------------------------------------------ + // + // Against a LOCAL `RawDmabufLatch`, never the process-wide static: these assertions are about + // the state machine, and sharing one global across a test binary's threads is how a latch test + // becomes order-dependent. + + /// The expensive cause stays sticky. A driver that cannot import what this compositor + /// allocates refuses identically every time, and the encode-stall recovery cannot tell that + /// from a transient — this latch is what stops it rebuilding the same doomed encoder forever. + #[test] + fn import_failures_latch_and_stay_latched() { + let l = RawDmabufLatch::new(); + assert!(!l.disabled()); + assert_eq!(l.note_import_failure(), None); // 1 + assert_eq!(l.note_import_failure(), None); // 2 + assert!(!l.disabled(), "must not latch before the streak completes"); + assert_eq!(l.note_import_failure(), Some(3)); + assert!(l.disabled()); + // Only the FIRST crossing reports, so the error line cannot repeat per frame. + assert_eq!(l.note_import_failure(), None); + // A success resets the streak but must NOT unlatch: once capture moved to CPU frames there + // are no more dmabuf imports, so an "ok" here would be about something else entirely. + l.note_import_ok(); + assert!(l.disabled()); + } + + /// A run of failures broken by a success spends none of the budget — the streak is + /// consecutive-only, which is what makes an occasional failure survivable. + #[test] + fn a_success_breaks_the_import_streak() { + let l = RawDmabufLatch::new(); + l.note_import_failure(); + l.note_import_failure(); + l.note_import_ok(); + assert_eq!(l.note_import_failure(), None, "streak restarted at 1"); + assert_eq!(l.note_import_failure(), None); + assert!(!l.disabled()); + assert_eq!(l.note_import_failure(), Some(3)); + } + + /// The cheap cause gets a retry. This is the behaviour change PW3 exists for: one timeout used + /// to mean CPU capture for the rest of the host's life, on every compositor and every node. + #[test] + fn a_negotiation_timeout_is_retried_before_it_latches() { + let l = RawDmabufLatch::new(); + assert_eq!(l.note_negotiation_timeout(), None, "first one retries"); + assert!( + !l.disabled(), + "the next capture build must still be allowed to try dmabuf" + ); + assert_eq!(l.note_negotiation_timeout(), Some(2)); + assert!(l.disabled()); + assert_eq!(l.note_negotiation_timeout(), None, "reports once"); + } + + /// A capture that negotiates credits the budget back, so a compositor that fails once and then + /// works never accumulates its way to a latch across an evening of reconnects. + #[test] + fn a_negotiated_capture_credits_the_retry_budget() { + let l = RawDmabufLatch::new(); + for _ in 0..10 { + assert_eq!(l.note_negotiation_timeout(), None); + l.note_negotiation_ok(); + } + assert!(!l.disabled()); + } + + /// A different capture is a different question. New node id (fresh virtual output, compositor + /// restart, the Bazzite Gaming↔Desktop switch) clears BOTH causes — the same capture does not. + #[test] + fn a_new_capture_identity_clears_the_latch_and_the_same_one_does_not() { + let l = RawDmabufLatch::new(); + // Nothing is latched yet, so observing a new capture re-arms NOTHING — that is what the + // return value means, and it is why a healthy session open logs no re-arm line. + assert!( + !l.observe_capture(7), + "nothing was latched, nothing re-armed" + ); + assert!(!l.observe_capture(7), "same capture, no clear"); + for _ in 0..RAW_DMABUF_FAILURE_LATCH { + l.note_import_failure(); + } + assert!(l.disabled()); + assert!( + !l.observe_capture(7), + "the SAME capture must keep its verdict — this is the 10s-stall hazard the latch exists for" + ); + assert!(l.disabled()); + assert!(l.observe_capture(9), "a different node re-arms it"); + assert!(!l.disabled()); + // ...and the streaks reset with it, so the fresh attempt gets a full budget. + assert_eq!(l.note_import_failure(), None); + } + + /// The negotiation latch is keyed the same way — a compositor restart must not inherit the + /// previous one's timeout verdict. + #[test] + fn a_new_capture_identity_clears_the_negotiation_latch_too() { + let l = RawDmabufLatch::new(); + l.observe_capture(1); + l.note_negotiation_timeout(); + l.note_negotiation_timeout(); + assert!(l.disabled()); + assert!(l.observe_capture(2)); + assert!(!l.disabled()); + } + + /// The session-open line has to name WHICH cause holds it off — "cpu because nothing here + /// does dmabuf" and "cpu because something failed earlier" are different bugs. + #[test] + fn latch_state_names_the_cause() { + let l = RawDmabufLatch::new(); + assert_eq!(l.state(), "live"); + l.note_negotiation_timeout(); + l.note_negotiation_timeout(); + assert_eq!(l.state(), "latched: negotiation timeouts"); + let l = RawDmabufLatch::new(); + for _ in 0..RAW_DMABUF_FAILURE_LATCH { + l.note_import_failure(); + } + assert_eq!(l.state(), "latched: encoder-import failures"); + for _ in 0..RAW_DMABUF_NEGOTIATION_LATCH { + l.note_negotiation_timeout(); + } + assert_eq!(l.state(), "latched: encoder-import + negotiation"); + } } diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index ed28763d..9f5b797c 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -890,7 +890,13 @@ fn parse_spike(args: &[String]) -> Result { "h264" => Codec::H264, "h265" | "hevc" => Codec::H265, "av1" => Codec::Av1, - other => bail!("unknown --codec '{other}' (h264|h265|av1)"), + // The spike is the only way to drive a PyroWave capture→encode pass without + // a client, which is what the Linux-host PyroWave work measures against. + // Needs the `pyrowave` feature (default-on) and pairs with + // `PUNKTFUNK_ENCODER=pyrowave`, which is what puts the CAPTURE side on the + // raw-dmabuf passthrough. + "pyrowave" => Codec::PyroWave, + other => bail!("unknown --codec '{other}' (h264|h265|av1|pyrowave)"), } } "--bitrate" => { @@ -1007,7 +1013,9 @@ SPIKE OPTIONS: KWin virtual output at --width x --height and captures it --seconds capture duration in seconds (default: 5) --fps target frame rate (default: 60) - --codec NVENC codec (default: h265) + --codec + encode codec (default: h265). 'pyrowave' also wants + PUNKTFUNK_ENCODER=pyrowave so capture takes the passthrough --bitrate target bitrate in Mbps (default: 20) --width --height synthetic source size (default: 1920x1080) --out raw Annex-B output (default: /tmp/punktfunk-spike.) diff --git a/crates/punktfunk-host/src/session_plan.rs b/crates/punktfunk-host/src/session_plan.rs index 0709015d..9926bf42 100644 --- a/crates/punktfunk-host/src/session_plan.rs +++ b/crates/punktfunk-host/src/session_plan.rs @@ -193,12 +193,29 @@ impl SessionPlan { // Surface the trade loudly: this is the single biggest per-frame cost a 4:4:4 // session adds (full-res CPU readback + swscale RGB→YUV444P every frame), and // it looks like an unexplained fps ceiling if you don't know it happened. - tracing::warn!( - "4:4:4 session on the NVENC path without PUNKTFUNK_ZEROCOPY: zero-copy GPU \ - capture DISABLED — every frame is CPU RGB + swscale RGB→YUV444P; expect a \ - lower fps ceiling than 4:2:0 at this mode (set PUNKTFUNK_ZEROCOPY=1 for the \ - GPU 4:4:4 convert)" - ); + // + // Name the SESSION's codec, not the backend the gate is named after. The gate + // keys on `linux_zero_copy_is_vaapi()`, which reads the host-global encoder pref + // — so a per-session PyroWave negotiation on an NVENC/auto host lands here and + // was told 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 whole design assumes. + if self.codec == crate::encode::Codec::PyroWave { + tracing::warn!( + "4:4:4 PyroWave session with PUNKTFUNK_ZEROCOPY off: zero-copy GPU \ + capture DISABLED — the wavelet encoder loses its raw-dmabuf passthrough \ + and every frame becomes a full-resolution CPU readback plus an upload \ + into its own Vulkan device; expect a materially lower fps ceiling (set \ + PUNKTFUNK_ZEROCOPY=1 to restore the passthrough)" + ); + } else { + tracing::warn!( + "4:4:4 session on the NVENC path without PUNKTFUNK_ZEROCOPY: zero-copy \ + GPU capture DISABLED — every frame is CPU RGB + swscale RGB→YUV444P; \ + expect a lower fps ceiling than 4:2:0 at this mode (set \ + PUNKTFUNK_ZEROCOPY=1 for the GPU 4:4:4 convert)" + ); + } } gpu && !force_cpu_for_nvenc_444 }; diff --git a/crates/punktfunk-host/src/spike.rs b/crates/punktfunk-host/src/spike.rs index 33b1169f..dca0f091 100644 --- a/crates/punktfunk-host/src/spike.rs +++ b/crates/punktfunk-host/src/spike.rs @@ -114,9 +114,21 @@ pub fn run(opts: Options) -> Result<()> { refresh_hz: opts.fps, }) .context("create virtual output")?; + // `resolve` is the shared GameStream/spike constructor and hard-codes `pyrowave: false` + // (GameStream never negotiates it). The spike DOES know its codec, and on Linux that + // flag is what puts the capture on the raw-dmabuf passthrough + // (`ZeroCopyPolicy::pyrowave_session`, set from the same comparison in + // `session_plan::output_format`). Left false, `--codec pyrowave` encoded PyroWave off a + // capture negotiated for somebody else, and the only way to exercise the real path was + // the host-global `PUNKTFUNK_ENCODER=pyrowave` lever — which ALSO flips + // `backend_is_vaapi`, so it cannot reproduce a per-session PyroWave negotiation on an + // auto/NVENC host at all. That is precisely the configuration PW2 exists for. + let mut want = + capture::OutputFormat::resolve(false, crate::encode::resolved_backend_is_gpu()); + want.pyrowave = opts.codec == Codec::PyroWave; capture::capture_virtual_output( vout, - capture::OutputFormat::resolve(false, crate::encode::resolved_backend_is_gpu()), + want, crate::session_plan::CaptureBackend::resolve(), compositor == crate::vdisplay::Compositor::Kwin, ) diff --git a/crates/pyrowave-sys/patches/0005-global-priority-queue.patch b/crates/pyrowave-sys/patches/0005-global-priority-queue.patch index 81e95632..5609243c 100644 --- a/crates/pyrowave-sys/patches/0005-global-priority-queue.patch +++ b/crates/pyrowave-sys/patches/0005-global-priority-queue.patch @@ -17,7 +17,38 @@ VK_ERROR_NOT_PERMITTED_KHR so a refused class NEVER regresses the encoder. Gated NOTE: on an RTX 4090 / Windows / WDDM this did not reduce the spikes (the graphics-vs-compute preemption granularity is the wall) — kept because it is correct, harmless (graceful fallback), and may help other GPUs/drivers. Reduce the encode's GPU cost (4:2:0/8-bit) or use H.265 for a -GPU-saturated game. +GPU-saturated game. **That measurement is Windows/WDDM and does NOT transfer to Linux** — a +different driver stack with a different preemption model. + +MEASURED ON LINUX/NVIDIA 2026-08-08, and it comes out the OTHER WAY: the elevated queue DOES cut +the tail. RTX 5070 Ti (driver 610.57.04), GRID 2 benchmark loop saturating the GPU at 54-87 %, +PyroWave 1080p, same binary in both arms (the only difference is CAP_SYS_NICE, i.e. whether the +class is granted at all), steady-state windows of 30 frames: + + 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 (repeat: p50 ~3.35, p99 ~4.8) + +So on this stack the priority class buys a materially tighter TAIL — p99 down ~30 %, worst frame +roughly halved — at the cost of ~0.6 ms on the median. For a streaming encoder that is the right +side of the trade: the tail is what shows up as a visible hitch. Do NOT delete this patch on the +strength of the RTX 4090/WDDM result above; the two stacks disagree. + +Caveats, so the number is not over-read: the arms were not interleaved and the background game +load drifted between them, capture was frame-starved (~2.5 fps) so this measures encode latency +under contention rather than a full-rate stream, and it is two granted runs against one refused +run. The direction was consistent across all 25 measurement windows. + +NOTE 2 — WHERE THIS PATCH IS ACTUALLY LIVE. It is gated `if (!inherit_info)`, and only the WINDOWS +path leaves `inherit_info` null: `crates/pf-encode/src/enc/windows/pyrowave.rs` calls +`pyrowave_create_device_by_compat`, so Granite builds the device itself and this block runs. +**On LINUX it has never done anything.** `crates/pf-encode/src/enc/linux/pyrowave.rs::open_inner` +passes its own instance/device create-infos into `pyrowave_device_create_info`, Granite's +`MyDeviceFactory::get_existing_create_info()` returns them, `create_device` takes the inherit +branch, and the whole block above is skipped. The Linux request is therefore wired natively in +**`crates/pf-encode/src/enc/linux/pyrowave.rs`** (search `queue_priority_candidates`), which +implements the SAME env grammar and the SAME downgrade ladder so one knob means one thing on both +platforms. If you change the grammar here, change it there in the same commit. diff --git a/crates/pyrowave-sys/vendor/pyrowave/Granite/vulkan/context.cpp b/crates/pyrowave-sys/vendor/pyrowave/Granite/vulkan/context.cpp index 5257fc33..479eeded 100644 diff --git a/crates/pyrowave-sys/vendor/pyrowave/PUNKTFUNK-VENDOR.txt b/crates/pyrowave-sys/vendor/pyrowave/PUNKTFUNK-VENDOR.txt index d145f8e0..3bfe2b24 100644 --- a/crates/pyrowave-sys/vendor/pyrowave/PUNKTFUNK-VENDOR.txt +++ b/crates/pyrowave-sys/vendor/pyrowave/PUNKTFUNK-VENDOR.txt @@ -46,4 +46,9 @@ upstream: realtime) so the wavelet encode can preempt a GPU-bound game on the shared shader cores. A create loop downgrades on NOT_PERMITTED so a refused class never regresses the encoder. Did not overcome the graphics-vs-compute preemption wall on an RTX 4090 (kept: correct + harmless, - may help other HW/drivers). + may help other HW/drivers) — that measurement is Windows/WDDM and does not transfer to Linux. + GATED ON !inherit_info, so it is LIVE ONLY ON THE WINDOWS PATH (pyrowave_create_device_by_compat, + where Granite builds its own device). Linux passes its own create-infos and takes the inherit + branch, so this patch is inert there; the Linux request lives natively in + crates/pf-encode/src/enc/linux/pyrowave.rs (queue_priority_candidates), with the same grammar + and the same downgrade ladder. Change one, change both. diff --git a/docs-site/content/docs/configuration.md b/docs-site/content/docs/configuration.md index 7b305475..e401b8cc 100644 --- a/docs-site/content/docs/configuration.md +++ b/docs-site/content/docs/configuration.md @@ -241,6 +241,7 @@ notes for context. | `PUNKTFUNK_NVENC_SPLIT_ARBITRATE` | `1` | Opt-in: let the host change its split-encode decision **live**, mid-session, as the pixel rate moves, instead of only choosing once at session start. Currently wired on the Linux direct-NVENC path. Only interesting alongside `PUNKTFUNK_SPLIT_ENCODE=auto` at very high pixel rates. | | `PUNKTFUNK_GPU_PRIORITY_CLASS` | `off` · `normal` · `high` · `realtime` · `auto` | **(Windows)** GPU scheduling priority for capture/encode under a GPU-saturating game. Default `auto` (starts `high`, upgrades to `realtime` when it's safe — e.g. HAGS off); `high` pins the static pre-gate behaviour; `realtime` is the strongest lever but can freeze NVENC on some setups. | | `PUNKTFUNK_IDD_DEPTH` | `N` (default `2`) | **(Windows)** IDD-push pipeline depth. `1` cuts latency once GPU priority is raised; higher smooths a contended GPU. | +| `PYROWAVE_QUEUE_PRIORITY` | `realtime` *(default)* · `high` · `off` | [PyroWave](/docs/pyrowave) sessions only. PyroWave encodes on the same GPU shader cores a game uses, so a demanding game can starve it and the frame rate drops. This asks the driver to schedule the encode ahead of the game. `realtime` tries the strongest class and falls back to `high`; `high` asks only for the middle one; `off` disables the request. A driver that refuses simply encodes at normal priority — it can never stop a session starting. The packages grant the host the `CAP_SYS_NICE` capability this needs; on a host built or installed by hand it will be refused, and the host says so once at session start. Set `off` if you see the desktop stutter while streaming. | ## Diagnostics diff --git a/docs-site/content/docs/pyrowave.mdx b/docs-site/content/docs/pyrowave.mdx index ee37c63c..8fcbb745 100644 --- a/docs-site/content/docs/pyrowave.mdx +++ b/docs-site/content/docs/pyrowave.mdx @@ -101,6 +101,27 @@ dropped packets. The stats overlay shows `pyrowave` as the decode path when the mode is active. +## Checking the host is really zero-copy + +On a Linux host the CPU fallback mentioned above is not an error — the session still streams, it +just pays a full-resolution copy of every frame, which shows up as a lower frame-rate ceiling and +higher CPU use rather than as anything obviously broken. The host log states which path a session +took, once, when the capture starts: + +``` +capture pipeline resolved: dmabuf-passthrough → pyrowave +``` + +`dmabuf-passthrough` is the good one: the compositor's buffer goes straight into the wavelet +encoder. `cpu` means the copy is happening, and a second line says why — a compositor that would +not allocate a dmabuf, `PUNKTFUNK_ZEROCOPY` set to `0`, or a per-frame fall-through such as the +compositor serving shared memory after agreeing to dmabufs. Each distinct reason is logged once per +session with a running count, so a persistent downgrade is easy to tell from a hiccup while the +display mode settles. + +If you see `cpu` and did not ask for it, check that `PUNKTFUNK_ZEROCOPY` is unset (it defaults to +on) and read the accompanying line — it names the cause and the fix. + ## Current limits - Linux and Windows hosts; Linux clients (the GTK desktop app and the session client, including diff --git a/docs-site/content/docs/running-as-a-service.md b/docs-site/content/docs/running-as-a-service.md index 750b6857..f510e8fd 100644 --- a/docs-site/content/docs/running-as-a-service.md +++ b/docs-site/content/docs/running-as-a-service.md @@ -205,6 +205,36 @@ the host. If the host answers, it's up. If not, check `journalctl --user -u punktfunk-host` on the host — on a Windows host, run `punktfunk-host service status` from an elevated prompt on the machine itself. +## GPU scheduling priority + +The Linux packages give the host binary one Linux capability, `CAP_SYS_NICE`, and it is worth +knowing why it is there and how to take it away. + +The [PyroWave](/docs/pyrowave) codec encodes on the same GPU shader cores your game is using, so a +demanding game can crowd it out and the stream's frame rate drops with it. The fix is to ask the +driver to schedule the encode ahead of the game, and every driver we tested gates that request on +this capability: without it the request is simply refused and nothing changes. The other codecs use +a separate video engine on the GPU and are unaffected either way. + +`CAP_SYS_NICE` lets a process raise its own scheduling priority. It grants no access to files, +the network or other users' processes, and it is **not** the same as running as root — the host +still runs as you, under your user session. + +To check, or to take it away: + +```sh +getcap /usr/bin/punktfunk-host # shows cap_sys_nice=ep when granted +sudo setcap -r /usr/bin/punktfunk-host # remove it; streaming still works +``` + +Removing it costs you nothing unless you stream PyroWave, and you can also just set +`PYROWAVE_QUEUE_PRIORITY=off` to stop the host asking. Note that a package update replaces the +binary and re-applies the capability. + +Two side effects, if you are debugging the host: a binary carrying a capability is treated as +security-sensitive by the dynamic loader, so `LD_LIBRARY_PATH` and `LD_PRELOAD` are ignored for it, +and it does not write core dumps by default. + ## Stopping and removing After a Linux package update the user service keeps running the old binary until it's restarted, and diff --git a/packaging/arch/punktfunk-host.install b/packaging/arch/punktfunk-host.install index 894da933..2774590e 100644 --- a/packaging/arch/punktfunk-host.install +++ b/packaging/arch/punktfunk-host.install @@ -12,9 +12,33 @@ _ensure_punktfunk_group() { getent group punktfunk >/dev/null 2>&1 || groupadd --system punktfunk 2>/dev/null || true } +# CAP_SYS_NICE on the host binary — the GPU-scheduling grant. +# +# WHY: PyroWave encodes on the GPU's shader cores, so a GPU-bound game starves it (measured: the +# encode dispatch goes from ~2 ms to 15-18 ms at 95 % game load). The fix is an elevated +# global-priority Vulkan queue, which the driver gates on CAP_SYS_NICE — measured 2026-08-08 on an +# RTX 5070 Ti: WITHOUT the capability every priority class is refused, WITH it the encoder is +# granted REALTIME on the first attempt. RADV is the same. Without this line the knob exists and +# does nothing. Same capability, same mechanism, as our gamescope package sets on its own binary. +# +# NARROW: CAP_SYS_NICE only permits raising scheduling priority (nice/ioprio/affinity/RT class). It +# grants no filesystem, network or user-switching privilege, and it is NOT setuid. +# +# TWO CONSEQUENCES worth knowing before you debug something odd on this host: +# * a file capability makes the process AT_SECURE, so the dynamic loader IGNORES LD_LIBRARY_PATH +# and LD_PRELOAD for it. A library-path shim that used to work will silently stop. +# * core dumps are suppressed for capability-carrying binaries by default (fs.suid_dumpable). +# +# Never fails the install: a box without libcap, or a filesystem that cannot store capabilities +# (some overlay/NFS setups), just runs at default priority exactly as before. +_grant_sched_capability() { + setcap 'cap_sys_nice=ep' usr/bin/punktfunk-host 2>/dev/null || true +} + post_install() { _ensure_update_group _ensure_punktfunk_group + _grant_sched_capability udevadm control --reload-rules 2>/dev/null || true udevadm trigger --subsystem-match=misc 2>/dev/null || true # Apply the UDP socket-buffer tuning now (also auto-applied at boot by systemd-sysctl). @@ -73,6 +97,8 @@ post_upgrade() { # root-only, and the virtual Steam Deck pad silently unable to attach. groupadd is idempotent, so # this is a no-op on boxes that installed fresh. _ensure_punktfunk_group + # A replaced binary is a NEW inode — file capabilities do not survive the upgrade, so re-grant. + _grant_sched_capability udevadm control --reload-rules 2>/dev/null || true sysctl -p /usr/lib/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || true _warn_stale_firewall_ports diff --git a/packaging/bazzite/build-sysext.sh b/packaging/bazzite/build-sysext.sh index c88acd62..32c2861b 100644 --- a/packaging/bazzite/build-sysext.sh +++ b/packaging/bazzite/build-sysext.sh @@ -130,6 +130,31 @@ SYSEXT_VERSION_ID=$PF_VR EXTENSION_RELOAD_MANAGER=1 EOF +# CAP_SYS_NICE on the host binary — the GPU-scheduling grant. PyroWave encodes on the GPU shader +# cores a game saturates, and the driver gates the elevated global-priority Vulkan queue that fixes +# it on this capability (measured 2026-08-08 on an RTX 5070 Ti: refused without it, granted REALTIME +# with it; RADV the same). Narrow — scheduling priority only, no filesystem/network privilege, not +# setuid. +# +# It has to be applied HERE, not in the merge hook: a merged sysext's /usr is a read-only squashfs, +# so nothing can setcap it afterwards. And it cannot ride in from the RPM either — the spec declares +# it with %caps, but rpm stores capabilities in its own header and `rpm2cpio | cpio` carries only +# the payload, so the staged file arrives with no capability at all. mksquashfs DOES record +# security.capability (only security.selinux is excluded below), so a setcap on the staging tree is +# what ends up in the image. +# +# Needs CAP_SETFCAP, i.e. root (or fakeroot) — a plain-user CI build cannot do it. That is not fatal: +# the image just ships as it does today and the encode runs at default GPU priority, so warn and +# carry on rather than fail a release build over a performance lever. +if [ -f "$STAGE/usr/bin/punktfunk-host" ]; then + if setcap 'cap_sys_nice=ep' "$STAGE/usr/bin/punktfunk-host" 2>/dev/null; then + echo "granted CAP_SYS_NICE to usr/bin/punktfunk-host (GPU-priority lever active)" + else + echo "WARNING: could not setcap CAP_SYS_NICE (need root/CAP_SETFCAP) — the image will ship" >&2 + echo " without it and PyroWave will encode at default GPU priority." >&2 + fi +fi + # SELinux labels as pseudo-xattrs (see header). matchpathcon resolves each target path against # the targeted policy's file_contexts; <> means "no specific entry" — skip those (the # handful of matches all resolve to real contexts for our payload). diff --git a/packaging/debian/build-deb.sh b/packaging/debian/build-deb.sh index 8ebdaac5..0485d930 100755 --- a/packaging/debian/build-deb.sh +++ b/packaging/debian/build-deb.sh @@ -294,6 +294,16 @@ if [ "$1" = "configure" ]; then # primitive that must not ride on the group users are told to join for gamepads # (security-review 2026-08-05 M-4). getent group punktfunk >/dev/null 2>&1 || addgroup --system punktfunk 2>/dev/null || true + # CAP_SYS_NICE — the GPU-scheduling grant. PyroWave encodes on the shader cores a game + # saturates, and the driver gates the elevated global-priority Vulkan queue that fixes it on + # this capability: measured 2026-08-08 on an RTX 5070 Ti, WITHOUT it every priority class is + # refused and WITH it the encoder is granted REALTIME first try (RADV behaves the same). + # Without this line the knob exists and does nothing. Narrow: it permits raising scheduling + # priority only — no filesystem, network or user-switching privilege, and no setuid. Note a + # capability-carrying binary is AT_SECURE, so the loader ignores LD_LIBRARY_PATH/LD_PRELOAD for + # it and core dumps are suppressed by default. Best-effort: a box without libcap, or a + # filesystem that cannot store capabilities, just runs at default priority as before. + setcap 'cap_sys_nice=ep' /usr/bin/punktfunk-host 2>/dev/null || true # Pick up the /dev/uinput rule without a reboot (best-effort, no-op in containers). udevadm control --reload-rules 2>/dev/null || true udevadm trigger --subsystem-match=misc 2>/dev/null || true diff --git a/packaging/nix/nixos-module.nix b/packaging/nix/nixos-module.nix index d5dcf8a3..8b7c2ae6 100644 --- a/packaging/nix/nixos-module.nix +++ b/packaging/nix/nixos-module.nix @@ -356,6 +356,26 @@ in allowedUDPPorts = nativeUDP ++ optionals cfg.host.gamestream gamestreamUDP; }; + # CAP_SYS_NICE — the GPU-scheduling grant. PyroWave encodes on the GPU shader cores a game + # saturates; the elevated global-priority Vulkan queue that fixes it is gated on this + # capability (measured 2026-08-08, RTX 5070 Ti: without it EVERY priority class is refused, + # with it the encoder gets REALTIME on the first attempt; RADV behaves the same). + # + # NixOS cannot `setcap` a store path — it is read-only and shared — so this goes through + # `security.wrappers`, which builds a small setcap'd wrapper in /run/wrappers/bin. The unit's + # ExecStart points at the wrapper below; everything else about the host is unchanged. + # + # Narrow: CAP_SYS_NICE permits raising scheduling priority only — no filesystem, network or + # user-switching privilege, and the wrapper is capability-based, NOT setuid. Two side effects + # to know: the wrapped binary is AT_SECURE (the loader ignores LD_LIBRARY_PATH/LD_PRELOAD for + # it) and core dumps are suppressed by default. + security.wrappers.punktfunk-host = { + source = "${cfg.host.package}/bin/punktfunk-host"; + capabilities = "cap_sys_nice=ep"; + owner = "root"; + group = "root"; + }; + systemd.user.services.punktfunk-host = { description = "punktfunk GameStream + punktfunk/1 streaming host"; documentation = [ "https://git.unom.io/unom/punktfunk" ]; @@ -374,8 +394,12 @@ in # PUNKTFUNK_GAMESCOPE_BIN so an operator's own override of that env still wins. ++ optional cfg.host.gamescopeHdr cfg.host.gamescopePackage; serviceConfig = { + # Through the wrapper (see `security.wrappers.punktfunk-host` above), NOT the store path + # directly — the store path carries no capability and the GPU-priority lever would be + # inert. `config.security.wrapperDir` rather than a hard-coded /run/wrappers/bin so an + # operator who has moved it is still correct. ExecStart = - "${cfg.host.package}/bin/punktfunk-host serve" + optionalString cfg.host.gamestream " --gamestream"; + "${config.security.wrapperDir}/punktfunk-host serve" + optionalString cfg.host.gamestream " --gamestream"; Restart = "on-failure"; RestartSec = 2; EnvironmentFile = diff --git a/packaging/rpm/punktfunk.spec b/packaging/rpm/punktfunk.spec index d4d3eea9..77361408 100644 --- a/packaging/rpm/punktfunk.spec +++ b/packaging/rpm/punktfunk.spec @@ -477,7 +477,15 @@ install -Dm0644 scripts/punktfunk-scripting.service %{buildroot}%{_userunitdir}/ %files %license LICENSE-MIT LICENSE-APACHE THIRD-PARTY-NOTICES.txt %doc README.md packaging/README.md -%{_bindir}/punktfunk-host +# CAP_SYS_NICE — the GPU-scheduling grant, declared the RPM-native way so rpm applies it at +# install, restores it on upgrade, and VERIFIES it (a plain %post setcap does none of those). +# PyroWave encodes on the shader cores a game saturates; the elevated global-priority Vulkan queue +# that fixes it is gated on this capability. Measured 2026-08-08 on an RTX 5070 Ti: without it +# every priority class is refused, with it the encoder gets REALTIME first try (RADV the same). +# Narrow — scheduling priority only, no filesystem/network/user-switching privilege, not setuid. +# Consequences: the binary becomes AT_SECURE, so the loader ignores LD_LIBRARY_PATH/LD_PRELOAD for +# it, and core dumps are suppressed by default. +%caps(cap_sys_nice=ep) %{_bindir}/punktfunk-host %{_bindir}/punktfunk-tray %{_udevrulesdir}/60-punktfunk.rules %dir %{_libexecdir}/punktfunk diff --git a/scripts/steamdeck/install.sh b/scripts/steamdeck/install.sh index efe7c329..ce462646 100755 --- a/scripts/steamdeck/install.sh +++ b/scripts/steamdeck/install.sh @@ -344,6 +344,25 @@ if [ "$SUDO_OK" = 1 ]; then warn "(everything else works; the pad arrives as a generic Xbox 360 controller). By hand:" warn " sudo groupadd --system punktfunk; sudo usermod -aG punktfunk $USER" fi + # CAP_SYS_NICE on the host binary — the GPU-scheduling grant, and the Deck is the box that + # needs it most: a Van Gogh APU shares one small GPU between the game and PyroWave's encode + # dispatch. The driver gates the elevated global-priority Vulkan queue on this capability + # (measured 2026-08-08 on an RTX 5070 Ti: refused without it, granted REALTIME with it; RADV + # behaves the same), so without this the knob exists and does nothing. + # + # The binary lives under $HOME, not /usr — so unlike the /etc drop-ins above this survives a + # SteamOS A/B update on its own and needs no atomic-keep entry. It DOES need re-applying after + # every rebuild, because a fresh binary is a new inode; re-running this installer does that. + # + # Narrow (scheduling priority only, no filesystem/network privilege, not setuid) and + # best-effort — a failure just means the encode runs at default priority as it does today. + if [ -x "$BIN" ]; then + if sudo setcap 'cap_sys_nice=ep' "$BIN" 2>/dev/null; then + ok "granted CAP_SYS_NICE (PyroWave encode can outrank a GPU-bound game)" + else + warn "could not grant CAP_SYS_NICE to $BIN — PyroWave encode stays at default GPU priority" + fi + fi # SteamOS A/B updates rebuild /etc and DROP everything not on Valve's keep list — verified # live: an OS update stripped the udev rule + vhci autoload + UDP sysctl (gamepads silently # degrade to Xbox 360, buffers back to 208 KB). The sanctioned fix is a preserve drop-in in