mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-24 13:01:48 +02:00
Agent v74: with an index.txt, collect_diag sends every module named in the newest session (after the last '# start'), deduplicated, last used first, each up to 4 MB and up to 30 MB in all, plus that session's index lines. A reporter's GPU hang came ~10 s after the last new module, so the newest written modules missed the culprit. Without an index (fp_spirv) nothing changes. The bundle drops the least recently used modules last when the zip would pass its size limit.
244 lines
11 KiB
Python
244 lines
11 KiB
Python
"""vk_shader_dump (GitHub #140): the Vulkan shim's SPIR-V dump, its setting, the GPU-hang triage per graphics API and
|
|
the shader dumps in diagnostics."""
|
|
import base64
|
|
import hashlib
|
|
import importlib.util
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from frameport.core.models import Analysis
|
|
from frameport.diag import bundle, redact
|
|
from frameport.patches import base
|
|
from frameport.patches.settings import UI
|
|
from frameport.validate import session
|
|
from frameport.validate.triage import graphics_api, triage
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
PKG = "com.example.game"
|
|
START = "09-28 17:39:01.000 1000 1000 I ActivityManager: Start proc 1147:com.example.game/u0a55 for activity\n"
|
|
HANG = "kernel: 1790000000.0 steamos kernel: msm_dpu: [drm:a6xx_hangcheck] hangcheck detected gpu lockup\n"
|
|
|
|
|
|
def fb(msg: str) -> str:
|
|
return f"09-28 17:39:02.000 1147 1174 I FrameBridge: {msg}\n"
|
|
|
|
|
|
def swapchain(fmt: int, result: int = 0) -> str:
|
|
return fb(f"xrCreateSwapchain 1680x1760 format={fmt} samples=1 array=2 faces=1 usage=0x21 flags=0x0 "
|
|
f"result={result}")
|
|
|
|
|
|
def _analysis(**kw):
|
|
d = dict(package=PKG, version="1.0", label="Game", abis=["arm64-v8a"], engine="Unreal", xr="OpenXR",
|
|
graphics="GLES or unknown (no Vulkan declaration)", direct_vrapi=False, libs=["libUE4.so"],
|
|
launcher_activity=None, has_info_category=True, meta_permissions=[], uses_glad_gl=False,
|
|
unity_msaa_levels=0, oculus_os_classes=False, is_overport_output=True, debuggable=False, extra={})
|
|
d.update(kw)
|
|
return Analysis(**d)
|
|
|
|
|
|
def test_setting_registered_like_zink_shader_dump():
|
|
base.load_all()
|
|
p = base.REGISTRY["adapter.vk_shader_dump"]
|
|
assert p.category == "adapter" and p.default == 0 and not p.default_on
|
|
assert "fp_vk_shaders" in p.description
|
|
assert UI["vk_shader_dump"]["group"] == "troubleshooting" and UI["vk_shader_dump"]["level"] == "advanced"
|
|
assert p.applies(_analysis()) and p.applies(_analysis(engine="Other"))
|
|
assert not p.applies(_analysis(engine="Unity", libs=["libunity.so"])) # no Vulkan shim in Unity games
|
|
assert not p.applies(_analysis(abis=["armeabi-v7a"])) # the shim is arm64 only
|
|
|
|
|
|
def test_graphics_api_from_swapchain_formats():
|
|
assert graphics_api(swapchain(43)) == "vulkan"
|
|
assert graphics_api(swapchain(35907)) == "gles"
|
|
assert graphics_api(swapchain(43, result=-26)) is None # refused swapchains don't count
|
|
assert graphics_api(swapchain(43) + swapchain(35907)) is None
|
|
assert graphics_api(START) is None
|
|
|
|
|
|
def test_gpu_hang_suggests_the_dump_for_the_sessions_api():
|
|
def hang(log):
|
|
r = triage(START + log, "UNKNOWN", PKG, kernel=HANG)
|
|
return next(f for f in r.findings if f.id == "gpu-hang")
|
|
|
|
assert hang(swapchain(43)).suggest == ["adapter.vk_shader_dump"]
|
|
assert hang(swapchain(35907)).suggest == ["adapter.zink_shader_dump"]
|
|
assert hang("").suggest == ["adapter.vk_shader_dump", "adapter.zink_shader_dump"] # unknown: both
|
|
# the game's analysis filters too: a Unity game never gets the Vulkan shim
|
|
entry = {"analysis": _analysis(engine="Unity", libs=["libunity.so"]).__dict__}
|
|
assert session.applicable(entry, ["adapter.vk_shader_dump", "adapter.zink_shader_dump"]) == \
|
|
["adapter.zink_shader_dump"]
|
|
vulkan_unity = {"analysis": _analysis(engine="Unity", graphics="Vulkan (declared in manifest)").__dict__}
|
|
assert session.applicable(vulkan_unity, ["adapter.vk_shader_dump", "adapter.zink_shader_dump"]) == []
|
|
|
|
|
|
def test_artifact_reads_the_setting():
|
|
so = (ROOT / "artifacts/arm64-v8a/libfp_vk.so").read_bytes()
|
|
assert b"vk_shader_dump=" in so and b"fp_vk_shaders" in so and b"index.txt" in so
|
|
|
|
|
|
HOST_TEST = r"""
|
|
#define _GNU_SOURCE
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#define LOG(...) (fprintf(stderr, __VA_ARGS__), fputc('\n', stderr))
|
|
#include "sha256.h"
|
|
#include "shader_dump.h"
|
|
|
|
int main(int argc, char **argv) {
|
|
if (argc < 2 || !dump_init(argv[1])) return 2;
|
|
uint32_t a[5] = {0x07230203, 0x00010000, 0, 10, 0}, b[7] = {0x07230203, 0x00010300, 0, 20, 0, 1, 2};
|
|
dump_module(a, sizeof a);
|
|
dump_module(b, sizeof b);
|
|
dump_module(a, sizeof a);
|
|
return 0;
|
|
}
|
|
"""
|
|
|
|
|
|
def _host_binary(tmp_path):
|
|
cc = shutil.which("cc") or shutil.which("gcc") or shutil.which("clang")
|
|
if not cc or os.name == "nt":
|
|
pytest.skip("needs a Linux host C compiler")
|
|
src = tmp_path / "t.c"
|
|
src.write_text(HOST_TEST)
|
|
exe = tmp_path / "t"
|
|
subprocess.run([cc, "-std=gnu11", "-Wall", "-Wextra", "-Werror", "-I", str(ROOT / "native/vkshim"), str(src),
|
|
"-pthread", "-o", str(exe)], check=True)
|
|
return exe
|
|
|
|
|
|
def test_dump_writes_each_module_once_with_an_index(tmp_path):
|
|
exe = _host_binary(tmp_path)
|
|
files = tmp_path / "files"
|
|
files.mkdir()
|
|
before = int(time.time() * 1000)
|
|
run = subprocess.run([str(exe), str(files)], capture_output=True, text=True)
|
|
assert run.returncode == 0, run.stderr
|
|
assert "dumping SPIR-V modules to" in run.stderr and "1 module(s) written" in run.stderr
|
|
a = b"".join(w.to_bytes(4, "little") for w in (0x07230203, 0x00010000, 0, 10, 0))
|
|
b = b"".join(w.to_bytes(4, "little") for w in (0x07230203, 0x00010300, 0, 20, 0, 1, 2))
|
|
name_a, name_b = (f"{len(x)}_{hashlib.sha256(x).hexdigest()}.spv" for x in (a, b))
|
|
out = files / "fp_vk_shaders"
|
|
assert sorted(p.name for p in out.iterdir()) == sorted([name_a, name_b, "index.txt"]) # no temporary files
|
|
assert (out / name_a).read_bytes() == a and (out / name_b).read_bytes() == b
|
|
lines = (out / "index.txt").read_text().splitlines()
|
|
assert lines[0].startswith("# start ") and "pid " in lines[0]
|
|
rows = [ln.split() for ln in lines[1:]]
|
|
assert [(r[0], r[3], r[4]) for r in rows] == [("1", name_a, "new"), ("2", name_b, "new"), ("3", name_a, "again")]
|
|
assert all(int(r[2]) >= before - 1000 for r in rows) and int(rows[0][1]) <= int(rows[2][1])
|
|
# a second run: modules already on disk are "known", nothing rewritten; the index keeps both runs
|
|
run = subprocess.run([str(exe), str(files)], capture_output=True, text=True)
|
|
assert run.returncode == 0 and "module(s) written" not in run.stderr
|
|
lines = (out / "index.txt").read_text().splitlines()
|
|
assert sum(ln.startswith("# start ") for ln in lines) == 2
|
|
assert [ln.split()[4] for ln in lines[-3:]] == ["known", "known", "again"]
|
|
|
|
|
|
def _load_agent(monkeypatch, tmp_path):
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
spec = importlib.util.spec_from_file_location("frameport_agent", ROOT / "agent/frameport_agent.py")
|
|
mod = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(mod)
|
|
return mod
|
|
|
|
|
|
def test_agent_collects_the_newest_modules(monkeypatch, tmp_path):
|
|
agent = _load_agent(monkeypatch, tmp_path)
|
|
files = tmp_path / "files"
|
|
d = files / "fp_vk_shaders"
|
|
d.mkdir(parents=True)
|
|
now = time.time()
|
|
names = []
|
|
for i in range(5):
|
|
data = bytes([i]) * 1000
|
|
n = f"{len(data)}_{hashlib.sha256(data).hexdigest()}.spv"
|
|
(d / n).write_bytes(data)
|
|
os.utime(d / n, (now - 100 + i, now - 100 + i)) # i = 4 is the newest
|
|
names.append(n)
|
|
(d / "index.txt").write_text("# start 1 pid 2\n1 0 1000 x.spv new\n")
|
|
out = agent.shader_dumps(str(files), max_bytes=2500)
|
|
res = out["fp_vk_shaders"]
|
|
assert set(res["modules"]) == {names[4], names[3]} and res["total"] == 5 # the newest two fit in 2500 bytes
|
|
assert base64.b64decode(res["modules"][names[4]]) == bytes([4]) * 1000
|
|
assert res["index"].startswith("# start")
|
|
assert "fp_spirv" not in out and agent.shader_dumps(str(tmp_path / "nothing")) == {}
|
|
assert agent.AGENT_VERSION >= 72
|
|
|
|
|
|
def _module(d, i, size=1000):
|
|
data = bytes([i]) * size
|
|
n = f"{len(data)}_{hashlib.sha256(data).hexdigest()}.spv"
|
|
(d / n).write_bytes(data)
|
|
return n
|
|
|
|
|
|
def test_agent_collects_the_newest_session(monkeypatch, tmp_path):
|
|
"""GitHub #140: every module of the newest session (index.txt), last used first, not the newest written."""
|
|
agent = _load_agent(monkeypatch, tmp_path)
|
|
files = tmp_path / "files"
|
|
d = files / "fp_vk_shaders"
|
|
d.mkdir(parents=True)
|
|
old = [_module(d, i) for i in range(3)] # only in the first session
|
|
a, b, c = (_module(d, i) for i in (10, 11, 12))
|
|
big = _module(d, 13, size=3000)
|
|
now = time.time()
|
|
for i, n in enumerate(old):
|
|
os.utime(d / n, (now + i, now + i)) # the newest written: would have won before
|
|
(d / "index.txt").write_text(
|
|
"# start 100 pid 1\n" + "".join(f"{i} 0 1000 {n} new\n" for i, n in enumerate(old + [a]))
|
|
+ "# start 200 pid 2\n"
|
|
f"1 0 2000 {a} known\n2 5 2005 {b} new\n3 9 2009 {big} new\n4 10 2010 {c} new\n5 20 2020 {b} again\n"
|
|
f"6 21 2021 {'1_' + 'f' * 64}.spv failed\n")
|
|
res = agent.shader_dumps(str(files))["fp_vk_shaders"]
|
|
assert res["session"] == 5 and res["total"] == 7
|
|
assert list(res["modules"]) == [b, c, big, a] # last use in the session, newest first; missing file skipped
|
|
assert not set(old) & set(res["modules"])
|
|
assert res["index"].startswith("[... earlier sessions left out ...]\n# start 200") and "pid 1" not in res["index"]
|
|
assert res["skipped"] == 1
|
|
# the total cap: 1000 + 1000 fit, the 3000-byte module doesn't, the next smaller one still does
|
|
capped = agent.shader_dumps(str(files), session_bytes=3000)["fp_vk_shaders"]
|
|
assert list(capped["modules"]) == [b, c, a] and capped["skipped"] == 2
|
|
monkeypatch.setattr(agent, "SHADER_MODULE_BYTES", 2000) # each module's cap
|
|
assert big not in agent.shader_dumps(str(files))["fp_vk_shaders"]["modules"]
|
|
# an index whose newest session names no module: the newest written, as before
|
|
(d / "index.txt").write_text("# start 300 pid 3\n")
|
|
fallback = agent.shader_dumps(str(files), max_bytes=2500)["fp_vk_shaders"]
|
|
assert "session" not in fallback and list(fallback["modules"]) == [old[2], old[1]]
|
|
assert agent.AGENT_VERSION >= 74
|
|
|
|
|
|
def test_bundle_drops_old_modules_to_fit(tmp_path):
|
|
w = bundle._Writer(redact.Redactor())
|
|
names = []
|
|
for _ in range(16):
|
|
data = os.urandom(20000) # incompressible
|
|
names.append(f"{len(data)}_{hashlib.sha256(data).hexdigest()}.spv")
|
|
w.files["s/" + names[-1]] = data
|
|
w.fit(limit=200000)
|
|
kept = [n for n in names if "s/" + n in w.files]
|
|
assert kept and kept == names[:len(kept)] and len(kept) < 16 # the newest (first) stay
|
|
assert any("shader module" in m for m in w.warnings)
|
|
w2 = bundle._Writer(redact.Redactor())
|
|
bundle.add_shader_dumps(w2, "g/", {"fp_vk_shaders": {"total": 50, "session": 3, "modules": {}}})
|
|
assert any("newest session" in m for m in w2.warnings)
|
|
|
|
|
|
def test_bundle_writes_the_dumps(tmp_path):
|
|
w = bundle._Writer(redact.Redactor())
|
|
data = b"\x03\x02\x23\x07" * 5
|
|
name = f"{len(data)}_{hashlib.sha256(data).hexdigest()}.spv"
|
|
bundle.add_shader_dumps(w, "games/x/target/shaders/", {
|
|
"fp_vk_shaders": {"total": 3, "index": "1 0 1 a.spv new /home/someone/x\n",
|
|
"modules": {name: base64.b64encode(data).decode(), "../evil.spv": "AAAA"}},
|
|
"../other": {"modules": {name: "AAAA"}}})
|
|
assert w.files["games/x/target/shaders/fp_vk_shaders/" + name] == data
|
|
assert b"someone" not in w.files["games/x/target/shaders/fp_vk_shaders/index.txt"]
|
|
assert not any("evil" in n or "other" in n for n in w.files)
|
|
assert any("older module" in m for m in w.warnings)
|