feat(pf-capture): a PyroWave session could drop to CPU capture and log nothing at all

Wave-2 PW2 (design/linux-host-performance-wave2-pyrowave.md). Observability only — no
behaviour change to any capture decision — and it lands first because every later package
in the program is measured by an A/B whose "before" is currently unreadable.

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

Four sites, matching PW2.1-2.4:

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

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

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

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

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

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

Gates on .21 at CI parity: fmt, workspace clippy -D warnings, pf-encode clippy with
nvenc,vulkan-encode,pyrowave and without, workspace tests.
This commit is contained in:
2026-08-08 13:36:51 +02:00
parent 27ceab2f6c
commit 2aa763ce70
4 changed files with 561 additions and 107 deletions
+507 -99
View File
@@ -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<u64> {
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());
}
}
+10 -2
View File
@@ -890,7 +890,13 @@ fn parse_spike(args: &[String]) -> Result<Options> {
"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 <N> capture duration in seconds (default: 5)
--fps <N> target frame rate (default: 60)
--codec <h264|h265|av1> NVENC codec (default: h265)
--codec <h264|h265|av1|pyrowave>
encode codec (default: h265). 'pyrowave' also wants
PUNKTFUNK_ENCODER=pyrowave so capture takes the passthrough
--bitrate <MBPS> target bitrate in Mbps (default: 20)
--width <W> --height <H> synthetic source size (default: 1920x1080)
--out <PATH> raw Annex-B output (default: /tmp/punktfunk-spike.<ext>)
+23 -6
View File
@@ -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
};
+21
View File
@@ -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