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:
@@ -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(
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 EGL→CUDA import path is NOT affected)"
|
||||
);
|
||||
match RAW_DMABUF.note_negotiation_timeout() {
|
||||
Some(streak) => tracing::warn!(
|
||||
streak,
|
||||
"zero-copy raw-dmabuf passthrough disabled: the compositor did not accept the \
|
||||
dmabuf-only capture offer {streak} builds in a row, so later captures negotiate the \
|
||||
CPU path instead of repeating that timeout (the EGL→CUDA import path is NOT \
|
||||
affected). A new capture (different node / compositor) clears this."
|
||||
),
|
||||
None => tracing::warn!(
|
||||
"the compositor did not accept the dmabuf-only capture offer — retrying dmabuf on the \
|
||||
next capture build before giving up on it"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// True once repeated encoder import failures latched the raw-dmabuf passthrough off (see
|
||||
/// [`note_raw_dmabuf_import_failure`]).
|
||||
/// Record a capture whose dmabuf offer negotiated — spends none of the retry budget.
|
||||
pub fn note_raw_dmabuf_negotiation_ok() {
|
||||
RAW_DMABUF.note_negotiation_ok();
|
||||
}
|
||||
|
||||
/// Tell the latch which capture is about to be built, so a verdict formed against a DIFFERENT
|
||||
/// compositor/node is not inherited. Returns `true` if a latch was cleared by the change.
|
||||
pub fn note_raw_dmabuf_capture(identity: u64) -> bool {
|
||||
let cleared = RAW_DMABUF.observe_capture(identity);
|
||||
if cleared {
|
||||
tracing::info!(
|
||||
identity,
|
||||
"zero-copy raw-dmabuf passthrough re-armed: this is a different capture from the one \
|
||||
that failed, so it gets a fresh dmabuf attempt"
|
||||
);
|
||||
}
|
||||
cleared
|
||||
}
|
||||
|
||||
/// True while either cause holds the raw-dmabuf passthrough off (see [`RawDmabufLatch`]).
|
||||
pub fn raw_dmabuf_import_disabled() -> bool {
|
||||
RAW_DMABUF_DISABLED.load(Ordering::Relaxed)
|
||||
RAW_DMABUF.disabled()
|
||||
}
|
||||
|
||||
/// Which cause holds the passthrough off, for the session-open diagnostic line.
|
||||
pub fn raw_dmabuf_latch_state() -> &'static str {
|
||||
RAW_DMABUF.state()
|
||||
}
|
||||
|
||||
/// The EGL→CUDA twin of the raw-passthrough negotiation latch: the capture advertised the GPU
|
||||
@@ -564,4 +731,131 @@ mod tests {
|
||||
note_gpu_import_death(); // third consecutive death
|
||||
assert!(gpu_import_disabled());
|
||||
}
|
||||
|
||||
// ---- PW3: the raw-dmabuf latch's two lifetimes ------------------------------------------
|
||||
//
|
||||
// Against a LOCAL `RawDmabufLatch`, never the process-wide static: these assertions are about
|
||||
// the state machine, and sharing one global across a test binary's threads is how a latch test
|
||||
// becomes order-dependent.
|
||||
|
||||
/// The expensive cause stays sticky. A driver that cannot import what this compositor
|
||||
/// allocates refuses identically every time, and the encode-stall recovery cannot tell that
|
||||
/// from a transient — this latch is what stops it rebuilding the same doomed encoder forever.
|
||||
#[test]
|
||||
fn import_failures_latch_and_stay_latched() {
|
||||
let l = RawDmabufLatch::new();
|
||||
assert!(!l.disabled());
|
||||
assert_eq!(l.note_import_failure(), None); // 1
|
||||
assert_eq!(l.note_import_failure(), None); // 2
|
||||
assert!(!l.disabled(), "must not latch before the streak completes");
|
||||
assert_eq!(l.note_import_failure(), Some(3));
|
||||
assert!(l.disabled());
|
||||
// Only the FIRST crossing reports, so the error line cannot repeat per frame.
|
||||
assert_eq!(l.note_import_failure(), None);
|
||||
// A success resets the streak but must NOT unlatch: once capture moved to CPU frames there
|
||||
// are no more dmabuf imports, so an "ok" here would be about something else entirely.
|
||||
l.note_import_ok();
|
||||
assert!(l.disabled());
|
||||
}
|
||||
|
||||
/// A run of failures broken by a success spends none of the budget — the streak is
|
||||
/// consecutive-only, which is what makes an occasional failure survivable.
|
||||
#[test]
|
||||
fn a_success_breaks_the_import_streak() {
|
||||
let l = RawDmabufLatch::new();
|
||||
l.note_import_failure();
|
||||
l.note_import_failure();
|
||||
l.note_import_ok();
|
||||
assert_eq!(l.note_import_failure(), None, "streak restarted at 1");
|
||||
assert_eq!(l.note_import_failure(), None);
|
||||
assert!(!l.disabled());
|
||||
assert_eq!(l.note_import_failure(), Some(3));
|
||||
}
|
||||
|
||||
/// The cheap cause gets a retry. This is the behaviour change PW3 exists for: one timeout used
|
||||
/// to mean CPU capture for the rest of the host's life, on every compositor and every node.
|
||||
#[test]
|
||||
fn a_negotiation_timeout_is_retried_before_it_latches() {
|
||||
let l = RawDmabufLatch::new();
|
||||
assert_eq!(l.note_negotiation_timeout(), None, "first one retries");
|
||||
assert!(
|
||||
!l.disabled(),
|
||||
"the next capture build must still be allowed to try dmabuf"
|
||||
);
|
||||
assert_eq!(l.note_negotiation_timeout(), Some(2));
|
||||
assert!(l.disabled());
|
||||
assert_eq!(l.note_negotiation_timeout(), None, "reports once");
|
||||
}
|
||||
|
||||
/// A capture that negotiates credits the budget back, so a compositor that fails once and then
|
||||
/// works never accumulates its way to a latch across an evening of reconnects.
|
||||
#[test]
|
||||
fn a_negotiated_capture_credits_the_retry_budget() {
|
||||
let l = RawDmabufLatch::new();
|
||||
for _ in 0..10 {
|
||||
assert_eq!(l.note_negotiation_timeout(), None);
|
||||
l.note_negotiation_ok();
|
||||
}
|
||||
assert!(!l.disabled());
|
||||
}
|
||||
|
||||
/// A different capture is a different question. New node id (fresh virtual output, compositor
|
||||
/// restart, the Bazzite Gaming↔Desktop switch) clears BOTH causes — the same capture does not.
|
||||
#[test]
|
||||
fn a_new_capture_identity_clears_the_latch_and_the_same_one_does_not() {
|
||||
let l = RawDmabufLatch::new();
|
||||
// Nothing is latched yet, so observing a new capture re-arms NOTHING — that is what the
|
||||
// return value means, and it is why a healthy session open logs no re-arm line.
|
||||
assert!(
|
||||
!l.observe_capture(7),
|
||||
"nothing was latched, nothing re-armed"
|
||||
);
|
||||
assert!(!l.observe_capture(7), "same capture, no clear");
|
||||
for _ in 0..RAW_DMABUF_FAILURE_LATCH {
|
||||
l.note_import_failure();
|
||||
}
|
||||
assert!(l.disabled());
|
||||
assert!(
|
||||
!l.observe_capture(7),
|
||||
"the SAME capture must keep its verdict — this is the 10s-stall hazard the latch exists for"
|
||||
);
|
||||
assert!(l.disabled());
|
||||
assert!(l.observe_capture(9), "a different node re-arms it");
|
||||
assert!(!l.disabled());
|
||||
// ...and the streaks reset with it, so the fresh attempt gets a full budget.
|
||||
assert_eq!(l.note_import_failure(), None);
|
||||
}
|
||||
|
||||
/// The negotiation latch is keyed the same way — a compositor restart must not inherit the
|
||||
/// previous one's timeout verdict.
|
||||
#[test]
|
||||
fn a_new_capture_identity_clears_the_negotiation_latch_too() {
|
||||
let l = RawDmabufLatch::new();
|
||||
l.observe_capture(1);
|
||||
l.note_negotiation_timeout();
|
||||
l.note_negotiation_timeout();
|
||||
assert!(l.disabled());
|
||||
assert!(l.observe_capture(2));
|
||||
assert!(!l.disabled());
|
||||
}
|
||||
|
||||
/// The session-open line has to name WHICH cause holds it off — "cpu because nothing here
|
||||
/// does dmabuf" and "cpu because something failed earlier" are different bugs.
|
||||
#[test]
|
||||
fn latch_state_names_the_cause() {
|
||||
let l = RawDmabufLatch::new();
|
||||
assert_eq!(l.state(), "live");
|
||||
l.note_negotiation_timeout();
|
||||
l.note_negotiation_timeout();
|
||||
assert_eq!(l.state(), "latched: negotiation timeouts");
|
||||
let l = RawDmabufLatch::new();
|
||||
for _ in 0..RAW_DMABUF_FAILURE_LATCH {
|
||||
l.note_import_failure();
|
||||
}
|
||||
assert_eq!(l.state(), "latched: encoder-import failures");
|
||||
for _ in 0..RAW_DMABUF_NEGOTIATION_LATCH {
|
||||
l.note_negotiation_timeout();
|
||||
}
|
||||
assert_eq!(l.state(), "latched: encoder-import + negotiation");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>)
|
||||
|
||||
@@ -193,12 +193,29 @@ impl SessionPlan {
|
||||
// Surface the trade loudly: this is the single biggest per-frame cost a 4:4:4
|
||||
// session adds (full-res CPU readback + swscale RGB→YUV444P every frame), and
|
||||
// it looks like an unexplained fps ceiling if you don't know it happened.
|
||||
tracing::warn!(
|
||||
"4:4:4 session on the NVENC path without PUNKTFUNK_ZEROCOPY: zero-copy GPU \
|
||||
capture DISABLED — every frame is CPU RGB + swscale RGB→YUV444P; expect a \
|
||||
lower fps ceiling than 4:2:0 at this mode (set PUNKTFUNK_ZEROCOPY=1 for the \
|
||||
GPU 4:4:4 convert)"
|
||||
);
|
||||
//
|
||||
// Name the SESSION's codec, not the backend the gate is named after. The gate
|
||||
// keys on `linux_zero_copy_is_vaapi()`, which reads the host-global encoder pref
|
||||
// — so a per-session PyroWave negotiation on an NVENC/auto host lands here and
|
||||
// was told it was "on the NVENC path", which is false in every particular: the
|
||||
// wavelet encoder never touches NVENC, never swscales to YUV444P, and what it
|
||||
// actually loses is the raw-dmabuf passthrough its whole design assumes.
|
||||
if self.codec == crate::encode::Codec::PyroWave {
|
||||
tracing::warn!(
|
||||
"4:4:4 PyroWave session with PUNKTFUNK_ZEROCOPY off: zero-copy GPU \
|
||||
capture DISABLED — the wavelet encoder loses its raw-dmabuf passthrough \
|
||||
and every frame becomes a full-resolution CPU readback plus an upload \
|
||||
into its own Vulkan device; expect a materially lower fps ceiling (set \
|
||||
PUNKTFUNK_ZEROCOPY=1 to restore the passthrough)"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"4:4:4 session on the NVENC path without PUNKTFUNK_ZEROCOPY: zero-copy \
|
||||
GPU capture DISABLED — every frame is CPU RGB + swscale RGB→YUV444P; \
|
||||
expect a lower fps ceiling than 4:2:0 at this mode (set \
|
||||
PUNKTFUNK_ZEROCOPY=1 for the GPU 4:4:4 convert)"
|
||||
);
|
||||
}
|
||||
}
|
||||
gpu && !force_cpu_for_nvenc_444
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user