diff --git a/crates/pf-capture/src/linux/pipewire.rs b/crates/pf-capture/src/linux/pipewire.rs index 37b17757..bae057df 100644 --- a/crates/pf-capture/src/linux/pipewire.rs +++ b/crates/pf-capture/src/linux/pipewire.rs @@ -70,6 +70,10 @@ struct UserData { linear_nv12_failed: bool, /// Rate-limit counter for the latest-frame-only diagnostic log (see `.process`). dbg_log_n: u64, + /// 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 +243,210 @@ 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", + } + } +} + +/// 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 @@ -374,102 +582,129 @@ fn consume_frame(ud: &mut UserData, spa_buf: *mut spa::sys::spa_buffer) { // 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 +1147,25 @@ 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(), + "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 +1196,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 +1255,7 @@ pub fn pipewire_thread( yuv444: want_444, linear_nv12_failed: false, dbg_log_n: 0, + passthrough_fallbacks: PassthroughFallbacks::default(), cursor: CursorState::new(cursor_id0_hides), expect_dims: if expect_exact_dims { preferred.map(|(w, h, _)| (w, h)) @@ -1708,4 +1978,142 @@ 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, PassthroughFallback, + PassthroughFallbacks, + }; + + /// 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()); + } } 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/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