fix(vk): samplers past the driver's allocation limit are reused, not lost #442

Open
mooki wants to merge 1 commit from mooki/Longshot:fix/sampler-reuse into main
Member

Type: fix · Risk: medium · Tested on: macOS 26.7.1, Apple M4 Max (game-free checks with a modeled driver, ThreadSanitizer, arm64 and x86_64 object compiles; no game run) · Needs: Windows or Linux, NVIDIA GPU

Why

A driver makes only so many samplers (maxSamplerAllocationCount: NVIDIA's about 4000, NVK's 4096), and the renderer
never destroyed one. It makes a sampler for each sampler state it meets, and every Anisotropic filtering, Render
resolution or Upscaler change makes a new set on top. Past the limit vkCreateSampler fails, and from then on every
draw that needs a new sampler is refused (docs/sampler-admission.md), so those objects stop being drawn for the rest
of the run. Whether a session reaches the limit is not measured.

How to reproduce

Seen on: found by reading the code; not seen in a game (whether a session reaches the limit is not measured). The
game-free test shows it on macOS 26.7.1, Apple M4 Max, with a modeled driver of 80 samplers, against main 6c9edffe's
sources. The samplers are the same code on every OS; the limit is the driver's.
Settings and switches: several Anisotropic filtering, Render resolution or Upscaler changes in one run; each makes a
new set of samplers.
Steps: game-free, from this branch's checkout: python -I -B tests/test_sampler_admission.py --cxx clang++ --headers <Vulkan include folder> --source <a tree of main>. In the game: play on until the samplers made pass the driver's
maxSamplerAllocationCount.
Expected: no draw refused: a sampler the game no longer uses is retired and its room reused.
Actual: past the limit vkCreateSampler fails and every draw that needs a new sampler is refused for the rest of the
run. In the test on main, limit stops at the driver's 81st sampler, and 15 of 150 cases fail.

What changed

Samplers with the same create info now share one sampler, and the renderer keeps the samplers under the driver's limit:
from 3/4 of the room, each new sampler first retires the least recently used one, destroyed once the GPU is past its
last use. Below that point nothing is retired.

  • Read first: sampler_room, sampler_retire and samplers_reap with sampler_for and the top of sampler_make.
  • vk: gpu/vk/vk_draw.cpp, the samplers' section.
    • SamplerBook replaces the samplers map by key. It owns one sampler for each SamplerInfo: the create info
      sampler_make fills in, less what no sample can reach (the anisotropy of a sampler without anisotropic filtering,
      the border colour of one no address mode clamps to its border). Keys with the same info share that sampler. A
      sampler that does not compare keeps its comparison in the info, because NVIDIA's driver compares through it anyway
      (the amd-fix note in sampler_make).
    • sampler_room() keeps the samplers under maxSamplerAllocationCount less 64 (a quarter of a smaller limit), left
      for the renderer's other samplers and an upscaler SDK's. From 3/4 of that room, each new sampler first retires the
      least recently resolved one (sampler_retire).
    • A retired sampler leaves every table at once: the book and its keys; each thread's mine (retired_gen); and
      sampler_for's tables and the descriptors kept by reuse (samplers_changed()). samplers_reap destroys it once
      the timeline reaches the vk_retirement_value() taken at its retirement, as vk_fsr3.cpp retires its images.
    • The threads' tables hide uses from the book. From half the retiring point on (watching), their entries last one
      present, so the book sees each sampler in use once a present, and the one retired is the least recently used.
    • Creation stops only when the samplers made and the retired ones the GPU may still use fill the room. Then nothing
      is retired, and the key fails for that present, as a failed creation does (a log line, 8 at most). No sampler is
      made past the room, and none is swapped for a default one.
  • docs: docs/sampler-admission.md states the rule: reuse, retirement, recency, the full room, and the tests' count.
  • tests: tests/sampler_admission_test.cpp, tests/test_sampler_admission.py.
    • The harness also cuts the samplers' section head (the book, sampler_room and the rest) and gives the book's three
      containers tagged throwing allocators.
    • A modeled driver whose maxSamplerAllocationCount is 80, and a GPU timeline. Five new cases, each run three ways
      (the ID table, the factory itself, the cache off):
      • limit: 300 recordings on two long-lived threads, as a parallel replay's workers. Each recording draws with 10
        samplers every recording uses and a window of 20 that moves 5 a recording, with the GPU 2 recordings behind:
        1,525 samplers in all. None is refused, each is made once, at most 60 are alive, and the modeled
        vkDestroySampler checks that none goes before the GPU has passed the last recording that drew with it.
      • limit-full: the GPU stopped. After 60 made (15 of them retired), the 61st key waits; nothing is made past the
        room and nothing more is retired. Once the GPU passes and a present comes, the 15 are destroyed, they were the
        least recently used, and a retired key is made anew.
      • limit-reuse: keys apart only in what no sample reaches share one sampler. A comparison the sampler is not
        enabled for, and border colours that are clamped to, keep theirs apart.
      • limit-alias-commit and limit-retire-commit: allocation failure in the key table and in noting a retirement.
    • The harness now lists every failing case instead of stopping at the first. The 45 existing cases are unchanged.
    • It fails on main.
  • Design: a thread's own tables can hand out a sampler retired by another thread until its next lookup. Every such use is
    in a recording before the signal the retirement waits for (vk_retirement_value's comment: workers are joined before
    the next signal).
  • Design: a full room does not wait for the GPU. Samplers retired in the recording being made can only go once it is
    submitted, so a wait there could not end; the key waits for a later present instead, as main's failed creation does.

Measurements

Verdict: fixed in the game-free test with a modeled driver: on main the 15 limit cases fail (of 150), here all 150
pass, and the threaded cases run clean under ThreadSanitizer (12 runs, 0 warnings). The full game-free suite has no
status change. No real driver's limit measured: no game run.

Commits: main 6c9edffe5d (2026-11-14); this PR fix/sampler-reuse @
99ca543a22.
Hardware: Apple M4 Max (16 CPUs), macOS 26.7.1, Apple clang 21. Game-free: no GPU or game involved
Scene: none (the proof is the test, as CONTRIBUTING's fix row allows)
Method: tests/test_sampler_admission.py against main's gpu/vk/vk_draw.cpp and gpu/render/rx_nvn.h (6c9edffe)
and against this PR; its threaded cases built with ThreadSanitizer; the full game-free suite on this PR, compared by
test name with main's on the same Mac
How to reproduce: python -I -B tests/test_sampler_admission.py --cxx clang++ --headers <Vulkan include folder> (add
--source <a tree of main> for main's side)

Before: main @ 6c9edffe After: this PR @ 99ca543a
tests/test_sampler_admission.py FAIL: 15 of 150, the 5 limit cases in each of the 3 ways PASS: 150
the threaded cases with ThreadSanitizer (limit, concurrent, shared-thread, thread-retry, 3 ways each) not run: limit is new 12 runs, 0 warnings
full game-free suite on macOS (tests/run_all.py --jobs 6) 531 passed, 32 failed, 17 skipped 531 passed, 32 failed, 17 skipped
main @ 6c9edffe:
FAIL: id limit: Assertion failed: (sampler && live.count(sampler)), function valid, file fixture.cpp, line 528.
FAIL: id limit-full: Assertion failed: (!get(60) && !get(60) && creates == 60 && !limit_failures), function limit_case, file fixture.cpp, line 597.
FAIL: id limit-reuse: Assertion failed: (get(1) == s0 && get(2) == s0 && get(4) == s0 && creates == 1), function limit_case, file fixture.cpp, line 607.
FAIL: id limit-alias-commit: Assertion failed: (allocation_faults == 1 && get() == s && creates == 1 && destroyed == 0), function limit_case, file fixture.cpp, line 617.
FAIL: id limit-retire-commit: Assertion failed: (allocation_faults == 1 && creates == 46 && destroyed == 0 && live.size() == 46), function limit_case, file fixture.cpp, line 624.
(the same five for make and cache-off)
15 of 150 sampler cache/admission/policy/limit cases failed

this PR @ 99ca543a:
150 sampler cache/admission/policy/limit cases passed

On main, limit stops at the driver's 81st sampler: main destroys none, so valid fails on the null that
sampler_make returns once vkCreateSampler refuses.

Screenshots: none; nothing on screen in the game-free tests.

Flags and settings

None.

Testing

Tested:

  • On an Apple M4 Max with macOS 26.7.1, game-free only.
  • tests/test_sampler_admission.py fails on main (the 15 limit cases) and passes here (150).
  • Its fixture with -fsanitize=thread: limit, concurrent, shared-thread and thread-retry in all three ways, with
    no warning.
  • The full suite, compared by test name with main's on the same Mac.
    • By test name, against main 6c9edffe's run on the same Mac: no status changed. tests/test_sampler_admission.py
      passes on both: main's own version there, the extended one here (its main-side failure is the run above).
    • These 32 checks fail here and on main on this Mac, by name (not triaged here: some need the macOS port's other pull
      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.
    • The same 17 checks skipped as on main.
    • Crash reports during the run: rev12-clang-2026-11-14-190631.ips (main's run: fault-arm64-2026-11-14-180704.ips,
      recorder_sync_test.exe-2026-11-14-180744.ips).
  • Object compiles of gpu/vk/vk_draw.cpp with the renderer's flags (nx2/build.py), Apple clang 21, arm64 and x86_64.
    • Main's renderer does not build on macOS as it is. vk_draw.cpp fails the same way on both sides, at
      posix_fallocate (pipeline_cache_file.h; arm64 checked on both).
    • With a compile-only prelude declaring it (and environ), both arches build on main and here.
  • After the rebase onto main aa95d35f, at 60c3c84a (99ca543a before the rebases onto e090cc48 and aa95d35f):
    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.py on wave S1's merge 11a5b4e7, which carries
    this head: 568/22/17 (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:

  • A real driver's limit: no game run, on any GPU. Below 3/4 of the limit (2,952 samplers on NVIDIA) nothing is retired,
    so play there differs from main only in keys sharing a sampler whose create info is the same in everything a sample
    reaches.
  • How many samplers a session makes. The first retirement logs vk: samplers: N made, the device allows M: the least recently used retired, and a full room logs vk: samplers: ... fill the N this device allows.
  • A build on Windows (llvm-mingw) or Linux. The new code uses only the standard library (std::unordered_map,
    std::erase_if, std::atomic) and Vulkan calls the file already makes.

Must be tested:

  • Windows or Linux, an NVIDIA GPU (a limit of about 4000 samplers), any driver: build this PR (head 60c3c84a) and main
    aa95d35f the same way and play a long session with several Render resolution, Upscaler and Anisotropic filtering
    changes, expecting on this PR no vk: samplers: ... fill line and no textures going missing after a vk: samplers: ... retired line, where main logs no vk: samplers: line and, once a session passes the limit, stops drawing what
    needs a new sampler (by hand: test request T-YH69).

Risks

What could break:

  • Medium: a sampler's lifetime against command buffers still in flight. A retired sampler is destroyed only once the
    timeline reaches vk_retirement_value(), a signal after every recording made so far, parallel segments included.
    The modeled driver checks that order in every limit case.
  • Below 3/4 of the limit nothing is retired or destroyed. The costs there are one relaxed atomic load more in
    sampler_for, two atomic loads in sampler_make, and computing a SamplerInfo when a new key is made.
  • Landing order: rebased on main 6c9edffe (2026-11-14) without a conflict; main had changed vk_draw.cpp (#404, #75)
    away from this PR's lines. No conflict with the other macOS-port PRs rebased on 6c9edffe (git merge-tree of the new
    heads, 19:49 CST). The macOS port's TexelDesc branch substitutes a default sampler on Apple where creation fails; under
    this rule it drops that substitution.

Turning it off: revert the commit. There is no switch.

Compatibility: Nothing changes: vk_draw.cpp is not in the translator stamp (nx2/translator_files.py), and saves,
the options file, the shader and pipeline caches and the vk ABI are unchanged.

Checklist

Everyone

  • One topic.
  • The title is a Conventional Commit (type(scope): description) of the status line's type.
  • Why gives the reason plainly and links the issues that fit (the tracker searched).
  • Measurements has a verdict, or one line saying why there is nothing to measure.
  • Testing names what was tested, what wasn't, and what must still be tested, where and how.
  • Rebased on current main: on aa95d35f (#761, 2026-11-15), head 60c3c84a, without a conflict; this PR's own
    commits, old → new: 99ca543a → 60c3c84a; the commit names elsewhere in this description are from before the
    rebase.
  • python -I -B tests/run_all.py passes (any failure that also fails on main is named). On macOS: 531 passed, 17
    skipped, and 32 failures that also fail on main 6c9edffe on this Mac, named under Testing. After the rebase onto main
    aa95d35f (head 60c3c84a): wave S1's merge 11a5b4e7, 568/22/17 against main aa95d35f's 543/33/17, no new failure.
  • No game files, dumps, build output or generated files.
  • third_party/ untouched (a new folder with a PROVENANCE.md instead).
  • The license is OK: GPL-3.0-or-later, nothing incompatible and nothing decompiled.

When it applies

  • AI assistance recorded with Assisted-by: / Co-Authored-By: trailers.
  • A game-free test added for the fix, failing on main and passing here.
  • A check under tests/ that reads changed source updated in this PR, not weakened.
  • Docs updated.
**Type:** fix · **Risk:** medium · **Tested on:** macOS 26.7.1, Apple M4 Max (game-free checks with a modeled driver, ThreadSanitizer, arm64 and x86_64 object compiles; no game run) · **Needs:** Windows or Linux, NVIDIA GPU ## Why A driver makes only so many samplers (`maxSamplerAllocationCount`: NVIDIA's about 4000, NVK's 4096), and the renderer never destroyed one. It makes a sampler for each sampler state it meets, and every Anisotropic filtering, Render resolution or Upscaler change makes a new set on top. Past the limit `vkCreateSampler` fails, and from then on every draw that needs a new sampler is refused (`docs/sampler-admission.md`), so those objects stop being drawn for the rest of the run. Whether a session reaches the limit is not measured. ## How to reproduce **Seen on:** found by reading the code; not seen in a game (whether a session reaches the limit is not measured). The game-free test shows it on macOS 26.7.1, Apple M4 Max, with a modeled driver of 80 samplers, against main 6c9edffe's sources. The samplers are the same code on every OS; the limit is the driver's. **Settings and switches:** several Anisotropic filtering, Render resolution or Upscaler changes in one run; each makes a new set of samplers. **Steps:** game-free, from this branch's checkout: `python -I -B tests/test_sampler_admission.py --cxx clang++ --headers <Vulkan include folder> --source <a tree of main>`. In the game: play on until the samplers made pass the driver's `maxSamplerAllocationCount`. **Expected:** no draw refused: a sampler the game no longer uses is retired and its room reused. **Actual:** past the limit `vkCreateSampler` fails and every draw that needs a new sampler is refused for the rest of the run. In the test on main, `limit` stops at the driver's 81st sampler, and 15 of 150 cases fail. ## What changed Samplers with the same create info now share one sampler, and the renderer keeps the samplers under the driver's limit: from 3/4 of the room, each new sampler first retires the least recently used one, destroyed once the GPU is past its last use. Below that point nothing is retired. - Read first: `sampler_room`, `sampler_retire` and `samplers_reap` with `sampler_for` and the top of `sampler_make`. - vk: `gpu/vk/vk_draw.cpp`, the samplers' section. - `SamplerBook` replaces the `samplers` map by key. It owns one sampler for each `SamplerInfo`: the create info `sampler_make` fills in, less what no sample can reach (the anisotropy of a sampler without anisotropic filtering, the border colour of one no address mode clamps to its border). Keys with the same info share that sampler. A sampler that does not compare keeps its comparison in the info, because NVIDIA's driver compares through it anyway (the `amd-fix` note in `sampler_make`). - `sampler_room()` keeps the samplers under `maxSamplerAllocationCount` less 64 (a quarter of a smaller limit), left for the renderer's other samplers and an upscaler SDK's. From 3/4 of that room, each new sampler first retires the least recently resolved one (`sampler_retire`). - A retired sampler leaves every table at once: the book and its keys; each thread's `mine` (`retired_gen`); and `sampler_for`'s tables and the descriptors kept by reuse (`samplers_changed()`). `samplers_reap` destroys it once the timeline reaches the `vk_retirement_value()` taken at its retirement, as `vk_fsr3.cpp` retires its images. - The threads' tables hide uses from the book. From half the retiring point on (`watching`), their entries last one present, so the book sees each sampler in use once a present, and the one retired is the least recently used. - Creation stops only when the samplers made and the retired ones the GPU may still use fill the room. Then nothing is retired, and the key fails for that present, as a failed creation does (a log line, 8 at most). No sampler is made past the room, and none is swapped for a default one. - docs: `docs/sampler-admission.md` states the rule: reuse, retirement, recency, the full room, and the tests' count. - tests: `tests/sampler_admission_test.cpp`, `tests/test_sampler_admission.py`. - The harness also cuts the samplers' section head (the book, `sampler_room` and the rest) and gives the book's three containers tagged throwing allocators. - A modeled driver whose `maxSamplerAllocationCount` is 80, and a GPU timeline. Five new cases, each run three ways (the ID table, the factory itself, the cache off): - `limit`: 300 recordings on two long-lived threads, as a parallel replay's workers. Each recording draws with 10 samplers every recording uses and a window of 20 that moves 5 a recording, with the GPU 2 recordings behind: 1,525 samplers in all. None is refused, each is made once, at most 60 are alive, and the modeled `vkDestroySampler` checks that none goes before the GPU has passed the last recording that drew with it. - `limit-full`: the GPU stopped. After 60 made (15 of them retired), the 61st key waits; nothing is made past the room and nothing more is retired. Once the GPU passes and a present comes, the 15 are destroyed, they were the least recently used, and a retired key is made anew. - `limit-reuse`: keys apart only in what no sample reaches share one sampler. A comparison the sampler is not enabled for, and border colours that are clamped to, keep theirs apart. - `limit-alias-commit` and `limit-retire-commit`: allocation failure in the key table and in noting a retirement. - The harness now lists every failing case instead of stopping at the first. The 45 existing cases are unchanged. - It fails on main. - Design: a thread's own tables can hand out a sampler retired by another thread until its next lookup. Every such use is in a recording before the signal the retirement waits for (`vk_retirement_value`'s comment: workers are joined before the next signal). - Design: a full room does not wait for the GPU. Samplers retired in the recording being made can only go once it is submitted, so a wait there could not end; the key waits for a later present instead, as main's failed creation does. ## Measurements **Verdict:** fixed in the game-free test with a modeled driver: on main the 15 limit cases fail (of 150), here all 150 pass, and the threaded cases run clean under ThreadSanitizer (12 runs, 0 warnings). The full game-free suite has no status change. No real driver's limit measured: no game run. Commits: main 6c9edffe5da1f5b5383977aeab3f544c0de25d4c (2026-11-14); this PR fix/sampler-reuse @ 99ca543a225feae8829c807bdfa369555fa195b9. Hardware: Apple M4 Max (16 CPUs), macOS 26.7.1, Apple clang 21. Game-free: no GPU or game involved Scene: none (the proof is the test, as CONTRIBUTING's `fix` row allows) Method: `tests/test_sampler_admission.py` against main's `gpu/vk/vk_draw.cpp` and `gpu/render/rx_nvn.h` (6c9edffe) and against this PR; its threaded cases built with ThreadSanitizer; the full game-free suite on this PR, compared by test name with main's on the same Mac How to reproduce: `python -I -B tests/test_sampler_admission.py --cxx clang++ --headers <Vulkan include folder>` (add `--source <a tree of main>` for main's side) | | Before: main @ 6c9edffe | After: this PR @ 99ca543a | |---|---|---| | `tests/test_sampler_admission.py` | FAIL: 15 of 150, the 5 limit cases in each of the 3 ways | PASS: 150 | | the threaded cases with ThreadSanitizer (`limit`, `concurrent`, `shared-thread`, `thread-retry`, 3 ways each) | not run: `limit` is new | 12 runs, 0 warnings | | full game-free suite on macOS (`tests/run_all.py --jobs 6`) | 531 passed, 32 failed, 17 skipped | 531 passed, 32 failed, 17 skipped | ``` main @ 6c9edffe: FAIL: id limit: Assertion failed: (sampler && live.count(sampler)), function valid, file fixture.cpp, line 528. FAIL: id limit-full: Assertion failed: (!get(60) && !get(60) && creates == 60 && !limit_failures), function limit_case, file fixture.cpp, line 597. FAIL: id limit-reuse: Assertion failed: (get(1) == s0 && get(2) == s0 && get(4) == s0 && creates == 1), function limit_case, file fixture.cpp, line 607. FAIL: id limit-alias-commit: Assertion failed: (allocation_faults == 1 && get() == s && creates == 1 && destroyed == 0), function limit_case, file fixture.cpp, line 617. FAIL: id limit-retire-commit: Assertion failed: (allocation_faults == 1 && creates == 46 && destroyed == 0 && live.size() == 46), function limit_case, file fixture.cpp, line 624. (the same five for make and cache-off) 15 of 150 sampler cache/admission/policy/limit cases failed this PR @ 99ca543a: 150 sampler cache/admission/policy/limit cases passed ``` On main, `limit` stops at the driver's 81st sampler: main destroys none, so `valid` fails on the null that `sampler_make` returns once `vkCreateSampler` refuses. Screenshots: none; nothing on screen in the game-free tests. ### Flags and settings None. ## Testing **Tested:** - On an Apple M4 Max with macOS 26.7.1, game-free only. - `tests/test_sampler_admission.py` fails on main (the 15 limit cases) and passes here (150). - Its fixture with `-fsanitize=thread`: `limit`, `concurrent`, `shared-thread` and `thread-retry` in all three ways, with no warning. - The full suite, compared by test name with main's on the same Mac. - By test name, against main 6c9edffe's run on the same Mac: no status changed. `tests/test_sampler_admission.py` passes on both: main's own version there, the extended one here (its main-side failure is the run above). - These 32 checks fail here and on main on this Mac, by name (not triaged here: some need the macOS port's other pull 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`. - The same 17 checks skipped as on main. - Crash reports during the run: rev12-clang-2026-11-14-190631.ips (main's run: fault-arm64-2026-11-14-180704.ips, recorder_sync_test.exe-2026-11-14-180744.ips). - Object compiles of `gpu/vk/vk_draw.cpp` with the renderer's flags (`nx2/build.py`), Apple clang 21, arm64 and x86_64. - Main's renderer does not build on macOS as it is. `vk_draw.cpp` fails the same way on both sides, at `posix_fallocate` (`pipeline_cache_file.h`; arm64 checked on both). - With a compile-only prelude declaring it (and `environ`), both arches build on main and here. - **After the rebase onto main aa95d35f**, at `60c3c84a` (`99ca543a` before the rebases onto e090cc48 and aa95d35f): 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.py` on wave S1's merge `11a5b4e7`, which carries this head: 568/22/17 (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:** - A real driver's limit: no game run, on any GPU. Below 3/4 of the limit (2,952 samplers on NVIDIA) nothing is retired, so play there differs from main only in keys sharing a sampler whose create info is the same in everything a sample reaches. - How many samplers a session makes. The first retirement logs `vk: samplers: N made, the device allows M: the least recently used retired`, and a full room logs `vk: samplers: ... fill the N this device allows`. - A build on Windows (llvm-mingw) or Linux. The new code uses only the standard library (`std::unordered_map`, `std::erase_if`, `std::atomic`) and Vulkan calls the file already makes. **Must be tested:** - Windows or Linux, an NVIDIA GPU (a limit of about 4000 samplers), any driver: build this PR (head 60c3c84a) and main aa95d35f the same way and play a long session with several Render resolution, Upscaler and Anisotropic filtering changes, expecting on this PR no `vk: samplers: ... fill` line and no textures going missing after a `vk: samplers: ... retired` line, where main logs no `vk: samplers:` line and, once a session passes the limit, stops drawing what needs a new sampler (by hand: test request T-YH69). ## Risks **What could break:** - Medium: a sampler's lifetime against command buffers still in flight. A retired sampler is destroyed only once the timeline reaches `vk_retirement_value()`, a signal after every recording made so far, parallel segments included. The modeled driver checks that order in every limit case. - Below 3/4 of the limit nothing is retired or destroyed. The costs there are one relaxed atomic load more in `sampler_for`, two atomic loads in `sampler_make`, and computing a `SamplerInfo` when a new key is made. - Landing order: rebased on `main` 6c9edffe (2026-11-14) without a conflict; main had changed `vk_draw.cpp` (#404, #75) away from this PR's lines. No conflict with the other macOS-port PRs rebased on 6c9edffe (`git merge-tree` of the new heads, 19:49 CST). The macOS port's TexelDesc branch substitutes a default sampler on Apple where creation fails; under this rule it drops that substitution. **Turning it off:** revert the commit. There is no switch. **Compatibility:** Nothing changes: `vk_draw.cpp` is not in the translator stamp (`nx2/translator_files.py`), and saves, the options file, the shader and pipeline caches and the vk ABI are unchanged. ## Checklist ### Everyone - [x] One topic. - [x] The title is a Conventional Commit (`type(scope): description`) of the status line's type. - [x] Why gives the reason plainly and links the issues that fit (the tracker searched). - [x] Measurements has a verdict, or one line saying why there is nothing to measure. - [x] Testing names what was tested, what wasn't, and what must still be tested, where and how. - [x] Rebased on current `main`: on aa95d35f (#761, 2026-11-15), head `60c3c84a`, without a conflict; this PR's own commits, old → new: `99ca543a` → `60c3c84a`; the commit names elsewhere in this description are from before the rebase. - [x] `python -I -B tests/run_all.py` passes (any failure that also fails on `main` is named). On macOS: 531 passed, 17 skipped, and 32 failures that also fail on main 6c9edffe on this Mac, named under Testing. After the rebase onto main aa95d35f (head `60c3c84a`): wave S1's merge `11a5b4e7`, 568/22/17 against main aa95d35f's 543/33/17, no new failure. - [x] No game files, dumps, build output or generated files. - [x] `third_party/` untouched (a new folder with a `PROVENANCE.md` instead). - [x] The license is OK: GPL-3.0-or-later, nothing incompatible and nothing decompiled. ### When it applies - [x] AI assistance recorded with `Assisted-by:` / `Co-Authored-By:` trailers. - [x] A game-free test added for the fix, failing on `main` and passing here. - [x] A check under `tests/` that reads changed source updated in this PR, not weakened. - [x] Docs updated.
fix(vk): samplers past the driver's allocation limit are reused, not lost
All checks were successful
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
CI / Conventional Commit title (pull_request) Successful in 7s
PR description / Description follows the template (pull_request_target) Successful in 8s
CI / Workflow lint (actionlint) (pull_request) Successful in 10s
Third-party parts / As their notes say (pull_request) Successful in 7s
Website / Website on Cloudflare (pull_request_target) Has been skipped
8342d01a67
A driver makes at most maxSamplerAllocationCount samplers (NVIDIA about 4000), and sampler_make never destroyed one:
past the limit vkCreateSampler failed and every draw needing a new sampler was refused for the rest of the run.

- gpu/vk/vk_draw.cpp: a sampler book owns the samplers made, one for each distinct create info (less what no sample
  reaches), shared by every key that resolves to it. From 3/4 of the room under the limit, each new sampler retires the
  least recently resolved one: out of every table at once (a retirement generation, samplers_changed), destroyed once
  the timeline reaches vk_retirement_value(). Near the limit the threads' tables last a present, so the book sees the
  samplers in use.
- docs/sampler-admission.md: the rule as it is now.
- tests: a modeled driver with a limit of 80 (the limit cases), failing on main.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
mooki force-pushed fix/sampler-reuse from 8342d01a67
All checks were successful
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
CI / Conventional Commit title (pull_request) Successful in 7s
PR description / Description follows the template (pull_request_target) Successful in 8s
CI / Workflow lint (actionlint) (pull_request) Successful in 10s
Third-party parts / As their notes say (pull_request) Successful in 7s
Website / Website on Cloudflare (pull_request_target) Has been skipped
to 99ca543a22
All checks were successful
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
Website / Website on Cloudflare (pull_request) Has been skipped
CI / Conventional Commit title (pull_request) Successful in 3s
CI / Workflow lint (actionlint) (pull_request) Successful in 6s
Third-party parts / As their notes say (pull_request) Successful in 10s
PR description / Description follows the template (pull_request_target) Successful in 5s
2026-11-15 01:51:57 +00:00
Compare
mooki changed title from WIP: fix(vk): samplers past the driver's allocation limit are reused, not lost to fix(vk): samplers past the driver's allocation limit are reused, not lost 2026-11-15 02:39:43 +00:00
mooki force-pushed fix/sampler-reuse from 99ca543a22
All checks were successful
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
Website / Website on Cloudflare (pull_request) Has been skipped
CI / Conventional Commit title (pull_request) Successful in 3s
CI / Workflow lint (actionlint) (pull_request) Successful in 6s
Third-party parts / As their notes say (pull_request) Successful in 10s
PR description / Description follows the template (pull_request_target) Successful in 5s
to fabaf7be5f
Some checks failed
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
Website / Website on Cloudflare (pull_request) Has been skipped
CI / Conventional Commit title (pull_request) Successful in 4s
PR description / Description follows the template (pull_request_target) Successful in 4s
CI / Workflow lint (actionlint) (pull_request) Successful in 5s
Guide screenshots / Guide images and local UI source review (pull_request) Failing after 6s
Third-party parts / As their notes say (pull_request) Successful in 8s
2026-11-15 07:01:34 +00:00
Compare
mooki force-pushed fix/sampler-reuse from fabaf7be5f
Some checks failed
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
Website / Website on Cloudflare (pull_request) Has been skipped
CI / Conventional Commit title (pull_request) Successful in 4s
PR description / Description follows the template (pull_request_target) Successful in 4s
CI / Workflow lint (actionlint) (pull_request) Successful in 5s
Guide screenshots / Guide images and local UI source review (pull_request) Failing after 6s
Third-party parts / As their notes say (pull_request) Successful in 8s
to 60c3c84adc
Some checks failed
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
Website / Website on Cloudflare (pull_request) Has been skipped
CI / Conventional Commit title (pull_request) Successful in 3s
CI / Workflow lint (actionlint) (pull_request) Successful in 6s
Guide screenshots / Guide images and local UI source review (pull_request) Failing after 6s
Third-party parts / As their notes say (pull_request) Successful in 9s
PR description / Description follows the template (pull_request_target) Successful in 5s
2026-11-15 13:02:50 +00:00
Compare
Some checks failed
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
Website / Website on Cloudflare (pull_request) Has been skipped
CI / Conventional Commit title (pull_request) Successful in 3s
CI / Workflow lint (actionlint) (pull_request) Successful in 6s
Guide screenshots / Guide images and local UI source review (pull_request) Failing after 6s
Third-party parts / As their notes say (pull_request) Successful in 9s
PR description / Description follows the template (pull_request_target) Successful in 5s
This pull request doesn't have enough approvals yet. 0 of 1 approvals granted.
This branch is out-of-date with the base branch
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u 'fix/sampler-reuse:mooki-fix/sampler-reuse'
git switch 'mooki-fix/sampler-reuse'
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
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/Longshot!442
No description provided.