Files
punktfunk/crates/pf-vkdecode/src/images.rs
T
enricobuehler c06ee55b61
ci / bun-nix (pull_request) Successful in 38s
apple / swift (pull_request) Successful in 1m31s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m11s
ci / docs-site (pull_request) Successful in 2m21s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m44s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m10s
ci / rust-arm64 (pull_request) Successful in 6m29s
android / android (pull_request) Successful in 7m44s
ci / rust (pull_request) Canceled after 9m40s
nix / flake (pull_request) Canceled after 9m38s
diag(vkdecode): --probe-decode reports what the driver says about video images
The Intel Arc refusal moved one step down the caps query and stopped again: the
coincide NV12 entry does not advertise SAMPLED. That sentence is punktfunk's, not
the driver's, and the last two times a conclusion was drawn from a sentence of ours
the conclusion was wrong.

So --probe-decode now prints the driver's own answers instead. For every profile the
client can negotiate (H.264 High, H.265 Main and Main 10, AV1 Main 8- and 10-bit) it
asks vkGetPhysicalDeviceVideoFormatPropertiesKHR in six usage combinations — the
three the image pools really create with, plus DPB|DST without sampling, SAMPLED
alone and DST alone, which are what localise a refusal to a half. Each answer is
printed as the driver gave it: format, usage and create flags named AND in hex with
unrecognised bits called out, image type, tiling. A failed query prints its VkResult
rather than vanishing into an empty list.

It goes through pf-vkdecode's own query rather than a copy of it, which meant
splitting query_formats into a physical-device form — the call never needed the
VkDevice the old signature demanded. VideoFormat gains imageType and imageTiling to
carry the whole record; VUID-VkImageCreateInfo-pNext-06811 compares both for
equality, so they were being assumed rather than read.

And because a driver that under-reports usage would be indistinguishable from one
that genuinely lacks it, the probe asks a second, independent question —
vkGetPhysicalDeviceImageFormatProperties2 over the same profile list — and prints it
only where the two disagree. A disagreement is the finding.

No behaviour change to any decode path: derivation reads the same fields it did.
2026-08-07 15:34:12 +02:00

585 lines
22 KiB
Rust

//! Decode image pools — the FFmpeg pool model, zero-copy:
//!
//! The PICTURE POOL is decoupled from DPB slots. Images outnumber slots by
//! [`HOLD_HEADROOM`], and a DPB slot binds an image at ACTIVATION time — a
//! re-activated slot may bind a DIFFERENT free image (spec-legal with
//! `SEPARATE_REFERENCE_IMAGES`, which the caps derivation requires for coincide
//! mode). A picture the consumer still holds is therefore NEVER a decode target:
//! its image simply stays off the free list until the release token returns.
//! This is the exact contract the presenter already speaks on the AVVkFrame path,
//! re-implemented without FFmpeg in the middle.
//!
//! - **coincide** (RADV): pool images are DPB + decode output + sampled surface
//! in one (`DPB|DST|SAMPLED`, per-slot images).
//! - **distinct** (NVIDIA): a separate reference-only DPB array (layered or
//! per-slot — never delivered, so its slot↔layer mapping stays fixed) plus the
//! pool as decode outputs (`DST|SAMPLED`).
//!
//! Every pool image carries its OWN timeline semaphore (the AVVkFrame contract):
//! the decoder signals `value+1` when it writes the image; the presenter waits
//! that value, samples, restores the layout, and signals `value+1` again in the
//! same submission — the decoder's ledger learns of that write-back at
//! `release_frame` and waits it before the image's next use.
use ash::vk;
use crate::caps::DecodeCaps;
use crate::caps::DecodeProfile;
use crate::caps::COINCIDE_USAGE;
use crate::caps::DPB_USAGE;
use crate::caps::OUTPUT_USAGE;
use crate::device::find_memory_type_preferring;
use crate::device::AllocError;
use crate::device::DecodeDevice;
/// Picture-pool headroom on top of the stream's DPB needs: how many decoded
/// pictures the CONSUMER may hold (delivered, unreleased) before the decoder
/// reports backpressure. The real client pipeline holds ~4-7 frames at steady
/// state (two bounded(2) channels, the FrameStore's 1..=3 preroll, the in-flight
/// present and the retired-frame slot), so 8 gives it a frame of slack; a
/// consumer holding MORE than this earns the `NoFreeSlot` error, which then
/// means exactly what it says.
///
/// (The 2026-08 .25 field failure taught the sizing lesson the hard way: any
/// FIXED pool ignoring the stream's DPB depth starves on a clean stream — the
/// vendored 25fps vector alone keeps `max_dpb_frames + 1 = 8` pictures resident.
/// Pool size is always `required_slots + HOLD_HEADROOM`.)
pub const HOLD_HEADROOM: u32 = 8;
/// The pure pool shape for one (caps, required-slots) pair.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PoolPlan {
/// Distinct-mode reference-only DPB array; 0 images in coincide mode (the
/// picture pool IS the DPB backing there).
pub dpb_image_count: u32,
pub dpb_layers_per_image: u32,
pub dpb_usage: vk::ImageUsageFlags,
/// The decoupled picture pool: decode outputs + (coincide) DPB bindings.
pub picture_count: u32,
pub picture_usage: vk::ImageUsageFlags,
pub picture_flags: vk::ImageCreateFlags,
}
/// Decide the pool shape. Pure — unit-tested below.
///
/// `required_slots` is the stream's `max_dpb_frames + 1`; the picture pool adds
/// [`HOLD_HEADROOM`] on top so consumer-held pictures never displace decode
/// targets. Layered-coincide never reaches here (the caps derivation rejects it).
pub fn plan_pools(caps: &DecodeCaps, required_slots: u32) -> PoolPlan {
let picture_count = required_slots + HOLD_HEADROOM;
let picture_flags = vk::ImageCreateFlags::MUTABLE_FORMAT;
if caps.coincide {
PoolPlan {
dpb_image_count: 0,
dpb_layers_per_image: 0,
dpb_usage: vk::ImageUsageFlags::empty(),
picture_count,
picture_usage: COINCIDE_USAGE,
picture_flags,
}
} else {
let (dpb_image_count, dpb_layers_per_image) = if caps.layered_dpb {
(1, required_slots)
} else {
(required_slots, 1)
};
PoolPlan {
dpb_image_count,
dpb_layers_per_image,
dpb_usage: DPB_USAGE,
picture_count,
picture_usage: OUTPUT_USAGE,
picture_flags,
}
}
}
/// One picture-pool image with its sync + occupancy ledger.
pub(crate) struct Picture {
pub image: vk::Image,
/// Full-picture view in the session's picture format (decode dst / DPB
/// binding).
pub view: vk::ImageView,
/// Per-plane views for the presenter's sampler path, in the formats
/// [`crate::caps::plane_formats`] resolved for the picture format (`R8`/`R8G8`
/// at 8 bits, the `R10X6` pair at 10).
pub plane_views: [vk::ImageView; 2],
/// The image's own timeline semaphore (AVVkFrame contract).
pub semaphore: vk::Semaphore,
/// Latest timeline value known signalled-or-enqueued: the decoder's write
/// signal, bumped to the presenter's write-back (`frame.value + 1`) when a
/// release token reports the frame was sampled.
pub value: u64,
/// A DPB slot currently binds this image (coincide mode).
pub bound: bool,
/// A decoded picture awaiting its output verdict lives here.
pub pending: bool,
/// Frames over this image not yet released (ready queue + consumer-held).
pub held: u32,
}
impl Picture {
/// Free for a new decode target: no slot binds it, no pending picture lives
/// in it, no unreleased frame reads it.
pub(crate) fn is_free(&self) -> bool {
!self.bound && !self.pending && self.held == 0
}
}
/// The decoupled picture pool. Destroys everything it created on drop
/// (null-safe); a pool with consumer-held images is retired to the decoder's
/// graveyard instead of dropped, and dies when its last release token arrives.
pub(crate) struct PicturePool {
device: ash::Device,
memory: Vec<vk::DeviceMemory>,
/// The picture format every image in this pool was created with (the
/// caps-resolved `output_format`). Stashed here because it is the ONLY place
/// that knows it by the time a frame is built: the session's caps are keyed
/// by profile and a delivered frame outlives its generation's caps entry.
/// [`crate::decoder::build_frame`] stamps it into every
/// [`crate::decoder::DecodedVkFrame`] so the consumer can tell an NV12
/// picture from a P010 or 4:4:4 one — the H.265 path makes the format the
/// STREAM's, not a constant.
pub(crate) format: vk::Format,
pub(crate) pictures: Vec<Picture>,
}
impl PicturePool {
/// Create `plan.picture_count` single-layer images at `extent` (the
/// granularity-ALIGNED allocation extent).
///
/// # Safety
///
/// `dev` wraps live handles ([`crate::DeviceHandles`] contract).
pub(crate) unsafe fn create(
dev: &DecodeDevice,
caps: &DecodeCaps,
plan: &PoolPlan,
extent: vk::Extent2D,
profile: DecodeProfile,
) -> Result<Self, AllocError> {
let mut pool = Self {
device: dev.ash().clone(),
memory: Vec::new(),
format: caps.output_format,
pictures: Vec::new(),
};
let families = dev.sharing_families();
for _ in 0..plan.picture_count {
// SAFETY: fn contract (live device); every created handle is parked
// in `pool` so a mid-build failure unwinds through Drop.
let (image, memory) = unsafe {
create_video_image(
dev,
caps.output_format,
extent,
1,
plan.picture_usage,
plan.picture_flags,
&families,
profile,
)?
};
pool.memory.push(memory);
// The picture is parked with null handles IMMEDIATELY (Drop ignores
// nulls), then each view/semaphore is filled as it is created — a
// failure anywhere unwinds everything created so far.
pool.pictures.push(Picture {
image,
view: vk::ImageView::null(),
plane_views: [vk::ImageView::null(); 2],
semaphore: vk::Semaphore::null(),
value: 0,
bound: false,
pending: false,
held: 0,
});
let picture = pool.pictures.len() - 1;
// SAFETY: `image` was just created with layer 0 in range (holds for
// all three creates in this block); the plane formats are the ones
// derive_caps/derive_caps_h265 resolved for THIS picture format and
// are plane-compatible with it under MUTABLE_FORMAT (caps-gated).
unsafe {
pool.pictures[picture].view = create_view(
&pool.device,
image,
caps.output_format,
vk::ImageAspectFlags::COLOR,
0,
)?;
pool.pictures[picture].plane_views[0] = create_view(
&pool.device,
image,
caps.plane_view_formats[0],
vk::ImageAspectFlags::PLANE_0,
0,
)?;
pool.pictures[picture].plane_views[1] = create_view(
&pool.device,
image,
caps.plane_view_formats[1],
vk::ImageAspectFlags::PLANE_1,
0,
)?;
}
let mut type_info = vk::SemaphoreTypeCreateInfo::default()
.semaphore_type(vk::SemaphoreType::TIMELINE)
.initial_value(0);
let sem_ci = vk::SemaphoreCreateInfo::default().push_next(&mut type_info);
// SAFETY: live device; timelineSemaphore enabled per the handles
// contract.
pool.pictures[picture].semaphore =
unsafe { pool.device.create_semaphore(&sem_ci, None)? };
}
Ok(pool)
}
/// Index of the first free image, if any.
pub(crate) fn free_index(&self) -> Option<usize> {
self.pictures.iter().position(Picture::is_free)
}
/// Total frames not yet released across the pool (graveyard retirement key).
pub(crate) fn held_total(&self) -> u32 {
self.pictures.iter().map(|p| p.held).sum()
}
}
impl Drop for PicturePool {
fn drop(&mut self) {
// SAFETY: every handle is this pool's own on the (contract-live) device;
// the owning decoder drains decode work before dropping/retiring, and a
// retired pool is only dropped once its last release token returned (the
// presenter's fence wait). Destroys ignore NULL (half-built unwinding).
unsafe {
for p in self.pictures.drain(..) {
self.device.destroy_image_view(p.view, None);
self.device.destroy_image_view(p.plane_views[0], None);
self.device.destroy_image_view(p.plane_views[1], None);
self.device.destroy_semaphore(p.semaphore, None);
self.device.destroy_image(p.image, None);
}
for memory in self.memory.drain(..) {
self.device.free_memory(memory, None);
}
}
}
}
/// Distinct-mode reference-only DPB backing (fixed slot↔layer mapping — these
/// images are never delivered, so nothing consumer-side ever pins them).
pub(crate) struct DpbPool {
device: ash::Device,
images: Vec<vk::Image>,
memory: Vec<vk::DeviceMemory>,
dpb_views: Vec<vk::ImageView>,
dpb_location: Vec<(usize, u32)>,
}
impl DpbPool {
/// # Safety
///
/// `dev` wraps live handles ([`crate::DeviceHandles`] contract).
pub(crate) unsafe fn create(
dev: &DecodeDevice,
caps: &DecodeCaps,
plan: &PoolPlan,
extent: vk::Extent2D,
profile: DecodeProfile,
) -> Result<Self, AllocError> {
let mut pool = Self {
device: dev.ash().clone(),
images: Vec::new(),
memory: Vec::new(),
dpb_views: Vec::new(),
dpb_location: Vec::new(),
};
let families = dev.sharing_families();
for _ in 0..plan.dpb_image_count {
// SAFETY: fn contract (live device); parked in `pool` for unwinding.
let (image, memory) = unsafe {
create_video_image(
dev,
caps.dpb_format,
extent,
plan.dpb_layers_per_image,
plan.dpb_usage,
vk::ImageCreateFlags::empty(),
&families,
profile,
)?
};
pool.images.push(image);
pool.memory.push(memory);
}
let slots = plan.dpb_image_count * plan.dpb_layers_per_image;
for slot in 0..slots {
let (image_index, layer) = if plan.dpb_image_count == 1 {
(0usize, slot)
} else {
(slot as usize, 0u32)
};
// SAFETY: the image was created above with `layer` in range.
let view = unsafe {
create_view(
&pool.device,
pool.images[image_index],
caps.dpb_format,
vk::ImageAspectFlags::COLOR,
layer,
)?
};
pool.dpb_views.push(view);
pool.dpb_location.push((image_index, layer));
}
Ok(pool)
}
/// The DPB binding view of `slot`.
pub(crate) fn dpb_view(&self, slot: u8) -> vk::ImageView {
self.dpb_views[usize::from(slot)]
}
/// The image + array layer behind DPB `slot` (barrier targeting).
pub(crate) fn dpb_target(&self, slot: u8) -> (vk::Image, u32) {
let (image_index, layer) = self.dpb_location[usize::from(slot)];
(self.images[image_index], layer)
}
}
impl Drop for DpbPool {
fn drop(&mut self) {
// SAFETY: own handles on the contract-live device; the owning decoder
// drains decode work before dropping state (nothing consumer-side ever
// references these). Destroys ignore NULL.
unsafe {
for view in self.dpb_views.drain(..) {
self.device.destroy_image_view(view, None);
}
for image in self.images.drain(..) {
self.device.destroy_image(image, None);
}
for memory in self.memory.drain(..) {
self.device.free_memory(memory, None);
}
}
}
}
/// One OPTIMAL-tiling video image bound to fresh DEVICE_LOCAL memory, profile-listed
/// (mirrors the encoder's `make_video_image`, minus its `&mut` profile-list plumbing).
///
/// # Safety
///
/// `dev` wraps live handles.
#[allow(clippy::too_many_arguments)]
unsafe fn create_video_image(
dev: &DecodeDevice,
format: vk::Format,
extent: vk::Extent2D,
layers: u32,
usage: vk::ImageUsageFlags,
flags: vk::ImageCreateFlags,
families: &[u32],
decode_profile: DecodeProfile,
) -> Result<(vk::Image, vk::DeviceMemory), AllocError> {
let mut chain = decode_profile.chain();
let profile = chain.wire();
let mut profile_list =
vk::VideoProfileListInfoKHR::default().profiles(std::slice::from_ref(profile));
let mut ci = vk::ImageCreateInfo::default()
.flags(flags)
.image_type(vk::ImageType::TYPE_2D)
.format(format)
.extent(vk::Extent3D {
width: extent.width,
height: extent.height,
depth: 1,
})
.mip_levels(1)
.array_layers(layers)
.samples(vk::SampleCountFlags::TYPE_1)
.tiling(vk::ImageTiling::OPTIMAL)
.usage(usage)
.initial_layout(vk::ImageLayout::UNDEFINED)
.push_next(&mut profile_list);
ci = if families.len() >= 2 {
ci.sharing_mode(vk::SharingMode::CONCURRENT)
.queue_family_indices(families)
} else {
ci.sharing_mode(vk::SharingMode::EXCLUSIVE)
};
// SAFETY: live device; `ci` roots a chain of locals outliving the call.
let image = unsafe { dev.ash().create_image(&ci, None)? };
// SAFETY: `image` was just created on this device.
let req = unsafe { dev.ash().get_image_memory_requirements(image) };
let props = dev.memory_properties();
// DEVICE_LOCAL preferred, any advertised type accepted (same rationale as the
// session bindings: `memoryTypeBits` is the driver's placement contract).
let type_index = match find_memory_type_preferring(
&props,
req.memory_type_bits,
vk::MemoryPropertyFlags::DEVICE_LOCAL,
) {
Ok(index) => index,
Err(e) => {
// SAFETY: destroying the just-created, never-bound image.
unsafe { dev.ash().destroy_image(image, None) };
return Err(e);
}
};
let alloc = vk::MemoryAllocateInfo::default()
.allocation_size(req.size)
.memory_type_index(type_index);
// SAFETY: live device; unwind destroys the unbound image so the error path
// leaks nothing.
let memory = match unsafe { dev.ash().allocate_memory(&alloc, None) } {
Ok(m) => m,
Err(e) => {
// SAFETY: destroying the just-created, never-bound image.
unsafe { dev.ash().destroy_image(image, None) };
return Err(e.into());
}
};
// SAFETY: fresh image + fresh memory of the required size.
if let Err(e) = unsafe { dev.ash().bind_image_memory(image, memory, 0) } {
// SAFETY: unwinding the two objects created above.
unsafe {
dev.ash().destroy_image(image, None);
dev.ash().free_memory(memory, None);
}
return Err(e.into());
}
Ok((image, memory))
}
/// One single-layer 2D view (`base_array_layer = layer`, identity swizzle).
///
/// # Safety
///
/// `image` is live on `device` with `layer` in range; `format`/`aspect` are
/// compatible with the image's creation (same format for COLOR, plane-compatible
/// under MUTABLE_FORMAT for the plane aspects).
unsafe fn create_view(
device: &ash::Device,
image: vk::Image,
format: vk::Format,
aspect: vk::ImageAspectFlags,
layer: u32,
) -> Result<vk::ImageView, vk::Result> {
let ci = vk::ImageViewCreateInfo::default()
.image(image)
.view_type(vk::ImageViewType::TYPE_2D)
.format(format)
.subresource_range(vk::ImageSubresourceRange {
aspect_mask: aspect,
base_mip_level: 0,
level_count: 1,
base_array_layer: layer,
layer_count: 1,
});
// SAFETY: the fn-level contract restates exactly what create_image_view needs.
unsafe { device.create_image_view(&ci, None) }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::caps::derive_caps;
use crate::caps::RawH264Caps;
use crate::caps::VideoFormat;
use crate::caps::NV12;
fn caps(coincide: bool, layered: bool) -> DecodeCaps {
// Every entry advertises its role's full usage plus MUTABLE_FORMAT — the
// derivation gates on those; this module's decision table is downstream.
let entry = |usage: vk::ImageUsageFlags| VideoFormat {
format: NV12,
image_usage: usage,
image_create_flags: vk::ImageCreateFlags::MUTABLE_FORMAT,
..Default::default()
};
let raw = RawH264Caps {
capability_flags: if layered {
vk::VideoCapabilityFlagsKHR::empty()
} else {
vk::VideoCapabilityFlagsKHR::SEPARATE_REFERENCE_IMAGES
},
decode_flags: if coincide {
vk::VideoDecodeCapabilityFlagsKHR::DPB_AND_OUTPUT_COINCIDE
} else {
vk::VideoDecodeCapabilityFlagsKHR::DPB_AND_OUTPUT_DISTINCT
},
dpb_formats: vec![entry(DPB_USAGE)],
output_formats: vec![entry(OUTPUT_USAGE)],
coincide_formats: vec![entry(COINCIDE_USAGE)],
..Default::default()
};
derive_caps(&raw).unwrap()
}
#[test]
fn coincide_pools_are_headroomed_dual_use_pictures_with_no_dpb_array() {
let plan = plan_pools(&caps(true, false), 8);
assert_eq!(
plan.dpb_image_count, 0,
"the picture pool IS the DPB backing"
);
assert_eq!(
plan.picture_count,
8 + HOLD_HEADROOM,
"the stream's DPB depth PLUS the consumer-hold headroom — a pool \
sized to either alone starves (.25 field failure)"
);
assert_eq!(
plan.picture_usage, COINCIDE_USAGE,
"pool pictures are DPB + decode dst + sampled surface in one"
);
assert_eq!(plan.picture_flags, vk::ImageCreateFlags::MUTABLE_FORMAT);
}
#[test]
fn distinct_keeps_a_fixed_dpb_array_and_headrooms_the_output_pool() {
let plan = plan_pools(&caps(false, true), 17);
assert_eq!(
(plan.dpb_image_count, plan.dpb_layers_per_image),
(1, 17),
"layered: one array, one layer per slot"
);
assert_eq!(plan.dpb_usage, DPB_USAGE);
assert_eq!(plan.picture_count, 17 + HOLD_HEADROOM);
assert_eq!(plan.picture_usage, OUTPUT_USAGE);
let plan = plan_pools(&caps(false, false), 3);
assert_eq!(
(plan.dpb_image_count, plan.dpb_layers_per_image),
(3, 1),
"separate reference images: one image per slot"
);
assert_eq!(plan.picture_count, 3 + HOLD_HEADROOM);
}
#[test]
fn picture_occupancy_frees_only_when_unbound_unpending_and_released() {
let mut p = Picture {
image: vk::Image::null(),
view: vk::ImageView::null(),
plane_views: [vk::ImageView::null(); 2],
semaphore: vk::Semaphore::null(),
value: 0,
bound: true,
pending: true,
held: 2,
};
assert!(!p.is_free());
p.bound = false;
assert!(!p.is_free(), "pending pictures are not decode targets");
p.pending = false;
assert!(!p.is_free(), "held frames are not decode targets");
p.held = 1;
assert!(!p.is_free());
p.held = 0;
assert!(p.is_free());
}
}