diff --git a/docs/tested-configurations.md b/docs/tested-configurations.md index a24ab21d2..cf33d2026 100644 --- a/docs/tested-configurations.md +++ b/docs/tested-configurations.md @@ -14,6 +14,8 @@ otherwise. | Date | Build | OS / desktop | GPU / driver | CPU / RAM | Settings | Where | Result | |---|---|---|---|---|---|---|---| +| 2026-11-12 | v2.1.0 (source e561602e) | Arch Linux, KDE Plasma Wayland, 2560x1440 window (mailbox, udmabuf on) | RX 9070 XT 16 GB, Mesa RADV 26.2.6 | Core i5-13600KF, 32 GB | 2560x1440 output, V-Sync off, parallel recorder on (`RX_VK_RECORDER=thread`, `RX_VK_BATCH=0`), `RX_VK_MIRROR_MB=4096`, idle live-import cap on (`RX_VK_IMPORT_HIGH=25`, `RX_VK_IMPORT_LOW=20`: ~3,858 MB high / ~3,086 MB low on the 15,434 MB system heap budget) | Long session across Hyrule (176,640 presents, ~1.5 h) | 63.5-65.6 fps at 641-708 draws, 67.2-68.3 fps at 186 draws throughout the session. 257 idle live imports (7,872 MB in all) given back; live imports stayed at 50-64 pools (2,256-2,720 MB), capture submit 0.18-0.43 ms (scan 0.08 ms over 3,923-4,251 MB), queue submit 0.32-0.73 ms, readbacks of GPU results waited 0.64-1.14 ms each. No device lost | +| 2026-11-12 | v2.1.0 (source e561602e) | as above | as above | as above | 2560x1440 output, V-Sync off, default `hookshot_env.txt` (default 2,048 MB mirror budget, default recorder, `imports_give_back` off on the 15,434 MB system heap budget since `budget >= 12 GB`) | Long session across Hyrule (684,360 presents, ~2 h) | At 10 min (40,920 presents): 60+ fps, 55 live imports (2,000 MB), capture submit 0.27 ms (scan 0.05 ms over 734 ranges, 2,417 MB), queue submit 0.55 ms, readback wait 2.19 ms each (270 of 3,000). After ~2 h (684,120 presents): faded to 44-48.4 fps at 303-408 draws (`vi: real-time step on: ratio 1.352, game's frame rate 44`); 221 live imports (7,392 MB; 67 retired given back, 2,992 MB), working set 15,351 MB (peak 16,107 MB, 1,511 MB swap), capture submit 0.50 ms (scan 0.09 ms over 334 ranges, 7,218 MB), queue submit 1.11-1.14 ms, readback wait 6.29-7.03 ms each (312-340 of 3,000). No device lost | | 2026-11-11 | v2.1.0-rc.3-154-g3c89158e (source 1eba3e78): main 15646d18 + this change | Windows 11, no window (unattended, scripted input) | RTX 4070 SUPER 12 GB, 617.42 | Ryzen 9 9950X3D (SMT off), 32 GB | 2560x1440, DLAA, `RX_VK_COMPACT=1`, `RX_VK_COMPACT_PCT=85`, `RX_VK_ASTC_BC_ALL=1`, `RX_VK_IMAGE_BLOCK_MB=32`; nothing else running, `RX_VK_TEST_ALLOWED_MB=10200`, `RX_VK_FRAME_TRACE=1` | From the title into Castle Town, then out to Hyrule Field (an area change) | Two runs (3,480 and 3,600 frames traced). The new columns filled in: summed `late_wait_ms` 260 / 319 ms, `readback_wait_ms` 13 / 13 ms, `tex_frame_ms` 323 / 555 ms; `late_wait_ms` was never over its frame's `memsync_ms` (summed 1,671 / 1,808 ms). No device lost | | 2026-11-12 | v2.1.0-rc.3-120-g1bde1868 (source 62267c81), the native-zstd branch built from source (llvm-mingw, no DLSS) | Windows 11, 5120x1440 (32:9) | RX 9070 XT, Vulkan driver 0x8001a0 | Ryzen 7 5700X3D, 64 GB | `HOS_NATIVE_ZSTD=2` | Hyrule Field, the standard test: the save loaded, the camera turning at full speed, frame rate unlocked (`HOS_FPS_MAX=240`), 20 s window; 2 warm-ups, then 4 runs an arm, alternating | fps 43.7-45.0, CPU 111.5-116.3 ms a frame, p99 28.5-30.0 ms, worst 45.0-56.7 ms, no frame over 100 ms; save load 28.0-28.7 s; 3 core escapes | | 2026-11-12 | v2.1.0-rc.3-120-g1bde1868 (source 62267c81), the native-zstd branch built from source (llvm-mingw, no DLSS) | Windows 11, 5120x1440 (32:9) | RX 9070 XT, Vulkan driver 0x8001a0 | Ryzen 7 5700X3D, 64 GB | `HOS_NATIVE_ZSTD=1` | Hyrule Field, the standard test: the save loaded, the camera turning at full speed, frame rate unlocked (`HOS_FPS_MAX=240`), 20 s window; 2 warm-ups, then 4 runs an arm, alternating | fps 42.3-44.8, CPU 112.8-114.9 ms a frame, p99 28.3-36.4 ms, worst 54.4-333.1 ms, 4 frames over 100 ms and 2 over 250, all in the first run; save load 25.9-28.9 s; 4 core escapes | diff --git a/gpu/vk/retired_give_back.h b/gpu/vk/retired_give_back.h index ccc1875d8..f2a63b817 100644 --- a/gpu/vk/retired_give_back.h +++ b/gpu/vk/retired_give_back.h @@ -40,9 +40,27 @@ constexpr uint64_t settled(uint64_t used, uint64_t pending) { return used > pend /// The bytes to give back: none up to high percent of the budget (0: off), past it down to low percent. constexpr uint64_t want(uint64_t used, uint64_t budget, int high, int low) { - if (high <= 0 || used <= budget / 100 * (uint64_t)high) return 0; + if (high <= 0 || !budget || used <= budget / 100 * (uint64_t)high) return 0; const uint64_t target = budget / 100 * (uint64_t)low; return used > target ? used - target : 0; } +/// The system-heap budget imports_give_back measures against: +/// - Under 12 GB (16 GB PCs and the Steam Deck), the heap's own budget as before (#479), unless RX_VK_IMPORT_CAP_MB > 0. +/// - At 12 GB and above (32 GB+ PCs, where imports_give_back was off), capped at kLiveBudgetCap (4500 MB) by default so +/// idle unmirrored pools do not pile up in amdgpu's per-submit BO list and capture ranges over a multi-hour session. +/// - RX_VK_IMPORT_CAP_MB= sets the cap in MB (> 0: capped on any budget; 0: off at >= 12 GB unless a watermark switch +/// is set). Setting RX_VK_IMPORT_HIGH or RX_VK_IMPORT_LOW without RX_VK_IMPORT_CAP_MB uses the full system-heap budget. +constexpr uint64_t kSmallBudget = 12ull << 30; +constexpr uint64_t kLiveBudgetCap = 4500ull << 20; +constexpr uint64_t live_budget(uint64_t budget, bool env_pct_set, int env_cap_mb = -1) { + if (env_cap_mb > 0) { + const uint64_t cap = (uint64_t)env_cap_mb << 20; + return budget < cap ? budget : cap; + } + if (env_pct_set || budget < kSmallBudget) return budget; + if (env_cap_mb == 0) return 0; + return budget < kLiveBudgetCap ? budget : kLiveBudgetCap; +} + } // namespace retired_give_back diff --git a/gpu/vk/vk_memory.cpp b/gpu/vk/vk_memory.cpp index 913b0c0d5..1e3f19e47 100644 --- a/gpu/vk/vk_memory.cpp +++ b/gpu/vk/vk_memory.cpp @@ -3386,17 +3386,22 @@ void mirrors_give_back(uint64_t want_bytes) { // (pools_mu held) } } -// ---- imports given back under pressure (16 GB PCs). The driver pins every imported pool and counts it in the system heap -// the GPU reaches, whose budget is about half the PC's RAM (7.5 GB of 16): imports were kept for the session, the heap -// went past its budget (7758 of 7503 MB on an RTX 3060 with 16 GB) and new imports failed with VK_ERROR_OUT_OF_DEVICE_MEMORY: -// terrain left out, load zones stuck. Past RX_VK_IMPORT_HIGH percent of the budget (85) the pools bound least lately give -// their imports back down to RX_VK_IMPORT_LOW percent (75): only pools without a mirror, not bound for kImportIdle -// submissions and done on the GPU. They leave the table as a retired pool does (the page table's entries 0, the caches -// emptied) and are imported again at their next binding; their memory is freed once everything recorded until now is -// done. On Linux, on by default only where that heap's budget is under 12 GB (where it ran out); off by default on Windows. -// RX_VK_IMPORT_HIGH=0: off, =85: on anywhere. (Caveat: last_value counts bindings, not shaders reaching a pool through the -// page table; a pool only reached that way would read zeros until it is bound again. Not seen in play: the pools given back -// were long unbound.) ---- +// ---- imports given back under pressure. The driver pins every imported pool and counts it in the system heap +// the GPU reaches, whose budget is about half the PC's RAM (7.5 GB of 16, 15.4 GB of 32): imports were kept for the session, +// the heap went past its budget (7758 of 7503 MB on an RTX 3060 with 16 GB) and new imports failed with +// VK_ERROR_OUT_OF_DEVICE_MEMORY: terrain left out, load zones stuck; on 32 GB+ Linux PCs (where the budget is 12 GB or more +// and this valve was off) 200+ idle unmirrored pools piled up over a two-hour session (7.4 GB live), bloating every amdgpu +// submit's buffer list and the submit-time capture scan. Past RX_VK_IMPORT_HIGH percent (85) of the effective budget the pools +// bound least lately give their imports back down to RX_VK_IMPORT_LOW percent (75): only pools without a mirror, not bound for +// kImportIdle submissions and done on the GPU. Under a 12 GB heap budget (16 GB PCs and the Steam Deck) the effective budget is +// the heap's own, as before; at 12 GB and above it is capped at 4500 MB by default (RX_VK_IMPORT_CAP_MB= sets another cap, +// and RX_VK_IMPORT_CAP_MB=0 turns the >= 12 GB cap off; setting RX_VK_IMPORT_HIGH or RX_VK_IMPORT_LOW explicitly without +// RX_VK_IMPORT_CAP_MB uses the full heap budget instead of the 4500 MB cap). They leave the table as a retired pool does (the +// page table's entries 0, the caches emptied) and are imported again at their next binding; their memory is freed once +// everything recorded until now is done. On by default on Linux; off by default on Windows. RX_VK_IMPORT_HIGH=0: off, =85: on +// anywhere against the full budget. (Caveat: last_value counts bindings, not shaders reaching a pool through the page table; a +// pool only reached that way would read zeros until it is bound again. Not seen in play: the pools given back were long +// unbound.) ---- bool system_heap_usage(uint64_t* used, uint64_t* budget); // (vk_vma.cpp) std::deque::node_type> evicted_pools; int64_t st_imports_evicted = 0; @@ -3416,29 +3421,37 @@ void imports_give_back() { // (pools_mu held) // watermarks (retired_imports_give_back), on by default on Windows AMD and Intel whatever the budget: that one // frees pools nothing uses any more, this one pools still live that have been idle static const char* env_high = getenv("RX_VK_IMPORT_HIGH"); + static const char* env_low = getenv("RX_VK_IMPORT_LOW"); + static const int cap_mb = env_int("RX_VK_IMPORT_CAP_MB", -1); #ifdef _WIN32 - static const int high = env_high ? atoi(env_high) : 0; + static const int high = std::clamp(env_int("RX_VK_IMPORT_HIGH", 0), 0, 100); #else - static const int high = env_high ? atoi(env_high) : 85; + static const int high = std::clamp(env_int("RX_VK_IMPORT_HIGH", 85), 0, 100); #endif - static const int low = getenv("RX_VK_IMPORT_LOW") ? std::clamp(atoi(getenv("RX_VK_IMPORT_LOW")), 10, 95) : 75; + static const int low = std::clamp(env_int("RX_VK_IMPORT_LOW", 75), 10, 95); static uint64_t calls = 0; if (high <= 0 || ++calls % 16) return; // (the driver asked every 16 submissions) uint64_t used = 0, budget = 0; if (!system_heap_usage(&used, &budget)) return; - static const bool small = [&] { // (on by default where the budget is small: 16 GB of RAM and less) - const bool on = env_high || budget < (12ull << 30); - logf("vk: imports given back past %d%% of a %llu MB system heap budget: %s", high, (unsigned long long)(budget >> 20), - on ? "on" : "off (RX_VK_IMPORT_HIGH=85 turns it on)"); - return on; + const uint64_t cap = retired_give_back::live_budget(budget, (env_high && *env_high) || (env_low && *env_low), cap_mb); + static const bool logged_cfg = [&] { + if (!cap) + logf("vk: imports given back past %d%% of a %llu MB system heap budget: off (RX_VK_IMPORT_CAP_MB=0)", high, + (unsigned long long)(budget >> 20)); + else if (cap < budget) + logf("vk: imports given back past %d%% of a %llu MB cap (%llu MB system heap budget): on", high, + (unsigned long long)(cap >> 20), (unsigned long long)(budget >> 20)); + else + logf("vk: imports given back past %d%% of a %llu MB system heap budget: on", high, (unsigned long long)(budget >> 20)); + return true; }(); + (void)logged_cfg; // (with both valves on, the other ran just before, on this same submission (the two count alike): what it queued is // still in the driver's figure until the GPU is past it, and live pools would go for the same bytes. Only where // retired imports wait; elsewhere they go back at every submission and this valve reads what it always read) if (retired_mode() == retired_give_back::Mode::GRACE) used = retired_give_back::settled(used, dead_import_bytes); - if (!small || used <= budget / 100 * (uint64_t)high) return; - const uint64_t target = budget / 100 * (uint64_t)low; - const uint64_t want = used > target ? used - target : 0; + const uint64_t want = retired_give_back::want(used, cap, high, low); + if (!want) return; std::vector::iterator> idle; for (auto it = imported.begin(); it != imported.end(); ++it) { const Pool& p = it->second; diff --git a/tests/retired_give_back_test.cpp b/tests/retired_give_back_test.cpp index 2dd9a987f..12235c605 100644 --- a/tests/retired_give_back_test.cpp +++ b/tests/retired_give_back_test.cpp @@ -11,6 +11,8 @@ namespace { using retired_give_back::Mode; using retired_give_back::due; +using retired_give_back::kLiveBudgetCap; +using retired_give_back::live_budget; using retired_give_back::mode; using retired_give_back::settled; using retired_give_back::want; @@ -50,7 +52,16 @@ static_assert(want(6 * kGB, 8 * kGB, kHigh, kLow) == 0, "75%: under the high wat static_assert(want(8 * kGB / 100 * 85, 8 * kGB, kHigh, kLow) == 0, "at the high watermark, not past it"); static_assert(want(7 * kGB, 8 * kGB, kHigh, kLow) == 7 * kGB - 8 * kGB / 100 * 75, "past it: down to the low watermark"); static_assert(want(8 * kGB, 8 * kGB, 0, kLow) == 0, "RX_VK_IMPORT_HIGH=0: off"); +static_assert(want(8 * kGB, 0, kHigh, kLow) == 0, "budget 0 (RX_VK_IMPORT_CAP_MB=0): off"); static_assert(settled(7 * kGB, kGB) == 6 * kGB && settled(kGB, 2 * kGB) == 0, "what is queued is as good as freed"); +static_assert(live_budget(7503 * kMB, false) == 7503 * kMB, "under 12 GB (16 GB PC / Deck): the heap's own budget, unchanged"); +static_assert(live_budget(15434 * kMB, false) == kLiveBudgetCap, "32 GB PC, default: capped at 4500 MB (high ~3825 MB, low ~3375 MB)"); +static_assert(live_budget(15434 * kMB, true) == 15434 * kMB, "RX_VK_IMPORT_HIGH or RX_VK_IMPORT_LOW set: the heap's full budget"); +static_assert(live_budget(15434 * kMB, false, 0) == 0, "RX_VK_IMPORT_CAP_MB=0 on >= 12 GB: off, as on main"); +static_assert(live_budget(7503 * kMB, false, 0) == 7503 * kMB, "RX_VK_IMPORT_CAP_MB=0 under 12 GB: keeps the < 12 GB behaviour"); +static_assert(live_budget(15434 * kMB, false, 6000) == 6000 * kMB, "RX_VK_IMPORT_CAP_MB=6000: tunable cap in MB"); +static_assert(want(3878 * kMB, live_budget(15434 * kMB, false), kHigh, kLow) == 3878 * kMB - 3375 * kMB, + "3878 MB used on a 15434 MB heap: past 85% of the 4500 MB cap, gives back down to 75% (3375 MB)"); struct Retired { uint64_t bytes; int64_t at_ms; bool imported = true; }; diff --git a/tests/test_retired_give_back.py b/tests/test_retired_give_back.py index 33296b8f0..22b3a0979 100644 --- a/tests/test_retired_give_back.py +++ b/tests/test_retired_give_back.py @@ -28,6 +28,9 @@ def check_source(): # (what is queued is not asked for twice, by this valve or by imports_give_back's) "retired_give_back::settled(used, dead_import_bytes), budget, high, low)", "if (retired_mode() == retired_give_back::Mode::GRACE) used = retired_give_back::settled(used, dead_import_bytes);", + 'static const int cap_mb = env_int("RX_VK_IMPORT_CAP_MB", -1);', + "const uint64_t cap = retired_give_back::live_budget(budget, (env_high && *env_high) || (env_low && *env_low), cap_mb);", + "const uint64_t want = retired_give_back::want(used, cap, high, low);", "dead_import_bytes += p.hi - p.lo;", "dead_import_bytes -= std::min(dead_import_bytes, dead_imports.front().bytes);"): if needle not in src: