Add texture compaction behind NX2NAVI_TEX_COMPACT, off by default.

With the setting on, textures no shader sampled for ten seconds are copied on the GPU out of the emptiest allocator
blocks into the holes of fuller ones, so the emptied blocks go back to the driver. A pass starts when the free space
inside the device-local blocks reaches NX2NAVI_TEX_COMPACT_MIN_MB (one block under VRAM pressure) and the block
table has blocks that hold nothing but such textures, with room elsewhere for what moves. It takes at most four
textures and NX2NAVI_TEX_COMPACT_FRAME_MB a frame, and none in a frame that loads an area, already made or uploaded
textures, or follows a long one. A released texture the size cap would drop is dropped instead of moved.

Only a texture whose image holds what the game's memory holds moves: uploaded, never written by the GPU, single
sampled, not depth. The entry takes the new image under the texture lock and its address generation moves, so every
slot, pool descriptor and memo resolves again. The old image and its views stay until the texture pool has taken
every set of its ring and the GPU is past the submissions recorded until then, as a retired texture's do.

Each pass logs what it moved and dropped, the blocks and bytes given back, and the time it took. A block emptied of
textures that the allocator keeps is left alone for ten minutes, and a pass that gave nothing back doubles the wait.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
chrg6
2026-11-12 22:32:33 -05:00
parent d668a62854
commit ec9dd522b1
8 changed files with 502 additions and 7 deletions

View File

@@ -253,6 +253,13 @@ keeps per-core data without locks and relies on that, so the host models it (`ru
starts at, and uploads, views and sampler LOD clamps count from there. A texture a shader reads by texel position, or
whose size it uses, is made whole (`gfx_shader.c` `reflect_exact`). `auto` picks by the device-local budget.
- Occlusion queries never block the CPU; a result that is not ready counts as visible.
- Holes in video memory: the allocator takes device memory in blocks and gives a block back only when it is empty,
so a long session leaves textures scattered between holes the driver still counts. The log's `mem: holes` line
says how much that is and how much could come back (`vk_memory.c` `holes_log`). With `NX2NAVI_TEX_COMPACT=1`
(off by default) textures no shader sampled for ten seconds are copied on the GPU out of the emptiest blocks into
the holes of fuller ones, a few per frame and only in frames with time to spare; which blocks is decided from a
table of what lies in them (`vk_tex_compact.h`), and the old image of a moved texture stays until no descriptor
set and no submission can still name it (`vk_texture.c`, "the pass").
- Allocation: Vulkan Memory Allocator. Debugging: Vulkan validation layers, per-pass frame dumps (`NX2NAVI_DUMP`,
`vk_dump.c`), a sampling profiler (`NX2NAVI_PROFILE`, `tools/prof_report.py`) and a flight recorder
(`NX2NAVI_FLIGHT`). RenderDoc cannot capture, because it hides `VK_EXT_external_memory_host` from the application.

View File

@@ -638,8 +638,8 @@ A change to how the holes in the allocator's blocks are counted or to which bloc
(`runtime/vk_tex_compact.h`: `tcp_tally`, `tcp_stats`, `tcp_plan`) passes `python tests ex_compact_check.py`, which
builds the header alone: a block with one allocation that stays is never chosen, the block that takes the fewest moved
bytes goes first, what moves fits the free bytes outside the chosen blocks, a plan under its smallest gain is no plan,
and 400 random tables keep those rules. The part of `runtime/vk_texture.c` that builds the table is not built by it.
No GPU, no game files.
and 400 random tables keep those rules. The part of `runtime/vk_texture.c` that builds the table and moves the images
(`NX2NAVI_TEX_COMPACT`) is not built by it. No GPU, no game files.
A change to which frames FSR 3.1 frame generation is told are camera cuts (`runtime/vk_fg_reset.h`, `runtime/vk_fg.cpp`
`generate` and `vk_fg_frame`) passes `python tests\fg_reset_check.py`, which builds the header alone and checks that each

View File

@@ -328,6 +328,20 @@
# which game memory gets no copy in video memory (memory = mirror), which costs far more frame time than a texture
# uploaded again. 0: textures never give way for video memory.
# tex_vram_pressure = 80
# Texture compaction (experimental). Video memory is taken in blocks (vma_block_mb), and a block goes back to the
# driver only once everything in it is freed: after a long session the blocks hold textures scattered between holes,
# and the game holds a gigabyte or two of video memory it does not use. 1: textures no shader sampled for ten seconds
# are copied on the GPU out of the emptiest blocks into the holes of fuller ones, a few per frame and only in frames
# with time to spare, so those blocks go back. Nothing on screen moves and nothing is loaded again. The log's
# "mem: holes" line says how much there is to gain, a "texture compaction: pass" line what each pass gave back.
# 0, the default: off. Read at start.
# tex_compact = 0
# The free space inside the blocks, in MiB (64 to 65536), from which a compaction pass starts; one block's worth while
# video memory is above tex_vram_pressure. Default: 256. Read at start.
# tex_compact_min_mb = 256
# The texture memory a compaction moves per frame at most, in MiB (1 to 32); a texture larger than this never moves.
# Default: 8. Read at start.
# tex_compact_frame_mb = 8
# Low-end GPU mode (experimental; also in the settings menu's Game tab). For older graphics cards, and ones short of
# video memory: 1 turns on the settings written for them and measured on a Radeon RX 580, for every key below that
# this file does not set itself. Mesh shaders run as compute where the card has none (mesh_emulation), video memory

View File

@@ -82,6 +82,7 @@ static const char *const known[] = {
"TEXUP_LOG_MS", "DYN_RES", "DYN_RES_TARGET", "DYN_RES_STEP", "TEXTURE_DETAIL",
"RECORDER_CORE", "RECORDER_PRIO", "REPLAY_TIME", "LOCK_TIME", "POOL_CREATE_BUDGET", "POOL_CREATE_MS", "POOL_FINI_IDLE", "POOL_LOAD_BACKLOG", "POOL_LOAD_MS",
"VMA_BLOCK_MB", "SYNC_WAKE_ALL", "LOG_ASYNC", "DEVICE_LOST_AT", "STAGING_FAIL_AT", "VRAM_BALLAST_MB",
"TEX_COMPACT", "TEX_COMPACT_MIN_MB", "TEX_COMPACT_FRAME_MB", // texture compaction (vk_texture.c); no menu row
"STANDIN_FILTER", // the filter of the scene upscaler's stand-in (vk_tonemap.c); no menu row
"COOP", "COOP_BETA", "COOP_NAME", "COOP_SERVER", "COOP_PORT", "COOP_ROOM", "COOP_AUTO", "COOP_DROP", // co-op (coop.c); the last three have no menu row
"COOP_PROGRESS", "COOP_ITEMS", "COOP_SONGS", "COOP_QUEST", "COOP_KEYS", "COOP_RUPEES", "COOP_AMMO", // the room's rules (coop_game.c)
@@ -160,6 +161,7 @@ static const struct { const char *key, *section; } sections[] = {
{"WB_MERGE_MAX", "diagnostics"}, {"MV_SURVEY", "diagnostics"}, {"TEXUP_LOG_MS", "diagnostics"},
{"WAIT_WATCH_S", "diagnostics"},
{"VMA_BLOCK_MB", "diagnostics"}, {"DEVICE_LOST_AT", "diagnostics"}, {"STAGING_FAIL_AT", "diagnostics"}, {"VRAM_BALLAST_MB", "diagnostics"},
{"TEX_COMPACT", "performance"}, {"TEX_COMPACT_MIN_MB", "performance"}, {"TEX_COMPACT_FRAME_MB", "performance"},
};
// The settings the menu shows: the value in effect when the key is unset, and what the key accepts. The only place a
@@ -378,6 +380,10 @@ static const CfgMeta metas[] = {
{"WB_MERGE_MAX", "32", "1..256", RESTART}, {"TEXUP_LOG_MS", "0", "0..60000", RESTART}, // vk_texture.c vk_texstat_frame
{"WAIT_WATCH_S", "0", "0..3600", RESTART}, // kernel.c wait_watch: a lock or thread-end wait this long is logged; 0 off
{"VMA_BLOCK_MB", "64", "16..256", RESTART}, // vk_headroom.c vk_vma_block_size
// vk_texture.c compact_on: idle textures move out of sparsely used blocks of video memory; the free space inside
// the blocks from which a pass starts, and the bytes moved per frame (also the largest texture that moves)
{"TEX_COMPACT", "0", "0|1", RESTART}, {"TEX_COMPACT_MIN_MB", "256", "64..65536", RESTART},
{"TEX_COMPACT_FRAME_MB", "8", "1..32", RESTART},
{"ASTC_BC1", "1", "0|1", RESTART}, // vk_astc.c bc1_on
{"PASS_CRUMBS", "", "0|1", RESTART}, // vk_crumbs.c pass marks; unset: VK_AMD_buffer_marker only
{"DRAW_CRUMBS", "0", "0|1", RESTART}, // vk_crumbs.c draw marks: a mark at each change of shaders inside a pass

View File

@@ -774,6 +774,14 @@ typedef struct {
uint64_t sparse_bytes, sparse_listed, emptiable_bytes, emptiable_move, emptiable_drop;
} VkTexHoles;
void vk_texture_holes(const VkBlockRow *other, int nother, VkTexHoles *h);
// Texture compaction (NX2NAVI_TEX_COMPACT; vk_texture.c "the pass"): idle textures move out of sparsely used blocks
// so that the blocks go back to the driver. vk_mem_census asks at each present whether the holes are due a look
// and, when they are, hands over the VRAM mirrors' rows for the plan; the allocator's free callback reports each
// device memory it gives back (vk_headroom.c on_free; any thread).
int vk_texture_compact_on(void);
int vk_texture_compact_due(void);
void vk_texture_compact_plan(const VkBlockRow *other, int nother);
void vk_texture_block_freed(uint64_t mem);
void vk_texture_want_room(uint64_t bytes); // a VRAM mirror that did not fit: idle textures make that much room
int vk_texture_commit_low(void); // the system's commit was low at the last check (vk_texture.c)
// vk_mem_vram's device-local use less the free space inside VMA's blocks (vk_memory.c), for the evictions

View File

@@ -98,10 +98,19 @@ static void VKAPI_PTR on_allocate(VmaAllocator a, uint32_t type, VkDeviceMemory
plat_event_set(&H.ev);
}
// NX2NAVI_TEX_COMPACT: every device memory VMA gives back to the driver is reported to the texture compaction, which
// counts the blocks its pass emptied (vk_texture.c vk_texture_block_freed). Any thread; the callback takes no lock.
static void VKAPI_PTR on_free(VmaAllocator a, uint32_t type, VkDeviceMemory m, VkDeviceSize size, void *user) {
(void)a, (void)type, (void)size, (void)user;
vk_texture_block_freed((uint64_t)(uintptr_t)m);
}
const VmaDeviceMemoryCallbacks *vk_headroom_callbacks(void) {
static const VmaDeviceMemoryCallbacks cb = {.pfnAllocate = on_allocate};
static const VmaDeviceMemoryCallbacks cb_free = {.pfnAllocate = on_allocate, .pfnFree = on_free};
const char *v = cfg_get("NX2NAVI_VRAM_HEADROOM"); // empty is unset: the key has no settings row for cfg_bool
H.on = !v || !*v || cfg_bool("NX2NAVI_VRAM_HEADROOM");
if (vk_texture_compact_on()) return &cb_free; // on_allocate does nothing for the headroom while H.on is 0
return H.on ? &cb : NULL;
}

View File

@@ -966,6 +966,7 @@ static int dead_pending(void);
static void vram_log(uint64_t frame);
#define HOLES_PERIOD 3600 // frames between two "mem: holes" lines (holes_log)
static void holes_log(uint64_t frame);
static void compact_look(void);
static void outside_watch(uint64_t frame);
static void up_ts_log(uint64_t frame);
@@ -983,6 +984,7 @@ void vk_mem_census(uint64_t frame) {
static uint64_t window_start;
if ((rt_trace & RT_TRACE_PERF) && frame % 600 == 0) vram_log(frame), mirror_pool_log(frame);
if (frame && frame % ((rt_trace & RT_TRACE_PERF) ? 600 : HOLES_PERIOD) == 0) holes_log(frame);
if (vk_texture_compact_due()) compact_look(); // NX2NAVI_TEX_COMPACT: 0 at once with the setting off
if ((rt_trace & RT_TRACE_PERF) && frame % 60 == 0) outside_watch(frame), up_ts_log(frame), vk_gtl_log(frame, 60);
if ((rt_trace & RT_TRACE_PERF) && !vk_mirror_on) dirty_frame(frame);
if ((rt_trace & RT_TRACE_PERF) ? frame % 60 == 0 : frame == 600 || frame == 1800)
@@ -4626,6 +4628,18 @@ static VkBlockRow *mirror_rows(VkCtx *c, int *n) {
return rows;
}
// NX2NAVI_TEX_COMPACT, when vk_texture_compact_due says the holes are due a look: the texture cache gets the mirrors'
// rows, taken here because the pool lock is not to be taken under the texture lock, and plans a pass or not
// (vk_texture.c vk_texture_compact_plan).
static void compact_look(void) {
VkCtx *c = vk_ctx();
if (!c) return;
int n;
VkBlockRow *rows = mirror_rows(c, &n);
vk_texture_compact_plan(rows, n);
free(rows);
}
// Every HOLES_PERIOD frames (600 with NX2NAVI_TRACE=perf), in every run: how much of VMA's device-local blocks is
// holes, and how much of that a compaction of the textures could give back. The first two figures are VMA's own
// (blocks and allocations, memory of its own counted in both). The rest is of the memory type the textures use most,

View File

@@ -119,6 +119,7 @@ typedef struct Tex {
uint64_t flat3d_wr; // wr_fence then
uint64_t used_frame; // presented frame (gfx_counters.frames) when a draw binding or lookup last named it
uint8_t in_rt; // in rts[] (bound into pool memory, the GPU wrote it)
uint8_t compacting; // a compaction pass lists it (CP.item): destroy_tex takes it off the list
uint8_t clobbered; // another image of another layout wrote over its pool memory since its own last write
} Tex;
@@ -722,8 +723,11 @@ static int slot_names(const Tex *e) {
// Why an entry was destroyed (drop_tex), in the order of drop_names.
enum { DROP_NONE, DROP_MAX, DROP_CAP, DROP_COMMIT, DROP_VRAM, DROP_IDLE_VRAM, DROP_IDLE_COMMIT, DROP_DISUSE,
DROP_OVERWRITTEN, DROP_REPLACED, DROP_REUSED, DROP_RANGE, DROP_EXACT, DROP_SHUTDOWN, DROP_N };
DROP_OVERWRITTEN, DROP_REPLACED, DROP_REUSED, DROP_RANGE, DROP_EXACT, DROP_SHUTDOWN, DROP_COMPACT, DROP_N };
static void drop_tex(Tex *e, int why);
static int compact_on(void); // NX2NAVI_TEX_COMPACT ("compaction", near the end of the file)
static void compact_step(void);
static void compact_forget(Tex *e);
// How an entry's content started (start_at; Tex.og_start), in the order of start_names.
enum { START_NEW, START_UPLOAD_LIVE, START_COPY_LIVE, START_COPY_RELEASED, START_EMPTY_LIVE, START_EMPTY_RELEASED,
START_COPY_GONE, START_FILL, START_N };
@@ -1881,7 +1885,7 @@ static const char *const drop_names[DROP_N] = {
"", "past RELEASED_MAX released entries", "past the released entries' size cap", "under commit pressure",
"under VRAM pressure", "idle, under VRAM pressure", "idle, under commit pressure", "unsampled (disuse)",
"overwritten at another address", "replaced by another descriptor at its address", "its image went to another texture",
"its storage went", "made whole again (texture detail)", "shutdown"};
"its storage went", "made whole again (texture detail)", "shutdown", "released, in a block a compaction empties"};
static const char *const start_names[START_N] = {
"started on its own", "filled from storage over a live entry", "content copied from a live entry",
"content copied from a released entry", "started empty over a live entry", "started empty over a released entry",
@@ -2149,6 +2153,7 @@ __attribute__((noinline)) static void destroy_tex(Tex *e) {
gone[j].ra = __builtin_return_address(0);
}
if (e->retiring) retire_remove(e);
if (e->compacting) compact_forget(e);
pool_unlist(e);
pool_forget(e);
own_remove(e);
@@ -3204,7 +3209,10 @@ void vk_texstat_add(int kind, uint64_t n, uint64_t bytes) {
plat_add64(&ts_bytes[kind], (int64_t)bytes);
}
static void compact_frame_time(uint64_t d);
void vk_texstat_frame(uint64_t frame, uint64_t d) {
if (compact_on()) compact_frame_time(d);
if (ts_log_ms < 0) {
ts_log_ms = cfg_int("NX2NAVI_TEXUP_LOG_MS");
if (ts_log_ms < 0) ts_log_ms = 0;
@@ -4905,6 +4913,7 @@ void vk_texture_new_submit(void) {
if (!++submit_epoch) submit_epoch = 1;
rate_tick();
sweep_pressure();
if (compact_on()) compact_step();
static uint64_t log_at;
uint64_t f = __atomic_load_n(&gfx_counters.frames, __ATOMIC_RELAXED) / 60;
if ((rt_trace & RT_TRACE_PERF) && f != log_at && tex_alias > 0) {
@@ -5781,7 +5790,7 @@ int vk_sampler_params(int id, samp_par *out) {
// type it came from, and the allocator's free bytes are counted per heap.
#define COMPACT_SPARSE_PCT 50
#define COMPACT_IDLE PRESENTS(600)
#define COMPACT_TEX_MAX ((uint64_t)8 << 20)
static int compact_held(uint64_t mem);
typedef struct {
TcpBlock *b;
@@ -5816,7 +5825,7 @@ static void table_add(CompTable *t, uint64_t mem, uint64_t size, uint64_t bytes,
t->cb = t->cb ? t->cb * 2 : 256;
if (!(t->b = realloc(t->b, sizeof *t->b * (size_t)t->cb))) rt_fatal("vk: out of memory for the block table");
}
t->b[t->nb] = (TcpBlock){.id = mem, .size = size};
t->b[t->nb] = (TcpBlock){.id = mem, .size = size, .held = (uint8_t)compact_held(mem)};
nvn_hm_put(&t->by_mem, &mem, (void *)(uintptr_t)(++t->nb));
}
if (t->na == t->ca) {
@@ -5862,11 +5871,432 @@ static void compact_table(CompTable *t, const VkBlockRow *other, int nother, uin
tcp_tally(t->b, t->nb, t->a, t->na);
}
// ---- the pass (NX2NAVI_TEX_COMPACT, default 0; read at start)
// With the setting on, the holes are closed by moving textures: every COMPACT_PERIOD presents, when the free space
// inside the device-local blocks is at least NX2NAVI_TEX_COMPACT_MIN_MB (one block while the video memory in use is
// above the VRAM pressure threshold), the block table is built and vk_tex_compact.h chooses the blocks to empty
// (vk_texture_compact_plan, at a present). Each submission then takes a few of the listed textures (compact_step):
// - a released one the size cap would drop is dropped (drop_tex, DROP_COMPACT), as that rule does it;
// - any other gets a new image in a hole of a block that is not being emptied (compact_move): the allocator is asked
// for existing blocks only, and it fills the fullest first; the old image is copied into it on the GPU, in the
// upload batch, and the entry takes the new image and allocation. Nothing is decoded or uploaded again.
// Moving was chosen over releasing because releasing has nothing to take: the entries a compaction would have to
// release are live ones the texture pool still names, which idle() never holds for (in four traced sessions the idle
// part of 4 to 5 GiB of CPU-provided textures was 0 MiB), and the path that takes such entries away (evict_disused) is
// off by default for the stand-ins it left on screen. A moved texture never shows a stand-in: until a slot is filled
// again it names the old image, which holds the same texels.
//
// Who holds a moved texture's old image or its views, and what becomes of each:
// - the entry itself (pub.image, alloc, the block fields): swapped under the texture lock; every later lookup, upload,
// read and write-back takes the new image.
// - slots and whatever was resolved through them (a slot's cached entry and view, the texture pool's tokens and
// descriptors, the draws' descriptor memos): tex_gen_bump moves the generation of the entry's address, as for an
// entry made or destroyed, so each is resolved again and gets a view of the new image.
// - the texture pool's descriptor sets: the current set is rewritten by the next sync, the others when they are next
// taken. The old image and its views stay until the pool has taken every set of its ring since the move
// (vk_pool_takes, as retire_sweep waits) and the GPU is past the submissions recorded until then (compact_old_sweep).
// - the replay's view cache, the stand-ins of 3D BC targets and the HDR path's notes, all keyed by the image handle
// (vk_exec_image_gone): purged when the old image goes, not before, so a submission that holds one of its views
// keeps a live view. Views of the new image are made on demand under its own handle.
// - command buffers recorded or in flight, a submission held at a wait, and the submission being recorded: they
// name the old image or its views, which live on with their texels unchanged; vk_defer_image then waits for the
// submission being recorded as for any destroyed texture.
// - the upload batch: the copy is recorded behind every upload of the old image (the batch and the queue keep
// their order) and ahead of every submission recorded after the move.
// - uploads in progress: none can be. A candidate is uploaded with no retry waiting, an upload records all its
// parts under the texture lock, and the first upload of an ASTC texture that changes its format (astc_reformat)
// is behind it.
// - render targets, the lists of GPU-written textures (owned, rts, gwin), the image reuse lists, and the inputs of
// DLSS, FSR and frame generation: all of these hold textures the GPU wrote, which never move (comes_back_intact,
// wr_fence).
// - a flat texture's 3D copy (flat3d) and a compute pass's depth twin (vk_exec_draw.c twin_update): images of their
// own, made from the entry's image when used; the copy keeps its content, the twin is copied again for each use.
//
// The work is bounded by the frame: COMPACT_FRAME_N textures and NX2NAVI_TEX_COMPACT_FRAME_MB of them per presented
// frame, none while the texture pool loads an area, while this frame already spent COMPACT_WORK_NS making or uploading
// textures, or right after a frame that took a quarter longer than the frames before it (compact_slack). A texture
// larger than the frame's bytes is not moved at all, so no frame copies more than that.
// A chosen block the pass cannot finish is given up: one of its textures came into use, or no hole outside the chosen
// blocks took it. A block emptied of its textures that the allocator does not give back holds memory of another kind
// (or is the one empty block the allocator keeps per memory type); it is not chosen again for COMPACT_HELD_FRAMES.
// A pass that gave nothing back doubles the wait for the next look, up to COMPACT_WAIT_MAX.
#define COMPACT_PERIOD PRESENTS(600)
#define COMPACT_WAIT_MAX PRESENTS(18000)
#define COMPACT_PASS_FRAMES PRESENTS(3600) // a pass still not through its list after this many presents ends there
#define COMPACT_SETTLE PRESENTS(120) // presents a finished pass waits for its blocks to be freed
#define COMPACT_HELD_FRAMES PRESENTS(36000)
#define COMPACT_ROOM_PCT 50
#define COMPACT_PASS_MAX ((uint64_t)1 << 30) // bytes one pass moves at most
#define COMPACT_FRAME_N 4
#define COMPACT_WORK_NS 1000000
#define COMPACT_OLD_MAX ((uint64_t)64 << 20) // the moved textures' old images hold this much at most while they wait
#define COMPACT_OLD_N 256
#define COMPACT_VICTIMS 32
#define COMPACT_HELD_N 64
typedef struct { VkImage img; VmaAllocation alloc; uint64_t bytes, takes, due, frame; } CompOld;
typedef struct { uint64_t mem, size; volatile int32_t freed; uint8_t gave_up; } CompVictim;
typedef struct { Tex *e; uint8_t v; } CompItem;
static struct {
int on; // -1: the settings are not read yet
uint64_t min_bytes, frame_bytes;
volatile int32_t active; // a pass runs; set and cleared under the texture lock, read without it
volatile uint64_t next_look; // gfx_counters.frames from which the holes are looked at again
uint64_t wait; // presents to the look after the next pass (0: COMPACT_PERIOD)
CompVictim v[COMPACT_VICTIMS]; // the blocks the pass empties
int nv;
CompItem *item; // their textures, block by block in the plan's order
int nitem, at;
uint64_t frame0, done_frame; // gfx_counters.frames when the pass began, when its list was through
uint64_t plan_ns, step_ns; // time the plan took (a present), and the steps (the submitting thread)
uint64_t holes0; // free bytes inside the device-local blocks when it began
uint64_t n_moved, moved_bytes, n_dropped, dropped_bytes, n_left;
uint64_t frame, frame_n, frame_b; // this presented frame's count and bytes
CompOld old[COMPACT_OLD_N]; // moved textures' old images, waiting for the pool sets and the GPU
int nold;
uint64_t old_bytes;
struct { uint64_t mem, until; } held[COMPACT_HELD_N]; // blocks emptied and not given back
unsigned held_next;
volatile uint64_t last_ns, avg_ns; // the last frame's interval and a running mean of them (compact_frame_time)
uint64_t passes, returned; // passes so far, and what they gave back
int idle_logs, old_told;
} CP = {.on = -1};
static int compact_on(void) {
if (CP.on < 0) {
int mb = cfg_int("NX2NAVI_TEX_COMPACT_MIN_MB"), fmb = cfg_int("NX2NAVI_TEX_COMPACT_FRAME_MB");
CP.min_bytes = (uint64_t)(mb < 64 ? 64 : mb > 65536 ? 65536 : mb) << 20;
CP.frame_bytes = (uint64_t)(fmb < 1 ? 1 : fmb > 32 ? 32 : fmb) << 20;
CP.on = cfg_bool("NX2NAVI_TEX_COMPACT") ? 1 : 0;
if (CP.on)
rt_log("vk: texture compaction on (NX2NAVI_TEX_COMPACT): idle textures move out of sparsely used blocks of "
"video memory once %llu MiB or more is free inside the blocks (NX2NAVI_TEX_COMPACT_MIN_MB), at most "
"%d textures and %llu MiB a frame (NX2NAVI_TEX_COMPACT_FRAME_MB)", (unsigned long long)(CP.min_bytes >> 20),
COMPACT_FRAME_N, (unsigned long long)(CP.frame_bytes >> 20));
}
return CP.on;
}
int vk_texture_compact_on(void) { return compact_on(); }
static void compact_frame_time(uint64_t d) { // each present (vk_texstat_frame)
uint64_t avg = __atomic_load_n(&CP.avg_ns, __ATOMIC_RELAXED);
__atomic_store_n(&CP.last_ns, d, __ATOMIC_RELAXED);
__atomic_store_n(&CP.avg_ns, avg ? avg + d / 16 - avg / 16 : d, __ATOMIC_RELAXED);
}
// Whether the frame being made has time to spare (see above).
static int compact_slack(void) {
if (vk_exec_pool_loading()) return 0;
if (__atomic_load_n(&ts_ns[VK_TS_CREATE], __ATOMIC_RELAXED) + __atomic_load_n(&ts_ns[VK_TS_UPLOAD], __ATOMIC_RELAXED) >
COMPACT_WORK_NS)
return 0;
uint64_t last = __atomic_load_n(&CP.last_ns, __ATOMIC_RELAXED), avg = __atomic_load_n(&CP.avg_ns, __ATOMIC_RELAXED);
return last <= avg + avg / 4;
}
// mem is a block a pass emptied of its textures and the allocator kept (compact_finish).
static int compact_held(uint64_t mem) {
uint64_t now = frames_now();
for (int i = 0; i < COMPACT_HELD_N; i++)
if (CP.held[i].mem == mem && CP.held[i].until > now) return 1;
return 0;
}
// The allocator gave device memory back to the driver (vk_headroom.c on_free; any thread, no lock): a block of the
// pass counts as returned.
void vk_texture_block_freed(uint64_t mem) {
if (!__atomic_load_n(&CP.active, __ATOMIC_ACQUIRE)) return;
for (int i = 0; i < COMPACT_VICTIMS; i++)
if (__atomic_load_n(&CP.v[i].mem, __ATOMIC_RELAXED) == mem) __atomic_store_n(&CP.v[i].freed, 1, __ATOMIC_RELEASE);
}
static int compact_victim(uint64_t mem) {
for (int i = 0; i < CP.nv; i++)
if (CP.v[i].mem == mem && !CP.v[i].gave_up) return 1;
return 0;
}
// destroy_tex: e goes another way while the pass lists it.
static void compact_forget(Tex *e) {
for (int i = CP.at; i < CP.nitem; i++)
if (CP.item[i].e == e) CP.item[i].e = NULL;
e->compacting = 0;
}
// The old images of moved textures whose views no pool set can hold any more, and no submission that held one still
// runs (see above): their views and they go, as a destroyed texture's.
static void compact_old_sweep(void) {
uint64_t takes = vk_pool_takes(), done = vk_sync_fence_done(), now = frames_now();
int k = 0;
for (int i = 0; i < CP.nold; i++) {
CompOld *o = &CP.old[i];
if (!o->due && takes - o->takes > VK_POOL_SETS) o->due = vk_sync_issued();
// The takes that count are those of syncs that left no slot behind (vk_exec_draw.c pool_sync), and with no
// creation time to spare, or nothing drawn through the pool, there may be none for a long time. The old
// images wait all the same: COMPACT_OLD_MAX bounds what they hold, and the pass moves nothing more.
if (!o->due && !CP.old_told && now - o->frame > COMPACT_PASS_FRAMES) {
CP.old_told = 1;
rt_log("vk: texture compaction: %d old images of moved textures (%.1f MiB) still wait for the texture "
"pool to take all its sets, %llu presents after the move (%llu takes since); the pass waits",
CP.nold, CP.old_bytes / 1048576.0, (unsigned long long)(now - o->frame),
(unsigned long long)(takes - o->takes));
}
if (o->due && done >= o->due) {
vk_exec_image_gone(o->img);
vk_defer_image(o->img, o->alloc);
CP.old_bytes -= o->bytes;
continue;
}
CP.old[k++] = *o;
}
CP.nold = k;
}
// e gets a new image in a hole of a block the pass does not empty, with the old image's texels (see above). 0 when no
// such hole took it; e is as it was then.
static int compact_move(VkCtx *c, Tex *e) {
const ImgKey *k = &e->ik;
VkImageCreateInfo ci = {.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, .flags = k->flags, .imageType = (VkImageType)k->type,
.format = (VkFormat)k->format, .extent = {k->w, k->h, k->d}, .mipLevels = k->levels,
.arrayLayers = k->layers, .samples = (VkSampleCountFlagBits)k->samples,
.tiling = VK_IMAGE_TILING_OPTIMAL, .usage = k->usage, .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED};
VkImage img;
if (vkCreateImage(c->device, &ci, NULL, &img) != VK_SUCCESS) return 0;
VkMemoryRequirements mr;
vkGetImageMemoryRequirements(c->device, img, &mr);
// existing blocks of the old allocation's memory type only: a new block, or another type's, would be no gain
VmaAllocationCreateInfo ai = {.flags = VMA_ALLOCATION_CREATE_NEVER_ALLOCATE_BIT, .memoryTypeBits = 1u << e->blk_type};
VmaAllocation a = NULL;
VmaAllocationInfo at;
int ok = (mr.memoryTypeBits >> e->blk_type & 1) && vmaAllocateMemory(c->vma, &mr, &ai, &a, &at) == VK_SUCCESS;
if (ok && compact_victim((uint64_t)(uintptr_t)at.deviceMemory)) { // only a block being emptied had the room
vmaFreeMemory(c->vma, a);
ok = 0;
}
if (ok && vmaBindImageMemory(c->vma, a, img) != VK_SUCCESS) {
vmaFreeMemory(c->vma, a);
ok = 0;
}
if (!ok) {
vkDestroyImage(c->device, img, NULL);
return 0;
}
vk_exec_image_gone(img); // views cached under the handle of a destroyed image (create_tex)
VkCommandBuffer cb = vk_upload_begin();
VkMemoryBarrier2 mb = {.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER_2, .srcStageMask = VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT,
.srcAccessMask = VK_ACCESS_2_MEMORY_READ_BIT | VK_ACCESS_2_MEMORY_WRITE_BIT,
.dstStageMask = VK_PIPELINE_STAGE_2_COPY_BIT, .dstAccessMask = VK_ACCESS_2_TRANSFER_READ_BIT};
VkImageMemoryBarrier2 ib = {.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER_2, .srcStageMask = VK_PIPELINE_STAGE_2_NONE,
.dstStageMask = VK_PIPELINE_STAGE_2_COPY_BIT, .dstAccessMask = VK_ACCESS_2_TRANSFER_WRITE_BIT,
.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED, .newLayout = VK_IMAGE_LAYOUT_GENERAL,
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.image = img,
.subresourceRange = {e->pub.aspect, 0, VK_REMAINING_MIP_LEVELS, 0, VK_REMAINING_ARRAY_LAYERS}};
VkDependencyInfo di = {.sType = VK_STRUCTURE_TYPE_DEPENDENCY_INFO, .memoryBarrierCount = 1, .pMemoryBarriers = &mb,
.imageMemoryBarrierCount = 1, .pImageMemoryBarriers = &ib};
vkCmdPipelineBarrier2(cb, &di);
VkImageCopy rg[16];
for (uint32_t lv = 0; lv < e->pub.levels;) { // every level whole, all layers at once
uint32_t n = 0;
for (; lv < e->pub.levels && n < 16; lv++, n++)
rg[n] = (VkImageCopy){.srcSubresource = {e->pub.aspect, lv, 0, e->pub.layers},
.dstSubresource = {e->pub.aspect, lv, 0, e->pub.layers},
.extent = {mip(e->pub.width, lv), mip(e->pub.height, lv), mip(e->pub.depth, lv)}};
vkCmdCopyImage(cb, e->pub.image, VK_IMAGE_LAYOUT_GENERAL, img, VK_IMAGE_LAYOUT_GENERAL, n, rg);
}
barrier(cb, VK_PIPELINE_STAGE_2_COPY_BIT, VK_ACCESS_2_TRANSFER_WRITE_BIT, ALL_RW);
vk_upload_end();
CP.old[CP.nold++] = (CompOld){e->pub.image, e->alloc, e->bytes, vk_pool_takes(), 0, frames_now()};
CP.old_bytes += e->bytes;
if (e->released) rel_bytes -= e->bytes;
e->pub.image = img, e->alloc = a, e->reused = 0;
own_memory(c, e);
if (e->released) rel_bytes += e->bytes;
tex_gen_bump(e->key.addr);
return 1;
}
// The pass is over: its line, the blocks the allocator kept, and when the holes are looked at next.
static void compact_finish(uint64_t now) {
VkCtx *c = vk_ctx();
uint64_t holes = 0, back = 0;
int n_back = 0, n_gave = 0, n_kept = 0;
for (int i = 0; i < CP.nv; i++) {
CompVictim *v = &CP.v[i];
if (__atomic_load_n(&v->freed, __ATOMIC_ACQUIRE)) {
n_back++, back += v->size;
} else if (v->gave_up) {
n_gave++;
} else { // emptied of its textures and still there
n_kept++;
CP.held[CP.held_next++ % COMPACT_HELD_N] = (typeof(CP.held[0])){v->mem, now + COMPACT_HELD_FRAMES};
}
}
if (c) {
VmaBudget vb[VK_MAX_MEMORY_HEAPS];
vmaGetHeapBudgets(c->vma, vb);
for (uint32_t h = 0; h < c->mem_props.memoryHeapCount && h < VK_MAX_MEMORY_HEAPS; h++)
if ((c->mem_props.memoryHeaps[h].flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT) &&
vb[h].statistics.blockBytes > vb[h].statistics.allocationBytes)
holes += vb[h].statistics.blockBytes - vb[h].statistics.allocationBytes;
}
CP.passes++, CP.returned += back;
CP.wait = back ? 0 : CP.wait ? (CP.wait * 2 < COMPACT_WAIT_MAX ? CP.wait * 2 : COMPACT_WAIT_MAX) : 2 * COMPACT_PERIOD;
uint64_t wait = CP.wait ? CP.wait : COMPACT_PERIOD;
const double M = 1048576.0;
rt_log("vk: texture compaction: pass %llu over %llu frames: %llu textures moved (%.1f MiB), %llu released ones dropped "
"(%.1f MiB), %llu left where they were; %d of %d blocks emptied and given back to the driver, %.0f MiB; %d "
"given up (a texture came into use or found no room elsewhere), %d emptied of textures and kept by the "
"allocator (its one empty block, or memory of another kind); free inside the blocks %.0f MiB before, %.0f MiB "
"now; %.2f ms on the submitting thread, %.2f ms for the plan; %.0f MiB given back by all passes so far; next "
"look in %llu presents", (unsigned long long)CP.passes, (unsigned long long)(now - CP.frame0),
(unsigned long long)CP.n_moved, CP.moved_bytes / M, (unsigned long long)CP.n_dropped, CP.dropped_bytes / M,
(unsigned long long)CP.n_left, n_back, CP.nv, back / M, n_gave, n_kept, CP.holes0 / M, holes / M,
CP.step_ns / 1e6, CP.plan_ns / 1e6, CP.returned / M, (unsigned long long)wait);
free(CP.item);
CP.item = NULL, CP.nitem = CP.at = 0;
__atomic_store_n(&CP.next_look, now + wait, __ATOMIC_RELAXED);
__atomic_store_n(&CP.active, 0, __ATOMIC_RELEASE);
}
// Each submission, texture lock held (vk_texture_new_submit): the old images that can go, then this frame's share of
// the pass.
static void compact_step(void) {
if (CP.nold) compact_old_sweep();
if (!CP.active) return;
uint64_t t0 = plat_now_ns(), now = frames_now();
if (now != CP.frame) CP.frame = now, CP.frame_n = 0, CP.frame_b = 0;
if (CP.at < CP.nitem && now - CP.frame0 > COMPACT_PASS_FRAMES) { // the frames never had the time: what is left stays
for (; CP.at < CP.nitem; CP.at++) {
CompItem *it = &CP.item[CP.at];
if (it->e) it->e->compacting = 0, CP.v[it->v].gave_up = 1, CP.n_left++;
}
}
if (CP.at < CP.nitem && compact_slack()) {
VkCtx *c = vk_ctx();
int marks = compact_marks();
while (c && CP.at < CP.nitem && CP.frame_n < COMPACT_FRAME_N && CP.frame_b < CP.frame_bytes &&
CP.nold < COMPACT_OLD_N && CP.old_bytes < COMPACT_OLD_MAX) {
CompItem it = CP.item[CP.at];
Tex *e = it.e;
if (e && CP.frame_b && CP.frame_b + e->bytes > CP.frame_bytes) break; // the next frame's
CP.at++;
if (!e) continue; // destroyed since the plan
e->compacting = 0;
CompVictim *v = &CP.v[it.v];
if (e->blk_mem != v->mem) continue; // its image was made again since (astc_reformat): no longer in the block
uint64_t bytes = e->bytes;
// as it is now: sampled, written or taken back since the plan, it may no longer move
int what = v->gave_up ? TCP_STAY : compact_what(e, now, marks, CP.frame_bytes);
if (what == TCP_DROP) {
drop_tex(e, DROP_COMPACT);
CP.n_dropped++, CP.dropped_bytes += bytes, CP.frame_n++;
} else if (what == TCP_MOVE && compact_move(c, e)) {
CP.n_moved++, CP.moved_bytes += bytes, CP.frame_n++, CP.frame_b += bytes;
} else {
v->gave_up = 1, CP.n_left++;
}
}
}
CP.step_ns += plat_now_ns() - t0;
if (CP.at < CP.nitem || CP.nold) return;
if (!CP.done_frame) CP.done_frame = now;
int waiting = 0; // blocks emptied whose memory the deferred destroys have not freed yet
for (int i = 0; i < CP.nv; i++) waiting += !CP.v[i].gave_up && !__atomic_load_n(&CP.v[i].freed, __ATOMIC_ACQUIRE);
if (!waiting || now - CP.done_frame >= COMPACT_SETTLE) compact_finish(now);
}
// At a present, no lock: whether the holes are to be looked at now (vk_memory.c vk_mem_census, which then gathers the
// VRAM mirrors' rows for vk_texture_compact_plan).
int vk_texture_compact_due(void) {
return compact_on() && !__atomic_load_n(&CP.active, __ATOMIC_ACQUIRE) &&
frames_now() >= __atomic_load_n(&CP.next_look, __ATOMIC_RELAXED);
}
// Looks at the holes and, when emptying blocks would give enough back, starts a pass (see above).
void vk_texture_compact_plan(const VkBlockRow *other, int nother) {
VkCtx *c = vk_ctx();
if (!c || !compact_on()) return;
uint64_t t0 = plat_now_ns();
lock();
uint64_t now = frames_now();
if (CP.active || CP.nold || now < CP.next_look) { // the last pass's old images first
unlock();
return;
}
__atomic_store_n(&CP.next_look, now + (CP.wait ? CP.wait : COMPACT_PERIOD), __ATOMIC_RELAXED);
VmaBudget vb[VK_MAX_MEMORY_HEAPS];
vmaGetHeapBudgets(c->vma, vb);
uint64_t holes = 0, used = 0, budget = 0;
for (uint32_t h = 0; h < c->mem_props.memoryHeapCount && h < VK_MAX_MEMORY_HEAPS; h++)
if ((c->mem_props.memoryHeaps[h].flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT) &&
vb[h].statistics.blockBytes > vb[h].statistics.allocationBytes)
holes += vb[h].statistics.blockBytes - vb[h].statistics.allocationBytes;
int exact;
int pressed = vram_pressure > 0 && vk_mem_vram(&used, &budget, &exact) && budget &&
used >= budget / 100 * (uint64_t)vram_pressure;
uint64_t min = pressed ? vk_vma_block_size() : CP.min_bytes;
if (holes < min) {
unlock();
return;
}
CompTable t;
compact_table(&t, other, nother, CP.frame_bytes);
uint32_t heap = c->mem_props.memoryTypes[t.type].heapIndex, order[COMPACT_VICTIMS];
const VmaStatistics *hs = &vb[heap < VK_MAX_MEMORY_HEAPS ? heap : 0].statistics;
TcpParams p = {.free_bytes = hs->blockBytes > hs->allocationBytes ? hs->blockBytes - hs->allocationBytes : 0,
.sparse_pct = COMPACT_SPARSE_PCT, .room_pct = COMPACT_ROOM_PCT, .min_gain = min,
.max_move = COMPACT_PASS_MAX, .max_blocks = COMPACT_VICTIMS};
TcpPlan plan;
int n = t.nb ? tcp_plan(t.b, t.nb, &p, order, COMPACT_VICTIMS, &plan) : 0;
CompItem *item = n ? malloc(sizeof *item * (size_t)(plan.n_move + plan.n_drop + 1)) : NULL;
if (!item) {
TcpStats s;
tcp_stats(t.b, t.nb, COMPACT_SPARSE_PCT, &s);
CP.wait = CP.wait ? (CP.wait * 2 < COMPACT_WAIT_MAX ? CP.wait * 2 : COMPACT_WAIT_MAX) : 2 * COMPACT_PERIOD;
__atomic_store_n(&CP.next_look, now + CP.wait, __ATOMIC_RELAXED);
if (CP.idle_logs < 8) {
CP.idle_logs++;
const double M = 1048576.0;
rt_log("vk: texture compaction: %.0f MiB free inside the blocks and no pass: %u of %u blocks with textures are "
"at most half full, %u of them (%.0f MiB) hold nothing but textures that could move or be dropped, "
"and a pass needs %.0f MiB of them with room elsewhere for what moves (%.0f MiB free in the memory "
"type's heap)%s; next look in %llu presents", holes / M, s.sparse, s.blocks, s.emptiable,
s.emptiable_bytes / M, min / M, p.free_bytes / M,
t.marks ? "" : "; the texture pool's marks are not in use, so no texture counts as unsampled",
(unsigned long long)CP.wait);
}
table_free(&t);
unlock();
return;
}
CP.nv = n, CP.nitem = 0;
for (int j = 0; j < n; j++) {
CP.v[j] = (CompVictim){.mem = t.b[order[j]].id, .size = t.b[order[j]].size};
for (int i = 0; i < t.na; i++) { // the block's textures; n is at most COMPACT_VICTIMS
if (t.a[i].block != order[j] || !t.ae[i] || t.a[i].what == TCP_STAY) continue;
item[CP.nitem++] = (CompItem){t.ae[i], (uint8_t)j};
t.ae[i]->compacting = 1;
}
}
for (int j = n; j < COMPACT_VICTIMS; j++) CP.v[j] = (CompVictim){0};
table_free(&t);
CP.item = item, CP.at = 0;
CP.frame0 = now, CP.done_frame = 0, CP.holes0 = holes, CP.step_ns = 0;
CP.n_moved = CP.moved_bytes = CP.n_dropped = CP.dropped_bytes = CP.n_left = 0, CP.old_told = 0;
CP.plan_ns = plat_now_ns() - t0;
__atomic_store_n(&CP.active, 1, __ATOMIC_RELEASE);
unlock();
}
void vk_texture_holes(const VkBlockRow *other, int nother, VkTexHoles *h) {
memset(h, 0, sizeof *h);
lock();
CompTable t;
compact_table(&t, other, nother, COMPACT_TEX_MAX);
compact_on(); // reads the frame's bytes, which are also the largest texture that moves
compact_table(&t, other, nother, CP.frame_bytes);
TcpStats s;
tcp_stats(t.b, t.nb, COMPACT_SPARSE_PCT, &s);
*h = (VkTexHoles){.type = t.type, .marks = t.marks, .blocks = s.blocks, .sparse = s.sparse, .emptiable = s.emptiable,
@@ -6028,6 +6458,13 @@ void vk_texture_shutdown(void) {
if (by_key.vals[i]) list[n++] = by_key.vals[i];
for (int i = 0; i < n; i++) drop_tex(list[i], DROP_SHUTDOWN);
free(list);
for (int i = 0; i < CP.nold; i++) { // moved textures' old images (compact_old_sweep)
vk_exec_image_gone(CP.old[i].img);
vk_defer_image(CP.old[i].img, CP.old[i].alloc);
}
free(CP.item);
CP.item = NULL, CP.nitem = CP.at = CP.nold = 0, CP.old_bytes = 0;
__atomic_store_n(&CP.active, 0, __ATOMIC_RELEASE);
for (uint32_t i = 0; i < samplers.cap; i++)
if (samplers.vals[i] && c) vkDestroySampler(c->device, (VkSampler)samplers.vals[i], NULL);
free(by_key.keys), free(by_key.vals), free(by_addr.keys), free(by_addr.vals), free(samplers.keys), free(samplers.vals);