feat(core/abr): a keyframe-begging decoder backs the bitrate off, loss or no loss

The RX-9070 field trace: 14 decode-recovery keyframe requests in 2 s at
~300 Mbps with loss_ppm=0 — and the controller held the rate, because no
loss/OWD/latency signal moved. Repeated keyframe asks ARE the decoder
saying it cannot keep up; now they are an ABR input.

The control task counts outbound CtrlRequest::Keyframe at its send choke
point (the one place every emitter funnels through); the pump drains the
count per 750 ms report window into on_window. Two asks in a window is
ordinary-bad (two-window confirmation, like an OWD rise); four or more is
severe (the emitters throttle at 100 ms, so 4+ means the window was spent
begging) and backs off ×0.7 after one window. RFI asks are deliberately
not counted — they are the routine loss-recovery path and loss_ppm
already prices them in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 23:35:32 +02:00
co-authored by Claude Fable 5
parent cd6ceb98e3
commit d014cea489
4 changed files with 228 additions and 51 deletions
+203 -50
View File
@@ -70,6 +70,17 @@ const CHANGE_COOLDOWN: Duration = Duration::from_millis(1500);
/// 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.
const HEAVY_LOSS_PPM: u32 = 20_000;
/// Decode-recovery KEYFRAME asks in one window at/above which the window is bad: the decoder
/// asked for a fresh picture twice inside 750 ms — it is being overdriven (or repeatedly
/// wedged), whatever loss_ppm says. This is the signal the RX-9070 field trace exposed: 14
/// requests in 2 s at ~300 Mbps with ZERO loss, and the controller kept the rate because no
/// loss/OWD/latency signal moved. RFI asks are deliberately NOT counted — they are the routine
/// loss-recovery mechanism and loss_ppm already prices them in.
const RECOVERY_KF_BAD: u32 = 2;
/// One window at/above this many keyframe asks is SEVERE (skips the two-window confirmation):
/// the emitters throttle at 100 ms, so 4+ inside a window means the decoder spent most of it
/// unable to produce pictures — the user is already watching the damage.
const RECOVERY_KF_SEVERE: u32 = 4;
/// How far the window's mean one-way delay may sit above the rolling baseline before it counts
/// as queue growth. 25 ms is far beyond jitter at any streamable frame rate.
const OWD_RISE_US: i64 = 25_000;
@@ -281,7 +292,8 @@ impl BitrateController {
/// ACTUAL delivered throughput (wire bytes received ÷ window — what the pipeline really
/// carried, as opposed to the target it was allowed; feeds the utilization climb gate and
/// the proven-throughput high-water mark), `flushed` = the pump's jump-to-live fired in the
/// window.
/// window, `recovery_kf` = decode-recovery keyframe asks the client sent in the window (see
/// [`RECOVERY_KF_BAD`]).
#[allow(clippy::too_many_arguments)]
pub(crate) fn on_window(
&mut self,
@@ -293,6 +305,7 @@ impl BitrateController {
encode_mean_us: Option<i64>,
actual_kbps: u32,
flushed: bool,
recovery_kf: u32,
) -> Option<u32> {
if !self.enabled {
return None;
@@ -366,12 +379,22 @@ impl BitrateController {
self.proven_kbps = actual_kbps;
}
// SEVERE = the user already saw damage (an unrecoverable frame, a jump-to-live flush, a
// deep decode-latency excursion) or loss far past any blip — one window is enough.
// Ordinary congestion (heavy-but-recoverable loss, an OWD rise, a decode-latency rise)
// still needs two consecutive windows.
let severe =
dropped > 0 || flushed || loss_ppm >= SEVERE_LOSS_PPM || decode_severe || encode_severe;
let bad = severe || loss_ppm >= HEAVY_LOSS_PPM || owd_bad || decode_bad || encode_bad;
// deep decode-latency excursion, a window spent begging for keyframes) or loss far past
// any blip — one window is enough. Ordinary congestion (heavy-but-recoverable loss, an
// OWD rise, a decode-latency rise, repeated keyframe asks) still needs two consecutive
// windows.
let severe = dropped > 0
|| flushed
|| loss_ppm >= SEVERE_LOSS_PPM
|| decode_severe
|| encode_severe
|| recovery_kf >= RECOVERY_KF_SEVERE;
let bad = severe
|| loss_ppm >= HEAVY_LOSS_PPM
|| owd_bad
|| decode_bad
|| encode_bad
|| recovery_kf >= RECOVERY_KF_BAD;
if bad {
self.bad_windows += 1;
self.clean_windows = 0;
@@ -485,6 +508,7 @@ mod tests {
None,
1_000_000,
false,
0,
);
if out.is_some() {
return out;
@@ -499,7 +523,17 @@ mod tests {
let mut c = BitrateController::new(0);
let now = Instant::now();
assert_eq!(
c.on_window(now, 5, 900_000, Some(500_000), None, None, 1_000_000, true),
c.on_window(
now,
5,
900_000,
Some(500_000),
None,
None,
1_000_000,
true,
0
),
None
);
}
@@ -518,7 +552,8 @@ mod tests {
None,
None,
1_000_000,
false
false,
0
),
None
);
@@ -532,7 +567,8 @@ mod tests {
None,
None,
1_000_000,
false
false,
0
),
Some(14_000)
);
@@ -547,7 +583,8 @@ mod tests {
None,
None,
1_000_000,
false
false,
0
),
None
); // bad #1 again
@@ -560,7 +597,8 @@ mod tests {
None,
None,
1_000_000,
false
false,
0
),
Some(9_800)
);
@@ -572,13 +610,13 @@ mod tests {
let mut c = BitrateController::new(20_000);
let start = Instant::now();
assert_eq!(
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false),
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false, 0),
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, None, None, 1_000_000, true),
c.on_window(ticks(start, 0), 0, 0, None, None, None, 1_000_000, true, 0),
Some(14_000)
);
// …and ≥6 % window loss.
@@ -592,7 +630,8 @@ mod tests {
None,
None,
1_000_000,
false
false,
0
),
Some(14_000)
);
@@ -603,18 +642,18 @@ mod tests {
let mut c = BitrateController::new(20_000);
let start = Instant::now();
assert_eq!(
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false),
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false, 0),
Some(14_000)
);
c.on_ack(14_000);
// A severe window INSIDE the 1.5 s cooldown (tick 1 = 750 ms) → held; at the cooldown
// boundary (tick 2 = 1.5 s) it fires.
assert_eq!(
c.on_window(ticks(start, 1), 1, 0, None, None, None, 1_000_000, false),
c.on_window(ticks(start, 1), 1, 0, None, None, None, 1_000_000, false, 0),
None
);
assert_eq!(
c.on_window(ticks(start, 2), 1, 0, None, None, None, 1_000_000, false),
c.on_window(ticks(start, 2), 1, 0, None, None, None, 1_000_000, false, 0),
Some(9_800)
);
}
@@ -625,17 +664,17 @@ mod tests {
let start = Instant::now();
// ×0.7 of 6000 = 4200 < floor → clamped to 5000.
assert_eq!(
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false),
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false, 0),
Some(5_000)
);
c.on_ack(5_000);
// At the floor, further bad windows request nothing.
assert_eq!(
c.on_window(ticks(start, 6), 1, 0, None, None, None, 1_000_000, false),
c.on_window(ticks(start, 6), 1, 0, None, None, None, 1_000_000, false, 0),
None
);
assert_eq!(
c.on_window(ticks(start, 7), 1, 0, None, None, None, 1_000_000, false),
c.on_window(ticks(start, 7), 1, 0, None, None, None, 1_000_000, false, 0),
None
);
}
@@ -645,7 +684,7 @@ mod tests {
let mut c = BitrateController::new(20_000);
let start = Instant::now();
assert_eq!(
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false),
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false, 0),
Some(14_000)
);
c.on_ack(14_000);
@@ -677,6 +716,7 @@ mod tests {
None,
1_000_000,
false,
0,
) {
c.on_ack(k);
got.push(k);
@@ -699,7 +739,8 @@ mod tests {
None,
None,
1_000_000,
false
false,
0
),
Some(40_000)
);
@@ -714,7 +755,8 @@ mod tests {
None,
None,
1_000_000,
false
false,
0
),
Some(28_000)
);
@@ -731,6 +773,7 @@ mod tests {
None,
1_000_000,
false,
0,
);
if next.is_some() {
assert!(i >= 8, "additive climb must wait for the clean run");
@@ -745,7 +788,7 @@ mod tests {
let mut c = BitrateController::new(0);
c.set_ceiling(1_000_000);
assert_eq!(
c.on_window(Instant::now(), 0, 0, None, None, None, 1_000_000, false),
c.on_window(Instant::now(), 0, 0, None, None, None, 1_000_000, false, 0),
None
);
let mut c = BitrateController::new(20_000);
@@ -768,7 +811,8 @@ mod tests {
None,
None,
1_000_000,
false
false,
0
),
None
);
@@ -783,7 +827,8 @@ mod tests {
None,
None,
1_000_000,
false
false,
0
),
None
);
@@ -796,7 +841,8 @@ mod tests {
None,
None,
1_000_000,
false
false,
0
),
Some(14_000)
);
@@ -819,7 +865,8 @@ mod tests {
Some(8_000),
None,
1_000_000,
false
false,
0
),
None
);
@@ -835,7 +882,8 @@ mod tests {
Some(38_000),
None,
1_000_000,
false
false,
0
),
None
);
@@ -848,12 +896,96 @@ mod tests {
Some(40_000),
None,
1_000_000,
false
false,
0
),
Some(14_000)
);
}
#[test]
fn keyframe_ask_storm_alone_is_a_congestion_signal() {
// The RX-9070 field shape: pristine link (zero loss, flat OWD), no latency signal — but
// the decoder keeps begging for keyframes. Two asks per window is ordinary-bad: two
// consecutive windows back off ×0.7.
let mut c = BitrateController::new(20_000);
let start = Instant::now();
assert_eq!(
c.on_window(
ticks(start, 0),
0,
0,
Some(10_000),
None,
None,
1_000_000,
false,
2
),
None
);
assert_eq!(
c.on_window(
ticks(start, 1),
0,
0,
Some(10_000),
None,
None,
1_000_000,
false,
2
),
Some(14_000)
);
}
#[test]
fn keyframe_ask_saturation_is_severe() {
// The emitters throttle at 100 ms, so 4+ asks in one 750 ms window means the decoder
// spent most of it unable to produce pictures — one window is enough.
let mut c = BitrateController::new(20_000);
let start = Instant::now();
assert_eq!(
c.on_window(
ticks(start, 0),
0,
0,
Some(10_000),
None,
None,
1_000_000,
false,
4
),
Some(14_000)
);
}
#[test]
fn a_single_keyframe_ask_is_not_congestion() {
// A lone hiccup's recovery ask must not read as congestion — windows carrying one ask
// stay clean (no backoff however many in a row).
let mut c = BitrateController::new(20_000);
let start = Instant::now();
for i in 0..4 {
assert_eq!(
c.on_window(
ticks(start, i),
0,
0,
Some(10_000),
None,
None,
1_000_000,
false,
1
),
None
);
}
}
#[test]
fn decode_latency_caps_the_slow_start_climb() {
// A fat link (probe measured ~300 Mbps) but a decoder that saturates around the start rate.
@@ -870,7 +1002,8 @@ mod tests {
Some(8_000),
None,
1_000_000,
false
false,
0
),
Some(40_000)
);
@@ -886,7 +1019,8 @@ mod tests {
Some(38_000),
None,
1_000_000,
false
false,
0
),
None
);
@@ -901,7 +1035,8 @@ mod tests {
Some(40_000),
None,
1_000_000,
false
false,
0
),
Some(28_000)
);
@@ -925,7 +1060,8 @@ mod tests {
Some(8_000),
None,
2_000,
false
false,
0
),
None
);
@@ -942,7 +1078,8 @@ mod tests {
Some(8_000),
None,
18_000,
false
false,
0
),
Some(27_000)
);
@@ -966,7 +1103,8 @@ mod tests {
Some(8_000),
None,
20_000,
false
false,
0
),
Some(30_000)
);
@@ -981,7 +1119,8 @@ mod tests {
Some(8_000),
None,
30_000,
false
false,
0
),
Some(45_000)
);
@@ -1004,7 +1143,8 @@ mod tests {
Some(8_000),
None,
20_000,
false
false,
0
),
Some(30_000)
);
@@ -1020,7 +1160,8 @@ mod tests {
Some(4_000),
None,
600,
false
false,
0
),
None
);
@@ -1043,7 +1184,8 @@ mod tests {
Some(8_000),
None,
1_000_000,
false
false,
0
),
None
);
@@ -1059,7 +1201,8 @@ mod tests {
Some(60_000),
None,
1_000_000,
false
false,
0
),
Some(14_000)
);
@@ -1141,6 +1284,7 @@ mod tests {
None,
1_000_000,
false,
0,
);
}
assert_eq!(c.host_cap_kbps, Some(794_000 + 794_000 / 8));
@@ -1163,7 +1307,8 @@ mod tests {
None,
Some(7_000),
1_000_000,
false
false,
0
),
None
);
@@ -1177,7 +1322,8 @@ mod tests {
None,
Some(11_500),
1_000_000,
false
false,
0
),
None
);
@@ -1190,7 +1336,8 @@ mod tests {
None,
Some(12_000),
1_000_000,
false
false,
0
),
Some(14_000)
);
@@ -1212,7 +1359,8 @@ mod tests {
None,
Some(7_000),
1_000_000,
false
false,
0
),
None
);
@@ -1226,7 +1374,8 @@ mod tests {
None,
Some(20_000),
1_000_000,
false
false,
0
),
Some(14_000)
);
@@ -1249,6 +1398,7 @@ mod tests {
Some(7_000),
1_000_000,
false,
0,
);
}
let _ = c.on_window(
@@ -1260,6 +1410,7 @@ mod tests {
Some(12_000),
1_000_000,
false,
0,
);
assert_eq!(
c.on_window(
@@ -1270,7 +1421,8 @@ mod tests {
None,
Some(12_500),
1_000_000,
false
false,
0
),
Some(14_000)
);
@@ -1287,7 +1439,8 @@ mod tests {
None,
Some(15_000),
1_000_000,
false
false,
0
),
None
);
@@ -1302,7 +1455,7 @@ mod tests {
let mut i = 0;
// Keep every window bad and never ack: exactly MAX_UNACKED requests, then silence.
while i < 60 {
if c.on_window(ticks(start, i), 1, 0, None, None, None, 1_000_000, false)
if c.on_window(ticks(start, i), 1, 0, None, None, None, 1_000_000, false, 0)
.is_some()
{
sent += 1;
+6
View File
@@ -112,6 +112,10 @@ pub(super) async fn run_pump(args: WorkerArgs) {
// Adaptive bitrate ack slot: the control task parks the latest BitrateChanged here; the
// pump's controller drains it on its report tick (`take()` — an ack is consumed once).
let bitrate_ack: Arc<Mutex<Option<u32>>> = Arc::new(Mutex::new(None));
// Decode-recovery keyframe asks (the ABR recovery signal): the control task counts every
// outbound `CtrlRequest::Keyframe` — the one choke point all emitters funnel through — and
// the pump drains the count per report window.
let recovery_kf = Arc::new(AtomicU32::new(0));
// Host-encode-latency accumulator (the ABR encode signal, see [`EncodeLatAcc`]): the
// datagram task adds one sample per 0xCF; the pump drains a window mean per report tick.
let encode_lat = Arc::new(Mutex::new(super::frame_channel::EncodeLatAcc::default()));
@@ -130,6 +134,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
mode_slot,
probe: probe.clone(),
bitrate_ack: bitrate_ack.clone(),
recovery_kf: recovery_kf.clone(),
clock_offset: clock_offset.clone(),
clock_gen: clock_gen.clone(),
clip_event_tx: clip_event_tx.clone(),
@@ -189,6 +194,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
frames_dropped,
fec_recovered,
bitrate_ack,
recovery_kf,
bitrate_kbps,
resolved_bitrate_kbps,
negotiated_codec,
@@ -16,6 +16,10 @@ pub(super) struct ControlTask {
pub(super) probe: Arc<Mutex<ProbeState>>,
/// The latest host `BitrateChanged` ack, drained by the pump's ABR on its report tick.
pub(super) bitrate_ack: Arc<Mutex<Option<u32>>>,
/// Outbound decode-recovery KEYFRAME asks, counted here because this is the one choke point
/// every emitter funnels through (embedder, `note_frame_index`, the pump's own asks) — the
/// pump drains the count per report window as the ABR's recovery signal.
pub(super) recovery_kf: Arc<AtomicU32>,
pub(super) clock_offset: Arc<std::sync::atomic::AtomicI64>,
pub(super) clock_gen: Arc<AtomicU32>,
/// Clipboard metadata events (ClipState/ClipOffer) feed the same event plane the
@@ -40,6 +44,7 @@ impl ControlTask {
mode_slot,
probe,
bitrate_ack,
recovery_kf,
clock_offset,
clock_gen,
clip_event_tx,
@@ -72,7 +77,10 @@ impl ControlTask {
let bytes = match req {
CtrlRequest::Mode(m) => Reconfigure { mode: m }.encode(),
CtrlRequest::Probe(p) => p.encode(),
CtrlRequest::Keyframe => RequestKeyframe.encode(),
CtrlRequest::Keyframe => {
recovery_kf.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
RequestKeyframe.encode()
}
CtrlRequest::Rfi(r) => r.encode(),
CtrlRequest::Loss(r) => r.encode(),
CtrlRequest::SetBitrate(k) => SetBitrate { bitrate_kbps: k }.encode(),
@@ -28,6 +28,9 @@ pub(super) struct DataPump {
pub(super) frames_dropped: Arc<std::sync::atomic::AtomicU64>,
pub(super) fec_recovered: Arc<std::sync::atomic::AtomicU64>,
pub(super) bitrate_ack: Arc<Mutex<Option<u32>>>,
/// Outbound decode-recovery keyframe asks, counted by the control task at its send choke
/// point; drained per report window as the ABR's recovery signal.
pub(super) recovery_kf: Arc<AtomicU32>,
/// The embedder's REQUESTED rate (0 = Automatic — the only case the ABR arms).
pub(super) bitrate_kbps: u32,
/// The rate the host actually configured (echoed in Welcome).
@@ -52,6 +55,7 @@ impl DataPump {
frames_dropped,
fec_recovered,
bitrate_ack,
recovery_kf: pump_recovery_kf,
bitrate_kbps,
resolved_bitrate_kbps,
negotiated_codec,
@@ -425,6 +429,10 @@ impl DataPump {
*acc = Default::default();
(count > 0).then(|| (sum / count as u64) as i64)
};
// Decode-recovery keyframe asks this window (counted at the control task's send
// choke point). Always drained so a discard window can't leak its count into
// the next one.
let recovery_kf_reqs = pump_recovery_kf.swap(0, Ordering::Relaxed);
// The window's ACTUAL delivered throughput — what the pipeline really carried, vs
// the target it was allowed. Wire bytes (headers + FEC) slightly overstate the
// media rate the decoder ingests; acceptable for the climb gate / proven-mark
@@ -446,6 +454,7 @@ impl DataPump {
encode_mean_us,
actual_kbps,
flush_in_window,
recovery_kf_reqs,
)
};
if let Some(kbps) = verdict {
@@ -460,6 +469,7 @@ impl DataPump {
encode_mean_us = encode_mean_us.unwrap_or(-1),
actual_kbps,
flushed = flush_in_window,
recovery_kf = recovery_kf_reqs,
"adaptive bitrate: requesting encoder re-target"
);
let _ = ctrl_tx.try_send(CtrlRequest::SetBitrate(kbps));