Linux: draw on the GPU the Wayland compositor draws with (#1088)

On a hybrid laptop whose Wayland compositor runs on the discrete GPU
(Hyprland with AQ_DRM_DEVICES naming the NVIDIA card first), the built-in
panel still hangs off the integrated GPU, so the panel rule (#8) picked it,
and the window stayed empty: the compositor took the frames and showed
nothing, with no error anywhere.

Which GPU drives a display stands in for which GPU the compositor draws
with. Under Wayland the compositor says so itself: the main_device of its
linux-dmabuf feedback (version 4), the device it imports client buffers on,
which Mesa's Vulkan device-select layer reads for the same purpose.
compositor_gpu.rs asks for it on a short-lived connection, maps the dev_t
to PCI ids through /sys/dev/char, and pick_adapter ranks that adapter
first. No answer (X11, an older compositor, a GPU no adapter matches)
leaves the existing rules in charge, and WGPU_POWER_PREF still overrides.

wayland-client and wayland-protocols are already in the tree through winit;
they become direct Linux-only dependencies.
This commit is contained in:
Atos Lins authored and GitHub committed 2026-10-11 11:59:29 -04:00
1 parent a7e24b9512
commit 86e74ec121
5 files changed
+230 -24

No files matched your search

Generated
+2
View File
@@ -3564,6 +3564,8 @@ dependencies = [
"serde_json",
"uds_windows",
"ureq",
"wayland-client",
"wayland-protocols",
"winresource",
"winsafe",
]
+6
View File
@@ -28,6 +28,12 @@ log = { workspace = true }
# behind a safe API. Same version as PhotoCraft. Pinned exactly: review the diff before bumping.
fmv-macos-events = "=0.1.0"
[target.'cfg(target_os = "linux")'.dependencies]
# Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback), so the window is
# drawn on that one (src/compositor_gpu.rs). Both are already in the tree through winit.
wayland-client = "0.31"
wayland-protocols = { version = "0.32", features = ["client"] }
[dev-dependencies]
# Waits for wgpu's adapter, device and error-scope futures in tests (already in the tree through wgpu).
pollster = "1.0"
+177
View File
@@ -0,0 +1,177 @@
//! The GPU the Wayland compositor draws with.
//!
//! A window drawn on another GPU reaches the compositor as that GPU's buffers, and the compositor
//! may not show them: it can refuse them and end the window's connection (#96), or take them and
//! show nothing, with no error on either side. The second happened on a hybrid laptop whose
//! Hyprland runs on the NVIDIA GPU (`AQ_DRM_DEVICES`) while the built-in panel hangs off the Intel
//! one: the panel rule picked Intel and the window stayed empty. Which GPU drives a display is a
//! guess at the compositor's GPU; the compositor states it, as the `main_device` of its
//! linux-dmabuf feedback (protocol version 4): the device it imports client buffers on.
#[cfg(target_os = "linux")]
use wayland_client::globals::{GlobalListContents, registry_queue_init};
#[cfg(target_os = "linux")]
use wayland_client::protocol::wl_registry::{self, WlRegistry};
#[cfg(target_os = "linux")]
use wayland_client::{Connection, Dispatch, QueueHandle};
#[cfg(target_os = "linux")]
use wayland_protocols::wp::linux_dmabuf::zv1::client::zwp_linux_dmabuf_feedback_v1::{self, ZwpLinuxDmabufFeedbackV1};
#[cfg(target_os = "linux")]
use wayland_protocols::wp::linux_dmabuf::zv1::client::zwp_linux_dmabuf_v1::{self, ZwpLinuxDmabufV1};
/// PCI `(vendor, device)` ids of the GPU the Wayland compositor draws with. `None` outside a
/// Wayland session, when the compositor doesn't say (linux-dmabuf older than version 4), when its
/// device isn't a PCI GPU, and on other systems.
#[cfg(target_os = "linux")]
pub(crate) fn compositor_gpu() -> Option<(u32, u32)> {
let device = match main_device() {
Ok(device) => device,
Err(why) => {
log::debug!("the compositor's GPU is unknown: {why}");
return None;
}
};
let pci = pci_ids(std::path::Path::new("/sys/dev/char"), device);
match pci {
Some((vendor, id)) => log::info!("the compositor draws with {vendor:04x}:{id:04x}"),
None => log::debug!("the compositor's device {:?} has no PCI ids", major_minor(device)),
}
pci
}
#[cfg(not(target_os = "linux"))]
pub(crate) fn compositor_gpu() -> Option<(u32, u32)> {
None
}
/// The `main_device` of the compositor's default linux-dmabuf feedback, asked on a connection of
/// its own that is closed again before the window opens.
#[cfg(target_os = "linux")]
fn main_device() -> Result<u64, String> {
let conn = Connection::connect_to_env().map_err(|e| format!("no Wayland compositor: {e}"))?;
let (globals, mut queue) = registry_queue_init::<Feedback>(&conn).map_err(|e| e.to_string())?;
let qh = queue.handle();
// Version 4 brought the feedback; older compositors only list formats.
let dmabuf: ZwpLinuxDmabufV1 = globals.bind(&qh, 4..=4, ()).map_err(|e| format!("linux-dmabuf version 4: {e}"))?;
let _feedback = dmabuf.get_default_feedback(&qh, ());
let mut state = Feedback::default();
// The compositor sends the whole feedback, ending with `done`, in reply; the bound only keeps
// a compositor that never ends it from holding up the start.
for _ in 0..4 {
if state.done {
break;
}
queue.roundtrip(&mut state).map_err(|e| e.to_string())?;
}
// Dropping the connection releases the objects.
let device = state.main_device.ok_or("the compositor named no main device")?;
// A `dev_t`, in this machine's byte order.
let device: [u8; 8] = device.try_into().map_err(|d: Vec<u8>| format!("a {}-byte device number", d.len()))?;
Ok(u64::from_ne_bytes(device))
}
/// What [`main_device`] collects from the feedback events.
#[cfg(target_os = "linux")]
#[derive(Default)]
struct Feedback {
main_device: Option<Vec<u8>>,
done: bool,
}
#[cfg(target_os = "linux")]
impl Dispatch<WlRegistry, GlobalListContents> for Feedback {
fn event(_: &mut Self, _: &WlRegistry, _: wl_registry::Event, _: &GlobalListContents, _: &Connection, _: &QueueHandle<Self>) {}
}
#[cfg(target_os = "linux")]
impl Dispatch<ZwpLinuxDmabufV1, ()> for Feedback {
// Version 4 sends no format or modifier events: the feedback replaces them.
fn event(_: &mut Self, _: &ZwpLinuxDmabufV1, _: zwp_linux_dmabuf_v1::Event, _: &(), _: &Connection, _: &QueueHandle<Self>) {}
}
#[cfg(target_os = "linux")]
impl Dispatch<ZwpLinuxDmabufFeedbackV1, ()> for Feedback {
fn event(
state: &mut Self,
_: &ZwpLinuxDmabufFeedbackV1,
event: zwp_linux_dmabuf_feedback_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
match event {
zwp_linux_dmabuf_feedback_v1::Event::MainDevice { device } => state.main_device = Some(device),
zwp_linux_dmabuf_feedback_v1::Event::Done => state.done = true,
_ => {}
}
}
}
/// The major and minor numbers of a Linux `dev_t`, as glibc's and musl's `major()` and `minor()`
/// split it.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn major_minor(device: u64) -> (u64, u64) {
let major = ((device & 0x0000_0000_000f_ff00) >> 8) | ((device & 0xffff_f000_0000_0000) >> 32);
let minor = (device & 0x0000_0000_0000_00ff) | ((device & 0x0000_0fff_fff0_0000) >> 12);
(major, minor)
}
/// The PCI ids of the GPU behind DRM device `device` (its `cardN` or `renderDN` node), from
/// `<sys_dev_char>/MAJOR:MINOR/device/{vendor,device}`.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn pci_ids(sys_dev_char: &std::path::Path, device: u64) -> Option<(u32, u32)> {
let read_hex = |p: std::path::PathBuf| -> Option<u32> {
let s = std::fs::read_to_string(p).ok()?;
u32::from_str_radix(s.trim().trim_start_matches("0x"), 16).ok()
};
let (major, minor) = major_minor(device);
let gpu = sys_dev_char.join(format!("{major}:{minor}")).join("device");
Some((read_hex(gpu.join("vendor"))?, read_hex(gpu.join("device"))?))
}
#[cfg(test)]
mod tests {
/// glibc's `makedev()`.
fn makedev(major: u64, minor: u64) -> u64 {
((major & 0x0000_0fff) << 8) | ((major & 0xffff_f000) << 32) | (minor & 0x0000_00ff) | ((minor & 0xffff_ff00) << 12)
}
#[test]
fn device_numbers_split_into_major_and_minor() {
// DRM nodes: card1 is 226:1, renderD128 is 226:128.
assert_eq!(super::major_minor(0xe201), (226, 1));
assert_eq!(super::major_minor(0xe280), (226, 128));
for (major, minor) in [(0, 0), (226, 129), (511, 1_048_575), (0xfff, 0xff), (0x1000, 0x100), (0xffff_ffff, 0xffff_ffff)] {
assert_eq!(super::major_minor(makedev(major, minor)), (major, minor));
}
}
#[cfg(target_os = "linux")]
#[test]
fn the_gpu_behind_a_device_number_comes_from_sysfs() -> std::io::Result<()> {
let dir = std::env::temp_dir().join(format!("pdfcraft-dev-char-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
// /sys/dev/char/226:128 links to the render node, whose `device` is the PCI GPU.
std::fs::create_dir_all(dir.join("226:128/device"))?;
std::fs::write(dir.join("226:128/device/vendor"), "0x10de\n")?;
std::fs::write(dir.join("226:128/device/device"), "0x25ac\n")?;
// A device that isn't a PCI GPU (a virtual one) has no ids.
std::fs::create_dir_all(dir.join("226:0/device"))?;
let render = super::pci_ids(&dir, makedev(226, 128));
let virtual_gpu = super::pci_ids(&dir, makedev(226, 0));
let missing = super::pci_ids(&dir, makedev(226, 1));
std::fs::remove_dir_all(&dir)?;
assert_eq!(render, Some((0x10de, 0x25ac)));
assert_eq!(virtual_gpu, None);
assert_eq!(missing, None);
Ok(())
}
#[test]
fn the_compositor_names_a_pci_gpu_or_nothing() {
// Whatever this session has: no compositor (CI), one that doesn't say, or a PCI GPU.
if let Some((vendor, _)) = super::compositor_gpu() {
assert_ne!(vendor, 0);
}
}
}
+44 -23
View File
@@ -31,6 +31,7 @@ use pdfcraft_ui_egui::PdfCraftApp;
#[cfg(target_os = "macos")]
mod apple_events;
mod compositor_gpu;
mod logging;
mod single_instance;
mod text_scale;
@@ -458,6 +459,10 @@ fn write_control_file(path: &str, port: u16, token: &str) -> std::io::Result<()>
/// desktop with a monitor on each GPU, issue #445) the discrete one wins, as the integrated one
/// may fail to present there. Where the system doesn't say which GPU drives a display (macOS,
/// containers, remote sessions) the power preference alone decides.
/// - Under Wayland, the GPU the compositor draws with comes before all of that: the compositor
/// says which one it is ([`compositor_gpu::compositor_gpu`]), and frames from another GPU may
/// never reach the screen, even from the GPU behind the built-in panel when the compositor runs
/// on the discrete one.
/// - On Windows, use Direct3D 12, falling back to OpenGL, and never load Vulkan drivers unless
/// `WGPU_BACKEND` asks for them. Creating a Vulkan instance loads every installed Vulkan driver
/// into the process, and a faulty one (an Intel driver in issue #37) crashed PdfCraft before
@@ -477,7 +482,8 @@ fn configure_gpu(native: &mut eframe::NativeOptions) {
if std::env::var_os("WGPU_POWER_PREF").is_none() {
setup.power_preference = eframe::wgpu::PowerPreference::LowPower;
let displays = display_gpus();
if !displays.is_empty() {
let compositor = compositor_gpu::compositor_gpu();
if !displays.is_empty() || compositor.is_some() {
setup.native_adapter_selector = Some(std::sync::Arc::new(move |adapters, surface| {
let usable: Vec<&eframe::wgpu::Adapter> = adapters.iter().filter(|a| surface.is_none_or(|s| a.is_surface_supported(s))).collect();
let infos: Vec<(u32, u32, eframe::wgpu::DeviceType)> = usable
@@ -487,7 +493,7 @@ fn configure_gpu(native: &mut eframe::NativeOptions) {
(info.vendor, info.device, info.device_type)
})
.collect();
let picked = pick_adapter(&infos, &displays).and_then(|i| usable.get(i)).map(|a| (*a).clone());
let picked = pick_adapter(&infos, &displays, compositor).and_then(|i| usable.get(i)).map(|a| (*a).clone());
match &picked {
// Which GPU draws is the first question when a window stays black (#8, #378, #445).
Some(a) => {
@@ -620,12 +626,12 @@ fn linux_display_gpus(drm: &std::path::Path) -> Vec<DisplayGpu> {
gpus
}
/// Index of the adapter to draw with: one that drives a display (by PCI ids) first. Among several
/// of those, the one driving the primary display (a hybrid laptop's panel, #8; the primary display
/// on Windows, #378), or else, with no primary display known (a Linux desktop with a monitor on
/// each GPU, #445), the discrete one. Otherwise the most frugal kind: integrated, discrete, other,
/// virtual, software.
fn pick_adapter(adapters: &[(u32, u32, eframe::wgpu::DeviceType)], displays: &[DisplayGpu]) -> Option<usize> {
/// Index of the adapter to draw with: the GPU the Wayland compositor draws with (`compositor`, by
/// PCI ids) first, then one that drives a display. Among several of those, the one driving the
/// primary display (a hybrid laptop's panel, #8; the primary display on Windows, #378), or else,
/// with no primary display known (a Linux desktop with a monitor on each GPU, #445), the discrete
/// one. Otherwise the most frugal kind: integrated, discrete, other, virtual, software.
fn pick_adapter(adapters: &[(u32, u32, eframe::wgpu::DeviceType)], displays: &[DisplayGpu], compositor: Option<(u32, u32)>) -> Option<usize> {
use eframe::wgpu::DeviceType;
let display = |pci: (u32, u32)| displays.iter().find(|g| g.pci == pci);
let multi_gpu_desktop = displays.len() > 1 && !displays.iter().any(|g| g.primary);
@@ -646,7 +652,7 @@ fn pick_adapter(adapters: &[(u32, u32, eframe::wgpu::DeviceType)], displays: &[D
DeviceType::VirtualGpu => 4,
DeviceType::Cpu => 5,
};
(!drives_display, !drives_primary, rank)
(compositor != Some((*vendor, *device)), !drives_display, !drives_primary, rank)
})
.map(|(i, _)| i)
}
@@ -967,17 +973,17 @@ mod tests {
#[test]
fn pick_adapter_prefers_the_gpu_driving_the_monitors() {
// A desktop whose monitors are all on the discrete GPU: the integrated one shows black.
assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA)]), Some(0));
assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA)], None), Some(0));
// Issue #378: the same on Windows, where the Ryzen integrated GPU without a monitor crashed
// the display driver. Windows also says which display is the primary one.
assert_eq!(super::pick_adapter(&adapters(), &[panel(NVIDIA)]), Some(0));
assert_eq!(super::pick_adapter(&adapters(), &[panel(NVIDIA)], None), Some(0));
}
#[test]
fn pick_adapter_prefers_the_gpu_driving_the_primary_display() {
// A Windows desktop with a monitor on each GPU: the window opens on the primary display.
assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), panel(NVIDIA)]), Some(0));
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)]), Some(1));
assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), panel(NVIDIA)], None), Some(0));
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1));
}
#[test]
@@ -1009,10 +1015,10 @@ mod tests {
#[test]
fn pick_adapter_keeps_the_integrated_gpu_on_hybrid_laptops() {
// Issue #8: the panel is on the integrated GPU, an external monitor on the discrete one.
assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA), panel(AMD_IGPU)]), Some(1));
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)]), Some(1));
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)]), Some(1));
assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU)]), Some(1));
assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA), panel(AMD_IGPU)], None), Some(1));
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1));
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], None), Some(1));
assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU)], None), Some(1));
}
#[test]
@@ -1021,17 +1027,32 @@ mod tests {
use eframe::wgpu::DeviceType;
let desktop =
[(INTEL_IGPU.0, INTEL_IGPU.1, DeviceType::IntegratedGpu), (NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (0, 0, DeviceType::Cpu)];
assert_eq!(super::pick_adapter(&desktop, &[monitor(INTEL_IGPU), monitor(NVIDIA)]), Some(1));
assert_eq!(super::pick_adapter(&desktop, &[monitor(NVIDIA), monitor(INTEL_IGPU)]), Some(1));
assert_eq!(super::pick_adapter(&desktop, &[monitor(INTEL_IGPU), monitor(NVIDIA)], None), Some(1));
assert_eq!(super::pick_adapter(&desktop, &[monitor(NVIDIA), monitor(INTEL_IGPU)], None), Some(1));
// A GPU driving no display still loses to one that does.
assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), monitor(INTEL_IGPU)]), Some(1));
assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), monitor(INTEL_IGPU)], None), Some(1));
}
#[test]
fn pick_adapter_prefers_the_gpu_the_compositor_draws_with() {
// A hybrid laptop whose Wayland compositor runs on the discrete GPU (Hyprland with
// `AQ_DRM_DEVICES` naming it first): the integrated GPU drives the built-in panel, yet the
// compositor shows nothing it draws, and reports no error either.
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some(NVIDIA)), Some(0));
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(NVIDIA)), Some(0));
// The usual hybrid laptop, with the compositor on the integrated GPU: as before (#8).
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(AMD_IGPU)), Some(1));
// No GPU drives a display as far as sysfs says (a container), but the compositor knows.
assert_eq!(super::pick_adapter(&adapters(), &[], Some(NVIDIA)), Some(0));
// A compositor GPU no adapter matches (a virtual one) leaves the displays in charge.
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some((0x1af4, 0x1050))), Some(1));
}
#[test]
fn pick_adapter_falls_back_to_low_power_without_a_match() {
assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234))]), Some(1));
assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234)), monitor((0x8086, 0x5678))]), Some(1));
assert_eq!(super::pick_adapter(&[], &[monitor(NVIDIA)]), None);
assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234))], None), Some(1));
assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234)), monitor((0x8086, 0x5678))], None), Some(1));
assert_eq!(super::pick_adapter(&[], &[monitor(NVIDIA)], None), None);
}
#[test]
+1 -1
View File
@@ -67,7 +67,7 @@ OpenGL can't start either, PdfCraft exits with both errors in the log. The fallb
| `RUST_LOG` | Log levels for standard error and the log file (see [Logs](#logs)) |
| `RUST_BACKTRACE` | `1` adds a backtrace to the report of an internal error |
| `XDG_DATA_HOME` | Linux/FreeBSD: base of the settings folder (`pdfcraft/`), the log folder and crash recovery |
| `WGPU_POWER_PREF` | GPU choice; by default PdfCraft draws on the GPU that drives the primary display (Windows) or the built-in panel (Linux), else the low-power (integrated) GPU. The log says which one was chosen |
| `WGPU_POWER_PREF` | GPU choice; by default PdfCraft draws on the GPU the Wayland compositor draws with (it says so through linux-dmabuf feedback), else the GPU that drives the primary display (Windows) or the built-in panel (Linux), else the low-power (integrated) GPU. The log says which one was chosen |
| `WGPU_BACKEND` | Graphics backend; by default Windows uses Direct3D 12, falling back to OpenGL |
| `PDFCRAFT_RENDERER` | `gl` starts with OpenGL (glow) and never loads wgpu; `wgpu` reports a wgpu failure instead of retrying with OpenGL; unset, wgpu is retried with OpenGL when it can't start (see [Renderer fallback](#renderer-fallback)) |
| `CRAFT_FONTS_DIR` | Build time: a [craft-fonts](https://github.com/storytold/craft-fonts) checkout to embed (Japanese fonts) |