Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/tested-configurations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
20 changes: 19 additions & 1 deletion gpu/vk/retired_give_back.h
Original file line number Diff line number Diff line change
Expand Up @@ -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=<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;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 6c9c7f4: live_budget now leaves budget < 12 GB (16 GB PCs and the Deck) untouched by default and adds RX_VK_IMPORT_CAP_MB (> 0 sets the cap in MB, 0 turns the >= 12 GB cap off). Setting RX_VK_IMPORT_HIGH or RX_VK_IMPORT_LOW without RX_VK_IMPORT_CAP_MB also uses the full budget.

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
57 changes: 35 additions & 22 deletions gpu/vk/vk_memory.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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=<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<std::map<uint64_t, Pool>::node_type> evicted_pools;
int64_t st_imports_evicted = 0;
Expand All @@ -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);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scoped to budget >= 12 GB in 6c9c7f4 so 16 GB PCs and integrated GPUs / the Steam Deck keep their existing budget threshold (~6.4 GB on 16 GB RAM) unless RX_VK_IMPORT_CAP_MB is explicitly set.

if (!want) return;
std::vector<std::map<uint64_t, Pool>::iterator> idle;
for (auto it = imported.begin(); it != imported.end(); ++it) {
const Pool& p = it->second;
Expand Down
11 changes: 11 additions & 0 deletions tests/retired_give_back_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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; };

Expand Down
3 changes: 3 additions & 0 deletions tests/test_retired_give_back.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down