Repository navigation
perf(vk): a PC-like dGPU policy (RX_VK_DGPU=1) - direct guest-memory binds, mirrors only where a measured trial proves them - #2338
blessedplummer wants to merge 1 commit into
Conversation
|
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. |
9538f3a to
299db9a
Compare
hckrman101
left a comment
There was a problem hiding this comment.
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
- With
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. - Direct binds use the same import as the iGPU /
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. - The tests need no game.
test_dgpu_policy.pyhasRUN_ALL_ARGSand run_all finds it. It passed on Ubuntu and Windows CI, and the newdgpu_defaultcase is listed inCASES. - CI: the only red job is Windows part 2, on
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=1 doesn'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 into r.cmd only. mirror_sync and mirror_make record their copies into upload_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)
- The
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. - On the fast path,
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. - An abort is retried forever. With
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. - The skip list is keyed only on the guest pool start, and a verdict lasts forever. One 120-frame window in one scene decides it for good, even across a GPU swap, a driver update or a different resolution. It might be worth recording the device or driver in the file, or letting entries expire.
- Nit:
RUN_ALL_ARGS = ["--cxx", "g++"]hard-codes g++. Neighbouring tests use"{cxx}", so a clang-only machine still runs them.
Questions for you
- Do you recommend turning this on by default, either on dGPUs or everywhere? Why or why not? Your own numbers show parity in lighter scenes and a win that grows with coherence load, so I'd like to hear where you think the line is.
- If we do turn it on, which settings and thresholds should we use? The 120/12/120-frame trial windows, the 1 MB candidate floor,
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? - What testing do we need before flipping it, and what have you already tested? I'm thinking of NVIDIA (proprietary on Windows and Linux), AMD (RADV and the Windows driver, including small-BAR / non-ReBAR systems, where PCIe reads aren't nearly free), Intel Arc, and iGPUs / UMA, on both Linux and Windows. For scenes, a few heavy and light ones (Hyrule Field, Kokiri, Kakariko, a dungeon, menus and pause, save reload) at 1080p and 4K, capped and uncapped. For each: fps and frame-time percentiles, flicker/artifact screenshots (the #1795 failure mode), and correctness (draw parity, readbacks, no cyan after a pause as in #2154). So far I only see RX 9070 XT / RADV at 4K.
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).
| // binds defer the make, so the measured window would compare direct against direct and the verdict would | ||
| // skip-list a pool for a measurement that never happened. Trials are a serial-replay feature: RX_VK_PARALLEL | ||
| // runs the stock all-mirrored policy instead) | ||
| if (!trials || par_active.load(std::memory_order_relaxed) || dgpu_trial.phase != dgpu::Trial::Idle) return; |
There was a problem hiding this comment.
par_active is set only while the segments are recorded (vk_replay.cpp sets it true, then false after par_pool.done), so it's always false here in memory_end_submission. Trials do start under RX_VK_PARALLEL, which contradicts the docs row and the PR description. If parallel should really be excluded, check the configured thread count (the RX_VK_PARALLEL value) instead.
| // would be stock paying ~8% of the replay for nothing - the counter is the pool's own field) | ||
| if (dgpu_on()) { | ||
| if (!par) p->dgpu_read += size; | ||
| if (dgpu_trial.lo == p->lo) p->dgpu_binds++; |
There was a problem hiding this comment.
dgpu_read is guarded by !par, but this increment isn't. Parallel segment workers take this lock-free path concurrently, so dgpu_binds is a plain non-atomic ++ from several threads: a data race, and lost counts feed the 2x bind-volume check. Guard it with !par too, or use a relaxed atomic.
| if (p->mstate == Pool::MIRROR_NONE) { // (made at the next submission's start) | ||
| p->mstate = Pool::MIRROR_WANTED; | ||
| mirror_wanted.push_back(p); | ||
| if (dgpu_on() && !p->dgpu_mirror) { // (the policy's default: in guest memory until a trial says otherwise) |
There was a problem hiding this comment.
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=1 keeps mirror_on() true, so a fix keyed on mirrors being off (like #1795's) wouldn't apply here.
| // measuring before it is bound would read the make's whole-pool copy as the pool's cost) | ||
| Pool* tp = dgpu_pool_at(dgpu_trial.lo); | ||
| const bool was_settle = dgpu_trial.phase == dgpu::Trial::Settle; | ||
| const dgpu::Act act = dgpu_trial.step(f, g, tp && tp->mstate == Pool::MIRROR_ON); |
There was a problem hiding this comment.
gpu_time_total_ns() only covers r.cmd (gpu_time_begin/end), but mirror_sync/mirror_make record into upload_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.
| } | ||
| break; | ||
| } | ||
| case dgpu::Act::Abort: { |
There was a problem hiding this comment.
An abort doesn't mark the pool, so the next window picks the same heaviest pool again. With RX_VK_DGPU=1 on 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. Requiring mirror_on() in dgpu_on(), or giving up on a pool after a couple of aborts, would avoid it.
| p->mstate = Pool::MIRROR_REFUSED; // (the fast path's copy: read binds take it without the lock) | ||
| p->dgpu_mirror = false; | ||
| if (p->dgpu_binds) { // (nothing bound it under the mirror: nothing measured; not a verdict, no skip line) | ||
| dgpu_skip_add(p->lo); |
There was a problem hiding this comment.
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?
| | `RX_VK_NO_PROGRAM_CACHE` (set) | Neither reads nor writes `hookshot_programs.bin` and the pipeline notes | | ||
| | `RX_VK_UB_WORD_HASH=0` | A/B test: use the old low-address-bit index for cached uniform words (default: mix upper address bits to reduce aligned-buffer collisions) | | ||
| | `RX_VK_MIRROR=0`, `RX_VK_MIRROR_MB=<MB>` | No VRAM mirrors of the game's memory pools (on for discrete cards); their budget | | ||
| | `RX_VK_DGPU=1` | The dGPU policy: every pool binds in guest memory - the GPU reads and writes the game's memory where it is, with no mirror made, no mirror sync and no write-back - and a pool the GPU reads heavily earns a mirror back through a measured trial (one at a time, the GPU's ms a frame before and after, one verdict a pool; a loss is kept in `hookshot_mirror_skip.txt` beside the game's own files, so later runs do not repeat it). A pool is a candidate when the GPU read at least 1 MB of it in guest memory over a window and did not write that much itself; trials run on a serial replay only (RX_VK_PARALLEL keeps the stock all-mirrored policy, whose mirror a parallel segment's binds defer). Unset: stock mirrors | |
There was a problem hiding this comment.
"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_PARALLEL with RX_VK_DGPU=1, pools are still marked refused at first bind, and trials still start (see the par_active comment). Either the code or this row needs to change.
| import tempfile | ||
| from pathlib import Path | ||
|
|
||
| RUN_ALL_ARGS = ["--cxx", "g++"] |
There was a problem hiding this comment.
Nit: the neighbouring tests use RUN_ALL_ARGS = ["--cxx", "{cxx}"], so run_all picks the compiler it found (clang++ first). Hard-coding g++ 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:
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. |
Joey-Magnus
left a comment
There was a problem hiding this comment.
Opt-in, so nothing changes for anyone until RX_VK_DGPU is set. The red Windows part 2 job is main's old scanf/ASTC failure (#2397). Waiting on the fix push for the accepted review points: #1795 in-place reads, trial timing GPU work only, the dgpu_binds++ race, the abort retried forever, and skip-list keying. hookshot_mirror_skip.txt should go under cache/ if #2317 lands first.
…ed, and its trials decide on frame time hckrman101's review of NotRexed#2338, all points taken: - Issue 1795's in-place hazard, per pool. A refused pool binds guest memory the recording executes, and the game rewrites its constants for the next frame before the GPU gets there - on the Deck that was the giant HUD heart, slabs and stretched geometry. The UB snapshot that fix provides for mirror-less devices now covers the policy's mirror-less pools too (dgpu_ub_in_place: a binding now says whether it is guest memory a recording will execute); a trial-won pool's mirror was synced at the submit and keeps the in-place bind. - The verdict compares the frame the player gets: present-to-present (present_time_total_ns, one clock read a present, never reset), not the recordings' GPU timestamps - the mirror's cost is mostly CPU and in the upload command buffer those never time, and a GPU-only verdict kept mirrors while the frame was CPU-bound, which is the case this policy wins. The windows stay 120/12/120. The GPU-only total is gone. - The claims and the code agree on parallel replays, with no race: the trials are gated on RX_VK_PARALLEL itself (par_active only covers the segments' recording and is false again by the time the pick runs), so the policy runs trial-less pure direct under it, and the fast path's counters (dgpu_read, dgpu_binds) only run when no parallel recording is in progress - they are lock-free there and fed the 2x bind-volume check with lost counts before. - No more abort-forever: the trials need a mirror that can come on (with mirrors off - an iGPU - there are none at all: direct is right on unified memory), and a trial whose mirror never comes on (a full mirror budget) is given up three times at most before the pool sits out for the session. - The skip list says the device and driver its verdicts were measured on; a file from another device or driver, or an older build's without the header, is ignored and its pools trial again. (Placement note for upstream's NotRexed#2317 at the path's single site: its per-user cache dir would take the file when it lands.) - The check runs on any C++ compiler ({cxx}, as the neighbouring tests do). Co-authored-by: GLM-5.3-Flash <glm-5.3-flash@opencode.noreply.github.com>
713ab28 to
02002e1
Compare
…ed, and its trials decide on frame time hckrman101's review of NotRexed#2338, all points taken: - Issue 1795's in-place hazard, per pool. A refused pool binds guest memory the recording executes, and the game rewrites its constants for the next frame before the GPU gets there - on the Deck that was the giant HUD heart, slabs and stretched geometry. The UB snapshot that fix provides for mirror-less devices now covers the policy's mirror-less pools too (dgpu_ub_in_place: a binding now says whether it is guest memory a recording will execute); a trial-won pool's mirror was synced at the submit and keeps the in-place bind. - The verdict compares the frame the player gets: present-to-present (present_time_total_ns, one clock read a present, never reset), not the recordings' GPU timestamps - the mirror's cost is mostly CPU and in the upload command buffer those never time, and a GPU-only verdict kept mirrors while the frame was CPU-bound, which is the case this policy wins. The windows stay 120/12/120. The GPU-only total is gone. - The claims and the code agree on parallel replays, with no race: the trials are gated on RX_VK_PARALLEL itself (par_active only covers the segments' recording and is false again by the time the pick runs), so the policy runs trial-less pure direct under it, and the fast path's counters (dgpu_read, dgpu_binds) only run when no parallel recording is in progress - they are lock-free there and fed the 2x bind-volume check with lost counts before. - No more abort-forever: the trials need a mirror that can come on (with mirrors off - an iGPU - there are none at all: direct is right on unified memory), and a trial whose mirror never comes on (a full mirror budget) is given up three times at most before the pool sits out for the session. - The skip list says the device and driver its verdicts were measured on; a file from another device or driver, or an older build's without the header, is ignored and its pools trial again. (Placement note for upstream's NotRexed#2317 at the path's single site: its per-user cache dir would take the file when it lands.) - The check runs on any C++ compiler ({cxx}, as the neighbouring tests do). Co-authored-by: GLM-5.3-Flash <glm-5.3-flash@opencode.noreply.github.com>
02002e1 to
6411767
Compare
|
The fix push is up (head All six findings, fixed:
Rebase notes: main's #2343 (GCN mirror-less by default) composes unchanged — on GCN, 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: 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 |
|
An NVIDIA data point, in case it helps scope the policy: on this card 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%),
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. |
6411767 to
ee3e782
Compare
…ed, and its trials decide on frame time hckrman101's review of NotRexed#2338, all points taken: - Issue 1795's in-place hazard, per pool. A refused pool binds guest memory the recording executes, and the game rewrites its constants for the next frame before the GPU gets there - on the Deck that was the giant HUD heart, slabs and stretched geometry. The UB snapshot that fix provides for mirror-less devices now covers the policy's mirror-less pools too (dgpu_ub_in_place: a binding now says whether it is guest memory a recording will execute); a trial-won pool's mirror was synced at the submit and keeps the in-place bind. - The verdict compares the frame the player gets: present-to-present (present_time_total_ns, one clock read a present, never reset), not the recordings' GPU timestamps - the mirror's cost is mostly CPU and in the upload command buffer those never time, and a GPU-only verdict kept mirrors while the frame was CPU-bound, which is the case this policy wins. The windows stay 120/12/120. The GPU-only total is gone. - The claims and the code agree on parallel replays, with no race: the trials are gated on RX_VK_PARALLEL itself (par_active only covers the segments' recording and is false again by the time the pick runs), so the policy runs trial-less pure direct under it, and the fast path's counters (dgpu_read, dgpu_binds) only run when no parallel recording is in progress - they are lock-free there and fed the 2x bind-volume check with lost counts before. - No more abort-forever: the trials need a mirror that can come on (with mirrors off - an iGPU - there are none at all: direct is right on unified memory), and a trial whose mirror never comes on (a full mirror budget) is given up three times at most before the pool sits out for the session. - The skip list says the device and driver its verdicts were measured on; a file from another device or driver, or an older build's without the header, is ignored and its pools trial again. (Placement note for upstream's NotRexed#2317 at the path's single site: its per-user cache dir would take the file when it lands.) - The check runs on any C++ compiler ({cxx}, as the neighbouring tests do). Co-authored-by: GLM-5.3-Flash <glm-5.3-flash@opencode.noreply.github.com>
ee3e782 to
41dd125
Compare
|
Rebased now — head Two notes on the current CI run:
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 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:
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 ( 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%, 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 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. |
A dGPU has its own fast memory and a slower bus to system RAM; stock mirrors every guest pool into VRAM and keeps both copies coherent with captures, shadows and write-backs. At 4K that coherence is CPU-bound: the write-back diff compares ~3.1 GB per 600 submits and mirror syncs move ~137k dirty pages, for 35.6 fps where 1080p runs 60 (issue NotRexed#2099). Under RX_VK_DGPU=1 a pool's first binding makes no mirror (MIRROR_REFUSED, the state iGPU runs use today): reads take the lock-free TLS path and GPU reads/writes land in the imported guest memory, coherent by construction - no mirror sync, no write-back. Every 600-submit window the heaviest guest-memory-read pool with >=1 MB of reads and not write-dominated runs a measured trial (120 frames direct -> 12 settle, held until the mirror is really on -> 120 mirrored, on the recordings' own timestamps); the mirror stays only when the win is both >10% (RX_VK_DGPU_GAIN) and >0.25 ms (RX_VK_DGPU_FLOOR) on present-to-present frame time, so the verdict sees the mirror's CPU cost and its PCIe benefit alike. Trials whose windows did not measure the same workload (zero binds, a >2x bind shift, a mirror that never came) abort with no verdict and no skip line; a pool that aborts three times sits out the session, and iGPUs run no trials. A lost verdict persists to hookshot_mirror_skip.txt beside the game's own state files, keyed to the device and driver (a foreign or header-less file is ignored and re-trialled). RX_VK_DGPU_TRIALS=0 is pure direct. Trials need serial replay (documented); under RX_VK_PARALLEL the policy runs trial-less. Unset = stock exactly: the added counters and logging are gated on the switch. Refused pools read uniform buffers as submitted (NotRexed#1795's per-pool form: the snapshot engages for mirror-less pools at the read, trial-won pools keep in-place mirror binds), so the artifact class the issue documents on mirror-less GPUs is covered under the policy too. Measured on RX 9070 XT / RADV, 4K, Uncapped frame cap, warp-normalized adjacent pairs (Hyrule Field): 70.2->78.0, 71.0->77.0, 59.6->76.6 fps (+9.7% median), draw parity 705 vs 709, GPU 9.2 ms of a ~14 ms frame. Mirror-sync dirty pages 136,872 -> 0; write-back diff 3.06-3.13 GB -> 330 MB or less per 600 submits. Review round 2 (external): RX_VK_DGPU_TRIALS=0's predicate was inverted and now matches the docs (=0 is pure direct); the window's read counter moved off a shared map into the pool (dgpu_read, both direct paths); Settle holds until the mirror is actually live; zero-bind baselines and >2x bind drift between the direct and mirrored windows abort the trial un-blacklisted; keep_line refuses signed and trailing-garbage lines. Review round 3 (hckrman101, Joey-Magnus): refused pools read UBs as submitted (NotRexed#1795 per-pool, live-smoked at 1.26M copies fault-free); verdicts decide on present-to-present frame time instead of GPU-only time (the mirror's CPU and upload-buffer cost is what the frame pays); the policy runs trial-less under RX_VK_PARALLEL and its fast-path counters are parallel-safe; no trials on iGPUs and a 3-abort cap; the skip list carries a device+driver header and ignores foreign files; the test driver uses {cxx}. Follow-up fix: the drift check counted served volume, so a working mirror's read collapse voided 40+ trials a run and trials never converged; binds now count once at the serving path and the drift rule compares the scene's pool set, with a three-no-verdict cap. The set rule still voided every trial in a streaming scene, so anchors now match by guest range (a recycled pool is the same anchor), anchor at 4x the floor, void only when a substantial fraction of the scene moves, and the candidate floor rose to 256 MB of reads a window (the verdict floor is unreachable below it - a trial there is churn by construction). Rebased onto main's env_on switch rule (NotRexed#2042) and the split-submission fence work (NotRexed#2419); the trials' functions sit outside the write-back observation's compile-span check (NotRexed#2131) and the GCN mirror-less default (NotRexed#2343) composes unchanged (on GCN the switch runs trial-less direct plus the UB snapshot). After the presenter-thread refactor (NotRexed#2325) the present-to-present tick lives in present_finish, which every made present passes exactly once in either threading mode, with a headless twin so offscreen runs stay measurable. Co-authored-by: GLM-5.3-Flash <glm-5.3-flash@opencode.noreply.github.com>
41dd125 to
71add74
Compare
|
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):
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 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 |
|
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 ( Cost, measured not inferred: parity in gameplay (+0.9%, inside noise) via the No policy machinery travels with it — no trials, no skip list, no |
**What this is for:** make the two capture-buffer switches follow the documented on/off rule, and restore the existing switch check ([current main CI](https://github.com/NotRexed/Hookshot/actions/runs/43151254256)). **What is measured:** the affected game-free check before and after, with GCC 16.2.1 and Clang 23.1.3 selected, plus one strict full-suite run, as recorded below. **Not measured:** a game run or Windows execution. ## Cause #2437 added two presence-only reads of `RX_VK_CAPTURE_KEEP_OLD` and one of `RX_VK_CAP_BLOCKS` in `gpu/vk/vk_memory.cpp`; setting them to `0` therefore enabled the alternatives, contrary to `hookshot_env.h` and the existing `test_env_on.py` rule. The relevant failure in [main's CI log](https://github.com/NotRexed/Hookshot/actions/runs/43151254256) is: ```text FAIL: on/off switches still read as set-means-on (set to 0 they are on): use env_on: RX_VK_CAPTURE_KEEP_OLD (gpu/vk/vk_memory.cpp:937, gpu/vk/vk_memory.cpp:960) RX_VK_CAP_BLOCKS (gpu/vk/vk_memory.cpp:995) ``` ## What changed Use the existing `env_on` helper at those three sites and document the switches in `docs/development.md`; defaults, enabled behavior, allocation policy and the test remain unchanged. ## How it was tested Recorded on 2026-11-11, Linux kernel `7.2.15-1-omarchy`, base `de866017`, head `8d7f7d47` (version and test records). - Untouched main `a016b7a6`: the affected check fails independently in both compiler environments with the diagnostic above; the branch passes independently in both (`python -I -B tests/run_all.py --strict --jobs 1 tests/test_env_on.py` records). - The unchanged check rejects the three original reads on main with both compilers; its C/C++/Python truth table passes on the branch, including unset, empty, `0`, `false`, `off` and `no` (standalone records). - One full `python -I -B tests/run_all.py --strict`: **`456 passed, 14 failed, 20 skipped in 139 s (linux, 32 at once)`**, load 17.65 → 26.92 (complete suite output). - That run's remaining failures are: `test_wayland_libdecor_hint.py`, `test_display_startup_failure.py`, `test_texel_buffer_ownership.py`, `test_audio_aux.py`, `test_audio_biquad.py`, `test_dlss_checks.py`, `test_framegen_preset.py`, `test_hdr_encode.py`, `test_linux_docker_packages.py`, `test_menu_i18n.py`, `test_mods_size_table.py`, `test_mouse_native.py`, `test_readback_part.py`, `test_rumble_menu.py` (complete suite output). The companion fixture, PPA-parser, camera-handover and hint-catalog branches address their separate causes; #2614 owns the HDR check and is not duplicated here. ## Not tested / risks The deliberate behavior change is for values that explicitly mean off; no Vulkan allocation or game run was performed, so allocation timing and hardware behavior are not measured. ## Overlaps The inspected same-file code changes merge cleanly; #2338 and #2618 add different documentation rows at the same insertion point, so those rows and this one must be retained together (recorded overlap hunks). Assisted-by: GPT-6 Astra Co-Authored-By: GPT-6 Astra <noreply@openai.com> Co-authored-by: GPT-6 Astra <noreply@openai.com>
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 perf(vk): GCN (RX 400/500): f16 lowered where the GPU has none; no VRAM mirrors or batched submits by default (from hookshot-k10) #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 (fix(vk): HUD and lighting artifacts on GPUs without buffer mirrors #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.