perf(render3d): Vulkan device memory in blocks, and a shadow map that survives running out of it

- A block sub-allocator: 64 MB blocks per memory type, images and buffers kept apart, first fit with
  alignment, freed ranges merged and empty blocks given back; anything over 16 MB still gets its own
  allocation. The self-tests' second world holds 94 allocations instead of 8735 (the driver's limit
  refused a shadow map before). Camp bench unchanged, 105.3 fps.
- shadow_set_res keeps the size that worked when the card has no memory for the new one, and records
  it in shadow_refused, instead of ending with no shadow map; gpu_tex_ok says whether a texture has an
  image behind it.
- Image barriers skip an image that was never made (a failed allocation used to crash there), and
  R3D_VK_ERRLOG=<file> appends every Vulkan failure line by line, so a crash no longer takes the
  message with it.
- R3D_VK_PROF reports draws asked for and not made, so a layer missing from a frame is never silent.

Validation on (VK_INSTANCE_LAYERS): the game's self-tests 61 OK, 0 errors, on the RTX 3070 Ti.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-15 15:44:21 +03:00
parent 3706920e16
commit 989bdca736
5 changed files with 255 additions and 21 deletions

View file

@ -22,9 +22,28 @@ var gvk_has_surface: bool = false # the instance and device can make a surfac
function gvk_fail(what: string, r: int) -> bool {
gvk_why = `{what} failed (VkResult {r})`
print(`r3d: vulkan: {gvk_why}`)
gvk_note(`r3d: vulkan: {gvk_why}`)
return false
}
# A Vulkan failure is printed, and with R3D_VK_ERRLOG=<file> also appended to that file, opened and
# closed for each line: stdout is buffered, and a crash straight after a failure (an image that was
# never made, then used) used to take the one line that explained it with it.
var gvk_errlog: string = null
var gvk_errlog_read: bool = false
function gvk_note(msg: string) -> void {
print(msg)
if not gvk_errlog_read {
gvk_errlog_read = true
if Os.has_env("R3D_VK_ERRLOG") { gvk_errlog = Os.env("R3D_VK_ERRLOG") }
}
if gvk_errlog == null { return }
let f = file_open(gvk_errlog, "ab")
if f == null { return }
let line = msg + "\n"
file_write(f, line, len(line))
file_close(f)
}
function gvk_handle(out: bytes) -> long { return Vk.get_i64(out, 0) }
function gvk_ext_in(props: bytes, n: int, want: string) -> bool {
@ -242,6 +261,193 @@ function gvk_alloc(req: bytes, want: int) -> long {
return gvk_handle(out)
}
# ---- sub-allocation ----------------------------------------------------------------------
# Device memory in blocks, resources carved out of them: one vkAllocateMemory per resource met the
# driver's limit in the self-tests' second world (8735 live allocations and a shadow map refused).
# Blocks are 64 MB of device-local memory or of host-visible, kept apart for images and for
# buffers (so the driver's granularity between the two never matters), first fit with alignment,
# and a freed range merges with its neighbours. A host-visible block is mapped once; a buffer's
# pointer is the block's plus its offset. Anything over 16 MB still gets an allocation of its own:
# bigger blocks held back video memory a second world then could not get (a 4096^2 shadow map and
# then its 2048^2 fallback were refused with 71 allocations live on an 8 GB card).
# An allocation is an id (0 is none), kept where a memory handle used to be.
const GVK_BLOCK_DEVICE: int = 67108864
const GVK_BLOCK_HOST: int = 67108864
const GVK_OWN_OVER: int = 16777216
var gvk_blk_mem: []long = null
var gvk_blk_kind: []int = null # memory type * 2, + 1 for images
var gvk_blk_size: []int = null
var gvk_blk_map: []pointer = null
var gvk_fr_blk: []int = null # free ranges: block, offset, length (0 = an unused slot)
var gvk_fr_off: []int = null
var gvk_fr_len: []int = null
var gvk_al_blk: []int = null # per allocation id: its block, or -1 for its own memory
var gvk_al_mem: []long = null # its own memory when it has one
var gvk_al_off: []int = null
var gvk_al_len: []int = null
var gvk_al_map: []pointer = null
var gvk_al_spare: []int = null # ids to reuse
function gvk_mem_init() -> void {
if gvk_al_blk != null { return }
let zero: long = 0
gvk_blk_mem = new []long; gvk_blk_kind = new []int; gvk_blk_size = new []int; gvk_blk_map = new []pointer
gvk_fr_blk = new []int; gvk_fr_off = new []int; gvk_fr_len = new []int
gvk_al_blk = new []int; gvk_al_mem = new []long; gvk_al_off = new []int; gvk_al_len = new []int
gvk_al_map = new []pointer; gvk_al_spare = new []int
push(gvk_al_blk, -1); push(gvk_al_mem, zero); push(gvk_al_off, 0); push(gvk_al_len, 0); push(gvk_al_map, null)
}
# one vkAllocateMemory of `size` bytes of memory type t, mapped when host-visible; 0 on failure
function gvk_mem_raw(t: int, size: int, host: bool, out_map: []pointer) -> long {
let zero: long = 0
let mai = bytes(VkMemoryAllocateInfo_sizeof)
Vk.zero(mai, VkMemoryAllocateInfo_sizeof)
Vk.put_i32(mai, VkMemoryAllocateInfo_sType, VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO)
let size_l: long = size
Vk.put_i64(mai, VkMemoryAllocateInfo_allocationSize, size_l)
Vk.put_i32(mai, VkMemoryAllocateInfo_memoryTypeIndex, t)
let out = bytes(8)
let r = Vk.allocate_memory(gvk_dev, mai, null, out)
if r != VK_SUCCESS { gvk_note(`r3d: vulkan: vkAllocateMemory of {size} bytes failed (VkResult {r}, {gvk_n_allocs} allocations live)`); return zero }
gvk_n_allocs += 1
let mem = gvk_handle(out)
out_map[0] = null
if host {
let pp = bytes(8)
if Vk.map_memory(gvk_dev, mem, zero, size_l, 0, pp) != VK_SUCCESS { gvk_note("r3d: vulkan: vkMapMemory failed"); return zero }
out_map[0] = Vk.get_ptr(pp, 0)
}
return mem
}
function gvk_fr_put(b: int, off: int, len_: int) -> void {
if len_ <= 0 { return }
for i in 0 .. len(gvk_fr_blk) { if gvk_fr_len[i] == 0 { gvk_fr_blk[i] = b; gvk_fr_off[i] = off; gvk_fr_len[i] = len_; return } }
push(gvk_fr_blk, b); push(gvk_fr_off, off); push(gvk_fr_len, len_)
}
# Memory for a resource from its requirements (a VkMemoryRequirements): an allocation id, 0 if none.
function gvk_mem_new(req: bytes, want: int, image: bool) -> int {
gvk_mem_init()
let allowed = Vk.get_i32(req, VkMemoryRequirements_memoryTypeBits)
var t = gvk_mem_type(allowed, want)
if t < 0 and want == VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT { t = gvk_mem_type(allowed, 0) }
if t < 0 { gvk_note(`r3d: vulkan: no memory type for properties {want}`); return 0 }
let host = (want & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) != 0
let size = Text.to_int(string(Vk.get_i64(req, VkMemoryRequirements_size)))
var align = Text.to_int(string(Vk.get_i64(req, VkMemoryRequirements_alignment)))
if align < 1 { align = 1 }
var blk = -1
var off = 0
let zero: long = 0
let mp = new []pointer
push(mp, null)
if size <= GVK_OWN_OVER {
var kind = t * 2
if image { kind += 1 }
var i = 0
while i < len(gvk_fr_blk) and blk < 0 {
let n = gvk_fr_len[i]
if n > 0 and gvk_blk_kind[gvk_fr_blk[i]] == kind {
let start = (gvk_fr_off[i] + align - 1) / align * align
let end = gvk_fr_off[i] + n
if start + size <= end {
blk = gvk_fr_blk[i]; off = start
let front = start - gvk_fr_off[i]
let back = end - (start + size)
if front > 0 { gvk_fr_len[i] = front } else { gvk_fr_len[i] = 0 }
gvk_fr_put(blk, start + size, back)
}
}
i += 1
}
if blk < 0 {
var bsize = GVK_BLOCK_DEVICE
if host { bsize = GVK_BLOCK_HOST }
let mem = gvk_mem_raw(t, bsize, host, mp)
if mem != 0 {
push(gvk_blk_mem, mem); push(gvk_blk_kind, kind); push(gvk_blk_size, bsize); push(gvk_blk_map, mp[0])
blk = len(gvk_blk_mem) - 1; off = 0
gvk_fr_put(blk, size, bsize - size)
}
}
}
var own: long = 0
var map: pointer = null
if blk < 0 {
own = gvk_mem_raw(t, size, host, mp)
if own == 0 { return 0 }
map = mp[0]
} else if gvk_blk_map[blk] != null {
map = mem_off(gvk_blk_map[blk], off)
}
var a = 0
if len(gvk_al_spare) > 0 {
a = gvk_al_spare[len(gvk_al_spare) - 1]
gvk_al_spare = gvk_list_drop_last(gvk_al_spare)
} else {
push(gvk_al_blk, -1); push(gvk_al_mem, zero); push(gvk_al_off, 0); push(gvk_al_len, 0); push(gvk_al_map, null)
a = len(gvk_al_blk) - 1
}
gvk_al_blk[a] = blk; gvk_al_mem[a] = own; gvk_al_off[a] = off; gvk_al_len[a] = size; gvk_al_map[a] = map
return a
}
function gvk_list_drop_last(l: []int) -> []int {
let out = new []int
for i in 0 .. len(l) - 1 { push(out, l[i]) }
return out
}
function gvk_mem_handle(a: int) -> long {
if gvk_al_blk[a] < 0 { return gvk_al_mem[a] }
return gvk_blk_mem[gvk_al_blk[a]]
}
function gvk_mem_offset(a: int) -> long {
let o: long = gvk_al_off[a]
return o
}
function gvk_mem_ptr(a: int) -> pointer { return gvk_al_map[a] }
# give an allocation back: its own memory is freed, a range returns to its block and merges
function gvk_mem_free(a: int) -> void {
if gvk_al_blk == null or a <= 0 or a >= len(gvk_al_blk) or gvk_al_len[a] == 0 { return }
let b = gvk_al_blk[a]
let zero: long = 0
if b < 0 {
if gvk_al_map[a] != null { Vk.unmap_memory(gvk_dev, gvk_al_mem[a]) }
Vk.free_memory(gvk_dev, gvk_al_mem[a], null)
gvk_n_allocs -= 1
} else {
var off = gvk_al_off[a]
var n = gvk_al_len[a]
# merge with a free range that ends where this starts, and one that starts where this ends
for i in 0 .. len(gvk_fr_blk) {
if gvk_fr_len[i] > 0 and gvk_fr_blk[i] == b and gvk_fr_off[i] + gvk_fr_len[i] == off {
off = gvk_fr_off[i]; n += gvk_fr_len[i]; gvk_fr_len[i] = 0
}
}
for i in 0 .. len(gvk_fr_blk) {
if gvk_fr_len[i] > 0 and gvk_fr_blk[i] == b and gvk_fr_off[i] == off + n {
n += gvk_fr_len[i]; gvk_fr_len[i] = 0
}
}
if off == 0 and n == gvk_blk_size[b] {
# the block is empty again: give it back, or a world swapped out keeps its memory for good
if gvk_blk_map[b] != null { Vk.unmap_memory(gvk_dev, gvk_blk_mem[b]) }
Vk.free_memory(gvk_dev, gvk_blk_mem[b], null)
gvk_n_allocs -= 1
gvk_blk_mem[b] = zero; gvk_blk_map[b] = null; gvk_blk_kind[b] = -1; gvk_blk_size[b] = 0
} else {
gvk_fr_put(b, off, n)
}
}
gvk_al_len[a] = 0; gvk_al_mem[a] = zero; gvk_al_map[a] = null; gvk_al_blk[a] = -1
push(gvk_al_spare, a)
}
function gvk_mem_id(x: long) -> int { return Text.to_int(string(x)) }
# ---- one-shot commands --------------------------------------------------------------------
# Uploads, bakes and read-backs record into a command buffer, submit it and wait. The frame
# itself does not go through here.