Cycles: Texture cache eviction for final renders

Evict unused texture cache tiles between render tiles and multiviews, to
reduce memory usage. Tiles that were loaded but not accessed during the
previous render tile are freed before the next tile begins rendering.

A per-tile access state byte tracks three values (NONE, REQUESTED, USED)
and is used both to drive tile loading from cache misses and to decide
which loaded tiles can be evicted at tile boundaries.

Cache eviction is automatically enabled, but there is a debug option to
disable it. There is also an debug option to preserve a specified amount
of unused image texture memory to aoid avoid trashing unnecessarily in
simple cases. It increases peak memory usage to render faster, however
in tests on a Macbook M3 it did not seem worth it. But left in to
experiment on other devices and scenes.

Pull Request: https://projects.blender.org/blender/blender/pulls/157244
This commit is contained in:
Brecht Van Lommel 2026-02-22 02:39:33 +01:00
parent 4132006bf0
commit c6d110e8ec
18 changed files with 380 additions and 28 deletions

View file

@ -1098,6 +1098,17 @@ class CyclesRenderSettings(bpy.types.PropertyGroup):
description=adaptive_compile_description,
default=False)
debug_use_texture_cache_eviction: BoolProperty(
name="Cache Eviction",
description="Evict unused tiles from the texture cache to free up memory",
default=True)
debug_texture_cache_preserve_unused: IntProperty(
name="Preserve Unused MB",
description="Preserve unused texture cache data, up to this amount of memory",
min=0,
default=0)
@classmethod
def register(cls):
bpy.types.Scene.cycles = PointerProperty(

View file

@ -909,6 +909,16 @@ class CYCLES_RENDER_PT_performance_texture_cache(CyclesButtonsPanel, Panel):
row.label()
row.operator("render.generate_texture_cache", text="Generate All")
prefs = context.preferences
if prefs.experimental.use_cycles_debug and prefs.view.show_developer_ui:
cscene = context.scene.cycles
col = layout.column(heading="Debug")
col.active = rd.use_texture_cache
col.prop(cscene, "debug_use_texture_cache_eviction")
sub = col.column()
sub.active = cscene.debug_use_texture_cache_eviction
sub.prop(cscene, "debug_texture_cache_preserve_unused")
class CYCLES_RENDER_PT_performance_acceleration_structure(CyclesButtonsPanel, Panel):
bl_label = "Acceleration Structure"

View file

@ -75,6 +75,9 @@ void debug_flags_sync_from_scene(blender::Scene &b_scene)
flags.metal.adaptive_compile = get_boolean(cscene, "debug_use_metal_adaptive_compile");
/* Synchronize OptiX flags. */
flags.optix.use_debug = get_boolean(cscene, "debug_use_optix_debug");
/* Synchronize Texture Cache flags. */
flags.texture_cache.use_eviction = get_boolean(cscene, "debug_use_texture_cache_eviction");
flags.texture_cache.preserve_unused = get_int(cscene, "debug_texture_cache_preserve_unused");
}
/* Reset debug flags to default values.

View file

@ -173,6 +173,13 @@ kernel_image_tile_map(KernelGlobals kg,
#endif
}
/* Mark tile as used for cache eviction tracking. Read before writing as we
* expect most of the time this was already written. */
if (kernel_data_array(image_texture_tile_access_state)[access_index] != KERNEL_TILE_ACCESS_USED)
{
kernel_data_array(image_texture_tile_access_state)[access_index] = KERNEL_TILE_ACCESS_USED;
}
/* Remap coordinates into tiled image space. */
const int offset = kernel_tile_descriptor_offset(tile_descriptor);
xy += make_float2(KERNEL_IMAGE_TEX_PADDING - (tile_x << tile_size_shift) +

View file

@ -14,6 +14,7 @@
#include "scene/stats.h"
#include "util/colorspace.h"
#include "util/debug.h"
#include "util/log.h"
#include "util/progress.h"
#include "util/task.h"
@ -722,6 +723,35 @@ void ImageManager::device_free(Scene *scene)
scene->dscene.image_texture_udims.free();
}
void ImageManager::evict_unused(Device *device, Scene *scene)
{
if (!DebugFlags().texture_cache.use_eviction) {
return;
}
DeviceScene &dscene = scene->dscene;
device_vector<uint8_t> &tile_access = dscene.image_texture_tile_access_state;
if (tile_access.size() == 0) {
return;
}
/* Read back tile access state from all devices and OR together. */
device->mem_or_from_device(tile_access);
image_cache.evict_unused(*device,
dscene,
{dscene.image_textures.data(), dscene.image_textures.size()},
tile_access.data());
/* Reset access state on both host and device, so no more tiles are marked as used.
* Any tile not marked as used before the next eviction cycle will be evicted. */
memset(tile_access.data(), KERNEL_TILE_ACCESS_NONE, tile_access.size() * sizeof(uint8_t));
tile_access.zero_to_device();
tile_access.clear_modified();
device_copy_image_textures(device, scene);
}
void ImageManager::collect_statistics(RenderStats *stats, Scene *scene)
{
DeviceScene &dscene = scene->dscene;

View file

@ -170,6 +170,8 @@ class ImageManager {
bool set_animation_frame_update(const int frame);
void evict_unused(Device *device, Scene *scene);
void collect_statistics(RenderStats *stats, Scene *scene);
void tag_update();

View file

@ -12,6 +12,7 @@
#include "scene/stats.h"
#include "util/atomic.h"
#include "util/debug.h"
#include "util/image.h"
#include "util/image_impl.h"
#include "util/image_metadata.h"
@ -104,6 +105,7 @@ void ImageCache::free_tiled_image(DeviceScene &dscene, const KernelImageTexture
for (int i = 0; i < tex.tile_num; i++) {
if (kernel_tile_descriptor_loaded(descriptors[i])) {
thread_scoped_lock device_lock(device_mutex);
free_tile(descriptors[i]);
}
}
@ -368,8 +370,6 @@ device_image &ImageCache::alloc_tile(Device &device,
void ImageCache::free_tile(const KernelTileDescriptor tile)
{
thread_scoped_lock device_lock(device_mutex);
const uint image_info_id = kernel_tile_descriptor_image_info_id(tile);
const uint tile_offset = kernel_tile_descriptor_offset(tile);
@ -479,7 +479,8 @@ KernelTileDescriptor ImageCache::load_tile(Device &device,
const int miplevel,
const int x,
const int y,
const bool for_cpu_cache_miss)
const bool for_cpu_cache_miss,
const size_t /*bit_index*/)
{
const int width = metadata.width >> miplevel;
const int height = metadata.height >> miplevel;
@ -527,6 +528,26 @@ KernelTileDescriptor ImageCache::load_tile(Device &device,
return (ok) ? tile_descriptor : KERNEL_TILE_LOAD_FAILED;
}
/* Find the mip level that contains the tile index. */
static int image_tile_find_miplevel(const KernelTileDescriptor *levels,
const int tile_levels,
const size_t tile_idx,
size_t &r_level_start)
{
int miplevel = 0;
size_t level_start = 0;
for (int m = 0; m < tile_levels; m++) {
const size_t level_offset = levels[m] - tile_levels;
if (tile_idx < level_offset) {
break;
}
level_start = level_offset;
miplevel = m;
}
r_level_start = level_start;
return miplevel;
}
void ImageCache::load_requested_tiles(Device &device,
DeviceScene &dscene,
const KernelImageTexture &tex,
@ -545,7 +566,7 @@ void ImageCache::load_requested_tiles(Device &device,
/* Scan access state for this image's tiles. */
for (size_t tile_idx = 0; tile_idx < tex.tile_num; tile_idx++) {
if (access_state[base_offset + tile_idx] != KERNEL_TILE_ACCESS_REQUESTED) {
if (!(access_state[base_offset + tile_idx] & KERNEL_TILE_ACCESS_REQUESTED)) {
continue;
}
@ -564,20 +585,9 @@ void ImageCache::load_requested_tiles(Device &device,
continue;
}
/* Find miplevel for this tile index. The stored level values are offsets from
* tile_descriptor_offset, so subtract tile_levels to get the tile index. */
int miplevel = 0;
size_t level_start = 0;
for (int m = 0; m < tex.tile_levels; m++) {
const size_t level_offset = levels[m] - tex.tile_levels;
if (tile_idx < level_offset) {
break;
}
level_start = level_offset;
miplevel = m;
}
/* Compute tile pixel coordinates within miplevel. */
/* Find miplevel for this tile index, and compute tile pixel coordinates. */
size_t level_start;
const int miplevel = image_tile_find_miplevel(levels, tex.tile_levels, tile_idx, level_start);
const size_t idx_in_level = tile_idx - level_start;
const int mip_width = metadata.width >> miplevel;
const size_t tiles_x = divide_up(mip_width, tile_size);
@ -586,8 +596,17 @@ void ImageCache::load_requested_tiles(Device &device,
const size_t x = tile_x * tile_size;
const size_t y = tile_y * tile_size;
descriptors[tile_idx] = load_tile(
device, dscene, loader, metadata, interpolation, extension, miplevel, x, y, false);
descriptors[tile_idx] = load_tile(device,
dscene,
loader,
metadata,
interpolation,
extension,
miplevel,
x,
y,
false,
base_offset + tile_idx);
}
}
@ -612,8 +631,19 @@ void ImageCache::load_requested_tile(Device &device,
{
const InterpolationType interpolation = InterpolationType(tex.interpolation);
const ExtensionType extension = ExtensionType(tex.extension);
KernelTileDescriptor tile_descriptor_new = load_tile(
device, dscene, loader, metadata, interpolation, extension, miplevel, x, y, true);
const size_t bit_index = &r_tile_descriptor - dscene.image_texture_tile_descriptors.data();
KernelTileDescriptor tile_descriptor_new = load_tile(device,
dscene,
loader,
metadata,
interpolation,
extension,
miplevel,
x,
y,
true,
bit_index);
r_tile_descriptor = tile_descriptor_new;
return;
}
@ -673,6 +703,106 @@ void ImageCache::collect_statistics(DeviceScene &dscene,
}
}
void ImageCache::evict_unused(const Device &device,
DeviceScene &dscene,
std::span<KernelImageTexture> image_textures,
const uint8_t *access_state)
{
device_vector<KernelTileDescriptor> &tile_descriptors = dscene.image_texture_tile_descriptors;
if (tile_descriptors.size() == 0) {
return;
}
const bool cpu_only = (device.info.type == DEVICE_CPU);
const size_t preserve_budget = size_t(DebugFlags().texture_cache.preserve_unused) * 1024 * 1024;
/* Hold the mutex for the entire eviction pass. */
thread_scoped_lock device_lock(device_mutex);
/* Collect unused tiles. */
struct UnusedTile {
size_t global_idx;
size_t tile_bytes;
};
vector<UnusedTile> unused_tiles;
size_t num_used = 0;
size_t num_evicted = 0;
size_t num_preserved = 0;
size_t preserved_bytes = 0;
/* Free a single unused tile, updating descriptor and statistics. */
const auto evict_tile = [&](const size_t global_idx) {
KernelTileDescriptor &descriptor = tile_descriptors[global_idx];
free_tile(descriptor);
descriptor = KERNEL_TILE_LOAD_NONE;
num_evicted++;
};
for (size_t img_idx = 0; img_idx < image_textures.size(); img_idx++) {
const KernelImageTexture &tex = image_textures[img_idx];
if (tex.tile_descriptor_offset == KERNEL_TILE_LOAD_NONE) {
continue;
}
const size_t base_offset = tex.tile_descriptor_offset + tex.tile_levels;
for (int i = 0; i < tex.tile_num; i++) {
const size_t global_idx = base_offset + i;
KernelTileDescriptor &descriptor = tile_descriptors[global_idx];
if (!kernel_tile_descriptor_loaded(descriptor)) {
continue;
}
if (access_state[global_idx] & KERNEL_TILE_ACCESS_USED) {
num_used++;
continue;
}
/* Look up the DeviceImage to determine host-mapped status and tile size. */
const uint image_info_id = kernel_tile_descriptor_image_info_id(descriptor);
DeviceImage *img = images[image_info_id];
bool is_host_mapped = false;
size_t tile_bytes = 0;
if (img) {
is_host_mapped = !cpu_only && (img->shared_pointer != nullptr);
const size_t pixel_bytes = img->data_elements * datatype_size(img->data_type);
const size_t tile_size_padded = img->data_height;
tile_bytes = tile_size_padded * tile_size_padded * pixel_bytes;
}
if (is_host_mapped) {
/* Host-mapped tiles are slower to sample, always evict. */
evict_tile(global_idx);
}
else {
unused_tiles.push_back({global_idx, tile_bytes});
preserved_bytes += tile_bytes;
}
}
}
/* Evict device-resident tiles until preserved bytes fits within the budget.
* Note this means tiles loaded earlier will be preserved, as tiles loaded later are
* less likely to be needed often. */
for (const UnusedTile &tile : unused_tiles) {
if (preserved_bytes <= preserve_budget) {
/* Within budget, preserve remaining device-resident tiles. */
num_preserved++;
}
else {
/* Over budget, evict to bring preserved bytes down. */
evict_tile(tile.global_idx);
preserved_bytes -= tile.tile_bytes;
}
}
if (num_evicted > 0) {
dscene.image_texture_tile_descriptors.tag_modified();
LOG_DEBUG << "Texture cache tile eviction: " << num_evicted << " evicted, " << num_used
<< " used, " << num_preserved << " preserved (" << preserved_bytes / (1024 * 1024)
<< " MB).";
}
}
size_t ImageCache::memory_size(DeviceScene &dscene) const
{
return dscene.image_texture_tile_access_state.memory_size() +

View file

@ -11,6 +11,8 @@
#include "util/set.h"
#include "util/unique_ptr_vector.h"
#include <span>
CCL_NAMESPACE_BEGIN
class DeviceQueue;
@ -107,6 +109,12 @@ class ImageCache {
const KernelImageTexture &tex,
const ImageMetaData &metadata,
ImageTileStats &tile_stats);
void evict_unused(const Device &device,
DeviceScene &dscene,
std::span<KernelImageTexture> image_textures,
const uint8_t *access_state);
size_t memory_size(DeviceScene &dscene) const;
/* Free image cache device data. */
@ -153,7 +161,8 @@ class ImageCache {
int miplevel,
int x,
int y,
const bool for_cpu_cache_miss);
const bool for_cpu_cache_miss,
const size_t bit_index);
};
CCL_NAMESPACE_END

View file

@ -11,8 +11,9 @@ set(INC_SYS
set(SRC
buffers.cpp
display_driver.cpp
cache_eviction.cpp
denoising.cpp
display_driver.cpp
merge.cpp
session.cpp
tile.cpp
@ -20,8 +21,9 @@ set(SRC
set(SRC_HEADERS
buffers.h
display_driver.h
cache_eviction.h
denoising.h
display_driver.h
merge.h
output_driver.h
session.h

View file

@ -0,0 +1,44 @@
/* SPDX-FileCopyrightText: 2026 Blender Foundation
*
* SPDX-License-Identifier: Apache-2.0 */
#include "session/cache_eviction.h"
#include <chrono>
#include "util/math_base.h"
#include "util/time.h"
CCL_NAMESPACE_BEGIN
CacheEvictionManager::CacheEvictionManager(bool background) : background_(background) {}
void CacheEvictionManager::reset()
{
render_tile_count_ = 0;
}
bool CacheEvictionManager::need_eviction(bool idle, bool switched_to_new_tile)
{
/* Final render. */
if (background_) {
/* Evict before rendering the next tile, except the first one where we
* can't determine what was shared with other tiles. */
if (idle || !switched_to_new_tile) {
return false;
}
render_tile_count_++;
return render_tile_count_ >= 2;
}
/* Viewport render. */
return false;
}
std::chrono::milliseconds CacheEvictionManager::wait_time(bool /*idle*/) const
{
return std::chrono::milliseconds::max();
}
CCL_NAMESPACE_END

View file

@ -0,0 +1,34 @@
/* SPDX-FileCopyrightText: 2026 Blender Foundation
*
* SPDX-License-Identifier: Apache-2.0 */
#pragma once
#include <chrono>
#include "util/types.h"
CCL_NAMESPACE_BEGIN
/* CacheEvictionManager has decides when texture cache eviction should
* happen in a render session. */
class CacheEvictionManager {
public:
explicit CacheEvictionManager(bool background);
/* Reset state when starting a new render. */
void reset();
/* For a render iteration, check if cache eviction is needed. */
bool need_eviction(bool idle, bool switched_to_new_tile);
/* Wait time until cache eviction needs to be performed. */
std::chrono::milliseconds wait_time(bool idle) const;
private:
const bool background_;
int render_tile_count_ = 0;
};
CCL_NAMESPACE_END

View file

@ -9,6 +9,7 @@
#include "integrator/path_trace.h"
#include "scene/background.h"
#include "scene/camera.h"
#include "scene/image.h"
#include "scene/integrator.h"
#include "scene/light.h"
#include "scene/mesh.h"
@ -28,7 +29,9 @@
CCL_NAMESPACE_BEGIN
Session::Session(const SessionParams &params_, const SceneParams &scene_params)
: params(params_), render_scheduler_(tile_manager_, params)
: params(params_),
eviction_manager_(params_.background),
render_scheduler_(tile_manager_, params)
{
TaskScheduler::init(params.threads);
@ -327,6 +330,8 @@ RenderWork Session::run_update_for_next_iteration()
/* After reset make sure the tile manager is at the first big tile. */
have_tiles = tile_manager_.next();
switched_to_new_tile = true;
eviction_manager_.reset();
}
/* Update denoiser settings. */
@ -369,6 +374,11 @@ RenderWork Session::run_update_for_next_iteration()
}
}
/* Evict unused image tiles periodically. */
if (eviction_manager_.need_eviction(!render_work, switched_to_new_tile)) {
scene->image_manager->evict_unused(device.get(), scene.get());
}
if (render_work) {
const scoped_timer update_timer;
@ -453,8 +463,20 @@ bool Session::run_wait_for_work(const RenderWork &render_work)
break;
}
/* Wait for either pause state changed, or extra samples added to render. */
pause_cond_.wait(pause_lock);
const std::chrono::milliseconds wait_time = eviction_manager_.wait_time(!render_work);
if (wait_time == std::chrono::milliseconds::zero()) {
/* Break out of the loop for cache eviction. */
break;
}
/* Wait for either pause state changed, extra samples added to render, or idle
* timer before performing eviction. */
if (wait_time == std::chrono::milliseconds::max()) {
pause_cond_.wait(pause_lock);
}
else {
pause_cond_.wait_for(pause_lock, wait_time);
}
if (pause_) {
progress.add_skip_time(pause_timer, params.background);

View file

@ -11,6 +11,7 @@
#include "scene/shader.h"
#include "scene/stats.h"
#include "session/buffers.h"
#include "session/cache_eviction.h"
#include "session/tile.h"
#include "util/progress.h"
@ -241,6 +242,9 @@ class Session {
TileManager tile_manager_;
BufferParams buffer_params_;
/* Manages when image cache eviction happens. */
CacheEvictionManager eviction_manager_;
/* Render scheduler is used to get work to be rendered with the current big tile. */
RenderScheduler render_scheduler_;

View file

@ -86,6 +86,17 @@ void DebugFlags::Metal::reset()
}
}
DebugFlags::TextureCache::TextureCache()
{
reset();
}
void DebugFlags::TextureCache::reset()
{
use_eviction = true;
preserve_unused = 0;
}
DebugFlags::OptiX::OptiX()
{
reset();
@ -102,6 +113,7 @@ void DebugFlags::reset()
cuda.reset();
optix.reset();
metal.reset();
texture_cache.reset();
}
CCL_NAMESPACE_END

View file

@ -106,6 +106,20 @@ class DebugFlags {
bool use_metalrt_pcmi = true;
};
/* Descriptor of Texture Cache feature-set to be used. */
struct TextureCache {
TextureCache();
/* Reset flags to their defaults. */
void reset();
/* Enable texture cache eviction. */
bool use_eviction = true;
/* Preserve unused image cache tile memory in megabytes. */
int preserve_unused = 0;
};
/* Get instance of debug flags registry. */
static DebugFlags &get()
{
@ -131,6 +145,9 @@ class DebugFlags {
/* Requested Metal flags. */
Metal metal;
/* Requested Texture Cache flags. */
TextureCache texture_cache;
private:
DebugFlags() = default;

View file

@ -124,9 +124,18 @@ struct KernelImageUDIM {
/* Tile access state, written by the kernel and read back by the host.
* - NONE: Tile has not been accessed since the last clear.
* - REQUESTED: Kernel hit a cache miss on this tile, it needs to be loaded.
* - USED: Tile was accessed since the last clear.
*
* Almost all tiles will progress from REQUESTED to USED, but with dependent
* texture lookups it is possible a requested tile does not actually get used
* because the request was based on a tile that was not yet loaded and used
* the average color instead.
*
* This is a bitflag so we can OR these states from multiple devices.
*/
#define KERNEL_TILE_ACCESS_NONE 0
#define KERNEL_TILE_ACCESS_REQUESTED (1 << 0)
#define KERNEL_TILE_ACCESS_USED (1 << 1)
/* Kernel data structure for image textures.
*

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:158ac3fe63aba92194f48fd687765d6d83588ff9c51aba577e179bf106686cb2
size 38218

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ca758934fd72402813c6c738d6d3d9a91599e082173ea9a1b07d27d2c29b2044
size 121619