feat(core): Automatic bitrate scales to measured link capacity — probe ceiling + slow start
ci / web (push) Successful in 49s
ci / docs-site (push) Successful in 52s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
decky / build-publish (push) Successful in 15s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 7s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 45s
apple / swift (push) Successful in 4m34s
ci / bench (push) Successful in 6m1s
flatpak / build-publish (push) Failing after 8m11s
docker / deploy-docs (push) Successful in 27s
deb / build-publish (push) Successful in 12m30s
windows-host / package (push) Successful in 14m50s
arch / build-publish (push) Successful in 15m1s
android / android (push) Successful in 15m47s
ci / rust (push) Successful in 17m55s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m17s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m54s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m4s
release / apple (push) Successful in 25m7s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 6m4s
apple / screenshots (push) Successful in 18m44s
ci / web (push) Successful in 49s
ci / docs-site (push) Successful in 52s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
decky / build-publish (push) Successful in 15s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 7s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 45s
apple / swift (push) Successful in 4m34s
ci / bench (push) Successful in 6m1s
flatpak / build-publish (push) Failing after 8m11s
docker / deploy-docs (push) Successful in 27s
deb / build-publish (push) Successful in 12m30s
windows-host / package (push) Successful in 14m50s
arch / build-publish (push) Successful in 15m1s
android / android (push) Successful in 15m47s
ci / rust (push) Successful in 17m55s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m17s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m54s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m4s
release / apple (push) Successful in 25m7s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 6m4s
apple / screenshots (push) Successful in 18m44s
The ABR ceiling was the negotiated start rate, so an 'Automatic' session was permanently boxed at the 20 Mbps default no matter the link — the most user-visible cap left after the transport work lifted the client receive ceiling to ~4.8 Gbps wire. - Startup link-capacity probe: ~2 s into an Automatic session the pump fires one speed-test burst (2 Gbps target, 800 ms) over the existing ProbeRequest machinery; delivered wire throughput x0.7 (FEC + variance headroom) becomes the controller's climb ceiling via set_ceiling(). Old hosts decline (all-zero reply) or never answer (a 6 s timeout clears the stuck probe state so LossReports resume) — the ceiling then stays negotiated, exactly the old behavior. PUNKTFUNK_ABR_PROBE=0 opts out. - Slow start: until the first congestion signal, every cooled clean window DOUBLES the rate toward the ceiling (20 Mbps -> 640 Mbps in ~10 s) instead of +6% per ~10 s (which would have taken ~10 minutes). Any congestion signal ends it for good; classic AIMD takes over. - Faster, severity-aware AIMD: a SEVERE window (unrecoverable frame, jump-to-live flush, or >=6% loss) backs off x0.7 immediately instead of waiting two windows; ordinary congestion (2-6% loss, OWD rise) keeps the two-window fuse. Additive climbs need 6 clean windows (~4.5 s, was ~10 s); the change cooldown drops 3 s -> 1.5 s. - PUNKTFUNK_VBV_FRAMES now also scales the direct-NVENC VBV (Windows + Linux, previously hardwired to 1 frame) — parity with AMF/VAAPI/QSV. Each accepted step still costs an encoder rebuild + IDR on the host; in-place rate reconfigure (NvEncReconfigureEncoder / AMF dynamic properties / Vulkan per-frame RC) is the planned follow-up that makes stepping free. Controller tests rewritten to the new policy (severity classes, slow-start climb, ceiling semantics; 144 green). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,12 +15,16 @@
|
|||||||
//! - **a jump-to-live flush** — the pump discarded its backlog, the strongest "we were behind"
|
//! - **a jump-to-live flush** — the pump discarded its backlog, the strongest "we were behind"
|
||||||
//! evidence there is.
|
//! evidence there is.
|
||||||
//!
|
//!
|
||||||
//! AIMD shape: two consecutive bad windows ⇒ multiplicative decrease (×0.7, floored); ~10 s of
|
//! AIMD shape: a SEVERE window (an unrecoverable frame, a flush, or ≥6 % loss) backs off ×0.7
|
||||||
//! clean windows ⇒ additive-ish increase (+~6 %, ceilinged at the session's starting rate — the
|
//! immediately; ordinary congestion (heavy-but-recoverable loss, an OWD rise) needs two
|
||||||
//! controller recovers *back to* what was negotiated, never beyond it). Changes are rate-limited
|
//! consecutive bad windows. Recovery is two-mode: **slow start** — until the first congestion
|
||||||
//! (each one costs the IDR the host's rebuilt encoder opens with) and the whole controller
|
//! signal the rate DOUBLES each clean window (cooldown-paced), which is how an Automatic session
|
||||||
//! disables itself against a host that never answers [`crate::quic::BitrateChanged`] (an older
|
//! climbs from the conservative start to the [`set_ceiling`](BitrateController::set_ceiling)
|
||||||
//! build that ignores unknown control messages).
|
//! measured by the startup link-capacity probe in seconds instead of minutes — then classic
|
||||||
|
//! additive recovery (+~6 % after ~4.5 s clean, ceilinged). Changes are rate-limited (each one
|
||||||
|
//! costs the IDR the host's rebuilt encoder opens with) and the whole controller disables itself
|
||||||
|
//! against a host that never answers [`crate::quic::BitrateChanged`] (an older build that
|
||||||
|
//! ignores unknown control messages).
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@@ -28,15 +32,21 @@ use std::time::{Duration, Instant};
|
|||||||
/// Never ask for less than this — below it the stream is unusable anyway and the floor keeps a
|
/// Never ask for less than this — below it the stream is unusable anyway and the floor keeps a
|
||||||
/// mis-measured window from cratering the session.
|
/// mis-measured window from cratering the session.
|
||||||
const FLOOR_KBPS: u32 = 5_000;
|
const FLOOR_KBPS: u32 = 5_000;
|
||||||
/// Consecutive bad windows before a decrease — one window can be a scheduler blip or a single
|
/// Consecutive bad windows before an ORDINARY decrease — one window can be a scheduler blip or a
|
||||||
/// Wi-Fi scan; two in a row (1.5 s) is a condition.
|
/// single Wi-Fi scan; two in a row (1.5 s) is a condition. A SEVERE window skips the wait.
|
||||||
const BAD_WINDOWS_TO_DECREASE: u32 = 2;
|
const BAD_WINDOWS_TO_DECREASE: u32 = 2;
|
||||||
/// Consecutive clean windows before probing back up (~10 s at the 750 ms cadence): recovery is
|
/// Window shard loss at/above which ONE window is enough to back off — 6 % is past any
|
||||||
/// deliberately much slower than backoff, classic AIMD.
|
/// blip/retry tail, and every 750 ms spent there is visible damage. Unrecoverable frames and
|
||||||
const CLEAN_WINDOWS_TO_INCREASE: u32 = 13;
|
/// jump-to-live flushes are severe for the same reason.
|
||||||
|
const SEVERE_LOSS_PPM: u32 = 60_000;
|
||||||
|
/// Consecutive clean windows before probing back up in congestion-avoidance mode (~4.5 s at the
|
||||||
|
/// 750 ms cadence): recovery stays slower than backoff, classic AIMD. (Slow start ignores this —
|
||||||
|
/// it doubles on every cooled clean window until the first congestion signal.)
|
||||||
|
const CLEAN_WINDOWS_TO_INCREASE: u32 = 6;
|
||||||
/// Minimum gap between requested changes — every accepted change costs an encoder rebuild + IDR
|
/// Minimum gap between requested changes — every accepted change costs an encoder rebuild + IDR
|
||||||
/// on the host, and back-to-back steps would outrun the ack/effect round trip.
|
/// on the host today (in-place reconfigure is planned), and back-to-back steps would outrun the
|
||||||
const CHANGE_COOLDOWN: Duration = Duration::from_secs(3);
|
/// ack/effect round trip.
|
||||||
|
const CHANGE_COOLDOWN: Duration = Duration::from_millis(1500);
|
||||||
/// Window shard loss beyond which the window counts bad even without an unrecoverable frame:
|
/// Window shard loss beyond which the window counts bad even without an unrecoverable frame:
|
||||||
/// 2 % sustained is congestion territory, not the random tail FEC exists for.
|
/// 2 % sustained is congestion territory, not the random tail FEC exists for.
|
||||||
const HEAVY_LOSS_PPM: u32 = 20_000;
|
const HEAVY_LOSS_PPM: u32 = 20_000;
|
||||||
@@ -56,9 +66,14 @@ pub(crate) struct BitrateController {
|
|||||||
enabled: bool,
|
enabled: bool,
|
||||||
/// The rate we believe the host encodes at (updated by acks; requests are not assumed).
|
/// The rate we believe the host encodes at (updated by acks; requests are not assumed).
|
||||||
current_kbps: u32,
|
current_kbps: u32,
|
||||||
/// The session's starting (negotiated) rate — the recovery ceiling.
|
/// The climb ceiling: the negotiated start rate until the startup link-capacity probe
|
||||||
|
/// raises it via [`set_ceiling`](Self::set_ceiling) — that measurement is what lets an
|
||||||
|
/// Automatic session scale past its conservative start.
|
||||||
ceiling_kbps: u32,
|
ceiling_kbps: u32,
|
||||||
floor_kbps: u32,
|
floor_kbps: u32,
|
||||||
|
/// Slow start: true until the first congestion signal — clean windows DOUBLE the rate
|
||||||
|
/// (cooldown-paced) instead of the +6 % additive step.
|
||||||
|
probing: bool,
|
||||||
/// Recent window mean OWDs (µs); the rolling min is the uncongested baseline.
|
/// Recent window mean OWDs (µs); the rolling min is the uncongested baseline.
|
||||||
owd_means: VecDeque<i64>,
|
owd_means: VecDeque<i64>,
|
||||||
bad_windows: u32,
|
bad_windows: u32,
|
||||||
@@ -78,6 +93,7 @@ impl BitrateController {
|
|||||||
current_kbps: start_kbps,
|
current_kbps: start_kbps,
|
||||||
ceiling_kbps: start_kbps,
|
ceiling_kbps: start_kbps,
|
||||||
floor_kbps: FLOOR_KBPS.min(start_kbps.max(1)),
|
floor_kbps: FLOOR_KBPS.min(start_kbps.max(1)),
|
||||||
|
probing: true,
|
||||||
owd_means: VecDeque::with_capacity(BASELINE_WINDOWS),
|
owd_means: VecDeque::with_capacity(BASELINE_WINDOWS),
|
||||||
bad_windows: 0,
|
bad_windows: 0,
|
||||||
clean_windows: 0,
|
clean_windows: 0,
|
||||||
@@ -86,6 +102,17 @@ impl BitrateController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Raise the climb ceiling to a measured link capacity (the startup speed-test probe's
|
||||||
|
/// delivered throughput with headroom already subtracted by the caller). Without this call
|
||||||
|
/// the ceiling stays the negotiated start rate — exactly the old behavior. Never lowers:
|
||||||
|
/// a congested-moment measurement must not shrink authority below what was negotiated
|
||||||
|
/// (descent is the congestion signals' job).
|
||||||
|
pub(crate) fn set_ceiling(&mut self, kbps: u32) {
|
||||||
|
if self.enabled && kbps > self.ceiling_kbps {
|
||||||
|
self.ceiling_kbps = kbps;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The host's [`crate::quic::BitrateChanged`] ack: its clamp is authoritative for what the
|
/// The host's [`crate::quic::BitrateChanged`] ack: its clamp is authoritative for what the
|
||||||
/// encoder now targets, and any ack proves the host renegotiates (resets the silence counter).
|
/// encoder now targets, and any ack proves the host renegotiates (resets the silence counter).
|
||||||
pub(crate) fn on_ack(&mut self, kbps: u32) {
|
pub(crate) fn on_ack(&mut self, kbps: u32) {
|
||||||
@@ -134,10 +161,16 @@ impl BitrateController {
|
|||||||
}
|
}
|
||||||
None => false,
|
None => false,
|
||||||
};
|
};
|
||||||
let bad = dropped > 0 || loss_ppm >= HEAVY_LOSS_PPM || owd_bad || flushed;
|
// SEVERE = the user already saw damage (an unrecoverable frame, a jump-to-live flush) or
|
||||||
|
// loss far past any blip — one window is enough. Ordinary congestion (heavy-but-
|
||||||
|
// recoverable loss, an OWD rise) still needs two consecutive windows.
|
||||||
|
let severe = dropped > 0 || flushed || loss_ppm >= SEVERE_LOSS_PPM;
|
||||||
|
let bad = severe || loss_ppm >= HEAVY_LOSS_PPM || owd_bad;
|
||||||
if bad {
|
if bad {
|
||||||
self.bad_windows += 1;
|
self.bad_windows += 1;
|
||||||
self.clean_windows = 0;
|
self.clean_windows = 0;
|
||||||
|
// Any congestion signal ends slow start for good — from here on, climbs are additive.
|
||||||
|
self.probing = false;
|
||||||
} else {
|
} else {
|
||||||
self.clean_windows += 1;
|
self.clean_windows += 1;
|
||||||
self.bad_windows = 0;
|
self.bad_windows = 0;
|
||||||
@@ -148,16 +181,27 @@ impl BitrateController {
|
|||||||
if !cooled {
|
if !cooled {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if self.bad_windows >= BAD_WINDOWS_TO_DECREASE && self.current_kbps > self.floor_kbps {
|
if (self.bad_windows >= BAD_WINDOWS_TO_DECREASE || (severe && self.bad_windows >= 1))
|
||||||
|
&& self.current_kbps > self.floor_kbps
|
||||||
|
{
|
||||||
let next = ((self.current_kbps as u64 * 7 / 10) as u32).max(self.floor_kbps);
|
let next = ((self.current_kbps as u64 * 7 / 10) as u32).max(self.floor_kbps);
|
||||||
self.bad_windows = 0;
|
self.bad_windows = 0;
|
||||||
return self.request(next, now);
|
return self.request(next, now);
|
||||||
}
|
}
|
||||||
if self.clean_windows >= CLEAN_WINDOWS_TO_INCREASE && self.current_kbps < self.ceiling_kbps
|
if self.current_kbps < self.ceiling_kbps {
|
||||||
{
|
// Slow start: double on every cooled clean window until the first congestion signal
|
||||||
let next = (self.current_kbps + self.current_kbps / 16 + 1).min(self.ceiling_kbps);
|
// (this is how an Automatic session reaches a probe-measured ceiling in seconds).
|
||||||
self.clean_windows = 0;
|
// Congestion avoidance: +~6 % after a sustained clean run.
|
||||||
return self.request(next, now);
|
if self.probing && self.clean_windows >= 1 {
|
||||||
|
let next = self.current_kbps.saturating_mul(2).min(self.ceiling_kbps);
|
||||||
|
self.clean_windows = 0;
|
||||||
|
return self.request(next, now);
|
||||||
|
}
|
||||||
|
if self.clean_windows >= CLEAN_WINDOWS_TO_INCREASE {
|
||||||
|
let next = (self.current_kbps + self.current_kbps / 16 + 1).min(self.ceiling_kbps);
|
||||||
|
self.clean_windows = 0;
|
||||||
|
return self.request(next, now);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -204,44 +248,66 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn two_bad_windows_step_down_multiplicatively() {
|
fn two_ordinary_bad_windows_step_down_multiplicatively() {
|
||||||
let mut c = BitrateController::new(20_000);
|
let mut c = BitrateController::new(20_000);
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
// One bad window is a blip — no reaction.
|
// Heavy-but-recoverable loss (2–6 %) is ORDINARY: one window is a blip — no reaction.
|
||||||
assert_eq!(c.on_window(ticks(start, 0), 1, 0, None, false), None);
|
assert_eq!(c.on_window(ticks(start, 0), 0, 25_000, None, false), None);
|
||||||
// The second consecutive bad window backs off ×0.7.
|
// The second consecutive bad window backs off ×0.7.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
c.on_window(ticks(start, 1), 1, 0, None, false),
|
c.on_window(ticks(start, 1), 0, 25_000, None, false),
|
||||||
Some(14_000)
|
Some(14_000)
|
||||||
);
|
);
|
||||||
c.on_ack(14_000);
|
c.on_ack(14_000);
|
||||||
// Still bad after the cooldown → another ×0.7 step from the ACKED rate.
|
// Still bad after the cooldown → another ×0.7 step from the ACKED rate.
|
||||||
assert_eq!(c.on_window(ticks(start, 6), 1, 0, None, false), None); // bad #1 again
|
assert_eq!(c.on_window(ticks(start, 6), 0, 25_000, None, false), None); // bad #1 again
|
||||||
assert_eq!(c.on_window(ticks(start, 7), 1, 0, None, false), Some(9_800));
|
assert_eq!(
|
||||||
|
c.on_window(ticks(start, 7), 0, 25_000, None, false),
|
||||||
|
Some(9_800)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn severe_window_backs_off_immediately() {
|
||||||
|
// An unrecoverable frame (the user SAW a freeze) skips the two-window wait…
|
||||||
|
let mut c = BitrateController::new(20_000);
|
||||||
|
let start = Instant::now();
|
||||||
|
assert_eq!(
|
||||||
|
c.on_window(ticks(start, 0), 1, 0, None, false),
|
||||||
|
Some(14_000)
|
||||||
|
);
|
||||||
|
// …and so does a jump-to-live flush.
|
||||||
|
let mut c = BitrateController::new(20_000);
|
||||||
|
assert_eq!(c.on_window(ticks(start, 0), 0, 0, None, true), Some(14_000));
|
||||||
|
// …and ≥6 % window loss.
|
||||||
|
let mut c = BitrateController::new(20_000);
|
||||||
|
assert_eq!(
|
||||||
|
c.on_window(ticks(start, 0), 0, 80_000, None, false),
|
||||||
|
Some(14_000)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cooldown_blocks_back_to_back_steps() {
|
fn cooldown_blocks_back_to_back_steps() {
|
||||||
let mut c = BitrateController::new(20_000);
|
let mut c = BitrateController::new(20_000);
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
assert_eq!(c.on_window(ticks(start, 0), 1, 0, None, false), None);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
c.on_window(ticks(start, 1), 1, 0, None, false),
|
c.on_window(ticks(start, 0), 1, 0, None, false),
|
||||||
Some(14_000)
|
Some(14_000)
|
||||||
);
|
);
|
||||||
c.on_ack(14_000);
|
c.on_ack(14_000);
|
||||||
// Two more bad windows land INSIDE the 3 s cooldown (ticks 2,3 = 1.5/2.25 s) → held.
|
// A severe window INSIDE the 1.5 s cooldown (tick 1 = 750 ms) → held; at the cooldown
|
||||||
assert_eq!(c.on_window(ticks(start, 2), 1, 0, None, false), None);
|
// boundary (tick 2 = 1.5 s) it fires.
|
||||||
assert_eq!(c.on_window(ticks(start, 3), 1, 0, None, false), None);
|
assert_eq!(c.on_window(ticks(start, 1), 1, 0, None, false), None);
|
||||||
|
assert_eq!(c.on_window(ticks(start, 2), 1, 0, None, false), Some(9_800));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn floor_is_never_crossed() {
|
fn floor_is_never_crossed() {
|
||||||
let mut c = BitrateController::new(6_000);
|
let mut c = BitrateController::new(6_000);
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
assert_eq!(c.on_window(ticks(start, 0), 1, 0, None, false), None);
|
|
||||||
// ×0.7 of 6000 = 4200 < floor → clamped to 5000.
|
// ×0.7 of 6000 = 4200 < floor → clamped to 5000.
|
||||||
assert_eq!(c.on_window(ticks(start, 1), 1, 0, None, false), Some(5_000));
|
assert_eq!(c.on_window(ticks(start, 0), 1, 0, None, false), Some(5_000));
|
||||||
c.on_ack(5_000);
|
c.on_ack(5_000);
|
||||||
// At the floor, further bad windows request nothing.
|
// At the floor, further bad windows request nothing.
|
||||||
assert_eq!(c.on_window(ticks(start, 6), 1, 0, None, false), None);
|
assert_eq!(c.on_window(ticks(start, 6), 1, 0, None, false), None);
|
||||||
@@ -252,21 +318,76 @@ mod tests {
|
|||||||
fn sustained_clean_recovers_toward_ceiling_only() {
|
fn sustained_clean_recovers_toward_ceiling_only() {
|
||||||
let mut c = BitrateController::new(20_000);
|
let mut c = BitrateController::new(20_000);
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
assert_eq!(c.on_window(ticks(start, 0), 1, 0, None, false), None);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
c.on_window(ticks(start, 1), 1, 0, None, false),
|
c.on_window(ticks(start, 0), 1, 0, None, false),
|
||||||
Some(14_000)
|
Some(14_000)
|
||||||
);
|
);
|
||||||
c.on_ack(14_000);
|
c.on_ack(14_000);
|
||||||
// 13 clean windows → one additive step up (14000 + 14000/16 + 1 = 14876).
|
// The backoff ended slow start → additive recovery: 6 clean windows → one +~6 % step
|
||||||
let up = run_clean(&mut c, start, 2, 13);
|
// (14000 + 14000/16 + 1 = 14876).
|
||||||
|
let up = run_clean(&mut c, start, 2, 7);
|
||||||
assert_eq!(up, Some(14_876));
|
assert_eq!(up, Some(14_876));
|
||||||
c.on_ack(14_876);
|
c.on_ack(14_876);
|
||||||
// Fully recovered → clean windows at the ceiling stay quiet (never probe past start).
|
// Fully recovered → clean windows at the ceiling stay quiet (never probe past it).
|
||||||
c.on_ack(20_000);
|
c.on_ack(20_000);
|
||||||
assert_eq!(run_clean(&mut c, start, 40, 20), None);
|
assert_eq!(run_clean(&mut c, start, 40, 20), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slow_start_doubles_to_a_probed_ceiling_then_stops() {
|
||||||
|
let mut c = BitrateController::new(20_000);
|
||||||
|
// The startup link-capacity probe measured ~430 Mbps delivered → ×0.7 ceiling.
|
||||||
|
c.set_ceiling(300_000);
|
||||||
|
let start = Instant::now();
|
||||||
|
// Every cooled clean window doubles until the ceiling caps the climb, then quiet.
|
||||||
|
let mut got = Vec::new();
|
||||||
|
for i in 0..14 {
|
||||||
|
if let Some(k) = c.on_window(ticks(start, i), 0, 0, Some(10_000), false) {
|
||||||
|
c.on_ack(k);
|
||||||
|
got.push(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(got, vec![40_000, 80_000, 160_000, 300_000]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_congestion_ends_slow_start_for_good() {
|
||||||
|
let mut c = BitrateController::new(20_000);
|
||||||
|
c.set_ceiling(300_000);
|
||||||
|
let start = Instant::now();
|
||||||
|
assert_eq!(
|
||||||
|
c.on_window(ticks(start, 0), 0, 0, Some(10_000), false),
|
||||||
|
Some(40_000)
|
||||||
|
);
|
||||||
|
c.on_ack(40_000);
|
||||||
|
// Severe window → immediate ×0.7, and slow start is over.
|
||||||
|
assert_eq!(
|
||||||
|
c.on_window(ticks(start, 2), 1, 0, Some(10_000), false),
|
||||||
|
Some(28_000)
|
||||||
|
);
|
||||||
|
c.on_ack(28_000);
|
||||||
|
// Clean again — but the next climb is additive, after the 6-window clean run.
|
||||||
|
let mut next = None;
|
||||||
|
for i in 3..12 {
|
||||||
|
next = c.on_window(ticks(start, i), 0, 0, Some(10_000), false);
|
||||||
|
if next.is_some() {
|
||||||
|
assert!(i >= 8, "additive climb must wait for the clean run");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(next, Some(29_751)); // 28000 + 28000/16 + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_ceiling_is_ignored_when_disabled_and_never_lowers() {
|
||||||
|
let mut c = BitrateController::new(0);
|
||||||
|
c.set_ceiling(1_000_000);
|
||||||
|
assert_eq!(c.on_window(Instant::now(), 0, 0, None, false), None);
|
||||||
|
let mut c = BitrateController::new(20_000);
|
||||||
|
c.set_ceiling(10_000); // below the negotiated start → ignored
|
||||||
|
assert_eq!(c.ceiling_kbps, 20_000);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn owd_rise_alone_is_a_congestion_signal() {
|
fn owd_rise_alone_is_a_congestion_signal() {
|
||||||
let mut c = BitrateController::new(20_000);
|
let mut c = BitrateController::new(20_000);
|
||||||
@@ -304,12 +425,4 @@ mod tests {
|
|||||||
}
|
}
|
||||||
assert_eq!(sent, MAX_UNACKED);
|
assert_eq!(sent, MAX_UNACKED);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn flush_counts_as_a_bad_window() {
|
|
||||||
let mut c = BitrateController::new(20_000);
|
|
||||||
let start = Instant::now();
|
|
||||||
assert_eq!(c.on_window(ticks(start, 0), 0, 0, None, true), None);
|
|
||||||
assert_eq!(c.on_window(ticks(start, 1), 0, 0, None, true), Some(14_000));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1952,6 +1952,22 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
});
|
});
|
||||||
|
// Startup link-capacity probe (Automatic sessions): the controller's ceiling is the
|
||||||
|
// negotiated start rate — the conservative 20 Mbps default, historically a box Automatic
|
||||||
|
// could NEVER climb out of. One speed-test burst shortly after the stream settles
|
||||||
|
// measures what the link actually delivers; ×0.7 (headroom for FEC overhead + variance)
|
||||||
|
// becomes the climb ceiling and slow start does the rest. Old hosts decline (all-zero
|
||||||
|
// reply) or never answer (timeout clears the state so LossReports resume) — either way
|
||||||
|
// the ceiling stays negotiated, exactly the old behavior. PUNKTFUNK_ABR_PROBE=0 opts out.
|
||||||
|
const CAPACITY_PROBE_KBPS: u32 = 2_000_000;
|
||||||
|
const CAPACITY_PROBE_MS: u32 = 800;
|
||||||
|
const CAPACITY_PROBE_DELAY: Duration = Duration::from_secs(2);
|
||||||
|
const CAPACITY_PROBE_TIMEOUT: Duration = Duration::from_secs(6);
|
||||||
|
let mut capacity_probe_at: Option<Instant> = (bitrate_kbps == 0
|
||||||
|
&& resolved_bitrate_kbps > 0
|
||||||
|
&& std::env::var("PUNKTFUNK_ABR_PROBE").map_or(true, |v| v != "0"))
|
||||||
|
.then(|| Instant::now() + CAPACITY_PROBE_DELAY);
|
||||||
|
let mut capacity_probe_deadline: Option<Instant> = None;
|
||||||
let (mut owd_sum_ns, mut owd_frames) = (0i128, 0u32);
|
let (mut owd_sum_ns, mut owd_frames) = (0i128, 0u32);
|
||||||
let mut flush_in_window = false;
|
let mut flush_in_window = false;
|
||||||
// Jump-to-live state (see the guard in the loop below): the clock-based over-bound run
|
// Jump-to-live state (see the guard in the loop below): the clock-based over-bound run
|
||||||
@@ -2006,6 +2022,65 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
}
|
}
|
||||||
p.active && !p.done
|
p.active && !p.done
|
||||||
};
|
};
|
||||||
|
// Fire the startup link-capacity probe once the stream has settled (see the constants
|
||||||
|
// above), and fold its measurement into the ABR ceiling when the result lands.
|
||||||
|
if let Some(at) = capacity_probe_at {
|
||||||
|
if Instant::now() >= at {
|
||||||
|
capacity_probe_at = None;
|
||||||
|
*pump_probe.lock().unwrap() = ProbeState {
|
||||||
|
active: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
if ctrl_tx
|
||||||
|
.try_send(CtrlRequest::Probe(ProbeRequest {
|
||||||
|
target_kbps: CAPACITY_PROBE_KBPS,
|
||||||
|
duration_ms: CAPACITY_PROBE_MS,
|
||||||
|
}))
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
capacity_probe_deadline = Some(Instant::now() + CAPACITY_PROBE_TIMEOUT);
|
||||||
|
tracing::info!(
|
||||||
|
target_kbps = CAPACITY_PROBE_KBPS,
|
||||||
|
duration_ms = CAPACITY_PROBE_MS,
|
||||||
|
"adaptive bitrate: startup link-capacity probe"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
pump_probe.lock().unwrap().active = false; // ctrl queue full — skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(deadline) = capacity_probe_deadline {
|
||||||
|
let mut p = pump_probe.lock().unwrap();
|
||||||
|
if p.done {
|
||||||
|
capacity_probe_deadline = None;
|
||||||
|
// An all-zero reply is a decline (old host / probe-less build) — keep the
|
||||||
|
// negotiated ceiling. Otherwise: delivered wire kbps × 0.7.
|
||||||
|
if p.host_duration_ms > 0 && p.delivered_bytes > 0 {
|
||||||
|
let delivered_kbps = (p.delivered_bytes.saturating_mul(8)
|
||||||
|
/ p.host_duration_ms.max(1) as u64)
|
||||||
|
as u32;
|
||||||
|
let ceiling = delivered_kbps.saturating_mul(7) / 10;
|
||||||
|
abr.set_ceiling(ceiling);
|
||||||
|
tracing::info!(
|
||||||
|
delivered_kbps,
|
||||||
|
ceiling_kbps = ceiling,
|
||||||
|
"adaptive bitrate: link-capacity probe done — climb ceiling set"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::info!(
|
||||||
|
"adaptive bitrate: capacity probe declined — keeping negotiated ceiling"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if Instant::now() >= deadline {
|
||||||
|
// The host never answered (a build that ignores ProbeRequest): clear the
|
||||||
|
// stuck-active state so LossReports resume, keep the negotiated ceiling.
|
||||||
|
p.active = false;
|
||||||
|
capacity_probe_deadline = None;
|
||||||
|
tracing::info!(
|
||||||
|
"adaptive bitrate: capacity probe timed out (old host?) — keeping negotiated ceiling"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
if !probe_active && last_report.elapsed() >= ADAPT_REPORT_INTERVAL {
|
if !probe_active && last_report.elapsed() >= ADAPT_REPORT_INTERVAL {
|
||||||
// A no-op clock flush earlier in this window suspected a wall-clock step: fire
|
// A no-op clock flush earlier in this window suspected a wall-clock step: fire
|
||||||
// the mid-stream re-sync now (once — the 60 s periodic covers everything else).
|
// the mid-stream re-sync now (once — the 60 s periodic covers everything else).
|
||||||
|
|||||||
@@ -332,6 +332,19 @@ impl Codec {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `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
|
||||||
|
/// direct-NVENC paths (which used to hardwire 1 frame) to parity. Larger values let complex
|
||||||
|
/// frames borrow bits — better rate utilization at the cost of per-frame size variance.
|
||||||
|
pub(crate) fn vbv_frames_env() -> f64 {
|
||||||
|
std::env::var("PUNKTFUNK_VBV_FRAMES")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse::<f64>().ok())
|
||||||
|
.filter(|v| v.is_finite() && *v > 0.0)
|
||||||
|
.unwrap_or(1.0)
|
||||||
|
}
|
||||||
|
|
||||||
/// Validate a requested encode resolution before we allocate buffers or open NVENC. Rejects
|
/// Validate a requested encode resolution before we allocate buffers or open NVENC. Rejects
|
||||||
/// zero/odd-sized and out-of-range modes with a clear error instead of letting buffer math
|
/// zero/odd-sized and out-of-range modes with a clear error instead of letting buffer math
|
||||||
/// overflow or the encoder open fail with an opaque NVENC code. A client can request any
|
/// overflow or the encoder open fail with an opaque NVENC code. A client can request any
|
||||||
|
|||||||
@@ -511,7 +511,9 @@ impl NvencCudaEncoder {
|
|||||||
cfg.rcParams.averageBitRate = bps;
|
cfg.rcParams.averageBitRate = bps;
|
||||||
cfg.rcParams.maxBitRate = bps;
|
cfg.rcParams.maxBitRate = bps;
|
||||||
if self.custom_vbv {
|
if self.custom_vbv {
|
||||||
let vbv = (bitrate as f64 / self.fps.max(1) as f64) as u32;
|
// ~1-frame VBV by default; PUNKTFUNK_VBV_FRAMES scales it (parity with AMF/VAAPI/QSV).
|
||||||
|
let vbv = ((bitrate as f64 / self.fps.max(1) as f64) * crate::encode::vbv_frames_env())
|
||||||
|
.clamp(1.0, u32::MAX as f64) as u32;
|
||||||
cfg.rcParams.vbvBufferSize = vbv;
|
cfg.rcParams.vbvBufferSize = vbv;
|
||||||
cfg.rcParams.vbvInitialDelay = vbv;
|
cfg.rcParams.vbvInitialDelay = vbv;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -734,7 +734,9 @@ impl NvencD3d11Encoder {
|
|||||||
// Shrink the VBV with the bitrate — NVENC validates it against the same level ceiling. Only
|
// Shrink the VBV with the bitrate — NVENC validates it against the same level ceiling. Only
|
||||||
// when the GPU advertises custom-VBV support (else leave the preset default, per the caps probe).
|
// when the GPU advertises custom-VBV support (else leave the preset default, per the caps probe).
|
||||||
if self.custom_vbv {
|
if self.custom_vbv {
|
||||||
let vbv = (bitrate as f64 / self.fps.max(1) as f64) as u32;
|
// ~1-frame VBV by default; PUNKTFUNK_VBV_FRAMES scales it (parity with AMF/VAAPI/QSV).
|
||||||
|
let vbv = ((bitrate as f64 / self.fps.max(1) as f64) * crate::encode::vbv_frames_env())
|
||||||
|
.clamp(1.0, u32::MAX as f64) as u32;
|
||||||
cfg.rcParams.vbvBufferSize = vbv;
|
cfg.rcParams.vbvBufferSize = vbv;
|
||||||
cfg.rcParams.vbvInitialDelay = vbv;
|
cfg.rcParams.vbvInitialDelay = vbv;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user