diff options
Diffstat (limited to 'tools/guest-diag/scrub_pin_qmp.py')
| -rw-r--r-- | tools/guest-diag/scrub_pin_qmp.py | 371 |
1 files changed, 371 insertions, 0 deletions
diff --git a/tools/guest-diag/scrub_pin_qmp.py b/tools/guest-diag/scrub_pin_qmp.py new file mode 100644 index 0000000..653bb5d --- /dev/null +++ b/tools/guest-diag/scrub_pin_qmp.py @@ -0,0 +1,371 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026 Open-IOV contributors +# scrub_pin_qmp.py - host-side channel-buffer (PMA scrubber / X DDX CE) +# semaphore+finish page scanner. +# +# The guest RM polls the channel-buffer host semaphore (channelWaitForFreeEntry) +# and finish payload (channelWaitForFinishPayload) through the *vmap* page of +# the buffer memdesc. After the guest RM re-creates its kernel VAS the host's +# GPU-VAS walk can keep resolving a stale page (r1712: pass authored +# 0x126b60000 while the guest polled 0x124fec000), so the waits never +# completed. This tool resolves the guest's own vmap pages and registers them +# through sema_scrubbuf_add; the service then authors the proven all-ones pair +# into the pages the guest actually reads (CONT.409). +# +# Usage (host, root for the QMP socket): +# sudo python3 tools/guest-diag/scrub_pin_qmp.py [--duration 0] [--interval 2] +# +# Every registered page must look like a channel buffer: the ring page +# (base+0x64000) is all-zero or 906F entries whose GET names a push inside +# the channel's own GPU window. A page that fails the shape test is skipped. +import argparse +import importlib.util +import os +import re +import subprocess +import sys +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) +OIV = os.path.normpath(os.path.join(HERE, "..", "..")) +VMEXEC = os.path.join(OIV, "scripts", "vgpu_vm.py") +PARAM = "/sys/module/vgsp/parameters/sema_scrubbuf_add" + +# nv_vm_map_pages areas seen for channel buffers (pb 0x64000 + gpfifo + sema +# + finish, with per-size variants). The fwimage boots observed 0x6e000 and +# neighbours; keep a small band and rely on the ring shape test. +SCRUB_SIZES = (0x6b000, 0x6c000, 0x6d000, 0x6e000, 0x6f000, 0x70000, + 0x71000, 0x72000) +PAGE = 0x1000 + + +def log(msg): + print("[%s] %s" % (time.strftime("%H:%M:%S"), msg), flush=True) + + +QMP_PATH = "/tmp/qemu-qmp.sock" + + +def load_qmp(wait=0.0): + """Connect to the QEMU monitor, retrying until it exists. + + The systemd unit starts at host boot, before the VM exists, and cycle.sh + removes and re-creates the socket on every VM cycle. Wait for the socket + instead of exiting, so one long-lived scanner covers the whole session + (it must already be scanning when Xorg creates its DDX channel buffer - + that is the point of running it from boot).""" + spec = importlib.util.spec_from_file_location( + "semfix_watch", os.path.join(HERE, "semfix_watch.py")) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + deadline = time.time() + wait if wait else None + last = "socket missing" + while True: + if os.path.exists(QMP_PATH): + try: + return mod.Qmp(QMP_PATH) + except Exception as e: + last = e + if deadline is not None and time.time() >= deadline: + raise SystemExit("no QMP socket: %s" % last) + time.sleep(2) + + +def vmexec(cmd, timeout=25): + try: + out = subprocess.run( + ["python3", VMEXEC, "exec", "--timeout", str(timeout), cmd], + capture_output=True, text=True, timeout=timeout + 15).stdout + except Exception: + return "" + return out + + +def guest_vmaps(): + out = vmexec( + "sudo -n grep -E 'nv_vm_map_pages' /proc/vmallocinfo 2>/dev/null | " + "awk '{split($1,a,\"-\"); " + "sz=strtonum(\"0x\" substr(a[2],3))-strtonum(\"0x\" substr(a[1],3)); " + "print a[1], sz}'", timeout=20) + found = [] + for line in out.splitlines(): + m = re.match(r"^(0x[0-9a-fA-F]+)\s+(\d+)$", line.strip()) + if m: + base, size = int(m.group(1), 16), int(m.group(2)) + if size in SCRUB_SIZES: + found.append((base, size)) + return found + + +def phys_read64(q, pa, n=2): + """Read n qwords of guest physical memory through the QEMU monitor. + + HMP prints each output line as "<addr>: <values>"; only the part after + the colon is data (the address tokens are not). + """ + out = q.hmp("xp/%dgx 0x%x" % (n, pa)) + vals = [] + for line in out.splitlines(): + if ":" not in line: + continue + vals += [int(x, 16) for x in re.findall(r"0x([0-9a-fA-F]+)", + line.split(":", 1)[1])] + return vals[:n] + + +def ring_ok(q, ring_pa): + """906F GPFIFO ring page: all-zero or entries whose GET names a push + inside the channel's own GPU window (the guest scanner's shape test, + guest_pin_stream.py _scrub_ring_ok).""" + vals = phys_read64(q, ring_pa, n=16) + if not vals: + return False + for i in range(0, len(vals), 2): + e0 = vals[i] & 0xffffffff + if e0 == 0: + continue + # Low 32 bits of the push GPU VA: the CeUtils scrubber window is + # 0x120000000..0x120080000, the DDX channel buffer pushes reach + # 0x1200a0000+, so accept through 0x120200000. + if not (0x20000000 <= e0 < 0x20200000) or (e0 & 3): + return False + return True + + +def wait_shape(q, hsem): + """A channel-buffer wait signature: the host semaphore reads a + plausible consumed count and the finish word is still zero. + + r1749: the DDX's own channel buffer does not expose the CeUtils ring + layout at +0x64000 (its ring reads the semaphore word 0xfff), so the + ring test rejected exactly the page X was blocked on + (_nv015102rm timed wait on vmap+0x6c004). The consumed value with a + zero finish word is the wait the release pair exists to end.""" + hv = phys_read64(q, hsem, n=4) + if len(hv) < 2: + return False + return 0 < hv[0] <= 0x20000 and hv[1] == 0 and hv[2] == 0 \ + and hv[3] == 0 + + +def register(fin_pa, hsem_pa): + req = "0x%x,0x%x" % (fin_pa, hsem_pa) + r = subprocess.run(["sudo", "tee", PARAM], input=req + "\n", + capture_output=True, text=True) + return r.returncode == 0 + + +def clear(): + register(0, 0) + + +def scan(q): + pages = [] + for base, size in guest_vmaps(): + try: + hsem = q.gva2gpa(base + 0x6c000) + ring = q.gva2gpa(base + 0x64000) + except Exception: + continue + if not hsem or not ring: + continue + # r1749/r1752: the ring-shape test rejected exactly the buffers + # the guest was blocked on, and the QMP physical reads are not + # stable enough to shape-test a moving page. The candidate set is + # already narrow (nv_vm_map_pages area of a channel-buffer size), + # so register every stable candidate; the service only authors a + # record the guest itself polls (and only while !submitted). + # + # r1760: in the single green cycle the strict "two identical reads" + # gate skipped the live DDX wait page (hsem_pa=0x103789000) for the + # WHOLE cycle -> no registration -> the Xorg DDX wedge the release + # exists to end. Retry the read a few times (the flakiness is often + # a single bad sample), and accept a page that reads wait-shaped + # even when the samples disagree: consumed count in (0, 0x20000] + # with a zero finish word is the exact signature of the wait this + # registration releases (wait_shape below). + stable = None + prev = None + last = [] + for _ in range(6): + hv = phys_read64(q, hsem, n=2) + if len(hv) < 2: + continue + last = hv + if prev is not None and hv == prev: + stable = hv + break + prev = hv + if stable is None: + # r1760: the live page read hsem=0x20000064/fin=0x6001 while the + # DDX spun on it and every sample pair disagreed. The page the + # guest waits on IS a live channel buffer; a nonzero sema word + # at the channel-buffer sema offset is that signature. Register + # with a loud log; the service only authors records the guest + # polls and (for all-ones) only while no USERD submissions are + # observed, so a false registration is bounded. + if wait_shape(q, hsem) or (last and last[0]): + log("register vmap %#x (unstable but live: last=%s) " + "hsem_pa=%#x" % (base, [hex(v) for v in last], hsem)) + else: + log("skip vmap %#x (unstable read) hsem_pa=%#x" % + (base, hsem)) + continue + pages.append((hsem + 4, hsem)) + wp = walk_page_cached(SCRUB_SEMA_VA) + if wp: + pages.append((wp + 4, wp)) + return pages + + +# r1762: the DDX scrub wait polls the CeUtils scrubber channel's sema/fin +# page at the GPU VA 0x12006c000 (+4 = fin, the mem_scrub finish word), which +# the service's kernel-VAS walk resolves (walk_probe) - NOT the guest-vmap +# candidate pages this scanner used to register (0x128013000 was already +# all-ones and the guest still spun; registering the walk-resolved +# 0x12801b000 released the wait in ~23 ms). Register both: the vmap path +# stays the fallback for boots where the walk is stale (r1712). +SCRUB_SEMA_VA = 0x12006c000 +WALK_PARAM = "/sys/module/vgsp/parameters/walk_probe" +_walk_cache = {"va": 0, "pa": 0, "at": 0.0} + + +def host_walk_page(va): + """Resolve a GPU VA to a guest PA through the service's kernel-VAS walk. + + walk_probe is a module parameter; the result lands in dmesg as the last + 'sema-walk-probe: ... discovered rc=0 pa=0x...' block.""" + try: + r = subprocess.run(["sudo", "tee", WALK_PARAM], + input="0x%x\n" % va, + capture_output=True, text=True) + if r.returncode != 0: + return 0 + out = subprocess.run(["sudo", "dmesg"], capture_output=True, + text=True).stdout + except Exception: + return 0 + pa = 0 + kern_pa = 0 + vas_h = None + for line in out.splitlines(): + if "sema-walk-probe: va=0x" in line: + pa = 0 # a newer block begins; only its result counts + kern_pa = 0 + vas_h = None + elif "sema-walk-probe: vas[" in line: + try: + vas_h = int(line.split("h=0x", 1)[1].split()[0], 16) + except Exception: + vas_h = None + elif "sema-walk-probe:" in line and "rc=0 pa=0x" in line: + try: + p = int(line.rsplit("pa=0x", 1)[1].split()[0], 16) + except Exception: + p = 0 + if "discovered" in line: + pa = p + elif vas_h == 0xc and p: + # r1764: the summary 'discovered' path can fail (rc=-2) + # while the per-VAS kernel row (h=0xc) still resolves the + # polled scrubber sema page (r1667-authoritative). + kern_pa = p + return pa or kern_pa + + +def walk_page_cached(va): + now = time.time() + if _walk_cache["va"] == va and now - _walk_cache["at"] < 15: + return _walk_cache["pa"] + pa = host_walk_page(va) + if pa != _walk_cache["pa"] or not _walk_cache["at"]: + log("scrubber walk page va=%#x -> pa=%#x (fin=%#x)" % + (va, pa, pa + 4)) + _walk_cache.update({"va": va, "pa": pa, "at": now}) + return pa + + +def module_generation(): + """Identity of the current vgsp module + QEMU instance. + + scrub_pin_qmp is long-lived: cycle.sh rmmod/insmods vgsp and relaunches + QEMU between rounds. Both reset the registration state the scanner + tracks implicitly, so a changed generation must force a full re-register + even when the guest's vmap addresses happen to repeat.""" + mod = "?" + try: + with open("/proc/modules") as f: + for line in f: + if line.startswith("vgsp "): + f = line.split() + mod = f[0] + "@" + (f[5] if len(f) > 5 else "?") + break + except Exception: + pass + pid = "?" + try: + with open("/tmp/qemu-harness.pid") as f: + pid = f.read().strip() + except Exception: + pass + return (mod, pid) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--duration", type=float, default=0, help="seconds (0 = forever)") + ap.add_argument("--interval", type=float, default=2.0) + args = ap.parse_args() + + q = load_qmp() + cur = None + gen = None + last_refresh = 0.0 + start = time.time() + while True: + if args.duration and time.time() - start >= args.duration: + break + g = module_generation() + if g != gen: + log("generation %s -> %s, re-registering" % (gen, g)) + gen = g + cur = None + try: + pages = scan(q) + except Exception as e: + log("scan failed: %s" % e) + pages = [] + key = tuple(sorted(pages)) + now = time.time() + if key != cur: + if cur is not None and not key: + # r1743: a transiently-empty scan (the live buffer was only + # visible through a rejected alias for a pass) used to + # clear() and the release stopped, wedging the DDX wait the + # registration exists to release. Keep the live records; + # only a *different non-empty* set replaces them. + log("scan empty; keeping %d registration(s)" % len(cur)) + else: + if cur is not None: + clear() + for fin, hsem in pages: + if register(fin, hsem): + log("registered fin=%#x hsem=%#x" % (fin, hsem)) + else: + log("register failed fin=%#x hsem=%#x" % (fin, hsem)) + cur = key + last_refresh = now + elif key and now - last_refresh > 30: + # Additive re-assert: the service reaps records on its own + # (bounded registry); re-registering is idempotent because + # records are keyed by hsem_pa. + for fin, hsem in pages: + register(fin, hsem) + last_refresh = now + time.sleep(args.interval) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) |
