perf(vk): a PC-like dGPU policy (RX_VK_DGPU=1) - direct guest-memory binds, mirrors only where a measured trial proves them #2338
Open
blessedplummer wants to merge 1 commit from
blessedplummer/dgpu-pr into main
pull from: blessedplummer/dgpu-pr
merge into: MagicBeanDev:main
MagicBeanDev:main
MagicBeanDev:github/pr/2893
MagicBeanDev:github/pr/2890
MagicBeanDev:github/pr/2887
MagicBeanDev:github/pr/2878
MagicBeanDev:github/pr/2875
MagicBeanDev:github/pr/2874
MagicBeanDev:github/pr/2873
MagicBeanDev:github/pr/2871
MagicBeanDev:github/pr/2756
MagicBeanDev:github/pr/2897
MagicBeanDev:github/pr/2896
MagicBeanDev:github/pr/2718
MagicBeanDev:github/pr/2842
MagicBeanDev:github/pr/2691
MagicBeanDev:github/pr/2895
MagicBeanDev:github/pr/2743
MagicBeanDev:github/pr/2765
MagicBeanDev:github/pr/2708
MagicBeanDev:github/pr/2732
MagicBeanDev:github/pr/2755
MagicBeanDev:github/pr/2817
MagicBeanDev:github/pr/2876
MagicBeanDev:release-please--branches--main
MagicBeanDev:release-please--branches--main--release-notes
MagicBeanDev:github/pr/2778
MagicBeanDev:github/pr/2888
MagicBeanDev:github/pr/2866
MagicBeanDev:github/pr/2877
MagicBeanDev:github/pr/2800
MagicBeanDev:github/pr/2829
MagicBeanDev:github/pr/2850
MagicBeanDev:github/pr/2889
MagicBeanDev:github/pr/2882
MagicBeanDev:github/pr/2860
MagicBeanDev:github/pr/2849
MagicBeanDev:github/pr/2886
MagicBeanDev:github/pr/2794
MagicBeanDev:github/pr/2840
MagicBeanDev:github/pr/2830
MagicBeanDev:github/pr/2834
MagicBeanDev:github/pr/2719
MagicBeanDev:github/pr/2742
MagicBeanDev:github/pr/2741
MagicBeanDev:github/pr/2820
MagicBeanDev:github/pr/2771
MagicBeanDev:github/pr/2859
MagicBeanDev:github/pr/2858
MagicBeanDev:github/pr/2823
MagicBeanDev:github/pr/2757
MagicBeanDev:github/pr/2769
MagicBeanDev:github/pr/2844
MagicBeanDev:github/pr/2843
MagicBeanDev:github/pr/2673
MagicBeanDev:github/pr/2855
MagicBeanDev:github/pr/2739
MagicBeanDev:github/pr/2807
MagicBeanDev:github/pr/2851
MagicBeanDev:github/pr/2789
MagicBeanDev:github/pr/2814
MagicBeanDev:github/pr/2818
MagicBeanDev:github/pr/2774
MagicBeanDev:github/pr/2772
MagicBeanDev:github/pr/2750
MagicBeanDev:github/pr/2786
MagicBeanDev:github/pr/2785
MagicBeanDev:github/pr/2782
MagicBeanDev:github/pr/2779
MagicBeanDev:github/pr/2775
MagicBeanDev:github/pr/2706
MagicBeanDev:github/pr/2766
MagicBeanDev:github/pr/2747
MagicBeanDev:github/pr/2759
MagicBeanDev:github/pr/2745
MagicBeanDev:github/pr/2740
MagicBeanDev:github/pr/2733
MagicBeanDev:github/pr/2725
MagicBeanDev:github/pr/2727
MagicBeanDev:github/pr/2728
MagicBeanDev:github/pr/2729
MagicBeanDev:github/pr/2721
MagicBeanDev:github/pr/2722
MagicBeanDev:github/pr/2715
MagicBeanDev:github/pr/2714
MagicBeanDev:github/pr/2707
MagicBeanDev:github/pr/2703
MagicBeanDev:github/pr/2705
MagicBeanDev:github/pr/2697
MagicBeanDev:github/pr/2679
MagicBeanDev:github/pr/2676
MagicBeanDev:github/pr/2669
MagicBeanDev:docs/what-hookshot-connects-to
MagicBeanDev:fix/hdr-encode-test
MagicBeanDev:fix/main-tests
MagicBeanDev:fix/play-cache-files
MagicBeanDev:perf/game-barrier-order
MagicBeanDev:feat/heap-lazy
MagicBeanDev:feat/texture-detail
MagicBeanDev:perf/astc-opaque-bc1
MagicBeanDev:fix/fs-dir-lock
MagicBeanDev:fix/framegen-native-kept
MagicBeanDev:fix/update-no-downgrade
MagicBeanDev:port/k10-gcn-f16-mirrors
MagicBeanDev:fix/framegen-mult-cap
MagicBeanDev:chore/release-as-2.1.1
MagicBeanDev:perf/pcache-load-switch
MagicBeanDev:fix/prepare-progress-log
MagicBeanDev:fix/update-closes-stale-game
MagicBeanDev:fix/hdr-scaled-sampled
MagicBeanDev:perf/first-start-preset
MagicBeanDev:perf/zero-upload
MagicBeanDev:fix/vcore-escape-2s
MagicBeanDev:test/fake-gh-atomic
MagicBeanDev:perf/batch-idle-flush
MagicBeanDev:test/finish-stub-a
MagicBeanDev:chore/record-2.1.0
MagicBeanDev:fix/main-red-tests
MagicBeanDev:fix/test-display-startup-driver-cache
MagicBeanDev:feat/mesh-shader-mode
MagicBeanDev:fix/menu-probes-at-startup
MagicBeanDev:fix/fps-counter-classic
MagicBeanDev:fix/test-log-batch-flake
MagicBeanDev:fix/narrow-screen-hud
MagicBeanDev:ci/prereleases
MagicBeanDev:release/v2.0.15
MagicBeanDev:hotfix/v2.0.15
MagicBeanDev:fix/present-ahead-replay
MagicBeanDev:release/v2.0.13
MagicBeanDev:fix/bracket-paths-2013
MagicBeanDev:fix/hdr-overlays-2013
MagicBeanDev:fix/fps-counter-scale-2013
MagicBeanDev:fix/stick-seen-at-rest
MagicBeanDev:hotfix/menu-fits-screen
MagicBeanDev:fix/f1-always-opens-menu
MagicBeanDev:fix/ini-keep-lines
MagicBeanDev:claude/cranky-pike-7cf247
MagicBeanDev:perf/texture-answer-flat
MagicBeanDev:fix/stamp-test-modules
MagicBeanDev:fix/appimage-fonts
MagicBeanDev:fix/display-awake
MagicBeanDev:release/v2.0.12
MagicBeanDev:fix/discord-windows
MagicBeanDev:rc/v2.0.12-tests
MagicBeanDev:fix/dlss-keep-feature
MagicBeanDev:rc/v2.0.12
MagicBeanDev:button-icons
No reviewers
Labels
Clear labels
accessibility
Barrier affecting people with disabilities
autorelease: pending
autorelease: tagged
bug
Something isn't working
dependencies
Pull requests that update a dependency file
documentation
Improvements or additions to documentation
duplicate
This issue or pull request already exists
enhancement
New feature or request
github_actions
Pull requests that update GitHub Actions code
good first issue
Good for newcomers
help wanted
Extra attention is needed
invalid
This doesn't seem right
question
Further information is requested
testing wanted
wontfix
This will not be worked on
No labels
accessibility
autorelease: pending
autorelease: tagged
bug
dependencies
documentation
duplicate
enhancement
github_actions
good first issue
help wanted
invalid
question
testing wanted
wontfix
Milestone
Clear milestone
No items
No milestone
Assignees
Clear assignees
No assignees
2 participants
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".
No due date set.
Dependencies
No dependencies set
Reference
MagicBeanDev/Hookshot!2338
Loading…
Reference in a new issue
No description provided.
Delete branch "blessedplummer/dgpu-pr"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Why
4K is CPU-bound by the memory sync (#2099): at 3840x2160 on an RX 9070 XT (RADV) the game runs 35.6 fps where 1080p runs 60, with the GPU starved (9 ms of a ~14 ms frame). The cost is unified-memory emulation: every guest pool gets a VRAM mirror, kept coherent by captures, shadows and write-backs - the write-back diff alone compares ~3.1 GB per 600 submits and mirror syncs move ~137k dirty pages in the same window.
A dGPU does not need that emulation. This PR adds one opt-in switch that makes the replay treat it like a PC engine treats its GPU: the GPU works in its own memory, and the bus carries only what the CPU actually reads.
What
RX_VK_DGPU=1changes (unset = stock exactly)MIRROR_REFUSED, the existing state iGPU runs use today - and the state #2343 now uses for GCN by default). Reads take the lock-free TLS path; GPU reads/writes land in the imported guest memory, coherent by construction - no mirror sync, no write-back, no shadow/diff for that pool. Mirror-less pools read uniform buffers as submitted (#1795's per-pool form), so the artifact class that issue documents is covered under the switch too.RX_VK_DGPU_GAIN) and >0.25 ms (RX_VK_DGPU_FLOOR). A lost verdict appends the pool tohookshot_mirror_skip.txt(game folder; carries a device+driver header, so a file from another device or driver is ignored) so later runs do not re-trial it. Trials whose windows did not measure the same workload abort with no verdict and no skip line; a pool that aborts or is voided three times sits out the session. Trials do not run underRX_VK_PARALLELor where mirrors cannot come on (iGPUs, GCN).RX_VK_DGPU_TRIALS=0(pure direct),RX_VK_DGPU_GAIN(10),RX_VK_DGPU_FLOOR(0.25).Default-off is exact: the added counters and logging are gated on the switch (found and fixed in review - the first cut taxed the unset fast path on iGPU tiers and logged on every stock run).
Measurement
Status: measured to finality. The trial machinery is proven end-to-end and the positive fps claim is measured to be not defensible on this hardware — the RX 9070 XT (Navi 48, RADV/Mesa, PCIe Gen5 x16, ReBAR on) that every measurement in this PR ran on; the RTX 3080 Ti numbers below in the thread are @cashcon57's rig, not ours. The reason is quantified, not assumed. On the fixed branch the trial engine works completely: the first keep was earned and held across 3/3 runs (pool 0x109a000000, 160 MB: 33.94 -> 21.80 / 36.41 -> 22.31 / 24.68 -> 22.03 ms in the trial windows - the mirror beats the policy's own direct mode by 10.7-39.7%, kept with 0 dirty pages synced per window), releases converge into the device-keyed skip list (0 -> 10 entries over a session), churn is bounded (23 -> 3 attempts/run), and the false-void signature is gone (0 voids across the run set). The headline heavy reader (0x1186000000, ~7 GB of guest reads/window) trialed at 21.70 -> 21.41 and was correctly released: ~7 GB/window of guest-memory reads costs 0.29 ms on this rig - ReBAR + Gen5 makes them nearly free, measured directly.
That is why the pairs still lose (P -4.1% / Q -1.3% / R -2.7%, median -2.7%, steady-state confirmed): the mirror can only climb back to near-stock, never past it, when stock's guest reads cost almost nothing and the UB snapshot costs parity (measured via the RX_VK_DGPU_TRIALS=0 discriminator). The value case is empirically confined to hardware where guest reads are NOT nearly free: small-BAR / Gen4-class / proprietary-driver rigs - exactly the matrix the review proposed (the independent RTX 3080 Ti datapoint in the thread is that class). Keep-as-opt-in-machinery vs withdraw-and-re-file-the-#1795-snapshot-standalone is put to the maintainers in the thread; both paths are held. The historical pairs below are from a pre-rework build on an older main (cross-era absolute fps is not comparable; adjacent pairs are the controlled evidence):
For the record, the pre-rework build's numbers (measured on an older main; cross-era absolute fps is not comparable, the adjacent pairs are the controlled evidence):
An earlier +10.7% batch was retracted before this PR: kill-autosaves drift the save position between runs, so those pairs compared different scenes. All pairs warp-normalize.
Known limits (stated honestly)
Tests and verification
tests/test_dgpu_policy.{py,cpp}(pure rules: state transitions, window boundaries, verdict rule incl. the measured 10.32->9.23 ms case as a CHECK, candidate threshold, void semantics, keep-line format) and adgpu_defaultcase in the direct-buffer-writes fixture asserting REFUSED, no mirror queued, no write-back, shadow pages forgotten.dgpu_policy,direct_buffer_writes,parallel_mirror_make,parallel_rb_refresh,wb_pages,bind_memo,draw_binds- all pass. Suite: 415 passed / 0 failed vs pristine 414 / 0 on the same base.docs/development.md: switch rows added.Related: #2099, #995, #1150, #1377, #1629.
The three failing checks (appimage, SPIR-V fixtures, Ubuntu Python checks) are runner-side DNS outages, not PR failures: all three lost archive.ubuntu.com / security.ubuntu.com resolution in the System packages step across three run windows (15:14, 15:20, 15:41 UTC) while the Windows jobs (which don't use apt) pass every time. The code itself is verified locally: full suite 352 passed / 0 failed vs pristine 351/0, and the seven game-free checks over the changed code all pass. I'll leave these to rerun once the resolver recovers; happy to re-trigger if preferred.
Thanks for this, and for the careful write-up. The measurements are clear, the retracted batch is a good sign, and keeping the decision logic in a pure header with its own test makes it easy to check. I read the whole diff against the head and the code around it. Some notes, then a few questions for you.
What I checked and found fine
RX_VK_DGPUunset, behaviour matches main. Every new branch is gated ondgpu_on(). Moving!parlater in the make condition doesn't change anything, because the terms beforemirror_makehave no side effects.gpu_ns_totalalways accumulates, but nothing reads it when the switch is off.RX_VK_MIRROR=0path. Host-pointer imports only take aHOST_VISIBLE|HOST_COHERENTtype, so no flush or invalidate is missing. (The dma-buf path's fallback to any memory type is on main already, not from this PR.) GPU writes are seen by the CPU once the submission completes, the same as the refused state today.test_dgpu_policy.pyhasRUN_ALL_ARGSand run_all finds it. It passed on Ubuntu and Windows CI, and the newdgpu_defaultcase is listed inCASES.test_native_astc_texture_count.py. That's main's ownscanfdeprecation failure, fixed on main by #2397, so a rebase clears it. There's no conflict with #2198 (it touches other files), and GitHub reports the PR as mergeable.Correctness: in-place reads of CPU-rewritten memory
Under the policy, every pool, uniform buffers included, is bound in guest memory. The GPU then reads the game's memory when it executes, not when the work was submitted. #1795 (open) documents what that does on mirror-less GPUs: the game rewrites constants right after the submit, and when the GPU runs behind, draws read the next frame's data. On a Steam Deck that showed up as a giant HUD heart, slabs and stretched geometry. On dGPUs today the mirror is synced at submit, which hides this. The policy takes that protection away. #1795's fix is keyed on mirrors being off, which
RX_VK_DGPU=1doesn't set, so it wouldn't cover this either. Draw-count parity doesn't catch this kind of bug. A flicker/screenshot check in a GPU-bound scene is needed (see question 3). One option is to have the policy turn on the UB snapshot for direct pools.The trial measures the wrong cost
gpu_time_total_ns()adds TOP..BOTTOM timestamps written intor.cmdonly.mirror_syncandmirror_makerecord their copies intoupload_command_buffer(), which isn't timed, and the CPU-side capture, diff and write-back work isn't counted at all. So the mirrored window leaves out exactly the costs this PR exists to remove, and verdicts lean towards keeping mirrors. Your own diagnosis is that the frame is CPU-bound, so a GPU-only verdict can keep a mirror that loses on frame time. It would be worth timing the upload buffer too, or deciding on present-to-present frame time or the replay's own ms.Smaller points (inline)
par_activeguard indgpu_picknever triggers.par_activeis only true while segments are being recorded and is false again beforememory_end_submission. So trials do run underRX_VK_PARALLEL. The docs row and the PR description both say they don't, and that parallel keeps the stock all-mirrored policy. Under the policy, parallel runs also mark pools refused. In practice the deferred make goes throughmirror_wantedand the settle waits forMIRROR_ON, so it probably works. The claim and the code just need to agree.p->dgpu_binds++isn't gated on!par. Parallel segment workers increment it concurrently without a lock: a data race, and lost counts feed the 2x bind-volume check.RX_VK_DGPU=1on an iGPU (mirror_on()false), or when a dGPU's mirror budget is full, the same heaviest pool starts a trial every window and aborts 132 frames later, logging each time. It might be worth requiringmirror_on()indgpu_on(), or giving up on a pool after N aborts.RUN_ALL_ARGS = ["--cxx", "g++"]hard-codes g++. Neighbouring tests use"{cxx}", so a clang-only machine still runs them.Questions for you
RX_VK_DGPU_GAINandRX_VK_DGPU_FLOOR, andRX_VK_DGPU_TRIALS: should trials run at all by default, or is pure direct enough? And should the skip list ship enabled?Related open PRs in this area to coordinate with: #1795 (UB snapshot when mirrors are off), #2154 (keeps mirrors of pools the GPU writes), #1377 (mirror the pools the GPU reads most) and #2343 (GCN: mirrors off by default).
"trials run on a serial replay only (RX_VK_PARALLEL keeps the stock all-mirrored policy ...)": as written, the code doesn't do this. Under
RX_VK_PARALLELwithRX_VK_DGPU=1, pools are still marked refused at first bind, and trials still start (see thepar_activecomment). Either the code or this row needs to change.@ -2666,0 +2791,4 @@// only a clear, repeatable win keeps the mirror (dgpu::mirror_worth). A candidate is not write-dominated: a pool// the GPU renders into lives best in guest memory (its writes land in the pool the CPU reads, and out of the// mirrors such pools cost only GPU write time, ~1 ms a frame, and gave nothing - Triforce's demotion measured),// while the diff around a mirror would buy the write-back's compare back. RX_VK_DGPU_TRIALS=0: none (pure direct,dgpu_readis guarded by!par, but this increment isn't. Parallel segment workers take this lock-free path concurrently, sodgpu_bindsis a plain non-atomic++from several threads: a data race, and lost counts feed the 2x bind-volume check. Guard it with!partoo, or use a relaxed atomic.This is the key behaviour change. Uniform and other read buffers of every pool are now read in place, when the GPU executes. #1795 shows that on mirror-less GPUs the game rewrites constants right after the submit, and draws read the next frame's data when the GPU runs behind. The dGPU mirror, synced at submit, hides that today.
RX_VK_DGPU=1keepsmirror_on()true, so a fix keyed on mirrors being off (like #1795's) wouldn't apply here.par_activeis set only while the segments are recorded (vk_replay.cpp sets it true, then false afterpar_pool.done), so it's always false here inmemory_end_submission. Trials do start underRX_VK_PARALLEL, which contradicts the docs row and the PR description. If parallel should really be excluded, check the configured thread count (theRX_VK_PARALLELvalue) instead.gpu_time_total_ns()only coversr.cmd(gpu_time_begin/end), butmirror_sync/mirror_makerecord intoupload_command_buffer(). The mirrored window therefore doesn't count its own sync copies, or any CPU-side capture/diff/write-back, and the verdict leans towards keeping the mirror. Since the 4K frame is CPU-bound by your own diagnosis, it may be better to decide on frame time or replay ms, or at least to time the upload buffer as well.One 120-frame window decides this for good, keyed only by guest address. A later GPU or driver swap, a resolution change, or a trial run in a scene that happens to be light keeps the pool out of trials permanently, unless the user knows to delete the file. Could the file record the device/driver (and maybe the resolution), or could entries expire?
An abort doesn't mark the pool, so the next window picks the same heaviest pool again. With
RX_VK_DGPU=1on an iGPU (mirror_on()false, so the mirror never comes on), or with the mirror budget full, this repeats every window forever: a 132-frame trial plus a log line each time. Requiringmirror_on()indgpu_on(), or giving up on a pool after a couple of aborts, would avoid it.@ -0,0 +1,188 @@"""Compile and run the dGPU policy's rules (gpu/vk/dgpu_policy.h; no game/GPU required), and check that vk_memory.cppNit: the neighbouring tests use
RUN_ALL_ARGS = ["--cxx", "{cxx}"], so run_all picks the compiler it found (clang++ first). Hard-codingg++fails on a machine that only has clang.Thanks for the thorough review — this is exactly the pass the PR needed. All findings accepted; fixes are in flight, ordered by your priority:
dgpu_binds++is a race; making them agree with no race.mirror_on()in the candidate gate; an abort cap).I'll answer your three questions against the updated code rather than pre-answering, since Q1/Q2 depend on what the #1795 extension requires — but the short forms: we currently recommend opt-in (one GPU, one scene class measured; the matrix you list is the right gate for a default flip), and trials should stay on where they're allowed since they're the mechanism that rejects what doesn't pay. Full answers coming with the fixes.
Opt-in, so nothing changes for anyone until
RX_VK_DGPUis set. The red Windows part 2 job is main's oldscanf/ASTC failure (#2397). Waiting on the fix push for the accepted review points: #1795 in-place reads, trial timing GPU work only, thedgpu_binds++race, the abort retried forever, and skip-list keying.hookshot_mirror_skip.txtshould go undercache/if #2317 lands first.The fix push is up (head
64117673): reworked branch, one commit, on top of current main (30c2ebd3). Per-item against your review, then the three questions.All six findings, fixed:
BufferRef::live) marks the read as in-place, and the UB snapshot engages for exactly those bindings (dgpu_ub_in_place()). Refused pools read uniforms as submitted; trial-won pools keep the in-place mirror binds, which are submit-time synced, so nothing changes for them. Unset is untouched. Live-verified on the 9070 XT: a boot run with the snapshot engaged did 1.26M UB stream copies, zero faults. Two honest open items below (event waits; snapshot cost).gpu_time_total_ns. The mirror's cost is CPU work + upload-buffer recording, invisible to GPU timestamps, and its PCIe benefit only shows frame-wise — a CPU-only metric would release mirrors that small-BAR devices need. WHY comment in place; 120/12/120 kept; settle still holds until the mirror is really on.RX_VK_PARALLEL(pure direct, documented), the fast-path counters are gated off parallel, and the never-truepar_activeguard is gone.mirror_on()(iGPUs: no trials at all — direct is right for UMA, and on GCN after #2343 this also means the switch runs trial-less there), and a pool that aborts 3 times without a verdict sits out the session.AMD Radeon RX 9070 XT (RADV GFX1201), driver 109056099, written on the live run); a foreign-device or header-less file is ignored and its pools re-trialled rather than trusted. Placement: beside the game's own state files, moving under #2317's per-user cache dir if that lands first (per Joey-Magnus's note — one WHY comment at the path site, no speculative restructuring).{cxx}—RUN_ALL_ARGSuses it now.Rebase notes: main's #2343 (GCN mirror-less by default) composes unchanged — on GCN,
mirror_on()is false so the trials never start; the switch runs pure direct plus the UB snapshot, i.e. their new default plus hazard protection. #2131's newtest_wb_observation_lifetimecompiles a slice of vk_memory.cpp alone and our trial functions sat inside the span — they moved just outside it; that check passes untouched. No mechanism duplication found with #2131: it measures how much copied data changed; our per-pool counters feed trial candidacy.Your three questions:
Q1 (default-on?) — Opt-in for now, and #2343 actually strengthens that answer rather than complicating it: upstream has already accepted device-conditional mirror defaults (mirror-less by default for GCN, from measured RX 580 data). This PR is the per-pool, measured version of the same judgment — instead of deciding once per device class, a pool earns its mirror only when a trial proves it pays and loses it when one proves it doesn't. The trial supplies the measurement instead of the vendor family supplying the verdict. What keeps it opt-in is hardware breadth, not a known defect: our only rig is RX 9070 XT / RADV / Linux, and the remaining reason is exactly the matrix you listed. A default flip (dGPUs first) becomes defensible after that matrix runs clean — including artifact screenshots in a GPU-bound scene — and after the UB snapshot's cost has an A/B on our rig (it is new in this push; engagement and safety are proven, cost is not yet).
Q2 (settings and shipping defaults) — Ship what the switch means today:
RX_VK_DGPU=1, trials ON by default (RX_VK_DGPU_TRIALS=0exists for pure-direct A/Bs — trials are the mechanism that earns heavily-read pools their mirrors back on small-BAR, so they are the policy's point). Trial structure 120/12/120; candidate floor ≥1 MB of guest-memory reads per 600-submit window and not write-dominated (render targets stay direct); verdict keeps the mirror only when faster by BOTH 10% (RX_VK_DGPU_GAIN) and 0.25 ms/frame (RX_VK_DGPU_FLOOR), on frame time; skip list ships enabled (it is what makes convergence stick across sessions) with the device+driver header. Trials don't run underRX_VK_PARALLELor where mirrors cannot come on (iGPUs/GCN).Q3 (testing) — What exists: RX 9070 XT / RADV / Linux / 4K, warp-normalized adjacent A/B pairs in real 3D gameplay (Hyrule Field, Kokiri Forest), three pairs +8.5% to +28.6% (median +9.7%) with per-pair draw-count parity and the OFF side reproducing the issue's fingerprint (wb-diff ~3.1 GB/600 submits, >130k mirror-sync pages → ~0); GPU never saturated, so the win is CPU-side as diagnosed. This push adds the UB snapshot, frame-time verdicts, the parallel/iGPU/abort gates and the device-keyed skip list — seven game-free checks pass, suite 415/0 vs pristine 414/0 on the same base, and a boot-level smoke ran both modes fault-free. What's needed before any default flip — we agree with your matrix and can supply the AMD/RADV/Linux column: NVIDIA proprietary (your RTX 4060 Ti offer is the small-BAR case that most needs the trials to earn mirrors back), AMD Windows, Intel Arc, iGPU/UMA — and now GCN as its own row, where the policy composes with #2343's default (trials off; cheap to verify). Scenes: Kakariko, a dungeon, menus/pause, save reload, plus our two; 1080p and 4K, capped and uncapped (at a cap the trials will keep nothing — frame time cannot show a sub-cap win; expected, worth observing); frame-time percentiles; artifact screenshots in a GPU-bound scene (the #1795 failure mode, which draw parity cannot catch); correctness incl. the #2154 cyan case and readbacks.
Two open items we flag rather than paper over: #1795's event-waits commit is keyed globally (mirrors off), not per pool — under this policy event waits stay on on a dGPU, and we'd value your read on whether that needs the same per-pool treatment. And the UB snapshot's frame-time cost on our rig is unmeasured beyond the boot smoke; we'll run the A/B when the matrix work starts.
Thanks to @Joey-Magnus for the second read and the cache/ note.
Re-measurement result on the fix push, disclosed rather than discovered by you: the +9.7% fps claim does not hold on the current code and is withdrawn from the PR body.
We re-ran the same warp-normalized Hyrule Field pairs (H/I/J, same recipe as E/F/G) on the reworked branch. Median is currently -1.9% (47.5 -> 46.6), ON losing all three pairs. The mechanisms are identified from the run logs, not guessed:
What held in the re-measurement: mirror-sync dirty pages ~0 under the switch (131k+ stock — the issue's fingerprint), draw parity 3/3, unset = stock exactly, zero faults. The correctness work from your review stands; it's the performance claim that isn't currently true.
We'll post the fix and the re-run numbers here when they land. The verdict metric change (frame time, per your point) is not implicated — it correctly released the one pool that reached a verdict (21.42 vs 22.08 ms) — the failure is the void check upstream of it.
This now conflicts with main in
gpu/vk/vk_replay.cpp. Could you merge main into the branch (or rebase) when you get a chance? I'll take another look once it's up to date.An NVIDIA data point, in case it helps scope the policy: on this card
RX_VK_DGPU=1went the other way.RTX 3080 Ti, driver 615.78.08, Ryzen 7 5800X3D, Linux (CachyOS), headless gamescope at 3840x2160, Hyrule Field, 4K output with DLSS Quality (Resolution 67%),
RX_VK_RECORDER=thread. Build: main49f75dc8+ this PR (plus #2314), the same binary with and without the switch, 2 rounds alternating:GPU time rises by about 4 ms a frame with the pools bound in guest memory, presumably the GPU reading host memory over PCIe where the mirrors had kept it in VRAM, and the memory sync didn't drop either. No errors in any run. So on NVIDIA it may want to stay off, or be gated by vendor, if the RADV gain doesn't carry over. Happy to run another configuration if useful.
Rebased now — head
41dd125d, on top of main atfce53453,MERGEABLE. The conflict you flagged was your #2325 presenter-thread extraction: the block the PR's present-to-present tick lived in becamepresent_finish(), so the tick moved into it. Verified with WHY comments in-code:present_finishis the one function every made present passes exactly once in either threading mode (the game thread's path and the presenter thread's; the hand-off branch returns before it, so no double-count), the mode is run-static so the tick's statics only ever touch the presenting thread, and a headless twin was added besideframe_log_present's offscreen call so headless runs stay measurable, symmetric with #2354.Two notes on the current CI run:
test_env_on) are main's own, not this PR: upstream's newHOS_FPS_LOGgetenv athost/oot_high_fps.c:15reads set-means-on ("set to 0 they are on"), which the #2042env_onrule and the PR's owntest_env_onreject. It fails identically on pristine main at this tip (pristine: 436 passed / 1 failed; this branch: 437 / 1 — the delta is the PR's one new test). Nothing for this branch to fix, but flagging so it isn't mistaken for PR breakage.Also — a second NVIDIA datapoint just arrived below from @cashcon57 (RTX 3080 Ti), which will feed the hardware matrix question from your review.
Thanks — this is exactly the datapoint the hardware matrix needed, and the first external one. Before drawing conclusions from it, one crucial question: which head of this PR did you build? (you mention main
49f75dc8+ the PR + #2314 — the PR has been reworked substantially since its first push).The reason it matters: between the fix push and last night we found and fixed a defect that would reproduce your numbers almost exactly. The review round added a bind-drift "void" check to invalidate trials that straddle a scene change — and it misfired on success: a working mirror collapses a pool's guest-read volume ~70x, which the check read as drift, so it voided 42-44 trials per run (mirrors repeatedly made, uploaded, voided, freed — which would show up as memory sync not dropping, even rising), only pools the mirror didn't help ever reached a verdict, and with zero mirrors kept every pool stayed bound in guest memory — where the #1795 uniform-buffer snapshot (a correctness fix, ~+14M stream copies per run, GPU +1.2-2.1 ms on RADV) applies to everything. Your +4 ms GPU time and un-dropped sync are consistent with that state.
The fix is landed on the current head (the drift rule now compares which pools bind between windows — a mirror changes served volume, not the pool set — plus a cap so churn cannot repeat), and the warp pairs are being re-run on RADV right now to re-establish the honest number.
So, two asks if you're willing:
41dd125d's tree — happy to point you at the exact commit) — on the fixed code, your two heavily-read pools should either earn mirrors back (trials decide on frame time, which is what your table measures) or be correctly refused with the churn gone.RX_VK_DGPU_TRIALS=0(pure direct, no trials at all) would isolate the snapshot cost from the trial machinery — if GPU time is still +4 ms there on NVIDIA, that's the real small-BAR PCIe story: guest-memory reads are not nearly free the way they are on this AMD's large BAR, and the trials are what should be earning those pools their mirrors back.Your core observation stands regardless: if guest-memory binds cost GPU time on NVIDIA proprietary that they don't cost on large-BAR AMD, then the policy's value is conditional on hardware — which is exactly what the opt-in switch and the trial mechanism are for (a pool whose mirror wins on frame time keeps it; one that loses doesn't), and it's a strong argument for why the hardware matrix in the review should gate any default-on flip. Vendor-conditional behavior may well be the honest end state, as you suggest — but the fixed trials, not a static vendor gate, are the version of that we can actually measure.
One more variable worth recording for this datapoint (and the matrix generally): the PCIe link itself.
This rig's 9070 XT runs Gen 5 x16 (32 GT/s). Your 3080 Ti on the 5800X3D platform is AM4 — Gen 4 x16 at best (16 GT/s, half the bandwidth), and Gen 3 (8 GT/s, a quarter) if the board negotiates down. The policy's whole trade is "more GPU reads served over PCIe" vs "no mirror coherence on the CPU" — so the read side's cost scales directly with link generation and width, and a 2-4x link difference is exactly the kind of thing that could flip a GPU-time delta between vendors independent of driver behavior or BAR size.
If you re-run, could you record the link state alongside the fps numbers? Either is fine:
Also worth checking whether Resizable BAR is actually enabled for the card (
nvidia-smi -q | grep -i baror the BIOS setting) — large BAR is what makes guest-memory reads tolerable, and a 3080 Ti with ReBAR off would explain a lot of a +4 ms GPU delta by itself.We'll record our own link state (Gen 5 x16) in the matrix baseline so cross-rig comparisons carry it. The honest framing either way: the trials measure frame time on your hardware, so a policy that correctly keeps mirrors where PCIe reads are expensive and refuses them where they're free should degrade gracefully across link generations — the re-run on the fixed head will show whether it actually does.
Re-run numbers, as promised. Verdict: still slightly negative with trials running (median -1.5%, pairs K/L/M), parity with them off (+0.9%,
RX_VK_DGPU_TRIALS=0) — but the run isolates everything precisely, and one finding changes the PR's context:The counting half of the fix is proven. The H/I/J failure signature (bind volume collapsing 70x on success, read as drift) is gone — every void now shows before≈after binds (23,814→24,009 on the repeatedly-voided pool). Voids dropped 42-44 → 15/run, all post-warp, none at title.
The scene-set rule is now the one blocker. It voids every gameplay trial in a streaming scene: each void cites "1 pool left the scene, 2 entered" — streaming pools legitimately cross the 64-bind anchor floor every window, so the set always moves and no verdict can ever be reached (0 keeps, 0 releases, 0 verdicts across the run; the 23-attempt cycle repeats identically). The floor was calibrated at the title screen and didn't survive contact with streaming gameplay — our calibration mistake, found exactly as designed (the cap bounded the churn; the logs named the cause). Also observed: pool recycling defeats id-keyed set comparison (the 0x109a range rebounded under a new id, 0x7fd3, with stable binds). Fix in flight: tolerate straddlers — fraction-threshold on the anchored set and overlap-aware matching, not any-movement-voids.
The snapshot cost question is answered, and the #1795 fix is vindicated: the
TRIALS=0discriminator pair measured parity (+0.9%) with the snapshot at full volume (16.4M as-submitted copies vs 2.2M stock) — its frame-level cost in this scene is noise. What costs is the trial engine's churn (mirror-carrying windows: make/upload/free ≈ 1 fps), and that is bounded and fixable.The strategic finding: the win the +9.7%-era pairs measured has largely dried up on main. Those pairs won by freeing memory-sync CPU time (OFF side was 3.24 ms/frame); after two rebases, current main's OFF-side memory sync is already 0.46-0.50 ms — upstream's own work (split-submission fences #2419, presenter thread #2325, the GCN work) absorbed most of that cost. What remains under this PR is the part main does not do: earned mirrors for heavily-read pools (the heavyweight here reads 6.7 GB/window — that is real PCIe traffic even at Gen5 x16) plus the #1795 per-pool protection. The remaining upside is unmeasurable until the set rule tolerates straddlers; the next re-run measures exactly that.
Coherence held everywhere: sync pages ~0 under the switch (133k+ stock), wb confined to trial windows, draw parity ≤1.9%, unset=stock exact, 0 faults in 9/9 runs including the discriminator.
P/Q/R numbers are in, and this round closes the measurement question with a clean split: the machinery is now proven end-to-end, and the positive fps claim is measured to be not defensible on this hardware — with the reason quantified rather than assumed.
What works (first time in the campaign's history):
0x109a000000(160 MB) measured 33.94 → 21.80 ms in the trial's windows (−35.7%), 36.41 → 22.31, 24.68 → 22.03 across the three pairs — kept every session, and nearly free to maintain (0 dirty pages synced per window for 160 MB of VRAM). This is the same pool that was recycled across H/I/J and voided 16–17×/run; the anchor tolerance and range matching handled it.0x1186000000(the 144 MB pool reading ~7 GB/window) trialed at 21.70 → 21.41 — a 0.29 ms (1.3%) gain, below the 10% AND-bar, released. ~7 GB/window of guest reads costs 0.29 ms on this rig: that is the ReBAR/Gen5 PCIe story measured directly.Why the pairs still lose: the mirror beats the policy's own direct mode by 10.7–39.7% in the trial windows — the trial metric is truthful and the keep is legitimate. But stock runs the same scene at 20.4–21.0 ms: on this ReBAR/Gen5 x16 rig, guest-memory GPU reads are nearly free (the 0.29 ms/7 GB number is the proof), the #1795 UB snapshot converts some reads into stream copies (its cost: parity, per the
TRIALS=0discriminator last round), and a mirror can only climb back to near-stock, never past it. Best policy state: −2.7% median (P −4.1 / Q −1.3 / R −2.7), steady-state confirmed post-keep, not a trial-window artifact.So the fork, put to the maintainers rather than assumed:
(a) Keep the PR open as opt-in machinery with no performance claim on this hardware — the value case is now empirically confined to hardware where guest reads are not nearly free: small-BAR / Gen4-class / proprietary-driver rigs (the @cashcon57 3080 Ti datapoint is exactly that class; @hckrman101's matrix predicted it). The trials would do the work there that they demonstrably do here — just with a different verdict landscape.
(b) Withdraw, and re-file the #1795 per-pool UB snapshot standalone — it is correctness-only, measured at parity cost, keyed per pool (main's keying is global mirror-off, which leaves mixed states unprotected), fully tested, and lands independent of this PR's fate.
We hold both paths ready and will follow the maintainers' preference; either way, no more GPU-side churn from this branch until the direction is set. The correctness results held through every round: sync pages ~0 under the switch (131k+ stock), wb confined to mirror windows, draw parity, unset=stock exact, 0 faults in 7/7.
For what it's worth: the review's hardware matrix framing turned out to be the precise predictor of this outcome — the rig where the win was measured turned out to be the rig where the win can't exist, and the numbers now say so explicitly instead of by omission.
Hardware identification, to remove an ambiguity several messages back could leave: every measurement we have posted in this PR — pairs E through R, the
RX_VK_DGPU_TRIALS=0discriminator, the coherence counters — ran on one rig: RX 9070 XT (Navi 48), ASRock Steel Legend, RADV/Mesa on Linux, PCIe Gen5 x16 with ReBAR enabled. The RTX 3080 Ti (driver 615.78.08, CachyOS) numbers are @cashcon57's separate rig, measured independently — the two are never mixed in any row above. Where our comments say "this rig" or "this hardware", they mean the 9070 XT; where the 3080 Ti is meant, it is named.Following up on the fork question above, one piece is now independent of this PR's fate: the #1795 per-pool uniform-buffer snapshot has been extracted into its own PR — #2609 (based on current main, one commit, 4 files +209/−5).
What it is, in one line: the snapshot condition re-keyed from global ("no mirrors on the device") to per binding ("this bind is the pool's guest memory and that pool is mirror-less"), so the mirrored device's own mirror-less states are covered — the budget's refusals (pools > 1 GB, budget-full), the queued/deferred-make windows (
RX_VK_MIRROR_DEFER, parallel segments, written-this-submission, failed-make retry) — which the global keying left exposed. Global-off (iGPU /RX_VK_MIRROR=0/ GCN default) is the degenerate case and behaves byte-identically to the keying #1795 proposed (measured: 1.28M copies on the 9070 XT withRX_VK_MIRROR=0). One documented behavior is superseded and stated plainly in the body:RX_VK_UB_SNAPSHOT=0now means the per-binding rule rather than fully-off.Cost, measured not inferred: parity in gameplay (+0.9%, inside noise) via the
RX_VK_DGPU_TRIALS=0discriminator above, while serving ~14M copies/run. Provenance and the hazard analysis trace back to this thread; the PR body links here.No policy machinery travels with it — no trials, no skip list, no
RX_VK_DGPUgating. This PR's fork question stays with the maintainers as posed; #2609 simply lets the correctness piece land whenever it's ready, whichever way (a) or (b) goes.The review points are addressed on
71add749:dgpu_bindsis counted only with!paron the lock-free path (gpu/vk/vk_memory.cpp:2872,:2881), a pool gives up after three aborts, and the skip list records device and driver. But it now conflicts with main invk_memory.cpp,vk_draw.cpp,docs/development.mdandtests/direct_buffer_writes_test.cpp.More to the point, your P/Q/R runs answer the question: on the RX 9070 XT the best policy state is 2.7% slower than main, whose memory sync is already about 0.5 ms a frame, and cashcon57's RTX 3080 Ti went from 46 to 34-39 fps with 4 ms more GPU time. No measured hardware comes out ahead; the small-BAR case is a guess. The correctness piece, the snapshot keyed on the binding's pool, is now #2609, the part worth landing.
I'd close this in favour of #2609 (your option b) and reopen if someone measures a win on a small-BAR or older-PCIe card. If it comes back,
hookshot_mirror_skip.txt(:2735) belongs undercache/once #2317 is in. Thanks for measuring and reporting this so carefully.