From 4d8a9629a6f49f57adfa420a7196e5967677f10f Mon Sep 17 00:00:00 2001
From: Takoroka <137927095+Takoroka@users.noreply.github.com>
Date: Sat, 31 Oct 2026 01:30:46 -0400
Subject: texture_cache: collect from 5 GiB of device memory on every GPU
The collector's thresholds follow the card's size and budget (critical
at budget - 768 MiB), so the cache keeps textures of earlier scenes
until the card is nearly full: Mario Kart World used 6.7 GB of an 8 GB
card, and more on larger cards. Cap the critical threshold at 5 GiB (and
expected/minimum below it) whatever the card. Same build, back to back:
peak device memory 6.7 -> 5.6 GB, frames over 100 ms 114 -> 64 in six
minutes, average frame time unchanged. The EDEN_NX2_TC_*_MIB overrides
still apply.
---
src/video_core/texture_cache/texture_cache.h | 8 ++++++++
1 file changed, 8 insertions(+)
(limited to 'src/video_core/texture_cache/texture_cache.h')
diff --git a/src/video_core/texture_cache/texture_cache.h b/src/video_core/texture_cache/texture_cache.h
index 63abffed0f..bd5f7debfb 100644
--- a/src/video_core/texture_cache/texture_cache.h
+++ b/src/video_core/texture_cache/texture_cache.h
@@ -185,6 +185,14 @@ void TextureCache
::RunGarbageCollector() {
template
void TextureCache::ApplyThresholdOverrides(bool log) {
+ // On every GPU: past what a frame needs, more device memory only keeps the textures of earlier
+ // scenes (title 1 filled 6.7 GB of an 8 GB card and draws its races in ~5 GB), so collect from
+ // MaxCriticalMemory on rather than near the card's budget.
+ critical_memory = (std::min)(critical_memory, MaxCriticalMemory);
+ expected_memory =
+ (std::min)(expected_memory, critical_memory - (std::min)(critical_memory, u64{768_MiB}));
+ minimum_memory =
+ (std::min)(minimum_memory, expected_memory - (std::min)(expected_memory, u64{1_GiB}));
// EDEN_NX2_TC_EXPECTED_MIB / EDEN_NX2_TC_CRITICAL_MIB override the collector's thresholds.
for (const auto& [name, target] : {std::pair{"EDEN_NX2_TC_EXPECTED_MIB", &expected_memory},
std::pair{"EDEN_NX2_TC_CRITICAL_MIB", &critical_memory}}) {
--
cgit v1.2.3
From 049c9633edd9b3f7eaed6b4b3a18a4ead0cd18b7 Mon Sep 17 00:00:00 2001
From: Takoroka <137927095+Takoroka@users.noreply.github.com>
Date: Sat, 31 Oct 2026 10:31:41 -0400
Subject: nx2: read texture handles that hold a header byte offset
Switch Sports' scenery shaders take their 64-bit texture handles from a uniform
register, and the texture part of those handles is the header's byte offset in the
pool (index * 32), not its index: 0x96e46000 names header 8960 (a 640x152 2D
texture), while the plain reading, 286720, runs far past the 33067-entry pool. Eden
skipped them at draw time (null view) and built the shaders for a garbage header, so
the island's ground, cliffs and buildings drew black.
When a handle's index falls past the pool but index / 32 lands inside it, take that
header, both when building the shader and when binding the view. Handles that
already resolve keep their meaning.
---
src/video_core/shader_environment.cpp | 5 +++--
src/video_core/texture_cache/texture_cache.h | 1 +
src/video_core/textures/texture.h | 11 +++++++++++
3 files changed, 15 insertions(+), 2 deletions(-)
(limited to 'src/video_core/texture_cache/texture_cache.h')
diff --git a/src/video_core/shader_environment.cpp b/src/video_core/shader_environment.cpp
index 9d189ad4f0..532b1f97f4 100644
--- a/src/video_core/shader_environment.cpp
+++ b/src/video_core/shader_environment.cpp
@@ -315,8 +315,9 @@ std::optional GenericEnvironment::TryFindSize() {
Tegra::Texture::TICEntry GenericEnvironment::ReadTextureInfo(GPUVAddr tic_addr, u32 tic_limit,
bool via_header_index, u32 raw) {
const auto handle{Tegra::Texture::TexturePair(raw, via_header_index)};
- ASSERT(handle.first <= tic_limit);
- const GPUVAddr descriptor_addr{tic_addr + handle.first * sizeof(Tegra::Texture::TICEntry)};
+ const u32 index{Tegra::Texture::TextureHeaderIndex(handle.first, tic_limit)};
+ ASSERT(index <= tic_limit);
+ const GPUVAddr descriptor_addr{tic_addr + index * sizeof(Tegra::Texture::TICEntry)};
Tegra::Texture::TICEntry entry;
gpu_memory->ReadBlock(descriptor_addr, &entry, sizeof(entry));
return entry;
diff --git a/src/video_core/texture_cache/texture_cache.h b/src/video_core/texture_cache/texture_cache.h
index 63abffed0f..b0fa8ac1ce 100644
--- a/src/video_core/texture_cache/texture_cache.h
+++ b/src/video_core/texture_cache/texture_cache.h
@@ -673,6 +673,7 @@ typename P::Framebuffer* TextureCache::GetFramebuffer() {
template
ImageViewId TextureCache::VisitImageView(u32 index, bool compute) {
auto& table = compute ? channel_state->compute_image_table : channel_state->graphics_image_table;
+ index = Tegra::Texture::TextureHeaderIndex(index, table.current_limit);
if (index > table.current_limit) {
LOG_DEBUG(HW_GPU, "Invalid image view index={}", index);
return NULL_IMAGE_VIEW_ID;
diff --git a/src/video_core/textures/texture.h b/src/video_core/textures/texture.h
index 9fc6a631bd..79396d8a68 100644
--- a/src/video_core/textures/texture.h
+++ b/src/video_core/textures/texture.h
@@ -165,6 +165,17 @@ static_assert(sizeof(TextureHandle) == 4, "TextureHandle has wrong size");
}
}
+/// Some Switch 2 bindless handles (Switch Sports' scenery, read from a uniform register) hold
+/// the texture header's byte offset in the pool, index * 32, where the handle normally has the
+/// index. Read it as an offset only when the plain index falls past the pool and the offset
+/// lands inside it, so handles that already resolve are left alone.
+[[nodiscard]] constexpr u32 TextureHeaderIndex(u32 index, u32 limit) {
+ if (index > limit && index % 32 == 0 && index / 32 <= limit) {
+ return index / 32;
+ }
+ return index;
+}
+
struct TICEntry {
union {
struct {
--
cgit v1.2.3
From 75385e994dc8cc8699a918180ee566e8723b8394 Mon Sep 17 00:00:00 2001
From: Takoroka <137927095+Takoroka@users.noreply.github.com>
Date: Sat, 31 Oct 2026 16:05:55 -0400
Subject: texture_cache: don't count deleted images still waiting to be
destroyed
The collector deletes images into a ring that destroys them TICKS_TO_DESTROY
frames later, and until then the driver still counts their memory. Reading the
driver's usage every frame, the collector saw no drop after deleting and kept
deleting 40+ images a frame in aggressive mode long after it had freed enough:
once a session reached the critical threshold, frames took over 100 ms for
seconds at a time.
Keep the bytes deleted into each slot of the ring and leave them out of the
reported usage until the ring destroys them.
---
src/video_core/texture_cache/texture_cache.h | 11 +++++++++++
src/video_core/texture_cache/texture_cache_base.h | 4 ++++
2 files changed, 15 insertions(+)
(limited to 'src/video_core/texture_cache/texture_cache.h')
diff --git a/src/video_core/texture_cache/texture_cache.h b/src/video_core/texture_cache/texture_cache.h
index c8aaf8dff5..71f89db367 100644
--- a/src/video_core/texture_cache/texture_cache.h
+++ b/src/video_core/texture_cache/texture_cache.h
@@ -250,6 +250,12 @@ void TextureCache
::TickFrame() {
// If we can obtain the memory info, use it instead of the estimate.
if (runtime.CanReportMemoryUsage()) {
total_used_memory = runtime.GetDeviceMemoryUsage();
+ // Images the collector deleted keep their memory for TICKS_TO_DESTROY frames: counting
+ // it kept the collector in aggressive mode, deleting 40+ images a frame for frames after
+ // it had freed enough (title 1: frames over 100 ms for seconds at a time).
+ for (const u64 bytes : sentenced_image_bytes) {
+ total_used_memory -= (std::min)(total_used_memory, bytes);
+ }
}
// Every 600 frames: device memory in use against the collector's thresholds, and what the
// images themselves account for (VRAM pressure makes the collector thrash).
@@ -316,6 +322,8 @@ void TextureCache
::TickFrame() {
RunGarbageCollector();
}
sentenced_images.Tick();
+ sentenced_image_index = (sentenced_image_index + 1) % TICKS_TO_DESTROY;
+ sentenced_image_bytes[sentenced_image_index] = 0;
sentenced_framebuffers.Tick();
sentenced_image_view.Tick();
TickAsyncDecode();
@@ -2832,6 +2840,8 @@ void TextureCache
::DeleteImage(ImageId image_id, bool immediate_delete) {
}
// TickFrame sets the total from the driver, which can be under the sum of these estimates.
total_used_memory -= (std::min)(total_used_memory, ImageMemoryBytes(slot_images[image_id]));
+ const u64 freed_bytes = (image.HasScaled() ? GetScaledImageSizeBytes(image) : 0) +
+ ImageMemoryBytes(slot_images[image_id]);
const GPUVAddr gpu_addr = image.gpu_addr;
const auto alloc_it = image_allocs_table.find(gpu_addr);
if (alloc_it == image_allocs_table.end()) {
@@ -2897,6 +2907,7 @@ void TextureCache
::DeleteImage(ImageId image_id, bool immediate_delete) {
LOG_INFO(Render_Vulkan, "Retained image for queued download: id={}", image_id.Value());
} else if (!immediate_delete) {
sentenced_images.Push(std::move(slot_images[image_id]));
+ sentenced_image_bytes[sentenced_image_index] += freed_bytes;
}
slot_images.erase(image_id);
diff --git a/src/video_core/texture_cache/texture_cache_base.h b/src/video_core/texture_cache/texture_cache_base.h
index 94102cd261..d51eefeee8 100644
--- a/src/video_core/texture_cache/texture_cache_base.h
+++ b/src/video_core/texture_cache/texture_cache_base.h
@@ -552,6 +552,10 @@ private:
static constexpr size_t TICKS_TO_DESTROY = 8;
#endif
DelayedDestructionRing sentenced_images;
+ /// Device memory of the images in each slot of sentenced_images (same index): deleted, but
+ /// counted in the driver's usage until the ring destroys them.
+ std::array sentenced_image_bytes{};
+ size_t sentenced_image_index = 0;
DelayedDestructionRing sentenced_image_view;
DelayedDestructionRing sentenced_framebuffers;
--
cgit v1.2.3
From dbd9572a5a03b1e0bb00946750cf866420a25596 Mon Sep 17 00:00:00 2001
From: Takoroka <137927095+Takoroka@users.noreply.github.com>
Date: Sat, 31 Oct 2026 20:04:22 -0400
Subject: texture_cache: delete images without walking every channel's tables
Deleting an image stamped every entry of every channel's view-id map
with CORRUPT_ID (validation, which is on in release builds) and cleared
every channel's descriptor table bitmaps on the spot. On Mario Kart
World's title screen (~5,400 entries in 5 channels) that took 362 us
per deleted image, with dozens deleted a frame while the collector runs:
~10% of the time in its frames over 100 ms.
The stamp is debug-only now. The tables are invalidated along with it,
so every entry is written again before it is read. Invalidate() marks a
table and its bitmap is cleared on the next read, once however many
images were deleted in between. Same title screen: 3.7 us per deleted
image.
---
src/video_core/texture_cache/descriptor_table.h | 10 +++++++++-
src/video_core/texture_cache/texture_cache.h | 10 ++++++++++
2 files changed, 19 insertions(+), 1 deletion(-)
(limited to 'src/video_core/texture_cache/texture_cache.h')
diff --git a/src/video_core/texture_cache/descriptor_table.h b/src/video_core/texture_cache/descriptor_table.h
index e40c128ab5..75383809c5 100644
--- a/src/video_core/texture_cache/descriptor_table.h
+++ b/src/video_core/texture_cache/descriptor_table.h
@@ -30,11 +30,17 @@ public:
}
void Invalidate() noexcept {
- std::ranges::fill(read_descriptors, 0);
+ // Cleared on the next read: the texture cache invalidates every channel's tables for
+ // each image it deletes, dozens of times a frame while its collector runs.
+ invalidated = true;
}
[[nodiscard]] std::pair Read(Tegra::MemoryManager const& gpu_memory, u32 index) noexcept {
DEBUG_ASSERT(index <= current_limit);
+ if (invalidated) [[unlikely]] {
+ std::ranges::fill(read_descriptors, 0);
+ invalidated = false;
+ }
const GPUVAddr gpu_addr = current_gpu_addr + index * sizeof(T);
std::pair result;
gpu_memory.ReadBlockUnsafe(gpu_addr, std::addressof(result.first), sizeof(T));
@@ -60,6 +66,7 @@ public:
read_descriptors.resize(Common::DivCeil(num_descriptors, 64U));
old_size = (std::min)(old_size, read_descriptors.size());
std::fill(read_descriptors.begin(), read_descriptors.begin() + old_size, 0);
+ invalidated = false;
//
descriptors.resize(num_descriptors);
}
@@ -68,6 +75,7 @@ public:
std::vector descriptors;
GPUVAddr current_gpu_addr{};
u32 current_limit{};
+ bool invalidated{}; ///< read_descriptors is to be cleared before the next read
};
} // namespace VideoCommon
diff --git a/src/video_core/texture_cache/texture_cache.h b/src/video_core/texture_cache/texture_cache.h
index c8aaf8dff5..559423dbee 100644
--- a/src/video_core/texture_cache/texture_cache.h
+++ b/src/video_core/texture_cache/texture_cache.h
@@ -1712,9 +1712,14 @@ void TextureCache::InvalidateScale(Image& image) {
for (size_t c : active_channel_ids) {
auto& channel_info = channel_storage[c];
+#ifndef NDEBUG
+ // A walk over every entry (title 1: ~5,400 in 5 channels) for each deleted image: debug
+ // builds only. The tables are invalidated below, so each entry is written again before
+ // it is read.
if constexpr (ENABLE_VALIDATION)
for (auto& e : channel_info.image_view_ids)
e.second = CORRUPT_ID;
+#endif
channel_info.graphics_image_table.Invalidate();
channel_info.compute_image_table.Invalidate();
}
@@ -2906,9 +2911,14 @@ void TextureCache
::DeleteImage(ImageId image_id, bool immediate_delete) {
}
for (size_t c : active_channel_ids) {
auto& channel_info = channel_storage[c];
+#ifndef NDEBUG
+ // A walk over every entry (title 1: ~5,400 in 5 channels) for each deleted image: debug
+ // builds only. The tables are invalidated below, so each entry is written again before
+ // it is read.
if constexpr (ENABLE_VALIDATION)
for (auto& e : channel_info.image_view_ids)
e.second = CORRUPT_ID;
+#endif
channel_info.graphics_image_table.Invalidate();
channel_info.compute_image_table.Invalidate();
}
--
cgit v1.2.3
From 446c34aa8b305deb9f0870721d2126da8c186368 Mon Sep 17 00:00:00 2001
From: Takoroka <137927095+Takoroka@users.noreply.github.com>
Date: Sat, 31 Oct 2026 23:24:25 -0400
Subject: texture_cache: free deleted images once the GPU is past them
Deleted images kept their device memory for TICKS_TO_DESTROY (8)
frames. While the collector deletes dozens a frame that held 1-1.5 GB
of VRAM on top of its threshold, which the collector does not count
(Mario Kart World on an 8 GB card: 6.6-7.0 GB in use against 5 GiB). On
runtimes that report GPU progress (Vulkan) an image is destroyed as
soon as the GPU is past it. Title screen, 10 minutes: VRAM in use 6.75
-> 6.34 GB on average, frames over 100 ms 22 -> 15.
(cherry picked from commit 8c899541baf398deb892a857a932590f32d2aa4f)
---
src/video_core/renderer_vulkan/vk_texture_cache.cpp | 8 ++++++++
src/video_core/renderer_vulkan/vk_texture_cache.h | 5 +++++
src/video_core/texture_cache/texture_cache.h | 18 ++++++++++++++++--
src/video_core/texture_cache/texture_cache_base.h | 11 +++++++++++
4 files changed, 40 insertions(+), 2 deletions(-)
(limited to 'src/video_core/texture_cache/texture_cache.h')
diff --git a/src/video_core/renderer_vulkan/vk_texture_cache.cpp b/src/video_core/renderer_vulkan/vk_texture_cache.cpp
index 5ba36feee9..b8d2e4edd4 100644
--- a/src/video_core/renderer_vulkan/vk_texture_cache.cpp
+++ b/src/video_core/renderer_vulkan/vk_texture_cache.cpp
@@ -1979,6 +1979,14 @@ u64 TextureCacheRuntime::GetDeviceMemoryUsage() const {
return device.GetDeviceMemoryUsage();
}
+u64 TextureCacheRuntime::CurrentTick() const noexcept {
+ return scheduler.CurrentTick();
+}
+
+bool TextureCacheRuntime::IsFree(u64 tick) const noexcept {
+ return scheduler.IsFree(tick);
+}
+
u64 TextureCacheRuntime::GetDeviceMemoryBudget() const {
return device.GetDeviceMemoryBudget();
}
diff --git a/src/video_core/renderer_vulkan/vk_texture_cache.h b/src/video_core/renderer_vulkan/vk_texture_cache.h
index 3944e183fd..49eba2d7d3 100644
--- a/src/video_core/renderer_vulkan/vk_texture_cache.h
+++ b/src/video_core/renderer_vulkan/vk_texture_cache.h
@@ -67,6 +67,11 @@ public:
u64 GetDeviceMemoryUsage() const;
+ /// GPU progress: the texture cache frees a deleted image once the GPU is past its last use.
+ [[nodiscard]] u64 CurrentTick() const noexcept;
+
+ [[nodiscard]] bool IsFree(u64 tick) const noexcept;
+
u64 GetDeviceMemoryBudget() const;
bool CanReportMemoryUsage() const;
diff --git a/src/video_core/texture_cache/texture_cache.h b/src/video_core/texture_cache/texture_cache.h
index 71f89db367..46ebf6a25f 100644
--- a/src/video_core/texture_cache/texture_cache.h
+++ b/src/video_core/texture_cache/texture_cache.h
@@ -247,6 +247,13 @@ void TextureCache
::TickFrame() {
}
}
}
+ if constexpr (requires { runtime.CurrentTick(); }) {
+ while (!gpu_sentenced_images.empty() &&
+ runtime.IsFree(gpu_sentenced_images.front().tick)) {
+ gpu_sentenced_bytes -= gpu_sentenced_images.front().bytes;
+ gpu_sentenced_images.pop_front();
+ }
+ }
// If we can obtain the memory info, use it instead of the estimate.
if (runtime.CanReportMemoryUsage()) {
total_used_memory = runtime.GetDeviceMemoryUsage();
@@ -256,6 +263,7 @@ void TextureCache
::TickFrame() {
for (const u64 bytes : sentenced_image_bytes) {
total_used_memory -= (std::min)(total_used_memory, bytes);
}
+ total_used_memory -= (std::min)(total_used_memory, gpu_sentenced_bytes);
}
// Every 600 frames: device memory in use against the collector's thresholds, and what the
// images themselves account for (VRAM pressure makes the collector thrash).
@@ -2906,8 +2914,14 @@ void TextureCache
::DeleteImage(ImageId image_id, bool immediate_delete) {
if (retained) {
LOG_INFO(Render_Vulkan, "Retained image for queued download: id={}", image_id.Value());
} else if (!immediate_delete) {
- sentenced_images.Push(std::move(slot_images[image_id]));
- sentenced_image_bytes[sentenced_image_index] += freed_bytes;
+ if constexpr (requires { runtime.CurrentTick(); }) {
+ gpu_sentenced_images.push_back(
+ {runtime.CurrentTick(), freed_bytes, std::move(slot_images[image_id])});
+ gpu_sentenced_bytes += freed_bytes;
+ } else {
+ sentenced_images.Push(std::move(slot_images[image_id]));
+ sentenced_image_bytes[sentenced_image_index] += freed_bytes;
+ }
}
slot_images.erase(image_id);
diff --git a/src/video_core/texture_cache/texture_cache_base.h b/src/video_core/texture_cache/texture_cache_base.h
index d51eefeee8..151dd9bfa1 100644
--- a/src/video_core/texture_cache/texture_cache_base.h
+++ b/src/video_core/texture_cache/texture_cache_base.h
@@ -556,6 +556,17 @@ private:
/// counted in the driver's usage until the ring destroys them.
std::array sentenced_image_bytes{};
size_t sentenced_image_index = 0;
+ /// Deleted images on runtimes that report GPU progress (CurrentTick, IsFree): destroyed, with
+ /// their device memory, as soon as the GPU is past them rather than TICKS_TO_DESTROY frames
+ /// later. While the collector deletes dozens a frame, eight frames of them held 1-1.5 GB of
+ /// VRAM above its threshold (title 1 on an 8 GB card: 6.6-7.0 GB in use against 5 GiB).
+ struct GpuSentencedImage {
+ u64 tick;
+ u64 bytes;
+ Image image;
+ };
+ std::deque gpu_sentenced_images;
+ u64 gpu_sentenced_bytes{};
DelayedDestructionRing sentenced_image_view;
DelayedDestructionRing sentenced_framebuffers;
--
cgit v1.2.3
From 3871de9da994f9c0fa581ecd993db40081ce6b48 Mon Sep 17 00:00:00 2001
From: Takoroka <137927095+Takoroka@users.noreply.github.com>
Date: Sat, 31 Oct 2026 23:24:28 -0400
Subject: texture_cache: find a deleted image's view entries without a scan
Deleting an image erased the channels' descriptor -> view entries
pointing to its views by scanning every channel's map (Mario Kart
World: ~5,000 entries, 50-130 us per deleted image, dozens a frame
while the collector runs). The cache keeps, per view, the (channel,
descriptor) entries that point to it and erases just those, skipping
entries of channels that are gone or that point to another view since.
(cherry picked from commit c3d22e8e09dacf39000c742d03dfc7517bd6f343)
---
src/video_core/texture_cache/texture_cache.h | 31 +++++++++++++++--------
src/video_core/texture_cache/texture_cache_base.h | 6 +++++
2 files changed, 27 insertions(+), 10 deletions(-)
(limited to 'src/video_core/texture_cache/texture_cache.h')
diff --git a/src/video_core/texture_cache/texture_cache.h b/src/video_core/texture_cache/texture_cache.h
index c8aaf8dff5..279668813f 100644
--- a/src/video_core/texture_cache/texture_cache.h
+++ b/src/video_core/texture_cache/texture_cache.h
@@ -689,8 +689,13 @@ ImageViewId TextureCache::VisitImageView(u32 index, bool compute) {
if (IsValidEntry(*gpu_memory, descriptor)) {
// Is new (registered view) on the texture cache side?
const auto [pair, is_new_tc] = channel_state->image_views.try_emplace(descriptor);
- if (is_new_tc)
+ if (is_new_tc) {
pair->second = CreateImageView(descriptor);
+ if (pair->second != NULL_IMAGE_VIEW_ID) {
+ image_view_descriptors[pair->second].emplace_back(current_channel_id,
+ descriptor);
+ }
+ }
PrepareImageView(pair->second, false, false);
channel_state->image_view_ids.insert_or_assign(map_index, pair->second);
return pair->second;
@@ -2917,17 +2922,23 @@ void TextureCache
::DeleteImage(ImageId image_id, bool immediate_delete) {
template
void TextureCache::RemoveImageViewReferences(std::span removed_views) {
- for (size_t c : active_channel_ids) {
- auto& channel_info = channel_storage[c];
- auto it = channel_info.image_views.begin();
- while (it != channel_info.image_views.end()) {
- const auto found = std::ranges::find(removed_views, it->second);
- if (found != removed_views.end()) {
- it = channel_info.image_views.erase(it);
- } else {
- ++it;
+ for (const ImageViewId removed : removed_views) {
+ const auto refs = image_view_descriptors.find(removed);
+ if (refs == image_view_descriptors.end()) {
+ continue;
+ }
+ for (const auto& [channel, descriptor] : refs->second) {
+ // The channel may be gone, or its entry point to another view since.
+ if (std::ranges::find(active_channel_ids, channel) == active_channel_ids.end()) {
+ continue;
+ }
+ auto& image_views = channel_storage[channel].image_views;
+ if (const auto it = image_views.find(descriptor);
+ it != image_views.end() && it->second == removed) {
+ image_views.erase(it);
}
}
+ image_view_descriptors.erase(refs);
}
}
diff --git a/src/video_core/texture_cache/texture_cache_base.h b/src/video_core/texture_cache/texture_cache_base.h
index 94102cd261..628a5d8558 100644
--- a/src/video_core/texture_cache/texture_cache_base.h
+++ b/src/video_core/texture_cache/texture_cache_base.h
@@ -433,6 +433,12 @@ private:
/// Remove image views references from the cache
void RemoveImageViewReferences(std::span removed_views);
+ /// Per image view, the (channel, descriptor) entries of the channels' image_views maps that
+ /// point to it. Deleting an image erased them by scanning every map (title 1: ~5,000 entries,
+ /// 50-130 us for each deleted image, dozens a frame while the collector runs).
+ std::unordered_map, 2>>
+ image_view_descriptors;
+
/// Remove framebuffers using the given image views from the cache
void RemoveFramebuffers(std::span removed_views);
--
cgit v1.2.3
From e2c512916c6f5042d030fcf1708043233f8df08e Mon Sep 17 00:00:00 2001
From: Takoroka <137927095+Takoroka@users.noreply.github.com>
Date: Sun, 1 Nov 2026 00:30:52 -0400
Subject: texture_cache: mark guest memory textures were uploaded from cold
Once a texture is uploaded the host GPU has its own copy, and the guest
rarely reads that memory again, but it stayed in the emulator's working
set. Every 600 frames the guest ranges of images uploaded since (64 KiB
or more, not GPU-written or sparse) are marked cold through
HostMemory::HintCold, backing and CPU view, from a low-priority worker,
so the OS pages them out before anything else when it needs RAM. Their
contents stay; using them brings them back. Mario Kart World's title
screen at ~10 minutes: game memory in RAM 4.8-5.5 -> 3.6 GB, the
emulator's real RAM 10.6-11.9 -> 9.4 GB, frames over 100 ms not worse.
(cherry picked from commit ea36b2c5ca352f8c8cdcc829a2d3bde9bdc06ece)
---
src/common/host_memory.cpp | 2 +-
src/common/host_memory.h | 3 +-
src/core/device_memory_manager.h | 7 ++++
src/core/device_memory_manager.inc | 44 +++++++++++++++++++++++
src/video_core/texture_cache/texture_cache.h | 14 ++++++++
src/video_core/texture_cache/texture_cache_base.h | 2 ++
6 files changed, 70 insertions(+), 2 deletions(-)
(limited to 'src/video_core/texture_cache/texture_cache.h')
diff --git a/src/common/host_memory.cpp b/src/common/host_memory.cpp
index c0e7160dd4..2c45dfd3a5 100644
--- a/src/common/host_memory.cpp
+++ b/src/common/host_memory.cpp
@@ -791,7 +791,7 @@ void HostMemory::Protect(size_t virtual_offset, size_t length, MemoryPermission
}
void HostMemory::HintCold(size_t physical_offset, size_t length,
- std::optional virtual_offset) {
+ std::optional virtual_offset) const {
if (length == 0 || backing_base == nullptr) {
return;
}
diff --git a/src/common/host_memory.h b/src/common/host_memory.h
index fe0938103c..4d8d15cf58 100644
--- a/src/common/host_memory.h
+++ b/src/common/host_memory.h
@@ -57,7 +57,8 @@ public:
/// Memory written once and read again rarely: its pages leave the working set (backing and,
/// when given, its view at virtual_offset), so the OS compresses or pages them out first when
/// it needs RAM. The contents stay; using the pages brings them back.
- void HintCold(size_t physical_offset, size_t length, std::optional virtual_offset);
+ void HintCold(size_t physical_offset, size_t length,
+ std::optional virtual_offset) const;
[[nodiscard]] u8* BackingBasePointer() noexcept {
return backing_base;
diff --git a/src/core/device_memory_manager.h b/src/core/device_memory_manager.h
index 9c034f01a1..05a8136169 100644
--- a/src/core/device_memory_manager.h
+++ b/src/core/device_memory_manager.h
@@ -18,6 +18,7 @@
#include "common/common_types.h"
#include "common/range_mutex.h"
#include "common/scratch_buffer.h"
+#include "common/thread_worker.h"
#include "common/sparse_large_vector.h"
namespace Core {
@@ -53,6 +54,10 @@ public:
void BindInterface(DeviceInterface* device_inter);
+ /// Marks the memory behind [address, address + size) cold (Common::HostMemory::HintCold),
+ /// from a low-priority thread: data the host GPU has its own copy of, e.g. uploaded textures.
+ void HintCold(DAddr address, size_t size);
+
DAddr Allocate(size_t size);
void AllocateFixed(DAddr start, size_t size);
void Free(DAddr start, size_t size);
@@ -194,7 +199,9 @@ private:
std::unique_ptr> impl;
const uintptr_t physical_base;
+ const DeviceMemory* device_memory_ptr;
DeviceInterface* device_inter;
+ std::unique_ptr cold_hint_worker;
struct TrackedEntry {
VAddr cpu_backing_address;
diff --git a/src/core/device_memory_manager.inc b/src/core/device_memory_manager.inc
index c45bc71656..77ab95e07e 100644
--- a/src/core/device_memory_manager.inc
+++ b/src/core/device_memory_manager.inc
@@ -171,6 +171,7 @@ struct DeviceMemoryManagerAllocator {
template
DeviceMemoryManager::DeviceMemoryManager(const DeviceMemory& device_memory_)
: physical_base{uintptr_t(device_memory_.buffer.BackingBasePointer())}
+ , device_memory_ptr{&device_memory_}
, device_inter{nullptr}
, compressed_device_addr(1ULL << ((Settings::values.memory_layout_mode.GetValue() == Settings::MemoryLayout::Memory_4Gb ? physical_min_bits : physical_max_bits) - Memory::YUZU_PAGEBITS))
, tracked_entries(device_as_size >> Memory::YUZU_PAGEBITS)
@@ -182,6 +183,49 @@ DeviceMemoryManager::DeviceMemoryManager(const DeviceMemory& device_memo
template
DeviceMemoryManager::~DeviceMemoryManager() = default;
+template
+void DeviceMemoryManager::HintCold(DAddr address, size_t size) {
+ struct Run {
+ u64 physical_offset;
+ VAddr virtual_address; ///< 0: no CPU mapping known
+ u64 size;
+ };
+ std::vector runs;
+ const size_t page_end = Common::DivCeil(address + size, Memory::YUZU_PAGESIZE);
+ for (size_t page = address >> Memory::YUZU_PAGEBITS; page < page_end; ++page) {
+ const u32 compressed = tracked_entries[page].compressed_physical_ptr;
+ if (compressed == 0) {
+ continue;
+ }
+ const u64 physical = u64{compressed - 1} << Memory::YUZU_PAGEBITS;
+ const VAddr vaddr = ExtractCPUBacking(page).second;
+ if (!runs.empty()) {
+ Run& last = runs.back();
+ const bool contiguous_view =
+ last.virtual_address == 0 ? vaddr == 0 : last.virtual_address + last.size == vaddr;
+ if (last.physical_offset + last.size == physical && contiguous_view) {
+ last.size += Memory::YUZU_PAGESIZE;
+ continue;
+ }
+ }
+ runs.push_back({physical, vaddr, Memory::YUZU_PAGESIZE});
+ }
+ if (runs.empty()) {
+ return;
+ }
+ if (!cold_hint_worker) {
+ cold_hint_worker = std::make_unique(1, "DeviceMemory:ColdHint");
+ }
+ cold_hint_worker->QueueWork([this, runs = std::move(runs)] {
+ for (const Run& run : runs) {
+ device_memory_ptr->buffer.HintCold(
+ run.physical_offset, run.size,
+ run.virtual_address != 0 ? std::optional{run.virtual_address}
+ : std::nullopt);
+ }
+ });
+}
+
template
void DeviceMemoryManager::BindInterface(DeviceInterface* device_inter_) {
device_inter = device_inter_;
diff --git a/src/video_core/texture_cache/texture_cache.h b/src/video_core/texture_cache/texture_cache.h
index 63abffed0f..13637aa265 100644
--- a/src/video_core/texture_cache/texture_cache.h
+++ b/src/video_core/texture_cache/texture_cache.h
@@ -315,6 +315,12 @@ void TextureCache::TickFrame() {
sentenced_image_view.Tick();
TickAsyncDecode();
TickAsyncUnswizzle();
+ if (frame_tick % 600 == 0 && !uploaded_ranges.empty()) {
+ for (const auto& [address, size] : uploaded_ranges) {
+ device_memory.HintCold(address, size);
+ }
+ uploaded_ranges.clear();
+ }
runtime.TickFrame();
++frame_tick;
@@ -1352,6 +1358,14 @@ void TextureCache
::RefreshContents(Image& image, ImageId image_id) {
auto staging = runtime.UploadStagingBuffer(MapSizeBytes(image));
UploadImageContents(image, staging);
runtime.InsertUploadMemoryBarrier();
+ // The host GPU has its own copy now and the guest rarely reads this memory again: TickFrame
+ // marks it cold, so the OS pages it out before anything else when it needs RAM (title 1
+ // streams several GB of textures through it).
+ constexpr u64 ColdHintMinBytes = 64_KiB;
+ if (image.guest_size_bytes >= ColdHintMinBytes &&
+ False(image.flags & (ImageFlagBits::GpuModified | ImageFlagBits::Sparse))) {
+ uploaded_ranges.emplace_back(image.cpu_addr, image.guest_size_bytes);
+ }
}
template
diff --git a/src/video_core/texture_cache/texture_cache_base.h b/src/video_core/texture_cache/texture_cache_base.h
index 11e95f98b3..26d989f2da 100644
--- a/src/video_core/texture_cache/texture_cache_base.h
+++ b/src/video_core/texture_cache/texture_cache_base.h
@@ -558,6 +558,8 @@ private:
Common::ThreadWorker texture_decode_worker{1, "TextureDecoder", {},
Common::ThreadPlacement::Efficiency};
std::vector> async_decodes;
+ /// Guest memory images were uploaded from since the last cold hint (device address, size).
+ std::vector> uploaded_ranges;
std::deque unswizzle_queue;
u8 current_unswizzle_frame;
--
cgit v1.2.3
From 1c794af56407f324eedad99478388ba0c9cf9ad6 Mon Sep 17 00:00:00 2001
From: Takoroka <137927095+Takoroka@users.noreply.github.com>
Date: Sun, 1 Nov 2026 00:36:38 -0400
Subject: texture_cache: back large sparse 2D images by sparse block
Mario Kart World's nine world-sized masks (R8 16384x16384, RG16 and
R16 8192x8192, 170-341 MB each) live in sparse regions the game maps
its own memory into, a few MiB of each. Since a mapped tile counts as
written, the texture cache backed and uploaded each mask in full: about
2.9 GB of device memory, and a whole-mask upload for every tile the
game streamed in.
Such images (single layer, one texel per block, colour, in a sparse
region, 32 MiB or more) are now unbacked SparseTiles images. The
texture cache records the guest ranges written to them; Image::BindTiles
gives the host sparse blocks under those ranges memory, and only those
rectangles are unswizzled, a row of whole GOB blocks at a time, and
uploaded. A new image starts with the tiles the game maps there (nx2:gpu
216 now records them per 64 KiB), or the written blocks where it maps
none. Unmapped tiles keep their blocks and are loaded as zeros.
Checked against the full unswizzle on 400 ordinary game images (row
padding, mip chains, odd sizes): no row differs. Not on AMD drivers,
which cannot sample images with unbound memory.
Mario Kart World title screen, 150 s, against backing the masks whole:
dedicated VRAM 6.6 -> 6.2 GB, shared 1.0 -> 0.3 GB, peak 8.2 -> 6.9 GB.
---
src/core/hle/service/nx2_gsv.h | 2 +
src/video_core/nx2.h | 60 ++++++
.../renderer_vulkan/vk_texture_cache.cpp | 210 +++++++++++++++++++++
src/video_core/renderer_vulkan/vk_texture_cache.h | 30 +++
src/video_core/texture_cache/image_base.h | 7 +
src/video_core/texture_cache/texture_cache.h | 65 +++++++
src/video_core/texture_cache/texture_cache_base.h | 9 +
src/video_core/texture_cache/types.h | 9 +
src/video_core/texture_cache/util.cpp | 204 ++++++++++++++++++++
src/video_core/texture_cache/util.h | 21 +++
10 files changed, 617 insertions(+)
(limited to 'src/video_core/texture_cache/texture_cache.h')
diff --git a/src/core/hle/service/nx2_gsv.h b/src/core/hle/service/nx2_gsv.h
index 44d2b16240..39ef402024 100644
--- a/src/core/hle/service/nx2_gsv.h
+++ b/src/core/hle/service/nx2_gsv.h
@@ -962,6 +962,7 @@ private:
++m_tile_unmaps;
LOG_DEBUG(Service, "nx2:gpu 216 unmap [{:#x}, +{:#x}]", gpu_address, size);
if (region != nullptr) {
+ Tegra::Nx2SparseTilesMapped(start, gpu_address - start, size, false);
registry.MapSparseTile(gpu_address, 0, size);
MarkTiles(*region, gpu_address - start, size, false);
} else {
@@ -998,6 +999,7 @@ private:
}
Tegra::Nx2SparseBlockWritten(start, block);
}
+ Tegra::Nx2SparseTilesMapped(start, gpu_address - start, size, true);
registry.MapSparseTile(gpu_address, cpu_address, size);
MarkTiles(*region, gpu_address - start, size, true);
}
diff --git a/src/video_core/nx2.h b/src/video_core/nx2.h
index 824dc76044..318607384b 100644
--- a/src/video_core/nx2.h
+++ b/src/video_core/nx2.h
@@ -9,6 +9,7 @@
#include
#include