fix(encode/nvenc): cache the level ceiling, expose the applied bitrate, force 2-way split at 4K120
Three encode-time fixes from the 4K120 field analysis (sessions pinned ~107 of 120 fps, 14-15 ms reported encode): - Ceiling truth: the codec-level bitrate clamp (binary search at session open) now records its result in a process-lifetime advisory cache keyed by GPU/config, so an ABR overshoot opens — or in-place reconfigures — straight AT the ceiling instead of re-running the ~6-open search (and a full rebuild + IDR) on every overshoot. New `Encoder::applied_bitrate_bps()` exposes the post-clamp rate so the session loop can stop pacing/acking a phantom requested rate (consumed host-side in a follow-up). - Clamp-search hygiene: only NVENC parameter/caps rejections steer the search now (`NvCallError` keeps the raw status downcastable); a transient failure (busy engine, session limit, OOM, driver skew) propagates instead of shrinking into — and now caching — a bogus ceiling. The floor fallback also records the split mode it actually opened with, so a later reconfigure re-presents the real session params. - Split-frame selection: one shared `resolve_split_mode` replaces the two byte-identical direct-SDK copies; the force-2-way threshold moves to `SPLIT_FORCE_PIXEL_RATE` (950 Mpix/s, shared with the libav path) because 4K120 = 995,328,000 px/s missed the old `> 1e9` gate by 0.47% and stayed on AUTO — which never engages at 2160 px height, leaving the second NVENC engine idle in exactly the mode the threshold existed for. The Linux session-ready info! line now carries the final split mode (journals are INFO+; this was undiagnosable from user logs). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -112,7 +112,7 @@ impl AuChunk {
|
||||
}
|
||||
|
||||
/// Codec selection negotiated with the client.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Codec {
|
||||
H264,
|
||||
H265,
|
||||
@@ -399,6 +399,16 @@ pub trait Encoder: Send {
|
||||
fn reconfigure_bitrate(&mut self, _bps: u64) -> bool {
|
||||
false
|
||||
}
|
||||
/// The bitrate (bps) the encoder is ACTUALLY running at (or will open at, for a lazily-opened
|
||||
/// backend) — the encoder-side truth after any internal clamp, e.g. the direct-NVENC
|
||||
/// codec-level ceiling search. The session loop reads this after every open/reconfigure and
|
||||
/// stores IT, not the requested rate, as the live bitrate — so the send pacer, the console
|
||||
/// and the client controller's ack all track what the ASIC really targets (a controller fed
|
||||
/// the requested rate keeps climbing from a phantom base, §ABR overdrive). `None` (the
|
||||
/// default) = the backend doesn't track an applied rate; the caller keeps the requested one.
|
||||
fn applied_bitrate_bps(&self) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
/// Wire-chunk the encoder's AUs at the session's shard payload size (the PyroWave
|
||||
/// datagram-aligned mode, plan §4.4): every `shard_payload` window of the emitted AU
|
||||
/// starts a fresh self-delimiting codec packet, zero-padded to the window — so a lost
|
||||
@@ -452,6 +462,17 @@ impl Codec {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixel rate (luma samples/s) at or above which NVENC split-frame encoding is FORCED 2-way —
|
||||
/// one number shared by the direct-SDK selector (`nvenc_core::resolve_split_mode`) and the libav
|
||||
/// `split_encode_mode` option author (`linux::NvencEncoder`), so the two paths can never disagree
|
||||
/// about which modes split. A single NVENC engine tops out ~1 Gpix/s on HEVC, and AUTO doesn't
|
||||
/// engage below ~2112 px height, so the sessions that need the second engine must be forced. Set
|
||||
/// BELOW 1 Gpix/s deliberately: 4K120 — the mode this threshold exists for — is 3840×2160×120 =
|
||||
/// 995,328,000, which a `> 1_000_000_000` gate missed by 0.47% and left on AUTO (pinned ~107 fps
|
||||
/// on a 4090). 950 M keeps margin for fractional refresh rates while leaving 1440p240 (884.7 M,
|
||||
/// comfortably single-engine) on AUTO.
|
||||
pub const SPLIT_FORCE_PIXEL_RATE: u64 = 950_000_000;
|
||||
|
||||
/// `PUNKTFUNK_VBV_FRAMES` — HRD/VBV size in frame intervals (default 1.0, the strict low-latency
|
||||
/// shape every backend ships: each frame must fit its rate share, keeping frame sizes uniform for
|
||||
/// the pacer). The AMF/VAAPI/QSV paths parse the same variable locally; this helper brings the
|
||||
|
||||
@@ -439,15 +439,19 @@ impl NvencEncoder {
|
||||
}
|
||||
|
||||
// Split-frame encode across both NVENC engines (GB203 has 2) when the pixel rate exceeds
|
||||
// a single engine's HEVC capacity (~1 Gpix/s); e.g. 5120x1440@240 = 1.77 Gpix/s needs it,
|
||||
// @120 = 0.88 Gpix/s does not. HEVC/AV1 only (not H.264). AUTO won't engage below ~2112px
|
||||
// a single engine's HEVC capacity; e.g. 5120x1440@240 = 1.77 Gpix/s needs it, @120
|
||||
// (0.88 Gpix/s) does not. HEVC/AV1 only (not H.264). AUTO won't engage below ~2112px
|
||||
// height, so we force `2`; below the threshold we leave it AUTO (split costs ~2% BD-rate).
|
||||
// Output is standard HEVC — transparent to the client. Override with PUNKTFUNK_SPLIT_ENCODE.
|
||||
// Threshold shared with the direct-SDK selector ([`super::SPLIT_FORCE_PIXEL_RATE`] — set
|
||||
// so 4K120 = 995.3 Mpix/s forces, which `> 1e9` famously missed by 0.47%). Output is
|
||||
// standard HEVC — transparent to the client. Override with PUNKTFUNK_SPLIT_ENCODE.
|
||||
let pix_rate = width as u64 * height as u64 * fps as u64;
|
||||
let split = std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok();
|
||||
match split.as_deref() {
|
||||
Some(mode) => opts.set("split_encode_mode", mode),
|
||||
None if matches!(codec, Codec::H265 | Codec::Av1) && pix_rate > 1_000_000_000 => {
|
||||
None if matches!(codec, Codec::H265 | Codec::Av1)
|
||||
&& pix_rate >= super::SPLIT_FORCE_PIXEL_RATE =>
|
||||
{
|
||||
opts.set("split_encode_mode", "2");
|
||||
tracing::info!(
|
||||
pix_rate,
|
||||
|
||||
@@ -61,8 +61,9 @@
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::nvenc_core::{
|
||||
apply_low_latency_config, build_init_params, codec_guid, resolve_slices, resolve_subframe,
|
||||
LowLatencyConfig, NvStatusExt, RFI_DPB,
|
||||
apply_low_latency_config, build_init_params, cached_ceiling, codec_guid, resolve_slices,
|
||||
resolve_split_mode, resolve_subframe, store_ceiling, CeilingKey, LowLatencyConfig, NvStatusExt,
|
||||
RFI_DPB,
|
||||
};
|
||||
use super::nvenc_status;
|
||||
use super::{AuChunk, ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
|
||||
@@ -1000,6 +1001,23 @@ impl NvencCudaEncoder {
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
/// This session config's identity in the process-lifetime bitrate-ceiling cache
|
||||
/// (`nvenc_core::{cached_ceiling, store_ceiling}`). GPU identity is the process-global shared
|
||||
/// `CUcontext` pointer — one context per process, stable for its lifetime; only valid once
|
||||
/// `cu_ctx` is bound (`init_session` start), which every caller is downstream of.
|
||||
fn ceiling_key(&self, split_mode: u32) -> CeilingKey {
|
||||
CeilingKey {
|
||||
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,
|
||||
split_mode,
|
||||
}
|
||||
}
|
||||
|
||||
/// Open + configure + initialize ONE NVENC CUDA session at `bitrate` (bps) and `split_mode`.
|
||||
/// Returns the session handle, or destroys it and returns the error.
|
||||
unsafe fn try_open_session(&self, bitrate: u64, split_mode: u32) -> Result<*mut c_void> {
|
||||
@@ -1074,58 +1092,71 @@ impl NvencCudaEncoder {
|
||||
}
|
||||
const FLOOR_BPS: u64 = 10_000_000;
|
||||
let requested_bps = self.bitrate_bps;
|
||||
// 2-way NVENC split-frame encoding (Ada dual-NVENC) above ~1 Gpix/s; env override
|
||||
// PUNKTFUNK_SPLIT_ENCODE = 0/disable | 1/auto | 2 | 3. HEVC/AV1 only.
|
||||
// 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 mut split_mode: u32 = match std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok().as_deref()
|
||||
{
|
||||
Some("0") | Some("disable") => {
|
||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32
|
||||
}
|
||||
Some("1") | Some("auto") => {
|
||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_FORCED_MODE as u32
|
||||
}
|
||||
Some("3") => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_THREE_FORCED_MODE as u32,
|
||||
Some("2") => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_TWO_FORCED_MODE as u32,
|
||||
_ if self.bit_depth >= 10 => {
|
||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32
|
||||
}
|
||||
_ if pixel_rate > 1_000_000_000 => {
|
||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_TWO_FORCED_MODE as u32
|
||||
}
|
||||
_ => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_MODE as u32,
|
||||
};
|
||||
let split_mode: u32 = resolve_split_mode(self.bit_depth, pixel_rate);
|
||||
const CLAMP_TOL_BPS: u64 = 20_000_000;
|
||||
|
||||
let mut probe = self.try_open_session(requested_bps, split_mode);
|
||||
// Disambiguate a forced-split rejection from a bitrate-cap rejection.
|
||||
// Ceiling cache (process lifetime, `nvenc_core`): a prior clamp search already found
|
||||
// this config's max accepted rate — open straight AT the ceiling instead of paying
|
||||
// the ~6-open binary search (and its session churn) on every ABR overshoot.
|
||||
let mut target_bps = requested_bps;
|
||||
if let Some(ceiling) = cached_ceiling(&self.ceiling_key(split_mode)) {
|
||||
if requested_bps > ceiling {
|
||||
tracing::info!(
|
||||
requested_mbps = requested_bps / 1_000_000,
|
||||
ceiling_mbps = ceiling / 1_000_000,
|
||||
"NVENC (Linux): requested bitrate above the cached codec-level ceiling — \
|
||||
opening at the ceiling"
|
||||
);
|
||||
target_bps = ceiling;
|
||||
}
|
||||
}
|
||||
|
||||
let mut probe = self.try_open_session(target_bps, split_mode);
|
||||
// The cache is advisory: a stale entry (driver change, identity collision) must not
|
||||
// wedge the open — retry the requested rate and let the search below rediscover.
|
||||
if probe.is_err() && target_bps < requested_bps {
|
||||
target_bps = requested_bps;
|
||||
probe = self.try_open_session(requested_bps, split_mode);
|
||||
}
|
||||
// Disambiguate a forced-split rejection from a bitrate-cap rejection. `used_split`
|
||||
// tracks the mode sessions ACTUALLY open with from here on — it feeds
|
||||
// `self.split_mode` (a reconfigure must re-present it) and the ceiling-cache key.
|
||||
let mut used_split = split_mode;
|
||||
let split_on =
|
||||
split_mode != nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
||||
if probe.is_err() && split_on {
|
||||
let no_split = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
||||
if let Ok(e) = self.try_open_session(requested_bps, no_split) {
|
||||
if let Ok(e) = self.try_open_session(target_bps, no_split) {
|
||||
tracing::warn!(
|
||||
"NVENC (Linux): split-encode rejected by codec/config — disabled"
|
||||
);
|
||||
split_mode = no_split;
|
||||
used_split = no_split;
|
||||
probe = Ok(e);
|
||||
}
|
||||
}
|
||||
|
||||
let enc = match probe {
|
||||
Ok(enc) => {
|
||||
self.bitrate_bps = requested_bps;
|
||||
self.bitrate_bps = target_bps;
|
||||
enc
|
||||
}
|
||||
// Only a parameter/caps rejection means "the bitrate is above the codec-level
|
||||
// ceiling". A transient failure (busy engine, session limit, OOM, device loss,
|
||||
// version skew) must propagate — a search steered by it would discover, and
|
||||
// cache, a bogus ceiling.
|
||||
Err(e) if !nvenc_status::is_param_rejection(&e) => return Err(e),
|
||||
Err(_) => {
|
||||
// Requested bitrate exceeds the codec-level ceiling — binary-search the max accepted.
|
||||
let mut lo = FLOOR_BPS;
|
||||
let mut hi = requested_bps;
|
||||
let mut hi = target_bps;
|
||||
let mut best: *mut c_void = ptr::null_mut();
|
||||
let mut best_bps = 0u64;
|
||||
while hi > lo + CLAMP_TOL_BPS {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
match self.try_open_session(mid, split_mode) {
|
||||
match self.try_open_session(mid, used_split) {
|
||||
Ok(e) => {
|
||||
if !best.is_null() {
|
||||
let _ = (api().destroy_encoder)(best);
|
||||
@@ -1134,18 +1165,30 @@ impl NvencCudaEncoder {
|
||||
best_bps = mid;
|
||||
lo = mid;
|
||||
}
|
||||
Err(_) => hi = mid,
|
||||
Err(e) if nvenc_status::is_param_rejection(&e) => hi = mid,
|
||||
Err(e) => {
|
||||
// Environmental mid-search failure: don't let it shrink the
|
||||
// search — release the partial result and propagate.
|
||||
if !best.is_null() {
|
||||
let _ = (api().destroy_encoder)(best);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
if best.is_null() {
|
||||
let no_split =
|
||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
||||
best = self
|
||||
.try_open_session(FLOOR_BPS, split_mode)
|
||||
.or_else(|_| self.try_open_session(FLOOR_BPS, no_split))
|
||||
.context(
|
||||
"NVENC initialize_encoder rejected even at the floor bitrate",
|
||||
)?;
|
||||
best = match self.try_open_session(FLOOR_BPS, used_split) {
|
||||
Ok(e) => e,
|
||||
Err(_) => {
|
||||
let e = self.try_open_session(FLOOR_BPS, no_split).context(
|
||||
"NVENC initialize_encoder rejected even at the floor bitrate",
|
||||
)?;
|
||||
used_split = no_split;
|
||||
e
|
||||
}
|
||||
};
|
||||
best_bps = FLOOR_BPS;
|
||||
}
|
||||
tracing::warn!(
|
||||
@@ -1153,15 +1196,13 @@ impl NvencCudaEncoder {
|
||||
clamped_mbps = best_bps / 1_000_000,
|
||||
"NVENC (Linux): requested bitrate above the GPU codec-level ceiling — clamped"
|
||||
);
|
||||
store_ceiling(self.ceiling_key(used_split), best_bps);
|
||||
self.bitrate_bps = best_bps;
|
||||
best
|
||||
}
|
||||
};
|
||||
self.encoder = enc;
|
||||
// (Best effort: the floor fallback above may have succeeded split-disabled without
|
||||
// updating `split_mode` — a later reconfigure then presents the forced mode, NVENC
|
||||
// rejects it, and the caller's rebuild fallback covers the mismatch.)
|
||||
self.split_mode = split_mode;
|
||||
self.split_mode = used_split;
|
||||
|
||||
// Output bitstream pool.
|
||||
for _ in 0..POOL {
|
||||
@@ -1345,6 +1386,10 @@ impl NvencCudaEncoder {
|
||||
mbps = self.bitrate_bps / 1_000_000,
|
||||
codec = ?self.codec_guid,
|
||||
fmt = ?self.buffer_fmt,
|
||||
// The FINAL split mode (post any rejection fallback) at INFO — journals run
|
||||
// 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,
|
||||
"NVENC CUDA session ready"
|
||||
);
|
||||
Ok(())
|
||||
@@ -2061,6 +2106,15 @@ impl Encoder for NvencCudaEncoder {
|
||||
self.bitrate_bps = bps;
|
||||
return true;
|
||||
}
|
||||
// Cached codec-level ceiling: clamp the target BEFORE the driver call, so a known
|
||||
// overshoot retargets to the ceiling IN PLACE instead of bouncing off the driver into
|
||||
// the caller's full-rebuild fallback (an IDR plus ~half a second of session churn per
|
||||
// ABR overshoot on the pre-cache path). The caller reads the clamp back through
|
||||
// [`Encoder::applied_bitrate_bps`].
|
||||
let bps = match cached_ceiling(&self.ceiling_key(self.split_mode)) {
|
||||
Some(ceiling) => bps.min(ceiling),
|
||||
None => bps,
|
||||
};
|
||||
// SAFETY: `inited` ⟹ `self.encoder` is the live session and every call here runs on the
|
||||
// encode thread with no NVENC call in flight (the session loop calls this between
|
||||
// submit/poll). `build_config` only queries the preset on that session; `cfg` outlives
|
||||
@@ -2108,6 +2162,12 @@ impl Encoder for NvencCudaEncoder {
|
||||
}
|
||||
}
|
||||
|
||||
fn applied_bitrate_bps(&self) -> Option<u64> {
|
||||
// `bitrate_bps` is the post-clamp truth: the open path's ceiling search and the
|
||||
// reconfigure path's cache clamp both write what the session ACTUALLY targets.
|
||||
Some(self.bitrate_bps)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Result<()> {
|
||||
Ok(()) // P1/ULL + frameIntervalP=1: each submit yields its AU; no internal queue to drain.
|
||||
}
|
||||
|
||||
@@ -67,6 +67,155 @@ pub(super) fn resolve_subframe(default_on: bool) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved NVENC split-frame encode mode for a session — ONE selector shared by the Windows and
|
||||
/// 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.
|
||||
/// 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).
|
||||
///
|
||||
/// 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 {
|
||||
use nv::NV_ENC_SPLIT_ENCODE_MODE as M;
|
||||
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,
|
||||
_ 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,
|
||||
_ => M::NV_ENC_SPLIT_AUTO_MODE as u32,
|
||||
};
|
||||
tracing::debug!(
|
||||
split_mode = mode,
|
||||
bit_depth,
|
||||
pixel_rate,
|
||||
"NVENC split-encode mode selected"
|
||||
);
|
||||
mode
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// rate selects the level), depth/chroma (they select the profile) and the split mode the
|
||||
/// sessions ACTUALLY opened with (a split session budgets per engine).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub(super) struct CeilingKey {
|
||||
/// GPU identity — Linux: the process-global shared `CUcontext` pointer; Windows: the render
|
||||
/// adapter LUID (0 when unresolved). Best effort: the cache is advisory (see
|
||||
/// [`cached_ceiling`]), so a colliding identity costs one failed open + re-search, never a
|
||||
/// wrong session.
|
||||
pub gpu: u64,
|
||||
pub codec: Codec,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub fps: u32,
|
||||
pub bit_depth: u8,
|
||||
pub chroma_444: bool,
|
||||
pub split_mode: u32,
|
||||
}
|
||||
|
||||
fn ceilings() -> &'static std::sync::Mutex<std::collections::HashMap<CeilingKey, u64>> {
|
||||
static CEILINGS: std::sync::OnceLock<
|
||||
std::sync::Mutex<std::collections::HashMap<CeilingKey, u64>>,
|
||||
> = std::sync::OnceLock::new();
|
||||
CEILINGS.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// The codec-level bitrate ceiling (bps) a previous clamp search discovered for `key` this
|
||||
/// process lifetime, if any. ADVISORY: the consumer must treat a failed open at the cached value
|
||||
/// as a stale entry (fall back to the full search, which rewrites it via [`store_ceiling`]) —
|
||||
/// that self-healing is what lets the key's GPU identity be best-effort. What this buys: an ABR
|
||||
/// overshoot on a config whose ceiling is already known opens (or in-place reconfigures) straight
|
||||
/// AT the ceiling instead of re-running the ~6-open binary search and its ~half-second of session
|
||||
/// churn per rebuild.
|
||||
pub(super) fn cached_ceiling(key: &CeilingKey) -> Option<u64> {
|
||||
ceilings().lock().unwrap().get(key).copied()
|
||||
}
|
||||
|
||||
/// Record the clamp search's discovered max accepted bitrate (bps) for `key`.
|
||||
pub(super) fn store_ceiling(key: CeilingKey, bps: u64) {
|
||||
ceilings().lock().unwrap().insert(key, bps);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nv::NV_ENC_SPLIT_ENCODE_MODE as M;
|
||||
|
||||
// These assume PUNKTFUNK_SPLIT_ENCODE is unset (CI); an operator override deliberately wins.
|
||||
|
||||
#[test]
|
||||
fn split_forces_two_way_at_4k120() {
|
||||
// The regression this threshold constant exists for: 3840×2160×120 = 995,328,000 sat
|
||||
// 0.47% under the old `> 1_000_000_000` gate and stayed AUTO — pinned ~107 fps on a
|
||||
// 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),
|
||||
M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_leaves_1440p240_auto() {
|
||||
// 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),
|
||||
M::NV_ENC_SPLIT_AUTO_MODE as u32
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_disabled_for_10bit_even_at_high_pixel_rate() {
|
||||
// The measured Main10 rule: split/merge overhead dominates 10-bit on Ada (7.6 ms forced-2
|
||||
// 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),
|
||||
M::NV_ENC_SPLIT_DISABLE_MODE as u32
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ceiling_cache_round_trips_and_keys_precisely() {
|
||||
let key = CeilingKey {
|
||||
gpu: 0xB0B0,
|
||||
codec: Codec::H265,
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
fps: 120,
|
||||
bit_depth: 8,
|
||||
chroma_444: false,
|
||||
split_mode: M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32,
|
||||
};
|
||||
assert_eq!(cached_ceiling(&key), None);
|
||||
store_ceiling(key, 794_000_000);
|
||||
assert_eq!(cached_ceiling(&key), Some(794_000_000));
|
||||
// Any config-identity change is a different ceiling — a miss, never a wrong clamp.
|
||||
assert_eq!(cached_ceiling(&CeilingKey { fps: 60, ..key }), None);
|
||||
assert_eq!(
|
||||
cached_ceiling(&CeilingKey {
|
||||
split_mode: M::NV_ENC_SPLIT_DISABLE_MODE as u32,
|
||||
..key
|
||||
}),
|
||||
None
|
||||
);
|
||||
// A re-search overwrites (the advisory-cache stale-entry path).
|
||||
store_ceiling(key, 620_000_000);
|
||||
assert_eq!(cached_ceiling(&key), Some(620_000_000));
|
||||
}
|
||||
}
|
||||
|
||||
/// Reference-frame DPB depth when RFI is supported (Apollo uses 5). A deeper DPB lets an invalidated
|
||||
/// reference fall back to an older still-valid frame instead of a full IDR; `numRefL0 = 1` keeps each
|
||||
/// P-frame single-reference for low latency. Also the window the backends' `invalidate_ref_frames`
|
||||
|
||||
@@ -72,9 +72,41 @@ pub(super) fn explain(status: nv::NVENCSTATUS) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed root of a failed NVENC entry-point call: carries the raw status so callers can classify
|
||||
/// the failure class, not just print it — the bitrate-clamp search must only read a
|
||||
/// parameter/caps rejection as "above the codec-level ceiling"; a transient failure shrinking the
|
||||
/// search would discover (and cache) a bogus ceiling. Recover it through an `anyhow` chain with
|
||||
/// `err.downcast_ref::<NvCallError>()` (see [`is_param_rejection`]).
|
||||
#[derive(Debug)]
|
||||
pub(super) struct NvCallError(pub(super) nv::NVENCSTATUS);
|
||||
|
||||
impl std::fmt::Display for NvCallError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{:?} — {}", self.0, explain(self.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for NvCallError {}
|
||||
|
||||
/// Whether `err` is an NVENC parameter/capability rejection: the driver understood the request
|
||||
/// and says THIS config is not encodable — the clamp search's "bitrate above the ceiling"
|
||||
/// evidence. Everything else (busy engine, session limit, OOM, device loss, version skew) is
|
||||
/// environmental and must propagate instead of steering the search.
|
||||
pub(super) fn is_param_rejection(err: &anyhow::Error) -> bool {
|
||||
matches!(
|
||||
err.downcast_ref::<NvCallError>(),
|
||||
Some(NvCallError(
|
||||
nv::NVENCSTATUS::NV_ENC_ERR_INVALID_PARAM
|
||||
| nv::NVENCSTATUS::NV_ENC_ERR_UNSUPPORTED_PARAM
|
||||
| nv::NVENCSTATUS::NV_ENC_ERR_UNIMPLEMENTED,
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
/// Build an actionable `anyhow::Error` for a failed NVENC entry-point call. `call` names the API
|
||||
/// (e.g. `"open_encode_session_ex"`); the message carries both the raw status and its real-world
|
||||
/// cause, so triage never again reads a version mismatch as "(no NVIDIA GPU?)".
|
||||
/// (e.g. `"open_encode_session_ex"`); the chain carries both the raw status and its real-world
|
||||
/// cause, so triage never again reads a version mismatch as "(no NVIDIA GPU?)". The
|
||||
/// [`NvCallError`] root keeps the status downcastable for failure-class checks.
|
||||
pub(super) fn call_err(call: &str, status: nv::NVENCSTATUS) -> anyhow::Error {
|
||||
anyhow::anyhow!("NVENC {call} failed: {status:?} — {}", explain(status))
|
||||
anyhow::Error::new(NvCallError(status)).context(format!("NVENC {call} failed"))
|
||||
}
|
||||
|
||||
@@ -37,8 +37,9 @@
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::nvenc_core::{
|
||||
apply_low_latency_config, build_init_params, codec_guid, resolve_slices, resolve_subframe,
|
||||
LowLatencyConfig, NvStatusExt, RFI_DPB,
|
||||
apply_low_latency_config, build_init_params, cached_ceiling, codec_guid, resolve_slices,
|
||||
resolve_split_mode, resolve_subframe, store_ceiling, CeilingKey, LowLatencyConfig, NvStatusExt,
|
||||
RFI_DPB,
|
||||
};
|
||||
use super::nvenc_status;
|
||||
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
|
||||
@@ -754,6 +755,27 @@ impl NvencD3d11Encoder {
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
/// This session config's identity in the process-lifetime bitrate-ceiling cache
|
||||
/// (`nvenc_core::{cached_ceiling, store_ceiling}`). GPU identity is the selected render
|
||||
/// adapter's LUID — the adapter the capturer's device (and so this session) lives on; `0`
|
||||
/// when unresolved. Best effort by design: the cache is advisory, a colliding identity costs
|
||||
/// one failed open + re-search, never a wrong session.
|
||||
fn ceiling_key(&self, split_mode: u32) -> CeilingKey {
|
||||
let gpu = pf_gpu::resolve_render_adapter_luid()
|
||||
.map(|l| ((l.HighPart as u32 as u64) << 32) | l.LowPart as u64)
|
||||
.unwrap_or(0);
|
||||
CeilingKey {
|
||||
gpu,
|
||||
codec: self.codec,
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
fps: self.fps,
|
||||
bit_depth: self.bit_depth,
|
||||
chroma_444: self.chroma_444,
|
||||
split_mode,
|
||||
}
|
||||
}
|
||||
|
||||
/// Open + configure + initialize ONE NVENC session at `bitrate` (bps) and `split_mode`. Returns
|
||||
/// the session handle, or destroys it and returns the error. NVENC has no re-init after a failed
|
||||
/// `initialize_encoder`, so the bitrate-clamp search in `init_session` calls this once per probe.
|
||||
@@ -834,43 +856,14 @@ impl NvencD3d11Encoder {
|
||||
// gets the highest the GPU can actually do, not a coarse fraction of it.
|
||||
const FLOOR_BPS: u64 = 10_000_000;
|
||||
let requested_bps = self.bitrate_bps;
|
||||
// 2-way NVENC split-frame encoding (Ada dual-NVENC) — the high-pixel-rate throughput lever
|
||||
// the Linux host enables via libavcodec `split_encode_mode`. A single Ada NVENC session tops
|
||||
// out ~0.8 Gpix/s, so at high motion a 5K@240 (1.77 Gpix/s) frame takes ~8 ms to encode and
|
||||
// the rate caps ~125 fps; splitting across both engines roughly halves that. Force 2-way
|
||||
// above ~1 Gpix/s (matching encode/linux.rs), AUTO below (the ~2% BD-rate cost isn't worth
|
||||
// it at low pixel rates). Env override PUNKTFUNK_SPLIT_ENCODE = 0/disable | 1/auto | 2 | 3.
|
||||
// HEVC/AV1 only; the init-failure fallback below disables it if a codec/config rejects it.
|
||||
// 2-way NVENC split-frame encoding (Ada dual-NVENC) — the high-pixel-rate throughput lever.
|
||||
// A single Ada NVENC session tops out ~0.8-1 Gpix/s, so at high motion a 5K@240
|
||||
// (1.77 Gpix/s) frame takes ~8 ms to encode and the rate caps ~125 fps; splitting across
|
||||
// both engines roughly halves that. Shared selector — see [`resolve_split_mode`] for the
|
||||
// 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 mut split_mode: u32 = match std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok().as_deref()
|
||||
{
|
||||
Some("0") | Some("disable") => {
|
||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32
|
||||
}
|
||||
Some("1") | Some("auto") => {
|
||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_FORCED_MODE as u32
|
||||
}
|
||||
Some("3") => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_THREE_FORCED_MODE as u32,
|
||||
Some("2") => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_TWO_FORCED_MODE as u32,
|
||||
// Main10 (10-bit / HDR): 2-way split is measurably SLOWER on Ada — at 5120x1440@240
|
||||
// Main10, forced-2 took 7.6 ms/frame (~131 fps) vs 2.8 ms (~357 fps) single-engine
|
||||
// (the split/merge overhead dominates for 10-bit). A single Ada NVENC engine already
|
||||
// handles 5K@240 Main10 well under the 4.17 ms budget, so DON'T split — splitting was
|
||||
// the "broken animations in HDR" (the stream capped at ~131 fps). Env still overrides.
|
||||
_ if self.bit_depth >= 10 => {
|
||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32
|
||||
}
|
||||
_ if pixel_rate > 1_000_000_000 => {
|
||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_TWO_FORCED_MODE as u32
|
||||
}
|
||||
_ => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_MODE as u32,
|
||||
};
|
||||
tracing::debug!(
|
||||
split_mode,
|
||||
bit_depth = self.bit_depth,
|
||||
pixel_rate,
|
||||
"NVENC split-encode mode selected"
|
||||
);
|
||||
let split_mode: u32 = resolve_split_mode(self.bit_depth, pixel_rate);
|
||||
// Find the highest bitrate the GPU's codec LEVEL accepts and CLAMP to it. NVENC rejects
|
||||
// `initialize_encoder` (InvalidParam) when the bitrate exceeds the level ceiling (e.g. a
|
||||
// 1 Gbps request on HEVC). Strategy: try the requested rate; if the only problem is a forced
|
||||
@@ -884,39 +877,69 @@ impl NvencD3d11Encoder {
|
||||
// built in the right mode from the start.
|
||||
let use_async = self.async_supported && async_retrieve_requested();
|
||||
|
||||
let mut probe = self.try_open_session(device, requested_bps, split_mode, use_async);
|
||||
// Ceiling cache (process lifetime, `nvenc_core`): a prior clamp search already found
|
||||
// this config's max accepted rate — open straight AT the ceiling instead of paying
|
||||
// the ~6-open binary search (and its session churn) on every ABR overshoot.
|
||||
let mut target_bps = requested_bps;
|
||||
if let Some(ceiling) = cached_ceiling(&self.ceiling_key(split_mode)) {
|
||||
if requested_bps > ceiling {
|
||||
tracing::info!(
|
||||
requested_mbps = requested_bps / 1_000_000,
|
||||
ceiling_mbps = ceiling / 1_000_000,
|
||||
"NVENC: requested bitrate above the cached codec-level ceiling — opening \
|
||||
at the ceiling"
|
||||
);
|
||||
target_bps = ceiling;
|
||||
}
|
||||
}
|
||||
|
||||
let mut probe = self.try_open_session(device, target_bps, split_mode, use_async);
|
||||
// The cache is advisory: a stale entry (driver change, identity collision) must not
|
||||
// wedge the open — retry the requested rate and let the search below rediscover.
|
||||
if probe.is_err() && target_bps < requested_bps {
|
||||
target_bps = requested_bps;
|
||||
probe = self.try_open_session(device, requested_bps, split_mode, use_async);
|
||||
}
|
||||
// Disambiguate a forced-split rejection from a bitrate-cap rejection: retry once at the
|
||||
// requested rate with split disabled — if THAT succeeds, split was the problem, not bitrate.
|
||||
// ANY non-disabled mode can be the rejection — AUTO included: AV1 rejects the whole
|
||||
// init with INVALID_PARAM on drivers/configs where auto split isn't valid for it,
|
||||
// which then masqueraded as a bitrate cap and failed "even at the floor".
|
||||
// `used_split` tracks the mode sessions ACTUALLY open with from here on — it feeds
|
||||
// `self.split_mode` (a reconfigure must re-present it) and the ceiling-cache key.
|
||||
let mut used_split = split_mode;
|
||||
let split_on =
|
||||
split_mode != nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
||||
if probe.is_err() && split_on {
|
||||
let no_split = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
||||
if let Ok(e) = self.try_open_session(device, requested_bps, no_split, use_async) {
|
||||
if let Ok(e) = self.try_open_session(device, target_bps, no_split, use_async) {
|
||||
tracing::warn!("NVENC: split-encode rejected by codec/config — disabled");
|
||||
split_mode = no_split;
|
||||
used_split = no_split;
|
||||
probe = Ok(e);
|
||||
}
|
||||
}
|
||||
|
||||
let enc = match probe {
|
||||
Ok(enc) => {
|
||||
self.bitrate_bps = requested_bps;
|
||||
self.bitrate_bps = target_bps;
|
||||
enc
|
||||
}
|
||||
// Only a parameter/caps rejection means "the bitrate is above the codec-level
|
||||
// ceiling". A transient failure (busy engine, session limit, OOM, device loss,
|
||||
// version skew) must propagate — a search steered by it would discover, and
|
||||
// cache, a bogus ceiling.
|
||||
Err(e) if !nvenc_status::is_param_rejection(&e) => return Err(e),
|
||||
Err(_) => {
|
||||
// Requested bitrate exceeds the codec-level ceiling — binary-search the max accepted.
|
||||
// `lo` is the highest known-good rate (FLOOR is assumed to fit), `hi` the lowest
|
||||
// rejected; `best` holds the live session at `lo` so we end up with the clamped one.
|
||||
let mut lo = FLOOR_BPS;
|
||||
let mut hi = requested_bps;
|
||||
let mut hi = target_bps;
|
||||
let mut best: *mut c_void = ptr::null_mut();
|
||||
let mut best_bps = 0u64;
|
||||
while hi > lo + CLAMP_TOL_BPS {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
match self.try_open_session(device, mid, split_mode, use_async) {
|
||||
match self.try_open_session(device, mid, used_split, use_async) {
|
||||
Ok(e) => {
|
||||
if !best.is_null() {
|
||||
let _ = (api().destroy_encoder)(best);
|
||||
@@ -925,7 +948,15 @@ impl NvencD3d11Encoder {
|
||||
best_bps = mid;
|
||||
lo = mid;
|
||||
}
|
||||
Err(_) => hi = mid,
|
||||
Err(e) if nvenc_status::is_param_rejection(&e) => hi = mid,
|
||||
Err(e) => {
|
||||
// Environmental mid-search failure: don't let it shrink the
|
||||
// search — release the partial result and propagate.
|
||||
if !best.is_null() {
|
||||
let _ = (api().destroy_encoder)(best);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
if best.is_null() {
|
||||
@@ -933,14 +964,19 @@ impl NvencD3d11Encoder {
|
||||
// trying split-disabled in case a forced split (not the bitrate) is the blocker.
|
||||
let no_split =
|
||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
||||
best = self
|
||||
.try_open_session(device, FLOOR_BPS, split_mode, use_async)
|
||||
.or_else(|_| {
|
||||
self.try_open_session(device, FLOOR_BPS, no_split, use_async)
|
||||
})
|
||||
.context(
|
||||
"NVENC initialize_encoder rejected even at the floor bitrate",
|
||||
)?;
|
||||
best = match self.try_open_session(device, FLOOR_BPS, used_split, use_async)
|
||||
{
|
||||
Ok(e) => e,
|
||||
Err(_) => {
|
||||
let e = self
|
||||
.try_open_session(device, FLOOR_BPS, no_split, use_async)
|
||||
.context(
|
||||
"NVENC initialize_encoder rejected even at the floor bitrate",
|
||||
)?;
|
||||
used_split = no_split;
|
||||
e
|
||||
}
|
||||
};
|
||||
best_bps = FLOOR_BPS;
|
||||
}
|
||||
tracing::warn!(
|
||||
@@ -948,21 +984,19 @@ impl NvencD3d11Encoder {
|
||||
clamped_mbps = best_bps / 1_000_000,
|
||||
"NVENC: requested bitrate above the GPU codec-level ceiling — clamped to the max accepted"
|
||||
);
|
||||
store_ceiling(self.ceiling_key(used_split), best_bps);
|
||||
self.bitrate_bps = best_bps;
|
||||
best
|
||||
}
|
||||
};
|
||||
self.encoder = enc;
|
||||
// Session init params a later `reconfigure_bitrate` must re-present verbatim. (Best
|
||||
// effort: the floor fallback above may have succeeded split-disabled without updating
|
||||
// `split_mode` — a reconfigure then presents the forced mode, NVENC rejects it, and
|
||||
// the caller's rebuild fallback covers the mismatch.)
|
||||
self.split_mode = split_mode;
|
||||
// Session init params a later `reconfigure_bitrate` must re-present verbatim.
|
||||
self.split_mode = used_split;
|
||||
self.session_async = use_async;
|
||||
// Session-budget accounting (Stage W3): record what this open holds so admission can
|
||||
// decline a parallel display the hardware can't afford. Weighted by the FINAL split
|
||||
// mode (a split session occupies one hardware session per engine).
|
||||
self.session_units = split_mode_units(split_mode);
|
||||
self.session_units = split_mode_units(used_split);
|
||||
LIVE_SESSION_UNITS.fetch_add(self.session_units, std::sync::atomic::Ordering::Relaxed);
|
||||
// (The clamp path above already logs the requested→clamped bitrate at warn; no second
|
||||
// info line for the same event here.)
|
||||
@@ -1552,6 +1586,15 @@ impl Encoder for NvencD3d11Encoder {
|
||||
self.bitrate_bps = bps;
|
||||
return true;
|
||||
}
|
||||
// Cached codec-level ceiling: clamp the target BEFORE the driver call, so a known
|
||||
// overshoot retargets to the ceiling IN PLACE instead of bouncing off the driver into
|
||||
// the caller's full-rebuild fallback (an IDR plus ~half a second of session churn per
|
||||
// ABR overshoot on the pre-cache path). The caller reads the clamp back through
|
||||
// [`Encoder::applied_bitrate_bps`].
|
||||
let bps = match cached_ceiling(&self.ceiling_key(self.split_mode)) {
|
||||
Some(ceiling) => bps.min(ceiling),
|
||||
None => bps,
|
||||
};
|
||||
// SAFETY: `inited` ⟹ `self.encoder` is the live session and this runs on the encode
|
||||
// thread between submit/poll (`nvEncReconfigureEncoder` is a submit-side call, the
|
||||
// sanctioned side of the two-thread async split — the retrieve thread only ever locks
|
||||
@@ -1600,6 +1643,12 @@ impl Encoder for NvencD3d11Encoder {
|
||||
}
|
||||
}
|
||||
|
||||
fn applied_bitrate_bps(&self) -> Option<u64> {
|
||||
// `bitrate_bps` is the post-clamp truth: the open path's ceiling search and the
|
||||
// reconfigure path's cache clamp both write what the session ACTUALLY targets.
|
||||
Some(self.bitrate_bps)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Result<()> {
|
||||
Ok(()) // P1/ULL + frameIntervalP=1: each submit yields its AU; no internal queue to drain.
|
||||
}
|
||||
|
||||
@@ -243,6 +243,12 @@ impl Encoder for TrackedEncoder {
|
||||
fn reconfigure_bitrate(&mut self, bps: u64) -> bool {
|
||||
self.inner.reconfigure_bitrate(bps)
|
||||
}
|
||||
// Forwarded (the same trap class as `set_wire_chunking`): the unforwarded default `None`
|
||||
// would tell the session loop "no encoder truth here" and it would keep pacing/acking the
|
||||
// requested rate even where NVENC clamped to the codec-level ceiling.
|
||||
fn applied_bitrate_bps(&self) -> Option<u64> {
|
||||
self.inner.applied_bitrate_bps()
|
||||
}
|
||||
fn flush(&mut self) -> Result<()> {
|
||||
self.inner.flush()
|
||||
}
|
||||
|
||||
@@ -985,6 +985,18 @@ async fn serve_session(
|
||||
// full IDR when the encoder supports it (native-AMF LTR / Windows NVENC).
|
||||
let (rfi_tx, rfi_rx) = std::sync::mpsc::channel::<(u32, u32)>();
|
||||
let (bitrate_tx, bitrate_rx) = std::sync::mpsc::channel::<u32>();
|
||||
// Encoder-truth bridge, data plane → control task (§ABR overdrive). The encode loop publishes
|
||||
// here; the control task reads at `SetBitrate`-resolve time, so the ack the client's
|
||||
// controller climbs from tracks what the encoder ACTUALLY does, not what was asked:
|
||||
// - `live_bitrate`: the encoder's applied rate (kbps) — also the send pacer's/console's view.
|
||||
// - `encoder_ceiling_kbps`: the discovered codec-level ceiling (0 = none discovered yet);
|
||||
// resolves land at min(policy clamp, ceiling), so overshoots stop costing rebuilds.
|
||||
// - `cadence_degraded`: encode can't hold the frame cadence — a climb is refused (acked at
|
||||
// the current rate); the network isn't the bottleneck, more bits are anti-medicine.
|
||||
// Plain atomics, not a channel: only the freshest value matters, and only at resolve time.
|
||||
let live_bitrate = Arc::new(AtomicU32::new(welcome.bitrate_kbps));
|
||||
let encoder_ceiling_kbps = Arc::new(AtomicU32::new(0));
|
||||
let cadence_degraded = Arc::new(AtomicBool::new(false));
|
||||
let (probe_tx, probe_rx) = std::sync::mpsc::channel::<ProbeRequest>();
|
||||
let (probe_result_tx, probe_result_rx) = tokio::sync::mpsc::unbounded_channel::<ProbeResult>();
|
||||
// Mode-switch outcome, data plane → control task (same pattern as `probe_result_tx`): the accept
|
||||
@@ -1036,6 +1048,9 @@ async fn serve_session(
|
||||
live_reconfig_ok,
|
||||
adaptive_fec,
|
||||
session_bitrate_kbps,
|
||||
live_bitrate.clone(),
|
||||
encoder_ceiling_kbps.clone(),
|
||||
cadence_degraded.clone(),
|
||||
fec_target_ctl,
|
||||
reconfig_tx,
|
||||
keyframe_tx,
|
||||
@@ -1429,6 +1444,9 @@ async fn serve_session(
|
||||
bitrate_rx,
|
||||
compositor,
|
||||
bitrate_kbps,
|
||||
live_bitrate,
|
||||
encoder_ceiling_kbps,
|
||||
cadence_degraded,
|
||||
bitrate_auto,
|
||||
bit_depth,
|
||||
chroma,
|
||||
|
||||
@@ -22,6 +22,13 @@ pub(super) async fn run(
|
||||
live_reconfig_ok: bool,
|
||||
adaptive_fec: bool,
|
||||
session_bitrate_kbps: u32,
|
||||
/// Encoder-truth bridge (data plane → here, §ABR overdrive): the encoder's live applied rate,
|
||||
/// its discovered codec-level ceiling (0 = unknown), and the "encode can't hold cadence"
|
||||
/// flag. Read at `SetBitrate`-resolve time so the ack — the base the client's controller
|
||||
/// climbs from — never promises a rate the encoder won't run at.
|
||||
live_bitrate: Arc<AtomicU32>,
|
||||
encoder_ceiling_kbps: Arc<AtomicU32>,
|
||||
cadence_degraded: Arc<AtomicBool>,
|
||||
fec_target_ctl: Arc<AtomicU8>,
|
||||
reconfig_tx: std::sync::mpsc::Sender<punktfunk_core::Mode>,
|
||||
keyframe_tx: std::sync::mpsc::Sender<()>,
|
||||
@@ -161,7 +168,31 @@ pub(super) async fn run(
|
||||
);
|
||||
session_bitrate_kbps
|
||||
} else {
|
||||
resolve_bitrate_kbps(req.bitrate_kbps)
|
||||
let mut r = resolve_bitrate_kbps(req.bitrate_kbps);
|
||||
// Encoder truth (§ABR overdrive): the ack below is the base the
|
||||
// client's controller climbs from, so it must not promise past the
|
||||
// encoder's discovered codec-level ceiling — the pre-fix path acked
|
||||
// 1.01 Gbps while the ASIC ran 794 Mbps, and the controller climbed
|
||||
// from the phantom number forever (a ~0.6 s rebuild + IDR per step).
|
||||
let ceiling = encoder_ceiling_kbps.load(Ordering::Relaxed);
|
||||
if ceiling != 0 && r > ceiling {
|
||||
r = ceiling;
|
||||
}
|
||||
// Climb refusal while encode can't hold cadence: on a fat LAN no
|
||||
// network signal ever stops the climb, and past the compute knee more
|
||||
// bits only deepen the miss. Resolve a CLIMB to the current applied
|
||||
// rate (descents pass — they're the cure); the short ack teaches the
|
||||
// client controller its ceiling.
|
||||
let live = live_bitrate.load(Ordering::Relaxed);
|
||||
if cadence_degraded.load(Ordering::Relaxed) && live != 0 && r > live {
|
||||
tracing::info!(
|
||||
requested_kbps = req.bitrate_kbps,
|
||||
held_kbps = live,
|
||||
"bitrate climb refused — encode is behind cadence"
|
||||
);
|
||||
r = live;
|
||||
}
|
||||
r
|
||||
};
|
||||
tracing::debug!(
|
||||
requested_kbps = req.bitrate_kbps,
|
||||
|
||||
@@ -909,6 +909,20 @@ pub(super) struct SessionContext {
|
||||
pub(super) compositor: crate::vdisplay::Compositor,
|
||||
/// Negotiated encoder bitrate (kbps).
|
||||
pub(super) bitrate_kbps: u32,
|
||||
/// The encoder's live APPLIED rate (kbps) — shared with the send pacer, the web console, the
|
||||
/// mgmt registry AND the control task (which acks climbs against it). The encode loop stores
|
||||
/// `Encoder::applied_bitrate_bps` here after every apply, so everything downstream tracks
|
||||
/// what the ASIC really targets, not what was requested (§ABR overdrive).
|
||||
pub(super) live_bitrate: Arc<AtomicU32>,
|
||||
/// The encoder's discovered codec-level bitrate ceiling (kbps; 0 = none discovered): written
|
||||
/// when an apply comes back short, read by this loop (pre-clamp incoming requests — a
|
||||
/// request already AT the ceiling then costs nothing) and by the control task (truthful
|
||||
/// acks from the first post-discovery request).
|
||||
pub(super) encoder_ceiling_kbps: Arc<AtomicU32>,
|
||||
/// "Encode can't hold the frame cadence" (the escalation leaky bucket is elevated, or the
|
||||
/// session escalated): while set, the control task refuses bitrate CLIMBS — the network
|
||||
/// isn't the bottleneck, feeding the encoder more bits deepens the miss.
|
||||
pub(super) cadence_degraded: Arc<AtomicBool>,
|
||||
/// The client asked for "Automatic" (`Hello::bitrate_kbps == 0`), so `bitrate_kbps` came from
|
||||
/// the host's codec-aware default. For PyroWave that default is the ~1.6 bpp operating point of
|
||||
/// the NEGOTIATED MODE (`resolve_bitrate_kbps_for`) — a mid-stream mode switch re-resolves it
|
||||
@@ -1035,6 +1049,9 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
bitrate_rx,
|
||||
compositor,
|
||||
mut bitrate_kbps,
|
||||
live_bitrate,
|
||||
encoder_ceiling_kbps,
|
||||
cadence_degraded,
|
||||
bitrate_auto,
|
||||
bit_depth,
|
||||
// The resolved chroma is already captured in `plan` (above); ignore the duplicate here.
|
||||
@@ -1254,9 +1271,9 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// The bounded channel applies backpressure (the encode thread blocks if the send falls behind,
|
||||
// so frames slow down rather than a dropped frame freezing the infinite-GOP stream).
|
||||
let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<SendMsg>(3);
|
||||
// Live encoder bitrate, shared with the send thread's stats sample: a mid-stream adaptive
|
||||
// bitrate change (bitrate_rx below) updates it so the console shows the actual target.
|
||||
let live_bitrate = Arc::new(AtomicU32::new(bitrate_kbps));
|
||||
// `live_bitrate` (SessionContext) is shared with the send thread's stats sample AND the
|
||||
// control task: a mid-stream adaptive bitrate change (bitrate_rx below) stores the
|
||||
// encoder-APPLIED rate, so the console, pacer and climb-refusal acks all see the truth.
|
||||
// Live session mode, same pattern (H3): a mid-stream mode switch (reconfig below) updates it so
|
||||
// a stats capture armed after a resize registers the real mode. Seeded with the refresh the
|
||||
// initial build actually achieved (`interval_hz`), not the request — KWin may cap a virtual
|
||||
|
||||
Reference in New Issue
Block a user