fix(pf-vkdecode): zero-copy pool model + the two faults the first hardware run found
WP-D leg 1 (.25 RADV, distinct mode) root causes, both real: 1. Output starvation: the fixed 4-deep ring lost to a stream that keeps max_dpb_frames+1 = 8 pictures pending. Zero-copy fix (user requirement, no copies): one picture pool of required_slots + HOLD_HEADROOM(8) images decoupled from DPB slots — a re-activated slot binds a fresh free image, so a delivered picture is never a decode target; the WP-B pin layer became dead and is deleted. Per-image timeline semaphores carry the AVVkFrame contract: decode signals value+1, the presenter waits and signals back, later decodes wait the image's latest value — layout traffic ordered against reference reads with no copy anywhere. 2. RESULT_STATUS queries HANG RADV's VCN firmware (ring timeout, DEVICE_LOST): queryResultStatusSupport=false on the decode family. Queries are now caps-gated; without them poll/wait degrade to timeline-completion verdicts (FFmpeg parity — and the likely reason upstream never wired nb_queries). The Ally-X-class detection runs where drivers advertise the query; .173 probes NVIDIA/Windows-AMD. Also: slice-only bitstream feeding (the field-proven consumer shape), graveyarded pool retirement keyed by release tokens + generation, decode-current-AU-before-status attribution, take_ready drained, H264-bit gating, teardown short-circuit on disconnected channel. On-glass: 48 AUs green on .25 holding 4 frames like the real client. Gates: fmt clean, container clippy -D warnings zero, 27+121+52 green both platforms.
This commit is contained in:
@@ -822,6 +822,12 @@ fn pump(
|
||||
// every frame (its decode really is done by now).
|
||||
let hw_fence = match &image {
|
||||
DecodedImage::VkFrame(v) => Some((v.timeline_sem, v.decode_done_value)),
|
||||
// DecodedImage::NativeVk carries the same (semaphore,
|
||||
// value) pair and COULD feed this sampled decode-time
|
||||
// stat identically — deliberately deferred to WP-D:
|
||||
// the native rung's field A/B should measure the same
|
||||
// stats surface the FFmpeg rung had at parity time,
|
||||
// and grow new ones after the verdict, not during it.
|
||||
_ => None,
|
||||
};
|
||||
if present {
|
||||
|
||||
@@ -163,11 +163,18 @@ pub enum NativeVkLayout {
|
||||
|
||||
/// The release token a presented/dropped [`NativeVkFrame`] hands back to the native
|
||||
/// decode backend: `seq` names the shipped frame, `generation` the decoder session it
|
||||
/// belongs to (a stale generation releases nothing — the pools it indexed are gone).
|
||||
/// belongs to (a stale generation routes to the decoder's graveyard — retired pools
|
||||
/// die on their last token), and `presented` reports whether the presenter SAMPLED
|
||||
/// the image — i.e. whether its submission enqueued the frame's `value + 1` timeline
|
||||
/// signal (the AVVkFrame write-back the decoder must wait before reusing the image).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct NativeReleaseToken {
|
||||
pub seq: u64,
|
||||
pub generation: u64,
|
||||
/// The presenter's sampling submission (with its `value + 1` signal) was
|
||||
/// enqueued for this frame. `false` for frames dropped unpresented
|
||||
/// (newest-wins displacement, demotion drain, failed submit).
|
||||
pub presented: bool,
|
||||
}
|
||||
|
||||
/// Sends the frame's [`NativeReleaseToken`] exactly once, on drop — the native path's
|
||||
@@ -192,6 +199,16 @@ impl NativeReleaseGuard {
|
||||
token: Some(token),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record that the sampling submission — including the frame's `value + 1`
|
||||
/// timeline signal — was enqueued. The presenter calls this exactly when its
|
||||
/// submit succeeded; the token then tells the decoder to wait that write-back
|
||||
/// before the image's next use.
|
||||
pub fn mark_presented(&mut self) {
|
||||
if let Some(token) = &mut self.token {
|
||||
token.presented = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for NativeReleaseGuard {
|
||||
@@ -442,8 +459,23 @@ const HW_DEMOTE_MIN_STREAK: std::time::Duration = std::time::Duration::from_mill
|
||||
/// presenter's device actually advertises Vulkan Video decode. Deliberately NOT an
|
||||
/// `auto` rung: entering the automatic ladder is WP-D's A/B verdict. Pure so the
|
||||
/// decision is CPU-testable.
|
||||
fn native_vulkan_gate(choice: &str, codec_id: ffmpeg::codec::Id, video_decode: bool) -> bool {
|
||||
choice == "native-vulkan" && codec_id == ffmpeg::codec::Id::H264 && video_decode
|
||||
/// `VK_VIDEO_CODEC_OPERATION_DECODE_H264_BIT_KHR` — the raw flag bit within
|
||||
/// [`VulkanDecodeDevice::decode_video_caps`] (this crate stays ash-free).
|
||||
const VIDEO_CODEC_OP_DECODE_H264: u32 = 0x0000_0001;
|
||||
|
||||
fn native_vulkan_gate(
|
||||
choice: &str,
|
||||
codec_id: ffmpeg::codec::Id,
|
||||
video_decode: bool,
|
||||
decode_video_caps: u32,
|
||||
) -> bool {
|
||||
choice == "native-vulkan"
|
||||
&& codec_id == ffmpeg::codec::Id::H264
|
||||
&& video_decode
|
||||
// The decode family must advertise the H264 op specifically —
|
||||
// `video_decode` alone proves the extension stack, not the codec (an
|
||||
// AV1-only decode family exists on real hardware).
|
||||
&& decode_video_caps & VIDEO_CODEC_OP_DECODE_H264 != 0
|
||||
}
|
||||
|
||||
/// Map a negotiated `quic` codec bit to the FFmpeg decoder id the client opens.
|
||||
@@ -642,7 +674,12 @@ impl Decoder {
|
||||
// other, than the FFmpeg rungs' failures do.
|
||||
let mut choice = choice;
|
||||
if choice == "native-vulkan" {
|
||||
if native_vulkan_gate(&choice, codec_id, vk.is_some_and(|v| v.video_decode)) {
|
||||
if native_vulkan_gate(
|
||||
&choice,
|
||||
codec_id,
|
||||
vk.is_some_and(|v| v.video_decode),
|
||||
vk.map_or(0, |v| v.decode_video_caps),
|
||||
) {
|
||||
let vk = vk.expect("gate demands video_decode, so vk is Some");
|
||||
match NativeVulkanDecoder::new(vk) {
|
||||
Ok(n) => {
|
||||
@@ -1314,16 +1351,34 @@ mod tests {
|
||||
#[test]
|
||||
fn native_vulkan_gate_is_by_name_h264_and_capable_device_only() {
|
||||
use ffmpeg::codec::Id;
|
||||
assert!(native_vulkan_gate("native-vulkan", Id::H264, true));
|
||||
const H264_OP: u32 = VIDEO_CODEC_OP_DECODE_H264;
|
||||
assert!(native_vulkan_gate("native-vulkan", Id::H264, true, H264_OP));
|
||||
// Never by any other preference — it is not an auto rung yet.
|
||||
for choice in ["auto", "", "hardware", "vulkan", "software"] {
|
||||
assert!(!native_vulkan_gate(choice, Id::H264, true), "{choice:?}");
|
||||
assert!(
|
||||
!native_vulkan_gate(choice, Id::H264, true, H264_OP),
|
||||
"{choice:?}"
|
||||
);
|
||||
}
|
||||
// The one codec pf-vkdecode speaks.
|
||||
assert!(!native_vulkan_gate("native-vulkan", Id::HEVC, true));
|
||||
assert!(!native_vulkan_gate("native-vulkan", Id::AV1, true));
|
||||
assert!(!native_vulkan_gate(
|
||||
"native-vulkan",
|
||||
Id::HEVC,
|
||||
true,
|
||||
H264_OP
|
||||
));
|
||||
assert!(!native_vulkan_gate("native-vulkan", Id::AV1, true, H264_OP));
|
||||
// No Vulkan-Video-capable presenter device.
|
||||
assert!(!native_vulkan_gate("native-vulkan", Id::H264, false));
|
||||
assert!(!native_vulkan_gate(
|
||||
"native-vulkan",
|
||||
Id::H264,
|
||||
false,
|
||||
H264_OP
|
||||
));
|
||||
// A decode family WITHOUT the H264 op (e.g. AV1-only) refuses even with
|
||||
// the extension stack present — the caps BIT is the codec gate.
|
||||
assert!(!native_vulkan_gate("native-vulkan", Id::H264, true, 0));
|
||||
assert!(!native_vulkan_gate("native-vulkan", Id::H264, true, 0x4));
|
||||
}
|
||||
|
||||
/// Lock the DRM FourCC magic numbers against typos — these are the exact values
|
||||
|
||||
@@ -107,6 +107,10 @@ struct Shipped {
|
||||
frame: DecodedVkFrame,
|
||||
/// The presenter (or a drop on the way there) returned the token.
|
||||
released: bool,
|
||||
/// The token said the sampling submission (with its `value + 1` timeline
|
||||
/// signal) was enqueued — forwarded to `release_frame` so the decoder waits
|
||||
/// the write-back before reusing the image.
|
||||
presented: bool,
|
||||
/// The status query read a conclusive verdict (or the poll belt expired).
|
||||
resolved: bool,
|
||||
/// Polls attempted after the token returned — see [`MAX_POLLS_AFTER_RELEASE`].
|
||||
@@ -124,6 +128,7 @@ fn note_token(outstanding: &mut [Shipped], token: NativeReleaseToken) -> bool {
|
||||
"a token's generation always matches the frame it rode on"
|
||||
);
|
||||
s.released = true;
|
||||
s.presented = token.presented;
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
@@ -133,9 +138,14 @@ fn note_token(outstanding: &mut [Shipped], token: NativeReleaseToken) -> bool {
|
||||
/// The native backend: the decoder plus the shipped-frame ledger and release channel.
|
||||
pub(crate) struct NativeVulkanDecoder {
|
||||
dec: VkH264Decoder,
|
||||
/// Cloned into every shipped frame's guard.
|
||||
release_tx: mpsc::Sender<NativeReleaseToken>,
|
||||
/// Cloned into every shipped frame's guard. `Option` so teardown can DROP the
|
||||
/// backend's own sender: only then does `release_rx` report Disconnected once
|
||||
/// the last guard is gone — the teardown short-circuit signal.
|
||||
release_tx: Option<mpsc::Sender<NativeReleaseToken>>,
|
||||
release_rx: mpsc::Receiver<NativeReleaseToken>,
|
||||
/// Display-ready frames not yet handed to the pump (burst outputs — decode
|
||||
/// delivers one per call; the rest wait here, oldest first).
|
||||
deliverable: std::collections::VecDeque<DecodedVkFrame>,
|
||||
outstanding: Vec<Shipped>,
|
||||
next_seq: u64,
|
||||
}
|
||||
@@ -181,8 +191,9 @@ impl NativeVulkanDecoder {
|
||||
let (release_tx, release_rx) = mpsc::channel();
|
||||
Ok(NativeVulkanDecoder {
|
||||
dec,
|
||||
release_tx,
|
||||
release_tx: Some(release_tx),
|
||||
release_rx,
|
||||
deliverable: std::collections::VecDeque::new(),
|
||||
outstanding: Vec::new(),
|
||||
next_seq: 0,
|
||||
})
|
||||
@@ -193,32 +204,45 @@ impl NativeVulkanDecoder {
|
||||
/// decode trouble — a decoder error, a plan that needed concealment, or a
|
||||
/// driver-reported corrupt PREVIOUS frame — routed through the caller's shared
|
||||
/// streak/demotion machinery.
|
||||
///
|
||||
/// Ordering: the CURRENT AU decodes FIRST — the planner's reference state must
|
||||
/// advance even when a PRIOR frame's status turns out Failed, or the recovery
|
||||
/// IDR would land on a decoder that skipped an AU and reports a phantom
|
||||
/// reference gap. The prior-frame verdicts are checked after; a corrupt verdict
|
||||
/// costs exactly this one AU's output (released unshown), never parser state.
|
||||
pub(crate) fn decode(&mut self, au: &[u8]) -> Result<Option<NativeVkFrame>> {
|
||||
self.drain_releases();
|
||||
let corrupt = self.settle_statuses();
|
||||
if corrupt > 0 {
|
||||
// Driver-reported decode corruption on an already-delivered frame — the
|
||||
// Ally X class, invisible to FFmpeg's query-less decoder. The frame is on
|
||||
// (or past) the glass; erroring THIS call is what arms the reanchor gate
|
||||
// and gets the IDR that replaces the corrupt content.
|
||||
return Err(anyhow!(
|
||||
"driver reported decode corruption on {corrupt} prior frame(s) \
|
||||
(RESULT_STATUS_ONLY query) — re-anchor needed"
|
||||
));
|
||||
}
|
||||
|
||||
let delivered = self.dec.decode(au).map_err(|e| anyhow!("decode: {e}"))?;
|
||||
let warnings = self.dec.take_warnings();
|
||||
if !warnings.is_empty() {
|
||||
// The AU was planned around missing/damaged references: the picture
|
||||
// decodes, but its content is concealed. Release it unshown and surface
|
||||
// the AU as decode trouble — same path, same volume as an FFmpeg
|
||||
// Everything this AU made display-ready, oldest first (`take_ready` drained
|
||||
// so burst outputs are never stranded inside the decoder).
|
||||
let mut fresh: Vec<DecodedVkFrame> = Vec::new();
|
||||
if let Some(frame) = delivered {
|
||||
fresh.push(frame);
|
||||
}
|
||||
while let Some(frame) = self.dec.take_ready() {
|
||||
fresh.push(frame);
|
||||
}
|
||||
|
||||
let corrupt = self.settle_statuses();
|
||||
if !warnings.is_empty() || corrupt > 0 {
|
||||
// Concealment planned into THIS AU, or driver-reported corruption on a
|
||||
// PRIOR frame (the Ally X class, invisible to FFmpeg's query-less
|
||||
// decoder): this call's output is released unshown and the call errors,
|
||||
// arming the reanchor gate — same path, same volume as an FFmpeg
|
||||
// reference-miss error (never quieter).
|
||||
if let Some(frame) = &delivered {
|
||||
if let Err(e) = self.dec.release_frame(frame) {
|
||||
tracing::debug!(error = %e, "releasing a concealed frame failed");
|
||||
for frame in fresh {
|
||||
if let Err(e) = self.dec.release_frame(&frame, false) {
|
||||
tracing::debug!(error = %e, "releasing an unshown frame failed");
|
||||
}
|
||||
}
|
||||
if corrupt > 0 {
|
||||
return Err(anyhow!(
|
||||
"driver reported decode corruption on {corrupt} prior frame(s) \
|
||||
(RESULT_STATUS_ONLY query) — re-anchor needed"
|
||||
));
|
||||
}
|
||||
tracing::warn!(
|
||||
?warnings,
|
||||
"native decode planned with concealment — dropping the frame, \
|
||||
@@ -230,7 +254,8 @@ impl NativeVulkanDecoder {
|
||||
);
|
||||
}
|
||||
|
||||
Ok(delivered.map(|frame| self.ship(frame)))
|
||||
self.deliverable.extend(fresh);
|
||||
Ok(self.deliverable.pop_front().map(|frame| self.ship(frame)))
|
||||
}
|
||||
|
||||
/// Wrap a delivered [`DecodedVkFrame`] for the presenter and enter it into the
|
||||
@@ -241,6 +266,7 @@ impl NativeVulkanDecoder {
|
||||
let token = NativeReleaseToken {
|
||||
seq,
|
||||
generation: frame.generation,
|
||||
presented: false,
|
||||
};
|
||||
let native = NativeVkFrame {
|
||||
image: frame.image.as_raw(),
|
||||
@@ -270,12 +296,19 @@ impl NativeVulkanDecoder {
|
||||
},
|
||||
keyframe: frame.is_idr,
|
||||
poc: frame.poc,
|
||||
guard: NativeReleaseGuard::new(self.release_tx.clone(), token),
|
||||
guard: NativeReleaseGuard::new(
|
||||
self.release_tx
|
||||
.as_ref()
|
||||
.expect("release_tx lives until Drop")
|
||||
.clone(),
|
||||
token,
|
||||
),
|
||||
};
|
||||
self.outstanding.push(Shipped {
|
||||
seq,
|
||||
frame,
|
||||
released: false,
|
||||
presented: false,
|
||||
resolved: false,
|
||||
polls_after_release: 0,
|
||||
});
|
||||
@@ -357,7 +390,7 @@ impl NativeVulkanDecoder {
|
||||
if !(s.released && s.resolved) {
|
||||
return true;
|
||||
}
|
||||
match dec.release_frame(&s.frame) {
|
||||
match dec.release_frame(&s.frame, s.presented) {
|
||||
Ok(()) => {}
|
||||
// A session rebuild (stream renegotiation) already dropped the pools
|
||||
// this frame indexed — nothing left to release.
|
||||
@@ -371,14 +404,43 @@ impl NativeVulkanDecoder {
|
||||
|
||||
impl Drop for NativeVulkanDecoder {
|
||||
fn drop(&mut self) {
|
||||
// Wait (bounded) for the presenter to hand back every shipped frame before the
|
||||
// decoder's Drop destroys the pool images: a returned token proves the
|
||||
// sampling submission's fence was waited, i.e. no GPU work of the presenter's
|
||||
// still reads the pools (the decoder's own drain covers only decode work).
|
||||
// Ordering contract: the run loop drops the PRESENTER's frame (its retired
|
||||
// slot, fence-waited) before joining the pump that owns this backend — so
|
||||
// by the time this Drop runs, outstanding tokens are either already in the
|
||||
// channel or arrive imminently; the bounded wait below is for that hand-off,
|
||||
// not for future GPU work.
|
||||
//
|
||||
// Frames never handed to the pump release directly (unsampled).
|
||||
for frame in std::mem::take(&mut self.deliverable) {
|
||||
if let Err(e) = self.dec.release_frame(&frame, false) {
|
||||
tracing::debug!(error = %e, "releasing an undelivered frame failed");
|
||||
}
|
||||
}
|
||||
// Drop our own sender FIRST: once every shipped guard is gone too, the
|
||||
// channel reports Disconnected — the "presenter can no longer produce
|
||||
// tokens" signal that short-circuits the wait instead of burning the full
|
||||
// budget against a presenter that is already gone.
|
||||
drop(self.release_tx.take());
|
||||
// Wait (bounded) for the presenter to hand back every shipped frame before
|
||||
// the decoder's Drop destroys the pool images: a returned token proves the
|
||||
// sampling submission's fence was waited, i.e. no GPU work of the
|
||||
// presenter's still reads the pools (the decoder's own drain covers only
|
||||
// decode work; graveyarded pools ride the same token contract).
|
||||
let deadline = Instant::now() + TEARDOWN_BUDGET;
|
||||
loop {
|
||||
self.drain_releases();
|
||||
self.outstanding.retain(|s| !s.released);
|
||||
let Self {
|
||||
dec, outstanding, ..
|
||||
} = self;
|
||||
outstanding.retain(|s| {
|
||||
if !s.released {
|
||||
return true;
|
||||
}
|
||||
if let Err(e) = dec.release_frame(&s.frame, s.presented) {
|
||||
tracing::debug!(error = %e, "teardown release_frame: {e}");
|
||||
}
|
||||
false
|
||||
});
|
||||
if self.outstanding.is_empty() {
|
||||
break;
|
||||
}
|
||||
@@ -391,7 +453,6 @@ impl Drop for NativeVulkanDecoder {
|
||||
);
|
||||
break;
|
||||
}
|
||||
// `self` holds a Sender, so the channel can't disconnect — only time out.
|
||||
match self
|
||||
.release_rx
|
||||
.recv_timeout((deadline - now).min(Duration::from_millis(50)))
|
||||
@@ -399,10 +460,25 @@ impl Drop for NativeVulkanDecoder {
|
||||
Ok(token) => {
|
||||
note_token(&mut self.outstanding, token);
|
||||
}
|
||||
Err(_) => continue,
|
||||
// Every sender is gone (ours dropped above, every guard dropped):
|
||||
// no more tokens can EVER arrive — anything still outstanding is a
|
||||
// bookkeeping ghost, not a held frame. Stop waiting.
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||
if !self.outstanding.is_empty() {
|
||||
tracing::debug!(
|
||||
outstanding = self.outstanding.len(),
|
||||
"release channel disconnected with entries outstanding — \
|
||||
no tokens can arrive; proceeding with teardown"
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => continue,
|
||||
}
|
||||
}
|
||||
// `self.dec` drops after this body: it drains its own decode-side GPU work.
|
||||
// `self.dec` drops after this body: it drains its own decode-side GPU work
|
||||
// and destroys any remaining graveyard pools (warned — a forfeit here means
|
||||
// the presenter kept frames past the budget).
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,9 +504,12 @@ mod tests {
|
||||
poc: 0,
|
||||
is_idr: false,
|
||||
query_slot: 0,
|
||||
submission: 0,
|
||||
picture: 0,
|
||||
generation,
|
||||
},
|
||||
released: false,
|
||||
presented: false,
|
||||
resolved: false,
|
||||
polls_after_release: 0,
|
||||
}
|
||||
@@ -452,18 +531,25 @@ mod tests {
|
||||
&mut outstanding,
|
||||
NativeReleaseToken {
|
||||
seq: 1,
|
||||
generation: 1
|
||||
generation: 1,
|
||||
presented: true,
|
||||
}
|
||||
));
|
||||
assert!(!outstanding[0].released);
|
||||
assert!(outstanding[1].released);
|
||||
assert!(
|
||||
outstanding[1].presented,
|
||||
"the token's presented flag rides into the ledger (the decoder waits \
|
||||
the presenter's value+1 write-back only when it was really enqueued)"
|
||||
);
|
||||
// A stray token (frame already settled away — e.g. a post-demotion drain)
|
||||
// matches nothing and must not panic or mis-mark.
|
||||
assert!(!note_token(
|
||||
&mut outstanding,
|
||||
NativeReleaseToken {
|
||||
seq: 7,
|
||||
generation: 1
|
||||
generation: 1,
|
||||
presented: false,
|
||||
}
|
||||
));
|
||||
assert!(!outstanding[0].released);
|
||||
@@ -475,6 +561,7 @@ mod tests {
|
||||
let token = NativeReleaseToken {
|
||||
seq: 42,
|
||||
generation: 3,
|
||||
presented: false,
|
||||
};
|
||||
let guard = NativeReleaseGuard::new(tx, token);
|
||||
assert!(
|
||||
@@ -518,6 +605,7 @@ mod tests {
|
||||
NativeReleaseToken {
|
||||
seq: 9,
|
||||
generation: 5,
|
||||
presented: false,
|
||||
},
|
||||
),
|
||||
};
|
||||
@@ -526,8 +614,11 @@ mod tests {
|
||||
rx.try_recv().ok(),
|
||||
Some(NativeReleaseToken {
|
||||
seq: 9,
|
||||
generation: 5
|
||||
})
|
||||
generation: 5,
|
||||
presented: false,
|
||||
}),
|
||||
"an unpresented drop reports presented=false — the decoder must not \
|
||||
wait a value+1 write-back that was never enqueued"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -542,6 +633,7 @@ mod tests {
|
||||
NativeReleaseToken {
|
||||
seq: 1,
|
||||
generation: 1,
|
||||
presented: false,
|
||||
},
|
||||
);
|
||||
drop(guard); // must not panic
|
||||
|
||||
@@ -408,12 +408,13 @@ impl Presenter {
|
||||
// stamped layout/semaphore/value at delivery and nothing mutates them).
|
||||
// Transition the picture's LAYER for sampling, run the same CSC pass with
|
||||
// the coded-vs-display UV scale (the 1088-row lesson), then transition BACK
|
||||
// to the decode layout: a coincide-mode picture is a live DPB slot the next
|
||||
// decode must find in VIDEO_DECODE_DPB_KHR (distinct-mode DST images get the
|
||||
// same round-trip — the decoder's reuse barrier discards via UNDEFINED, so
|
||||
// the restore costs nothing and keeps one rule). The pool images are created
|
||||
// CONCURRENT across the graphics+decode families, so these are plain layout
|
||||
// transitions — no queue-family ownership transfer.
|
||||
// to the decode layout the frame names — and the submit below signals the
|
||||
// image's timeline at `value + 1` when these reads/restores complete, which
|
||||
// the decoder (told via the release token) waits before that image's next
|
||||
// decode use: the layout round-trip is ORDERED against decode, not raced.
|
||||
// The pool images are created CONCURRENT across the graphics+decode
|
||||
// families, so these are plain layout transitions — no queue-family
|
||||
// ownership transfer.
|
||||
let mut native_wait: Option<(vk::Semaphore, u64)> = None;
|
||||
if let (Some(f), Some(v)) = (&native_frame, &self.video) {
|
||||
let image = vk::Image::from_raw(f.image);
|
||||
@@ -638,15 +639,18 @@ impl Presenter {
|
||||
}
|
||||
// The native frame's decode-complete timeline: wait it at FRAGMENT_SHADER
|
||||
// (chaining with the acquire barrier — the same dependency-chain rule as
|
||||
// `vkframe_acquire_barrier`). Deliberately NO signal back on the decoder's
|
||||
// timeline: its per-slot values are the DECODER's counter (a foreign signal
|
||||
// would collide with its next decode's value) — the slot-return contract is
|
||||
// the release token the frame's guard sends once our fence proves the reads
|
||||
// done.
|
||||
// `vkframe_acquire_barrier`), and SIGNAL `value + 1` when our reads and
|
||||
// the layout restore are done — the exact AVVkFrame write-back contract
|
||||
// of the arm above. The decoder learns of the enqueued signal through
|
||||
// the release token (`mark_presented`) and waits it before the image's
|
||||
// next decode use; per-IMAGE timelines make the value spaces private, so
|
||||
// this cannot collide with any other image's counter.
|
||||
if let Some((sem, value)) = &native_wait {
|
||||
wait_sems.push(*sem);
|
||||
wait_stages.push(vk::PipelineStageFlags::FRAGMENT_SHADER);
|
||||
wait_values.push(*value);
|
||||
signal_sems.push(*sem);
|
||||
signal_values.push(*value + 1);
|
||||
}
|
||||
let mut timeline = vk::TimelineSemaphoreSubmitInfo::default()
|
||||
.wait_semaphore_values(&wait_values)
|
||||
@@ -721,9 +725,14 @@ impl Presenter {
|
||||
if let Some(f) = win_frame.take() {
|
||||
self.retired_hw = Some(Retired::D3d11(f));
|
||||
}
|
||||
// Native frame: parked until the fence proves the sampling reads done — its
|
||||
// drop THEN sends the decoder's release token (never at record time).
|
||||
if let Some(f) = native_frame.take() {
|
||||
// Native frame: the submit above enqueued our `value + 1` signal — mark
|
||||
// the token so the decoder waits that write-back before reusing the
|
||||
// image (a failed submit skipped this whole block, leaving the token
|
||||
// unmarked: no phantom signal is ever promised). Then park until the
|
||||
// fence proves the sampling reads done — the drop THEN sends the
|
||||
// release token (never at record time).
|
||||
if let Some(mut f) = native_frame.take() {
|
||||
f.guard.mark_presented();
|
||||
self.retired_hw = Some(Retired::NativeVk(f));
|
||||
}
|
||||
|
||||
|
||||
@@ -151,6 +151,14 @@ pub enum CapsError {
|
||||
/// so the per-plane `R8`/`R8G8` views the presenter samples through cannot
|
||||
/// exist on this device.
|
||||
NoMutableFormat { mode: &'static str },
|
||||
/// The driver forces COINCIDE mode AND a layered DPB (one image array, no
|
||||
/// `SEPARATE_REFERENCE_IMAGES`): the picture-pool model — a re-activated slot
|
||||
/// binding a fresh free image, so delivered pictures are never decode targets
|
||||
/// — cannot exist when every slot is a fixed layer of one array. No fleet
|
||||
/// device has this shape (NVIDIA = distinct, RADV = separate reference
|
||||
/// images); a device that does demotes to the next decoder rung rather than
|
||||
/// getting a degraded copy path built for it.
|
||||
CoincideLayeredDpb,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CapsError {
|
||||
@@ -177,6 +185,13 @@ impl std::fmt::Display for CapsError {
|
||||
"the {mode} NV12 entry does not allow MUTABLE_FORMAT (per-plane views)"
|
||||
)
|
||||
}
|
||||
CapsError::CoincideLayeredDpb => {
|
||||
write!(
|
||||
f,
|
||||
"coincide mode with a layered DPB (no SEPARATE_REFERENCE_IMAGES) — \
|
||||
the picture-pool model needs per-slot images; demote this device"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,12 +211,20 @@ pub fn derive_caps(raw: &RawH264Caps) -> Result<DecodeCaps, CapsError> {
|
||||
return Err(CapsError::NoDecodeMode);
|
||||
}
|
||||
|
||||
let layered_dpb = !raw
|
||||
.capability_flags
|
||||
.contains(vk::VideoCapabilityFlagsKHR::SEPARATE_REFERENCE_IMAGES);
|
||||
// Coincide preferred when both are offered (struct docs). Each picked entry is
|
||||
// validated against the EXACT usage/create-flags its pool will use: presenter-
|
||||
// facing images (coincide pool, distinct outputs) additionally need
|
||||
// MUTABLE_FORMAT for their per-plane views; the distinct DPB needs neither
|
||||
// sampling nor plane views.
|
||||
let (dpb_format, output_format) = if coincide {
|
||||
if layered_dpb {
|
||||
// The picture-pool model needs per-slot images (a slot re-binds a
|
||||
// fresh image at activation); one fixed layer per slot cannot do that.
|
||||
return Err(CapsError::CoincideLayeredDpb);
|
||||
}
|
||||
let mode = "coincide (DPB|DST|SAMPLED)";
|
||||
let entry = pick_nv12(&raw.coincide_formats, mode)?;
|
||||
require_usage(&entry, COINCIDE_USAGE, mode)?;
|
||||
@@ -219,9 +242,7 @@ pub fn derive_caps(raw: &RawH264Caps) -> Result<DecodeCaps, CapsError> {
|
||||
|
||||
Ok(DecodeCaps {
|
||||
coincide,
|
||||
layered_dpb: !raw
|
||||
.capability_flags
|
||||
.contains(vk::VideoCapabilityFlagsKHR::SEPARATE_REFERENCE_IMAGES),
|
||||
layered_dpb,
|
||||
min_bitstream_offset_alignment: raw.min_bitstream_buffer_offset_alignment.max(1),
|
||||
min_bitstream_size_alignment: raw.min_bitstream_buffer_size_alignment.max(1),
|
||||
picture_access_granularity: raw.picture_access_granularity,
|
||||
@@ -656,6 +677,19 @@ mod tests {
|
||||
assert_eq!((aligned.width, aligned.height), (321, 241));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coincide_with_a_layered_dpb_is_unsupported_not_worked_around() {
|
||||
// A driver forcing coincide AND a single layered DPB array: the pool
|
||||
// model (fresh image per activation) cannot exist there, and no fleet
|
||||
// device has this shape — refuse so the ladder demotes.
|
||||
let mut raw = radv_like();
|
||||
raw.capability_flags = vk::VideoCapabilityFlagsKHR::empty();
|
||||
assert_eq!(
|
||||
derive_caps(&raw).unwrap_err(),
|
||||
CapsError::CoincideLayeredDpb
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_alignments_normalize_to_one_so_ring_math_never_divides_by_zero() {
|
||||
let mut raw = radv_like();
|
||||
|
||||
+674
-368
File diff suppressed because it is too large
Load Diff
@@ -161,6 +161,12 @@ pub struct DecodeDevice {
|
||||
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 {
|
||||
@@ -225,6 +231,32 @@ impl DecodeDevice {
|
||||
// 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);
|
||||
@@ -232,13 +264,14 @@ impl DecodeDevice {
|
||||
Ok(Self {
|
||||
instance,
|
||||
device,
|
||||
physical_device: vk::PhysicalDevice::from_raw(handles.physical_device as u64),
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -246,6 +279,12 @@ impl DecodeDevice {
|
||||
&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
|
||||
}
|
||||
|
||||
+293
-258
@@ -1,25 +1,25 @@
|
||||
//! DPB + decode-output image pools, caps-driven for BOTH DPB arrangements:
|
||||
//! Decode image pools — the FFmpeg pool model, zero-copy:
|
||||
//!
|
||||
//! - **coincide** (`DPB_AND_OUTPUT_COINCIDE`, RADV's shape): the decode output IS
|
||||
//! the DPB picture — one pool, every slot usable both as setup/reference and as
|
||||
//! the frame handed to the presenter.
|
||||
//! - **distinct** (NVIDIA's shape): a reference-only DPB pool plus a small ring of
|
||||
//! output images the decoder writes `dst` into.
|
||||
//! 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.
|
||||
//!
|
||||
//! Within either mode the DPB is **layered** (one image, one array layer per slot —
|
||||
//! mandatory when the driver lacks `SEPARATE_REFERENCE_IMAGES`) or **per-slot**
|
||||
//! (one image each). [`plan_pools`] is the pure decision table; [`ImagePool`] is
|
||||
//! the thin Vulkan half.
|
||||
//! - **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`).
|
||||
//!
|
||||
//! Presenter-facing surfaces (outputs) carry `MUTABLE_FORMAT` (advertised by the
|
||||
//! driver — [`crate::caps::derive_caps`] refuses otherwise; nothing here aliases,
|
||||
//! so no `ALIAS`) so per-plane `R8`/`R8G8` views exist for the presenter's
|
||||
//! sampling path, one TIMELINE semaphore per output slot signals decode
|
||||
//! completion, and the conformance-window crop rides the frame struct — the
|
||||
//! 1088-row smear class dies by construction because the consumer is TOLD the
|
||||
//! crop instead of guessing from the pool shape. Images are allocated at the
|
||||
//! `pictureAccessGranularity`-rounded extent; the stream's coded extent rides
|
||||
//! separately for per-picture resources.
|
||||
//! 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 ash::vk::native as hh;
|
||||
@@ -33,105 +33,109 @@ use crate::device::find_memory_type;
|
||||
use crate::device::AllocError;
|
||||
use crate::device::DecodeDevice;
|
||||
|
||||
/// Distinct-mode output ring depth: decode-ahead is one-in/one-out under the
|
||||
/// punktfunk envelope, so a small ring covers pipelining plus a frame in the
|
||||
/// presenter's hands.
|
||||
pub const OUTPUT_RING: u32 = 4;
|
||||
/// 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, slot-count) pair.
|
||||
/// 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,
|
||||
pub dpb_flags: vk::ImageCreateFlags,
|
||||
/// 0 in coincide mode — outputs ARE the DPB slots.
|
||||
pub output_image_count: u32,
|
||||
pub output_usage: vk::ImageUsageFlags,
|
||||
pub output_flags: vk::ImageCreateFlags,
|
||||
/// Semaphore/query/command ring size: DPB slots when coincide, the output
|
||||
/// ring otherwise.
|
||||
pub output_slots: u32,
|
||||
/// 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 — the four caps combinations are unit-tested below.
|
||||
/// Decide the pool shape. Pure — unit-tested below.
|
||||
///
|
||||
/// The usages are exactly the ones the caps derivation validated against the
|
||||
/// driver's advertised envelope ([`DPB_USAGE`]/[`OUTPUT_USAGE`]/[`COINCIDE_USAGE`]);
|
||||
/// presenter-facing images add only `MUTABLE_FORMAT` (advertised — derive_caps
|
||||
/// gates on it; no `ALIAS`: nothing aliases these images).
|
||||
pub fn plan_pools(caps: &DecodeCaps, dpb_slots: u32, output_ring: u32) -> PoolPlan {
|
||||
let (dpb_image_count, dpb_layers_per_image) = if caps.layered_dpb {
|
||||
(1, dpb_slots)
|
||||
} else {
|
||||
(dpb_slots, 1)
|
||||
};
|
||||
let presented_flags = vk::ImageCreateFlags::MUTABLE_FORMAT;
|
||||
/// `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,
|
||||
dpb_layers_per_image,
|
||||
dpb_usage: COINCIDE_USAGE,
|
||||
dpb_flags: presented_flags,
|
||||
output_image_count: 0,
|
||||
output_usage: vk::ImageUsageFlags::empty(),
|
||||
output_flags: vk::ImageCreateFlags::empty(),
|
||||
output_slots: dpb_slots,
|
||||
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,
|
||||
dpb_flags: vk::ImageCreateFlags::empty(),
|
||||
output_image_count: output_ring,
|
||||
output_usage: OUTPUT_USAGE,
|
||||
output_flags: presented_flags,
|
||||
output_slots: output_ring,
|
||||
picture_count,
|
||||
picture_usage: OUTPUT_USAGE,
|
||||
picture_flags,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One presenter-facing output slot: the image (a DPB slot's in coincide mode, a
|
||||
/// ring image otherwise), its full + per-plane views, and the timeline semaphore
|
||||
/// each decode into this slot signals.
|
||||
pub(crate) struct OutputSlot {
|
||||
/// One picture-pool image with its sync + occupancy ledger.
|
||||
pub(crate) struct Picture {
|
||||
pub image: vk::Image,
|
||||
/// The image's array layer this slot occupies (barriers target it; the VIEWS
|
||||
/// already select it, so picture resources use `base_array_layer` 0).
|
||||
pub layer: u32,
|
||||
/// Full-picture view in the pool format (what decode binds as `dst`).
|
||||
/// Full-picture NV12 view (decode dst / DPB binding).
|
||||
pub view: vk::ImageView,
|
||||
/// `R8_UNORM` / `R8G8_UNORM` plane views for the presenter's sampler path.
|
||||
/// `R8`/`R8G8` plane views for the presenter's sampler path.
|
||||
pub plane_views: [vk::ImageView; 2],
|
||||
/// The image's own timeline semaphore (AVVkFrame contract).
|
||||
pub semaphore: vk::Semaphore,
|
||||
/// Last timeline value signalled on `semaphore` (0 = never used).
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// The Vulkan half: images, memory, views, semaphores. Destroys everything it
|
||||
/// created on drop (null-safe, so a half-built pool from a failed create unwinds
|
||||
/// cleanly).
|
||||
pub(crate) struct ImagePool {
|
||||
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,
|
||||
pub(crate) coincide: bool,
|
||||
/// The STREAM's coded extent — what per-picture resources report.
|
||||
pub(crate) coded_extent: vk::Extent2D,
|
||||
/// The allocation extent: `coded_extent` rounded up to the device's
|
||||
/// `pictureAccessGranularity` (images only; never leaks into picture params).
|
||||
image_extent: vk::Extent2D,
|
||||
images: Vec<vk::Image>,
|
||||
memory: Vec<vk::DeviceMemory>,
|
||||
/// Per-DPB-slot full view (setup/reference binding).
|
||||
dpb_views: Vec<vk::ImageView>,
|
||||
/// Per-DPB-slot (image index, array layer) for barrier targeting.
|
||||
dpb_location: Vec<(usize, u32)>,
|
||||
pub(crate) outputs: Vec<OutputSlot>,
|
||||
pub(crate) pictures: Vec<Picture>,
|
||||
}
|
||||
|
||||
impl ImagePool {
|
||||
/// Create the pools for `plan`: images at the granularity-rounded
|
||||
/// `image_extent`, picture metadata at the stream's `coded_extent`.
|
||||
impl PicturePool {
|
||||
/// Create `plan.picture_count` single-layer images at `extent` (the
|
||||
/// granularity-ALIGNED allocation extent).
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
@@ -140,157 +144,182 @@ impl ImagePool {
|
||||
dev: &DecodeDevice,
|
||||
caps: &DecodeCaps,
|
||||
plan: &PoolPlan,
|
||||
coded_extent: vk::Extent2D,
|
||||
extent: vk::Extent2D,
|
||||
std_profile_idc: hh::StdVideoH264ProfileIdc,
|
||||
) -> Result<Self, AllocError> {
|
||||
let mut pool = Self {
|
||||
device: dev.ash().clone(),
|
||||
coincide: caps.coincide,
|
||||
coded_extent,
|
||||
image_extent: caps.aligned_extent(coded_extent),
|
||||
images: Vec::new(),
|
||||
memory: Vec::new(),
|
||||
dpb_views: Vec::new(),
|
||||
dpb_location: Vec::new(),
|
||||
outputs: Vec::new(),
|
||||
pictures: Vec::new(),
|
||||
};
|
||||
// SAFETY: caller's contract; on error `pool` drops and unwinds whatever
|
||||
// half was built (Drop is null-safe and destroys only owned objects).
|
||||
unsafe { pool.build(dev, caps, plan, std_profile_idc)? };
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// As [`Self::create`].
|
||||
unsafe fn build(
|
||||
&mut self,
|
||||
dev: &DecodeDevice,
|
||||
caps: &DecodeCaps,
|
||||
plan: &PoolPlan,
|
||||
std_profile_idc: hh::StdVideoH264ProfileIdc,
|
||||
) -> Result<(), AllocError> {
|
||||
let families = dev.sharing_families();
|
||||
|
||||
// DPB images + per-slot views.
|
||||
for _ in 0..plan.dpb_image_count {
|
||||
// SAFETY: fn contract (live device).
|
||||
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.dpb_format,
|
||||
self.image_extent,
|
||||
plan.dpb_layers_per_image,
|
||||
plan.dpb_usage,
|
||||
plan.dpb_flags,
|
||||
caps.output_format,
|
||||
extent,
|
||||
1,
|
||||
plan.picture_usage,
|
||||
plan.picture_flags,
|
||||
&families,
|
||||
std_profile_idc,
|
||||
)?
|
||||
};
|
||||
self.images.push(image);
|
||||
self.memory.push(memory);
|
||||
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); plane formats are
|
||||
// NV12-compatible under MUTABLE_FORMAT (caps-gated by derive_caps).
|
||||
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,
|
||||
vk::Format::R8_UNORM,
|
||||
vk::ImageAspectFlags::PLANE_0,
|
||||
0,
|
||||
)?;
|
||||
pool.pictures[picture].plane_views[1] = create_view(
|
||||
&pool.device,
|
||||
image,
|
||||
vk::Format::R8G8_UNORM,
|
||||
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)? };
|
||||
}
|
||||
let dpb_slots = plan.dpb_image_count * plan.dpb_layers_per_image;
|
||||
for slot in 0..dpb_slots {
|
||||
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,
|
||||
std_profile_idc: hh::StdVideoH264ProfileIdc,
|
||||
) -> 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,
|
||||
std_profile_idc,
|
||||
)?
|
||||
};
|
||||
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: `image` was created above with at least `layer + 1` layers.
|
||||
// SAFETY: the image was created above with `layer` in range.
|
||||
let view = unsafe {
|
||||
create_view(
|
||||
&self.device,
|
||||
self.images[image_index],
|
||||
&pool.device,
|
||||
pool.images[image_index],
|
||||
caps.dpb_format,
|
||||
vk::ImageAspectFlags::COLOR,
|
||||
layer,
|
||||
)?
|
||||
};
|
||||
self.dpb_views.push(view);
|
||||
self.dpb_location.push((image_index, layer));
|
||||
pool.dpb_views.push(view);
|
||||
pool.dpb_location.push((image_index, layer));
|
||||
}
|
||||
|
||||
// Output slots: over the DPB slots (coincide) or over a fresh ring.
|
||||
let output_targets: Vec<(vk::Image, u32)> = if caps.coincide {
|
||||
self.dpb_location
|
||||
.iter()
|
||||
.map(|&(image_index, layer)| (self.images[image_index], layer))
|
||||
.collect()
|
||||
} else {
|
||||
let mut targets = Vec::new();
|
||||
for _ in 0..plan.output_image_count {
|
||||
// SAFETY: fn contract (live device).
|
||||
let (image, memory) = unsafe {
|
||||
create_video_image(
|
||||
dev,
|
||||
caps.output_format,
|
||||
self.image_extent,
|
||||
1,
|
||||
plan.output_usage,
|
||||
plan.output_flags,
|
||||
&families,
|
||||
std_profile_idc,
|
||||
)?
|
||||
};
|
||||
self.images.push(image);
|
||||
self.memory.push(memory);
|
||||
targets.push((image, 0));
|
||||
}
|
||||
targets
|
||||
};
|
||||
|
||||
for (image, layer) in output_targets {
|
||||
// SAFETY: `image` exists with `layer` in range (holds for all four
|
||||
// creates below); the formats are plane-compatible with the pool
|
||||
// format (NV12: R8 + R8G8) and the image carries MUTABLE_FORMAT for
|
||||
// the reinterpreting views.
|
||||
let view = unsafe {
|
||||
create_view(
|
||||
&self.device,
|
||||
image,
|
||||
caps.output_format,
|
||||
vk::ImageAspectFlags::COLOR,
|
||||
layer,
|
||||
)?
|
||||
};
|
||||
// SAFETY: as above.
|
||||
let plane_y = unsafe {
|
||||
create_view(
|
||||
&self.device,
|
||||
image,
|
||||
vk::Format::R8_UNORM,
|
||||
vk::ImageAspectFlags::PLANE_0,
|
||||
layer,
|
||||
)?
|
||||
};
|
||||
// SAFETY: as above.
|
||||
let plane_uv = unsafe {
|
||||
create_view(
|
||||
&self.device,
|
||||
image,
|
||||
vk::Format::R8G8_UNORM,
|
||||
vk::ImageAspectFlags::PLANE_1,
|
||||
layer,
|
||||
)?
|
||||
};
|
||||
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 is enabled per the
|
||||
// DeviceHandles feature contract.
|
||||
let semaphore = unsafe { self.device.create_semaphore(&sem_ci, None)? };
|
||||
self.outputs.push(OutputSlot {
|
||||
image,
|
||||
layer,
|
||||
view,
|
||||
plane_views: [plane_y, plane_uv],
|
||||
semaphore,
|
||||
value: 0,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
/// The DPB binding view of `slot`.
|
||||
@@ -305,21 +334,15 @@ impl ImagePool {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ImagePool {
|
||||
impl Drop for DpbPool {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: every handle below was created by this pool on this (still-live,
|
||||
// per the DeviceHandles contract) device; the owning decoder drains GPU
|
||||
// work before dropping state. vkDestroy*/vkFree ignore NULL handles.
|
||||
// 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 out in self.outputs.drain(..) {
|
||||
self.device.destroy_image_view(out.view, None);
|
||||
self.device.destroy_image_view(out.plane_views[0], None);
|
||||
self.device.destroy_image_view(out.plane_views[1], None);
|
||||
self.device.destroy_semaphore(out.semaphore, None);
|
||||
}
|
||||
for image in self.images.drain(..) {
|
||||
self.device.destroy_image(image, None);
|
||||
}
|
||||
@@ -480,54 +503,66 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coincide_layered_is_one_dual_use_array_with_no_output_ring() {
|
||||
let plan = plan_pools(&caps(true, true), 5, OUTPUT_RING);
|
||||
assert_eq!((plan.dpb_image_count, plan.dpb_layers_per_image), (1, 5));
|
||||
fn coincide_pools_are_headroomed_dual_use_pictures_with_no_dpb_array() {
|
||||
let plan = plan_pools(&caps(true, false), 8);
|
||||
assert_eq!(
|
||||
plan.dpb_usage, COINCIDE_USAGE,
|
||||
"coincide: the DPB image is the decode output AND the sampled surface"
|
||||
plan.dpb_image_count, 0,
|
||||
"the picture pool IS the DPB backing"
|
||||
);
|
||||
assert_eq!(
|
||||
plan.dpb_flags,
|
||||
vk::ImageCreateFlags::MUTABLE_FORMAT,
|
||||
"plane views need MUTABLE_FORMAT; nothing aliases, so no ALIAS"
|
||||
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.output_image_count, 0);
|
||||
assert_eq!(plan.output_slots, 5, "one output slot per DPB slot");
|
||||
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 coincide_separate_is_one_dual_use_image_per_slot() {
|
||||
let plan = plan_pools(&caps(true, false), 5, OUTPUT_RING);
|
||||
assert_eq!((plan.dpb_image_count, plan.dpb_layers_per_image), (5, 1));
|
||||
assert_eq!(plan.output_image_count, 0);
|
||||
assert_eq!(plan.output_slots, 5);
|
||||
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 distinct_layered_is_a_reference_only_array_plus_an_output_ring() {
|
||||
let plan = plan_pools(&caps(false, true), 17, OUTPUT_RING);
|
||||
assert_eq!((plan.dpb_image_count, plan.dpb_layers_per_image), (1, 17));
|
||||
assert_eq!(
|
||||
plan.dpb_usage, DPB_USAGE,
|
||||
"distinct: the DPB is never sampled and never a decode dst"
|
||||
);
|
||||
assert_eq!(plan.dpb_flags, vk::ImageCreateFlags::empty());
|
||||
assert_eq!(plan.output_image_count, OUTPUT_RING);
|
||||
assert_eq!(plan.output_usage, OUTPUT_USAGE);
|
||||
assert_eq!(
|
||||
plan.output_flags,
|
||||
vk::ImageCreateFlags::MUTABLE_FORMAT,
|
||||
"plane views need MUTABLE_FORMAT; nothing aliases, so no ALIAS"
|
||||
);
|
||||
assert_eq!(plan.output_slots, OUTPUT_RING);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_separate_is_per_slot_reference_images_plus_the_ring() {
|
||||
let plan = plan_pools(&caps(false, false), 3, 2);
|
||||
assert_eq!((plan.dpb_image_count, plan.dpb_layers_per_image), (3, 1));
|
||||
assert_eq!(plan.output_image_count, 2);
|
||||
assert_eq!(plan.output_slots, 2);
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,13 +27,17 @@
|
||||
//! coincide/distinct/layered decision table ([`DecodeCaps`]).
|
||||
//! - [`session`]: `VkVideoSessionKHR` + versioned session parameters (pure ledger
|
||||
//! decides Add-vs-Recreate; extent/DPB renegotiation rebuilds the session).
|
||||
//! - [`images`]: DPB + output pools for BOTH DPB arrangements (pure [`plan_pools`]
|
||||
//! decides the shape), per-plane views, one timeline semaphore per output slot,
|
||||
//! crop carried on the frame.
|
||||
//! - [`ring`]: the host-visible bitstream upload ring (pure alignment/growth math).
|
||||
//! - [`images`]: the picture pool DECOUPLED from DPB slots (the zero-copy FFmpeg
|
||||
//! pool model — a re-activated slot binds a fresh free image, so delivered
|
||||
//! pictures are never decode targets), per-image timeline semaphores with the
|
||||
//! presenter `value+1` write-back, per-plane views, [`HOLD_HEADROOM`] sizing.
|
||||
//! - [`ring`]: the host-visible bitstream upload ring (pure alignment/growth
|
||||
//! math) — SLICE NALUs only (feeding a whole AU hangs VCN firmware).
|
||||
//! - [`decoder`]: [`VkH264Decoder`] — plan → convert → upload → record → submit,
|
||||
//! with a per-op `RESULT_STATUS_ONLY` query ([`VkH264Decoder::poll_status`]) so
|
||||
//! driver-reported corruption is finally observable (the Ally X class).
|
||||
//! driver-reported corruption is finally observable (the Ally X class) —
|
||||
//! caps-gated per queue family: where `queryResultStatusSupport` is absent
|
||||
//! (RADV), verdicts degrade to timeline completion, FFmpeg parity.
|
||||
//!
|
||||
//! Unsafe posture: unlike pf-bitstream (which forbids unsafe outright), this crate
|
||||
//! cannot — the `ash::vk::native` bindgen structs are zero-initialized the way the
|
||||
@@ -80,6 +84,7 @@ pub use device::QueueLock;
|
||||
pub use device::QueueSubmitGuard;
|
||||
pub use images::plan_pools;
|
||||
pub use images::PoolPlan;
|
||||
pub use images::HOLD_HEADROOM;
|
||||
pub use params::pps_to_std;
|
||||
pub use params::sps_to_std;
|
||||
pub use params::OwnedStdPps;
|
||||
|
||||
+151
-52
@@ -360,6 +360,114 @@ mod tests {
|
||||
aus
|
||||
}
|
||||
|
||||
/// The decoder's picture-pool occupancy (`bound`/`pending`/`held`, exactly
|
||||
/// `decode_inner`'s bookkeeping minus the GPU) over the WHOLE vendored
|
||||
/// vector, with a consumer that HOLDS `hold` delivered frames before
|
||||
/// releasing the oldest — the real client's shape (~4-7 held across its
|
||||
/// channels, preroll and in-flight present). Returns the first starved AU
|
||||
/// index, if any.
|
||||
fn simulate_pool_occupancy(pool_size: usize, hold: usize) -> Option<usize> {
|
||||
use std::collections::VecDeque;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct SimPicture {
|
||||
bound: bool,
|
||||
pending: bool,
|
||||
held: u32,
|
||||
}
|
||||
|
||||
let aus = split_into_aus(TEST_25FPS);
|
||||
let mut planner = H264Planner::new();
|
||||
let mut slots: Option<SlotMap> = None;
|
||||
let mut pictures = vec![SimPicture::default(); pool_size];
|
||||
let mut slot_image: Vec<Option<usize>> = Vec::new();
|
||||
// id -> pool image of the decoded picture awaiting its output verdict.
|
||||
let mut pending: BTreeMap<PicId, usize> = BTreeMap::new();
|
||||
// Delivered frames the consumer holds, oldest first.
|
||||
let mut consumer: VecDeque<usize> = VecDeque::new();
|
||||
|
||||
for (index, au) in aus.iter().enumerate() {
|
||||
let plan = planner.plan_au(au).expect("the clean vector plans");
|
||||
let slots = slots.get_or_insert_with(|| {
|
||||
slot_image = vec![None; plan.picture.max_dpb_frames + 1];
|
||||
SlotMap::new(plan.picture.max_dpb_frames)
|
||||
});
|
||||
let vk = plan_to_vk(&plan, slots, 0).expect("the clean vector converts");
|
||||
|
||||
// Binding sync: released slots unbind; the setup slot rebinds fresh.
|
||||
let setup = usize::from(vk.setup_slot);
|
||||
let mut held_slots = vec![false; slot_image.len()];
|
||||
for (slot, _id) in slots.held() {
|
||||
held_slots[usize::from(slot)] = true;
|
||||
}
|
||||
for (slot, binding) in slot_image.iter_mut().enumerate() {
|
||||
if let Some(picture) = *binding {
|
||||
if !held_slots[slot] || slot == setup {
|
||||
pictures[picture].bound = false;
|
||||
*binding = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The decode target: a free pool image.
|
||||
let Some(dst) = pictures
|
||||
.iter()
|
||||
.position(|p| !p.bound && !p.pending && p.held == 0)
|
||||
else {
|
||||
return Some(index);
|
||||
};
|
||||
pictures[dst].pending = true;
|
||||
pictures[dst].bound = true;
|
||||
slot_image[setup] = Some(dst);
|
||||
pending.insert(vk.setup_id, dst);
|
||||
|
||||
// Settle: outputs deliver to the consumer; removed-never-output free.
|
||||
for id in &plan.dpb.outputs {
|
||||
if let Some(picture) = pending.remove(id) {
|
||||
pictures[picture].pending = false;
|
||||
pictures[picture].held += 1;
|
||||
consumer.push_back(picture);
|
||||
}
|
||||
}
|
||||
for id in &plan.dpb.removed {
|
||||
if let Some(picture) = pending.remove(id) {
|
||||
pictures[picture].pending = false;
|
||||
}
|
||||
}
|
||||
// The hold-N consumer: releases only once it holds MORE than `hold`.
|
||||
while consumer.len() > hold {
|
||||
let released = consumer.pop_front().expect("nonempty");
|
||||
pictures[released].held -= 1;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The .25 field-failure regression, pool-model edition: the vendored vector
|
||||
/// keeps up to `max_dpb_frames + 1 = 8` pictures resident AND the real
|
||||
/// client holds ~4 delivered frames — the pool must absorb BOTH at once.
|
||||
/// `required_slots + HOLD_HEADROOM` never starves; the counterfactual shows
|
||||
/// an under-headroomed pool starving on the same clean stream, which is the
|
||||
/// exact class the fixed-size ring shipped in the first WP-B round.
|
||||
#[test]
|
||||
fn the_full_vector_with_a_hold_four_consumer_never_starves_the_picture_pool() {
|
||||
// This vector: max_dpb_frames = 7 → required_slots = 8 (measured;
|
||||
// asserted inside via SlotMap sizing).
|
||||
let required_slots = 8;
|
||||
let headroom = crate::images::HOLD_HEADROOM as usize;
|
||||
assert_eq!(
|
||||
simulate_pool_occupancy(required_slots + headroom, 4),
|
||||
None,
|
||||
"the shipped sizing must survive the whole vector with 4 held frames"
|
||||
);
|
||||
// Counterfactual: holds beyond the headroom starve — the documented
|
||||
// NoFreeSlot condition, now meaning exactly what it says.
|
||||
assert!(
|
||||
simulate_pool_occupancy(required_slots + 2, 4).is_some(),
|
||||
"an under-headroomed pool must starve (else this regression proves nothing)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_full_25fps_vector_converts_with_stable_slots_and_start_code_offsets() {
|
||||
let aus = split_into_aus(TEST_25FPS);
|
||||
@@ -684,15 +792,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_dpb_bump_reuses_the_evicted_slot_only_after_its_frame_is_released() {
|
||||
fn a_full_dpb_bump_reuses_the_slot_but_the_pool_model_binds_a_fresh_image() {
|
||||
// Depth-1 DPB (Level 1 at 320x240 ⇒ max_dpb_frames 1, capacity 2): every
|
||||
// stored P evicts the previous picture, and that picture's id lands in
|
||||
// BOTH `outputs` and `removed` of the SAME plan — the exact sequence
|
||||
// where, without pins, `plan_to_vk` frees the evicted slot and
|
||||
// immediately re-assigns it as this AU's setup while the evicted
|
||||
// picture's frame is still in the consumer's hands (the HIGH overwrite
|
||||
// bug of the adversarial round). This test drives the decoder's exact
|
||||
// call pattern: pin at frame creation, unpin at release_frame.
|
||||
// BOTH `outputs` and `removed` of the SAME plan — so `plan_to_vk` frees
|
||||
// the evicted slot and immediately re-assigns it as this AU's setup.
|
||||
// That SLOT reuse is fine and expected; the picture-pool model's whole
|
||||
// point is that the re-activated slot binds a DIFFERENT free image, so
|
||||
// the delivered picture's image is never the new decode target while the
|
||||
// consumer holds it (the HIGH overwrite bug of the adversarial round,
|
||||
// and the .25 field failure's class).
|
||||
let sps = SpsBuilder::new()
|
||||
.seq_parameter_set_id(0)
|
||||
.profile_idc(Profile::Main)
|
||||
@@ -714,64 +823,54 @@ mod tests {
|
||||
Synthesizer::<'_, Pps, _>::synthesize(3, &pps, &mut au0, true).unwrap();
|
||||
au0.extend(write_idr_slice(None));
|
||||
|
||||
// First, the COUNTERFACTUAL (no pins): the bump hands the evicted
|
||||
// picture's slot straight back as the next setup — the bug this guards.
|
||||
{
|
||||
let mut planner = H264Planner::new();
|
||||
let p0 = planner.plan_au(&au0).unwrap();
|
||||
let mut slots = SlotMap::new(p0.picture.max_dpb_frames);
|
||||
let vk0 = plan_to_vk(&p0, &mut slots, 0).unwrap();
|
||||
let p1 = planner
|
||||
.plan_au(&write_p_slice(1, 2, None, 1, None))
|
||||
.unwrap();
|
||||
let id0 = vk0.setup_id;
|
||||
assert!(p1.dpb.outputs.contains(&id0) && p1.dpb.removed.contains(&id0));
|
||||
let vk1 = plan_to_vk(&p1, &mut slots, 0).unwrap();
|
||||
assert_eq!(
|
||||
vk1.setup_slot, vk0.setup_slot,
|
||||
"without pins the delivered frame's image IS the next decode target"
|
||||
);
|
||||
}
|
||||
|
||||
// Now the decoder's discipline: every frame pins its slot at creation.
|
||||
let mut planner = H264Planner::new();
|
||||
let p0 = planner.plan_au(&au0).unwrap();
|
||||
let mut slots = SlotMap::new(p0.picture.max_dpb_frames);
|
||||
let vk0 = plan_to_vk(&p0, &mut slots, 0).unwrap();
|
||||
slots.pin(vk0.setup_slot);
|
||||
|
||||
// AU1 bumps AU0's picture (outputs + removed) — the freed slot is
|
||||
// pinned, so the setup lands elsewhere and the unreleased frame's image
|
||||
// is never a decode target.
|
||||
// Decoder-side pool bookkeeping (mirrors decode_inner): image 0 hosts
|
||||
// picture 0; the consumer receives and HOLDS its frame.
|
||||
let pool = 2 + 1; // required_slots + 1 of headroom is enough here
|
||||
let mut bound = vec![false; pool];
|
||||
let mut held = vec![0u32; pool];
|
||||
let mut slot_image: Vec<Option<usize>> = vec![None; slots.capacity()];
|
||||
let free = |bound: &[bool], held: &[u32]| (0..pool).find(|&i| !bound[i] && held[i] == 0);
|
||||
|
||||
let img0 = free(&bound, &held).unwrap();
|
||||
bound[img0] = true;
|
||||
slot_image[usize::from(vk0.setup_slot)] = Some(img0);
|
||||
|
||||
// AU1 bumps AU0's picture: outputs+removed carry id0, and plan_to_vk
|
||||
// hands the SAME slot back as the setup.
|
||||
let p1 = planner
|
||||
.plan_au(&write_p_slice(1, 2, None, 1, None))
|
||||
.unwrap();
|
||||
assert!(p1.dpb.removed.contains(&vk0.setup_id));
|
||||
assert!(p1.dpb.outputs.contains(&vk0.setup_id) && p1.dpb.removed.contains(&vk0.setup_id));
|
||||
let vk1 = plan_to_vk(&p1, &mut slots, 0).unwrap();
|
||||
assert_ne!(
|
||||
assert_eq!(
|
||||
vk1.setup_slot, vk0.setup_slot,
|
||||
"a pinned (delivered, unreleased) slot must never be the setup"
|
||||
"slot reuse across the bump is the planner's normal behaviour"
|
||||
);
|
||||
slots.pin(vk1.setup_slot);
|
||||
|
||||
// With NOTHING released, the next AU has no assignable slot: explicit
|
||||
// backpressure (AllPinned → the decoder's NoFreeSlot), never an overwrite.
|
||||
let p2 = planner
|
||||
.plan_au(&write_p_slice(2, 4, None, 1, None))
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
plan_to_vk(&p2, &mut slots, 0),
|
||||
Err(PlanToVkError::Slot(SlotError::AllPinned { .. }))
|
||||
));
|
||||
// Binding sync: the re-activated slot drops its old binding; picture 0's
|
||||
// image is now delivered to the consumer (held), NOT freed.
|
||||
bound[img0] = false;
|
||||
held[img0] += 1; // outputs → delivered, consumer holds it
|
||||
slot_image[usize::from(vk1.setup_slot)] = None;
|
||||
|
||||
// The consumer releases frame 0 (decoder: release_frame → unpin): its
|
||||
// slot becomes the next setup — reuse happens exactly one release later.
|
||||
assert!(slots.unpin(vk0.setup_slot));
|
||||
let p3 = planner
|
||||
.plan_au(&write_idr_slice(None))
|
||||
.expect("an IDR restart plans after the stalled AU");
|
||||
let vk3 = plan_to_vk(&p3, &mut slots, 0).unwrap();
|
||||
assert_eq!(vk3.setup_slot, vk0.setup_slot);
|
||||
// The pool hands the re-activated slot a FRESH image — never image 0.
|
||||
let img1 = free(&bound, &held).expect("headroom guarantees a free image");
|
||||
assert_ne!(
|
||||
img1, img0,
|
||||
"the held (delivered, unreleased) image must never be re-bound as a \
|
||||
decode target — the pool decoupling IS the overwrite fix"
|
||||
);
|
||||
bound[img1] = true;
|
||||
slot_image[usize::from(vk1.setup_slot)] = Some(img1);
|
||||
|
||||
// Once the consumer releases frame 0, image 0 returns to the free list.
|
||||
held[img0] -= 1;
|
||||
assert_eq!(free(&bound, &held), Some(img0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -297,19 +297,29 @@ impl BitstreamRing {
|
||||
/// it has (bounded by the caller's timeout policy). The split keeps this
|
||||
/// module free of any semaphore knowledge.
|
||||
///
|
||||
/// `segments` are the byte ranges of `au` to upload, CONCATENATED — the
|
||||
/// decoder passes the SLICE NALUs only. The buffer must contain nothing but
|
||||
/// slice data: the VCN firmware scans the submitted range itself, and
|
||||
/// non-slice NALUs (AUD/SEI/SPS/PPS, which real AUs open with) in the range
|
||||
/// hang it — the 2026-08 .25 `vcn_unified_0 ring timeout`. FFmpeg's decoder
|
||||
/// feeds slices-only for the same reason; parameter sets ride the session
|
||||
/// parameters object instead.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// Live device (contract), and the tokens passed to prior
|
||||
/// [`SlotStates::set_pending`] calls must genuinely cover every GPU read of
|
||||
/// their slots — recycling rewrites slot bytes as soon as a token reports done.
|
||||
/// Live device (contract); `segments` are in-bounds ranges of `au`; and the
|
||||
/// tokens passed to prior [`SlotStates::set_pending`] calls genuinely cover
|
||||
/// every GPU read of their slots — recycling rewrites slot bytes as soon as a
|
||||
/// token reports done.
|
||||
pub(crate) unsafe fn upload<E: From<AllocError>>(
|
||||
&mut self,
|
||||
dev: &DecodeDevice,
|
||||
au: &[u8],
|
||||
segments: &[std::ops::Range<usize>],
|
||||
poll: &mut dyn FnMut(&Token) -> Result<bool, E>,
|
||||
wait: &mut dyn FnMut(&Token) -> Result<(), E>,
|
||||
) -> Result<UploadedAu, E> {
|
||||
let len = au.len() as u64;
|
||||
let len: u64 = segments.iter().map(|s| s.len() as u64).sum();
|
||||
if !self.layout.fits(len) {
|
||||
// Grow: drain EVERYTHING in flight (their reads target the old buffer),
|
||||
// then recreate the backing under the grown layout.
|
||||
@@ -356,14 +366,22 @@ impl BitstreamRing {
|
||||
// SAFETY: `ptr` is the live persistent mapping of a buffer of
|
||||
// `layout.buffer_size()` bytes; `offset + range <= buffer_size` because
|
||||
// `range <= slot_size` (fits/grown above) and offset is `slot * slot_size`
|
||||
// with `slot < slots`. The slot is not concurrently read: its previous use
|
||||
// completed (poll/wait above) and its next use is submitted after this copy.
|
||||
// with `slot < slots`; each segment is an in-bounds range of `au` (fn
|
||||
// contract) and the cursor advances by exactly the bytes written, staying
|
||||
// within `len <= range`. The slot is not concurrently read: its previous
|
||||
// use completed (poll/wait above) and its next use is submitted after
|
||||
// this copy.
|
||||
unsafe {
|
||||
let base = self.ptr.add(offset as usize);
|
||||
std::ptr::copy_nonoverlapping(au.as_ptr(), base, au.len());
|
||||
let mut cursor = 0usize;
|
||||
for segment in segments {
|
||||
let bytes = &au[segment.clone()];
|
||||
std::ptr::copy_nonoverlapping(bytes.as_ptr(), base.add(cursor), bytes.len());
|
||||
cursor += bytes.len();
|
||||
}
|
||||
// Zero the alignment tail so the recorded range never hands the driver
|
||||
// stale bytes from a previous AU behind this one's end.
|
||||
std::ptr::write_bytes(base.add(au.len()), 0, (range - len) as usize);
|
||||
std::ptr::write_bytes(base.add(cursor), 0, (range - len) as usize);
|
||||
}
|
||||
Ok(UploadedAu {
|
||||
offset,
|
||||
|
||||
+18
-115
@@ -14,10 +14,8 @@ use tracing::trace;
|
||||
/// The H.264 slot ceiling: 16 reference frames plus the picture being decoded.
|
||||
const MAX_SLOTS: usize = 17;
|
||||
|
||||
/// What went wrong with a slot operation. `Full`/`AlreadyAssigned` are caller
|
||||
/// bugs, not stream conditions — pf-bitstream degrades stream damage to warnings
|
||||
/// long before here. `AllPinned` is neither: it reports a consumer that has not
|
||||
/// released delivered frames (backpressure, not a ledger fault).
|
||||
/// What went wrong with a slot operation. Both variants are caller bugs, not stream
|
||||
/// conditions — pf-bitstream degrades stream damage to warnings long before here.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SlotError {
|
||||
/// No free slot. The map is sized to `max_dpb_frames + 1`, which the planner's
|
||||
@@ -25,11 +23,6 @@ pub enum SlotError {
|
||||
Full { capacity: usize },
|
||||
/// The id already holds a slot; ids are per-picture and never re-assigned.
|
||||
AlreadyAssigned { id: PicId, slot: u8 },
|
||||
/// Free slots exist but every one is [pinned](SlotMap::pin) by an out-of-DPB
|
||||
/// reader (a delivered frame the consumer has not released). Assigning one
|
||||
/// would let the next decode overwrite an image someone is still reading —
|
||||
/// the caller must surface backpressure instead.
|
||||
AllPinned { free: usize },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SlotError {
|
||||
@@ -44,12 +37,6 @@ impl std::fmt::Display for SlotError {
|
||||
SlotError::AlreadyAssigned { id, slot } => {
|
||||
write!(f, "picture {id} already holds slot {slot}")
|
||||
}
|
||||
SlotError::AllPinned { free } => {
|
||||
write!(
|
||||
f,
|
||||
"every free DPB slot ({free}) is pinned by an unreleased frame"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,19 +49,16 @@ impl std::error::Error for SlotError {}
|
||||
/// Invariants (unit-tested):
|
||||
/// - a [`PicId`] keeps its slot from [`Self::assign`] until [`Self::release`];
|
||||
/// - a slot is reused only after its holder is released;
|
||||
/// - assigning past capacity errors instead of evicting;
|
||||
/// - a [pinned](Self::pin) slot is never assigned, held or free — pins are the
|
||||
/// WP-B two-phase-release layer: a slot backing a DELIVERED-but-unreleased
|
||||
/// frame stays pinned past its DPB eviction, because in coincide mode the next
|
||||
/// setup assignment would otherwise overwrite the very image the consumer is
|
||||
/// still reading (the full-DPB bump hands `outputs`+`removed` the same id in
|
||||
/// the same plan, and the freed slot is exactly the lowest one).
|
||||
/// - assigning past capacity errors instead of evicting.
|
||||
///
|
||||
/// Slots are pure planner bookkeeping: consumers never hold a SLOT (the decoder's
|
||||
/// picture pool decouples IMAGES from slots — a re-activated slot binds a fresh
|
||||
/// free image, so a delivered picture's image is never a decode target while the
|
||||
/// consumer reads it).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SlotMap {
|
||||
/// `slots[i]` holds the id bound to slot `i`, `None` while the slot is free.
|
||||
slots: Vec<Option<PicId>>,
|
||||
/// Out-of-DPB reader counts per slot (refcounted, orthogonal to residency).
|
||||
pinned: Vec<u32>,
|
||||
}
|
||||
|
||||
impl SlotMap {
|
||||
@@ -94,7 +78,6 @@ impl SlotMap {
|
||||
);
|
||||
Self {
|
||||
slots: vec![None; max_dpb_frames + 1],
|
||||
pinned: vec![0; max_dpb_frames + 1],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,62 +102,21 @@ impl SlotMap {
|
||||
.filter_map(|(index, slot)| slot.map(|id| (index as u8, id)))
|
||||
}
|
||||
|
||||
/// Bind `id` to the lowest free UNPINNED slot.
|
||||
///
|
||||
/// Free-but-pinned slots are skipped (their images are still read outside the
|
||||
/// DPB); when only such slots remain the error is [`SlotError::AllPinned`],
|
||||
/// distinct from [`SlotError::Full`] because it names a consumer that owes a
|
||||
/// release, not a ledger bug.
|
||||
/// Bind `id` to the lowest free slot.
|
||||
pub fn assign(&mut self, id: PicId) -> Result<u8, SlotError> {
|
||||
if let Some(slot) = self.slot_of(id) {
|
||||
return Err(SlotError::AlreadyAssigned { id, slot });
|
||||
}
|
||||
let mut free_but_pinned = 0usize;
|
||||
let assignable = self.slots.iter().enumerate().position(|(index, slot)| {
|
||||
if slot.is_some() {
|
||||
return false;
|
||||
}
|
||||
if self.pinned[index] > 0 {
|
||||
free_but_pinned += 1;
|
||||
return false;
|
||||
}
|
||||
true
|
||||
});
|
||||
match assignable {
|
||||
Some(free) => {
|
||||
self.slots[free] = Some(id);
|
||||
// The envelope-gated capacity (<= 17) keeps every index within u8.
|
||||
Ok(free as u8)
|
||||
}
|
||||
None if free_but_pinned > 0 => Err(SlotError::AllPinned {
|
||||
free: free_but_pinned,
|
||||
}),
|
||||
None => Err(SlotError::Full {
|
||||
let free = self
|
||||
.slots
|
||||
.iter()
|
||||
.position(Option::is_none)
|
||||
.ok_or(SlotError::Full {
|
||||
capacity: self.slots.len(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add one out-of-DPB reader to `slot` (refcounted): the slot stays
|
||||
/// unassignable — even after its picture leaves the DPB — until the matching
|
||||
/// [`Self::unpin`]. The decoder pins a slot for every live [frame] it backs
|
||||
/// and unpins on `release_frame`/internal drop.
|
||||
///
|
||||
/// [frame]: crate::decoder::DecodedVkFrame
|
||||
pub fn pin(&mut self, slot: u8) {
|
||||
self.pinned[usize::from(slot)] += 1;
|
||||
}
|
||||
|
||||
/// Remove one reader from `slot`. Returns `false` (and changes nothing) when
|
||||
/// the slot carried no pin — a double release, tolerated but never silent at
|
||||
/// the caller.
|
||||
pub fn unpin(&mut self, slot: u8) -> bool {
|
||||
let count = &mut self.pinned[usize::from(slot)];
|
||||
if *count == 0 {
|
||||
return false;
|
||||
}
|
||||
*count -= 1;
|
||||
true
|
||||
})?;
|
||||
self.slots[free] = Some(id);
|
||||
// The envelope-gated capacity (<= 17) keeps every index within u8.
|
||||
Ok(free as u8)
|
||||
}
|
||||
|
||||
/// The slot `id` holds, if any.
|
||||
@@ -309,45 +251,6 @@ mod tests {
|
||||
assert_eq!(slots.slot_of(2), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pinned_slot_is_skipped_by_assign_until_every_pin_is_released() {
|
||||
let mut slots = SlotMap::new(1); // capacity 2
|
||||
let s0 = slots.assign(1).unwrap();
|
||||
slots.pin(s0);
|
||||
slots.pin(s0); // refcounted: two readers
|
||||
slots.release(1); // DPB eviction — the pin must keep protecting the slot
|
||||
|
||||
// The pinned slot is skipped; the other free slot is handed out.
|
||||
let s1 = slots.assign(2).unwrap();
|
||||
assert_ne!(s1, s0);
|
||||
|
||||
// Now every free slot is pinned: a DISTINCT error from Full (the ledger
|
||||
// is fine; the consumer owes a release).
|
||||
assert_eq!(slots.assign(3), Err(SlotError::AllPinned { free: 1 }));
|
||||
|
||||
// One unpin is not enough (two readers were counted)…
|
||||
assert!(slots.unpin(s0));
|
||||
assert_eq!(slots.assign(3), Err(SlotError::AllPinned { free: 1 }));
|
||||
// …the second frees it, and the slot is assignable again.
|
||||
assert!(slots.unpin(s0));
|
||||
assert_eq!(slots.assign(3), Ok(s0));
|
||||
|
||||
// A pin-less unpin is a reported no-op, not an underflow.
|
||||
assert!(!slots.unpin(s1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_and_all_pinned_stay_distinct_verdicts() {
|
||||
let mut slots = SlotMap::new(1); // capacity 2
|
||||
slots.assign(1).unwrap();
|
||||
slots.assign(2).unwrap();
|
||||
// Genuinely full (all HELD): the missed-removals bug class.
|
||||
assert_eq!(slots.assign(3), Err(SlotError::Full { capacity: 2 }));
|
||||
// A pin on a HELD slot changes nothing about that verdict.
|
||||
slots.pin(0);
|
||||
assert_eq!(slots.assign(3), Err(SlotError::Full { capacity: 2 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hundred_synthetic_dpb_updates_churn_without_aliasing_a_slot() {
|
||||
// A sliding window of 4 references over 100 pictures: each id's slot must
|
||||
|
||||
@@ -15,15 +15,18 @@
|
||||
//! - `timelineSemaphore` + `synchronization2` feature support (Vulkan 1.3 core).
|
||||
//!
|
||||
//! What it proves: device wrap → caps query/derivation on REAL caps → session +
|
||||
//! parameters creation → DPB/output/ring pools → 48 AUs of the vendored 25fps
|
||||
//! vector decoded through `vkCmdDecodeVideoKHR` — well past DPB-full, so the
|
||||
//! bump-eviction slot-reuse path runs — with the full frame lifecycle each
|
||||
//! delivery: `wait_status` reading the RESULT_STATUS_ONLY query back as
|
||||
//! COMPLETE, then `release_frame` returning the slot (the two-phase release the
|
||||
//! coincide-mode overwrite fix depends on). What it deliberately does NOT prove
|
||||
//! (WP-D on-glass): pixel correctness vs the ffmpeg rung, presenter
|
||||
//! interop/layout round-trips, soak, and both vendors' DPB arrangements at once
|
||||
//! (each box exercises only its own).
|
||||
//! 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 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).
|
||||
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
@@ -69,7 +72,7 @@ fn split_into_aus(stream: &[u8]) -> Vec<&[u8]> {
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs a Vulkan Video H.264 decode device (fleet boxes; see module docs)"]
|
||||
fn decodes_48_aus_with_status_reads_and_frame_releases_past_dpb_full() {
|
||||
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");
|
||||
@@ -117,6 +120,30 @@ fn decodes_48_aus_with_status_reads_and_frame_releases_past_dpb_full() {
|
||||
.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() {
|
||||
@@ -187,22 +214,34 @@ fn decodes_48_aus_with_status_reads_and_frame_releases_past_dpb_full() {
|
||||
let mut decoder = unsafe { VkH264Decoder::new(&handles, Box::new(NoopQueueLock)) }
|
||||
.expect("wrap the device");
|
||||
|
||||
// 48 AUs — far past the vector's DPB depth, so evicted slots recycle
|
||||
// repeatedly — with WP-C's one-in/one-out lifecycle on every delivered
|
||||
// frame: wait its status (the program's whole point: the driver must
|
||||
// say COMPLETE, per op) and release it so its slot may host a later
|
||||
// decode. Output lags decode by a couple of AUs (B-pictures), so the
|
||||
// delivered count is asserted with slack.
|
||||
// 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 au in aus.iter().take(48) {
|
||||
let mut next = decoder
|
||||
.decode(au)
|
||||
.expect("decode an AU of the clean vector");
|
||||
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));
|
||||
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),
|
||||
@@ -213,18 +252,31 @@ fn decodes_48_aus_with_status_reads_and_frame_releases_past_dpb_full() {
|
||||
assert!(frame.value > 0);
|
||||
geometry_checked = true;
|
||||
}
|
||||
assert_eq!(
|
||||
decoder.wait_status(&frame),
|
||||
DecodeStatus::Ok,
|
||||
"the driver must report every decode op COMPLETE"
|
||||
);
|
||||
decoder
|
||||
.release_frame(&frame)
|
||||
.expect("a current-generation frame releases");
|
||||
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}"
|
||||
|
||||
Reference in New Issue
Block a user