test(vkdecode): the HEVC GPU legs, which find M3 broken on every driver

M3 was recorded as code complete. Its exit criteria named the HEVC gpu_smoke
and gpu_parity legs, and the goldens for them were committed — 250 per-frame
NV12 hashes, cross-checked between two independent FFmpeg builds, with a
header saying they are "consumed the same way by the HEVC parity test". No
such test existed. Both GPU files were H.264 only, with zero references to
h265, so nothing had ever decoded a single HEVC frame through this crate on
hardware.

They exist now, and the first run answered. On AMD Adrenalin 25.10.30.02
(distinct mode, queryResultStatusSupport=false) 247 of 250 frames diverge
from libavcodec, and that device's smoke leg PASSES — because smoke only
reads the driver's verdict and that driver reports none. That is the Ally X
class, reproduced in-house on demand: output that is wrong everywhere the
picture is looked at and clean everywhere the decoder is asked. Both NVIDIA
drivers reject the stream outright and name the cause themselves,
"Invalid PPS/SPS id in slice header (pps_id=119 / 115)" — the identical two
values, and the smoke leg dies at the identical AU 9, on a 4090 under 610.88
on Windows and on an RTX 5070 Ti under 610.43.03 on Linux. Same wrong values,
same access unit, two GPU generations, two operating systems: deterministic,
and therefore ours rather than any driver's.

It is not an ordering fault. Five of the divergent hashes appear nowhere in
the 250 goldens, so the pixels are wrong rather than correct-but-reordered.
Parity dies at frame 1 while smoke dies at AU 9 only because smoke holds four
frames before it looks; the first inter-predicted picture is already corrupt.

The legs are committed ahead of the fix deliberately. They are the regression
test for the defect, they are #[ignore]d so no CI leg changes colour, and the
evidence above is worth recording in the order it was obtained.

Adding a third and fourth copy of ~150 lines of unsafe Vulkan bring-up was
not acceptable, so it moved to tests/common. The two behavioural differences
between the callers are now named parameters rather than accidents: the parity
legs read back on a graphics queue and require one, while the smoke legs
accept a decode-only device and fall back to the decode family — which also
decides whether pool images are EXCLUSIVE or CONCURRENT, so it is load-bearing
rather than cosmetic. H.264 came through the refactor unchanged, verified two
ways: argument-by-argument against the previous file, and on hardware, still
250/250 bit-identical on NVIDIA Windows, AMD Windows and now NVIDIA Linux.

The loader is deliberately leaked at teardown. ash::Entry owns the Arc<Library>,
so dropping it unloads the Vulkan loader with every ICD and implicit layer;
harmless while each binary held one GPU leg, but each now holds two, and the
second would re-open a loader the first had torn down.

Three guards run without a GPU, because everything above is #[ignore]d: the
golden file's count and digest shape, the HEVC access-unit split agreeing with
what the CPU planner emits (with iraps == 1 pinning "no CRA anywhere", so a
re-synced vector that opens with one fails here rather than as a frame-count
mismatch on the fleet), the vector staying Main 4:2:0 8-bit since both legs
hard-code that probe, and a refusal to run the smoke legs with
PF_VKD_TEST_READBACK set, which would quietly grow the pool a usage flag
production never carries.
This commit is contained in:
2026-08-06 09:47:30 +02:00
parent 9c10b97e31
commit 5c6b09a5c5
3 changed files with 1228 additions and 561 deletions
+532
View File
@@ -0,0 +1,532 @@
//! Shared Vulkan Video bring-up for the `#[ignore]`d GPU legs.
//!
//! `tests/gpu_smoke.rs` and `tests/gpu_parity.rs` each drive TWO codecs, and the
//! path from "a Vulkan loader exists" to "a [`DeviceHandles`] a decoder can be
//! constructed on" is the same ~150 unsafe lines every time: loader → instance →
//! pick a physical device whose queue families carry the codec's decode ops →
//! logical device with the decode extensions plus `timelineSemaphore` and
//! `synchronization2`. Four copies of that would be four places for a
//! fleet-only failure to hide, so it lives here once, parameterised by the one
//! thing that genuinely differs between the callers ([`Graphics`]: the parity
//! legs read back on a graphics queue and so REQUIRE one, while the smoke legs
//! accept a decode-only device and fall back to the decode family — which also
//! decides whether pool images end up EXCLUSIVE or CONCURRENT, so it is not
//! cosmetic). [`Request::report_families`] exists so a caller CAN suppress the
//! per-family table, but all four legs currently ask for it: it is the first
//! thing a fleet failure report needs, and it is a physical-device property
//! query, never a recorded RESULT_STATUS query, so it cannot trip the RADV VCN
//! hang.
//!
//! Cargo does not treat `tests/common/mod.rs` as a test target of its own (it
//! auto-discovers `tests/*.rs` and `tests/*/main.rs` only), so this file is
//! compiled purely as a `mod common;` of each test binary — and therefore under
//! each one's `#![deny(clippy::undocumented_unsafe_blocks)]`.
//!
//! Environment knobs, honoured exactly as they were before this module existed:
//! - `PF_VKD_SMOKE_VENDOR` (hex `0x1002`/`0x10de`, or decimal): pin a PCI vendor
//! on a multi-GPU box, so a run is attributable to one driver instead of
//! whichever device enumerated first.
//! - RADV additionally needs `RADV_PERFTEST=video_decode` in the environment;
//! without it no device advertises the decode extensions and [`bring_up`]
//! panics with "no physical device with VK_KHR_video_decode_*", which is the
//! correct report rather than a confusing later failure.
// Each of the two test binaries drives a different subset of this module (the
// smoke legs never touch `Setup::pd`, the parity legs never pass
// `Graphics::DecodeFamilyIsFine`), and a test binary gets no `pub` exemption
// from dead-code analysis.
#![allow(dead_code)]
use std::io::Cursor;
use ash::vk;
use ash::vk::Handle;
use pf_vkdecode::DecodeStatus;
use pf_vkdecode::DecodedVkFrame;
use pf_vkdecode::DeviceHandles;
use pf_vkdecode::VkDecodeError;
/// The vendored H.264 vector both GPU legs decode: 250 AUs of real encoder
/// output, 320x240 — the same file pf-bitstream's WP-A tests plan, at the same
/// relative path. It is **two slice NALUs per picture** (500 slice NALs over 250
/// AUs, with 4 IDRs), which is why the splitter's `first_mb_in_slice == 0` branch
/// is load-bearing here rather than decorative — and, per libavcodec's own DXVA
/// slice-control descriptors captured on hardware, why its slice-control buffer
/// is two records wide where the HEVC vector's is one.
pub const TEST_25FPS_H264: &[u8] = include_bytes!(
"../../../pf-bitstream/vendor/cros-codecs/src/codec/h264/test_data/test-25fps.h264"
);
/// The vendored H.265 twin: 250 AUs, 320x240 Main 8-bit 4:2:0, one IDR_N_LP then
/// 249 TRAIL pictures (verified by pf-bitstream's
/// `the_full_25fps_vector_plans_every_picture_and_every_pic_id_reaches_output`).
pub const TEST_25FPS_H265: &[u8] = include_bytes!(
"../../../pf-bitstream/vendor/cros-codecs/src/codec/h265/test_data/test-25fps.h265"
);
/// Test-only H.264 AU splitter, mirroring pf-bitstream's
/// (`#[cfg(test)]`-private there): a new AU starts at a non-VCL NALU following
/// slices, or at a slice whose `first_mb_in_slice` is 0 (the first bit of the byte
/// after the 1-byte NAL header) when the current AU already has slices.
pub fn split_h264_aus(stream: &[u8]) -> Vec<&[u8]> {
use cros_codecs::codec::h264::parser::Nalu;
use cros_codecs::codec::h264::parser::NaluType;
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-only H.265 AU splitter, a verbatim copy of pf-bitstream's
/// (`#[cfg(test)]`-private in `h265.rs`, and the same one `pic_h265`'s and
/// `fault_detection`'s tests carry).
///
/// The two differences from [`split_h264_aus`] are the whole point and are why
/// this is copied rather than re-derived: HEVC's NAL header is TWO bytes, so
/// `first_slice_segment_in_pic_flag` is the top bit of `stream[header_start + 2]`
/// (H.264 reads `+ 1`), and "is a slice" is the numeric range `nal_unit_type < 32`
/// rather than an enum pair. Getting either wrong silently merges or splits AUs,
/// which shows up as a frame-count mismatch a long way from its cause.
pub fn split_h265_aus(stream: &[u8]) -> Vec<&[u8]> {
use cros_codecs::codec::h265::parser::Nalu;
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 header_start = cursor.position() as usize;
let start = header_start - nalu.offset;
let is_slice = (nalu.header.type_ as u32) < 32;
let first_slice_flag =
is_slice && stream.get(header_start + 2).is_some_and(|b| b & 0x80 != 0);
if au_has_slice && (!is_slice || first_slice_flag) {
aus.push(&stream[au_start..start]);
au_start = start;
au_has_slice = false;
}
au_has_slice |= is_slice;
}
aus.push(&stream[au_start..]);
aus
}
/// The slice of a decoder's surface the GPU legs drive.
///
/// `VkH264Decoder` and `VkH265Decoder` expose it method-for-method (the crate
/// docs say so deliberately) but share no trait — codec DISPATCH is the client
/// wiring's job, not this crate's. Binding it here lets each GPU leg run ONE body
/// against both codecs, which is the only way "the H.265 leg proves the same thing
/// the H.264 leg does" can be a fact instead of a claim about two hand-copied
/// functions.
pub trait TestDecoder {
fn decode(&mut self, au: &[u8]) -> Result<Option<DecodedVkFrame>, VkDecodeError>;
fn take_ready(&mut self) -> Option<DecodedVkFrame>;
fn wait_status(&mut self, frame: &DecodedVkFrame) -> DecodeStatus;
fn release_frame(
&mut self,
frame: &DecodedVkFrame,
presenter_signaled: bool,
) -> Result<(), VkDecodeError>;
fn flush(&mut self);
fn status_queries(&self) -> bool;
fn debug_snapshot(&self) -> String;
}
/// Forwarding impl — one macro so the two decoders can never drift into being
/// driven differently by accident.
macro_rules! impl_test_decoder {
($ty:ty) => {
impl TestDecoder for $ty {
fn decode(&mut self, au: &[u8]) -> Result<Option<DecodedVkFrame>, VkDecodeError> {
<$ty>::decode(self, au)
}
fn take_ready(&mut self) -> Option<DecodedVkFrame> {
<$ty>::take_ready(self)
}
fn wait_status(&mut self, frame: &DecodedVkFrame) -> DecodeStatus {
<$ty>::wait_status(self, frame)
}
fn release_frame(
&mut self,
frame: &DecodedVkFrame,
presenter_signaled: bool,
) -> Result<(), VkDecodeError> {
<$ty>::release_frame(self, frame, presenter_signaled)
}
fn flush(&mut self) {
<$ty>::flush(self);
}
fn status_queries(&self) -> bool {
<$ty>::status_queries(self)
}
fn debug_snapshot(&self) -> String {
<$ty>::debug_snapshot(self)
}
}
};
}
impl_test_decoder!(pf_vkdecode::VkH264Decoder);
impl_test_decoder!(pf_vkdecode::VkH265Decoder);
/// Serializes the GPU legs within one test binary. Hold it for the whole leg.
///
/// Cargo runs a binary's tests on PARALLEL threads by default. While each GPU test
/// file held exactly one test that never mattered; with one leg per codec it
/// matters twice over:
///
/// - two decoders would contend for the same decode queue and double peak video
/// memory on a device that may not have it, turning an attribution run into a
/// race and any failure into something nobody can pin on a codec;
/// - the parity legs set `PF_VKD_TEST_READBACK` through `std::env::set_var`, which
/// is not thread-safe and would be racing a second leg's reads of it.
///
/// Poisoning is deliberately ignored: if the first leg panics, the second must
/// still run and report its own codec's verdict rather than fail as a casualty.
pub fn gpu_lock() -> std::sync::MutexGuard<'static, ()> {
static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(());
GPU.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Which codec a bring-up must find silicon (and an extension) for.
#[derive(Clone, Copy)]
pub struct Codec {
/// The video codec operation the chosen decode queue family must advertise.
/// Checked per FAMILY, not per device: a device can advertise the extension
/// while only one of its families carries the op.
pub op: vk::VideoCodecOperationFlagsKHR,
/// The codec's device extension — required on the physical device AND enabled
/// on the logical one, per [`DeviceHandles`]' contract (a decoder whose
/// extension was not enabled reaches `vkCreateVideoSessionKHR` on an
/// unenabled codec).
pub extension: &'static std::ffi::CStr,
}
/// H.264 decode (`VkH264Decoder`).
pub const H264: Codec = Codec {
op: vk::VideoCodecOperationFlagsKHR::DECODE_H264,
extension: ash::khr::video_decode_h264::NAME,
};
/// H.265 decode (`VkH265Decoder`).
pub const H265: Codec = Codec {
op: vk::VideoCodecOperationFlagsKHR::DECODE_H265,
extension: ash::khr::video_decode_h265::NAME,
};
/// What a caller needs from the GRAPHICS queue family — the one behavioural
/// difference between the smoke and parity bring-ups, an explicit parameter so
/// it cannot drift back into being an accident of two copied loops.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Graphics {
/// A device with no graphics family is still usable: `graphics_qf` falls back
/// to the decode family. The smoke legs want this — they submit nothing
/// outside the decoder. The fallback is not cosmetic: `decode_qf ==
/// graphics_qf` makes the picture pool's images EXCLUSIVE rather than
/// CONCURRENT (`DecodeDevice::sharing_families`), which is exactly the
/// arrangement a decode-only device has to run.
DecodeFamilyIsFine,
/// A device without a graphics family is SKIPPED, not defaulted. The parity
/// legs want this — their readback records `vkCmdCopyImageToBuffer` on the
/// graphics queue.
Required,
}
/// One bring-up request. A struct rather than positional arguments because the
/// two fields below are precisely where the callers disagree, and a bare
/// `bring_up(H265, true, false)` at a call site is how that disagreement becomes
/// invisible again.
pub struct Request {
/// The codec whose decode ops and extension are required.
pub codec: Codec,
/// Whether a graphics queue family is required or merely preferred.
pub graphics: Graphics,
/// Print each candidate device's per-family `flags / video_ops /
/// query_result_status` table. It is the first thing a fleet failure report
/// needs: which families exist, which of them decode this codec, and whether
/// per-op status verdicts exist on this box at all (RADV: they do not, and
/// recording one hangs the VCN — the 2026-08 .25 lesson).
pub report_families: bool,
}
/// A live instance + logical device a decoder can be constructed on.
///
/// Torn down explicitly through [`Setup::destroy`] rather than `Drop`, so the
/// ordering against the decoder — which must be gone FIRST — stays visible in the
/// test body, exactly as it was when each test carried its own teardown.
pub struct Setup {
/// Kept because [`Setup::handles`] hands the loader's
/// `vkGetInstanceProcAddr` to the decoder, which resolves everything through
/// it.
pub entry: ash::Entry,
pub instance: ash::Instance,
pub pd: vk::PhysicalDevice,
pub device: ash::Device,
pub decode_qf: u32,
/// The graphics family, or `decode_qf` under
/// [`Graphics::DecodeFamilyIsFine`] when the device has none.
pub graphics_qf: u32,
}
impl Setup {
/// The borrowed-handle bundle both decoders are constructed from. Valid only
/// while `self` is alive and un-destroyed ([`DeviceHandles`]' contract).
pub fn handles(&self) -> DeviceHandles {
DeviceHandles {
get_instance_proc_addr: self.entry.static_fn().get_instance_proc_addr as usize,
instance: self.instance.handle().as_raw() as usize,
physical_device: self.pd.as_raw() as usize,
device: self.device.handle().as_raw() as usize,
decode_qf: self.decode_qf,
decode_queue_index: 0,
graphics_qf: self.graphics_qf,
}
}
/// # Safety
///
/// Every object created from this device — the decoder's session/pools, any
/// readback handles — is already destroyed, and no [`DeviceHandles`] taken
/// from [`Setup::handles`] is still in use.
pub unsafe fn destroy(self) {
// SAFETY: fn contract — nothing derived from these handles survives, so
// the device can be destroyed and then the instance it came from.
unsafe {
self.device.destroy_device(None);
self.instance.destroy_instance(None);
}
// Deliberately LEAK the loader. `ash::Entry` owns an `Arc<Library>`, so
// dropping it `dlclose`/`FreeLibrary`s the Vulkan loader together with
// every ICD and implicit layer. That was harmless while each test binary
// held a single GPU leg (the unload was immediately followed by process
// exit), but each binary now holds two legs serialized by `gpu_lock`, so
// the second leg would re-`dlopen` a loader the first just tore down —
// a documented way to fault inside `Entry::load` or to take a signal at
// exit AFTER both legs reported ok, which reads as a decoder defect.
// The process is about to end regardless, so leaking is free.
std::mem::forget(self.entry);
}
}
/// The optional `PF_VKD_SMOKE_VENDOR` pin: multi-GPU boxes enumerate several
/// decode-capable devices and first-match hides all but one — the pin makes a run
/// attributable to a specific vendor's driver.
fn vendor_pin() -> Option<u32> {
std::env::var("PF_VKD_SMOKE_VENDOR").ok().map(|raw| {
let trimmed = raw.trim();
trimmed
.strip_prefix("0x")
.or_else(|| trimmed.strip_prefix("0X"))
.map_or_else(|| trimmed.parse(), |hex| u32::from_str_radix(hex, 16))
.unwrap_or_else(|_| panic!("PF_VKD_SMOKE_VENDOR is not a PCI vendor id: {raw:?}"))
})
}
/// Loader → instance → a physical device that can decode `request.codec` →
/// logical device with the decode extensions and `timelineSemaphore` +
/// `synchronization2`.
///
/// Panics (the test harness's only failure channel) with the reason when the box
/// cannot host the request; the message names the codec's OWN extension, so a box
/// with H.264 silicon but no H.265 says exactly that rather than "no Vulkan
/// Video".
pub fn bring_up(request: &Request) -> Setup {
// ---- 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 by
// `Setup::destroy` after everything created from it.
let instance =
unsafe { entry.create_instance(&instance_ci, None) }.expect("create a Vulkan 1.3 instance");
let vendor_filter = vendor_pin();
// ---- physical device with a decode queue family for this codec ----
// 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 props = unsafe { instance.get_physical_device_properties(pd) };
if vendor_filter.is_some_and(|vendor| props.vendor_id != vendor) {
continue;
}
// 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(request.codec.extension))
{
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
// Each family's video ops + RESULT_STATUS query support (see
// `Request::report_families`) — printed per CANDIDATE device, so a
// multi-GPU box reports every device it considered.
if request.report_families {
let mut status_props =
vec![vk::QueueFamilyQueryResultStatusPropertiesKHR::default(); family_count];
let mut families2: Vec<vk::QueueFamilyProperties2<'_>> = status_props
.iter_mut()
.map(|s| vk::QueueFamilyProperties2::default().push_next(s))
.collect();
// SAFETY: as the query above.
unsafe { instance.get_physical_device_queue_family_properties2(pd, &mut families2) };
drop(families2);
for (i, s) in status_props.iter().enumerate() {
eprintln!(
"family {i}: flags={:?} video_ops={:?} query_result_status={}",
flags_per_family[i],
video_props[i].video_codec_operations,
s.query_result_status_support != vk::FALSE,
);
}
}
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(request.codec.op)
&& decode_qf.is_none()
{
decode_qf = Some(index as u32);
}
}
match (request.graphics, decode_qf, graphics_qf) {
// A graphics queue is required and present.
(Graphics::Required, Some(decode), Some(graphics)) => {
picked = Some((pd, decode, graphics));
break;
}
// Not required: fall back to the decode family (see the variant docs).
(Graphics::DecodeFamilyIsFine, Some(decode), graphics) => {
picked = Some((pd, decode, graphics.unwrap_or(decode)));
break;
}
// No decode family for this codec, or none of the graphics kind
// required — keep looking.
(Graphics::Required, _, None) | (_, None, _) => {}
}
}
let (pd, decode_qf, graphics_qf) = picked.unwrap_or_else(|| {
panic!(
"no physical device with {} and a decode queue{}{}",
request.codec.extension.to_string_lossy(),
match request.graphics {
Graphics::Required => " and a graphics queue",
Graphics::DecodeFamilyIsFine => "",
},
match vendor_filter {
Some(vendor) => format!(" (PF_VKD_SMOKE_VENDOR pinned vendor 0x{vendor:04x})"),
None => String::new(),
},
)
});
// Attribution header: which device (and driver) this run actually exercised.
{
let mut driver_props = vk::PhysicalDeviceDriverProperties::default();
let mut props2 = vk::PhysicalDeviceProperties2::default().push_next(&mut driver_props);
// SAFETY: live physical device; the chain fills the Vulkan 1.2 core
// driver-identity struct.
unsafe { instance.get_physical_device_properties2(pd, &mut props2) };
let props = props2.properties;
eprintln!(
"picked: {:?} vendor=0x{:04x} driver={:?} info={:?}",
props.device_name_as_c_str().unwrap_or(c"?"),
props.vendor_id,
driver_props.driver_name_as_c_str().unwrap_or(c"?"),
driver_props.driver_info_as_c_str().unwrap_or(c"?"),
);
}
// ---- 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(),
request.codec.extension.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
// by `Setup::destroy` after the decoder drops.
let device =
unsafe { instance.create_device(pd, &device_ci, None) }.expect("create the decode device");
Setup {
entry,
instance,
pd,
device,
decode_qf,
graphics_qf,
}
}
+413 -277
View File
@@ -1,5 +1,6 @@
//! GPU frame-hash parity test (WP-D) — `#[ignore]`d because it needs real
//! Vulkan Video hardware.
//! GPU frame-hash parity tests (WP-D) — the two decode legs are `#[ignore]`d
//! because they need real Vulkan Video hardware; the coherence guards at the
//! bottom of this file are not, and run in ordinary CI.
//!
//! Run on a Vulkan-Video box with:
//!
@@ -9,86 +10,76 @@
//!
//! (RADV boxes additionally need `RADV_PERFTEST=video_decode`; multi-GPU boxes
//! pin the vendor with `PF_VKD_SMOKE_VENDOR=0x1002` / `0x10de`, same knob as
//! the smoke test.)
//! the smoke tests. Device bring-up lives in `tests/common/mod.rs`.)
//!
//! What it proves: H.264 decoding is exactly specified — every conformant
//! decoder must produce bit-identical output — so the vendored 25fps vector is
//! decoded through [`VkH264Decoder`], every output frame's NV12 planes are read
//! back (`vkCmdCopyImageToBuffer` on the graphics queue — GPU→CPU is fine in a
//! test; the pool grows TRANSFER_SRC via the decoder's `PF_VKD_TEST_READBACK`
//! hook), cropped to the display region, SHA-256-hashed in DISPLAY order and
//! compared against goldens from libavcodec's SOFTWARE decoder (the reference
//! implementation — provenance in `data/test-25fps.nv12.sha256`). ALL frames
//! are collected, including the tail [`VkH264Decoder::flush`] delivers, and the
//! frame count must match libavcodec's too.
//! What they prove: H.264 and H.265 decoding are both exactly specified — every
//! conformant decoder must produce bit-identical output — so the vendored 25fps
//! vector of each codec is decoded through [`VkH264Decoder`] / [`VkH265Decoder`],
//! every output frame's NV12 planes are read back (`vkCmdCopyImageToBuffer` on the
//! graphics queue — GPU→CPU is fine in a test; the pool grows TRANSFER_SRC via the
//! decoders' `PF_VKD_TEST_READBACK` hook), cropped to the display region,
//! SHA-256-hashed in DISPLAY order and compared against goldens from libavcodec's
//! SOFTWARE decoder (the reference implementation — provenance in
//! `data/test-25fps.nv12.sha256` and `data/test-25fps-h265.nv12.sha256`). ALL
//! frames are collected, including the tail `flush` delivers, and the frame count
//! must match libavcodec's too.
//!
//! Both legs run ONE body ([`collect_hashes`]) over `common::TestDecoder`, so the
//! H.265 leg cannot quietly test something weaker than the H.264 one. A box that
//! decodes only one codec runs that leg and reports the other as "no physical
//! device with VK_KHR_video_decode_…", which is a fact about the box.
//!
//! The readback follows the presenter's exact frame contract: wait the frame's
//! timeline `value`, round-trip the layout, signal `value + 1` in the SAME
//! submission, then `release_frame(frame, true)` — and every submission is
//! host-waited before the next decode, so nothing here races the decode queue.
//!
//! Reading a failure: frame 0 is IDR-only — if it already mismatches, suspect
//! Reading a failure: frame 0 is intra-only — if it already mismatches, suspect
//! the readback geometry (row pitch / crop) or intra decode; mismatches that
//! only appear on later frames point at inter prediction / DPB management.
#![deny(clippy::undocumented_unsafe_blocks)]
use std::io::Cursor;
mod common;
use ash::vk;
use ash::vk::Handle;
use cros_codecs::codec::h264::parser::Nalu;
use cros_codecs::codec::h264::parser::NaluType;
use common::TestDecoder;
use pf_vkdecode::DecodeStatus;
use pf_vkdecode::DecodedVkFrame;
use pf_vkdecode::DeviceHandles;
use pf_vkdecode::NoopQueueLock;
use pf_vkdecode::VkH264Decoder;
use pf_vkdecode::VkH265Decoder;
use sha2::Digest;
// 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"
);
/// Golden SHA-256 per display-order frame of the H.264 vector, from libavcodec
/// software decode (generation command + ffmpeg version in the file's header).
const GOLDENS_H264: &str = include_str!("data/test-25fps.nv12.sha256");
/// Golden SHA-256 per display-order frame, from libavcodec software decode
/// (generation command + ffmpeg version in the file's header).
const GOLDENS: &str = include_str!("data/test-25fps.nv12.sha256");
/// The H.265 twin, cross-checked between two independent FFmpeg builds (header).
const GOLDENS_H265: &str = include_str!("data/test-25fps-h265.nv12.sha256");
/// The vector's display (conformance-window) size; the goldens hash exactly
/// this region as tightly packed NV12.
const DISPLAY_W: u32 = 320;
const DISPLAY_H: u32 = 240;
const FRAME_BYTES: usize = (DISPLAY_W * DISPLAY_H * 3 / 2) as usize;
/// The H.264 vector's display (conformance-window) region; the goldens hash
/// exactly this as tightly packed NV12.
const DISPLAY_H264: (u32, u32) = (320, 240);
/// 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;
/// The H.265 vector's display region. Its SPS carries NO conformance window at
/// all, so this is also its coded size (golden header) — the two vectors merely
/// HAPPEN to share dimensions, which is why [`Readback`] takes the size as a
/// parameter instead of reading one global pair.
const DISPLAY_H265: (u32, u32) = (320, 240);
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);
/// Both vectors' picture format: 8-bit 4:2:0. H.264 is NV12 by envelope
/// (`derive_caps` wants nothing else), H.265 Main resolves to it from the SPS —
/// and [`DecodedVkFrame::format`] exists precisely so a pool misconfigured to
/// P010 fails loudly instead of hashing differently.
const EXPECTED_FORMAT: vk::Format = pf_vkdecode::NV12;
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
}
/// Every vendored 25fps vector, in both codecs, is 250 display frames.
const FRAME_COUNT: usize = 250;
/// The golden file's hash lines (comments and blanks skipped).
fn golden_hashes() -> Vec<&'static str> {
GOLDENS
.lines()
fn golden_hashes(file: &'static str) -> Vec<&'static str> {
file.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.collect()
@@ -110,6 +101,11 @@ fn sha256_hex(data: &[u8]) -> String {
/// of its video layout, copy, restore the layout, signal `value + 1` in the
/// same submission — and is host-waited (fence) before returning, so the test
/// stays fully serialized against the decode queue.
///
/// The display size is a CONSTRUCTION parameter, not a module constant: it sizes
/// the staging buffer and is the crop every read asserts against, and the two
/// vectors sharing 320x240 today is a coincidence that must not become the next
/// vector's silent corruption.
struct Readback {
device: ash::Device,
queue: vk::Queue,
@@ -119,6 +115,10 @@ struct Readback {
buffer: vk::Buffer,
memory: vk::DeviceMemory,
mapped: *const u8,
/// The display region every read copies, and the crop it requires.
display: (u32, u32),
/// `w * h * 3 / 2` — the tightly packed NV12 frame this buffer holds.
frame_bytes: usize,
}
impl Readback {
@@ -132,7 +132,18 @@ impl Readback {
pd: vk::PhysicalDevice,
device: &ash::Device,
graphics_qf: u32,
display: (u32, u32),
) -> Self {
let (width, height) = display;
// The two-plane copy below halves both dimensions for the R8G8 plane, so
// an odd display region would silently drop a chroma row/column.
assert_eq!(
(width % 2, height % 2),
(0, 0),
"the display region must be chroma-aligned"
);
let frame_bytes = (width * height * 3 / 2) as usize;
// SAFETY: fn contract — live device, queue 0 of this family exists.
let queue = unsafe { device.get_device_queue(graphics_qf, 0) };
let pool_ci = vk::CommandPoolCreateInfo::default()
@@ -153,7 +164,7 @@ impl Readback {
.expect("create the readback fence");
let buffer_ci = vk::BufferCreateInfo::default()
.size(FRAME_BYTES as u64)
.size(frame_bytes as u64)
.usage(vk::BufferUsageFlags::TRANSFER_DST)
.sharing_mode(vk::SharingMode::EXCLUSIVE);
// SAFETY: live device; destroyed in `destroy`.
@@ -198,6 +209,8 @@ impl Readback {
buffer,
memory,
mapped,
display,
frame_bytes,
}
}
@@ -210,14 +223,16 @@ impl Readback {
/// # Safety
///
/// `frame` was delivered by a decoder on this device and is not yet
/// released; its image carries TRANSFER_SRC usage (the decoder's
/// released; its image carries TRANSFER_SRC usage (the decoders'
/// `PF_VKD_TEST_READBACK` hook); no other work uses the graphics queue or
/// this frame's image concurrently (the test is fully serialized).
unsafe fn read_nv12(&self, frame: &DecodedVkFrame) -> Vec<u8> {
let (width, height) = self.display;
assert_eq!(
(frame.crop.width, frame.crop.height),
(DISPLAY_W, DISPLAY_H),
"the vector's display size (goldens hash exactly this region)"
self.display,
"the vector's display size this readback was built for (the goldens \
hash exactly this region)"
);
assert_eq!(
(frame.crop.x % 2, frame.crop.y % 2),
@@ -279,13 +294,13 @@ impl Readback {
z: 0,
},
image_extent: vk::Extent3D {
width: DISPLAY_W,
height: DISPLAY_H,
width,
height,
depth: 1,
},
},
vk::BufferImageCopy {
buffer_offset: u64::from(DISPLAY_W * DISPLAY_H),
buffer_offset: u64::from(width * height),
buffer_row_length: 0,
buffer_image_height: 0,
image_subresource: layers(vk::ImageAspectFlags::PLANE_1),
@@ -295,14 +310,14 @@ impl Readback {
z: 0,
},
image_extent: vk::Extent3D {
width: DISPLAY_W / 2,
height: DISPLAY_H / 2,
width: width / 2,
height: height / 2,
depth: 1,
},
},
];
// SAFETY: the image is in TRANSFER_SRC_OPTIMAL via the barrier above and
// carries TRANSFER_SRC usage (fn contract); the buffer's FRAME_BYTES
// carries TRANSFER_SRC usage (fn contract); the buffer's `frame_bytes`
// exactly spans the two packed regions.
unsafe {
self.device.cmd_copy_image_to_buffer(
@@ -375,10 +390,10 @@ impl Readback {
// SAFETY: the fence was observed signalled above.
unsafe { self.device.reset_fences(&[self.fence]) }.expect("reset the readback fence");
// SAFETY: `mapped` points at FRAME_BYTES host-coherent bytes; the fence
// wait (plus the HOST_READ barrier) ordered the device writes before
// this host read.
unsafe { std::slice::from_raw_parts(self.mapped, FRAME_BYTES) }.to_vec()
// SAFETY: `mapped` points at `frame_bytes` host-coherent bytes (the
// buffer was created at that size); the fence wait (plus the HOST_READ
// barrier) ordered the device writes before this host read.
unsafe { std::slice::from_raw_parts(self.mapped, self.frame_bytes) }.to_vec()
}
/// # Safety
@@ -401,7 +416,7 @@ impl Readback {
/// the presenter write-back the readback enqueued). `index` is the display
/// index the hash will land at.
fn consume_frame(
decoder: &mut VkH264Decoder,
decoder: &mut impl TestDecoder,
readback: &Readback,
frame: &DecodedVkFrame,
index: usize,
@@ -412,6 +427,13 @@ fn consume_frame(
"frame {index}: decode op not COMPLETE\n state: {}",
decoder.debug_snapshot()
);
// A pool built for the wrong picture format would decode correctly and then
// hash differently for a reason no mismatch report could explain
// (`DecodedVkFrame::format` docs) — refuse it here instead.
assert_eq!(
frame.format, EXPECTED_FORMAT,
"frame {index}: 8-bit 4:2:0 vector must decode into an NV12 pool"
);
// SAFETY: the frame is delivered and unreleased on the readback's device;
// the pool carries TRANSFER_SRC (PF_VKD_TEST_READBACK was set before the
// decoder's first decode); the test is fully serialized, so nothing else
@@ -423,217 +445,49 @@ fn consume_frame(
sha256_hex(&nv12)
}
#[test]
#[ignore = "needs a Vulkan Video H.264 decode device (fleet boxes; see module docs)"]
fn every_frame_hashes_bit_identical_to_libavcodec() {
// The decoder reads this at session creation (first decode call): pool
// images grow TRANSFER_SRC so vkCmdCopyImageToBuffer is legal.
std::env::set_var("PF_VKD_TEST_READBACK", "1");
let goldens = golden_hashes();
assert_eq!(
goldens.len(),
250,
"the golden file carries one hash per libavcodec frame"
);
// ---- 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");
// Optional vendor pin (`PF_VKD_SMOKE_VENDOR`, hex `0x1002` or decimal) — the
// smoke test's knob, same semantics: makes multi-GPU runs attributable.
let vendor_filter: Option<u32> = std::env::var("PF_VKD_SMOKE_VENDOR").ok().map(|raw| {
let trimmed = raw.trim();
trimmed
.strip_prefix("0x")
.or_else(|| trimmed.strip_prefix("0X"))
.map_or_else(|| trimmed.parse(), |hex| u32::from_str_radix(hex, 16))
.unwrap_or_else(|_| panic!("PF_VKD_SMOKE_VENDOR is not a PCI vendor id: {raw:?}"))
});
// ---- 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 props = unsafe { instance.get_physical_device_properties(pd) };
if vendor_filter.is_some_and(|vendor| props.vendor_id != vendor) {
continue;
}
// 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);
}
}
// Unlike the smoke test this one NEEDS a graphics queue (the readback
// runs there), so a device without one is skipped, not defaulted.
if let (Some(decode), Some(graphics)) = (decode_qf, graphics_qf) {
picked = Some((pd, decode, graphics));
break;
}
}
let (pd, decode_qf, graphics_qf) = picked.expect(
"a physical device with VK_KHR_video_decode_h264, a decode queue and a graphics queue",
);
// Attribution header: which device (and driver) this run actually exercised.
{
let mut driver_props = vk::PhysicalDeviceDriverProperties::default();
let mut props2 = vk::PhysicalDeviceProperties2::default().push_next(&mut driver_props);
// SAFETY: live physical device; the chain fills the Vulkan 1.2 core
// driver-identity struct.
unsafe { instance.get_physical_device_properties2(pd, &mut props2) };
let props = props2.properties;
eprintln!(
"picked: {:?} vendor=0x{:04x} driver={:?} info={:?}",
props.device_name_as_c_str().unwrap_or(c"?"),
props.vendor_id,
driver_props.driver_name_as_c_str().unwrap_or(c"?"),
driver_props.driver_info_as_c_str().unwrap_or(c"?"),
);
}
// ---- 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");
// ---- decode the WHOLE vector, hash every display-order frame ----
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,
};
/// Decode every AU, hash every delivered frame in display order, including the
/// tail `flush` hands back. One body for both codecs.
fn collect_hashes(
decoder: &mut impl TestDecoder,
readback: &Readback,
aus: &[&[u8]],
) -> Vec<String> {
let mut hashes: Vec<String> = Vec::new();
{
// 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");
// SAFETY: live instance/device; queue 0 of `graphics_qf` was created
// above; destroyed at the end of this block after its last read.
let readback = unsafe { Readback::new(&instance, pd, &device, graphics_qf) };
let aus = split_into_aus(TEST_25FPS);
for (au_index, au) in aus.iter().enumerate() {
let mut next = decoder.decode(au).unwrap_or_else(|e| {
panic!(
"AU {au_index}: decode failed: {e}\n state: {}",
decoder.debug_snapshot()
)
});
while let Some(frame) = next {
let hash = consume_frame(&mut decoder, &readback, &frame, hashes.len());
hashes.push(hash);
next = decoder.take_ready();
}
}
// The decoder emits in bumping (display) order and the stream may hold
// frames — the flush tail belongs in the comparison too.
decoder.flush();
while let Some(frame) = decoder.take_ready() {
let hash = consume_frame(&mut decoder, &readback, &frame, hashes.len());
for (au_index, au) in aus.iter().enumerate() {
let mut next = decoder.decode(au).unwrap_or_else(|e| {
panic!(
"AU {au_index}: decode failed: {e}\n state: {}",
decoder.debug_snapshot()
)
});
while let Some(frame) = next {
let hash = consume_frame(decoder, readback, &frame, hashes.len());
hashes.push(hash);
next = decoder.take_ready();
}
eprintln!("final state: {}", decoder.debug_snapshot());
// SAFETY: every readback was fence-waited inside `read_nv12`; nothing
// else references its handles.
unsafe { readback.destroy() };
}
// ---- teardown (decoder is gone; its Drop drained the queue) ----
// SAFETY: every object created from the device (the decoder's pools/session,
// the readback's buffer/pool/fence) was destroyed above.
unsafe {
device.destroy_device(None);
instance.destroy_instance(None);
// The decoders emit in bumping (display) order and a stream may hold frames —
// the flush tail belongs in the comparison too.
decoder.flush();
while let Some(frame) = decoder.take_ready() {
let hash = consume_frame(decoder, readback, &frame, hashes.len());
hashes.push(hash);
}
eprintln!(
"final state: {} status_queries={}",
decoder.debug_snapshot(),
decoder.status_queries()
);
hashes
}
// ---- the verdict ----
/// The verdict, run AFTER teardown so a mismatch panic cannot leave the device
/// alive.
fn assert_bit_identical(hashes: &[String], goldens: &[&str], codec: &str) {
assert_eq!(
hashes.len(),
goldens.len(),
"frame count diverges from libavcodec ({} decoded vs {} golden)",
"{codec}: frame count diverges from libavcodec ({} decoded vs {} golden)",
hashes.len(),
goldens.len()
);
@@ -649,14 +503,296 @@ fn every_frame_hashes_bit_identical_to_libavcodec() {
assert_eq!(
mismatches,
0,
"{mismatches}/{} frames diverge from libavcodec (first 10 printed above; \
frame 0 is IDR-only — if IT mismatches, suspect readback geometry \
(pitch/crop) or intra decode; later-only mismatches point at inter \
prediction / DPB management)",
"{codec}: {mismatches}/{} frames diverge from libavcodec (first 10 printed \
above; frame 0 is intra-only — if IT mismatches, suspect readback \
geometry (pitch/crop) or intra decode; later-only mismatches point at \
inter prediction / DPB management)",
hashes.len()
);
eprintln!(
"{} frames bit-identical to libavcodec software decode",
"{codec}: {} frames bit-identical to libavcodec software decode",
hashes.len()
);
}
#[test]
#[ignore = "needs a Vulkan Video H.264 decode device (fleet boxes; see module docs)"]
fn h264_every_frame_hashes_bit_identical_to_libavcodec() {
// One codec at a time on the device, and the `set_var` below happens only
// under this lock (see `common::gpu_lock`).
let _gpu = common::gpu_lock();
// The decoder reads this at session creation (first decode call): pool
// images grow TRANSFER_SRC so vkCmdCopyImageToBuffer is legal.
std::env::set_var("PF_VKD_TEST_READBACK", "1");
let goldens = golden_hashes(GOLDENS_H264);
assert_eq!(
goldens.len(),
FRAME_COUNT,
"the golden file carries one hash per libavcodec frame"
);
let setup = common::bring_up(&common::Request {
codec: common::H264,
// Unlike the smoke legs this one NEEDS a graphics queue (the readback
// records there), so a device without one is skipped, not defaulted.
graphics: common::Graphics::Required,
report_families: true,
});
let handles = setup.handles();
let hashes = {
// SAFETY: `setup` outlives this block (destroyed below, after the decoder
// and readback drop at the block's end), it was created with the H.264
// decode extensions + timeline/sync2 features, and its queue fields name
// the families/queues it created.
let mut decoder = unsafe { VkH264Decoder::new(&handles, Box::new(NoopQueueLock)) }
.expect("wrap the device");
// SAFETY: live instance/device; queue 0 of `graphics_qf` was created by
// the bring-up; destroyed at the end of this block after its last read.
let readback = unsafe {
Readback::new(
&setup.instance,
setup.pd,
&setup.device,
setup.graphics_qf,
DISPLAY_H264,
)
};
let hashes = collect_hashes(
&mut decoder,
&readback,
&common::split_h264_aus(common::TEST_25FPS_H264),
);
// SAFETY: every readback was fence-waited inside `read_nv12`; nothing
// else references its handles.
unsafe { readback.destroy() };
hashes
};
// SAFETY: the decoder is gone (its Drop drained the queue and destroyed its
// session/pools), the readback's handles are destroyed, and nothing else
// references the setup's handles.
unsafe { setup.destroy() };
assert_bit_identical(&hashes, &goldens, "H.264");
}
#[test]
#[ignore = "needs a Vulkan Video H.265 decode device (fleet boxes; see module docs)"]
fn h265_every_frame_hashes_bit_identical_to_libavcodec() {
// As the H.264 leg: one codec at a time, `set_var` under the lock.
let _gpu = common::gpu_lock();
std::env::set_var("PF_VKD_TEST_READBACK", "1");
let goldens = golden_hashes(GOLDENS_H265);
assert_eq!(
goldens.len(),
FRAME_COUNT,
"the golden file carries one hash per libavcodec frame"
);
let setup = common::bring_up(&common::Request {
codec: common::H265,
graphics: common::Graphics::Required,
report_families: true,
});
let handles = setup.handles();
let hashes = {
// SAFETY: as the H.264 leg — `setup` outlives this block and was created
// with the H.265 decode extensions + timeline/sync2 features.
let mut decoder = unsafe { VkH265Decoder::new(&handles, Box::new(NoopQueueLock)) }
.expect("wrap the device");
// The construction-time shape gate the client's ladder relies on, on the
// vector's own facts (Main, 4:2:0, 8-bit → NV12): a device that cannot
// host the combination refuses here with a caps reason instead of failing
// mid-stream.
decoder
.probe_stream_support(1, 0)
.expect("the box must host H.265 Main 8-bit 4:2:0 (the vector's shape)");
// SAFETY: as the H.264 leg — live instance/device, queue 0 of
// `graphics_qf` exists; destroyed at the end of this block.
let readback = unsafe {
Readback::new(
&setup.instance,
setup.pd,
&setup.device,
setup.graphics_qf,
DISPLAY_H265,
)
};
let hashes = collect_hashes(
&mut decoder,
&readback,
&common::split_h265_aus(common::TEST_25FPS_H265),
);
// SAFETY: every readback was fence-waited inside `read_nv12`; nothing
// else references its handles.
unsafe { readback.destroy() };
hashes
};
// SAFETY: as the H.264 leg — decoder and readback are gone.
unsafe { setup.destroy() };
assert_bit_identical(&hashes, &goldens, "H.265");
}
// ---------------------------------------------------------------------------
// CPU coherence guards — NOT `#[ignore]`d.
//
// The legs above only run on the fleet, so without these nothing in ordinary CI
// notices that a re-synced vendored vector, a golden regeneration or an edit to
// `common`'s AU splitters has made the two disagree. They would then fail on the
// fleet as a frame-count mismatch, which reads like a decoder defect and costs a
// hardware round trip to disprove.
//
// Each guard pins the whole chain the parity verdict rests on: the AU split, the
// planner's output count, and the golden line count — with NO GPU involved.
// ---------------------------------------------------------------------------
#[test]
fn h265_goldens_and_au_split_agree_with_the_planner() {
use pf_bitstream::h265::H265Planner;
let goldens = golden_hashes(GOLDENS_H265);
assert_eq!(
goldens.len(),
FRAME_COUNT,
"data/test-25fps-h265.nv12.sha256 must carry one hash per display frame"
);
assert!(
goldens
.iter()
.all(|line| line.len() == 64 && line.bytes().all(|b| b.is_ascii_hexdigit())),
"every golden line is a bare lowercase SHA-256 hex digest"
);
// The AU split the parity leg feeds the decoder. `common::split_h265_aus` is
// the copy of pf-bitstream's private splitter, and it keys on HEVC's 2-byte
// NAL header — a `+ 1` there (H.264's offset) silently merges or splits AUs.
let aus = common::split_h265_aus(common::TEST_25FPS_H265);
assert_eq!(
aus.len(),
FRAME_COUNT,
"the vendored H.265 vector is {FRAME_COUNT} access units \
(pf-bitstream's own planner test pins the same number)"
);
// Walk the CPU planner over the same AUs: it is the authority on how many
// frames the GPU leg can possibly deliver, because the decoder builds exactly
// one delivered frame per `dpb.outputs` id (plus the flush tail).
let mut planner = H265Planner::new();
let mut outputs = 0usize;
let mut iraps = 0usize;
for (index, au) in aus.iter().enumerate() {
let plan = planner.plan_au(au).unwrap_or_else(|e| {
panic!(
"AU {index}: the clean vector must plan without errors, got {e:?} \
— if this is RaslSkipped the vector has gained CRA/RASL pictures \
and the parity legs' expected frame count needs rederiving"
);
});
outputs += plan.dpb.outputs.len();
iraps += usize::from(plan.picture.is_irap);
// Pin the picture shape the H.265 legs hard-code. They call
// `probe_stream_support(1, 0)` (4:2:0, 8-bit) and assert the NV12 output
// format; a re-synced Main-10 or 4:4:4 vector would make both of those
// silently probe and expect the WRONG profile on the fleet, which is a
// confusing hardware-only failure. Fail here, on CPU, with the reason.
assert_eq!(
(
plan.picture.chroma_format_idc,
plan.picture.bit_depth_luma_minus8
),
(1, 0),
"AU {index}: the vendored H.265 vector must stay Main 4:2:0 8-bit — \
the parity and smoke legs hard-code probe_stream_support(1, 0) and \
an NV12 output format, so a re-synced vector of another shape needs \
both legs updated, not just the goldens"
);
if index == 0 {
assert!(plan.picture.is_idr, "the vector opens with an IDR");
assert_eq!(
(plan.picture.coded_width, plan.picture.coded_height),
DISPLAY_H265,
"the vector is 320x240"
);
assert_eq!(
(
plan.picture.display_crop.x,
plan.picture.display_crop.y,
plan.picture.display_crop.width,
plan.picture.display_crop.height,
),
(0, 0, DISPLAY_H265.0, DISPLAY_H265.1),
"the vector carries NO conformance window — coded size IS display \
size (the golden header's claim, and what `Readback` asserts)"
);
}
}
outputs += planner.flush().outputs.len();
assert_eq!(
outputs,
goldens.len(),
"the planner outputs {outputs} pictures but the goldens carry {} hashes — \
the parity leg's frame-count assertion would fail on hardware for a \
reason that has nothing to do with the GPU",
goldens.len()
);
// No CRA/BLA anywhere means `PlanError::RaslSkipped` — the Ok-skip that
// returns `Ok(None)` rather than an error (h265 module docs, and
// `VkH265Decoder::decode`'s RASL arm) — is UNREACHABLE on this vector, so the
// count above cannot be perturbed by it. If a re-synced vector ever opens with
// a CRA, this assertion fires first and says where to look.
assert_eq!(
iraps, 1,
"the vector holds exactly one IRAP (the opening IDR); a CRA/BLA would make \
RASL skips reachable and the expected frame count needs rederiving"
);
}
#[test]
fn h264_goldens_and_au_split_agree_with_the_planner() {
use pf_bitstream::h264::H264Planner;
let goldens = golden_hashes(GOLDENS_H264);
assert_eq!(
goldens.len(),
FRAME_COUNT,
"data/test-25fps.nv12.sha256 must carry one hash per display frame"
);
assert!(
goldens
.iter()
.all(|line| line.len() == 64 && line.bytes().all(|b| b.is_ascii_hexdigit())),
"every golden line is a bare lowercase SHA-256 hex digest"
);
let aus = common::split_h264_aus(common::TEST_25FPS_H264);
assert_eq!(
aus.len(),
FRAME_COUNT,
"the vendored H.264 vector is {FRAME_COUNT} access units"
);
let mut planner = H264Planner::new();
let mut outputs = 0usize;
for (index, au) in aus.iter().enumerate() {
let plan = planner
.plan_au(au)
.unwrap_or_else(|e| panic!("AU {index}: the clean vector must plan, got {e:?}"));
outputs += plan.dpb.outputs.len();
}
outputs += planner.flush().outputs.len();
assert_eq!(
outputs,
goldens.len(),
"the planner outputs {outputs} pictures but the goldens carry {} hashes",
goldens.len()
);
}
+283 -284
View File
@@ -1,4 +1,4 @@
//! GPU smoke test — `#[ignore]`d because it needs real Vulkan Video hardware.
//! GPU smoke tests — `#[ignore]`d because they need real Vulkan Video hardware.
//!
//! Run on a Vulkan-Video box with:
//!
@@ -10,11 +10,18 @@
//! 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;
//! `VK_KHR_video_decode_queue` and the leg's codec extension
//! (`VK_KHR_video_decode_h264` / `VK_KHR_video_decode_h265`), with a queue
//! family carrying `VIDEO_DECODE_KHR` ops for that codec;
//! - `timelineSemaphore` + `synchronization2` feature support (Vulkan 1.3 core).
//!
//! What it proves: device wrap → caps query/derivation on REAL caps → session +
//! One leg per codec, running the SAME body ([`smoke`]) over the vendored 25fps
//! vector of that codec — a box that decodes only one of the two runs that leg and
//! reports the other as "no physical device with VK_KHR_video_decode_…", which is
//! a fact about the box rather than a failure. Device bring-up lives in
//! `tests/common/mod.rs`.
//!
//! What they prove: device wrap → caps query/derivation on REAL caps → session +
//! parameters creation → the decoupled picture pool → 48 AUs of the vendored
//! 25fps vector decoded through `vkCmdDecodeVideoKHR` — well past DPB-full, so
//! slot re-activation binds fresh pool images repeatedly — while the consumer
@@ -23,308 +30,300 @@
//! Every frame's RESULT_STATUS_ONLY query must read COMPLETE before its
//! release. This is the regression test for the .25 field failure class: any
//! pool sizing that ignores the stream's DPB depth or the client's hold depth
//! starves exactly here. What it deliberately does NOT prove (WP-D on-glass):
//! pixel correctness vs the ffmpeg rung, presenter interop (the `value + 1`
//! signal-back — no presenter runs here, so releases pass `false`), soak, and
//! both vendors' DPB arrangements at once (each box exercises only its own).
//! starves exactly here. What they deliberately do NOT prove (that is
//! `gpu_parity`'s and WP-D on-glass's ground): pixel correctness vs the ffmpeg
//! rung, presenter interop (the `value + 1` signal-back — no presenter runs here,
//! so releases pass `false`), soak, and both vendors' DPB arrangements at once
//! (each box exercises only its own).
#![deny(clippy::undocumented_unsafe_blocks)]
use std::io::Cursor;
mod common;
use ash::vk;
use ash::vk::Handle;
use cros_codecs::codec::h264::parser::Nalu;
use cros_codecs::codec::h264::parser::NaluType;
use common::TestDecoder;
use pf_vkdecode::DecodeStatus;
use pf_vkdecode::DeviceHandles;
use pf_vkdecode::DecodedVkFrame;
use pf_vkdecode::NoopQueueLock;
use pf_vkdecode::VkH264Decoder;
use pf_vkdecode::VkH265Decoder;
// 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"
);
/// AUs fed: far past either vector's DPB depth (`max_dpb_frames = 7` for the
/// H.264 clip), so DPB slots re-activate onto fresh pool images repeatedly.
const AUS: usize = 48;
/// The REAL client's consumption shape: the consumer holds four delivered frames
/// and releases only the oldest beyond that (its channels + preroll + in-flight
/// present hold ~4-7).
const CLIENT_HOLD: usize = 4;
/// 48 AUs may legitimately leave a few pictures buffered for reorder; anything
/// below this is a delivery failure, not reordering.
const MIN_DELIVERED: usize = 40;
/// 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;
/// The geometry one leg's vector must deliver.
struct Geometry {
/// The vector's display (conformance-window) region.
display: (u32, u32),
/// The ALLOCATED extent, when the leg knows it for a fact.
///
/// `pictureAccessGranularity` rounds the coded size up, so this is a
/// per-vector AND per-driver fact, not a property of the bitstream. The H.264
/// leg has asserted `(320, 240)` on the fleet since WP-B and keeps asserting
/// it; the H.265 leg has NO hardware evidence yet, so it asserts only the
/// invariant that always holds (allocated >= display) and PRINTS what it got
/// — which is exactly what a first fleet run needs in order to pin it later.
exact_coded: Option<(u32, u32)>,
}
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;
/// Decode [`AUS`] access units while holding [`CLIENT_HOLD`] frames, asserting the
/// decode verdict of every frame before its release.
///
/// One body for both codecs (over `common::TestDecoder`) so "the H.265 leg proves
/// what the H.264 leg proves" is structural rather than a claim about two copies.
fn smoke(decoder: &mut impl TestDecoder, aus: &[&[u8]], geometry: &Geometry) {
// The smoke legs exist to prove the PRODUCTION pool arrangement survives 48
// AUs at the client's hold depth. `PF_VKD_TEST_READBACK` adds TRANSFER_SRC to
// the picture pool for whoever sets it, so a shell that exported it while
// iterating on the parity legs would quietly test a pool production never
// builds — and the leg would still pass. Refuse rather than mislead.
assert!(
std::env::var_os("PF_VKD_TEST_READBACK").is_none(),
"PF_VKD_TEST_READBACK is set in the environment: it grows the picture pool \
a usage flag production never carries, so this leg would no longer be \
testing the production pool arrangement. Unset it for the smoke legs \
(the parity legs set it themselves, under the same GPU lock)."
);
// Status is read (COMPLETE required, the program's whole point) as each frame
// retires; `take_ready` is drained every AU so nothing is stranded. No
// presenter runs here, so releases report `presenter_signaled = false` (no
// `value + 1` write-back).
let mut held: std::collections::VecDeque<DecodedVkFrame> = std::collections::VecDeque::new();
let mut delivered = 0usize;
let mut geometry_checked = false;
for (index, au) in aus.iter().enumerate().take(AUS) {
let mut next = decoder.decode(au).unwrap_or_else(|e| {
panic!(
"AU {index}: decode failed: {e}\n state: {}",
decoder.debug_snapshot()
)
});
while let Some(frame) = next {
if !geometry_checked {
assert_eq!(
(frame.crop.width, frame.crop.height),
geometry.display,
"the vector's display region"
);
assert!(
frame.coded_width >= frame.crop.width
&& frame.coded_height >= frame.crop.height,
"the ALLOCATED extent ({}x{}) must cover the display region ({}x{})",
frame.coded_width,
frame.coded_height,
frame.crop.width,
frame.crop.height,
);
if let Some(exact) = geometry.exact_coded {
assert_eq!(
(frame.coded_width, frame.coded_height),
exact,
"ALLOCATED extent (this vector needs no granularity padding here)"
);
}
// A pool built for the wrong picture format decodes and then
// renders with the wrong maths (`DecodedVkFrame::format` docs);
// both vectors are 8-bit 4:2:0, so both must land on NV12.
assert_eq!(
frame.format,
pf_vkdecode::NV12,
"8-bit 4:2:0 vector must decode into an NV12 pool"
);
assert_ne!(frame.image, vk::Image::null());
assert_ne!(frame.semaphore, vk::Semaphore::null());
assert!(frame.value > 0);
eprintln!(
"geometry: allocated {}x{} display {}x{} format {:?} layout {:?}",
frame.coded_width,
frame.coded_height,
frame.crop.width,
frame.crop.height,
frame.format,
frame.layout,
);
geometry_checked = true;
}
held.push_back(frame);
delivered += 1;
// Steady state: keep CLIENT_HOLD frames in hand, retire beyond.
while held.len() > CLIENT_HOLD {
let oldest = held.pop_front().expect("nonempty");
assert_eq!(
decoder.wait_status(&oldest),
DecodeStatus::Ok,
"AU {index}: decode op not COMPLETE\n state: {}",
decoder.debug_snapshot()
);
decoder
.release_frame(&oldest, false)
.unwrap_or_else(|e| panic!("AU {index}: release failed: {e}"));
}
next = decoder.take_ready();
}
au_has_slice |= is_slice;
}
aus.push(&stream[au_start..]);
aus
// Retire the tail the consumer still holds.
for frame in held.drain(..) {
assert_eq!(decoder.wait_status(&frame), DecodeStatus::Ok);
decoder
.release_frame(&frame, false)
.expect("tail frames release");
}
assert!(
delivered >= MIN_DELIVERED,
"expected at least {MIN_DELIVERED} delivered frames from {AUS} AUs, got {delivered}"
);
// The DPB mode the caps derivation chose, and whether this box answers per-op
// status at all — a passing run should say so too (failure paths already carry
// the snapshot).
eprintln!(
"final state: {} status_queries={}",
decoder.debug_snapshot(),
decoder.status_queries()
);
}
#[test]
#[ignore = "needs a Vulkan Video H.264 decode device (fleet boxes; see module docs)"]
fn decodes_48_aus_holding_four_frames_like_the_real_client() {
// ---- 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");
fn h264_decodes_48_aus_holding_four_frames_like_the_real_client() {
// One codec at a time on the device (see `common::gpu_lock`).
let _gpu = common::gpu_lock();
// Optional vendor pin (`PF_VKD_SMOKE_VENDOR`, hex `0x1002` or decimal): multi-GPU
// boxes enumerate several decode-capable devices and first-match hides all but
// one — the pin makes a run attributable to a specific vendor's driver.
let vendor_filter: Option<u32> = std::env::var("PF_VKD_SMOKE_VENDOR").ok().map(|raw| {
let trimmed = raw.trim();
trimmed
.strip_prefix("0x")
.or_else(|| trimmed.strip_prefix("0X"))
.map_or_else(|| trimmed.parse(), |hex| u32::from_str_radix(hex, 16))
.unwrap_or_else(|_| panic!("PF_VKD_SMOKE_VENDOR is not a PCI vendor id: {raw:?}"))
let setup = common::bring_up(&common::Request {
codec: common::H264,
// The smoke legs submit nothing outside the decoder, so a decode-only
// device is usable (and its EXCLUSIVE pool sharing is worth exercising).
graphics: common::Graphics::DecodeFamilyIsFine,
report_families: true,
});
// ---- 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 props = unsafe { instance.get_physical_device_properties(pd) };
if vendor_filter.is_some_and(|vendor| props.vendor_id != vendor) {
continue;
}
// 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
// Print each family's video ops + RESULT_STATUS query support — the
// context every failure report needs first (which mode the box runs and
// whether per-op status verdicts even exist here; RADV: they do not,
// and recording one hangs the VCN — the 2026-08 .25 lesson).
{
let mut status_props =
vec![vk::QueueFamilyQueryResultStatusPropertiesKHR::default(); family_count];
let mut families2: Vec<vk::QueueFamilyProperties2<'_>> = status_props
.iter_mut()
.map(|s| vk::QueueFamilyProperties2::default().push_next(s))
.collect();
// SAFETY: as the query above.
unsafe { instance.get_physical_device_queue_family_properties2(pd, &mut families2) };
drop(families2);
for (i, s) in status_props.iter().enumerate() {
eprintln!(
"family {i}: flags={:?} video_ops={:?} query_result_status={}",
flags_per_family[i],
video_props[i].video_codec_operations,
s.query_result_status_support != vk::FALSE,
);
}
}
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");
// Attribution header: which device (and driver) this run actually exercised.
let handles = setup.handles();
{
let mut driver_props = vk::PhysicalDeviceDriverProperties::default();
let mut props2 = vk::PhysicalDeviceProperties2::default().push_next(&mut driver_props);
// SAFETY: live physical device; the chain fills the Vulkan 1.2 core
// driver-identity struct.
unsafe { instance.get_physical_device_properties2(pd, &mut props2) };
let props = props2.properties;
eprintln!(
"picked: {:?} vendor=0x{:04x} driver={:?} info={:?}",
props.device_name_as_c_str().unwrap_or(c"?"),
props.vendor_id,
driver_props.driver_name_as_c_str().unwrap_or(c"?"),
driver_props.driver_info_as_c_str().unwrap_or(c"?"),
);
}
// ---- 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.
// SAFETY: `setup` outlives this block (destroyed below, after the decoder
// drops at the block's end), it was created with the H.264 decode
// extensions + timeline/sync2 features, and its queue fields name the
// families/queues it created.
let mut decoder = unsafe { VkH264Decoder::new(&handles, Box::new(NoopQueueLock)) }
.expect("wrap the device");
// 48 AUs — far past the vector's DPB depth (max_dpb_frames = 7), so DPB
// slots re-activate onto fresh pool images repeatedly — with the REAL
// client's consumption shape: the consumer HOLDS four delivered frames
// and releases only the oldest beyond that (its channels + preroll +
// in-flight present hold ~4-7). Status is read (COMPLETE required, the
// program's whole point) as each frame retires; `take_ready` is drained
// every AU so nothing is stranded. No presenter runs here, so releases
// report `presenter_signaled = false` (no `value+1` write-back).
const CLIENT_HOLD: usize = 4;
let aus = split_into_aus(TEST_25FPS);
let mut held: std::collections::VecDeque<pf_vkdecode::DecodedVkFrame> =
std::collections::VecDeque::new();
let mut delivered = 0usize;
let mut geometry_checked = false;
for (index, au) in aus.iter().enumerate().take(48) {
let mut next = decoder.decode(au).unwrap_or_else(|e| {
panic!(
"AU {index}: decode failed: {e}\n state: {}",
decoder.debug_snapshot()
)
});
while let Some(frame) = next {
if !geometry_checked {
assert_eq!(
(frame.coded_width, frame.coded_height),
(320, 240),
"ALLOCATED extent (320x240 needs no granularity padding here)"
);
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;
}
held.push_back(frame);
delivered += 1;
// Steady state: keep CLIENT_HOLD frames in hand, retire beyond.
while held.len() > CLIENT_HOLD {
let oldest = held.pop_front().expect("nonempty");
assert_eq!(
decoder.wait_status(&oldest),
DecodeStatus::Ok,
"AU {index}: decode op not COMPLETE\n state: {}",
decoder.debug_snapshot()
);
decoder
.release_frame(&oldest, false)
.unwrap_or_else(|e| panic!("AU {index}: release failed: {e}"));
}
next = decoder.take_ready();
}
}
// Retire the tail the consumer still holds.
for frame in held.drain(..) {
assert_eq!(decoder.wait_status(&frame), DecodeStatus::Ok);
decoder
.release_frame(&frame, false)
.expect("tail frames release");
}
assert!(
delivered >= 40,
"expected at least 40 delivered frames from 48 AUs, got {delivered}"
smoke(
&mut decoder,
&common::split_h264_aus(common::TEST_25FPS_H264),
&Geometry {
display: (320, 240),
exact_coded: Some((320, 240)),
},
);
// The DPB mode the caps derivation chose — a passing run should say so
// too (failure paths already carry it via the same snapshot).
eprintln!("final state: {}", decoder.debug_snapshot());
}
// ---- 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);
}
// SAFETY: the decoder is gone (its Drop drained the queue and destroyed its
// session/pools), and nothing else references the setup's handles.
unsafe { setup.destroy() };
}
#[test]
#[ignore = "needs a Vulkan Video H.265 decode device (fleet boxes; see module docs)"]
fn h265_decodes_48_aus_holding_four_frames_like_the_real_client() {
// One codec at a time on the device (see `common::gpu_lock`).
let _gpu = common::gpu_lock();
let setup = common::bring_up(&common::Request {
codec: common::H265,
graphics: common::Graphics::DecodeFamilyIsFine,
report_families: true,
});
let handles = setup.handles();
{
// SAFETY: as the H.264 leg — `setup` outlives this block and was created
// with the H.265 decode extensions + timeline/sync2 features.
let mut decoder = unsafe { VkH265Decoder::new(&handles, Box::new(NoopQueueLock)) }
.expect("wrap the device");
// The construction-time shape gate the client's ladder relies on, on the
// vector's own facts (Main, 4:2:0, 8-bit → NV12). Called here rather than
// left to the first AU so a device that cannot host the combination says
// so as a refusal with a caps reason, not as a mid-stream decode failure —
// and so this path has hardware evidence at all.
decoder
.probe_stream_support(1, 0)
.expect("the box must host H.265 Main 8-bit 4:2:0 (the vector's shape)");
smoke(
&mut decoder,
&common::split_h265_aus(common::TEST_25FPS_H265),
&Geometry {
display: (320, 240),
// No hardware evidence for HEVC's `pictureAccessGranularity` on
// any fleet box yet; the leg prints what it allocates instead of
// asserting a number nobody has observed.
exact_coded: None,
},
);
}
// SAFETY: as the H.264 leg — the decoder is gone and nothing else references
// the setup's handles.
unsafe { setup.destroy() };
}
// ---------------------------------------------------------------------------
// CPU coherence guard — NOT `#[ignore]`d.
//
// The legs above only run on the fleet, so [`MIN_DELIVERED`] would otherwise be a
// number copied from the H.264 leg and never checked against the H.265 vector's
// own reorder depth. It is the CPU planner that decides how many of the first
// [`AUS`] pictures can possibly be delivered — the decoder builds exactly one
// frame per `dpb.outputs` id — so the floor is checkable here, without a GPU, and
// a re-synced vector that reorders more deeply fails HERE instead of looking like
// a pool-starvation bug on hardware.
// ---------------------------------------------------------------------------
#[test]
fn the_delivery_floor_is_under_what_the_planners_emit_from_the_first_48_aus() {
let h264 = {
let mut planner = pf_bitstream::h264::H264Planner::new();
common::split_h264_aus(common::TEST_25FPS_H264)
.iter()
.take(AUS)
.enumerate()
.map(|(index, au)| {
planner
.plan_au(au)
.unwrap_or_else(|e| panic!("H.264 AU {index} must plan, got {e:?}"))
.dpb
.outputs
.len()
})
.sum::<usize>()
};
let h265 = {
let mut planner = pf_bitstream::h265::H265Planner::new();
common::split_h265_aus(common::TEST_25FPS_H265)
.iter()
.take(AUS)
.enumerate()
.map(|(index, au)| {
planner
.plan_au(au)
.unwrap_or_else(|e| panic!("H.265 AU {index} must plan, got {e:?}"))
.dpb
.outputs
.len()
})
.sum::<usize>()
};
eprintln!("outputs from the first {AUS} AUs: h264={h264} h265={h265}");
// No `flush` here on purpose: the smoke legs do not flush either, so the
// planner's un-flushed output count is exactly the frame budget they have.
assert!(
h264 >= MIN_DELIVERED,
"the H.264 leg asserts >= {MIN_DELIVERED} delivered but the planner only \
outputs {h264} pictures from the first {AUS} AUs"
);
assert!(
h265 >= MIN_DELIVERED,
"the H.265 leg asserts >= {MIN_DELIVERED} delivered but the planner only \
outputs {h265} pictures from the first {AUS} AUs"
);
}