From 96d656ba1ddda5d3479737c0bd79a5b9a2f2b952 Mon Sep 17 00:00:00 2001 From: chrg6 Date: Thu, 12 Nov 2026 22:27:25 -0500 Subject: [PATCH] Time the staging allocation's wait for an upload batch, and say what the batch limit and the staging pool do now. A staging buffer that does not fit the staging pool's blocks waits for the oldest upload batch with the upload lock held, and a replay submission that sends its uploads waits for that lock. The wait cannot leave the lock: the caller holds the open batch's command buffer and the buffer it asks for belongs to that batch. What the wait costs on an area load was not measured anywhere, so it is timed and logged now: the first four waits, then at every doubling of their count, with the total and the longest. Since staging stays inside the pool's blocks every batch is submitted at 64 MiB of staging, so upload_batch_mb = 0 is that limit and not "no limit", and the values above it (the low-end mode's 256, the menu's 128 to 1024) change nothing; a discrete card keeps 256 MiB of staging for the run, not 128. The settings table, the menu's text, nx2navi.ini.example, the audit's table and the comments say so. Co-Authored-By: Claude Fable 5.2 --- AUDIT.md | 2 +- nx2navi.ini.example | 3 ++- runtime/cfg.c | 4 +++- runtime/ui_text.c | 5 +++-- runtime/vk_memory.c | 25 ++++++++++++++++++++++--- 5 files changed, 31 insertions(+), 8 deletions(-) diff --git a/AUDIT.md b/AUDIT.md index a7346cea..16a58342 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -1075,7 +1075,7 @@ back. | 68 | Lazy write-back command buffer and fence | `lz_init_lock` | One | Never freed explicitly | | 69 | Upload batches (command buffers and fences: inflight and spare) | `cmd_lock` | Grow only | Reused; shutdown | | 70 | Staging cache | `cmd_lock` | `STG_CACHE` 24 buffers, `STG_CACHE_BYTES` 192 MiB | At batch retire | -| 71 | Staging VMA pool | — | `STG_BLOCK` 64 MiB blocks, `STG_MIN_BLOCKS` 2; requests ≤ 32 MiB | — | +| 71 | Staging VMA pool | — | `STG_BLOCK` 64 MiB blocks; `STG_MIN_BLOCKS` 4 kept on a discrete GPU (256 MiB of host memory), `STG_MIN_BLOCKS_UMA` 2 on an integrated one; requests ≤ 32 MiB; an open batch is submitted at `STG_FLUSH` 64 MiB of staging | — | | 72 | Staging arena | `cmd_lock` | `ARENA_CHUNK` 8 MiB, ≤ 2 MiB per request, `ARENA_KEEP` 8; `NX2NAVI_STG_ARENA` | At fence; shutdown | | 73 | Deferred destruction list `dead[]` (images, views, buffers, host frees) | `cmd_lock` | None found | `vk_mem_collect` on the completion thread once the stamped value is reached | | 74 | Headroom spare (half a VMA block per device-local type; block `NX2NAVI_VMA_BLOCK_MB` 64, 16..256) | "vk headroom" | 85 % of VMA's budget; `NX2NAVI_VRAM_HEADROOM=0` off | Replaced, dropped when there is no room or commit is low, at stop | diff --git a/nx2navi.ini.example b/nx2navi.ini.example index a72321a1..d2a0fca4 100644 --- a/nx2navi.ini.example +++ b/nx2navi.ini.example @@ -311,7 +311,8 @@ # 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 # stays within what the driver reports free (vram_heap_limit = auto, vram_reserve_mb = 512), large texture upload -# batches go to the GPU early (upload_batch_mb = 256), game memory is copied for the GPU while a frame is recorded and +# batches go to the GPU early (upload_batch_mb = 256; every batch goes at 64 MiB of staging at the latest with or +# without it, so only a value below 64 acts), game memory is copied for the GPU while a frame is recorded and # the game waits less for the GPU (mirror_stage = 1, submit_finish_gpu = 0), frames are prepared on a thread of their # own (replay_async = 1, which can show a dark or flickering frame now and then), pools the game refills every frame # stay in system memory (mirror_hot = 25), and a texture read after another image was drawn over its memory gets diff --git a/runtime/cfg.c b/runtime/cfg.c index 90c8b685..55646ba6 100644 --- a/runtime/cfg.c +++ b/runtime/cfg.c @@ -318,7 +318,9 @@ static const CfgMeta metas[] = { {"FOLIAGE", "full", "full|reduced|off", RESTART}, // vk_exec_draw.c foliage_skip {"VRAM_HEAP_LIMIT", "0", NULL, RESTART}, // vk_device.c vram_heap_limits: auto, 0 or MiB {"VRAM_RESERVE_MB", "0", "0..4096", RESTART}, // vk_vma_budget.c: video memory kept free of VMA's allocations - {"UPLOAD_BATCH_MB", "0", NULL, RESTART}, // vk_memory.c: MiB of staging before a batch goes early, 0 no limit + // vk_memory.c vk_upload_end: MiB of staging before a batch goes early. 0: at 64 MiB, the staging pool's own limit + // (STG_FLUSH), which also holds for every value above it + {"UPLOAD_BATCH_MB", "0", NULL, RESTART}, {"MESH_EMULATION", "0", "0|1", RESTART}, {"MESH_EMU_MB", "256", "32..2048", RESTART}, {"MESH_EMU_TASKS", "16384", "256..262144", RESTART}, {"MESH_EMU_BATCH", "1", "0|1", RESTART}, {"VEC_INDEX_FIX", "0", "0|auto|1", RESTART}, {"GUEST_OPT", "1", "1|2", RESTART}, // tools/make_game.py: the next Rebuild the Game diff --git a/runtime/ui_text.c b/runtime/ui_text.c index 5349e42b..56a854c3 100644 --- a/runtime/ui_text.c +++ b/runtime/ui_text.c @@ -119,8 +119,9 @@ static const char *const text_en[UI_TEXT_COUNT] = { "driver does not run out. Off: no limit.", [UI_MENU_LOWEND_UPLOAD_BATCH] = "Upload batch limit", [UI_MENU_LOWEND_UPLOAD_BATCH_DESC] = "Texture uploads waiting in staging memory are sent to the GPU early once they " - "hold this much, so an area load cannot run video memory out.", - [UI_MENU_LOWEND_NO_LIMIT] = "No limit", + "hold this much, so an area load cannot run video memory out. Every batch is " + "sent at 64 MB at the latest, so none of these choices changes anything now.", + [UI_MENU_LOWEND_NO_LIMIT] = "64 MB", [UI_MENU_LOWEND_ASTC_KEEP] = "Keep the ASTC decode buffer", [UI_MENU_LOWEND_ASTC_KEEP_DESC] = "The buffer that GPU-decodes compressed textures is kept between upload batches " "instead of being allocated again for each one.", diff --git a/runtime/vk_memory.c b/runtime/vk_memory.c index fa75347d..2465fbea 100644 --- a/runtime/vk_memory.c +++ b/runtime/vk_memory.c @@ -5097,7 +5097,8 @@ VkCommandBuffer vk_upload_begin(void) { // NX2NAVI_UPLOAD_BATCH_MB (256 with low_end_gpu = 1; 0, the default, = no limit of its own): once the open batch's // staging buffers hold that much, the outermost vk_upload_end submits it. It does so too once the batch's staging // reaches STG_FLUSH, so that an area load's staging stays inside the staging pool's blocks (the staging pool's -// comment). A save load made thousands of textures between two replay submissions, and every one of them went into +// comment): 0 therefore means STG_FLUSH (64 MiB), not "never", and a value above STG_FLUSH changes nothing, the +// low-end mode's 256 and the settings menu's 128 to 1024 included. A save load made thousands of textures between two replay submissions, and every one of them went into // one batch: about 1.5 GB of raw ASTC blocks plus the decoder's work in a single submission, // which AMD's 23.10 driver on an RX 580 refused with VK_ERROR_OUT_OF_DEVICE_MEMORY. Submitted early, a batch still // precedes the replay submission that uses its textures (the queue keeps order), and its staging is freed as it retires. @@ -5168,19 +5169,27 @@ void vk_mem_oom_report(const char *what, VkResult r) { // which it waits for in order (a wait covers all the queue's work submitted ahead of that batch, tens of ms on a heavy // frame, with cmd_lock held). Only when none of that makes room (the open batch's own staging, staging buffers their // callers hold, the blocks' fragmentation) does the pool grow by a block, and the log says so. -// A discrete GPU keeps STG_MIN_BLOCKS blocks; an integrated one, whose host memory is the machine's RAM and where low -// memory ended the game before (vk_mem_give_back), keeps STG_MIN_BLOCKS_UMA, and its loads wait for batches sooner. +// A discrete GPU keeps STG_MIN_BLOCKS blocks, 256 MiB of host memory for the whole run (128 MiB up to 0.9.2); an +// integrated one, whose host memory is the machine's RAM and where low memory ended the game before +// (vk_mem_give_back), keeps STG_MIN_BLOCKS_UMA, 128 MiB, and its loads wait for batches sooner. #define STG_BLOCK (64ull << 20) #define STG_MIN_BLOCKS 4 #define STG_MIN_BLOCKS_UMA 2 static VmaPool stg_pool; static int stg_pool_tried; static uint64_t stg_room_cache, stg_room_wait, stg_room_new; // cmd_lock: how stg_create found room (rt_log below) +static uint64_t stg_wait_ticks, stg_wait_max; // cmd_lock: qpc ticks stg_create waited for batches // vmaCreateBuffer of a staging buffer (*ai names stg_pool) inside the pool's blocks: when they are full, the kept // staging buffers and the arena's idle chunks go, then the oldest batch in flight is waited for and retired, until the // buffer fits. chunk: the buffer is an arena chunk, and VK_INCOMPLETE says that a retired batch gave chunks back to // arena_free, to take one of those instead. Without a batch to wait for (or once the device is lost) the pool grows. +// The wait runs with cmd_lock held, and cannot leave it: the caller is inside vk_upload_begin .. vk_upload_end with +// the open batch's command buffer in hand, and the staging buffer it asks for belongs to that batch, so letting go of +// the lock would let another thread submit the batch under it. Meanwhile a replay submission that wants to send its +// uploads (vk_upload_flush) waits for the lock; its own commands would queue behind the same batches, so what it +// loses is recording time, not GPU time. How long that is on a load is not measured anywhere yet: the waits are +// timed here and logged (the first four, then at every doubling of their count), so a played session's log says. static VkResult stg_create(VkCtx *c, const VkBufferCreateInfo *bi, const VmaAllocationCreateInfo *ai, VkBuffer *b, VmaAllocation *a, VmaAllocationInfo *info, int chunk) { VmaAllocationCreateInfo in = *ai; @@ -5198,13 +5207,23 @@ static VkResult stg_create(VkCtx *c, const VkBufferCreateInfo *bi, const VmaAllo } if (ninflight && !vk_device_is_lost()) { int before = ninflight; + uint64_t w0 = qpc(); VkResult w = vkWaitForFences(c->device, 1, &inflight[0].fence, VK_TRUE, UINT64_MAX); if (w != VK_SUCCESS) { vk_device_lost_check(w, "the wait for an upload batch"); rt_fatal("vk: the wait for an upload failed (%d)", (int)w); } + uint64_t took = qpc() - w0; + stg_wait_ticks += took; + if (took > stg_wait_max) stg_wait_max = took; retire(c, 0); stg_room_wait++; + if (stg_room_wait <= 4 || !(stg_room_wait & (stg_room_wait - 1))) + rt_log("vk: a staging buffer of %.1f MiB waited %.1f ms for an upload batch to finish, with the upload " + "lock held (%llu such waits so far, %.0f ms in all, the longest %.1f ms; %d batches still in " + "flight). A replay submission that sends its uploads waits for that lock", + bi->size / 1048576.0, qpc_ms(took), (unsigned long long)stg_room_wait, qpc_ms(stg_wait_ticks), + qpc_ms(stg_wait_max), ninflight); if (chunk && narena_free) { r = VK_INCOMPLETE; break;