Install
openclaw skills install @mina-atef-00/single-gpu-passthroughComprehensive skill for the author's single-GPU passthrough setup: Fedora 44 bootc atomic host with Intel i5-12400F, NVIDIA RTX 2060 12GB (TU106), QEMU 10.2.2, libvirt 12.0.0, Wayland (niri on greetd). Covers hook scripts, kernel config, libvirt domain XML, virsh operations, and troubleshooting for single-GPU VFIO passthrough to a Win10 VM.
openclaw skills install @mina-atef-00/single-gpu-passthrough| Component | Detail |
|---|---|
| Host OS | Fedora 44 (bootc atomic — ostree-based, immutable root) |
| Kernel | 7.0.12-201.fc44.x86_64 |
| CPU | Intel i5-12400F (12th Gen Alder Lake, 6C/12T) |
| GPU | NVIDIA RTX 2060 12GB (TU106) — PCI 01:00.0 + HDMI Audio 01:00.1 |
| NVIDIA Driver | 610.43.02 |
| Display | greetd → dms-greeter → niri (Wayland compositor) |
| QEMU | 10.2.2 |
| libvirt | 12.0.0 (modular daemons) |
| VM Name | win10 |
| Boot | UEFI (OVMF) |
| Storage | NVMe 238GB (boot), HDD 1.8TB (data: niabc NTFS / niaext ext4) |
The GPU and its HDMI audio controller share a clean IOMMU group (Group 12):
Both devices must be passed together as they share IOMMU Group 12. They connect via PCI bridge at 0000:00:01.0.
03:00.0 — Realtek RTL8111H Gigabit Ethernet04:00.0 — MAXIO NVMe SSD ControllerKernel parameters are set via rpm-ostree kargs (NOT /etc/default/grub — bootc is immutable):
sudo rpm-ostree kargs \
--append='intel_iommu=on' \
--append='iommu=pt' \
--append='rd.driver.blacklist=nouveau' \
--append='modprobe.blacklist=nouveau' \
--append='nvidia-drm.modeset=1'
Current cmdline (from /proc/cmdline):
intel_iommu=on iommu=pt rd.driver.blacklist=nouveau modprobe.blacklist=nouveau nvidia-drm.modeset=1
IMPORTANT — bootc atomic context: On a bootc/ostree system:
/etc is writable (persists across updates)/usr is immutable (resets on each deployment update)rpm-ostree kargs — add/remove requires a rebootsudo rpm-ostree kargs --append='new_param=value'sudo rpm-ostree kargs --delete='old_param=value'sudo rpm-ostree kargs --replace='old=value' --replace-to='new=value'rpm-ostree kargs/etc/modprobe.d/ is writable on bootc. The following reside there:
NVreg_RegistryDwords, NVreg_EnableGpuFirmware, NVreg_DynamicPowerManagement, nvidia-drm modeset=1max_mem_regions=509VFIO modules are not loaded at boot — they are loaded dynamically by the hook scripts on VM prepare.
# Check IOMMU is enabled
dmesg | grep -i iommu
cat /proc/cmdline | grep -o 'intel_iommu\|iommu'
# Check IOMMU groups
for g in $(find /sys/kernel/iommu_groups/* -maxdepth 0 -type d | sort -V); do
echo "IOMMU Group $(basename $g):"
for d in $g/devices/*; do
echo -n " $(basename $d): "
lspci -nns "$(basename $d)" 2>/dev/null || echo "unknown"
done
done
# Via virsh
virsh nodedev-list --cap pci
virsh nodedev-dumpxml pci_0000_01_00_0 # GPU
virsh nodedev-dumpxml pci_0000_01_00_1 # HDMI Audio
The passthrough uses libvirt qemu hooks — scripts that fire on VM lifecycle events. Three scripts form the system:
/etc/libvirt/hooks/qemu — dispatcher: called by libvirt on VM prepare/release~/.local/sbin/vfio-startup — detaches GPU from host, called on VM prepare~/.local/sbin/vfio-teardown — reattaches GPU to host, called on VM releaselibvirt-nosleep@.service — systemd inhibit sleep while VM runs| Script | Deployed To | Purpose |
|---|---|---|
| qemu hook | /etc/libvirt/hooks/qemu | Dispatches startup/teardown per VM name |
| vfio-startup | ~/.local/sbin/vfio-startup | Prepare phase: detach GPU |
| vfio-teardown | ~/.local/sbin/vfio-teardown | Release phase: reattach GPU |
| inhibit service | /etc/systemd/system/libvirt-nosleep@.service | Prevents sleep while VM active |
NOTE: The repo's install_hooks.sh installs to /usr/local/bin/ but The author modified the qemu hook to call ~/.local/sbin/ instead. Always check which paths are active before modifying.
The source repo is at ~/projects/single-gpu-passthrough/ but the deployed scripts have been customized — they differ from the repo versions significantly.
The qemu hook receives 4 positional arguments:
/etc/libvirt/hooks/qemu <vm_name> <operation> <sub-operation> <extra>
| Phase | When Called | Operation | Sub-Op |
|---|---|---|---|
| Prepare | Before libvirt resource labeling, before guest start | prepare | begin |
| Start | After labeling, before QEMU starts | start | begin |
| Started | After QEMU process started | started | begin |
| Stopped | Before libvirt restores labels | stopped | end |
| Release | After libvirt releases resources | release | end |
The domain XML is passed on stdin during all guest hook calls.
the author's hook uses prepare and release — the outermost lifecycle phases.
Path: /etc/libvirt/hooks/qemu
#!/bin/bash
OBJECT="$1"
OPERATION="$2"
if [[ $OBJECT == "win10" ]]; then
case "$OPERATION" in
"prepare")
systemctl start libvirt-nosleep@"$OBJECT" 2>&1 | tee -a /var/log/libvirt/custom_hooks.log
~/.local/sbin/vfio-startup 2>&1 | tee -a /var/log/libvirt/custom_hooks.log
;;
"release")
systemctl stop libvirt-nosleep@"$OBJECT" 2>&1 | tee -a /var/log/libvirt/custom_hooks.log
~/.local/sbin/vfio-teardown 2>&1 | tee -a /var/log/libvirt/custom_hooks.log
;;
esac
fi
Key details:
win10 (change this for different VM names)/var/log/libvirt/custom_hooks.logPath: ~/.local/sbin/vfio-startup
Execution flow:
systemctl stop greetd, then killall -9 niri qs zen kitty Xwayland pipewire wireplumberecho 0 > /sys/class/vtconsole/vtcon0/bind and vtcon1/bindecho efi-framebuffer.0 > /sys/bus/platform/drivers/efi-framebuffer/unbindnvidia_uvm, nvidia_drm, nvidia_modeset, nvidia, i2c_nvidia_gpu, drm_kms_helpervirsh nodedev-detach pci_0000_01_00_0 (GPU)virsh nodedev-detach pci_0000_01_00_1 (HDMI Audio)/var/log/libvirt/vfio-startup.log with set -x traceIMPORTANT: The NVIDIA module unload is a single modprobe -r call with all modules listed. If any module is busy (e.g., nvidia_uvm from a container), the entire unload fails. In that case, try unloading individually or find what's holding references:
lsmod | grep nvidia
lsof | grep nvidia # what processes hold nvidia files
Path: ~/.local/sbin/vfio-teardown
Execution flow:
virsh nodedev-reattach pci_0000_01_00_0 (GPU)virsh nodedev-reattach pci_0000_01_00_1 (HDMI Audio)nvidia_drm, nvidia_modeset, nvidia_uvm, nvidiaecho 1 > vtcon0/bind and vtcon1/bindsystemctl start greetd/var/log/libvirt/vfio-teardown.log with set -x tracePath: /etc/systemd/system/libvirt-nosleep@.service
[Unit]
Description=Preventing sleep while libvirt domain "%i" is running
[Service]
Type=simple
ExecStart=/usr/bin/systemd-inhibit --what=sleep --why="Libvirt domain \"%i\" is running" --who=%U --mode=block sleep infinity
This uses systemd-inhibit to block system sleep (suspend/hibernate) while the VM runs.
To deploy hooks from scratch:
# Become root
sudo -i
# Copy scripts
cp ~/projects/single-gpu-passthrough/hooks/vfio-startup ~/.local/sbin/vfio-startup
cp ~/projects/single-gpu-passthrough/hooks/vfio-teardown ~/.local/sbin/vfio-teardown
cp ~/projects/single-gpu-passthrough/hooks/qemu /etc/libvirt/hooks/qemu
cp ~/projects/single-gpu-passthrough/systemd-no-sleep/libvirt-nosleep@.service /etc/systemd/system/libvirt-nosleep@.service
# Make executable
chmod +x ~/.local/sbin/vfio-startup ~/.local/sbin/vfio-teardown /etc/libvirt/hooks/qemu
# Create log file
mkdir -p /var/log/libvirt
touch /var/log/libvirt/custom_hooks.log /var/log/libvirt/vfio-startup.log /var/log/libvirt/vfio-teardown.log
# Reload systemd
systemctl daemon-reload
# Verify hooks directory exists
ls -la /etc/libvirt/hooks/qemu
NOTE: The repo scripts are the base versions. The deployed scripts at ~/.local/sbin/ have been significantly customized for the author's Wayland+niri setup. Always check the deployed versions before modifying.
Fedora 44 libvirt uses modular daemons (not the monolithic libvirtd):
systemctl status virtqemud # QEMU-specific daemon
systemctl status virtnodedevd # Node device management (needed for nodedev-detach)
systemctl status virtnetworkd # Network management
systemctl status virtstoraged # Storage management
Ensure virtnodedevd is running — the hooks depend on it for virsh nodedev-detach and nodedev-reattach.
The VM should be defined in virsh. Key passthrough-specific XML elements:
<domain type="kvm">
<name>win10</name>
<memory unit="GiB">16</memory>
<vcpu>12</vcpu>
<os>
<type>hvm</type>
<loader readonly="yes" type="pflash">/usr/share/edk2/ovmf/OVMF_CODE_4M.qcow2</loader>
<nvram>/var/lib/libvirt/qemu/nvram/win10_VARS.fd</nvram>
<boot dev="hd"/>
</os>
<features>
<acpi/>
<apic/>
<hyperv>
<relaxed state="on"/>
<vapic state="on"/>
<spinlocks state="on" retries="8191"/>
</hyperv>
<kvm>
<hidden state="on"/>
</kvm>
</features>
<cpu mode="host-passthrough" check="none">
<topology sockets="1" dies="1" cores="6" threads="2"/>
</cpu>
<devices>
<!-- VFIO GPU Passthrough -->
<hostdev mode="subsystem" type="pci" managed="yes">
<source>
<address domain="0x0000" bus="0x01" slot="0x00" function="0x0"/>
</source>
<rom bar="off"/>
</hostdev>
<!-- VFIO HDMI Audio -->
<hostdev mode="subsystem" type="pci" managed="yes">
<source>
<address domain="0x0000" bus="0x01" slot="0x00" function="0x1"/>
</source>
</hostdev>
</devices>
</domain>
Key points:
managed="yes" — libvirt manages driver binding (detach/attach), which is what the hooks use via nodedev-detachrom bar="off" on GPU — prevents QEMU from trying to map the GPU ROM BAR (often causes issues with NVIDIA cards)kvm hidden state=on — hides KVM hypervisor signature from NVIDIA drivers (avoids Code 43)cpu mode="host-passthrough" — pass all host CPU features to guest (gaming performance)Alternatively, for finer control, the QEMU command-line equivalent (used when not via libvirt):
-device vfio-pci,host=01:00.0,multifunction=on,x-vga=on,rombar=0
-device vfio-pci,host=01:00.1
Where:
multifunction=on — tells QEMU the GPU is a multi-function device (required when passing both functions 0 and 1)x-vga=on — marks this device as the primary VGA (needed for boot-time video)rombar=0 — don't expose the option ROM BAR (avoids conflicts)The VM disk should be on the ext4 partition for better performance:
/mnt/media/win10.qcow2# Create qcow2 disk (example: 120GB sparse)
qemu-img create -f qcow2 /mnt/media/win10.qcow2 120G
Default libvirt NAT network is sufficient:
virsh net-start default
virsh net-autostart default
Use virtio NIC for best performance:
<interface type="network">
<mac address="52:54:00:xx:xx:xx"/>
<source network="default"/>
<model type="virtio"/>
</interface>
virsh start win10
Libvirt will:
/etc/libvirt/hooks/qemu win10 prepare begin -virsh shutdown win10 # Graceful shutdown
# or
virsh destroy win10 # Force power off
Libvirt will:
/etc/libvirt/hooks/qemu win10 release end <reason># Hook logs
tail -f /var/log/libvirt/custom_hooks.log
tail -f /var/log/libvirt/vfio-startup.log
tail -f /var/log/libvirt/vfio-teardown.log
# virsh status
virsh list
watch -n 1 virsh list
# GPU status
lspci -nnk -s 01:00
nvidia-smi # shows when host driver is bound
# Check current driver
lspci -nnk -s 01:00
# Check if NVIDIA modules still loaded
lsmod | grep nvidia
# Check if display server still running
systemctl is-active greetd
# Check what holds the GPU
fuser -v /dev/dri/* 2>/dev/null
lsof | grep -i nvidia
# Force detach (if not managed)
virsh nodedev-detach pci_0000_01_00_0
virsh nodedev-detach pci_0000_01_00_1
rom bar="off" is set in VM XMLx-vga=on in the QEMU argsNVIDIA drivers detect the KVM hypervisor and disable themselves. Fixes:
kvm hidden state=on in domain XMLhyperv features: relaxed, vapic, spinlockshost-passthrough<vendor_id state="on" value="1234567890ab"/># Check usage
lsmod | grep nvidia
# Find what's using nvidia_uvm
cat /sys/devices/virtual/misc/uvm/uevent
# Kill remaining processes
ps aux | grep -i nvidia
killall nvidia-persistenced 2>/dev/null
# Check greetd status
systemctl status greetd
# Manually start
sudo systemctl start greetd
# Check niri log
journalctl -u greetd -n 50 --no-pager
On Fedora bootc (ostree atomic host):
rpm-ostree install, they persist through updatesrpm-ostree usroverlay for /usr modifications, those are temporary (lost on reboot)rpm-ostree kargs --append/--delete/--replace requires a rebootIf libvirt's managed mode isn't working, bind VFIO-PCI manually at startup:
# Bind GPU to vfio-pci (instead of virsh nodedev-detach)
echo "0000:01:00.0" > /sys/bus/pci/devices/0000:01:00.0/driver/unbind
echo "0000:01:00.1" > /sys/bus/pci/devices/0000:01:00.1/driver/unbind
echo "10de 1f03" > /sys/bus/pci/drivers/vfio-pci/new_id
echo "10de 10f9" > /sys/bus/pci/drivers/vfio-pci/new_id
But this is redundant with virsh nodedev-detach — prefer the virsh method which handles the bind/unbind lifecycle correctly.
| Path | Purpose |
|---|---|
~/projects/single-gpu-passthrough/ | Git repo with hook sources |
~/.local/sbin/vfio-startup | Deployed startup script (customized!) |
~/.local/sbin/vfio-teardown | Deployed teardown script (customized!) |
/etc/libvirt/hooks/qemu | Libvirt hook dispatcher |
/etc/systemd/system/libvirt-nosleep@.service | Sleep inhibitor service |
/var/log/libvirt/custom_hooks.log | Hook dispatcher log |
/var/log/libvirt/vfio-startup.log | Startup trace log |
/var/log/libvirt/vfio-teardown.log | Teardown trace log |
/var/lib/libvirt/qemu/nvram/ | VM NVRAM store (UEFI variables) |
/mnt/media/ | Ext4 data partition (recommended VM disk location) |
/usr/share/edk2/ovmf/