GPU ADAPTER SELECTION — facett decides which device it renders on
nornir:class=design
Only the chosen design is named here (L8); the alternatives that were rejected are not. The measured evidence sits in §2, and it refutes the hypothesis that started this work — which is the point of measuring first.
1. The question
The OSM 2D map was reported slow on a 32-core Threadripper PRO 3975WX with a GeForce RTX 4090. The obvious first suspicion was that facett was not on the 4090 at all: the box also exposes an ASPEED BMC graphics core (2a:00.0 ASPEED Graphics Family, the server board's remote-console framebuffer) and Mesa's llvmpipe software rasteriser, and nothing in the workspace ever printed which adapter was actually opened. Every headless probe in the repo called
instance.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::default(), // == LowPower
..
})
PowerPreference::default() is LowPower — on a machine with an integrated GPU that deliberately asks for the slower device — and eframe's own selection was left to wgpu's internal ordering. The choice was never a policy and never observable.
2. ESTABLISHING THE TRUTH FIRST — and the hypothesis is REFUTED
cargo run -p facett-core --features wgpu --example gpu_probe, on oden:
== ENUMERATED 3 adapter(s) ==
[0] score= 5028 NVIDIA GeForce RTX 4090 [discrete/Vulkan] vendor=0x10de device=0x2684
[1] score= 8 llvmpipe (LLVM 21.1.8, 256 bits) [cpu/Vulkan] vendor=0x10005 device=0x0000
[2] score= 2021 NVIDIA GeForce RTX 4090/PCIe/SSE2 [other/Gl] vendor=0x10de device=0x0000
== OLD PATH request_adapter(PowerPreference::default()) -> NVIDIA GeForce RTX 4090 [discrete/Vulkan]
== OLD PATH request_adapter(HighPerformance) -> NVIDIA GeForce RTX 4090 [discrete/Vulkan]
== OLD PATH request_adapter(LowPower) -> NVIDIA GeForce RTX 4090 [discrete/Vulkan]
facett was already on the RTX 4090. All three power preferences returned it. Three facts explain why the trap did not fire on this box:
- The ASPEED BMC never enumerates. It has a DRM card node (
/dev/dri/card0) but no render node —/dev/dri/renderD128is the NVIDIA one, and there is no ASPEED Vulkan ICD or GL driver. wgpu therefore never sees it.glxinfo -BreportsNVIDIA GeForce RTX 4090/PCIe/SSE2: the whole KDE/Wayland session is on the 4090. - llvmpipe does enumerate (it is a real Vulkan ICD), so it is a live risk on any box where the NVIDIA ICD fails to load — but wgpu's own ordering already deprioritises
DeviceType::Cpu, so it did not win here. - There is exactly one hardware GPU, so there was no discrete-vs-integrated decision to get wrong.
So adapter selection was not the cause of the slow 2D map. The cause is in osm-2d-perf-design.md. The policy below still ships, for three reasons: the choice must be observable (it was not), it must be overridable (it was not), and the traps are real on the machines this code also runs on — a Dell/HPE board puts a Matrox G200 BMC in the enumeration, a laptop puts an iGPU there, and a container with a broken ICD leaves only llvmpipe.
3. The policy
facett-core/src/render/adapter.rs — pure and feature-free. It does not depend on wgpu, so the whole ranking is unit-tested on a machine with no GPU stack at all, and any facett crate can consume it whether or not it links a GPU.
AdapterFacts mirrors wgpu::AdapterInfo in plain types (name, vendor, device, AdapterKind, backend, driver). AdapterKind mirrors wgpu::DeviceType.
3.1 The score
adapter_score(&AdapterFacts) -> i64. The class tier dominates; the tie-breaks are small enough (max +30 against a 1000-wide tier gap) that they can never lift a lesser class over a better one.
| rank | class | tier | why |
| 1 | DiscreteGpu |
5000 | a real card with its own VRAM |
| 2 | IntegratedGpu |
4000 | real hardware; far better than software |
| 3 | VirtualGpu |
3000 | virtio-gpu / venus / passthrough shim |
| 4 | Other |
2000 | an unclassified real driver still beats software |
| 5 | software | 0 | llvmpipe / lavapipe / SwiftShader / WARP — explicit last resort |
| 6 | management display | −1000 | ASPEED / Matrox G200 BMC — below even software |
Tie-breaks inside a tier: NVIDIA +20, AMD +10, Intel +5 (by PCI vendor id or name), and Vulkan/Metal/Dx12 +8 versus Gl +1 — so the same physical device is taken through its native driver rather than the GL wrapper. Ties beyond that keep enumeration order (stable sort), so a two-identical-GPU box is deterministic.
3.2 The two special cases, and why each is matched the way it is
Software rasterisers are matched by name as well as by device type:
hit("llvmpipe") || hit("lavapipe") || hit("swiftshader") || hit("softpipe")
|| hit("software rasterizer") || hit("microsoft basic render") || name.contains("warp")
Type alone is not enough. lavapipe is itself a Vulkan ICD, so restricting the instance to Backends::VULKAN does not exclude software — a common and expensive mistake — and an ICD that reports VirtualGpu (or Other) would sail straight past a device_type == Cpu check. The name test forces it into the software tier regardless.
BMC / management displays are matched by PCI vendor id first:
pub const VENDOR_ASPEED: u32 = 0x1a03; // AST2400/2500/2600 — Supermicro, ASRock Rack
pub const VENDOR_MATROX: u32 = 0x102b; // MGA G200 — Dell iDRAC, HPE iLO
with a name fallback (ASPEED, AST…, MGA G200, G200e, MATROX) for backends that report vendor 0. Vendor-first because the device name varies by driver — ASPEED Graphics Family, AST, MGA G200eW WPCM450 are all the same part. These cores exist to paint a 1024×768 remote IPMI console; they have no usable 3D engine, so selecting one is not "slow", it is "no UI". On a headless-managed server the BMC is often PCI device 0, which is exactly why a naive "first adapter wins" lands there while the real GPU idles. They therefore rank below even software: llvmpipe at least draws.
3.3 Override + observability (L4)
| knob | effect |
FACETT_GPU_ADAPTER=<index> |
that adapter verbatim, even software/BMC — an escape hatch is an escape hatch |
FACETT_GPU_ADAPTER=discrete|integrated|virtual|software |
best adapter of that class; falls back to the policy if none matches |
FACETT_GPU_ADAPTER=<substring> |
case-insensitive match on name or driver (nvidia, 4090, llvmpipe) |
FACETT_GPU_ADAPTER=auto / unset |
the policy |
WGPU_POWER_PREF=low |
request LowPower (wgpu's own standard var, honoured for familiarity) |
AdapterSelection is the decision as data: the chosen device, the index, the reason, a software flag, a forced flag, and every rejected candidate with its score and its software / management_display verdicts. record_selection prints a loud one-line banner to stderr (the demo installs no logger) plus an explicit warning when the chosen device is a CPU rasteriser, and stores the decision in a process-global. facett-demo's state_json publishes it under "gpu", so a robot asserts
"gpu": { "kind": "discrete", "name": "NVIDIA GeForce RTX 4090", "software": false, … }
instead of a human reading a terminal. The key is always present: null means "no GPU lane was brought up" (headless / wasm / CPU-painter), which is a different statement from "software": true.
4. The wgpu edge
facett-core/src/render/gpu/adapter_wgpu.rs (feature wgpu) is the only part that touches a GPU type:
facts_of(&wgpu::AdapterInfo) -> AdapterFactsandfacts_of_all(&[Adapter]);select_adapter(&[wgpu::Adapter])— decide, log, record, return the adapter;request_best_adapter(&Instance, Backends)— the drop-in replacement for the naiverequest_adapter(&Default::default())the workspace's headless probes hand-roll. It enumerates and applies the policy rather than trusting wgpu's internal preference order, and only falls back torequest_adapter(withHighPerformance) if enumeration comes back empty;facett_wgpu_options() -> egui_wgpu::WgpuConfiguration— what an eframe host installs.
Backends are PRIMARY | GL: the real drivers plus GL as a genuine last resort, so a machine with only a GL driver still gets a window. The policy ranks a device's Vulkan entry above its GL entry, so GL only wins when it is all there is.
facett-demo/src/gpu_select.rs keeps its public API (three binaries install it) but is now a thin adapter over the shared policy — the ranking is not twinned (L5).
5. RED-when-broken proofs (12 tests, no GPU required)
| test | goes red when |
ranks_discrete_over_integrated_bmc_and_software |
the headline: given [ASPEED, llvmpipe, iGPU, RTX 4090] the ranker must return the 4090, and the full ranking must be [3, 2, 1, 0] |
bmc_never_wins |
an ASPEED/Matrox entry outranks software or an iGPU; also covers vendor-id-only and name-only detection |
software_never_beats_hardware |
llvmpipe beats any real GPU — including a lavapipe that lies and reports VirtualGpu |
software_alone_is_selected_and_flagged |
software-only enumeration returns nothing, or fails to set software: true |
tie_breaks_stay_inside_their_tier |
a vendor bonus lifts an iGPU over a discrete GPU, or GL beats Vulkan for the same device |
env_override_forces_a_device |
any override form stops working, or a bad override fails to start instead of degrading to the policy |
selection_json_is_robot_readable |
the state_json fragment loses the chosen device, its kind, or the rejected candidates |
total_over_garbage |
empty names / unknown backends panic |
oden_enumeration_resolves_to_the_discrete_gpu |
the same case expressed in wgpu types (catches a broken facts_of) |
device_type_mapping_is_exact |
a wgpu upgrade adds a device type that silently lands in the wrong tier |
wgpu_options_install_the_policy (×2, core + demo) |
someone drops the native_adapter_selector and hands the choice back to wgpu |
6. What is still open
- facett-core's eight are done (2026-08-31). All eight of its hand-rolled probes — five
render::gpu::*test helpers and the three library entry points (l1::render_overlay,gpu::offscreen_render,gpu::render_graph_offscreen) — now go throughrender::gputurn::probe::open, the ONE bring-up writer, which callsrequest_best_adapterand additionally refuses or skips a software adapter rather than measuring on it. That last part is not hygiene: MEASURED on oden the same day, with the 4090 saturated by concurrent lanes a suite silently selectedllvmpipe (LLVM 21.1.8, Vulkan, Cpu), rendered different pixels and reported a verdict about llvmpipe.refuse_software_adapteris the assertion for the sites that get their device from elsewhere (egui_kittest) and can only inspect it afterwards;probe::openapplies it by construction for the sites that ask. - Ten hand-rolls remain, all outside facett-core:
facett-graphview(4, two of them insrc/gpu_layout.rsand two insrc/gpu.rs),facett-map3d-flight(3),facett-map3d/tests/sky_lut_proof.rs,facett-map/tests/gpu_clock.rsandfacett-map/tests/common/mod.rs. Each is one call toprobe::openaway; the pattern is now proven and the diff per site is two lines. - The selection is recorded process-globally. That is right for an app (one device), but a host that deliberately opens two devices would only see the last.