feat(host/encode): de-escalate the latency escalation once cadence holds clean

Escalate-and-hold's missing half. The contention escalation (capture depth,
then the NVENC pipelined retrieve) was permanent: one sustained overrun — even
one CAUSED by the ABR overdrive's rebuild storms — cost the session its
depth-1 latency and its sub-frame streaming forever, and `encode_us` reported
queue depth instead of ASIC time for the rest of the session.

- The leaky bucket now keeps scoring after escalation. A sustained
  every-frame-on-cadence run (~5 s at 120 fps) winds back one stage in reverse
  order: pipelined retrieve first (its rebuild restores the IO-stream binding
  and sub-frame chunked streaming), then capture depth back to 1. Attempts are
  paced by an exponential backoff (1 → 5 → 25 min, capped) — a workload that
  truly needs the escalation converges to keeping it, but never a permanent
  latch.

- NVENC (Linux) implements `set_pipelined(false)`: a `want_sync` latch handled
  at the same drained safe point as the engage side (`maybe_disengage_async`
  mirrors `maybe_engage_async`); the lazy sync re-init re-arms everything and
  opens on an IDR. The stream loop polls until the switch lands, then re-runs
  the escalation warmup so the wind-back's own stall can't re-escalate it.
  `PUNKTFUNK_NVENC_ASYNC=1` (operator-pinned async) refuses the wind-back;
  the trait doc now specifies the two-way contract.

- While escalated, `cadence_degraded` stays latched (bitrate climbs refused)
  even with the bucket drained: the headroom is spent, and climbs resuming
  mid-escalation would saw against it and starve the clean run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 01:44:54 +02:00
co-authored by Claude Fable 5
parent 1b27706a9b
commit 78fe77b049
3 changed files with 138 additions and 15 deletions
+9 -2
View File
@@ -352,8 +352,15 @@ pub trait Encoder: Send {
/// encoder analog of the capturer depth escalation: AUs ride ~one loop tick behind (`poll`
/// may return `None` while an encode is in flight) in exchange for capture/submit no longer
/// serializing on the encode wait. Returns whether pipelined retrieve is (now) active; the
/// switch may be deferred to the next safe point internally. `false` from the default impl =
/// unsupported — the session loop stops asking. De-escalation is a v2 item everywhere.
/// switch may be deferred to the next safe point internally. `set_pipelined(true)` returning
/// `false` (the default impl) = unsupported — the session loop stops asking.
///
/// `set_pipelined(false)` requests the wind-back (de-escalation, latency recovery): the
/// backend restores its sync-retrieve mode — and the latency features that mode carries
/// (IO-stream binding, sub-frame chunking) — at its next safe point, usually via a session
/// rebuild whose first frame is an IDR. The return is still "is pipelined retrieve active":
/// the caller polls until it reads `false`. Backends that never escalate return `false`
/// trivially. An operator pin (`PUNKTFUNK_NVENC_ASYNC=1`) refuses the wind-back.
fn set_pipelined(&mut self, _on: bool) -> bool {
false
}