eerunner: --gsdump headless GS-dump capture

Records the GIF command stream from a --liverun into a .gs dump, so a GS
workload can be replayed in pcsx2-gsrunner with no emulator in front of it.

This is the only honest way to A/B the GS across two builds. Measuring the
GS inside a live run does not work: both the MTGS ring and the software
rasterizer's job queue spin while waiting for work, so a build with a faster
EE shifts the GS threads' instruction counts without changing a single pixel
of GS work. Measured on Xenosaga with byte-identical GS workloads (same PRIM,
DRW and Mpps), a live run reported the GS threads executing 23% more
instructions while burning 21% fewer cycles -- the signature of spin, not
rasterization. Replaying the same captured dump standalone put the same build
11% ahead on instructions.

Recording arms after --gsdump-at frames (default 30, so the scene has settled)
and stops on its own once the requested frame count has been recorded; the
frame budget is raised automatically if it would truncate the dump.
This commit is contained in:
Brian Degenhardt
2026-07-24 21:04:35 -07:00
parent 12a1667aa5
commit 540c5ae0c5
+85
View File
@@ -62,6 +62,7 @@
#include "pcsx2/Achievements.h"
#include "pcsx2/DebugTools/Debug.h"
#include "pcsx2/GS/GS.h"
#include "pcsx2/MTGS.h"
#include "pcsx2/Host.h"
#include "pcsx2/INISettingsInterface.h"
#include "pcsx2/ImGui/FullscreenUI.h"
@@ -123,6 +124,17 @@ static bool s_renderer_explicit = false; // user passed --renderer (an explicit
static std::string s_memdump_prefix; // --memdump <prefix>: write <prefix>.{interp,jit}.bin at the last frame
static bool s_perf_jitdump = false; // --perf-jitdump: emit Linux perf jitdump for `perf inject --jit` (profiling)
// --gsdump: headless GS-dump capture from a --liverun. A .gs dump is a recording of
// the GIF command stream, so replaying one in pcsx2-gsrunner exercises the GS with
// ZERO emulator in front of it — which is the only way to A/B the GS across two
// builds honestly. Measuring the GS inside a live run cannot work: the MTGS ring and
// the SW rasterizer's job queue both spin while waiting, so a build with a faster EE
// changes the GS threads' instruction counts without changing a single pixel of GS
// work. Capture once here, then replay the same dump on both builds.
static std::string s_gsdump_path; // --gsdump <path.png>: dump basename (extension replaced)
static uint32_t s_gsdump_frames = 0; // number of frames to record
static uint32_t s_gsdump_at = 30; // --gsdump-at F: start recording after frame F
// twindiff (cross-BUILD divergence finder) state — see the big comment above
// RunTwinDump. The same byte-identical runner is built in two trees (working
// vs broken); one dumps a trace, the other compares against it.
@@ -544,6 +556,12 @@ static void PrintCommandLineHelp(const char* progname)
std::fprintf(stderr, " cop2 narrows further into cop2move (QMFC2/CFC2/QMTC2/CTC2), cop2vu (the VU\n");
std::fprintf(stderr, " macro ops) and cop2ls (LQC2/SQC2).\n");
std::fprintf(stderr, " Pairs well with --mkstate --renderer sw for a visual oracle. arm64 only.\n");
std::fprintf(stderr, " --gsdump <path>[:<frames>]: with --liverun, record a .gs dump of the GIF stream (default 1\n");
std::fprintf(stderr, " frame) starting after --gsdump-at frames (default 30, so the scene has settled).\n");
std::fprintf(stderr, " Replaying that dump in pcsx2-gsrunner is the only honest way to A/B the GS across\n");
std::fprintf(stderr, " builds: it drives the GS with no emulator in front of it, so the MTGS ring and the\n");
std::fprintf(stderr, " SW job queue can't spin-wait a faster EE into a bogus GS instruction-count delta.\n");
std::fprintf(stderr, " --gsdump-at <frame>: frame after which --gsdump starts recording (default 30).\n");
std::fprintf(stderr, " --renderer <null|vk|ogl|sw>: GS renderer (default null). Use vk on Intel GPUs / boxes where\n");
std::fprintf(stderr, " the auto-check declines Vulkan and the surfaceless GL path fails to open GS.\n");
std::fprintf(stderr, " --savestate <file>: Savestate to load after Initialize (required).\n");
@@ -642,6 +660,40 @@ bool EERunner::ParseCommandLineArgs(int argc, char* argv[], VMBootParameters& pa
s_mode = RunMode::LiveRun;
continue;
}
else if (CHECK_ARG_PARAM("--gsdump"))
{
const std::string_view spec(argv[++i]);
const std::string_view::size_type at = spec.rfind(':');
std::string_view path = spec;
s_gsdump_frames = 1;
// "<path>:<frames>" — the colon is optional, and a Windows-style
// drive letter can't appear here, so rfind is unambiguous.
if (at != std::string_view::npos)
{
if (const std::optional<u32> n = StringUtil::FromChars<u32>(spec.substr(at + 1), 10))
{
s_gsdump_frames = std::max<u32>(1, n.value());
path = spec.substr(0, at);
}
}
s_gsdump_path = path;
// QueueSnapshot only honours a caller-supplied path when it ends in
// ".png" (it strips the extension and appends the dump's own).
if (!StringUtil::EndsWithNoCase(s_gsdump_path, ".png"))
s_gsdump_path += ".png";
continue;
}
else if (CHECK_ARG_PARAM("--gsdump-at"))
{
const std::optional<u32> n = StringUtil::FromChars<u32>(std::string_view(argv[++i]), 10);
if (!n.has_value())
{
Console.Error("--gsdump-at expects a frame number.");
return false;
}
s_gsdump_at = n.value();
continue;
}
else if (CHECK_ARG("--perf-jitdump"))
{
// Emit a Linux perf jitdump so `perf inject --jit` can resolve EE_/VU1_/...
@@ -829,6 +881,27 @@ bool EERunner::ParseCommandLineArgs(int argc, char* argv[], VMBootParameters& pa
return false;
}
if (!s_gsdump_path.empty())
{
if (s_mode != RunMode::LiveRun)
{
Console.Error("--gsdump needs --liverun: the diff modes suppress the real GS, so there is no GIF stream to record.");
return false;
}
if (s_renderer_explicit && s_renderer == GSRendererType::Null)
{
Console.Error("--gsdump cannot use --renderer null: Null drops GIF/PATH3 instead of consuming it.");
return false;
}
// Recording is armed after frame s_gsdump_at and needs s_gsdump_frames more
// vsyncs to close the file; a short --frames would truncate it silently.
if (s_frames < s_gsdump_at + s_gsdump_frames + 1)
{
s_frames = s_gsdump_at + s_gsdump_frames + 1;
Console.WarningFmt("--gsdump: raising --frames to {} so the dump can finish.", s_frames);
}
}
if (s_iso_path.empty())
{
Console.Error("No ISO provided (use --iso <file>); the savestate needs its disc mounted.");
@@ -3877,6 +3950,18 @@ static int RunLiveRun()
VMManager::Execute(); // blocks for one frame; if the EE wedges, never returns
s_liverun_frame.store(f + 1, std::memory_order_relaxed);
// Arm the GS dump once the scene has settled. Recording stops on its own
// after s_gsdump_frames vsyncs (GSRenderer counts m_dump_frames down), so
// the run just has to outlast it — hence the frame-budget check below.
if (!s_gsdump_path.empty() && (f + 1) == s_gsdump_at)
{
Console.WriteLn(fmt::format("GSDUMP: recording {} frame(s) from frame {} to '{}'",
s_gsdump_frames, s_gsdump_at, s_gsdump_path));
const std::string path = s_gsdump_path;
const u32 nframes = s_gsdump_frames;
MTGS::RunOnGSThread([path, nframes]() { GSQueueSnapshot(path, nframes); });
}
if (watch_addr)
{
const u32 v = eeR32(watch_addr);