feat(pf-encode): use every NVENC engine the GPU has, not a hard-coded two

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.
This commit is contained in:
2026-08-06 21:54:00 +02:00
parent 70b81ac3d7
commit 88f29a9411
3 changed files with 363 additions and 26 deletions
+193 -14
View File
@@ -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<ChunkState>,
}
@@ -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<CapturedFrame> = (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<u32>| -> (u128, usize) {
// Returns (early-half p50 µs, late-half p50 µs, median bytes/AU).
let run_leg = |open_split: &str, switch_to: Option<u32>| -> (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(&times[..half]), med(&times[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<CapturedFrame> = (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
+157 -11
View File
@@ -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 {
+13 -1
View File
@@ -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.