mirror of
https://github.com/valentderah/totk-pc-recomp.git
synced 2026-11-11 01:41:02 +00:00
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3003 lines
155 KiB
C
3003 lines
155 KiB
C
// Vulkan memory of the graphics layer: NVN memory pools as Vulkan
|
|
// buffers, the virtual-pool page table, GPU address resolution, page write generations for re-uploads, and the
|
|
// upload batch (command buffer, staging buffers, deferred destruction) that vk_texture.c records into.
|
|
//
|
|
// Behaviour ported from MacNativeGames games/totk/third_party/d2r_gfx/gfx/nvn/totk_vpool.c (request checks, unmap
|
|
// semantics, 64 KiB pages) and nvn_dirty.c (generation stamps: a texture is stale when a covered page carries a
|
|
// generation newer than its upload), @ b75be8cf (MIT, Copyright (c) 2026 jhaizhou; origin d2r-mobile); and from LAS
|
|
// Recomp runtime/nvn/vulkan/exec.c:2094-2131 (vk_pages_changed: every write-watch query resets the OS bits, so each
|
|
// query stamps the pages it saw with a new generation shared by all users) and textures.c:240-270 (deferred
|
|
// destruction) @ da35b83a (MIT, Copyright (c) 2026 Peiikai). MNG aliases host VM (mach_vm_remap) and protects pages
|
|
// (fault handler); here virtual pools are a GPU-side page table over device-local memory and dirty pages come from
|
|
// GetWriteWatch (mem_written_pages), so neither is needed.
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
#include "cfg.h"
|
|
#include "gfx.h"
|
|
#include "host.h"
|
|
#include "nvn_api.h"
|
|
#include "nvn_pool_index.h"
|
|
#include "vk.h"
|
|
#include "vk_exec.h"
|
|
#include "wb_merge_spv.h"
|
|
#include "platform/platform.h"
|
|
|
|
// Timestamps for the tracking cost, in ns; qpc_ms converts.
|
|
static uint64_t qpc(void) { return plat_now_ns(); }
|
|
|
|
static double qpc_ms(uint64_t ticks) { return (double)ticks / 1e6; }
|
|
|
|
// ---------------------------------------------------------------- pools
|
|
enum { POOL_IMPORT = 1, POOL_PHYS, POOL_VIRT };
|
|
enum { MS_NONE, MS_ON, MS_REFUSED };
|
|
|
|
typedef struct { struct PoolRec *phys; uint64_t off; } VPage; // virtual page -> physical pool and offset
|
|
|
|
#define FLUSH_RANGES 32 // flush ranges a mirrored pool keeps between two syncs (flush_add)
|
|
typedef struct { uint64_t lo, hi; } FlushRange;
|
|
|
|
typedef struct PoolRec {
|
|
const pool_t *key;
|
|
int kind;
|
|
uint32_t flags;
|
|
uint64_t lo, size; // GPU address range of the pool
|
|
uint64_t import_lo, import_hi; // POOL_IMPORT: the imported host range (aligned outwards)
|
|
VkBuffer buf;
|
|
VkDeviceMemory mem; // POOL_IMPORT
|
|
VmaAllocation alloc; // POOL_PHYS
|
|
VPage *pt; // POOL_VIRT: size / 64 KiB entries
|
|
uint64_t npages;
|
|
volatile int32_t used; // resolved at least once: the GPU may still read it at finalize
|
|
// TOTK_TRACE=perf dirty census (vk_mem_dirty_submit), replay thread under the submit lock only:
|
|
uint32_t bound_sub; // last submit that bound it as a buffer (dirty_sub), 0 never
|
|
uint32_t synced_gen; // page generation its lazy-mirror count covers; DIRTY_UNSYNCED until scanned
|
|
uint8_t bufbound; // ever bound as a vertex, index, uniform, storage or indirect buffer
|
|
uint8_t untracked; // the last dirty scan found no write watch over it
|
|
// VRAM mirror (POOL_IMPORT, "VRAM mirror" below). mstate and mbuf change on the replay thread only.
|
|
uint8_t mstate; // MS_NONE until the first buffer use, then MS_ON or MS_REFUSED
|
|
VkBuffer mbuf; // MS_ON: device-local copy of [import_lo, import_hi), same offsets as buf
|
|
VmaAllocation malloc_;
|
|
uint32_t acc_sub; // replay: the last submission that synced the mirror (mirror_sync)
|
|
uint32_t wb_sub; // replay: the last submission that wrote mirror bytes back to guest memory
|
|
uint32_t mgen; // gen_lock: every page write stamped up to this generation is in the mirror
|
|
// (or in a copy queued before its next use)
|
|
FlushRange fl[FLUSH_RANGES + 1]; // flush_lock: nvnMemoryPoolFlushMappedRange pages not copied yet (flush_add)
|
|
int fl_n; // flush_lock: ranges in fl; read without the lock to skip an empty list
|
|
uint64_t fl_calls, fl_sum; // flush_lock: the calls and bytes behind fl, for mirror_sync's statistics
|
|
// Shadow storage (POOL_IMPORT in shadow mode, "shadow storage" below): buf is a host-visible copy of the guest pages.
|
|
VmaAllocation salloc;
|
|
uint8_t *smap; // its mapping; offsets as in buf (from import_lo)
|
|
uint32_t sgen; // gen_lock: every page write stamped up to this generation is in the shadow
|
|
volatile uint8_t stouched; // on the touched list: the GPU may use it in the submission being recorded
|
|
uint64_t suse; // shadow_lock: the newest timeline value whose submission reads the shadow
|
|
uint64_t sdefer; // shadow_lock: the newest value whose prologue carries a deferred copy into the
|
|
// shadow (TOTK_SHADOW_DEFER); 0 never
|
|
} PoolRec;
|
|
|
|
static void mirror_forget(PoolRec *r); // pool_lock exclusive: off the mirrored list
|
|
static int shadow_on; // shadow mode: imported pools get shadow storage instead (vk_mem_class_log)
|
|
static int shadow_pool(VkCtx *c, PoolRec *r, uint64_t addr, uint64_t size);
|
|
static void shadow_touch(PoolRec *r);
|
|
static void shadow_forget(PoolRec *r);
|
|
static void pool_retiring(PoolRec *r); // vk_pool_fini: kept until pool_retire (or vk_mem_shutdown)
|
|
static void pool_retire(void *arg); // completion thread: destroys a finalized pool's record
|
|
static uint64_t retire_value(void); // the timeline value after which a finalized pool's record may go
|
|
static void grow(void **arr, int *cap, size_t elem);
|
|
|
|
static PlatMutex pool_lock = PLAT_MUTEX_INIT;
|
|
// Bumped under pool_lock (exclusive) by every change to the index or a page table: vk_mem_resolve's per-thread hits
|
|
// stay valid while it is unchanged.
|
|
static uint64_t pool_gen = 1;
|
|
// Bumped when a buffer use may get another buffer for the same span (a mirror created, refused or forgotten) and at
|
|
// every submission's start (mirror syncs are per submission). vk_mem_gen: both together.
|
|
static uint64_t mirror_gen;
|
|
static pidx pool_index; // POOL_IMPORT, POOL_PHYS and POOL_VIRT records by GPU address
|
|
static PoolRec **phys_list; // physical pools by key, for MapVirtual requests
|
|
static int nphys, cap_phys;
|
|
static int32_t n_imports, n_import_failures;
|
|
static PFN_vkGetMemoryHostPointerPropertiesEXT p_host_props;
|
|
|
|
static VkBufferUsageFlags pool_usage(const VkCtx *c) {
|
|
VkBufferUsageFlags u = VK_BUFFER_USAGE_TRANSFER_SRC_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT |
|
|
VK_BUFFER_USAGE_UNIFORM_TEXEL_BUFFER_BIT | VK_BUFFER_USAGE_STORAGE_TEXEL_BUFFER_BIT |
|
|
VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT | VK_BUFFER_USAGE_STORAGE_BUFFER_BIT |
|
|
VK_BUFFER_USAGE_INDEX_BUFFER_BIT | VK_BUFFER_USAGE_VERTEX_BUFFER_BIT |
|
|
VK_BUFFER_USAGE_INDIRECT_BUFFER_BIT;
|
|
if (c->has_transform_feedback)
|
|
u |= VK_BUFFER_USAGE_TRANSFORM_FEEDBACK_BUFFER_BIT_EXT | VK_BUFFER_USAGE_TRANSFORM_FEEDBACK_COUNTER_BUFFER_BIT_EXT;
|
|
if (c->has_conditional_rendering) u |= VK_BUFFER_USAGE_CONDITIONAL_RENDERING_BIT_EXT;
|
|
return u;
|
|
}
|
|
|
|
static int memory_type(const VkCtx *c, uint32_t bits, VkMemoryPropertyFlags want) {
|
|
for (uint32_t i = 0; i < c->mem_props.memoryTypeCount; i++)
|
|
if ((bits >> i & 1) && (c->mem_props.memoryTypes[i].propertyFlags & want) == want) return (int)i;
|
|
for (uint32_t i = 0; i < c->mem_props.memoryTypeCount; i++)
|
|
if (bits >> i & 1) return (int)i;
|
|
return -1;
|
|
}
|
|
|
|
// CPU-visible storage: the guest pages themselves become the buffer's memory (VK_EXT_external_memory_host).
|
|
static int import_pool(VkCtx *c, PoolRec *r, uint64_t addr, uint64_t size) {
|
|
if (shadow_on) return shadow_pool(c, r, addr, size);
|
|
if (!p_host_props)
|
|
p_host_props = (PFN_vkGetMemoryHostPointerPropertiesEXT)vkGetDeviceProcAddr(c->device, "vkGetMemoryHostPointerPropertiesEXT");
|
|
uint64_t al = c->min_imported_host_alignment ? c->min_imported_host_alignment : 0x1000;
|
|
r->import_lo = addr & ~(al - 1);
|
|
r->import_hi = (addr + size + al - 1) & ~(al - 1);
|
|
uint64_t n = r->import_hi - r->import_lo;
|
|
VkMemoryHostPointerPropertiesEXT hp = {.sType = VK_STRUCTURE_TYPE_MEMORY_HOST_POINTER_PROPERTIES_EXT};
|
|
VkResult res = p_host_props ? p_host_props(c->device, VK_EXTERNAL_MEMORY_HANDLE_TYPE_HOST_ALLOCATION_BIT_EXT,
|
|
(void *)(uintptr_t)r->import_lo, &hp)
|
|
: VK_ERROR_EXTENSION_NOT_PRESENT;
|
|
if (res != VK_SUCCESS || !hp.memoryTypeBits) {
|
|
rt_log("vk: pool %#llx+%#llx: host pointer not importable (%d)", (unsigned long long)addr, (unsigned long long)size, (int)res);
|
|
return 0;
|
|
}
|
|
VkExternalMemoryBufferCreateInfo eb = {.sType = VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_BUFFER_CREATE_INFO,
|
|
.handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_HOST_ALLOCATION_BIT_EXT};
|
|
VkBufferCreateInfo bi = {.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO, .pNext = &eb, .size = n,
|
|
.usage = pool_usage(c), .sharingMode = VK_SHARING_MODE_EXCLUSIVE};
|
|
if ((res = vkCreateBuffer(c->device, &bi, NULL, &r->buf)) != VK_SUCCESS) {
|
|
rt_log("vk: pool %#llx: vkCreateBuffer for the import failed (%d)", (unsigned long long)addr, (int)res);
|
|
return 0;
|
|
}
|
|
VkMemoryRequirements req;
|
|
vkGetBufferMemoryRequirements(c->device, r->buf, &req);
|
|
int type = memory_type(c, req.memoryTypeBits & hp.memoryTypeBits,
|
|
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);
|
|
if (type < 0 || req.size > n) {
|
|
rt_log("vk: pool %#llx: no memory type for the import (buffer %#x, host %#x, size %#llx of %#llx)",
|
|
(unsigned long long)addr, req.memoryTypeBits, hp.memoryTypeBits, (unsigned long long)req.size,
|
|
(unsigned long long)n);
|
|
vkDestroyBuffer(c->device, r->buf, NULL);
|
|
r->buf = VK_NULL_HANDLE;
|
|
return 0;
|
|
}
|
|
VkImportMemoryHostPointerInfoEXT im = {.sType = VK_STRUCTURE_TYPE_IMPORT_MEMORY_HOST_POINTER_INFO_EXT,
|
|
.handleType = VK_EXTERNAL_MEMORY_HANDLE_TYPE_HOST_ALLOCATION_BIT_EXT,
|
|
.pHostPointer = (void *)(uintptr_t)r->import_lo};
|
|
VkMemoryAllocateInfo ai = {.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, .pNext = &im, .allocationSize = n,
|
|
.memoryTypeIndex = (uint32_t)type};
|
|
if ((res = vkAllocateMemory(c->device, &ai, NULL, &r->mem)) != VK_SUCCESS ||
|
|
(res = vkBindBufferMemory(c->device, r->buf, r->mem, 0)) != VK_SUCCESS) {
|
|
rt_log("vk: pool %#llx+%#llx: host memory import failed (%d)", (unsigned long long)addr, (unsigned long long)size, (int)res);
|
|
if (r->mem) vkFreeMemory(c->device, r->mem, NULL);
|
|
vkDestroyBuffer(c->device, r->buf, NULL);
|
|
r->buf = VK_NULL_HANDLE;
|
|
r->mem = VK_NULL_HANDLE;
|
|
return 0;
|
|
}
|
|
plat_inc32(&n_imports);
|
|
return 1;
|
|
}
|
|
|
|
// Physical pool: device-local memory of the pool size that both buffers and optimal-tiling images can be bound to.
|
|
static int alloc_physical(VkCtx *c, PoolRec *r) {
|
|
VkBufferCreateInfo bi = {.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO, .size = r->size, .usage = pool_usage(c),
|
|
.sharingMode = VK_SHARING_MODE_EXCLUSIVE};
|
|
VkResult res = vkCreateBuffer(c->device, &bi, NULL, &r->buf);
|
|
if (res != VK_SUCCESS) { rt_log("vk: physical pool: vkCreateBuffer failed (%d)", (int)res); return 0; }
|
|
VkMemoryRequirements rb;
|
|
vkGetBufferMemoryRequirements(c->device, r->buf, &rb);
|
|
VkImageCreateInfo ii = {.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, .imageType = VK_IMAGE_TYPE_2D,
|
|
.format = VK_FORMAT_R8G8B8A8_UNORM, .extent = {256, 256, 1}, .mipLevels = 1, .arrayLayers = 1,
|
|
.samples = VK_SAMPLE_COUNT_1_BIT, .tiling = VK_IMAGE_TILING_OPTIMAL,
|
|
.usage = VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT};
|
|
VkDeviceImageMemoryRequirements di = {.sType = VK_STRUCTURE_TYPE_DEVICE_IMAGE_MEMORY_REQUIREMENTS, .pCreateInfo = &ii};
|
|
VkMemoryRequirements2 ri = {.sType = VK_STRUCTURE_TYPE_MEMORY_REQUIREMENTS_2};
|
|
vkGetDeviceImageMemoryRequirements(c->device, &di, &ri);
|
|
uint32_t bits = rb.memoryTypeBits & ri.memoryRequirements.memoryTypeBits;
|
|
if (!bits) bits = rb.memoryTypeBits;
|
|
VkDeviceSize align = NVN_VPOOL_PAGE;
|
|
if (rb.alignment > align) align = rb.alignment;
|
|
if (ri.memoryRequirements.alignment > align) align = ri.memoryRequirements.alignment;
|
|
VkMemoryRequirements req = {.size = (rb.size + align - 1) & ~(align - 1), .alignment = align, .memoryTypeBits = bits};
|
|
VmaAllocationCreateInfo aci = {.flags = VMA_ALLOCATION_CREATE_DEDICATED_MEMORY_BIT,
|
|
.requiredFlags = VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT};
|
|
if ((res = vmaAllocateMemory(c->vma, &req, &aci, &r->alloc, NULL)) != VK_SUCCESS ||
|
|
(res = vmaBindBufferMemory(c->vma, r->alloc, r->buf)) != VK_SUCCESS) {
|
|
rt_log("vk: physical pool of %#llx bytes: device-local allocation failed (%d)", (unsigned long long)r->size, (int)res);
|
|
if (r->alloc) vmaFreeMemory(c->vma, r->alloc);
|
|
vkDestroyBuffer(c->device, r->buf, NULL);
|
|
r->buf = VK_NULL_HANDLE;
|
|
r->alloc = NULL;
|
|
return 0;
|
|
}
|
|
if (vk_fix_zero()) { // Switch pool memory starts zeroed; once per pool, not per image placed in it
|
|
VkCommandBuffer cb = vk_upload_begin();
|
|
vkCmdFillBuffer(cb, r->buf, 0, VK_WHOLE_SIZE, 0);
|
|
VkMemoryBarrier2 mb = {.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER_2, .srcStageMask = VK_PIPELINE_STAGE_2_ALL_TRANSFER_BIT,
|
|
.srcAccessMask = VK_ACCESS_2_TRANSFER_WRITE_BIT, .dstStageMask = VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT,
|
|
.dstAccessMask = VK_ACCESS_2_MEMORY_READ_BIT | VK_ACCESS_2_MEMORY_WRITE_BIT};
|
|
VkDependencyInfo di = {.sType = VK_STRUCTURE_TYPE_DEPENDENCY_INFO, .memoryBarrierCount = 1, .pMemoryBarriers = &mb};
|
|
vkCmdPipelineBarrier2(cb, &di);
|
|
vk_upload_end();
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
int vk_fix_zero(void) {
|
|
static int on = -1;
|
|
if (on < 0) {
|
|
on = cfg_bool("TOTK_FIX_ZERO");
|
|
}
|
|
return on;
|
|
}
|
|
|
|
static void index_put(PoolRec *r) {
|
|
__atomic_add_fetch(&pool_gen, 1, __ATOMIC_RELEASE);
|
|
pidx_ent e = {.lo = r->lo, .hi = r->lo + r->size, .base = r->lo, .buf = r, .gpu = r->lo, .key = r->key};
|
|
pidx_remove(&pool_index, r->key, NULL); // an object initialized again without Finalize
|
|
if (!pidx_insert(&pool_index, &e)) rt_fatal("vk: out of memory for the pool index");
|
|
}
|
|
|
|
static PoolRec *phys_find(const pool_t *key) {
|
|
for (int i = 0; i < nphys; i++) if (phys_list[i]->key == key) return phys_list[i];
|
|
return NULL;
|
|
}
|
|
|
|
static void destroy_rec(VkCtx *c, PoolRec *r) {
|
|
if (r->mbuf) vmaDestroyBuffer(c->vma, r->mbuf, r->malloc_);
|
|
if (r->salloc) vmaDestroyBuffer(c->vma, r->buf, r->salloc), r->buf = VK_NULL_HANDLE;
|
|
if (r->buf) vkDestroyBuffer(c->device, r->buf, NULL);
|
|
if (r->mem) vkFreeMemory(c->device, r->mem, NULL);
|
|
if (r->alloc) vmaFreeMemory(c->vma, r->alloc);
|
|
free(r->pt);
|
|
free(r);
|
|
}
|
|
|
|
int vk_pool_init(pool_t *p) {
|
|
VkCtx *c = vk_ctx();
|
|
if (!c) { rt_log("vk: MemoryPoolInitialize before vk_init"); return 0; }
|
|
// The GPU never sees a GPU_NO_ACCESS pool, except a physical one (0x189; 1.4.3 also 0x18c, CPU-visible) through the
|
|
// virtual pools that map it. Either gets device-local memory: 1.4.3's CPU never writes its 0x18c pool's storage
|
|
// (unchanged over 3600 frames of the intro), so the guest storage and the GPU copy need no sync.
|
|
if ((p->flags & NVN_MEMORY_POOL_FLAGS_GPU_NO_ACCESS) && !(p->flags & NVN_MEMORY_POOL_FLAGS_PHYSICAL)) return 1;
|
|
// A pool of zero bytes (TotK makes some now and then) holds no address the GPU can use: it gets no record. A record
|
|
// would import 0 bytes (invalid usage; the driver refuses the memory) and put an empty range into the index at the
|
|
// pool's address, where a lookup of the pool that starts there found the empty one first. vk_pool_fini finds no
|
|
// record for it and does nothing.
|
|
if (!p->size) return 1;
|
|
PoolRec *r = calloc(1, sizeof *r);
|
|
if (!r) rt_fatal("vk: out of memory for a pool record");
|
|
r->key = p;
|
|
r->flags = p->flags;
|
|
r->lo = (uintptr_t)p->mem;
|
|
r->size = p->size;
|
|
int ok;
|
|
if (p->flags & NVN_MEMORY_POOL_FLAGS_VIRTUAL) {
|
|
r->kind = POOL_VIRT;
|
|
r->npages = p->size / NVN_VPOOL_PAGE;
|
|
r->pt = calloc(r->npages ? r->npages : 1, sizeof *r->pt);
|
|
ok = r->pt != NULL;
|
|
} else if (p->flags & NVN_MEMORY_POOL_FLAGS_PHYSICAL) {
|
|
r->kind = POOL_PHYS;
|
|
ok = alloc_physical(c, r);
|
|
} else {
|
|
r->kind = POOL_IMPORT;
|
|
mem_thaw((uintptr_t)p->mem, p->size); // the import may need write access to the pages
|
|
ok = import_pool(c, r, (uintptr_t)p->mem, p->size);
|
|
if (!ok) {
|
|
// Not fatal for the game: the pool works for the CPU, and vk_mem_resolve reports its addresses as unbacked.
|
|
long n = plat_inc32(&n_import_failures);
|
|
if (n <= 8) rt_log("vk: pool %p (flags %#x, %#llx bytes) has no GPU buffer (%ld so far)", (void *)p->mem,
|
|
p->flags, (unsigned long long)p->size, n);
|
|
r->buf = VK_NULL_HANDLE;
|
|
ok = 1;
|
|
}
|
|
}
|
|
if (!ok) { free(r->pt); free(r); return 0; }
|
|
plat_mutex_lock(&pool_lock);
|
|
index_put(r);
|
|
if (r->kind == POOL_PHYS) {
|
|
if (nphys == cap_phys) {
|
|
cap_phys = cap_phys ? cap_phys * 2 : 16;
|
|
phys_list = realloc(phys_list, (size_t)cap_phys * sizeof *phys_list);
|
|
if (!phys_list) rt_fatal("vk: out of memory for the physical pool list");
|
|
}
|
|
phys_list[nphys++] = r;
|
|
}
|
|
plat_mutex_unlock(&pool_lock);
|
|
return 1;
|
|
}
|
|
|
|
void vk_pool_fini(pool_t *p) {
|
|
VkCtx *c = vk_ctx();
|
|
if (!c) return;
|
|
pidx_ent e;
|
|
plat_mutex_lock(&pool_lock);
|
|
int had = pidx_remove(&pool_index, p, &e);
|
|
__atomic_add_fetch(&pool_gen, 1, __ATOMIC_RELEASE);
|
|
PoolRec *r = had ? e.buf : NULL;
|
|
if (r && r->mstate == MS_ON) mirror_forget(r);
|
|
if (r && r->kind == POOL_PHYS) {
|
|
for (int i = 0; i < nphys; i++)
|
|
if (phys_list[i] == r) { phys_list[i] = phys_list[--nphys]; break; }
|
|
for (int i = 0; i < pool_index.n; i++) { // virtual pages that still map it become unmapped
|
|
PoolRec *v = pool_index.e[i].buf;
|
|
if (v->kind != POOL_VIRT) continue;
|
|
for (uint64_t k = 0; k < v->npages; k++)
|
|
if (v->pt[k].phys == r) v->pt[k].phys = NULL;
|
|
}
|
|
}
|
|
plat_mutex_unlock(&pool_lock);
|
|
if (!r) return;
|
|
if (r->smap) shadow_forget(r);
|
|
if (r->kind == POOL_IMPORT) mem_thaw(r->lo, r->size); // the storage goes back to the guest writable
|
|
// Images cut from this range go first; a used buffer or memory waits until the GPU is done with it, since the
|
|
// guest gets the storage back when this returns.
|
|
vk_texture_range_gone(r->lo, r->size, r->alloc);
|
|
if (r->used && r->kind != POOL_VIRT) {
|
|
vk_upload_flush(1);
|
|
vk_queue_lock();
|
|
vkQueueWaitIdle(c->gfx_queue);
|
|
vk_queue_unlock();
|
|
}
|
|
vk_mem_collect();
|
|
// The record, its buffers and its memory outlive the submission being recorded, of any pool kind. The wait above
|
|
// covers submitted work only. With the synchronous replay another game thread may be recording right now, and that
|
|
// recording may already name this pool: a mirrored pool's sync copies and write-backs, an import pool's buffer
|
|
// through a texture upload or a released cache entry (vk_texture.c slot_resolve), a physical pool's memory under
|
|
// the images vk_texture_range_gone just deferred to that submission. The game frees a pool only once its own GPU
|
|
// work is done, so only the port's work can name it. Freed here, the submission ran with freed memory. A shadow
|
|
// with a deferred copy (TOTK_SHADOW_DEFER) is named by the queue (dq) until the prologue of that submission.
|
|
pool_retiring(r);
|
|
vk_completion_add(retire_value(), pool_retire, r);
|
|
}
|
|
|
|
// Requests were checked by the front end (nvn_api_core.c, MNG totk_vpool.c); apply them in order.
|
|
int vk_pool_map_virtual(pool_t *v, int n, const void *requests) {
|
|
const nvn_mapping_t *rq = requests;
|
|
plat_mutex_lock(&pool_lock);
|
|
const pidx_ent *e = pidx_find(&pool_index, (uintptr_t)v->mem, 0);
|
|
PoolRec *r = e && e->key == v ? e->buf : NULL;
|
|
int ok = r && r->kind == POOL_VIRT;
|
|
__atomic_add_fetch(&pool_gen, 1, __ATOMIC_RELEASE);
|
|
for (int i = 0; ok && i < n; i++) {
|
|
PoolRec *ph = rq[i].physical_pool ? phys_find((const pool_t *)(const void *)rq[i].physical_pool) : NULL;
|
|
if (rq[i].physical_pool && !ph) {
|
|
rt_log("vk: MapVirtual request %d names physical pool %p that has no Vulkan memory", i,
|
|
(const void *)rq[i].physical_pool);
|
|
ok = 0;
|
|
break;
|
|
}
|
|
uint64_t first = (uint64_t)rq[i].virtual_offset / NVN_VPOOL_PAGE, count = rq[i].size / NVN_VPOOL_PAGE;
|
|
if (first + count > r->npages) { ok = 0; break; }
|
|
for (uint64_t k = 0; k < count; k++)
|
|
r->pt[first + k] = (VPage){ph, ph ? (uint64_t)rq[i].physical_offset + k * NVN_VPOOL_PAGE : 0};
|
|
}
|
|
plat_mutex_unlock(&pool_lock);
|
|
if (!ok) rt_log("vk: MapVirtual on pool %p failed", (void *)v->mem);
|
|
return ok;
|
|
}
|
|
|
|
// Per-thread copies of resolved import and physical pools that overlap no other index entry (so pidx_find picks them for
|
|
// any range inside), valid while pool_gen is unchanged: the replay resolves a few dozen pools thousands of times per
|
|
// frame (16 vertex streams and every buffer binding of each draw). Direct-mapped by the 4 KiB page of the address, so
|
|
// a hit is one compare however many pools a frame touches. TOTK_RESOLVE_CHECK=1 compares every hit with the locked
|
|
// lookup and stops on a difference.
|
|
static int rec_class(const PoolRec *r) {
|
|
if (r->kind != POOL_IMPORT) return VK_MCLS_DEVICE;
|
|
return r->flags & NVN_MEMORY_POOL_FLAGS_CPU_NO_ACCESS ? VK_MCLS_HOST_NOACC
|
|
: r->flags & NVN_MEMORY_POOL_FLAGS_CPU_CACHED ? VK_MCLS_HOST_CACHED : VK_MCLS_HOST_UNC;
|
|
}
|
|
|
|
typedef struct { uint64_t gen, lo, hi, base, end; VkBuffer buf; VmaAllocation alloc; int cls; void *pool; } ResolveHit;
|
|
#define RHIT_SLOTS 512
|
|
static _Thread_local ResolveHit rhit[RHIT_SLOTS];
|
|
|
|
static ResolveHit *rhit_slot(uint64_t addr) { return &rhit[(addr >> 12 ^ addr >> 21) & (RHIT_SLOTS - 1)]; }
|
|
|
|
static int resolve_check_on(void) {
|
|
static int on = -1;
|
|
if (on < 0) {
|
|
const char *e = cfg_get("TOTK_RESOLVE_CHECK");
|
|
on = e && *e && strcmp(e, "0");
|
|
}
|
|
return on;
|
|
}
|
|
|
|
static int resolve_locked(uint64_t addr, uint64_t size, VkMemSpan *out);
|
|
|
|
int vk_mem_resolve(uint64_t addr, uint64_t size, VkMemSpan *out) {
|
|
memset(out, 0, sizeof *out);
|
|
uint64_t g = __atomic_load_n(&pool_gen, __ATOMIC_ACQUIRE), end = addr + size;
|
|
const ResolveHit *h = rhit_slot(addr);
|
|
if (end >= addr && h->gen == g && addr >= h->lo && end <= h->hi) {
|
|
out->buffer = h->buf;
|
|
out->offset = addr - h->base;
|
|
out->avail = h->end - addr;
|
|
out->alloc = h->alloc;
|
|
out->cls = h->cls;
|
|
out->pool = h->pool;
|
|
if (h->alloc) out->alloc_offset = out->offset;
|
|
if (shadow_on && h->pool && !((PoolRec *)h->pool)->stouched) shadow_touch(h->pool);
|
|
if (resolve_check_on()) {
|
|
VkMemSpan s;
|
|
if (!resolve_locked(addr, size, &s) || memcmp(&s, out, sizeof s))
|
|
rt_fatal("vk: resolve cache of 0x%llx differs from the pool index", (unsigned long long)addr);
|
|
}
|
|
return 1;
|
|
}
|
|
return resolve_locked(addr, size, out);
|
|
}
|
|
|
|
int vk_mem_span_live(const VkMemSpan *sp) {
|
|
return sp->buffer && sp->pool && !sp->alloc && !shadow_on && ((const PoolRec *)sp->pool)->kind == POOL_IMPORT;
|
|
}
|
|
|
|
// The pool index lookup under the lock; a non-overlapped import or physical pool goes into the address's cache slot.
|
|
static int resolve_locked(uint64_t addr, uint64_t size, VkMemSpan *out) {
|
|
memset(out, 0, sizeof *out);
|
|
plat_mutex_lock_shared(&pool_lock);
|
|
const pidx_ent *e = pidx_find(&pool_index, (uintptr_t)addr, size);
|
|
PoolRec *r = e ? e->buf : NULL;
|
|
int ok = 0;
|
|
if (r && (r->kind == POOL_IMPORT ? r->buf != VK_NULL_HANDLE : r->kind == POOL_PHYS)) {
|
|
int i = (int)(e - pool_index.e);
|
|
if ((i == 0 || pool_index.maxhi[i - 1] <= e->lo) && (i + 1 == pool_index.n || pool_index.e[i + 1].lo >= e->hi)) {
|
|
ResolveHit *h = rhit_slot(addr);
|
|
h->gen = __atomic_load_n(&pool_gen, __ATOMIC_RELAXED); // stable: changed only under the exclusive lock
|
|
h->lo = e->lo, h->hi = e->hi, h->buf = r->buf, h->cls = rec_class(r);
|
|
if (r->kind == POOL_IMPORT) h->base = r->import_lo, h->end = r->import_hi, h->alloc = NULL, h->pool = r;
|
|
else h->base = r->lo, h->end = r->lo + r->size, h->alloc = r->alloc, h->pool = NULL;
|
|
}
|
|
}
|
|
if (r && r->kind == POOL_IMPORT && r->buf) {
|
|
out->cls = rec_class(r);
|
|
out->pool = r;
|
|
out->buffer = r->buf;
|
|
out->offset = addr - r->import_lo;
|
|
out->avail = r->import_hi - addr;
|
|
if (r->smap && !r->stouched) shadow_touch(r);
|
|
ok = 1;
|
|
} else if (r && r->kind == POOL_PHYS) {
|
|
out->buffer = r->buf;
|
|
out->offset = out->alloc_offset = addr - r->lo;
|
|
out->avail = r->size - out->offset;
|
|
out->alloc = r->alloc;
|
|
out->cls = VK_MCLS_DEVICE;
|
|
ok = 1;
|
|
} else if (r && r->kind == POOL_VIRT) {
|
|
uint64_t rel = addr - r->lo, k = rel / NVN_VPOOL_PAGE;
|
|
if (k < r->npages && r->pt[k].phys) {
|
|
PoolRec *ph = r->pt[k].phys;
|
|
uint64_t off = r->pt[k].off + rel % NVN_VPOOL_PAGE, end = r->pt[k].off + NVN_VPOOL_PAGE;
|
|
for (uint64_t j = k + 1; j < r->npages && r->pt[j].phys == ph && r->pt[j].off == end; j++) end += NVN_VPOOL_PAGE;
|
|
out->buffer = ph->buf;
|
|
out->offset = out->alloc_offset = off;
|
|
out->avail = end - off;
|
|
out->alloc = ph->alloc;
|
|
out->cls = VK_MCLS_DEVICE;
|
|
ok = out->avail >= size;
|
|
if (ok && !ph->used) plat_xchg32(&ph->used, 1); // read first: the flag is only ever set
|
|
}
|
|
}
|
|
if (ok && !r->used) plat_xchg32(&r->used, 1);
|
|
plat_mutex_unlock_shared(&pool_lock);
|
|
if (!ok) memset(out, 0, sizeof *out);
|
|
return ok;
|
|
}
|
|
|
|
uint64_t vk_mem_gen(void) {
|
|
return __atomic_load_n(&pool_gen, __ATOMIC_ACQUIRE) + __atomic_load_n(&mirror_gen, __ATOMIC_ACQUIRE);
|
|
}
|
|
|
|
int vk_mem_class_of(uint64_t addr) {
|
|
VkMemSpan m;
|
|
return vk_mem_resolve(addr, 1, &m) ? m.cls : VK_MCLS_NONE;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- census
|
|
static volatile int64_t traffic[VK_MCLS_N][VK_MUSE_N];
|
|
|
|
static _Thread_local int64_t (*count_to)[VK_MUSE_N];
|
|
|
|
// Every caller is the replay (vk_exec*, under vk_sync_submit's lock), about ten adds per draw: one writer at a time, so
|
|
// a plain add, not a locked one. An add racing the census's reset may be lost. Threads recording chunks in parallel
|
|
// count into their own arrays (vk_mem_count_local), added with atomics once per chunk.
|
|
void vk_mem_count(int cls, int use, uint64_t bytes) {
|
|
if ((unsigned)cls >= VK_MCLS_N || (unsigned)use >= VK_MUSE_N) return;
|
|
if (count_to) count_to[cls][use] += (int64_t)bytes;
|
|
else __atomic_store_n(&traffic[cls][use], __atomic_load_n(&traffic[cls][use], __ATOMIC_RELAXED) + (int64_t)bytes,
|
|
__ATOMIC_RELAXED);
|
|
}
|
|
|
|
void vk_mem_count_local(int64_t (*acc)[VK_MUSE_N]) { count_to = acc; }
|
|
|
|
void vk_mem_count_flush(int64_t (*acc)[VK_MUSE_N]) {
|
|
for (int c = 0; c < VK_MCLS_N; c++)
|
|
for (int u = 0; u < VK_MUSE_N; u++)
|
|
if (acc[c][u]) __atomic_fetch_add(&traffic[c][u], acc[c][u], __ATOMIC_RELAXED), acc[c][u] = 0;
|
|
}
|
|
|
|
static void flag_names(uint32_t f, char *buf, size_t n) {
|
|
static const char *const names[] = {"CPU_NO_ACCESS", "CPU_UNCACHED", "CPU_CACHED", "GPU_NO_ACCESS", "GPU_UNCACHED",
|
|
"GPU_CACHED", "SHADER_CODE", "COMPRESSIBLE", "PHYSICAL", "VIRTUAL"};
|
|
size_t k = 0;
|
|
buf[0] = 0;
|
|
for (int i = 0; i < 10; i++)
|
|
if (f >> i & 1) k += (size_t)snprintf(buf + k, k < n ? n - k : 0, "%s%s", k ? "|" : "", names[i]);
|
|
if (!k) snprintf(buf, n, "none");
|
|
}
|
|
|
|
// One line per (kind, flags) group of the live pools, then the bytes the replay bound or copied per frame by pool
|
|
// class since the previous census (the first one: since frame 1200). Logged at frame 1800 (in the world from the
|
|
// run-game saves), at frame 600, and with TOTK_TRACE=perf every 1800 frames.
|
|
static void dirty_frame(uint64_t frame);
|
|
|
|
static void mirror_log(uint64_t frame);
|
|
static void shadow_log(uint64_t frame);
|
|
static int dead_pending(void);
|
|
// Deferrals made off the recording thread while another thread recorded a submission (cmd_lock, defer), the newest
|
|
// one's stamp: the case the stamp from the recording start covers. In the census line and the device fault report.
|
|
static uint64_t n_cross_defer, cross_defer_tl;
|
|
|
|
static void vram_log(uint64_t frame);
|
|
|
|
static volatile uint64_t mirror_frame; // the frame vk_mem_census last saw, for mirror_sync's log lines
|
|
|
|
void vk_mem_census(uint64_t frame) {
|
|
mirror_frame = frame;
|
|
static uint64_t window_start;
|
|
if ((rt_trace & RT_TRACE_PERF) && frame % 600 == 0) vram_log(frame);
|
|
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)
|
|
mirror_log(frame), shadow_log(frame); // mirror_log also logs the write-watch line
|
|
if (frame == 1200) { // loading is over: the first window starts here
|
|
for (int c = 0; c < VK_MCLS_N; c++)
|
|
for (int u = 0; u < VK_MUSE_N; u++) plat_xchg64(&traffic[c][u], 0);
|
|
window_start = frame;
|
|
return;
|
|
}
|
|
// Frame 600 as well: runs on slow machines end before 1800.
|
|
if (frame != 600 && (frame % 1800 || !(frame == 1800 || (rt_trace & RT_TRACE_PERF)))) return;
|
|
typedef struct { int kind; uint32_t flags; uint64_t n, bytes; } Group;
|
|
Group g[64];
|
|
int ng = 0;
|
|
uint64_t total[4] = {0}, count[4] = {0};
|
|
plat_mutex_lock_shared(&pool_lock);
|
|
for (int i = 0; i < pool_index.n; i++) {
|
|
const PoolRec *r = pool_index.e[i].buf;
|
|
int j = 0;
|
|
while (j < ng && (g[j].kind != r->kind || g[j].flags != r->flags)) j++;
|
|
if (j == ng && ng < 64) g[ng++] = (Group){r->kind, r->flags, 0, 0};
|
|
if (j < ng) g[j].n++, g[j].bytes += r->size;
|
|
total[r->kind] += r->size, count[r->kind]++;
|
|
}
|
|
plat_mutex_unlock_shared(&pool_lock);
|
|
rt_log("mem: census at frame %llu: import %llu pools %.1f MiB (%d without a GPU buffer), physical %llu pools %.1f MiB, "
|
|
"virtual %llu pools %.1f MiB of address space; %llu objects deferred off the recording thread during a recording",
|
|
(unsigned long long)frame, (unsigned long long)count[POOL_IMPORT], total[POOL_IMPORT] / 1048576.0,
|
|
n_import_failures, (unsigned long long)count[POOL_PHYS], total[POOL_PHYS] / 1048576.0,
|
|
(unsigned long long)count[POOL_VIRT], total[POOL_VIRT] / 1048576.0,
|
|
(unsigned long long)__atomic_load_n(&n_cross_defer, __ATOMIC_RELAXED));
|
|
// The baseline for the commit a crash report prints: a crash far above it points at growth during play.
|
|
double proc, used, limit;
|
|
plat_commit_info(&proc, &used, &limit);
|
|
uint64_t room = vk_mem_host_room();
|
|
rt_log("mem: commit at frame %llu: this process %.0f MB, the system %.0f of %.0f MB; room for device memory %s%.0f MiB",
|
|
(unsigned long long)frame, proc, used, limit, room == UINT64_MAX ? "not counted " : "",
|
|
room == UINT64_MAX ? 0.0 : room / 1048576.0);
|
|
// Per heap, what VMA holds (blocks, and the allocations inside them) beside what the driver counts for the process:
|
|
// usage above VMA's blocks is the driver's own memory (pipelines, command buffers, descriptor pools).
|
|
VkCtx *vc = vk_ctx();
|
|
if (vc && vc->vma) {
|
|
VmaBudget vb[VK_MAX_MEMORY_HEAPS];
|
|
vmaGetHeapBudgets(vc->vma, vb);
|
|
for (uint32_t h = 0; h < vc->mem_props.memoryHeapCount && h < VK_MAX_MEMORY_HEAPS; h++)
|
|
rt_log("mem: heap %u (flags %#x) at frame %llu: vma %u blocks %.0f MiB, %u allocations %.0f MiB; driver usage "
|
|
"%.0f of %.0f MiB", h, vc->mem_props.memoryHeaps[h].flags, (unsigned long long)frame,
|
|
vb[h].statistics.blockCount, vb[h].statistics.blockBytes / 1048576.0, vb[h].statistics.allocationCount,
|
|
vb[h].statistics.allocationBytes / 1048576.0, vb[h].usage / 1048576.0, vb[h].budget / 1048576.0);
|
|
}
|
|
static const char *const kinds[] = {"", "import", "physical", "virtual"};
|
|
for (int j = 0; j < ng; j++) {
|
|
char f[160];
|
|
flag_names(g[j].flags, f, sizeof f);
|
|
rt_log("mem: %s %#x %s: %llu pools, %.1f MiB", kinds[g[j].kind], g[j].flags, f, (unsigned long long)g[j].n,
|
|
g[j].bytes / 1048576.0);
|
|
}
|
|
uint64_t frames = window_start && frame > window_start ? frame - window_start : frame;
|
|
static const char *const cls[] = {"", "host CPU_NO_ACCESS", "host CPU_UNCACHED", "host CPU_CACHED", "device", "scratch"};
|
|
rt_log("mem: traffic per frame over %llu frames, MiB (bound ranges, not bytes read): class: vertex index uniform "
|
|
"storage indirect copy-src gpu-write cpu-read", (unsigned long long)frames);
|
|
for (int c = 1; c < VK_MCLS_N; c++) {
|
|
double v[VK_MUSE_N];
|
|
for (int u = 0; u < VK_MUSE_N; u++) v[u] = plat_xchg64(&traffic[c][u], 0) / 1048576.0 / (double)(frames ? frames : 1);
|
|
rt_log("mem: %s: %.2f %.2f %.2f %.2f %.3f %.2f %.2f %.3f", cls[c], v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7]);
|
|
}
|
|
window_start = frame;
|
|
}
|
|
|
|
// TOTK_VRAM_LIMIT_MIB: pretend the device-local budget is at most this many MiB, so the paths that react to the budget
|
|
// can be driven on a card larger than the one a report came from. The driver knows nothing of it.
|
|
static uint64_t vram_limit(void) {
|
|
static int64_t limit = -1;
|
|
if (limit < 0) {
|
|
const char *s = cfg_get("TOTK_VRAM_LIMIT_MIB");
|
|
limit = s && *s ? (int64_t)strtoull(s, NULL, 10) << 20 : 0;
|
|
if (limit) rt_log("mem: device-local budget limited to %lld MiB (TOTK_VRAM_LIMIT_MIB)", (long long)(limit >> 20));
|
|
}
|
|
return (uint64_t)limit;
|
|
}
|
|
|
|
// Device memory that takes host memory. A UMA GPU's memory is the machine's RAM. On Windows the AMD driver charges
|
|
// device-local allocations to the process commit one to one (RX 9070: VRAM use and private bytes grew by the same
|
|
// 1164, 256 and 256 MiB), so on a 16 GB machine the commit limit is reached while the VRAM budget shows gigabytes free,
|
|
// and the driver then fails inside a call (#285: std::bad_alloc in amdvlk64.dll under gpl_link). Whether a discrete
|
|
// GPU's driver does so is measured once (vk_mem_host_probe), not assumed per vendor: an RTX 5080 log (#398) showed
|
|
// 10.4 GB of process commit at frame 1200, about what the RX 9070 holds with its VRAM counted. Linux overcommits, so
|
|
// there only a UMA GPU's physical memory counts (plat_mem_room).
|
|
enum { HB_NONE, HB_COMMIT, HB_UMA, HB_PROBE };
|
|
static int host_backed;
|
|
static uint64_t room_reserve;
|
|
static volatile uint64_t room_cached, room_ms;
|
|
|
|
uint64_t vk_mem_host_room(void) {
|
|
if (!host_backed) return UINT64_MAX;
|
|
uint64_t now = plat_now_ms(), at = __atomic_load_n(&room_ms, __ATOMIC_ACQUIRE);
|
|
if (at && now - at < 250) return __atomic_load_n(&room_cached, __ATOMIC_RELAXED); // a query costs a system call
|
|
uint64_t commit, phys;
|
|
plat_mem_room(&commit, &phys);
|
|
uint64_t r = host_backed == HB_UMA && phys < commit ? phys : commit;
|
|
r = r == UINT64_MAX ? r : r > room_reserve ? r - room_reserve : 0;
|
|
__atomic_store_n(&room_cached, r, __ATOMIC_RELAXED);
|
|
__atomic_store_n(&room_ms, now | 1, __ATOMIC_RELEASE);
|
|
return r;
|
|
}
|
|
|
|
int vk_mem_uma(void) { return host_backed == HB_UMA; }
|
|
|
|
// A discrete GPU: one device-local allocation of HB_PROBE_SIZE, and the process commit before and after it. The driver
|
|
// charges it at allocation or not at all as far as this sees; one that charged only at first use would read as not
|
|
// counted, as before the probe. Another thread's allocation in between could only add, and half the size is the line.
|
|
#define HB_PROBE_SIZE (128ull << 20)
|
|
void vk_mem_host_probe(void) {
|
|
VkCtx *c = vk_ctx();
|
|
if (!c || host_backed != HB_PROBE) return;
|
|
host_backed = HB_NONE;
|
|
uint64_t commit, phys;
|
|
plat_mem_room(&commit, &phys);
|
|
if (commit == UINT64_MAX) return; // no commit limit (Linux)
|
|
uint32_t type = UINT32_MAX;
|
|
for (uint32_t i = 0; i < c->mem_props.memoryTypeCount && type == UINT32_MAX; i++)
|
|
if ((c->mem_props.memoryTypes[i].propertyFlags & (VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT | VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT)) ==
|
|
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT)
|
|
type = i;
|
|
VkMemoryAllocateInfo ai = {.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, .allocationSize = HB_PROBE_SIZE,
|
|
.memoryTypeIndex = type};
|
|
uint64_t r0, p0, r1, p1;
|
|
VkDeviceMemory m;
|
|
plat_process_memory(&r0, &p0);
|
|
if (type == UINT32_MAX || !p0 || vkAllocateMemory(c->device, &ai, NULL, &m) != VK_SUCCESS) {
|
|
rt_log("mem: device memory against system memory: not measured (no device-local type or allocation failed): not counted");
|
|
return;
|
|
}
|
|
plat_process_memory(&r1, &p1);
|
|
vkFreeMemory(c->device, m, NULL);
|
|
int64_t d = (int64_t)(p1 - p0);
|
|
host_backed = d >= (int64_t)HB_PROBE_SIZE / 2 ? HB_COMMIT : HB_NONE;
|
|
rt_log("mem: device memory against system memory: %s (a %llu MiB device-local allocation took %.0f MiB of process "
|
|
"commit), reserve %llu MiB (TOTK_MEM_RESERVE_MIB)", host_backed == HB_COMMIT ? "commit" : "not counted",
|
|
(unsigned long long)(HB_PROBE_SIZE >> 20), d / 1048576.0, (unsigned long long)(room_reserve >> 20));
|
|
}
|
|
|
|
// The budget is at most the use plus what the system can still give, so the paths that react to VRAM pressure (the
|
|
// mirror refusal, released textures, the headroom block) react to a system short of memory too.
|
|
static void host_cap(uint64_t used, uint64_t *budget) {
|
|
uint64_t room = vk_mem_host_room();
|
|
if (room == UINT64_MAX || used + room >= *budget) return;
|
|
*budget = used + room ? used + room : 1;
|
|
static volatile int32_t logged;
|
|
if (!plat_xchg32(&logged, 1))
|
|
rt_log("mem: system memory limits the device-local budget: %.0f MiB in use, %.0f MiB more beyond a reserve of "
|
|
"%.0f MiB (logged once)", used / 1048576.0, room / 1048576.0, room_reserve / 1048576.0);
|
|
}
|
|
|
|
int vk_mem_vram(uint64_t *used, uint64_t *budget, int *exact) {
|
|
VkCtx *c = vk_ctx();
|
|
*used = *budget = 0, *exact = 0;
|
|
if (!c) return 0;
|
|
uint64_t limit = vram_limit();
|
|
const VkPhysicalDeviceMemoryProperties *mp = &c->mem_props;
|
|
if (c->has_memory_budget) {
|
|
VkPhysicalDeviceMemoryBudgetPropertiesEXT b = {.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_MEMORY_BUDGET_PROPERTIES_EXT};
|
|
VkPhysicalDeviceMemoryProperties2 p2 = {.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_MEMORY_PROPERTIES_2, .pNext = &b};
|
|
vkGetPhysicalDeviceMemoryProperties2(c->phys, &p2);
|
|
for (uint32_t h = 0; h < p2.memoryProperties.memoryHeapCount && h < VK_MAX_MEMORY_HEAPS; h++)
|
|
if (p2.memoryProperties.memoryHeaps[h].flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT)
|
|
*used += b.heapUsage[h], *budget += b.heapBudget[h];
|
|
*exact = 1;
|
|
if (limit && *budget > limit) *budget = limit;
|
|
if (*budget) host_cap(*used, budget);
|
|
return *budget != 0;
|
|
}
|
|
VmaBudget vb[VK_MAX_MEMORY_HEAPS];
|
|
vmaGetHeapBudgets(c->vma, vb);
|
|
for (uint32_t h = 0; h < mp->memoryHeapCount && h < VK_MAX_MEMORY_HEAPS; h++)
|
|
if (mp->memoryHeaps[h].flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT)
|
|
*used += vb[h].statistics.blockBytes, *budget += mp->memoryHeaps[h].size;
|
|
if (limit && *budget > limit) *budget = limit;
|
|
if (*budget) host_cap(*used, budget);
|
|
return *budget != 0;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- page write generations
|
|
// Two-level table over the guest address space (below 2^39): 256 MiB chunks of 65,536 page generations, allocated
|
|
// when a page of the chunk is first stamped. 0 = never stamped.
|
|
#define GEN_CHUNK_SHIFT 28
|
|
#define GEN_PAGE_SHIFT 12
|
|
#define GEN_CHUNKS (RT_ADDRESS_LIMIT >> GEN_CHUNK_SHIFT)
|
|
#define GEN_PER_CHUNK (1u << (GEN_CHUNK_SHIFT - GEN_PAGE_SHIFT))
|
|
static uint32_t *gen_chunk[GEN_CHUNKS];
|
|
static uint32_t gen_now; // last generation handed out
|
|
static PlatMutex gen_lock = PLAT_MUTEX_INIT;
|
|
static uint64_t gen_pages[1024];
|
|
// The newest generation stamped on any page of each 2 MiB region (the write-watch snapshot regions below): the mirror
|
|
// sync skips regions with nothing newer than its pool's mirror.
|
|
#define REG_SHIFT 21
|
|
static uint32_t reg_max[RT_ADDRESS_LIMIT >> REG_SHIFT];
|
|
|
|
// Every write-watch query of the generation table (gen_lock held exclusively): calls, bytes and time since the last
|
|
// mirror summary, all consumers together; the queries that found written pages (they also flush TLBs) apart.
|
|
static uint64_t ww_calls, ww_bytes, ww_ticks, ww_dirty_calls, ww_dirty_ticks, ww_pages;
|
|
|
|
static uint64_t ww_query(uint64_t addr, uint64_t size) {
|
|
uint64_t t0 = qpc();
|
|
uint64_t n = mem_written_pages(addr, size, gen_pages, sizeof gen_pages / sizeof *gen_pages);
|
|
uint64_t dt = qpc() - t0;
|
|
ww_ticks += dt, ww_calls++, ww_bytes += size;
|
|
if (n && n != UINT64_MAX) ww_dirty_calls++, ww_dirty_ticks += dt, ww_pages += n;
|
|
return n;
|
|
}
|
|
|
|
// Frozen regions: a 2 MiB region whose pages nothing wrote for freeze_idle submissions is made read-only
|
|
// (memory.c mem_freeze), and write-watch queries skip it until a write or a file read thaws it. Most of the pool memory
|
|
// the mirror and the texture checks cover is static (meshes, textures), and a query costs about 1 us per MiB (4-5 on an
|
|
// 8th-gen i7), so this keeps the queries to the regions the CPU writes. The region need not lie wholly in pools: NVN
|
|
// pools are small and not 2 MiB aligned, so most regions also hold other guest memory, and a rule that refused those
|
|
// left 340 of 436 queried MiB a frame unfrozen in town although nothing had written them for 300 submissions. The
|
|
// other memory is safe to freeze for the same reason pools are: the only kernel writes into guest memory are file
|
|
// reads, which thaw their buffer first (fs_host.c, romfs.c), and every user-mode write faults and thaws.
|
|
#define FREEZE_PER_SUB 32
|
|
static int freeze_idle; // 0: off
|
|
static uint32_t ww_ep = 1; // gen_lock: the submission being recorded (vk_mem_mirror_begin)
|
|
static uint32_t reg_last[RT_ADDRESS_LIMIT >> REG_SHIFT]; // gen_lock: ww_ep of the region's last stamp
|
|
static uint32_t reg_tried[RT_ADDRESS_LIMIT >> REG_SHIFT]; // gen_lock: ww_ep of its last freeze candidacy
|
|
static uint32_t *fcand; // gen_lock: regions to try at the next freeze pass
|
|
static int nfcand, cfcand;
|
|
static uint64_t n_frz_refused; // gen_lock: candidates mem_freeze refused
|
|
// Regions the GPU writes. Storage-buffer stores, buffer copies and fills land in imported guest memory, directly (import
|
|
// mode) or through a pool's mirror that vk_mem_mirror_end copies or merges into the imported buffer. They are not CPU
|
|
// writes: no fault thaws the region and no write-watch bit is set, so a region only the GPU writes looked idle, was
|
|
// frozen, and the GPU went on writing pages the CPU side had made read-only. LAS Recomp never changes the protection of
|
|
// imported memory: on NVIDIA, VirtualProtect on pages imported with VK_EXT_external_memory_host left the GPU reading
|
|
// wrong data (runtime/nvn/vulkan/exec.c, the comment on its write watch); a GPU write there is the likely cause of the
|
|
// device faults "write invalid" at a page of guest memory on NVIDIA (#788, #802). reg_gpu holds the timeline value of
|
|
// the newest submission that writes into the region (gpu_writes): freeze_pass leaves the region alone until the GPU is
|
|
// past that value, and a frozen region is thawed when a write into it is recorded.
|
|
static uint64_t reg_gpu[RT_ADDRESS_LIMIT >> REG_SHIFT]; // replay thread
|
|
static volatile int64_t n_frz_gpu, n_thaw_gpu; // freeze candidates kept for a GPU write; thaws for one (mirror_log)
|
|
|
|
static uint32_t *gen_slot(uint64_t page_addr, int create) {
|
|
uint64_t ci = page_addr >> GEN_CHUNK_SHIFT;
|
|
if (ci >= GEN_CHUNKS) return NULL;
|
|
if (!gen_chunk[ci]) {
|
|
if (!create) return NULL;
|
|
gen_chunk[ci] = calloc(GEN_PER_CHUNK, sizeof(uint32_t));
|
|
if (!gen_chunk[ci]) rt_fatal("vk: out of memory for page generations");
|
|
}
|
|
return &gen_chunk[ci][(page_addr >> GEN_PAGE_SHIFT) & (GEN_PER_CHUNK - 1)];
|
|
}
|
|
|
|
// gen_lock held exclusively; g is the newest generation handed out.
|
|
static void stamp_page(uint64_t a, uint32_t g) {
|
|
uint32_t *s = gen_slot(a, 1);
|
|
if (!s) return;
|
|
*s = g;
|
|
reg_max[a >> REG_SHIFT] = g;
|
|
reg_last[a >> REG_SHIFT] = ww_ep;
|
|
}
|
|
|
|
static void stamp_range(uint64_t lo, uint64_t hi, uint32_t g) {
|
|
for (uint64_t a = lo; a < hi; a += 1ull << GEN_PAGE_SHIFT) stamp_page(a, g);
|
|
}
|
|
|
|
void vk_guest_written(uint64_t addr, uint64_t size) {
|
|
if (!size || nvn_gpu_only(addr)) return;
|
|
uint64_t lo = addr & ~0xfffull, hi = (addr + size + 0xfff) & ~0xfffull;
|
|
plat_mutex_lock(&gen_lock);
|
|
stamp_range(lo, hi, ++gen_now);
|
|
plat_mutex_unlock(&gen_lock);
|
|
}
|
|
|
|
int vk_mem_changed(uint64_t addr, uint64_t size, uint32_t since, uint32_t *now) {
|
|
if (!size) { *now = since; return 0; }
|
|
uint64_t lo = addr & ~0xfffull, hi = (addr + size + 0xfff) & ~0xfffull;
|
|
plat_mutex_lock(&gen_lock);
|
|
uint32_t g = ++gen_now;
|
|
int tracked = 1, frozen = 1;
|
|
for (uint64_t a = lo & ~((1ull << REG_SHIFT) - 1); a < hi && frozen; a += 1ull << REG_SHIFT) frozen = mem_frozen(a);
|
|
while (!frozen) {
|
|
uint64_t n = ww_query(lo, hi - lo);
|
|
if (n == UINT64_MAX) { tracked = 0; break; }
|
|
for (uint64_t k = 0; k < n; k++) stamp_page(gen_pages[k], g);
|
|
if (n < sizeof gen_pages / sizeof *gen_pages) break; // a full answer may have left pages for the next call
|
|
}
|
|
int changed = 0;
|
|
for (uint64_t a = lo; tracked && a < hi && !changed; a += 1ull << GEN_PAGE_SHIFT) {
|
|
uint32_t *s = gen_slot(a, 0);
|
|
if (s && *s > since) changed = 1;
|
|
}
|
|
plat_mutex_unlock(&gen_lock);
|
|
*now = g;
|
|
return tracked ? changed : -1;
|
|
}
|
|
|
|
// Write-watch snapshots per submission: the guest address space in 2 MiB regions, each queried at most once per epoch
|
|
// (a guest submission), at the first check that covers it. A query costs about 1.3 us plus 0.55 us per MiB, so one per
|
|
// region instead of one per texture. The snapshot only adds observations to the generation table: a page written
|
|
// before it carries a generation newer than any check before it, and a page written after it keeps its OS bit until
|
|
// the next query over it (at the latest the next epoch's snapshot).
|
|
#define SNAP_SHIFT 21
|
|
#define SNAP_REGIONS (RT_ADDRESS_LIMIT >> SNAP_SHIFT)
|
|
static uint32_t snap_at[SNAP_REGIONS]; // epoch of the region's last snapshot; 0 never
|
|
static uint8_t snap_untracked[SNAP_REGIONS];
|
|
static uint64_t n_snap_checks, n_snap_calls, n_snap_untracked;
|
|
|
|
// gen_lock held exclusively. Queries the regions r0..r1 that were not snapped in epoch, one query per run of them;
|
|
// returns the number of queries.
|
|
static uint64_t snap_regions(uint64_t r0, uint64_t r1, uint32_t epoch) {
|
|
uint32_t g = 0;
|
|
uint64_t calls = 0;
|
|
for (uint64_t r = r0; r <= r1;) {
|
|
if (snap_at[r] == epoch) { r++; continue; }
|
|
if (mem_frozen(r << SNAP_SHIFT)) { // nothing in it changed since its freeze
|
|
snap_at[r] = epoch, snap_untracked[r] = 0;
|
|
r++;
|
|
continue;
|
|
}
|
|
uint64_t k = r; // one query over a run of regions not seen in this epoch
|
|
while (k + 1 <= r1 && snap_at[k + 1] != epoch && !mem_frozen((k + 1) << SNAP_SHIFT)) k++;
|
|
uint64_t a = r << SNAP_SHIFT, b = (k + 1) << SNAP_SHIFT;
|
|
int tracked = 1;
|
|
if (!g) g = ++gen_now;
|
|
for (;;) {
|
|
uint64_t n = ww_query(a, b - a);
|
|
if (n == UINT64_MAX) { tracked = 0; break; }
|
|
for (uint64_t j = 0; j < n; j++) stamp_page(gen_pages[j], g);
|
|
if (n < sizeof gen_pages / sizeof *gen_pages) break;
|
|
}
|
|
calls++;
|
|
for (uint64_t j = r; j <= k; j++) {
|
|
snap_at[j] = epoch, snap_untracked[j] = (uint8_t)!tracked;
|
|
if (tracked && freeze_idle && ww_ep - reg_last[j] >= (uint32_t)freeze_idle &&
|
|
ww_ep - reg_tried[j] >= (uint32_t)freeze_idle) {
|
|
reg_tried[j] = ww_ep;
|
|
if (nfcand == cfcand) grow((void **)&fcand, &cfcand, sizeof *fcand);
|
|
fcand[nfcand++] = (uint32_t)j;
|
|
}
|
|
}
|
|
r = k + 1;
|
|
}
|
|
return calls;
|
|
}
|
|
|
|
int vk_mem_changed_snap(uint64_t addr, uint64_t size, uint32_t since, uint32_t epoch, uint32_t *now) {
|
|
if (!size) { *now = since; return 0; }
|
|
uint64_t lo = addr & ~0xfffull, hi = (addr + size + 0xfff) & ~0xfffull;
|
|
uint64_t r0 = lo >> SNAP_SHIFT, r1 = (hi - 1) >> SNAP_SHIFT;
|
|
if (!epoch || r1 >= SNAP_REGIONS) return vk_mem_changed(addr, size, since, now);
|
|
int excl = 0;
|
|
plat_mutex_lock_shared(&gen_lock);
|
|
for (uint64_t r = r0; r <= r1; r++)
|
|
if (snap_at[r] != epoch) { excl = 1; break; }
|
|
if (excl) {
|
|
plat_mutex_unlock_shared(&gen_lock);
|
|
plat_mutex_lock(&gen_lock);
|
|
n_snap_calls += snap_regions(r0, r1, epoch);
|
|
}
|
|
int untracked = 0, changed = 0;
|
|
for (uint64_t r = r0; r <= r1; r++) untracked |= snap_untracked[r];
|
|
// Pages only in regions stamped after since (reg_max; REG_SHIFT is SNAP_SHIFT): an unchanged texture of 4 MiB was
|
|
// 1,024 page reads, at about 1,100 texture checks a frame.
|
|
for (uint64_t r = r0; !untracked && r <= r1 && !changed; r++) {
|
|
if (reg_max[r] <= since) continue;
|
|
uint64_t a = r << REG_SHIFT > lo ? r << REG_SHIFT : lo, b = (r + 1) << REG_SHIFT < hi ? (r + 1) << REG_SHIFT : hi;
|
|
for (; a < b && !changed; a += 1ull << GEN_PAGE_SHIFT) {
|
|
uint32_t *s = gen_slot(a, 0);
|
|
if (s && *s > since) changed = 1;
|
|
}
|
|
}
|
|
*now = gen_now; // every observation so far is in the table
|
|
__atomic_add_fetch(&n_snap_checks, 1, __ATOMIC_RELAXED);
|
|
if (untracked) __atomic_add_fetch(&n_snap_untracked, 1, __ATOMIC_RELAXED);
|
|
if (excl) plat_mutex_unlock(&gen_lock);
|
|
else plat_mutex_unlock_shared(&gen_lock);
|
|
return untracked ? -1 : changed;
|
|
}
|
|
|
|
void vk_mem_snap_stats(uint64_t *checks, uint64_t *calls, uint64_t *untracked) {
|
|
*checks = __atomic_exchange_n(&n_snap_checks, 0, __ATOMIC_RELAXED);
|
|
*calls = __atomic_exchange_n(&n_snap_calls, 0, __ATOMIC_RELAXED);
|
|
*untracked = __atomic_exchange_n(&n_snap_untracked, 0, __ATOMIC_RELAXED);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- dirty census (TOTK_TRACE=perf)
|
|
// What a device-local mirror of the buffer-bound pools would upload. The replay notes every
|
|
// imported pool it binds as a vertex, index, uniform, storage or indirect buffer (vk_mem_bound_note). At the start of
|
|
// each submit one write-watch query per run of such pools stamps the pages written since with a new generation, in the
|
|
// shared generation table (no second consumer of the OS bits: texture checks see the stamps), and the pages of those
|
|
// pools are counted:
|
|
// - eager: pages stamped after the previous submit's scan, in every buffer-bound pool (upload at every submit);
|
|
// - lazy: pages stamped after the pool's previous count, in pools the previous submit bound (upload when bound);
|
|
// - first use: the size of a pool the first time it is bound (a mirror copies the whole pool then).
|
|
// Classes: CPU_CACHED, CPU_UNCACHED, other (CPU_NO_ACCESS). Logged as `mem: dirty` every 60 frames.
|
|
#define DIRTY_UNSYNCED UINT32_MAX
|
|
#define DIRTY_GAP 0x100000ull // pools closer than this share one query (the gap's pages are stamped too)
|
|
#define DIRTY_WIN 60
|
|
enum { DC_CACHED, DC_UNC, DC_OTHER, DC_N };
|
|
typedef struct {
|
|
uint64_t eager[DC_N], lazy[DC_N], first[DC_N];
|
|
uint64_t submits, queries, scan_ns, sub_max, frames;
|
|
} DirtyAcc;
|
|
static uint32_t dirty_sub = 1, dirty_gen; // submit lock: the current submit number, generation of the last scan
|
|
static uint64_t first_pending[DC_N], ever_n[DC_N], ever_bytes[DC_N]; // submit lock
|
|
static PoolRec **dlist; // submit lock: the buffer-bound pools of a scan
|
|
static int dcap;
|
|
static PlatMutex dirty_lock = PLAT_MUTEX_INIT; // everything below
|
|
static DirtyAcc dfr, dwin; // this frame, this window
|
|
static uint32_t dwin_kib[DIRTY_WIN][DC_N]; // eager KiB of each frame of the window
|
|
static uint64_t live_n[DC_N], live_bytes[DC_N], live_untracked, ever_snap_n[DC_N], ever_snap_bytes[DC_N];
|
|
|
|
static int dc_class(const PoolRec *r) {
|
|
int c = rec_class(r);
|
|
return c == VK_MCLS_HOST_CACHED ? DC_CACHED : c == VK_MCLS_HOST_UNC ? DC_UNC : DC_OTHER;
|
|
}
|
|
|
|
void vk_mem_bound_note(void *pool) {
|
|
PoolRec *r = pool;
|
|
if (r->bound_sub == dirty_sub) return;
|
|
r->bound_sub = dirty_sub;
|
|
if (r->bufbound) return;
|
|
r->bufbound = 1, r->synced_gen = DIRTY_UNSYNCED;
|
|
int k = dc_class(r);
|
|
ever_n[k]++, ever_bytes[k] += r->size, first_pending[k] += r->size;
|
|
}
|
|
|
|
static uint64_t page_lo(const PoolRec *r) { return r->lo & ~0xfffull; }
|
|
static uint64_t page_hi(const PoolRec *r) { return (r->lo + r->size + 0xfff) & ~0xfffull; }
|
|
|
|
// gen_lock held exclusively. 0: the range is not write-watched.
|
|
static int query_stamp(uint64_t lo, uint64_t hi, uint32_t g) {
|
|
for (;;) {
|
|
uint64_t n = ww_query(lo, hi - lo);
|
|
if (n == UINT64_MAX) return 0;
|
|
for (uint64_t k = 0; k < n; k++) stamp_page(gen_pages[k], g);
|
|
if (n < sizeof gen_pages / sizeof *gen_pages) return 1;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- shadow storage
|
|
// Where importing guest memory costs too much (Linux: re-protecting pages that amdgpu imported as a userptr
|
|
// invalidates the buffer, about 1.3 ms per 16 MiB at the next submit, and the import's write pinning clears the
|
|
// write watch), a CPU-visible pool gets a host-visible buffer of its own instead, at the offsets the import would have:
|
|
// - CPU -> shadow: right before each submit (vk_mem_shadow_sync), the pools the replay resolved since the last one are
|
|
// queried for written pages; pages stamped after a pool's sgen are copied in by the CPU.
|
|
// - GPU -> guest: the replay notes each GPU write into CPU-visible memory at record time (vk_mem_gpu_pending) and
|
|
// reports it at the fence (vk_gpu_written), which copies those bytes back into guest memory. Until then the CPU
|
|
// copies skip them: a page the CPU and the GPU both write keeps the GPU's bytes.
|
|
// TOTK_SHADOW=1/0 forces the mode on or off; the default is on for Linux and Wine.
|
|
typedef struct { uint64_t lo, hi; } SRange;
|
|
static PlatMutex shadow_lock = PLAT_MUTEX_INIT; // after pool_lock; before gen_lock
|
|
static PoolRec **stouch; // shadow_lock: pools resolved since the last sync
|
|
static int nstouch, cstouch;
|
|
static SRange *spend; // shadow_lock: GPU writes recorded and not copied back yet
|
|
static int nspend, cspend;
|
|
static int64_t s_live_n, s_live_bytes; // shadow_lock
|
|
static uint64_t s_syncs, s_copied, s_queries, s_untracked, s_wb_bytes, s_ticks; // shadow_lock: since the last log
|
|
|
|
// TOTK_SHADOW_DEFER (with TOTK_SYNC_AHEAD): a shadow update the CPU must not write yet is queued instead and executed
|
|
// by the GPU, in queue order, at the start of the prologue of the part submitted next (vk_mem_shadow_prologue).
|
|
// defer_on: the switch is on. sdefer_now: the pool being synced is deferred, and sdefer_fence the value its copies
|
|
// belong to. Replay thread (sub_lock): the queue.
|
|
typedef struct { PoolRec *r; uint64_t addr, len; } DRange;
|
|
static int defer_on;
|
|
static int sdefer_now;
|
|
static uint64_t sdefer_fence;
|
|
static DRange *dq; // guest ranges to copy into their pools' shadows
|
|
static int ndq, cdq;
|
|
static uint64_t s_defer_pools, s_defer_bytes, s_defer_copies, s_wait_ticks, s_waits; // shadow_lock: since the last log
|
|
|
|
static int shadow_pool(VkCtx *c, PoolRec *r, uint64_t addr, uint64_t size) {
|
|
r->import_lo = addr & ~0xfffull;
|
|
r->import_hi = (addr + size + 0xfff) & ~0xfffull;
|
|
uint64_t n = r->import_hi - r->import_lo;
|
|
VkBufferCreateInfo bi = {.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO, .size = n, .usage = pool_usage(c),
|
|
.sharingMode = VK_SHARING_MODE_EXCLUSIVE};
|
|
// Cached where the device has it: the fence copies read it back on the CPU.
|
|
VmaAllocationCreateInfo ai = {.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT,
|
|
.requiredFlags = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,
|
|
.preferredFlags = VK_MEMORY_PROPERTY_HOST_CACHED_BIT};
|
|
VmaAllocationInfo info;
|
|
VkResult res = vmaCreateBuffer(c->vma, &bi, &ai, &r->buf, &r->salloc, &info);
|
|
if (res != VK_SUCCESS) {
|
|
rt_log("vk: pool %#llx+%#llx: shadow allocation failed (%d)", (unsigned long long)addr, (unsigned long long)size,
|
|
(int)res);
|
|
r->buf = VK_NULL_HANDLE, r->salloc = NULL;
|
|
return 0;
|
|
}
|
|
r->smap = info.pMappedData;
|
|
plat_mutex_lock(&gen_lock);
|
|
r->sgen = gen_now; // the copy below holds every write stamped so far; later stamps are copied again
|
|
plat_mutex_unlock(&gen_lock);
|
|
memcpy(r->smap, (const void *)(uintptr_t)r->import_lo, n);
|
|
plat_mutex_lock(&shadow_lock);
|
|
s_live_n++, s_live_bytes += (int64_t)n;
|
|
plat_mutex_unlock(&shadow_lock);
|
|
plat_inc32(&n_imports);
|
|
return 1;
|
|
}
|
|
|
|
static void shadow_touch(PoolRec *r) {
|
|
plat_mutex_lock(&shadow_lock);
|
|
if (!r->stouched) {
|
|
if (nstouch == cstouch) grow((void **)&stouch, &cstouch, sizeof *stouch);
|
|
stouch[nstouch++] = r;
|
|
r->stouched = 1;
|
|
}
|
|
plat_mutex_unlock(&shadow_lock);
|
|
}
|
|
|
|
// vk_pool_fini, after the pool left the index: off the touched list before its record goes.
|
|
static void shadow_forget(PoolRec *r) {
|
|
plat_mutex_lock(&shadow_lock);
|
|
for (int i = 0; i < nstouch; i++)
|
|
if (stouch[i] == r) { stouch[i] = stouch[--nstouch]; break; }
|
|
s_live_n--, s_live_bytes -= (int64_t)(r->import_hi - r->import_lo);
|
|
plat_mutex_unlock(&shadow_lock);
|
|
}
|
|
|
|
// shadow_lock held: copies guest [a, b) into the shadow, leaving out the bytes of GPU writes not copied back yet
|
|
// (spend from index i on).
|
|
static void shadow_copy(PoolRec *r, uint64_t a, uint64_t b, int i) {
|
|
for (; i < nspend; i++)
|
|
if (spend[i].lo < b && spend[i].hi > a) {
|
|
if (spend[i].lo > a) shadow_copy(r, a, spend[i].lo, i + 1);
|
|
if (spend[i].hi < b) shadow_copy(r, spend[i].hi, b, i + 1);
|
|
return;
|
|
}
|
|
if (sdefer_now) { // the GPU copies it later: the bytes are read from guest memory when the prologue is recorded
|
|
if (ndq == cdq) grow((void **)&dq, &cdq, sizeof *dq);
|
|
dq[ndq++] = (DRange){r, a, b - a};
|
|
r->sdefer = sdefer_fence;
|
|
s_defer_bytes += b - a;
|
|
return;
|
|
}
|
|
memcpy(r->smap + (a - r->import_lo), (const void *)(uintptr_t)a, b - a);
|
|
s_copied += b - a;
|
|
}
|
|
|
|
// TOTK_SHADOW_SNAP (default 1 on a native host, 0 under Wine, where it has not been measured): a pool's sync takes its written pages from the per-submission write-watch snapshots
|
|
// (snap_regions, shared with the texture checks): each 2 MiB region is queried at most once per guest submission, however
|
|
// many pools share it and however many parts of the submission touch them. The GPU then reads a pool as of the first
|
|
// part that syncs it, nearer the submit call, the moment the game means (vk_usnap.c). 0 queries each pool at every
|
|
// sync, as before. Measured on a Steam Deck at Lookout Landing: 716 pool syncs and 729 queries a frame took 4.8 ms of
|
|
// the replay thread to copy 2.3 MiB, the query walk costing about 4 us per MiB there; with the snapshots, about 58
|
|
// queries and 1.7 ms for the same bytes.
|
|
static int shadow_snap = -1;
|
|
static void shadow_copy_newer(PoolRec *r, uint32_t since);
|
|
|
|
// shadow_lock and gen_lock held: stamps the pool's written pages and copies those newer than its shadow.
|
|
static void shadow_sync_pool(PoolRec *r) {
|
|
uint64_t lo = r->import_lo, hi = r->import_hi;
|
|
if (shadow_snap < 0) {
|
|
shadow_snap = rt_knob("SHADOW_SNAP", plat_is_wine() ? "0" : "1");
|
|
rt_log("mem: shadow sync: %s (TOTK_SHADOW_SNAP%s)",
|
|
shadow_snap ? "per-submission region snapshots" : "a query per pool at every part",
|
|
plat_is_wine() ? "; off by default under Wine" : "");
|
|
}
|
|
uint32_t epoch = shadow_snap ? vk_texture_epoch() : 0;
|
|
uint64_t r0 = lo >> SNAP_SHIFT, r1 = (hi - 1) >> SNAP_SHIFT;
|
|
if (epoch && r1 < SNAP_REGIONS) {
|
|
s_queries += snap_regions(r0, r1, epoch); // stamps what the snapshot found, if the region was not seen yet
|
|
int untracked = 0;
|
|
for (uint64_t k = r0; k <= r1; k++) untracked |= snap_untracked[k];
|
|
uint32_t since = r->sgen;
|
|
r->sgen = gen_now;
|
|
if (untracked) { // nothing tells which pages changed: all of them
|
|
s_untracked++;
|
|
shadow_copy(r, lo, hi, 0);
|
|
return;
|
|
}
|
|
shadow_copy_newer(r, since);
|
|
return;
|
|
}
|
|
uint32_t g = ++gen_now;
|
|
int tracked = 1;
|
|
for (uint64_t a = lo; a < hi;) { // one query per run of regions that are not frozen
|
|
uint64_t b = (a | ((1ull << REG_SHIFT) - 1)) + 1;
|
|
if (b > hi) b = hi;
|
|
if (mem_frozen(a)) { a = b; continue; }
|
|
while (b < hi && !mem_frozen(b)) b = b + (1ull << REG_SHIFT) < hi ? b + (1ull << REG_SHIFT) : hi;
|
|
s_queries++;
|
|
if (!query_stamp(a, b, g)) tracked = 0;
|
|
a = b;
|
|
}
|
|
uint32_t since = r->sgen;
|
|
r->sgen = gen_now;
|
|
if (!tracked) { // nothing tells which pages changed: all of them
|
|
s_untracked++;
|
|
shadow_copy(r, lo, hi, 0);
|
|
return;
|
|
}
|
|
shadow_copy_newer(r, since);
|
|
}
|
|
|
|
// shadow_lock and gen_lock held: copies the pool's pages stamped after since into its shadow, in runs.
|
|
static void shadow_copy_newer(PoolRec *r, uint32_t since) {
|
|
uint64_t lo = r->import_lo, hi = r->import_hi;
|
|
uint64_t run = 0;
|
|
for (uint64_t k = lo >> REG_SHIFT; k <= (hi - 1) >> REG_SHIFT; k++) {
|
|
uint64_t a = k << REG_SHIFT, b = a + (1ull << REG_SHIFT);
|
|
if (a < lo) a = lo;
|
|
if (b > hi) b = hi;
|
|
const uint32_t *ch = gen_chunk[a >> GEN_CHUNK_SHIFT]; // a 2 MiB region lies in one chunk
|
|
if (reg_max[k] <= since || !ch) {
|
|
if (run) shadow_copy(r, run, a, 0), run = 0;
|
|
continue;
|
|
}
|
|
for (uint64_t x = a; x < b; x += 1ull << GEN_PAGE_SHIFT) {
|
|
int dirty = ch[(x >> GEN_PAGE_SHIFT) & (GEN_PER_CHUNK - 1)] > since;
|
|
if (dirty && !run) run = x;
|
|
else if (!dirty && run) shadow_copy(r, run, x, 0), run = 0;
|
|
}
|
|
}
|
|
if (run) shadow_copy(r, run, hi, 0);
|
|
}
|
|
|
|
// TOTK_SYNC_AHEAD (nvn_api_core.c): the game may have been told a value is done (at most the guard) that the GPU still
|
|
// runs, and then write memory that value reads. The CPU copies its writes into a shadow, so while the guard is not done
|
|
// a shadow with any reader in flight must not be written by the CPU: that reader may be the guarded value or an older
|
|
// one (the parts of one submission share a value), and a pool records only its newest reader. A shadow with no reader
|
|
// in flight is copied at once.
|
|
// With TOTK_SHADOW_DEFER the update of such a shadow is queued instead (this function). Without it the replay thread
|
|
// waits (shadow_wait_guard).
|
|
|
|
// shadow_lock held: whether a shadow this sync is about to update has a reader in flight.
|
|
static int shadow_reader_busy(uint64_t done) {
|
|
for (int i = 0; i < nstouch; i++)
|
|
if (stouch[i]->suse > done) return 1;
|
|
return 0;
|
|
}
|
|
|
|
// shadow_lock held (released while waiting), no TOTK_SHADOW_DEFER: waits until the guard is done when a shadow to
|
|
// update has a reader in flight, then re-reads the guard under the lock, since the game may have raised it meanwhile. The wait
|
|
// runs without shadow_lock: the completion thread needs it for the fence callbacks (vk_gpu_written). *g and *done are
|
|
// current on return.
|
|
static void shadow_wait_guard(uint64_t fence, uint64_t *g, uint64_t *done) {
|
|
while (*g > *done && (!fence || *g < fence) && shadow_reader_busy(*done)) {
|
|
uint64_t want = *g;
|
|
plat_mutex_unlock(&shadow_lock);
|
|
uint64_t t0 = qpc();
|
|
vk_sync_fence_wait(want, UINT64_MAX);
|
|
uint64_t dt = qpc() - t0;
|
|
plat_mutex_lock(&shadow_lock);
|
|
s_wait_ticks += dt, s_waits++;
|
|
*g = nvn_sync_ahead_guard();
|
|
*done = vk_sync_fence_done();
|
|
}
|
|
}
|
|
|
|
// shadow_lock held, TOTK_SHADOW_DEFER: the pool's update goes through the queue when a reader of it is in flight while
|
|
// the guard is (see above), or when an earlier deferred copy into it has not executed yet: a direct memcpy must never
|
|
// land before it, or the queued copy would overwrite newer bytes with older ones.
|
|
static int shadow_defers(const PoolRec *r, uint64_t g, uint64_t done) {
|
|
if (r->sdefer > done) return 1;
|
|
return g > done && r->suse > done;
|
|
}
|
|
|
|
// nvnSyncWait raised the guard: a sync that read the old guard holds shadow_lock until its copies are made, so taking
|
|
// the lock once here makes the game return from the wait only after such a sync finished. Later syncs see the new
|
|
// guard.
|
|
void vk_mem_shadow_fence(void) {
|
|
plat_mutex_lock(&shadow_lock);
|
|
plat_mutex_unlock(&shadow_lock);
|
|
}
|
|
|
|
int vk_mem_shadow_on(void) { return shadow_on; }
|
|
|
|
void vk_mem_shadow_sync(uint64_t fence) {
|
|
if (!shadow_on) return;
|
|
plat_mutex_lock(&shadow_lock);
|
|
// The guard is read under the lock: see vk_mem_shadow_fence.
|
|
uint64_t g = nvn_sync_ahead_guard(), done = g ? vk_sync_fence_done() : 0;
|
|
if (!defer_on && nstouch) shadow_wait_guard(fence, &g, &done);
|
|
uint64_t t0 = qpc();
|
|
if (nstouch) {
|
|
plat_mutex_lock(&gen_lock);
|
|
for (int i = 0; i < nstouch; i++) {
|
|
PoolRec *r = stouch[i];
|
|
sdefer_now = defer_on && fence && shadow_defers(r, g, done);
|
|
sdefer_fence = fence;
|
|
int before = ndq;
|
|
shadow_sync_pool(r), r->stouched = 0;
|
|
if (ndq > before) s_defer_pools++;
|
|
sdefer_now = 0;
|
|
if (fence > r->suse) r->suse = fence;
|
|
}
|
|
plat_mutex_unlock(&gen_lock);
|
|
s_syncs += (uint64_t)nstouch;
|
|
nstouch = 0;
|
|
}
|
|
s_ticks += qpc() - t0;
|
|
plat_mutex_unlock(&shadow_lock);
|
|
}
|
|
|
|
int vk_mem_shadow_deferred(void) { return ndq; }
|
|
|
|
void vk_mem_gpu_pending(uint64_t addr, uint64_t size) {
|
|
if (!shadow_on || !size) return;
|
|
plat_mutex_lock(&shadow_lock);
|
|
if (nspend == cspend) grow((void **)&spend, &cspend, sizeof *spend);
|
|
spend[nspend++] = (SRange){addr, addr + size};
|
|
plat_mutex_unlock(&shadow_lock);
|
|
}
|
|
|
|
void vk_gpu_written(uint64_t addr, uint64_t size) {
|
|
if (shadow_on && size) {
|
|
plat_mutex_lock_shared(&pool_lock);
|
|
const pidx_ent *e = pidx_find(&pool_index, addr, size);
|
|
PoolRec *r = e ? e->buf : NULL;
|
|
plat_mutex_lock(&shadow_lock);
|
|
if (r && r->smap && addr >= r->import_lo && addr + size <= r->import_hi) {
|
|
mem_thaw(addr, size);
|
|
memcpy((void *)(uintptr_t)addr, r->smap + (addr - r->import_lo), size);
|
|
s_wb_bytes += size;
|
|
}
|
|
for (int i = 0; i < nspend; i++)
|
|
if (spend[i].lo == addr && spend[i].hi == addr + size) { spend[i] = spend[--nspend]; break; }
|
|
plat_mutex_unlock(&shadow_lock);
|
|
plat_mutex_unlock_shared(&pool_lock);
|
|
}
|
|
vk_guest_written(addr, size);
|
|
}
|
|
|
|
static void shadow_log(uint64_t frame) {
|
|
if (!shadow_on) return;
|
|
plat_mutex_lock(&shadow_lock);
|
|
rt_log("mem: shadow at frame %llu: %lld pools %.1f MiB; since the last: %llu pool syncs, %llu queries, %.1f MiB "
|
|
"copied in (%llu untracked), %.1f MiB written back, %.2f ms, %d GPU writes pending",
|
|
(unsigned long long)frame, (long long)s_live_n, s_live_bytes / 1048576.0, (unsigned long long)s_syncs,
|
|
(unsigned long long)s_queries, s_copied / 1048576.0, (unsigned long long)s_untracked, s_wb_bytes / 1048576.0,
|
|
qpc_ms(s_ticks), nspend);
|
|
if (defer_on || s_waits) // only with TOTK_SYNC_AHEAD: the shadow log of everyone else stays as it was
|
|
rt_log("mem: shadow deferred (%s): %llu pools, %.1f MiB in %llu copies; replay waited for a reader %llu times, "
|
|
"%.2f ms", defer_on ? "on" : "off", (unsigned long long)s_defer_pools, s_defer_bytes / 1048576.0,
|
|
(unsigned long long)s_defer_copies, (unsigned long long)s_waits, qpc_ms(s_wait_ticks));
|
|
s_syncs = s_queries = s_copied = s_untracked = s_wb_bytes = s_ticks = 0;
|
|
s_defer_pools = s_defer_bytes = s_defer_copies = s_wait_ticks = s_waits = 0;
|
|
plat_mutex_unlock(&shadow_lock);
|
|
}
|
|
|
|
void vk_mem_dirty_submit(void) {
|
|
if (!(rt_trace & RT_TRACE_PERF) || vk_mirror_on) return; // the mirror's own line replaces the census
|
|
uint64_t t0 = kern_now_ns();
|
|
uint32_t prev = dirty_sub; // the submit whose replay noted the bindings
|
|
dirty_sub = prev + 1 ? prev + 1 : 1;
|
|
uint64_t eager[DC_N] = {0}, lazy[DC_N] = {0}, ln[DC_N] = {0}, lb[DC_N] = {0}, untracked = 0, queries = 0;
|
|
plat_mutex_lock_shared(&pool_lock);
|
|
if (dcap < pool_index.n) {
|
|
dcap = pool_index.n * 2;
|
|
dlist = realloc(dlist, (size_t)dcap * sizeof *dlist);
|
|
if (!dlist) rt_fatal("vk: out of memory for the dirty census");
|
|
}
|
|
int n = 0;
|
|
for (int i = 0; i < pool_index.n; i++) {
|
|
PoolRec *r = pool_index.e[i].buf;
|
|
if (r->kind == POOL_IMPORT && r->bufbound) dlist[n++] = r;
|
|
}
|
|
plat_mutex_lock(&gen_lock);
|
|
uint32_t g = ++gen_now, since = dirty_gen;
|
|
dirty_gen = g;
|
|
for (int i = 0; i < n;) { // one query per run of pools (sorted by address)
|
|
uint64_t lo = page_lo(dlist[i]), hi = page_hi(dlist[i]);
|
|
int j = i + 1;
|
|
for (; j < n && page_lo(dlist[j]) <= hi + DIRTY_GAP; j++)
|
|
if (page_hi(dlist[j]) > hi) hi = page_hi(dlist[j]);
|
|
queries++;
|
|
int ok = query_stamp(lo, hi, g);
|
|
for (int k = i; k < j; k++) {
|
|
dlist[k]->untracked = 0;
|
|
if (!ok) queries++, dlist[k]->untracked = (uint8_t)!query_stamp(page_lo(dlist[k]), page_hi(dlist[k]), g);
|
|
}
|
|
i = j;
|
|
}
|
|
uint64_t covered = 0;
|
|
for (int i = 0; i < n; i++) {
|
|
PoolRec *r = dlist[i];
|
|
int k = dc_class(r);
|
|
ln[k]++, lb[k] += r->size;
|
|
int fresh = r->synced_gen == DIRTY_UNSYNCED, lz = !fresh && r->bound_sub == prev;
|
|
uint32_t ls = r->synced_gen;
|
|
if (fresh || r->bound_sub == prev) r->synced_gen = g;
|
|
if (r->untracked) { untracked += r->size; continue; }
|
|
if (fresh) continue; // counted as first use
|
|
uint64_t lo = page_lo(r), hi = page_hi(r), ne = 0, nl = 0;
|
|
if (lo < covered) lo = covered; // overlapping pools: each page once
|
|
if (lo >= hi) continue;
|
|
covered = hi;
|
|
for (uint64_t a = lo; a < hi;) {
|
|
uint64_t ci = a >> GEN_CHUNK_SHIFT, ce = (ci + 1) << GEN_CHUNK_SHIFT;
|
|
if (ce > hi) ce = hi;
|
|
const uint32_t *ch = ci < GEN_CHUNKS ? gen_chunk[ci] : NULL;
|
|
if (ch)
|
|
for (uint64_t x = a; x < ce; x += 1ull << GEN_PAGE_SHIFT) {
|
|
uint32_t v = ch[(x >> GEN_PAGE_SHIFT) & (GEN_PER_CHUNK - 1)];
|
|
ne += v > since;
|
|
nl += lz && v > ls;
|
|
}
|
|
a = ce;
|
|
}
|
|
eager[k] += ne << GEN_PAGE_SHIFT, lazy[k] += nl << GEN_PAGE_SHIFT;
|
|
}
|
|
plat_mutex_unlock(&gen_lock);
|
|
plat_mutex_unlock_shared(&pool_lock);
|
|
uint64_t tot = 0;
|
|
for (int k = 0; k < DC_N; k++) tot += eager[k];
|
|
plat_mutex_lock(&dirty_lock);
|
|
for (int k = 0; k < DC_N; k++) {
|
|
dfr.eager[k] += eager[k], dfr.lazy[k] += lazy[k], dfr.first[k] += first_pending[k];
|
|
first_pending[k] = 0;
|
|
live_n[k] = ln[k], live_bytes[k] = lb[k], ever_snap_n[k] = ever_n[k], ever_snap_bytes[k] = ever_bytes[k];
|
|
}
|
|
live_untracked = untracked;
|
|
dfr.submits++, dfr.queries += queries;
|
|
if (tot > dfr.sub_max) dfr.sub_max = tot;
|
|
dfr.scan_ns += kern_now_ns() - t0;
|
|
plat_mutex_unlock(&dirty_lock);
|
|
}
|
|
|
|
static void dirty_frame(uint64_t frame) {
|
|
plat_mutex_lock(&dirty_lock);
|
|
if (dwin.frames < DIRTY_WIN)
|
|
for (int k = 0; k < DC_N; k++) dwin_kib[dwin.frames][k] = (uint32_t)(dfr.eager[k] >> 10);
|
|
for (int k = 0; k < DC_N; k++) dwin.eager[k] += dfr.eager[k], dwin.lazy[k] += dfr.lazy[k], dwin.first[k] += dfr.first[k];
|
|
dwin.submits += dfr.submits, dwin.queries += dfr.queries, dwin.scan_ns += dfr.scan_ns, dwin.frames++;
|
|
if (dfr.sub_max > dwin.sub_max) dwin.sub_max = dfr.sub_max;
|
|
memset(&dfr, 0, sizeof dfr);
|
|
if (frame % DIRTY_WIN) { plat_mutex_unlock(&dirty_lock); return; }
|
|
DirtyAcc w = dwin;
|
|
uint32_t kib[DIRTY_WIN][DC_N];
|
|
memcpy(kib, dwin_kib, sizeof kib);
|
|
uint64_t lv[DC_N], lvb[DC_N], ev[DC_N], evb[DC_N], unt = live_untracked;
|
|
for (int k = 0; k < DC_N; k++) lv[k] = live_n[k], lvb[k] = live_bytes[k], ev[k] = ever_snap_n[k], evb[k] = ever_snap_bytes[k];
|
|
memset(&dwin, 0, sizeof dwin);
|
|
plat_mutex_unlock(&dirty_lock);
|
|
if (!w.frames) return;
|
|
uint64_t fl[2][3][2];
|
|
nvn_flush_stats(fl);
|
|
double f = (double)w.frames, M = 1048576.0;
|
|
uint64_t sub_tot = 0;
|
|
for (int k = 0; k < DC_N; k++) sub_tot += w.eager[k];
|
|
#define C3(a, d) (a)[0] / (d), (a)[1] / (d), (a)[2] / (d)
|
|
rt_log("mem: dirty frame %llu: frames %llu submits_per_frame %.2f; per frame, cached/uncached/other: eager_mib "
|
|
"%.3f/%.3f/%.3f lazy_mib %.3f/%.3f/%.3f first_mib %.3f/%.3f/%.3f flush_pool_calls %.1f/%.1f/%.1f "
|
|
"flush_pool_mib %.3f/%.3f/%.3f flush_buf_calls %.1f/%.1f/%.1f flush_buf_mib %.3f/%.3f/%.3f; per submit: "
|
|
"avg_mib %.3f max_mib %.3f; mirror: live_pools %llu/%llu/%llu live_mib %.1f/%.1f/%.1f ever_pools %llu/%llu/%llu "
|
|
"ever_mib %.1f/%.1f/%.1f untracked_mib %.1f; cost: queries_per_frame %.1f scan_ms_per_frame %.3f",
|
|
(unsigned long long)frame, (unsigned long long)w.frames, w.submits / f,
|
|
C3(w.eager, f * M), C3(w.lazy, f * M), C3(w.first, f * M),
|
|
fl[0][0][0] / f, fl[0][1][0] / f, fl[0][2][0] / f, fl[0][0][1] / f / M, fl[0][1][1] / f / M, fl[0][2][1] / f / M,
|
|
fl[1][0][0] / f, fl[1][1][0] / f, fl[1][2][0] / f, fl[1][0][1] / f / M, fl[1][1][1] / f / M, fl[1][2][1] / f / M,
|
|
w.submits ? sub_tot / (double)w.submits / M : 0.0, w.sub_max / M,
|
|
(unsigned long long)lv[0], (unsigned long long)lv[1], (unsigned long long)lv[2], C3(lvb, M),
|
|
(unsigned long long)ev[0], (unsigned long long)ev[1], (unsigned long long)ev[2], C3(evb, M), unt / M,
|
|
w.queries / f, w.scan_ns / 1e6 / f);
|
|
#undef C3
|
|
char line[DIRTY_WIN * 36 + 64];
|
|
size_t k = (size_t)snprintf(line, sizeof line, "mem: dirty frames KiB cached/uncached/other:");
|
|
for (uint64_t i = 0; i < w.frames && i < DIRTY_WIN; i++)
|
|
k += (size_t)snprintf(line + k, k < sizeof line ? sizeof line - k : 0, " %u/%u/%u", kib[i][0], kib[i][1], kib[i][2]);
|
|
rt_log("%s", line);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- VRAM mirror of buffer-bound pools
|
|
// An imported pool keeps vertices, indices, constants
|
|
// and storage buffers in system memory, so a discrete GPU reads them over PCIe on every draw (GTX 1050 Ti and RTX
|
|
// 3090 Ti: about 110 ms of GPU time per frame in the field). In mirror mode (device class discrete, TOTK_MEMORY) an
|
|
// imported pool gets a device-local buffer over its imported range the first time the replay uses it as a vertex,
|
|
// index, uniform, storage or indirect buffer (vk_mem_use). From then on those bindings, and the buffer copies, fills
|
|
// and uniform updates that touch the pool (vk_mem_pick), use the mirror at the same offsets. Guest memory stays what
|
|
// the CPU reads and writes; textures and every host path that reads or writes pool bytes keep using it.
|
|
// - CPU -> mirror: the first access of a submission to a mirror syncs it (mirror_sync): the 2 MiB write-watch
|
|
// snapshots of its regions for this epoch (snap_regions, shared with the texture checks: the OS bits keep one
|
|
// consumer, the shared page generations) and every page stamped after the mirror's mgen goes into a copy; so do the
|
|
// nvnMemoryPoolFlushMappedRange ranges, each on its own: one span from the lowest to the highest flush of a 252 MiB
|
|
// pool copied up to the whole pool for a few MiB flushed, 30-50 ms of GPU time over PCIe 3.0 x8 in crowded towns
|
|
// (TOTK_FLUSH_SPAN=1 restores it). Writes between the ranges need no copy: the write watch sees them. The first use
|
|
// copies the whole range. The copies run in a prologue command
|
|
// buffer that vk_sync.c submits right before the part that accessed the mirror first, between full barriers.
|
|
// vk_mem_mirror_begin snaps the regions of the pools the previous submission accessed in a few merged queries.
|
|
// - GPU -> guest: what the replay writes into a mirror (storage buffers its shaders write, buffer copies, fills,
|
|
// uniform updates, texture-to-buffer copies: vk_mem_gpu_wrote) goes back into the imported buffer at the end of the
|
|
// submission, exact byte ranges, before the fence signals (vk_mem_mirror_end).
|
|
// - TOTK_MEMORY=verify: every submission also copies up to VERIFY_BYTES of mirror pages, round robin, into host memory;
|
|
// at its fence the pages the CPU has not written since their sync are compared with guest memory.
|
|
// A pool stays imported when its mirror would not fit the VRAM budget, its range is not write-watched, it overlaps
|
|
// another pool or the allocation fails.
|
|
#define VERIFY_BYTES (4u << 20)
|
|
#define VERIFY_SAMPLES 64
|
|
#define VERIFY_LOG 16
|
|
enum { MR_BUDGET, MR_UNTRACKED, MR_OVERLAP, MR_ALLOC, MR_N };
|
|
|
|
int vk_mirror_on;
|
|
static int mirror_verify;
|
|
static int mirror_gap = 2; // pre-snap: hot pools at most this many 2 MiB regions apart share one query
|
|
static PoolRec **mlist; // pool_lock: mirrored pools by import_lo
|
|
static int nm, mcap;
|
|
static uint32_t msub; // replay: number of the submission being recorded
|
|
static uint32_t mepoch; // replay: its write-watch snapshot epoch (vk_texture_epoch)
|
|
typedef struct { PoolRec *r; uint64_t off, size; } MRange;
|
|
static MRange *pend, *wbl; // replay: copies for the next prologue; GPU writes into mirrors this submission
|
|
static int npend, cpend, nwbl, cwbl;
|
|
static VkBufferCopy *mregions; // replay: vkCmdCopyBuffer regions
|
|
static int cregions;
|
|
static PlatMutex flush_lock = PLAT_MUTEX_INIT;
|
|
static int flush_cap = FLUSH_RANGES; // ranges a pool keeps; 1 with TOTK_FLUSH_SPAN=1
|
|
// The first uses of a submission's mirrors, the copies they queue, the GPU writes noted and their snapshots (pend, wbl, snl, mrep): threads
|
|
// recording chunks of one submission take it. A mirror synced in this submission is read without it (vk_mem_use): its
|
|
// acc_sub is stored last. Before pool_lock, gen_lock and flush_lock.
|
|
static PlatMutex mirror_lock = PLAT_MUTEX_INIT;
|
|
|
|
typedef struct {
|
|
uint64_t subs, syncs, upload_bytes, upload_copies, first_n, first_bytes, wb_bytes, wb_copies, wb_ranges;
|
|
uint64_t presnap_queries, sync_queries, presnap_t, sync_t, prologues, presnap_bytes;
|
|
uint64_t snap_bytes, merge_bytes, merge_dispatches, snap_allocs; // write-back merge
|
|
uint64_t page_bytes, flush_n, flush_bytes, flush_calls, flush_sum; // what mirror_sync queues, by source
|
|
} MirrorAcc;
|
|
static MirrorAcc mrep; // replay: this submission
|
|
static MirrorAcc macc; // mirror_stat_lock: since the last summary
|
|
static PlatMutex mirror_stat_lock = PLAT_MUTEX_INIT;
|
|
static volatile int64_t m_live_n, m_live_bytes, m_refused_n[MR_N], m_refused_bytes[MR_N];
|
|
static volatile int64_t m_flush_calls, m_flush_bytes, v_pages, v_skipped, v_mismatch;
|
|
|
|
static void mirror_mode(int discrete) {
|
|
const char *e = cfg_value("TOTK_MEMORY");
|
|
int mode = discrete ? 1 : 0; // 0 import, 1 mirror, 2 verify
|
|
const char *why = discrete ? "auto, discrete gpu" : "auto, uma";
|
|
if (strcmp(e, "auto")) {
|
|
if (!strcmp(e, "import")) mode = 0, why = "TOTK_MEMORY=import";
|
|
else if (!strcmp(e, "mirror")) mode = 1, why = "TOTK_MEMORY=mirror";
|
|
else if (!strcmp(e, "verify")) mode = 2, why = "TOTK_MEMORY=verify";
|
|
else rt_log("mem: TOTK_MEMORY=%s is not import, mirror, verify or auto: auto", e);
|
|
}
|
|
vk_mirror_on = mode > 0;
|
|
mirror_verify = mode == 2;
|
|
const char *g = cfg_get("TOTK_MIRROR_GAP");
|
|
mirror_gap = g && *g ? atoi(g) : 2;
|
|
if (mirror_gap < 0) mirror_gap = 0;
|
|
// Submissions a region stays unwritten before it is frozen. 60 rather than 300: in a fight 100 of the 257 MiB
|
|
// queried a frame lay in regions idle for 30-299 submissions, and a thaw by a write costs about 60 us.
|
|
if (rt_knob("FLUSH_SPAN", "0")) {
|
|
flush_cap = 1;
|
|
rt_log("mem: a pool's flushes copy one span from the lowest to the highest (TOTK_FLUSH_SPAN=1)");
|
|
}
|
|
const char *fi = cfg_get("TOTK_FREEZE_IDLE");
|
|
freeze_idle = fi && *fi ? atoi(fi) : 60;
|
|
if (freeze_idle < 0) freeze_idle = 0;
|
|
const char *sh = cfg_get("TOTK_SHADOW");
|
|
#ifdef _WIN32
|
|
shadow_on = sh && *sh ? strcmp(sh, "0") != 0 : plat_is_wine();
|
|
#else
|
|
shadow_on = !sh || !*sh || strcmp(sh, "0");
|
|
#endif
|
|
defer_on = shadow_on && cfg_bool("TOTK_SYNC_AHEAD") && cfg_bool("TOTK_SHADOW_DEFER");
|
|
if (defer_on) rt_log("mem: TOTK_SHADOW_DEFER: shadow updates that wait for a reader are queued for the GPU instead");
|
|
if (shadow_on) rt_log("mem: shadow storage: CPU-visible pools are host-visible copies of guest memory, not imports%s",
|
|
sh && *sh ? " (TOTK_SHADOW)" : "");
|
|
rt_log("mem: mode %s (%s): %s", mode == 2 ? "verify" : mode ? "mirror" : "import", why,
|
|
mode ? "pools used as buffers get a device-local mirror" : "every CPU-visible pool is imported host memory");
|
|
// Import is the integrated-GPU mode. On a discrete card every draw reads the pools over PCIe (the comment above
|
|
// this section has the measurements), which is the difference between playable and 5 fps when it is not intended.
|
|
if (!mode && discrete)
|
|
rt_log("mem: warning: import on a discrete GPU: the GPU reads the game's pools over PCIe on every draw, far "
|
|
"slower than mirror (the auto default on a discrete GPU); use memory = auto unless testing");
|
|
}
|
|
|
|
static void range_add(MRange **a, int *n, int *cap, PoolRec *r, uint64_t off, uint64_t size) {
|
|
if (*n && (*a)[*n - 1].r == r && (*a)[*n - 1].off + (*a)[*n - 1].size == off) {
|
|
(*a)[*n - 1].size += size;
|
|
return;
|
|
}
|
|
if (*n == *cap) grow((void **)a, cap, sizeof **a);
|
|
(*a)[(*n)++] = (MRange){r, off, size};
|
|
}
|
|
|
|
static int range_cmp(const void *x, const void *y) {
|
|
const MRange *a = x, *b = y;
|
|
if (a->r != b->r) return (uintptr_t)a->r < (uintptr_t)b->r ? -1 : 1;
|
|
return a->off < b->off ? -1 : a->off > b->off;
|
|
}
|
|
|
|
// Records the ranges as one vkCmdCopyBuffer per pool, overlapping and adjacent ranges merged; to_mirror: buf -> mbuf,
|
|
// else mbuf -> buf. Returns the commands recorded; adds what they move to *bytes and *copies.
|
|
static int copy_ranges(VkCommandBuffer cb, MRange *a, int n, int to_mirror, uint64_t *bytes, uint64_t *copies) {
|
|
qsort(a, (size_t)n, sizeof *a, range_cmp);
|
|
int rec = 0;
|
|
for (int i = 0; i < n;) {
|
|
PoolRec *r = a[i].r;
|
|
uint64_t lim = r->import_hi - r->import_lo;
|
|
int k = 0;
|
|
for (; i < n && a[i].r == r; i++) {
|
|
uint64_t lo = a[i].off, hi = a[i].off + a[i].size;
|
|
if (hi > lim) hi = lim;
|
|
if (lo >= hi) continue;
|
|
if (k && lo <= mregions[k - 1].srcOffset + mregions[k - 1].size) {
|
|
uint64_t end = mregions[k - 1].srcOffset + mregions[k - 1].size;
|
|
if (hi > end) mregions[k - 1].size += hi - end;
|
|
continue;
|
|
}
|
|
if (k == cregions) grow((void **)&mregions, &cregions, sizeof *mregions);
|
|
mregions[k++] = (VkBufferCopy){lo, lo, hi - lo};
|
|
}
|
|
if (!k) continue;
|
|
if (to_mirror) vkCmdCopyBuffer(cb, r->buf, r->mbuf, (uint32_t)k, mregions);
|
|
else vkCmdCopyBuffer(cb, r->mbuf, r->buf, (uint32_t)k, mregions);
|
|
rec++;
|
|
for (int j = 0; j < k; j++) *bytes += mregions[j].size;
|
|
*copies += (uint64_t)k;
|
|
}
|
|
return rec;
|
|
}
|
|
|
|
static void mbarrier(VkCommandBuffer cb, VkPipelineStageFlags2 ss, VkAccessFlags2 sa, VkPipelineStageFlags2 ds,
|
|
VkAccessFlags2 da) {
|
|
VkMemoryBarrier2 mb = {.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER_2, .srcStageMask = ss, .srcAccessMask = sa,
|
|
.dstStageMask = ds, .dstAccessMask = da};
|
|
VkDependencyInfo di = {.sType = VK_STRUCTURE_TYPE_DEPENDENCY_INFO, .memoryBarrierCount = 1, .pMemoryBarriers = &mb};
|
|
vkCmdPipelineBarrier2(cb, &di);
|
|
}
|
|
|
|
static void refuse(PoolRec *r, int why, const char *what) {
|
|
r->mstate = MS_REFUSED;
|
|
uint64_t n = r->import_hi - r->import_lo;
|
|
if (plat_inc64(&m_refused_n[why]) <= 4) {
|
|
char f[160];
|
|
flag_names(r->flags, f, sizeof f);
|
|
rt_log("mem: pool %#llx+%#llx (%s) stays imported: %s", (unsigned long long)r->lo, (unsigned long long)r->size,
|
|
f, what);
|
|
}
|
|
plat_add64(&m_refused_bytes[why], (int64_t)n);
|
|
}
|
|
|
|
// Replay thread: the pool's mirror on its first buffer use.
|
|
static void mirror_create(PoolRec *r) {
|
|
VkCtx *c = vk_ctx();
|
|
uint64_t n = r->import_hi - r->import_lo;
|
|
__atomic_add_fetch(&mirror_gen, 1, __ATOMIC_RELEASE);
|
|
if (r->kind != POOL_IMPORT || !r->buf || !n) {
|
|
__atomic_store_n(&r->mstate, MS_REFUSED, __ATOMIC_RELEASE);
|
|
return;
|
|
}
|
|
plat_mutex_lock_shared(&pool_lock);
|
|
int i = pidx_upper(&pool_index, r->lo) - 1;
|
|
while (i >= 0 && pool_index.e[i].buf != r) i--;
|
|
int alone = i >= 0 && (i == 0 || pool_index.maxhi[i - 1] <= pool_index.e[i].lo) &&
|
|
(i + 1 == pool_index.n || pool_index.e[i + 1].lo >= pool_index.e[i].hi);
|
|
plat_mutex_unlock_shared(&pool_lock);
|
|
if (i < 0) { // finalized meanwhile
|
|
__atomic_store_n(&r->mstate, MS_REFUSED, __ATOMIC_RELEASE);
|
|
return;
|
|
}
|
|
if (!alone) return refuse(r, MR_OVERLAP, "it overlaps another pool");
|
|
uint64_t r0 = r->import_lo >> SNAP_SHIFT, r1 = (r->import_hi - 1) >> SNAP_SHIFT;
|
|
if (r1 >= SNAP_REGIONS) return refuse(r, MR_UNTRACKED, "outside the write-watched address space");
|
|
uint64_t used, budget;
|
|
int exact;
|
|
if (vk_mem_vram(&used, &budget, &exact) && used + n + budget / 8 > budget) {
|
|
char why[128];
|
|
snprintf(why, sizeof why, "its %.1f MiB mirror does not fit the VRAM budget (%.0f of %.0f MiB in use)",
|
|
n / 1048576.0, used / 1048576.0, budget / 1048576.0);
|
|
return refuse(r, MR_BUDGET, why);
|
|
}
|
|
plat_mutex_lock(&gen_lock);
|
|
mrep.sync_queries += snap_regions(r0, r1, mepoch);
|
|
int untracked = 0;
|
|
for (uint64_t k = r0; k <= r1; k++) untracked |= snap_untracked[k];
|
|
r->mgen = gen_now; // the whole-range copy below holds every write observed so far
|
|
plat_mutex_unlock(&gen_lock);
|
|
if (untracked) return refuse(r, MR_UNTRACKED, "its range is not write-watched");
|
|
VkBufferCreateInfo bi = {.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO, .size = n, .usage = pool_usage(c),
|
|
.sharingMode = VK_SHARING_MODE_EXCLUSIVE};
|
|
VmaAllocationCreateInfo ai = {.usage = VMA_MEMORY_USAGE_AUTO_PREFER_DEVICE,
|
|
.requiredFlags = VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT};
|
|
VkResult res = vmaCreateBuffer(c->vma, &bi, &ai, &r->mbuf, &r->malloc_, NULL);
|
|
if (res != VK_SUCCESS) {
|
|
r->mbuf = VK_NULL_HANDLE, r->malloc_ = NULL;
|
|
char why[96];
|
|
snprintf(why, sizeof why, "device-local allocation of %.1f MiB failed (%d)", n / 1048576.0, (int)res);
|
|
return refuse(r, MR_ALLOC, why);
|
|
}
|
|
plat_mutex_lock(&pool_lock);
|
|
if (nm == mcap) grow((void **)&mlist, &mcap, sizeof *mlist);
|
|
int at = nm;
|
|
while (at > 0 && mlist[at - 1]->import_lo > r->import_lo) at--;
|
|
memmove(mlist + at + 1, mlist + at, (size_t)(nm - at) * sizeof *mlist);
|
|
mlist[at] = r;
|
|
nm++;
|
|
plat_mutex_unlock(&pool_lock);
|
|
range_add(&pend, &npend, &cpend, r, 0, n);
|
|
__atomic_store_n(&r->acc_sub, msub, __ATOMIC_RELEASE);
|
|
__atomic_store_n(&r->mstate, MS_ON, __ATOMIC_RELEASE);
|
|
mrep.first_n++, mrep.first_bytes += n;
|
|
plat_inc64(&m_live_n);
|
|
plat_add64(&m_live_bytes, (int64_t)n);
|
|
}
|
|
|
|
static void mirror_forget(PoolRec *r) {
|
|
__atomic_add_fetch(&mirror_gen, 1, __ATOMIC_RELEASE);
|
|
for (int i = 0; i < nm; i++)
|
|
if (mlist[i] == r) {
|
|
memmove(mlist + i, mlist + i + 1, (size_t)(nm - i - 1) * sizeof *mlist);
|
|
nm--;
|
|
plat_dec64(&m_live_n);
|
|
plat_add64(&m_live_bytes, -(int64_t)(r->import_hi - r->import_lo));
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Finalized pools waiting for pool_retire; vk_mem_shutdown destroys those whose callback never ran.
|
|
static PoolRec **retiring;
|
|
static int nretiring, cretiring;
|
|
static PlatMutex retire_lock = PLAT_MUTEX_INIT;
|
|
|
|
static void pool_retiring(PoolRec *r) {
|
|
plat_mutex_lock(&retire_lock);
|
|
if (nretiring == cretiring) grow((void **)&retiring, &cretiring, sizeof *retiring);
|
|
retiring[nretiring++] = r;
|
|
plat_mutex_unlock(&retire_lock);
|
|
}
|
|
|
|
static void pool_retire(void *arg) {
|
|
plat_mutex_lock(&retire_lock);
|
|
for (int i = 0; i < nretiring; i++)
|
|
if (retiring[i] == arg) { retiring[i] = retiring[--nretiring]; break; }
|
|
plat_mutex_unlock(&retire_lock);
|
|
destroy_rec(vk_ctx(), arg);
|
|
}
|
|
|
|
// Replay thread, the first access of this submission: queues the copies of the pages written since the last sync.
|
|
static void mirror_sync(PoolRec *r) {
|
|
uint64_t t0 = qpc();
|
|
uint64_t lo = r->import_lo, hi = r->import_hi;
|
|
uint64_t r0 = lo >> SNAP_SHIFT, r1 = (hi - 1) >> SNAP_SHIFT;
|
|
int excl = 0;
|
|
plat_mutex_lock_shared(&gen_lock);
|
|
for (uint64_t k = r0; k <= r1; k++)
|
|
if (snap_at[k] != mepoch) { excl = 1; break; }
|
|
if (excl) {
|
|
plat_mutex_unlock_shared(&gen_lock);
|
|
plat_mutex_lock(&gen_lock);
|
|
mrep.sync_queries += snap_regions(r0, r1, mepoch);
|
|
}
|
|
uint32_t since = r->mgen;
|
|
for (uint64_t k = r0; k <= r1; k++) {
|
|
if (reg_max[k] <= since) continue;
|
|
uint64_t a = k << SNAP_SHIFT, b = a + (1ull << SNAP_SHIFT);
|
|
if (a < lo) a = lo;
|
|
if (b > hi) b = hi;
|
|
const uint32_t *ch = gen_chunk[a >> GEN_CHUNK_SHIFT]; // a 2 MiB region lies in one chunk
|
|
if (!ch) continue;
|
|
for (uint64_t x = a; x < b; x += 1ull << GEN_PAGE_SHIFT)
|
|
if (ch[(x >> GEN_PAGE_SHIFT) & (GEN_PER_CHUNK - 1)] > since)
|
|
range_add(&pend, &npend, &cpend, r, x - lo, 1ull << GEN_PAGE_SHIFT), mrep.page_bytes += 1ull << GEN_PAGE_SHIFT;
|
|
}
|
|
r->mgen = gen_now;
|
|
if (excl) plat_mutex_unlock(&gen_lock);
|
|
else plat_mutex_unlock_shared(&gen_lock);
|
|
if (__atomic_load_n(&r->fl_n, __ATOMIC_ACQUIRE)) {
|
|
FlushRange fl[FLUSH_RANGES + 1];
|
|
plat_mutex_lock(&flush_lock);
|
|
int n = r->fl_n;
|
|
memcpy(fl, r->fl, (size_t)n * sizeof *fl);
|
|
uint64_t calls = r->fl_calls, sum = r->fl_sum;
|
|
r->fl_n = 0, r->fl_calls = r->fl_sum = 0;
|
|
plat_mutex_unlock(&flush_lock);
|
|
mrep.flush_calls += calls, mrep.flush_sum += sum;
|
|
for (int i = 0; i < n; i++) {
|
|
uint64_t a = fl[i].lo > lo ? fl[i].lo : lo, b = fl[i].hi < hi ? fl[i].hi : hi;
|
|
if (a >= b) continue;
|
|
range_add(&pend, &npend, &cpend, r, a - lo, b - a);
|
|
mrep.flush_n++, mrep.flush_bytes += b - a;
|
|
if ((rt_trace & RT_TRACE_PERF) && b - a >= 16u << 20)
|
|
rt_log("mem: mirror frame %llu: flush range %.1f MiB at +%.1f MiB of a %.1f MiB pool (%d ranges from %llu "
|
|
"flushes of %.3f MiB)", (unsigned long long)mirror_frame, (b - a) / 1048576.0, (a - lo) / 1048576.0,
|
|
(hi - lo) / 1048576.0, n, (unsigned long long)calls, sum / 1048576.0);
|
|
}
|
|
}
|
|
mrep.syncs++;
|
|
mrep.sync_t += qpc() - t0;
|
|
__atomic_store_n(&r->acc_sub, msub, __ATOMIC_RELEASE);
|
|
}
|
|
|
|
// 1 when r is settled for this submission without mirror_lock: refused, or mirrored and synced (*on).
|
|
static int mirror_settled(PoolRec *r, int *on) {
|
|
int st = __atomic_load_n(&r->mstate, __ATOMIC_ACQUIRE);
|
|
*on = st == MS_ON;
|
|
return st == MS_REFUSED || (st == MS_ON && __atomic_load_n(&r->acc_sub, __ATOMIC_ACQUIRE) == msub);
|
|
}
|
|
|
|
void vk_mem_use(VkMemSpan *sp) {
|
|
PoolRec *r = sp->pool;
|
|
int on;
|
|
if (!mirror_settled(r, &on)) {
|
|
plat_mutex_lock(&mirror_lock);
|
|
if (r->mstate == MS_NONE) mirror_create(r);
|
|
on = r->mstate == MS_ON;
|
|
if (on && r->acc_sub != msub) mirror_sync(r);
|
|
plat_mutex_unlock(&mirror_lock);
|
|
}
|
|
if (on) sp->buffer = r->mbuf;
|
|
}
|
|
|
|
void vk_mem_pick(VkMemSpan *sp) {
|
|
PoolRec *r = sp->pool;
|
|
int on;
|
|
if (!r) return;
|
|
if (!mirror_settled(r, &on)) {
|
|
plat_mutex_lock(&mirror_lock);
|
|
on = r->mstate == MS_ON;
|
|
if (on && r->acc_sub != msub) mirror_sync(r);
|
|
plat_mutex_unlock(&mirror_lock);
|
|
}
|
|
if (on) sp->buffer = r->mbuf;
|
|
}
|
|
|
|
int vk_mem_mirror_probe(uint64_t addr) {
|
|
VkMemSpan sp;
|
|
if (!vk_mirror_on || !vk_mem_resolve(addr, 1, &sp) || !sp.pool) return 0;
|
|
mepoch = vk_texture_epoch();
|
|
vk_mem_use(&sp);
|
|
return ((PoolRec *)sp.pool)->mstate == MS_ON;
|
|
}
|
|
|
|
// ---- write-back merge
|
|
// A storage buffer a shader writes whole records of does not
|
|
// go back to guest memory as a plain copy. The CPU may write fields of the same records while the GPU runs: TotK's
|
|
// particle emitters write a new particle's whole record into a free slot of a buffer that the simulation shaders update
|
|
// (the waterfall splashes and mist, 2026-11-02), and a plain copy puts the mirror's stale copy of those words back over
|
|
// them. Instead:
|
|
// - record time (gpu_wrote from vk_mem_gpu_wrote_shader): each distinct range of the submission gets room in a snapshot
|
|
// chunk (snap_add);
|
|
// - each part's prologue, after the CPU -> mirror copies: the mirror's words of the ranges the part added are copied
|
|
// into their snapshots (snap_copies), the state before the part's GPU work. Words of an earlier part's writes that a
|
|
// later range overlaps are covered by the earlier range's own snapshot;
|
|
// - vk_mem_mirror_end: shaders/wb_merge.comp writes each 32-bit word that differs between the mirror and the snapshot
|
|
// into the pool's imported buffer (wb_merge_record), one dispatch per pool and group of ranges.
|
|
// Words the GPU left unchanged keep the CPU's value; a word both changed takes the GPU's, as a plain copy would.
|
|
// TOTK_WB_MERGE=0 restores the plain copies; so does a pipeline or allocation failure.
|
|
#define SNAP_CHUNK (1u << 20)
|
|
#define MERGE_SPAN (64ull << 20) // the bytes of one pool a merge dispatch binds at most
|
|
#define MERGE_WORDS (65535u * 64u) // the words one merge dispatch covers at most
|
|
#define MERGE_MAX (4ull << 20) // larger written ranges stay plain copies
|
|
typedef struct { PoolRec *r; uint64_t off, size, soff; int chunk; } SnapRange;
|
|
typedef struct { VkBuffer buf; VmaAllocation alloc; uint64_t size, used; int host; } SnapChunk;
|
|
typedef struct { int n; SnapChunk c[]; } SnapFree; // a submission's chunks and range lists, pooled at its fence
|
|
static SnapRange *snl; // replay: this submission's merged ranges; [0, snl_copied) have their snapshot copy
|
|
static int nsnl, csnl, snl_copied;
|
|
static SnapChunk *chunks; // replay: this submission's snapshot chunks and merge range lists
|
|
static int nchunks, cchunks;
|
|
// Chunks a finished submission used (snap_free) wait here for chunk_new, so a frame with merges creates no buffer once
|
|
// the pool holds the frames in flight: a VMA allocation that needs a new memory block costs 8-11 ms on the replay
|
|
// thread. Chunk sizes are powers of two so that one fits the next request; what the pool cannot hold is destroyed.
|
|
#define SNAP_LIST_MIN (64u << 10)
|
|
#define SNAP_POOL_N 32
|
|
#define SNAP_POOL_BYTES (16ull << 20)
|
|
static PlatMutex snap_pool_lock = PLAT_MUTEX_INIT;
|
|
static SnapChunk snap_pool[SNAP_POOL_N];
|
|
static int nsnap_pool;
|
|
static uint64_t snap_pool_bytes;
|
|
static int merge_on = -1;
|
|
static struct {
|
|
int tried, ok;
|
|
PFN_vkCmdPushDescriptorSetKHR push;
|
|
VkDescriptorSetLayout dsl;
|
|
VkPipelineLayout layout;
|
|
VkPipeline pipe;
|
|
} WM;
|
|
|
|
static int merge_init(VkCtx *c) {
|
|
WM.tried = 1;
|
|
WM.push = (PFN_vkCmdPushDescriptorSetKHR)vkGetDeviceProcAddr(c->device, "vkCmdPushDescriptorSetKHR");
|
|
VkDescriptorSetLayoutBinding b[4];
|
|
for (int i = 0; i < 4; i++)
|
|
b[i] = (VkDescriptorSetLayoutBinding){.binding = (uint32_t)i, .descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER,
|
|
.descriptorCount = 1, .stageFlags = VK_SHADER_STAGE_COMPUTE_BIT};
|
|
VkDescriptorSetLayoutCreateInfo dci = {.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO,
|
|
.flags = VK_DESCRIPTOR_SET_LAYOUT_CREATE_PUSH_DESCRIPTOR_BIT_KHR,
|
|
.bindingCount = 4, .pBindings = b};
|
|
VkPushConstantRange pr = {VK_SHADER_STAGE_COMPUTE_BIT, 0, 8};
|
|
VkShaderModuleCreateInfo mi = {.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO, .codeSize = sizeof wb_merge_spv,
|
|
.pCode = wb_merge_spv};
|
|
VkShaderModule m = VK_NULL_HANDLE;
|
|
if (!WM.push || vkCreateDescriptorSetLayout(c->device, &dci, NULL, &WM.dsl) != VK_SUCCESS) goto fail;
|
|
VkPipelineLayoutCreateInfo li = {.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO, .setLayoutCount = 1,
|
|
.pSetLayouts = &WM.dsl, .pushConstantRangeCount = 1, .pPushConstantRanges = &pr};
|
|
if (vkCreatePipelineLayout(c->device, &li, NULL, &WM.layout) != VK_SUCCESS ||
|
|
vkCreateShaderModule(c->device, &mi, NULL, &m) != VK_SUCCESS)
|
|
goto fail;
|
|
VkComputePipelineCreateInfo ci = {.sType = VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO,
|
|
.stage = {.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO,
|
|
.stage = VK_SHADER_STAGE_COMPUTE_BIT, .module = m, .pName = "main"},
|
|
.layout = WM.layout};
|
|
VkResult res = vkCreateComputePipelines(c->device, VK_NULL_HANDLE, 1, &ci, NULL, &WM.pipe);
|
|
vkDestroyShaderModule(c->device, m, NULL);
|
|
if (res != VK_SUCCESS) goto fail;
|
|
return WM.ok = 1;
|
|
fail:
|
|
rt_log("mem: write-back merge off: its pipeline could not be created");
|
|
return 0;
|
|
}
|
|
|
|
static int merging(void) {
|
|
if (merge_on < 0) {
|
|
merge_on = cfg_bool("TOTK_WB_MERGE");
|
|
rt_log("mem: write-back merge %s (TOTK_WB_MERGE)", merge_on ? "on" : "off");
|
|
}
|
|
if (!merge_on) return 0;
|
|
if (!WM.tried) merge_init(vk_ctx());
|
|
return WM.ok;
|
|
}
|
|
|
|
// A chunk of at least size bytes in chunks[]: the smallest fitting one of the pool, else a new buffer; -1 when the
|
|
// allocation fails.
|
|
static int chunk_new(uint64_t size, int host) {
|
|
uint64_t want = host ? SNAP_LIST_MIN : SNAP_CHUNK;
|
|
while (want < size) want <<= 1;
|
|
if (nchunks == cchunks) grow((void **)&chunks, &cchunks, sizeof *chunks);
|
|
SnapChunk *ch = &chunks[nchunks];
|
|
int best = -1;
|
|
plat_mutex_lock(&snap_pool_lock);
|
|
for (int i = 0; i < nsnap_pool; i++)
|
|
if (snap_pool[i].host == host && snap_pool[i].size >= want && (best < 0 || snap_pool[i].size < snap_pool[best].size))
|
|
best = i;
|
|
if (best >= 0) {
|
|
*ch = snap_pool[best];
|
|
snap_pool[best] = snap_pool[--nsnap_pool];
|
|
snap_pool_bytes -= ch->size;
|
|
}
|
|
plat_mutex_unlock(&snap_pool_lock);
|
|
if (best < 0) {
|
|
VkBufferCreateInfo bi = {.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO, .size = want,
|
|
.usage = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT,
|
|
.sharingMode = VK_SHARING_MODE_EXCLUSIVE};
|
|
VmaAllocationCreateInfo ai = {.usage = host ? VMA_MEMORY_USAGE_AUTO_PREFER_HOST : VMA_MEMORY_USAGE_AUTO_PREFER_DEVICE,
|
|
.flags = host ? VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT |
|
|
VMA_ALLOCATION_CREATE_MAPPED_BIT : 0};
|
|
if (vmaCreateBuffer(vk_ctx()->vma, &bi, &ai, &ch->buf, &ch->alloc, NULL) != VK_SUCCESS) return -1;
|
|
ch->size = want, ch->host = host;
|
|
mrep.snap_allocs++;
|
|
}
|
|
ch->used = 0;
|
|
return nchunks++;
|
|
}
|
|
|
|
// Record time: room in a snapshot chunk for [off, off+size) of r's mirror, once per submission. 0: plain copy instead.
|
|
static int snap_add(PoolRec *r, uint64_t off, uint64_t size) {
|
|
for (int i = nsnl; i-- > 0;)
|
|
if (snl[i].r == r && snl[i].off == off && snl[i].size == size) return 1;
|
|
int k = -1;
|
|
for (int i = nchunks; i-- > 0;) // the newest snapshot chunk, if the range fits
|
|
if (chunks[i].used != UINT64_MAX && chunks[i].size - chunks[i].used >= size) { k = i; break; }
|
|
if (k < 0 && (k = chunk_new(size > SNAP_CHUNK ? size : SNAP_CHUNK, 0)) < 0) return 0;
|
|
if (nsnl == csnl) grow((void **)&snl, &csnl, sizeof *snl);
|
|
snl[nsnl++] = (SnapRange){r, off, size, chunks[k].used, k};
|
|
chunks[k].used += (size + 15) & ~15ull;
|
|
return 1;
|
|
}
|
|
|
|
// Prologue: the snapshot copies of the ranges added since the last prologue.
|
|
static void snap_copies(VkCommandBuffer cb) {
|
|
for (int i = snl_copied; i < nsnl; i++) {
|
|
VkBufferCopy cp = {snl[i].off, snl[i].soff, snl[i].size};
|
|
vkCmdCopyBuffer(cb, snl[i].r->mbuf, chunks[snl[i].chunk].buf, 1, &cp);
|
|
mrep.snap_bytes += snl[i].size;
|
|
}
|
|
snl_copied = nsnl;
|
|
}
|
|
|
|
static int snap_cmp(const void *x, const void *y) {
|
|
const SnapRange *a = x, *b = y;
|
|
if (a->r != b->r) return (uintptr_t)a->r < (uintptr_t)b->r ? -1 : 1;
|
|
if (a->chunk != b->chunk) return a->chunk < b->chunk ? -1 : 1;
|
|
return a->off < b->off ? -1 : a->off > b->off;
|
|
}
|
|
|
|
// One dispatch: ranges s[0..n) share a pool and a chunk and lie in [base, base + span) of the pool.
|
|
static void merge_dispatch(VkCommandBuffer cb, const SnapRange *s, int n, uint64_t base, uint64_t span, int list,
|
|
uint64_t list_off, uint32_t total) {
|
|
VkDescriptorBufferInfo bi[4] = {{s->r->mbuf, base, span}, {chunks[s->chunk].buf, 0, chunks[s->chunk].size},
|
|
{s->r->buf, base, span}, {chunks[list].buf, list_off, (uint64_t)n * 16}};
|
|
VkWriteDescriptorSet w[4];
|
|
for (int i = 0; i < 4; i++)
|
|
w[i] = (VkWriteDescriptorSet){.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, .dstBinding = (uint32_t)i,
|
|
.descriptorCount = 1, .descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER,
|
|
.pBufferInfo = &bi[i]};
|
|
rec_push(WM.push, cb, VK_PIPELINE_BIND_POINT_COMPUTE, WM.layout, 0, 4, w); // in call order (vk_rec.h)
|
|
uint32_t pc[2] = {(uint32_t)n, total};
|
|
vkCmdPushConstants(cb, WM.layout, VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof pc, pc);
|
|
vkCmdDispatch(cb, (total + 63) / 64, 1, 1);
|
|
}
|
|
|
|
// vk_mem_mirror_end: the merge dispatches of this submission's ranges. Returns the commands recorded; 0 with nsnl
|
|
// left as it was when the range list cannot be allocated (the caller copies them plainly).
|
|
static int wb_merge_record(VkCommandBuffer cb) {
|
|
VkCtx *c = vk_ctx();
|
|
uint64_t align = c->props.limits.minStorageBufferOffsetAlignment;
|
|
if (align < 16) align = 16;
|
|
SnapRange *s = malloc(sizeof *s * (size_t)nsnl);
|
|
if (!s) rt_fatal("vk: out of memory");
|
|
memcpy(s, snl, sizeof *s * (size_t)nsnl);
|
|
qsort(s, (size_t)nsnl, sizeof *s, snap_cmp);
|
|
// the range lists: at most one 16-byte entry per range, each dispatch's list at an aligned offset
|
|
int list = chunk_new((uint64_t)nsnl * 16 + (uint64_t)nsnl * align, 1);
|
|
if (list < 0) { free(s); return 0; }
|
|
chunks[list].used = UINT64_MAX; // not a snapshot chunk
|
|
VmaAllocationInfo li;
|
|
vmaGetAllocationInfo(c->vma, chunks[list].alloc, &li);
|
|
uint32_t *lm = li.pMappedData;
|
|
uint64_t lo = 0;
|
|
int rec = 0;
|
|
vkCmdBindPipeline(cb, VK_PIPELINE_BIND_POINT_COMPUTE, WM.pipe);
|
|
for (int i = 0; i < nsnl;) {
|
|
uint64_t base = s[i].off & ~(align - 1), end = s[i].off + s[i].size;
|
|
uint32_t total = 0;
|
|
int j = i;
|
|
lo = (lo + align - 1) & ~(align - 1);
|
|
uint64_t l0 = lo;
|
|
for (; j < nsnl && s[j].r == s[i].r && s[j].chunk == s[i].chunk; j++) {
|
|
uint64_t e = s[j].off + s[j].size > end ? s[j].off + s[j].size : end;
|
|
if (j > i && (e - base > MERGE_SPAN || total + s[j].size / 4 > MERGE_WORDS)) break;
|
|
end = e;
|
|
uint32_t *q = lm + lo / 4;
|
|
q[0] = (uint32_t)((s[j].off - base) / 4), q[1] = (uint32_t)(s[j].soff / 4), q[2] = (uint32_t)(s[j].size / 4);
|
|
q[3] = total;
|
|
total += (uint32_t)(s[j].size / 4);
|
|
lo += 16;
|
|
mrep.merge_bytes += s[j].size;
|
|
}
|
|
merge_dispatch(cb, s + i, j - i, base, end - base, list, l0, total);
|
|
mrep.merge_dispatches++;
|
|
rec += 3;
|
|
i = j;
|
|
}
|
|
vmaFlushAllocation(c->vma, chunks[list].alloc, 0, VK_WHOLE_SIZE);
|
|
for (int i = 0; i < nsnl; i++) snl[i].r->wb_sub = msub;
|
|
free(s);
|
|
return rec;
|
|
}
|
|
|
|
static void snap_free(void *arg) { // completion thread: the submission is done with its chunks
|
|
SnapFree *f = arg;
|
|
plat_mutex_lock(&snap_pool_lock);
|
|
for (int i = 0; i < f->n; i++) {
|
|
SnapChunk *ch = &f->c[i];
|
|
if (nsnap_pool == SNAP_POOL_N || snap_pool_bytes + ch->size > SNAP_POOL_BYTES) continue;
|
|
snap_pool[nsnap_pool++] = *ch;
|
|
snap_pool_bytes += ch->size;
|
|
ch->buf = VK_NULL_HANDLE;
|
|
}
|
|
plat_mutex_unlock(&snap_pool_lock);
|
|
for (int i = 0; i < f->n; i++)
|
|
if (f->c[i].buf) vmaDestroyBuffer(vk_ctx()->vma, f->c[i].buf, f->c[i].alloc);
|
|
free(f);
|
|
}
|
|
|
|
// Record time: the submission being recorded writes [off, off+size) of r's guest range, directly into the imported
|
|
// buffer or into the mirror whose bytes go back at its end. Keeps the regions unfrozen until the GPU is past it
|
|
// (reg_gpu). Shadow storage needs nothing: the GPU writes the shadow and the CPU copies it back, thawing as it goes.
|
|
static void gpu_writes(const PoolRec *r, uint64_t off, uint64_t size) {
|
|
if (!freeze_idle || r->smap) return;
|
|
uint64_t a = r->import_lo + off, b = a + size;
|
|
if (b <= a || b > RT_ADDRESS_LIMIT) return;
|
|
uint64_t f = vk_exec_recording_fence();
|
|
if (!f) f = vk_sync_issued() + 1;
|
|
int frozen = 0;
|
|
for (uint64_t j = a >> REG_SHIFT; j <= (b - 1) >> REG_SHIFT; j++) {
|
|
if (__atomic_load_n(®_gpu[j], __ATOMIC_RELAXED) < f) __atomic_store_n(®_gpu[j], f, __ATOMIC_RELAXED);
|
|
frozen |= mem_frozen(j << REG_SHIFT);
|
|
}
|
|
if (frozen) mem_thaw(a, b - a), plat_inc64(&n_thaw_gpu);
|
|
}
|
|
|
|
static void gpu_wrote(const VkMemSpan *sp, uint64_t size, int shader) {
|
|
PoolRec *r = sp->pool;
|
|
if (r && size) gpu_writes(r, sp->offset, size);
|
|
if (!r || r->mstate != MS_ON || sp->buffer != r->mbuf || !size) return;
|
|
plat_mutex_lock(&mirror_lock);
|
|
// A binding to the end of its pool (no size) can span hundreds of MiB: those stay copies.
|
|
if (shader && !(sp->offset & 3) && !(size & 3) && size <= MERGE_MAX && merging() && snap_add(r, sp->offset, size)) {
|
|
plat_mutex_unlock(&mirror_lock);
|
|
return;
|
|
}
|
|
range_add(&wbl, &nwbl, &cwbl, r, sp->offset, size);
|
|
plat_mutex_unlock(&mirror_lock);
|
|
}
|
|
void vk_mem_gpu_wrote(const VkMemSpan *sp, uint64_t size) { gpu_wrote(sp, size, 0); }
|
|
void vk_mem_gpu_wrote_shader(const VkMemSpan *sp, uint64_t size) { gpu_wrote(sp, size, 1); }
|
|
|
|
// Adds [a, b) to l, n sorted ranges that do not touch; returns the new count. Above cap ranges (l holds cap + 1) the two
|
|
// nearest merge, so a full list copies the fewest bytes that were not flushed; cap 1 keeps one span over all flushes.
|
|
static int flush_add(FlushRange *l, int n, uint64_t a, uint64_t b, int cap) {
|
|
int i = 0;
|
|
while (i < n && l[i].hi < a) i++;
|
|
int j = i;
|
|
for (; j < n && l[j].lo <= b; j++) {
|
|
if (l[j].lo < a) a = l[j].lo;
|
|
if (l[j].hi > b) b = l[j].hi;
|
|
}
|
|
memmove(l + i + 1, l + j, (size_t)(n - j) * sizeof *l);
|
|
l[i] = (FlushRange){a, b};
|
|
n += 1 - (j - i);
|
|
if (n > cap) {
|
|
int k = 0;
|
|
for (int x = 1; x + 1 < n; x++)
|
|
if (l[x + 1].lo - l[x].hi < l[k + 1].lo - l[k].hi) k = x;
|
|
l[k].hi = l[k + 1].hi;
|
|
memmove(l + k + 1, l + k + 2, (size_t)(n - k - 2) * sizeof *l);
|
|
n--;
|
|
}
|
|
return n;
|
|
}
|
|
|
|
int vk_mem_flush_add_probe(void) {
|
|
FlushRange l[5];
|
|
int n = 0;
|
|
n = flush_add(l, n, 0x8000, 0x9000, 4);
|
|
n = flush_add(l, n, 0x1000, 0x2000, 4);
|
|
n = flush_add(l, n, 0x4000, 0x5000, 4);
|
|
if (n != 3 || l[0].lo != 0x1000 || l[1].lo != 0x4000 || l[2].hi != 0x9000) return 1; // kept apart, sorted
|
|
n = flush_add(l, n, 0x2000, 0x3000, 4);
|
|
if (n != 3 || l[0].hi != 0x3000) return 2; // touching ranges join
|
|
n = flush_add(l, n, 0x800, 0x8800, 4);
|
|
if (n != 1 || l[0].lo != 0x800 || l[0].hi != 0x9000) return 3; // one covers several
|
|
n = 0;
|
|
n = flush_add(l, n, 0x10000, 0x11000, 2);
|
|
n = flush_add(l, n, 0x40000, 0x41000, 2);
|
|
n = flush_add(l, n, 0x12000, 0x13000, 2); // over the cap: nearest merge
|
|
if (n != 2 || l[0].lo != 0x10000 || l[0].hi != 0x13000 || l[1].lo != 0x40000) return 4;
|
|
n = 0;
|
|
n = flush_add(l, n, 0x10000, 0x11000, 1);
|
|
n = flush_add(l, n, 0x40000, 0x41000, 1);
|
|
if (n != 1 || l[0].lo != 0x10000 || l[0].hi != 0x41000) return 5; // TOTK_FLUSH_SPAN=1
|
|
return 0;
|
|
}
|
|
|
|
uint64_t vk_mem_mirror_uploaded(void) {
|
|
plat_mutex_lock(&mirror_stat_lock);
|
|
uint64_t n = macc.upload_bytes;
|
|
plat_mutex_unlock(&mirror_stat_lock);
|
|
return n;
|
|
}
|
|
|
|
void nvn_pool_flushed(const pool_t *p, uint64_t off, uint64_t size) {
|
|
if (!vk_mirror_on || !size || !p->mem) return;
|
|
plat_inc64(&m_flush_calls);
|
|
plat_add64(&m_flush_bytes, (int64_t)size);
|
|
plat_mutex_lock_shared(&pool_lock);
|
|
const pidx_ent *e = pidx_find(&pool_index, (uintptr_t)p->mem, 0);
|
|
PoolRec *r = e && e->key == p ? e->buf : NULL;
|
|
if (r && r->mstate == MS_ON) {
|
|
uint64_t a = ((uintptr_t)p->mem + off) & ~0xfffull, b = ((uintptr_t)p->mem + off + size + 0xfff) & ~0xfffull;
|
|
plat_mutex_lock(&flush_lock);
|
|
__atomic_store_n(&r->fl_n, flush_add(r->fl, r->fl_n, a, b, flush_cap), __ATOMIC_RELEASE);
|
|
r->fl_calls++, r->fl_sum += size;
|
|
plat_mutex_unlock(&flush_lock);
|
|
}
|
|
plat_mutex_unlock_shared(&pool_lock);
|
|
}
|
|
|
|
// gen_lock exclusive: freezes up to FREEZE_PER_SUB candidate regions still idle. The query after the
|
|
// protection collects the writes made before it took effect.
|
|
static void freeze_pass(void) {
|
|
uint64_t gpu_done = vk_sync_fence_done();
|
|
for (int done = 0; nfcand && done < FREEZE_PER_SUB;) {
|
|
uint64_t j = fcand[--nfcand], a = j << REG_SHIFT;
|
|
if (ww_ep - reg_last[j] < (uint32_t)freeze_idle || mem_frozen(a)) continue;
|
|
if (__atomic_load_n(®_gpu[j], __ATOMIC_RELAXED) > gpu_done) { plat_inc64(&n_frz_gpu); continue; } // gpu_writes
|
|
done++;
|
|
if (!mem_freeze(a)) { n_frz_refused++; continue; }
|
|
query_stamp(a, a + (1ull << REG_SHIFT), ++gen_now);
|
|
}
|
|
}
|
|
|
|
void vk_mem_mirror_begin(void) {
|
|
if (!vk_mirror_on) { // import mode: the texture checks' snapshots still skip frozen regions
|
|
if (!freeze_idle) return;
|
|
plat_mutex_lock(&gen_lock);
|
|
ww_ep++;
|
|
freeze_pass();
|
|
plat_mutex_unlock(&gen_lock);
|
|
return;
|
|
}
|
|
uint64_t t0 = qpc();
|
|
nsnl = snl_copied = nchunks = 0; // the previous submission handed its chunks to snap_free
|
|
uint32_t prev = msub;
|
|
msub = msub + 1 ? msub + 1 : 1;
|
|
__atomic_add_fetch(&mirror_gen, 1, __ATOMIC_RELEASE);
|
|
mepoch = vk_texture_epoch();
|
|
uint64_t q = 0, run0 = 0, run1 = 0;
|
|
int have = 0;
|
|
plat_mutex_lock_shared(&pool_lock);
|
|
plat_mutex_lock(&gen_lock);
|
|
ww_ep++;
|
|
uint64_t b0 = ww_bytes;
|
|
for (int i = 0; i < nm; i++) {
|
|
const PoolRec *r = mlist[i];
|
|
if (r->acc_sub != prev) continue;
|
|
uint64_t a = r->import_lo >> SNAP_SHIFT, b = (r->import_hi - 1) >> SNAP_SHIFT;
|
|
if (have && a <= run1 + 1 + (uint64_t)mirror_gap) {
|
|
if (b > run1) run1 = b;
|
|
continue;
|
|
}
|
|
if (have) q += snap_regions(run0, run1, mepoch);
|
|
run0 = a, run1 = b, have = 1;
|
|
}
|
|
if (have) q += snap_regions(run0, run1, mepoch);
|
|
mrep.presnap_bytes += ww_bytes - b0;
|
|
if (freeze_idle) freeze_pass();
|
|
plat_mutex_unlock(&gen_lock);
|
|
plat_mutex_unlock_shared(&pool_lock);
|
|
mrep.subs++;
|
|
mrep.presnap_queries += q;
|
|
mrep.presnap_t += qpc() - t0;
|
|
}
|
|
|
|
int vk_mem_mirror_pending(void) {
|
|
plat_mutex_lock(&mirror_lock);
|
|
int n = npend || snl_copied < nsnl;
|
|
plat_mutex_unlock(&mirror_lock);
|
|
return n;
|
|
}
|
|
|
|
void vk_mem_mirror_prologue(VkCommandBuffer cb) {
|
|
plat_mutex_lock(&mirror_lock);
|
|
if (!npend && snl_copied == nsnl) {
|
|
plat_mutex_unlock(&mirror_lock);
|
|
return;
|
|
}
|
|
mbarrier(cb, VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT, VK_ACCESS_2_MEMORY_WRITE_BIT, VK_PIPELINE_STAGE_2_COPY_BIT,
|
|
VK_ACCESS_2_TRANSFER_READ_BIT | VK_ACCESS_2_TRANSFER_WRITE_BIT);
|
|
copy_ranges(cb, pend, npend, 1, &mrep.upload_bytes, &mrep.upload_copies);
|
|
if (snl_copied < nsnl) { // the snapshots read what the uploads above wrote
|
|
if (npend) mbarrier(cb, VK_PIPELINE_STAGE_2_COPY_BIT, VK_ACCESS_2_TRANSFER_WRITE_BIT, VK_PIPELINE_STAGE_2_COPY_BIT,
|
|
VK_ACCESS_2_TRANSFER_READ_BIT);
|
|
snap_copies(cb);
|
|
}
|
|
mbarrier(cb, VK_PIPELINE_STAGE_2_COPY_BIT, VK_ACCESS_2_TRANSFER_WRITE_BIT, VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT,
|
|
VK_ACCESS_2_MEMORY_READ_BIT | VK_ACCESS_2_MEMORY_WRITE_BIT);
|
|
npend = 0;
|
|
mrep.prologues++;
|
|
plat_mutex_unlock(&mirror_lock);
|
|
}
|
|
|
|
// ---- verify mode
|
|
typedef struct { PoolRec *r; uint64_t off, len, dst; uint32_t mgen; } VSample;
|
|
typedef struct { uint32_t sub; int n; VkBuffer buf; VmaAllocation alloc; const uint8_t *map; VSample s[VERIFY_SAMPLES]; } VJob;
|
|
static int ver_i;
|
|
static uint64_t ver_off;
|
|
|
|
// The CPU wrote the page since the mirror's sync that mgen records: stamped later, or its write-watch bit is set.
|
|
static int page_dirty(uint64_t a, uint32_t mgen) {
|
|
uint32_t *s = gen_slot(a, 0);
|
|
if (s && __atomic_load_n(s, __ATOMIC_RELAXED) > mgen) return 1;
|
|
uint64_t p[1];
|
|
return mem_written_peek(a, 1ull << GEN_PAGE_SHIFT, p, 1) != 0; // UINT64_MAX (not watched) counts as written
|
|
}
|
|
|
|
static void verify_cb(void *arg) { // completion thread
|
|
VJob *j = arg;
|
|
static volatile int32_t logged;
|
|
uint64_t pages = 0, skipped = 0, bad = 0;
|
|
for (int i = 0; i < j->n; i++) {
|
|
const VSample *s = &j->s[i];
|
|
PoolRec *r = s->r;
|
|
uint32_t wb = __atomic_load_n(&r->wb_sub, __ATOMIC_RELAXED);
|
|
if (wb != j->sub && wb - j->sub < 0x80000000u) { // a later submission wrote back into it
|
|
skipped += s->len >> GEN_PAGE_SHIFT;
|
|
continue;
|
|
}
|
|
for (uint64_t o = 0; o < s->len; o += 1ull << GEN_PAGE_SHIFT) {
|
|
uint64_t a = r->import_lo + s->off + o;
|
|
const uint8_t *m = j->map + s->dst + o, *g = (const uint8_t *)(uintptr_t)a;
|
|
pages++;
|
|
if (!memcmp(m, g, 1ull << GEN_PAGE_SHIFT)) continue;
|
|
if (page_dirty(a, s->mgen)) { skipped++; continue; }
|
|
bad++;
|
|
if (plat_inc32(&logged) <= VERIFY_LOG) {
|
|
int k = 0, e = (1 << GEN_PAGE_SHIFT) - 1;
|
|
while (k < e && m[k] == g[k]) k++;
|
|
while (e > k && m[e] == g[e]) e--;
|
|
char f[160];
|
|
flag_names(r->flags, f, sizeof f);
|
|
rt_log("mem: verify mismatch: pool %#llx+%#llx (%s) offset %#llx: bytes %#x..%#x of the page differ, "
|
|
"mirror %02x guest %02x at the first (submission %u)", (unsigned long long)r->lo,
|
|
(unsigned long long)r->size, f, (unsigned long long)(a - r->lo), k, e, m[k], g[k], j->sub);
|
|
}
|
|
}
|
|
}
|
|
plat_add64(&v_pages, (int64_t)pages);
|
|
plat_add64(&v_skipped, (int64_t)skipped);
|
|
plat_add64(&v_mismatch, (int64_t)bad);
|
|
vmaDestroyBuffer(vk_ctx()->vma, j->buf, j->alloc);
|
|
free(j);
|
|
}
|
|
|
|
static int verify_record(VkCommandBuffer cb, uint64_t fence) {
|
|
VJob *j = calloc(1, sizeof *j);
|
|
if (!j) rt_fatal("vk: out of memory");
|
|
uint64_t left = VERIFY_BYTES, dst = 0;
|
|
plat_mutex_lock_shared(&pool_lock);
|
|
for (int tries = 0; nm && left && j->n < VERIFY_SAMPLES && tries <= nm; tries++) {
|
|
if (ver_i >= nm) ver_i = 0, ver_off = 0;
|
|
PoolRec *r = mlist[ver_i];
|
|
uint64_t size = r->import_hi - r->import_lo;
|
|
if (ver_off >= size) { ver_i++, ver_off = 0; continue; }
|
|
uint64_t len = size - ver_off < left ? size - ver_off : left;
|
|
j->s[j->n++] = (VSample){r, ver_off, len, dst, r->mgen};
|
|
dst += len, left -= len, ver_off += len;
|
|
if (ver_off >= size) ver_i++, ver_off = 0;
|
|
}
|
|
plat_mutex_unlock_shared(&pool_lock);
|
|
if (!j->n) { free(j); return 0; }
|
|
VkBufferCreateInfo bi = {.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO, .size = dst,
|
|
.usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT};
|
|
VmaAllocationCreateInfo ai = {.usage = VMA_MEMORY_USAGE_AUTO_PREFER_HOST,
|
|
.flags = VMA_ALLOCATION_CREATE_HOST_ACCESS_RANDOM_BIT | VMA_ALLOCATION_CREATE_MAPPED_BIT};
|
|
VmaAllocationInfo info;
|
|
if (vmaCreateBuffer(vk_ctx()->vma, &bi, &ai, &j->buf, &j->alloc, &info) != VK_SUCCESS) { free(j); return 0; }
|
|
j->map = info.pMappedData;
|
|
j->sub = msub;
|
|
mbarrier(cb, VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT, VK_ACCESS_2_MEMORY_WRITE_BIT, VK_PIPELINE_STAGE_2_COPY_BIT,
|
|
VK_ACCESS_2_TRANSFER_READ_BIT);
|
|
for (int i = 0; i < j->n; i++) {
|
|
VkBufferCopy c = {j->s[i].off, j->s[i].dst, j->s[i].len};
|
|
vkCmdCopyBuffer(cb, j->s[i].r->mbuf, j->buf, 1, &c);
|
|
}
|
|
mbarrier(cb, VK_PIPELINE_STAGE_2_COPY_BIT, VK_ACCESS_2_TRANSFER_WRITE_BIT, VK_PIPELINE_STAGE_2_HOST_BIT,
|
|
VK_ACCESS_2_HOST_READ_BIT);
|
|
vk_completion_add(fence, verify_cb, j);
|
|
return j->n + 2;
|
|
}
|
|
|
|
int vk_mem_mirror_end(VkCommandBuffer cb, uint64_t fence) {
|
|
if (!vk_mirror_on) return 0;
|
|
int rec = 0;
|
|
if (nsnl) {
|
|
// the merge reads the mirror the submission's shaders wrote and writes the imported buffers
|
|
mbarrier(cb, VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT, VK_ACCESS_2_MEMORY_WRITE_BIT,
|
|
VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT, VK_ACCESS_2_SHADER_STORAGE_READ_BIT);
|
|
int m = wb_merge_record(cb);
|
|
if (!m) { // no range list: copy them plainly
|
|
for (int i = 0; i < nsnl; i++) range_add(&wbl, &nwbl, &cwbl, snl[i].r, snl[i].off, snl[i].size);
|
|
}
|
|
rec += m + 1;
|
|
mbarrier(cb, VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT, VK_ACCESS_2_SHADER_STORAGE_WRITE_BIT,
|
|
VK_PIPELINE_STAGE_2_HOST_BIT | VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT,
|
|
VK_ACCESS_2_HOST_READ_BIT | VK_ACCESS_2_MEMORY_READ_BIT | VK_ACCESS_2_MEMORY_WRITE_BIT);
|
|
}
|
|
if (nchunks) { // the last part's prologue still records snapshot copies into them; back to the pool at the fence
|
|
SnapFree *f = malloc(sizeof *f + sizeof(SnapChunk) * (size_t)nchunks);
|
|
if (!f) rt_fatal("vk: out of memory");
|
|
f->n = nchunks;
|
|
memcpy(f->c, chunks, sizeof(SnapChunk) * (size_t)nchunks);
|
|
vk_completion_add(fence, snap_free, f);
|
|
}
|
|
if (nwbl) {
|
|
mbarrier(cb, VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT, VK_ACCESS_2_MEMORY_WRITE_BIT, VK_PIPELINE_STAGE_2_COPY_BIT,
|
|
VK_ACCESS_2_TRANSFER_READ_BIT);
|
|
for (int i = 0; i < nwbl; i++) wbl[i].r->wb_sub = msub;
|
|
mrep.wb_ranges += (uint64_t)nwbl;
|
|
rec += copy_ranges(cb, wbl, nwbl, 0, &mrep.wb_bytes, &mrep.wb_copies) + 2;
|
|
mbarrier(cb, VK_PIPELINE_STAGE_2_COPY_BIT, VK_ACCESS_2_TRANSFER_WRITE_BIT,
|
|
VK_PIPELINE_STAGE_2_HOST_BIT | VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT,
|
|
VK_ACCESS_2_HOST_READ_BIT | VK_ACCESS_2_MEMORY_READ_BIT | VK_ACCESS_2_MEMORY_WRITE_BIT);
|
|
nwbl = 0;
|
|
}
|
|
if (mirror_verify) rec += verify_record(cb, fence);
|
|
plat_mutex_lock(&mirror_stat_lock);
|
|
uint64_t *d = (uint64_t *)&macc, *s = (uint64_t *)&mrep;
|
|
for (size_t i = 0; i < sizeof macc / sizeof(uint64_t); i++) d[i] += s[i];
|
|
plat_mutex_unlock(&mirror_stat_lock);
|
|
memset(&mrep, 0, sizeof mrep);
|
|
return rec;
|
|
}
|
|
|
|
// One summary line: with TOTK_TRACE=perf every 60 frames, else at frames 600 and 1800.
|
|
static void mirror_log(uint64_t frame) {
|
|
static uint64_t last, ww_last;
|
|
plat_mutex_lock(&gen_lock);
|
|
uint64_t wc = ww_calls, wb = ww_bytes, wt = ww_ticks, dc = ww_dirty_calls, dt = ww_dirty_ticks, wp = ww_pages;
|
|
ww_pages = 0;
|
|
uint64_t nr = n_frz_refused;
|
|
ww_calls = ww_bytes = ww_ticks = ww_dirty_calls = ww_dirty_ticks = n_frz_refused = 0;
|
|
plat_mutex_unlock(&gen_lock);
|
|
double wf = frame > ww_last ? (double)(frame - ww_last) : 1.0;
|
|
ww_last = frame;
|
|
uint64_t fz, ff, ft, fo;
|
|
mem_freeze_stats(&fz, &ff, &ft, &fo);
|
|
if (rt_trace & RT_TRACE_PERF)
|
|
rt_log("mem: write-watch frame %llu: per frame, every consumer: %.1f queries over %.1f MiB, %.3f ms (%.1f found "
|
|
"%.0f written pages, %.3f ms); frozen %.0f MiB; since the last line: freezes %llu, thaws by a write %llu, by a file read "
|
|
"or mapping change %llu, refused %llu",
|
|
(unsigned long long)frame, wc / wf, wb / wf / 1048576.0, qpc_ms(wt) / wf, dc / wf, wp / wf, qpc_ms(dt) / wf,
|
|
fz * 2.0, (unsigned long long)ff, (unsigned long long)ft, (unsigned long long)fo, (unsigned long long)nr);
|
|
int64_t kg = plat_xchg64(&n_frz_gpu, 0), tg = plat_xchg64(&n_thaw_gpu, 0);
|
|
if (kg || tg) // gpu_writes: before this, every count here was a region frozen under a GPU write
|
|
rt_log("mem: frame %llu: regions the GPU writes, since the last line: %lld kept from freezing, %lld frozen ones "
|
|
"thawed", (unsigned long long)frame, (long long)kg, (long long)tg);
|
|
if (!vk_mirror_on) return;
|
|
plat_mutex_lock(&mirror_stat_lock);
|
|
MirrorAcc a = macc;
|
|
memset(&macc, 0, sizeof macc);
|
|
plat_mutex_unlock(&mirror_stat_lock);
|
|
double f = frame > last ? (double)(frame - last) : 1.0, M = 1048576.0;
|
|
last = frame;
|
|
uint64_t fc = (uint64_t)plat_xchg64(&m_flush_calls, 0), fb = (uint64_t)plat_xchg64(&m_flush_bytes, 0);
|
|
char ver[160] = "";
|
|
if (mirror_verify)
|
|
snprintf(ver, sizeof ver, "; verify: pages compared %lld, skipped %lld (written since), mismatches %lld",
|
|
(long long)plat_xchg64(&v_pages, 0), (long long)plat_xchg64(&v_skipped, 0),
|
|
(long long)plat_xchg64(&v_mismatch, 0));
|
|
rt_log("mem: mirror frame %llu: pools %lld, %.1f MiB device-local; stay imported: budget %lld (%.1f MiB), untracked "
|
|
"%lld, overlapping %lld, failed %lld; per frame: submits %.2f, syncs %.1f, upload %.3f MiB in %.1f ranges, "
|
|
"first use %.2f pools %.3f MiB, write-back %.3f MiB in %.1f ranges, merge %.3f MiB in %.1f dispatches "
|
|
"(snapshots %.3f MiB, new buffers %.2f), flush calls %.1f (%.3f MiB), queries "
|
|
"%.1f pre-snap (%.1f MiB) + %.1f sync, prologues %.2f, tracking cpu %.3f ms (pre-snap %.3f, syncs %.3f)%s",
|
|
(unsigned long long)frame, (long long)m_live_n, m_live_bytes / M, (long long)m_refused_n[MR_BUDGET],
|
|
m_refused_bytes[MR_BUDGET] / M, (long long)m_refused_n[MR_UNTRACKED], (long long)m_refused_n[MR_OVERLAP],
|
|
(long long)m_refused_n[MR_ALLOC], a.subs / f, a.syncs / f, a.upload_bytes / f / M, a.upload_copies / f,
|
|
a.first_n / f, a.first_bytes / f / M, a.wb_bytes / f / M, a.wb_copies / f, a.merge_bytes / f / M,
|
|
a.merge_dispatches / f, a.snap_bytes / f / M, a.snap_allocs / f, fc / f, fb / f / M,
|
|
a.presnap_queries / f, a.presnap_bytes / f / M, a.sync_queries / f, a.prologues / f, qpc_ms(a.presnap_t + a.sync_t) / f,
|
|
qpc_ms(a.presnap_t) / f, qpc_ms(a.sync_t) / f, ver);
|
|
rt_log("mem: mirror frame %llu: upload sources per frame, overlaps counted twice: written pages %.3f MiB; flush ranges "
|
|
"%.2f, %.3f MiB, from %.1f flushes of %.3f MiB", (unsigned long long)frame, a.page_bytes / f / M, a.flush_n / f,
|
|
a.flush_bytes / f / M, a.flush_calls / f, a.flush_sum / f / M);
|
|
}
|
|
|
|
// With TOTK_TRACE=perf every 600 frames: what holds device-local memory, to see what grows over a long session.
|
|
// VMA's blocks and allocations on the device-local heaps, texture entries with their own memory, the VRAM mirrors and
|
|
// the objects waiting for their last submission to retire.
|
|
static void vram_log(uint64_t frame) {
|
|
VkCtx *c = vk_ctx();
|
|
if (!c) return;
|
|
VmaBudget vb[VK_MAX_MEMORY_HEAPS];
|
|
vmaGetHeapBudgets(c->vma, vb);
|
|
uint64_t blocks = 0, block_bytes = 0, allocs = 0, alloc_bytes = 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)
|
|
blocks += vb[h].statistics.blockCount, block_bytes += vb[h].statistics.blockBytes,
|
|
allocs += vb[h].statistics.allocationCount, alloc_bytes += vb[h].statistics.allocationBytes;
|
|
VkTexCensus t;
|
|
vk_texture_census(&t);
|
|
const double M = 1048576.0;
|
|
rt_log("mem: vram frame %llu: vma %llu blocks %.0f MiB, %llu allocations %.0f MiB; textures live %llu (own memory %llu, "
|
|
"%.0f MiB), released %llu (own memory %llu, %.0f MiB); mirrors %lld %.0f MiB; releases pending %d",
|
|
(unsigned long long)frame, (unsigned long long)blocks, block_bytes / M, (unsigned long long)allocs,
|
|
alloc_bytes / M, (unsigned long long)t.live, (unsigned long long)t.own_live, t.bytes_live / M,
|
|
(unsigned long long)t.released, (unsigned long long)t.own_released, t.bytes_released / M, (long long)m_live_n,
|
|
m_live_bytes / M, dead_pending());
|
|
vk_texture_released_log(frame);
|
|
}
|
|
|
|
// Device class: INTEGRATED -> uma; a discrete GPU with a DEVICE_LOCAL heap of 1 GiB or more beside a
|
|
// heap that is not device-local (host memory) -> discrete; otherwise uma (import everywhere). Sets the mirror mode.
|
|
void vk_mem_class_log(const VkPhysicalDeviceProperties *p, const VkPhysicalDeviceMemoryProperties *m) {
|
|
int big_local = -1, host_heap = -1;
|
|
for (uint32_t h = 0; h < m->memoryHeapCount; h++) {
|
|
if (m->memoryHeaps[h].flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT) {
|
|
if (m->memoryHeaps[h].size >= (1ull << 30) && big_local < 0) big_local = (int)h;
|
|
} else if (host_heap < 0) host_heap = (int)h;
|
|
}
|
|
static const char *const types[] = {"other", "integrated", "discrete", "virtual", "cpu"};
|
|
int t = (int)p->deviceType;
|
|
const char *tn = t >= 0 && t <= 4 ? types[t] : "unknown";
|
|
const char *cls, *why;
|
|
if (p->deviceType == VK_PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU) cls = "uma", why = "integrated gpu";
|
|
else if (p->deviceType != VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU) cls = "uma", why = "not a discrete gpu";
|
|
else if (big_local < 0) cls = "uma", why = "no device-local heap of 1 GiB or more";
|
|
else if (host_heap < 0) cls = "uma", why = "every heap is device-local";
|
|
else cls = "discrete", why = "device-local heap separate from host memory";
|
|
int discrete = cls[0] == 'd';
|
|
host_backed = !discrete ? HB_UMA : HB_PROBE; // a discrete GPU is measured once the device exists
|
|
const char *rs = cfg_get("TOTK_MEM_RESERVE_MIB");
|
|
room_reserve = (rs && *rs ? strtoull(rs, NULL, 10) : 1536) << 20; // Windows, other programs, our own heap
|
|
char line[2048];
|
|
size_t k = (size_t)snprintf(line, sizeof line, "mem: class %s (%s; device type %d %s, %s)", cls, why, t, tn, p->deviceName);
|
|
for (uint32_t h = 0; h < m->memoryHeapCount; h++)
|
|
k += (size_t)snprintf(line + k, k < sizeof line ? sizeof line - k : 0, "; heap %u %.0f MiB flags %#x%s%s", h,
|
|
m->memoryHeaps[h].size / 1048576.0, m->memoryHeaps[h].flags,
|
|
m->memoryHeaps[h].flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT ? " DEVICE_LOCAL" : "",
|
|
m->memoryHeaps[h].flags & VK_MEMORY_HEAP_MULTI_INSTANCE_BIT ? " MULTI_INSTANCE" : "");
|
|
rt_log("%s", line);
|
|
k = (size_t)snprintf(line, sizeof line, "mem: types");
|
|
for (uint32_t i = 0; i < m->memoryTypeCount; i++) {
|
|
VkMemoryPropertyFlags f = m->memoryTypes[i].propertyFlags;
|
|
k += (size_t)snprintf(line + k, k < sizeof line ? sizeof line - k : 0, "; %u heap %u flags %#x%s%s%s%s%s", i,
|
|
m->memoryTypes[i].heapIndex, f, f & VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT ? " DEVICE_LOCAL" : "",
|
|
f & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT ? " HOST_VISIBLE" : "",
|
|
f & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT ? " HOST_COHERENT" : "",
|
|
f & VK_MEMORY_PROPERTY_HOST_CACHED_BIT ? " HOST_CACHED" : "",
|
|
f & VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT ? " LAZY" : "");
|
|
}
|
|
rt_log("%s", line);
|
|
if (host_backed == HB_UMA)
|
|
rt_log("mem: device memory against system memory: commit and physical memory (uma), reserve %llu MiB "
|
|
"(TOTK_MEM_RESERVE_MIB)", (unsigned long long)(room_reserve >> 20));
|
|
mirror_mode(discrete);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- upload batches and deferred destruction
|
|
typedef struct { VkBuffer buf; VmaAllocation alloc; } Staging;
|
|
typedef struct {
|
|
VkCommandBuffer cb;
|
|
VkFence fence;
|
|
uint64_t seq;
|
|
Staging *stg;
|
|
int nstg, cap;
|
|
VkDeviceSize held; // staging and scratch bytes freed when the batch completes (vk_upload_held)
|
|
} Batch;
|
|
|
|
enum { DEAD_IMAGE = 1, DEAD_IMAGE_VMA, DEAD_VIEW, DEAD_BUFFER_VMA, DEAD_HOST }; // DEAD_HOST: free(handle)
|
|
typedef struct { int kind; uint64_t handle; VmaAllocation alloc; uint64_t seq, tl; } Dead;
|
|
|
|
static PlatRMutex cmd_lock;
|
|
static PlatOnce cmd_once = PLAT_ONCE_INIT;
|
|
static VkCommandPool cmd_pool;
|
|
static Batch open_b;
|
|
static int open_active;
|
|
static Batch *inflight;
|
|
static int ninflight, cap_inflight;
|
|
static Batch *spare; // completed batches whose command buffer and fence are reused
|
|
static int nspare, cap_spare;
|
|
static uint64_t next_seq, done_seq; // batches handed out / completed (all batches up to done_seq)
|
|
static Dead *dead;
|
|
static int ndead, cap_dead;
|
|
static uint64_t tl_noted; // value of the submission being recorded, else of the last one (vk_mem_note_fence)
|
|
static _Thread_local uint64_t tl_noted_here; // the value this thread noted: it records that submission
|
|
|
|
static void cmd_init(void *p) {
|
|
(void)p;
|
|
return;
|
|
}
|
|
|
|
static void grow(void **arr, int *cap, size_t elem) {
|
|
*cap = *cap ? *cap * 2 : 16;
|
|
void *n = realloc(*arr, (size_t)*cap * elem);
|
|
if (!n) rt_fatal("vk: out of memory (upload bookkeeping)");
|
|
*arr = n;
|
|
}
|
|
|
|
static void free_staging(VkCtx *c, Batch *b) {
|
|
for (int i = 0; i < b->nstg; i++) vmaDestroyBuffer(c->vma, b->stg[i].buf, b->stg[i].alloc);
|
|
b->nstg = 0;
|
|
}
|
|
|
|
// Batches complete in submission order (one queue): retire from the front while their fences are signalled.
|
|
static void retire(VkCtx *c, int wait_all) {
|
|
int k = 0;
|
|
for (; k < ninflight; k++) {
|
|
Batch *b = &inflight[k];
|
|
if (wait_all) vkWaitForFences(c->device, 1, &b->fence, VK_TRUE, UINT64_MAX);
|
|
else if (vkGetFenceStatus(c->device, b->fence) != VK_SUCCESS) break;
|
|
free_staging(c, b);
|
|
done_seq = b->seq;
|
|
if (nspare == cap_spare) grow((void **)&spare, &cap_spare, sizeof *spare);
|
|
spare[nspare++] = *b;
|
|
}
|
|
memmove(inflight, inflight + k, (size_t)(ninflight - k) * sizeof *inflight);
|
|
ninflight -= k;
|
|
}
|
|
|
|
static void collect_locked(VkCtx *c) {
|
|
retire(c, 0);
|
|
uint64_t tl = 0;
|
|
if (c->timeline) vkGetSemaphoreCounterValue(c->device, c->timeline, &tl);
|
|
int k = 0;
|
|
for (int i = 0; i < ndead; i++) {
|
|
Dead *d = &dead[i];
|
|
if (d->seq > done_seq || d->tl > tl) { dead[k++] = *d; continue; }
|
|
if (d->kind == DEAD_HOST) {
|
|
free((void *)(uintptr_t)d->handle);
|
|
continue;
|
|
}
|
|
rec_check_handle(d->kind == DEAD_VIEW ? REC_VIEW : d->kind == DEAD_BUFFER_VMA ? REC_BUFFER : REC_IMAGE, d->handle, 1,
|
|
d->tl, tl);
|
|
switch (d->kind) {
|
|
case DEAD_IMAGE: vkDestroyImage(c->device, (VkImage)(uintptr_t)d->handle, NULL); break;
|
|
case DEAD_IMAGE_VMA: vmaDestroyImage(c->vma, (VkImage)(uintptr_t)d->handle, d->alloc); break;
|
|
case DEAD_VIEW: vkDestroyImageView(c->device, (VkImageView)(uintptr_t)d->handle, NULL); break;
|
|
case DEAD_BUFFER_VMA: vmaDestroyBuffer(c->vma, (VkBuffer)(uintptr_t)d->handle, d->alloc); break;
|
|
}
|
|
}
|
|
ndead = k;
|
|
}
|
|
|
|
VkCommandBuffer vk_upload_begin(void) {
|
|
plat_once(&cmd_once, cmd_init, NULL);
|
|
plat_rmutex_lock(&cmd_lock);
|
|
VkCtx *c = vk_ctx();
|
|
if (!c) rt_fatal("vk: upload before vk_init");
|
|
if (open_active) return open_b.cb;
|
|
if (!cmd_pool) {
|
|
VkCommandPoolCreateInfo pi = {.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO,
|
|
.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT,
|
|
.queueFamilyIndex = c->gfx_family};
|
|
if (vkCreateCommandPool(c->device, &pi, NULL, &cmd_pool) != VK_SUCCESS) rt_fatal("vk: upload command pool");
|
|
}
|
|
retire(c, 0);
|
|
memset(&open_b, 0, sizeof open_b);
|
|
if (nspare) {
|
|
Batch s = spare[--nspare];
|
|
open_b.cb = s.cb, open_b.fence = s.fence, open_b.stg = s.stg, open_b.cap = s.cap;
|
|
vkResetCommandBuffer(open_b.cb, 0);
|
|
vkResetFences(c->device, 1, &open_b.fence);
|
|
} else {
|
|
VkCommandBufferAllocateInfo ai = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO, .commandPool = cmd_pool,
|
|
.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY, .commandBufferCount = 1};
|
|
VkFenceCreateInfo fi = {.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO};
|
|
if (vkAllocateCommandBuffers(c->device, &ai, &open_b.cb) != VK_SUCCESS ||
|
|
vkCreateFence(c->device, &fi, NULL, &open_b.fence) != VK_SUCCESS)
|
|
rt_fatal("vk: upload command buffer");
|
|
}
|
|
VkCommandBufferBeginInfo bi = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
|
|
.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT};
|
|
vkBeginCommandBuffer(open_b.cb, &bi);
|
|
open_b.seq = ++next_seq;
|
|
vk_crumb(open_b.cb, VK_CRUMB_UPLOAD, open_b.seq);
|
|
open_active = 1;
|
|
return open_b.cb;
|
|
}
|
|
|
|
void vk_upload_end(void) { plat_rmutex_unlock(&cmd_lock); }
|
|
|
|
// Staging memory comes from a pool of its own that keeps STG_MIN_BLOCKS blocks from its first use (at boot). In the
|
|
// default pools VMA creates a block under the lock of its memory type (VmaBlockVector::Allocate), so a block made by
|
|
// the headroom thread (vk_headroom.c) or a burst of uploads held every staging allocation of the render thread: one
|
|
// took 47-50 ms, for a few bytes, in the first frame after a save load. Larger buffers than a block go to the default
|
|
// pool.
|
|
#define STG_BLOCK (64ull << 20)
|
|
#define STG_MIN_BLOCKS 2
|
|
static VmaPool stg_pool;
|
|
static int stg_pool_tried;
|
|
|
|
typedef struct { const VkBufferCreateInfo *bi; const VmaAllocationCreateInfo *aci; VkBuffer b; VmaAllocation a;
|
|
VmaAllocationInfo info; VkResult r; } StgTry;
|
|
static int staging_try(void *arg) {
|
|
StgTry *t = arg;
|
|
vk_mem_collect(); // upload batches and deferred objects the GPU finished meanwhile
|
|
t->r = rt_oom_forced(RT_OOM_STAGING) ? VK_ERROR_OUT_OF_DEVICE_MEMORY
|
|
: vmaCreateBuffer(vk_ctx()->vma, t->bi, t->aci, &t->b, &t->a, &t->info);
|
|
return t->r == VK_SUCCESS;
|
|
}
|
|
|
|
// A failed staging buffer waits for memory (rt_oom_wait) before it ends the game: the headroom blocks go and the GPU's
|
|
// finished batches are freed at once, released textures at the next submit (vk_oom_release). The caller may hold the
|
|
// texture lock and cmd_lock (vk_upload_begin), both of which vk_oom_release may take after them, and no VMA lock. The open
|
|
// batch is not submitted: its caller records into it after this returns.
|
|
void *vk_staging(VkDeviceSize size, VkBuffer *buf, VmaAllocation *alloc) {
|
|
VkCtx *c = vk_ctx();
|
|
VkBufferCreateInfo bi = {.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO, .size = size ? size : 16,
|
|
.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT};
|
|
VmaAllocationCreateInfo aci = {.usage = VMA_MEMORY_USAGE_AUTO_PREFER_HOST,
|
|
.flags = VMA_ALLOCATION_CREATE_HOST_ACCESS_RANDOM_BIT | VMA_ALLOCATION_CREATE_MAPPED_BIT};
|
|
if (!__atomic_load_n(&stg_pool_tried, __ATOMIC_ACQUIRE)) {
|
|
plat_once(&cmd_once, cmd_init, NULL);
|
|
plat_rmutex_lock(&cmd_lock);
|
|
if (!stg_pool_tried) {
|
|
uint32_t type;
|
|
VmaPoolCreateInfo pi = {.blockSize = STG_BLOCK, .minBlockCount = STG_MIN_BLOCKS};
|
|
if (vmaFindMemoryTypeIndexForBufferInfo(c->vma, &bi, &aci, &type) == VK_SUCCESS) {
|
|
pi.memoryTypeIndex = type;
|
|
if (vmaCreatePool(c->vma, &pi, &stg_pool) != VK_SUCCESS) stg_pool = NULL;
|
|
}
|
|
rt_log("vk: staging pool %s (%d blocks of %llu MiB kept)", stg_pool ? "created" : "unavailable: default pool",
|
|
STG_MIN_BLOCKS, (unsigned long long)(STG_BLOCK >> 20));
|
|
__atomic_store_n(&stg_pool_tried, 1, __ATOMIC_RELEASE);
|
|
}
|
|
plat_rmutex_unlock(&cmd_lock);
|
|
}
|
|
if (stg_pool && bi.size <= STG_BLOCK / 2) aci.pool = stg_pool;
|
|
StgTry st = {.bi = &bi, .aci = &aci};
|
|
st.r = rt_oom_forced(RT_OOM_STAGING) ? VK_ERROR_OUT_OF_DEVICE_MEMORY
|
|
: vmaCreateBuffer(c->vma, &bi, &aci, &st.b, &st.a, &st.info);
|
|
if (st.r != VK_SUCCESS) {
|
|
VkResult first = st.r;
|
|
if (first == VK_ERROR_OUT_OF_HOST_MEMORY || first == VK_ERROR_OUT_OF_DEVICE_MEMORY) {
|
|
vk_oom_release();
|
|
rt_oom_wait("vk: staging buffer", bi.size, staging_try, &st);
|
|
}
|
|
if (st.r != VK_SUCCESS)
|
|
rt_fatal("vk: staging buffer of %llu bytes failed (%d, first %d)", (unsigned long long)size, (int)st.r,
|
|
(int)first);
|
|
}
|
|
VkBuffer b = st.b;
|
|
VmaAllocation a = st.a;
|
|
VmaAllocationInfo info = st.info;
|
|
*buf = b;
|
|
if (alloc) *alloc = a; // the caller destroys it (vmaDestroyBuffer) after vk_upload_flush(1)
|
|
else {
|
|
if (open_b.nstg == open_b.cap) grow((void **)&open_b.stg, &open_b.cap, sizeof *open_b.stg);
|
|
open_b.stg[open_b.nstg++] = (Staging){b, a};
|
|
open_b.held += bi.size;
|
|
}
|
|
return info.pMappedData;
|
|
}
|
|
|
|
typedef struct { VkBuffer b; VmaAllocation a; } StgFree;
|
|
static void free_shadow_stg(void *arg) {
|
|
StgFree *f = arg;
|
|
vmaDestroyBuffer(vk_ctx()->vma, f->b, f->a);
|
|
free(f);
|
|
}
|
|
|
|
// Records the deferred shadow copies (vk_mem_shadow_sync queued the ranges): the guest bytes go into staging buffers
|
|
// now, the GPU copies them into the pools' shadows. The barrier before waits for every earlier command, writes and
|
|
// reads alike (a read of the shadow must be over before the copy overwrites it); the one after makes the copies visible
|
|
// to the rest of the queue, including the mirror copies that follow in this command buffer. The staging buffers are
|
|
// freed when the value completes. Replay thread, sub_lock held.
|
|
void vk_mem_shadow_prologue(VkCommandBuffer cb, uint64_t fence) {
|
|
if (!ndq) return;
|
|
mbarrier(cb, VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT, VK_ACCESS_2_MEMORY_WRITE_BIT | VK_ACCESS_2_MEMORY_READ_BIT,
|
|
VK_PIPELINE_STAGE_2_COPY_BIT, VK_ACCESS_2_TRANSFER_READ_BIT | VK_ACCESS_2_TRANSFER_WRITE_BIT);
|
|
const uint64_t lim = STG_BLOCK / 2;
|
|
uint64_t copies = 0;
|
|
int i = 0;
|
|
uint64_t at_i = 0; // bytes of dq[i] already recorded
|
|
while (i < ndq) {
|
|
uint64_t total = 0, jo = at_i; // how much the next staging buffer holds
|
|
for (int j = i; j < ndq && total < lim;) {
|
|
uint64_t left = dq[j].len - jo, take = left < lim - total ? left : lim - total;
|
|
total += take;
|
|
if (take == left) j++, jo = 0;
|
|
else jo += take;
|
|
}
|
|
VkBuffer sb;
|
|
VmaAllocation sa;
|
|
uint8_t *p = vk_staging(total, &sb, &sa);
|
|
for (uint64_t at = 0; at < total;) {
|
|
const DRange *d = &dq[i];
|
|
uint64_t left = d->len - at_i, take = left < total - at ? left : total - at;
|
|
memcpy(p + at, (const void *)(uintptr_t)(d->addr + at_i), take);
|
|
VkBufferCopy bc = {at, d->addr + at_i - d->r->import_lo, take};
|
|
vkCmdCopyBuffer(cb, sb, d->r->buf, 1, &bc);
|
|
copies++;
|
|
at += take, at_i += take;
|
|
if (at_i == d->len) i++, at_i = 0;
|
|
}
|
|
vmaFlushAllocation(vk_ctx()->vma, sa, 0, VK_WHOLE_SIZE);
|
|
StgFree *f = malloc(sizeof *f);
|
|
if (!f) rt_fatal("vk: out of memory");
|
|
f->b = sb, f->a = sa;
|
|
vk_completion_add(fence, free_shadow_stg, f);
|
|
}
|
|
mbarrier(cb, VK_PIPELINE_STAGE_2_COPY_BIT, VK_ACCESS_2_TRANSFER_WRITE_BIT, VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT,
|
|
VK_ACCESS_2_MEMORY_READ_BIT | VK_ACCESS_2_MEMORY_WRITE_BIT);
|
|
ndq = 0;
|
|
plat_mutex_lock(&shadow_lock);
|
|
s_defer_copies += copies;
|
|
plat_mutex_unlock(&shadow_lock);
|
|
}
|
|
|
|
// Device-local scratch of the upload batch (the GPU deswizzle's linear copies, the ASTC decoder's blocks and texels):
|
|
// a pool of its own, like staging, so the replay thread neither waits on the default pool's lock while another thread
|
|
// makes a block there (the headroom thread: about 8 ms for 256 MiB) nor makes one itself for a few MiB. One block
|
|
// of SCR_BLOCK is kept from the first use; larger buffers than half a block go to the default pool.
|
|
#define SCR_BLOCK (64ull << 20)
|
|
static VmaPool scr_pool;
|
|
static int scr_pool_tried;
|
|
|
|
VkResult vk_scratch_buffer(VkDeviceSize size, VkBuffer *buf, VmaAllocation *alloc) {
|
|
VkCtx *c = vk_ctx();
|
|
VkBufferCreateInfo bi = {.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO, .size = size ? size : 16,
|
|
.usage = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_SRC_BIT |
|
|
VK_BUFFER_USAGE_TRANSFER_DST_BIT};
|
|
VmaAllocationCreateInfo aci = {.usage = VMA_MEMORY_USAGE_AUTO_PREFER_DEVICE};
|
|
if (!__atomic_load_n(&scr_pool_tried, __ATOMIC_ACQUIRE)) {
|
|
plat_once(&cmd_once, cmd_init, NULL);
|
|
plat_rmutex_lock(&cmd_lock);
|
|
if (!scr_pool_tried) {
|
|
// two blocks with 8 GiB or more of device-local memory: fight2's first world frame deswizzles about
|
|
// 280 MiB, more than the GPU finishes while the replay records, and the pool grew by four blocks there
|
|
VkDeviceSize vram = 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 && c->mem_props.memoryHeaps[h].size > vram)
|
|
vram = c->mem_props.memoryHeaps[h].size;
|
|
uint32_t type;
|
|
VmaPoolCreateInfo pi = {.blockSize = SCR_BLOCK, .minBlockCount = vram >= (8ull << 30) ? 2 : 1};
|
|
if (vmaFindMemoryTypeIndexForBufferInfo(c->vma, &bi, &aci, &type) == VK_SUCCESS) {
|
|
pi.memoryTypeIndex = type;
|
|
if (vmaCreatePool(c->vma, &pi, &scr_pool) != VK_SUCCESS) scr_pool = NULL;
|
|
}
|
|
rt_log("vk: upload scratch pool %s (%u blocks of %llu MiB kept)", scr_pool ? "created" : "unavailable: default pool",
|
|
(unsigned)pi.minBlockCount, (unsigned long long)(SCR_BLOCK >> 20));
|
|
__atomic_store_n(&scr_pool_tried, 1, __ATOMIC_RELEASE);
|
|
}
|
|
plat_rmutex_unlock(&cmd_lock);
|
|
}
|
|
if (scr_pool && bi.size <= SCR_BLOCK / 2) aci.pool = scr_pool;
|
|
VkResult r = vmaCreateBuffer(c->vma, &bi, &aci, buf, alloc, NULL);
|
|
if (r == VK_SUCCESS && open_active) open_b.held += bi.size; // the caller defers its free to the open batch
|
|
return r;
|
|
}
|
|
|
|
VkDeviceSize vk_upload_held(void) { return open_active ? open_b.held : 0; }
|
|
|
|
// Own memory of the game's textures (vk_texture.c create_tex): a pool of its own with blocks of TOTK_TEX_BLOCK_MIB
|
|
// (default 64; 0 puts textures back in the default pools). In the default pools textures shared 256 MiB blocks with the
|
|
// mirrors and other buffers, and a block went back to the driver only once all of them were gone: on the RX 9070,
|
|
// after 4.5 minutes of roaming the field, 3 of the 9 device-local blocks were 13-27% full, held by 87-223 small
|
|
// textures and buffers each, and the full ones had hundreds of free gaps under 1 MiB, so blocks held 800 MiB more than
|
|
// their allocations. Where device memory takes process commit (vk_mem_host_probe) that commit backs nothing. A smaller
|
|
// block, holding textures only, empties sooner and wastes less when it does not. With the pool the same 10-minute route
|
|
// ended with 807 MiB of blocks beyond their allocations instead of 1037, the texture pool's 22 blocks mostly 75-100%
|
|
// full, and the slow frames did not grow.
|
|
// An image of a quarter block or more gets memory of its own (a dedicated allocation) that goes back to the driver
|
|
// with the image; so do images the driver prefers that for, and images the pool's memory type does not take. VMA
|
|
// checks neither for a custom pool (VmaAllocator_T::AllocateMemory takes the pool's type as it is), so the image's
|
|
// requirements are read here first.
|
|
static VmaPool tex_pool;
|
|
static uint32_t tex_type;
|
|
static VkDeviceSize tex_block;
|
|
static int tex_pool_tried;
|
|
|
|
static void tex_pool_init(VkCtx *c, const VkImageCreateInfo *ci) {
|
|
plat_once(&cmd_once, cmd_init, NULL);
|
|
plat_rmutex_lock(&cmd_lock);
|
|
if (!tex_pool_tried) {
|
|
tex_block = (VkDeviceSize)cfg_int("TOTK_TEX_BLOCK_MIB") << 20; // schema default 64
|
|
VmaAllocationCreateInfo aci = {.usage = VMA_MEMORY_USAGE_AUTO_PREFER_DEVICE};
|
|
VmaPoolCreateInfo pi = {.blockSize = tex_block};
|
|
if (tex_block && vmaFindMemoryTypeIndexForImageInfo(c->vma, ci, &aci, &tex_type) == VK_SUCCESS) {
|
|
pi.memoryTypeIndex = tex_type;
|
|
if (vmaCreatePool(c->vma, &pi, &tex_pool) != VK_SUCCESS) tex_pool = NULL;
|
|
}
|
|
if (tex_pool) {
|
|
vk_headroom_pool(tex_pool, tex_type, tex_block);
|
|
rt_log("vk: texture pool: blocks of %llu MiB in memory type %u; images of %llu MiB or more get memory of "
|
|
"their own (TOTK_TEX_BLOCK_MIB)", (unsigned long long)(tex_block >> 20), tex_type,
|
|
(unsigned long long)(tex_block >> 22));
|
|
} else {
|
|
rt_log("vk: texture pool %s: textures go to the default pools (TOTK_TEX_BLOCK_MIB)",
|
|
tex_block ? "unavailable" : "off");
|
|
}
|
|
__atomic_store_n(&tex_pool_tried, 1, __ATOMIC_RELEASE);
|
|
}
|
|
plat_rmutex_unlock(&cmd_lock);
|
|
}
|
|
|
|
VkResult vk_texture_image(const VkImageCreateInfo *ci, VkImage *img, VmaAllocation *alloc) {
|
|
VkCtx *c = vk_ctx();
|
|
VmaAllocationCreateInfo ai = {.usage = VMA_MEMORY_USAGE_AUTO_PREFER_DEVICE};
|
|
if (!__atomic_load_n(&tex_pool_tried, __ATOMIC_ACQUIRE)) tex_pool_init(c, ci);
|
|
if (tex_pool) {
|
|
VkDeviceImageMemoryRequirements q = {.sType = VK_STRUCTURE_TYPE_DEVICE_IMAGE_MEMORY_REQUIREMENTS, .pCreateInfo = ci};
|
|
VkMemoryDedicatedRequirements dr = {.sType = VK_STRUCTURE_TYPE_MEMORY_DEDICATED_REQUIREMENTS};
|
|
VkMemoryRequirements2 mr = {.sType = VK_STRUCTURE_TYPE_MEMORY_REQUIREMENTS_2, .pNext = &dr};
|
|
vkGetDeviceImageMemoryRequirements(c->device, &q, &mr);
|
|
if (dr.requiresDedicatedAllocation || dr.prefersDedicatedAllocation || mr.memoryRequirements.size >= tex_block / 4)
|
|
ai.flags = VMA_ALLOCATION_CREATE_DEDICATED_MEMORY_BIT;
|
|
else if (mr.memoryRequirements.memoryTypeBits >> tex_type & 1)
|
|
ai = (VmaAllocationCreateInfo){.usage = VMA_MEMORY_USAGE_UNKNOWN, .pool = tex_pool}; // the pool's type
|
|
}
|
|
return vmaCreateImage(c->vma, ci, &ai, img, alloc, NULL);
|
|
}
|
|
|
|
// At device creation: the staging and scratch pools with their kept blocks, made through a first small buffer each
|
|
// (freed at once), so the first world frame does not allocate them (staging was first used there once the GPU took
|
|
// the early texture uploads).
|
|
void vk_upload_pools_init(void) {
|
|
VkCtx *c = vk_ctx();
|
|
if (!c) return;
|
|
VkBuffer b;
|
|
VmaAllocation a;
|
|
vk_staging(16, &b, &a);
|
|
vmaDestroyBuffer(c->vma, b, a);
|
|
if (vk_scratch_buffer(16, &b, &a) == VK_SUCCESS) vmaDestroyBuffer(c->vma, b, a);
|
|
}
|
|
|
|
uint64_t vk_upload_flush(int wait) {
|
|
plat_once(&cmd_once, cmd_init, NULL);
|
|
plat_rmutex_lock(&cmd_lock);
|
|
VkCtx *c = vk_ctx();
|
|
uint64_t seq = next_seq;
|
|
if (c && open_active) {
|
|
for (int i = 0; i < open_b.nstg; i++) vmaFlushAllocation(c->vma, open_b.stg[i].alloc, 0, VK_WHOLE_SIZE);
|
|
vk_crumb(open_b.cb, VK_CRUMB_UPLOAD_END, open_b.seq);
|
|
vkEndCommandBuffer(open_b.cb);
|
|
VkSubmitInfo si = {.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO, .commandBufferCount = 1, .pCommandBuffers = &open_b.cb};
|
|
vk_queue_lock();
|
|
VkResult r = vkQueueSubmit(c->gfx_queue, 1, &si, open_b.fence);
|
|
vk_queue_unlock();
|
|
if (r != VK_SUCCESS) {
|
|
vk_device_fault_report(r);
|
|
rt_fatal("vk: upload submit failed (%d)", (int)r);
|
|
}
|
|
if (ninflight == cap_inflight) grow((void **)&inflight, &cap_inflight, sizeof *inflight);
|
|
inflight[ninflight++] = open_b;
|
|
open_active = 0;
|
|
}
|
|
if (c && wait) {
|
|
while (ninflight && done_seq < seq) {
|
|
vkWaitForFences(c->device, 1, &inflight[0].fence, VK_TRUE, UINT64_MAX);
|
|
retire(c, 0);
|
|
}
|
|
}
|
|
if (c) collect_locked(c);
|
|
plat_rmutex_unlock(&cmd_lock);
|
|
return seq;
|
|
}
|
|
|
|
static void defer(int kind, uint64_t handle, VmaAllocation alloc) {
|
|
plat_once(&cmd_once, cmd_init, NULL);
|
|
plat_rmutex_lock(&cmd_lock);
|
|
if (ndead == cap_dead) grow((void **)&dead, &cap_dead, sizeof *dead);
|
|
// The open batch may record into the object: it counts as the newest user. The GPU must also be past every
|
|
// submission that names it, including the one being recorded, which may name it and is not submitted yet:
|
|
// vk_texture.c slot_resolve hands a draw a released entry, and entry_of, sweep_released and the VRAM pressure drop
|
|
// destroy released entries. tl_noted is that submission's value from the start of its recording (vk_sync.c
|
|
// vk_sync_submit), whichever thread destroys the object. With the synchronous replay a game thread finalizing a
|
|
// texture (vk_backend.c vk_be_texture_release) destroys entries while another game thread records; stamped with
|
|
// the last submitted value, as the recording thread's own fence was the only one seen, the object was freed once
|
|
// the GPU reached that value, before the submission naming it ran: "write invalid" device faults on NVIDIA. LAS
|
|
// Recomp and Savage Wind Recomp stamp every deferral with the submission being recorded (runtime/nvn/vulkan/
|
|
// textures.c defer: vk_pending_seq).
|
|
uint64_t tl = tl_noted;
|
|
if (kind != DEAD_HOST && tl > vk_sync_issued() && tl_noted_here != tl) n_cross_defer++, cross_defer_tl = tl;
|
|
dead[ndead++] = (Dead){kind, handle, alloc, open_active ? open_b.seq : next_seq, tl};
|
|
if (kind != DEAD_HOST)
|
|
rec_check_handle(kind == DEAD_VIEW ? REC_VIEW : kind == DEAD_BUFFER_VMA ? REC_BUFFER : REC_IMAGE, handle, 0, tl,
|
|
vk_sync_fence_done());
|
|
plat_rmutex_unlock(&cmd_lock);
|
|
}
|
|
|
|
static int dead_pending(void) {
|
|
plat_once(&cmd_once, cmd_init, NULL);
|
|
plat_rmutex_lock(&cmd_lock);
|
|
int n = ndead;
|
|
plat_rmutex_unlock(&cmd_lock);
|
|
return n;
|
|
}
|
|
|
|
void vk_defer_image(VkImage img, VmaAllocation alloc) {
|
|
if (img) defer(alloc ? DEAD_IMAGE_VMA : DEAD_IMAGE, (uint64_t)(uintptr_t)img, alloc);
|
|
}
|
|
void vk_defer_view(VkImageView view) { if (view) defer(DEAD_VIEW, (uint64_t)(uintptr_t)view, NULL); }
|
|
void vk_defer_buffer(VkBuffer buf, VmaAllocation alloc) {
|
|
if (buf) defer(DEAD_BUFFER_VMA, (uint64_t)(uintptr_t)buf, alloc);
|
|
}
|
|
void vk_defer_free(void *p) { if (p) defer(DEAD_HOST, (uint64_t)(uintptr_t)p, NULL); }
|
|
|
|
// The device fault report: no lock, since a thread that holds cmd_lock may be waiting for the report's own lock
|
|
// (vk_upload_flush reports a failed submit with cmd_lock held).
|
|
void vk_mem_fault_log(void) {
|
|
rt_log("vk: deferred frees at the fault: %d pending, stamp %llu, submitted %llu, done %llu; %llu deferred off the "
|
|
"recording thread during a recording (newest stamp %llu)",
|
|
__atomic_load_n(&ndead, __ATOMIC_RELAXED), (unsigned long long)__atomic_load_n(&tl_noted, __ATOMIC_RELAXED),
|
|
(unsigned long long)vk_sync_issued(), (unsigned long long)vk_sync_fence_done(),
|
|
(unsigned long long)__atomic_load_n(&n_cross_defer, __ATOMIC_RELAXED),
|
|
(unsigned long long)__atomic_load_n(&cross_defer_tl, __ATOMIC_RELAXED));
|
|
}
|
|
|
|
void vk_mem_note_fence(uint64_t timeline_value) {
|
|
plat_once(&cmd_once, cmd_init, NULL);
|
|
plat_rmutex_lock(&cmd_lock);
|
|
if (timeline_value > tl_noted) tl_noted = timeline_value;
|
|
tl_noted_here = timeline_value;
|
|
plat_rmutex_unlock(&cmd_lock);
|
|
}
|
|
|
|
// The later of the submission being recorded (tl_noted, noted from its start: vk_sync.c vk_sync_submit) and the next
|
|
// one to be submitted.
|
|
static uint64_t retire_value(void) {
|
|
plat_once(&cmd_once, cmd_init, NULL);
|
|
plat_rmutex_lock(&cmd_lock);
|
|
uint64_t v = vk_sync_issued() + 1;
|
|
if (tl_noted > v) v = tl_noted;
|
|
plat_rmutex_unlock(&cmd_lock);
|
|
return v;
|
|
}
|
|
|
|
void vk_mem_collect(void) {
|
|
VkCtx *c = vk_ctx();
|
|
if (!c) return;
|
|
plat_once(&cmd_once, cmd_init, NULL);
|
|
plat_rmutex_lock(&cmd_lock);
|
|
collect_locked(c);
|
|
plat_rmutex_unlock(&cmd_lock);
|
|
}
|
|
|
|
// Lock order: the texture lock, then cmd_lock (create_tex records under the first), then the headroom lock, which is
|
|
// held only around VMA calls. A caller holding cmd_lock takes it again (recursive) and keeps the fence thread's own
|
|
// collect (vk_sync.c) out meanwhile, so this collects for it.
|
|
void vk_oom_release(void) {
|
|
rt_oom_request();
|
|
vk_headroom_release();
|
|
vk_mem_collect();
|
|
}
|
|
|
|
void vk_mem_stats(VkMemStats *s) {
|
|
memset(s, 0, sizeof *s);
|
|
plat_mutex_lock_shared(&pool_lock);
|
|
for (int i = 0; i < pool_index.n; i++) {
|
|
const PoolRec *r = pool_index.e[i].buf;
|
|
if (r->kind == POOL_IMPORT) s->imported_pools++;
|
|
else if (r->kind == POOL_PHYS) s->physical_pools++;
|
|
else s->virtual_pools++;
|
|
}
|
|
plat_mutex_unlock_shared(&pool_lock);
|
|
s->import_failures = (uint64_t)n_import_failures;
|
|
s->upload_batches = next_seq;
|
|
}
|
|
|
|
void vk_mem_shutdown(void) {
|
|
VkCtx *c = vk_ctx();
|
|
if (!c) return;
|
|
vk_upload_flush(1);
|
|
vk_queue_lock();
|
|
vkDeviceWaitIdle(c->device);
|
|
vk_queue_unlock();
|
|
vk_texture_shutdown();
|
|
plat_rmutex_lock(&cmd_lock);
|
|
retire(c, 1);
|
|
uint64_t keep = tl_noted;
|
|
tl_noted = 0;
|
|
for (int i = 0; i < ndead; i++) dead[i].seq = 0, dead[i].tl = 0; // the device is idle
|
|
collect_locked(c);
|
|
tl_noted = keep;
|
|
for (int i = 0; i < nspare; i++) {
|
|
vkDestroyFence(c->device, spare[i].fence, NULL);
|
|
free(spare[i].stg);
|
|
}
|
|
nspare = 0;
|
|
if (cmd_pool) vkDestroyCommandPool(c->device, cmd_pool, NULL);
|
|
cmd_pool = VK_NULL_HANDLE;
|
|
if (stg_pool) vmaDestroyPool(c->vma, stg_pool); // after retire: no staging buffer is left in it
|
|
stg_pool = NULL;
|
|
stg_pool_tried = 0;
|
|
if (scr_pool) vmaDestroyPool(c->vma, scr_pool); // deferred scratch buffers went with collect_locked above
|
|
scr_pool = NULL;
|
|
scr_pool_tried = 0;
|
|
vk_headroom_pool(NULL, 0, 0); // frees the headroom's anchor in the texture pool
|
|
if (tex_pool) vmaDestroyPool(c->vma, tex_pool); // textures went with vk_texture_shutdown, deferred ones above
|
|
tex_pool = NULL;
|
|
tex_pool_tried = 0;
|
|
plat_rmutex_unlock(&cmd_lock);
|
|
plat_mutex_lock(&pool_lock);
|
|
for (int i = 0; i < pool_index.n; i++) destroy_rec(c, pool_index.e[i].buf);
|
|
pool_index.n = 0;
|
|
nm = npend = nwbl = nsnl = snl_copied = ndq = 0; // mirrors went with their pools
|
|
plat_mutex_lock(&snap_pool_lock);
|
|
for (int i = 0; i < nsnap_pool; i++) vmaDestroyBuffer(c->vma, snap_pool[i].buf, snap_pool[i].alloc);
|
|
nsnap_pool = 0, snap_pool_bytes = 0;
|
|
plat_mutex_unlock(&snap_pool_lock);
|
|
plat_mutex_lock(&shadow_lock);
|
|
nstouch = nspend = 0; // shadows too
|
|
s_live_n = s_live_bytes = 0;
|
|
plat_mutex_unlock(&shadow_lock);
|
|
for (int i = 0; i < nretiring; i++) destroy_rec(c, retiring[i]); // finalized ones whose callback never ran
|
|
nretiring = 0;
|
|
__atomic_add_fetch(&pool_gen, 1, __ATOMIC_RELEASE);
|
|
nphys = 0;
|
|
plat_mutex_unlock(&pool_lock);
|
|
plat_mutex_lock(&gen_lock);
|
|
for (uint64_t i = 0; i < GEN_CHUNKS; i++) free(gen_chunk[i]), gen_chunk[i] = NULL;
|
|
plat_mutex_unlock(&gen_lock);
|
|
}
|