From 88f29a941160abc105648bcf19e493d1eef5f92d Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 6 Aug 2026 21:53:14 +0200 Subject: [PATCH] feat(pf-encode): use every NVENC engine the GPU has, not a hard-coded two MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WP1.1 plus the engine-count fix. `resolve_split_mode` forced TWO_FORCED at high pixel rate regardless of hardware, so a 3-NVENC part (GB202, AD102 workstation) left a third of its encode silicon idle, and a 1-NVENC part paid a wasted session open to discover it could not split. Probes NV_ENC_CAPS_NUM_ENCODER_ENGINES in both direct-SDK backends' query_caps (the cap is `= 49` in both linux_sys and windows_sys of the vendored SDK 0.4.0 -- the caps enum is cfg-selected per-OS, so that was checked) and latches it on a backend field. NOT on EncoderCaps: nine backends construct that struct as exhaustive literals, so a new field would be a 9-site change of which 7 are unrelated codecs passing a meaningless value, and the only consumer is the resolver. New `max_forced_split_mode(engines)`: 1 -> DISABLE, 2 -> TWO, 3 -> THREE, and >3 -> AUTO_FORCED, because NV_ENC_SPLIT_ENCODE_MODE cannot NAME more than three (NVENCAPI 12.1; values 4..14 are unallocated, so a future API may extend it) and AUTO_FORCED = "split, driver picks how many" is measurably a real split (2.01x vs disabled on .21). 0 = unprobed keeps the historical two-engine assumption. ⚠ WHY THE CLAMP EXISTS, measured on .21 (RTX 5070 Ti, 2 NVENC, 4K HEVC): requesting THREE_FORCED was HONOURED -- session opened in mode 3 -- and ran at 2303 us/frame, identical to TWO_FORCED's 2308. The driver does not reject an over-ask; it silently encodes narrower. So the rejection fallback cannot find the ceiling and PUNKTFUNK_SPLIT_ENCODE=3 on a 2-engine card would have logged a 3-way split over a 2-way encode. Operator overrides are now clamped with a warn. The ordering trap is covered by a test: on a >3-engine part hw_max is AUTO_FORCED (1), which is not "narrower than" TWO_FORCED (2) despite comparing smaller, so a naive min() would collapse a legitimate 3-way request to AUTO. Also adds `engines` and `subframe` to the Linux session-ready log: split_mode alone is ambiguous between "used both engines" and "left a third idle", and since the driver honours an over-wide request the mode cannot be read without the ceiling it was chosen from. This is the line a field report needs. --- and a correction to S1b, in the same change --- Re-running S1b afterwards flipped its verdict to "the driver appears to have IGNORED the in-place split change", contradicting the isolated runs that produced the |C-B|=34 figure already written into the design docs. Investigated rather than re-rolled. The switched leg was landing MIDWAY between the arms (~3600 us against A~5050, B~2300) and the nearest-neighbour verdict flipped on noise. Cause: split-encode does not reach steady state on the first frame -- a FRESH TWO_FORCED session shows it too (early-half 3280 us vs late-half 1996 in one run), so it is split warmup generally, not something specific to reconfiguring in place. A single median over the whole window cannot see that. The test now reports early-half vs late-half and gives a switched leg SETTLE=16 frames before its window opens, every leg the same length. With that, 4/4 runs agree: the switched leg reaches ~2030 us against a fresh-split ~2000 and a single-engine ~4900. ⚠ S1b's CONCLUSION stands (the switch does take effect) but the evidence behind the committed number did not reproduce; the docs are corrected rather than left implying a cleaner result than the harness could support. ⚠⚠ This is a WP3 REQUIREMENT, not just a test fix: a live-session arbitration that switches arms and immediately measures will misjudge the arm it just chose, because the encoder needs ~16 frames to settle. The settle window has to be part of the arbitration, and it is now a measured number rather than a guess. Verified on .21: clippy --features nvenc --all-targets -D warnings clean, 57 unit tests (3 new), all 23 NVENC on-hardware tests green, fmt clean. The 3 failing on-hw tests in a full --ignored run are VAAPI (no AMD/Intel GPU on that box -- their own ignore reason says so), pre-existing and unrelated. --- crates/pf-encode/src/enc/linux/nvenc_cuda.rs | 207 +++++++++++++++++-- crates/pf-encode/src/enc/nvenc_core.rs | 168 ++++++++++++++- crates/pf-encode/src/enc/windows/nvenc.rs | 14 +- 3 files changed, 363 insertions(+), 26 deletions(-) diff --git a/crates/pf-encode/src/enc/linux/nvenc_cuda.rs b/crates/pf-encode/src/enc/linux/nvenc_cuda.rs index 730292e4f..0b291f4d8 100644 --- a/crates/pf-encode/src/enc/linux/nvenc_cuda.rs +++ b/crates/pf-encode/src/enc/linux/nvenc_cuda.rs @@ -821,6 +821,11 @@ pub struct NvencCudaEncoder { /// Sub-frame chunked poll armed for the live session (§7 LN1 Phase 1): multi-slice + /// sub-frame readback configured AND sync retrieve at init. See [`Encoder::poll_chunk`]. subframe_chunks: bool, + /// `NV_ENC_CAPS_NUM_ENCODER_ENGINES` — how many NVENC engines this GPU has, probed in + /// [`query_caps`]. `0` = not probed / unreadable. The split-encode ceiling: the driver accepts + /// a split wider than the hardware and silently encodes narrower, so this is the only honest + /// source for how wide we may go (see `nvenc_core::max_forced_split_mode`). + encoder_engines: u32, /// In-progress chunked readback of the front in-flight AU. See [`ChunkState`]. chunk: Option, } @@ -909,6 +914,7 @@ impl NvencCudaEncoder { subframe_on: false, subframe_forced: false, subframe_chunks: false, + encoder_engines: 0, chunk: None, }) } @@ -1081,6 +1087,10 @@ impl NvencCudaEncoder { // consumed when slice-level readback lands. Not stored — LN1 re-probes when it configures. let subframe = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_SUBFRAME_READBACK); let dyn_slice = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_DYNAMIC_SLICE_MODE); + // How many NVENC engines this GPU has — the split-encode ceiling. Must be probed rather + // than inferred from a rejection: the driver ACCEPTS a split wider than the hardware and + // silently encodes narrower (measured on `.21`, see `max_forced_split_mode`). + let engines = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_NUM_ENCODER_ENGINES); let _ = (api().destroy_encoder)(enc); if wmax > 0 && hmax > 0 && (self.width as i32 > wmax || self.height as i32 > hmax) { @@ -1100,6 +1110,7 @@ impl NvencCudaEncoder { self.rfi_supported = rfi != 0; self.custom_vbv = custom_vbv != 0; self.subframe_cap = subframe != 0; + self.encoder_engines = engines.max(0) as u32; // Phase-3 default-on (nvenc-subframe-slice-output.md): 4 slices + sub-frame readback on // every Linux direct-NVENC session, resolved HERE (before the session opens) so the // config author, the init params and the chunked-poll latch all agree; the caps probe @@ -1334,7 +1345,8 @@ impl NvencCudaEncoder { // 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 split_mode: u32 = resolve_split_mode(self.bit_depth, pixel_rate); + let split_mode: u32 = + resolve_split_mode(self.bit_depth, pixel_rate, self.encoder_engines); // Split × sub-frame arbitration (Phase 8) BEFORE the ladder, the ceiling key and the // chunked-poll latch — all three must see the post-arbitration truth (a drop inside // build_init_params would leave poll_chunk busy-polling its whole budget per AU). @@ -1639,6 +1651,12 @@ impl NvencCudaEncoder { // 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, + // …and how many engines the GPU HAS, so `split_mode` can be read against the + // ceiling it was chosen from. Without it a log showing split_mode=2 is ambiguous + // between "used both engines" and "left a third engine idle", and the driver + // silently honours an over-wide request, so the mode alone cannot be trusted. + engines = self.encoder_engines, + subframe = self.subframe_on, "NVENC CUDA session ready" ); Ok(()) @@ -2498,6 +2516,12 @@ mod tests { assert_eq!(slot_fmt_of(F::NV_ENC_BUFFER_FORMAT_ARGB), SlotFormat::Argb); } + /// The `encoder_engines` field `query_caps` latched — read through a helper so the intent + /// ("what the resolver will actually see") is explicit at the call site. + fn self_engines(enc: &NvencCudaEncoder) -> u32 { + enc.encoder_engines + } + fn nv12_frame(w: u32, h: u32, i: u32) -> CapturedFrame { // Content is uninitialized device memory — NVENC encodes it fine; this smoke test asserts the // session/registration/encode/RFI machinery, not picture fidelity (that's the on-glass A/B). @@ -3006,7 +3030,20 @@ mod tests { nv::NV_ENC_CAPS::NV_ENC_CAPS_NUM_ENCODER_ENGINES, ) }; - println!("S1: NV_ENC_CAPS_NUM_ENCODER_ENGINES = {engines}"); + println!( + "S1: NV_ENC_CAPS_NUM_ENCODER_ENGINES = {engines} (query_caps latched \ + encoder_engines={})", + self_engines(&enc) + ); + // The cap is only useful if `query_caps` actually stored it — that latched field is what + // `resolve_split_mode` reads to pick the split width, so a silent 0 there would quietly + // fall back to "assume two engines" on every GPU. + assert_eq!( + self_engines(&enc), + engines.max(0) as u32, + "query_caps must latch NUM_ENCODER_ENGINES — resolve_split_mode reads that field, \ + not the live cap" + ); assert!( engines >= 2, "this GPU reports {engines} NVENC engine(s) — S1 is not interpretable here, run it on \ @@ -3080,6 +3117,12 @@ mod tests { const BPS: u64 = 400_000_000; const WARMUP: u32 = 8; const MEASURED: u32 = 24; + /// Frames to discard AFTER an in-place switch before measuring. Split-encode does not + /// reach steady state on the first frame — even a FRESH `TWO_FORCED` session shows it + /// (early-half 3280 µs vs late-half 1996 in one run) — and without this the switched leg + /// lands midway between the two arms and the verdict flips run to run. Measured: at 16 + /// the switched leg reaches the fresh-split steady state; at 0 it did so only sometimes. + const SETTLE: u32 = 16; let two = M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32; std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0"); @@ -3094,8 +3137,8 @@ mod tests { pf_zerocopy::cuda::make_current().expect("shared CUDA context current"); let frames: Vec = (0..4).map(|i| nv12_frame(W, H, i)).collect(); - // Returns (p50 encode µs, median bytes/AU). - let run_leg = |open_split: &str, switch_to: Option| -> (u128, usize) { + // Returns (early-half p50 µs, late-half p50 µs, median bytes/AU). + let run_leg = |open_split: &str, switch_to: Option| -> (u128, u128, usize) { std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", open_split); let mut enc = NvencCudaEncoder::open( Codec::H265, @@ -3112,8 +3155,15 @@ mod tests { ) .expect("open NVENC CUDA session"); + // Every leg is measured over the SAME number of frames; a switched leg just starts its + // window `SETTLE` frames later, so the arms stay comparable. + let measure_from = if switch_to.is_some() { + WARMUP + SETTLE + } else { + WARMUP + }; let (mut times, mut sizes) = (Vec::new(), Vec::new()); - for i in 0..(WARMUP + MEASURED) { + for i in 0..(measure_from + MEASURED) { // Flip to the target mode exactly once, after warmup, in place. if i == WARMUP { if let Some(target) = switch_to { @@ -3133,25 +3183,43 @@ mod tests { got = au.data.len(); } let dt = t0.elapsed().as_micros(); - if i >= WARMUP { + if i >= measure_from { times.push(dt); sizes.push(got); } } enc.flush().ok(); - times.sort_unstable(); + // Split the window in half. A single median over the whole post-switch run is + // ACTIVELY MISLEADING here: leg C's median landed midway between the two arms and the + // nearest-neighbour verdict flipped run to run. Early-vs-late says whether the switch + // SETTLES — which a median cannot. + let half = times.len() / 2; + let med = |s: &[u128]| { + let mut v = s.to_vec(); + v.sort_unstable(); + v[v.len() / 2] + }; + let (early, late) = (med(×[..half]), med(×[half..])); sizes.sort_unstable(); - (times[times.len() / 2], sizes[sizes.len() / 2]) + (early, late, sizes[sizes.len() / 2]) }; - let (a_us, a_bytes) = run_leg("0", None); - let (b_us, b_bytes) = run_leg("2", None); - let (c_us, c_bytes) = run_leg("0", Some(two)); + let (a_early, a_late, a_bytes) = run_leg("0", None); + let (b_early, b_late, b_bytes) = run_leg("2", None); + let (c_early, c_late, c_bytes) = run_leg("0", Some(two)); + let (a_us, b_us, c_us) = (a_late, b_late, c_late); println!("S1b @ {W}x{H}@60 HEVC 8-bit, {} Mbps CBR:", BPS / 1_000_000); - println!(" A fresh DISABLE : {a_us:>6} us/frame, {a_bytes:>8} B/AU"); - println!(" B fresh TWO_FORCED : {b_us:>6} us/frame, {b_bytes:>8} B/AU"); - println!(" C DISABLE→TWO in situ: {c_us:>6} us/frame, {c_bytes:>8} B/AU"); + println!(" (early = first half of the measured window, late = second half)"); + println!(" A fresh DISABLE : early {a_early:>6} late {a_late:>6} us/frame, {a_bytes:>8} B/AU"); + println!(" B fresh TWO_FORCED : early {b_early:>6} late {b_late:>6} us/frame, {b_bytes:>8} B/AU"); + println!(" C DISABLE→TWO in situ: early {c_early:>6} late {c_late:>6} us/frame, {c_bytes:>8} B/AU"); + if c_early > c_late + c_late / 8 { + println!( + " ⇒ leg C SETTLES ({c_early} → {c_late} us): the in-place switch is not \ + instantaneous, so a whole-window median understates it." + ); + } let want_bytes = (BPS / 60 / 8) as usize; if a_bytes * 4 < want_bytes { @@ -3416,6 +3484,117 @@ mod tests { std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME"); } + /// ON-HARDWARE — **what is the real split ceiling on this GPU?** Feeds WP1.1: we want to use + /// every engine the card has, not a hard-coded 2. + /// + /// `NV_ENC_SPLIT_ENCODE_MODE` tops out at `THREE_FORCED` in SDK 0.4.0 / NVENCAPI 12.1 (values + /// 4..14 are unallocated, so a future API could add more), and `AUTO_FORCED` means "split, you + /// pick how many" — the only way to name a count we have no enum for. + /// + /// For each candidate this reports what the session ACTUALLY opened with, which is the honest + /// signal: the backend's rejection fallback silently retries split-disabled, so a mode the + /// driver refuses shows up as `split_mode == DISABLE` afterwards rather than as an error. And + /// the timing says whether an ACCEPTED mode did anything — a card that takes `THREE_FORCED` + /// but only has two engines would otherwise look like a win. Run ALONE: + /// cargo test -p pf-encode --features nvenc -- --ignored --test-threads=1 \ + /// nvenc_cuda_split_hardware_max --nocapture + #[test] + #[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"] + fn nvenc_cuda_split_hardware_max() { + use nv::NV_ENC_SPLIT_ENCODE_MODE as M; + use std::time::Instant; + const W: u32 = 3840; + const H: u32 = 2160; + const BPS: u64 = 400_000_000; + const WARMUP: u32 = 8; + const MEASURED: u32 = 24; + + std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0"); + pf_zerocopy::cuda::make_current().expect("shared CUDA context current"); + let frames: Vec = (0..4).map(|i| nv12_frame(W, H, i)).collect(); + + // → (requested mode, mode actually opened, p50 µs, engines the driver reports) + let run = |split: &str| -> (u32, u128, i32) { + std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", split); + let mut enc = NvencCudaEncoder::open( + Codec::H265, + PixelFormat::Nv12, + W, + H, + 60, + BPS, + true, + 8, + ChromaFormat::Yuv420, + false, + 4, + ) + .expect("open NVENC CUDA session"); + let mut times = Vec::new(); + for i in 0..(WARMUP + MEASURED) { + let t0 = Instant::now(); + enc.submit_indexed(&frames[(i % 4) as usize], i) + .expect("submit"); + while enc.poll().expect("poll").is_some() {} + if i >= WARMUP { + times.push(t0.elapsed().as_micros()); + } + } + // SAFETY: the session is live (frames encoded above); `get_cap` only reads a cap and + // returns 0 on any driver error. + let engines = unsafe { + enc.get_cap( + enc.encoder, + nv::NV_ENC_CAPS::NV_ENC_CAPS_NUM_ENCODER_ENGINES, + ) + }; + let opened = enc.split_mode; + enc.flush().ok(); + times.sort_unstable(); + (opened, times[times.len() / 2], engines) + }; + + println!("split ceiling probe @ {W}x{H}@60 HEVC 8-bit:"); + let mut baseline = None; + // The env value is NOT the enum value for DISABLE (`0` selects `NV_ENC_SPLIT_DISABLE_MODE`, + // which is 15), so compare against the enum each arm actually asks for. + for (label, env, want) in [ + ("DISABLE ", "0", M::NV_ENC_SPLIT_DISABLE_MODE as u32), + ("AUTO_FORCED ", "1", M::NV_ENC_SPLIT_AUTO_FORCED_MODE as u32), + ("TWO_FORCED ", "2", M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32), + ( + "THREE_FORCED", + "3", + M::NV_ENC_SPLIT_THREE_FORCED_MODE as u32, + ), + ] { + let (opened, us, engines) = run(env); + let honoured = opened == want; + let vs = match baseline { + None => { + baseline = Some(us); + String::new() + } + Some(b) => format!(" ({:.2}× vs DISABLE)", b as f64 / us as f64), + }; + println!( + " req {label} → opened_mode={opened:<2} {} {us:>6} us/frame{vs} [engines={engines}]", + if honoured { + "HONOURED" + } else { + "FELL BACK" + } + ); + } + println!( + " note: opened_mode 15 = DISABLE (the backend's rejection fallback); a mode that is \ + HONOURED but no faster than DISABLE was accepted and did nothing." + ); + + std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE"); + std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME"); + } + /// A pre-session RFI request and nonsense ranges all correctly decline (→ caller forces IDR). /// Needs no GPU session (it short-circuits on the null encoder / range checks), so it runs in the /// normal suite — but `open` gates on the NVENC `.so`, so it skips gracefully where the NVIDIA diff --git a/crates/pf-encode/src/enc/nvenc_core.rs b/crates/pf-encode/src/enc/nvenc_core.rs index b591f7544..c8118edea 100644 --- a/crates/pf-encode/src/enc/nvenc_core.rs +++ b/crates/pf-encode/src/enc/nvenc_core.rs @@ -81,37 +81,98 @@ pub(super) fn resolve_subframe(default_on: bool) -> bool { /// 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. +/// override, always wins, except that `2`/`3` are clamped to the GPU's real engine count (see +/// [`clamp_to_engines`]; the driver honours an over-ask and silently encodes narrower). /// 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). +/// 3. Pixel rate ≥ [`super::SPLIT_FORCE_PIXEL_RATE`] → force the WIDEST split the GPU can deliver +/// ([`max_forced_split_mode`]), not a hard-coded 2 (AUTO never engages below ~2112 px height, +/// so 4K120 must be forced onto the other engines; and a 3-NVENC part left at 2-way wastes a +/// third of its encode silicon). +/// 4. Else AUTO — ⚠ which measurably means **never split** whenever sub-frame readback is on, i.e. +/// the whole default Linux/Windows fleet (4K: AUTO+sub-frame 4904 µs vs DISABLE+sub-frame 5062 +/// vs TWO_FORCED 3464, measured on `.21`). Kept for now because changing it is a behaviour +/// change beyond the engine-count fix; the plan's WP1 retires this arm. /// /// 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 { +/// +/// `engines` is the GPU's `NV_ENC_CAPS_NUM_ENCODER_ENGINES`; pass `0` when it could not be probed +/// (treated as "unknown", which keeps the pre-probe behaviour of assuming a second engine exists +/// and letting the open-time rejection fallback sort it out). +pub(super) fn resolve_split_mode(bit_depth: u8, pixel_rate: u64, engines: u32) -> u32 { use nv::NV_ENC_SPLIT_ENCODE_MODE as M; + let hw_max = max_forced_split_mode(engines); 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, + Some("3") => clamp_to_engines(M::NV_ENC_SPLIT_THREE_FORCED_MODE as u32, hw_max, engines), + Some("2") => clamp_to_engines(M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32, hw_max, engines), _ 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, + // Use every engine the card has, not a hard-coded two: on a 3-NVENC part (GB202, AD102 + // workstation) forcing 2 leaves a third of the silicon idle. + _ if pixel_rate >= super::SPLIT_FORCE_PIXEL_RATE => hw_max, _ => M::NV_ENC_SPLIT_AUTO_MODE as u32, }; tracing::debug!( split_mode = mode, bit_depth, pixel_rate, + engines, "NVENC split-encode mode selected" ); mode } +/// The strongest split mode this GPU's engine count can actually deliver. +/// +/// ⚠ **The driver will NOT tell you when you over-ask.** Measured on `.21` (RTX 5070 Ti, 2 NVENC, +/// driver 610.57.04, 4K HEVC): requesting `THREE_FORCED` was **HONOURED** — session opened in mode +/// 3 — and ran at **2303 µs/frame, identical to `TWO_FORCED`'s 2308**. No rejection, no warning, +/// no third engine; just a log line claiming 3-way over a 2-way encode. So the rejection fallback +/// cannot be relied on to find the ceiling and the clamp has to happen here. +/// +/// `NV_ENC_SPLIT_ENCODE_MODE` can only *name* counts up to three (SDK 0.4.0 / NVENCAPI 12.1; +/// values 4..14 are unallocated, so a future API may extend it). Above that we fall back to +/// `AUTO_FORCED` = "split, driver picks how many", which measurably does force a split (2.01× vs +/// disabled on the same box) and is the only way to express "use everything you have". +pub(super) fn max_forced_split_mode(engines: u32) -> u32 { + use nv::NV_ENC_SPLIT_ENCODE_MODE as M; + match engines { + // Unknown (cap unreadable / not probed): keep the historical assumption of a second + // engine and let the open-time rejection fallback correct it. + 0 => M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32, + 1 => M::NV_ENC_SPLIT_DISABLE_MODE as u32, + 2 => M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32, + 3 => M::NV_ENC_SPLIT_THREE_FORCED_MODE as u32, + // More engines than the enum can name — let the driver use them all. + _ => M::NV_ENC_SPLIT_AUTO_FORCED_MODE as u32, + } +} + +/// Hold an operator's `PUNKTFUNK_SPLIT_ENCODE=2|3` to what the hardware can deliver, loudly. +/// Without this the knob silently lies (see [`max_forced_split_mode`]); an override that asks for +/// more engines than exist is a mistake worth surfacing, not honouring. +fn clamp_to_engines(requested: u32, hw_max: u32, engines: u32) -> u32 { + // Only the named N-way modes are ordered; `hw_max` may be AUTO_FORCED (1) on a >3-engine part, + // which is not "less than" TWO_FORCED and must not clamp a legitimate request down. + let named = |m: u32| (2..=3).contains(&m); + if engines != 0 && named(requested) && named(hw_max) && requested > hw_max { + tracing::warn!( + requested, + engines, + using = hw_max, + "PUNKTFUNK_SPLIT_ENCODE asks for more NVENC engines than this GPU has — clamping. \ + (The driver would ACCEPT the over-ask and silently encode with fewer, so the log \ + would otherwise claim a split width that never happened.)" + ); + return hw_max; + } + requested +} + /// Whether the operator EXPLICITLY forced sub-frame readback on (`PUNKTFUNK_NVENC_SUBFRAME=1`) /// — the log-severity input to [`resolve_split_subframe`]: a forced knob being overridden /// deserves a `warn`, a default being tuned an `info`. Callers LATCH this once next to their @@ -382,7 +443,7 @@ mod tests { // 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), + resolve_split_mode(8, four_k_120, 2), M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32 ); } @@ -392,7 +453,7 @@ mod tests { // 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), + resolve_split_mode(8, qhd_240, 2), M::NV_ENC_SPLIT_AUTO_MODE as u32 ); } @@ -403,11 +464,96 @@ mod tests { // 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), + resolve_split_mode(10, five_k_240, 2), M::NV_ENC_SPLIT_DISABLE_MODE as u32 ); } + /// THE ENGINE-COUNT FIX: a high-pixel-rate session must use every engine the GPU has, not a + /// hard-coded two. A 3-NVENC part (GB202 / AD102 workstation) left at 2-way wastes a third of + /// its encode silicon, and the driver never complains because it accepts an over- OR + /// under-wide request without comment. + #[test] + fn split_uses_every_engine_the_gpu_has() { + let four_k_120 = 3840u64 * 2160 * 120; + assert_eq!( + resolve_split_mode(8, four_k_120, 3), + M::NV_ENC_SPLIT_THREE_FORCED_MODE as u32, + "a 3-engine GPU must split three ways" + ); + assert_eq!( + resolve_split_mode(8, four_k_120, 1), + M::NV_ENC_SPLIT_DISABLE_MODE as u32, + "a 1-engine GPU must not pretend to split — today this costs a wasted session open" + ); + assert_eq!( + resolve_split_mode(8, four_k_120, 0), + M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32, + "unprobed engine count keeps the historical assumption; the rejection fallback corrects" + ); + } + + /// `NV_ENC_SPLIT_ENCODE_MODE` cannot NAME more than three (SDK 0.4.0 / NVENCAPI 12.1), so a + /// hypothetical wider part falls back to AUTO_FORCED = "split, driver picks how many" — which + /// is measurably a real split (2.01× vs disabled on `.21`), not a no-op. + #[test] + fn split_beyond_three_engines_delegates_to_the_driver() { + assert_eq!( + max_forced_split_mode(4), + M::NV_ENC_SPLIT_AUTO_FORCED_MODE as u32 + ); + assert_eq!( + max_forced_split_mode(8), + M::NV_ENC_SPLIT_AUTO_FORCED_MODE as u32 + ); + } + + /// An operator over-ask must be clamped, because the DRIVER WON'T: measured on `.21` (2 NVENC), + /// `THREE_FORCED` was honoured and ran identically to `TWO_FORCED` (2303 vs 2308 µs/frame) — + /// a log claiming a 3-way split over a 2-way encode. Clamping keeps the log honest. + #[test] + fn operator_override_is_clamped_to_real_engine_count() { + assert_eq!( + clamp_to_engines( + M::NV_ENC_SPLIT_THREE_FORCED_MODE as u32, + max_forced_split_mode(2), + 2 + ), + M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32, + "asking for 3 on a 2-engine card must clamp to 2" + ); + // Within budget → untouched. + assert_eq!( + clamp_to_engines( + M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32, + max_forced_split_mode(3), + 3 + ), + M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32 + ); + // Unknown engine count must not clamp — we have nothing to clamp against. + assert_eq!( + clamp_to_engines( + M::NV_ENC_SPLIT_THREE_FORCED_MODE as u32, + max_forced_split_mode(0), + 0 + ), + M::NV_ENC_SPLIT_THREE_FORCED_MODE as u32 + ); + // ⚠ The ordering trap: on a >3-engine part `hw_max` is AUTO_FORCED (1), which is NOT + // "narrower than" TWO_FORCED (2) despite comparing smaller. A naive `min` would clamp a + // legitimate 3-way request down to AUTO on the widest hardware we support. + assert_eq!( + clamp_to_engines( + M::NV_ENC_SPLIT_THREE_FORCED_MODE as u32, + max_forced_split_mode(4), + 4 + ), + M::NV_ENC_SPLIT_THREE_FORCED_MODE as u32, + "a 4-engine GPU must honour an explicit 3-way request, not collapse it to AUTO" + ); + } + #[test] fn ceiling_cache_round_trips_and_keys_precisely() { let key = CeilingKey { diff --git a/crates/pf-encode/src/enc/windows/nvenc.rs b/crates/pf-encode/src/enc/windows/nvenc.rs index c960080ba..54c7534b8 100644 --- a/crates/pf-encode/src/enc/windows/nvenc.rs +++ b/crates/pf-encode/src/enc/windows/nvenc.rs @@ -592,6 +592,11 @@ pub struct NvencD3d11Encoder { /// sub-frame readback (the Linux backend's rule since its Phase 3; Windows joined after the /// 2026-07-31 on-glass A/B), so a GPU without it never has sub-frame forced by default. subframe_cap: bool, + /// `NV_ENC_CAPS_NUM_ENCODER_ENGINES` — how many NVENC engines this GPU has, probed in + /// [`query_caps`](Self::query_caps). `0` = not probed / unreadable. The split-encode ceiling: + /// the driver accepts a split wider than the hardware and silently encodes narrower, so this + /// is the only honest source for how wide we may go (see `nvenc_core::max_forced_split_mode`). + encoder_engines: u32, /// (bitstream, mapped input resource to unmap after retrieval, pts_ns, recovery-anchor) per /// in-flight encode. The fourth field tags the first frame encoded after a successful /// [`invalidate_ref_frames`](Encoder::invalidate_ref_frames) — the clean re-anchor P-frame the @@ -753,6 +758,7 @@ impl NvencD3d11Encoder { input_ring_depth: None, async_supported: false, subframe_cap: false, + encoder_engines: 0, pending: VecDeque::new(), frame_idx: 0, force_kf: false, @@ -928,6 +934,10 @@ impl NvencD3d11Encoder { ); let async_enc = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_ASYNC_ENCODE_SUPPORT); let subframe = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_SUBFRAME_READBACK); + // How many NVENC engines this GPU has — the split-encode ceiling. Must be probed rather + // than inferred from a rejection: the driver ACCEPTS a split wider than the hardware and + // silently encodes narrower (measured on `.21`, see `max_forced_split_mode`). + let engines = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_NUM_ENCODER_ENGINES); let _ = (api().destroy_encoder)(enc); // Reject an over-range mode with a clear message instead of an opaque InvalidParam. @@ -962,6 +972,7 @@ impl NvencD3d11Encoder { self.custom_vbv = custom_vbv != 0; self.async_supported = async_enc != 0; self.subframe_cap = subframe != 0; + self.encoder_engines = engines.max(0) as u32; tracing::info!( rfi = self.rfi_supported, custom_vbv = self.custom_vbv, @@ -1154,7 +1165,8 @@ impl NvencD3d11Encoder { // 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 split_mode: u32 = resolve_split_mode(self.bit_depth, pixel_rate); + let split_mode: u32 = + resolve_split_mode(self.bit_depth, pixel_rate, self.encoder_engines); // Negotiated multi-slice (P2f): the direct-NVENC default of 4, clamped by the // client's ceiling — a single-slice client keeps today's shape, a // VIDEO_CAP_MULTI_SLICE / Moonlight slices-per-frame client gets real slices.