Files
punktfunk/crates/pf-vkdecode/tests/gpu_smoke.rs
T
enricobuehler dfebb9dfbb chore(safety): hoist the unsafe lints into the workspace tables (WP2c hoist)
undocumented_unsafe_blocks joins unsafe_op_in_unsafe_fn in
[workspace.lints], and the ~100 scattered per-file #![deny(...)] attributes
(85 files) are deleted — a new crate, or a new module in an old one, is now
covered on creation rather than on remembering. The per-file form is how
pf-vkhdr-layer, wdk-probe and half of pf-clipboard stayed uncovered.

There are THREE workspaces, so the claim is made three times: the main
Cargo.toml, packaging/windows/drivers (workspace table + [lints]
workspace = true in all seven members), and packaging/windows/pf-vkhdr-layer
(its [lints] table, previous commit). pf-update now opts into workspace
lints; the two vendored member snapshots (cros-codecs, usbip-sim) stay out
deliberately and now both say so.

Newly-covered fallout was two link-sanity tests (pyrowave-sys, libvpl-sys)
— proofs written. Stale prose that claimed the workspace held
unsafe_op_in_unsafe_fn at "warn" (it has been deny) or pointed at the
deleted attributes is corrected.

nvenc_core.rs is carved OUT of the unsafe_op_in_unsafe_fn fence: its
exemption rationale ("raw entry-table calls almost line for line") was
false — the file makes zero FFI calls. Its unsafe surface is C-union writes
whose soundness hangs on which codec arm is active, and its own 4:4:4 note
records the shipped bug (hevcConfig bytes stamped onto an AV1 config) that
per-operation blocks make visible. It now runs the strictest discipline in
the crate: clippy::multiple_unsafe_ops_per_block at deny, one union access
per block, each naming its codec guard.

Verified here: cargo fmt clean in all three workspaces; native clippy
-D warnings clean for everything that compiles on macOS (the three
pre-existing mac-native failures — pf-client-core wol.rs, pf-encode
dead-code/closure-call, probe mic_burst — reproduce on the clean tree).
Linux/Windows legs ride the .25/.133 gate.
2026-08-11 23:26:28 +02:00

425 lines
19 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` /
//! `VK_KHR_video_decode_av1`), with a queue
//! family carrying `VIDEO_DECODE_KHR` ops for that codec;
//! - `timelineSemaphore` + `synchronization2` feature support (Vulkan 1.3 core);
//! - on RADV, `RADV_PERFTEST=video_decode` in the environment — for AV1 exactly as
//! for the other two, and without it the AV1 leg reports missing silicon it has.
//!
//! One leg per codec, running the SAME body ([`smoke`]) over the vendored 25fps
//! vector of that codec — a box that decodes only some of the three runs those legs
//! and reports the rest as "no physical device with VK_KHR_video_decode_…", which is
//! a fact about the box rather than a failure (AV1 is the one most likely to say so
//! on today's fleet). 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).
mod common;
use ash::vk;
use common::TestDecoder;
use pf_vkdecode::DecodeStatus;
use pf_vkdecode::DecodedVkFrame;
use pf_vkdecode::NoopQueueLock;
use pf_vkdecode::VkAv1Decoder;
use pf_vkdecode::VkH264Decoder;
use pf_vkdecode::VkH265Decoder;
/// AUs fed: far past every vector's DPB depth (`max_dpb_frames = 7` for the
/// H.264 clip, eight reference slots for AV1), 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 all three codecs (over `common::TestDecoder`) so "the AV1 leg proves
/// what the H.264 leg proves" is structural rather than a claim about three 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);
// all three vectors are 8-bit 4:2:0, so all three 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() };
}
/// The AV1 leg — the rung's first hardware evidence of ANY kind.
///
/// The same 48 access units at the same client hold depth, but AV1 loads the pool
/// harder than either H.26x leg does and that is the point of running it: the first
/// 48 temporal units carry 53 coded frames to show 48 (the number
/// [`the_delivery_floor_is_under_what_the_planners_emit_from_the_first_48_aus`]
/// prints), each hidden frame keeps a pool image resident as a reference while
/// nothing displays it, and eight reference slots re-activate against that. A pool
/// sized as if one access unit meant one picture starves exactly here — which is this
/// leg's whole job, since `gpu_parity`'s AV1 leg would report the same starvation as a
/// decode failure with a less obvious cause.
#[test]
#[ignore = "needs a Vulkan Video AV1 decode device (fleet boxes; see module docs)"]
fn av1_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::AV1,
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 AV1 decode extension + timeline/sync2 features.
let mut decoder = unsafe { VkAv1Decoder::new(&handles, Box::new(NoopQueueLock)) }
.expect("wrap the device");
// The construction-time shape gate on the vector's own facts (Main, 4:2:0,
// 8-bit, NO film grain → NV12). The film-grain argument is the one that has
// no H.26x counterpart: grain synthesis is part of the Vulkan decode PROFILE,
// so a box offering only the grain-enabled profile refuses HERE with a caps
// reason rather than at the first temporal unit.
decoder.probe_stream_support(1, 8, false).expect(
"the box must host AV1 Main 4:2:0 8-bit without film grain (the vector's shape)",
);
smoke(
&mut decoder,
&common::split_av1_aus(common::TEST_25FPS_AV1),
&Geometry {
display: (320, 240),
// As HEVC: no hardware evidence for AV1's `pictureAccessGranularity`
// on any fleet box yet, so the leg prints what it allocates rather
// than asserting a number nobody has observed. AV1's decode extent is
// the POST-superres width, which is another reason not to guess.
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 guards — 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 or AV1
// 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>()
};
// AV1 needs the extra fold: one temporal unit can plan SEVERAL frames, so the
// outputs of a unit are the outputs of all of its plans — and counting one plan
// per unit is exactly how a reader would under-count here.
let (av1, av1_frames) = {
let mut planner = pf_bitstream::av1::Av1Planner::new();
let mut outputs = 0usize;
let mut frames = 0usize;
for (index, au) in common::split_av1_aus(common::TEST_25FPS_AV1)
.iter()
.take(AUS)
.enumerate()
{
let plans = planner
.plan_au(au)
.unwrap_or_else(|e| panic!("AV1 temporal unit {index} must plan, got {e:?}"));
frames += plans.len();
outputs += plans.iter().map(|p| p.dpb.outputs.len()).sum::<usize>();
}
(outputs, frames)
};
eprintln!(
"outputs from the first {AUS} AUs: h264={h264} h265={h265} av1={av1} \
(av1 decoded {av1_frames} frames to show {av1} — the hidden ones)"
);
// 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"
);
assert!(
av1 >= MIN_DELIVERED,
"the AV1 leg asserts >= {MIN_DELIVERED} delivered but the planner only \
outputs {av1} pictures from the first {AUS} temporal units"
);
// The AV1 leg's load is not the same as the other two's, and the assertion above
// cannot see the difference: the pool must hold the hidden frames as well as the
// shown ones. If these ever became equal, the leg would have stopped exercising
// multi-frame temporal units — the one pool pressure AV1 has that H.26x has not —
// while still passing everything above.
assert!(
av1_frames > av1,
"the first {AUS} AV1 temporal units must decode MORE frames ({av1_frames}) \
than they show ({av1}); equal counts mean the hidden-frame coverage is gone"
);
}