feat(pf-vkdecode): the GPU half — session, DPB pools, decode recording, status queries

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.
This commit is contained in:
2026-08-05 16:44:44 +02:00
parent d24f7fc6ac
commit 540c0d3027
12 changed files with 4711 additions and 21 deletions
+52
View File
@@ -74,6 +74,15 @@ pub struct AuPlan {
pub slices: Vec<SlicePlan>,
pub dpb: DpbUpdate,
pub warnings: Vec<PlanWarning>,
/// 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<Sps>,
/// 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<Pps>,
}
/// 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));
}
}
+695
View File
@@ -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<VideoFormat>,
/// Formats usable for DISTINCT-mode outputs (queried with [`OUTPUT_USAGE`]).
pub output_formats: Vec<VideoFormat>,
/// Formats usable when DPB and output COINCIDE ([`COINCIDE_USAGE`]).
pub coincide_formats: Vec<VideoFormat>,
}
/// 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<DecodeCaps, CapsError> {
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<VideoFormat, CapsError> {
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<RawH264Caps, vk::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<Vec<VideoFormat>, 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::<vk::VideoDecodeH264ProfileInfoKHR<'_>>()
};
assert_eq!(
h264.std_profile_idc,
hh::StdVideoH264ProfileIdc_STD_VIDEO_H264_PROFILE_IDC_MAIN
);
assert_eq!(
h264.picture_layout,
vk::VideoDecodeH264PictureLayoutFlagsKHR::PROGRESSIVE
);
}
}
File diff suppressed because it is too large Load Diff
+395
View File
@@ -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<vk::Result> 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<u32, AllocError> {
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<Self, DeviceError> {
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::<usize, vk::PFN_vkGetInstanceProcAddr>(
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<u32> {
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);
}
}
+533
View File
@@ -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<vk::Image>,
memory: Vec<vk::DeviceMemory>,
/// Per-DPB-slot full view (setup/reference binding).
dpb_views: Vec<vk::ImageView>,
/// Per-DPB-slot (image index, array layer) for barrier targeting.
dpb_location: Vec<(usize, u32)>,
pub(crate) outputs: Vec<OutputSlot>,
}
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<Self, AllocError> {
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<vk::ImageView, vk::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);
}
}
+49 -6
View File
@@ -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;
+3 -2
View File
@@ -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
+91
View File
@@ -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
+487
View File
@@ -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<T> {
pending: Vec<Option<T>>,
/// 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<T> SlotStates<T> {
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<E>(
&mut self,
mut is_done: impl FnMut(&T) -> Result<bool, E>,
) -> Result<Option<usize>, 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<Item = &T> {
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<Token>,
}
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<Self, AllocError> {
// 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::<u8>(),
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<E: From<AllocError>>(
&mut self,
dev: &DecodeDevice,
au: &[u8],
poll: &mut dyn FnMut(&Token) -> Result<bool, E>,
wait: &mut dyn FnMut(&Token) -> Result<(), E>,
) -> Result<UploadedAu, E> {
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<u64> = 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<u64> = 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));
}
}
+689
View File
@@ -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<Sps>)>,
pps: Vec<((u8, u8), Rc<Pps>)>,
update_seq: u32,
}
impl ParamsLedger {
/// Decide the action for activating (`sps`, `pps`). Pure — mutate via
/// [`Self::commit`].
pub(crate) fn plan(&self, sps: &Rc<Sps>, pps: &Rc<Pps>) -> 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<Sps>, pps: &Rc<Pps>) {
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<vk::Result> for SessionError {
fn from(r: vk::Result) -> Self {
SessionError::Vk(r)
}
}
impl From<ParamsError> for SessionError {
fn from(e: ParamsError) -> Self {
SessionError::Params(e)
}
}
impl From<AllocError> 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<vk::DeviceMemory>,
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<Self, SessionError> {
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<vk::VideoSessionParametersKHR, SessionError> {
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<Sps>, pps: &Rc<Pps>) -> 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<Sps>,
pps: &Rc<Pps>,
) -> 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<Sps>, Rc<Pps>) {
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);
}
}
+115 -13
View File
@@ -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<Option<PicId>>,
/// Out-of-DPB reader counts per slot (refcounted, orthogonal to residency).
pinned: Vec<u32>,
}
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<u8, SlotError> {
if let Some(slot) = self.slot_of(id) {
return Err(SlotError::AlreadyAssigned { id, slot });
}
let free = self
.slots
.iter()
.position(Option::is_none)
.ok_or(SlotError::Full {
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
+241
View File
@@ -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<vk::QueueFamilyProperties2<'_>> = 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<vk::QueueFlags> = 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);
}
}