Merge pull request 'Say which GPU can do Vulkan Video, and why not when it can't' (#89) from worktree-vulkan-decode-probe into main
ci / rust-arm64 (push) Successful in 3m35s
ci / bun-nix (push) Successful in 29s
ci / web (push) Successful in 1m6s
ci / docs-site (push) Successful in 1m18s
apple / swift (push) Successful in 1m29s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m20s
deb / build-publish-host (push) Failing after 2m7s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 34s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 32s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 26s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
deb / build-publish-client-arm64 (push) Successful in 3m7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 14s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 14s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 24s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m33s
android / android (push) Successful in 6m25s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m2s
docker / builders-arm64cross (push) Successful in 10s
docker / deploy-docs (push) Successful in 31s
apple / screenshots (push) Successful in 6m4s
arch / build-publish (push) Successful in 7m45s
ci / rust (push) Successful in 8m13s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m7s
deb / build-publish (push) Successful in 8m36s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m30s
flatpak / build-publish (push) Successful in 17m4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m4s

Reviewed-on: #89
This commit was merged in pull request #89.
This commit is contained in:
2026-08-07 10:50:26 +00:00
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