diff --git a/crates/pf-encode/src/enc/linux/nvenc_cuda.rs b/crates/pf-encode/src/enc/linux/nvenc_cuda.rs index 413d895b..bb18374b 100644 --- a/crates/pf-encode/src/enc/linux/nvenc_cuda.rs +++ b/crates/pf-encode/src/enc/linux/nvenc_cuda.rs @@ -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, + /// 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, /// In-progress chunked readback of the front in-flight AU. See [`ChunkState`]. chunk: Option, } @@ -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 = (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 diff --git a/crates/pf-encode/src/enc/nvenc_core.rs b/crates/pf-encode/src/enc/nvenc_core.rs index 7e3f44ec..183e2b4f 100644 --- a/crates/pf-encode/src/enc/nvenc_core.rs +++ b/crates/pf-encode/src/enc/nvenc_core.rs @@ -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, + 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 { + 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> { + static V: std::sync::OnceLock>> = + 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 { + 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, 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" + ); + } +}