A 2026-08-12 field report (RTX 5060 client): every HEVC session demoted to D3D11VA with 81 "outside device caps: stream level (Std code point 12) above the device's maxLevelIdc (H.265 Std level 11)" refusals — the host's AMF encoder stamps general_level_idc 6.2 (the codec maximum) on a 4K120 stream that needs 5.2, and NVIDIA's driver caps H.265 decode at 6.1. The hardware decodes the actual stream trivially; only the declaration was oversized. AV1 passed the same gate, which is why "native-vulkan runs only with AV1". The declared level is a claim, and the stream's real demands are enforced where they are physical facts — coded extent and DPB depth, both checked at session build. So the up-front level gate (H.264 + H.265) now warns once and proceeds, and every SPS/VPS handed to the Vulkan parameters object has its level clamped to the device ceiling (a set above maxLevelIdc is invalid usage). AV1's gate is untouched: its code space is the bitstream's own and no over-declaration has been seen in the field. Verified on .173 (RTX 4090, driver 610.88): HEVC and AV1 both decode on the native Vulkan rung at 60 fps against an NVENC host; unit tests pin the clamp (lowers, only lowers, mutates the driver-visible block in place).
1959 lines
85 KiB
Rust
1959 lines
85 KiB
Rust
//! [`VkH265Decoder`]: the assembled native H.265 decoder — [`crate::decoder`] one
|
|
//! codec over, over pf-bitstream's H.265 planner and M3's CPU half.
|
|
//!
|
|
//! Per AU: `plan_au` → `plan_to_vk_h265` → slices-only upload into the bitstream
|
|
//! ring → record (barriers, `vkCmdBeginVideoCodingKHR` with every bound DPB slot,
|
|
//! the one-time session RESET control, a caps-gated `RESULT_STATUS_ONLY` query
|
|
//! bracketing `vkCmdDecodeVideoKHR`) → submit on the decode queue under the
|
|
//! caller's [`QueueLock`] with a per-image timeline signal.
|
|
//!
|
|
//! Everything codec-agnostic is SHARED with the H.264 decoder rather than
|
|
//! re-implemented: the picture pool and its zero-copy hand-off contract
|
|
//! ([`crate::images`]), the bitstream ring and its slices-only packing, the op ring
|
|
//! (command buffers + status queries), the pending/ready/graveyard bookkeeping,
|
|
//! and `settle_dpb`/`build_frame` from [`crate::decoder`]. What is genuinely
|
|
//! H.265's own lives here:
|
|
//!
|
|
//! - **The picture format is the stream's, not a constant.** Main decodes to NV12,
|
|
//! Main 10 to P010, RExt 4:4:4 to the two-plane 4:4:4 formats — and the same
|
|
//! facts shape the Vulkan profile every object is created against
|
|
//! ([`H265ProfileKey`]). A device that cannot host the combination is refused
|
|
//! BEFORE a session exists, so the ladder demotes cleanly.
|
|
//! - **Every referenced slot must be BOUND by this decode op.**
|
|
//! `StdVideoDecodeH265PictureInfo`'s `RefPicSetStCurrBefore`/`StCurrAfter`/
|
|
//! `LtCurr` arrays name DPB SLOT INDICES ([`crate::pic_h265`] builds them, and
|
|
//! its `DecodePlanVkH265::std_pic` docs carry the full argument for why they are
|
|
//! slots rather than positions in the reference list). The recording below lays
|
|
//! the op's references out in exactly [`DecodePlanVkH265::refs`] order — the set
|
|
//! of slots those arrays can name — and FAILS CLOSED if any of them has no bound
|
|
//! image: a named slot the op does not bind is unresolvable for the hardware.
|
|
//! (H.264 has no such arrays and only traces the case.)
|
|
//! - **Slice SEGMENT offsets, rebased and prefix-normalised.** The plan's offsets
|
|
//! are AU-relative and start-code-inclusive; the ring carries the slice NALUs
|
|
//! ALONE (non-VCL NALUs inside the decode range hang VCN firmware — the
|
|
//! `vcn_unified_0` ring timeout), so `pSliceSegmentOffsets` gets
|
|
//! [`pack_slices`]' ([`crate::ring`]) output, which also trims each segment to a
|
|
//! three-byte Annex-B prefix so drivers that reach the slice header by a fixed
|
|
//! `+3 +2` skip land on it.
|
|
//! - **RASL skips are not failures.** A RASL picture after an open-GOP CRA join is
|
|
//! undecodable by definition (8.1.3 NOTE); [`VkH265Decoder::decode`] answers it
|
|
//! like any other decode that produced no new picture — the next frame already
|
|
//! queued, or `None` — leaving the planner and the DPB untouched. No re-anchor,
|
|
//! no keyframe request: the very next AU plans normally.
|
|
//!
|
|
//! Codec dispatch (which decoder a stream gets) is the client wiring's job, not
|
|
//! this crate's: the public surface here mirrors [`crate::VkH264Decoder`]
|
|
//! method-for-method so the dispatch is a two-arm enum.
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::collections::VecDeque;
|
|
|
|
use ash::vk;
|
|
use ash::vk::native as hh;
|
|
use pf_bitstream::h265::AuPlan;
|
|
use pf_bitstream::h265::H265Planner;
|
|
use pf_bitstream::h265::PicId;
|
|
use pf_bitstream::h265::PlanError;
|
|
use pf_bitstream::h265::PlanWarning;
|
|
use tracing::debug;
|
|
use tracing::trace;
|
|
use tracing::warn;
|
|
|
|
use crate::caps::DecodeCaps;
|
|
use crate::caps::DecodeProfile;
|
|
use crate::caps_h265::derive_caps_h265;
|
|
use crate::caps_h265::query_h265_caps;
|
|
use crate::caps_h265::H265ProfileKey;
|
|
use crate::decoder::build_frame;
|
|
use crate::decoder::settle_dpb;
|
|
use crate::decoder::wait_timeline;
|
|
use crate::decoder::DecodeStatus;
|
|
use crate::decoder::DecodedVkFrame;
|
|
use crate::decoder::OpRing;
|
|
use crate::decoder::PendingPic;
|
|
use crate::decoder::RetiredPool;
|
|
use crate::decoder::VkDecodeError;
|
|
use crate::device::DecodeDevice;
|
|
use crate::device::DeviceHandles;
|
|
use crate::device::QueueLock;
|
|
use crate::device::QueueSubmitGuard;
|
|
use crate::images::plan_pools;
|
|
use crate::images::DpbPool;
|
|
use crate::images::PicturePool;
|
|
use crate::params_h265::level_to_std as level_to_std_h265;
|
|
use crate::pic_h265::plan_to_vk_h265;
|
|
use crate::pic_h265::DecodePlanVkH265;
|
|
use crate::pic_h265::PlanToVkH265Error;
|
|
use crate::ring::pack_slices;
|
|
use crate::ring::BitstreamRing;
|
|
use crate::ring::RingLayout;
|
|
use crate::ring::UploadedAu;
|
|
use crate::ring::INITIAL_SLOT_SIZE;
|
|
use crate::ring::RING_SLOTS;
|
|
use crate::session_h265::ParamsActionH265;
|
|
use crate::session_h265::SessionConfigH265;
|
|
use crate::session_h265::VideoSessionH265;
|
|
use crate::session_h265::VpsSource;
|
|
use crate::slots::SlotMap;
|
|
|
|
/// Everything tied to ONE H.265 session generation. A stream renegotiation
|
|
/// (extent, DPB depth, profile — including a bit-depth or chroma-format switch)
|
|
/// retires it and builds fresh.
|
|
struct SessionStateH265 {
|
|
session: VideoSessionH265,
|
|
slots: SlotMap,
|
|
/// Distinct mode's reference-only DPB backing; `None` in coincide mode (the
|
|
/// picture pool backs the DPB there).
|
|
dpb: Option<DpbPool>,
|
|
pool: PicturePool,
|
|
ring: BitstreamRing,
|
|
ops: OpRing,
|
|
/// Last-known Std reference info per DPB slot — `vkCmdBeginVideoCodingKHR`
|
|
/// wants codec reference info for EVERY bound slot, including ones this AU's
|
|
/// RPS does not reference; refreshed from each plan's setup/ref entries so
|
|
/// marking transitions (short-term → long-term promotion, which in HEVC only
|
|
/// ever happens through a LATER picture's `RefPicSetLtCurr`) propagate.
|
|
slot_refs: Vec<Option<hh::StdVideoDecodeH265ReferenceInfo>>,
|
|
/// Coincide mode: which pool image each DPB slot currently binds (rebound at
|
|
/// every activation — the decoupling that keeps delivered images safe).
|
|
slot_image: Vec<Option<usize>>,
|
|
/// Per command-buffer completion tokens (reuse gate).
|
|
cmd_marks: Vec<Option<(vk::Semaphore, u64)>>,
|
|
/// Per query-slot submission ordinals (staleness validation).
|
|
query_marks: Vec<u64>,
|
|
/// Submissions recorded on this session (cmd/query indexing).
|
|
submitted: u64,
|
|
/// The newest submission's completion token (session drain).
|
|
last_submit: Option<(vk::Semaphore, u64)>,
|
|
/// The STREAM's coded extent (renegotiation comparison).
|
|
coded_extent: vk::Extent2D,
|
|
/// The granularity-aligned allocation extent (picture resources + frames).
|
|
image_extent: vk::Extent2D,
|
|
}
|
|
|
|
/// The post-failure recovery latch: set when an AU failed after its planning had
|
|
/// already advanced, consumed by the next `decode`, which flushes to the next
|
|
/// IRAP before planning anything new.
|
|
///
|
|
/// Why this exists at all — the fail-closed/recover split:
|
|
///
|
|
/// This decoder FAILS CLOSED, and that stays: when an AU cannot be carried
|
|
/// through to a submitted decode, it returns an error rather than substituting a
|
|
/// reference or decoding against a slot whose image is gone. H.264's
|
|
/// soft-degrade (trace the missing binding, drop that reference, decode anyway)
|
|
/// is not available here because `StdVideoDecodeH265PictureInfo`'s
|
|
/// `RefPicSetStCurrBefore`/`StCurrAfter`/`LtCurr` arrays hold INDICES into the
|
|
/// decode op's reference array — dropping one entry re-points every later index
|
|
/// at the wrong picture, which is the corruption-hiding class this crate refuses.
|
|
///
|
|
/// But failing closed once must not wedge the stream FOREVER, and without this
|
|
/// latch it did: by the time an AU reaches a failure exit, `plan_to_vk_h265` has
|
|
/// already mutated the [`SlotMap`] (releases + the setup assignment) and the
|
|
/// coincide binding sync has already cleared the setup slot's image binding. The
|
|
/// planner and the slot map then both believe picture N is resident while no
|
|
/// image holds it, so every later AU referencing N fails in
|
|
/// [`build_scope`] with `UnboundReferenceSlot` — a transient consumer
|
|
/// backpressure (`NoFreeSlot` on AU N) turned into a permanently dead stream.
|
|
///
|
|
/// The recovery is a FLUSH TO THE NEXT IRAP, not H.264-style reference
|
|
/// substitution: `H265Planner::flush` drops the whole DPB and refuses everything
|
|
/// until the next IRAP, this decoder resets the slot ledger and image bindings to
|
|
/// match, and the integration layer — which already sets `want_keyframe` on EVERY
|
|
/// decode error — has an IDR on the way. So the stream re-anchors on real,
|
|
/// complete data instead of resuming over a DPB nobody can vouch for.
|
|
///
|
|
/// Its own type (the [`crate::session::ResetArm`] idiom one module over) so the
|
|
/// latch/consume cycle is unit-testable without a live device.
|
|
#[derive(Debug, Default)]
|
|
pub(crate) struct RecoveryLatch(bool);
|
|
|
|
impl RecoveryLatch {
|
|
/// Record that recovery is owed. Idempotent: two failures in a row still owe
|
|
/// exactly one flush.
|
|
pub(crate) fn latch(&mut self) {
|
|
self.0 = true;
|
|
}
|
|
|
|
/// Whether recovery is owed, CLEARING the latch — the recovery runs once per
|
|
/// failure run, not on every later decode.
|
|
pub(crate) fn take(&mut self) -> bool {
|
|
std::mem::take(&mut self.0)
|
|
}
|
|
|
|
/// Whether recovery is owed, without consuming it (state snapshots).
|
|
pub(crate) fn is_latched(&self) -> bool {
|
|
self.0
|
|
}
|
|
}
|
|
|
|
/// The native Vulkan Video H.265 decoder. Mirrors [`crate::VkH264Decoder`]'s
|
|
/// public surface exactly.
|
|
pub struct VkH265Decoder {
|
|
dev: DecodeDevice,
|
|
lock: Box<dyn QueueLock>,
|
|
planner: H265Planner,
|
|
/// Caps per profile key, queried once per profile (a Main→Main 10 switch is a
|
|
/// different key and re-queries).
|
|
caps: Option<(H265ProfileKey, DecodeCaps)>,
|
|
state: Option<SessionStateH265>,
|
|
/// Decoded pictures awaiting their planner output verdict, keyed by [`PicId`].
|
|
pending: BTreeMap<PicId, PendingPic>,
|
|
/// Display-ready frames not yet handed out.
|
|
ready: VecDeque<DecodedVkFrame>,
|
|
/// Retired generations' pools with consumer-held images (die on their last
|
|
/// release token).
|
|
graveyard: Vec<RetiredPool>,
|
|
/// The most recent plan's warnings ([`Self::take_warnings`]).
|
|
last_warnings: Vec<PlanWarning>,
|
|
/// The outstanding recovery point SEI, if any — see [`crate::recovery`].
|
|
/// Named apart from [`Self::recovery`], which is this decoder's DPB-recovery
|
|
/// latch: the two are unrelated (one is a fact about the stream's prediction
|
|
/// structure, the other about this decoder's own wedged state).
|
|
recovery_watch: crate::recovery::RecoveryWatch,
|
|
/// Pictures planned so far — stamped onto each one as
|
|
/// [`DecodedVkFrame::decode_order`]. Survives session rebuilds for the same
|
|
/// reason the watch does.
|
|
decoded: u64,
|
|
/// Session generation: bumped on every rebuild, stamped into frames.
|
|
generation: u64,
|
|
device_lost: bool,
|
|
/// Recovery owed after a failed AU whose planning had already advanced
|
|
/// ([`RecoveryLatch`] docs for the whole argument).
|
|
recovery: RecoveryLatch,
|
|
/// The over-declared-level warning has fired (once per decoder — the condition
|
|
/// is a property of the stream's SPS, so repeating it per AU is noise).
|
|
level_clamp_warned: bool,
|
|
}
|
|
|
|
impl VkH265Decoder {
|
|
/// Wrap the borrowed device. Sessions/pools are built lazily from the first
|
|
/// AU's SPS (their shape is the stream's, not the device's).
|
|
///
|
|
/// # Safety
|
|
///
|
|
/// The full [`DeviceHandles`] caller contract (liveness, enabled extensions
|
|
/// and features, truthful queue families) — held for this decoder's whole
|
|
/// lifetime, not just this call. The device must additionally have been
|
|
/// created with `VK_KHR_video_decode_h265` enabled; that part of the contract
|
|
/// is checked below AS FAR AS IT CAN BE — the check reads the decode queue
|
|
/// family's advertised `videoCodecOperations`, which is the
|
|
/// device's own claim about the family, not proof that the client enabled the
|
|
/// extension at `vkCreateDevice`. (punktfunk's presenter enables h264 + h265 +
|
|
/// av1, filtered by what the device supports — `pf-presenter/src/vk/setup.rs`
|
|
/// — so the two coincide there.) Getting it wrong is undefined behaviour at
|
|
/// session creation rather than an error, which is why the family check runs
|
|
/// before anything is queried or created.
|
|
pub unsafe fn new(
|
|
handles: &DeviceHandles,
|
|
lock: Box<dyn QueueLock>,
|
|
) -> Result<Self, VkDecodeError> {
|
|
// SAFETY: forwarded caller contract.
|
|
let dev = unsafe { DecodeDevice::wrap(handles)? };
|
|
// Before anything is queried or created: does this queue family actually
|
|
// run H.265 decode ops? `query_h265_caps` would succeed on capable
|
|
// hardware regardless (physical-device query), and the first
|
|
// `vkCreateVideoSessionKHR` with a DECODE_H265 profile on a device that
|
|
// never enabled the extension is UB — this is the ladder's clean demote.
|
|
dev.require_codec_op(vk::VideoCodecOperationFlagsKHR::DECODE_H265, "H.265 decode")?;
|
|
Ok(Self {
|
|
dev,
|
|
lock,
|
|
planner: H265Planner::new(),
|
|
caps: None,
|
|
state: None,
|
|
pending: BTreeMap::new(),
|
|
ready: VecDeque::new(),
|
|
graveyard: Vec::new(),
|
|
last_warnings: Vec::new(),
|
|
recovery_watch: crate::recovery::RecoveryWatch::new(),
|
|
decoded: 0,
|
|
generation: 0,
|
|
device_lost: false,
|
|
recovery: RecoveryLatch::default(),
|
|
level_clamp_warned: false,
|
|
})
|
|
}
|
|
|
|
/// Ask the device, BEFORE a single AU is fed, whether it can decode a stream of
|
|
/// the negotiated (chroma format, bit depth) shape — the construction-time half
|
|
/// of what the lazy `ensure_state` path would otherwise only discover at the
|
|
/// first SPS.
|
|
///
|
|
/// Why it exists: the session's picture format is the STREAM's, and a device
|
|
/// that advertises H.265 decode need not advertise a picture format for every
|
|
/// shape of it — 4:4:4 RExt is absent everywhere but NVIDIA, and 10-bit is
|
|
/// absent on some older silicon. Discovering that lazily makes the refusal a
|
|
/// mid-stream ERROR STREAK, which demotes past the FFmpeg rungs to
|
|
/// VAAPI/D3D11VA/software; discovering it here makes it a construction failure,
|
|
/// which the client's ladder answers by falling through to the next rung with
|
|
/// the session's hardware decode intact. Same query, same derivation, same
|
|
/// [`crate::CapsError`] — only the timing differs.
|
|
///
|
|
/// The negotiated facts are a HINT (the in-band SPS is authoritative), so this
|
|
/// is deliberately not a promise that decode will succeed: the level ceiling and
|
|
/// an SPS that disagrees with the Welcome still surface at the first AU. What it
|
|
/// does guarantee is that a shape the device provably cannot host never gets a
|
|
/// session built for it.
|
|
pub fn probe_stream_support(
|
|
&self,
|
|
chroma_format_idc: u8,
|
|
bit_depth_luma_minus8: u8,
|
|
) -> Result<(), VkDecodeError> {
|
|
let key = H265ProfileKey::from_negotiated(chroma_format_idc, bit_depth_luma_minus8)?;
|
|
let wanted = key
|
|
.output_format()
|
|
.expect("from_negotiated gated the chroma/depth combination");
|
|
// SAFETY: the constructor's `DeviceHandles` contract holds for this
|
|
// decoder's whole lifetime, so the physical device is live — the same
|
|
// proof `ensure_state`'s identical call carries.
|
|
let raw = unsafe { query_h265_caps(&self.dev, key) }.map_err(VkDecodeError::from)?;
|
|
derive_caps_h265(&raw, wanted)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Decode one access unit. Returns the next display-ready frame, if the
|
|
/// planner declared one.
|
|
///
|
|
/// A RASL picture the planner refuses after an open-GOP join is NOT an error:
|
|
/// it is undecodable BY DEFINITION (8.1.3 NOTE — its references precede the
|
|
/// join), so this returns `Ok` with whatever was already display-ready (a
|
|
/// frame an earlier AU decoded, or `None`) and leaves the planner, the DPB and
|
|
/// the slot ledger untouched; the next AU plans normally. Treating it as an
|
|
/// error would make every CRA join request a keyframe the host has no reason
|
|
/// to send. The warning ledger IS cleared, per [`Self::take_warnings`].
|
|
///
|
|
/// Never panics. `VkDecodeError::DeviceLost` latches: every later call fails
|
|
/// fast until the owner rebuilds the decoder on fresh handles.
|
|
pub fn decode(&mut self, au: &[u8]) -> Result<Option<DecodedVkFrame>, VkDecodeError> {
|
|
if self.device_lost {
|
|
return Err(VkDecodeError::DeviceLost);
|
|
}
|
|
let result = self.decode_inner(au);
|
|
if matches!(result, Err(VkDecodeError::DeviceLost)) {
|
|
self.device_lost = true;
|
|
}
|
|
result
|
|
}
|
|
|
|
fn decode_inner(&mut self, au: &[u8]) -> Result<Option<DecodedVkFrame>, VkDecodeError> {
|
|
// A previous AU failed after its planning had advanced: clear the stale
|
|
// DPB residency BEFORE planning this one, or every AU referencing the
|
|
// stranded picture fails forever ([`RecoveryLatch`] docs).
|
|
if self.recovery.take() {
|
|
self.recover_dpb();
|
|
}
|
|
// Cleared BEFORE planning for the same reason the RASL arm below clears it:
|
|
// "cleared by the next decode" must hold for an AU that fails to plan at
|
|
// all, or the previous AU's warnings could be re-read as fresh damage. The
|
|
// ledger is drained after every successful decode (`take_warnings` is a
|
|
// `mem::take`), so the failed-plan case is the only one that could carry
|
|
// over — a hole closed by construction, not a fix for a field symptom.
|
|
self.last_warnings.clear();
|
|
let plan = match self.planner.plan_au(au) {
|
|
Ok(plan) => plan,
|
|
Err(PlanError::RaslSkipped { poc }) => {
|
|
trace!(poc, "RASL picture after a CRA join — skipped, not failed");
|
|
return Ok(self.ready.pop_front());
|
|
}
|
|
Err(e) => return Err(VkDecodeError::PlanH265(e)),
|
|
};
|
|
for warning in &plan.warnings {
|
|
// The recovery verdict is the integration layer's
|
|
// ([`Self::take_warnings`]); never silent here though.
|
|
trace!(?warning, "plan warning");
|
|
}
|
|
self.last_warnings = plan.warnings.clone();
|
|
// One picture per AU under this envelope: stamp its DECODE-order ordinal
|
|
// before anything can reorder it (see `DecodedVkFrame::decode_order`).
|
|
self.decoded = self.decoded.saturating_add(1);
|
|
let decode_order = self.decoded;
|
|
// The recovery-point watch, folded ONCE per successfully planned AU and in
|
|
// DECODE order — the order the SEI's POC delta is measured in. The mark
|
|
// rides the pending picture into display order (crate::recovery).
|
|
let recovery = self.recovery_watch.note_h265(
|
|
plan.picture.pic_order_cnt,
|
|
plan.picture.is_irap,
|
|
plan.picture.recovery_point,
|
|
);
|
|
if recovery != crate::recovery::RecoveryMark::NONE {
|
|
trace!(
|
|
sei = recovery.sei_here,
|
|
recovery_point = recovery.is_recovery_point,
|
|
poc = plan.picture.pic_order_cnt,
|
|
"recovery point SEI"
|
|
);
|
|
}
|
|
|
|
// From here the PLANNER has already advanced past this AU — its DPB holds
|
|
// the picture whatever happens next — so any failure below leaves the
|
|
// planner's DPB and this decoder's slot/image ledgers able to disagree.
|
|
// Latch the recovery for the next decode rather than returning into a
|
|
// permanently wedged state. (Deliberately wider than the paths that
|
|
// mutate the SlotMap: a failure BEFORE `plan_to_vk_h265` mutates it —
|
|
// `UnresolvedReference`, an `ensure_state` refusal — strands the picture
|
|
// the other way round, planner-resident with no slot at all, and wedges
|
|
// just as hard. One flush cures both.)
|
|
let result = self.decode_planned(&plan, au, recovery, decode_order);
|
|
if result.is_err() {
|
|
self.recovery.latch();
|
|
}
|
|
result
|
|
}
|
|
|
|
/// The submission half of one decode, from the point the planner has already
|
|
/// advanced. Split out so [`Self::decode_inner`] can latch recovery on ANY
|
|
/// failure past that line without threading a flag through every exit.
|
|
/// `au` is the same buffer `plan`'s slice ranges index into; `recovery` is the
|
|
/// recovery-point verdict already folded for this AU and `decode_order` its
|
|
/// decode-order ordinal (both advance in decode order, so neither can be
|
|
/// derived here — this path is not reached for every planned AU).
|
|
fn decode_planned(
|
|
&mut self,
|
|
plan: &AuPlan,
|
|
au: &[u8],
|
|
recovery: crate::recovery::RecoveryMark,
|
|
decode_order: u64,
|
|
) -> Result<Option<DecodedVkFrame>, VkDecodeError> {
|
|
self.ensure_state(plan)?;
|
|
|
|
// The VPS this SPS activates: the stream's own, or the fallback identity
|
|
// for a stream joined after its VPS NALU (session_h265 module docs).
|
|
let vps = VpsSource::for_sps(&plan.sps);
|
|
|
|
// Convert, with ONE rebuild retry on CapacityMismatch — the designed
|
|
// trigger for a DPB-depth renegotiation (pic_h265.rs docs).
|
|
let mut vk_plan: Option<DecodePlanVkH265> = None;
|
|
for attempt in 0..2 {
|
|
// A parameters RECREATE destroys the old object, which an in-flight
|
|
// decode may still be executing against: drain first.
|
|
if self
|
|
.state
|
|
.as_ref()
|
|
.expect("ensure_state built it")
|
|
.session
|
|
.parameters_action(&vps, &plan.sps, &plan.pps)
|
|
== ParamsActionH265::Recreate
|
|
{
|
|
self.drain_gpu()?;
|
|
}
|
|
let state = self.state.as_mut().expect("ensure_state built it");
|
|
// SAFETY: live device (constructor contract); the drain above
|
|
// satisfies ensure_parameters' Recreate contract, and Current/Add
|
|
// touch nothing a submitted decode reads.
|
|
unsafe {
|
|
state
|
|
.session
|
|
.ensure_parameters(&vps, &plan.sps, &plan.pps)?
|
|
};
|
|
match plan_to_vk_h265(plan, &mut state.slots) {
|
|
Ok(converted) => {
|
|
vk_plan = Some(converted);
|
|
break;
|
|
}
|
|
Err(PlanToVkH265Error::CapacityMismatch { required, capacity }) if attempt == 0 => {
|
|
debug!(
|
|
required,
|
|
capacity, "DPB depth renegotiated — rebuilding session"
|
|
);
|
|
self.rebuild_state(plan)?;
|
|
}
|
|
Err(e) => return Err(VkDecodeError::ConvertH265(e)),
|
|
}
|
|
}
|
|
let vk_plan = vk_plan.expect("the rebuilt session matches its own plan");
|
|
|
|
let state = self.state.as_mut().expect("ensured above");
|
|
// The per-AU active-reference gate: the session was created with
|
|
// maxActiveReferencePictures; binding more in one decode op would be a
|
|
// silent VUID violation on the drivers that matter most.
|
|
let max_active = state.session.config.max_active_references as usize;
|
|
if vk_plan.refs.len() > max_active {
|
|
return Err(VkDecodeError::Unsupported(format!(
|
|
"AU references {} pictures, session allows {max_active} active references",
|
|
vk_plan.refs.len()
|
|
)));
|
|
}
|
|
|
|
// Coincide binding sync: slots the planner released no longer bind their
|
|
// images (the pictures may still be pending/held — untouched), and the
|
|
// setup slot's PREVIOUS binding is cleared before it binds fresh.
|
|
let setup = usize::from(vk_plan.setup_slot);
|
|
if state.dpb.is_none() {
|
|
let mut held = vec![false; state.slot_image.len()];
|
|
for (slot, _id) in state.slots.held() {
|
|
held[usize::from(slot)] = true;
|
|
}
|
|
for (slot, binding) in state.slot_image.iter_mut().enumerate() {
|
|
if let Some(picture) = *binding {
|
|
if !held[slot] || slot == setup {
|
|
state.pool.pictures[picture].bound = false;
|
|
*binding = None;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The decode target: a FREE pool image (never one a consumer holds — the
|
|
// whole point of the pool model).
|
|
let Some(dst) = state.pool.free_index() else {
|
|
debug!(
|
|
held = state.pool.held_total(),
|
|
"picture pool exhausted — release_frame owed"
|
|
);
|
|
return Err(VkDecodeError::NoFreeSlot);
|
|
};
|
|
|
|
// Cross-queue waits (the AVVkFrame contract): the dst image's last known
|
|
// timeline value (covers a presenter write-back after release), plus —
|
|
// coincide mode — every referenced image's value, so reference reads
|
|
// order after any presenter layout restore already reported back.
|
|
let mut waits: Vec<(vk::Semaphore, u64)> = Vec::new();
|
|
{
|
|
let dst_pic = &state.pool.pictures[dst];
|
|
if dst_pic.value > 0 {
|
|
waits.push((dst_pic.semaphore, dst_pic.value));
|
|
}
|
|
}
|
|
if state.dpb.is_none() {
|
|
for r in &vk_plan.refs {
|
|
if let Some(picture) = state.slot_image[usize::from(r.slot)] {
|
|
let pic = &state.pool.pictures[picture];
|
|
if pic.value > 0 && !waits.iter().any(|(sem, _)| *sem == pic.semaphore) {
|
|
waits.push((pic.semaphore, pic.value));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let signal_value = state.pool.pictures[dst].value + 1;
|
|
|
|
// Command buffer + query slot for this submission.
|
|
let submission = state.submitted;
|
|
let cmd_index = (submission % state.ops.cmds.len() as u64) as usize;
|
|
if let Some((sem, value)) = state.cmd_marks[cmd_index] {
|
|
// SAFETY: live device; the token is a pool image's semaphore.
|
|
unsafe { wait_timeline(self.dev.ash(), sem, value, "command buffer reuse")? };
|
|
}
|
|
let query_index = (submission % u64::from(state.ops.query_count)) as u32;
|
|
|
|
// Upload the AU (recycles/grows against submission-completion tokens).
|
|
let device = self.dev.ash().clone();
|
|
let mut poll = |token: &(vk::Semaphore, u64)| -> Result<bool, VkDecodeError> {
|
|
// SAFETY: live device; the token's semaphore is a pool semaphore.
|
|
let current = unsafe { device.get_semaphore_counter_value(token.0) }
|
|
.map_err(VkDecodeError::from)?;
|
|
Ok(current >= token.1)
|
|
};
|
|
let device2 = self.dev.ash().clone();
|
|
let mut wait = |token: &(vk::Semaphore, u64)| -> Result<(), VkDecodeError> {
|
|
// SAFETY: as above.
|
|
unsafe { wait_timeline(&device2, token.0, token.1, "bitstream slot drain") }
|
|
};
|
|
// SLICE SEGMENT NALUs only, concatenated — an HEVC AU opens with AUD/SEI
|
|
// (and, at IRAPs, VPS/SPS/PPS) NALUs, and feeding those to the VCN
|
|
// firmware inside the decode range HANGS it (the .25 `vcn_unified_0 ring
|
|
// timeout` the H.264 path was built around; the parameter sets ride the
|
|
// session parameters object instead). The plan's AU-relative offsets are
|
|
// rebased into the packed buffer.
|
|
let plan_segments: Vec<std::ops::Range<usize>> =
|
|
plan.slices.iter().map(|s| s.data.clone()).collect();
|
|
// One rebased offset per slice segment, in plan order — the same count and
|
|
// order as `vk_plan.slice_offsets` (both are built by walking
|
|
// `plan.slices`), so `pSliceSegmentOffsets` and `sliceSegmentCount` agree
|
|
// by construction rather than by check. `pack_slices` additionally
|
|
// normalises each segment's Annex-B prefix to THREE bytes and computes the
|
|
// offsets from the normalised lengths, in one call, so the bytes and the
|
|
// offsets cannot drift apart (`crate::ring::three_byte_prefix` — the
|
|
// four-byte prefix this vector's 249 TRAIL segments carry is what shifted
|
|
// NVIDIA's slice-header parse by a byte).
|
|
let Some(packed) = pack_slices(au, &plan_segments) else {
|
|
return Err(VkDecodeError::Unsupported(
|
|
"packed slice data exceeds the u32 offsets Vulkan submits".into(),
|
|
));
|
|
};
|
|
let slice_offsets = packed.offsets;
|
|
// SAFETY: live device; the segments are the plan's own in-bounds slice
|
|
// ranges (narrowed by the prefix normalisation, so still in bounds); every
|
|
// pending token is the completion signal of the submission that consumed
|
|
// the slot.
|
|
let upload = unsafe {
|
|
state
|
|
.ring
|
|
.upload(&self.dev, au, &packed.segments, &mut poll, &mut wait)?
|
|
};
|
|
|
|
// Record + submit, signalling the dst image's next timeline value.
|
|
// SAFETY: live device; every handle recorded below belongs to this
|
|
// session generation, and the packed slices sit uploaded in the ring slot.
|
|
unsafe {
|
|
record_and_submit_h265(
|
|
&self.dev,
|
|
&*self.lock,
|
|
state,
|
|
&vk_plan,
|
|
&slice_offsets,
|
|
&upload,
|
|
dst,
|
|
cmd_index,
|
|
query_index,
|
|
&waits,
|
|
signal_value,
|
|
)?;
|
|
}
|
|
|
|
// Post-submit bookkeeping.
|
|
let dst_sem = state.pool.pictures[dst].semaphore;
|
|
state.pool.pictures[dst].value = signal_value;
|
|
state.pool.pictures[dst].pending = true;
|
|
if state.dpb.is_none() {
|
|
state.pool.pictures[dst].bound = true;
|
|
state.slot_image[setup] = Some(dst);
|
|
}
|
|
state.cmd_marks[cmd_index] = Some((dst_sem, signal_value));
|
|
state.query_marks[query_index as usize] = submission;
|
|
state.submitted += 1;
|
|
state.last_submit = Some((dst_sem, signal_value));
|
|
state
|
|
.ring
|
|
.pending
|
|
.set_pending(upload.slot, (dst_sem, signal_value));
|
|
|
|
// Refresh the per-slot reference cache from this AU's facts.
|
|
state.slot_refs[setup] = Some(vk_plan.setup_ref);
|
|
for r in &vk_plan.refs {
|
|
state.slot_refs[usize::from(r.slot)] = Some(r.std);
|
|
}
|
|
|
|
self.pending.insert(
|
|
vk_plan.setup_id,
|
|
PendingPic {
|
|
image: dst,
|
|
submission,
|
|
query_slot: query_index,
|
|
timeline_value: signal_value,
|
|
crop: plan.picture.display_crop,
|
|
colour: plan.picture.colour,
|
|
poc: plan.picture.pic_order_cnt,
|
|
is_idr: plan.picture.is_idr,
|
|
recovery,
|
|
decode_order,
|
|
},
|
|
);
|
|
|
|
// The plan's DPB verdicts over the pending map: outputs become ready
|
|
// frames (their images move pending → held until released);
|
|
// removed-but-never-output pictures free their images.
|
|
let (ready, dropped) = settle_dpb(&mut self.pending, &plan.dpb);
|
|
let state = self.state.as_mut().expect("ensured above");
|
|
for entry in ready {
|
|
let frame = build_frame(
|
|
&mut state.pool,
|
|
state.dpb.is_none(),
|
|
state.image_extent,
|
|
&entry,
|
|
self.generation,
|
|
);
|
|
self.ready.push_back(frame);
|
|
}
|
|
for entry in dropped {
|
|
debug!(
|
|
poc = entry.poc,
|
|
"picture removed without output — freeing its image"
|
|
);
|
|
state.pool.pictures[entry.image].pending = false;
|
|
}
|
|
Ok(self.ready.pop_front())
|
|
}
|
|
|
|
/// Hand a delivered frame back. `presenter_signaled` reports whether the
|
|
/// consumer SAMPLED the image (and therefore enqueued the `value + 1`
|
|
/// timeline signal per the [`DecodedVkFrame`] contract) — the decoder then
|
|
/// waits that write-back before the image's next use. Every frame
|
|
/// `decode`/`take_ready` returns must come back exactly once, including
|
|
/// stale-generation frames (their retired pool dies on its last token).
|
|
pub fn release_frame(
|
|
&mut self,
|
|
frame: &DecodedVkFrame,
|
|
presenter_signaled: bool,
|
|
) -> Result<(), VkDecodeError> {
|
|
let pool = if frame.generation == self.generation {
|
|
match &mut self.state {
|
|
Some(state) => &mut state.pool,
|
|
None => {
|
|
return Err(VkDecodeError::StaleFrame {
|
|
frame_generation: frame.generation,
|
|
current_generation: self.generation,
|
|
})
|
|
}
|
|
}
|
|
} else {
|
|
match self
|
|
.graveyard
|
|
.iter_mut()
|
|
.find(|r| r.generation == frame.generation)
|
|
{
|
|
Some(retired) => &mut retired.pool,
|
|
None => {
|
|
return Err(VkDecodeError::StaleFrame {
|
|
frame_generation: frame.generation,
|
|
current_generation: self.generation,
|
|
})
|
|
}
|
|
}
|
|
};
|
|
let index = frame.picture as usize;
|
|
if index >= pool.pictures.len() {
|
|
return Err(VkDecodeError::StaleFrame {
|
|
frame_generation: frame.generation,
|
|
current_generation: self.generation,
|
|
});
|
|
}
|
|
let picture = &mut pool.pictures[index];
|
|
match picture.held.checked_sub(1) {
|
|
Some(remaining) => picture.held = remaining,
|
|
None => {
|
|
debug!(index, "frame released more often than delivered");
|
|
return Ok(());
|
|
}
|
|
}
|
|
if presenter_signaled {
|
|
picture.value = picture.value.max(frame.value + 1);
|
|
}
|
|
// A retired pool dies on its last token (presenter fence-waited before
|
|
// the token per the release contract; decode work drained at retirement).
|
|
if frame.generation != self.generation {
|
|
self.graveyard
|
|
.retain(|r| r.generation != frame.generation || r.pool.held_total() > 0);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// A display-ready frame beyond the one `decode` returned, if any. Drain after
|
|
/// every decode; frames left here still occupy pool images.
|
|
pub fn take_ready(&mut self) -> Option<DecodedVkFrame> {
|
|
self.ready.pop_front()
|
|
}
|
|
|
|
/// The warnings of the most recent successfully planned AU (concealment
|
|
/// signals — the integration layer's want_keyframe hook). Cleared by the
|
|
/// next `decode`.
|
|
pub fn take_warnings(&mut self) -> Vec<PlanWarning> {
|
|
std::mem::take(&mut self.last_warnings)
|
|
}
|
|
|
|
/// The current session generation ([`DecodedVkFrame::generation`] of newly
|
|
/// delivered frames).
|
|
pub fn generation(&self) -> u64 {
|
|
self.generation
|
|
}
|
|
|
|
/// The DECODE-order ordinal of the most recently planned picture — the
|
|
/// watermark a consumer compares [`DecodedVkFrame::decode_order`] against to
|
|
/// tell a frame decoded before a loss from one decoded after it. Especially
|
|
/// load-bearing here: [`Self::recover_dpb`] flushes every buffered picture
|
|
/// into `ready` at once, so a pre-loss picture routinely reaches the consumer
|
|
/// after the loss that flushed it. 0 before the first AU plans.
|
|
pub fn decode_order(&self) -> u64 {
|
|
self.decoded
|
|
}
|
|
|
|
/// One-line state snapshot for failure paths and field logs (not a stable
|
|
/// format).
|
|
pub fn debug_snapshot(&self) -> String {
|
|
// A latched recovery is the single most useful thing to see next to a
|
|
// failure: it says the NEXT decode flushes to an IRAP rather than
|
|
// resuming (RecoveryLatch docs).
|
|
let recovery = if self.recovery.is_latched() {
|
|
" recovery=owed"
|
|
} else {
|
|
""
|
|
};
|
|
match &self.state {
|
|
None => format!("gen={}{recovery} <no session>", self.generation),
|
|
Some(state) => {
|
|
let occupancy: Vec<String> = state
|
|
.pool
|
|
.pictures
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, p)| {
|
|
format!(
|
|
"{i}:{}{}h{}",
|
|
if p.bound { "B" } else { "-" },
|
|
if p.pending { "P" } else { "-" },
|
|
p.held
|
|
)
|
|
})
|
|
.collect();
|
|
format!(
|
|
"h265 gen={}{recovery} mode={} slots_held={}/{} pool=[{}] pending={} \
|
|
ready={} graveyard={}",
|
|
self.generation,
|
|
if state.dpb.is_none() {
|
|
"coincide"
|
|
} else {
|
|
"distinct"
|
|
},
|
|
state.slots.active(),
|
|
state.slots.capacity(),
|
|
occupancy.join(" "),
|
|
self.pending.len(),
|
|
self.ready.len(),
|
|
self.graveyard.len(),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Read `frame`'s decode status WITHOUT waiting.
|
|
///
|
|
/// [`DecodeStatus::Failed`] covers driver-reported errors AND a query slot
|
|
/// re-armed before it was read (the status is then unprovable — same
|
|
/// conservative verdict).
|
|
///
|
|
/// On drivers whose decode family lacks `queryResultStatusSupport` (RADV)
|
|
/// there is no per-op verdict to read: `Ok` then means "the decode op
|
|
/// COMPLETED on the timeline" — the same information FFmpeg has on every
|
|
/// driver, no worse.
|
|
pub fn poll_status(&mut self, frame: &DecodedVkFrame) -> DecodeStatus {
|
|
self.read_status(frame, false)
|
|
}
|
|
|
|
/// Does this decode queue family answer per-op `RESULT_STATUS` queries at all?
|
|
/// See [`crate::VkH264Decoder::status_queries`] — the fact is the DEVICE's, identical
|
|
/// for both codecs, and it is what tells a clean integrity report apart from
|
|
/// an undetectable one.
|
|
pub fn status_queries(&self) -> bool {
|
|
self.dev.result_status_queries()
|
|
}
|
|
|
|
/// [`Self::poll_status`], but WAITs for the op to complete first.
|
|
pub fn wait_status(&mut self, frame: &DecodedVkFrame) -> DecodeStatus {
|
|
self.read_status(frame, true)
|
|
}
|
|
|
|
fn read_status(&mut self, frame: &DecodedVkFrame, block: bool) -> DecodeStatus {
|
|
if frame.generation != self.generation {
|
|
trace!(
|
|
frame_generation = frame.generation,
|
|
current = self.generation,
|
|
"status asked for a stale-generation frame — Failed, without \
|
|
touching the new pools"
|
|
);
|
|
return DecodeStatus::Failed;
|
|
}
|
|
let Some(state) = &self.state else {
|
|
return DecodeStatus::Failed;
|
|
};
|
|
let Some(query_pool) = state.ops.query_pool else {
|
|
// No queries on this driver: the verdict degrades to timeline
|
|
// completion (poll_status docs).
|
|
if block {
|
|
// SAFETY: live device; pool-owned semaphore.
|
|
return match unsafe {
|
|
wait_timeline(self.dev.ash(), frame.semaphore, frame.value, "status wait")
|
|
} {
|
|
Ok(()) => DecodeStatus::Ok,
|
|
Err(VkDecodeError::DeviceLost) => {
|
|
self.device_lost = true;
|
|
DecodeStatus::Failed
|
|
}
|
|
Err(_) => DecodeStatus::Failed,
|
|
};
|
|
}
|
|
// SAFETY: live device; pool-owned semaphore.
|
|
return match unsafe { self.dev.ash().get_semaphore_counter_value(frame.semaphore) } {
|
|
Ok(current) if current >= frame.value => DecodeStatus::Ok,
|
|
Ok(_) => DecodeStatus::Pending,
|
|
Err(vk::Result::ERROR_DEVICE_LOST) => {
|
|
self.device_lost = true;
|
|
DecodeStatus::Failed
|
|
}
|
|
Err(_) => DecodeStatus::Failed,
|
|
};
|
|
};
|
|
let slot = frame.query_slot as usize;
|
|
if slot >= state.query_marks.len() || state.query_marks[slot] != frame.submission {
|
|
trace!(
|
|
slot,
|
|
"status query slot re-armed before it was read — unprovable, reported Failed"
|
|
);
|
|
return DecodeStatus::Failed;
|
|
}
|
|
let flags = if block {
|
|
vk::QueryResultFlags::WAIT | vk::QueryResultFlags::WITH_STATUS_KHR
|
|
} else {
|
|
vk::QueryResultFlags::WITH_STATUS_KHR
|
|
};
|
|
let mut status = [0i32; 1];
|
|
// SAFETY: live device; the query pool is this session generation's own and
|
|
// `frame.query_slot` indexes within its count (checked above against the
|
|
// marks array it is sized to).
|
|
let result = unsafe {
|
|
self.dev
|
|
.ash()
|
|
.get_query_pool_results(query_pool, frame.query_slot, &mut status, flags)
|
|
};
|
|
match result {
|
|
// VkQueryResultStatusKHR: >0 complete, 0 not ready, <0 error.
|
|
Ok(()) if status[0] > 0 => DecodeStatus::Ok,
|
|
Ok(()) if status[0] == 0 => DecodeStatus::Pending,
|
|
Ok(()) => DecodeStatus::Failed,
|
|
Err(vk::Result::NOT_READY) => DecodeStatus::Pending,
|
|
Err(vk::Result::ERROR_DEVICE_LOST) => {
|
|
self.device_lost = true;
|
|
DecodeStatus::Failed
|
|
}
|
|
Err(r) => {
|
|
debug!(?r, "status query read failed");
|
|
DecodeStatus::Failed
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Wait — bounded by `timeout_ns` — for a delivered frame's decode-complete
|
|
/// signal. Pure measurement (the integration layer's sampled decode-latency
|
|
/// stat): touches no decoder state. `frame` must be unreleased, which pins its
|
|
/// pool — and with it the semaphore — alive.
|
|
pub fn wait_decoded(&self, frame: &DecodedVkFrame, timeout_ns: u64) -> bool {
|
|
if frame.generation != self.generation {
|
|
return false;
|
|
}
|
|
let semaphores = [frame.semaphore];
|
|
let values = [frame.value];
|
|
let info = vk::SemaphoreWaitInfo::default()
|
|
.semaphores(&semaphores)
|
|
.values(&values);
|
|
// SAFETY: live device (constructor contract); the semaphore is a pool
|
|
// semaphore the unreleased frame keeps alive (fn docs); the info arrays
|
|
// are locals outliving the call.
|
|
unsafe { self.dev.ash().wait_semaphores(&info, timeout_ns) }.is_ok()
|
|
}
|
|
|
|
/// Drain the planner (teardown / stream discontinuity): every buffered
|
|
/// picture becomes display-ready via [`Self::take_ready`] (zero-copy — the
|
|
/// images already hold the content), all DPB slots free, and any picture
|
|
/// removed without ever reaching output frees its image.
|
|
pub fn flush(&mut self) {
|
|
let update = self.planner.flush();
|
|
let (ready, dropped) = settle_dpb(&mut self.pending, &update);
|
|
if let Some(state) = &mut self.state {
|
|
state.slots.apply(&update);
|
|
for entry in ready {
|
|
let frame = build_frame(
|
|
&mut state.pool,
|
|
state.dpb.is_none(),
|
|
state.image_extent,
|
|
&entry,
|
|
self.generation,
|
|
);
|
|
self.ready.push_back(frame);
|
|
}
|
|
for entry in dropped {
|
|
state.pool.pictures[entry.image].pending = false;
|
|
}
|
|
// Defensive: a pending picture neither output nor removed should not
|
|
// exist after a flush; free any leftover.
|
|
for (_, entry) in std::mem::take(&mut self.pending) {
|
|
debug!(poc = entry.poc, "pending picture survived a flush — freed");
|
|
state.pool.pictures[entry.image].pending = false;
|
|
}
|
|
} else {
|
|
self.pending.clear();
|
|
}
|
|
}
|
|
|
|
/// Clear the DPB state a failed AU left behind, so planning resumes at the
|
|
/// next IRAP instead of erroring on residency nothing can honour.
|
|
///
|
|
/// Three ledgers have to agree and, after a post-planning failure, do not:
|
|
/// the PLANNER's DPB, this decoder's [`SlotMap`], and the slot→image
|
|
/// bindings. [`Self::flush`] settles the first (and hands back any picture
|
|
/// that did reach output — those frames are real and are still delivered),
|
|
/// then [`reset_slot_bindings`] empties the other two. Pool images the stale
|
|
/// bindings pinned go back on the free list; images a consumer still HOLDS
|
|
/// stay pinned by their own `held` counts, exactly as they would across a
|
|
/// session rebuild.
|
|
///
|
|
/// Deliberately not a session rebuild: the session, pools and ring are all
|
|
/// still valid — only the DPB bookkeeping is stale — and a rebuild would
|
|
/// churn every image allocation for a condition an IDR fixes anyway.
|
|
fn recover_dpb(&mut self) {
|
|
debug!(
|
|
snapshot = %self.debug_snapshot(),
|
|
"recovering from a failed AU — flushing the H.265 DPB to the next IRAP"
|
|
);
|
|
self.flush();
|
|
if let Some(state) = &mut self.state {
|
|
let unbound = reset_slot_bindings(
|
|
&mut state.slots,
|
|
&mut state.slot_image,
|
|
&mut state.slot_refs,
|
|
);
|
|
for picture in unbound {
|
|
state.pool.pictures[picture].bound = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Session/caps for THIS plan exist and match its extent + profile, and the
|
|
/// stream sits inside the device's level ceiling. DPB-depth mismatches
|
|
/// surface later as `plan_to_vk_h265`'s `CapacityMismatch` (the designed
|
|
/// trigger) and take the same rebuild path.
|
|
fn ensure_state(&mut self, plan: &AuPlan) -> Result<(), VkDecodeError> {
|
|
let key = profile_key_for(plan)?;
|
|
if self.caps.as_ref().map(|(k, _)| *k) != Some(key) {
|
|
let wanted = key
|
|
.output_format()
|
|
.expect("from_stream gated the chroma/depth combination");
|
|
// SAFETY: live device (constructor contract).
|
|
let raw = unsafe { query_h265_caps(&self.dev, key) }.map_err(VkDecodeError::from)?;
|
|
self.caps = Some((key, derive_caps_h265(&raw, wanted)?));
|
|
}
|
|
// The declared level vs the device ceiling: a DECLARED level above
|
|
// `maxLevelIdc` is NOT a refusal. The level in an SPS is a claim, and
|
|
// encoders over-claim in the wild — AMF stamps 6.2 (the codec maximum)
|
|
// on 4K120 streams that need 5.2, which on an RTX 5060 (ceiling 6.1)
|
|
// demoted every HEVC session to D3D11VA (2026-08-12 field report). The
|
|
// stream's REAL demands are enforced where they are physical facts:
|
|
// coded extent and DPB depth, checked in `rebuild_state`. The session's
|
|
// parameter sets are clamped to the ceiling (`SessionConfigH265::
|
|
// max_level_idc`) so the driver is never handed a level above its caps,
|
|
// and the comparison stays within one codec's Std code space
|
|
// (`MaxLevelIdc`'s tag carries that argument).
|
|
let caps_max_level = self.caps.as_ref().expect("queried above").1.max_level_idc;
|
|
let stream_level = level_to_std_h265(plan.picture.level_idc);
|
|
if stream_level > caps_max_level.code_point() && !self.level_clamp_warned {
|
|
self.level_clamp_warned = true;
|
|
warn!(
|
|
stream_level,
|
|
ceiling = %caps_max_level,
|
|
"stream declares an H.265 level above the device ceiling — the \
|
|
declared level is advisory (over-declared by some encoders); \
|
|
proceeding with the parameter sets clamped to the ceiling"
|
|
);
|
|
}
|
|
let coded = vk::Extent2D {
|
|
width: plan.picture.coded_width,
|
|
height: plan.picture.coded_height,
|
|
};
|
|
match &self.state {
|
|
Some(state) if state.coded_extent == coded && state.session.config.profile == key => {
|
|
Ok(())
|
|
}
|
|
_ => self.rebuild_state(plan),
|
|
}
|
|
}
|
|
|
|
/// Tear down the current session generation (draining its decode work,
|
|
/// retiring its picture pool to the graveyard when the consumer still holds
|
|
/// images) and build a fresh one shaped by `plan`, bumping
|
|
/// [`Self::generation`] so frames of the old one route to the graveyard.
|
|
///
|
|
/// The renegotiation-safety argument is [`crate::VkH264Decoder`]'s, unchanged:
|
|
/// pools with consumer holds retire INTACT to the graveyard, every frame and
|
|
/// token carries its generation, and the session objects (query pool included)
|
|
/// die only after [`Self::drain_gpu`] with no consumer-facing handle pointing
|
|
/// at them.
|
|
fn rebuild_state(&mut self, plan: &AuPlan) -> Result<(), VkDecodeError> {
|
|
self.drain_gpu()?;
|
|
if let Some(state) = self.state.take() {
|
|
debug!("rebuilding H.265 decode session (stream renegotiation)");
|
|
let SessionStateH265 { mut pool, .. } = state;
|
|
for frame in self.ready.drain(..) {
|
|
let picture = &mut pool.pictures[frame.picture as usize];
|
|
picture.held = picture.held.saturating_sub(1);
|
|
}
|
|
for (_, entry) in std::mem::take(&mut self.pending) {
|
|
pool.pictures[entry.image].pending = false;
|
|
}
|
|
for picture in &mut pool.pictures {
|
|
picture.bound = false;
|
|
}
|
|
let held = pool.held_total();
|
|
if held > 0 {
|
|
debug!(
|
|
held,
|
|
generation = self.generation,
|
|
"consumer still holds images of the retired generation — graveyarding"
|
|
);
|
|
self.graveyard.push(RetiredPool {
|
|
generation: self.generation,
|
|
pool,
|
|
});
|
|
}
|
|
}
|
|
self.generation += 1;
|
|
|
|
let (key, caps) = self.caps.as_ref().expect("ensure_state queried caps");
|
|
let key = *key;
|
|
let required_slots = plan.picture.max_dpb_frames as u32 + 1;
|
|
if required_slots > caps.max_dpb_slots {
|
|
return Err(VkDecodeError::Unsupported(format!(
|
|
"stream needs {required_slots} DPB slots, device caps at {}",
|
|
caps.max_dpb_slots
|
|
)));
|
|
}
|
|
let coded = vk::Extent2D {
|
|
width: plan.picture.coded_width,
|
|
height: plan.picture.coded_height,
|
|
};
|
|
// Bounds-checked at the ALLOCATION extent (granularity-rounded): that is
|
|
// what the images are created at and what maxCodedExtent must cover.
|
|
let image_extent = caps.aligned_extent(coded);
|
|
if coded.width < caps.min_coded_extent.width
|
|
|| coded.height < caps.min_coded_extent.height
|
|
|| image_extent.width > caps.max_coded_extent.width
|
|
|| image_extent.height > caps.max_coded_extent.height
|
|
{
|
|
return Err(VkDecodeError::Unsupported(format!(
|
|
"coded extent {}x{} (allocated {}x{}) outside device range {}x{}..{}x{}",
|
|
coded.width,
|
|
coded.height,
|
|
image_extent.width,
|
|
image_extent.height,
|
|
caps.min_coded_extent.width,
|
|
caps.min_coded_extent.height,
|
|
caps.max_coded_extent.width,
|
|
caps.max_coded_extent.height
|
|
)));
|
|
}
|
|
|
|
let config = SessionConfigH265 {
|
|
max_coded_extent: image_extent,
|
|
max_dpb_slots: required_slots,
|
|
max_active_references: (required_slots - 1).min(caps.max_active_references),
|
|
profile: key,
|
|
max_level_idc: caps.max_level_idc.code_point(),
|
|
};
|
|
let mut pool_plan = plan_pools(caps, required_slots);
|
|
// TEST-ONLY readback hook, exactly as the H.264 decoder's: the parity
|
|
// test copies decoded pictures back to hash them, and
|
|
// `vkCmdCopyImageToBuffer` needs TRANSFER_SRC on the source — a bit the
|
|
// zero-copy production pools deliberately do not carry.
|
|
if std::env::var("PF_VKD_TEST_READBACK").is_ok_and(|v| v == "1") {
|
|
pool_plan.picture_usage |= vk::ImageUsageFlags::TRANSFER_SRC;
|
|
}
|
|
let decode_profile = DecodeProfile::H265(key);
|
|
// SAFETY: live device per the constructor contract, for every create in
|
|
// this block; each created half is owned by a Drop type the moment it
|
|
// exists, so a mid-build failure unwinds cleanly.
|
|
let state = unsafe {
|
|
let session = VideoSessionH265::create(&self.dev, caps, config)?;
|
|
let dpb = if caps.coincide {
|
|
None
|
|
} else {
|
|
Some(
|
|
DpbPool::create(&self.dev, caps, &pool_plan, image_extent, decode_profile)
|
|
.map_err(VkDecodeError::from)?,
|
|
)
|
|
};
|
|
let pool =
|
|
PicturePool::create(&self.dev, caps, &pool_plan, image_extent, decode_profile)
|
|
.map_err(VkDecodeError::from)?;
|
|
let ring = BitstreamRing::create(
|
|
&self.dev,
|
|
RingLayout::new(
|
|
INITIAL_SLOT_SIZE,
|
|
RING_SLOTS,
|
|
caps.min_bitstream_offset_alignment,
|
|
caps.min_bitstream_size_alignment,
|
|
),
|
|
decode_profile,
|
|
)
|
|
.map_err(VkDecodeError::from)?;
|
|
let ops = OpRing::create(
|
|
&self.dev,
|
|
decode_profile,
|
|
pool_plan.picture_count,
|
|
RING_SLOTS,
|
|
)
|
|
.map_err(VkDecodeError::from)?;
|
|
SessionStateH265 {
|
|
session,
|
|
slots: SlotMap::new(plan.picture.max_dpb_frames),
|
|
slot_refs: vec![None; required_slots as usize],
|
|
slot_image: vec![None; required_slots as usize],
|
|
cmd_marks: vec![None; RING_SLOTS as usize],
|
|
query_marks: vec![u64::MAX; pool_plan.picture_count as usize],
|
|
submitted: 0,
|
|
last_submit: None,
|
|
coded_extent: coded,
|
|
image_extent,
|
|
dpb,
|
|
pool,
|
|
ring,
|
|
ops,
|
|
}
|
|
};
|
|
self.state = Some(state);
|
|
Ok(())
|
|
}
|
|
|
|
/// Wait out every in-flight decode submission of the current session.
|
|
fn drain_gpu(&mut self) -> Result<(), VkDecodeError> {
|
|
let Some(state) = &self.state else {
|
|
return Ok(());
|
|
};
|
|
if let Some((sem, value)) = state.last_submit {
|
|
// SAFETY: live device; the token is a pool image's semaphore.
|
|
unsafe { wait_timeline(self.dev.ash(), sem, value, "session drain")? };
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Drop for VkH265Decoder {
|
|
fn drop(&mut self) {
|
|
// Best-effort decode drain so the pools' Drop impls never destroy
|
|
// in-flight decode work; a wedged driver falls through after the bounded
|
|
// timeout. Presenter-side sampling of graveyarded/held images is the
|
|
// CALLER's teardown contract (the H.264 decoder's Drop docs).
|
|
if let Err(e) = self.drain_gpu() {
|
|
debug!(error = %e, "drain on drop failed; tearing down anyway");
|
|
}
|
|
if !self.graveyard.is_empty() {
|
|
debug!(
|
|
pools = self.graveyard.len(),
|
|
"graveyard not fully token-drained at decoder drop — destroying anyway \
|
|
(upstream teardown forfeited its bounded wait)"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The Vulkan profile this AU's stream needs — profile idc plus the chroma format
|
|
/// and bit depths, all of which the SPS carries and the profile must restate.
|
|
/// (`separate_colour_plane_flag` comes off the active SPS rather than the picture
|
|
/// plan, which does not carry it: the planner has no use for it, the profile gate
|
|
/// does.)
|
|
fn profile_key_for(plan: &AuPlan) -> Result<H265ProfileKey, VkDecodeError> {
|
|
H265ProfileKey::from_stream(
|
|
plan.picture.general_profile_idc,
|
|
plan.picture.chroma_format_idc,
|
|
plan.sps.separate_colour_plane_flag,
|
|
plan.picture.bit_depth_luma_minus8,
|
|
plan.picture.bit_depth_chroma_minus8,
|
|
)
|
|
.map_err(VkDecodeError::ParamsH265)
|
|
}
|
|
|
|
/// Empty the three per-slot ledgers a recovery resets: DPB residency, the
|
|
/// slot→image bindings and the cached per-slot reference info. Returns the pool
|
|
/// image indices the cleared bindings were pinning, for the caller to unbind
|
|
/// (pure over the ledgers so the recovery is testable without a device — the pool
|
|
/// is the one piece that needs one).
|
|
///
|
|
/// All three are emptied TOGETHER on purpose: leaving reference info behind would
|
|
/// let [`build_scope`] bind a slot the planner no longer knows about, which is the
|
|
/// same "plausible-looking picture in the wrong place" the unbound-reference
|
|
/// refusal exists to prevent.
|
|
fn reset_slot_bindings(
|
|
slots: &mut SlotMap,
|
|
slot_image: &mut [Option<usize>],
|
|
slot_refs: &mut [Option<hh::StdVideoDecodeH265ReferenceInfo>],
|
|
) -> Vec<usize> {
|
|
// `release` is the only way a slot is freed (SlotMap docs); the collect is
|
|
// because `held` borrows the map the releases mutate.
|
|
for (_slot, id) in slots.held().collect::<Vec<_>>() {
|
|
slots.release(id);
|
|
}
|
|
let unbound = slot_image.iter_mut().filter_map(Option::take).collect();
|
|
for cached in slot_refs.iter_mut() {
|
|
*cached = None;
|
|
}
|
|
unbound
|
|
}
|
|
|
|
/// The picture resource view bound for DPB `slot`: the bound pool image
|
|
/// (coincide) or the DPB array layer (distinct).
|
|
fn slot_view(state: &SessionStateH265, slot: u8) -> Option<vk::ImageView> {
|
|
match &state.dpb {
|
|
Some(dpb) => Some(dpb.dpb_view(slot)),
|
|
None => state.slot_image[usize::from(slot)].map(|p| state.pool.pictures[p].view),
|
|
}
|
|
}
|
|
|
|
/// One entry of a coding scope's bound-slot list: the DPB slot index it binds
|
|
/// (`-1` for the setup ACTIVATION entry), the picture resource view, and the
|
|
/// codec reference info that slot's association carries.
|
|
/// (No derived equality: `StdVideoDecodeH265ReferenceInfo` is a plain-C bindgen
|
|
/// struct without it. Assertions compare the fields that carry meaning.)
|
|
#[derive(Debug, Clone, Copy)]
|
|
struct ScopeEntry {
|
|
slot_index: i32,
|
|
view: vk::ImageView,
|
|
std: hh::StdVideoDecodeH265ReferenceInfo,
|
|
}
|
|
|
|
/// Build the coding scope's bound-slot list and say how many leading entries are
|
|
/// THIS AU's references.
|
|
///
|
|
/// The layout:
|
|
///
|
|
/// 1. every entry of `refs`, IN ORDER — the decode op takes exactly this prefix;
|
|
/// 2. every other still-held slot, so its association survives the scope (their
|
|
/// resources must stay bound even when this AU does not reference them);
|
|
/// 3. the setup slot as the activation entry, slot index `-1`.
|
|
///
|
|
/// A reference whose slot binds no image is a hard error, never a skip:
|
|
/// `StdVideoDecodeH265PictureInfo`'s `RefPicSetStCurrBefore`/`StCurrAfter`/
|
|
/// `LtCurr` arrays name DPB slots, and every slot they name is one of `refs`'
|
|
/// ([`crate::pic_h265`]) — so dropping an entry leaves the hardware with a named
|
|
/// slot this op never bound, which it can only answer by guessing or failing.
|
|
/// Output that looks plausible and is wrong is the outcome this refusal exists to
|
|
/// prevent.
|
|
fn build_scope(
|
|
refs: &[crate::pic_h265::VkRefH265],
|
|
held_slots: impl Iterator<Item = u8>,
|
|
setup_slot: u8,
|
|
setup_view: vk::ImageView,
|
|
setup_ref: hh::StdVideoDecodeH265ReferenceInfo,
|
|
slot_refs: &[Option<hh::StdVideoDecodeH265ReferenceInfo>],
|
|
view_of: impl Fn(u8) -> Option<vk::ImageView>,
|
|
) -> Result<(Vec<ScopeEntry>, usize), VkDecodeError> {
|
|
let mut scope: Vec<ScopeEntry> = Vec::with_capacity(refs.len() + slot_refs.len() + 1);
|
|
for r in refs {
|
|
match view_of(r.slot) {
|
|
Some(view) => scope.push(ScopeEntry {
|
|
slot_index: i32::from(r.slot),
|
|
view,
|
|
std: r.std,
|
|
}),
|
|
None => return Err(VkDecodeError::UnboundReferenceSlot { slot: r.slot }),
|
|
}
|
|
}
|
|
let reference_count = scope.len();
|
|
for slot in held_slots {
|
|
if slot == setup_slot || refs.iter().any(|r| r.slot == slot) {
|
|
continue;
|
|
}
|
|
match (
|
|
slot_refs.get(usize::from(slot)).copied().flatten(),
|
|
view_of(slot),
|
|
) {
|
|
(Some(std), Some(view)) => scope.push(ScopeEntry {
|
|
slot_index: i32::from(slot),
|
|
view,
|
|
std,
|
|
}),
|
|
// Unreachable in practice: every held slot was a setup slot once.
|
|
_ => trace!(
|
|
slot,
|
|
"held slot without reference info/binding — left unbound"
|
|
),
|
|
}
|
|
}
|
|
scope.push(ScopeEntry {
|
|
slot_index: -1,
|
|
view: setup_view,
|
|
std: setup_ref,
|
|
});
|
|
Ok((scope, reference_count))
|
|
}
|
|
|
|
/// Record one H.265 decode op into the chosen command buffer and submit it under
|
|
/// the queue lock: image waits per the pool contract, the dst image's timeline
|
|
/// signal at `signal_value`.
|
|
///
|
|
/// # Safety
|
|
///
|
|
/// Live device; `state` is the current session generation with `vk_plan` derived
|
|
/// against its `SlotMap`, `dst` a free pool image, the AU resident in `upload`'s
|
|
/// ring slot, and the command buffer's previous submission completed (caller
|
|
/// waited its mark).
|
|
#[allow(clippy::too_many_arguments)]
|
|
unsafe fn record_and_submit_h265(
|
|
dev: &DecodeDevice,
|
|
lock: &dyn QueueLock,
|
|
state: &mut SessionStateH265,
|
|
vk_plan: &DecodePlanVkH265,
|
|
slice_offsets: &[u32],
|
|
upload: &UploadedAu,
|
|
dst: usize,
|
|
cmd_index: usize,
|
|
query_index: u32,
|
|
waits: &[(vk::Semaphore, u64)],
|
|
signal_value: u64,
|
|
) -> Result<(), VkDecodeError> {
|
|
let device = dev.ash();
|
|
let cmd = state.ops.cmds[cmd_index];
|
|
let coded_extent = state.coded_extent;
|
|
let coincide = state.dpb.is_none();
|
|
|
|
// ---- the reference layout, decided BEFORE anything is recorded ----
|
|
// `refs` order is the contract (build_scope docs): the Std picture info's RPS
|
|
// arrays index into this exact array, so a missing entry is fatal, not
|
|
// skippable — and it must fail before the command buffer is even begun.
|
|
let setup_view = if coincide {
|
|
state.pool.pictures[dst].view
|
|
} else {
|
|
state
|
|
.dpb
|
|
.as_ref()
|
|
.expect("distinct mode")
|
|
.dpb_view(vk_plan.setup_slot)
|
|
};
|
|
let held_slots: Vec<u8> = state.slots.held().map(|(slot, _id)| slot).collect();
|
|
let (scope, reference_count) = build_scope(
|
|
&vk_plan.refs,
|
|
held_slots.into_iter(),
|
|
vk_plan.setup_slot,
|
|
setup_view,
|
|
vk_plan.setup_ref,
|
|
&state.slot_refs,
|
|
|slot| slot_view(state, slot),
|
|
)?;
|
|
|
|
let begin_info =
|
|
vk::CommandBufferBeginInfo::default().flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT);
|
|
// SAFETY: the buffer's previous submission completed (fn contract) and its
|
|
// pool allows per-buffer reset, so begin implicitly resets it.
|
|
unsafe {
|
|
device
|
|
.begin_command_buffer(cmd, &begin_info)
|
|
.map_err(VkDecodeError::from)?
|
|
};
|
|
|
|
// ---- barriers (outside the video coding scope) ----
|
|
// Prior reconstructions must be visible to this op's reference reads.
|
|
let memory_barriers = [vk::MemoryBarrier2::default()
|
|
.src_stage_mask(vk::PipelineStageFlags2::VIDEO_DECODE_KHR)
|
|
.src_access_mask(vk::AccessFlags2::VIDEO_DECODE_WRITE_KHR)
|
|
.dst_stage_mask(vk::PipelineStageFlags2::VIDEO_DECODE_KHR)
|
|
.dst_access_mask(
|
|
vk::AccessFlags2::VIDEO_DECODE_READ_KHR | vk::AccessFlags2::VIDEO_DECODE_WRITE_KHR,
|
|
)];
|
|
// Decode targets are fully overwritten: discard via UNDEFINED with an
|
|
// execution+memory dependency on earlier ops that touched them.
|
|
let decode_layer_barrier = |image: vk::Image, layer: u32, new_layout: vk::ImageLayout| {
|
|
vk::ImageMemoryBarrier2::default()
|
|
.src_stage_mask(vk::PipelineStageFlags2::VIDEO_DECODE_KHR)
|
|
.src_access_mask(
|
|
vk::AccessFlags2::VIDEO_DECODE_READ_KHR | vk::AccessFlags2::VIDEO_DECODE_WRITE_KHR,
|
|
)
|
|
.dst_stage_mask(vk::PipelineStageFlags2::VIDEO_DECODE_KHR)
|
|
.dst_access_mask(
|
|
vk::AccessFlags2::VIDEO_DECODE_READ_KHR | vk::AccessFlags2::VIDEO_DECODE_WRITE_KHR,
|
|
)
|
|
.old_layout(vk::ImageLayout::UNDEFINED)
|
|
.new_layout(new_layout)
|
|
.src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
|
|
.dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
|
|
.image(image)
|
|
.subresource_range(vk::ImageSubresourceRange {
|
|
aspect_mask: vk::ImageAspectFlags::COLOR,
|
|
base_mip_level: 0,
|
|
level_count: 1,
|
|
base_array_layer: layer,
|
|
layer_count: 1,
|
|
})
|
|
};
|
|
let dst_image = state.pool.pictures[dst].image;
|
|
let mut image_barriers = Vec::new();
|
|
if coincide {
|
|
// The dst pool image IS the setup DPB picture.
|
|
image_barriers.push(decode_layer_barrier(
|
|
dst_image,
|
|
0,
|
|
vk::ImageLayout::VIDEO_DECODE_DPB_KHR,
|
|
));
|
|
} else {
|
|
let dpb = state.dpb.as_ref().expect("distinct mode");
|
|
let (setup_image, setup_layer) = dpb.dpb_target(vk_plan.setup_slot);
|
|
image_barriers.push(decode_layer_barrier(
|
|
setup_image,
|
|
setup_layer,
|
|
vk::ImageLayout::VIDEO_DECODE_DPB_KHR,
|
|
));
|
|
image_barriers.push(decode_layer_barrier(
|
|
dst_image,
|
|
0,
|
|
vk::ImageLayout::VIDEO_DECODE_DST_KHR,
|
|
));
|
|
}
|
|
let dependency = vk::DependencyInfo::default()
|
|
.memory_barriers(&memory_barriers)
|
|
.image_memory_barriers(&image_barriers);
|
|
// SAFETY: recording into the begun buffer; synchronization2 is enabled per
|
|
// the DeviceHandles feature contract.
|
|
unsafe { device.cmd_pipeline_barrier2(cmd, &dependency) };
|
|
|
|
// This op's status query slot, reset before the coding scope (encoder idiom).
|
|
// None on drivers without queryResultStatusSupport (RADV — recording a query
|
|
// there hangs the VCN; OpRing docs). NEVER remove this gate.
|
|
if let Some(query_pool) = state.ops.query_pool {
|
|
// SAFETY: recording; `query_index` is within the pool's count (fn contract).
|
|
unsafe { device.cmd_reset_query_pool(cmd, query_pool, query_index, 1) };
|
|
}
|
|
|
|
// ---- bound-slot staging ----
|
|
// Staged arrays over the scope decided above: resources → std infos → codec
|
|
// slot infos → slot infos. Each vector is fully built before the next borrows
|
|
// it, so nothing reallocates under a stored pointer.
|
|
let resources: Vec<vk::VideoPictureResourceInfoKHR<'_>> = scope
|
|
.iter()
|
|
.map(|entry| {
|
|
vk::VideoPictureResourceInfoKHR::default()
|
|
.coded_extent(coded_extent)
|
|
.base_array_layer(0)
|
|
.image_view_binding(entry.view)
|
|
})
|
|
.collect();
|
|
let std_refs: Vec<hh::StdVideoDecodeH265ReferenceInfo> =
|
|
scope.iter().map(|entry| entry.std).collect();
|
|
let mut dpb_infos: Vec<vk::VideoDecodeH265DpbSlotInfoKHR<'_>> = std_refs
|
|
.iter()
|
|
.map(|std| vk::VideoDecodeH265DpbSlotInfoKHR::default().std_reference_info(std))
|
|
.collect();
|
|
let mut begin_slots: Vec<vk::VideoReferenceSlotInfoKHR<'_>> = Vec::with_capacity(scope.len());
|
|
for (index, entry) in scope.iter().enumerate() {
|
|
begin_slots.push(
|
|
vk::VideoReferenceSlotInfoKHR::default()
|
|
.slot_index(entry.slot_index)
|
|
.picture_resource(&resources[index]),
|
|
);
|
|
}
|
|
for (slot_info, dpb_info) in begin_slots.iter_mut().zip(dpb_infos.iter_mut()) {
|
|
*slot_info = (*slot_info).push_next(dpb_info);
|
|
}
|
|
// The decode op's reference list: exactly this AU's references, in `refs`
|
|
// order — `RefPicSetStCurrBefore`/`StCurrAfter`/`LtCurr` index into THIS
|
|
// array (module docs), which is why the entries were built refs-first and why
|
|
// a missing binding failed the whole op above rather than compacting.
|
|
let decode_refs: Vec<vk::VideoReferenceSlotInfoKHR<'_>> =
|
|
begin_slots[..reference_count].to_vec();
|
|
|
|
// The setup slot as the decode op sees it: its REAL index (the begin list's
|
|
// twin entry carries -1), same resource, its own codec info chain.
|
|
let setup_std = vk_plan.setup_ref;
|
|
let mut setup_dpb = vk::VideoDecodeH265DpbSlotInfoKHR::default().std_reference_info(&setup_std);
|
|
let setup_resource = resources[scope.len() - 1];
|
|
let setup_slot_info = vk::VideoReferenceSlotInfoKHR::default()
|
|
.slot_index(i32::from(vk_plan.setup_slot))
|
|
.picture_resource(&setup_resource)
|
|
.push_next(&mut setup_dpb);
|
|
|
|
// Decode destination: the setup picture itself (coincide) or the pool image
|
|
// (distinct).
|
|
let dst_resource = if coincide {
|
|
setup_resource
|
|
} else {
|
|
vk::VideoPictureResourceInfoKHR::default()
|
|
.coded_extent(coded_extent)
|
|
.base_array_layer(0)
|
|
.image_view_binding(state.pool.pictures[dst].view)
|
|
};
|
|
|
|
let std_pic = vk_plan.std_pic;
|
|
// Offsets rebased into the packed slices-only buffer (NOT the plan's
|
|
// AU-absolute offsets — the AU's non-slice NALUs were never uploaded).
|
|
let mut h265_pic = vk::VideoDecodeH265PictureInfoKHR::default()
|
|
.std_picture_info(&std_pic)
|
|
.slice_segment_offsets(slice_offsets);
|
|
let mut decode_info = vk::VideoDecodeInfoKHR::default()
|
|
.src_buffer(state.ring.buffer())
|
|
.src_buffer_offset(upload.offset)
|
|
.src_buffer_range(upload.range)
|
|
.dst_picture_resource(dst_resource)
|
|
.setup_reference_slot(&setup_slot_info)
|
|
.push_next(&mut h265_pic);
|
|
if reference_count > 0 {
|
|
decode_info = decode_info.reference_slots(&decode_refs);
|
|
}
|
|
|
|
let begin_coding = vk::VideoBeginCodingInfoKHR::default()
|
|
.video_session(state.session.session())
|
|
.video_session_parameters(state.session.parameters())
|
|
.reference_slots(&begin_slots);
|
|
// The one-shot session RESET, consumed HERE but re-armed on every error path
|
|
// below — a RESET recorded into a command buffer that never reaches the
|
|
// queue initialized nothing, and the next successful recording must carry it
|
|
// or the session runs its whole life uninitialized.
|
|
let did_reset = state.session.take_needs_reset();
|
|
// SAFETY: recording into the begun buffer, through end_command_buffer; every
|
|
// pointed-to struct above is a local (or session-state field) that outlives
|
|
// the calls; the session/parameters handles are this generation's own.
|
|
let recorded: Result<(), vk::Result> = unsafe {
|
|
(dev.video_queue().fp().cmd_begin_video_coding_khr)(cmd, &begin_coding);
|
|
if did_reset {
|
|
// Session first-use initialization — ONCE, before its first decode.
|
|
let control = vk::VideoCodingControlInfoKHR::default()
|
|
.flags(vk::VideoCodingControlFlagsKHR::RESET);
|
|
(dev.video_queue().fp().cmd_control_video_coding_khr)(cmd, &control);
|
|
}
|
|
if let Some(query_pool) = state.ops.query_pool {
|
|
device.cmd_begin_query(cmd, query_pool, query_index, vk::QueryControlFlags::empty());
|
|
}
|
|
(dev.video_decode_queue().fp().cmd_decode_video_khr)(cmd, &decode_info);
|
|
if let Some(query_pool) = state.ops.query_pool {
|
|
device.cmd_end_query(cmd, query_pool, query_index);
|
|
}
|
|
(dev.video_queue().fp().cmd_end_video_coding_khr)(
|
|
cmd,
|
|
&vk::VideoEndCodingInfoKHR::default(),
|
|
);
|
|
device.end_command_buffer(cmd)
|
|
};
|
|
if let Err(e) = recorded {
|
|
if did_reset {
|
|
state.session.re_arm_reset();
|
|
}
|
|
return Err(VkDecodeError::from(e));
|
|
}
|
|
|
|
// ---- submit, under the caller's queue lock ----
|
|
let cmd_infos = [vk::CommandBufferSubmitInfo::default().command_buffer(cmd)];
|
|
let wait_infos: Vec<vk::SemaphoreSubmitInfo<'_>> = waits
|
|
.iter()
|
|
.map(|&(semaphore, value)| {
|
|
vk::SemaphoreSubmitInfo::default()
|
|
.semaphore(semaphore)
|
|
.value(value)
|
|
.stage_mask(vk::PipelineStageFlags2::VIDEO_DECODE_KHR)
|
|
})
|
|
.collect();
|
|
let signals = [vk::SemaphoreSubmitInfo::default()
|
|
.semaphore(state.pool.pictures[dst].semaphore)
|
|
.value(signal_value)
|
|
.stage_mask(vk::PipelineStageFlags2::ALL_COMMANDS)];
|
|
let submits = [vk::SubmitInfo2::default()
|
|
.command_buffer_infos(&cmd_infos)
|
|
.wait_semaphore_infos(&wait_infos)
|
|
.signal_semaphore_infos(&signals)];
|
|
let guard = QueueSubmitGuard::acquire(lock);
|
|
// SAFETY: the decode queue is the device's own (DeviceHandles contract) and
|
|
// externally synchronized by the guard; the submit arrays are locals.
|
|
let result = unsafe { device.queue_submit2(dev.decode_queue(), &submits, vk::Fence::null()) };
|
|
drop(guard);
|
|
if let Err(e) = result {
|
|
// The recorded RESET never executed: the next recording must redo it.
|
|
if did_reset {
|
|
state.session.re_arm_reset();
|
|
}
|
|
return Err(VkDecodeError::from(e));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use ash::vk::Handle as _;
|
|
|
|
use super::*;
|
|
use crate::pic_h265::VkRefH265;
|
|
|
|
/// A reference-info value carrying just the two fields the assertions read.
|
|
fn std_ref(poc: i32, long_term: bool) -> hh::StdVideoDecodeH265ReferenceInfo {
|
|
// SAFETY: StdVideoDecodeH265ReferenceInfo is a plain-C bindgen struct of a
|
|
// bitfield word and one integer; all-zero is valid for every field.
|
|
let mut std: hh::StdVideoDecodeH265ReferenceInfo = unsafe { std::mem::zeroed() };
|
|
std.PicOrderCntVal = poc;
|
|
std.flags
|
|
.set_used_for_long_term_reference(u32::from(long_term));
|
|
std
|
|
}
|
|
|
|
fn vk_ref(slot: u8, poc: i32, long_term: bool) -> VkRefH265 {
|
|
VkRefH265 {
|
|
slot,
|
|
std: std_ref(poc, long_term),
|
|
id: u64::from(slot) + 100,
|
|
}
|
|
}
|
|
|
|
/// A distinguishable fake view per slot (never dereferenced — the scope only
|
|
/// carries handles around).
|
|
fn fake_view(slot: u8) -> vk::ImageView {
|
|
vk::ImageView::from_raw(u64::from(slot) + 1)
|
|
}
|
|
|
|
#[test]
|
|
fn the_scopes_leading_entries_are_the_refs_in_plan_order() {
|
|
// The plan's refs are NOT in slot order (they are in RPS set order:
|
|
// StCurrBefore, StCurrAfter, LtCurr) — and the Std index arrays point at
|
|
// positions in THAT order, so the scope must not sort or dedup them.
|
|
let refs = vec![
|
|
vk_ref(5, 40, false),
|
|
vk_ref(1, 60, false),
|
|
vk_ref(3, 8, true),
|
|
];
|
|
let slot_refs = vec![Some(std_ref(0, false)); 8];
|
|
let (scope, reference_count) = build_scope(
|
|
&refs,
|
|
[1u8, 3, 5, 7].into_iter(),
|
|
2,
|
|
fake_view(2),
|
|
std_ref(50, false),
|
|
&slot_refs,
|
|
|slot| Some(fake_view(slot)),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(reference_count, 3, "exactly this AU's references lead");
|
|
assert_eq!(
|
|
scope[..reference_count]
|
|
.iter()
|
|
.map(|e| e.slot_index)
|
|
.collect::<Vec<_>>(),
|
|
vec![5, 1, 3],
|
|
"plan order, not slot order — the RPS index arrays depend on it"
|
|
);
|
|
for (entry, r) in scope.iter().zip(&refs) {
|
|
assert_eq!(entry.view, fake_view(r.slot));
|
|
assert_eq!(entry.std.PicOrderCntVal, r.std.PicOrderCntVal);
|
|
assert_eq!(
|
|
entry.std.flags.used_for_long_term_reference(),
|
|
r.std.flags.used_for_long_term_reference(),
|
|
"the long-term marking rides with the binding"
|
|
);
|
|
}
|
|
|
|
// Then the other still-held slot (7), then the setup ACTIVATION entry.
|
|
assert_eq!(scope[3].slot_index, 7);
|
|
let last = scope.last().unwrap();
|
|
assert_eq!(
|
|
last.slot_index, -1,
|
|
"the setup slot binds its resource without a current association"
|
|
);
|
|
assert_eq!(last.view, fake_view(2));
|
|
assert_eq!(last.std.PicOrderCntVal, 50);
|
|
assert_eq!(
|
|
scope.len(),
|
|
5,
|
|
"3 refs + 1 other held slot + the activation"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_reference_slot_without_a_bound_image_fails_the_whole_op() {
|
|
// Compacting past it would shift every later RefPicSetStCurr* index onto
|
|
// the wrong picture — plausible-looking, wrong output. Fail closed.
|
|
let refs = vec![vk_ref(4, 10, false), vk_ref(6, 20, false)];
|
|
let slot_refs = vec![Some(std_ref(0, false)); 8];
|
|
let err = build_scope(
|
|
&refs,
|
|
[4u8, 6].into_iter(),
|
|
0,
|
|
fake_view(0),
|
|
std_ref(30, false),
|
|
&slot_refs,
|
|
|slot| (slot != 6).then(|| fake_view(slot)),
|
|
)
|
|
.unwrap_err();
|
|
assert!(
|
|
matches!(err, VkDecodeError::UnboundReferenceSlot { slot: 6 }),
|
|
"{err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn held_slots_are_bound_once_and_the_setup_slot_never_twice() {
|
|
// Slot 3 is BOTH a reference and still held; slot 2 is the setup slot and
|
|
// also held (the previous picture in it). Neither may appear twice: a
|
|
// duplicate slot index in one coding scope is invalid, and a second entry
|
|
// for a reference would also break the index arrays.
|
|
let refs = vec![vk_ref(3, 12, false)];
|
|
let slot_refs = vec![Some(std_ref(99, false)); 8];
|
|
let (scope, reference_count) = build_scope(
|
|
&refs,
|
|
[1u8, 2, 3].into_iter(),
|
|
2,
|
|
fake_view(2),
|
|
std_ref(24, false),
|
|
&slot_refs,
|
|
|slot| Some(fake_view(slot)),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(reference_count, 1);
|
|
let indices: Vec<i32> = scope.iter().map(|e| e.slot_index).collect();
|
|
assert_eq!(indices, vec![3, 1, -1]);
|
|
assert_eq!(
|
|
indices.iter().filter(|&&i| i == 3).count(),
|
|
1,
|
|
"a referenced slot is bound exactly once"
|
|
);
|
|
assert!(
|
|
!indices.contains(&2),
|
|
"the setup slot is bound only as the -1 activation entry"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_held_slot_with_no_cached_reference_info_is_left_unbound_not_faked() {
|
|
// Only reachable if a slot was never a setup slot on this session; the
|
|
// scope drops it rather than binding zeroed reference info (which would
|
|
// claim POC 0, short-term, for a picture that is neither).
|
|
let refs: Vec<VkRefH265> = Vec::new();
|
|
let mut slot_refs: Vec<Option<hh::StdVideoDecodeH265ReferenceInfo>> = vec![None; 4];
|
|
slot_refs[1] = Some(std_ref(7, false));
|
|
let (scope, reference_count) = build_scope(
|
|
&refs,
|
|
[1u8, 3].into_iter(),
|
|
0,
|
|
fake_view(0),
|
|
std_ref(9, false),
|
|
&slot_refs,
|
|
|slot| Some(fake_view(slot)),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(reference_count, 0, "an IRAP references nothing");
|
|
assert_eq!(
|
|
scope.iter().map(|e| e.slot_index).collect::<Vec<_>>(),
|
|
vec![1, -1],
|
|
"slot 3 had no cached info and is simply not bound"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_post_mutation_failure_wedges_every_later_au_until_the_ledgers_are_reset() {
|
|
// The exact shape the recovery exists for. An AU planned, `plan_to_vk_h265`
|
|
// assigned it slot 2 in the SlotMap, the coincide binding sync cleared
|
|
// slot 2's image binding — and THEN the decode failed (pool exhausted, ring
|
|
// upload, submit, any of them). Nothing restores the state, so the planner
|
|
// and the SlotMap both believe the picture is resident while no image holds
|
|
// it.
|
|
let mut slots = SlotMap::new(3);
|
|
slots.assign(100).unwrap(); // slot 0, an older reference, image bound
|
|
slots.assign(200).unwrap(); // slot 1, another, image bound
|
|
slots.assign(300).unwrap(); // slot 2, THIS AU's setup — binding cleared
|
|
let mut slot_image: Vec<Option<usize>> = vec![Some(7), Some(8), None, None];
|
|
let mut slot_refs: Vec<Option<hh::StdVideoDecodeH265ReferenceInfo>> =
|
|
vec![Some(std_ref(10, false)); 4];
|
|
|
|
// Every later AU that references slot 2 fails, forever: build_scope will
|
|
// not silently compact past an unbound reference (the RPS index arrays).
|
|
let err = build_scope(
|
|
&[vk_ref(2, 30, false)],
|
|
[0u8, 1, 2].into_iter(),
|
|
0,
|
|
fake_view(0),
|
|
std_ref(40, false),
|
|
&slot_refs,
|
|
|slot| slot_image[usize::from(slot)].map(|_| fake_view(slot)),
|
|
)
|
|
.unwrap_err();
|
|
assert!(
|
|
matches!(err, VkDecodeError::UnboundReferenceSlot { slot: 2 }),
|
|
"{err}"
|
|
);
|
|
|
|
// The recovery: flush to the next IRAP and empty all three ledgers.
|
|
let unbound = reset_slot_bindings(&mut slots, &mut slot_image, &mut slot_refs);
|
|
assert_eq!(
|
|
unbound,
|
|
vec![7, 8],
|
|
"the pool images the stale bindings pinned go back on the free list"
|
|
);
|
|
assert_eq!(slots.active(), 0, "no picture is DPB-resident any more");
|
|
assert_eq!(
|
|
slots.capacity(),
|
|
4,
|
|
"capacity survives — no session rebuild"
|
|
);
|
|
assert!(slot_image.iter().all(Option::is_none));
|
|
assert!(
|
|
slot_refs.iter().all(Option::is_none),
|
|
"cached reference info goes too, or build_scope could bind a slot the \
|
|
planner no longer knows about"
|
|
);
|
|
|
|
// And the IRAP that follows plans against an empty DPB: it references
|
|
// nothing, takes the lowest slot, and its scope builds.
|
|
let setup_slot = slots.assign(400).unwrap();
|
|
assert_eq!(setup_slot, 0, "the freed slots are assignable again");
|
|
slot_image[usize::from(setup_slot)] = Some(9);
|
|
let (scope, reference_count) = build_scope(
|
|
&[],
|
|
slots.held().map(|(slot, _id)| slot),
|
|
setup_slot,
|
|
fake_view(setup_slot),
|
|
std_ref(0, false),
|
|
&slot_refs,
|
|
|slot| slot_image[usize::from(slot)].map(|_| fake_view(slot)),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(reference_count, 0, "an IRAP references nothing");
|
|
assert_eq!(
|
|
scope.iter().map(|e| e.slot_index).collect::<Vec<_>>(),
|
|
vec![-1],
|
|
"only the setup activation entry — the stream is decoding again"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_recovery_latch_is_owed_once_and_consumed_by_exactly_one_decode() {
|
|
// Two failures in a row still owe ONE flush, and the decode that performs
|
|
// it clears the debt — otherwise every later decode would re-flush and the
|
|
// stream could never build a DPB again.
|
|
let mut latch = RecoveryLatch::default();
|
|
assert!(!latch.is_latched(), "a fresh decoder owes nothing");
|
|
assert!(!latch.take());
|
|
|
|
latch.latch();
|
|
latch.latch();
|
|
assert!(
|
|
latch.is_latched(),
|
|
"visible in debug_snapshot before it runs"
|
|
);
|
|
assert!(latch.take(), "the next decode recovers");
|
|
assert!(!latch.is_latched());
|
|
assert!(!latch.take(), "and the one after that just decodes");
|
|
}
|
|
|
|
#[test]
|
|
fn the_picture_info_carries_the_rebased_offsets_and_the_h265_std_struct() {
|
|
// The submission-final wiring, without a device: `pSliceSegmentOffsets`
|
|
// must be the REBASED array (one entry per slice segment, counted by
|
|
// ash from the slice's length), and the picture info must point at the
|
|
// plan's own Std struct.
|
|
let mut au = vec![0xAAu8; 2000];
|
|
for start in [40usize, 900, 1500] {
|
|
au[start..start + 3].copy_from_slice(&[0, 0, 1]);
|
|
}
|
|
let offsets = pack_slices(&au, &[40..900, 900..1500, 1500..2000])
|
|
.unwrap()
|
|
.offsets;
|
|
assert_eq!(offsets, vec![0, 860, 1460]);
|
|
|
|
// SAFETY: StdVideoDecodeH265PictureInfo is a plain-C bindgen struct of a
|
|
// bitfield word, integers and byte arrays; all-zero is valid.
|
|
let mut std_pic: hh::StdVideoDecodeH265PictureInfo = unsafe { std::mem::zeroed() };
|
|
std_pic.PicOrderCntVal = 42;
|
|
let picture_info = vk::VideoDecodeH265PictureInfoKHR::default()
|
|
.std_picture_info(&std_pic)
|
|
.slice_segment_offsets(&offsets);
|
|
assert_eq!(picture_info.slice_segment_count, 3);
|
|
assert_eq!(
|
|
picture_info.s_type,
|
|
vk::StructureType::VIDEO_DECODE_H265_PICTURE_INFO_KHR
|
|
);
|
|
// SAFETY: the two pointers were just taken from `offsets` and `std_pic`,
|
|
// both alive for this scope.
|
|
unsafe {
|
|
assert_eq!(
|
|
std::slice::from_raw_parts(picture_info.p_slice_segment_offsets, 3),
|
|
&offsets[..]
|
|
);
|
|
assert_eq!((*picture_info.p_std_picture_info).PicOrderCntVal, 42);
|
|
}
|
|
|
|
// And a DPB slot info chains the H.265 reference info, not the H.264 one.
|
|
let std = std_ref(17, true);
|
|
let dpb_info = vk::VideoDecodeH265DpbSlotInfoKHR::default().std_reference_info(&std);
|
|
assert_eq!(
|
|
dpb_info.s_type,
|
|
vk::StructureType::VIDEO_DECODE_H265_DPB_SLOT_INFO_KHR
|
|
);
|
|
// SAFETY: the pointer was just taken from `std`, alive for this scope.
|
|
unsafe {
|
|
assert_eq!((*dpb_info.p_std_reference_info).PicOrderCntVal, 17);
|
|
assert_eq!(
|
|
(*dpb_info.p_std_reference_info)
|
|
.flags
|
|
.used_for_long_term_reference(),
|
|
1
|
|
);
|
|
}
|
|
}
|
|
}
|