diff --git a/clients/linux/src/app.rs b/clients/linux/src/app.rs
index 65dec580..fe4315cd 100644
--- a/clients/linux/src/app.rs
+++ b/clients/linux/src/app.rs
@@ -587,7 +587,7 @@ pub fn shortcuts_window(parent: &adw::ApplicationWindow) -> gtk::ShortcutsWindow
diff --git a/crates/pf-client-core/src/session.rs b/crates/pf-client-core/src/session.rs
index 5807ff69..663a6f9d 100644
--- a/crates/pf-client-core/src/session.rs
+++ b/crates/pf-client-core/src/session.rs
@@ -74,7 +74,10 @@ pub struct Stats {
/// `host + network`. An old host never emits 0xCF, so this stays false and the
/// combined stage renders unchanged.
pub split: bool,
- /// p50 `decode` stage: received → decoded, single-clock client-local (ms).
+ /// p50 `decode` stage: received → decode COMPLETE, single-clock client-local (ms).
+ /// Hardware paths measure GPU completion via the frame's timeline fence (an async
+ /// decoder's submission returning in ~0.1 ms is not "decoded"); software measures
+ /// the synchronous CPU decode.
pub decode_ms: f32,
/// Unrecoverable network frame drops this window, and their share of
/// received+lost (%). The OSD renders the counter line only when nonzero.
@@ -343,13 +346,33 @@ fn pump(
}
pending_split.push_back((frame.pts_ns, hn / 1000));
}
- // `decode` stage: received→decoded, single clock, no skew.
- decode_us.push(decoded_ns.saturating_sub(received_ns) / 1000);
+ // Ship the frame FIRST, then settle the decode stat: on the
+ // Vulkan path receive_frame returns at SUBMISSION (~0.1 ms) and
+ // the hardware decodes asynchronously — waiting the frame's
+ // timeline fence here (after the presenter already has the
+ // frame) measures true received→decode-complete at zero
+ // pipeline cost. Software/VAAPI keep the synchronous stamp.
+ let hw_fence = match &image {
+ DecodedImage::VkFrame(v) => {
+ Some((v.timeline_sem, v.decode_done_value))
+ }
+ _ => None,
+ };
let _ = frame_tx.force_send(DecodedFrame {
pts_ns: frame.pts_ns,
decoded_ns,
image,
});
+ // `decode` stage: received→decode COMPLETE, single clock.
+ let decode_done_ns = match hw_fence {
+ Some((sem, value))
+ if decoder.wait_hw_decoded(sem, value, 50_000_000) =>
+ {
+ now_ns()
+ }
+ _ => decoded_ns,
+ };
+ decode_us.push(decode_done_ns.saturating_sub(received_ns) / 1000);
}
Ok(None) => no_output_streak += 1,
// Survivable (loss until the next IDR/RFI recovery) — keep feeding.
diff --git a/crates/pf-client-core/src/video.rs b/crates/pf-client-core/src/video.rs
index 3fe3c38a..8d1b9739 100644
--- a/crates/pf-client-core/src/video.rs
+++ b/crates/pf-client-core/src/video.rs
@@ -68,6 +68,12 @@ pub struct VkVideoFrame {
/// The frame pool's VkFormat (`AVVulkanFramesContext.format[0]`, raw i32) — the
/// multiplanar format the presenter builds its per-plane views against.
pub vk_format: i32,
+ /// The frame's timeline semaphore (raw VkSemaphore; creation-constant) and the
+ /// value FFmpeg's decode submission signals on completion — the pump waits this
+ /// pair AFTER shipping the frame to measure true GPU decode time (zero pipeline
+ /// cost: the presenter already waits the same pair on the GPU).
+ pub timeline_sem: u64,
+ pub decode_done_value: u64,
pub width: u32,
pub height: u32,
pub color: ColorDesc,
@@ -313,6 +319,15 @@ impl Decoder {
done(Backend::Software(SoftwareDecoder::new(codec_id)?))
}
+ /// Wait for a Vulkan-Video frame's GPU decode to complete (timeline semaphore) —
+ /// the pump's decode-stat measurement. `false` = not the Vulkan backend, or timeout.
+ pub fn wait_hw_decoded(&self, timeline_sem: u64, value: u64, timeout_ns: u64) -> bool {
+ match &self.backend {
+ Backend::Vulkan(v) => v.wait_timeline(timeline_sem, value, timeout_ns),
+ _ => false,
+ }
+ }
+
/// Drain the "please ask the host for an IDR" flag — the pump calls this each iteration
/// (throttled) so a demoted/erroring decoder can resynchronize under the infinite GOP.
pub fn take_keyframe_request(&mut self) -> bool {
@@ -807,6 +822,10 @@ struct VulkanDecoder {
hw_device: *mut ffmpeg::ffi::AVBufferRef,
packet: *mut ffmpeg::ffi::AVPacket,
frame: *mut ffmpeg::ffi::AVFrame,
+ /// `vkWaitSemaphores` on the shared device — the decode-complete measurement
+ /// (resolved through the same get_proc_addr chain FFmpeg uses).
+ wait_semaphores: pf_ffvk::PFN_vkWaitSemaphores,
+ vk_device: pf_ffvk::VkDevice,
/// Storage `AVVulkanDeviceContext` points into (extension string arrays + the
/// feature chain) — FFmpeg reads the extension lists past init (frames-context
/// setup keys code paths off them), so this lives exactly as long as `hw_device`.
@@ -926,6 +945,26 @@ impl VulkanDecoder {
return Err(averr("av_hwdevice_ctx_init(VULKAN)", r));
}
+ // vkWaitSemaphores for the pump's decode-complete stat: loader →
+ // vkGetDeviceProcAddr → device fn (core 1.2, guaranteed by our gate).
+ let gipa = (*hwctx)
+ .get_proc_addr
+ .expect("get_proc_addr was just set above");
+ let gdpa: pf_ffvk::PFN_vkGetDeviceProcAddr = std::mem::transmute(gipa(
+ (*hwctx).inst,
+ c"vkGetDeviceProcAddr".as_ptr(),
+ ));
+ let wait_semaphores: pf_ffvk::PFN_vkWaitSemaphores = std::mem::transmute(gdpa
+ .expect("vkGetDeviceProcAddr resolvable")(
+ (*hwctx).act_dev,
+ c"vkWaitSemaphores".as_ptr(),
+ ));
+ if wait_semaphores.is_none() {
+ ffi::av_buffer_unref(&mut hw_device);
+ bail!("vkWaitSemaphores unresolvable on this device");
+ }
+ let vk_device = (*hwctx).act_dev;
+
let codec = ffi::avcodec_find_decoder(codec_id.into());
if codec.is_null() {
ffi::av_buffer_unref(&mut hw_device);
@@ -951,6 +990,8 @@ impl VulkanDecoder {
hw_device,
packet: ffi::av_packet_alloc(),
frame: ffi::av_frame_alloc(),
+ wait_semaphores,
+ vk_device,
_ctx_storage: store,
})
}
@@ -985,6 +1026,27 @@ impl VulkanDecoder {
}
}
+ /// Block until the timeline semaphore reaches `value` (GPU decode complete) or the
+ /// timeout passes. Pure measurement — the presenter's own GPU wait is what gates
+ /// sampling, so a timeout here only degrades the stat, never the picture.
+ fn wait_timeline(&self, sem: u64, value: u64, timeout_ns: u64) -> bool {
+ let sems = [sem as pf_ffvk::VkSemaphore];
+ let values = [value];
+ let info = pf_ffvk::VkSemaphoreWaitInfo {
+ sType: pf_ffvk::VkStructureType_VK_STRUCTURE_TYPE_SEMAPHORE_WAIT_INFO,
+ pNext: std::ptr::null(),
+ flags: 0,
+ semaphoreCount: 1,
+ pSemaphores: sems.as_ptr(),
+ pValues: values.as_ptr(),
+ };
+ // SAFETY: resolved from this device at init; handles outlive the decoder.
+ let r = unsafe {
+ self.wait_semaphores.expect("checked at init")(self.vk_device, &info, timeout_ns)
+ };
+ r == 0 // VK_SUCCESS (VK_TIMEOUT = 2)
+ }
+
/// Lift the decoded `AVVkFrame` into a [`VkVideoFrame`]: clone the AVFrame (the
/// guard — keeps the image + frames context alive through present) and ship the
/// POINTERS; the presenter reads the live sync state under the frames-context lock
@@ -1024,12 +1086,18 @@ impl VulkanDecoder {
ffi::av_frame_free(&mut clone);
bail!("multi-image Vulkan frames unsupported (disjoint pool)");
}
+ // Safe without the frames lock: the handle is creation-constant and
+ // sem_value was last written by the decode submission on THIS thread.
+ let timeline_sem = (*vkf).sem[0] as u64;
+ let decode_done_value = (*vkf).sem_value[0];
Ok(VkVideoFrame {
vkframe: vkf as usize,
frames_ctx: fc as usize,
lock_frame,
unlock_frame,
vk_format,
+ timeline_sem,
+ decode_done_value,
width: (*self.frame).width as u32,
height: (*self.frame).height as u32,
color: ColorDesc::from_raw(self.frame),
diff --git a/crates/pf-ffvk/build.rs b/crates/pf-ffvk/build.rs
index f5212fce..b14e9580 100644
--- a/crates/pf-ffvk/build.rs
+++ b/crates/pf-ffvk/build.rs
@@ -57,6 +57,10 @@ fn main() {
.allowlist_type("VkPhysicalDeviceVulkan13Features")
// AVVulkanFramesContext.img_flags values (plane views need MUTABLE_FORMAT).
.allowlist_type("VkImageCreateFlagBits")
+ // Timeline-semaphore wait — the pump measures true GPU decode completion.
+ .allowlist_type("VkSemaphoreWaitInfo")
+ .allowlist_type("PFN_vkWaitSemaphores")
+ .allowlist_type("PFN_vkGetDeviceProcAddr")
// …plus nothing else of FFmpeg: the core types these structs reference only
// ever appear behind pointers here, so keep them opaque instead of duplicating
// ffmpeg-sys-next's definitions (callers cast pointers between the crates).
diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs
index e5db14a1..89986349 100644
--- a/crates/pf-presenter/src/run.rs
+++ b/crates/pf-presenter/src/run.rs
@@ -366,8 +366,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result