From d2daeacc60bfcfcdce413dbcb1b381026cf155ba Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 19 Jul 2026 15:57:12 +0200 Subject: [PATCH] fix(pyrowave): per-session raw-dmabuf zero-copy capture on the Linux NVIDIA host A PyroWave session on an NVIDIA-auto host was forced onto CPU-RGB capture (session_plan flipped gpu=false): Mutter blits tiled->LINEAR, we mmap + de-pad ~30 MB, the encoder re-uploads it - three full-frame CPU touches per frame at 5120x1440 while an HEVC session on the same box rides the tiled EGL/CUDA zero-copy. The dmabuf passthrough + Vulkan tiled import were already validated (8dc5d672) but only reachable via the global PUNKTFUNK_ENCODER=pyrowave lab policy. ZeroCopyPolicy gains pyrowave_session (from OutputFormat.pyrowave, i.e. the negotiated codec): the capturer skips the NVENC-only EGL->CUDA importer, takes the raw-dmabuf passthrough, and advertises the wavelet encoder's Vulkan-importable modifiers so Mutter+NVIDIA negotiates tiled zero-copy. The forced-CPU flip in session_plan is gone. Co-Authored-By: Claude Fable 5 --- crates/pf-capture/src/lib.rs | 7 +++- crates/pf-capture/src/linux/mod.rs | 39 +++++++++++++---------- crates/punktfunk-host/src/capture.rs | 39 +++++++++++++++-------- crates/punktfunk-host/src/session_plan.rs | 34 ++++++-------------- 4 files changed, 65 insertions(+), 54 deletions(-) diff --git a/crates/pf-capture/src/lib.rs b/crates/pf-capture/src/lib.rs index 02cd1c02..2157370f 100644 --- a/crates/pf-capture/src/lib.rs +++ b/crates/pf-capture/src/lib.rs @@ -244,8 +244,13 @@ pub struct ZeroCopyPolicy { /// The resolved backend produces GPU-resident frames (everything but the software encoder) — /// used only to phrase the CPU-fallback warning (the host `encode::resolved_backend_is_gpu`). pub backend_is_gpu: bool, + /// THIS session encodes PyroWave: the frames' consumer is the wavelet encoder's own Vulkan + /// device, which imports raw dmabufs on ANY vendor — so the capturer takes the raw-dmabuf + /// passthrough (like the VAAPI backend) instead of the EGL→CUDA import whose payloads only + /// NVENC can consume. Per-session (the codec is negotiated), unlike `backend_is_vaapi`. + pub pyrowave_session: bool, /// The PyroWave encoder's Vulkan-importable dmabuf modifiers for the capture's packed-RGB fourcc, - /// resolved when the encoder pref is `pyrowave` (the passthrough advertises them so Mutter+NVIDIA, + /// resolved when the session encodes PyroWave (the passthrough advertises them so Mutter+NVIDIA, /// which allocates tiled-only, still negotiates zero-copy). Empty otherwise. pub pyrowave_modifiers: Vec, } diff --git a/crates/pf-capture/src/linux/mod.rs b/crates/pf-capture/src/linux/mod.rs index e4c72288..ca340e5c 100644 --- a/crates/pf-capture/src/linux/mod.rs +++ b/crates/pf-capture/src/linux/mod.rs @@ -255,9 +255,11 @@ fn spawn_pipewire( want_hdr }; // Mirror of the thread's `vaapi_passthrough` decision (deterministic from here: on a VAAPI - // backend the EGL→CUDA importer is never built) — kept on the capturer so `next_frame`'s - // negotiation-timeout branch knows a failed negotiation was the LINEAR-dmabuf offer. - let vaapi_dmabuf = zerocopy && !force_shm && policy.backend_is_vaapi; + // backend or a PyroWave session the EGL→CUDA importer is never built) — kept on the capturer + // so `next_frame`'s negotiation-timeout branch knows a failed negotiation was the raw-dmabuf + // passthrough offer. + let vaapi_dmabuf = + zerocopy && !force_shm && (policy.backend_is_vaapi || policy.pyrowave_session); let join = thread::Builder::new() .name("punktfunk-pipewire".into()) .spawn(move || { @@ -1945,16 +1947,18 @@ mod pipewire { // Build the GPU importer up front — normally the ISOLATED worker process // (design/zerocopy-worker-isolation.md), so a driver fault on a dying compositor's // dmabuf kills the worker, not this host. If it fails, log and fall back to the CPU path - // (we simply won't request dmabuf below). Skipped entirely when the encode backend is - // VAAPI: those frames go to the raw-dmabuf passthrough, and building the importer there - // would waste a CUDA probe — or worse, on an NVIDIA box forced to PUNKTFUNK_ENCODER=vaapi, - // succeed and produce CUDA payloads the VAAPI encoder must reject. Also skipped once - // repeated worker deaths latched the import off (a wedged GPU stack must not crash-loop). + // (we simply won't request dmabuf below). Skipped entirely when the frames go to the + // raw-dmabuf passthrough — the encode backend is VAAPI, or the SESSION encodes PyroWave + // (its Vulkan device imports raw dmabufs on any vendor): building the importer there + // would waste a CUDA probe — or worse, succeed and produce CUDA payloads only NVENC can + // consume. Also skipped once repeated worker deaths latched the import off (a wedged GPU + // stack must not crash-loop). let backend_is_vaapi = policy.backend_is_vaapi; + let raw_passthrough = backend_is_vaapi || policy.pyrowave_session; // HDR never builds the EGL→CUDA importer: its de-tile blit renders into 8-bit RGBA8, // which would silently crush the 10-bit depth. The HDR consumers are the CPU mmap path // (LINEAR de-pad → X2Rgb10 CPU frames) and the VAAPI raw-dmabuf passthrough. - let mut importer = if zerocopy && !backend_is_vaapi && !want_hdr { + let mut importer = if zerocopy && !raw_passthrough && !want_hdr { if pf_zerocopy::gpu_import_disabled() { tracing::warn!( "zero-copy GPU import disabled after repeated import-worker deaths — using CPU path" @@ -1980,9 +1984,11 @@ mod pipewire { // host. KWin/gamescope don't need it (they blit into the buffer, so no read-before-render // race). let force_shm = std::env::var("PUNKTFUNK_FORCE_SHM").as_deref() == Ok("1"); - // VAAPI zero-copy passthrough: zero-copy on, no EGL→CUDA importer (any non-NVIDIA host), and - // the encoder backend is VAAPI → hand the raw dmabuf to the encoder (it imports + GPU-CSCs). - let vaapi_passthrough = zerocopy && !force_shm && importer.is_none() && backend_is_vaapi; + // Raw-dmabuf zero-copy passthrough: zero-copy on, no EGL→CUDA importer, and the frames' + // consumer imports raw dmabufs itself — the VAAPI backend (libva import + GPU CSC) or a + // PyroWave session (the wavelet encoder's own Vulkan device, any vendor) → hand the raw + // dmabuf straight to the encoder. + let vaapi_passthrough = zerocopy && !force_shm && importer.is_none() && raw_passthrough; // Modifiers our import stack handles for BGRx: the EGL-importable (tiled) set, plus LINEAR // (0) — NVIDIA's EGL won't list it, but LINEAR dmabufs (gamescope's only offer) import via // CUDA external memory instead. For the VAAPI passthrough path we advertise LINEAR only: @@ -1998,8 +2004,9 @@ mod pipewire { // advertisement with every modifier its device samples from, so compositors that // never allocate LINEAR (Mutter+NVIDIA) still negotiate zero-copy dmabufs. The modifiers // were resolved by the facade (`ZeroCopyPolicy::pyrowave_modifiers`) — non-empty only when - // the host's `pyrowave` feature is on AND the encoder pref is `pyrowave` — so capture never - // calls back into `encode` and needs no feature gate of its own (the emptiness check gates it). + // the host's `pyrowave` feature is on AND the session (or the global encoder pref) is + // PyroWave — so capture never calls back into `encode` and needs no feature gate of its + // own (the emptiness check gates it). if vaapi_passthrough && !policy.pyrowave_modifiers.is_empty() { for &m in &policy.pyrowave_modifiers { if !modifiers.contains(&m) { @@ -2019,11 +2026,11 @@ mod pipewire { ); } else if zerocopy && !want_dmabuf { tracing::warn!("zero-copy: no importable dmabuf modifiers — using CPU path"); - } else if vaapi_passthrough { + } else if vaapi_passthrough && policy.pyrowave_modifiers.is_empty() { tracing::info!( "zero-copy: advertising LINEAR dmabuf for direct VAAPI import (GPU CSC)" ); - } else if want_dmabuf { + } else if want_dmabuf && !vaapi_passthrough { tracing::info!( count = modifiers.len(), sample = ?&modifiers[..modifiers.len().min(6)], diff --git a/crates/punktfunk-host/src/capture.rs b/crates/punktfunk-host/src/capture.rs index 2a6ca074..f8aa5374 100644 --- a/crates/punktfunk-host/src/capture.rs +++ b/crates/punktfunk-host/src/capture.rs @@ -26,24 +26,35 @@ pub use pf_capture::{dxgi, synthetic_nv12}; /// capture→encode cycle). Resolved here (the host facade) and threaded in, so the edge stays one-way /// (plan §2.4 / §W6). #[cfg(target_os = "linux")] -fn zero_copy_policy() -> pf_capture::ZeroCopyPolicy { +fn zero_copy_policy(pyrowave_session: bool) -> pf_capture::ZeroCopyPolicy { let backend_is_vaapi = crate::encode::linux_zero_copy_is_vaapi(); + // The raw-dmabuf passthrough serves a PyroWave session on ANY vendor (the wavelet encoder's + // own Vulkan device imports the dmabuf) — per-session from the negotiated codec, plus the + // global `PUNKTFUNK_ENCODER=pyrowave` lab lever (which also flips `backend_is_vaapi`). #[cfg(feature = "pyrowave")] - let pyrowave_modifiers = - if backend_is_vaapi && pf_host_config::config().encoder_pref.as_str() == "pyrowave" { - // BGRx is the capture path's canonical packed-RGB format (the modifier advertisement keys - // on it). `drm_fourcc(Bgrx)` is always `Some`. - pf_frame::drm_fourcc(PixelFormat::Bgrx) - .map(crate::encode::pyrowave_capture_modifiers) - .unwrap_or_default() - } else { - Vec::new() - }; + let pyrowave_session = + pyrowave_session || pf_host_config::config().encoder_pref.as_str() == "pyrowave"; + #[cfg(not(feature = "pyrowave"))] + let pyrowave_session = { + let _ = pyrowave_session; + false + }; + #[cfg(feature = "pyrowave")] + let pyrowave_modifiers = if pyrowave_session { + // BGRx is the capture path's canonical packed-RGB format (the modifier advertisement keys + // on it). `drm_fourcc(Bgrx)` is always `Some`. + pf_frame::drm_fourcc(PixelFormat::Bgrx) + .map(crate::encode::pyrowave_capture_modifiers) + .unwrap_or_default() + } else { + Vec::new() + }; #[cfg(not(feature = "pyrowave"))] let pyrowave_modifiers = Vec::new(); pf_capture::ZeroCopyPolicy { backend_is_vaapi, backend_is_gpu: crate::encode::resolved_backend_is_gpu(), + pyrowave_session, pyrowave_modifiers, } } @@ -57,7 +68,9 @@ pub fn open_portal_monitor(want_hdr: bool) -> Result> { // session so it inherits that grant headlessly; wlroots/Sway has no RemoteDesktop portal, // so use a plain ScreenCast session there. let anchored = crate::inject::default_backend() == crate::inject::Backend::Libei; - pf_capture::open_portal_monitor(anchored, want_hdr, zero_copy_policy()) + // Monitor mirrors never carry the native PyroWave plane (GameStream protocol) — per-session + // passthrough is virtual-output-only; the global encoder-pref lever still applies inside. + pf_capture::open_portal_monitor(anchored, want_hdr, zero_copy_policy(false)) } #[cfg(not(target_os = "linux"))] @@ -88,7 +101,7 @@ pub fn capture_virtual_output( vout.keepalive, want.gpu, want.chroma_444, - zero_copy_policy(), + zero_copy_policy(want.pyrowave), ) } diff --git a/crates/punktfunk-host/src/session_plan.rs b/crates/punktfunk-host/src/session_plan.rs index f34dafda..07471e3b 100644 --- a/crates/punktfunk-host/src/session_plan.rs +++ b/crates/punktfunk-host/src/session_plan.rs @@ -155,36 +155,22 @@ impl SessionPlan { } gpu && !force_cpu_for_nvenc_444 }; - // PyroWave on an NVIDIA-auto host: the `gpu` capture path resolves to the EGL→CUDA - // import that only NVENC can consume — the wavelet backend ingests raw dmabufs - // (the AMD/Intel path) or CPU RGB. Flip THIS session to CPU RGB capture; the - // Phase-2 exit sessions ran exactly this shape at 60 fps (the encode itself stays - // sub-ms GPU compute). Per-session raw-dmabuf passthrough on NVIDIA (true - // zero-copy without the PUNKTFUNK_ENCODER=pyrowave capture policy) is the - // follow-up; the AMD/Intel dmabuf path is untouched. - #[cfg(target_os = "linux")] - let gpu = { - let pyro_needs_cpu = self.codec == crate::encode::Codec::PyroWave - && !crate::encode::linux_zero_copy_is_vaapi(); - if gpu && pyro_needs_cpu { - tracing::info!( - "PyroWave session on the NVIDIA capture path: GPU (CUDA) capture disabled \ - for this session — frames arrive as CPU RGB and upload to the wavelet \ - encoder (raw-dmabuf zero-copy on NVIDIA is a follow-up)" - ); - } - gpu && !pyro_needs_cpu - }; + // PyroWave on Linux keeps `gpu = true`: the capture facade sees `pyrowave` below and + // routes the session onto the raw-dmabuf passthrough (the wavelet encoder's own Vulkan + // device imports the compositor's dmabuf on ANY vendor — `ZeroCopyPolicy::pyrowave_session` + // advertises its importable modifiers, so Mutter+NVIDIA negotiates tiled zero-copy instead + // of the old forced CPU-RGB readback). The EGL→CUDA importer is skipped there — its + // payloads only NVENC consumes. crate::capture::OutputFormat { gpu, hdr: self.hdr, // 4:4:4 needs a full-chroma source: on Windows this keeps the capturer on RGB (not the // default NV12/P010 video-engine output) so NVENC can CSC to 4:4:4. chroma_444: self.chroma.is_444(), - // PyroWave (Windows): the IDD-push capturer makes its NV12 out-ring shareable + signals a - // shared fence so the wavelet encoder can zero-copy-import the texture into its own Vulkan - // device. Inert on Linux (the wavelet backend ingests dmabufs / CPU RGB there — handled - // by the `gpu` flips above, not this flag). + // PyroWave: on Windows the IDD-push capturer makes its NV12 out-ring shareable + signals + // a shared fence so the wavelet encoder can zero-copy-import the texture into its own + // Vulkan device; on Linux the capture facade flips the zero-copy policy to the + // raw-dmabuf passthrough (see above). pyrowave: self.codec == crate::encode::Codec::PyroWave, } }