feat(pf-encode): WP3 — live split arbitration, measured on the session, no IDR
The fix S1 unlocked. Rather than predict the right split mode at open — which
cannot work, because the decision depends on bits/frame and an Automatic client's
steady-state bitrate is unknown at open (ABR climbs in place afterwards) — the
encoder now measures both arms on the live session and keeps the winner. S1
proved nvEncReconfigureEncoder takes a changed splitEncodeMode with
resetEncoder=0, emits no IDR, and actually applies it, so the experiment is
invisible on the wire.
Deliberately measures instead of modelling: hard-coded per-arch constants are
exactly how the rule this replaces went wrong (one 5120x1440@240 Ada datapoint
generalised into a fleet-wide 10-bit veto). A measurement tracks driver updates
for free.
`SplitArbiter` (pure state machine, unit-tested without a GPU): measure incumbent
-> switch -> SETTLE -> measure challenger -> keep the winner, else switch back.
Verdicts cache per (gpu, codec, mode, depth, chroma) so later sessions open
straight into the winning arm; the key is CeilingKey minus split_mode, since the
split mode is the thing being decided.
⚠ SETTLE_FRAMES=16 is load-bearing, not padding: split-encode does not reach
steady state on the first frame (a FRESH TWO_FORCED session measured early-half
3280us vs late-half 1996), so judging an arm right after switching reads the
transient — intermittently, which would then be cached. A unit test feeds exactly
that transient and asserts the arbiter still sees the steady state.
Safety gates, all correctness conditions rather than preferences: opt-in
(PUNKTFUNK_NVENC_SPLIT_ARBITRATE=1) while it earns trust; an operator
PUNKTFUNK_SPLIT_ENCODE pin always wins; skip if a verdict is already cached; sync
depth-1 only (async_rt.is_none(), same gate chunked poll uses — under pipelined
retrieve the submit->AU span includes queue depth and the comparison is noise);
needs >=2 engines; never H.264.
⚠ And the one that bounds this increment: NO SUB-FRAME TRADE. For HEVC, forcing
split gives up sub-frame readback, which costs send/encode overlap the ENCODER
CANNOT SEE — it measures encode time only, so it would reliably prefer split and
silently make end-to-end latency worse. So arbitration runs only where nothing is
traded: sub-frame already off, or AV1 (both features legal). Pricing that trade
needs the host's send cost and is the next work package.
Challenger choice tests the question worth asking — anything not already the
widest forced split is challenged BY the widest ("are we leaving engines idle?").
The naive "challenge whatever we are not" spent the experiment re-proving that
splitting beats not-splitting, while parking the session on the slow arm to do
it, because 4K60 sits on the fallthrough AUTO.
⚠ Every new nvenc_core item is linux-gated: the arbiter is wired into the Linux
backend only for now and nvenc_core compiles on Windows too. Caught by the .133
check, not by reasoning — the first cut failed Windows clippy with 12 dead_code
errors, the exact item-level trap this file already carries a scar from.
Verified .21: clippy --features nvenc --all-targets -D warnings clean, 62 unit
tests (4 new arbiter tests), 23/23 NVENC on-hardware green including a new
end-to-end convergence test asserting ZERO extra IDRs and a cached verdict.
Verified .133: Windows clippy -D warnings clean, zero dead_code. fmt clean.
This commit is contained in:
@@ -67,9 +67,11 @@
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::nvenc_core::{
|
||||
apply_low_latency_config, build_init_params, cached_ceiling, codec_guid, plan_range_recovery,
|
||||
resolve_slices, resolve_split_mode, resolve_split_subframe, resolve_subframe, store_ceiling,
|
||||
subframe_env_forced, CeilingKey, LowLatencyConfig, NvStatusExt, RangePlan,
|
||||
apply_low_latency_config, build_init_params, cached_ceiling, cached_split_verdict, codec_guid,
|
||||
max_forced_split_mode, plan_range_recovery, resolve_slices, resolve_split_mode,
|
||||
resolve_split_subframe, resolve_subframe, store_ceiling, store_split_verdict,
|
||||
subframe_env_forced, ArbAction, CeilingKey, LowLatencyConfig, NvStatusExt, RangePlan,
|
||||
SplitArbiter, SplitKey,
|
||||
};
|
||||
use super::nvenc_status;
|
||||
use super::{AuChunk, ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
|
||||
@@ -826,6 +828,11 @@ pub struct NvencCudaEncoder {
|
||||
/// 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,
|
||||
/// Submit stamp for the split arbiter's per-frame cost (sync depth-1 path only).
|
||||
last_submit_at: Option<std::time::Instant>,
|
||||
/// The live split-mode experiment, when one is running. `None` = not arbitrating (gated off,
|
||||
/// already decided this process, or the config is one we refuse to arbitrate).
|
||||
arbiter: Option<SplitArbiter>,
|
||||
/// In-progress chunked readback of the front in-flight AU. See [`ChunkState`].
|
||||
chunk: Option<ChunkState>,
|
||||
}
|
||||
@@ -915,6 +922,8 @@ impl NvencCudaEncoder {
|
||||
subframe_forced: false,
|
||||
subframe_chunks: false,
|
||||
encoder_engines: 0,
|
||||
last_submit_at: None,
|
||||
arbiter: None,
|
||||
chunk: None,
|
||||
})
|
||||
}
|
||||
@@ -1345,8 +1354,25 @@ 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 =
|
||||
let mut split_mode: u32 =
|
||||
resolve_split_mode(self.bit_depth, pixel_rate, self.encoder_engines);
|
||||
// A verdict this process already measured for this exact config wins over the static
|
||||
// rule — that is the whole point of arbitrating, and it lets later sessions skip the
|
||||
// ~1 s experiment. An operator pin still beats both (checked inside `resolve_split_mode`,
|
||||
// so only consult the cache when the knob is unset).
|
||||
if std::env::var_os("PUNKTFUNK_SPLIT_ENCODE").is_none() {
|
||||
if let Some(known) = cached_split_verdict(&self.split_key()) {
|
||||
if known != split_mode {
|
||||
tracing::info!(
|
||||
from = split_mode,
|
||||
to = known,
|
||||
"NVENC: using the split mode a previous arbitration measured as \
|
||||
fastest for this config"
|
||||
);
|
||||
}
|
||||
split_mode = known;
|
||||
}
|
||||
}
|
||||
// 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).
|
||||
@@ -1659,10 +1685,140 @@ impl NvencCudaEncoder {
|
||||
subframe = self.subframe_on,
|
||||
"NVENC CUDA session ready"
|
||||
);
|
||||
self.arm_split_arbiter();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide whether this session may run a live split experiment, and arm it if so.
|
||||
///
|
||||
/// Opt-in (`PUNKTFUNK_NVENC_SPLIT_ARBITRATE=1`) while it earns trust. Every other gate is a
|
||||
/// correctness condition, not a preference:
|
||||
///
|
||||
/// - **Operator pin wins.** `PUNKTFUNK_SPLIT_ENCODE` set ⇒ never arbitrate; a pinned mode is an
|
||||
/// instruction, and an A/B that overrides it would make the knob useless for exactly the
|
||||
/// debugging it exists for.
|
||||
/// - **Already decided.** A cached verdict for this config was applied at open; re-running the
|
||||
/// experiment every session would pay its cost forever.
|
||||
/// - **Sync depth-1 only** (`async_rt.is_none()`), the same gate chunked poll uses: the
|
||||
/// per-frame cost is measured as submit → AU, which is only the encode on this path. Under
|
||||
/// pipelined retrieve that span includes queue depth and the comparison would be noise.
|
||||
/// - **Needs a second engine**, and split must be applicable at all (never H.264).
|
||||
/// - ⚠ **No sub-frame trade.** For HEVC, forcing split gives up sub-frame readback, which costs
|
||||
/// send/encode overlap the ENCODER CANNOT SEE — it measures encode time only, so it would
|
||||
/// reliably prefer split and silently make end-to-end latency worse. So we arbitrate only
|
||||
/// where nothing is traded: sub-frame already off, or AV1 (where both features are legal).
|
||||
/// Pricing that trade needs the host's send cost and is the next work package.
|
||||
fn arm_split_arbiter(&mut self) {
|
||||
if !matches!(
|
||||
std::env::var("PUNKTFUNK_NVENC_SPLIT_ARBITRATE").as_deref(),
|
||||
Ok("1")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if std::env::var_os("PUNKTFUNK_SPLIT_ENCODE").is_some()
|
||||
|| cached_split_verdict(&self.split_key()).is_some()
|
||||
|| self.async_rt.is_some()
|
||||
|| self.encoder_engines < 2
|
||||
|| self.codec == Codec::H264
|
||||
{
|
||||
return;
|
||||
}
|
||||
if self.subframe_on && self.codec != Codec::Av1 {
|
||||
tracing::debug!(
|
||||
"NVENC split arbitration skipped: sub-frame readback is on and this codec cannot \
|
||||
keep it while split, so the trade costs send overlap the encoder cannot measure"
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Pick the challenger that tests the question worth asking: "are we leaving engines idle?"
|
||||
// So anything that is not already the widest forced split is challenged BY the widest, and
|
||||
// only a session already there is challenged by single-engine ("is splitting even helping
|
||||
// here?").
|
||||
//
|
||||
// ⚠ Not "whatever we are not": with the fallthrough `AUTO` incumbent that a 4K60 session
|
||||
// gets, the naive version challenged with DISABLE and spent the experiment re-proving that
|
||||
// splitting beats not-splitting — while parking the session on the slow arm to do it.
|
||||
let disable = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
||||
let widest = max_forced_split_mode(self.encoder_engines);
|
||||
let challenger = if self.split_mode == widest {
|
||||
disable
|
||||
} else {
|
||||
widest
|
||||
};
|
||||
if challenger == self.split_mode {
|
||||
return;
|
||||
}
|
||||
tracing::info!(
|
||||
incumbent = self.split_mode,
|
||||
challenger,
|
||||
"NVENC split arbitration armed — measuring both arms on the live session (no IDR)"
|
||||
);
|
||||
self.arbiter = Some(SplitArbiter::new(self.split_mode, challenger));
|
||||
}
|
||||
|
||||
/// The config identity this session's split verdict is cached under.
|
||||
fn split_key(&self) -> SplitKey {
|
||||
SplitKey {
|
||||
gpu: self.cu_ctx as u64,
|
||||
codec: self.codec,
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
fps: self.fps,
|
||||
bit_depth: self.bit_depth,
|
||||
chroma_444: self.chroma_444,
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the LIVE session to `mode` without an IDR — spike S1 proved `nvEncReconfigureEncoder`
|
||||
/// takes a changed `splitEncodeMode` with `resetEncoder=0`, emits no keyframe, and actually
|
||||
/// applies it. Reuses the bitrate reconfigure path at the CURRENT rate, so only the split mode
|
||||
/// moves. Returns whether the driver accepted it; on refusal the field is restored so the
|
||||
/// encoder's idea of its own session stays truthful.
|
||||
fn apply_split_mode(&mut self, mode: u32) -> bool {
|
||||
let previous = self.split_mode;
|
||||
self.split_mode = mode;
|
||||
if self.reconfigure_bitrate(self.bitrate_bps) {
|
||||
true
|
||||
} else {
|
||||
tracing::warn!(
|
||||
from = previous,
|
||||
to = mode,
|
||||
"NVENC split arbitration: driver refused the in-place split change — staying put"
|
||||
);
|
||||
self.split_mode = previous;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed one frame's encode cost to the split arbiter and act on its verdict.
|
||||
fn feed_split_arbiter(&mut self, encode_us: u64) {
|
||||
let Some(arb) = self.arbiter.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let action = arb.on_frame(encode_us);
|
||||
let done = arb.is_done();
|
||||
match action {
|
||||
Some(ArbAction::SwitchTo(mode)) => {
|
||||
if !self.apply_split_mode(mode) {
|
||||
// The experiment cannot proceed if the session will not move — abandon it
|
||||
// rather than compare two measurements of the same arm.
|
||||
self.arbiter = None;
|
||||
return;
|
||||
}
|
||||
}
|
||||
Some(ArbAction::Settled(mode)) => {
|
||||
store_split_verdict(self.split_key(), mode);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if done {
|
||||
// A "switch back to the incumbent" verdict settles on the mode now live.
|
||||
store_split_verdict(self.split_key(), self.split_mode);
|
||||
self.arbiter = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy the captured `DeviceBuffer` into the ring slot's registered input surface (device→device
|
||||
/// on the shared context). `sync` blocks until the copy completes (the pre-existing behavior);
|
||||
/// `!sync` enqueues on the encode thread's copy stream and leaves ordering to the session's
|
||||
@@ -2037,6 +2193,10 @@ impl Encoder for NvencCudaEncoder {
|
||||
// never emits an IDR on its own, so this matches the eventual pictureType.
|
||||
is_idr,
|
||||
));
|
||||
// Stamp for the split arbiter's per-frame cost. Deliberately a single field rather
|
||||
// than a sixth `pending` element: the arbiter only runs on the sync depth-1 path
|
||||
// (`async_rt.is_none()`), where at most one encode is outstanding.
|
||||
self.last_submit_at = Some(std::time::Instant::now());
|
||||
}
|
||||
if sample {
|
||||
tracing::info!(
|
||||
@@ -2217,6 +2377,16 @@ impl Encoder for NvencCudaEncoder {
|
||||
if !map.is_null() {
|
||||
let _ = (api().unmap_input_resource)(self.encoder, map);
|
||||
}
|
||||
// One frame's encode cost, submit → AU complete. Only meaningful on this sync,
|
||||
// depth-1 path (the arbiter is gated to it), where `lock_bitstream` above blocked
|
||||
// until the ASIC finished, so the span is the encode rather than a queue wait.
|
||||
let encode_us = self
|
||||
.last_submit_at
|
||||
.take()
|
||||
.map(|t| t.elapsed().as_micros() as u64);
|
||||
if let Some(us) = encode_us {
|
||||
self.feed_split_arbiter(us);
|
||||
}
|
||||
Ok(Some(EncodedFrame {
|
||||
data,
|
||||
pts_ns,
|
||||
@@ -3613,6 +3783,103 @@ mod tests {
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
}
|
||||
|
||||
/// ON-HARDWARE — the live split arbitration end to end (WP3). Opens a 4K session that the
|
||||
/// static rule leaves single-engine, lets the arbiter run, and asserts it converges to the
|
||||
/// faster arm **without emitting a single IDR** and records a verdict other sessions can reuse.
|
||||
///
|
||||
/// Sub-frame is pinned off so the arbiter's own no-trade gate lets it arm (see
|
||||
/// `arm_split_arbiter`); this is the shape the first increment supports.
|
||||
///
|
||||
/// Asserts behaviour, not timing: that it settles, that it lands on the arm the ~2× split
|
||||
/// advantage implies, and — the load-bearing one — **zero keyframes after the opening IDR**,
|
||||
/// which is the whole reason this design is allowed to exist. Run ALONE:
|
||||
/// cargo test -p pf-encode --features nvenc -- --ignored --test-threads=1 \
|
||||
/// nvenc_cuda_split_arbitration_converges --nocapture
|
||||
#[test]
|
||||
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
|
||||
fn nvenc_cuda_split_arbitration_converges() {
|
||||
const W: u32 = 3840;
|
||||
const H: u32 = 2160;
|
||||
let disable = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
||||
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SPLIT_ARBITRATE", "1");
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
|
||||
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
let frames: Vec<CapturedFrame> = (0..4).map(|i| nv12_frame(W, H, i)).collect();
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
Codec::H265,
|
||||
PixelFormat::Nv12,
|
||||
W,
|
||||
H,
|
||||
60,
|
||||
400_000_000,
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
4,
|
||||
)
|
||||
.expect("open NVENC CUDA session");
|
||||
|
||||
let mut keyframes = 0usize;
|
||||
let mut aus = 0usize;
|
||||
// Enough frames for measure + settle + measure with room to spare.
|
||||
for i in 0..140u32 {
|
||||
enc.submit_indexed(&frames[(i % 4) as usize], i)
|
||||
.expect("submit");
|
||||
while let Some(au) = enc.poll().expect("poll") {
|
||||
aus += 1;
|
||||
keyframes += au.keyframe as usize;
|
||||
}
|
||||
}
|
||||
let final_mode = enc.split_mode;
|
||||
let still_arbitrating = enc.arbiter.is_some();
|
||||
let verdict = cached_split_verdict(&enc.split_key());
|
||||
let enc_engines = enc.encoder_engines;
|
||||
enc.flush().ok();
|
||||
|
||||
println!(
|
||||
"arbitration: {aus} AUs, {keyframes} keyframes, final split_mode={final_mode}, \
|
||||
cached verdict={verdict:?}, still running={still_arbitrating}"
|
||||
);
|
||||
assert!(aus > 100, "not enough AUs to complete an arbitration");
|
||||
assert!(
|
||||
!still_arbitrating,
|
||||
"arbitration did not finish in 140 frames"
|
||||
);
|
||||
assert_eq!(
|
||||
keyframes, 1,
|
||||
"THE POINT OF THIS DESIGN: arbitration must cost ZERO extra IDRs — only the session's \
|
||||
opening one"
|
||||
);
|
||||
assert_eq!(
|
||||
verdict,
|
||||
Some(final_mode),
|
||||
"the winning arm must be cached so later sessions skip the experiment"
|
||||
);
|
||||
assert_ne!(
|
||||
final_mode, disable,
|
||||
"at 4K with two engines a splitting arm is ~2x faster, so single-engine must not win"
|
||||
);
|
||||
// The static rule leaves 4K60 on the fallthrough AUTO (497.7 Mpix/s is under
|
||||
// SPLIT_FORCE_PIXEL_RATE), so the experiment is AUTO vs the widest forced split — the
|
||||
// "are we leaving engines idle?" question. Either outcome is legitimate; what must NOT
|
||||
// happen is landing on single-engine.
|
||||
println!(
|
||||
" (incumbent was the static rule's choice; challenger was mode {})",
|
||||
max_forced_split_mode(enc_engines)
|
||||
);
|
||||
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SPLIT_ARBITRATE");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
// The verdict cache is process-global: leaving this session's result in it would steer
|
||||
// every later test that opens the same config with the split env unset (the D5 legs do
|
||||
// exactly that).
|
||||
super::super::nvenc_core::clear_split_verdicts();
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -353,6 +353,153 @@ mod split_subframe_tests {
|
||||
}
|
||||
}
|
||||
|
||||
// Split arbitration is wired into the Linux direct-SDK backend only for now, and
|
||||
// `nvenc_core` compiles on Windows too — so every item below is linux-gated or it trips
|
||||
// the item-level dead_code trap this file already carries a scar from (see
|
||||
// `subframe_env_forced`). Ungating is part of the Windows wiring, not a cleanup.
|
||||
#[cfg(target_os = "linux")]
|
||||
/// What the split arbiter wants the backend to do next.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum ArbAction {
|
||||
/// Reconfigure the live session to this split mode (in place — S1 proved this is IDR-free).
|
||||
SwitchTo(u32),
|
||||
/// Arbitration finished; this mode won and the arbiter will ask for nothing further.
|
||||
Settled(u32),
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ArbState {
|
||||
MeasuringIncumbent,
|
||||
Settling,
|
||||
MeasuringChallenger,
|
||||
Done,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
/// Picks the faster of two NVENC split modes **on the live session**, by measuring both.
|
||||
///
|
||||
/// This exists because the alternative — predicting the right mode at open — cannot work: the
|
||||
/// decision depends on bits/frame, and for an Automatic client the host does not know the
|
||||
/// steady-state bitrate at open (ABR climbs in place afterwards). Spike S1 showed
|
||||
/// `nvEncReconfigureEncoder` accepts a changed `splitEncodeMode` with `resetEncoder=0`, emits **no
|
||||
/// IDR**, and genuinely takes effect — so the encoder can simply try both and keep the winner,
|
||||
/// with nothing visible on the wire.
|
||||
///
|
||||
/// Deliberately measures rather than models: hard-coded per-architecture constants are exactly how
|
||||
/// the rule this replaces went wrong (one 5120×1440@240 Ada datapoint generalised into a fleet-wide
|
||||
/// 10-bit veto). A measurement tracks driver updates for free.
|
||||
///
|
||||
/// ⚠ **`SETTLE_FRAMES` is load-bearing, not padding.** Split-encode does not reach steady state on
|
||||
/// the first frame — a *fresh* `TWO_FORCED` session measured early-half 3280 µs against late-half
|
||||
/// 1996 on `.21`. Judging an arm immediately after switching to it reads the transient, and does so
|
||||
/// **intermittently**, which is the worst failure mode: the verdict would be wrong only sometimes,
|
||||
/// and then be cached.
|
||||
pub(super) struct SplitArbiter {
|
||||
state: ArbState,
|
||||
incumbent: u32,
|
||||
challenger: u32,
|
||||
samples: Vec<u64>,
|
||||
incumbent_us: u64,
|
||||
settle_left: u32,
|
||||
}
|
||||
|
||||
/// Frames discarded after a switch before the challenger is judged (measured — see the struct doc).
|
||||
#[cfg(target_os = "linux")]
|
||||
const SETTLE_FRAMES: u32 = 16;
|
||||
/// Frames measured per arm. Long enough to median out content variation, short enough that the
|
||||
/// whole arbitration is over in well under a second at 60 fps.
|
||||
#[cfg(target_os = "linux")]
|
||||
const SAMPLE_FRAMES: usize = 24;
|
||||
/// The challenger must beat the incumbent by this much to win. Switching is not free (a
|
||||
/// reconfigure, and for HEVC it costs sub-frame readback), so a coin-flip difference should leave
|
||||
/// the session where it already is.
|
||||
#[cfg(target_os = "linux")]
|
||||
const WIN_MARGIN_PCT: u64 = 10;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl SplitArbiter {
|
||||
pub(super) fn new(incumbent: u32, challenger: u32) -> Self {
|
||||
Self {
|
||||
state: ArbState::MeasuringIncumbent,
|
||||
incumbent,
|
||||
challenger,
|
||||
samples: Vec::with_capacity(SAMPLE_FRAMES),
|
||||
incumbent_us: 0,
|
||||
settle_left: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed one frame's encode time. Returns an action when the arbiter wants the session changed.
|
||||
pub(super) fn on_frame(&mut self, us: u64) -> Option<ArbAction> {
|
||||
match self.state {
|
||||
ArbState::Done => None,
|
||||
ArbState::Settling => {
|
||||
self.settle_left = self.settle_left.saturating_sub(1);
|
||||
if self.settle_left == 0 {
|
||||
self.state = ArbState::MeasuringChallenger;
|
||||
self.samples.clear();
|
||||
}
|
||||
None
|
||||
}
|
||||
ArbState::MeasuringIncumbent => {
|
||||
self.samples.push(us);
|
||||
if self.samples.len() < SAMPLE_FRAMES {
|
||||
return None;
|
||||
}
|
||||
self.incumbent_us = median(&mut self.samples);
|
||||
self.state = ArbState::Settling;
|
||||
self.settle_left = SETTLE_FRAMES;
|
||||
Some(ArbAction::SwitchTo(self.challenger))
|
||||
}
|
||||
ArbState::MeasuringChallenger => {
|
||||
self.samples.push(us);
|
||||
if self.samples.len() < SAMPLE_FRAMES {
|
||||
return None;
|
||||
}
|
||||
let challenger_us = median(&mut self.samples);
|
||||
self.state = ArbState::Done;
|
||||
// Strictly better by the margin, or the incumbent keeps the session. Equal-ish is
|
||||
// deliberately a win for the incumbent: we are already there.
|
||||
let threshold = self
|
||||
.incumbent_us
|
||||
.saturating_sub(self.incumbent_us.saturating_mul(WIN_MARGIN_PCT) / 100);
|
||||
if challenger_us < threshold {
|
||||
tracing::info!(
|
||||
winner = self.challenger,
|
||||
winner_us = challenger_us,
|
||||
loser = self.incumbent,
|
||||
loser_us = self.incumbent_us,
|
||||
"NVENC split arbitration: challenger wins — keeping it"
|
||||
);
|
||||
Some(ArbAction::Settled(self.challenger))
|
||||
} else {
|
||||
tracing::info!(
|
||||
winner = self.incumbent,
|
||||
winner_us = self.incumbent_us,
|
||||
loser = self.challenger,
|
||||
loser_us = challenger_us,
|
||||
"NVENC split arbitration: incumbent held — switching back"
|
||||
);
|
||||
// The session is currently running the challenger, so returning to the
|
||||
// incumbent is an actual reconfigure, not a no-op.
|
||||
Some(ArbAction::SwitchTo(self.incumbent))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn is_done(&self) -> bool {
|
||||
self.state == ArbState::Done
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn median(v: &mut [u64]) -> u64 {
|
||||
v.sort_unstable();
|
||||
v[v.len() / 2]
|
||||
}
|
||||
|
||||
/// One session config's identity for the process-lifetime bitrate-ceiling cache
|
||||
/// ([`cached_ceiling`]/[`store_ceiling`]). Everything the driver's codec-level validation keys
|
||||
/// off: the GPU (different NVENC generations have different level ceilings), dims/fps (the luma
|
||||
@@ -397,6 +544,55 @@ pub(super) fn store_ceiling(key: CeilingKey, bps: u64) {
|
||||
ceilings().lock().unwrap().insert(key, bps);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
/// A config's identity for the split-arbitration verdict cache — [`CeilingKey`] **minus
|
||||
/// `split_mode`**, because the split mode is the thing being decided. Including it would key each
|
||||
/// verdict under the arm that produced it and the cache could never answer "which arm should this
|
||||
/// config use?".
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub(super) struct SplitKey {
|
||||
pub gpu: u64,
|
||||
pub codec: Codec,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub fps: u32,
|
||||
pub bit_depth: u8,
|
||||
pub chroma_444: bool,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn split_verdicts() -> &'static std::sync::Mutex<std::collections::HashMap<SplitKey, u32>> {
|
||||
static V: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<SplitKey, u32>>> =
|
||||
std::sync::OnceLock::new();
|
||||
V.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
/// The split mode a previous arbitration found fastest for `key` this process lifetime.
|
||||
///
|
||||
/// Process-lifetime and advisory, exactly like [`cached_ceiling`]: a session that reads a verdict
|
||||
/// opens straight into the winning arm and skips the ~1 s exploration. It is NOT persisted — a
|
||||
/// driver update can change the answer, and a stale verdict on disk would outlive its evidence
|
||||
/// (persisting it needs the driver version in the key; see the plan's WP3).
|
||||
pub(super) fn cached_split_verdict(key: &SplitKey) -> Option<u32> {
|
||||
split_verdicts().lock().unwrap().get(key).copied()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
/// Record an arbitration result for `key`.
|
||||
pub(super) fn store_split_verdict(key: SplitKey, mode: u32) {
|
||||
split_verdicts().lock().unwrap().insert(key, mode);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
/// Drop every cached verdict. Test-only: the cache is process-global, so an on-hardware test that
|
||||
/// runs an arbitration would otherwise leak its verdict into every later test that opens the same
|
||||
/// config with `PUNKTFUNK_SPLIT_ENCODE` unset — which is exactly the shape the D5 legs use.
|
||||
#[cfg(test)]
|
||||
pub(super) fn clear_split_verdicts() {
|
||||
split_verdicts().lock().unwrap().clear();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1041,3 +1237,115 @@ pub(super) unsafe fn apply_low_latency_config(cfg: &mut nv::NV_ENC_CONFIG, c: Lo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod arbiter_tests {
|
||||
use super::{ArbAction, SplitArbiter, SETTLE_FRAMES};
|
||||
use nvidia_video_codec_sdk::sys::nvEncodeAPI::NV_ENC_SPLIT_ENCODE_MODE as M;
|
||||
|
||||
/// Drive an arbiter with a fixed cost per arm and return every action it emitted.
|
||||
fn drive(incumbent_us: u64, challenger_us: u64) -> (Vec<ArbAction>, u32) {
|
||||
let (inc, chal) = (
|
||||
M::NV_ENC_SPLIT_DISABLE_MODE as u32,
|
||||
M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32,
|
||||
);
|
||||
let mut arb = SplitArbiter::new(inc, chal);
|
||||
let mut actions = Vec::new();
|
||||
// Whatever the session is currently running; the harness follows the arbiter's switches
|
||||
// so the cost it reports matches the arm actually in effect.
|
||||
let mut live = inc;
|
||||
for _ in 0..500 {
|
||||
if arb.is_done() {
|
||||
break;
|
||||
}
|
||||
let us = if live == inc {
|
||||
incumbent_us
|
||||
} else {
|
||||
challenger_us
|
||||
};
|
||||
if let Some(a) = arb.on_frame(us) {
|
||||
actions.push(a);
|
||||
match a {
|
||||
ArbAction::SwitchTo(m) => live = m,
|
||||
ArbAction::Settled(m) => live = m,
|
||||
}
|
||||
}
|
||||
}
|
||||
(actions, live)
|
||||
}
|
||||
|
||||
/// A clearly faster challenger is adopted, and the session ends up running it.
|
||||
#[test]
|
||||
fn arbiter_adopts_a_clearly_faster_challenger() {
|
||||
let (actions, live) = drive(5000, 2400);
|
||||
assert_eq!(
|
||||
actions[0],
|
||||
ArbAction::SwitchTo(M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32),
|
||||
"must try the challenger before judging it"
|
||||
);
|
||||
assert_eq!(
|
||||
actions.last(),
|
||||
Some(&ArbAction::Settled(M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32))
|
||||
);
|
||||
assert_eq!(live, M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32);
|
||||
}
|
||||
|
||||
/// A slower challenger is rejected and the session is put BACK — the arbiter is mid-experiment
|
||||
/// when it decides, so "keep the incumbent" is a real reconfigure, not a no-op. Getting this
|
||||
/// wrong would strand every losing arbitration on the losing arm.
|
||||
#[test]
|
||||
fn arbiter_restores_the_incumbent_when_the_challenger_loses() {
|
||||
let (actions, live) = drive(2400, 5000);
|
||||
assert_eq!(
|
||||
actions.last(),
|
||||
Some(&ArbAction::SwitchTo(M::NV_ENC_SPLIT_DISABLE_MODE as u32)),
|
||||
"a losing experiment must be undone"
|
||||
);
|
||||
assert_eq!(live, M::NV_ENC_SPLIT_DISABLE_MODE as u32);
|
||||
}
|
||||
|
||||
/// Within the margin the incumbent holds: switching costs a reconfigure and, on HEVC, sub-frame
|
||||
/// readback, so a coin-flip difference must not move the session.
|
||||
#[test]
|
||||
fn arbiter_keeps_the_incumbent_inside_the_margin() {
|
||||
// 5 % better — under WIN_MARGIN_PCT.
|
||||
let (_, live) = drive(2400, 2280);
|
||||
assert_eq!(live, M::NV_ENC_SPLIT_DISABLE_MODE as u32);
|
||||
}
|
||||
|
||||
/// THE SETTLE CONTRACT: the challenger must not be judged on frames taken immediately after the
|
||||
/// switch. Feed it a transient — slow for the whole settle window, fast afterwards — and it
|
||||
/// must still see the fast steady state. Without the settle window this arbiter would read the
|
||||
/// transient, reject a genuinely better arm, and cache that verdict.
|
||||
#[test]
|
||||
fn arbiter_ignores_the_post_switch_transient() {
|
||||
let (inc, chal) = (
|
||||
M::NV_ENC_SPLIT_DISABLE_MODE as u32,
|
||||
M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32,
|
||||
);
|
||||
let mut arb = SplitArbiter::new(inc, chal);
|
||||
let mut switched_at = None;
|
||||
let mut frame = 0usize;
|
||||
let mut outcome = None;
|
||||
while outcome.is_none() && frame < 500 {
|
||||
let us = match switched_at {
|
||||
None => 5000,
|
||||
// The transient: as slow as the incumbent for exactly the settle window.
|
||||
Some(s) if frame - s <= SETTLE_FRAMES as usize => 5000,
|
||||
Some(_) => 2000,
|
||||
};
|
||||
match arb.on_frame(us) {
|
||||
Some(ArbAction::SwitchTo(m)) if m == chal => switched_at = Some(frame),
|
||||
Some(a) => outcome = Some(a),
|
||||
None => {}
|
||||
}
|
||||
frame += 1;
|
||||
}
|
||||
assert_eq!(
|
||||
outcome,
|
||||
Some(ArbAction::Settled(chal)),
|
||||
"the settle window must hide the post-switch transient — otherwise a better arm is \
|
||||
rejected on its own warmup"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user