From 540c0d3027f4a1d9515dce0aac9923e21d0b82a3 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 5 Aug 2026 16:44:44 +0200 Subject: [PATCH] =?UTF-8?q?feat(pf-vkdecode):=20the=20GPU=20half=20?= =?UTF-8?q?=E2=80=94=20session,=20DPB=20pools,=20decode=20recording,=20sta?= =?UTF-8?q?tus=20queries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M2 WP-B. VkVideoSessionKHR lifecycle with drain-before-destroy on parameters recreation, DPB pools in both coincide and distinct modes (caps-derived, usage/flags validated against the driver's format properties), an aligned bitstream ring, vkCmdDecodeVideoKHR recording with one-shot RESET re-armed on failed submits, timeline-semaphore completion, and the per-op RESULT_STATUS query ring — the signal FFmpeg's hwaccel never reads and the reason this program exists. Frame lifetime is two-phase by construction: release_frame pins a delivered frame's slot against reuse, closing the coincide-mode overwrite the adversarial review round proved (a full DPB handed a just-returned frame's image back as the same call's decode target). Nine review findings fixed pre-commit; a counterfactual test pins the collision. Generation-stamped frames, memory-type misses as errors, granularity-aligned extents, level gate. AuPlan now carries its activated SPS/PPS (Rc) so backends never re-parse. GPU smoke test (ignored) decodes 48 AUs past DPB-full with releases — the fleet runs it in WP-D. Gates: fmt clean, clippy -D warnings zero, 45+27+53 tests green on macOS and the linux/amd64 container. --- crates/pf-bitstream/src/h264.rs | 52 + crates/pf-vkdecode/src/caps.rs | 695 +++++++++++++ crates/pf-vkdecode/src/decoder.rs | 1361 +++++++++++++++++++++++++ crates/pf-vkdecode/src/device.rs | 395 +++++++ crates/pf-vkdecode/src/images.rs | 533 ++++++++++ crates/pf-vkdecode/src/lib.rs | 55 +- crates/pf-vkdecode/src/params.rs | 5 +- crates/pf-vkdecode/src/pic.rs | 91 ++ crates/pf-vkdecode/src/ring.rs | 487 +++++++++ crates/pf-vkdecode/src/session.rs | 689 +++++++++++++ crates/pf-vkdecode/src/slots.rs | 128 ++- crates/pf-vkdecode/tests/gpu_smoke.rs | 241 +++++ 12 files changed, 4711 insertions(+), 21 deletions(-) create mode 100644 crates/pf-vkdecode/src/caps.rs create mode 100644 crates/pf-vkdecode/src/decoder.rs create mode 100644 crates/pf-vkdecode/src/device.rs create mode 100644 crates/pf-vkdecode/src/images.rs create mode 100644 crates/pf-vkdecode/src/ring.rs create mode 100644 crates/pf-vkdecode/src/session.rs create mode 100644 crates/pf-vkdecode/tests/gpu_smoke.rs diff --git a/crates/pf-bitstream/src/h264.rs b/crates/pf-bitstream/src/h264.rs index 1813f8ba..fcbe3369 100644 --- a/crates/pf-bitstream/src/h264.rs +++ b/crates/pf-bitstream/src/h264.rs @@ -74,6 +74,15 @@ pub struct AuPlan { pub slices: Vec, pub dpb: DpbUpdate, pub warnings: Vec, + /// The SPS the planner activated for this AU — the one [`Self::picture`]'s + /// parameters derive from (the FIRST slice's PPS's SPS; a later slice may + /// legally reference another PPS, and that drift deliberately does not reach + /// here). Cloned out of the parser's table so backends build their parameter + /// objects from exactly what was activated, never by re-parsing the AU. + pub sps: Rc, + /// The PPS the picture was begun with (the first slice's), same contract as + /// [`Self::sps`]. Its `sps` field is the same `Rc` as [`Self::sps`]. + pub pps: Rc, } /// Per-picture parameters, captured after 8.2.1 POC derivation and before end-of-picture @@ -465,6 +474,10 @@ impl H264Planner { // Captured before finish_picture: MMCO5 rewrites the stored POC afterwards, but // backends submit the picture with its 8.2.1 values. let picture = Self::picture_plan(&cur, recovery_point); + // The activated parameter sets ride out with the plan (AuPlan field docs); + // cloned before finish_picture consumes `cur`. + let pps = Rc::clone(&cur.first_slice_pps); + let sps = Rc::clone(&pps.sps); let stored = self.finish_picture(cur, &mut warnings)?; // `removed` is the delta against what the backend last SAW alive, not against @@ -485,6 +498,8 @@ impl H264Planner { removed, }, warnings, + sps, + pps, }) } @@ -2402,5 +2417,42 @@ mod tests { height: 64 } ); + // The accessor pair follows the same first-slice rule: backends build + // their parameter objects from these, so drifting to PPS 1 here would + // desynchronize them from `picture`. + assert_eq!(plan.pps.pic_parameter_set_id, 0); + assert_eq!(plan.sps.seq_parameter_set_id, 0); + assert!( + Rc::ptr_eq(&plan.sps, &plan.pps.sps), + "the SPS accessor is the PPS's own SPS, not a second copy" + ); + assert!( + !plan.sps.frame_cropping_flag, + "SPS 0, not the cropped SPS 1" + ); + } + + #[test] + fn the_plans_parameter_set_accessors_carry_the_activated_content() { + let (sps, pps) = authored_sps_pps(); + let mut au0 = param_set_au(&sps, &pps); + au0.extend(write_idr_slice()); + + let plan = H264Planner::new().plan_au(&au0).unwrap(); + // The parser re-parses the in-band parameter sets, so pointer identity + // with the authored `sps`/`pps` is not expected (and whole-struct + // equality would compare parser-side normalizations like the flat + // scaling-list fill); the contract is that the ACTIVATED content rides + // out. Spot-check the fields backends build parameter objects from. + assert_eq!(plan.sps.seq_parameter_set_id, sps.seq_parameter_set_id); + assert_eq!(plan.sps.profile_idc, sps.profile_idc); + assert_eq!(plan.sps.level_idc, sps.level_idc); + assert_eq!(plan.sps.max_num_ref_frames, sps.max_num_ref_frames); + assert_eq!(plan.sps.width(), sps.width()); + assert_eq!(plan.sps.height(), sps.height()); + assert_eq!(plan.pps.pic_parameter_set_id, pps.pic_parameter_set_id); + assert_eq!(plan.pps.seq_parameter_set_id, pps.seq_parameter_set_id); + assert_eq!(plan.pps.pic_init_qp_minus26, pps.pic_init_qp_minus26); + assert!(Rc::ptr_eq(&plan.sps, &plan.pps.sps)); } } diff --git a/crates/pf-vkdecode/src/caps.rs b/crates/pf-vkdecode/src/caps.rs new file mode 100644 index 00000000..6f3d56cd --- /dev/null +++ b/crates/pf-vkdecode/src/caps.rs @@ -0,0 +1,695 @@ +//! H.264 decode capability query + derivation. +//! +//! Split on purpose: [`query_h264_caps`] is the one THIN function that talks to the +//! driver (`vkGetPhysicalDeviceVideoCapabilitiesKHR` + the three video-format-property +//! enumerations) and only COPIES facts into [`RawH264Caps`]; [`derive_caps`] turns +//! those facts into the [`DecodeCaps`] the session/image/ring modules consume and is +//! a pure function over a hand-buildable struct — every mode/format decision is +//! unit-tested without a GPU (the RADV-vs-NVIDIA coincide/distinct split is exactly +//! the driver variance the risk register names). + +use ash::vk; +use ash::vk::native as hh; + +use crate::device::DecodeDevice; + +/// The 8-bit 4:2:0 semi-planar format every punktfunk H.264 session decodes to. +/// (P010 joins with the HEVC/10-bit milestone; H.264 in this program is 8-bit.) +pub const NV12: vk::Format = vk::Format::G8_B8R8_2PLANE_420_UNORM; + +/// The usage the pools actually create with, per role — the format queries ask the +/// driver about EXACTLY these combinations (a query for less would validate an +/// image nobody creates): +/// +/// distinct-mode DPB images: reference-only, never sampled. +pub const DPB_USAGE: vk::ImageUsageFlags = vk::ImageUsageFlags::VIDEO_DECODE_DPB_KHR; +/// Distinct-mode output images: decode destination + presenter sampling. +pub const OUTPUT_USAGE: vk::ImageUsageFlags = vk::ImageUsageFlags::from_raw( + vk::ImageUsageFlags::VIDEO_DECODE_DST_KHR.as_raw() | vk::ImageUsageFlags::SAMPLED.as_raw(), +); +/// Coincide-mode images: DPB + decode destination + presenter sampling in one. +pub const COINCIDE_USAGE: vk::ImageUsageFlags = vk::ImageUsageFlags::from_raw( + vk::ImageUsageFlags::VIDEO_DECODE_DPB_KHR.as_raw() + | vk::ImageUsageFlags::VIDEO_DECODE_DST_KHR.as_raw() + | vk::ImageUsageFlags::SAMPLED.as_raw(), +); + +/// One `VkVideoFormatPropertiesKHR` entry as this crate consumes it: the format +/// plus the driver's advertised usage/create-flag envelope for it — creation must +/// stay INSIDE that envelope (finding of the adversarial round: the flags used to +/// be assumed, not honoured). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct VideoFormat { + pub format: vk::Format, + /// `imageUsageFlags` the driver supports for this format under the queried + /// profile (a superset of the query's usage on a conformant driver). + pub image_usage: vk::ImageUsageFlags, + /// `imageCreateFlags` the driver allows — per-plane views require + /// `MUTABLE_FORMAT` to appear here. + pub image_create_flags: vk::ImageCreateFlags, +} + +/// Everything the thin query copies out of the driver, hand-buildable for tests. +/// +/// The three format lists correspond to the three REAL usage combinations the +/// pools create with ([`DPB_USAGE`], [`OUTPUT_USAGE`], [`COINCIDE_USAGE`] — the +/// presenter-facing ones include `SAMPLED`), in the exact shape the driver was +/// asked: a usage the implementation does not support yields an EMPTY list (the +/// thin query maps `VK_ERROR_FORMAT_NOT_SUPPORTED` / +/// `VK_ERROR_IMAGE_USAGE_NOT_SUPPORTED` for that usage to empty rather than +/// failing the whole probe). +#[derive(Debug, Clone, Default)] +pub struct RawH264Caps { + /// `VkVideoCapabilitiesKHR::flags`. + pub capability_flags: vk::VideoCapabilityFlagsKHR, + /// `VkVideoDecodeCapabilitiesKHR::flags` (the coincide/distinct advertisement). + pub decode_flags: vk::VideoDecodeCapabilityFlagsKHR, + pub min_bitstream_buffer_offset_alignment: u64, + pub min_bitstream_buffer_size_alignment: u64, + pub picture_access_granularity: vk::Extent2D, + pub min_coded_extent: vk::Extent2D, + pub max_coded_extent: vk::Extent2D, + pub max_dpb_slots: u32, + pub max_active_reference_pictures: u32, + /// `VkVideoDecodeH264CapabilitiesKHR::maxLevelIdc` (index-coded Std level). + pub max_level_idc: hh::StdVideoH264LevelIdc, + /// `VkVideoCapabilitiesKHR::stdHeaderVersion` — session creation echoes it back. + pub std_header_version: vk::ExtensionProperties, + /// Formats usable for DISTINCT-mode DPB images (queried with [`DPB_USAGE`]). + pub dpb_formats: Vec, + /// Formats usable for DISTINCT-mode outputs (queried with [`OUTPUT_USAGE`]). + pub output_formats: Vec, + /// Formats usable when DPB and output COINCIDE ([`COINCIDE_USAGE`]). + pub coincide_formats: Vec, +} + +/// The derived facts the rest of the crate keys off. One value per session profile; +/// rebuilt only when the stream renegotiates to a different profile. +#[derive(Debug, Clone)] +pub struct DecodeCaps { + /// Chosen DPB/output arrangement: `true` = the decode output IS the DPB image + /// (RADV's shape), `false` = separate DPB array + output images (NVIDIA's). + /// When a driver advertises both, coincide wins — half the images, and the + /// mode field data trusts most on the fleet's AMD boxes. + pub coincide: bool, + /// `true` when the driver does NOT advertise `SEPARATE_REFERENCE_IMAGES`: every + /// DPB slot must then be a layer of ONE image array. When separate references + /// are allowed this stays `false` and each slot gets its own image (simpler + /// lifetime story; nothing downstream requires the layered arrangement). + pub layered_dpb: bool, + /// Bitstream buffer alignments, normalized to at least 1 so ring math never + /// divides by the zero an uninitialized fixture would carry. + pub min_bitstream_offset_alignment: u64, + pub min_bitstream_size_alignment: u64, + pub picture_access_granularity: vk::Extent2D, + pub min_coded_extent: vk::Extent2D, + pub max_coded_extent: vk::Extent2D, + pub max_dpb_slots: u32, + pub max_active_references: u32, + pub max_level_idc: hh::StdVideoH264LevelIdc, + /// DPB image format (== `output_format` in coincide mode). + pub dpb_format: vk::Format, + /// Decode-output image format. + pub output_format: vk::Format, + pub std_header_version: vk::ExtensionProperties, +} + +impl DecodeCaps { + /// `coded` rounded up to the device's `pictureAccessGranularity` — the extent + /// pool IMAGES are created at (the per-picture `codedExtent` stays the stream's + /// coded size; only the backing store rounds up). A zero granularity axis (an + /// uninitialized fixture) degrades to 1. + pub fn aligned_extent(&self, coded: vk::Extent2D) -> vk::Extent2D { + let round = |value: u32, granularity: u32| -> u32 { + let granularity = granularity.max(1); + value.div_ceil(granularity) * granularity + }; + vk::Extent2D { + width: round(coded.width, self.picture_access_granularity.width), + height: round(coded.height, self.picture_access_granularity.height), + } + } +} + +/// Raw caps that do not add up to a usable decoder. All of these are device gaps +/// the caller demotes on (the ladder's next rung), not stream conditions. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CapsError { + /// The driver advertises neither COINCIDE nor DISTINCT — no way to arrange a + /// DPB at all (a broken driver; the spec requires at least one). + NoDecodeMode, + /// The mode's format list does not contain [`NV12`]. `mode` names which list. + NoNv12Format { mode: &'static str }, + /// The driver's NV12 entry for `mode` does not advertise every usage bit the + /// pool would create with (`missing` names the gap) — creating anyway would be + /// a silent VUID violation. + UsageUnsupported { + mode: &'static str, + missing: vk::ImageUsageFlags, + }, + /// The presenter-facing NV12 entry for `mode` does not allow `MUTABLE_FORMAT`, + /// so the per-plane `R8`/`R8G8` views the presenter samples through cannot + /// exist on this device. + NoMutableFormat { mode: &'static str }, +} + +impl std::fmt::Display for CapsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CapsError::NoDecodeMode => { + write!( + f, + "driver advertises neither DPB_AND_OUTPUT_COINCIDE nor DISTINCT" + ) + } + CapsError::NoNv12Format { mode } => { + write!(f, "no NV12 in the {mode} video format properties") + } + CapsError::UsageUnsupported { mode, missing } => { + write!( + f, + "the {mode} NV12 entry does not advertise usage {missing:?}" + ) + } + CapsError::NoMutableFormat { mode } => { + write!( + f, + "the {mode} NV12 entry does not allow MUTABLE_FORMAT (per-plane views)" + ) + } + } + } +} + +impl std::error::Error for CapsError {} + +/// Derive the session-shaping facts from one raw query. Pure — the whole +/// coincide/distinct/layered decision table lives here and in the tests below. +pub fn derive_caps(raw: &RawH264Caps) -> Result { + let coincide = raw + .decode_flags + .contains(vk::VideoDecodeCapabilityFlagsKHR::DPB_AND_OUTPUT_COINCIDE); + let distinct = raw + .decode_flags + .contains(vk::VideoDecodeCapabilityFlagsKHR::DPB_AND_OUTPUT_DISTINCT); + if !coincide && !distinct { + return Err(CapsError::NoDecodeMode); + } + + // Coincide preferred when both are offered (struct docs). Each picked entry is + // validated against the EXACT usage/create-flags its pool will use: presenter- + // facing images (coincide pool, distinct outputs) additionally need + // MUTABLE_FORMAT for their per-plane views; the distinct DPB needs neither + // sampling nor plane views. + let (dpb_format, output_format) = if coincide { + let mode = "coincide (DPB|DST|SAMPLED)"; + let entry = pick_nv12(&raw.coincide_formats, mode)?; + require_usage(&entry, COINCIDE_USAGE, mode)?; + require_mutable(&entry, mode)?; + (entry.format, entry.format) + } else { + let dpb = pick_nv12(&raw.dpb_formats, "DPB")?; + require_usage(&dpb, DPB_USAGE, "DPB")?; + let out_mode = "output (DST|SAMPLED)"; + let output = pick_nv12(&raw.output_formats, out_mode)?; + require_usage(&output, OUTPUT_USAGE, out_mode)?; + require_mutable(&output, out_mode)?; + (dpb.format, output.format) + }; + + Ok(DecodeCaps { + coincide, + layered_dpb: !raw + .capability_flags + .contains(vk::VideoCapabilityFlagsKHR::SEPARATE_REFERENCE_IMAGES), + min_bitstream_offset_alignment: raw.min_bitstream_buffer_offset_alignment.max(1), + min_bitstream_size_alignment: raw.min_bitstream_buffer_size_alignment.max(1), + picture_access_granularity: raw.picture_access_granularity, + min_coded_extent: raw.min_coded_extent, + max_coded_extent: raw.max_coded_extent, + max_dpb_slots: raw.max_dpb_slots, + max_active_references: raw.max_active_reference_pictures, + max_level_idc: raw.max_level_idc, + dpb_format, + output_format, + std_header_version: raw.std_header_version, + }) +} + +fn pick_nv12(formats: &[VideoFormat], mode: &'static str) -> Result { + formats + .iter() + .copied() + .find(|f| f.format == NV12) + .ok_or(CapsError::NoNv12Format { mode }) +} + +/// The pool's creation usage must sit inside the driver's advertised envelope. +fn require_usage( + entry: &VideoFormat, + usage: vk::ImageUsageFlags, + mode: &'static str, +) -> Result<(), CapsError> { + let missing = usage & !entry.image_usage; + if missing.is_empty() { + Ok(()) + } else { + Err(CapsError::UsageUnsupported { mode, missing }) + } +} + +fn require_mutable(entry: &VideoFormat, mode: &'static str) -> Result<(), CapsError> { + if entry + .image_create_flags + .contains(vk::ImageCreateFlags::MUTABLE_FORMAT) + { + Ok(()) + } else { + Err(CapsError::NoMutableFormat { mode }) + } +} + +/// A complete H.264 decode profile chain in one movable value, mirroring the +/// encoder's `RgbProfileStack`: profile identity in Vulkan is BY VALUE, so every +/// consumer (caps query, session create, image/buffer create, query pool create) +/// rebuilds a structurally identical chain rather than sharing pointers. +/// +/// [`Self::wire`] links `profile.p_next` to this struct's OWN `h264` field; the +/// value must not move between `wire()` and the last use of the returned reference +/// (the borrow checker pins it — `wire` borrows `self` for the reference's life). +pub(crate) struct H264ProfileChain { + h264: vk::VideoDecodeH264ProfileInfoKHR<'static>, + profile: vk::VideoProfileInfoKHR<'static>, +} + +impl H264ProfileChain { + /// Build the (unwired) chain for one SPS profile. `std_profile_idc` is the + /// value WP-A's conversion validated (66/77/100/244 pass-through); H.264 here + /// is 8-bit 4:2:0 progressive by the program envelope. + pub(crate) fn new(std_profile_idc: hh::StdVideoH264ProfileIdc) -> Self { + Self { + h264: vk::VideoDecodeH264ProfileInfoKHR::default() + .std_profile_idc(std_profile_idc) + .picture_layout(vk::VideoDecodeH264PictureLayoutFlagsKHR::PROGRESSIVE), + profile: vk::VideoProfileInfoKHR::default() + .video_codec_operation(vk::VideoCodecOperationFlagsKHR::DECODE_H264) + .chroma_subsampling(vk::VideoChromaSubsamplingFlagsKHR::TYPE_420) + .luma_bit_depth(vk::VideoComponentBitDepthFlagsKHR::TYPE_8) + .chroma_bit_depth(vk::VideoComponentBitDepthFlagsKHR::TYPE_8), + } + } + + /// Wire the internal `p_next` chain and hand out the profile root. Do not move + /// `self` while the returned reference (or any pointer taken from it) lives. + pub(crate) fn wire(&mut self) -> &vk::VideoProfileInfoKHR<'static> { + self.profile.p_next = (&self.h264 as *const vk::VideoDecodeH264ProfileInfoKHR<'_>).cast(); + &self.profile + } +} + +/// The one function that asks the driver: video capabilities (with the decode + +/// H.264 capability structs chained) plus the three format-property enumerations. +/// Copies facts out and returns; derivation happens in [`derive_caps`]. +/// +/// # Safety +/// +/// `dev` wraps live handles per the [`crate::DeviceHandles`] contract (this calls +/// instance-level functions against its physical device). +pub(crate) unsafe fn query_h264_caps( + dev: &DecodeDevice, + std_profile_idc: hh::StdVideoH264ProfileIdc, +) -> Result { + let mut chain = H264ProfileChain::new(std_profile_idc); + let profile = chain.wire(); + + let mut h264_caps = vk::VideoDecodeH264CapabilitiesKHR::default(); + let mut decode_caps = vk::VideoDecodeCapabilitiesKHR::default(); + let mut caps = vk::VideoCapabilitiesKHR::default() + .push_next(&mut decode_caps) + .push_next(&mut h264_caps); + // SAFETY: physical device is live (DeviceHandles contract); `profile` roots a + // fully wired, immovable chain; `caps` chains driver-fillable structs that all + // outlive the call. + let r = unsafe { + (dev.video_queue_instance() + .fp() + .get_physical_device_video_capabilities_khr)( + dev.physical_device(), profile, &mut caps + ) + }; + if r != vk::Result::SUCCESS { + return Err(r); + } + // Copy everything out before the chained &mut borrows end (encoder precedent). + let capability_flags = caps.flags; + let min_bitstream_buffer_offset_alignment = caps.min_bitstream_buffer_offset_alignment; + let min_bitstream_buffer_size_alignment = caps.min_bitstream_buffer_size_alignment; + let picture_access_granularity = caps.picture_access_granularity; + let min_coded_extent = caps.min_coded_extent; + let max_coded_extent = caps.max_coded_extent; + let max_dpb_slots = caps.max_dpb_slots; + let max_active_reference_pictures = caps.max_active_reference_pictures; + let std_header_version = caps.std_header_version; + let decode_flags = decode_caps.flags; + let max_level_idc = h264_caps.max_level_idc; + + // The three queries carry the REAL creation usages (SAMPLED included for the + // presenter-facing roles) so the answers validate the images the pools build. + // SAFETY: same liveness as above; the helper wires its own chain (this and + // the two calls below). + let dpb_formats = unsafe { query_formats(dev, std_profile_idc, DPB_USAGE)? }; + // SAFETY: as above. + let output_formats = unsafe { query_formats(dev, std_profile_idc, OUTPUT_USAGE)? }; + // SAFETY: as above. + let coincide_formats = unsafe { query_formats(dev, std_profile_idc, COINCIDE_USAGE)? }; + + Ok(RawH264Caps { + capability_flags, + decode_flags, + min_bitstream_buffer_offset_alignment, + min_bitstream_buffer_size_alignment, + picture_access_granularity, + min_coded_extent, + max_coded_extent, + max_dpb_slots, + max_active_reference_pictures, + max_level_idc, + std_header_version, + dpb_formats, + output_formats, + coincide_formats, + }) +} + +/// Enumerate the video format properties for one usage combination. A usage the +/// implementation rejects outright maps to an EMPTY list (that is the driver saying +/// "not this arrangement", which [`derive_caps`] then routes around). +/// +/// # Safety +/// +/// As [`query_h264_caps`]. +unsafe fn query_formats( + dev: &DecodeDevice, + std_profile_idc: hh::StdVideoH264ProfileIdc, + usage: vk::ImageUsageFlags, +) -> Result, vk::Result> { + let mut chain = H264ProfileChain::new(std_profile_idc); + let profile = chain.wire(); + let mut profile_list = + vk::VideoProfileListInfoKHR::default().profiles(std::slice::from_ref(profile)); + let info = vk::PhysicalDeviceVideoFormatInfoKHR::default() + .image_usage(usage) + .push_next(&mut profile_list); + + let fp = dev + .video_queue_instance() + .fp() + .get_physical_device_video_format_properties_khr; + let mut count = 0u32; + // SAFETY: live physical device; `info` roots a wired chain outliving the call; + // null properties pointer is the spec's count-query form. + let r = unsafe { + fp( + dev.physical_device(), + &info, + &mut count, + std::ptr::null_mut(), + ) + }; + match r { + vk::Result::SUCCESS => {} + // "This usage/profile combination has no formats" — an arrangement gap, + // not a failure (derive_caps decides whether a usable mode remains). + vk::Result::ERROR_FORMAT_NOT_SUPPORTED + | vk::Result::ERROR_IMAGE_USAGE_NOT_SUPPORTED_KHR => return Ok(Vec::new()), + err => return Err(err), + } + let mut props = vec![vk::VideoFormatPropertiesKHR::default(); count as usize]; + // SAFETY: as above, with a properties array of exactly the driver-reported count. + let r = unsafe { fp(dev.physical_device(), &info, &mut count, props.as_mut_ptr()) }; + if r != vk::Result::SUCCESS && r != vk::Result::INCOMPLETE { + return Err(r); + } + props.truncate(count as usize); + Ok(props + .iter() + .map(|p| VideoFormat { + format: p.format, + image_usage: p.image_usage_flags, + image_create_flags: p.image_create_flags, + }) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A format entry advertising `usage` plus the mutable-format allowance. + fn entry(format: vk::Format, usage: vk::ImageUsageFlags) -> VideoFormat { + VideoFormat { + format, + image_usage: usage, + image_create_flags: vk::ImageCreateFlags::MUTABLE_FORMAT + | vk::ImageCreateFlags::ALIAS + | vk::ImageCreateFlags::EXTENDED_USAGE, + } + } + + /// A raw-caps fixture in RADV's shape: coincide advertised, separate reference + /// images allowed, sane alignments. + fn radv_like() -> RawH264Caps { + RawH264Caps { + capability_flags: vk::VideoCapabilityFlagsKHR::SEPARATE_REFERENCE_IMAGES, + decode_flags: vk::VideoDecodeCapabilityFlagsKHR::DPB_AND_OUTPUT_COINCIDE, + min_bitstream_buffer_offset_alignment: 128, + min_bitstream_buffer_size_alignment: 128, + picture_access_granularity: vk::Extent2D { + width: 1, + height: 1, + }, + min_coded_extent: vk::Extent2D { + width: 16, + height: 16, + }, + max_coded_extent: vk::Extent2D { + width: 8192, + height: 8192, + }, + max_dpb_slots: 17, + max_active_reference_pictures: 16, + max_level_idc: hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_6_2, + std_header_version: vk::ExtensionProperties::default(), + dpb_formats: vec![], + output_formats: vec![], + coincide_formats: vec![ + entry(NV12, COINCIDE_USAGE), + entry( + vk::Format::G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16, + COINCIDE_USAGE, + ), + ], + } + } + + /// NVIDIA's shape: distinct only, NO separate reference images (layered DPB + /// array), and only the distinct-mode format lists populated. The DPB entry + /// deliberately advertises NEITHER sampling nor mutable formats — reference + /// arrays need neither, and requiring them there would fail real devices. + fn nvidia_like() -> RawH264Caps { + RawH264Caps { + capability_flags: vk::VideoCapabilityFlagsKHR::empty(), + decode_flags: vk::VideoDecodeCapabilityFlagsKHR::DPB_AND_OUTPUT_DISTINCT, + dpb_formats: vec![VideoFormat { + format: NV12, + image_usage: DPB_USAGE, + image_create_flags: vk::ImageCreateFlags::empty(), + }], + output_formats: vec![entry(NV12, OUTPUT_USAGE)], + coincide_formats: vec![], + ..radv_like() + } + } + + #[test] + fn a_coincide_device_derives_coincide_with_one_shared_format() { + let caps = derive_caps(&radv_like()).unwrap(); + assert!(caps.coincide); + assert!( + !caps.layered_dpb, + "separate reference images advertised — per-slot images" + ); + assert_eq!(caps.dpb_format, NV12); + assert_eq!(caps.output_format, NV12); + assert_eq!(caps.max_dpb_slots, 17); + assert_eq!(caps.min_bitstream_offset_alignment, 128); + } + + #[test] + fn a_distinct_device_derives_distinct_with_a_layered_dpb() { + let caps = derive_caps(&nvidia_like()).unwrap(); + assert!(!caps.coincide); + assert!( + caps.layered_dpb, + "no SEPARATE_REFERENCE_IMAGES — one image array carries every slot" + ); + assert_eq!(caps.dpb_format, NV12); + assert_eq!(caps.output_format, NV12); + } + + #[test] + fn a_device_advertising_both_modes_prefers_coincide() { + let mut raw = radv_like(); + raw.decode_flags = vk::VideoDecodeCapabilityFlagsKHR::DPB_AND_OUTPUT_COINCIDE + | vk::VideoDecodeCapabilityFlagsKHR::DPB_AND_OUTPUT_DISTINCT; + raw.dpb_formats = vec![entry(NV12, DPB_USAGE)]; + raw.output_formats = vec![entry(NV12, OUTPUT_USAGE)]; + let caps = derive_caps(&raw).unwrap(); + assert!(caps.coincide, "coincide wins when both are offered"); + } + + #[test] + fn no_mode_and_no_nv12_are_distinct_hard_errors() { + let mut raw = radv_like(); + raw.decode_flags = vk::VideoDecodeCapabilityFlagsKHR::empty(); + assert_eq!(derive_caps(&raw).unwrap_err(), CapsError::NoDecodeMode); + + let mut raw = radv_like(); + raw.coincide_formats = vec![entry( + vk::Format::G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16, + COINCIDE_USAGE, + )]; + assert_eq!( + derive_caps(&raw).unwrap_err(), + CapsError::NoNv12Format { + mode: "coincide (DPB|DST|SAMPLED)" + } + ); + + // Distinct mode reports which HALF is missing NV12. + let mut raw = nvidia_like(); + raw.output_formats = vec![]; + assert_eq!( + derive_caps(&raw).unwrap_err(), + CapsError::NoNv12Format { + mode: "output (DST|SAMPLED)" + } + ); + } + + #[test] + fn an_advertised_usage_missing_a_creation_bit_is_an_error_naming_the_gap() { + // A coincide entry that supports decode but NOT sampling: the presenter + // cannot read it, so derivation must refuse rather than create anyway. + let mut raw = radv_like(); + raw.coincide_formats = vec![entry( + NV12, + vk::ImageUsageFlags::VIDEO_DECODE_DPB_KHR | vk::ImageUsageFlags::VIDEO_DECODE_DST_KHR, + )]; + assert_eq!( + derive_caps(&raw).unwrap_err(), + CapsError::UsageUnsupported { + mode: "coincide (DPB|DST|SAMPLED)", + missing: vk::ImageUsageFlags::SAMPLED + } + ); + + // Same on the distinct output half. + let mut raw = nvidia_like(); + raw.output_formats = vec![entry(NV12, vk::ImageUsageFlags::VIDEO_DECODE_DST_KHR)]; + assert_eq!( + derive_caps(&raw).unwrap_err(), + CapsError::UsageUnsupported { + mode: "output (DST|SAMPLED)", + missing: vk::ImageUsageFlags::SAMPLED + } + ); + } + + #[test] + fn a_presenter_facing_entry_without_mutable_format_is_refused() { + let mut raw = radv_like(); + raw.coincide_formats = vec![VideoFormat { + format: NV12, + image_usage: COINCIDE_USAGE, + image_create_flags: vk::ImageCreateFlags::empty(), + }]; + assert_eq!( + derive_caps(&raw).unwrap_err(), + CapsError::NoMutableFormat { + mode: "coincide (DPB|DST|SAMPLED)" + } + ); + + // The distinct DPB entry needs NO mutable-format allowance (nvidia_like's + // DPB entry has empty create flags and derives fine). + assert!(derive_caps(&nvidia_like()).is_ok()); + } + + #[test] + fn extents_round_up_to_the_picture_access_granularity() { + let mut raw = radv_like(); + raw.picture_access_granularity = vk::Extent2D { + width: 64, + height: 16, + }; + let caps = derive_caps(&raw).unwrap(); + // 1920x1080: width already aligned, height rounds to 1088 — the exact + // padded shape the old smeared-rows class came from, now explicit. + let aligned = caps.aligned_extent(vk::Extent2D { + width: 1920, + height: 1080, + }); + assert_eq!((aligned.width, aligned.height), (1920, 1088)); + + // Granularity 1 is the identity; a zero axis degrades to 1, not a panic. + let mut raw = radv_like(); + raw.picture_access_granularity = vk::Extent2D { + width: 0, + height: 1, + }; + let caps = derive_caps(&raw).unwrap(); + let aligned = caps.aligned_extent(vk::Extent2D { + width: 321, + height: 241, + }); + assert_eq!((aligned.width, aligned.height), (321, 241)); + } + + #[test] + fn zero_alignments_normalize_to_one_so_ring_math_never_divides_by_zero() { + let mut raw = radv_like(); + raw.min_bitstream_buffer_offset_alignment = 0; + raw.min_bitstream_buffer_size_alignment = 0; + let caps = derive_caps(&raw).unwrap(); + assert_eq!(caps.min_bitstream_offset_alignment, 1); + assert_eq!(caps.min_bitstream_size_alignment, 1); + } + + #[test] + fn the_profile_chain_wires_h264_behind_the_root_profile() { + let mut chain = + H264ProfileChain::new(hh::StdVideoH264ProfileIdc_STD_VIDEO_H264_PROFILE_IDC_MAIN); + let profile = chain.wire(); + assert_eq!( + profile.video_codec_operation, + vk::VideoCodecOperationFlagsKHR::DECODE_H264 + ); + assert!(!profile.p_next.is_null()); + // SAFETY: wire() pointed p_next at chain's own h264 field, which lives for + // this whole scope and is a valid VideoDecodeH264ProfileInfoKHR. + let h264 = unsafe { + &*profile + .p_next + .cast::>() + }; + assert_eq!( + h264.std_profile_idc, + hh::StdVideoH264ProfileIdc_STD_VIDEO_H264_PROFILE_IDC_MAIN + ); + assert_eq!( + h264.picture_layout, + vk::VideoDecodeH264PictureLayoutFlagsKHR::PROGRESSIVE + ); + } +} diff --git a/crates/pf-vkdecode/src/decoder.rs b/crates/pf-vkdecode/src/decoder.rs new file mode 100644 index 00000000..663015dd --- /dev/null +++ b/crates/pf-vkdecode/src/decoder.rs @@ -0,0 +1,1361 @@ +//! [`VkH264Decoder`]: the assembled native decoder — pf-bitstream's planner and +//! WP-A's conversions driving a Vulkan Video session end to end. +//! +//! Per AU: `plan_au` → `plan_to_vk` → AU upload into the bitstream ring → record +//! (barriers, `vkCmdBeginVideoCodingKHR` with every bound DPB slot, the one-time +//! session RESET control, a `RESULT_STATUS_ONLY` query bracketing +//! `vkCmdDecodeVideoKHR`) → submit on the decode queue under the caller's +//! [`QueueLock`] with a per-output-slot timeline signal. +//! +//! The status query is THE point of this program: FFmpeg's `vulkan_decode.c` runs +//! `nb_queries = 0` and therefore architecturally cannot see driver-reported decode +//! corruption (the Xbox Ally X field case). Here every decode op has a query slot, +//! [`VkH264Decoder::poll_status`] reads it WITHOUT waiting, and a non-COMPLETE +//! result is the concealment signal WP-C wires to `want_keyframe`. +//! +//! What stays for WP-C (the integration layer): feeding `PlanWarning`s and Failed +//! statuses into the recovery machinery, presenting frames (including the +//! coincide-mode layout dance — see [`DecodedVkFrame::layout`]), and throttling so +//! output slots are consumed before their ring position recycles. + +use std::collections::BTreeMap; +use std::collections::VecDeque; + +use ash::vk; +use ash::vk::native as hh; +use pf_bitstream::h264::AuPlan; +use pf_bitstream::h264::DisplayCrop; +use pf_bitstream::h264::DpbUpdate; +use pf_bitstream::h264::H264Planner; +use pf_bitstream::h264::PicId; +use pf_bitstream::h264::PlanError; +use tracing::debug; +use tracing::trace; + +use crate::caps::derive_caps; +use crate::caps::query_h264_caps; +use crate::caps::CapsError; +use crate::caps::DecodeCaps; +use crate::caps::H264ProfileChain; +use crate::device::AllocError; +use crate::device::DecodeDevice; +use crate::device::DeviceError; +use crate::device::DeviceHandles; +use crate::device::QueueLock; +use crate::device::QueueSubmitGuard; +use crate::images::plan_pools; +use crate::images::ImagePool; +use crate::images::OUTPUT_RING; +use crate::params::level_to_std; +use crate::params::ParamsError; +use crate::pic::plan_to_vk; +use crate::pic::DecodePlanVk; +use crate::pic::PlanToVkError; +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::ParamsAction; +use crate::session::SessionConfig; +use crate::session::SessionError; +use crate::session::VideoSession; +use crate::slots::SlotError; +use crate::slots::SlotMap; + +/// Ceiling on any blocking GPU wait on the decode thread (5 s) — generous against +/// a real decode, finite against a wedged driver, matching the encoder's fence +/// budget so the session layer's recovery path is never parked forever. +const DECODE_TIMEOUT_NS: u64 = 5_000_000_000; + +/// Result of one decode op's `RESULT_STATUS_ONLY` query. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DecodeStatus { + /// The op has not completed (or its status is not yet readable). + Pending, + /// The driver reports the op COMPLETE. + Ok, + /// The driver reports an error status, the query slot was recycled before it + /// was read, or the device is lost — in every case the frame's content is + /// unproven and the caller should treat it as concealed (want_keyframe). + Failed, +} + +/// One decoded, display-ready picture. Handles are BORROWED from the decoder's +/// pools: valid until the decoder rebuilds its session (stream renegotiation — +/// detectable via [`Self::generation`]). The consumer waits `semaphore >= value` +/// before reading pixels and MUST hand every delivered frame back through +/// [`VkH264Decoder::release_frame`] once done — the frame's slot is excluded from +/// every reuse path (setup assignment, output-ring recycling) until then, which +/// is what makes a delivered image safe to read while decoding continues. +#[derive(Debug, Clone)] +pub struct DecodedVkFrame { + pub image: vk::Image, + /// Full-picture NV12 view (what decode wrote through). + pub view: vk::ImageView, + /// `R8`/`R8G8` per-plane views for the presenter's sampler path. + pub plane_views: [vk::ImageView; 2], + /// The image array layer the picture occupies (the views already select it). + pub layer: u32, + /// The layout the picture is in when the semaphore signals: + /// `VIDEO_DECODE_DST_KHR` (distinct mode) or `VIDEO_DECODE_DPB_KHR` (coincide + /// mode — the picture may still be a live reference, so a consumer that + /// transitions it for sampling MUST transition it back before the next decode + /// references the slot; WP-C owns that dance). + pub layout: vk::ImageLayout, + pub coded_width: u32, + pub coded_height: u32, + /// Conformance-window crop: the region to display. First-class here so no + /// consumer ever derives geometry from the (padded) pool shape again. + pub crop: DisplayCrop, + /// Timeline pair: the picture's pixels are ready when `semaphore` reaches + /// `value`. + pub semaphore: vk::Semaphore, + pub value: u64, + pub poc: i32, + pub is_idr: bool, + /// The decode op's slot in the status query pool (for [`VkH264Decoder::poll_status`]). + pub query_slot: u32, + /// The session generation this frame's handles belong to. Bumped on every + /// session rebuild; a frame from an older generation points into destroyed + /// pools, so every decoder entry point taking a frame checks this FIRST and + /// reports the frame stale rather than touching the new pools. + pub generation: u64, +} + +/// Everything that can go wrong. Never panics; device loss is first-class so the +/// session layer can tear down and rebuild. +#[derive(Debug)] +pub enum VkDecodeError { + /// pf-bitstream could not plan the AU at all. + Plan(PlanError), + /// A parameter set has no Std representation (stream-integrity failure). + Params(ParamsError), + /// Plan-to-Vulkan conversion failed (caller/session bugs; `CapacityMismatch` + /// is consumed internally by the rebuild path and only surfaces if the rebuilt + /// session STILL mismatches). + Convert(PlanToVkError), + /// The device's capabilities cannot host any session (demote to the next rung). + Caps(CapsError), + /// The handle bundle was rejected. + Device(DeviceError), + /// The stream asks for more than this device's caps allow. + Unsupported(String), + /// A Vulkan call failed (anything but device loss). + Vk(vk::Result), + /// `VK_ERROR_DEVICE_LOST` — every later call fails fast with this until the + /// owner rebuilds on fresh handles. + DeviceLost, + /// A bounded GPU wait expired: the driver is wedged; treat as fatal for this + /// decoder instance. + Timeout(&'static str), + /// Every slot that could host this decode still backs an unreleased frame: + /// the consumer owes [`VkH264Decoder::release_frame`] calls. Unreachable + /// under WP-C's one-in/one-out loop (each delivered frame is released before + /// the next `decode`); reaching it means the AU was planned but NOT decoded — + /// the caller should release frames and request a keyframe. + NoFreeSlot, + /// The frame belongs to an older session generation (its handles point into + /// destroyed pools). Delivered frames do not survive a stream renegotiation. + StaleFrame { + frame_generation: u64, + current_generation: u64, + }, + /// No device memory type satisfies an allocation's requirements. + NoMemoryType { + type_bits: u32, + flags: vk::MemoryPropertyFlags, + }, +} + +impl std::fmt::Display for VkDecodeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + VkDecodeError::Plan(e) => write!(f, "AU planning failed: {e}"), + VkDecodeError::Params(e) => write!(f, "parameter-set conversion failed: {e}"), + VkDecodeError::Convert(e) => write!(f, "plan conversion failed: {e}"), + VkDecodeError::Caps(e) => write!(f, "decode capabilities unusable: {e}"), + VkDecodeError::Device(e) => write!(f, "device handles rejected: {e}"), + VkDecodeError::Unsupported(what) => write!(f, "outside device caps: {what}"), + VkDecodeError::Vk(r) => write!(f, "Vulkan call failed: {r:?}"), + VkDecodeError::DeviceLost => write!(f, "VK_ERROR_DEVICE_LOST"), + VkDecodeError::Timeout(what) => { + write!(f, "GPU wait expired after {DECODE_TIMEOUT_NS} ns: {what}") + } + VkDecodeError::NoFreeSlot => { + write!( + f, + "every candidate slot backs an unreleased frame — release_frame owed" + ) + } + VkDecodeError::StaleFrame { + frame_generation, + current_generation, + } => { + write!( + f, + "frame from session generation {frame_generation}, current is \ + {current_generation} — its handles are gone" + ) + } + VkDecodeError::NoMemoryType { type_bits, flags } => { + write!( + f, + "no memory type satisfies bits {type_bits:#x} with {flags:?}" + ) + } + } + } +} + +impl std::error::Error for VkDecodeError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + VkDecodeError::Plan(e) => Some(e), + VkDecodeError::Params(e) => Some(e), + VkDecodeError::Convert(e) => Some(e), + VkDecodeError::Caps(e) => Some(e), + VkDecodeError::Device(e) => Some(e), + _ => None, + } + } +} + +impl From for VkDecodeError { + fn from(r: vk::Result) -> Self { + if r == vk::Result::ERROR_DEVICE_LOST { + VkDecodeError::DeviceLost + } else { + VkDecodeError::Vk(r) + } + } +} + +impl From for VkDecodeError { + fn from(e: PlanError) -> Self { + VkDecodeError::Plan(e) + } +} + +impl From for VkDecodeError { + fn from(e: ParamsError) -> Self { + VkDecodeError::Params(e) + } +} + +impl From for VkDecodeError { + fn from(e: CapsError) -> Self { + VkDecodeError::Caps(e) + } +} + +impl From for VkDecodeError { + fn from(e: DeviceError) -> Self { + VkDecodeError::Device(e) + } +} + +impl From for VkDecodeError { + fn from(e: SessionError) -> Self { + match e { + SessionError::Vk(r) => VkDecodeError::from(r), + SessionError::Params(p) => VkDecodeError::Params(p), + SessionError::NoMemoryType { type_bits, flags } => { + VkDecodeError::NoMemoryType { type_bits, flags } + } + } + } +} + +impl From for VkDecodeError { + fn from(e: AllocError) -> Self { + match e { + AllocError::Vk(r) => VkDecodeError::from(r), + AllocError::NoMemoryType { type_bits, flags } => { + VkDecodeError::NoMemoryType { type_bits, flags } + } + } + } +} + +/// Query pool + command pool/buffers, one op slot per output slot. Owns and +/// destroys its Vulkan objects. +struct OpRing { + device: ash::Device, + query_pool: vk::QueryPool, + cmd_pool: vk::CommandPool, + cmds: Vec, +} + +impl OpRing { + /// # Safety + /// + /// `dev` wraps live handles ([`DeviceHandles`] contract). + unsafe fn create( + dev: &DecodeDevice, + std_profile_idc: hh::StdVideoH264ProfileIdc, + op_slots: u32, + ) -> Result { + let mut chain = H264ProfileChain::new(std_profile_idc); + let profile = chain.wire(); + let mut query_ci = vk::QueryPoolCreateInfo::default() + .query_type(vk::QueryType::RESULT_STATUS_ONLY_KHR) + .query_count(op_slots); + // Chained manually: `push_next` would clobber the profile's own `p_next` + // (its H264 half) — the encoder's exact precedent for this trap. + query_ci.p_next = (profile as *const vk::VideoProfileInfoKHR<'_>).cast(); + // SAFETY: live device; `query_ci` roots the wired chain for the call. The + // video profile chained in satisfies the "same profile as the session" + // rule for queries used inside a coding scope. + let query_pool = unsafe { dev.ash().create_query_pool(&query_ci, None)? }; + + let pool_ci = vk::CommandPoolCreateInfo::default() + .queue_family_index(dev.decode_qf()) + .flags(vk::CommandPoolCreateFlags::RESET_COMMAND_BUFFER); + // SAFETY: live device; unwind destroys the query pool on failure. + let cmd_pool = match unsafe { dev.ash().create_command_pool(&pool_ci, None) } { + Ok(p) => p, + Err(e) => { + // SAFETY: destroying the just-created query pool. + unsafe { dev.ash().destroy_query_pool(query_pool, None) }; + return Err(e); + } + }; + let alloc = vk::CommandBufferAllocateInfo::default() + .command_pool(cmd_pool) + .command_buffer_count(op_slots); + // SAFETY: live device + the pool created above; unwind destroys both pools + // (destroying the command pool frees any allocated buffers). + let cmds = match unsafe { dev.ash().allocate_command_buffers(&alloc) } { + Ok(c) => c, + Err(e) => { + // SAFETY: destroying the two pools created above. + unsafe { + dev.ash().destroy_command_pool(cmd_pool, None); + dev.ash().destroy_query_pool(query_pool, None); + } + return Err(e); + } + }; + Ok(Self { + device: dev.ash().clone(), + query_pool, + cmd_pool, + cmds, + }) + } +} + +impl Drop for OpRing { + fn drop(&mut self) { + // SAFETY: own handles on the contract-live device; the owning decoder + // drains GPU work before dropping state. Destroying the command pool frees + // its buffers; both destroys ignore NULL. + unsafe { + self.device.destroy_command_pool(self.cmd_pool, None); + self.device.destroy_query_pool(self.query_pool, None); + } + } +} + +/// Everything tied to ONE session generation. A stream renegotiation (extent, DPB +/// depth, profile) drops and rebuilds the whole struct. +struct SessionState { + session: VideoSession, + slots: SlotMap, + pool: ImagePool, + 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 + /// slices do not reference; refreshed from each plan's setup/ref entries so + /// marking transitions (e.g. MMCO long-term promotion) propagate. + slot_refs: Vec>, + /// Distinct-mode output ring cursor (unused in coincide mode). + out_cursor: usize, + /// Live-frame counts per OUTPUT slot: every [`DecodedVkFrame`] built over the + /// slot (pending, ready, or delivered-and-unreleased) counts one; the slot is + /// not reusable while nonzero. The coincide twin of this gate additionally + /// pins the [`SlotMap`] so `plan_to_vk`'s setup assignment skips the slot. + live_frames: Vec, +} + +impl SessionState { + /// Count a new frame over `out_slot` (and pin its DPB slot in coincide mode, + /// where output slot == DPB slot). + fn note_frame_live(&mut self, out_slot: usize) { + self.live_frames[out_slot] += 1; + if self.pool.coincide { + // Output slots mirror DPB slots one-to-one in coincide mode; the + // envelope-gated capacity (<= 17) keeps the index within u8. + self.slots.pin(out_slot as u8); + } + } + + /// Un-count a frame over `out_slot` (release or internal drop). + fn note_frame_dead(&mut self, out_slot: usize) { + match self.live_frames[out_slot].checked_sub(1) { + Some(remaining) => self.live_frames[out_slot] = remaining, + None => { + debug!(out_slot, "frame released more often than counted"); + return; + } + } + if self.pool.coincide && !self.slots.unpin(out_slot as u8) { + debug!(out_slot, "coincide slot unpinned without a pin"); + } + } +} + +/// The native Vulkan Video H.264 decoder. +pub struct VkH264Decoder { + dev: DecodeDevice, + lock: Box, + planner: H264Planner, + /// Caps per Std profile idc, queried once per profile. + caps: Option<(hh::StdVideoH264ProfileIdc, DecodeCaps)>, + state: Option, + /// Decoded pictures awaiting their planner output verdict, keyed by [`PicId`]. + pending_frames: BTreeMap, + /// Display-ready frames not yet handed out (under the zero-reorder punktfunk + /// envelope at most one per AU; deeper only around discontinuities/flushes). + ready: VecDeque, + /// Session generation: bumped on every rebuild, stamped into frames so stale + /// ones are detectable ([`DecodedVkFrame::generation`]). + generation: u64, + device_lost: bool, +} + +impl VkH264Decoder { + /// 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. + pub unsafe fn new( + handles: &DeviceHandles, + lock: Box, + ) -> Result { + // SAFETY: forwarded caller contract. + let dev = unsafe { DecodeDevice::wrap(handles)? }; + Ok(Self { + dev, + lock, + planner: H264Planner::new(), + caps: None, + state: None, + pending_frames: BTreeMap::new(), + ready: VecDeque::new(), + generation: 0, + device_lost: false, + }) + } + + /// Decode one access unit. Returns the next display-ready frame, if the + /// planner declared one (zero-reorder streams: the AU's own picture). + /// + /// 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, 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, VkDecodeError> { + let plan = self.planner.plan_au(au)?; + for warning in &plan.warnings { + // Concealment wiring (want_keyframe) is WP-C's; never silent though. + trace!(?warning, "plan warning"); + } + + self.ensure_state(&plan)?; + let sps_id = plan.sps.seq_parameter_set_id; + + // Convert, with ONE rebuild retry on CapacityMismatch — the designed + // trigger for a DPB-depth renegotiation (pic.rs docs). + let mut vk_plan: Option = 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. Recreate is a + // parameter-set content change under a stable id — rare enough (an + // encoder reconfiguration) that the stall is the right trade. + if self + .state + .as_ref() + .expect("ensure_state built it") + .session + .parameters_action(&plan.sps, &plan.pps) + == ParamsAction::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(&plan.sps, &plan.pps)? }; + match plan_to_vk(&plan, &mut state.slots, sps_id) { + Ok(converted) => { + vk_plan = Some(converted); + break; + } + Err(PlanToVkError::CapacityMismatch { required, capacity }) if attempt == 0 => { + debug!( + required, + capacity, "DPB depth renegotiated — rebuilding session" + ); + self.rebuild_state(&plan)?; + } + Err(PlanToVkError::Slot(SlotError::AllPinned { free })) => { + // Every free slot backs an unreleased frame. A GPU drain + // first (bounded — it costs nothing on this error path and + // rules out any in-flight hold), but consumer pins clear + // ONLY via release_frame, so the verdict stands: explicit + // backpressure. The AU was planned but not decoded; the + // caller releases frames and requests recovery. Unreachable + // under the one-in/one-out release loop. + debug!(free, "no unpinned DPB slot — release_frame owed"); + self.drain_gpu()?; + return Err(VkDecodeError::NoFreeSlot); + } + Err(e) => return Err(VkDecodeError::Convert(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() + ))); + } + + // Output slot: the setup slot itself (coincide — output IS the DPB + // picture, and the pin layer above already guaranteed it backs no live + // frame) or the next FREE ring slot (distinct — slots with live frames + // are skipped; all-busy is the same backpressure verdict as AllPinned). + let out_slot = if state.pool.coincide { + usize::from(vk_plan.setup_slot) + } else { + let ring_len = state.pool.outputs.len(); + let mut chosen = None; + for _ in 0..ring_len { + let candidate = state.out_cursor; + state.out_cursor = (state.out_cursor + 1) % ring_len; + if state.live_frames[candidate] == 0 { + chosen = Some(candidate); + break; + } + } + match chosen { + Some(slot) => slot, + None => { + debug!("every output-ring slot backs an unreleased frame"); + self.drain_gpu()?; + return Err(VkDecodeError::NoFreeSlot); + } + } + }; + let state = self.state.as_mut().expect("ensured above"); + // The op slot's command buffer + query must not still be in flight, and + // (distinct mode) neither may the output image. + let prev = ( + state.pool.outputs[out_slot].semaphore, + state.pool.outputs[out_slot].value, + ); + // SAFETY: live device; the semaphore is the pool's own. + unsafe { wait_timeline(self.dev.ash(), prev.0, prev.1, "output slot reuse")? }; + + // Upload the AU (recycles/grows against the same timeline facts). + let device = self.dev.ash().clone(); + let mut poll = |token: &(vk::Semaphore, u64)| -> Result { + // 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") } + }; + // SAFETY: live device; the pending tokens cover their slots' GPU reads by + // construction (every submit signals its output slot's semaphore and marks + // its bitstream slot with that pair). + let upload = unsafe { state.ring.upload(&self.dev, au, &mut poll, &mut wait)? }; + + // Record + submit, signalling the output slot's next timeline value. + let signal_value = state.pool.outputs[out_slot].value + 1; + // SAFETY: live device; every handle recorded below belongs to this + // session generation, and the AU sits uploaded in the ring slot. + unsafe { + record_and_submit( + &self.dev, + &*self.lock, + state, + &vk_plan, + &upload, + out_slot, + signal_value, + )?; + } + state.pool.outputs[out_slot].value = signal_value; + state.ring.pending.set_pending( + upload.slot, + (state.pool.outputs[out_slot].semaphore, signal_value), + ); + + // Refresh the per-slot reference cache from this AU's facts. + state.slot_refs[usize::from(vk_plan.setup_slot)] = Some(vk_plan.setup_ref); + for r in &vk_plan.refs { + state.slot_refs[usize::from(r.slot)] = Some(r.std); + } + + // Frame bookkeeping: the decoded picture waits for its output verdict, + // and counts as LIVE over its slot from this moment (two-phase release: + // the slot is reusable only after the DPB removed the picture AND + // release_frame ran / the frame was dropped internally). + let out = &state.pool.outputs[out_slot]; + let frame = DecodedVkFrame { + image: out.image, + view: out.view, + plane_views: out.plane_views, + layer: out.layer, + layout: if state.pool.coincide { + vk::ImageLayout::VIDEO_DECODE_DPB_KHR + } else { + vk::ImageLayout::VIDEO_DECODE_DST_KHR + }, + coded_width: plan.picture.coded_width, + coded_height: plan.picture.coded_height, + crop: plan.picture.display_crop, + semaphore: out.semaphore, + value: signal_value, + poc: plan.picture.pic_order_cnt, + is_idr: plan.picture.is_idr, + query_slot: out_slot as u32, + generation: self.generation, + }; + state.note_frame_live(out_slot); + self.pending_frames.insert(vk_plan.setup_id, frame); + + // The plan's DPB verdicts over the pending map: outputs become ready, + // removed-but-never-output ids (no_output_of_prior_pics_flag discards) + // are DROPPED — releasing their slots, not leaking their frames. + let (ready, dropped) = settle_dpb(&mut self.pending_frames, &plan.dpb); + for frame in ready { + self.ready.push_back(frame); + } + for frame in dropped { + debug!( + poc = frame.poc, + slot = frame.query_slot, + "picture removed without output — dropping its frame" + ); + state.note_frame_dead(frame.query_slot as usize); + } + Ok(self.ready.pop_front()) + } + + /// Hand a delivered frame back: its slot becomes reusable (once the planner's + /// DPB has also removed the picture). Every frame `decode`/`take_ready` + /// returns MUST come back through here exactly once — until then its image is + /// protected from every decode target and the pipeline eventually reports + /// [`VkDecodeError::NoFreeSlot`] instead of overwriting it. + pub fn release_frame(&mut self, frame: &DecodedVkFrame) -> Result<(), VkDecodeError> { + if frame.generation != self.generation { + // The pools this frame indexed are gone; there is nothing to release. + return Err(VkDecodeError::StaleFrame { + frame_generation: frame.generation, + current_generation: self.generation, + }); + } + let Some(state) = &mut self.state else { + return Err(VkDecodeError::StaleFrame { + frame_generation: frame.generation, + current_generation: self.generation, + }); + }; + let slot = frame.query_slot as usize; + if slot >= state.live_frames.len() { + return Err(VkDecodeError::StaleFrame { + frame_generation: frame.generation, + current_generation: self.generation, + }); + } + state.note_frame_dead(slot); + Ok(()) + } + + /// A display-ready frame beyond the one `decode` returned, if any (only + /// non-empty around discontinuities/flushes — the punktfunk envelope is + /// zero-reorder). + pub fn take_ready(&mut self) -> Option { + self.ready.pop_front() + } + + /// Read `frame`'s decode status WITHOUT waiting. + /// + /// [`DecodeStatus::Failed`] covers driver-reported errors AND a query slot + /// recycled before it was read (the status is then unprovable — same + /// conservative verdict), so poll before the pipeline wraps a ring. + pub fn poll_status(&mut self, frame: &DecodedVkFrame) -> DecodeStatus { + self.read_status(frame, false) + } + + /// [`Self::poll_status`], but WAITs for the op to complete first — the only + /// place a status read blocks (the GPU smoke test's assertion path; WP-C's + /// steady state polls). + 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 slot = frame.query_slot as usize; + if slot >= state.pool.outputs.len() || state.pool.outputs[slot].value != frame.value { + trace!( + slot, + "status query slot recycled 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 + // output ring it is sized to). + let result = unsafe { + self.dev.ash().get_query_pool_results( + state.ops.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 + } + } + } + + /// Drain the planner (teardown / stream discontinuity): every buffered + /// picture becomes display-ready via [`Self::take_ready`] (those frames stay + /// live until released), all DPB slots free, and any picture removed without + /// ever reaching output has its frame dropped and its slot un-counted. + pub fn flush(&mut self) { + let update = self.planner.flush(); + let (ready, dropped) = settle_dpb(&mut self.pending_frames, &update); + if let Some(state) = &mut self.state { + state.slots.apply(&update); + for frame in &dropped { + state.note_frame_dead(frame.query_slot as usize); + } + // Defensive: a pending frame neither output nor removed should not + // exist (flush drains everything); un-count any leftover. + for (_, frame) in std::mem::take(&mut self.pending_frames) { + debug!(poc = frame.poc, "pending frame survived a flush — dropped"); + state.note_frame_dead(frame.query_slot as usize); + } + } else { + self.pending_frames.clear(); + } + for frame in ready { + self.ready.push_back(frame); + } + } + + /// 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`'s `CapacityMismatch` (the designed trigger) + /// and take the same rebuild path. + fn ensure_state(&mut self, plan: &AuPlan) -> Result<(), VkDecodeError> { + let std_profile = std_profile_for(plan)?; + if self.caps.as_ref().map(|(p, _)| *p) != Some(std_profile) { + // SAFETY: live device (constructor contract). + let raw = + unsafe { query_h264_caps(&self.dev, std_profile) }.map_err(VkDecodeError::from)?; + self.caps = Some((std_profile, derive_caps(&raw)?)); + } + // The level gate: a stream above the device's maxLevelIdc is refused up + // front (Std code points ascend with the level, so the comparison is + // numeric), never submitted on a hope. + let caps_max_level = self.caps.as_ref().expect("queried above").1.max_level_idc; + let stream_level = level_to_std(plan.picture.level_idc); + if stream_level > caps_max_level { + return Err(VkDecodeError::Unsupported(format!( + "stream level (Std code point {stream_level}) above the device's \ + maxLevelIdc ({caps_max_level})" + ))); + } + let coded = vk::Extent2D { + width: plan.picture.coded_width, + height: plan.picture.coded_height, + }; + match &self.state { + // Compared against the STREAM's coded extent (the pool tracks it + // beside its granularity-rounded image extent). + Some(state) + if state.pool.coded_extent == coded + && state.session.config.std_profile_idc == std_profile => + { + Ok(()) + } + _ => self.rebuild_state(plan), + } + } + + /// Tear down the current session generation (draining its GPU work) and build + /// a fresh one shaped by `plan`, bumping [`Self::generation`] so frames of the + /// old one are detectably stale. + fn rebuild_state(&mut self, plan: &AuPlan) -> Result<(), VkDecodeError> { + self.drain_gpu()?; + if self.state.is_some() { + debug!("rebuilding decode session (stream renegotiation)"); + } + // Frames referencing the old pools die with them (their generation stamp + // makes any copy the consumer still holds report stale, never read). + if !self.pending_frames.is_empty() || !self.ready.is_empty() { + debug!( + pending = self.pending_frames.len(), + ready = self.ready.len(), + "dropping undelivered frames across a session rebuild" + ); + self.pending_frames.clear(); + self.ready.clear(); + } + self.state = None; + self.generation += 1; + + let (std_profile, caps) = self.caps.as_ref().expect("ensure_state queried caps"); + let std_profile = *std_profile; + let dpb_slots = plan.picture.max_dpb_frames as u32 + 1; + if dpb_slots > caps.max_dpb_slots { + return Err(VkDecodeError::Unsupported(format!( + "stream needs {dpb_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 = SessionConfig { + max_coded_extent: image_extent, + max_dpb_slots: dpb_slots, + max_active_references: (dpb_slots - 1).min(caps.max_active_references), + std_profile_idc: std_profile, + }; + let pool_plan = plan_pools(caps, dpb_slots, OUTPUT_RING); + // 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 = VideoSession::create(&self.dev, caps, config)?; + let pool = ImagePool::create(&self.dev, caps, &pool_plan, coded, std_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, + ), + std_profile, + ) + .map_err(VkDecodeError::from)?; + let ops = OpRing::create(&self.dev, std_profile, pool_plan.output_slots) + .map_err(VkDecodeError::from)?; + SessionState { + session, + slots: SlotMap::new(plan.picture.max_dpb_frames), + slot_refs: vec![None; dpb_slots as usize], + live_frames: vec![0; pool_plan.output_slots as usize], + pool, + ring, + ops, + out_cursor: 0, + } + }; + self.state = Some(state); + Ok(()) + } + + /// Wait out every in-flight decode of the current session generation. + fn drain_gpu(&mut self) -> Result<(), VkDecodeError> { + let Some(state) = &self.state else { + return Ok(()); + }; + for out in &state.pool.outputs { + // SAFETY: live device; pool-owned semaphore. + unsafe { wait_timeline(self.dev.ash(), out.semaphore, out.value, "session drain")? }; + } + Ok(()) + } +} + +impl Drop for VkH264Decoder { + fn drop(&mut self) { + // Best-effort drain so the pools' Drop impls never destroy in-flight + // objects; a wedged driver falls through after the bounded timeout (the + // destroys then race the GPU, but the alternative is hanging teardown + // forever — same trade the encoder's fence budget makes). + if let Err(e) = self.drain_gpu() { + debug!(error = %e, "drain on drop failed; tearing down anyway"); + } + } +} + +/// Map the plan's `profile_idc` to the Std code point (identity for the four +/// Vulkan-representable profiles, reject otherwise — WP-A's exact rule). +fn std_profile_for(plan: &AuPlan) -> Result { + match u32::from(plan.picture.profile_idc) { + p @ (66 | 77 | 100 | 244) => Ok(p), + _ => Err(VkDecodeError::Params(ParamsError::UnmappableProfileIdc( + plan.picture.profile_idc, + ))), + } +} + +/// Split one [`DpbUpdate`]'s verdicts over the pending-frame map: `outputs` (in +/// bump order) become ready; `removed` ids that never reached output — an IDR's +/// `no_output_of_prior_pics_flag` discard, or a flush racing a drop — are +/// returned separately so the caller releases their slots instead of leaking +/// them in the map forever. Pure and generic for testability. +fn settle_dpb(pending: &mut BTreeMap, dpb: &DpbUpdate) -> (Vec, Vec) { + let mut ready = Vec::new(); + for id in &dpb.outputs { + match pending.remove(id) { + Some(frame) => ready.push(frame), + // Ids planned before this decoder existed (post-recovery), or + // dropped across a rebuild: display-order gaps, not errors. + None => trace!(id, "output id without a pending frame"), + } + } + let dropped = dpb + .removed + .iter() + .filter_map(|id| pending.remove(id)) + .collect(); + (ready, dropped) +} + +/// Bounded timeline wait (no-op for the never-signalled value 0). +/// +/// # Safety +/// +/// `device` is live and `semaphore` is a live timeline semaphore on it. +unsafe fn wait_timeline( + device: &ash::Device, + semaphore: vk::Semaphore, + value: u64, + what: &'static str, +) -> Result<(), VkDecodeError> { + if value == 0 { + return Ok(()); + } + let semaphores = [semaphore]; + let values = [value]; + let info = vk::SemaphoreWaitInfo::default() + .semaphores(&semaphores) + .values(&values); + // SAFETY: fn contract; the info arrays are locals outliving the call. + match unsafe { device.wait_semaphores(&info, DECODE_TIMEOUT_NS) } { + Ok(()) => Ok(()), + Err(vk::Result::TIMEOUT) => Err(VkDecodeError::Timeout(what)), + Err(e) => Err(VkDecodeError::from(e)), + } +} + +/// Record one decode op into the out-slot's command buffer and submit it under +/// the queue lock with the timeline signal. +/// +/// # Safety +/// +/// Live device; `state` is the current session generation with `vk_plan` derived +/// against its `SlotMap` and the AU resident in `upload`'s ring slot; the out +/// slot's previous use has completed (caller waited its timeline value). +#[allow(clippy::too_many_arguments)] +unsafe fn record_and_submit( + dev: &DecodeDevice, + lock: &dyn QueueLock, + state: &mut SessionState, + vk_plan: &DecodePlanVk, + upload: &UploadedAu, + out_slot: usize, + signal_value: u64, +) -> Result<(), VkDecodeError> { + let device = dev.ash(); + let cmd = state.ops.cmds[out_slot]; + let coded_extent = state.pool.coded_extent; + + 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, + )]; + // The setup target layer is fully overwritten: discard via UNDEFINED, with an + // execution+memory dependency on earlier ops that touched the layer. + 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 (setup_image, setup_layer) = state.pool.dpb_target(vk_plan.setup_slot); + let mut image_barriers = vec![decode_layer_barrier( + setup_image, + setup_layer, + vk::ImageLayout::VIDEO_DECODE_DPB_KHR, + )]; + if !state.pool.coincide { + let out = &state.pool.outputs[out_slot]; + image_barriers.push(decode_layer_barrier( + out.image, + out.layer, + 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). + // SAFETY: recording; the pool is sized to the output ring (fn contract). + unsafe { device.cmd_reset_query_pool(cmd, state.ops.query_pool, out_slot as u32, 1) }; + + // ---- bound-slot staging ---- + // Scope list: this AU's references first, then every other still-held slot + // (their resources must stay bound for their associations to persist), then + // the setup slot as the ACTIVATION entry (slot index -1 binds its resource + // without a current association; the decode op's setup slot then claims it). + let mut scope: Vec<(i32, u8, hh::StdVideoDecodeH264ReferenceInfo)> = Vec::new(); + for r in &vk_plan.refs { + scope.push((i32::from(r.slot), r.slot, r.std)); + } + for (slot, _id) in state.slots.held() { + if slot == vk_plan.setup_slot || scope.iter().any(|(_, s, _)| *s == slot) { + continue; + } + match state.slot_refs[usize::from(slot)] { + Some(std) => scope.push((i32::from(slot), slot, std)), + // Unreachable in practice: every held slot was a setup slot once. + None => trace!( + slot, + "held slot without cached reference info — left unbound" + ), + } + } + let reference_count = vk_plan.refs.len(); + scope.push((-1, vk_plan.setup_slot, vk_plan.setup_ref)); + + // Staged arrays: 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> = scope + .iter() + .map(|&(_, slot, _)| { + vk::VideoPictureResourceInfoKHR::default() + .coded_extent(coded_extent) + .base_array_layer(0) + .image_view_binding(state.pool.dpb_view(slot)) + }) + .collect(); + let std_refs: Vec = + scope.iter().map(|&(_, _, std)| std).collect(); + let mut dpb_infos: Vec> = std_refs + .iter() + .map(|std| vk::VideoDecodeH264DpbSlotInfoKHR::default().std_reference_info(std)) + .collect(); + let mut begin_slots: Vec> = Vec::with_capacity(scope.len()); + for (index, &(slot_index, _, _)) in scope.iter().enumerate() { + begin_slots.push( + vk::VideoReferenceSlotInfoKHR::default() + .slot_index(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 (the first + // `reference_count` scope entries, which carry their real slot indices). + let decode_refs: Vec> = + 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::VideoDecodeH264DpbSlotInfoKHR::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 output image. + let dst_resource = if state.pool.coincide { + setup_resource + } else { + vk::VideoPictureResourceInfoKHR::default() + .coded_extent(coded_extent) + .base_array_layer(0) + .image_view_binding(state.pool.outputs[out_slot].view) + }; + + let std_pic = vk_plan.std_pic; + let mut h264_pic = vk::VideoDecodeH264PictureInfoKHR::default() + .std_picture_info(&std_pic) + .slice_offsets(&vk_plan.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 h264_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); + } + device.cmd_begin_query( + cmd, + state.ops.query_pool, + out_slot as u32, + vk::QueryControlFlags::empty(), + ); + (dev.video_decode_queue().fp().cmd_decode_video_khr)(cmd, &decode_info); + device.cmd_end_query(cmd, state.ops.query_pool, out_slot as u32); + (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 signals = [vk::SemaphoreSubmitInfo::default() + .semaphore(state.pool.outputs[out_slot].semaphore) + .value(signal_value) + .stage_mask(vk::PipelineStageFlags2::ALL_COMMANDS)]; + let submits = [vk::SubmitInfo2::default() + .command_buffer_infos(&cmd_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 super::*; + + #[test] + fn settle_dpb_readies_outputs_in_order_and_returns_never_output_removals() { + let mut pending: BTreeMap = BTreeMap::new(); + pending.insert(1, 100); + pending.insert(2, 200); + pending.insert(3, 300); + + // Picture 1 outputs (and is also removed — the normal bump); picture 2 + // is removed WITHOUT ever reaching output (no_output_of_prior_pics): + // its frame must come back as dropped, not leak in the map. + let update = DpbUpdate { + stored: Some(3), + outputs: vec![1], + removed: vec![1, 2], + }; + let (ready, dropped) = settle_dpb(&mut pending, &update); + assert_eq!(ready, vec![100]); + assert_eq!(dropped, vec![200]); + assert_eq!( + pending.keys().copied().collect::>(), + vec![3], + "the still-buffered picture stays pending" + ); + + // Output order is bump order, and unknown ids are tolerated. + let mut pending: BTreeMap = BTreeMap::new(); + pending.insert(5, 500); + pending.insert(4, 400); + let update = DpbUpdate { + stored: None, + outputs: vec![5, 99, 4], + removed: vec![], + }; + let (ready, dropped) = settle_dpb(&mut pending, &update); + assert_eq!(ready, vec![500, 400], "bump order, not id order"); + assert!(dropped.is_empty()); + } + + #[test] + fn std_level_code_points_ascend_so_the_max_level_gate_compares_numerically() { + use pf_bitstream::h264::Level; + // The gate is `level_to_std(stream) > caps.max_level_idc`; that is only + // sound if the Std code points ascend with the level. Pin the ordering + // across the range (and the 1b fold onto 1.1). + let ascending = [ + Level::L1, + Level::L1_1, + Level::L2_0, + Level::L3_1, + Level::L4, + Level::L4_2, + Level::L5_2, + Level::L6_2, + ]; + for pair in ascending.windows(2) { + assert!( + level_to_std(pair[0]) < level_to_std(pair[1]), + "{:?} vs {:?}", + pair[0], + pair[1] + ); + } + assert_eq!(level_to_std(Level::L1B), level_to_std(Level::L1_1)); + + // The gate itself, on both sides of a ceiling. + let max = level_to_std(Level::L4_1); + assert!( + level_to_std(Level::L4) <= max, + "within the ceiling: allowed" + ); + assert!( + level_to_std(Level::L4_2) > max, + "above the ceiling: Unsupported" + ); + } +} diff --git a/crates/pf-vkdecode/src/device.rs b/crates/pf-vkdecode/src/device.rs new file mode 100644 index 00000000..f0b1acbe --- /dev/null +++ b/crates/pf-vkdecode/src/device.rs @@ -0,0 +1,395 @@ +//! Borrowed-device wrap: the presenter's live Vulkan handles loaded into ash +//! function tables, plus the queue-lock contract every queue submission runs under. +//! +//! Ownership: everything in [`DeviceHandles`] is BORROWED. This crate never creates +//! and never destroys the instance/device — [`DecodeDevice`]'s ash wrappers are +//! function tables over foreign handles, and dropping them destroys nothing. The +//! objects this crate does create (sessions, images, buffers, pools) are destroyed +//! by their owning structs' `Drop` impls, all of which must run before the borrowed +//! device dies — the same liveness contract FFmpeg's decoder had over the identical +//! handle bundle (`pf-client-core`'s `VulkanDecodeDevice`), now written down. + +use ash::vk; +use ash::vk::Handle; + +/// The borrowed handles of the presenter's decode-capable device, as raw integers so +/// the type stays FFI-plain (mirrors `pf-client-core`'s `VulkanDecodeDevice`, which +/// adapts into this in WP-C — pf-vkdecode deliberately does not depend on it). +/// +/// Caller contract (checked where cheap, otherwise trusted): +/// - All four handles are live, and stay live for the lifetime of every object this +/// crate builds from them (the presenter outlives every session pump). +/// - The instance/device were created with the Vulkan Video decode stack enabled: +/// `VK_KHR_video_queue`, `VK_KHR_video_decode_queue`, `VK_KHR_video_decode_h264`, +/// plus the `synchronization2` and `timelineSemaphore` features (the presenter's +/// device meets all of this when it advertises `video_decode`). +/// - `decode_qf`/`decode_queue_index` name a queue with `VIDEO_DECODE_KHR` ops whose +/// family advertises H.264 decode; `graphics_qf` is the family the presenter +/// samples on (image sharing crosses the two when they differ). +#[derive(Debug, Clone)] +pub struct DeviceHandles { + /// `PFN_vkGetInstanceProcAddr` from the loader; everything else is resolved + /// through it. + pub get_instance_proc_addr: usize, + pub instance: usize, + pub physical_device: usize, + pub device: usize, + /// The video-decode queue family. + pub decode_qf: u32, + /// Queue index within `decode_qf` this decoder submits on. + pub decode_queue_index: u32, + /// The presenter's graphics+present family (the other side of image sharing). + pub graphics_qf: u32, +} + +/// External synchronization for `vkQueueSubmit`: the caller supplies the lock that +/// serializes EVERY submit on the shared device — in WP-C that is pf-client-core's +/// `QueueLock`, the same object the presenter holds around its own submits/presents +/// (the 2026-07-09 `VK_ERROR_DEVICE_LOST` race is why this is a first-class contract +/// and not an afterthought). Tests use [`NoopQueueLock`]. +/// +/// `lock` blocks until the queue is free and takes it; `unlock` releases it. Use +/// [`QueueSubmitGuard`] rather than calling the pair by hand. +pub trait QueueLock { + fn lock(&self); + fn unlock(&self); +} + +/// A [`QueueLock`] that guards nothing — for tests and for callers whose decode +/// queue is provably not shared with any other submitter. +#[derive(Debug, Default)] +pub struct NoopQueueLock; + +impl QueueLock for NoopQueueLock { + fn lock(&self) {} + fn unlock(&self) {} +} + +/// RAII scope over a [`QueueLock`]: acquired for exactly the duration of a queue +/// submission, released on drop (including unwinds — though this crate's own paths +/// never panic while holding it). +pub struct QueueSubmitGuard<'a> { + lock: &'a dyn QueueLock, +} + +impl<'a> QueueSubmitGuard<'a> { + /// Take the queue (blocking until free). + pub fn acquire(lock: &'a dyn QueueLock) -> Self { + lock.lock(); + Self { lock } + } +} + +impl Drop for QueueSubmitGuard<'_> { + fn drop(&mut self) { + self.lock.unlock(); + } +} + +/// A [`DeviceHandles`] bundle that cannot be wrapped. Every variant is a caller +/// bug (a half-filled bundle), not a runtime condition. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeviceError { + /// One of the four raw handles is zero/null. + NullHandle(&'static str), +} + +/// A device allocation that cannot proceed. Wraps the raw Vulkan failure OR the +/// memory-type miss that used to be silently papered over with index 0 — a wrong +/// type index is at best an immediate validation error and at worst a mapping of +/// the wrong heap, so a miss is an ERROR here, never a fallback. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AllocError { + Vk(vk::Result), + /// No memory type satisfies (`type_bits`, `flags`) on this device. + NoMemoryType { + type_bits: u32, + flags: vk::MemoryPropertyFlags, + }, +} + +impl From for AllocError { + fn from(r: vk::Result) -> Self { + AllocError::Vk(r) + } +} + +/// First memory type matching `bits` and `want` — an [`AllocError::NoMemoryType`] +/// when none does (the encoder's `find_mem` falls back to 0 there; here the miss +/// surfaces). +pub(crate) fn find_memory_type( + props: &vk::PhysicalDeviceMemoryProperties, + bits: u32, + want: vk::MemoryPropertyFlags, +) -> Result { + for i in 0..props.memory_type_count { + if (bits & (1 << i)) != 0 && props.memory_types[i as usize].property_flags.contains(want) { + return Ok(i); + } + } + Err(AllocError::NoMemoryType { + type_bits: bits, + flags: want, + }) +} + +impl std::fmt::Display for DeviceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DeviceError::NullHandle(which) => { + write!(f, "DeviceHandles.{which} is null — a half-filled bundle") + } + } + } +} + +impl std::error::Error for DeviceError {} + +/// The borrowed device with ash function tables loaded: the object every other +/// module in this crate makes its Vulkan calls through. +/// +/// Clone is cheap-ish (ash tables are plain structs of function pointers) and safe: +/// clones share the same borrowed handles under the same liveness contract. +#[derive(Clone)] +pub struct DecodeDevice { + instance: ash::Instance, + device: ash::Device, + physical_device: vk::PhysicalDevice, + video_queue_instance: ash::khr::video_queue::Instance, + video_queue: ash::khr::video_queue::Device, + video_decode_queue: ash::khr::video_decode_queue::Device, + decode_queue: vk::Queue, + decode_qf: u32, + graphics_qf: u32, +} + +impl DecodeDevice { + /// Load ash function tables over the borrowed handles. + /// + /// # Safety + /// + /// The full [`DeviceHandles`] caller contract: live handles (outliving `self` + /// and everything created through it), the video-decode extensions/features + /// enabled at creation, and truthful queue-family fields. Null handles are + /// rejected here; everything else cannot be checked and is trusted. + pub unsafe fn wrap(handles: &DeviceHandles) -> Result { + if handles.get_instance_proc_addr == 0 { + return Err(DeviceError::NullHandle("get_instance_proc_addr")); + } + if handles.instance == 0 { + return Err(DeviceError::NullHandle("instance")); + } + if handles.physical_device == 0 { + return Err(DeviceError::NullHandle("physical_device")); + } + if handles.device == 0 { + return Err(DeviceError::NullHandle("device")); + } + + // SAFETY: the usize is non-zero (checked above) and the caller contract says + // it is the loader's PFN_vkGetInstanceProcAddr; fn pointers and usize share + // size/ABI on every supported target. + let gipa: vk::PFN_vkGetInstanceProcAddr = unsafe { + std::mem::transmute::( + handles.get_instance_proc_addr, + ) + }; + // SAFETY: `gipa` is a valid Vulkan-1.0-conformant loader entry point per the + // caller contract, valid for the returned Entry's lifetime (handle liveness). + let entry = unsafe { + ash::Entry::from_static_fn(ash::StaticFn { + get_instance_proc_addr: gipa, + }) + }; + // SAFETY: `handles.instance` is a live VkInstance created through this very + // loader (caller contract), so loading instance-level functions against it + // is exactly the ash::Instance::load contract. + let instance = unsafe { + ash::Instance::load( + entry.static_fn(), + vk::Instance::from_raw(handles.instance as u64), + ) + }; + // SAFETY: `handles.device` is a live VkDevice of that instance (caller + // contract) — the ash::Device::load contract. + let device = unsafe { + ash::Device::load( + instance.fp_v1_0(), + vk::Device::from_raw(handles.device as u64), + ) + }; + let video_queue_instance = ash::khr::video_queue::Instance::new(&entry, &instance); + let video_queue = ash::khr::video_queue::Device::new(&instance, &device); + let video_decode_queue = ash::khr::video_decode_queue::Device::new(&instance, &device); + // SAFETY: the caller contract guarantees `decode_qf`/`decode_queue_index` + // name a queue the device was created with. + let decode_queue = + unsafe { device.get_device_queue(handles.decode_qf, handles.decode_queue_index) }; + // `entry` is only the ladder the tables above were loaded through; nothing + // needs it afterwards (ash tables own their function pointers). + drop(entry); + + Ok(Self { + instance, + device, + physical_device: vk::PhysicalDevice::from_raw(handles.physical_device as u64), + video_queue_instance, + video_queue, + video_decode_queue, + decode_queue, + decode_qf: handles.decode_qf, + graphics_qf: handles.graphics_qf, + }) + } + + pub(crate) fn ash(&self) -> &ash::Device { + &self.device + } + + pub(crate) fn physical_device(&self) -> vk::PhysicalDevice { + self.physical_device + } + + pub(crate) fn video_queue_instance(&self) -> &ash::khr::video_queue::Instance { + &self.video_queue_instance + } + + pub(crate) fn video_queue(&self) -> &ash::khr::video_queue::Device { + &self.video_queue + } + + pub(crate) fn video_decode_queue(&self) -> &ash::khr::video_decode_queue::Device { + &self.video_decode_queue + } + + pub(crate) fn decode_queue(&self) -> vk::Queue { + self.decode_queue + } + + pub(crate) fn decode_qf(&self) -> u32 { + self.decode_qf + } + + /// The queue families image sharing spans: empty (EXCLUSIVE) when decode and + /// graphics are one family, both otherwise (CONCURRENT — the presenter samples + /// decode output on its own family and per-frame ownership transfers would buy + /// latency for nothing at punktfunk's frame rates). + pub(crate) fn sharing_families(&self) -> Vec { + if self.decode_qf == self.graphics_qf { + Vec::new() + } else { + vec![self.decode_qf, self.graphics_qf] + } + } + + /// The device's memory properties (queried fresh; cheap and stateless). + pub(crate) fn memory_properties(&self) -> vk::PhysicalDeviceMemoryProperties { + // SAFETY: `physical_device` is live per the DeviceHandles contract; the call + // fills a plain struct and touches nothing else. + unsafe { + self.instance + .get_physical_device_memory_properties(self.physical_device) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_half_filled_bundle_is_rejected_before_any_ffi() { + let mut handles = DeviceHandles { + get_instance_proc_addr: 0, + instance: 1, + physical_device: 1, + device: 1, + decode_qf: 0, + decode_queue_index: 0, + graphics_qf: 0, + }; + // SAFETY: wrap rejects the null handle before making any Vulkan call, so no + // part of the liveness contract is exercised. (`Err` matched by hand: the + // Ok side holds ash tables, which carry no Debug for unwrap_err.) + let result = unsafe { DecodeDevice::wrap(&handles) }; + let Err(err) = result else { + panic!("a null gipa must be rejected") + }; + assert_eq!(err, DeviceError::NullHandle("get_instance_proc_addr")); + + handles.get_instance_proc_addr = 1; + handles.device = 0; + // SAFETY: as above — the null device handle is rejected before any FFI. + let result = unsafe { DecodeDevice::wrap(&handles) }; + let Err(err) = result else { + panic!("a null device must be rejected") + }; + assert_eq!(err, DeviceError::NullHandle("device")); + } + + #[test] + fn a_memory_type_miss_is_an_error_never_a_fallback_to_index_zero() { + let mut props = vk::PhysicalDeviceMemoryProperties { + memory_type_count: 2, + ..Default::default() + }; + props.memory_types[0].property_flags = vk::MemoryPropertyFlags::DEVICE_LOCAL; + props.memory_types[1].property_flags = + vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT; + + // A hit resolves to the matching index, not the first. + assert_eq!( + find_memory_type( + &props, + 0b11, + vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT + ), + Ok(1) + ); + // A type excluded by the requirement bits does not count as a hit. + assert_eq!( + find_memory_type(&props, 0b01, vk::MemoryPropertyFlags::HOST_VISIBLE), + Err(AllocError::NoMemoryType { + type_bits: 0b01, + flags: vk::MemoryPropertyFlags::HOST_VISIBLE + }) + ); + // Flags nothing advertises: an error carrying the miss, never index 0. + assert_eq!( + find_memory_type(&props, 0b11, vk::MemoryPropertyFlags::PROTECTED), + Err(AllocError::NoMemoryType { + type_bits: 0b11, + flags: vk::MemoryPropertyFlags::PROTECTED + }) + ); + } + + #[test] + fn the_queue_submit_guard_brackets_the_lock() { + use std::sync::atomic::AtomicI32; + use std::sync::atomic::Ordering; + + #[derive(Default)] + struct CountingLock { + depth: AtomicI32, + peak: AtomicI32, + } + impl QueueLock for CountingLock { + fn lock(&self) { + let d = self.depth.fetch_add(1, Ordering::SeqCst) + 1; + self.peak.fetch_max(d, Ordering::SeqCst); + } + fn unlock(&self) { + self.depth.fetch_sub(1, Ordering::SeqCst); + } + } + + let lock = CountingLock::default(); + { + let _guard = QueueSubmitGuard::acquire(&lock); + assert_eq!(lock.depth.load(Ordering::SeqCst), 1); + } + assert_eq!(lock.depth.load(Ordering::SeqCst), 0, "released on drop"); + assert_eq!(lock.peak.load(Ordering::SeqCst), 1); + } +} diff --git a/crates/pf-vkdecode/src/images.rs b/crates/pf-vkdecode/src/images.rs new file mode 100644 index 00000000..89491e64 --- /dev/null +++ b/crates/pf-vkdecode/src/images.rs @@ -0,0 +1,533 @@ +//! DPB + decode-output image pools, caps-driven for BOTH DPB arrangements: +//! +//! - **coincide** (`DPB_AND_OUTPUT_COINCIDE`, RADV's shape): the decode output IS +//! the DPB picture — one pool, every slot usable both as setup/reference and as +//! the frame handed to the presenter. +//! - **distinct** (NVIDIA's shape): a reference-only DPB pool plus a small ring of +//! output images the decoder writes `dst` into. +//! +//! Within either mode the DPB is **layered** (one image, one array layer per slot — +//! mandatory when the driver lacks `SEPARATE_REFERENCE_IMAGES`) or **per-slot** +//! (one image each). [`plan_pools`] is the pure decision table; [`ImagePool`] is +//! the thin Vulkan half. +//! +//! Presenter-facing surfaces (outputs) carry `MUTABLE_FORMAT` (advertised by the +//! driver — [`crate::caps::derive_caps`] refuses otherwise; nothing here aliases, +//! so no `ALIAS`) so per-plane `R8`/`R8G8` views exist for the presenter's +//! sampling path, one TIMELINE semaphore per output slot signals decode +//! completion, and the conformance-window crop rides the frame struct — the +//! 1088-row smear class dies by construction because the consumer is TOLD the +//! crop instead of guessing from the pool shape. Images are allocated at the +//! `pictureAccessGranularity`-rounded extent; the stream's coded extent rides +//! separately for per-picture resources. + +use ash::vk; +use ash::vk::native as hh; + +use crate::caps::DecodeCaps; +use crate::caps::H264ProfileChain; +use crate::caps::COINCIDE_USAGE; +use crate::caps::DPB_USAGE; +use crate::caps::OUTPUT_USAGE; +use crate::device::find_memory_type; +use crate::device::AllocError; +use crate::device::DecodeDevice; + +/// Distinct-mode output ring depth: decode-ahead is one-in/one-out under the +/// punktfunk envelope, so a small ring covers pipelining plus a frame in the +/// presenter's hands. +pub const OUTPUT_RING: u32 = 4; + +/// The pure pool shape for one (caps, slot-count) pair. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PoolPlan { + pub dpb_image_count: u32, + pub dpb_layers_per_image: u32, + pub dpb_usage: vk::ImageUsageFlags, + pub dpb_flags: vk::ImageCreateFlags, + /// 0 in coincide mode — outputs ARE the DPB slots. + pub output_image_count: u32, + pub output_usage: vk::ImageUsageFlags, + pub output_flags: vk::ImageCreateFlags, + /// Semaphore/query/command ring size: DPB slots when coincide, the output + /// ring otherwise. + pub output_slots: u32, +} + +/// Decide the pool shape. Pure — the four caps combinations are unit-tested below. +/// +/// The usages are exactly the ones the caps derivation validated against the +/// driver's advertised envelope ([`DPB_USAGE`]/[`OUTPUT_USAGE`]/[`COINCIDE_USAGE`]); +/// presenter-facing images add only `MUTABLE_FORMAT` (advertised — derive_caps +/// gates on it; no `ALIAS`: nothing aliases these images). +pub fn plan_pools(caps: &DecodeCaps, dpb_slots: u32, output_ring: u32) -> PoolPlan { + let (dpb_image_count, dpb_layers_per_image) = if caps.layered_dpb { + (1, dpb_slots) + } else { + (dpb_slots, 1) + }; + let presented_flags = vk::ImageCreateFlags::MUTABLE_FORMAT; + if caps.coincide { + PoolPlan { + dpb_image_count, + dpb_layers_per_image, + dpb_usage: COINCIDE_USAGE, + dpb_flags: presented_flags, + output_image_count: 0, + output_usage: vk::ImageUsageFlags::empty(), + output_flags: vk::ImageCreateFlags::empty(), + output_slots: dpb_slots, + } + } else { + PoolPlan { + dpb_image_count, + dpb_layers_per_image, + dpb_usage: DPB_USAGE, + dpb_flags: vk::ImageCreateFlags::empty(), + output_image_count: output_ring, + output_usage: OUTPUT_USAGE, + output_flags: presented_flags, + output_slots: output_ring, + } + } +} + +/// One presenter-facing output slot: the image (a DPB slot's in coincide mode, a +/// ring image otherwise), its full + per-plane views, and the timeline semaphore +/// each decode into this slot signals. +pub(crate) struct OutputSlot { + pub image: vk::Image, + /// The image's array layer this slot occupies (barriers target it; the VIEWS + /// already select it, so picture resources use `base_array_layer` 0). + pub layer: u32, + /// Full-picture view in the pool format (what decode binds as `dst`). + pub view: vk::ImageView, + /// `R8_UNORM` / `R8G8_UNORM` plane views for the presenter's sampler path. + pub plane_views: [vk::ImageView; 2], + pub semaphore: vk::Semaphore, + /// Last timeline value signalled on `semaphore` (0 = never used). + pub value: u64, +} + +/// The Vulkan half: images, memory, views, semaphores. Destroys everything it +/// created on drop (null-safe, so a half-built pool from a failed create unwinds +/// cleanly). +pub(crate) struct ImagePool { + device: ash::Device, + pub(crate) coincide: bool, + /// The STREAM's coded extent — what per-picture resources report. + pub(crate) coded_extent: vk::Extent2D, + /// The allocation extent: `coded_extent` rounded up to the device's + /// `pictureAccessGranularity` (images only; never leaks into picture params). + image_extent: vk::Extent2D, + images: Vec, + memory: Vec, + /// Per-DPB-slot full view (setup/reference binding). + dpb_views: Vec, + /// Per-DPB-slot (image index, array layer) for barrier targeting. + dpb_location: Vec<(usize, u32)>, + pub(crate) outputs: Vec, +} + +impl ImagePool { + /// Create the pools for `plan`: images at the granularity-rounded + /// `image_extent`, picture metadata at the stream's `coded_extent`. + /// + /// # Safety + /// + /// `dev` wraps live handles ([`crate::DeviceHandles`] contract). + pub(crate) unsafe fn create( + dev: &DecodeDevice, + caps: &DecodeCaps, + plan: &PoolPlan, + coded_extent: vk::Extent2D, + std_profile_idc: hh::StdVideoH264ProfileIdc, + ) -> Result { + let mut pool = Self { + device: dev.ash().clone(), + coincide: caps.coincide, + coded_extent, + image_extent: caps.aligned_extent(coded_extent), + images: Vec::new(), + memory: Vec::new(), + dpb_views: Vec::new(), + dpb_location: Vec::new(), + outputs: Vec::new(), + }; + // SAFETY: caller's contract; on error `pool` drops and unwinds whatever + // half was built (Drop is null-safe and destroys only owned objects). + unsafe { pool.build(dev, caps, plan, std_profile_idc)? }; + Ok(pool) + } + + /// # Safety + /// + /// As [`Self::create`]. + unsafe fn build( + &mut self, + dev: &DecodeDevice, + caps: &DecodeCaps, + plan: &PoolPlan, + std_profile_idc: hh::StdVideoH264ProfileIdc, + ) -> Result<(), AllocError> { + let families = dev.sharing_families(); + + // DPB images + per-slot views. + for _ in 0..plan.dpb_image_count { + // SAFETY: fn contract (live device). + let (image, memory) = unsafe { + create_video_image( + dev, + caps.dpb_format, + self.image_extent, + plan.dpb_layers_per_image, + plan.dpb_usage, + plan.dpb_flags, + &families, + std_profile_idc, + )? + }; + self.images.push(image); + self.memory.push(memory); + } + let dpb_slots = plan.dpb_image_count * plan.dpb_layers_per_image; + for slot in 0..dpb_slots { + let (image_index, layer) = if plan.dpb_image_count == 1 { + (0usize, slot) + } else { + (slot as usize, 0u32) + }; + // SAFETY: `image` was created above with at least `layer + 1` layers. + let view = unsafe { + create_view( + &self.device, + self.images[image_index], + caps.dpb_format, + vk::ImageAspectFlags::COLOR, + layer, + )? + }; + self.dpb_views.push(view); + self.dpb_location.push((image_index, layer)); + } + + // Output slots: over the DPB slots (coincide) or over a fresh ring. + let output_targets: Vec<(vk::Image, u32)> = if caps.coincide { + self.dpb_location + .iter() + .map(|&(image_index, layer)| (self.images[image_index], layer)) + .collect() + } else { + let mut targets = Vec::new(); + for _ in 0..plan.output_image_count { + // SAFETY: fn contract (live device). + let (image, memory) = unsafe { + create_video_image( + dev, + caps.output_format, + self.image_extent, + 1, + plan.output_usage, + plan.output_flags, + &families, + std_profile_idc, + )? + }; + self.images.push(image); + self.memory.push(memory); + targets.push((image, 0)); + } + targets + }; + + for (image, layer) in output_targets { + // SAFETY: `image` exists with `layer` in range (holds for all four + // creates below); the formats are plane-compatible with the pool + // format (NV12: R8 + R8G8) and the image carries MUTABLE_FORMAT for + // the reinterpreting views. + let view = unsafe { + create_view( + &self.device, + image, + caps.output_format, + vk::ImageAspectFlags::COLOR, + layer, + )? + }; + // SAFETY: as above. + let plane_y = unsafe { + create_view( + &self.device, + image, + vk::Format::R8_UNORM, + vk::ImageAspectFlags::PLANE_0, + layer, + )? + }; + // SAFETY: as above. + let plane_uv = unsafe { + create_view( + &self.device, + image, + vk::Format::R8G8_UNORM, + vk::ImageAspectFlags::PLANE_1, + layer, + )? + }; + let mut type_info = vk::SemaphoreTypeCreateInfo::default() + .semaphore_type(vk::SemaphoreType::TIMELINE) + .initial_value(0); + let sem_ci = vk::SemaphoreCreateInfo::default().push_next(&mut type_info); + // SAFETY: live device; timelineSemaphore is enabled per the + // DeviceHandles feature contract. + let semaphore = unsafe { self.device.create_semaphore(&sem_ci, None)? }; + self.outputs.push(OutputSlot { + image, + layer, + view, + plane_views: [plane_y, plane_uv], + semaphore, + value: 0, + }); + } + Ok(()) + } + + /// The DPB binding view of `slot`. + pub(crate) fn dpb_view(&self, slot: u8) -> vk::ImageView { + self.dpb_views[usize::from(slot)] + } + + /// The image + array layer behind DPB `slot` (barrier targeting). + pub(crate) fn dpb_target(&self, slot: u8) -> (vk::Image, u32) { + let (image_index, layer) = self.dpb_location[usize::from(slot)]; + (self.images[image_index], layer) + } +} + +impl Drop for ImagePool { + fn drop(&mut self) { + // SAFETY: every handle below was created by this pool on this (still-live, + // per the DeviceHandles contract) device; the owning decoder drains GPU + // work before dropping state. vkDestroy*/vkFree ignore NULL handles. + unsafe { + for view in self.dpb_views.drain(..) { + self.device.destroy_image_view(view, None); + } + for out in self.outputs.drain(..) { + self.device.destroy_image_view(out.view, None); + self.device.destroy_image_view(out.plane_views[0], None); + self.device.destroy_image_view(out.plane_views[1], None); + self.device.destroy_semaphore(out.semaphore, None); + } + for image in self.images.drain(..) { + self.device.destroy_image(image, None); + } + for memory in self.memory.drain(..) { + self.device.free_memory(memory, None); + } + } + } +} + +/// One OPTIMAL-tiling video image bound to fresh DEVICE_LOCAL memory, profile-listed +/// (mirrors the encoder's `make_video_image`, minus its `&mut` profile-list plumbing). +/// +/// # Safety +/// +/// `dev` wraps live handles. +#[allow(clippy::too_many_arguments)] +unsafe fn create_video_image( + dev: &DecodeDevice, + format: vk::Format, + extent: vk::Extent2D, + layers: u32, + usage: vk::ImageUsageFlags, + flags: vk::ImageCreateFlags, + families: &[u32], + std_profile_idc: hh::StdVideoH264ProfileIdc, +) -> Result<(vk::Image, vk::DeviceMemory), AllocError> { + let mut chain = H264ProfileChain::new(std_profile_idc); + let profile = chain.wire(); + let mut profile_list = + vk::VideoProfileListInfoKHR::default().profiles(std::slice::from_ref(profile)); + let mut ci = vk::ImageCreateInfo::default() + .flags(flags) + .image_type(vk::ImageType::TYPE_2D) + .format(format) + .extent(vk::Extent3D { + width: extent.width, + height: extent.height, + depth: 1, + }) + .mip_levels(1) + .array_layers(layers) + .samples(vk::SampleCountFlags::TYPE_1) + .tiling(vk::ImageTiling::OPTIMAL) + .usage(usage) + .initial_layout(vk::ImageLayout::UNDEFINED) + .push_next(&mut profile_list); + ci = if families.len() >= 2 { + ci.sharing_mode(vk::SharingMode::CONCURRENT) + .queue_family_indices(families) + } else { + ci.sharing_mode(vk::SharingMode::EXCLUSIVE) + }; + // SAFETY: live device; `ci` roots a chain of locals outliving the call. + let image = unsafe { dev.ash().create_image(&ci, None)? }; + // SAFETY: `image` was just created on this device. + let req = unsafe { dev.ash().get_image_memory_requirements(image) }; + let props = dev.memory_properties(); + let type_index = match find_memory_type( + &props, + req.memory_type_bits, + vk::MemoryPropertyFlags::DEVICE_LOCAL, + ) { + Ok(index) => index, + Err(e) => { + // SAFETY: destroying the just-created, never-bound image. + unsafe { dev.ash().destroy_image(image, None) }; + return Err(e); + } + }; + let alloc = vk::MemoryAllocateInfo::default() + .allocation_size(req.size) + .memory_type_index(type_index); + // SAFETY: live device; unwind destroys the unbound image so the error path + // leaks nothing. + let memory = match unsafe { dev.ash().allocate_memory(&alloc, None) } { + Ok(m) => m, + Err(e) => { + // SAFETY: destroying the just-created, never-bound image. + unsafe { dev.ash().destroy_image(image, None) }; + return Err(e.into()); + } + }; + // SAFETY: fresh image + fresh memory of the required size. + if let Err(e) = unsafe { dev.ash().bind_image_memory(image, memory, 0) } { + // SAFETY: unwinding the two objects created above. + unsafe { + dev.ash().destroy_image(image, None); + dev.ash().free_memory(memory, None); + } + return Err(e.into()); + } + Ok((image, memory)) +} + +/// One single-layer 2D view (`base_array_layer = layer`, identity swizzle). +/// +/// # Safety +/// +/// `image` is live on `device` with `layer` in range; `format`/`aspect` are +/// compatible with the image's creation (same format for COLOR, plane-compatible +/// under MUTABLE_FORMAT for the plane aspects). +unsafe fn create_view( + device: &ash::Device, + image: vk::Image, + format: vk::Format, + aspect: vk::ImageAspectFlags, + layer: u32, +) -> Result { + let ci = vk::ImageViewCreateInfo::default() + .image(image) + .view_type(vk::ImageViewType::TYPE_2D) + .format(format) + .subresource_range(vk::ImageSubresourceRange { + aspect_mask: aspect, + base_mip_level: 0, + level_count: 1, + base_array_layer: layer, + layer_count: 1, + }); + // SAFETY: the fn-level contract restates exactly what create_image_view needs. + unsafe { device.create_image_view(&ci, None) } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::caps::derive_caps; + use crate::caps::RawH264Caps; + use crate::caps::VideoFormat; + use crate::caps::NV12; + + fn caps(coincide: bool, layered: bool) -> DecodeCaps { + // Every entry advertises its role's full usage plus MUTABLE_FORMAT — the + // derivation gates on those; this module's decision table is downstream. + let entry = |usage: vk::ImageUsageFlags| VideoFormat { + format: NV12, + image_usage: usage, + image_create_flags: vk::ImageCreateFlags::MUTABLE_FORMAT, + }; + let raw = RawH264Caps { + capability_flags: if layered { + vk::VideoCapabilityFlagsKHR::empty() + } else { + vk::VideoCapabilityFlagsKHR::SEPARATE_REFERENCE_IMAGES + }, + decode_flags: if coincide { + vk::VideoDecodeCapabilityFlagsKHR::DPB_AND_OUTPUT_COINCIDE + } else { + vk::VideoDecodeCapabilityFlagsKHR::DPB_AND_OUTPUT_DISTINCT + }, + dpb_formats: vec![entry(DPB_USAGE)], + output_formats: vec![entry(OUTPUT_USAGE)], + coincide_formats: vec![entry(COINCIDE_USAGE)], + ..Default::default() + }; + derive_caps(&raw).unwrap() + } + + #[test] + fn coincide_layered_is_one_dual_use_array_with_no_output_ring() { + let plan = plan_pools(&caps(true, true), 5, OUTPUT_RING); + assert_eq!((plan.dpb_image_count, plan.dpb_layers_per_image), (1, 5)); + assert_eq!( + plan.dpb_usage, COINCIDE_USAGE, + "coincide: the DPB image is the decode output AND the sampled surface" + ); + assert_eq!( + plan.dpb_flags, + vk::ImageCreateFlags::MUTABLE_FORMAT, + "plane views need MUTABLE_FORMAT; nothing aliases, so no ALIAS" + ); + assert_eq!(plan.output_image_count, 0); + assert_eq!(plan.output_slots, 5, "one output slot per DPB slot"); + } + + #[test] + fn coincide_separate_is_one_dual_use_image_per_slot() { + let plan = plan_pools(&caps(true, false), 5, OUTPUT_RING); + assert_eq!((plan.dpb_image_count, plan.dpb_layers_per_image), (5, 1)); + assert_eq!(plan.output_image_count, 0); + assert_eq!(plan.output_slots, 5); + } + + #[test] + fn distinct_layered_is_a_reference_only_array_plus_an_output_ring() { + let plan = plan_pools(&caps(false, true), 17, OUTPUT_RING); + assert_eq!((plan.dpb_image_count, plan.dpb_layers_per_image), (1, 17)); + assert_eq!( + plan.dpb_usage, DPB_USAGE, + "distinct: the DPB is never sampled and never a decode dst" + ); + assert_eq!(plan.dpb_flags, vk::ImageCreateFlags::empty()); + assert_eq!(plan.output_image_count, OUTPUT_RING); + assert_eq!(plan.output_usage, OUTPUT_USAGE); + assert_eq!( + plan.output_flags, + vk::ImageCreateFlags::MUTABLE_FORMAT, + "plane views need MUTABLE_FORMAT; nothing aliases, so no ALIAS" + ); + assert_eq!(plan.output_slots, OUTPUT_RING); + } + + #[test] + fn distinct_separate_is_per_slot_reference_images_plus_the_ring() { + let plan = plan_pools(&caps(false, false), 3, 2); + assert_eq!((plan.dpb_image_count, plan.dpb_layers_per_image), (3, 1)); + assert_eq!(plan.output_image_count, 2); + assert_eq!(plan.output_slots, 2); + } +} diff --git a/crates/pf-vkdecode/src/lib.rs b/crates/pf-vkdecode/src/lib.rs index 9b59e356..92944a2b 100644 --- a/crates/pf-vkdecode/src/lib.rs +++ b/crates/pf-vkdecode/src/lib.rs @@ -2,8 +2,9 @@ //! (design/client-native-decode.md §3.2). //! //! This crate sits between [`pf_bitstream`]'s per-AU planning and Vulkan Video -//! submission. WP-A (this round) is the CPU-testable half, and everything in it runs -//! without a GPU: +//! submission. +//! +//! WP-A — the CPU-testable half, everything runs without a GPU: //! //! - [`params`]: the vendored parser's `Sps`/`Pps` converted into the //! `StdVideoH264*ParameterSet` structs session parameters are created from, behind @@ -16,19 +17,58 @@ //! `StdVideoDecodeH264PictureInfo`/`StdVideoDecodeH264ReferenceInfo` set plus slice //! offsets and slot bindings a `vkCmdDecodeVideoKHR` call wants. //! -//! WP-B adds the other half: VkVideoSessionKHR/session-parameters objects, DPB image -//! memory, command recording and result queries. Nothing here touches a VkDevice. +//! WP-B — the GPU half, built ON the borrowed presenter device (this crate never +//! creates or destroys a VkDevice) with the decision logic split out pure so it +//! stays CPU-testable: +//! +//! - [`device`]: [`DeviceHandles`] (the borrowed handle bundle + its liveness +//! contract), the [`QueueLock`] trait every submit runs under, [`DecodeDevice`]. +//! - [`caps`]: one thin driver query + [`derive_caps`], the pure +//! coincide/distinct/layered decision table ([`DecodeCaps`]). +//! - [`session`]: `VkVideoSessionKHR` + versioned session parameters (pure ledger +//! decides Add-vs-Recreate; extent/DPB renegotiation rebuilds the session). +//! - [`images`]: DPB + output pools for BOTH DPB arrangements (pure [`plan_pools`] +//! decides the shape), per-plane views, one timeline semaphore per output slot, +//! crop carried on the frame. +//! - [`ring`]: the host-visible bitstream upload ring (pure alignment/growth math). +//! - [`decoder`]: [`VkH264Decoder`] — plan → convert → upload → record → submit, +//! with a per-op `RESULT_STATUS_ONLY` query ([`VkH264Decoder::poll_status`]) so +//! driver-reported corruption is finally observable (the Ally X class). //! //! Unsafe posture: unlike pf-bitstream (which forbids unsafe outright), this crate //! cannot — the `ash::vk::native` bindgen structs are zero-initialized the way the -//! encode side does it (`pf-encode/src/enc/linux/vk_build.rs`), and WP-B brings Vulkan -//! FFI. Every unsafe block therefore carries a written `// SAFETY:` proof, enforced: +//! encode side does it (`pf-encode/src/enc/linux/vk_build.rs`), and the GPU half is +//! Vulkan FFI. Every unsafe block therefore carries a written `// SAFETY:` proof, +//! enforced (and unlike the encoder there is NO file-level +//! `unsafe_op_in_unsafe_fn` exemption — every operation is individually fenced): #![deny(clippy::undocumented_unsafe_blocks)] +pub mod caps; +pub mod decoder; +pub mod device; +pub mod images; pub mod params; pub mod pic; +pub mod ring; +pub mod session; pub mod slots; +pub use caps::derive_caps; +pub use caps::CapsError; +pub use caps::DecodeCaps; +pub use caps::RawH264Caps; +pub use caps::VideoFormat; +pub use decoder::DecodeStatus; +pub use decoder::DecodedVkFrame; +pub use decoder::VkDecodeError; +pub use decoder::VkH264Decoder; +pub use device::DecodeDevice; +pub use device::DeviceHandles; +pub use device::NoopQueueLock; +pub use device::QueueLock; +pub use device::QueueSubmitGuard; +pub use images::plan_pools; +pub use images::PoolPlan; pub use params::pps_to_std; pub use params::sps_to_std; pub use params::OwnedStdPps; @@ -38,5 +78,8 @@ pub use pic::plan_to_vk; pub use pic::DecodePlanVk; pub use pic::PlanToVkError; pub use pic::VkRef; +pub use ring::RingLayout; +pub use session::ParamsAction; +pub use session::SessionConfig; pub use slots::SlotError; pub use slots::SlotMap; diff --git a/crates/pf-vkdecode/src/params.rs b/crates/pf-vkdecode/src/params.rs index 9a5fe84a..d1e4c668 100644 --- a/crates/pf-vkdecode/src/params.rs +++ b/crates/pf-vkdecode/src/params.rs @@ -121,8 +121,9 @@ impl OwnedStdPps { } /// H.264 `level_idc` (value-coded: 10 ⇒ 1.0) to Vulkan's index-coded -/// `StdVideoH264LevelIdc`. -const fn level_to_std(level: Level) -> hh::StdVideoH264LevelIdc { +/// `StdVideoH264LevelIdc`. The Std code points ascend with the level, so the +/// decoder's `maxLevelIdc` gate compares them numerically. +pub(crate) const fn level_to_std(level: Level) -> hh::StdVideoH264LevelIdc { match level { Level::L1 => hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_1_0, // Vulkan has no 1b code point. 1b is signalled on the wire as level_idc 11 diff --git a/crates/pf-vkdecode/src/pic.rs b/crates/pf-vkdecode/src/pic.rs index 5e088513..0c94b655 100644 --- a/crates/pf-vkdecode/src/pic.rs +++ b/crates/pf-vkdecode/src/pic.rs @@ -683,6 +683,97 @@ mod tests { ); } + #[test] + fn a_full_dpb_bump_reuses_the_evicted_slot_only_after_its_frame_is_released() { + // Depth-1 DPB (Level 1 at 320x240 ⇒ max_dpb_frames 1, capacity 2): every + // stored P evicts the previous picture, and that picture's id lands in + // BOTH `outputs` and `removed` of the SAME plan — the exact sequence + // where, without pins, `plan_to_vk` frees the evicted slot and + // immediately re-assigns it as this AU's setup while the evicted + // picture's frame is still in the consumer's hands (the HIGH overwrite + // bug of the adversarial round). This test drives the decoder's exact + // call pattern: pin at frame creation, unpin at release_frame. + let sps = SpsBuilder::new() + .seq_parameter_set_id(0) + .profile_idc(Profile::Main) + .level_idc(Level::L1) + .frame_mbs_only_flag(true) + .direct_8x8_inference_flag(true) + .max_num_ref_frames(1) + .log2_max_frame_num_minus4(0) + .pic_order_cnt_type(0) + .log2_max_pic_order_cnt_lsb_minus4(0) + .resolution(320, 240) + .build(); + let pps = PpsBuilder::new(Rc::clone(&sps)) + .pic_parameter_set_id(0) + .pic_init_qp(26) + .build(); + let mut au0 = Vec::new(); + Synthesizer::<'_, Sps, _>::synthesize(3, &sps, &mut au0, true).unwrap(); + Synthesizer::<'_, Pps, _>::synthesize(3, &pps, &mut au0, true).unwrap(); + au0.extend(write_idr_slice(None)); + + // First, the COUNTERFACTUAL (no pins): the bump hands the evicted + // picture's slot straight back as the next setup — the bug this guards. + { + let mut planner = H264Planner::new(); + let p0 = planner.plan_au(&au0).unwrap(); + let mut slots = SlotMap::new(p0.picture.max_dpb_frames); + let vk0 = plan_to_vk(&p0, &mut slots, 0).unwrap(); + let p1 = planner + .plan_au(&write_p_slice(1, 2, None, 1, None)) + .unwrap(); + let id0 = vk0.setup_id; + assert!(p1.dpb.outputs.contains(&id0) && p1.dpb.removed.contains(&id0)); + let vk1 = plan_to_vk(&p1, &mut slots, 0).unwrap(); + assert_eq!( + vk1.setup_slot, vk0.setup_slot, + "without pins the delivered frame's image IS the next decode target" + ); + } + + // Now the decoder's discipline: every frame pins its slot at creation. + let mut planner = H264Planner::new(); + let p0 = planner.plan_au(&au0).unwrap(); + let mut slots = SlotMap::new(p0.picture.max_dpb_frames); + let vk0 = plan_to_vk(&p0, &mut slots, 0).unwrap(); + slots.pin(vk0.setup_slot); + + // AU1 bumps AU0's picture (outputs + removed) — the freed slot is + // pinned, so the setup lands elsewhere and the unreleased frame's image + // is never a decode target. + let p1 = planner + .plan_au(&write_p_slice(1, 2, None, 1, None)) + .unwrap(); + assert!(p1.dpb.removed.contains(&vk0.setup_id)); + let vk1 = plan_to_vk(&p1, &mut slots, 0).unwrap(); + assert_ne!( + vk1.setup_slot, vk0.setup_slot, + "a pinned (delivered, unreleased) slot must never be the setup" + ); + slots.pin(vk1.setup_slot); + + // With NOTHING released, the next AU has no assignable slot: explicit + // backpressure (AllPinned → the decoder's NoFreeSlot), never an overwrite. + let p2 = planner + .plan_au(&write_p_slice(2, 4, None, 1, None)) + .unwrap(); + assert!(matches!( + plan_to_vk(&p2, &mut slots, 0), + Err(PlanToVkError::Slot(SlotError::AllPinned { .. })) + )); + + // The consumer releases frame 0 (decoder: release_frame → unpin): its + // slot becomes the next setup — reuse happens exactly one release later. + assert!(slots.unpin(vk0.setup_slot)); + let p3 = planner + .plan_au(&write_idr_slice(None)) + .expect("an IDR restart plans after the stalled AU"); + let vk3 = plan_to_vk(&p3, &mut slots, 0).unwrap(); + assert_eq!(vk3.setup_slot, vk0.setup_slot); + } + #[test] fn a_nonzero_delta_bottom_reaches_setup_and_reference_poc_pairs_distinctly() { // A PPS with bottom_field_pic_order_in_frame_present_flag: progressive diff --git a/crates/pf-vkdecode/src/ring.rs b/crates/pf-vkdecode/src/ring.rs new file mode 100644 index 00000000..15d14af0 --- /dev/null +++ b/crates/pf-vkdecode/src/ring.rs @@ -0,0 +1,487 @@ +//! Host-visible bitstream upload ring: one persistent-mapped `VIDEO_DECODE_SRC` +//! buffer cut into equal slots, honouring the profile's +//! `minBitstreamBufferOffsetAlignment`/`SizeAlignment`. +//! +//! Split like the rest of the crate: [`RingLayout`] + [`SlotStates`] are the pure, +//! unit-tested halves (offset/alignment math including growth, and the recycle +//! bookkeeping); [`BitstreamRing`] is the thin Vulkan half that allocates the +//! buffer and copies AU bytes. Slots recycle when the timeline value of the submit +//! that consumed them completes; an AU larger than the slot size grows the ring by +//! RECREATING the buffer (after draining every in-flight slot) — growth is rare +//! (an IDR burst outsizing the initial slots) and a stall there beats permanently +//! oversized slots. + +use ash::vk; +use tracing::debug; + +use crate::caps::H264ProfileChain; +use crate::device::find_memory_type; +use crate::device::AllocError; +use crate::device::DecodeDevice; + +/// Initial per-slot capacity. Sized for comfort at streaming bitrates (a 4K IDR at +/// punktfunk rates is a few hundred KiB); the ring grows on first contact with a +/// larger AU rather than pre-reserving worst cases. +pub const INITIAL_SLOT_SIZE: u64 = 2 * 1024 * 1024; +/// Slot count: enough to keep uploads ahead of a couple of in-flight decodes; the +/// pipeline depth itself is bounded by the output/query rings, not by this. +pub const RING_SLOTS: u32 = 4; + +/// `x` rounded up to a multiple of power-of-two `align`. +const fn align_up(x: u64, align: u64) -> u64 { + (x + align - 1) & !(align - 1) +} + +/// Pure geometry of the ring buffer. Both Vulkan alignments are powers of two per +/// the spec's alignment-value convention, which [`RingLayout::new`] debug-asserts; +/// the slot size is a multiple of BOTH, so every slot offset satisfies the offset +/// alignment and every full-slot range satisfies the size alignment. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RingLayout { + pub slot_size: u64, + pub slots: u32, + pub offset_alignment: u64, + pub size_alignment: u64, +} + +impl RingLayout { + pub fn new(min_slot_size: u64, slots: u32, offset_alignment: u64, size_alignment: u64) -> Self { + debug_assert!( + offset_alignment.is_power_of_two() && size_alignment.is_power_of_two(), + "Vulkan alignment values are powers of two" + ); + debug_assert!(slots > 0 && min_slot_size > 0); + let align = offset_alignment.max(size_alignment); + Self { + slot_size: align_up(min_slot_size, align), + slots, + offset_alignment, + size_alignment, + } + } + + /// Byte offset of `slot` — a `minBitstreamBufferOffsetAlignment` multiple by + /// construction. + pub fn offset_of(&self, slot: u32) -> u64 { + debug_assert!(slot < self.slots); + u64::from(slot) * self.slot_size + } + + /// Whether an AU of `len` bytes fits one slot (its aligned range included). + pub fn fits(&self, len: u64) -> bool { + self.record_range(len) <= self.slot_size + } + + /// The `srcBufferRange` to record for an AU of `len` bytes: the length rounded + /// up to `minBitstreamBufferSizeAlignment`. + pub fn record_range(&self, len: u64) -> u64 { + align_up(len, self.size_alignment) + } + + /// Total buffer size. + pub fn buffer_size(&self) -> u64 { + self.slot_size * u64::from(self.slots) + } + + /// The layout a recreation adopts so an AU of `len` bytes fits with headroom: + /// slot size doubles from the current one until sufficient (geometric growth — + /// one recreation per size class, not one per oversized AU). + pub fn grown_for(&self, len: u64) -> Self { + let mut slot = self.slot_size.max(1); + while align_up(len, self.size_alignment) > slot { + slot *= 2; + } + Self::new(slot, self.slots, self.offset_alignment, self.size_alignment) + } +} + +/// Pure recycle bookkeeping: which slots are free, which carry an in-flight token. +/// Generic over the token so the FIFO/recycle behaviour is testable without a +/// device (the ring instantiates `T = (vk::Semaphore, u64)`). +#[derive(Debug)] +pub(crate) struct SlotStates { + pending: Vec>, + /// Round-robin cursor: slots are handed out in order, so the slot AT the + /// cursor is always the oldest in-flight one — the right one to wait on. + cursor: usize, +} + +impl SlotStates { + pub(crate) fn new(slots: usize) -> Self { + Self { + pending: (0..slots).map(|_| None).collect(), + cursor: 0, + } + } + + /// Acquire the next slot in round-robin order. `is_done` is consulted when the + /// slot still carries a token (`Ok(true)` frees it); returning `Ok(false)` + /// yields `Ok(None)` — the caller then waits on [`Self::oldest`]'s token and + /// retries. Errors pass through untouched. + pub(crate) fn acquire( + &mut self, + mut is_done: impl FnMut(&T) -> Result, + ) -> Result, E> { + let slot = self.cursor; + if let Some(token) = &self.pending[slot] { + if !is_done(token)? { + return Ok(None); + } + self.pending[slot] = None; + } + self.cursor = (self.cursor + 1) % self.pending.len(); + Ok(Some(slot)) + } + + /// The oldest in-flight token (the one blocking [`Self::acquire`]), if any. + pub(crate) fn oldest(&self) -> Option<&T> { + self.pending[self.cursor].as_ref() + } + + /// Record `token` as `slot`'s in-flight use. + pub(crate) fn set_pending(&mut self, slot: usize, token: T) { + debug_assert!( + self.pending[slot].is_none(), + "slot handed out while pending" + ); + self.pending[slot] = Some(token); + } + + /// All in-flight tokens (drain-before-recreate walks these). + pub(crate) fn in_flight(&self) -> impl Iterator { + self.pending.iter().filter_map(Option::as_ref) + } + + /// Forget every token (after the caller has drained them). + pub(crate) fn clear(&mut self) { + for p in &mut self.pending { + *p = None; + } + self.cursor = 0; + } +} + +/// One uploaded AU: what `vkCmdDecodeVideoKHR` needs plus the slot to mark pending +/// once the submit's timeline token exists. +#[derive(Debug, Clone, Copy)] +pub(crate) struct UploadedAu { + pub offset: u64, + pub range: u64, + pub slot: usize, +} + +/// The in-flight token a used slot waits on: a timeline (semaphore, value) pair — +/// the same pair the submit that consumed the slot signalled. +pub(crate) type Token = (vk::Semaphore, u64); + +/// The Vulkan half: buffer + memory + persistent map. Created against the session's +/// video profile (the spec requires the src buffer to be profile-listed). +pub(crate) struct BitstreamRing { + device: ash::Device, + layout: RingLayout, + std_profile_idc: ash::vk::native::StdVideoH264ProfileIdc, + buffer: vk::Buffer, + memory: vk::DeviceMemory, + ptr: *mut u8, + pub(crate) pending: SlotStates, +} + +impl BitstreamRing { + /// Allocate the buffer for `layout`. + /// + /// # Safety + /// + /// `dev` wraps live handles ([`crate::DeviceHandles`] contract). + pub(crate) unsafe fn create( + dev: &DecodeDevice, + layout: RingLayout, + std_profile_idc: ash::vk::native::StdVideoH264ProfileIdc, + ) -> Result { + // SAFETY: live device; allocate_backing only creates objects it returns. + let (buffer, memory, ptr) = + unsafe { Self::allocate_backing(dev, &layout, std_profile_idc)? }; + Ok(Self { + device: dev.ash().clone(), + layout, + std_profile_idc, + buffer, + memory, + ptr, + pending: SlotStates::new(layout.slots as usize), + }) + } + + pub(crate) fn buffer(&self) -> vk::Buffer { + self.buffer + } + + /// # Safety + /// + /// As [`Self::create`]. + unsafe fn allocate_backing( + dev: &DecodeDevice, + layout: &RingLayout, + std_profile_idc: ash::vk::native::StdVideoH264ProfileIdc, + ) -> Result<(vk::Buffer, vk::DeviceMemory, *mut u8), AllocError> { + let mut chain = H264ProfileChain::new(std_profile_idc); + let profile = chain.wire(); + let mut profile_list = + vk::VideoProfileListInfoKHR::default().profiles(std::slice::from_ref(profile)); + let ci = vk::BufferCreateInfo::default() + .size(layout.buffer_size()) + .usage(vk::BufferUsageFlags::VIDEO_DECODE_SRC_KHR) + .sharing_mode(vk::SharingMode::EXCLUSIVE) + .push_next(&mut profile_list); + // SAFETY: live device; `ci` roots a chain of locals outliving the call. + let buffer = unsafe { dev.ash().create_buffer(&ci, None)? }; + // SAFETY: `buffer` was just created on this device. + let req = unsafe { dev.ash().get_buffer_memory_requirements(buffer) }; + let mem_props = dev.memory_properties(); + let type_index = match find_memory_type( + &mem_props, + req.memory_type_bits, + vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT, + ) { + Ok(index) => index, + Err(e) => { + // SAFETY: destroying the just-created, never-bound buffer. + unsafe { dev.ash().destroy_buffer(buffer, None) }; + return Err(e); + } + }; + let alloc = vk::MemoryAllocateInfo::default() + .allocation_size(req.size) + .memory_type_index(type_index); + // SAFETY: live device; on failure the buffer is destroyed before returning + // so nothing leaks. + let memory = match unsafe { dev.ash().allocate_memory(&alloc, None) } { + Ok(m) => m, + Err(e) => { + // SAFETY: destroying the just-created, never-bound buffer. + unsafe { dev.ash().destroy_buffer(buffer, None) }; + return Err(e.into()); + } + }; + // SAFETY: fresh buffer + fresh memory of at least the required size. + if let Err(e) = unsafe { dev.ash().bind_buffer_memory(buffer, memory, 0) } { + // SAFETY: unwinding the two objects created above (unbound/unused). + unsafe { + dev.ash().destroy_buffer(buffer, None); + dev.ash().free_memory(memory, None); + } + return Err(e.into()); + } + // SAFETY: `memory` is HOST_VISIBLE and unmapped; WHOLE_SIZE maps its full + // range for the buffer's lifetime (vkFreeMemory implicitly unmaps). + let ptr = match unsafe { + dev.ash() + .map_memory(memory, 0, vk::WHOLE_SIZE, vk::MemoryMapFlags::empty()) + } { + Ok(p) => p.cast::(), + Err(e) => { + // SAFETY: unwinding the two objects created above. + unsafe { + dev.ash().destroy_buffer(buffer, None); + dev.ash().free_memory(memory, None); + } + return Err(e.into()); + } + }; + Ok((buffer, memory, ptr)) + } + + /// Upload one AU, recycling or growing as needed. + /// + /// `poll`/`wait` bridge to the caller's timeline-semaphore facts: `poll` + /// answers "has this token completed?" without blocking; `wait` blocks until + /// it has (bounded by the caller's timeout policy). The split keeps this + /// module free of any semaphore knowledge. + /// + /// # Safety + /// + /// Live device (contract), and the tokens passed to prior + /// [`SlotStates::set_pending`] calls must genuinely cover every GPU read of + /// their slots — recycling rewrites slot bytes as soon as a token reports done. + pub(crate) unsafe fn upload>( + &mut self, + dev: &DecodeDevice, + au: &[u8], + poll: &mut dyn FnMut(&Token) -> Result, + wait: &mut dyn FnMut(&Token) -> Result<(), E>, + ) -> Result { + let len = au.len() as u64; + if !self.layout.fits(len) { + // Grow: drain EVERYTHING in flight (their reads target the old buffer), + // then recreate the backing under the grown layout. + for token in self.pending.in_flight() { + wait(token)?; + } + self.pending.clear(); + let grown = self.layout.grown_for(len); + debug!( + old = self.layout.slot_size, + new = grown.slot_size, + au = len, + "bitstream ring grows for an oversized AU" + ); + // SAFETY: every in-flight read was drained above; destroy_backing only + // touches this ring's own objects. + unsafe { self.destroy_backing() }; + // SAFETY: caller's live-device contract. + let (buffer, memory, ptr) = + unsafe { Self::allocate_backing(dev, &grown, self.std_profile_idc)? }; + self.layout = grown; + self.buffer = buffer; + self.memory = memory; + self.ptr = ptr; + self.pending = SlotStates::new(grown.slots as usize); + } + + let slot = match self.pending.acquire(&mut *poll)? { + Some(slot) => slot, + None => { + // The oldest slot is still in flight: wait it out, then retry — + // guaranteed to succeed now. + if let Some(token) = self.pending.oldest() { + wait(token)?; + } + self.pending + .acquire(|_| Ok(true))? + .expect("the waited slot is free") + } + }; + + let offset = self.layout.offset_of(slot as u32); + let range = self.layout.record_range(len); + // SAFETY: `ptr` is the live persistent mapping of a buffer of + // `layout.buffer_size()` bytes; `offset + range <= buffer_size` because + // `range <= slot_size` (fits/grown above) and offset is `slot * slot_size` + // with `slot < slots`. The slot is not concurrently read: its previous use + // completed (poll/wait above) and its next use is submitted after this copy. + unsafe { + let base = self.ptr.add(offset as usize); + std::ptr::copy_nonoverlapping(au.as_ptr(), base, au.len()); + // Zero the alignment tail so the recorded range never hands the driver + // stale bytes from a previous AU behind this one's end. + std::ptr::write_bytes(base.add(au.len()), 0, (range - len) as usize); + } + Ok(UploadedAu { + offset, + range, + slot, + }) + } + + /// Destroy buffer + memory (which implicitly unmaps). Callers must have + /// drained in-flight reads first. + /// + /// # Safety + /// + /// Live device; no submitted-and-unfinished GPU work reads the buffer. + unsafe fn destroy_backing(&mut self) { + // SAFETY: the fn-level contract — objects are this ring's own, reads drained. + unsafe { + self.device.destroy_buffer(self.buffer, None); + self.device.free_memory(self.memory, None); + } + self.buffer = vk::Buffer::null(); + self.memory = vk::DeviceMemory::null(); + self.ptr = std::ptr::null_mut(); + } +} + +impl Drop for BitstreamRing { + fn drop(&mut self) { + if self.buffer == vk::Buffer::null() { + return; + } + // SAFETY: the owning decoder drains its queue before dropping state (and the + // borrowed device is alive by the DeviceHandles liveness contract). + unsafe { self.destroy_backing() }; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slot_offsets_and_ranges_honour_both_alignments() { + // Deliberately DIFFERENT alignments: offset 256, size 64. + let layout = RingLayout::new(1000, 4, 256, 64); + // Slot size rounds up to a multiple of max(256, 64). + assert_eq!(layout.slot_size, 1024); + for slot in 0..4 { + assert_eq!(layout.offset_of(slot) % 256, 0, "offset alignment"); + } + assert_eq!(layout.buffer_size(), 4096); + // Ranges round to the SIZE alignment, independent of the offset one. + assert_eq!(layout.record_range(1), 64); + assert_eq!(layout.record_range(64), 64); + assert_eq!(layout.record_range(65), 128); + assert!(layout.fits(1024)); + assert!(!layout.fits(1025)); + } + + #[test] + fn growth_doubles_the_slot_size_until_the_au_fits_and_keeps_alignment() { + let layout = RingLayout::new(1024, 4, 128, 128); + let grown = layout.grown_for(5000); + assert_eq!(grown.slot_size, 8192, "1024 → 2048 → 4096 → 8192"); + assert_eq!(grown.slots, 4); + assert!(grown.fits(5000)); + assert_eq!(grown.offset_of(3) % 128, 0); + + // An AU already fitting changes nothing. + assert_eq!(layout.grown_for(512), layout); + + // The aligned RANGE drives growth, not the raw length: a 1025-byte AU has + // a 1152-byte range under a 128 alignment and needs the next size up. + assert_eq!(layout.grown_for(1025).slot_size, 2048); + } + + #[test] + fn one_byte_alignments_degenerate_cleanly() { + let layout = RingLayout::new(100, 2, 1, 1); + assert_eq!(layout.slot_size, 100); + assert_eq!(layout.record_range(37), 37); + assert!(layout.fits(100)); + assert!(!layout.fits(101)); + } + + #[test] + fn slots_recycle_in_fifo_order_only_after_their_token_completes() { + let mut states: SlotStates = SlotStates::new(2); + let s0 = states.acquire(|_| Ok::<_, ()>(true)).unwrap().unwrap(); + states.set_pending(s0, 10); + let s1 = states.acquire(|_| Ok::<_, ()>(true)).unwrap().unwrap(); + states.set_pending(s1, 11); + assert_ne!(s0, s1); + + // Ring full, oldest (slot 0, token 10) not done: acquire yields None and + // names the token to wait on. + assert_eq!(states.acquire(|&t| Ok::<_, ()>(t > 10)).unwrap(), None); + assert_eq!(states.oldest(), Some(&10)); + + // Once done, the OLDEST slot is the one handed back (FIFO, not LIFO). + let s2 = states.acquire(|_| Ok::<_, ()>(true)).unwrap().unwrap(); + assert_eq!(s2, s0); + + // Errors from the completion probe pass through untouched. + states.set_pending(s2, 12); + assert_eq!(states.acquire(|_| Err("gpu gone")).unwrap_err(), "gpu gone"); + } + + #[test] + fn clear_forgets_every_token_and_restarts_the_cursor() { + let mut states: SlotStates = SlotStates::new(3); + for token in 0..3 { + let s = states.acquire(|_| Ok::<_, ()>(true)).unwrap().unwrap(); + states.set_pending(s, token); + } + assert_eq!(states.in_flight().count(), 3); + states.clear(); + assert_eq!(states.in_flight().count(), 0); + assert_eq!(states.acquire(|_| Ok::<_, ()>(true)).unwrap(), Some(0)); + } +} diff --git a/crates/pf-vkdecode/src/session.rs b/crates/pf-vkdecode/src/session.rs new file mode 100644 index 00000000..d56b9b66 --- /dev/null +++ b/crates/pf-vkdecode/src/session.rs @@ -0,0 +1,689 @@ +//! `VkVideoSessionKHR` + `VkVideoSessionParametersKHR` lifecycle. +//! +//! The session is created from the STREAM's facts (the SPS's coded extent and DPB +//! depth), its memory requirements bound exactly like the encoder does, and its +//! parameters object holds WP-A's converted `StdVideoH264*ParameterSet`s. Parameter +//! versioning follows Vulkan's rules precisely: +//! +//! - a NEW (sps-id / pps-id) is ADDED via `vkUpdateVideoSessionParametersKHR` with +//! `updateSequenceCount` = previous + 1 (the spec's exact-increment rule); +//! - an EXISTING id whose content changed cannot be updated in place — the object +//! is RECREATED (Vulkan forbids replacing a stored parameter set), as is an +//! object whose capacity would overflow; +//! - a stream renegotiation that resizes the DPB or the coded extent recreates the +//! whole session — `plan_to_vk`'s `CapacityMismatch` is the trigger the decoder +//! sees for the DPB half, the extent comparison covers the other. +//! +//! [`ParamsLedger`] is the pure half of that decision table (unit-tested); +//! [`VideoSession`] is the thin Vulkan half. + +use std::rc::Rc; + +use ash::vk; +use ash::vk::native as hh; +use cros_codecs::codec::h264::parser::Pps; +use cros_codecs::codec::h264::parser::Sps; +use tracing::debug; + +use crate::caps::DecodeCaps; +use crate::caps::H264ProfileChain; +use crate::device::find_memory_type; +use crate::device::AllocError; +use crate::device::DecodeDevice; +use crate::params::pps_to_std; +use crate::params::sps_to_std; +use crate::params::ParamsError; + +/// Parameter-object capacity. Punktfunk hosts emit one SPS + one PPS per stream; +/// the headroom absorbs id churn across renegotiations without recreation, and an +/// overflow beyond it recreates rather than fails. +pub(crate) const MAX_STD_SPS: usize = 4; +pub(crate) const MAX_STD_PPS: usize = 8; + +/// What the ledger decided for one (SPS, PPS) activation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ParamsAction { + /// Both sets are already stored with identical content — nothing to do. + Current, + /// At least one set is new; one update call (seq += 1) adds what is missing. + Add { add_sps: bool, add_pps: bool }, + /// A stored id changed content, or capacity would overflow: recreate the + /// parameters object (Vulkan cannot replace or evict a stored set). + Recreate, +} + +/// Pure bookkeeping for the parameters object: which sets it holds (by id AND +/// content — the parser re-parses in-band parameter sets every keyframe, so +/// pointer identity means nothing) and the update sequence counter. +#[derive(Debug, Default)] +pub(crate) struct ParamsLedger { + sps: Vec<(u8, Rc)>, + pps: Vec<((u8, u8), Rc)>, + update_seq: u32, +} + +impl ParamsLedger { + /// Decide the action for activating (`sps`, `pps`). Pure — mutate via + /// [`Self::commit`]. + pub(crate) fn plan(&self, sps: &Rc, pps: &Rc) -> ParamsAction { + let sps_key = sps.seq_parameter_set_id; + let pps_key = (pps.seq_parameter_set_id, pps.pic_parameter_set_id); + + let stored_sps = self.sps.iter().find(|(id, _)| *id == sps_key); + let stored_pps = self.pps.iter().find(|(id, _)| *id == pps_key); + if let Some((_, stored)) = stored_sps { + if **stored != **sps { + return ParamsAction::Recreate; + } + } + if let Some((_, stored)) = stored_pps { + if **stored != **pps { + return ParamsAction::Recreate; + } + } + let add_sps = stored_sps.is_none(); + let add_pps = stored_pps.is_none(); + if !add_sps && !add_pps { + return ParamsAction::Current; + } + if (add_sps && self.sps.len() >= MAX_STD_SPS) || (add_pps && self.pps.len() >= MAX_STD_PPS) + { + return ParamsAction::Recreate; + } + ParamsAction::Add { add_sps, add_pps } + } + + /// Apply a decided action. `Add` bumps the sequence count by EXACTLY one (the + /// Vulkan update rule — one call may carry both sets); `Recreate` resets the + /// ledger to just the current pair with a fresh object's zero counter (any + /// other id the stream still references simply re-Adds on next activation). + pub(crate) fn commit(&mut self, action: ParamsAction, sps: &Rc, pps: &Rc) { + match action { + ParamsAction::Current => {} + ParamsAction::Add { add_sps, add_pps } => { + if add_sps { + self.sps.push((sps.seq_parameter_set_id, Rc::clone(sps))); + } + if add_pps { + self.pps.push(( + (pps.seq_parameter_set_id, pps.pic_parameter_set_id), + Rc::clone(pps), + )); + } + self.update_seq += 1; + } + ParamsAction::Recreate => { + self.sps.clear(); + self.pps.clear(); + self.sps.push((sps.seq_parameter_set_id, Rc::clone(sps))); + self.pps.push(( + (pps.seq_parameter_set_id, pps.pic_parameter_set_id), + Rc::clone(pps), + )); + self.update_seq = 0; + } + } + } + + /// The sequence count the NEXT `vkUpdateVideoSessionParametersKHR` must carry. + pub(crate) fn next_update_seq(&self) -> u32 { + self.update_seq + 1 + } +} + +/// The session's create-time shape; a plan disagreeing with it forces a rebuild. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SessionConfig { + pub max_coded_extent: vk::Extent2D, + pub max_dpb_slots: u32, + pub max_active_references: u32, + /// The Std profile the session was created against (a profile change is a + /// renegotiation too). + pub std_profile_idc: hh::StdVideoH264ProfileIdc, +} + +/// Session creation/parameter failures the decoder maps into its error type. +#[derive(Debug)] +pub(crate) enum SessionError { + Vk(vk::Result), + Params(ParamsError), + /// Session memory binding found no matching memory type (never a fallback). + NoMemoryType { + type_bits: u32, + flags: vk::MemoryPropertyFlags, + }, +} + +impl From for SessionError { + fn from(r: vk::Result) -> Self { + SessionError::Vk(r) + } +} + +impl From for SessionError { + fn from(e: ParamsError) -> Self { + SessionError::Params(e) + } +} + +impl From for SessionError { + fn from(e: AllocError) -> Self { + match e { + AllocError::Vk(r) => SessionError::Vk(r), + AllocError::NoMemoryType { type_bits, flags } => { + SessionError::NoMemoryType { type_bits, flags } + } + } + } +} + +/// The Vulkan half: session + bound memory + parameters object. +pub(crate) struct VideoSession { + device: ash::Device, + video_queue: ash::khr::video_queue::Device, + session: vk::VideoSessionKHR, + memory: Vec, + parameters: vk::VideoSessionParametersKHR, + ledger: ParamsLedger, + pub(crate) config: SessionConfig, + /// The session has never run a coding scope: the first one records a + /// `VK_VIDEO_CODING_CONTROL_RESET_BIT_KHR` control before anything else (the + /// spec's initialization requirement; same shape as the encoder's first-frame + /// RESET install). + needs_reset: ResetArm, +} + +impl VideoSession { + /// Create the session + an EMPTY parameters object (sets arrive via + /// [`Self::ensure_parameters`], which the decoder calls before the first + /// decode). + /// + /// # Safety + /// + /// `dev` wraps live handles ([`crate::DeviceHandles`] contract). + pub(crate) unsafe fn create( + dev: &DecodeDevice, + caps: &DecodeCaps, + config: SessionConfig, + ) -> Result { + let mut chain = H264ProfileChain::new(config.std_profile_idc); + let profile = chain.wire(); + let std_header_version = caps.std_header_version; + let session_ci = vk::VideoSessionCreateInfoKHR::default() + .queue_family_index(dev.decode_qf()) + .video_profile(profile) + .picture_format(caps.output_format) + .max_coded_extent(config.max_coded_extent) + .reference_picture_format(caps.dpb_format) + .max_dpb_slots(config.max_dpb_slots) + .max_active_reference_pictures(config.max_active_references) + .std_header_version(&std_header_version); + let mut session = vk::VideoSessionKHR::null(); + // SAFETY: live device; `session_ci` roots locals (chain, header version) + // that outlive the call. + let r = unsafe { + (dev.video_queue().fp().create_video_session_khr)( + dev.ash().handle(), + &session_ci, + std::ptr::null(), + &mut session, + ) + }; + if r != vk::Result::SUCCESS { + return Err(SessionError::Vk(r)); + } + + let mut built = Self { + device: dev.ash().clone(), + video_queue: dev.video_queue().clone(), + session, + memory: Vec::new(), + parameters: vk::VideoSessionParametersKHR::null(), + ledger: ParamsLedger::default(), + config, + needs_reset: ResetArm::armed(), + }; + // SAFETY: fn contract; on error `built` drops and unwinds the session + + // whatever memory was bound. + unsafe { + built.bind_memory(dev)?; + built.parameters = built.create_parameters_object(&[], &[])?; + } + Ok(built) + } + + /// Query and bind the session's memory requirements (the encoder's exact shape). + /// + /// # Safety + /// + /// As [`Self::create`]. + unsafe fn bind_memory(&mut self, dev: &DecodeDevice) -> Result<(), SessionError> { + let get = dev + .video_queue() + .fp() + .get_video_session_memory_requirements_khr; + let mut count = 0u32; + // SAFETY: live device + the session created above; null pointer is the + // count-query form. + let _ = unsafe { + get( + self.device.handle(), + self.session, + &mut count, + std::ptr::null_mut(), + ) + }; + let mut reqs = vec![vk::VideoSessionMemoryRequirementsKHR::default(); count as usize]; + // SAFETY: as above with an array of the reported count. + let _ = unsafe { + get( + self.device.handle(), + self.session, + &mut count, + reqs.as_mut_ptr(), + ) + }; + + let props = dev.memory_properties(); + let mut binds = Vec::with_capacity(reqs.len()); + for rq in &reqs { + let mr = rq.memory_requirements; + let type_index = find_memory_type( + &props, + mr.memory_type_bits, + vk::MemoryPropertyFlags::DEVICE_LOCAL, + )?; + let alloc = vk::MemoryAllocateInfo::default() + .allocation_size(mr.size) + .memory_type_index(type_index); + // SAFETY: live device; the allocation is parked in `self.memory` so the + // Drop unwind owns it from the moment it exists. + let memory = unsafe { self.device.allocate_memory(&alloc, None)? }; + self.memory.push(memory); + binds.push( + vk::BindVideoSessionMemoryInfoKHR::default() + .memory_bind_index(rq.memory_bind_index) + .memory(memory) + .memory_offset(0) + .memory_size(mr.size), + ); + } + // SAFETY: session + freshly allocated memory, one bind per requirement. + let r = unsafe { + (self.video_queue.fp().bind_video_session_memory_khr)( + self.device.handle(), + self.session, + binds.len() as u32, + binds.as_ptr(), + ) + }; + if r != vk::Result::SUCCESS { + return Err(SessionError::Vk(r)); + } + Ok(()) + } + + /// Create a parameters object holding exactly `sps`/`pps` (either may be empty). + /// + /// # Safety + /// + /// Live device + live session; the Std slices' backing (the `OwnedStd*` + /// wrappers) outlives this call — Vulkan copies all parameter data before + /// returning. + unsafe fn create_parameters_object( + &self, + sps: &[hh::StdVideoH264SequenceParameterSet], + pps: &[hh::StdVideoH264PictureParameterSet], + ) -> Result { + let add = vk::VideoDecodeH264SessionParametersAddInfoKHR::default() + .std_sp_ss(sps) + .std_pp_ss(pps); + let mut h264 = vk::VideoDecodeH264SessionParametersCreateInfoKHR::default() + .max_std_sps_count(MAX_STD_SPS as u32) + .max_std_pps_count(MAX_STD_PPS as u32) + .parameters_add_info(&add); + let ci = vk::VideoSessionParametersCreateInfoKHR::default() + .video_session(self.session) + .push_next(&mut h264); + let mut parameters = vk::VideoSessionParametersKHR::null(); + // SAFETY: fn contract; `ci` roots locals outliving the call. + let r = unsafe { + (self.video_queue.fp().create_video_session_parameters_khr)( + self.device.handle(), + &ci, + std::ptr::null(), + &mut parameters, + ) + }; + if r != vk::Result::SUCCESS { + return Err(SessionError::Vk(r)); + } + Ok(parameters) + } + + /// The ledger's verdict for activating (`sps`, `pps`), without mutating + /// anything — the decoder consults this BEFORE [`Self::ensure_parameters`] so + /// a [`ParamsAction::Recreate`] can be preceded by a full in-flight drain + /// (the destroy inside the recreate must never race a submitted decode). + pub(crate) fn parameters_action(&self, sps: &Rc, pps: &Rc) -> ParamsAction { + self.ledger.plan(sps, pps) + } + + /// Make the parameters object hold this AU's activated (SPS, PPS), converting + /// through WP-A and Adding/Recreating per the ledger's decision. + /// + /// # Safety + /// + /// Live device; when [`Self::parameters_action`] says `Recreate`, the caller + /// has ALREADY drained every in-flight decode (waited each output slot's + /// newest submitted timeline value) — the old object is destroyed here, and a + /// still-executing decode reading it would be use-after-free at the driver + /// level. The decoder enforces exactly that ordering in `decode_inner`; + /// `Current`/`Add` touch no object a submitted decode can be reading. + pub(crate) unsafe fn ensure_parameters( + &mut self, + sps: &Rc, + pps: &Rc, + ) -> Result<(), SessionError> { + let action = self.ledger.plan(sps, pps); + match action { + ParamsAction::Current => Ok(()), + ParamsAction::Add { add_sps, add_pps } => { + let owned_sps = if add_sps { + Some(sps_to_std(sps)?) + } else { + None + }; + let owned_pps = if add_pps { + Some(pps_to_std(pps)?) + } else { + None + }; + let sps_slice: &[hh::StdVideoH264SequenceParameterSet] = match &owned_sps { + Some(o) => std::slice::from_ref(o.std()), + None => &[], + }; + let pps_slice: &[hh::StdVideoH264PictureParameterSet] = match &owned_pps { + Some(o) => std::slice::from_ref(o.std()), + None => &[], + }; + let mut add = vk::VideoDecodeH264SessionParametersAddInfoKHR::default() + .std_sp_ss(sps_slice) + .std_pp_ss(pps_slice); + let update = vk::VideoSessionParametersUpdateInfoKHR::default() + .update_sequence_count(self.ledger.next_update_seq()) + .push_next(&mut add); + // SAFETY: live device + parameters object; `update` roots locals + // (incl. the OwnedStd backings) outliving the call, and Vulkan + // copies parameter data before returning. + let r = unsafe { + (self.video_queue.fp().update_video_session_parameters_khr)( + self.device.handle(), + self.parameters, + &update, + ) + }; + if r != vk::Result::SUCCESS { + return Err(SessionError::Vk(r)); + } + self.ledger.commit(action, sps, pps); + Ok(()) + } + ParamsAction::Recreate => { + debug!( + sps_id = sps.seq_parameter_set_id, + pps_id = pps.pic_parameter_set_id, + "recreating session parameters (content change or capacity)" + ); + let owned_sps = sps_to_std(sps)?; + let owned_pps = pps_to_std(pps)?; + // SAFETY: fn contract (the OwnedStd backings live across the call). + let fresh = unsafe { + self.create_parameters_object( + std::slice::from_ref(owned_sps.std()), + std::slice::from_ref(owned_pps.std()), + )? + }; + // SAFETY: the fn-level contract — the caller drained every + // in-flight decode before a Recreate reached here (checked via + // parameters_action), so no submitted work reads the old object; + // it is this session's own handle. + unsafe { + (self.video_queue.fp().destroy_video_session_parameters_khr)( + self.device.handle(), + self.parameters, + std::ptr::null(), + ); + } + self.parameters = fresh; + self.ledger.commit(action, sps, pps); + Ok(()) + } + } + } + + pub(crate) fn session(&self) -> vk::VideoSessionKHR { + self.session + } + + pub(crate) fn parameters(&self) -> vk::VideoSessionParametersKHR { + self.parameters + } + + /// Whether the next coding scope must record the initialization RESET — + /// `true` exactly once per session, PROVIDED the command buffer that recorded + /// it actually reaches the queue: a recording/submit failure after this + /// returned `true` must call [`Self::re_arm_reset`], or the session would run + /// its whole life uninitialized. + pub(crate) fn take_needs_reset(&mut self) -> bool { + self.needs_reset.take() + } + + /// Undo a consumed [`Self::take_needs_reset`] whose RESET never reached the + /// queue (end/submit failed after recording it). + pub(crate) fn re_arm_reset(&mut self) { + self.needs_reset.re_arm(); + } +} + +/// The one-shot session-RESET arm, its own type so the take/re-arm cycle is +/// testable without a live session object. +#[derive(Debug)] +pub(crate) struct ResetArm(bool); + +impl ResetArm { + pub(crate) fn armed() -> Self { + Self(true) + } + + pub(crate) fn take(&mut self) -> bool { + std::mem::take(&mut self.0) + } + + pub(crate) fn re_arm(&mut self) { + self.0 = true; + } +} + +impl Drop for VideoSession { + fn drop(&mut self) { + // SAFETY: all handles are this session's own on the (contract-live) device; + // the owning decoder drains GPU work before dropping state. The destroy + // entry points ignore NULL handles, covering half-built sessions. + unsafe { + (self.video_queue.fp().destroy_video_session_parameters_khr)( + self.device.handle(), + self.parameters, + std::ptr::null(), + ); + (self.video_queue.fp().destroy_video_session_khr)( + self.device.handle(), + self.session, + std::ptr::null(), + ); + for memory in self.memory.drain(..) { + self.device.free_memory(memory, None); + } + } + } +} + +#[cfg(test)] +mod tests { + use cros_codecs::codec::h264::parser::PpsBuilder; + use cros_codecs::codec::h264::parser::Profile; + use cros_codecs::codec::h264::parser::SpsBuilder; + use pf_bitstream::h264::Level; + + use super::*; + + fn authored(sps_id: u8, pps_id: u8, qp: u8) -> (Rc, Rc) { + let sps = SpsBuilder::new() + .seq_parameter_set_id(sps_id) + .profile_idc(Profile::Main) + .level_idc(Level::L4) + .frame_mbs_only_flag(true) + .direct_8x8_inference_flag(true) + .max_num_ref_frames(4) + .resolution(64, 64) + .build(); + let pps = PpsBuilder::new(Rc::clone(&sps)) + .pic_parameter_set_id(pps_id) + .pic_init_qp(qp) + .build(); + (sps, pps) + } + + #[test] + fn a_reactivated_identical_pair_is_current_even_across_reparses() { + let (sps_a, pps_a) = authored(0, 0, 26); + // The parser re-parses in-band sets each keyframe: same content, NEW Rcs. + let (sps_b, pps_b) = authored(0, 0, 26); + assert!(!Rc::ptr_eq(&sps_a, &sps_b)); + + let mut ledger = ParamsLedger::default(); + let first = ledger.plan(&sps_a, &pps_a); + assert_eq!( + first, + ParamsAction::Add { + add_sps: true, + add_pps: true + } + ); + ledger.commit(first, &sps_a, &pps_a); + assert_eq!(ledger.plan(&sps_b, &pps_b), ParamsAction::Current); + } + + #[test] + fn a_new_pps_id_over_a_stored_sps_adds_only_the_pps() { + let (sps, pps0) = authored(0, 0, 26); + let pps1 = PpsBuilder::new(Rc::clone(&sps)) + .pic_parameter_set_id(1) + .pic_init_qp(26) + .build(); + + let mut ledger = ParamsLedger::default(); + let a = ledger.plan(&sps, &pps0); + ledger.commit(a, &sps, &pps0); + assert_eq!( + ledger.plan(&sps, &pps1), + ParamsAction::Add { + add_sps: false, + add_pps: true + } + ); + } + + #[test] + fn changed_content_under_a_stored_id_recreates_and_resets_the_sequence() { + let (sps, pps) = authored(0, 0, 26); + let mut ledger = ParamsLedger::default(); + let a = ledger.plan(&sps, &pps); + ledger.commit(a, &sps, &pps); + assert_eq!(ledger.next_update_seq(), 2, "one Add happened"); + + // Same ids, different content (qp changed): Vulkan cannot replace a + // stored set, so this must recreate. + let (sps2, pps2) = authored(0, 0, 30); + let action = ledger.plan(&sps2, &pps2); + assert_eq!(action, ParamsAction::Recreate); + ledger.commit(action, &sps2, &pps2); + assert_eq!( + ledger.next_update_seq(), + 1, + "a fresh object restarts its counter" + ); + // And the pair is now Current under the new content. + assert_eq!(ledger.plan(&sps2, &pps2), ParamsAction::Current); + } + + #[test] + fn capacity_overflow_recreates_with_just_the_current_pair() { + let mut ledger = ParamsLedger::default(); + // Fill the PPS capacity under one SPS. + let (sps, first) = authored(0, 0, 26); + let a = ledger.plan(&sps, &first); + ledger.commit(a, &sps, &first); + for pps_id in 1..MAX_STD_PPS as u8 { + let pps = PpsBuilder::new(Rc::clone(&sps)) + .pic_parameter_set_id(pps_id) + .pic_init_qp(26) + .build(); + let a = ledger.plan(&sps, &pps); + assert!(matches!(a, ParamsAction::Add { .. })); + ledger.commit(a, &sps, &pps); + } + assert_eq!(ledger.next_update_seq() - 1, MAX_STD_PPS as u32); + + // One past capacity: recreate; afterwards the evicted first PPS re-Adds. + let overflow = PpsBuilder::new(Rc::clone(&sps)) + .pic_parameter_set_id(MAX_STD_PPS as u8) + .pic_init_qp(26) + .build(); + let action = ledger.plan(&sps, &overflow); + assert_eq!(action, ParamsAction::Recreate); + ledger.commit(action, &sps, &overflow); + assert_eq!( + ledger.plan(&sps, &first), + ParamsAction::Add { + add_sps: false, + add_pps: true + }, + "sets evicted by a recreate re-add on next activation" + ); + } + + #[test] + fn the_reset_arm_fires_once_unless_the_failed_submit_re_arms_it() { + let mut arm = ResetArm::armed(); + assert!(arm.take(), "a fresh session needs its RESET"); + assert!( + !arm.take(), + "consumed — the next scope must NOT reset again" + ); + + // The recorded RESET never reached the queue (end/submit failed): the + // re-arm makes the next successful recording carry it instead. + arm.re_arm(); + assert!(arm.take()); + assert!(!arm.take()); + } + + #[test] + fn update_sequence_counts_one_per_add_call_not_per_set() { + let (sps, pps) = authored(0, 0, 26); + let mut ledger = ParamsLedger::default(); + assert_eq!(ledger.next_update_seq(), 1); + // One call carries BOTH sets: the counter moves by exactly one. + let a = ledger.plan(&sps, &pps); + assert_eq!( + a, + ParamsAction::Add { + add_sps: true, + add_pps: true + } + ); + ledger.commit(a, &sps, &pps); + assert_eq!(ledger.next_update_seq(), 2); + } +} diff --git a/crates/pf-vkdecode/src/slots.rs b/crates/pf-vkdecode/src/slots.rs index 4e3cc0a9..e37631b9 100644 --- a/crates/pf-vkdecode/src/slots.rs +++ b/crates/pf-vkdecode/src/slots.rs @@ -14,8 +14,10 @@ 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. +/// What went wrong with a slot operation. `Full`/`AlreadyAssigned` are caller +/// bugs, not stream conditions — pf-bitstream degrades stream damage to warnings +/// long before here. `AllPinned` is neither: it reports a consumer that has not +/// released delivered frames (backpressure, not a ledger fault). #[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 @@ -23,6 +25,11 @@ pub enum SlotError { Full { capacity: usize }, /// The id already holds a slot; ids are per-picture and never re-assigned. AlreadyAssigned { id: PicId, slot: u8 }, + /// Free slots exist but every one is [pinned](SlotMap::pin) by an out-of-DPB + /// reader (a delivered frame the consumer has not released). Assigning one + /// would let the next decode overwrite an image someone is still reading — + /// the caller must surface backpressure instead. + AllPinned { free: usize }, } impl std::fmt::Display for SlotError { @@ -37,6 +44,12 @@ impl std::fmt::Display for SlotError { SlotError::AlreadyAssigned { id, slot } => { write!(f, "picture {id} already holds slot {slot}") } + SlotError::AllPinned { free } => { + write!( + f, + "every free DPB slot ({free}) is pinned by an unreleased frame" + ) + } } } } @@ -49,11 +62,19 @@ impl std::error::Error for SlotError {} /// 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. +/// - assigning past capacity errors instead of evicting; +/// - a [pinned](Self::pin) slot is never assigned, held or free — pins are the +/// WP-B two-phase-release layer: a slot backing a DELIVERED-but-unreleased +/// frame stays pinned past its DPB eviction, because in coincide mode the next +/// setup assignment would otherwise overwrite the very image the consumer is +/// still reading (the full-DPB bump hands `outputs`+`removed` the same id in +/// the same plan, and the freed slot is exactly the lowest one). #[derive(Debug, Clone)] pub struct SlotMap { /// `slots[i]` holds the id bound to slot `i`, `None` while the slot is free. slots: Vec>, + /// Out-of-DPB reader counts per slot (refcounted, orthogonal to residency). + pinned: Vec, } impl SlotMap { @@ -73,6 +94,7 @@ impl SlotMap { ); Self { slots: vec![None; max_dpb_frames + 1], + pinned: vec![0; max_dpb_frames + 1], } } @@ -97,21 +119,62 @@ impl SlotMap { .filter_map(|(index, slot)| slot.map(|id| (index as u8, id))) } - /// Bind `id` to the lowest free slot. + /// Bind `id` to the lowest free UNPINNED slot. + /// + /// Free-but-pinned slots are skipped (their images are still read outside the + /// DPB); when only such slots remain the error is [`SlotError::AllPinned`], + /// distinct from [`SlotError::Full`] because it names a consumer that owes a + /// release, not a ledger bug. pub fn assign(&mut self, id: PicId) -> Result { 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 { + let mut free_but_pinned = 0usize; + let assignable = self.slots.iter().enumerate().position(|(index, slot)| { + if slot.is_some() { + return false; + } + if self.pinned[index] > 0 { + free_but_pinned += 1; + return false; + } + true + }); + match assignable { + Some(free) => { + self.slots[free] = Some(id); + // The envelope-gated capacity (<= 17) keeps every index within u8. + Ok(free as u8) + } + None if free_but_pinned > 0 => Err(SlotError::AllPinned { + free: free_but_pinned, + }), + None => Err(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) + }), + } + } + + /// Add one out-of-DPB reader to `slot` (refcounted): the slot stays + /// unassignable — even after its picture leaves the DPB — until the matching + /// [`Self::unpin`]. The decoder pins a slot for every live [frame] it backs + /// and unpins on `release_frame`/internal drop. + /// + /// [frame]: crate::decoder::DecodedVkFrame + pub fn pin(&mut self, slot: u8) { + self.pinned[usize::from(slot)] += 1; + } + + /// Remove one reader from `slot`. Returns `false` (and changes nothing) when + /// the slot carried no pin — a double release, tolerated but never silent at + /// the caller. + pub fn unpin(&mut self, slot: u8) -> bool { + let count = &mut self.pinned[usize::from(slot)]; + if *count == 0 { + return false; + } + *count -= 1; + true } /// The slot `id` holds, if any. @@ -246,6 +309,45 @@ mod tests { assert_eq!(slots.slot_of(2), None); } + #[test] + fn a_pinned_slot_is_skipped_by_assign_until_every_pin_is_released() { + let mut slots = SlotMap::new(1); // capacity 2 + let s0 = slots.assign(1).unwrap(); + slots.pin(s0); + slots.pin(s0); // refcounted: two readers + slots.release(1); // DPB eviction — the pin must keep protecting the slot + + // The pinned slot is skipped; the other free slot is handed out. + let s1 = slots.assign(2).unwrap(); + assert_ne!(s1, s0); + + // Now every free slot is pinned: a DISTINCT error from Full (the ledger + // is fine; the consumer owes a release). + assert_eq!(slots.assign(3), Err(SlotError::AllPinned { free: 1 })); + + // One unpin is not enough (two readers were counted)… + assert!(slots.unpin(s0)); + assert_eq!(slots.assign(3), Err(SlotError::AllPinned { free: 1 })); + // …the second frees it, and the slot is assignable again. + assert!(slots.unpin(s0)); + assert_eq!(slots.assign(3), Ok(s0)); + + // A pin-less unpin is a reported no-op, not an underflow. + assert!(!slots.unpin(s1)); + } + + #[test] + fn full_and_all_pinned_stay_distinct_verdicts() { + let mut slots = SlotMap::new(1); // capacity 2 + slots.assign(1).unwrap(); + slots.assign(2).unwrap(); + // Genuinely full (all HELD): the missed-removals bug class. + assert_eq!(slots.assign(3), Err(SlotError::Full { capacity: 2 })); + // A pin on a HELD slot changes nothing about that verdict. + slots.pin(0); + assert_eq!(slots.assign(3), Err(SlotError::Full { capacity: 2 })); + } + #[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 diff --git a/crates/pf-vkdecode/tests/gpu_smoke.rs b/crates/pf-vkdecode/tests/gpu_smoke.rs new file mode 100644 index 00000000..c2a9ebd1 --- /dev/null +++ b/crates/pf-vkdecode/tests/gpu_smoke.rs @@ -0,0 +1,241 @@ +//! GPU smoke test — `#[ignore]`d because it needs real Vulkan Video hardware. +//! +//! Run on a Vulkan-Video box with: +//! +//! ```text +//! cargo test -p pf-vkdecode -- --ignored +//! ``` +//! +//! Environment expectations (the fleet's RADV boxes .21/.25, the NVIDIA .173, or +//! any machine like them): +//! - a Vulkan 1.3 loader on the library path (`libvulkan.so.1` / `vulkan-1.dll`); +//! - a physical device advertising `VK_KHR_video_queue`, +//! `VK_KHR_video_decode_queue` and `VK_KHR_video_decode_h264`, with a queue +//! family carrying `VIDEO_DECODE_KHR` ops for H.264; +//! - `timelineSemaphore` + `synchronization2` feature support (Vulkan 1.3 core). +//! +//! What it proves: device wrap → caps query/derivation on REAL caps → session + +//! parameters creation → DPB/output/ring pools → 48 AUs of the vendored 25fps +//! vector decoded through `vkCmdDecodeVideoKHR` — well past DPB-full, so the +//! bump-eviction slot-reuse path runs — with the full frame lifecycle each +//! delivery: `wait_status` reading the RESULT_STATUS_ONLY query back as +//! COMPLETE, then `release_frame` returning the slot (the two-phase release the +//! coincide-mode overwrite fix depends on). What it deliberately does NOT prove +//! (WP-D on-glass): pixel correctness vs the ffmpeg rung, presenter +//! interop/layout round-trips, soak, and both vendors' DPB arrangements at once +//! (each box exercises only its own). + +#![deny(clippy::undocumented_unsafe_blocks)] + +use std::io::Cursor; + +use ash::vk; +use ash::vk::Handle; +use cros_codecs::codec::h264::parser::Nalu; +use cros_codecs::codec::h264::parser::NaluType; +use pf_vkdecode::DecodeStatus; +use pf_vkdecode::DeviceHandles; +use pf_vkdecode::NoopQueueLock; +use pf_vkdecode::VkH264Decoder; + +// The same vendored vector the WP-A tests convert, same relative path. +const TEST_25FPS: &[u8] = include_bytes!( + "../../pf-bitstream/vendor/cros-codecs/src/codec/h264/test_data/test-25fps.h264" +); + +/// Test-only AU splitter, mirroring pf-bitstream's (`#[cfg(test)]`-private there). +fn split_into_aus(stream: &[u8]) -> Vec<&[u8]> { + let mut aus = Vec::new(); + let mut cursor = Cursor::new(stream); + let mut au_start = 0usize; + let mut au_has_slice = false; + + while let Ok(nalu) = Nalu::next(&mut cursor) { + let nalu_offset = cursor.position() as usize; + let start = nalu_offset - nalu.offset; + let is_slice = matches!(nalu.header.type_, NaluType::Slice | NaluType::SliceIdr); + let first_mb_zero = is_slice && stream.get(nalu_offset + 1).is_some_and(|b| b & 0x80 != 0); + + if au_has_slice && (!is_slice || first_mb_zero) { + aus.push(&stream[au_start..start]); + au_start = start; + au_has_slice = false; + } + au_has_slice |= is_slice; + } + aus.push(&stream[au_start..]); + aus +} + +#[test] +#[ignore = "needs a Vulkan Video H.264 decode device (fleet boxes; see module docs)"] +fn decodes_48_aus_with_status_reads_and_frame_releases_past_dpb_full() { + // ---- instance ---- + // SAFETY: loads the system Vulkan loader; no Vulkan objects exist yet. + let entry = unsafe { ash::Entry::load() }.expect("a Vulkan loader on this box"); + let app = vk::ApplicationInfo::default().api_version(vk::make_api_version(0, 1, 3, 0)); + let instance_ci = vk::InstanceCreateInfo::default().application_info(&app); + // SAFETY: valid create info rooted in locals; the instance is destroyed at the + // end of this test after everything created from it. + let instance = + unsafe { entry.create_instance(&instance_ci, None) }.expect("create a Vulkan 1.3 instance"); + + // ---- physical device with an H.264 decode queue family ---- + // SAFETY: live instance. + let physical_devices = + unsafe { instance.enumerate_physical_devices() }.expect("enumerate physical devices"); + let mut picked: Option<(vk::PhysicalDevice, u32, u32)> = None; + for pd in physical_devices { + // SAFETY: `pd` was just enumerated from this instance. + let ext_props = + unsafe { instance.enumerate_device_extension_properties(pd) }.unwrap_or_default(); + let has = |name: &std::ffi::CStr| { + ext_props.iter().any(|e| { + e.extension_name_as_c_str() + .is_ok_and(|extension| extension == name) + }) + }; + if !(has(ash::khr::video_queue::NAME) + && has(ash::khr::video_decode_queue::NAME) + && has(ash::khr::video_decode_h264::NAME)) + { + continue; + } + // SAFETY: live physical device; the two-call form fills the chained video + // properties for each family. + let family_count = unsafe { instance.get_physical_device_queue_family_properties2_len(pd) }; + let mut video_props = vec![vk::QueueFamilyVideoPropertiesKHR::default(); family_count]; + let mut families: Vec> = video_props + .iter_mut() + .map(|v| vk::QueueFamilyProperties2::default().push_next(v)) + .collect(); + // SAFETY: as above, arrays sized to the reported count. + unsafe { instance.get_physical_device_queue_family_properties2(pd, &mut families) }; + let flags_per_family: Vec = families + .iter() + .map(|f| f.queue_family_properties.queue_flags) + .collect(); + drop(families); // release the &mut borrows so video_props is readable + + let mut decode_qf = None; + let mut graphics_qf = None; + for (index, flags) in flags_per_family.iter().enumerate() { + if flags.contains(vk::QueueFlags::GRAPHICS) && graphics_qf.is_none() { + graphics_qf = Some(index as u32); + } + if flags.contains(vk::QueueFlags::VIDEO_DECODE_KHR) + && video_props[index] + .video_codec_operations + .contains(vk::VideoCodecOperationFlagsKHR::DECODE_H264) + && decode_qf.is_none() + { + decode_qf = Some(index as u32); + } + } + if let Some(decode) = decode_qf { + picked = Some((pd, decode, graphics_qf.unwrap_or(decode))); + break; + } + } + let (pd, decode_qf, graphics_qf) = + picked.expect("a physical device with VK_KHR_video_decode_h264 and a decode queue"); + + // ---- logical device: decode (+ graphics) queues, video + sync features ---- + let priorities = [1.0f32]; + let mut queue_infos = vec![vk::DeviceQueueCreateInfo::default() + .queue_family_index(decode_qf) + .queue_priorities(&priorities)]; + if graphics_qf != decode_qf { + queue_infos.push( + vk::DeviceQueueCreateInfo::default() + .queue_family_index(graphics_qf) + .queue_priorities(&priorities), + ); + } + let extensions = [ + ash::khr::video_queue::NAME.as_ptr(), + ash::khr::video_decode_queue::NAME.as_ptr(), + ash::khr::video_decode_h264::NAME.as_ptr(), + ]; + let mut features12 = vk::PhysicalDeviceVulkan12Features::default().timeline_semaphore(true); + let mut features13 = vk::PhysicalDeviceVulkan13Features::default().synchronization2(true); + let device_ci = vk::DeviceCreateInfo::default() + .queue_create_infos(&queue_infos) + .enabled_extension_names(&extensions) + .push_next(&mut features12) + .push_next(&mut features13); + // SAFETY: live physical device, valid create info rooted in locals; destroyed + // at the end of this test after the decoder drops. + let device = + unsafe { instance.create_device(pd, &device_ci, None) }.expect("create the decode device"); + + // ---- the decoder over borrowed handles, exactly as WP-C will hold it ---- + let handles = DeviceHandles { + get_instance_proc_addr: entry.static_fn().get_instance_proc_addr as usize, + instance: instance.handle().as_raw() as usize, + physical_device: pd.as_raw() as usize, + device: device.handle().as_raw() as usize, + decode_qf, + decode_queue_index: 0, + graphics_qf, + }; + { + // SAFETY: the handles above are live for this whole block (the decoder + // drops at its end, before the device/instance destroys below), the device + // was created with the decode extensions + timeline/sync2 features, and + // the queue fields name the families/queues created above. + let mut decoder = unsafe { VkH264Decoder::new(&handles, Box::new(NoopQueueLock)) } + .expect("wrap the device"); + + // 48 AUs — far past the vector's DPB depth, so evicted slots recycle + // repeatedly — with WP-C's one-in/one-out lifecycle on every delivered + // frame: wait its status (the program's whole point: the driver must + // say COMPLETE, per op) and release it so its slot may host a later + // decode. Output lags decode by a couple of AUs (B-pictures), so the + // delivered count is asserted with slack. + let aus = split_into_aus(TEST_25FPS); + let mut delivered = 0usize; + let mut geometry_checked = false; + for au in aus.iter().take(48) { + let mut next = decoder + .decode(au) + .expect("decode an AU of the clean vector"); + while let Some(frame) = next { + if !geometry_checked { + assert_eq!((frame.coded_width, frame.coded_height), (320, 240)); + assert_eq!( + (frame.crop.width, frame.crop.height), + (320, 240), + "the vector is uncropped" + ); + assert_ne!(frame.image, vk::Image::null()); + assert_ne!(frame.semaphore, vk::Semaphore::null()); + assert!(frame.value > 0); + geometry_checked = true; + } + assert_eq!( + decoder.wait_status(&frame), + DecodeStatus::Ok, + "the driver must report every decode op COMPLETE" + ); + decoder + .release_frame(&frame) + .expect("a current-generation frame releases"); + delivered += 1; + next = decoder.take_ready(); + } + } + assert!( + delivered >= 40, + "expected at least 40 delivered frames from 48 AUs, got {delivered}" + ); + } + + // ---- teardown (decoder is gone; its Drop drained the queue) ---- + // SAFETY: every object created from the device (the decoder's pools/session) + // was destroyed when the decoder dropped above. + unsafe { + device.destroy_device(None); + instance.destroy_instance(None); + } +}