Four findings, all real. **`SlotMap`'s own docs had become false.** "feed it every `DpbUpdate` in decode order (via `Self::apply` or `plan_to_vk`, which applies internally)" — `plan_to_vk` no longer applies internally, which is the entire point of the change, and `release`'s docs named it as one of the two things that may free a slot. A reader following those docs would build the next caller wrong in exactly the way this commit's parent fixed. Both now say which conversions defer, which one does not, and why H.265 is the one that does not. **The deferred release warned on a legitimate event.** `release_deferred` warned per id when a deferred release found no slot — but a renegotiation replaces the whole `Session`, and with it the slot map, INSIDE `plan`, while the planner's own drain reports every drained picture in that same access unit's `removed`. Every one of those ids then misses, and nothing is wrong. `debug!`, with the legitimate cause named so the illegitimate one stays diagnosable. **HEVC's exemption was asserted only in its consequence.** `the_current_picture_is_ named_by_curr_pic_and_never_aliases_a_reference` checked that no reference shares the decode target's slot — which on the vendored vector holds whether or not the reasoning behind it does. That is precisely how the H.264 leg passed for two milestones. The test now also asserts the PLANNER property the exemption rests on (`removed ∩ dpb_refs = ∅`, falsified by moving `dpb_snapshot()` above `decode_rps`), and records that the low-delay measurement was 0 of 300 against H.264's 297 of 300 from the same host and the same run. It also records what is still missing: no low-delay HEVC stream is vendored, so HEVC's freedom is a re-derivable argument plus one measurement, not a standing hardware leg. **Two stale cross-references.** Both AV1 conversions told the reader the H.264/H.265 zero was "measured on reordering vectors and not a proof" — the open question this commit's parent closed. They now say what the answer was.
298 lines
12 KiB
Rust
298 lines
12 KiB
Rust
//! The hardware DPB slot ledger: [`pf_bitstream::h264::PicId`]s mapped to the slot
|
|
//! indices a Vulkan Video session binds DPB images by.
|
|
//!
|
|
//! Division of labour: pf-bitstream's DPB runs the 8.2.5/C.4.5.3 processes and
|
|
//! DECIDES which pictures live and die — this map only translates its verdicts into
|
|
//! stable slot indices. It therefore never evicts on its own: running out of slots is
|
|
//! an error ([`SlotError::Full`]), because it can only mean removals were missed, and
|
|
//! a silent eviction would hide that bug behind corrupted output.
|
|
|
|
use pf_bitstream::h264::DpbUpdate;
|
|
use pf_bitstream::h264::PicId;
|
|
use tracing::trace;
|
|
|
|
/// The H.264 slot ceiling: 16 reference frames plus the picture being decoded.
|
|
const MAX_SLOTS: usize = 17;
|
|
|
|
/// What went wrong with a slot operation. Both variants are caller bugs, not stream
|
|
/// conditions — pf-bitstream degrades stream damage to warnings long before here.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum SlotError {
|
|
/// No free slot. The map is sized to `max_dpb_frames + 1`, which the planner's
|
|
/// DPB never exceeds; overflow means this map missed `removed` entries.
|
|
Full { capacity: usize },
|
|
/// The id already holds a slot; ids are per-picture and never re-assigned.
|
|
AlreadyAssigned { id: PicId, slot: u8 },
|
|
}
|
|
|
|
impl std::fmt::Display for SlotError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
SlotError::Full { capacity } => {
|
|
write!(
|
|
f,
|
|
"all {capacity} DPB slots are held — removals were missed"
|
|
)
|
|
}
|
|
SlotError::AlreadyAssigned { id, slot } => {
|
|
write!(f, "picture {id} already holds slot {slot}")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for SlotError {}
|
|
|
|
/// The slot ledger. One per decode session; feed it every [`DpbUpdate`] in decode
|
|
/// order.
|
|
///
|
|
/// [`Self::apply`] does that whole-update. `plan_to_vk` and `plan_to_vk_av1` do it in
|
|
/// two halves instead: they ASSIGN the stored picture and hand the removals back as a
|
|
/// `release_after_decode` list for the caller to apply once the decode op is issued.
|
|
/// The split is not a convenience — releasing a removal before the assignment lets
|
|
/// [`Self::assign`] return the slot this AU's own submission still names, which is a
|
|
/// picture decoding into one it predicts from. A caller that drops the list leaks a
|
|
/// slot per AU.
|
|
///
|
|
/// `plan_to_vk_h265` still applies its removals internally, and that is safe rather
|
|
/// than lucky: `H265Planner` snapshots `dpb_refs` AFTER `decode_rps`, so a picture
|
|
/// this AU's RPS dropped is never in the set its reference lists are built from.
|
|
///
|
|
/// Invariants (unit-tested):
|
|
/// - a [`PicId`] keeps its slot from [`Self::assign`] until [`Self::release`];
|
|
/// - a slot is reused only after its holder is released;
|
|
/// - assigning past capacity errors instead of evicting.
|
|
///
|
|
/// Slots are pure planner bookkeeping: consumers never hold a SLOT (the decoder's
|
|
/// picture pool decouples IMAGES from slots — a re-activated slot binds a fresh
|
|
/// free image, so a delivered picture's image is never a decode target while the
|
|
/// consumer reads it).
|
|
#[derive(Debug, Clone)]
|
|
pub struct SlotMap {
|
|
/// `slots[i]` holds the id bound to slot `i`, `None` while the slot is free.
|
|
slots: Vec<Option<PicId>>,
|
|
}
|
|
|
|
impl SlotMap {
|
|
/// Sized from [`pf_bitstream::h264::PicturePlan::max_dpb_frames`] plus one for
|
|
/// the picture being decoded (its setup slot coexists with a full reference
|
|
/// window).
|
|
///
|
|
/// pf-bitstream's envelope gate rejects any SPS asking for a DPB deeper than the
|
|
/// spec's 16 frames before a plan exists, so a larger request here is a caller
|
|
/// bug — debug-asserted, never silently clamped (a clamp would turn the bug into
|
|
/// silent evictions later).
|
|
pub fn new(max_dpb_frames: usize) -> Self {
|
|
debug_assert!(
|
|
max_dpb_frames < MAX_SLOTS,
|
|
"a {max_dpb_frames}-frame DPB exceeds the H.264 ceiling pf-bitstream's \
|
|
envelope gate enforces"
|
|
);
|
|
Self {
|
|
slots: vec![None; max_dpb_frames + 1],
|
|
}
|
|
}
|
|
|
|
/// Total slot count (fixed at construction).
|
|
pub fn capacity(&self) -> usize {
|
|
self.slots.len()
|
|
}
|
|
|
|
/// Slots currently held.
|
|
pub fn active(&self) -> usize {
|
|
self.slots.iter().filter(|slot| slot.is_some()).count()
|
|
}
|
|
|
|
/// The held slots as `(slot, id)` pairs, in slot order — WP-B walks this to
|
|
/// build `VkVideoReferenceSlotInfoKHR` bindings and to map slots back to their
|
|
/// images.
|
|
pub fn held(&self) -> impl Iterator<Item = (u8, PicId)> + '_ {
|
|
self.slots
|
|
.iter()
|
|
.enumerate()
|
|
// The envelope-gated capacity (<= 17) keeps every index within u8.
|
|
.filter_map(|(index, slot)| slot.map(|id| (index as u8, id)))
|
|
}
|
|
|
|
/// Bind `id` to the lowest free slot.
|
|
pub fn assign(&mut self, id: PicId) -> Result<u8, SlotError> {
|
|
if let Some(slot) = self.slot_of(id) {
|
|
return Err(SlotError::AlreadyAssigned { id, slot });
|
|
}
|
|
let free = self
|
|
.slots
|
|
.iter()
|
|
.position(Option::is_none)
|
|
.ok_or(SlotError::Full {
|
|
capacity: self.slots.len(),
|
|
})?;
|
|
self.slots[free] = Some(id);
|
|
// The envelope-gated capacity (<= 17) keeps every index within u8.
|
|
Ok(free as u8)
|
|
}
|
|
|
|
/// The slot `id` holds, if any.
|
|
pub fn slot_of(&self, id: PicId) -> Option<u8> {
|
|
self.slots
|
|
.iter()
|
|
.position(|slot| *slot == Some(id))
|
|
// The envelope-gated capacity (<= 17) keeps every index within u8.
|
|
.map(|index| index as u8)
|
|
}
|
|
|
|
/// Free `id`'s slot. Returns whether the id held one.
|
|
///
|
|
/// Slot lifetime is DPB RESIDENCY: a picture holds its slot for exactly as long
|
|
/// as the planner's DPB holds the picture — as a reference OR as a decoded
|
|
/// picture awaiting output — and that residency ends only when a
|
|
/// [`DpbUpdate::removed`] entry reports it. This method is that report's
|
|
/// primitive: [`Self::apply`] calls it with the planner's `removed` ids, and so
|
|
/// do the conversions' callers via `release_after_decode` — one AU's removals,
|
|
/// deferred until its decode op is issued. Nothing else may release a slot.
|
|
///
|
|
/// Releasing is CPU-side bookkeeping (the slot becomes assignable to a later
|
|
/// picture); keeping the released slot's IMAGE out of reuse until in-flight
|
|
/// decodes complete is the backend's synchronization, not this ledger's.
|
|
pub fn release(&mut self, id: PicId) -> bool {
|
|
match self.slots.iter().position(|slot| *slot == Some(id)) {
|
|
Some(index) => {
|
|
self.slots[index] = None;
|
|
true
|
|
}
|
|
None => false,
|
|
}
|
|
}
|
|
|
|
/// Apply one [`DpbUpdate`]: release every `removed` id.
|
|
///
|
|
/// `outputs` is deliberately ignored: output-readiness is display sequencing,
|
|
/// not the end of DPB residency — a display-ready picture can still be a
|
|
/// reference (its slot stays), and only its later `removed` entry frees the
|
|
/// slot.
|
|
pub fn apply(&mut self, update: &DpbUpdate) {
|
|
for &id in &update.removed {
|
|
if !self.release(id) {
|
|
// Tolerated but never silent: reachable only when the caller skipped
|
|
// feeding an AU's plan through this map.
|
|
trace!(id, "DpbUpdate removed an id this SlotMap never assigned");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn a_pic_id_keeps_its_slot_until_released_and_the_slot_is_then_reusable() {
|
|
let mut slots = SlotMap::new(3); // capacity 4
|
|
let s0 = slots.assign(10).unwrap();
|
|
let s1 = slots.assign(11).unwrap();
|
|
assert_ne!(s0, s1);
|
|
|
|
// Stable across unrelated churn.
|
|
assert_eq!(slots.slot_of(10), Some(s0));
|
|
slots.release(11);
|
|
assert_eq!(slots.slot_of(10), Some(s0));
|
|
assert_eq!(slots.slot_of(11), None);
|
|
|
|
// The freed slot is reusable; the held one is not.
|
|
let s2 = slots.assign(12).unwrap();
|
|
assert_eq!(s2, s1, "the lowest free slot is the released one");
|
|
assert_eq!(slots.slot_of(10), Some(s0));
|
|
assert_eq!(slots.active(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn assigning_past_capacity_is_an_error_never_a_silent_eviction() {
|
|
let mut slots = SlotMap::new(1); // capacity 2
|
|
slots.assign(1).unwrap();
|
|
slots.assign(2).unwrap();
|
|
assert_eq!(slots.assign(3), Err(SlotError::Full { capacity: 2 }));
|
|
// The failed assign evicted nothing.
|
|
assert_eq!(slots.slot_of(1), Some(0));
|
|
assert_eq!(slots.slot_of(2), Some(1));
|
|
}
|
|
|
|
#[test]
|
|
fn re_assigning_a_held_id_is_an_error_not_a_move() {
|
|
let mut slots = SlotMap::new(2);
|
|
let s = slots.assign(7).unwrap();
|
|
assert_eq!(
|
|
slots.assign(7),
|
|
Err(SlotError::AlreadyAssigned { id: 7, slot: s })
|
|
);
|
|
assert_eq!(slots.active(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn capacity_is_dpb_frames_plus_one_for_the_setup_slot() {
|
|
assert_eq!(SlotMap::new(16).capacity(), 17);
|
|
assert_eq!(SlotMap::new(4).capacity(), 5);
|
|
}
|
|
|
|
#[test]
|
|
#[should_panic(expected = "envelope")]
|
|
fn a_dpb_past_the_h264_ceiling_is_a_debug_panic_not_a_clamp() {
|
|
// pf-bitstream's envelope gate makes this unreachable from a real stream;
|
|
// reaching it means a caller bypassed the planner.
|
|
let _ = SlotMap::new(17);
|
|
}
|
|
|
|
#[test]
|
|
fn held_lists_slot_id_pairs_in_slot_order() {
|
|
let mut slots = SlotMap::new(3);
|
|
slots.assign(10).unwrap();
|
|
slots.assign(11).unwrap();
|
|
slots.assign(12).unwrap();
|
|
slots.release(11);
|
|
assert_eq!(slots.held().collect::<Vec<_>>(), vec![(0, 10), (2, 12)]);
|
|
}
|
|
|
|
#[test]
|
|
fn apply_releases_removed_ids_and_ignores_outputs() {
|
|
let mut slots = SlotMap::new(3);
|
|
slots.assign(1).unwrap();
|
|
slots.assign(2).unwrap();
|
|
slots.apply(&DpbUpdate {
|
|
stored: None,
|
|
outputs: vec![1], // display-ready, still a reference: must keep its slot
|
|
removed: vec![2],
|
|
});
|
|
assert_eq!(slots.slot_of(1), Some(0));
|
|
assert_eq!(slots.slot_of(2), None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_hundred_synthetic_dpb_updates_churn_without_aliasing_a_slot() {
|
|
// A sliding window of 4 references over 100 pictures: each id's slot must
|
|
// stay fixed while it lives, and no two live ids may ever share a slot.
|
|
let mut slots = SlotMap::new(4);
|
|
let mut recorded: Vec<(PicId, u8)> = Vec::new();
|
|
for id in 0u64..100 {
|
|
let slot = slots.assign(id).unwrap();
|
|
assert!(
|
|
recorded.iter().all(|&(_, held)| held != slot),
|
|
"assign handed out a slot a live picture still holds"
|
|
);
|
|
recorded.push((id, slot));
|
|
|
|
let removed = if id >= 4 { vec![id - 4] } else { Vec::new() };
|
|
slots.apply(&DpbUpdate {
|
|
stored: Some(id),
|
|
outputs: vec![id],
|
|
removed: removed.clone(),
|
|
});
|
|
for gone in removed {
|
|
recorded.retain(|&(held_id, _)| held_id != gone);
|
|
}
|
|
// Every live picture still holds exactly the slot it was assigned.
|
|
for &(live, slot) in &recorded {
|
|
assert_eq!(slots.slot_of(live), Some(slot));
|
|
}
|
|
assert!(slots.active() <= 5);
|
|
}
|
|
}
|
|
}
|