docs: readme, analysis, results, testing and guest setup
This commit is contained in:
382
README.md
Normal file
382
README.md
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
# vfio-native
|
||||||
|
|
||||||
|
Make a KVM guest present a hardware profile that is self-consistent with real silicon and real
|
||||||
|
firmware, and measure the result against an independent library.
|
||||||
|
|
||||||
|
A lot of software behaves differently, or refuses to run at all, once it can tell it is inside a
|
||||||
|
hypervisor. Malware samples self-disable or take a benign branch. Commercial software takes a
|
||||||
|
licensing branch. Drivers and firmware take a different code path. Any of those makes a virtual
|
||||||
|
machine useless for observing what the software would actually do on a physical machine - you end
|
||||||
|
up measuring the sandbox instead of the subject.
|
||||||
|
|
||||||
|
Two independent problems produce that gap, and this addresses both.
|
||||||
|
|
||||||
|
**KVM diverges from the AMD64 architecture** in ways a guest can read directly. The wrong
|
||||||
|
exception vector for an SVM instruction at CPL>0. `#GP` where the architecture specifies `#UD`. A
|
||||||
|
hypercall at CPL>0 that raises no exception at all. Those are spec-conformance defects, and one of
|
||||||
|
them closes a `FIXME` the kernel already carries against itself. The three KVM patches make KVM
|
||||||
|
match the architecture and nothing more, which is why they are written to be acceptable upstream.
|
||||||
|
|
||||||
|
**QEMU's emulated platform is not internally consistent** with any board that was ever
|
||||||
|
manufactured. ACPI link-device names no real firmware uses, PCI vendor and subsystem IDs that
|
||||||
|
belong to the emulator rather than to a device, firmware table fields no shipping board sets,
|
||||||
|
device identity strings that name the emulator. Correcting them makes the emulated platform look
|
||||||
|
like a platform.
|
||||||
|
|
||||||
|
Alongside both there is a VFIO tuning layer - vCPU pinning onto real SMT pairs, sizing the guest to
|
||||||
|
one cache domain, keeping the emulator thread off the vCPU cores, host CPU isolation while the
|
||||||
|
guest runs. It is independent of the fidelity work and measured separately.
|
||||||
|
|
||||||
|
Scored against [VMAware](https://github.com/NotRequiem/VMAware), which runs 85 detection
|
||||||
|
techniques. A stock KVM guest starts at 10/85. This gets it to **1/85** on both the v2.8.1 release
|
||||||
|
and current HEAD, and the one survivor needs a real GPU passed through. VMAware's own verdict:
|
||||||
|
|
||||||
|
```
|
||||||
|
VM brand: Unknown
|
||||||
|
VM likeliness: 20%
|
||||||
|
VM confirmation: false
|
||||||
|
===== CONCLUSION: Running on bare metal =====
|
||||||
|
```
|
||||||
|
|
||||||
|
**The corrections cost no measurable performance.** Measured against the same guest with the
|
||||||
|
fidelity work abandoned entirely and every Hyper-V enlightenment enabled, and again with the
|
||||||
|
patched modules against stock, the difference is smaller than the spread between runs. See
|
||||||
|
[docs/RESULTS.md](docs/RESULTS.md).
|
||||||
|
|
||||||
|
Run this only on hardware you control and with software you are licensed to run.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to run it
|
||||||
|
|
||||||
|
### 1. Install
|
||||||
|
|
||||||
|
Three packages, all built to AUR rules and proven in a clean `extra-x86_64-build` chroot. They
|
||||||
|
fetch this repo at tag `v1.1.0`, so from a checkout the same three `makepkg -si` work:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://git.archworks.co/sandwich/vfio-native
|
||||||
|
cd vfio-native/packaging
|
||||||
|
(cd vfio-native && makepkg -si)
|
||||||
|
(cd vfio-native-qemu && makepkg -si)
|
||||||
|
(cd vfio-native-kvm-dkms && makepkg -si)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Package | What it is |
|
||||||
|
| --- | --- |
|
||||||
|
| `vfio-native` | the three commands, the patches, the ACPI tables, the benchmark, the libvirt hook |
|
||||||
|
| `vfio-native-qemu` | QEMU 11.1.1 with the platform-identity patches, in `/opt/qemu-native` |
|
||||||
|
| `vfio-native-kvm-dkms` | the patched KVM modules, rebuilt by DKMS for every installed 7.2.x kernel |
|
||||||
|
|
||||||
|
The modules land in `updates/dkms/`, which modprobe prefers, but nothing reloads them for you.
|
||||||
|
With every VM shut down:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo modprobe -r kvm_amd kvm && sudo modprobe kvm_amd
|
||||||
|
```
|
||||||
|
|
||||||
|
That gives you three commands. The post-install prints the whole flow, so you do not need to read
|
||||||
|
any further to use it:
|
||||||
|
|
||||||
|
| Command | |
|
||||||
|
| --- | --- |
|
||||||
|
| `vm-native-setup` | configure a libvirt domain |
|
||||||
|
| `vm-native-verify` | measure whether it actually worked |
|
||||||
|
| `vm-native-gpu` | set up GPU passthrough |
|
||||||
|
|
||||||
|
Only `full` needs the QEMU and KVM packages. `native` is domain XML only.
|
||||||
|
|
||||||
|
Not an Arch user? The scripts in `scripts/` are plain shell and work standalone, and the module
|
||||||
|
sources build as `arch/x86/kvm` against any 7.2.x tree; `scripts/install-modules.sh` is the manual
|
||||||
|
path. The packaging is convenience, not a dependency.
|
||||||
|
|
||||||
|
### 2. Fix the guest, once
|
||||||
|
|
||||||
|
[docs/GUEST-SETUP.md](docs/GUEST-SETUP.md) is the full, ordered walkthrough from a plain Windows
|
||||||
|
VM, including the two steps that have to happen before the disk moves to NVMe. The short version:
|
||||||
|
|
||||||
|
A Windows guest that runs its own hypervisor reports that truthfully, and no host-side setting
|
||||||
|
changes it - the guest really is running Hyper-V. Turning it off also makes the VM **faster**,
|
||||||
|
because VBS costs 5-15% on CPU-bound workloads.
|
||||||
|
|
||||||
|
```
|
||||||
|
bcdedit /set hypervisorlaunchtype off
|
||||||
|
Disable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All -NoRestart
|
||||||
|
reg add "HKLM\SYSTEM\CurrentControlSet\Control\DeviceGuard" /v EnableVirtualizationBasedSecurity /t REG_DWORD /d 0 /f
|
||||||
|
```
|
||||||
|
|
||||||
|
Reboot twice. Uninstall the QEMU and SPICE guest agents. Move the disk to emulated NVMe
|
||||||
|
(`bus='nvme'` with a `<serial>`) - the patched QEMU will not boot a virtio disk.
|
||||||
|
|
||||||
|
### 3. Fix the host, once
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo cpupower frequency-set -g performance
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Configure the domain
|
||||||
|
|
||||||
|
```sh
|
||||||
|
virsh -c qemu:///system shutdown win11
|
||||||
|
vm-native-setup
|
||||||
|
```
|
||||||
|
|
||||||
|
It is an interview with a default for every answer: level, cores, RAM, identity, USB, disk, Secure Boot. It reads your CPU layout itself - AMD CCDs or
|
||||||
|
Intel P/E cores - sizes the guest to one cache domain, pins each vCPU onto a real SMT pair, keeps
|
||||||
|
the emulator off the vCPU cores, moves the disks to emulated NVMe, replaces the virtio device set,
|
||||||
|
wires Secure Boot with a key store it generates, writes the SMBIOS and ACPI identity, and gives
|
||||||
|
the guest a CPU identity from the host's own generation whose thread count matches what it
|
||||||
|
actually has. `-r` gives the deployment its own serials and MAC; `-u auto` passes keyboard and
|
||||||
|
mouse through.
|
||||||
|
|
||||||
|
Every flag answers one question in advance and `-y` takes every default. Scripted:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vm-native-setup -d win11 -p full -c 8 -m 16 -r -u auto -y
|
||||||
|
```
|
||||||
|
|
||||||
|
| Flag | |
|
||||||
|
| --- | --- |
|
||||||
|
| `-d` | domain |
|
||||||
|
| `-p` | `tuned`, `native` or `full` (default `full`) |
|
||||||
|
| `-c` | guest cores; SMT doubles this into vCPUs |
|
||||||
|
| `-m` | guest RAM in GiB |
|
||||||
|
| `-s` | Secure Boot with enrolled keys, `on` (default) or `off` |
|
||||||
|
| `-u` | USB passthrough: `none`, `auto`, or `vid:pid,...,0000:bb:dd.f` |
|
||||||
|
| `-r` | randomise the hardware identity: serials, MAC, memory module |
|
||||||
|
| `-y` | no prompts |
|
||||||
|
|
||||||
|
It backs the domain up first and prints the revert command. Re-running it is safe.
|
||||||
|
|
||||||
|
### 5. Check it worked
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vm-native-verify
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
OK QPC cost (ns) 14.6
|
||||||
|
OK rdtsc cost (ns) 6.6
|
||||||
|
OK 1-thread (Mops) 5254.2
|
||||||
|
OK L3 latency (ns) 10.94
|
||||||
|
OK jitter p99.99 (us) 1.600
|
||||||
|
OK stalls >100us 0
|
||||||
|
```
|
||||||
|
|
||||||
|
`vm-native-verify` also prints whether CPUID passthrough is on, which is the last step.
|
||||||
|
|
||||||
|
### 6. Switch on CPUID passthrough, after every guest boot
|
||||||
|
|
||||||
|
The `TIMER` check times the world switch on an intercepted `CPUID`, and the only way to stop
|
||||||
|
paying it is to not exit. `vm-native-setup` prints the two lines for your declared SKU; on this
|
||||||
|
host they are:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
echo 'AMD Ryzen 7 7700X 8-Core Processor' | sudo tee /sys/module/kvm_amd/parameters/brand_string
|
||||||
|
echo Y | sudo tee /sys/module/kvm_amd/parameters/cpuid_passthrough
|
||||||
|
```
|
||||||
|
|
||||||
|
Run them once the guest is up, never before: a booting Windows enumerates CPUID bits KVM
|
||||||
|
synthesises, and hangs if they vanish half way through. Switch it off again (`echo N`) before the
|
||||||
|
next boot. The module applies it only to vCPU threads pinned to exactly one host CPU, so on an
|
||||||
|
unpinned guest it does nothing rather than something wrong.
|
||||||
|
|
||||||
|
It is a module parameter, so it is one switch for the whole host: every pinned guest gets it, and
|
||||||
|
a cold boot of any of them while it is on hits the race. With more than one such guest, switch it
|
||||||
|
off before any of them boots and on again once they are all up.
|
||||||
|
|
||||||
|
For the detection score, run VMAware in the guest **from the console session, not over SSH** -
|
||||||
|
OpenSSH lands you in session 0, which is not where an interactive desktop session runs. See
|
||||||
|
[docs/TESTING.md](docs/TESTING.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The three levels
|
||||||
|
|
||||||
|
All get identical performance tuning. The level only changes how much of the platform is
|
||||||
|
corrected.
|
||||||
|
|
||||||
|
| Level | Score | Needs | Upkeep |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `tuned` | 13/85 | nothing | none |
|
||||||
|
| `native` | 7/85 | nothing | none |
|
||||||
|
| `full` | **1/85** | `vfio-native-qemu` + `vfio-native-kvm-dkms`, passthrough switched on after boot | DKMS rebuilds on kernel updates inside 7.2.x |
|
||||||
|
|
||||||
|
`tuned` buys the pinning and the cache-domain sizing and nothing else.
|
||||||
|
|
||||||
|
`native` is domain XML only, so it survives any host update untouched. Take it if you would rather
|
||||||
|
not maintain anything.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The one thing to get right
|
||||||
|
|
||||||
|
**Leave the host at least 4 physical cores.**
|
||||||
|
|
||||||
|
Windows calibrates the TSC at boot, and that calibration is a race. If the host cannot schedule
|
||||||
|
the guest's vCPU threads cleanly through it, Windows abandons the TSC and
|
||||||
|
`QueryPerformanceCounter` costs about **1300 ns instead of 15** for the rest of that boot.
|
||||||
|
Software that polls the clock in a tight loop calls QPC thousands of times a second.
|
||||||
|
|
||||||
|
Measured over four cold boots at each size, same XML, on a 16-core host:
|
||||||
|
|
||||||
|
| Guest | Host keeps | Boots with a fast clock |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 24 vCPU | 4 cores | **4 / 4** |
|
||||||
|
| 28 vCPU | 2 cores | 3 / 4 |
|
||||||
|
| 32 vCPU | 0 cores | 2 / 4 |
|
||||||
|
|
||||||
|
It is headroom, not a vCPU ceiling. 24 of 32 threads is reliable, which matters when the guest
|
||||||
|
does CPU-heavy work rather than latency-sensitive work alone. `vm-native-setup` warns if you go
|
||||||
|
below the margin.
|
||||||
|
|
||||||
|
Because it is a race, **one measurement proves nothing**. If `vm-native-verify` reports QPC over
|
||||||
|
1000 ns, reboot and measure again before changing anything.
|
||||||
|
|
||||||
|
Sizing the guest inside one cache domain is a separate trade: it takes L3 latency from about
|
||||||
|
17 ns to 10, which is worth it for latency-sensitive work and not for throughput.
|
||||||
|
|
||||||
|
None of these change the outcome, measured: `useplatformclock false`, `useplatformtick no`,
|
||||||
|
`disabledynamictick yes`, adding `invtsc`, pinning without leaving headroom, or which cache
|
||||||
|
domain the vCPUs sit on.
|
||||||
|
|
||||||
|
> **Do not put every vCPU on a realtime scheduler.** `<vcpusched scheduler='fifo'>` across a
|
||||||
|
> guest sized to the whole machine booted once, failed twice, ran away to 800% CPU and left
|
||||||
|
> `systemctl` unresponsive until QEMU was killed by hand. Realtime priority on as many threads as
|
||||||
|
> the host has cores starves everything else, including the emulator thread the guest needs.
|
||||||
|
|
||||||
|
## The patches, by detection
|
||||||
|
|
||||||
|
Every patch is grouped by the check it clears, so any of them can be taken or left.
|
||||||
|
[patches/README.md](patches/README.md) is the map.
|
||||||
|
|
||||||
|
**KVM** - five patches for upstream and one that is not, four detections. They correct KVM
|
||||||
|
against the architecture rather than adding a layer on top of it, pass `checkpatch.pl --strict`,
|
||||||
|
and ship with a cover letter and a selftest in `patches/kvm/`.
|
||||||
|
|
||||||
|
| Patch | Clears |
|
||||||
|
| --- | --- |
|
||||||
|
| `0001-KVM-SVM-intercept-GP-when-guest-EFER.SVME-is-clear` | `SVM_EXCEPTIONS` |
|
||||||
|
| `0002-KVM-x86-emulator-UD-not-GP-for-VMCALL-at-CPL-0` | `KVM_INTERCEPTION` |
|
||||||
|
| `0003-KVM-x86-UD-for-KVM-hypercalls-issued-at-CPL-0` | `KVM_INTERCEPTION` |
|
||||||
|
| `0004-KVM-SVM-intercept-ICEBP-and-skip-it-before-injecting-its-DB` | `DBVM` |
|
||||||
|
| `0005-KVM-selftests-verify-ICEBP-DB-reports-RIP-past-the-ICEBP` | the test for 0004 |
|
||||||
|
| `EXPERIMENTAL-0006-runtime-cpuid-passthrough` | `TIMER` |
|
||||||
|
|
||||||
|
0002 and 0003 are a pair - the check tries two stubs and reports whichever misbehaves first, so
|
||||||
|
either alone leaves it standing. `EXPERIMENTAL-0006` is built into the DKMS package but off by
|
||||||
|
default; it is not upstream material, because it hands the guest raw host CPUID.
|
||||||
|
|
||||||
|
**QEMU** - eight patchsets against v11.1.1: `01-firmware`, `02-disk-identity`, `03-pci-ids`,
|
||||||
|
`04-fw-cfg`, `05-usb-hid`, `06-audio`, and `07-edid` and `08-cpu-misc`, which clear no check by
|
||||||
|
themselves and are carried for other detectors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GPU passthrough
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vm-native-gpu
|
||||||
|
```
|
||||||
|
|
||||||
|
Lists your GPUs with IOMMU groups, says which drives a display, and picks the path.
|
||||||
|
|
||||||
|
**One GPU** - the normal case. The host gives the card up while the guest runs and takes it back
|
||||||
|
after:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo vm-native-gpu --single win11 0000:03:00.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Installs a libvirt hook scoped to that domain only. The host has no display while the guest runs,
|
||||||
|
so get SSH working first.
|
||||||
|
|
||||||
|
**Two GPUs, one spare** - easier and safer. Binds it to `vfio-pci` at boot so the host never claims
|
||||||
|
it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo vm-native-gpu --dual 0000:0f:00.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Undo either with `sudo vm-native-gpu --revert`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## When the guest is off, the host is whole
|
||||||
|
|
||||||
|
No configuration here reserves host resources while the guest is not running. Static hugepages are
|
||||||
|
deliberately not used, because they would take memory permanently.
|
||||||
|
|
||||||
|
The CPU-isolation hook confines the host to the cores the guest is not using, and restores on
|
||||||
|
every exit path - clean shutdown, hard destroy and crash, all verified. The confinement is
|
||||||
|
`--runtime` only, so a reboot clears it regardless. It lives in `/etc/libvirt/hooks/qemu.d/`,
|
||||||
|
which libvirt runs after any `qemu` hook you already have, so it does not replace one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Maintenance
|
||||||
|
|
||||||
|
| Event | `tuned` / `native` | `full` |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Kernel update inside 7.2.x | nothing | DKMS rebuilds the modules; reload or reboot |
|
||||||
|
| Kernel update past 7.2 | nothing | **the modules stop building** - bump `_kver` in the DKMS package |
|
||||||
|
| QEMU package update | nothing | nothing, `/opt/qemu-native` is its own package |
|
||||||
|
| Newer QEMU base | nothing | re-apply `patches/qemu/` |
|
||||||
|
|
||||||
|
The DKMS tree carries 7.2.3's KVM sources. They build against any 7.2.x headers, and DKMS refuses
|
||||||
|
anything else, so after a kernel upgrade past 7.2 the stock modules load silently, the guest boots
|
||||||
|
fine and scores worse, with nothing to tell you but a pacman hook message:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cat /sys/module/kvm/srcversion /sys/module/kvm_amd/srcversion
|
||||||
|
modinfo -F srcversion kvm kvm_amd
|
||||||
|
```
|
||||||
|
|
||||||
|
Differ? Check `dkms status`. Check **both** modules - the SVM fixes land in `kvm-amd.ko` and the
|
||||||
|
hypercall fixes in `kvm.ko`, so verifying one reports success on a stale build of the other.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The last two checks
|
||||||
|
|
||||||
|
**`GPU_CAPABILITIES`.** Wants a display reporting a gamma ramp, which means a real GPU passed
|
||||||
|
through. Not a GPU enumeration despite the name - it is one `GetDeviceCaps` call. Out of scope
|
||||||
|
for an emulated display; passing a real GPU through with `vm-native-gpu` clears it.
|
||||||
|
|
||||||
|
**`TIMER` - cleared, but with a workaround.** Two detectors that OR together. The exception-latency
|
||||||
|
one was first measured as a hardware floor at ratio 6.2; that was a broken probe - rebuilt to
|
||||||
|
VMAware's own mechanism it reads 1.4 against a threshold of 2.5 on stock KVM, so it was never real.
|
||||||
|
The instruction-latency one is the AMD world switch on an intercepted `CPUID`. `EXPERIMENTAL-0006`
|
||||||
|
clears it, but as a workaround rather than a spec fix: it stops intercepting `CPUID` on a 1:1-pinned
|
||||||
|
vCPU and reprograms the brand-string MSRs per core so raw `CPUID` still names the declared SKU, with
|
||||||
|
RDPRU passed through because raw CPUID advertises it. Opt-in, runtime-only, and constrained to
|
||||||
|
pinned vCPUs for that reason. Window 2080 -> 199 ticks, ratio 7.9 -> 0.76. `docs/ANALYSIS.md` has
|
||||||
|
the whole trail, including the two wrong turns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
patches/kvm/ five upstream-style kernel patches, a cover letter, plus one experimental
|
||||||
|
patches/qemu/ eight patchsets against QEMU 11.1.1
|
||||||
|
packaging/ three AUR-shaped packages: tools, patched QEMU, DKMS modules
|
||||||
|
scripts/ setup, verify, GPU, libvirt hook, module install
|
||||||
|
scripts/generate-tables.py raw SMBIOS type 7/26/27/28/29 blobs for -smbios file=
|
||||||
|
bench/ the benchmark and the TIMER probe, C, mingw-w64
|
||||||
|
acpi/ SSDT sources for the battery, platform devices and sensor probes
|
||||||
|
docs/ANALYSIS.md why each check fires, with source references
|
||||||
|
docs/RESULTS.md every measurement
|
||||||
|
docs/TESTING.md how to measure it yourself
|
||||||
|
```
|
||||||
|
|
||||||
|
Longer write-up: [archworks.co/docs/vfio-native](https://archworks.co/docs/vfio-native/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
Written against [VMAware](https://github.com/NotRequiem/VMAware) by NotRequiem, which is the
|
||||||
|
honest way to score this - it is the independent library, not a checklist that grades itself.
|
||||||
|
|
||||||
|
GPL-2.0, matching the kernel and QEMU patches.
|
||||||
371
docs/ANALYSIS.md
Normal file
371
docs/ANALYSIS.md
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
# Why each VMAware check fires, and what it took to clear it
|
||||||
|
|
||||||
|
The engineering record behind the fidelity work. `README.md` says *what to apply and when*; this
|
||||||
|
says *why*, with the source references. Written 2026-09-03 at 2/85, revised 2026-09-05 at **1/85**
|
||||||
|
on both VMAware v2.8.1 and VMAware HEAD (commit 95fecc2) - the one survivor is `GPU_CAPABILITIES`,
|
||||||
|
and VMAware's own verdict is "Running on bare metal", likeliness 20%, confirmation false.
|
||||||
|
|
||||||
|
Source read against: VMAware `src/vmaware.hpp`, QEMU 11.1.1, kernel 7.2.3 `arch/x86/kvm`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLEARED - CPUID_SIGNATURE
|
||||||
|
|
||||||
|
**Not about hypervisor leaves.** The guest's `0x40000000` reads all zeros, and so does the
|
||||||
|
bare-metal host, so it was never the trigger.
|
||||||
|
|
||||||
|
The AMD branch (`cpuid_signature()`, vmaware.hpp:6941-6970) reads **leaf 7 subleaf 0, EDX** and
|
||||||
|
returns true if bit 26 (`IBRS`/`IBPB`), 27 (`STIBP`) or 31 (`SSBD`) is set. AMD reserves those
|
||||||
|
three to zero and enumerates its mitigations in `0x80000008.EBX` instead.
|
||||||
|
|
||||||
|
Measured with an unprivileged CPUID probe:
|
||||||
|
|
||||||
|
| | leaf 7.0 EDX | bits 26/27/31 | `0x80000008.EBX` 12/14/15/24/25 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| host, bare metal | `0x10000010` | `0 0 0` | `1 1 1 1 1` |
|
||||||
|
| guest, before | `0x9c000010` | set | passed through |
|
||||||
|
| guest, after | `0x30000010` | `0 0 0` | `1 1 1 1 1` |
|
||||||
|
|
||||||
|
KVM synthesises the Intel-style `SPEC_CTRL` interface on AMD, and `host-passthrough` enables it.
|
||||||
|
Three `<feature policy='disable'>` lines fix it. All three are needed - the check ORs the bits.
|
||||||
|
|
||||||
|
Verified in-guest afterwards that every AMD-native mitigation bit survives, so the guest keeps
|
||||||
|
the interface Windows actually uses on AMD. Residual: KVM also synthesises bit 29
|
||||||
|
(`arch-capabilities`), which bare metal lacks. Nothing reads it, so it is left alone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLEARED - SVM_EXCEPTIONS
|
||||||
|
|
||||||
|
**A bug KVM documents against itself.**
|
||||||
|
|
||||||
|
`svm.c:272` is unconditional:
|
||||||
|
|
||||||
|
```c
|
||||||
|
svm->vmcb->save.efer = efer | EFER_SVME;
|
||||||
|
```
|
||||||
|
|
||||||
|
Hardware therefore never sees the guest's real `SVME=0`. A CPL3 `VMLOAD` passes the SVME check,
|
||||||
|
hardware notices CPL is not zero, and injects `#GP` *before* the VMLOAD intercept can fire. Bare
|
||||||
|
metal raises `#UD`, and `#UD` is the one exception `svm_exceptions()` treats as innocent
|
||||||
|
(vmaware.hpp:14551).
|
||||||
|
|
||||||
|
There is a `FIXME` at `svm.c:1076-1080` describing exactly this.
|
||||||
|
|
||||||
|
The diagnostic that pinned it: the scored run printed `[ DETECTED ]` with **no debug line**.
|
||||||
|
Both noisy exits in the function call `vma_debug`, so it must have fallen through to the bare
|
||||||
|
`return true` at vmaware.hpp:14570 - which forces `svm_visible == true` and a non-`#UD` fault.
|
||||||
|
|
||||||
|
Fix, in `svm_recalc_instruction_intercepts()`:
|
||||||
|
|
||||||
|
```c
|
||||||
|
if (!(vcpu->arch.efer & EFER_SVME)) {
|
||||||
|
svm_set_intercept(svm, INTERCEPT_VMLOAD);
|
||||||
|
svm_set_intercept(svm, INTERCEPT_VMSAVE);
|
||||||
|
svm_set_intercept(svm, INTERCEPT_CLGI);
|
||||||
|
svm_set_intercept(svm, INTERCEPT_STGI);
|
||||||
|
set_exception_intercept(svm, GP_VECTOR); /* added */
|
||||||
|
} else {
|
||||||
|
```
|
||||||
|
|
||||||
|
`gp_interception()` then decodes the SVM opcode, sees `!is_guest_mode`, and calls
|
||||||
|
`svm_invoke_exit_handler(SVM_EXIT_VMLOAD)` -> `vmload_interception` ->
|
||||||
|
`nested_svm_check_permissions` (nested.c:1664), which queues `UD_VECTOR`.
|
||||||
|
|
||||||
|
The shipped `0001` also keeps the intercept across a guest clearing `SVME` later (the stock
|
||||||
|
`svm_set_efer()` dropped it there), drops it again on `SVME=1` where the erratum workaround does
|
||||||
|
not need it, and never arms it for SEV guests, whose instructions KVM cannot decode - the same
|
||||||
|
exclusion the erratum path already makes.
|
||||||
|
|
||||||
|
**Do NOT disable `svm` in guest CPUID as an alternative.** It does not change the exception, and
|
||||||
|
it moves the check into its `!svm_visible` branch, which is *worse* - "SVM hypervisor hiding CPU
|
||||||
|
capabilities", weight 150.
|
||||||
|
|
||||||
|
**Do NOT use `kvm.enable_vmware_backdoor=1`** as the no-rebuild shortcut. It arms the same
|
||||||
|
intercept, but permanently opens two well-known VMware detection surfaces in every guest on
|
||||||
|
the host: `emulate.c:2562` stops faulting CPL3 `IN`/`OUT` on ports `0x5658`/`0x5659`, and
|
||||||
|
`emulate.c:3905` makes CPL3 `RDPMC` of pseudo-counters `0x10000`-`0x10002` return host TSC,
|
||||||
|
which is architecturally impossible on real silicon. VMAware is blind to both. pafish,
|
||||||
|
al-khaser and other detection tooling are not.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLEARED - FIRMWARE
|
||||||
|
|
||||||
|
**A chain that reports only its first hit.** `firmware()` (vmaware.hpp:8860) runs six sections
|
||||||
|
in fixed order against each firmware buffer, DSDT first, and the first match returns. Fixing one
|
||||||
|
link costs a full QEMU rebuild and boot and only reveals the next - so bundle.
|
||||||
|
|
||||||
|
Links, in order:
|
||||||
|
|
||||||
|
1. ~~SMI Resources reservation string~~ - `_UID` renamed
|
||||||
|
2. ~~PRTP/PRTA routing symmetry~~ - renamed `IRQP`/`IRQA`
|
||||||
|
3. ~~Sequential PIRQ names, vmaware.hpp:9142~~ - `GSIA-H` -> `APCA-H`
|
||||||
|
4. ~~PNP0A06 resource stubs, vmaware.hpp:9148~~ - `GPER` -> `RSRA`, `PHPR` -> `RSRB`
|
||||||
|
5. ~~FACP C2/C3 latencies, vmaware.hpp:9242~~ - `0xfff` -> `0xffe`
|
||||||
|
6. Debug Port OperationRegion at `0x0402` - already dead on this tree
|
||||||
|
7. HPET register-validation loop - dormant, domain sets `hpet present='no'`. Re-arms if enabled.
|
||||||
|
8. Dummy SATA, DMAR, APIC source overrides - already dead on this tree
|
||||||
|
|
||||||
|
Link 3 requires **all four** of `LNKE`, `LNKH`, `GSIE`, `GSIH`, which is QEMU's eight-link plus
|
||||||
|
eight-GSI layout. Real boards have four PCI link devices and no GSI-named ones, so rename the
|
||||||
|
`GSI*` half and leave `LNK*` alone. Those names are declared by `build_gsi_link_dev()` and
|
||||||
|
referenced only through `build_q35_routing_table("GSI")`, which derives every name from one
|
||||||
|
3-character prefix - so a prefix change moves every reference with it.
|
||||||
|
|
||||||
|
Do not pick `IRQ` as the replacement: `_SB.PCI0.IRQA` is already the APIC routing package and a
|
||||||
|
bare NameSeg would bind to it.
|
||||||
|
|
||||||
|
`0xffe` keeps "C-state not supported" semantics (anything above 100 / 1000 means unsupported)
|
||||||
|
while not matching the check's exact-equality test against `0x0FFF`.
|
||||||
|
|
||||||
|
**WAET must stay out.** `"WAET"` is target index 18 in the section-2 scan and the table's
|
||||||
|
signature sits at offset 0, i.e. an instant hit. An earlier sed-based patcher claimed it restored
|
||||||
|
WAET and never did: `build_waet()` has zero call sites, so its `grep -q build_waet` guard was
|
||||||
|
satisfied by the definition alone and the insert never ran. Guard on a call site, not on a
|
||||||
|
definition.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OPEN - GPU_CAPABILITIES (weight 20)
|
||||||
|
|
||||||
|
**Not a GPU check.** Four lines: `GetDC(nullptr)`, then `GetDeviceCaps(hdc, COLORMGMTCAPS)`,
|
||||||
|
DETECTED if the result lacks `CM_GAMMA_RAMP` (0x2) or is `CM_NONE`. No adapter enumeration, no
|
||||||
|
DXGI, no WMI, no device blacklist, no EDID - so the patched `SAM`/`SyncMaster` EDID override is
|
||||||
|
irrelevant to this check.
|
||||||
|
|
||||||
|
A plausible theory said it was a harness artefact: scoring runs over SSH, OpenSSH on Windows
|
||||||
|
lands in session 0, and a non-interactive window station has no gamma LUT. **Tested and
|
||||||
|
disproven.** Re-ran as a scheduled task with `/it`, confirmed `SessionId=1` and
|
||||||
|
`ScreenBounds={0,0,640,480}` - same score, still fires.
|
||||||
|
|
||||||
|
So it is genuine: `<video><model type='none'/>` leaves Windows on a stub display with no gamma
|
||||||
|
ramp. Real GPU passthrough fixes it. Out of scope without a spare card to pass through;
|
||||||
|
`vm-native-gpu` is the path.
|
||||||
|
|
||||||
|
An emulated adapter is not a workaround: `qxl` and `virtio-gpu` are on the DEVICES blacklist
|
||||||
|
(weight 100), and any display device can arm `BOOT_LOGO` (weight 90, brands QEMU) by giving
|
||||||
|
OVMF a GOP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLEARED - KVM_INTERCEPTION
|
||||||
|
|
||||||
|
**The debug string is a red herring**, and it cost this project two wrong turns. It says "KVM
|
||||||
|
attempting to patch instructions on the fly", which points straight at
|
||||||
|
`KVM_X86_QUIRK_FIX_HYPERCALL_INSN`. That is not the cause.
|
||||||
|
|
||||||
|
VMAware runs its stubs at CPL3. KVM's emulator declares VMCALL in `group7_rm0[1]`
|
||||||
|
(`emulate.c:3971`) as:
|
||||||
|
|
||||||
|
```c
|
||||||
|
I(SrcNone | Priv | EmulateOnUD, em_hypercall),
|
||||||
|
```
|
||||||
|
|
||||||
|
`Priv` but **no** `PrivUD`. So `emulate.c:5348`:
|
||||||
|
|
||||||
|
```c
|
||||||
|
if ((ctxt->d & Priv) && ops->cpl(ctxt)) {
|
||||||
|
if (ctxt->d & PrivUD)
|
||||||
|
rc = emulate_ud(ctxt);
|
||||||
|
else
|
||||||
|
rc = emulate_gp(ctxt, 0);
|
||||||
|
goto done;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
takes the `#GP(0)` branch, which Windows surfaces as STATUS_ACCESS_VIOLATION. The quirk code in
|
||||||
|
`emulator_fix_hypercall()` sits downstream of that CPL check and is never reached. Disabling the
|
||||||
|
quirk is a measured no-op - confirmed on three purpose-built `/dev/kvm` harnesses showing
|
||||||
|
quirk-on and quirk-off producing byte-identical `#GP` with unmodified code bytes.
|
||||||
|
|
||||||
|
The earlier "cleared INTERCEPT_VMMCALL, effect: none" log entry was therefore correct, and my
|
||||||
|
own mid-session reinterpretation of it as "closed for a bad reason" was wrong.
|
||||||
|
|
||||||
|
Both stubs need to raise `#UD`, and **either half alone leaves the detection standing** - fix
|
||||||
|
stub 0 only and the loop reaches stub 1, whose silent `-KVM_EPERM` trips the generic-hypervisor
|
||||||
|
branch instead. That is why every previous single-sided attempt measured as "no effect".
|
||||||
|
|
||||||
|
**Half A.** `PrivUD` (`emulate.c:171`, `((u64)1 << 51)`, "#UD instead of #GP on CPL > 0") added
|
||||||
|
to the VMCALL entry in `group7_rm0`. The flag had zero users in the tree before this. `Priv`
|
||||||
|
already routes to the CPL check; `PrivUD` picks the other branch. RIP does not advance.
|
||||||
|
|
||||||
|
**Half B.** `kvm_emulate_hypercall()` injects `#UD` for a CPL>0 hypercall instead of the silent
|
||||||
|
`-KVM_EPERM`. Placed **after** the Xen and Hyper-V dispatch, so enlightened guests never reach
|
||||||
|
it - and `kvm_hv_hypercall` already does the identical `cpl != 0 -> kvm_queue_exception(UD_VECTOR)`
|
||||||
|
at `hyperv.c:2546`, so this makes the KVM-PV path consistent with the Hyper-V one rather than
|
||||||
|
inventing new behaviour.
|
||||||
|
|
||||||
|
The blunt alternative - clearing `INTERCEPT_VMMCALL` - was correctly rejected: it breaks CPL0
|
||||||
|
hypercalls and bugchecks the Hyper-V domains.
|
||||||
|
|
||||||
|
Blast radius checked on the live host: `svm_patch_hypercall` writes VMMCALL and issues it at
|
||||||
|
CPL0; Linux paravirt alternative-patches to VMMCALL, also CPL0; every `KVM_HC_*` already failed
|
||||||
|
at CPL>0, so only the *shape* of the failure changes. Confirmed empirically by booting a
|
||||||
|
Hyper-V-enlightened Windows guest and a Linux paravirt guest on the patched module - both boot
|
||||||
|
and execute normally.
|
||||||
|
|
||||||
|
Half A ships as `patches/kvm/0002-KVM-x86-emulator-UD-not-GP-for-VMCALL-at-CPL-0.patch`, half B as
|
||||||
|
`patches/kvm/0003-KVM-x86-UD-for-hypercalls-issued-at-CPL-0.patch`. Both land in `kvm.ko`, not
|
||||||
|
`kvm-amd.ko`, so verify `/sys/module/kvm/srcversion` - checking only `kvm_amd` reports success on
|
||||||
|
a stale build.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLEARED - TIMER
|
||||||
|
|
||||||
|
**There is no clock to lie to.** The check does not use the TSC. It times a bare `CPUID` (EAX=0)
|
||||||
|
against eight `_mm_lfence()`, using a second thread on another core spinning `counter++` on a
|
||||||
|
64-byte-aligned volatile; the "tick" is a cross-core cache-line bounce (the code says so at
|
||||||
|
vmaware.hpp:7480, "this is a cache-based counter").
|
||||||
|
|
||||||
|
`serialize_available = cpu::is_intel()` is hard-false on AMD, so the LFENCE branch always runs.
|
||||||
|
Threads are pinned *inside* the guest with `SetThreadGroupAffinity`. The estimator takes a
|
||||||
|
500-1000 sample batch per trial, an interquartile mean of the middle 50%, then the **minimum
|
||||||
|
across 5 trials**, with samples bracketed by counter-edge spin-waits and followed by
|
||||||
|
`burn_random_cycles()` so a hypervisor cannot predict when to freeze the counter thread.
|
||||||
|
|
||||||
|
**There are TWO detectors, and they OR together** (vmaware.hpp:7484, 7488, 7493). Clearing one
|
||||||
|
achieves nothing.
|
||||||
|
|
||||||
|
### Detector 2 was never the wall - the probe was wrong
|
||||||
|
|
||||||
|
The first version of `bench/timerprobe.c` reported the exception-latency detector at **6.169**
|
||||||
|
against a threshold of 2.5, and two days of work treated that as a hypervisor floor. It was a
|
||||||
|
measurement error. VMAware's software side is `RtlCaptureContext` + `ZwRaiseException` inside an
|
||||||
|
SEH frame with `EXCEPTION_EXECUTE_HANDLER`, which is a syscall in, a kernel dispatch, and an
|
||||||
|
`RtlUnwindEx` out. The probe used `RaiseException` under a vectored handler, which never enters the
|
||||||
|
kernel on the software side, so its reference window was several times too short and the ratio
|
||||||
|
several times too high.
|
||||||
|
|
||||||
|
Rebuilt mechanism for mechanism (`__C_specific_handler` scope table, `ZwRaiseException`,
|
||||||
|
`EXCEPTION_EXECUTE_HANDLER`), the probe reads **1.47**, and VMAware's own debug line agrees on
|
||||||
|
the same boot with the stock `#DB` intercept in place:
|
||||||
|
|
||||||
|
TIMER: Exception > VMM -> 10167 | nVMM -> 7066 | Ratio -> 1.439
|
||||||
|
|
||||||
|
Detector 2 passes on stock KVM. No bare-metal Windows baseline was taken - the brief asked for
|
||||||
|
one, no physical Windows machine was available - so the only claim made here is the measured one:
|
||||||
|
the guest is under the threshold with `#DB` intercepted, by a margin of 1.1.
|
||||||
|
|
||||||
|
### Detector 1 is a world switch, and the only way to not pay it is to not exit
|
||||||
|
|
||||||
|
Measured: `cpuid 2054-2383 / lfence 262-310 ticks`, ratio 7.6-8.2, threshold 2.5. The exit itself
|
||||||
|
is ~1800 ticks, on the order of 400 ns, an ordinary Zen 4 world switch. Cross-CCD counter
|
||||||
|
placement, the exit fastpath, TSC offsetting and CPUID-leaf overrides were all measured and all
|
||||||
|
failed (table below).
|
||||||
|
|
||||||
|
`EXPERIMENTAL-0006-runtime-cpuid-passthrough` clears `INTERCEPT_CPUID` on every guest entry once
|
||||||
|
`cpuid_passthrough=1`, so `CPUID` runs on the silicon. Three things follow from raw CPUID, and the
|
||||||
|
patch handles each:
|
||||||
|
|
||||||
|
- **The brand string reverts to the host SKU.** The AMD Processor Name String MSRs
|
||||||
|
(`0xC0010030-35`) back `CPUID 0x80000002-4` directly and are writable and per-thread on Zen 4,
|
||||||
|
so the declared SKU is written on the pinned core at entry and restored in `svm_vcpu_put()`.
|
||||||
|
`THREAD_MISMATCH` stays clear; VMAware's own thread database resolves `7700X` to 16 threads.
|
||||||
|
- **Raw CPUID advertises RDPRU** (`0x80000008 EBX[4]`), which KVM masks out of its own CPUID and
|
||||||
|
intercepts with `kvm_handle_invalid_op()`, i.e. `#UD`. VMAware's `INTERRUPT_SHADOW` and
|
||||||
|
`SINGLE_STEP` both have an RDPRU variant gated on exactly that bit, and both report the `#UD` as
|
||||||
|
an "exception anomaly, hypervisor seems to be present with CPUID interception disabled". Two
|
||||||
|
full scans fired both checks before this was understood, and an exact-stub reproducer with a
|
||||||
|
`kvm_exit` trace showed the TF `#DB` after a native CPUID landing at the right RIP every time -
|
||||||
|
the single-step path was never at fault. The RDPRU intercept is cleared together with the CPUID
|
||||||
|
one; the guest then reads the pinned core's real `MPERF`/`APERF`.
|
||||||
|
- **Raw CPUID must only be enabled after boot.** Windows enumerates KVM's synthetic leaf-1 bits
|
||||||
|
(`x2apic`, `tsc-deadline`) during boot and hangs if they vanish mid-enumeration. The module
|
||||||
|
parameter is runtime-toggled for this reason.
|
||||||
|
|
||||||
|
The intercept is also withheld from any vCPU thread whose allowed-CPU mask is wider than one CPU
|
||||||
|
(`cpumask_weight(current->cpus_ptr) == 1`), so the brand override cannot leak onto a host core: the
|
||||||
|
patch verifies the 1:1 pinning itself rather than trusting the operator.
|
||||||
|
|
||||||
|
Measured with the passthrough on, VMAware HEAD debug, console session:
|
||||||
|
|
||||||
|
TIMER: Instruction > VMM -> 219 | nVMM -> 290 | Ratio -> 0.755
|
||||||
|
TIMER: Exception > VMM -> 10147 | nVMM -> 7551 | Ratio -> 1.344
|
||||||
|
VM detections: 1/85 ===== CONCLUSION: Running on bare metal =====
|
||||||
|
|
||||||
|
The `#DB` intercept is left in place. Clearing it too was measured (detector 2 drops to 0.96) and
|
||||||
|
gains nothing the score needs, while it removes the single-step re-injection KVM relies on for
|
||||||
|
its own NMI-window logic. An earlier note here blamed that clear for the `INTERRUPT_SHADOW` and
|
||||||
|
`SINGLE_STEP` detections; that was wrong, RDPRU was the cause both times.
|
||||||
|
|
||||||
|
Measured and dead before the passthrough:
|
||||||
|
|
||||||
|
| Attempt | Result |
|
||||||
|
| --- | --- |
|
||||||
|
| BetterTiming TSC compensation | no detection change, 6x slower boot (6.1 s -> 38.6 s CPU) |
|
||||||
|
| Hypervisor-Phantom CPUID override | ratio halved to ~810 ticks, **still detected**, and added `SINGLE_STEP` (10 -> 11) |
|
||||||
|
| Clear `INTERCEPT_CPUID` from boot | guest never boots |
|
||||||
|
| All four intercepts cleared | triple fault, `EFER=0`, instant |
|
||||||
|
| CPUID exit fastpath in `svm_exit_handlers_fastpath` | the fastpath runs *after* the world switch (`svm.c:4429`, called at `:4664`), so it cannot remove the exit that is being measured |
|
||||||
|
| Cross-CCD topology to grow the baseline | the measured ceiling falls ~1.7x short, and VMAware picks its own nearby core pair anyway |
|
||||||
|
| Both of the above combined | still short |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLEARED - DBVM (weight 150, VMAware HEAD only)
|
||||||
|
|
||||||
|
Added to VMAware after v2.8.1; the release build does not fire it, HEAD does, with
|
||||||
|
`DBVM: ICEBP failed to advance guest RIP`. Named after a hypervisor-backed debugger, but the sub-check
|
||||||
|
that fires is generic: it clears DR0-DR7 through `NtSetContextThread`, executes `F1 C3`
|
||||||
|
(`icebp; ret`) under SEH, and compares the exception context's RIP against `stub + 1`.
|
||||||
|
|
||||||
|
`ICEBP` (`INT1`) raises a trap-like `#DB`, so the frame RIP on hardware is the next instruction.
|
||||||
|
The first theory here - that SVM's `#DB` exception intercept catches it and `db_interception()`
|
||||||
|
re-injects it with RIP still on the `F1` byte - was wrong, and it was wrong in a way that only a
|
||||||
|
trace could show: on Zen 4 the exception intercept never fires for ICEBP at all, and every warm
|
||||||
|
reproducer read the correct `+1`. What the `kvm_exit` trace of a cold selftest shows instead:
|
||||||
|
|
||||||
|
kvm_exit: reason npf rip 0x402ec4 ... intr_info 0x80000301 <- ICEBP at 0x402ec4
|
||||||
|
kvm_inj_exception: #DB [reinjected]
|
||||||
|
|
||||||
|
The `#DB` was being delivered when it hit a nested page fault on an unmapped IDT/handler page.
|
||||||
|
The exit carries the pending `#DB` in `EXITINTINFO` with the saved RIP on the ICEBP, and
|
||||||
|
`svm_complete_interrupts()` re-queues it as a plain hardware exception, which the next `VMRUN`
|
||||||
|
injects at that RIP. So the guest sees the ICEBP's own address whenever the delivery touches a
|
||||||
|
page KVM has not mapped yet - deterministic in a fresh VM, sporadic in a running Windows guest
|
||||||
|
(page compaction and reclaim keep unmapping NPT entries), which is why the scanner hit it and the
|
||||||
|
warm probes did not.
|
||||||
|
|
||||||
|
`0004-KVM-SVM-intercept-ICEBP-and-skip-it-before-injecting-its-DB` enables SVM's dedicated
|
||||||
|
`INTERCEPT_ICEBP`, which fires before the `#DB` exists, skips the instruction with
|
||||||
|
`svm_skip_emulated_instruction()` and queues the `#DB` with the same DR6 payload
|
||||||
|
`db_interception()` uses. Once RIP has been advanced, an injection that is itself interrupted is
|
||||||
|
re-injected with the advanced RIP. This is the shape VMX already has (`is_icebp()` in
|
||||||
|
`vmx/vmcs.h`, skipped in `handle_exception_nmi()`).
|
||||||
|
|
||||||
|
Proof, at CPL0 with no Windows involved - a KVM selftest (`x86/icebp_test.c`, shipped as 5/5 of
|
||||||
|
the series) executes `icebp` under a `#DB` handler that records the frame RIP:
|
||||||
|
|
||||||
|
unpatched svm.c: 0x402ec4 != 0x402ec5 (db_rip != next_rip) FAIL
|
||||||
|
with 0004: PASS
|
||||||
|
|
||||||
|
and the two full scans with 0004 in place no longer fire `DBVM`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Method notes worth keeping
|
||||||
|
|
||||||
|
**Score from the console session.** OpenSSH on Windows puts you in session 0, the services
|
||||||
|
session. Every score this project took for its first two days came from there. Use a scheduled
|
||||||
|
task with `/it` and confirm `(Get-Process -Id $PID).SessionId` is 1 - `schtasks /run` reports
|
||||||
|
success regardless of where the task actually ran.
|
||||||
|
|
||||||
|
**One change per boot.** The 70-second silent power-off from an ACPI namespace failure has no
|
||||||
|
error message and no log line. The only way to attribute it is to have changed one thing.
|
||||||
|
|
||||||
|
**Read the debug line, not the count.** `[ DETECTED ]` with no debug line is not "cleared" -
|
||||||
|
some targets return silently (`Xen` at vmaware.hpp:9174, `BXPC` at :9186).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
VMAware v2.8.1 debug: 1/85 GPU_CAPABILITIES Running on bare metal
|
||||||
|
VMAware HEAD debug: 1/85 GPU_CAPABILITIES Running on bare metal, likeliness 20%
|
||||||
|
|
||||||
|
with `0001-0004` in the modules, `EXPERIMENTAL-0006` built in and enabled after boot on a 16 vCPU
|
||||||
|
guest pinned 1:1 to one CCD, declaring a Ryzen 7 7700X. The remaining check needs a real GPU passed
|
||||||
|
through, which needs a spare card. `bench/timerprobe.c` reproduces both `TIMER` detectors and
|
||||||
|
`bench/`-adjacent probes for the others live in `docs/TESTING.md`.
|
||||||
198
docs/GUEST-SETUP.md
Normal file
198
docs/GUEST-SETUP.md
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
# From a plain Windows VM to the tuned, corrected one
|
||||||
|
|
||||||
|
`vm-native-setup` does the host side in one pass. This is the guest side, in the order that
|
||||||
|
works, plus the two host steps that have to happen between them. Everything here was run on a
|
||||||
|
Windows 11 guest; the order is the part that cost time to learn.
|
||||||
|
|
||||||
|
The shape of it:
|
||||||
|
|
||||||
|
```
|
||||||
|
base domain guest host guest again
|
||||||
|
----------- ----- ---- -----------
|
||||||
|
virtio disk, QXL, -> 1. hypervisor + VBS off -> 4. vm-native-setup -> 5. remove virtio
|
||||||
|
agents, 52:54:00 MAC 2. SSH + RDP on (disk to NVMe, devices, drivers + agents
|
||||||
|
3. stornvme boot-start firmware, identity) -> 6. score, verify
|
||||||
|
```
|
||||||
|
|
||||||
|
Steps 1 to 3 are done with the guest still on its virtio disk. Step 4 changes the disk bus, and
|
||||||
|
step 5 is only safe after that: remove `viostor` while the disk is still virtio and the next boot
|
||||||
|
is `INACCESSIBLE_BOOT_DEVICE`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Turn off the guest's own hypervisor
|
||||||
|
|
||||||
|
A Windows guest that runs Hyper-V reports that truthfully, and no host-side setting changes it.
|
||||||
|
Virtualization-based security also costs 5 to 15 percent on CPU-bound work, so this is the one
|
||||||
|
step that makes the VM faster as well as quieter. As Administrator, in order:
|
||||||
|
|
||||||
|
```
|
||||||
|
bcdedit /set hypervisorlaunchtype off
|
||||||
|
bcdedit /set vsmlaunchtype off
|
||||||
|
reg add HKLM\SYSTEM\CurrentControlSet\Control\DeviceGuard /v EnableVirtualizationBasedSecurity /t REG_DWORD /d 0 /f
|
||||||
|
reg add HKLM\SYSTEM\CurrentControlSet\Control\DeviceGuard /v RequirePlatformSecurityFeatures /t REG_DWORD /d 0 /f
|
||||||
|
reg add HKLM\SYSTEM\CurrentControlSet\Control\DeviceGuard\Scenarios\HypervisorEnforcedCodeIntegrity /v Enabled /t REG_DWORD /d 0 /f
|
||||||
|
reg add HKLM\SYSTEM\CurrentControlSet\Control\DeviceGuard\Scenarios\CredentialGuard /v Enabled /t REG_DWORD /d 0 /f
|
||||||
|
reg add HKLM\SYSTEM\CurrentControlSet\Control\Lsa /v LsaCfgFlags /t REG_DWORD /d 0 /f
|
||||||
|
reg add HKLM\SOFTWARE\Policies\Microsoft\Windows\DeviceGuard /v EnableVirtualizationBasedSecurity /t REG_DWORD /d 0 /f
|
||||||
|
reg add HKLM\SOFTWARE\Policies\Microsoft\Windows\DeviceGuard /v HypervisorEnforcedCodeIntegrity /t REG_DWORD /d 0 /f
|
||||||
|
```
|
||||||
|
|
||||||
|
Then the optional features that bring a hypervisor with them. WSL is one of them and it is lost:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Disable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All, Microsoft-Windows-Subsystem-Linux, VirtualMachinePlatform, HypervisorPlatform, Containers-DisposableClientVM -NoRestart
|
||||||
|
```
|
||||||
|
|
||||||
|
Reboot twice. Verify:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
(Get-CimInstance Win32_ComputerSystem).HypervisorPresent # False
|
||||||
|
(Get-CimInstance -Namespace root\Microsoft\Windows\DeviceGuard Win32_DeviceGuard).VirtualizationBasedSecurityStatus # 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Some installs will not let go.** On one Windows 11 install here the status stayed at 2 through
|
||||||
|
nine reboots with every one of the settings above applied. Memory Integrity had been on since
|
||||||
|
install and the policy is enforced from a place none of these keys reach. The two things that
|
||||||
|
worked: a clean install where VBS was never enabled, or the Core Isolation > Memory Integrity
|
||||||
|
toggle in Windows Security, then the commands again. Check the two values before anything else,
|
||||||
|
because every later step assumes they read `False` and `0`.
|
||||||
|
|
||||||
|
## 2. Two ways in that survive a dark console
|
||||||
|
|
||||||
|
The full profile removes the emulated display, and GPU passthrough hands the host's screen to the
|
||||||
|
guest. Both need a way in that does not depend on a console. As Administrator:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
|
||||||
|
Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0
|
||||||
|
Set-Service sshd -StartupType Automatic; Start-Service sshd
|
||||||
|
New-NetFirewallRule -Name sshd -DisplayName 'OpenSSH Server' -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22
|
||||||
|
reg add "HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server" /v fDenyTSConnections /t REG_DWORD /d 0 /f
|
||||||
|
Enable-NetFirewallRule -DisplayGroup "Remote Desktop"
|
||||||
|
```
|
||||||
|
|
||||||
|
Put your public key in `C:\ProgramData\ssh\administrators_authorized_keys` for an Administrator
|
||||||
|
account. `vm-native-verify` uses that key.
|
||||||
|
|
||||||
|
SSH into the guest fails with `Corrupted MAC on input` until the host has the e1000e offload rule
|
||||||
|
from the `vfio-native` package. The emulated NIC's TX offloads corrupt integrity-checked traffic on
|
||||||
|
the host side of the tap; SMB tolerates it, SSH does not. The package installs a udev rule that
|
||||||
|
turns the offloads off on every libvirt tap as it appears, and `vm-native-setup` says so if it is
|
||||||
|
missing.
|
||||||
|
|
||||||
|
## 3. Make the NVMe driver boot-critical
|
||||||
|
|
||||||
|
The disk is about to move from virtio to emulated NVMe, and Windows only loads boot-start drivers
|
||||||
|
before it can read the disk. `stornvme` is inbox but not always boot-start on an install that
|
||||||
|
never saw an NVMe disk. Before the move:
|
||||||
|
|
||||||
|
```
|
||||||
|
reg add HKLM\SYSTEM\CurrentControlSet\Services\stornvme /v Start /t REG_DWORD /d 0 /f
|
||||||
|
reg add HKLM\SYSTEM\CurrentControlSet\Services\storahci /v Start /t REG_DWORD /d 0 /f
|
||||||
|
reg delete HKLM\SYSTEM\CurrentControlSet\Services\stornvme\StartOverride /f
|
||||||
|
reg delete HKLM\SYSTEM\CurrentControlSet\Services\storahci\StartOverride /f
|
||||||
|
```
|
||||||
|
|
||||||
|
The `reg delete` lines may say the key does not exist, which is fine. Skip this step and the
|
||||||
|
first NVMe boot ends at Windows Boot Manager with `0xc0000225`, "a required device isn't connected
|
||||||
|
or can't be accessed". That is exactly what a set-aside copy of the daily VM's disk did here.
|
||||||
|
|
||||||
|
Shut the guest down.
|
||||||
|
|
||||||
|
## 4. The host pass
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vm-native-setup -d win11 -p full -r
|
||||||
|
```
|
||||||
|
|
||||||
|
It moves every disk to emulated NVMe with a serial, replaces the virtio device set (balloon,
|
||||||
|
RNG, serial channel, agent channel, virtiofs, virtio NIC and inputs) with what a real board has,
|
||||||
|
removes the emulated display for the full level, wires Secure Boot with a key store it generates,
|
||||||
|
writes the SMBIOS and ACPI identity, pins the vCPUs, and points the domain at the patched QEMU.
|
||||||
|
`-r` gives this deployment its own serials, MAC and memory module, which matters because every
|
||||||
|
installation of a tool that ships fixed identity strings shares one fingerprint. The generated
|
||||||
|
values live in `~/.local/share/vfio-native/<domain>/identity.env` and stay put until you pass `-r`
|
||||||
|
again.
|
||||||
|
|
||||||
|
Two things it asks or warns about:
|
||||||
|
|
||||||
|
- **USB passthrough.** It lists your devices and which controller each sits behind. `auto` passes
|
||||||
|
keyboard and mouse: a whole controller when only they sit on it and its IOMMU group is clean,
|
||||||
|
the individual devices otherwise. The host loses whatever is passed for as long as the guest
|
||||||
|
runs, so have SSH working first.
|
||||||
|
- **The firmware store.** Enrolling Secure Boot keys means the domain's EFI variable store is
|
||||||
|
recreated from the new template. Boot entries come back on their own. If BitLocker is on in the
|
||||||
|
guest, suspend it first or the next boot asks for the recovery key.
|
||||||
|
|
||||||
|
Before the first boot, if the host has less free memory than the guest's RAM, free and compact
|
||||||
|
it so the guest lands on transparent hugepages; `vm-native-setup` prints the two commands when it
|
||||||
|
applies. The NIC stays `e1000e`, so the network survives the driver removal in the next step. Do not use
|
||||||
|
virtiofs for host files: it is a virtio device the scanner names, and its shared memory backing
|
||||||
|
blocks transparent hugepages for the whole guest. Share over SMB on the e1000e link instead.
|
||||||
|
|
||||||
|
## 5. Remove the virtio drivers and the agents
|
||||||
|
|
||||||
|
Boot the guest. It is on NVMe now, so `viostor` and `vioscsi` can go. In this order, as
|
||||||
|
Administrator:
|
||||||
|
|
||||||
|
1. Uninstall `virtio-win-guest-tools` from Apps, or its cached installer with `/uninstall /quiet`.
|
||||||
|
Then `msiexec /x` whatever it leaves behind: the QEMU guest agent, the Spice agent, the Red Hat
|
||||||
|
QXL driver, the virtio-win driver installer.
|
||||||
|
2. Remove the Red Hat packages from the driver store:
|
||||||
|
|
||||||
|
```
|
||||||
|
pnputil /enum-drivers
|
||||||
|
pnputil /delete-driver oemNN.inf /uninstall /force
|
||||||
|
```
|
||||||
|
|
||||||
|
one per `Red Hat` or `virtio` entry the first command lists.
|
||||||
|
3. Delete the service keys they leave behind:
|
||||||
|
|
||||||
|
```
|
||||||
|
for %s in (viostor vioscsi netkvm vioser BalloonService VirtioFsSvc pvpanic vioinput viorng qemu-ga spice-agent) do reg delete HKLM\SYSTEM\CurrentControlSet\Services\%s /f
|
||||||
|
```
|
||||||
|
|
||||||
|
Reboot. Nothing in the guest now names the emulator.
|
||||||
|
|
||||||
|
## 6. Score and verify
|
||||||
|
|
||||||
|
From the host, with the guest up for a minute:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
echo 'AMD Ryzen 7 7700X 8-Core Processor' | sudo tee /sys/module/kvm_amd/parameters/brand_string
|
||||||
|
echo Y | sudo tee /sys/module/kvm_amd/parameters/cpuid_passthrough
|
||||||
|
vm-native-verify
|
||||||
|
```
|
||||||
|
|
||||||
|
`vm-native-setup` printed those two lines with your SKU. The switch is host-wide: it applies to
|
||||||
|
every pinned guest at once, and a cold boot of any of them while it is on hits the enumeration
|
||||||
|
race, so with several guests turn it off before any boots and on when they are all up. Then the
|
||||||
|
scanner, from the console session, as `docs/TESTING.md` describes. The reference guest reads 1/85.
|
||||||
|
|
||||||
|
## 7. GPU and the rest of the desk
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo vm-native-gpu --single win11 0000:03:00.0 /path/to/vbios.rom
|
||||||
|
```
|
||||||
|
|
||||||
|
writes every function of the card into the domain, adds the vBIOS as the ROM file if you give
|
||||||
|
one, removes the emulated display, and installs the hook that frees the card when the guest starts
|
||||||
|
and gives it back when it stops. The host has no screen while the guest runs. `--dual` is the same
|
||||||
|
without the hook, for a second card the host does not use. USB devices were handled in step 4;
|
||||||
|
re-run `vm-native-setup -u <spec>` to change them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What is not fixed by any of this
|
||||||
|
|
||||||
|
- **`GPU_CAPABILITIES`** needs the real GPU. Nothing emulated passes it.
|
||||||
|
- **A guest that has run its own hypervisor.** If step 1's two values will not read `False` and
|
||||||
|
`0`, no host setting rescues it. Reinstall.
|
||||||
|
- **Windows activation.** `-r` changes the board serial and the MAC. A digital licence tied to
|
||||||
|
the previous hardware hash may want re-activation. Run `-r` once, early, not per boot.
|
||||||
|
- **The EDID.** The emulated monitor's serial is compiled into the `07-edid` QEMU patch and `-r`
|
||||||
|
does not touch it. It only exists while the guest still has an emulated display; the full level
|
||||||
|
has none.
|
||||||
|
- **Memory module serials.** QEMU takes one string set for every DIMM, so all modules report the
|
||||||
|
same serial. Real boards do not, and nothing scored here reads it.
|
||||||
154
docs/RESULTS.md
Normal file
154
docs/RESULTS.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# Performance: what the corrected guest costs
|
||||||
|
|
||||||
|
Benchmarks are `bench/vmbench.c`, cross-compiled with mingw-w64 and copied into the guest.
|
||||||
|
Nothing is installed in the guest. No GPU is passed through, so every number here is a CPU,
|
||||||
|
memory, scheduling or clock proxy for interactive and CPU-bound workloads - there is no graphics
|
||||||
|
figure to be had.
|
||||||
|
|
||||||
|
Run `scripts/verify-perf.sh` to reproduce any of this.
|
||||||
|
|
||||||
|
## The corrections are free
|
||||||
|
|
||||||
|
**Ceiling** is the same guest with the fidelity work abandoned entirely - hypervisor visible, the
|
||||||
|
full Hyper-V enlightenment set, `hypervclock`. It is not a usable config, only a reference for how
|
||||||
|
fast this guest can possibly go.
|
||||||
|
|
||||||
|
**Conformant** is the shipping config, the `full` level: 16 vCPU pinned to one CCD, hypervisor
|
||||||
|
CPUID bit cleared, no enlightenments, patched QEMU, patched KVM modules, declaring a Ryzen 7
|
||||||
|
7700X. Scores 1/85, verdict "Running on bare metal".
|
||||||
|
|
||||||
|
Medians of 3 sequential runs each, patched modules in both, host governor `performance`:
|
||||||
|
|
||||||
|
| | Conformant | Ceiling (detectable) | Difference |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| int latency, Mops | 880 | 882 | none |
|
||||||
|
| int throughput, Mops | 5258 | 5294 | -0.7% |
|
||||||
|
| fp, Mops | 3512 | 3566 | -1.5% |
|
||||||
|
| QPC cost, ns | 15.1 | 14.5 | none |
|
||||||
|
| jitter p99.99, us | 1.7 | 1.4 | none |
|
||||||
|
| stalls >100us / 10 s | 0-2 | 0-1 | none |
|
||||||
|
|
||||||
|
Run-to-run spread inside each config (874-894 Mops conformant, 874-893 ceiling) is wider than the
|
||||||
|
gap between them. **The full correction set costs nothing measurable.** There is no performance
|
||||||
|
argument for leaving the guest uncorrected.
|
||||||
|
|
||||||
|
The Hyper-V enlightenments in particular buy nothing here. The one place they were expected to
|
||||||
|
matter is the clock, and once the guest is sized to one CCD Windows uses the TSC for QPC by
|
||||||
|
itself, leaving `hypervclock` nothing to improve.
|
||||||
|
|
||||||
|
### The KVM patches are free too, and so is the CPUID passthrough
|
||||||
|
|
||||||
|
Measured 2026-09-05 on one boot each, same XML, three sequential `vmbench all` runs per row, the
|
||||||
|
guest settled 60 s, nothing else running in it, host governor `performance`, the isolation hook
|
||||||
|
confining the host to the twelve threads the guest does not use. Three module builds:
|
||||||
|
|
||||||
|
| | `0001-0003` | `0001-0006`, passthrough off | `0001-0006`, passthrough on |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| int throughput, Mops | 5308 / 5315 / 5318 | 5315 / 5315 / 5311 | 5256 / 5246 / 5250 |
|
||||||
|
| QPC cost, ns | 14.4 / 14.8 / 14.6 | 14.4 / 14.4 / 14.5 | 14.4 / 14.7 / 14.7 |
|
||||||
|
| L3 latency, ns | 10.26 / 10.19 / 10.20 | 10.21 / 10.29 / 10.35 | 10.40 / 10.39 / 10.39 |
|
||||||
|
| jitter p99.99, us | 1.4 / 1.3 / 1.3 | 1.4 / 1.4 / 1.4 | 1.4 / 1.4 / 1.4 |
|
||||||
|
| stalls >100us / 10 s | 0 | 1 / 0 / 0 | 0 / 0 / 0 |
|
||||||
|
| DRAM read, GB/s | - | 52.4 / 52.2 / 51.6 | 49.5 / 51.5 / 52.1 |
|
||||||
|
|
||||||
|
The passthrough-on column reads 1.2% lower on integer throughput, and every one of its three runs
|
||||||
|
sits below every off run. That is exactly the shape a real cost would have, so it was re-run
|
||||||
|
interleaved, off then on, three times, `vmbench cpu` only:
|
||||||
|
|
||||||
|
off 5285.5 on 5276.0
|
||||||
|
off 5285.5 on 5329.0
|
||||||
|
off 5329.3 on 5330.5
|
||||||
|
|
||||||
|
Gone. The first batch's gap was an order effect: it ran off-then-on straight after a QEMU build on
|
||||||
|
the host, while the host load was still decaying. **Benchmarks that share a machine with other
|
||||||
|
work measure the sharing, even when the other work has just finished.** Interleave, or the
|
||||||
|
ordering becomes the result.
|
||||||
|
|
||||||
|
What the passthrough actually costs, measured from the host side:
|
||||||
|
|
||||||
|
- **vcpu_put rate**: ~2300 context switches per second across the 16 vCPU threads, ~143 per vCPU,
|
||||||
|
idle or under a one-thread load. Each one restores and re-applies the six brand-string MSRs.
|
||||||
|
- **Brand MSR writes**: six `WRMSR` through `/dev/cpu/8/msr` take 791 ns against 712 ns for six
|
||||||
|
`RDMSR` the same way, so at most ~130 ns per write including the syscall. Twelve writes per
|
||||||
|
put/load cycle at 143 per second is under 0.03% of a vCPU.
|
||||||
|
- **Unpinned guest**: the module withholds the passthrough from any vCPU thread whose allowed-CPU
|
||||||
|
mask is wider than one CPU, checked on every entry, so the writes never happen. Measured with
|
||||||
|
`virsh vcpupin 0 0-31 --live` on a running guest: the CPUID window goes from 199 ticks back to
|
||||||
|
2440, and returns to 199 when re-pinned.
|
||||||
|
|
||||||
|
### A correction worth recording
|
||||||
|
|
||||||
|
An earlier version of this file reported a 3% cost for the corrections. That was wrong, and it was
|
||||||
|
a measurement bug rather than a real effect: the repeat runs launched `clock`, `cpu` and `jitter`
|
||||||
|
with `&`, so all three ran concurrently and competed for the same cores. Running them
|
||||||
|
sequentially removes the gap. Benchmarks that share a machine with other benchmarks measure the
|
||||||
|
sharing.
|
||||||
|
|
||||||
|
## The finding that mattered
|
||||||
|
|
||||||
|
**Windows' boot-time TSC calibration is a race, and losing it costs 85x on every timing call.**
|
||||||
|
QPC then costs ~1300 ns instead of ~15 for the rest of that boot. `rdtsc` stays cheap either way,
|
||||||
|
so the TSC itself is fine; it is Windows' decision that changes.
|
||||||
|
|
||||||
|
What decides it is how much of the host the guest leaves alone. Four cold boots at each size,
|
||||||
|
same XML:
|
||||||
|
|
||||||
|
| Guest | Host keeps | Fast clock |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 24 vCPU | 4 cores | 4 / 4 |
|
||||||
|
| 28 vCPU | 2 cores | 3 / 4 |
|
||||||
|
| 32 vCPU | 0 cores | 2 / 4 |
|
||||||
|
|
||||||
|
An earlier version of this file reported a hard 16-vCPU ceiling. That was wrong twice over: it
|
||||||
|
came from two data points with nothing tested between them, and one sample at each, when the
|
||||||
|
behaviour is probabilistic rather than a threshold.
|
||||||
|
|
||||||
|
Ruled out by measurement, not assumption: the clock-source BCD settings
|
||||||
|
(`useplatformclock false`, `useplatformtick no`, `disabledynamictick yes`) change nothing;
|
||||||
|
`invtsc` changes nothing; pinning alone changes nothing; the cache-domain span changes nothing.
|
||||||
|
|
||||||
|
Realtime vCPU scheduling (`<vcpusched scheduler='fifo'>`) makes it actively worse: on a guest
|
||||||
|
sized to the whole machine it failed to boot on two of three attempts and wedged the host.
|
||||||
|
|
||||||
|
The second-order effect is larger than the clock tax. Going from 32 to 16 vCPU takes stalls over
|
||||||
|
100 us from 452 per 10 s to 0-2, and p99.99 from 87 us to 1.6 us. That tail is what shows up as
|
||||||
|
hitching in an interactive session, and mean throughput does not show it.
|
||||||
|
|
||||||
|
Dropping to 16 vCPU also gains ~5% single-thread and takes L3 latency from 17.4 to 10.2 ns,
|
||||||
|
because 8 cores on one CCD share one L3 rather than straddling the Infinity Fabric.
|
||||||
|
|
||||||
|
## Why the guest declares a Ryzen 7 7700X
|
||||||
|
|
||||||
|
16 vCPU contradicts a Ryzen 9 7950X, which is 32 threads, and VMAware's thread-count check reads
|
||||||
|
the **CPUID** brand string - not SMBIOS, so the injected SMBIOS type 4 string is irrelevant to it.
|
||||||
|
|
||||||
|
The guest therefore declares a Ryzen 7 7700X, a real 8-core/16-thread Zen 4 part. The thread count
|
||||||
|
is then correct and the check passes. It also makes the guest internally consistent: it genuinely
|
||||||
|
is an 8-core Zen 4 chip on a single CCD, which is exactly what a 7700X is.
|
||||||
|
|
||||||
|
That is the thesis of the whole project in one example. The check does not stop firing because a
|
||||||
|
better identity string was picked. It stops firing because the declared identity finally agrees
|
||||||
|
with the silicon underneath it. Parts of a machine that contradict each other are exactly what a
|
||||||
|
detection technique looks for.
|
||||||
|
|
||||||
|
## Known limits of this measurement
|
||||||
|
|
||||||
|
- `c2c` is not trustworthy and no conclusion is drawn from it. It reports ~34 ns for what should
|
||||||
|
be an SMT pair, and moved 38 -> 127 ns between near-identical configs. The
|
||||||
|
`InterlockedExchange` ping-pong's own overhead likely dominates.
|
||||||
|
- `cpu_mt` at 8 threads is a harness artefact: it pins to CPUs 0-7, which in an 8c/2t guest are
|
||||||
|
four cores' SMT pairs rather than eight cores, so it measures SMT contention, not scaling.
|
||||||
|
- A background compute workload on the host is the most likely source of the occasional
|
||||||
|
multi-millisecond outlier in the jitter tail, and it is not controlled for.
|
||||||
|
- Hugepage coverage is a host-state figure, not a config figure: the same guest read 16% and 49%
|
||||||
|
of resident memory on 2 MiB pages on two boots, depending on free memory and compaction before
|
||||||
|
the start (`docs/TESTING.md`). None of the numbers above were taken with the coverage checked,
|
||||||
|
which is one reason to trust the interleaved comparisons more than absolute values.
|
||||||
|
- The `diskio` rows are not used for any conclusion: `rand4k_IOPS` moved between 5139 and 13650
|
||||||
|
across otherwise identical runs, which is the host page cache, not the guest.
|
||||||
|
- The `vm-native-verify` sample quoted in the README and the docs page read 5012 Mops single-thread,
|
||||||
|
4% under the 5220-5330 the controlled runs above read on the same build. It was one run, taken
|
||||||
|
a minute after boot with the guest still settling. `vm-native-verify` is a pass/fail gate, and
|
||||||
|
its threshold is set for that; the cost claims here rest only on the interleaved runs.
|
||||||
|
- The `#GP` intercept's individual cost has not been isolated; the three-column table above
|
||||||
|
bounds the whole five-patch set at "within run-to-run spread".
|
||||||
294
docs/TESTING.md
Normal file
294
docs/TESTING.md
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
# Testing methodology
|
||||||
|
|
||||||
|
Two separate questions, measured separately: **does the platform read as native**, and **is it
|
||||||
|
fast**. A configuration can pass one and fail the other, and most published tuning advice is never
|
||||||
|
measured at all.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Measuring the detection score
|
||||||
|
|
||||||
|
### The scanner
|
||||||
|
|
||||||
|
[VMAware](https://github.com/NotRequiem/VMAware) runs 85 techniques and reports which fired. Use
|
||||||
|
the debug build, which prints *why* each one fired - that string is the only efficient way to work
|
||||||
|
through them.
|
||||||
|
|
||||||
|
```
|
||||||
|
vmaware_debug.exe --all --no-ansi
|
||||||
|
```
|
||||||
|
|
||||||
|
Grading with this project's own checklist instead is generous: it tests what the project already
|
||||||
|
fixed. VMAware is independent of it, which is the point.
|
||||||
|
|
||||||
|
Score against **two** builds: the latest release (`vmaware_debug.exe` from the GitHub release page,
|
||||||
|
v2.8.1 at the time of writing) and a debug build of HEAD. HEAD grows checks between releases -
|
||||||
|
`DBVM` arrived after v2.8.1 and fired on this guest while the release build said 2/85. HEAD builds
|
||||||
|
for Windows with clang (`--target=x86_64-w64-mingw32` against the mingw sysroot) with `-DVMAWARE_DEBUG`;
|
||||||
|
one file with a frameless SEH leaf needs the gcc assembler, so build that object with
|
||||||
|
`x86_64-w64-mingw32-gcc` and link the rest with clang.
|
||||||
|
|
||||||
|
### Score from the console session, not over SSH
|
||||||
|
|
||||||
|
OpenSSH on Windows drops you in **session 0**, the services session. That is not where an
|
||||||
|
interactive application runs, and it has no real display. Run it as a scheduled task instead:
|
||||||
|
|
||||||
|
```
|
||||||
|
schtasks /create /tn VMAware /tr "C:\path\run.cmd" /sc once /st 00:00 /it /rl highest /f
|
||||||
|
schtasks /run /tn VMAware
|
||||||
|
```
|
||||||
|
|
||||||
|
`schtasks /run` reports success regardless of where the task actually ran, so confirm from inside
|
||||||
|
it:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
(Get-Process -Id $PID).SessionId # must be 1, not 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Run as Administrator either way - several checks need it.
|
||||||
|
|
||||||
|
This one cost real time here. A plausible theory said `GPU_CAPABILITIES` was a session-0 artefact,
|
||||||
|
because a non-interactive window station has no gamma LUT. Re-running it confirmed in session 1
|
||||||
|
gave the identical score with the check still firing. It was genuine, and the theory was
|
||||||
|
comfortable rather than correct.
|
||||||
|
|
||||||
|
### Read the debug line, not the count
|
||||||
|
|
||||||
|
`[ DETECTED ]` with no debug line above it does **not** mean cleared - some checks return
|
||||||
|
silently. Conversely, a check firing with an unexpected debug string usually means your model of
|
||||||
|
it is wrong.
|
||||||
|
|
||||||
|
`SVM_EXCEPTIONS` was diagnosed entirely from the *absence* of a debug line: both noisy exits in
|
||||||
|
that function log something, so silence forced the conclusion that it fell through to the bare
|
||||||
|
`return true`, which in turn pinned down exactly what the exception must have been.
|
||||||
|
|
||||||
|
### The guest ignores ACPI shutdown when it feels like it
|
||||||
|
|
||||||
|
`virsh shutdown` is a request, and a Windows guest with a dialog open or an update pending sits
|
||||||
|
there. Every script here waits, then asks from inside:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh User@guest 'shutdown /s /t 0 /f'
|
||||||
|
```
|
||||||
|
|
||||||
|
and only then touches modules. `modprobe -r kvm` with the domain still up fails, and a module
|
||||||
|
swap racing a guest that is still shutting down is how a scan ends up measuring the wrong build.
|
||||||
|
|
||||||
|
### One change per boot
|
||||||
|
|
||||||
|
The ACPI failure mode is a guest that powers itself off after about 70 seconds having read nothing
|
||||||
|
from disk. No error, no log line, nothing in the journal. The only way to attribute it is to have
|
||||||
|
changed exactly one thing since the last known-good boot.
|
||||||
|
|
||||||
|
Snapshot before any firmware change:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
qemu-img snapshot -c pre-acpi-$(date +%Y%m%d) /path/to/guest.qcow2
|
||||||
|
```
|
||||||
|
|
||||||
|
### Chains report only their first hit
|
||||||
|
|
||||||
|
`FIRMWARE` walks about a dozen fingerprints and returns on the first match, so fixing one link
|
||||||
|
costs a full rebuild and boot and only reveals the next. Bundle every edit into one rebuild, or you
|
||||||
|
will spend an evening discovering links one at a time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Measuring performance
|
||||||
|
|
||||||
|
### Why not an application benchmark
|
||||||
|
|
||||||
|
The benchmark guest has no GPU, so there is no graphics figure to measure. Everything here is a
|
||||||
|
CPU, memory, scheduling or clock proxy. That is a real limitation and worth stating plainly rather
|
||||||
|
than implying the numbers cover a whole application.
|
||||||
|
|
||||||
|
They are chosen because each one stands in for something an interactive or CPU-bound workload
|
||||||
|
actually does:
|
||||||
|
|
||||||
|
| Measurement | What it stands in for |
|
||||||
|
| --- | --- |
|
||||||
|
| QPC cost | software that polls the clock in a tight loop calls QPC thousands of times a second |
|
||||||
|
| single-thread throughput | the main thread of a latency-sensitive process |
|
||||||
|
| memory latency | the dominant cost in most pointer-chasing work |
|
||||||
|
| core-to-core latency | work-queue handoffs between threads |
|
||||||
|
| jitter tail | hitching in an interactive session |
|
||||||
|
| storage IO | bulk load and streaming of data off disk |
|
||||||
|
|
||||||
|
### The harness
|
||||||
|
|
||||||
|
`bench/vmbench.c`, cross-compiled with mingw-w64 and copied in. **Nothing is installed in the
|
||||||
|
guest** - no Cinebench, no AIDA64, nothing that would itself be a detectable artefact.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
x86_64-w64-mingw32-gcc -O2 -o vmbench.exe vmbench.c
|
||||||
|
```
|
||||||
|
|
||||||
|
`vm-native-verify` does the build, copy, run and grading for you.
|
||||||
|
|
||||||
|
### How the harness avoids lying
|
||||||
|
|
||||||
|
- **A volatile sink** on every loop result, so the optimiser cannot delete the work.
|
||||||
|
- **Pointer-chase over a random single cycle** for memory latency, one node per 64-byte line. A
|
||||||
|
strided walk would be prefetched and would measure bandwidth instead.
|
||||||
|
- **`QueryPerformanceCounter` for timing, never `rdtsc`.** TSC behaviour is one of the things this
|
||||||
|
project changes, so timing with it would measure the instrument.
|
||||||
|
- **Percentiles, not means,** for jitter. The tail is the whole point.
|
||||||
|
- **Warm-up passes** before every timed section.
|
||||||
|
- **Minimum across repetitions** where the metric is a floor, interquartile mean where it is a
|
||||||
|
distribution.
|
||||||
|
|
||||||
|
### The mistakes that produce fake results
|
||||||
|
|
||||||
|
**Measure a race more than once.** Windows' boot-time TSC calibration either succeeds or does not,
|
||||||
|
and the result holds for that whole boot. The same XML gives 15 ns on one boot and 1250 ns on the
|
||||||
|
next when host headroom is tight. A single sample per configuration produced a confident,
|
||||||
|
published, wrong conclusion here - a hard "never exceed 16 vCPUs" rule drawn from two data points
|
||||||
|
with nothing tested between them. Four boots per configuration is the minimum for anything
|
||||||
|
boot-dependent.
|
||||||
|
|
||||||
|
**Never run benchmarks concurrently.** This produced a phantom "3% cost of the corrections" here
|
||||||
|
that survived into a written conclusion before it was caught. The repeat loop was:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vmbench clock & vmbench cpu & vmbench jitter 0 10
|
||||||
|
```
|
||||||
|
|
||||||
|
Three benchmarks competing for the same cores. Both configurations were measured the same way, so
|
||||||
|
the comparison *looked* controlled, and the artefact still leaked through because the two schedule
|
||||||
|
contention differently. Sequential runs removed the gap entirely.
|
||||||
|
|
||||||
|
**Benchmark a settled guest.** A machine that just booted is indexing, patching and starting
|
||||||
|
services, and all of it lands in the stall counts. `vm-native-verify` waits 60 seconds by default.
|
||||||
|
|
||||||
|
**Set the host governor first.** `powersave` costs 2-3% and makes everything noisier.
|
||||||
|
|
||||||
|
**Watch for host confounders.** A background compute workload on the host is the most likely
|
||||||
|
source of the occasional multi-millisecond outlier in the jitter tail. It is not controlled for,
|
||||||
|
and that is stated rather than left out.
|
||||||
|
|
||||||
|
### Expected numbers
|
||||||
|
|
||||||
|
On a 7950X with the guest correctly configured:
|
||||||
|
|
||||||
|
| Reading | Expected | If wrong |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| QPC cost | ~15 ns | over 1000 ns means the boot TSC calibration lost its race; reboot and re-measure |
|
||||||
|
| rdtsc cost | ~7 ns | - |
|
||||||
|
| 1 thread | ~5300 Mops | host governor is not `performance` |
|
||||||
|
| L1 / L2 / L3 | ~0.8 / ~3.4 / ~10 ns | high L3 means vCPUs across two cache domains |
|
||||||
|
| DRAM | ~60-90 ns | check transparent hugepages are actually applying |
|
||||||
|
| memory read | ~50 GB/s | - |
|
||||||
|
| jitter p99.99 | ~2 us | pinning missing, or emulator on a vCPU core |
|
||||||
|
| stalls >100us | 0-2 per 10 s | same causes; this is the tail an interactive session feels as hitching |
|
||||||
|
|
||||||
|
### Verifying hugepages actually apply
|
||||||
|
|
||||||
|
A guest can silently run entirely on 4 KiB pages. `memfd` with `shared` memory backing blocks
|
||||||
|
transparent hugepages, because shared mappings are not anonymous:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pid=$(pgrep -f "[q]emu-system-x86_64" | head -1)
|
||||||
|
sudo awk '/AnonHugePages/ {s+=$2} END {print s/1024 " MiB"}' /proc/$pid/smaps
|
||||||
|
```
|
||||||
|
|
||||||
|
Should be close to the guest's RAM size. It read **0 MiB** here until the leftover backing was
|
||||||
|
removed, at which point it read 8110 MiB of an 8 GiB guest.
|
||||||
|
|
||||||
|
That figure needs free, unfragmented host memory at the moment the guest touches its RAM. On the
|
||||||
|
same host with 6 GiB free, 14 GiB of page cache and `defrag` at `defer+madvise`, an 8 GiB guest
|
||||||
|
booted with 1154 MiB of its 7196 MiB resident on hugepages, and `/proc/vmstat` showed
|
||||||
|
`thp_fault_fallback` at 46% of `thp_fault_alloc`. Dropping the page cache and compacting before
|
||||||
|
the boot took it to 4064 MiB of 8261:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo sync; echo 3 | sudo tee /proc/sys/vm/drop_caches; echo 1 | sudo tee /proc/sys/vm/compact_memory
|
||||||
|
```
|
||||||
|
|
||||||
|
`vm-native-verify` prints the figure; without root it falls back to the host-wide
|
||||||
|
`AnonHugePages`, which is the guest's own number when only one guest runs. `vm-native-setup` says
|
||||||
|
when free memory is below the guest's RAM. Static hugepages would guarantee it and are deliberately
|
||||||
|
not used, because they take the memory from the host permanently.
|
||||||
|
|
||||||
|
Note the `[q]` in that pattern - an unbracketed `pgrep -f` matches your own shell and will hand you
|
||||||
|
the wrong PID.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The selftest
|
||||||
|
|
||||||
|
`patches/kvm/0005-KVM-selftests-verify-ICEBP-DB-reports-RIP-past-the-ICEBP.patch` adds
|
||||||
|
`tools/testing/selftests/kvm/x86/icebp_test.c`. Build it in a kernel tree with the series applied:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make -C tools/testing/selftests/kvm "$PWD/tools/testing/selftests/kvm/x86/icebp_test"
|
||||||
|
sudo tools/testing/selftests/kvm/x86/icebp_test
|
||||||
|
```
|
||||||
|
|
||||||
|
On unpatched SVM it fails with `0x402ec4 != 0x402ec5 (db_rip != next_rip)`; with 0004 it passes.
|
||||||
|
It runs against whatever `kvm_amd` is loaded, so it is also the quickest way to tell which build
|
||||||
|
is live without booting the Windows guest.
|
||||||
|
|
||||||
|
## Reproducing the TIMER analysis
|
||||||
|
|
||||||
|
`bench/timerprobe.c` reproduces VMAware's instruction-latency detector so its ratio can be measured
|
||||||
|
directly, without needing a debug build of VMAware itself. It agrees with VMAware's own figure to
|
||||||
|
within a few percent, which is what makes it usable for iterating.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
x86_64-w64-mingw32-gcc -O2 -o timerprobe.exe timerprobe.c
|
||||||
|
timerprobe.exe # sweep every counter placement, then detector 2
|
||||||
|
timerprobe.exe 0 2 # both detectors, measuring on cpu 0, counter on cpu 2
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
detector 1, instruction latency - counter placement swept:
|
||||||
|
counter=2 ratio= 7.896 (cpuid 2069 / lfence 262) DETECTED
|
||||||
|
|
||||||
|
detector 2, exception latency:
|
||||||
|
exception db= 8703.3 api= 5941.8 ratio= 1.465 pass (db traps seen: 4000)
|
||||||
|
```
|
||||||
|
|
||||||
|
With `cpuid_passthrough` on, detector 1 reads `cpuid 199 / lfence 262, ratio 0.760, pass`.
|
||||||
|
|
||||||
|
The sweep matters: it shows the best and worst case across placements, rather than only the one
|
||||||
|
VMAware happened to choose. That is how the cross-CCD idea was tested and ruled out.
|
||||||
|
|
||||||
|
Detector 2 is where the probe itself was wrong for two days. The first version used
|
||||||
|
`RaiseException` under a vectored handler for the software side, which never enters the kernel,
|
||||||
|
and reported a ratio of 6.169. VMAware's software side is `RtlCaptureContext` plus a direct
|
||||||
|
`ZwRaiseException` inside an SEH frame that executes its handler, so it pays a syscall, a kernel
|
||||||
|
dispatch and an `RtlUnwindEx`. The probe now does the same, with a `__C_specific_handler` scope
|
||||||
|
table because mingw has no `__try`, and its number agrees with VMAware's own debug line to within
|
||||||
|
a few percent. **Reproduce the mechanism, not the API name.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Host-side checks worth taking
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# is the patched module actually the one loaded
|
||||||
|
cat /sys/module/kvm/srcversion
|
||||||
|
cat /sys/module/kvm_amd/srcversion
|
||||||
|
|
||||||
|
# did the CPU isolation hook fire, and did it restore
|
||||||
|
journalctl -t libvirt-cpu-isolation -n 10
|
||||||
|
|
||||||
|
# is the host whole again with the guest off
|
||||||
|
systemctl show --property=AllowedCPUs system.slice
|
||||||
|
nproc
|
||||||
|
free -g
|
||||||
|
```
|
||||||
|
|
||||||
|
The last group is not optional. A hook that confines the host and fails to restore is worse than
|
||||||
|
no hook, and it has happened here - a hung domain start left the host on twelve of thirty-two CPUs
|
||||||
|
with nothing running. Both the clean-shutdown and hard-destroy paths are now verified explicitly
|
||||||
|
rather than assumed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A rule learned the hard way
|
||||||
|
|
||||||
|
**Never call `virsh` from inside a libvirt hook.** libvirt blocks waiting for the hook to return
|
||||||
|
while the hook waits on libvirt. The deadlock wedges domain start, which wedges `virsh list`, which
|
||||||
|
is what virt-manager reads - so the symptom is virt-manager showing no VMs at all, which looks
|
||||||
|
nothing like the cause.
|
||||||
Reference in New Issue
Block a user