diff --git a/crates/pf-encode/src/enc/codec.rs b/crates/pf-encode/src/enc/codec.rs index e6cbafc3..1487b595 100644 --- a/crates/pf-encode/src/enc/codec.rs +++ b/crates/pf-encode/src/enc/codec.rs @@ -112,7 +112,7 @@ impl AuChunk { } /// Codec selection negotiated with the client. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Codec { H264, H265, @@ -399,6 +399,16 @@ pub trait Encoder: Send { fn reconfigure_bitrate(&mut self, _bps: u64) -> bool { false } + /// The bitrate (bps) the encoder is ACTUALLY running at (or will open at, for a lazily-opened + /// backend) — the encoder-side truth after any internal clamp, e.g. the direct-NVENC + /// codec-level ceiling search. The session loop reads this after every open/reconfigure and + /// stores IT, not the requested rate, as the live bitrate — so the send pacer, the console + /// and the client controller's ack all track what the ASIC really targets (a controller fed + /// the requested rate keeps climbing from a phantom base, §ABR overdrive). `None` (the + /// default) = the backend doesn't track an applied rate; the caller keeps the requested one. + fn applied_bitrate_bps(&self) -> Option { + None + } /// Wire-chunk the encoder's AUs at the session's shard payload size (the PyroWave /// datagram-aligned mode, plan §4.4): every `shard_payload` window of the emitted AU /// starts a fresh self-delimiting codec packet, zero-padded to the window — so a lost @@ -452,6 +462,17 @@ impl Codec { } } +/// Pixel rate (luma samples/s) at or above which NVENC split-frame encoding is FORCED 2-way — +/// one number shared by the direct-SDK selector (`nvenc_core::resolve_split_mode`) and the libav +/// `split_encode_mode` option author (`linux::NvencEncoder`), so the two paths can never disagree +/// about which modes split. A single NVENC engine tops out ~1 Gpix/s on HEVC, and AUTO doesn't +/// engage below ~2112 px height, so the sessions that need the second engine must be forced. Set +/// BELOW 1 Gpix/s deliberately: 4K120 — the mode this threshold exists for — is 3840×2160×120 = +/// 995,328,000, which a `> 1_000_000_000` gate missed by 0.47% and left on AUTO (pinned ~107 fps +/// on a 4090). 950 M keeps margin for fractional refresh rates while leaving 1440p240 (884.7 M, +/// comfortably single-engine) on AUTO. +pub const SPLIT_FORCE_PIXEL_RATE: u64 = 950_000_000; + /// `PUNKTFUNK_VBV_FRAMES` — HRD/VBV size in frame intervals (default 1.0, the strict low-latency /// shape every backend ships: each frame must fit its rate share, keeping frame sizes uniform for /// the pacer). The AMF/VAAPI/QSV paths parse the same variable locally; this helper brings the diff --git a/crates/pf-encode/src/enc/linux/mod.rs b/crates/pf-encode/src/enc/linux/mod.rs index bd973f2c..929c2d2d 100644 --- a/crates/pf-encode/src/enc/linux/mod.rs +++ b/crates/pf-encode/src/enc/linux/mod.rs @@ -439,15 +439,19 @@ impl NvencEncoder { } // Split-frame encode across both NVENC engines (GB203 has 2) when the pixel rate exceeds - // a single engine's HEVC capacity (~1 Gpix/s); e.g. 5120x1440@240 = 1.77 Gpix/s needs it, - // @120 = 0.88 Gpix/s does not. HEVC/AV1 only (not H.264). AUTO won't engage below ~2112px + // a single engine's HEVC capacity; e.g. 5120x1440@240 = 1.77 Gpix/s needs it, @120 + // (0.88 Gpix/s) does not. HEVC/AV1 only (not H.264). AUTO won't engage below ~2112px // height, so we force `2`; below the threshold we leave it AUTO (split costs ~2% BD-rate). - // Output is standard HEVC — transparent to the client. Override with PUNKTFUNK_SPLIT_ENCODE. + // Threshold shared with the direct-SDK selector ([`super::SPLIT_FORCE_PIXEL_RATE`] — set + // so 4K120 = 995.3 Mpix/s forces, which `> 1e9` famously missed by 0.47%). Output is + // standard HEVC — transparent to the client. Override with PUNKTFUNK_SPLIT_ENCODE. let pix_rate = width as u64 * height as u64 * fps as u64; let split = std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok(); match split.as_deref() { Some(mode) => opts.set("split_encode_mode", mode), - None if matches!(codec, Codec::H265 | Codec::Av1) && pix_rate > 1_000_000_000 => { + None if matches!(codec, Codec::H265 | Codec::Av1) + && pix_rate >= super::SPLIT_FORCE_PIXEL_RATE => + { opts.set("split_encode_mode", "2"); tracing::info!( pix_rate, diff --git a/crates/pf-encode/src/enc/linux/nvenc_cuda.rs b/crates/pf-encode/src/enc/linux/nvenc_cuda.rs index 8c0da416..0c3ef438 100644 --- a/crates/pf-encode/src/enc/linux/nvenc_cuda.rs +++ b/crates/pf-encode/src/enc/linux/nvenc_cuda.rs @@ -61,8 +61,9 @@ #![deny(clippy::undocumented_unsafe_blocks)] use super::nvenc_core::{ - apply_low_latency_config, build_init_params, codec_guid, resolve_slices, resolve_subframe, - LowLatencyConfig, NvStatusExt, RFI_DPB, + apply_low_latency_config, build_init_params, cached_ceiling, codec_guid, resolve_slices, + resolve_split_mode, resolve_subframe, store_ceiling, CeilingKey, LowLatencyConfig, NvStatusExt, + RFI_DPB, }; use super::nvenc_status; use super::{AuChunk, ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps}; @@ -1000,6 +1001,23 @@ impl NvencCudaEncoder { Ok(cfg) } + /// This session config's identity in the process-lifetime bitrate-ceiling cache + /// (`nvenc_core::{cached_ceiling, store_ceiling}`). GPU identity is the process-global shared + /// `CUcontext` pointer — one context per process, stable for its lifetime; only valid once + /// `cu_ctx` is bound (`init_session` start), which every caller is downstream of. + fn ceiling_key(&self, split_mode: u32) -> CeilingKey { + CeilingKey { + gpu: self.cu_ctx as u64, + codec: self.codec, + width: self.width, + height: self.height, + fps: self.fps, + bit_depth: self.bit_depth, + chroma_444: self.chroma_444, + split_mode, + } + } + /// Open + configure + initialize ONE NVENC CUDA session at `bitrate` (bps) and `split_mode`. /// Returns the session handle, or destroys it and returns the error. unsafe fn try_open_session(&self, bitrate: u64, split_mode: u32) -> Result<*mut c_void> { @@ -1074,58 +1092,71 @@ impl NvencCudaEncoder { } const FLOOR_BPS: u64 = 10_000_000; let requested_bps = self.bitrate_bps; - // 2-way NVENC split-frame encoding (Ada dual-NVENC) above ~1 Gpix/s; env override - // PUNKTFUNK_SPLIT_ENCODE = 0/disable | 1/auto | 2 | 3. HEVC/AV1 only. + // 2-way NVENC split-frame encoding (Ada dual-NVENC) — shared selector, see + // [`resolve_split_mode`] for the precedence (env override / 10-bit / pixel rate). let pixel_rate = self.width as u64 * self.height as u64 * self.fps.max(1) as u64; - let mut split_mode: u32 = match std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok().as_deref() - { - Some("0") | Some("disable") => { - nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32 - } - Some("1") | Some("auto") => { - nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_FORCED_MODE as u32 - } - Some("3") => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_THREE_FORCED_MODE as u32, - Some("2") => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_TWO_FORCED_MODE as u32, - _ if self.bit_depth >= 10 => { - nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32 - } - _ if pixel_rate > 1_000_000_000 => { - nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_TWO_FORCED_MODE as u32 - } - _ => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_MODE as u32, - }; + let split_mode: u32 = resolve_split_mode(self.bit_depth, pixel_rate); const CLAMP_TOL_BPS: u64 = 20_000_000; - let mut probe = self.try_open_session(requested_bps, split_mode); - // Disambiguate a forced-split rejection from a bitrate-cap rejection. + // Ceiling cache (process lifetime, `nvenc_core`): a prior clamp search already found + // this config's max accepted rate — open straight AT the ceiling instead of paying + // the ~6-open binary search (and its session churn) on every ABR overshoot. + let mut target_bps = requested_bps; + if let Some(ceiling) = cached_ceiling(&self.ceiling_key(split_mode)) { + if requested_bps > ceiling { + tracing::info!( + requested_mbps = requested_bps / 1_000_000, + ceiling_mbps = ceiling / 1_000_000, + "NVENC (Linux): requested bitrate above the cached codec-level ceiling — \ + opening at the ceiling" + ); + target_bps = ceiling; + } + } + + let mut probe = self.try_open_session(target_bps, split_mode); + // The cache is advisory: a stale entry (driver change, identity collision) must not + // wedge the open — retry the requested rate and let the search below rediscover. + if probe.is_err() && target_bps < requested_bps { + target_bps = requested_bps; + probe = self.try_open_session(requested_bps, split_mode); + } + // Disambiguate a forced-split rejection from a bitrate-cap rejection. `used_split` + // tracks the mode sessions ACTUALLY open with from here on — it feeds + // `self.split_mode` (a reconfigure must re-present it) and the ceiling-cache key. + let mut used_split = split_mode; let split_on = split_mode != nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32; if probe.is_err() && split_on { let no_split = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32; - if let Ok(e) = self.try_open_session(requested_bps, no_split) { + if let Ok(e) = self.try_open_session(target_bps, no_split) { tracing::warn!( "NVENC (Linux): split-encode rejected by codec/config — disabled" ); - split_mode = no_split; + used_split = no_split; probe = Ok(e); } } let enc = match probe { Ok(enc) => { - self.bitrate_bps = requested_bps; + self.bitrate_bps = target_bps; enc } + // Only a parameter/caps rejection means "the bitrate is above the codec-level + // ceiling". A transient failure (busy engine, session limit, OOM, device loss, + // version skew) must propagate — a search steered by it would discover, and + // cache, a bogus ceiling. + Err(e) if !nvenc_status::is_param_rejection(&e) => return Err(e), Err(_) => { // Requested bitrate exceeds the codec-level ceiling — binary-search the max accepted. let mut lo = FLOOR_BPS; - let mut hi = requested_bps; + let mut hi = target_bps; let mut best: *mut c_void = ptr::null_mut(); let mut best_bps = 0u64; while hi > lo + CLAMP_TOL_BPS { let mid = lo + (hi - lo) / 2; - match self.try_open_session(mid, split_mode) { + match self.try_open_session(mid, used_split) { Ok(e) => { if !best.is_null() { let _ = (api().destroy_encoder)(best); @@ -1134,18 +1165,30 @@ impl NvencCudaEncoder { best_bps = mid; lo = mid; } - Err(_) => hi = mid, + Err(e) if nvenc_status::is_param_rejection(&e) => hi = mid, + Err(e) => { + // Environmental mid-search failure: don't let it shrink the + // search — release the partial result and propagate. + if !best.is_null() { + let _ = (api().destroy_encoder)(best); + } + return Err(e); + } } } if best.is_null() { let no_split = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32; - best = self - .try_open_session(FLOOR_BPS, split_mode) - .or_else(|_| self.try_open_session(FLOOR_BPS, no_split)) - .context( - "NVENC initialize_encoder rejected even at the floor bitrate", - )?; + best = match self.try_open_session(FLOOR_BPS, used_split) { + Ok(e) => e, + Err(_) => { + let e = self.try_open_session(FLOOR_BPS, no_split).context( + "NVENC initialize_encoder rejected even at the floor bitrate", + )?; + used_split = no_split; + e + } + }; best_bps = FLOOR_BPS; } tracing::warn!( @@ -1153,15 +1196,13 @@ impl NvencCudaEncoder { clamped_mbps = best_bps / 1_000_000, "NVENC (Linux): requested bitrate above the GPU codec-level ceiling — clamped" ); + store_ceiling(self.ceiling_key(used_split), best_bps); self.bitrate_bps = best_bps; best } }; self.encoder = enc; - // (Best effort: the floor fallback above may have succeeded split-disabled without - // updating `split_mode` — a later reconfigure then presents the forced mode, NVENC - // rejects it, and the caller's rebuild fallback covers the mismatch.) - self.split_mode = split_mode; + self.split_mode = used_split; // Output bitstream pool. for _ in 0..POOL { @@ -1345,6 +1386,10 @@ impl NvencCudaEncoder { mbps = self.bitrate_bps / 1_000_000, codec = ?self.codec_guid, fmt = ?self.buffer_fmt, + // The FINAL split mode (post any rejection fallback) at INFO — journals run + // INFO+, and "did 4K120 actually split across engines?" was undiagnosable from + // a user log without it (Windows only had a debug! at selection time). + split_mode = self.split_mode, "NVENC CUDA session ready" ); Ok(()) @@ -2061,6 +2106,15 @@ impl Encoder for NvencCudaEncoder { self.bitrate_bps = bps; return true; } + // Cached codec-level ceiling: clamp the target BEFORE the driver call, so a known + // overshoot retargets to the ceiling IN PLACE instead of bouncing off the driver into + // the caller's full-rebuild fallback (an IDR plus ~half a second of session churn per + // ABR overshoot on the pre-cache path). The caller reads the clamp back through + // [`Encoder::applied_bitrate_bps`]. + let bps = match cached_ceiling(&self.ceiling_key(self.split_mode)) { + Some(ceiling) => bps.min(ceiling), + None => bps, + }; // SAFETY: `inited` ⟹ `self.encoder` is the live session and every call here runs on the // encode thread with no NVENC call in flight (the session loop calls this between // submit/poll). `build_config` only queries the preset on that session; `cfg` outlives @@ -2108,6 +2162,12 @@ impl Encoder for NvencCudaEncoder { } } + fn applied_bitrate_bps(&self) -> Option { + // `bitrate_bps` is the post-clamp truth: the open path's ceiling search and the + // reconfigure path's cache clamp both write what the session ACTUALLY targets. + Some(self.bitrate_bps) + } + fn flush(&mut self) -> Result<()> { Ok(()) // P1/ULL + frameIntervalP=1: each submit yields its AU; no internal queue to drain. } diff --git a/crates/pf-encode/src/enc/nvenc_core.rs b/crates/pf-encode/src/enc/nvenc_core.rs index 09baefa7..93684189 100644 --- a/crates/pf-encode/src/enc/nvenc_core.rs +++ b/crates/pf-encode/src/enc/nvenc_core.rs @@ -67,6 +67,155 @@ pub(super) fn resolve_subframe(default_on: bool) -> bool { } } +/// Resolved NVENC split-frame encode mode for a session — ONE selector shared by the Windows and +/// Linux direct-SDK backends (they had drifted into byte-identical duplicates, one of which +/// logged and one didn't). Precedence: +/// 1. `PUNKTFUNK_SPLIT_ENCODE` = `0`/`disable` | `1`/`auto` (AUTO_FORCED) | `2` | `3` — operator +/// override, always wins. +/// 2. 10-bit → DISABLE: 2-way split is measurably SLOWER on Ada for Main10 — at 5120×1440@240 +/// forced-2 took 7.6 ms/frame (~131 fps) vs 2.8 ms (~357 fps) single-engine (the split/merge +/// overhead dominates), and a single engine handles 5K@240 Main10 well under budget. This was +/// the "broken animations in HDR" cap at ~131 fps. +/// 3. Pixel rate ≥ [`super::SPLIT_FORCE_PIXEL_RATE`] → force 2-way (AUTO never engages below +/// ~2112 px height, so 4K120 must be forced onto the second engine). +/// 4. Else AUTO (the ~2% BD-rate split cost isn't worth it at low pixel rates). +/// +/// The caller still owns the rejection fallback (retry split-disabled) — a codec/config that +/// rejects the chosen mode downgrades at open, not here. +pub(super) fn resolve_split_mode(bit_depth: u8, pixel_rate: u64) -> u32 { + use nv::NV_ENC_SPLIT_ENCODE_MODE as M; + let mode = match std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok().as_deref() { + Some("0") | Some("disable") => M::NV_ENC_SPLIT_DISABLE_MODE as u32, + Some("1") | Some("auto") => M::NV_ENC_SPLIT_AUTO_FORCED_MODE as u32, + Some("3") => M::NV_ENC_SPLIT_THREE_FORCED_MODE as u32, + Some("2") => M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32, + _ if bit_depth >= 10 => M::NV_ENC_SPLIT_DISABLE_MODE as u32, + _ if pixel_rate >= super::SPLIT_FORCE_PIXEL_RATE => M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32, + _ => M::NV_ENC_SPLIT_AUTO_MODE as u32, + }; + tracing::debug!( + split_mode = mode, + bit_depth, + pixel_rate, + "NVENC split-encode mode selected" + ); + mode +} + +/// One session config's identity for the process-lifetime bitrate-ceiling cache +/// ([`cached_ceiling`]/[`store_ceiling`]). Everything the driver's codec-level validation keys +/// off: the GPU (different NVENC generations have different level ceilings), dims/fps (the luma +/// rate selects the level), depth/chroma (they select the profile) and the split mode the +/// sessions ACTUALLY opened with (a split session budgets per engine). +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub(super) struct CeilingKey { + /// GPU identity — Linux: the process-global shared `CUcontext` pointer; Windows: the render + /// adapter LUID (0 when unresolved). Best effort: the cache is advisory (see + /// [`cached_ceiling`]), so a colliding identity costs one failed open + re-search, never a + /// wrong session. + pub gpu: u64, + pub codec: Codec, + pub width: u32, + pub height: u32, + pub fps: u32, + pub bit_depth: u8, + pub chroma_444: bool, + pub split_mode: u32, +} + +fn ceilings() -> &'static std::sync::Mutex> { + static CEILINGS: std::sync::OnceLock< + std::sync::Mutex>, + > = std::sync::OnceLock::new(); + CEILINGS.get_or_init(Default::default) +} + +/// The codec-level bitrate ceiling (bps) a previous clamp search discovered for `key` this +/// process lifetime, if any. ADVISORY: the consumer must treat a failed open at the cached value +/// as a stale entry (fall back to the full search, which rewrites it via [`store_ceiling`]) — +/// that self-healing is what lets the key's GPU identity be best-effort. What this buys: an ABR +/// overshoot on a config whose ceiling is already known opens (or in-place reconfigures) straight +/// AT the ceiling instead of re-running the ~6-open binary search and its ~half-second of session +/// churn per rebuild. +pub(super) fn cached_ceiling(key: &CeilingKey) -> Option { + ceilings().lock().unwrap().get(key).copied() +} + +/// Record the clamp search's discovered max accepted bitrate (bps) for `key`. +pub(super) fn store_ceiling(key: CeilingKey, bps: u64) { + ceilings().lock().unwrap().insert(key, bps); +} + +#[cfg(test)] +mod tests { + use super::*; + use nv::NV_ENC_SPLIT_ENCODE_MODE as M; + + // These assume PUNKTFUNK_SPLIT_ENCODE is unset (CI); an operator override deliberately wins. + + #[test] + fn split_forces_two_way_at_4k120() { + // The regression this threshold constant exists for: 3840×2160×120 = 995,328,000 sat + // 0.47% under the old `> 1_000_000_000` gate and stayed AUTO — pinned ~107 fps on a + // 4090 because AUTO never engages at 2160 px height. + let four_k_120 = 3840u64 * 2160 * 120; + assert_eq!( + resolve_split_mode(8, four_k_120), + M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32 + ); + } + + #[test] + fn split_leaves_1440p240_auto() { + // 884.7 Mpix/s is comfortably single-engine — the threshold move must not drag it in. + let qhd_240 = 2560u64 * 1440 * 240; + assert_eq!( + resolve_split_mode(8, qhd_240), + M::NV_ENC_SPLIT_AUTO_MODE as u32 + ); + } + + #[test] + fn split_disabled_for_10bit_even_at_high_pixel_rate() { + // The measured Main10 rule: split/merge overhead dominates 10-bit on Ada (7.6 ms forced-2 + // vs 2.8 ms single-engine at 5K240) — 10-bit precedes the pixel-rate arm. + let five_k_240 = 5120u64 * 1440 * 240; + assert_eq!( + resolve_split_mode(10, five_k_240), + M::NV_ENC_SPLIT_DISABLE_MODE as u32 + ); + } + + #[test] + fn ceiling_cache_round_trips_and_keys_precisely() { + let key = CeilingKey { + gpu: 0xB0B0, + codec: Codec::H265, + width: 3840, + height: 2160, + fps: 120, + bit_depth: 8, + chroma_444: false, + split_mode: M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32, + }; + assert_eq!(cached_ceiling(&key), None); + store_ceiling(key, 794_000_000); + assert_eq!(cached_ceiling(&key), Some(794_000_000)); + // Any config-identity change is a different ceiling — a miss, never a wrong clamp. + assert_eq!(cached_ceiling(&CeilingKey { fps: 60, ..key }), None); + assert_eq!( + cached_ceiling(&CeilingKey { + split_mode: M::NV_ENC_SPLIT_DISABLE_MODE as u32, + ..key + }), + None + ); + // A re-search overwrites (the advisory-cache stale-entry path). + store_ceiling(key, 620_000_000); + assert_eq!(cached_ceiling(&key), Some(620_000_000)); + } +} + /// Reference-frame DPB depth when RFI is supported (Apollo uses 5). A deeper DPB lets an invalidated /// reference fall back to an older still-valid frame instead of a full IDR; `numRefL0 = 1` keeps each /// P-frame single-reference for low latency. Also the window the backends' `invalidate_ref_frames` diff --git a/crates/pf-encode/src/enc/nvenc_status.rs b/crates/pf-encode/src/enc/nvenc_status.rs index e7672a6f..367d999f 100644 --- a/crates/pf-encode/src/enc/nvenc_status.rs +++ b/crates/pf-encode/src/enc/nvenc_status.rs @@ -72,9 +72,41 @@ pub(super) fn explain(status: nv::NVENCSTATUS) -> String { } } +/// Typed root of a failed NVENC entry-point call: carries the raw status so callers can classify +/// the failure class, not just print it — the bitrate-clamp search must only read a +/// parameter/caps rejection as "above the codec-level ceiling"; a transient failure shrinking the +/// search would discover (and cache) a bogus ceiling. Recover it through an `anyhow` chain with +/// `err.downcast_ref::()` (see [`is_param_rejection`]). +#[derive(Debug)] +pub(super) struct NvCallError(pub(super) nv::NVENCSTATUS); + +impl std::fmt::Display for NvCallError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?} — {}", self.0, explain(self.0)) + } +} + +impl std::error::Error for NvCallError {} + +/// Whether `err` is an NVENC parameter/capability rejection: the driver understood the request +/// and says THIS config is not encodable — the clamp search's "bitrate above the ceiling" +/// evidence. Everything else (busy engine, session limit, OOM, device loss, version skew) is +/// environmental and must propagate instead of steering the search. +pub(super) fn is_param_rejection(err: &anyhow::Error) -> bool { + matches!( + err.downcast_ref::(), + Some(NvCallError( + nv::NVENCSTATUS::NV_ENC_ERR_INVALID_PARAM + | nv::NVENCSTATUS::NV_ENC_ERR_UNSUPPORTED_PARAM + | nv::NVENCSTATUS::NV_ENC_ERR_UNIMPLEMENTED, + )) + ) +} + /// Build an actionable `anyhow::Error` for a failed NVENC entry-point call. `call` names the API -/// (e.g. `"open_encode_session_ex"`); the message carries both the raw status and its real-world -/// cause, so triage never again reads a version mismatch as "(no NVIDIA GPU?)". +/// (e.g. `"open_encode_session_ex"`); the chain carries both the raw status and its real-world +/// cause, so triage never again reads a version mismatch as "(no NVIDIA GPU?)". The +/// [`NvCallError`] root keeps the status downcastable for failure-class checks. pub(super) fn call_err(call: &str, status: nv::NVENCSTATUS) -> anyhow::Error { - anyhow::anyhow!("NVENC {call} failed: {status:?} — {}", explain(status)) + anyhow::Error::new(NvCallError(status)).context(format!("NVENC {call} failed")) } diff --git a/crates/pf-encode/src/enc/windows/nvenc.rs b/crates/pf-encode/src/enc/windows/nvenc.rs index 5023f76d..234da9b3 100644 --- a/crates/pf-encode/src/enc/windows/nvenc.rs +++ b/crates/pf-encode/src/enc/windows/nvenc.rs @@ -37,8 +37,9 @@ #![deny(clippy::undocumented_unsafe_blocks)] use super::nvenc_core::{ - apply_low_latency_config, build_init_params, codec_guid, resolve_slices, resolve_subframe, - LowLatencyConfig, NvStatusExt, RFI_DPB, + apply_low_latency_config, build_init_params, cached_ceiling, codec_guid, resolve_slices, + resolve_split_mode, resolve_subframe, store_ceiling, CeilingKey, LowLatencyConfig, NvStatusExt, + RFI_DPB, }; use super::nvenc_status; use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps}; @@ -754,6 +755,27 @@ impl NvencD3d11Encoder { Ok(cfg) } + /// This session config's identity in the process-lifetime bitrate-ceiling cache + /// (`nvenc_core::{cached_ceiling, store_ceiling}`). GPU identity is the selected render + /// adapter's LUID — the adapter the capturer's device (and so this session) lives on; `0` + /// when unresolved. Best effort by design: the cache is advisory, a colliding identity costs + /// one failed open + re-search, never a wrong session. + fn ceiling_key(&self, split_mode: u32) -> CeilingKey { + let gpu = pf_gpu::resolve_render_adapter_luid() + .map(|l| ((l.HighPart as u32 as u64) << 32) | l.LowPart as u64) + .unwrap_or(0); + CeilingKey { + gpu, + codec: self.codec, + width: self.width, + height: self.height, + fps: self.fps, + bit_depth: self.bit_depth, + chroma_444: self.chroma_444, + split_mode, + } + } + /// Open + configure + initialize ONE NVENC session at `bitrate` (bps) and `split_mode`. Returns /// the session handle, or destroys it and returns the error. NVENC has no re-init after a failed /// `initialize_encoder`, so the bitrate-clamp search in `init_session` calls this once per probe. @@ -834,43 +856,14 @@ impl NvencD3d11Encoder { // gets the highest the GPU can actually do, not a coarse fraction of it. const FLOOR_BPS: u64 = 10_000_000; let requested_bps = self.bitrate_bps; - // 2-way NVENC split-frame encoding (Ada dual-NVENC) — the high-pixel-rate throughput lever - // the Linux host enables via libavcodec `split_encode_mode`. A single Ada NVENC session tops - // out ~0.8 Gpix/s, so at high motion a 5K@240 (1.77 Gpix/s) frame takes ~8 ms to encode and - // the rate caps ~125 fps; splitting across both engines roughly halves that. Force 2-way - // above ~1 Gpix/s (matching encode/linux.rs), AUTO below (the ~2% BD-rate cost isn't worth - // it at low pixel rates). Env override PUNKTFUNK_SPLIT_ENCODE = 0/disable | 1/auto | 2 | 3. - // HEVC/AV1 only; the init-failure fallback below disables it if a codec/config rejects it. + // 2-way NVENC split-frame encoding (Ada dual-NVENC) — the high-pixel-rate throughput lever. + // A single Ada NVENC session tops out ~0.8-1 Gpix/s, so at high motion a 5K@240 + // (1.77 Gpix/s) frame takes ~8 ms to encode and the rate caps ~125 fps; splitting across + // both engines roughly halves that. Shared selector — see [`resolve_split_mode`] for the + // precedence (env override / the measured Main10 don't-split rule / pixel rate). + // The init-failure fallback below disables it if a codec/config rejects it. let pixel_rate = self.width as u64 * self.height as u64 * self.fps.max(1) as u64; - let mut split_mode: u32 = match std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok().as_deref() - { - Some("0") | Some("disable") => { - nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32 - } - Some("1") | Some("auto") => { - nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_FORCED_MODE as u32 - } - Some("3") => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_THREE_FORCED_MODE as u32, - Some("2") => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_TWO_FORCED_MODE as u32, - // Main10 (10-bit / HDR): 2-way split is measurably SLOWER on Ada — at 5120x1440@240 - // Main10, forced-2 took 7.6 ms/frame (~131 fps) vs 2.8 ms (~357 fps) single-engine - // (the split/merge overhead dominates for 10-bit). A single Ada NVENC engine already - // handles 5K@240 Main10 well under the 4.17 ms budget, so DON'T split — splitting was - // the "broken animations in HDR" (the stream capped at ~131 fps). Env still overrides. - _ if self.bit_depth >= 10 => { - nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32 - } - _ if pixel_rate > 1_000_000_000 => { - nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_TWO_FORCED_MODE as u32 - } - _ => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_MODE as u32, - }; - tracing::debug!( - split_mode, - bit_depth = self.bit_depth, - pixel_rate, - "NVENC split-encode mode selected" - ); + let split_mode: u32 = resolve_split_mode(self.bit_depth, pixel_rate); // Find the highest bitrate the GPU's codec LEVEL accepts and CLAMP to it. NVENC rejects // `initialize_encoder` (InvalidParam) when the bitrate exceeds the level ceiling (e.g. a // 1 Gbps request on HEVC). Strategy: try the requested rate; if the only problem is a forced @@ -884,39 +877,69 @@ impl NvencD3d11Encoder { // built in the right mode from the start. let use_async = self.async_supported && async_retrieve_requested(); - let mut probe = self.try_open_session(device, requested_bps, split_mode, use_async); + // Ceiling cache (process lifetime, `nvenc_core`): a prior clamp search already found + // this config's max accepted rate — open straight AT the ceiling instead of paying + // the ~6-open binary search (and its session churn) on every ABR overshoot. + let mut target_bps = requested_bps; + if let Some(ceiling) = cached_ceiling(&self.ceiling_key(split_mode)) { + if requested_bps > ceiling { + tracing::info!( + requested_mbps = requested_bps / 1_000_000, + ceiling_mbps = ceiling / 1_000_000, + "NVENC: requested bitrate above the cached codec-level ceiling — opening \ + at the ceiling" + ); + target_bps = ceiling; + } + } + + let mut probe = self.try_open_session(device, target_bps, split_mode, use_async); + // The cache is advisory: a stale entry (driver change, identity collision) must not + // wedge the open — retry the requested rate and let the search below rediscover. + if probe.is_err() && target_bps < requested_bps { + target_bps = requested_bps; + probe = self.try_open_session(device, requested_bps, split_mode, use_async); + } // Disambiguate a forced-split rejection from a bitrate-cap rejection: retry once at the // requested rate with split disabled — if THAT succeeds, split was the problem, not bitrate. // ANY non-disabled mode can be the rejection — AUTO included: AV1 rejects the whole // init with INVALID_PARAM on drivers/configs where auto split isn't valid for it, // which then masqueraded as a bitrate cap and failed "even at the floor". + // `used_split` tracks the mode sessions ACTUALLY open with from here on — it feeds + // `self.split_mode` (a reconfigure must re-present it) and the ceiling-cache key. + let mut used_split = split_mode; let split_on = split_mode != nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32; if probe.is_err() && split_on { let no_split = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32; - if let Ok(e) = self.try_open_session(device, requested_bps, no_split, use_async) { + if let Ok(e) = self.try_open_session(device, target_bps, no_split, use_async) { tracing::warn!("NVENC: split-encode rejected by codec/config — disabled"); - split_mode = no_split; + used_split = no_split; probe = Ok(e); } } let enc = match probe { Ok(enc) => { - self.bitrate_bps = requested_bps; + self.bitrate_bps = target_bps; enc } + // Only a parameter/caps rejection means "the bitrate is above the codec-level + // ceiling". A transient failure (busy engine, session limit, OOM, device loss, + // version skew) must propagate — a search steered by it would discover, and + // cache, a bogus ceiling. + Err(e) if !nvenc_status::is_param_rejection(&e) => return Err(e), Err(_) => { // Requested bitrate exceeds the codec-level ceiling — binary-search the max accepted. // `lo` is the highest known-good rate (FLOOR is assumed to fit), `hi` the lowest // rejected; `best` holds the live session at `lo` so we end up with the clamped one. let mut lo = FLOOR_BPS; - let mut hi = requested_bps; + let mut hi = target_bps; let mut best: *mut c_void = ptr::null_mut(); let mut best_bps = 0u64; while hi > lo + CLAMP_TOL_BPS { let mid = lo + (hi - lo) / 2; - match self.try_open_session(device, mid, split_mode, use_async) { + match self.try_open_session(device, mid, used_split, use_async) { Ok(e) => { if !best.is_null() { let _ = (api().destroy_encoder)(best); @@ -925,7 +948,15 @@ impl NvencD3d11Encoder { best_bps = mid; lo = mid; } - Err(_) => hi = mid, + Err(e) if nvenc_status::is_param_rejection(&e) => hi = mid, + Err(e) => { + // Environmental mid-search failure: don't let it shrink the + // search — release the partial result and propagate. + if !best.is_null() { + let _ = (api().destroy_encoder)(best); + } + return Err(e); + } } } if best.is_null() { @@ -933,14 +964,19 @@ impl NvencD3d11Encoder { // trying split-disabled in case a forced split (not the bitrate) is the blocker. let no_split = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32; - best = self - .try_open_session(device, FLOOR_BPS, split_mode, use_async) - .or_else(|_| { - self.try_open_session(device, FLOOR_BPS, no_split, use_async) - }) - .context( - "NVENC initialize_encoder rejected even at the floor bitrate", - )?; + best = match self.try_open_session(device, FLOOR_BPS, used_split, use_async) + { + Ok(e) => e, + Err(_) => { + let e = self + .try_open_session(device, FLOOR_BPS, no_split, use_async) + .context( + "NVENC initialize_encoder rejected even at the floor bitrate", + )?; + used_split = no_split; + e + } + }; best_bps = FLOOR_BPS; } tracing::warn!( @@ -948,21 +984,19 @@ impl NvencD3d11Encoder { clamped_mbps = best_bps / 1_000_000, "NVENC: requested bitrate above the GPU codec-level ceiling — clamped to the max accepted" ); + store_ceiling(self.ceiling_key(used_split), best_bps); self.bitrate_bps = best_bps; best } }; self.encoder = enc; - // Session init params a later `reconfigure_bitrate` must re-present verbatim. (Best - // effort: the floor fallback above may have succeeded split-disabled without updating - // `split_mode` — a reconfigure then presents the forced mode, NVENC rejects it, and - // the caller's rebuild fallback covers the mismatch.) - self.split_mode = split_mode; + // Session init params a later `reconfigure_bitrate` must re-present verbatim. + self.split_mode = used_split; self.session_async = use_async; // Session-budget accounting (Stage W3): record what this open holds so admission can // decline a parallel display the hardware can't afford. Weighted by the FINAL split // mode (a split session occupies one hardware session per engine). - self.session_units = split_mode_units(split_mode); + self.session_units = split_mode_units(used_split); LIVE_SESSION_UNITS.fetch_add(self.session_units, std::sync::atomic::Ordering::Relaxed); // (The clamp path above already logs the requested→clamped bitrate at warn; no second // info line for the same event here.) @@ -1552,6 +1586,15 @@ impl Encoder for NvencD3d11Encoder { self.bitrate_bps = bps; return true; } + // Cached codec-level ceiling: clamp the target BEFORE the driver call, so a known + // overshoot retargets to the ceiling IN PLACE instead of bouncing off the driver into + // the caller's full-rebuild fallback (an IDR plus ~half a second of session churn per + // ABR overshoot on the pre-cache path). The caller reads the clamp back through + // [`Encoder::applied_bitrate_bps`]. + let bps = match cached_ceiling(&self.ceiling_key(self.split_mode)) { + Some(ceiling) => bps.min(ceiling), + None => bps, + }; // SAFETY: `inited` ⟹ `self.encoder` is the live session and this runs on the encode // thread between submit/poll (`nvEncReconfigureEncoder` is a submit-side call, the // sanctioned side of the two-thread async split — the retrieve thread only ever locks @@ -1600,6 +1643,12 @@ impl Encoder for NvencD3d11Encoder { } } + fn applied_bitrate_bps(&self) -> Option { + // `bitrate_bps` is the post-clamp truth: the open path's ceiling search and the + // reconfigure path's cache clamp both write what the session ACTUALLY targets. + Some(self.bitrate_bps) + } + fn flush(&mut self) -> Result<()> { Ok(()) // P1/ULL + frameIntervalP=1: each submit yields its AU; no internal queue to drain. } diff --git a/crates/pf-encode/src/lib.rs b/crates/pf-encode/src/lib.rs index 19ebccfc..689b6f7e 100644 --- a/crates/pf-encode/src/lib.rs +++ b/crates/pf-encode/src/lib.rs @@ -243,6 +243,12 @@ impl Encoder for TrackedEncoder { fn reconfigure_bitrate(&mut self, bps: u64) -> bool { self.inner.reconfigure_bitrate(bps) } + // Forwarded (the same trap class as `set_wire_chunking`): the unforwarded default `None` + // would tell the session loop "no encoder truth here" and it would keep pacing/acking the + // requested rate even where NVENC clamped to the codec-level ceiling. + fn applied_bitrate_bps(&self) -> Option { + self.inner.applied_bitrate_bps() + } fn flush(&mut self) -> Result<()> { self.inner.flush() } diff --git a/crates/punktfunk-host/src/native.rs b/crates/punktfunk-host/src/native.rs index 102cf098..a9bb54d2 100644 --- a/crates/punktfunk-host/src/native.rs +++ b/crates/punktfunk-host/src/native.rs @@ -985,6 +985,18 @@ async fn serve_session( // full IDR when the encoder supports it (native-AMF LTR / Windows NVENC). let (rfi_tx, rfi_rx) = std::sync::mpsc::channel::<(u32, u32)>(); let (bitrate_tx, bitrate_rx) = std::sync::mpsc::channel::(); + // Encoder-truth bridge, data plane → control task (§ABR overdrive). The encode loop publishes + // here; the control task reads at `SetBitrate`-resolve time, so the ack the client's + // controller climbs from tracks what the encoder ACTUALLY does, not what was asked: + // - `live_bitrate`: the encoder's applied rate (kbps) — also the send pacer's/console's view. + // - `encoder_ceiling_kbps`: the discovered codec-level ceiling (0 = none discovered yet); + // resolves land at min(policy clamp, ceiling), so overshoots stop costing rebuilds. + // - `cadence_degraded`: encode can't hold the frame cadence — a climb is refused (acked at + // the current rate); the network isn't the bottleneck, more bits are anti-medicine. + // Plain atomics, not a channel: only the freshest value matters, and only at resolve time. + let live_bitrate = Arc::new(AtomicU32::new(welcome.bitrate_kbps)); + let encoder_ceiling_kbps = Arc::new(AtomicU32::new(0)); + let cadence_degraded = Arc::new(AtomicBool::new(false)); let (probe_tx, probe_rx) = std::sync::mpsc::channel::(); let (probe_result_tx, probe_result_rx) = tokio::sync::mpsc::unbounded_channel::(); // Mode-switch outcome, data plane → control task (same pattern as `probe_result_tx`): the accept @@ -1036,6 +1048,9 @@ async fn serve_session( live_reconfig_ok, adaptive_fec, session_bitrate_kbps, + live_bitrate.clone(), + encoder_ceiling_kbps.clone(), + cadence_degraded.clone(), fec_target_ctl, reconfig_tx, keyframe_tx, @@ -1429,6 +1444,9 @@ async fn serve_session( bitrate_rx, compositor, bitrate_kbps, + live_bitrate, + encoder_ceiling_kbps, + cadence_degraded, bitrate_auto, bit_depth, chroma, diff --git a/crates/punktfunk-host/src/native/control.rs b/crates/punktfunk-host/src/native/control.rs index e682f9cd..0c59e7a8 100644 --- a/crates/punktfunk-host/src/native/control.rs +++ b/crates/punktfunk-host/src/native/control.rs @@ -22,6 +22,13 @@ pub(super) async fn run( live_reconfig_ok: bool, adaptive_fec: bool, session_bitrate_kbps: u32, + /// Encoder-truth bridge (data plane → here, §ABR overdrive): the encoder's live applied rate, + /// its discovered codec-level ceiling (0 = unknown), and the "encode can't hold cadence" + /// flag. Read at `SetBitrate`-resolve time so the ack — the base the client's controller + /// climbs from — never promises a rate the encoder won't run at. + live_bitrate: Arc, + encoder_ceiling_kbps: Arc, + cadence_degraded: Arc, fec_target_ctl: Arc, reconfig_tx: std::sync::mpsc::Sender, keyframe_tx: std::sync::mpsc::Sender<()>, @@ -161,7 +168,31 @@ pub(super) async fn run( ); session_bitrate_kbps } else { - resolve_bitrate_kbps(req.bitrate_kbps) + let mut r = resolve_bitrate_kbps(req.bitrate_kbps); + // Encoder truth (§ABR overdrive): the ack below is the base the + // client's controller climbs from, so it must not promise past the + // encoder's discovered codec-level ceiling — the pre-fix path acked + // 1.01 Gbps while the ASIC ran 794 Mbps, and the controller climbed + // from the phantom number forever (a ~0.6 s rebuild + IDR per step). + let ceiling = encoder_ceiling_kbps.load(Ordering::Relaxed); + if ceiling != 0 && r > ceiling { + r = ceiling; + } + // Climb refusal while encode can't hold cadence: on a fat LAN no + // network signal ever stops the climb, and past the compute knee more + // bits only deepen the miss. Resolve a CLIMB to the current applied + // rate (descents pass — they're the cure); the short ack teaches the + // client controller its ceiling. + let live = live_bitrate.load(Ordering::Relaxed); + if cadence_degraded.load(Ordering::Relaxed) && live != 0 && r > live { + tracing::info!( + requested_kbps = req.bitrate_kbps, + held_kbps = live, + "bitrate climb refused — encode is behind cadence" + ); + r = live; + } + r }; tracing::debug!( requested_kbps = req.bitrate_kbps, diff --git a/crates/punktfunk-host/src/native/stream.rs b/crates/punktfunk-host/src/native/stream.rs index 4e66dd29..17bdfb30 100644 --- a/crates/punktfunk-host/src/native/stream.rs +++ b/crates/punktfunk-host/src/native/stream.rs @@ -909,6 +909,20 @@ pub(super) struct SessionContext { pub(super) compositor: crate::vdisplay::Compositor, /// Negotiated encoder bitrate (kbps). pub(super) bitrate_kbps: u32, + /// The encoder's live APPLIED rate (kbps) — shared with the send pacer, the web console, the + /// mgmt registry AND the control task (which acks climbs against it). The encode loop stores + /// `Encoder::applied_bitrate_bps` here after every apply, so everything downstream tracks + /// what the ASIC really targets, not what was requested (§ABR overdrive). + pub(super) live_bitrate: Arc, + /// The encoder's discovered codec-level bitrate ceiling (kbps; 0 = none discovered): written + /// when an apply comes back short, read by this loop (pre-clamp incoming requests — a + /// request already AT the ceiling then costs nothing) and by the control task (truthful + /// acks from the first post-discovery request). + pub(super) encoder_ceiling_kbps: Arc, + /// "Encode can't hold the frame cadence" (the escalation leaky bucket is elevated, or the + /// session escalated): while set, the control task refuses bitrate CLIMBS — the network + /// isn't the bottleneck, feeding the encoder more bits deepens the miss. + pub(super) cadence_degraded: Arc, /// The client asked for "Automatic" (`Hello::bitrate_kbps == 0`), so `bitrate_kbps` came from /// the host's codec-aware default. For PyroWave that default is the ~1.6 bpp operating point of /// the NEGOTIATED MODE (`resolve_bitrate_kbps_for`) — a mid-stream mode switch re-resolves it @@ -1035,6 +1049,9 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option(3); - // Live encoder bitrate, shared with the send thread's stats sample: a mid-stream adaptive - // bitrate change (bitrate_rx below) updates it so the console shows the actual target. - let live_bitrate = Arc::new(AtomicU32::new(bitrate_kbps)); + // `live_bitrate` (SessionContext) is shared with the send thread's stats sample AND the + // control task: a mid-stream adaptive bitrate change (bitrate_rx below) stores the + // encoder-APPLIED rate, so the console, pacer and climb-refusal acks all see the truth. // Live session mode, same pattern (H3): a mid-stream mode switch (reconfig below) updates it so // a stats capture armed after a resize registers the real mode. Seeded with the refresh the // initial build actually achieved (`interval_hz`), not the request — KWin may cap a virtual