#!/bin/bash # Set up GPU passthrough for a native-profile guest, with the smallest change to # the host that will do the job. A passed-through card gives the guest real # silicon in place of an emulated display adapter. # # vm-native-gpu show GPUs, then ask and do it # vm-native-gpu --help show GPUs and what to do, change nothing # vm-native-gpu --single [rom] pass the host's only GPU # vm-native-gpu --dual [rom] pass a GPU the host is not using # vm-native-gpu --apply [rom] wire the GPU into the domain XML only # vm-native-gpu --xml print the block, change nothing # vm-native-gpu --revert undo the hook and modprobe file # # --single, --dual and --apply all write the domain XML: every function in the # card's IOMMU group as a , an optional vBIOS rom file, the emulated # display removed and any GL render node pointing at the card dropped. The # domain is backed up first and a re-run replaces what an earlier run wrote. # # Most machines have one GPU, so single-GPU is the normal path. The host gives # the card up while the guest runs: the display manager is stopped, the console # and framebuffer are unbound, the GPU driver is unloaded and the card is bound # to vfio-pci. All of it is reversed when the guest stops. # # The host has no display for as long as the guest runs. Have SSH working before # you try it - that is your way back if a step fails. # # If you do have a second GPU the host is not using, --dual is strictly better: # the card is bound to vfio-pci at boot, nothing is torn down at VM start, and # the host keeps its display the whole time. set -uo pipefail MODPROBE=/etc/modprobe.d/vfio-native.conf HOOK=/etc/libvirt/hooks/qemu.d/20-gpu-passthrough.sh die() { echo "$*" >&2; exit 1; } need_root() { [ "$(id -u)" = 0 ] || die "run this as root"; } # --- discovery --------------------------------------------------------------- gpus() { lspci -Dnn | grep -E "VGA compatible controller|3D controller" | sed 's/ (rev [0-9a-f]*)//' } iommu_group() { local d="$1" g g=$(readlink -f "/sys/bus/pci/devices/$d/iommu_group" 2>/dev/null) echo "${g##*/}" } # every device sharing the GPU's IOMMU group must go to the guest with it group_members() { local d="$1" g g=$(iommu_group "$d") [ -n "$g" ] || return for m in /sys/kernel/iommu_groups/"$g"/devices/*; do [ -e "$m" ] && basename "$m" done } driver_of() { local l l=$(readlink -f "/sys/bus/pci/devices/$1/driver" 2>/dev/null) [ -n "$l" ] && echo "${l##*/}" || echo "(none)" } # does this card currently drive a connected display? drives_display() { local d="$1" c for c in /sys/class/drm/card*/device; do [ -e "$c" ] || continue if [ "$(basename "$(readlink -f "$c")")" = "$d" ]; then for s in "$(dirname "$c")"*/status; do [ -e "$s" ] && grep -qx connected "$s" && return 0 done fi done return 1 } ids_of() { # vendor:device for vfio-pci binding local d for d in $(group_members "$1"); do local cls; cls=$(cat "/sys/bus/pci/devices/$d/class" 2>/dev/null) # only real functions of the card: skip bridges (class 0x0604xx) case "$cls" in 0x0604*) continue;; esac printf '%s:%s\n' \ "$(cut -c3- < "/sys/bus/pci/devices/$d/vendor")" \ "$(cut -c3- < "/sys/bus/pci/devices/$d/device")" done | sort -u | paste -sd, } show() { echo "GPUs in this machine:" echo while read -r line; do d=${line%% *} grp=$(iommu_group "$d"); drv=$(driver_of "$d") if drives_display "$d"; then use="drives a connected display"; else use="no display attached"; fi echo " $line" echo " pci $d iommu group $grp driver $drv" echo " $use" echo " group members: $(group_members "$d" | paste -sd' ')" echo done < <(gpus) local n; n=$(gpus | wc -l) echo "What to do:" local n cand="" n=$(gpus | wc -l) while read -r line; do d=${line%% *} drives_display "$d" || cand="$d" done < <(gpus) if [ "$n" -ge 2 ] && [ -n "$cand" ]; then echo " You have a spare GPU ($cand, no display attached), which is the" echo " easy case. Bind it to vfio-pci at boot and the host never touches" echo " it - nothing to tear down at VM start, host keeps its display:" echo echo " sudo vm-native-gpu --dual $cand" echo echo " Check first that nothing on the host is using it:" echo " sudo fuser -v /dev/dri/by-path/pci-$cand-*" echo " A compositor often holds every DRM device even with no monitor on" echo " it. Binding at boot fixes that; it needs an initramfs rebuild and" echo " a reboot." else local only; only=$(gpus | head -1 | cut -d' ' -f1) echo " One GPU, which is the normal case. The host hands it over while" echo " the guest runs and takes it back afterwards:" echo echo " sudo vm-native-gpu --single $only" echo echo " The host has no display for as long as the guest runs. Get SSH" echo " working first - that is your way back if a step fails." fi } # --- dual GPU: bind at boot, no hooks --------------------------------------- dual() { need_root local dom="$1" d="$2" rom="${3:-}" ids [ -e "/sys/bus/pci/devices/$d" ] || die "no such PCI device: $d" drives_display "$d" && die "$d is driving a connected display - move your monitors off it first" ids=$(ids_of "$d") [ -n "$ids" ] || die "could not read device ids for $d" cat > "$MODPROBE" </dev/null 2>&1 || die "no such domain: $dom" echo "This installs a libvirt hook that, every time the guest starts, will:" echo " - stop your display manager and switch to multi-user.target" echo " - unbind the virtual consoles and the EFI framebuffer" echo " - unload the GPU driver and bind the card to vfio-pci" echo "and reverse all of it when the guest stops." echo echo "The host has NO DISPLAY while the guest runs. If it fails part-way you" echo "may be left at a black screen and need SSH to recover." echo echo "It fires ONLY for domain \"$dom\". Other VMs are untouched." echo read -rp "install it for domain $dom, PCI $d? [y/N]: " a [ "$a" = y ] || { echo aborted; exit 1; } sed -e "s|@@GPU@@|$d|g" -e "s|@@DOMAIN@@|$dom|g" > "$HOOK" <<'HOOKEOF' #!/bin/bash # Single-GPU passthrough for one domain, installed by vfio-native. # # Frees the GPU before the guest starts and gives it back afterwards. The actual # vfio-pci bind/unbind is left to libvirt, because the is managed='yes' # - this only has to make the card free for libvirt to take. # # Exits 0 on every path. libvirt treats a non-zero prepare hook as fatal, and a # GPU helper must never be the reason a VM refuses to start. GPU="@@GPU@@" DOMAIN="@@DOMAIN@@" LOG=/var/log/libvirt/gpu-passthrough.log STATE=/run/vfio-native log() { echo "$(date +%T) $*" >> "$LOG"; } # Only ever act for the domain this was installed for. [ "$1" = "$DOMAIN" ] || exit 0 mkdir -p "$STATE" case "$2" in prepare) log "$DOMAIN starting: releasing $GPU" # 1. stop whatever is holding the DRM device dm=$(systemctl list-units --type=service --state=running --no-legend 2>/dev/null | awk '{print $1}' | grep -xE '(gdm|sddm|lightdm|lxdm|greetd|display-manager)\.service' | head -1) if [ -n "$dm" ]; then echo "$dm" > "$STATE/dm" log "stopping $dm" systemctl stop "$dm" # wait for it to actually let go, rather than racing it for _ in $(seq 1 20); do systemctl is-active --quiet "$dm" || break; sleep 0.5; done fi # 2. release the console framebuffers : > "$STATE/consoles" for i in /sys/class/vtconsole/vtcon*; do [ -e "$i/name" ] || continue if grep -q "frame buffer" "$i/name" 2>/dev/null; then echo 0 > "$i/bind" 2>/dev/null && { basename "$i" >> "$STATE/consoles"; log "unbound $(basename "$i")"; } fi done [ -e /sys/bus/platform/drivers/efi-framebuffer/unbind ] && echo efi-framebuffer.0 > /sys/bus/platform/drivers/efi-framebuffer/unbind 2>/dev/null [ -e /sys/bus/platform/drivers/simple-framebuffer/unbind ] && for fb in /sys/bus/platform/drivers/simple-framebuffer/simple-framebuffer.*; do [ -e "$fb" ] && basename "$fb" > /sys/bus/platform/drivers/simple-framebuffer/unbind 2>/dev/null done sleep 1 # 3. unload the host GPU driver, remembering which one to put back drv=$(basename "$(readlink -f "/sys/bus/pci/devices/$GPU/driver" 2>/dev/null)" 2>/dev/null) case "$drv" in amdgpu|radeon) echo "$drv" > "$STATE/driver" modprobe -r amdgpu radeon 2>/dev/null ;; nvidia*) echo nvidia > "$STATE/driver" modprobe -r nvidia_uvm nvidia_drm nvidia_modeset nvidia 2>/dev/null ;; nouveau) echo nouveau > "$STATE/driver" modprobe -r nouveau 2>/dev/null ;; vfio-pci) log "already on vfio-pci" ;; *) log "unexpected driver '$drv', leaving it alone" ;; esac modprobe vfio-pci 2>/dev/null log "released; libvirt will bind $GPU to vfio-pci" ;; release) log "$DOMAIN stopped: reclaiming $GPU" drv=$(cat "$STATE/driver" 2>/dev/null) [ -n "$drv" ] && { modprobe "$drv" 2>/dev/null; log "reloaded $drv"; } sleep 1 while read -r c; do [ -n "$c" ] && echo 1 > "/sys/class/vtconsole/$c/bind" 2>/dev/null done < "$STATE/consoles" 2>/dev/null dm=$(cat "$STATE/dm" 2>/dev/null) if [ -n "$dm" ]; then log "starting $dm" systemctl start "$dm" fi rm -f "$STATE/dm" "$STATE/consoles" "$STATE/driver" log "host display restored" ;; esac exit 0 HOOKEOF chmod +x "$HOOK" echo "installed $HOOK" echo apply "$dom" "$d" "$rom" } # Write the passthrough into the domain: the whole IOMMU group as s # tagged with a user alias so a re-run replaces exactly these, the vBIOS rom if # given, the emulated display gone, and no GL render node left on the card. apply() { local dom="$1" d="$2" rom="${3:-}" v v=$(command -v virsh) || die "virsh not found" "$v" -c qemu:///system dominfo "$dom" >/dev/null 2>&1 || die "no such domain: $dom" [ "$("$v" -c qemu:///system domstate "$dom")" = "shut off" ] || die "shut $dom down first" [ -z "$rom" ] || [ -f "$rom" ] || die "rom file not found: $rom" local backup="${SUDO_USER:+/home/$SUDO_USER}"; backup="${backup:-$HOME}/vfio-native-backup" mkdir -p "$backup" # the revert target is the state before this tool first touched the domain "$v" -c qemu:///system dumpxml --inactive "$dom" > "$backup/$dom.gpu-current.xml" grep -q 'ua-vfionative-gpu' "$backup/$dom.gpu-current.xml" || cp "$backup/$dom.gpu-current.xml" "$backup/$dom.before-gpu.xml" local devs="" m for m in $( { group_members "$d"; ls -d "/sys/bus/pci/devices/${d%.*}".* | xargs -n1 basename; } | sort -u); do case "$(cat "/sys/bus/pci/devices/$m/class" 2>/dev/null)" in 0x0604*) continue;; esac IFS=':.' read -r dm bs sl fn <<< "$m" devs+=" \n \n" devs+="
\n \n" [ -n "$rom" ] && [ "$m" = "$d" ] && devs+=" \n" devs+=" \n \n" done DEVS="$devs" GPU="$d" python3 - "$backup/$dom.gpu-current.xml" "$backup/$dom.gpu.xml" <<'PY' import io, os, re, sys s = io.open(sys.argv[1], encoding="utf-8").read() s = re.sub(r"\s*(?:(?!).)*(?:(?!).)*", "", s, flags=re.S) s = re.sub(r"", "", s, flags=re.S) s = re.sub(r"\s*" % re.escape(os.environ["GPU"]), "", s) s = s.replace(" ", os.environ["DEVS"].replace("\\n", "\n") + " ", 1) io.open(sys.argv[2], "w", encoding="utf-8").write(s) PY "$v" -c qemu:///system define "$backup/$dom.gpu.xml" >/dev/null || die "define failed" echo "$dom: $(grep -c "ua-vfionative-gpu" "$backup/$dom.gpu.xml") hostdev(s) for $d${rom:+ with rom $rom}, emulated display removed." echo "revert: virsh -c qemu:///system define $backup/$dom.before-gpu.xml" } xml() { local d="$1" echo "Add this to the domain, inside . Every device in the GPU's" echo "IOMMU group has to go together:" echo for m in $(group_members "$d"); do local cls; cls=$(cat "/sys/bus/pci/devices/$m/class" 2>/dev/null) case "$cls" in 0x0604*) continue;; esac IFS=':. ' read -r dom bus slot fn <<< "$(echo "$m" | tr ':.' ' ')" printf " \n" printf " \n" printf "
\n" \ "$dom" "$bus" "$slot" "$fn" printf " \n \n" done echo echo "Then remove the emulated display so the guest has only the real card:" echo " " echo "and drop any node that points its GL rendernode at this card." } # Run bare: the same information, then the questions, then the matching command. guided() { show [ -t 0 ] || return 0 local n cand="" only d dom mode rom a n=$(gpus | wc -l) while read -r line; do d=${line%% *}; drives_display "$d" || cand="$d"; done < <(gpus) only=$(gpus | head -1 | cut -d' ' -f1) echo "-----------------------------------------------------------------------" read -rp "set up passthrough now? (y/n) [n]: " a; [ "$a" = y ] || return 0 read -rp "domain: " dom; [ -n "$dom" ] || die "need a domain" if [ "$n" -ge 2 ] && [ -n "$cand" ]; then read -rp "GPU to pass [$cand]: " d; d=${d:-$cand} read -rp "mode - dual (bind at boot, host keeps its display) or single (host gives it up while the guest runs) [dual]: " mode; mode=${mode:-dual} else read -rp "GPU to pass [$only]: " d; d=${d:-$only} mode=single fi read -rp "vBIOS rom file for the guest, or empty [none]: " rom [ -z "$rom" ] || [ -f "$rom" ] || die "rom file not found: $rom" [ "$(id -u)" = 0 ] || die "installing the ${mode} setup needs root: sudo $(basename "$0") --$mode $dom $d${rom:+ $rom}" "$mode" "$dom" "$d" "$rom" } revert() { need_root local did=0 [ -e "$MODPROBE" ] && { rm -f "$MODPROBE"; echo "removed $MODPROBE"; did=1; } [ -e "$HOOK" ] && { rm -f "$HOOK"; echo "removed $HOOK"; did=1; } [ "$did" = 1 ] || echo "nothing installed by this script was found" [ -e "$MODPROBE" ] || echo "rebuild the initramfs (sudo mkinitcpio -P) and reboot to release the card" } case "${1:-}" in --dual) [ $# -ge 3 ] || die "usage: --dual [romfile]"; dual "$2" "$3" "${4:-}";; --single) [ $# -ge 3 ] || die "usage: --single [romfile]"; single "$2" "$3" "${4:-}";; --apply) [ $# -ge 3 ] || die "usage: --apply [romfile]"; apply "$2" "$3" "${4:-}";; --xml) [ $# -ge 2 ] || die "need a PCI id"; xml "$2";; --revert) revert;; "") guided;; -h|--help) show;; *) die "unknown option: $1";; esac