fix(vk): scan the import table as far back as its largest live import #431
No reviewers
Labels
No labels
area/accessibility
area/audio
area/input
area/install
area/menu
area/renderer
area/saves
area/upscaling
beanstalk: approved @ 1697603
beanstalk: approved @ 2c5a62e
beanstalk: approved @ 4fd82b7
beanstalk: approved @ 64d6d09
beanstalk: approved @ 6f91fae
beanstalk: approved @ 70a78f9
beanstalk: approved @ 8015c9a
beanstalk: approved @ 8d361dc
beanstalk: approved @ 9d8356e
beanstalk: approved @ abf2227
beanstalk: approved @ d82f202
beanstalk: approved @ e5f1e40
beanstalk: approved @ ee8ab5f
ci: approved
good first issue
gpu/amd
gpu/igpu
gpu/intel
gpu/nvidia
help wanted
kind
bug
kind
chore
kind
docs
kind
feature
kind
performance
kind
question
needs measurements
platform/linux
platform/macos
platform/steam-deck
platform/windows
preview
priority
critical
priority
high
priority
low
regression
status
blocked
status
confirmed
status
duplicate
status
invalid
status
needs-info
status
wontfix
testing wanted
ci: approved
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
MagicBeanDev/Longshot!431
Loading…
Reference in a new issue
No description provided.
Delete branch "mooki/Longshot:fix/import-lookback"
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?
Type: fix · Risk: low · Tested on: macOS 26.7.1, Apple M4 Max (game-free checks, arm64 and x86_64 object compiles; no game run) · Needs: Windows or Linux, any GPU
Why
Once an import of game memory is larger than 1 GiB, the renderer can lose track of it. It can then import the same bytes
a second time, read them through a stale second copy, or let the game's freed pages under the large import be
decommitted while the GPU still maps them (a device fault).
vk_memory.cppkeeps its imports in a map by start address. Every scan down that map stopped once an import ended morethan 1 GiB below the address ("imports are at most 1 GB"). That is not true.
pool_lookup's merge escape keeps a wantedrange larger than 1 GiB as one import, over the smaller imports inside it ("would take in … pools: left overlapping"), and
import_poolallows imports up to the device'smax_import_size. A scan that met a small import inside the large onestopped there and never saw the large one. That is reachable whenever a binding (or, with
RX_VK_IMPORT_CHUNK_MB=0, a gamepool) still spans more than 1 GiB once rounded out to whole chunks. How often the game does that is not measured.
How to reproduce
Seen on: found by reading the code; not seen in a game. The game-free test shows it on macOS 26.7.1, Apple M4 Max,
against main 6c9edffe's
vk_memory.cpp. The import table is the same code on every OS.Settings and switches: defaults: a binding that still spans more than 1 GiB once rounded out to whole chunks (or,
with
RX_VK_IMPORT_CHUNK_MB=0, such a game pool).Steps: game-free, from this branch's checkout:
python -I -B tests/test_import_reach.py --cxx clang++ --headers <Vulkan headers> --source <a tree of main>. A binding at 1 GiB imports its 16 MiB chunk C, then a 4 GiB binding from 0goes through the merge escape and leaves the 4 GiB import A over C. Each case then works 2 GiB past C, inside A.
Expected: every scan finds A: no second import of A's bytes,
memory_guest_imported= 1, A markedgpu_only, abackground import inside A dropped, nothing sent to the background importer or prefetched.
Actual: the scans stop at C and miss A: the source check and 6 of 7 cases fail on main (lookup, query, note, drain,
background, prefetch; the lines under Measurements).
What changed
The import table now counts its live imports by size, and every scan down it stops only where no live import can still
reach the address, instead of 1 GiB past an import's end. While every live import is smaller than 1 GiB the scans stop no
later than before; they go further only while an import of 1 GiB or more is live.
import_reach.h, thenimported_add/imported_takeand the eightimport_reach::beyondsites invk_memory.cpp.gpu/vk/import_reach.h(new, no Vulkan).Reachcounts the live imports by span class (std::bit_width).bound()is above every live span, within twice the largest.beyond(r, start, a)says whether a scan going down froman import starting at
startcan stop: nothing further down reachesa.gpu/vk/vk_memory.cpp.live_reach, withimported_addandimported_take. They are now the table's only ways in (pool_lookup,bg_drain,mirrors_trim) and out (retire_pool,imports_give_back), and keep the count.import_reach::beyondinstead of 1 GiB past an import's end:pool_lookup's merge plan,bg_drain,prefetch_chunks(imported_over),import_in_background,memory_note_gpu_writeandmemory_guest_imported.pool_lookup'scoveringandimport_in_background's "covered", looked only at the lastimport starting at or below the range. They now walk down until
beyondtoo, so an import further down that holds thewhole range is found.
before, and sooner when the imports are small (chunk-sized imports of 4 or 16 MiB, up to 64 MiB with
import_span,give a bound of at most 128 MiB). They go further only while an import of 1 GiB or more is live.
tests/test_import_reach.pywithtests/import_reach_test.cpp(new).struct Pool,retire_pool,bg_drain,prefetch_chunks,span_game_pools,pool_lookup,pool_of,import_in_background,memory_note_gpu_write,memory_guest_importedand, where they exist, the two helpers out ofvk_memory.cpp, and runs them against recorded imports: ranges, no payload, no Vulkan, no game.leaves the 4 GiB import A over C. Each case then works 2 GiB past C, inside A.
lookup,query,note,drain,backgroundandprefetcheach expect the scan to find A: no second import ofA's bytes,
memory_guest_imported= 1, A markedgpu_only, a background import inside A dropped, nothing sent to thebackground importer or prefetched.
retirechecks that the bound comes back down after A is retired.headerchecksthe classes, the bound and the stop rule.
imported_add/imported_takechanges the table, and no scan keeps the 1 GiB stop.--source <tree>runs another tree'svk_memory.cpp.live_reachalso cut the two helpers and include the header:nx2/test_import_windows.py(pool_lookup,prefetch_chunks) andtests/test_parallel_rb_refresh.pywithtests/parallel_rb_refresh_test.cpp(memory_note_gpu_write). Both put their own pools in the table throughimported_add, andtest_import_windows's stand-inretire_pooltakes them out throughimported_take.tests/test_mirror_gpu_only.pyfinds the new pool's insertion asimported_add(p). Their checks are otherwise the same.late_fix_build's page lookup,memory_rb_refresh's page lookup andtrims_settle's start. None of them imports, sonone can make a second copy. With a large import over a smaller one, they can miss the large one: a page's fix is not
built or a readback page is not refreshed. That is a follow-up, not part of this fix.
Measurements
Verdict: fixed in the game-free test: it fails on main (the source check and 6 of 7 cases) and passes here (the
source check and 8 of 8 cases). The full game-free suite changes only by the new test and the load-sensitive
test_gpu_hang_watch. A bookkeeping fix: nothing timed (speed is under Not tested).Commits: main
6c9edffe5d(2026-11-14); this PR fix/import-lookback @693acd941b.Hardware: Apple M4 Max (16 CPUs), macOS 26.7.1, Apple clang 21. Game-free: no GPU, no game
Scene: none (a renderer bookkeeping fix; its proof is the test, as CONTRIBUTING's
fixrow allows)Method:
tests/test_import_reach.pyagainst main'sgpu/vk/vk_memory.cpp(6c9edffe) and against this PR;the full game-free suite on both
How to reproduce:
python -I -B tests/test_import_reach.py --cxx clang++ --headers <Vulkan headers>(add--source <a tree of main>for main's side)6c9edffe693acd94tests/test_import_reach.pytests/run_all.py --jobs 6)Screenshots: none; nothing on screen in the game-free tests.
Flags and settings
None.
Testing
Tested:
tests/test_import_reach.pyfails on main and passes here (above).5e072fc8), two deliberate breakages of this PR'svk_memory.cpp(bg_drainputting its importin with
imported.emplace;memory_guest_importedwith the 1 GiB stop again) each made it fail: the first in thesource check, the second in the source check and
query.vk_memory.cpprebased without a change to its text.nx2/test_import_windows.py,tests/test_parallel_rb_refresh.py,tests/test_mirror_gpu_only.py,tests/test_sparse_mirror.py,tests/test_retired_give_back.py,tests/test_import_chunk_default.py,tests/test_import_buffer_binding.py,tests/test_texel_buffer_ownership.pyandtests/test_direct_buffer_writes.py.tests/test_mirror_trim.pyfails on a Mac on both sides before it reaches anything this PR changes (its owntest_mirror_trim.cpp: "call to 'wait' is ambiguous"). Its source check and itstrims_settlerun, called on theirown, pass here.
test_import_reach, absent on main,passes here;
test_gpu_hang_watch: pass on main, FAIL here.test_gpu_hang_watchis load-sensitive (a time bound, 3.5 s against its 3.4 s; load averages at the run's start22.57 31.52 27.73) and is accepted without a rerun. This PR does not touch
vk_replay.cpp.requests, some are checks main added or changed since
5e072fc8):test_appimage_package_retry,test_appimage_player_build,test_collect_wide,test_control,test_cpm_discovery,test_crash_dialog,test_crash_record,test_env_on,test_game_layout,test_lift_cleanup_error,test_linux_preflight,test_longshot_ctl,test_low_memory_notice,test_make_game_cache,test_mem_gate,test_mem_gate_children,test_mirror_trim,test_oot_state_tap,test_pcache_header_read,test_pipeline_cache_file,test_pipeline_cache_save,test_pipeline_play_cache,test_pipeline_tour,test_prof_symbols,test_program_cache_cleanup,test_program_cache_one_variant,test_readback_late,test_report_run,test_split_giants,test_vk_pgo,test_vram_budget_row,test_walk_key.fault-arm64-2026-11-14-180704.ips, recorder_sync_test.exe-2026-11-14-180744.ips).
gpu/vk/vk_memory.cppwith the renderer's flags (nx2/build.py), Apple clang 21, arm64 and x86_64.environin vk_shader.cpp,posix_fallocateinpipeline_cache_file.h). These compiles use a compile-only prelude that declares those two names.
aa95d35f, at497be2d4(693acd94before the rebases ontoe090cc48andaa95d35f):Sweep3's checks of this head (the C/C++ it changes compiled, and its own tests run, on arm64 and x86_64 where they
apply) gave the same results as before;
python -I -B tests/run_all.pyon wave B's mergedb0e2e16, which carriesthis head: 569/32/18 (passed/failed/skipped) against main aa95d35f's 543/33/17 on this Mac, no new failure
(
reports/2026-11-15-rebase-sweep-3.md).Not tested:
before, only stopping sooner. The two covering checks now also find an older import that holds the range under a newer,
smaller one the merge escape left over it, and use it rather than import again. With an import of 1 GiB or more live,
the scans and the covering checks find it, where main imported its bytes again.
range: a few steps with chunk-sized imports, more while a large import is live. They run only when
pool_of's cachesmiss and, with
RX_VK_ASYNC_IMPORT=1, for a read binding in a chunk not imported yet. Not measured.std::bit_width(<bit>, asvk_bank.cppdoes) and the map's node handles, whichvk_memory.cppalready used.Must be tested:
497be2d4) and mainaa95d35fthe same way (llvm-mingw onWindows), run
python -I -B tests/run_all.pyin each checkout, then play a few minutes with this PR, expecting thesame suite results as main plus
test_import_reachpassing here, and no change inlongshot_log.txtor on screen; ifa line
vk: pool … would take in … pools, … MB: left overlappingappears, no latervk: pool … imported … as lo..hiinside that range, where main can import those bytes again (by hand: test request T-4R7H).
Risks
What could break:
of 1 GiB or more is live, and no later than before otherwise.
imported_add, the bound could be too low and a scan could stop too early.tests/test_import_reach.py's source check fails on that.vk_memory.cpp,tests/test_mirror_gpu_only.pyandtests/test_parallel_rb_refresh.py(git merge-tree): whichever lands secondneeds a rebase. The macOS port carries the same fix in its renderer branch; it rebases onto this one.
0030e14a(imported_add(Pool&&)and thetest_import_reach/test_darwin_rendererfixtures) makes #431 and #491 build together on macOS.main6c9edffe(2026-11-14) without a conflict: main had not changed any file this PR touches since itsbase.
Turning it off: revert the commit. There is no switch.
Compatibility: Nothing changes:
vk_memory.cppandimport_reach.hare not in the translator stamp(
nx2/translator_files.py). Saves, the options file, the shader and pipeline caches and the vk ABI are unchanged.Checklist
Everyone
type(scope): description) of the status line's type.main: onaa95d35f(#761, 2026-11-15), head497be2d4, with one conflict:gpu/vk/vk_memory.cpp's includes met #735's rewordedmirror_budget.hline (both kept); this PR's own commits, old →new:
07501d53→e968b6d5,693acd94→497be2d4; the commit names elsewhere in this description are from beforethe rebase.
python -I -B tests/run_all.pypasses (any failure that also fails onmainis named). On macOS: 531 passed, 17skipped, and 32 failures that also fail on main
6c9edffeon this Mac, named under Testing, plus the load-sensitivetest_gpu_hang_watchtiming (Testing). After the rebase onto mainaa95d35f(head497be2d4): wave B's mergedb0e2e16, 569/32/18 against main aa95d35f's 543/33/17, no new failure.third_party/untouched (a new folder with aPROVENANCE.mdinstead).When it applies
Assisted-by:/Co-Authored-By:trailers.mainand passing here.tests/that reads changed source updated in this PR, not weakened.Every scan down vk_memory.cpp's import table by start (pool_lookup's covering import and merge plan, bg_drain, prefetch_chunks, import_in_background, memory_note_gpu_write and memory_guest_imported) stopped once an import ended more than 1 GiB below its address, on the belief that no import is larger than that. pool_lookup's own merge escape leaves an import above 1 GiB over the smaller ones inside it ("left overlapping"), and the device allows imports up to max_import_size: a scan stopped at a small import inside a large one, missed the large one, and the same bytes were imported again, two memories over the same bytes. import_reach.h counts the live imports by span class; its bound is above every live span, within twice the largest. imported_add and imported_take, now the table's only ways in and out, keep the count. A scan stops only where no import further down can reach its address, so it also stops sooner than before when the imports are small. The covering-import checks in pool_lookup and import_in_background look past the last import at or below the range for the same reason. Tests: test_import_reach.py runs the actual scans with a 4 GiB import over a 16 MiB chunk, left by the merge escape (imports are recorded ranges: no payload, no Vulkan, no game). Its lookup, query, note, drain, background and prefetch cases fail on main5e072fc8and pass here, and a source check keeps every change to the table in the two helpers. test_import_windows runs pool_lookup with the helpers; test_mirror_gpu_only names the new publication point. Assisted-by: Claude Opus 5.5 Co-Authored-By: Claude Opus 5.5 <[email protected]>83edcf62b6693acd941bWIP: fix(vk): scan the import table as far back as its largest live importto fix(vk): scan the import table as far back as its largest live import693acd941b639ab978be639ab978be497be2d480View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.