forked from unom/punktfunk
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:
@@ -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(×[..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<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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user