Files
punktfunk/crates/pf-vkdecode/tests/gpu_smoke.rs
T
enricobuehler 5c6b09a5c5 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.
2026-08-06 09:47:30 +02:00

330 lines
15 KiB
Rust

//! GPU smoke tests — `#[ignore]`d because they need 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 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).
//!
//! 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
//! HOLDS FOUR delivered frames unreleased at steady state (the real client's
//! pipeline shape: bounded channels, FrameStore preroll, in-flight present).
//! 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 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)]
mod common;
use ash::vk;
use common::TestDecoder;
use pf_vkdecode::DecodeStatus;
use pf_vkdecode::DecodedVkFrame;
use pf_vkdecode::NoopQueueLock;
use pf_vkdecode::VkH264Decoder;
use pf_vkdecode::VkH265Decoder;
/// 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;
/// 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)>,
}
/// 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();
}
}
// 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 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();
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,
});
let handles = setup.handles();
{
// 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");
smoke(
&mut decoder,
&common::split_h264_aus(common::TEST_25FPS_H264),
&Geometry {
display: (320, 240),
exact_coded: Some((320, 240)),
},
);
}
// 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"
);
}