Merge branch 'worktree-wave2-pw3-dmabuf-latch' into worktree-wave2-pyrowave

# Conflicts:
#	packaging/arch/punktfunk-host.install
#	scripts/steamdeck/install.sh
This commit is contained in:
2026-08-09 01:13:23 +02:00
18 changed files with 1634 additions and 147 deletions
+23
View File
@@ -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(
+736 -100
View File
@@ -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<Option<u64>> {
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<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
@@ -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}"
);
}
}
}
+303 -6
View File
@@ -321,6 +321,15 @@ struct DeviceHold {
instance_ci: Box<vk::InstanceCreateInfo<'static>>,
_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<vk::DeviceCreateInfo<'static>>,
}
/// 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<vk::QueueGlobalPriorityKHR> {
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<u32>,
perf_logged_at: Option<std::time::Instant>,
/// 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::<u64>() / 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));
}
}
+322 -28
View File
@@ -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<u32> {
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<u32> {
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 EGLCUDA 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 EGLCUDA 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");
}
}
+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 RGBYUV444P; 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 RGBYUV444P; \
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
};
+13 -1
View File
@@ -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,
)
@@ -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
+6 -1
View File
@@ -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.
+1
View File
@@ -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
+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
@@ -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
+26
View File
@@ -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
+25
View File
@@ -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; <<none>> means "no specific entry" — skip those (the
# handful of matches all resolve to real contexts for our payload).
+10
View File
@@ -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
+25 -1
View File
@@ -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 =
+9 -1
View File
@@ -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
+19
View File
@@ -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