Files
punktfunk/crates/pf-vkdecode/src/device.rs
T
enricobuehler ca92dab6fd fix(pf-vkdecode): prefer DEVICE_LOCAL, never require it — NVIDIA runs; both DPB modes hardware-green
Session-memory and image allocation now honor each binding's
memoryTypeBits with DEVICE_LOCAL preferred, not required: NVIDIA 610.88
legally places a video-session binding in host-visible-only memory and
the hard requirement refused the whole device. The bitstream ring keeps
its hard HOST_VISIBLE|COHERENT need. Smoke test gains
PF_VKD_SMOKE_VENDOR device pinning + attribution and a final-state
print (DPB mode now observed, not inferred).

On-glass matrix after this fix (.173, vendor-pinned): NVIDIA 4090
PASSES in COINCIDE mode — the first end-to-end run of the RESULT_STATUS
query path, ~44 per-frame driver verdicts on the recording pattern that
hangs RADV's VCN — and Adrenalin re-passes in distinct mode unchanged.
With RADV's distinct pass, both DPB arrangements and three of four
desktop drivers are now hardware-validated; Intel remains a clean caps
refusal (no SAMPLED on decode outputs — its rung stays D3D11VA).

Gates: fmt clean, clippy -D warnings zero, 45+27+53 green both
platforms.
2026-08-05 20:03:25 +02:00

489 lines
19 KiB
Rust

//! Borrowed-device wrap: the presenter's live Vulkan handles loaded into ash
//! function tables, plus the queue-lock contract every queue submission runs under.
//!
//! Ownership: everything in [`DeviceHandles`] is BORROWED. This crate never creates
//! and never destroys the instance/device — [`DecodeDevice`]'s ash wrappers are
//! function tables over foreign handles, and dropping them destroys nothing. The
//! objects this crate does create (sessions, images, buffers, pools) are destroyed
//! by their owning structs' `Drop` impls, all of which must run before the borrowed
//! device dies — the same liveness contract FFmpeg's decoder had over the identical
//! handle bundle (`pf-client-core`'s `VulkanDecodeDevice`), now written down.
use ash::vk;
use ash::vk::Handle;
/// The borrowed handles of the presenter's decode-capable device, as raw integers so
/// the type stays FFI-plain (mirrors `pf-client-core`'s `VulkanDecodeDevice`, which
/// adapts into this in WP-C — pf-vkdecode deliberately does not depend on it).
///
/// Caller contract (checked where cheap, otherwise trusted):
/// - All four handles are live, and stay live for the lifetime of every object this
/// crate builds from them (the presenter outlives every session pump).
/// - The instance/device were created with the Vulkan Video decode stack enabled:
/// `VK_KHR_video_queue`, `VK_KHR_video_decode_queue`, `VK_KHR_video_decode_h264`,
/// plus the `synchronization2` and `timelineSemaphore` features (the presenter's
/// device meets all of this when it advertises `video_decode`).
/// - `decode_qf`/`decode_queue_index` name a queue with `VIDEO_DECODE_KHR` ops whose
/// family advertises H.264 decode; `graphics_qf` is the family the presenter
/// samples on (image sharing crosses the two when they differ).
#[derive(Debug, Clone)]
pub struct DeviceHandles {
/// `PFN_vkGetInstanceProcAddr` from the loader; everything else is resolved
/// through it.
pub get_instance_proc_addr: usize,
pub instance: usize,
pub physical_device: usize,
pub device: usize,
/// The video-decode queue family.
pub decode_qf: u32,
/// Queue index within `decode_qf` this decoder submits on.
pub decode_queue_index: u32,
/// The presenter's graphics+present family (the other side of image sharing).
pub graphics_qf: u32,
}
/// External synchronization for `vkQueueSubmit`: the caller supplies the lock that
/// serializes EVERY submit on the shared device — in WP-C that is pf-client-core's
/// `QueueLock`, the same object the presenter holds around its own submits/presents
/// (the 2026-07-09 `VK_ERROR_DEVICE_LOST` race is why this is a first-class contract
/// and not an afterthought). Tests use [`NoopQueueLock`].
///
/// `lock` blocks until the queue is free and takes it; `unlock` releases it. Use
/// [`QueueSubmitGuard`] rather than calling the pair by hand.
pub trait QueueLock {
fn lock(&self);
fn unlock(&self);
}
/// A [`QueueLock`] that guards nothing — for tests and for callers whose decode
/// queue is provably not shared with any other submitter.
#[derive(Debug, Default)]
pub struct NoopQueueLock;
impl QueueLock for NoopQueueLock {
fn lock(&self) {}
fn unlock(&self) {}
}
/// RAII scope over a [`QueueLock`]: acquired for exactly the duration of a queue
/// submission, released on drop (including unwinds — though this crate's own paths
/// never panic while holding it).
pub struct QueueSubmitGuard<'a> {
lock: &'a dyn QueueLock,
}
impl<'a> QueueSubmitGuard<'a> {
/// Take the queue (blocking until free).
pub fn acquire(lock: &'a dyn QueueLock) -> Self {
lock.lock();
Self { lock }
}
}
impl Drop for QueueSubmitGuard<'_> {
fn drop(&mut self) {
self.lock.unlock();
}
}
/// A [`DeviceHandles`] bundle that cannot be wrapped. Every variant is a caller
/// bug (a half-filled bundle), not a runtime condition.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeviceError {
/// One of the four raw handles is zero/null.
NullHandle(&'static str),
}
/// A device allocation that cannot proceed. Wraps the raw Vulkan failure OR the
/// memory-type miss that used to be silently papered over with index 0 — a wrong
/// type index is at best an immediate validation error and at worst a mapping of
/// the wrong heap, so a miss is an ERROR here, never a fallback.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AllocError {
Vk(vk::Result),
/// No memory type satisfies (`type_bits`, `flags`) on this device.
NoMemoryType {
type_bits: u32,
flags: vk::MemoryPropertyFlags,
},
}
impl From<vk::Result> for AllocError {
fn from(r: vk::Result) -> Self {
AllocError::Vk(r)
}
}
/// First memory type matching `bits` and `want` — an [`AllocError::NoMemoryType`]
/// when none does (the encoder's `find_mem` falls back to 0 there; here the miss
/// surfaces).
pub(crate) fn find_memory_type(
props: &vk::PhysicalDeviceMemoryProperties,
bits: u32,
want: vk::MemoryPropertyFlags,
) -> Result<u32, AllocError> {
for i in 0..props.memory_type_count {
if (bits & (1 << i)) != 0 && props.memory_types[i as usize].property_flags.contains(want) {
return Ok(i);
}
}
Err(AllocError::NoMemoryType {
type_bits: bits,
flags: want,
})
}
/// First memory type matching `bits` that also carries `prefer`; when none does,
/// the first type matching `bits` at all. A driver constrains `memoryTypeBits` to
/// where the allocation can legally live — NVIDIA (610.88) reports some video-
/// session bindings host-visible-ONLY, which is spec-legal, so a hard `prefer`
/// requirement there is unsatisfiable by construction. Still an
/// [`AllocError::NoMemoryType`] when `bits` selects nothing whatsoever (that
/// miss-is-error contract stays; only the property preference softens). Mapped
/// staging paths (the bitstream ring) must NOT use this: they require
/// `HOST_VISIBLE|HOST_COHERENT` as a hard property, not a preference.
pub(crate) fn find_memory_type_preferring(
props: &vk::PhysicalDeviceMemoryProperties,
bits: u32,
prefer: vk::MemoryPropertyFlags,
) -> Result<u32, AllocError> {
match find_memory_type(props, bits, prefer) {
Ok(index) => Ok(index),
Err(_) => find_memory_type(props, bits, vk::MemoryPropertyFlags::empty()),
}
}
impl std::fmt::Display for DeviceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DeviceError::NullHandle(which) => {
write!(f, "DeviceHandles.{which} is null — a half-filled bundle")
}
}
}
}
impl std::error::Error for DeviceError {}
/// The borrowed device with ash function tables loaded: the object every other
/// module in this crate makes its Vulkan calls through.
///
/// Clone is cheap-ish (ash tables are plain structs of function pointers) and safe:
/// clones share the same borrowed handles under the same liveness contract.
#[derive(Clone)]
pub struct DecodeDevice {
instance: ash::Instance,
device: ash::Device,
physical_device: vk::PhysicalDevice,
video_queue_instance: ash::khr::video_queue::Instance,
video_queue: ash::khr::video_queue::Device,
video_decode_queue: ash::khr::video_decode_queue::Device,
decode_queue: vk::Queue,
decode_qf: u32,
graphics_qf: u32,
/// The decode family advertises `queryResultStatusSupport`: per-op
/// RESULT_STATUS queries are legal in its video coding scopes. FALSE on RADV
/// (2026-08, .25: recording one anyway hangs the VCN ring) — the decoder
/// must skip queries entirely there and fall back to timeline-completion
/// verdicts.
result_status_queries: bool,
}
impl DecodeDevice {
/// Load ash function tables over the borrowed handles.
///
/// # Safety
///
/// The full [`DeviceHandles`] caller contract: live handles (outliving `self`
/// and everything created through it), the video-decode extensions/features
/// enabled at creation, and truthful queue-family fields. Null handles are
/// rejected here; everything else cannot be checked and is trusted.
pub unsafe fn wrap(handles: &DeviceHandles) -> Result<Self, DeviceError> {
if handles.get_instance_proc_addr == 0 {
return Err(DeviceError::NullHandle("get_instance_proc_addr"));
}
if handles.instance == 0 {
return Err(DeviceError::NullHandle("instance"));
}
if handles.physical_device == 0 {
return Err(DeviceError::NullHandle("physical_device"));
}
if handles.device == 0 {
return Err(DeviceError::NullHandle("device"));
}
// SAFETY: the usize is non-zero (checked above) and the caller contract says
// it is the loader's PFN_vkGetInstanceProcAddr; fn pointers and usize share
// size/ABI on every supported target.
let gipa: vk::PFN_vkGetInstanceProcAddr = unsafe {
std::mem::transmute::<usize, vk::PFN_vkGetInstanceProcAddr>(
handles.get_instance_proc_addr,
)
};
// SAFETY: `gipa` is a valid Vulkan-1.0-conformant loader entry point per the
// caller contract, valid for the returned Entry's lifetime (handle liveness).
let entry = unsafe {
ash::Entry::from_static_fn(ash::StaticFn {
get_instance_proc_addr: gipa,
})
};
// SAFETY: `handles.instance` is a live VkInstance created through this very
// loader (caller contract), so loading instance-level functions against it
// is exactly the ash::Instance::load contract.
let instance = unsafe {
ash::Instance::load(
entry.static_fn(),
vk::Instance::from_raw(handles.instance as u64),
)
};
// SAFETY: `handles.device` is a live VkDevice of that instance (caller
// contract) — the ash::Device::load contract.
let device = unsafe {
ash::Device::load(
instance.fp_v1_0(),
vk::Device::from_raw(handles.device as u64),
)
};
let video_queue_instance = ash::khr::video_queue::Instance::new(&entry, &instance);
let video_queue = ash::khr::video_queue::Device::new(&instance, &device);
let video_decode_queue = ash::khr::video_decode_queue::Device::new(&instance, &device);
// SAFETY: the caller contract guarantees `decode_qf`/`decode_queue_index`
// name a queue the device was created with.
let decode_queue =
unsafe { device.get_device_queue(handles.decode_qf, handles.decode_queue_index) };
// Whether the decode family supports RESULT_STATUS queries (per-family
// cap; struct field docs).
let physical_device = vk::PhysicalDevice::from_raw(handles.physical_device as u64);
// SAFETY: live physical device (caller contract); the two-call form fills
// the chained per-family status-support structs.
let family_count =
unsafe { instance.get_physical_device_queue_family_properties2_len(physical_device) };
let result_status_queries = if (handles.decode_qf as usize) < family_count {
let mut status_props =
vec![vk::QueueFamilyQueryResultStatusPropertiesKHR::default(); family_count];
let mut families: Vec<vk::QueueFamilyProperties2<'_>> = status_props
.iter_mut()
.map(|s| vk::QueueFamilyProperties2::default().push_next(s))
.collect();
// SAFETY: as above, arrays sized to the reported count.
unsafe {
instance
.get_physical_device_queue_family_properties2(physical_device, &mut families)
};
drop(families);
status_props[handles.decode_qf as usize].query_result_status_support != vk::FALSE
} else {
false
};
// `entry` is only the ladder the tables above were loaded through; nothing
// needs it afterwards (ash tables own their function pointers).
drop(entry);
Ok(Self {
instance,
device,
physical_device,
video_queue_instance,
video_queue,
video_decode_queue,
decode_queue,
decode_qf: handles.decode_qf,
graphics_qf: handles.graphics_qf,
result_status_queries,
})
}
pub(crate) fn ash(&self) -> &ash::Device {
&self.device
}
/// Whether the decode family supports per-op RESULT_STATUS queries (struct
/// field docs — FALSE on RADV, where recording one hangs the VCN).
pub(crate) fn result_status_queries(&self) -> bool {
self.result_status_queries
}
pub(crate) fn physical_device(&self) -> vk::PhysicalDevice {
self.physical_device
}
pub(crate) fn video_queue_instance(&self) -> &ash::khr::video_queue::Instance {
&self.video_queue_instance
}
pub(crate) fn video_queue(&self) -> &ash::khr::video_queue::Device {
&self.video_queue
}
pub(crate) fn video_decode_queue(&self) -> &ash::khr::video_decode_queue::Device {
&self.video_decode_queue
}
pub(crate) fn decode_queue(&self) -> vk::Queue {
self.decode_queue
}
pub(crate) fn decode_qf(&self) -> u32 {
self.decode_qf
}
/// The queue families image sharing spans: empty (EXCLUSIVE) when decode and
/// graphics are one family, both otherwise (CONCURRENT — the presenter samples
/// decode output on its own family and per-frame ownership transfers would buy
/// latency for nothing at punktfunk's frame rates).
pub(crate) fn sharing_families(&self) -> Vec<u32> {
if self.decode_qf == self.graphics_qf {
Vec::new()
} else {
vec![self.decode_qf, self.graphics_qf]
}
}
/// The device's memory properties (queried fresh; cheap and stateless).
pub(crate) fn memory_properties(&self) -> vk::PhysicalDeviceMemoryProperties {
// SAFETY: `physical_device` is live per the DeviceHandles contract; the call
// fills a plain struct and touches nothing else.
unsafe {
self.instance
.get_physical_device_memory_properties(self.physical_device)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_half_filled_bundle_is_rejected_before_any_ffi() {
let mut handles = DeviceHandles {
get_instance_proc_addr: 0,
instance: 1,
physical_device: 1,
device: 1,
decode_qf: 0,
decode_queue_index: 0,
graphics_qf: 0,
};
// SAFETY: wrap rejects the null handle before making any Vulkan call, so no
// part of the liveness contract is exercised. (`Err` matched by hand: the
// Ok side holds ash tables, which carry no Debug for unwrap_err.)
let result = unsafe { DecodeDevice::wrap(&handles) };
let Err(err) = result else {
panic!("a null gipa must be rejected")
};
assert_eq!(err, DeviceError::NullHandle("get_instance_proc_addr"));
handles.get_instance_proc_addr = 1;
handles.device = 0;
// SAFETY: as above — the null device handle is rejected before any FFI.
let result = unsafe { DecodeDevice::wrap(&handles) };
let Err(err) = result else {
panic!("a null device must be rejected")
};
assert_eq!(err, DeviceError::NullHandle("device"));
}
#[test]
fn a_memory_type_miss_is_an_error_never_a_fallback_to_index_zero() {
let mut props = vk::PhysicalDeviceMemoryProperties {
memory_type_count: 2,
..Default::default()
};
props.memory_types[0].property_flags = vk::MemoryPropertyFlags::DEVICE_LOCAL;
props.memory_types[1].property_flags =
vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT;
// A hit resolves to the matching index, not the first.
assert_eq!(
find_memory_type(
&props,
0b11,
vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT
),
Ok(1)
);
// A type excluded by the requirement bits does not count as a hit.
assert_eq!(
find_memory_type(&props, 0b01, vk::MemoryPropertyFlags::HOST_VISIBLE),
Err(AllocError::NoMemoryType {
type_bits: 0b01,
flags: vk::MemoryPropertyFlags::HOST_VISIBLE
})
);
// Flags nothing advertises: an error carrying the miss, never index 0.
assert_eq!(
find_memory_type(&props, 0b11, vk::MemoryPropertyFlags::PROTECTED),
Err(AllocError::NoMemoryType {
type_bits: 0b11,
flags: vk::MemoryPropertyFlags::PROTECTED
})
);
}
#[test]
fn preferring_picks_the_preferred_type_and_falls_back_inside_the_bits() {
let mut props = vk::PhysicalDeviceMemoryProperties {
memory_type_count: 4,
..Default::default()
};
props.memory_types[0].property_flags =
vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT;
props.memory_types[1].property_flags = vk::MemoryPropertyFlags::DEVICE_LOCAL;
props.memory_types[2].property_flags = vk::MemoryPropertyFlags::DEVICE_LOCAL;
props.memory_types[3].property_flags =
vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT;
// The preferred property wins over a lower-indexed non-preferred type.
assert_eq!(
find_memory_type_preferring(&props, 0b0011, vk::MemoryPropertyFlags::DEVICE_LOCAL),
Ok(1)
);
// The NVIDIA session-binding shape: `memoryTypeBits` names only a
// host-visible type — honor the bits instead of erroring.
assert_eq!(
find_memory_type_preferring(&props, 0b1000, vk::MemoryPropertyFlags::DEVICE_LOCAL),
Ok(3)
);
// Bits selecting nothing remain a hard miss, never index 0.
assert_eq!(
find_memory_type_preferring(&props, 0b0000, vk::MemoryPropertyFlags::DEVICE_LOCAL),
Err(AllocError::NoMemoryType {
type_bits: 0b0000,
flags: vk::MemoryPropertyFlags::empty()
})
);
}
#[test]
fn the_queue_submit_guard_brackets_the_lock() {
use std::sync::atomic::AtomicI32;
use std::sync::atomic::Ordering;
#[derive(Default)]
struct CountingLock {
depth: AtomicI32,
peak: AtomicI32,
}
impl QueueLock for CountingLock {
fn lock(&self) {
let d = self.depth.fetch_add(1, Ordering::SeqCst) + 1;
self.peak.fetch_max(d, Ordering::SeqCst);
}
fn unlock(&self) {
self.depth.fetch_sub(1, Ordering::SeqCst);
}
}
let lock = CountingLock::default();
{
let _guard = QueueSubmitGuard::acquire(&lock);
assert_eq!(lock.depth.load(Ordering::SeqCst), 1);
}
assert_eq!(lock.depth.load(Ordering::SeqCst), 0, "released on drop");
assert_eq!(lock.peak.load(Ordering::SeqCst), 1);
}
}