perf(encode/nvenc-linux): LN3 — pipelined-retrieve escalation replaces the depth-1 async foot-gun
apple / swift (push) Successful in 1m24s
apple / screenshots (push) Successful in 5m16s
windows-host / package (push) Successful in 10m11s
ci / web (push) Successful in 59s
ci / docs-site (push) Successful in 1m40s
ci / bench (push) Successful in 5m13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
decky / build-publish (push) Successful in 19s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
android / android (push) Successful in 17m6s
arch / build-publish (push) Successful in 17m27s
deb / build-publish (push) Successful in 12m39s
deb / build-publish-host (push) Successful in 9m33s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 7m27s
ci / rust (push) Successful in 27m10s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m1s
docker / deploy-docs (push) Successful in 17s

PUNKTFUNK_NVENC_ASYNC gains a real tri-state: 1 = always (as before, now
documented as ~+1 tick at depth-1), 0 = never (vetoes escalation), unset =
ADAPTIVE — off until the session loop's cadence-overrun detector escalates.

The host loop's adaptive-depth leaky bucket grows a second stage: once the
capturer's depth is maxed (Linux portal is permanently depth-1), it asks
the encoder for pipelined retrieve via the new Encoder::set_pipelined hook
(asked exactly once; default impl declines, Windows untouched).

nvenc_cuda engages at a safe point via a clean session rebuild WITHOUT the
IO-stream binding: with input==output stream bound, later stream work
waits on prior encode completions and would serialize a pipelined session
— stream-ordered submit and two-thread retrieve are mutually exclusive.
The ordered gate now also requires async_rt absence (belt-and-braces for
the runtime switch). Re-open's first frame is the standard session IDR.

On-hardware test: escalate mid-session → retrieve thread live, binding
gone, all AUs deliver, first post-escalation AU is the IDR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 20:05:31 +02:00
parent 256244430b
commit ae67315804
3 changed files with 187 additions and 23 deletions
+9
View File
@@ -269,6 +269,15 @@ pub trait Encoder: Send {
fn invalidate_ref_frames(&mut self, _first_frame: i64, _last_frame: i64) -> bool { fn invalidate_ref_frames(&mut self, _first_frame: i64, _last_frame: i64) -> bool {
false false
} }
/// Escalate into a pipelined (two-thread) retrieve mode under sustained GPU contention — the
/// encoder analog of the capturer depth escalation: AUs ride ~one loop tick behind (`poll`
/// may return `None` while an encode is in flight) in exchange for capture/submit no longer
/// serializing on the encode wait. Returns whether pipelined retrieve is (now) active; the
/// switch may be deferred to the next safe point internally. `false` from the default impl =
/// unsupported — the session loop stops asking. De-escalation is a v2 item everywhere.
fn set_pipelined(&mut self, _on: bool) -> bool {
false
}
/// Pull the next encoded AU if one is ready. /// Pull the next encoded AU if one is ready.
fn poll(&mut self) -> Result<Option<EncodedFrame>>; fn poll(&mut self) -> Result<Option<EncodedFrame>>;
/// Tear the underlying hardware encoder down and rebuild it in place, keeping the session's /// Tear the underlying hardware encoder down and rebuild it in place, keeping the session's
+145 -12
View File
@@ -25,8 +25,11 @@
//! with NO per-frame `cuStreamSynchronize` and the encode orders after them on the stream — //! with NO per-frame `cuStreamSynchronize` and the encode orders after them on the stream —
//! the submit path's CPU stalls are gone even though the copy itself remains. //! the submit path's CPU stalls are gone even though the copy itself remains.
//! //!
//! **Two-thread retrieve** (`PUNKTFUNK_NVENC_ASYNC=1`, the same opt-in knob as the Windows //! **Two-thread retrieve** (`PUNKTFUNK_NVENC_ASYNC`: `1` = always, `0` = never, unset =
//! backend — gpu-contention plan §5.B, latency plan T2.2): NVENC *async mode* //! **adaptive** — engaged by the session loop's contention escalation via
//! [`Encoder::set_pipelined`] when depth-1 can't hold cadence; at depth-1 it costs ~one loop
//! tick of latency, which is why it is not simply on. gpu-contention plan §5.B, latency plan
//! T2.2/§7 LN3): NVENC *async mode*
//! (`enableEncodeAsync` + completion events) is Windows-only, so the session here stays SYNC — //! (`enableEncodeAsync` + completion events) is Windows-only, so the session here stays SYNC —
//! but the NVENC guide's threading model still applies: the main thread should only *submit* //! but the NVENC guide's threading model still applies: the main thread should only *submit*
//! while a secondary thread does the (blocking) `nvEncLockBitstream`. With the flag set, an //! while a secondary thread does the (blocking) `nvEncLockBitstream`. With the flag set, an
@@ -237,15 +240,25 @@ fn load_api() -> std::result::Result<EncodeApi, String> {
/// bitstream/ring slot is never reused mid-encode. /// bitstream/ring slot is never reused mid-encode.
const POOL: usize = 8; const POOL: usize = 8;
/// Whether the operator asked for the two-thread retrieve (`PUNKTFUNK_NVENC_ASYNC` truthy — the /// The operator's `PUNKTFUNK_NVENC_ASYNC` intent (the SAME knob as the Windows backend):
/// SAME knob as the Windows backend, so one env drives the split on either host OS). Opt-in /// `Some(true)` = force the two-thread retrieve from session open — note that at the Linux
/// until on-glass validated. Unlike Windows this changes NO session parameter (Linux stays sync /// default pipeline depth of 1 this adds ~one loop tick of latency (the non-blocking poll's AU
/// mode; only the blocking lock moves off the encode thread), so there is no async-rejecting /// rides the next tick), so it only pays under GPU contention; `Some(false)` = never (also
/// config to fail the open. /// vetoes the session loop's contention escalation via [`Encoder::set_pipelined`]); `None`
/// (unset) = adaptive — off until the session loop escalates on sustained cadence overrun.
/// Unlike Windows this changes NO session parameter (Linux stays sync mode; only the blocking
/// lock moves off the encode thread), so there is no async-rejecting config to fail the open.
fn async_retrieve_env() -> Option<bool> {
match std::env::var("PUNKTFUNK_NVENC_ASYNC") {
Ok(v) if matches!(v.trim(), "1" | "true" | "yes" | "on") => Some(true),
Ok(v) if matches!(v.trim(), "0" | "false" | "no" | "off") => Some(false),
_ => None,
}
}
/// Operator forced the two-thread retrieve on from session open (see [`async_retrieve_env`]).
fn async_retrieve_requested() -> bool { fn async_retrieve_requested() -> bool {
std::env::var("PUNKTFUNK_NVENC_ASYNC") async_retrieve_env() == Some(true)
.map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on"))
.unwrap_or(false)
} }
/// Max encodes in flight in two-thread mode (`PUNKTFUNK_NVENC_ASYNC_DEPTH`, default 4, clamped /// Max encodes in flight in two-thread mode (`PUNKTFUNK_NVENC_ASYNC_DEPTH`, default 4, clamped
@@ -486,6 +499,11 @@ pub struct NvencCudaEncoder {
/// The two-thread retrieve runtime (`PUNKTFUNK_NVENC_ASYNC`) — `None` in the default /// The two-thread retrieve runtime (`PUNKTFUNK_NVENC_ASYNC`) — `None` in the default
/// single-thread mode and between sessions. Exists only `init_session`→`teardown`. /// single-thread mode and between sessions. Exists only `init_session`→`teardown`.
async_rt: Option<AsyncRetrieve>, async_rt: Option<AsyncRetrieve>,
/// The session loop escalated into pipelined retrieve ([`Encoder::set_pipelined`], the
/// contention analog of the capturer depth escalation). Sticky across session rebuilds
/// (escalate-and-hold, like the depth escalation); the switch itself happens at the next
/// safe point via [`maybe_engage_async`](Self::maybe_engage_async).
want_async: bool,
/// Boxed `CUstream` the session's IO-stream binding points at (`NvEncSetIOCudaStreams` takes /// Boxed `CUstream` the session's IO-stream binding points at (`NvEncSetIOCudaStreams` takes
/// POINTERS to `CUstream`, and this struct moves — the pointee needs a stable heap address for /// POINTERS to `CUstream`, and this struct moves — the pointee needs a stable heap address for
/// the session's lifetime). Null when stream-ordering is off; freed in `teardown` AFTER the /// the session's lifetime). Null when stream-ordering is off; freed in `teardown` AFTER the
@@ -573,11 +591,35 @@ impl NvencCudaEncoder {
split_mode: nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32, split_mode: nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32,
last_rfi_range: None, last_rfi_range: None,
async_rt: None, async_rt: None,
want_async: false,
io_stream: ptr::null_mut(), io_stream: ptr::null_mut(),
stream_ordered: false, stream_ordered: false,
}) })
} }
/// Engage the escalated pipelined retrieve at a safe point: nothing in flight, and — because
/// a live session has its IO streams bound for stream-ordered submit, whose output-stream
/// semantics would make every later stream op wait on the previous encode and so serialize a
/// pipelined session — via a clean session rebuild WITHOUT the binding (the re-open's first
/// frame is the standard session-opening IDR). No-op until [`want_async`](Self::want_async)
/// is set and `pending` drains.
fn maybe_engage_async(&mut self) {
if !self.want_async || self.async_rt.is_some() || !self.pending.is_empty() {
return;
}
if self.inited {
// SAFETY: encode thread, `pending` empty ⇒ no encode in flight; `teardown` handles
// exactly this live-session state (and a torn-down encoder lazily re-inits on the
// next submit, which spawns the retrieve thread and skips the IO-stream arming).
unsafe { self.teardown() };
tracing::info!(
"NVENC pipelined-retrieve escalation: rebuilding the session without the \
IO-stream binding (stream-ordered submit and two-thread retrieve are mutually \
exclusive); next frame opens with an IDR"
);
}
}
/// Tear down the encode session + pooled resources. Reused on a size change and at Drop. /// Tear down the encode session + pooled resources. Reused on a size change and at Drop.
unsafe fn teardown(&mut self) { unsafe fn teardown(&mut self) {
if self.encoder.is_null() { if self.encoder.is_null() {
@@ -1035,10 +1077,11 @@ impl NvencCudaEncoder {
self.inited = true; self.inited = true;
// Two-thread retrieve (T2.2): spawn the lock thread against the live session. No // Two-thread retrieve (T2.2): spawn the lock thread against the live session. No
// session parameter differs — teardown/rebuild always stops it before destroy. // session parameter differs — teardown/rebuild always stops it before destroy.
if async_retrieve_requested() { if async_retrieve_requested() || self.want_async {
self.async_rt = Some(AsyncRetrieve::spawn(self.encoder as usize)); self.async_rt = Some(AsyncRetrieve::spawn(self.encoder as usize));
tracing::info!( tracing::info!(
depth = async_inflight_cap(), depth = async_inflight_cap(),
escalated = self.want_async,
"NVENC two-thread retrieve enabled (submit thread + blocking-lock thread)" "NVENC two-thread retrieve enabled (submit thread + blocking-lock thread)"
); );
} }
@@ -1168,6 +1211,9 @@ impl Encoder for NvencCudaEncoder {
"Linux direct-NVENC needs a CUDA frame (FramePayload::Cuda); got a CPU/dmabuf frame" "Linux direct-NVENC needs a CUDA frame (FramePayload::Cuda); got a CPU/dmabuf frame"
), ),
}; };
// A pending pipelined-retrieve escalation engages here, at the submit-side safe point
// (nothing in flight after the previous poll drained).
self.maybe_engage_async();
// Re-init on a size change (the capturer can return at a different resolution after a mode // Re-init on a size change (the capturer can return at a different resolution after a mode
// switch). Format changes (NV12↔YUV444) likewise re-init. // switch). Format changes (NV12↔YUV444) likewise re-init.
let new_fmt = buffer_format(buf); let new_fmt = buffer_format(buf);
@@ -1247,7 +1293,10 @@ impl Encoder for NvencCudaEncoder {
// `poll` (both host loops do — see `Encoder::submit`'s doc), which blocks until the encode // `poll` (both host loops do — see `Encoder::submit`'s doc), which blocks until the encode
// (and therefore the enqueued copy) completed. A pipelined caller (pending non-empty) // (and therefore the enqueued copy) completed. A pipelined caller (pending non-empty)
// falls back to the blocking copy so an early-recycled source can never be read late. // falls back to the blocking copy so an early-recycled source can never be read late.
let ordered = self.stream_ordered && self.pending.is_empty(); // `async_rt` must be absent too: in two-thread mode the frame may be recycled right after
// submit returns while the stream still holds its copy (belt-and-braces — an escalated
// session was rebuilt without the binding, so `stream_ordered` is false there anyway).
let ordered = self.stream_ordered && self.async_rt.is_none() && self.pending.is_empty();
let t0 = std::time::Instant::now(); let t0 = std::time::Instant::now();
// Copy the captured buffer into this slot's input surface before encoding it. // Copy the captured buffer into this slot's input surface before encoding it.
@@ -1455,6 +1504,21 @@ impl Encoder for NvencCudaEncoder {
self.force_kf = true; self.force_kf = true;
} }
fn set_pipelined(&mut self, on: bool) -> bool {
if !on {
// v1 is escalate-and-hold (no de-escalation), mirroring the depth escalation.
return self.want_async || self.async_rt.is_some();
}
if async_retrieve_env() == Some(false) {
return false; // operator veto: PUNKTFUNK_NVENC_ASYNC=0 means NEVER
}
if !self.want_async && self.async_rt.is_none() {
self.want_async = true;
self.maybe_engage_async();
}
true
}
fn caps(&self) -> EncoderCaps { fn caps(&self) -> EncoderCaps {
EncoderCaps { EncoderCaps {
supports_rfi: self.rfi_supported, supports_rfi: self.rfi_supported,
@@ -2090,6 +2154,75 @@ mod tests {
); );
} }
/// ON-HARDWARE (RTX box `.21`): the §7 LN3 pipelined-retrieve escalation —
/// `set_pipelined(true)` on a live sync session must rebuild it without the IO-stream
/// binding, spawn the retrieve thread on the re-open, and keep delivering AUs (the first
/// post-escalation AU is the re-open's session-opening IDR; pipelined `poll` is
/// non-blocking, so AUs may ride a later tick).
#[test]
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
fn nvenc_cuda_pipelined_escalation() {
const W: u32 = 1280;
const H: u32 = 720;
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
if async_retrieve_env() == Some(false) {
println!("skipped: PUNKTFUNK_NVENC_ASYNC=0 vetoes the escalation");
return;
}
let mut enc = NvencCudaEncoder::open(
Codec::H265,
PixelFormat::Nv12,
W,
H,
60,
8_000_000,
true,
8,
ChromaFormat::Yuv420,
)
.expect("open NVENC CUDA session");
// Steady sync frames first (stream-ordered mode).
for i in 0..3u32 {
let frame = nv12_frame(W, H, i);
enc.submit_indexed(&frame, i).expect("submit");
enc.poll().expect("poll").expect("AU");
}
assert!(enc.async_rt.is_none(), "session starts sync");
assert!(enc.set_pipelined(true), "escalation must be accepted");
let mut aus = 0usize;
let mut first_key = false;
for i in 3..13u32 {
let frame = nv12_frame(W, H, i);
enc.submit_indexed(&frame, i)
.expect("submit post-escalation");
while let Some(au) = enc.poll().expect("poll") {
if aus == 0 {
first_key = au.keyframe;
}
aus += 1;
}
std::thread::sleep(std::time::Duration::from_millis(3));
}
// Drain the pipelined tail (bounded).
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(500);
while aus < 10 && std::time::Instant::now() < deadline {
if enc.poll().expect("poll").is_some() {
aus += 1;
}
std::thread::sleep(std::time::Duration::from_millis(1));
}
assert!(
enc.async_rt.is_some(),
"retrieve thread must be live after escalation"
);
assert!(
!enc.stream_ordered,
"IO-stream binding must be gone in pipelined mode"
);
assert_eq!(aus, 10, "every post-escalation frame must deliver an AU");
assert!(first_key, "first post-escalation AU is the re-open IDR");
}
/// ON-HARDWARE (RTX box `.21`), MEASUREMENT probe for latency plan §7 LN1 — answers the /// ON-HARDWARE (RTX box `.21`), MEASUREMENT probe for latency plan §7 LN1 — answers the
/// go/no-go question for sub-frame slice output: with `PUNKTFUNK_NVENC_SLICES=4` + /// go/no-go question for sub-frame slice output: with `PUNKTFUNK_NVENC_SLICES=4` +
/// `PUNKTFUNK_NVENC_SUBFRAME=1`, do slices become READABLE incrementally while the frame is /// `PUNKTFUNK_NVENC_SUBFRAME=1`, do slices become READABLE incrementally while the frame is
+33 -11
View File
@@ -1207,6 +1207,9 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
let mut cur_depth: usize = 1; let mut cur_depth: usize = 1;
let mut behind_score: u32 = 0; let mut behind_score: u32 = 0;
let mut depth_frames: u64 = 0; let mut depth_frames: u64 = 0;
// Second escalation stage (§7 LN3): once depth is maxed (or was never available — Linux),
// ask the encoder for pipelined retrieve exactly once. Latched whether it accepts or not.
let mut pipeline_asked = false;
// ~20 net behind-frames (≈0.3 s sustained) escalates; a lone hitch decays away. Warmup skips // ~20 net behind-frames (≈0.3 s sustained) escalates; a lone hitch decays away. Warmup skips
// the first ~1 s so bring-up (display acquire, encoder open) never triggers it. // the first ~1 s so bring-up (display acquire, encoder open) never triggers it.
const DEPTH_ESCALATE: u32 = 20; const DEPTH_ESCALATE: u32 = 20;
@@ -2056,10 +2059,15 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// Adaptive-depth escalate signal (measured BEFORE the trailing sleep): "behind" = the // Adaptive-depth escalate signal (measured BEFORE the trailing sleep): "behind" = the
// frame's work overran its cadence deadline `next`, so the trailing sleep would be // frame's work overran its cadence deadline `next`, so the trailing sleep would be
// zero/negative. At depth-1 that means the synchronous poll (encode + WDDM wait) can't // zero/negative. At depth-1 that means the synchronous poll (encode + WDDM wait) can't
// fit a frame interval — the contention case pipelining is for — so escalate to the // fit a frame interval — the contention case pipelining is for — so escalate, and hold
// capturer's max and hold there. Leaky bucket + warmup skip reject one-off hitches and // there. Leaky bucket + warmup skip reject one-off hitches and bring-up; no
// bring-up. Once escalated, `cur_depth` stays (no de-escalation in v1). // de-escalation in v1. Two stages: first the CAPTURER's max depth (Windows IDD depth-2
if idd_adaptive_enabled() && cur_depth < max_depth { // overlap); where depth can't grow (Linux portal is permanently depth-1, §7 LN3), the
// ENCODER's pipelined retrieve is the same trade on the other side of submit — the
// two-thread lock moves the encode wait off this loop so capture/submit keep cadence,
// at ~one tick of AU latency. `enc.set_pipelined` may decline (unsupported backend or
// an explicit PUNKTFUNK_NVENC_ASYNC=0); either way it is asked exactly once.
if idd_adaptive_enabled() && (cur_depth < max_depth || !pipeline_asked) {
depth_frames += 1; depth_frames += 1;
if depth_frames > DEPTH_WARMUP_FRAMES { if depth_frames > DEPTH_WARMUP_FRAMES {
let behind = std::time::Instant::now() >= next; let behind = std::time::Instant::now() >= next;
@@ -2069,13 +2077,27 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
behind_score.saturating_sub(1) behind_score.saturating_sub(1)
}; };
if behind_score >= DEPTH_ESCALATE { if behind_score >= DEPTH_ESCALATE {
cur_depth = max_depth; if cur_depth < max_depth {
tracing::info!( cur_depth = max_depth;
depth = cur_depth, tracing::info!(
"IDD pipeline depth escalated — encode can't hold cadence at depth-1 \ depth = cur_depth,
(GPU contention); pipelining for the rest of the session (latency \ "IDD pipeline depth escalated — encode can't hold cadence at depth-1 \
trade for throughput)" (GPU contention); pipelining for the rest of the session (latency \
); trade for throughput)"
);
} else {
pipeline_asked = true;
if enc.set_pipelined(true) {
tracing::info!(
"encoder pipelined retrieve escalated — encode can't hold \
cadence and the capturer has no depth to give; the encode wait \
moves off the loop for the rest of the session (latency trade \
for throughput)"
);
}
}
// Give the action time to take effect before judging again.
behind_score = 0;
} }
} }
} }