Skip to content

tools: AGC frame capture and replay benchmark - #505

Merged
RINNECODER merged 3 commits into
mainfrom
tools/agc-frame-replay
Oct 15, 2026
Merged

RINNECODER merged 3 commits into
mainfrom
tools/agc-frame-replay

Conversation

@RINNECODER

@RINNECODER RINNECODER commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

What

Frame capture and replay for the Metal AGC driver. Perf and correctness work no longer needs a full game run for each iteration: capture a frame once, then replay it in seconds through the same driver code.

Capture (env-gated, off by default)

  • ANYPS5_AGC_CAPTURE=<dir> arms it. ANYPS5_AGC_CAPTURE_FLIP=<n> (default 600) selects the flip. ANYPS5_AGC_CAPTURE_AFTER_S=<s> instead selects the first flip after that many seconds. ANYPS5_AGC_CAPTURE_FRAMES=<n> records consecutive frames (default 1).
  • After flip N-1 executes, the worker drains the GPU. It then writes <dir>/frame.agc (versioned, little-endian; format in AgcFrameCapture.hpp) with:
    • every borrowed guest range, with its resident non-zero 1 MiB chunks LZ4-compressed (the pre-frame state);
    • each queue's state (registers, constant RAM, index and indirect bases, predication) at its first recorded submission;
    • every submission until flip N, in execution order: queue, command address and words, and the shader registry it was submitted with. Each also carries the 64 KiB pages that changed since the previous submission, taken just before it runs, so CPU writes the guest made mid-frame are kept;
    • the scanout pixels the next present showed, also written as game-<n>.png.
  • Hooks sit only at the submission boundary (queue worker loop, before and after ExecuteSubmission) and in Present, plus Submission::commandAddress and a GPU-busy completion handler that is off unless the replay enables it. The per-draw path is untouched.

Replay

build-rosetta/rosetta/bin/anyps5_agc_replay <dir> [--frames N] [--repeat K] [--dump-png out.png] [--expect-hash HEX] [--expect-png ref.png [--max-diff-pixels N]] [--metallib P] [--quiet] (x86_64, run with arch -x86_64)

  • Maps the ranges at their guest addresses. A range already in use in the replay process (the small staging buffer) is mapped elsewhere and translated. The driver is configured as DirectDriver does, including working-set mode.
  • Each repeat restores pre-frame memory (only chunks whose hash changed) and the queue states, submits the recorded command buffers, waits idle, and reads the display buffer through MetalPresentation::ReadPixels.
  • Page deltas are applied from a replay hook on the worker just before the submission runs. A page is written only while the replay's copy still equals the game's previous contents, so bytes the replayed GPU work wrote are kept.
  • Per frame it reports:
    • worker CPU ms (thread CPU time of the agc-queue-* threads, minus the replay's own page writes);
    • worker instructions (per-thread counters);
    • wall ms;
    • GPU busy ms (union of command buffer GPU intervals);
    • the hash.
  • The summary gives median, min/max spread and IQR over the timed repeats. Repeat 1 is a warm-up for both timing and hash.
  • --expect-hash exits 1 unless every timed repeat matches it. --expect-png compares against a reference PNG within a pixel budget. Compiled shaders go to <dir>/shader_cache, never the user's cache.

Driver API additions (replay-only): MetalDriver::ReplaceShaderRegistryForReplay, ReplaceQueueStateForReplay, ApplyDirectWorkingSet.

Results (three frames of the test title, captured in game runs and replayed under load from other agents)

menu (load 12-16, another game on the GPU) loading screen before the 3D map (load ~9) live 3D map (hexes, terrain, HUD; medium load)
capture 2.7 GiB resident, 1.35 GiB file, 2 submissions 2.9 GiB resident, 1.4 GiB file, 2 submissions (67,730 words) 3.3 GiB resident, 1.6 GiB file, 11 submissions (~100k words)
game stall during capture ~7 s snapshot + ~7 s per submission ~6 s snapshot + ~6 s per submission ~6 s snapshot + ~5.5 s per submission
worker instructions, 20 timed repeats median 1165 M, IQR 2.1%, min-max 14% median 845 M, IQR 2.5%, min-max 8.2% median 7882 M, IQR 1.2%, min-max 3.3%
worker CPU median 155 ms, IQR 10.5%, min-max 40% median 112 ms, IQR 23.5%, min-max 62% median 584 ms, IQR 4.6%, min-max 15.5%
wall median 316 ms, IQR 10.5% median 436 ms, IQR 16.6% median 1106 ms, IQR 6.2%, min-max 18.3%
GPU busy median 179 ms, IQR 14.6% median 341 ms, IQR 17.3% median 538 ms, IQR 8.7%, min-max 29.6%
hash over repeats 1-4 px flicker on a 3D character (see below) stable over all 21 repeats stable over all 21 repeats
vs the frame the game showed 4 of 2.07M px differ (max delta 3) pixel-exact (same hash) pixel-exact (same hash)
  • Correctness:
    • On the loading-screen and live-map frames, the replay is bit-identical to what the game showed and stable across repeats, so --expect-hash works there. (On the live map, short --repeat 3 runs with --expect-hash passed 3 of 4 times; the one exit 1 was not diagnosed.)
    • On the menu frame, repeat 1 (cold pipelines and caches) differs from warm repeats in ~390 pixels by 1-3 levels. Warm repeats still flicker in 1-4 pixels on the 3D character. This persists with the image cache, batching, deferral and working set all off, and with memory fully restored each repeat, so it is GPU execution-order nondeterminism in that pass, not uninitialized capture memory. Use --expect-png with a small --max-diff-pixels there.
  • Spread: thread CPU time is still load-sensitive, because ~40% of it is kernel time (wiring, faults). Worker instruction count is the stable metric (IQR 2-2.5%).

Limitations

  • Live map capture takes a long run. The map is only on screen after ~200 s; arming at 330 s with a 600 s wall limit worked (11 submissions, ~66 s of game stall). Arming at 150-200 s records the loading screen.
  • Load sensitivity. Thread CPU time still varies under load, about 40% of the worker time is kernel time (wiring, faults), and wall and GPU busy include other processes' GPU work. The most stable number is worker instructions (IQR ~2%).
  • Approximations: only the first recorded queue's state is restored per queue; indirect register lists are re-read from replay memory; guest pages aliased at two addresses become separate copies.
  • Capture data stays out of the repo. No title data is in the repo or CI; captures live outside it.

Codex review

finding verdict
P1: preserve global submission order during replay (per-queue grouping lets hooks run out of the captured cross-queue order, so delta "previous" hashes mismatch) Valid, fixed. Each worker's hook waits until its submission is next in the capture's global order before applying its delta. A 5 s escape hatch prevents a hang, and runs that hit it report a warning count.
P1: capture guest ranges added during the frame Valid, fixed in two steps. Capture: before each recorded submission, a borrowed range missing from the snapshot is recorded (Range and Chunk records) with its contents at that point. Replay: the range exists only from that submission (see the next row).
P1 (raised on #506): replay mapped every captured range before the first submission, so ranges recorded mid-frame existed, with later contents, for earlier submissions Valid, fixed. Each range keeps the submission it was recorded at. The replay drains, fills its chunks and replaces the driver's borrowed ranges at that submission; a later range replaces an overlapping earlier one; each repeat restarts from the pre-frame set with the late ranges zeroed. Bookkeeping is in AgcCaptureLogic.hpp with a unit test. Not exercised by the live-map capture (it has no mid-frame ranges); its replay hash is unchanged, 5 of 5 runs. Unmaps are still not recorded.
P2 (raised on #506): a target present with no buffer took a slot but never counted towards completion, so no End record was written Valid, fixed. Such a present now counts towards completion and only has no Display record (the replay already skips frames without one). Covered by the same unit test.
P2: preserve initialized pages that are not resident (mincore under memory pressure) Partly valid, kept with justification. The filter counts a page as data when any mincore flag is set, including MINCORE_PAGED_OUT, which xnu reports for compressor and swap pages. Only never-populated pages, which are zero, read as 0. Reading every page would fault the whole 13.5 GiB mapping into the game. The reasoning is now in a comment, and ANYPS5_AGC_CAPTURE_READ_ALL=1 disables the filter.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DgW4n3qYgi5jAAohEmXRQj

ANYPS5_AGC_CAPTURE=<dir> records one frame the Metal AGC driver executes:
the pre-frame guest memory (resident GPU-visible ranges, LZ4 chunks), each
queue's state, every submission with its shader registry and the guest
pages that changed before it ran, and the scanout pixels the game showed.
Hooks sit at the submission boundary (queue worker loop) and in Present.

anyps5_agc_replay maps the capture at its guest addresses, feeds the
recorded command buffers through the same driver K times and reports worker
CPU ms, worker instructions, wall ms, GPU busy ms and the image hash, with
--expect-hash / --expect-png regression checks and --dump-png.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgW4n3qYgi5jAAohEmXRQj
@coderabbitai

coderabbitai Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: cb525752-db06-4702-bbfa-d3fef2289662
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-14T23:14:47.419754Z 45569a7 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 45569a7aff

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +474 to +476
// Each queue's submissions in order; the hook applies a submission's page delta just before it runs.
std::map<std::uint32_t, std::vector<const Submit*>> byQueue;
for (const auto* submit : submits) byQueue[submit->queue].push_back(submit);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve global submission order during replay

When a captured frame uses multiple queues, grouping submissions only by queue lets their worker hooks run in scheduler order rather than the globally serialized order recorded in the file. Each page delta's previous hash depends on that global capture order, so if queue B runs before an earlier captured queue A, B can take the current != page.previous path and silently discard a required CPU delta as though replayed GPU work produced it, making both output and timing nondeterministic. Gate the hooks on the next global captured submission, not merely each queue's cursor.

Useful? React with 👍 / 👎.

Comment on lines +353 to +358
for (auto& table : tables) {
// A range that is no longer borrowed is not read by this submission.
const auto found = std::find_if(context.ranges.begin(), context.ranges.end(), [&](const auto& range) {
return range.guestAddress == table.guest && range.host.data() == table.host && range.host.size() == table.bytes;
});
if (found == context.ranges.end()) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Capture guest ranges added during the frame

If the title creates or replaces a GPU-visible allocation after capture begins, tables contains only the ranges snapshotted by Begin, and this loop never emits the new range or its contents even though it is present in context.ranges. A later captured submission may therefore reference an address absent from the replay's configured ranges, causing validation or host() lookup to fail, or leaving required data unavailable. Detect ranges not represented in tables and record their initial state before recording the submission.

Useful? React with 👍 / 👎.

Comment on lines +319 to +321
if (!AnyResident(*residentIn, base, page, data, bytes)) return;
hashesOut[i] = Hash(data, bytes);
if (hashesOut[i] == ZeroHash(bytes) && std::all_of(data, data + bytes, [](std::byte b) { return b == std::byte{0}; })) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve initialized pages that are not resident

Under memory pressure, mincore can report an anonymous page as nonresident even though its initialized contents are preserved in compressed memory or swap; this early return treats such a page as zero and omits its chunk from the capture. If the GPU is the first consumer to fault that page back in during the recorded frame, the game reads the preserved bytes while replay reads a zero-filled mapping, silently invalidating the benchmark result. Residency can optimize when pages are read, but it cannot be used as proof that their contents are zero.

Useful? React with 👍 / 👎.

… mid-frame

The replay hook now waits until a submission is the next one in the
capture's global order before applying its page delta (5 s escape hatch,
counted and reported). The capture records a range that appears after the
snapshot with its contents before the submission that first sees it. The
residency filter's reliance on MINCORE_PAGED_OUT is documented.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgW4n3qYgi5jAAohEmXRQj
… a blank present

Replay: a guest range the capture recorded mid-frame was mapped and filled before the first
submission. It now exists only from the submission that introduced it (the driver's borrowed
ranges are replaced after a drain, its chunks are written then), a later range replaces an
overlapping earlier one, and the next repeat starts from the pre-frame set again.

Capture: a present in the recorded window without a buffer took a slot but never counted towards
completion, so the End record was not written. It now counts and only has no Display record.

The two rules live in AgcCaptureLogic.hpp and are covered by metal_utility_tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgW4n3qYgi5jAAohEmXRQj
@RINNECODER
RINNECODER merged commit 94d9cc5 into main Oct 15, 2026
1 check passed
@RINNECODER
RINNECODER deleted the tools/agc-frame-replay branch October 15, 2026 04:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant