refactor(pf-encode): extract the slot-family RFI recovery policy (WP7.2)
AMF (user-LTR bitfield), QSV (mfxExtRefListCtrl) and Vulkan Video (the
app-owned DPB slot table) each hand-implemented the same loss-recovery
decision: distrust every reference encoded at-or-after the loss start,
anchor on the newest one strictly older. Three copies had already
diverged once — the fecbec2d taint sweep reached AMF/QSV a commit before
the Vulkan backend was carved out, and Vulkan shipped without it. The
decision now lives once in enc/rfi.rs, pure and unit-tested (this path
had zero coverage and is the loss-recovery path); every mechanism —
how a force is applied, how distrust is persisted — stays in its
backend.
Behavior-preservation notes, each critic-verified against the shipped
code:
- Callers feed only currently-trusted references; taint (>= loss) and
anchor (< loss) predicates are disjoint, so pre-sweep-view pick ==
post-sweep-table pick on every input. Tie-break preserved (first
entry wins == the strict '>' all three used, ascending slot order).
- plan_slot_recovery delegates its pick half to pick_anchor, which
keeps both items live on every leg (pick_anchor's only external
caller is Linux+vulkan-encode; dead_code is an item lint) and makes
'anchor chosen from the taint snapshot' structural.
- The decline arms are deliberately NOT uniform and stay put: AMF/QSV
clear an un-consumed pending_force; Vulkan leaves pending_loss armed
(a stale arm re-resolves at frame-build into the healing IDR) — now
pinned in comments on both sides.
- Vulkan's frame-build site is a pick-only re-run by design (the arm
carries the loss start, not the slot); pick_recovery_slot is deleted
and its tests migrated 1:1 into the shared module.
Test-count deltas are expected: the rfi tests now run on every Windows
combo (amf.rs is featureless there) and on the Linux feature leg.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
//! The slot-family RFI (reference-frame invalidation) recovery **policy**, shared by the three
|
||||
//! backends that answer a loss with a re-reference to a known-good older frame instead of an IDR:
|
||||
//! native AMF (user-LTR bitfield), native QSV (`mfxExtRefListCtrl` LTR) and Vulkan Video (the
|
||||
//! app-owned DPB slot table). One decision, three mechanisms — the policy lived as three
|
||||
//! hand-copies and had already diverged once (the fecbec2d taint sweep reached AMF/QSV a commit
|
||||
//! before the Vulkan backend was carved out, and Vulkan shipped without it until a later fix).
|
||||
//! The NVENC twins' *range* policy is the other half of WP7.2, in
|
||||
//! [`super::nvenc_core::plan_range_recovery`].
|
||||
//!
|
||||
//! Policy only. Every mechanism — how a force is applied, how distrust is *persisted* (AMF clears
|
||||
//! its mirror slot to `None`, QSV sets a separate `ltr_tainted` flag because its mirror must keep
|
||||
//! naming the slot for the RejectedRefList, Vulkan blanks `slot_wire` to `-1` while `slot_poc`
|
||||
//! keeps the picture resident for the RPS) — stays in its backend. Callers feed their
|
||||
//! **currently-trusted** references and apply the returned taints through their own marker; that
|
||||
//! caller-side filter is exactly what makes the three persistence schemes equivalent under one
|
||||
//! pure function.
|
||||
//!
|
||||
//! The decline arm is also the backend's: AMF/QSV clear an un-consumed `pending_force` (the sweep
|
||||
//! may have emptied the slot it points at), while Vulkan deliberately leaves `pending_loss` armed
|
||||
//! (a stale arm is re-resolved at frame-build, where a failed re-pick forces the IDR that heals
|
||||
//! the stream). Do not harmonize them here.
|
||||
|
||||
/// One loss event's recovery decision over a slot table: which trusted references become
|
||||
/// untrustworthy, and which one anchors the recovery.
|
||||
pub(super) struct SlotPlan {
|
||||
/// Bitmask of slots whose reference was encoded **at or after** the loss start — inside the
|
||||
/// client's corrupt window, so the client either never received it or decoded it against a
|
||||
/// broken chain. Serving one as "known-good" on a LATER loss ships corruption tagged as the
|
||||
/// recovery anchor; the backend must record the distrust in its own persistent marker, because
|
||||
/// these wires would otherwise look like valid pre-loss anchors to the next loss event.
|
||||
pub(super) tainted: u32,
|
||||
/// The recovery anchor: the newest trusted reference **strictly older** than the loss —
|
||||
/// `(slot, wire)` — i.e. the most recent picture the client still holds intact, so
|
||||
/// re-referencing it costs the smallest residual. `None`: every candidate is inside or after
|
||||
/// the corrupt window — the caller declines and its (coalesced) keyframe path recovers.
|
||||
pub(super) anchor: Option<(usize, i64)>,
|
||||
}
|
||||
|
||||
/// Plan the recovery for a loss starting at wire frame `loss_first`, over the backend's
|
||||
/// currently-trusted references (`(slot, wire)`; previously-distrusted entries must already be
|
||||
/// filtered out by the caller — see the module doc). Sweep and pick are one call so the anchor is
|
||||
/// chosen from the same snapshot the taints are computed from, by construction: the anchor
|
||||
/// delegates to [`pick_anchor`], and `wire >= loss_first` (taint) and `wire < loss_first`
|
||||
/// (anchor) are disjoint, so a slot tainted by this call can never be this call's anchor.
|
||||
pub(super) fn plan_slot_recovery(refs: &[(usize, i64)], loss_first: i64) -> SlotPlan {
|
||||
// The callers' validity gate (`first < 0 → decline`) is what makes their sentinel filters
|
||||
// (-1 / `None`) exact views of "trusted"; this assert keeps the contract visible from inside
|
||||
// the extracted code. Plain assert: the lint legs run --release, and a compiled-out check
|
||||
// here would silently drop taints instead of failing loudly.
|
||||
assert!(
|
||||
loss_first >= 0,
|
||||
"loss_first must be validity-gated by the caller"
|
||||
);
|
||||
let mut tainted = 0u32;
|
||||
for &(slot, wire) in refs {
|
||||
if wire >= loss_first {
|
||||
assert!(slot < 32, "slot table exceeds the u32 taint mask");
|
||||
tainted |= 1 << slot;
|
||||
}
|
||||
}
|
||||
SlotPlan {
|
||||
tainted,
|
||||
anchor: pick_anchor(refs, loss_first),
|
||||
}
|
||||
}
|
||||
|
||||
/// The pick half alone: newest trusted reference strictly older than the loss. Ties break to the
|
||||
/// first entry in `refs` (every caller feeds ascending slot order, so the lowest slot wins —
|
||||
/// the strict `>` all three backends used). Standalone because Vulkan re-picks at frame-build
|
||||
/// time: its arm carries the loss start, not the slot, so the slot is resolved against the table
|
||||
/// as it stands when the recovery frame is actually encoded.
|
||||
pub(super) fn pick_anchor(refs: &[(usize, i64)], loss_first: i64) -> Option<(usize, i64)> {
|
||||
let mut best: Option<(usize, i64)> = None;
|
||||
for &(slot, wire) in refs {
|
||||
if wire < loss_first && best.is_none_or(|(_, b)| wire > b) {
|
||||
best = Some((slot, wire));
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{pick_anchor, plan_slot_recovery};
|
||||
|
||||
/// Adapt a raw slot table (the Vulkan `slot_wire` shape: `-1` = empty) into the trusted view
|
||||
/// the policy takes — the same filter the backend adapters apply.
|
||||
fn view(wires: &[i64]) -> Vec<(usize, i64)> {
|
||||
wires
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(s, &w)| (w >= 0).then_some((s, w)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Apply a plan's taints the way the Vulkan adapter does (blank the wire) — the persistence
|
||||
/// half the pure fn hands back to the caller.
|
||||
fn apply(wires: &mut [i64], tainted: u32) {
|
||||
for (s, w) in wires.iter_mut().enumerate() {
|
||||
if tainted & (1 << s) != 0 {
|
||||
*w = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The RFI anchor picker: newest resident wire strictly older than the loss; empty/newer
|
||||
/// slots never qualify. (Migrated 1:1 from `vulkan_video.rs`'s `pick_recovery_slot` tests —
|
||||
/// same vectors, now `(slot, wire)`-valued.)
|
||||
#[test]
|
||||
fn picks_newest_pre_loss() {
|
||||
// slots hold wires 5..12 (ring position arbitrary); loss starts at 9 → anchor = wire 8.
|
||||
let wires = [8i64, 9, 10, 11, 12, 5, 6, 7];
|
||||
assert_eq!(pick_anchor(&view(&wires), 9), Some((0, 8)));
|
||||
// loss older than everything resident → no anchor (caller keyframes).
|
||||
assert_eq!(pick_anchor(&view(&wires), 5), None);
|
||||
// empty slots (-1) are skipped by the view filter and never anchor.
|
||||
assert_eq!(pick_anchor(&view(&[-1, 3, -1, 4]), 5), Some((3, 4)));
|
||||
assert_eq!(pick_anchor(&view(&[-1; 8]), 5), None);
|
||||
// wire == loss_first is INSIDE the corrupt window: strictly-older only.
|
||||
assert_eq!(pick_anchor(&view(&[9, 8]), 9), Some((1, 8)));
|
||||
// tie on the wire → first entry (= lowest slot) wins, the strict `>` all three backends
|
||||
// used.
|
||||
assert_eq!(pick_anchor(&[(2, 7), (5, 7)], 9), Some((2, 7)));
|
||||
// empty view.
|
||||
assert_eq!(pick_anchor(&[], 9), None);
|
||||
}
|
||||
|
||||
/// The taint sweep (fecbec2d's fix): a slot encoded inside an EARLIER, still unrepaired loss
|
||||
/// window must not become the "known-good" anchor of a LATER loss. Without persisted
|
||||
/// distrust the picker accepts it — it is resident and its wire is below the second loss
|
||||
/// start — and the frame ships tagged `recovery_anchor`, lifting the client's freeze onto a
|
||||
/// reference it never decoded. (Migrated 1:1 from `vulkan_video.rs`; the hand-replicated
|
||||
/// sweep is now `plan_slot_recovery` itself.)
|
||||
#[test]
|
||||
fn taint_sweep_excludes_slots_from_an_earlier_loss() {
|
||||
// Slots hold wires 0..7. Loss 1 starts at wire 4, so wires 4..7 are undecodable at the
|
||||
// client. A second loss report arrives at wire 6 while they are all still resident.
|
||||
let tainted_wires = [4i64, 5, 6, 7];
|
||||
|
||||
// WITHOUT the sweep this is the bug: the newest wire below 6 is wire 5 — squarely inside
|
||||
// loss 1's unrepaired window — and it would be served as the "known-good" anchor.
|
||||
let unswept = [0i64, 1, 2, 3, 4, 5, 6, 7];
|
||||
let (_, picked_wire) = pick_anchor(&view(&unswept), 6).expect("unswept picks something");
|
||||
assert!(
|
||||
tainted_wires.contains(&picked_wire),
|
||||
"precondition: without the sweep the anchor comes from the earlier loss window"
|
||||
);
|
||||
|
||||
// WITH the plan, loss 1 taints 4..7 (and anchors on wire 3 — never a tainted wire, by
|
||||
// the disjoint predicates), so loss 2 can only reach genuinely clean wires.
|
||||
let mut wires = unswept;
|
||||
let plan = plan_slot_recovery(&view(&wires), 4);
|
||||
assert_eq!(plan.tainted, 0b1111_0000);
|
||||
assert_eq!(plan.anchor, Some((3, 3)));
|
||||
apply(&mut wires, plan.tainted);
|
||||
assert_eq!(wires, [0, 1, 2, 3, -1, -1, -1, -1]);
|
||||
let (slot, wire) = pick_anchor(&view(&wires), 6).expect("clean wires remain");
|
||||
assert_eq!((slot, wire), (3, 3), "newest clean survivor is wire 3");
|
||||
|
||||
// Encoding resumes after recovery; wires 8..11 refill the swept slots and are clean. A
|
||||
// later loss at wire 10 legitimately anchors on wire 9 — the sweep must not over-reject.
|
||||
wires[4] = 8;
|
||||
wires[5] = 9;
|
||||
wires[6] = 10;
|
||||
wires[7] = 11;
|
||||
let plan = plan_slot_recovery(&view(&wires), 10);
|
||||
assert_eq!(plan.anchor, Some((5, 9)), "wire 9 is post-recovery, clean");
|
||||
apply(&mut wires, plan.tainted);
|
||||
|
||||
// A loss covering every live wire leaves nothing clean → decline, caller serves an IDR.
|
||||
let mut all = [5i64, 6, 7, 8, 9, 10, 11, 12];
|
||||
let plan = plan_slot_recovery(&view(&all), 5);
|
||||
assert_eq!(plan.tainted, 0b1111_1111);
|
||||
assert_eq!(plan.anchor, None);
|
||||
apply(&mut all, plan.tainted);
|
||||
assert_eq!(pick_anchor(&view(&all), 5), None);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user