feat(client): say which GPU can do Vulkan Video, and why not when it can't

Field report from an Intel Arc + NVIDIA laptop: pinning the Vulkan rung on the Arc
iGPU silently produced D3D11VA, and there was no way to tell whether the build had
tried at all. That ambiguity was ours, in three places.

The "unavailable" log printed three of the FIVE conjuncts that gate Vulkan Video.
A device with 1.3, the features and a decode queue family — but no codec extension
— logged dev_is_13=true features_ok=true decode_family=true next to the word
"unavailable" and named nothing actionable. It now prints all five, plus which
base extensions are missing, which codec extensions are present, the decode
family's own advertised codec operations, and the device name and vendor. It also
no longer says "VAAPI/software" on Windows, where the rung below is D3D11VA.

The native-vulkan PIN refusal logged `video_decode` alone. On a device that
decodes something but not THIS codec, that reads as a contradiction: refused, yet
video_decode=true. It now carries the caps mask and the codec bit that was wanted,
so "your GPU can't" is distinguishable from "we asked for the wrong thing" — only
the second is our bug.

And `--probe-decode` is new: per-adapter Vulkan Video capability with no session,
no surface and no logical device. For each GPU it answers usable yes/no, the
driver's own decode ops, the extensions, and — when the answer is no — which
conjunct failed, in words. Separate from --list-adapters, which the desktop shells
parse line-by-line for their GPU picker and which therefore keeps printing bare
names.

The listing is ordered like pick_device (discrete first) and marks entry 0 as the
default presenter, because that ordering is very likely the reporter's actual
answer: pick_device ranks DISCRETE_GPU above INTEGRATED_GPU, Vulkan Video decodes
on the PRESENTER's device by design (that is what makes it zero-copy), and
PUNKTFUNK_DECODER does not move the presenter. So on a hybrid laptop, pinning the
decoder while the dGPU presents probes the wrong GPU entirely —
PUNKTFUNK_VK_DEVICE=<index> is the knob that moves it, and the index printed is
that value.

To keep the probe honest, VIDEO_BASE and VIDEO_CODECS moved to module scope and
the five-way AND became video_decode_gate(), called by both the probe and device
creation. A probe holding its own copy of the rule is one that eventually reports
a capability the session then refuses — which reads to everyone as a decoder bug
rather than a probe bug.

Gates: fmt clean; clippy -D warnings over punktfunk-client-session and
pf-presenter. The Linux container was unavailable (the host's disk filled and took
the docker daemon with it), so this ran on the macOS host target only — the
container leg is owed, and CI covers it on the PR.
This commit is contained in:
2026-08-07 12:35:55 +02:00
parent bbbcf321e5
commit 6ff62b087c
4 changed files with 333 additions and 16 deletions
+74
View File
@@ -537,6 +537,80 @@ mod session_main {
};
}
// `--probe-decode`: per-adapter Vulkan Video decode capability, then exit. Human
// output on purpose — this is a triage tool, not a picker source, which is also
// why it is a separate flag: `--list-adapters` is parsed line-by-line by the
// desktop shells' GPU picker and must keep printing bare names.
if arg_flag("--probe-decode") {
return match pf_presenter::vk::probe_decode() {
Ok(adapters) => {
if adapters.is_empty() {
println!("no Vulkan physical devices");
}
for (i, a) in adapters.iter().enumerate() {
// The index IS the PUNKTFUNK_VK_DEVICE value, and entry 0 is what
// a default run presents on — the decoder shares that device, so
// on a hybrid box this line is usually the answer.
let kind = if a.discrete { "discrete" } else { "integrated" };
println!(
"[{i}] {} ({kind}){}",
a.name,
if i == 0 { " <- default presenter" } else { "" }
);
println!(
" vulkan video decode: {}",
if a.usable { "YES" } else { "no" }
);
let codecs: Vec<&str> = [(0x1u32, "H.264"), (0x2, "H.265"), (0x4, "AV1")]
.iter()
.filter(|(bit, _)| a.codec_ops & bit != 0)
.map(|(_, n)| *n)
.collect();
println!(
" driver decode ops: {}",
if codecs.is_empty() {
format!("none (0x{:X})", a.codec_ops)
} else {
format!("{} (0x{:X})", codecs.join(", "), a.codec_ops)
}
);
if !a.usable {
// Say which conjunct failed. "no" with no reason is the thing
// this whole flag exists to stop.
let mut why: Vec<String> = Vec::new();
if !a.api_1_3 {
why.push("device is not Vulkan 1.3".into());
}
if !a.features_ok {
why.push(
"missing samplerYcbcrConversion / timelineSemaphore / \
synchronization2"
.into(),
);
}
if a.decode_family.is_none() {
why.push("no queue family advertises VIDEO_DECODE".into());
}
if !a.base_missing.is_empty() {
why.push(format!("missing {}", a.base_missing.join(", ")));
}
if a.codec_exts.is_empty() {
why.push("no VK_KHR_video_decode_{h264,h265,av1} extension".into());
}
println!(" why not: {}", why.join("; "));
} else {
println!(" extensions: {}", a.codec_exts.join(", "));
}
}
0
}
Err(e) => {
eprintln!("probe-decode: {e:#}");
EXIT_PRESENTER_FAILED
}
};
}
// `--list-audio`: the PipeWire endpoints the settings pickers offer, as
// `sink|source<TAB>node.name<TAB>description` lines — a debug window into the
// same enumeration the GTK shell probes.
+11
View File
@@ -1766,9 +1766,20 @@ impl Decoder {
"native Vulkan decode init failed — demoting to the standard ladder"),
}
} else {
// The gate is an AND of three, so name all three. `video_decode=true`
// beside "refused" is otherwise unreadable: it says the device decodes
// SOMETHING while refusing THIS codec, and the bit that would explain it
// — the decode family's advertised operations — went unprinted. That is
// the difference between "your GPU can't do this" and "we asked for the
// wrong thing", and only the second is our bug.
tracing::warn!(
codec = codec_name,
video_decode = vk.is_some_and(|v| v.video_decode),
decode_video_caps =
format_args!("0x{:X}", vk.map_or(0, |v| v.decode_video_caps)),
codec_op_needed =
format_args!("0x{:X}", native_codec(wire).map_or(0, |(_, op)| op)),
device = vk.map_or("", |v| v.device_name.as_str()),
"PUNKTFUNK_DECODER=native-vulkan refused (needs an H.264, HEVC or AV1 \
session and a presenter device whose decode family advertises that \
codec) — standard ladder"
+1 -1
View File
@@ -41,7 +41,7 @@ mod reconfig;
mod resources;
mod setup;
pub use setup::{list_adapters, PresentPref};
pub use setup::{list_adapters, probe_decode, AdapterDecode, PresentPref};
/// One presenter iteration's video input.
pub enum FrameInput<'a> {
+247 -15
View File
@@ -13,6 +13,68 @@ use ash::vk;
use ash::vk::Handle as _;
use std::ffi::{c_char, CString};
/// The two extensions Vulkan Video decode cannot work without, whatever the codec.
///
/// Module scope, not function scope, because [`probe_decode`] answers "can this GPU do
/// Vulkan Video decode?" and MUST answer it with the same list the device creation path
/// gates on. A probe that keeps its own copy is a probe that eventually lies — and a
/// lying capability report is worse than none, because it sends a field reporter looking
/// in the wrong half of the stack.
pub(crate) const VIDEO_BASE: [&std::ffi::CStr; 2] = [
ash::khr::video_queue::NAME,
ash::khr::video_decode_queue::NAME,
];
/// The per-codec decode extensions, same sharing rule as [`VIDEO_BASE`]. AV1's name is
/// spelled out because ash 0.38's headers (1.3.281) predate its promotion.
pub(crate) const VIDEO_CODECS: [&std::ffi::CStr; 3] = [
ash::khr::video_decode_h264::NAME,
ash::khr::video_decode_h265::NAME,
c"VK_KHR_video_decode_av1",
];
/// The Vulkan Video decode gate: all five must hold. One function so the device creation
/// path and [`probe_decode`] cannot drift into disagreeing about what "supported" means —
/// the failure that produces is a probe reporting a capability the session then refuses,
/// which reads to everyone as a bug in the decoder rather than in the probe.
pub(crate) fn video_decode_gate(
api_1_3: bool,
features_ok: bool,
has_decode_family: bool,
base_exts_present: bool,
any_codec_ext: bool,
) -> bool {
api_1_3 && features_ok && has_decode_family && base_exts_present && any_codec_ext
}
/// What one physical device can do for Vulkan Video decode, without creating a logical
/// device or a surface — the answer `punktfunk-session --probe-decode` prints.
///
/// Exists because "I pinned the Vulkan rung and got D3D11VA instead" was, until this,
/// only answerable by starting a session and reading a log. Every field question about
/// hardware decode starts here: WHICH adapter, and does it advertise the codec.
#[derive(Debug, Clone)]
pub struct AdapterDecode {
/// Marketing name — also the `PUNKTFUNK_VK_ADAPTER` match key.
pub name: String,
/// Discrete GPUs sort first, exactly as `pick_device` ranks them, so index 0 here is
/// the device a default run will pick.
pub discrete: bool,
pub api_1_3: bool,
pub features_ok: bool,
/// The queue family index that advertises `VIDEO_DECODE_KHR`, if any.
pub decode_family: Option<u32>,
/// Raw `VkVideoCodecOperationFlagsKHR` from that family — what the DRIVER says it can
/// decode, independent of which extensions are exposed.
pub codec_ops: u32,
/// Required base extensions this device does NOT expose.
pub base_missing: Vec<String>,
/// Per-codec decode extensions it does.
pub codec_exts: Vec<String>,
/// [`video_decode_gate`] over the fields above.
pub usable: bool,
}
/// `VK_EXT_present_mode_fifo_latest_ready`, hand-declared: it postdates the Vulkan headers
/// ash 0.38 is generated from (1.3.281), so there is no binding for it — which is also why
/// an unenabled driver reports the mode back as the bare number `1000361000`.
@@ -283,22 +345,15 @@ impl Presenter {
.map(|(i, (_, v))| (i as u32, v.video_codec_operations))
};
const VIDEO_BASE: [&std::ffi::CStr; 2] = [
ash::khr::video_queue::NAME,
ash::khr::video_decode_queue::NAME,
];
const VIDEO_CODECS: [&std::ffi::CStr; 3] = [
ash::khr::video_decode_h264::NAME,
ash::khr::video_decode_h265::NAME,
c"VK_KHR_video_decode_av1",
];
let codec_exts: Vec<&std::ffi::CStr> =
VIDEO_CODECS.into_iter().filter(|n| has(n)).collect();
let video_ok = dev_is_13
&& features_ok
&& decode_family.is_some()
&& VIDEO_BASE.iter().all(|n| has(n))
&& !codec_exts.is_empty();
let video_ok = video_decode_gate(
dev_is_13,
features_ok,
decode_family.is_some(),
VIDEO_BASE.iter().all(|n| has(n)),
!codec_exts.is_empty(),
);
let (decode_qf, decode_caps) = decode_family.unwrap_or((qfi, Default::default()));
let mut video_ext_names: Vec<&std::ffi::CStr> = Vec::new();
@@ -320,11 +375,42 @@ impl Presenter {
"Vulkan Video decode available on this device"
);
} else {
// ALL FIVE conjuncts, and the evidence behind each. The three this used to
// print could every one be true while the answer was still no — a device with
// Vulkan 1.3, the features and a decode queue family, but missing a codec
// extension, logged `dev_is_13=true features_ok=true decode_family=true` next
// to the word "unavailable" and named nothing that could be acted on. A field
// reporter cannot then tell "this build never tried" from "the driver said
// no", which is the single most expensive ambiguity a fallback can have: it
// makes a missing capability and a bug in our gate look identical.
//
// `queue_codec_ops` matters most on a device that HAS a decode queue: the
// driver names the codecs it can decode there, so an empty `codec_exts`
// beside a non-empty ops mask means the extensions are what is missing, not
// the hardware.
let base_missing: Vec<&str> = VIDEO_BASE
.iter()
.filter(|n| !has(n))
.map(|n| n.to_str().unwrap_or("?"))
.collect();
let codec_ext_names: Vec<&str> = codec_exts
.iter()
.map(|n| n.to_str().unwrap_or("?"))
.collect();
tracing::info!(
dev_is_13,
features_ok,
decode_family = decode_family.is_some(),
"Vulkan Video decode unavailable — decoder falls back (VAAPI/software)"
video_base_missing = ?base_missing,
codec_exts_present = ?codec_ext_names,
queue_codec_ops = ?decode_family.map(|(_, ops)| ops),
device = %dev_props
.device_name_as_c_str()
.map(|c| c.to_string_lossy().into_owned())
.unwrap_or_default(),
vendor_id = format_args!("0x{:04X}", dev_props.vendor_id),
"Vulkan Video decode unavailable on this device — the decoder falls back \
one rung (D3D11VA on Windows, VAAPI on Linux, then software)"
);
}
@@ -621,6 +707,152 @@ impl Presenter {
}
}
/// Every physical device's Vulkan Video decode capability
/// (`punktfunk-session --probe-decode`). No surface, no logical device, no session.
///
/// The question this answers is the one every hardware-decode field report starts with:
/// the rung was pinned and something else ran — did this build never try, or did the
/// driver refuse? Ordered like [`list_adapters`] (discrete first, `pick_device`'s
/// tie-break), so the FIRST entry is the device a default run presents on — which is also
/// the device Vulkan Video decodes on, because the decoder shares the presenter's device
/// by design. On a hybrid laptop those two facts together are usually the whole answer:
/// pinning the decoder does not move the presenter, so probing the iGPU's capability
/// while the dGPU is presenting reports on the wrong GPU. `PUNKTFUNK_VK_DEVICE=<index>`
/// is the knob that moves it.
pub fn probe_decode() -> Result<Vec<AdapterDecode>> {
// SAFETY: per the Vulkan contract above - a create/allocate call on the live device, over
// builder structs that are locals outliving the call; the handle it returns is owned by the
// value being built here.
let entry = unsafe { ash::Entry::load() }.context("libvulkan not loadable")?;
let app_name = CString::new("punktfunk-session").unwrap();
let app_info = vk::ApplicationInfo::default()
.application_name(&app_name)
.api_version(vk::API_VERSION_1_3);
// SAFETY: per the Vulkan contract above - a create/allocate call on the live device, over
// builder structs that are locals outliving the call; the handle it returns is owned by the
// value being built here.
let instance = unsafe {
entry.create_instance(
&vk::InstanceCreateInfo::default().application_info(&app_info),
None,
)
}
.context("vkCreateInstance")?;
// SAFETY: per the Vulkan contract above - a read-only query on the live instance/device,
// filling locals returned by value.
let devices = unsafe { instance.enumerate_physical_devices() }?;
let mut out: Vec<(u8, AdapterDecode)> = Vec::with_capacity(devices.len());
for pdev in devices {
// SAFETY: per the Vulkan contract above - a read-only query on the live
// instance/device, filling locals returned by value.
let props = unsafe { instance.get_physical_device_properties(pdev) };
let name = props
.device_name_as_c_str()
.map(|c| c.to_string_lossy().into_owned())
.unwrap_or_default();
if name.is_empty() {
continue;
}
let rank = match props.device_type {
vk::PhysicalDeviceType::DISCRETE_GPU => 0u8,
vk::PhysicalDeviceType::INTEGRATED_GPU => 1,
_ => 2,
};
let api_1_3 = vk::api_version_major(props.api_version) > 1
|| vk::api_version_minor(props.api_version) >= 3;
// The same three features the creation path demands (`features_ok` there).
let mut f11 = vk::PhysicalDeviceVulkan11Features::default();
let mut f12 = vk::PhysicalDeviceVulkan12Features::default();
let mut f13 = vk::PhysicalDeviceVulkan13Features::default();
let mut feats = vk::PhysicalDeviceFeatures2::default()
.push_next(&mut f11)
.push_next(&mut f12)
.push_next(&mut f13);
// SAFETY: per the Vulkan contract above - a read-only query on the live
// instance/device, filling locals returned by value.
unsafe { instance.get_physical_device_features2(pdev, &mut feats) };
let features_ok = f11.sampler_ycbcr_conversion == vk::TRUE
&& f12.timeline_semaphore == vk::TRUE
&& f13.synchronization2 == vk::TRUE;
// SAFETY: per the Vulkan contract above - a read-only query on the live
// instance/device, filling locals returned by value.
let ext_props =
unsafe { instance.enumerate_device_extension_properties(pdev) }.unwrap_or_default();
let has = |n: &std::ffi::CStr| {
ext_props
.iter()
.any(|e| e.extension_name_as_c_str() == Ok(n))
};
let base_missing: Vec<String> = VIDEO_BASE
.iter()
.filter(|n| !has(n))
.map(|n| n.to_string_lossy().into_owned())
.collect();
let codec_exts: Vec<String> = VIDEO_CODECS
.iter()
.filter(|n| has(n))
.map(|n| n.to_string_lossy().into_owned())
.collect();
// The decode queue family and the codec operations the DRIVER claims for it —
// reported even when the extensions are absent, because "the hardware can, the
// driver does not expose it" is a different conversation from "this GPU cannot".
// SAFETY: per the Vulkan contract above - a read-only query on the live
// instance/device, filling locals returned by value.
let n = unsafe { instance.get_physical_device_queue_family_properties2_len(pdev) };
let mut video: Vec<vk::QueueFamilyVideoPropertiesKHR> =
vec![vk::QueueFamilyVideoPropertiesKHR::default(); n];
let mut qprops: Vec<vk::QueueFamilyProperties2> = video
.iter_mut()
.map(|v| vk::QueueFamilyProperties2::default().push_next(v))
.collect();
// SAFETY: per the Vulkan contract above - a read-only query on the live
// instance/device, filling locals returned by value.
unsafe { instance.get_physical_device_queue_family_properties2(pdev, &mut qprops) };
let flags: Vec<vk::QueueFlags> = qprops
.iter()
.map(|p| p.queue_family_properties.queue_flags)
.collect();
drop(qprops);
let found = flags
.iter()
.zip(&video)
.enumerate()
.find(|(_, (f, _))| f.contains(vk::QueueFlags::VIDEO_DECODE_KHR))
.map(|(i, (_, v))| (i as u32, v.video_codec_operations));
let usable = video_decode_gate(
api_1_3,
features_ok,
found.is_some(),
base_missing.is_empty(),
!codec_exts.is_empty(),
);
out.push((
rank,
AdapterDecode {
name,
discrete: rank == 0,
api_1_3,
features_ok,
decode_family: found.map(|(i, _)| i),
codec_ops: found.map_or(0, |(_, ops)| ops.as_raw()),
base_missing,
codec_exts,
usable,
},
));
}
out.sort_by_key(|(rank, _)| *rank);
// SAFETY: per the Vulkan contract above - this destroys objects this type owns, and no
// logical device was created against this instance, so nothing is in flight on it.
unsafe { instance.destroy_instance(None) };
Ok(out.into_iter().map(|(_, a)| a).collect())
}
/// The physical devices' marketing names — the shells' GPU-picker source
/// (`punktfunk-session --list-adapters`). No surface and no logical device; discrete
/// GPUs first (mirroring `pick_device`'s tie-break), duplicates collapsed (the name is