diff --git a/README.md b/README.md index 71b1eac..46a8d3d 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,48 @@ about one device (`adb devices`) all turn the defaulting off, and nothing is guessed when this checkout's emulator is not running: adb's own error is better than a wrapper picking a stranger's device. +## Rendering on the GPU with no display + +This machine has no screen, and for a long time that meant the emulator +rasterised in software. It does not have to: the VM sees the host's GPU +through virtio-gpu, and `/dev/dri/renderD128` is readable without being in +the `video` group. + +What was in the way is that the emulator's `-gpu host` renderer speaks GLX, +so with no `DISPLAY` it cannot open a context at all -- it says +`GlxEnginegetDefaultDisplay: Failed to open display 0` and falls back. So +`emu up` starts a **headless sway** whose only job is to provide an +Xwayland, and Xwayland's GLX runs on the render node. The guest's GLES then +reports `virgl (AMD Radeon RX 7900 XT)` instead of a software rasteriser. +One compositor is started for the machine and reused, on a named socket +under `XDG_RUNTIME_DIR`; it needs no seat, no DRM master and no card node. + +Measured 2026-08-30, scrolling a list in the stock **Settings** app: + +| | janky frames | 90th percentile | slow UI-thread frames | +|---|---|---|---| +| software | 25.5% | 93ms | 8 | +| on the GPU | 3.3% | 28ms | 0 | + +An emulator also costs about **1.1 GB less** resident this way (2.9 GB +against 3.9 GB), which is a whole emulator's worth of headroom on a machine +that only takes two. + +This matters for more than speed. Before it, frame timings measured in here +said nothing at all about an app: our own app scrolled *better* than +Settings did, because both were bounded by the rasteriser rather than by +anything either of them was doing. Now the platform floor is low enough that +an app's own jank is visible above it. + +`GPU_HOST_FEATURES` carries the one workaround: host Vulkan is switched off, +because gfxstream asks Venus for a memory type for its ColorBuffers that +Venus does not offer, and the emulator dies before adb sees it. GLES is +unaffected. It is worth retrying whenever mesa moves -- the failure is loud +and costs one boot to find. + +If sway is missing, or the compositor will not start, `emu up` says so and +starts on software rendering as it always did. None of this is essential. + ## Refusing to start one `emu up` checks `MemAvailable` first and refuses if starting an emulator diff --git a/bin/emu b/bin/emu index 52630f4..1392220 100755 --- a/bin/emu +++ b/bin/emu @@ -112,6 +112,72 @@ check_room() { exit 1 } +# Host Vulkan is off, and this is the one flag here that is about a bug +# rather than a preference. +# +# gfxstream asks the host for a memory type it can use for its ColorBuffers, +# and Venus -- the virtio-gpu Vulkan driver this VM sees the real GPU +# through -- does not offer one: "Format VK_FORMAT_R8G8B8A8_UNORM is not +# supported", then "Failed to find memory type for ColorBuffers", then the +# emulator dies before adb ever sees it. GLES on the same driver is fine, and +# is what the guest falls back to, so the only thing lost is Vulkan inside +# the guest. Try dropping this whenever mesa moves: the failure is loud and +# immediate, so it costs one boot to find out. +GPU_HOST_FEATURES=${GPU_HOST_FEATURES:--feature -Vulkan} + +# An X server on this machine's GPU, started once and reused, or nothing. +# +# The emulator's `-gpu host` renderer talks GLX. With no DISPLAY it cannot +# open one -- "GlxEnginegetDefaultDisplay: Failed to open display 0" -- and +# the only other thing it will do is rasterise in software. Measured on this +# VM on 2026-08-30, that is not a small difference: scrolling a list in the +# stock Settings app went from 25% janky frames to 3%, and the emulator's own +# resident size fell from 3.9 GB to 2.9 GB. +# +# So: a headless sway, purely to get an Xwayland whose GLX runs on the +# virtio-gpu render node. It needs no seat, no DRM master and no card node -- +# only /dev/dri/renderD128, which is world-readable here. Writes nothing +# outside XDG_RUNTIME_DIR and goes away with the machine. +# +# Prints the DISPLAY it ended up with; exit 1 means the caller should fall +# back to software rather than fail, because none of this is essential. +headless_display() { + command -v sway >/dev/null 2>&1 || return 1 + [ -e /dev/dri/renderD128 ] || return 1 + + run="${XDG_RUNTIME_DIR:-/tmp}/emulator-tools" + mkdir -p "$run" + export SWAYSOCK="$run/sway.sock" + # Named rather than left to sway's pid-based default, which is what makes + # a second `emu up` reuse the first one's compositor instead of starting + # another beside it. + if ! swaymsg -t get_version >/dev/null 2>&1; then + rm -f "$SWAYSOCK" + conf="$(dirname "$(readlink -f "$0")")/../share/emu/sway.conf" + WLR_BACKENDS=headless WLR_LIBINPUT_NO_DEVICES=1 LIBSEAT_BACKEND=noop \ + setsid sway -c "$conf" >"$run/sway.log" 2>&1 & + for _ in $(seq 20); do + swaymsg -t get_version >/dev/null 2>&1 && break + sleep 0.5 + done + swaymsg -t get_version >/dev/null 2>&1 || { + echo "emu: could not start the headless compositor; see $run/sway.log" >&2 + return 1 + } + fi + + # Asked of the compositor rather than guessed, because Xwayland takes the + # first free display number and this machine may already have one. + rm -f "$run/display" + swaymsg exec -- "sh -c 'printf %s \"\$DISPLAY\" > $run/display'" >/dev/null 2>&1 + for _ in $(seq 20); do + [ -s "$run/display" ] && break + sleep 0.5 + done + [ -s "$run/display" ] || return 1 + cat "$run/display" +} + cmd_up() { if serial=$(avd_serial "$adb" "$avd"); then echo "emu: '$avd' is already running ($serial)" >&2 @@ -143,12 +209,16 @@ cmd_up() { pkill -f "[e]mulator.*-avd $avd" >/dev/null 2>&1 || true log="/tmp/$avd-emulator.log" - : >"$log" + rm -f "$log" if [ -n "${DISPLAY:-}" ] || [ -n "${WAYLAND_DISPLAY:-}" ]; then echo "emu: starting '$avd' with GPU acceleration" >&2 "$sdk/emulator/emulator" -avd "$avd" -gpu host -no-audio >"$log" 2>&1 & + elif display=$(headless_display); then + echo "emu: no display -- starting '$avd' headless on the GPU ($display)" >&2 + DISPLAY="$display" "$sdk/emulator/emulator" -avd "$avd" \ + -gpu host $GPU_HOST_FEATURES -no-audio -no-window >"$log" 2>&1 & else - echo "emu: no display -- starting '$avd' headless" >&2 + echo "emu: no display and no compositor -- starting '$avd' on software rendering" >&2 "$sdk/emulator/emulator" -avd "$avd" -gpu swiftshader_indirect -no-audio -no-window \ >"$log" 2>&1 & fi diff --git a/share/emu/sway.conf b/share/emu/sway.conf new file mode 100644 index 0000000..5a05386 --- /dev/null +++ b/share/emu/sway.conf @@ -0,0 +1,23 @@ +# The compositor `emu` starts when this machine has no display, so that the +# Android emulator has an X server to talk to. +# +# Nothing here is meant to be looked at; there is no screen. The point is +# Xwayland: the emulator's `-gpu host` renderer speaks GLX, and with no +# DISPLAY at all it falls back to a software rasteriser. Xwayland's GLX is +# backed by this VM's virtio-gpu render node, so the guest's GLES ends up on +# the host's real GPU. +# +# `xwayland force` starts it immediately rather than at the first X client, +# so `emu` can read DISPLAY out of a running compositor instead of racing it. +xwayland force + +# Sized to a phone so the emulator's window is never scaled or clipped. It is +# larger than any AVD here; an output smaller than the window is the one way +# this arrangement can affect what the emulator renders. +output HEADLESS-1 mode 1440x3120@60Hz + +# No bar, no wallpaper, no keybindings, no pointer follow: each of those is a +# client to start or a thing to go wrong on a machine with nobody at the +# keyboard, and none of them has anything to draw on. +default_border none +focus_follows_mouse no