Skip to content

metal: per-draw translation cache (skip capture and recompile on a hit) - #506

Closed
RINNECODER wants to merge 3 commits into
mainfrom
perf/per-draw-cache
Closed

RINNECODER wants to merge 3 commits into
mainfrom
perf/per-draw-cache

Conversation

@RINNECODER

@RINNECODER RINNECODER commented Oct 15, 2026 •

Copy link
Copy Markdown
Owner

Depends on #505 (the AGC frame replay tool; this branch is cut from it, so the first two commits are #505's).

What

A per-draw translation cache on the Metal draw path, the counterpart of the Vulkan driver's DrawLookup. Every draw used to capture and recompile each stage and then copy each RecompileResult. Now a draw looks up a cache first:

  • Key: every register-derived input of BuildDrawRecompileRequest: shader snapshot identity, user data, vertex and pixel stage info, mesh and tessellation state, and the draw parameters the recompile reads. The key is stored whole and compared exactly, not only by hash.
  • Validation: the guest dwords the resource capture read (descriptors, SRT tables and everything it followed) are read again through GuestMemory and compared on every lookup. Changed descriptor or constant contents therefore miss. Each key keeps up to 4 variants, most recently hit first.
  • Not cached: a capture that met unmapped or partly mapped memory (ShaderMemory::Partial), because its result depends on the mapping and not only on the bytes.
  • On a hit: the draw reuses the shared RecompileResults, rect-list stages included. Deferred jobs share them instead of copying. FoldDrawOffsets still runs per draw.
  • Toggles: ANYPS5_METAL_DRAW_CACHE=0 turns the cache off; ANYPS5_METAL_DRAW_CACHE_STATS=1 prints its counters.
  • Test: metal_draw_translation_cache checks that changed descriptor, constant or key words miss, that restored bytes hit their variant again, and that unreadable memory misses. A mutation that drops the byte comparison turns it red.

Measured (frame replay, live 3D map capture, 11 CBs)

Replay-safety rules applied: no game running, one replay process at a time, each run a separate process (--repeat 2: warm-up plus one timed repeat), 10 runs, 2 s pauses. The machine was loaded by other builds (load 7-12), so spreads are wider than the replay's usual 1.2%.

build (10 runs each) worker M instructions worker CPU ms wall ms GPU busy ms
#505 baseline 8473 (IQR 4.9%) 840 (IQR 24%) 1559 (IQR 37%) 706 (IQR 40%)
this PR 8283 (IQR 4.9%) 841 (IQR 25%) 1573 (IQR 16%) 731 (IQR 22%)
change -2.2% (inside noise) 0% +0.9% +3.5%

An interleaved A/B on one binary (cache off/on, 10 pairs) ran on an even busier machine. It gave a paired median of -3.5% worker instructions, with an IQR of -18% to +9%: inconclusive.

  • Hit rate: 1159 draw keys per repeat. After the warm-up every lookup hits, with 0 content misses (91.6% over a 12-repeat run, warm-up included).
  • Why the gain is small: sample profiles of the worker over the timed repeats show that capture and recompile are already cheap on a warm replay (the recompile memo and metal: per-draw shader conversion lookup without key rebuilds or MSL copies #503): with the cache off they are about 5% of the worker's active time, and the draw path outside MetalDraw::Draw is 3.7%. With the cache on, that share falls to about 3%. Most of the remaining per-draw cost is inside MetalDraw::Draw:
    • ConvertToMetal hit path: about 40% of Draw (conversion key build, lookup, and Returned() copying the Result and the guest RecompileResult per draw);
    • texture resolution (textureKeys): about 25-30%;
    • Bindings and pipeline construction: about 10% each.
  • Caveat: the replay repeats one frame, so every draw hits. In the game, user data and constants change between frames (the packet census found 0% byte-identical CBs), so draws whose user data changes will miss here as well.

Correctness

  • Live map, --expect-hash (reference hash): 8/10 passes on the baseline and 8/10 plus 10/10 (A/B) with the cache. Every failure, on both builds, is the known GPU-order flicker: 1-2 pixels at max delta 3 (the same pixel at 508,770 in all of them).
  • Menu, --expect-png (tools: AGC frame capture and replay benchmark #505's reference, max 8 pixels): passes, 2 pixels differ at max delta 3.
  • Unit test: metal_draw_translation_cache passes (shader-bridge build).

Upstream draw-cache PRs

Upstream boykopovar#5906, boykopovar#5542, #494 and boykopovar#6093 (and #493, already synced) change only the Vulkan backend (Driver/Draw, Dispatch, Recorder, ShaderResources, GuestBufferMemory, VulkanDevice). None of those files are compiled into the Rosetta/Metal driver, so they cannot change these numbers.

Review findings

# source severity finding action
1 Codex P1 tools/agc-replay/AgcReplay.mm: ranges first seen mid-frame are mapped before the first submission instead of at the submission that introduced them #505's code, not this change. Real for captures that allocate or unmap GPU memory mid-frame; no effect on the live-map capture measured here (its replay hash equals the game's). To be fixed on #505.
2 Codex P2 AgcFrameCapture.mm: a target present with no buffer consumes a target index without a Display, so the End record is never written #505's code, not this change. Real (capture-side robustness). To be fixed on #505.
- Codex - no findings on the per-draw cache commit -

🤖 Generated with Claude Code

https://claude.ai/code/session_01DgW4n3qYgi5jAAohEmXRQj

RINNECODER and others added 3 commits October 14, 2026 18:09
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
… 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
The Metal draw path captured and recompiled every stage of every draw,
then copied each RecompileResult. Draws now look up a per-draw cache
first, the Metal counterpart of the Vulkan driver's DrawLookup:

- key: every register-derived input of BuildDrawRecompileRequest
  (shader snapshot identity, user data, stage and vertex info, pixel
  info, mesh/tessellation state, the draw parameters the recompile
  reads), stored whole and compared exactly, not only by hash;
- validation: the guest dwords the resource capture read (descriptors,
  SRT tables, everything it followed), read again through GuestMemory
  and compared on every lookup, so changed descriptor or constant
  contents miss; up to 4 variants per key, most recently hit first;
- a capture that met unmapped or partly mapped memory is never cached
  (ShaderMemory::Partial), since its result depends on the mapping.

A hit reuses the shared RecompileResults (rect-list stages included);
deferred jobs share them instead of copying. FoldDrawOffsets still runs
per draw. ANYPS5_METAL_DRAW_CACHE=0 turns the cache off;
ANYPS5_METAL_DRAW_CACHE_STATS=1 prints its counters.

Test: metal_draw_translation_cache (changed descriptor, constant or
key words miss; restored bytes hit their variant).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 15, 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: 8a393cce-c197-4b72-a443-a3a3c91954c0
  • 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 15, 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-15T03:01:51.883538Z c7d072b 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: c7d072bf98

ℹ️ 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 +434 to +446
for (auto& range : capture.ranges) {
const auto begin = range.guest & ~(pageSize - 1);
const auto end = (range.guest + range.bytes + pageSize - 1) & ~(pageSize - 1);
void* mapped = mmap(reinterpret_cast<void*>(begin), end - begin, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0);
if (mapped == MAP_FAILED) Fail("cannot map guest memory");
if (mapped != reinterpret_cast<void*>(begin)) {
std::fprintf(stderr, "note: guest range 0x%llx (+0x%llx) is in use in this process; mapped at %p\n",
static_cast<unsigned long long>(range.guest), static_cast<unsigned long long>(range.bytes), mapped);
range.host = static_cast<std::byte*>(mapped) + (range.guest - begin);
} else {
range.host = reinterpret_cast<std::byte*>(range.guest);
}
ranges.push_back({range.guest, {range.host, range.bytes}, range.writable});

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 Delay late-created range mapping until its submission

When a guest range is first observed during Deltas, AgcFrameCapture.mm emits it as a new Range/Chunk immediately before that submission, but the loader collects every such record and this loop maps all of them before replaying the first submission. Consequently, any capture where the title allocates, unmaps, or reuses GPU memory during the recorded frames replays earlier submissions with memory that did not yet exist (and potentially with later contents), silently invalidating output and benchmark results. Preserve the range's activation point and add/remove it at the corresponding submission rather than configuring every captured range up front.

Useful? React with 👍 / 👎.

Comment on lines +204 to +207
++presents;
if (phase == Phase::Done || phase == Phase::Waiting || buffer == nullptr) return;
// Present k scans out flip k: the presents of the recorded flips are target .. target + frames - 1.
if (presents < target || presents >= target + frames) 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 Finalize captures when a target present has no buffer

If one of the target presents has buffer == nullptr (for example, a blanked output), this increments presents and returns without emitting a Display or incrementing displays. Later presents eventually fall outside target .. target + frames - 1, so displays can never reach frames; the End record is never written and the replay tool rejects the permanently incomplete capture. Either record/skip the null frame while advancing completion or avoid consuming its target present index.

Useful? React with 👍 / 👎.

@RINNECODER

Copy link
Copy Markdown
Owner Author

Closing: no measurable gain on the frame replay; the shader-hit cost it targeted is addressed by #507.

@RINNECODER RINNECODER closed this Oct 15, 2026
@RINNECODER
RINNECODER deleted the perf/per-draw-cache 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