fix(render3d): nothing allocated in the steady state - the Vulkan allocator reuses its records
gvk_mem_new made a one-slot []pointer per allocation, and turned the requirement's size and alignment into strings to read them as ints; gvk_list_drop_last rebuilt the spare-record list to drop its last entry; gvk_mem_id did the string round trip on every free. One slot is kept (gvk_map_slot), int() truncates a long, the spare list pops. Every Text.to_int(string(x)) in render3d is int(x) now. Vk.heap_bytes() (vk_mac.ll: malloc_zone_statistics' size_in_use; 0 on Windows) and examples/rendering/steady.ludic, in the suite: a buffer released and made again 5000 times and 600 whole frames gain 0 bytes each - the allocator before this, 1,120,000 over the 5000. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3915af34c2
commit
866037a0d7
9 changed files with 119 additions and 19 deletions
9
changes/render3d-zero-per-frame.md
Normal file
9
changes/render3d-zero-per-frame.md
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
bump: patch
|
||||||
|
type: fix
|
||||||
|
**render3d allocates nothing in its steady state.** A buffer released and made again - what a
|
||||||
|
streamed or growing buffer does every few frames - left about 224 bytes behind each time: the
|
||||||
|
Vulkan allocator made a one-slot list per allocation, turned two 64-bit sizes into strings to read
|
||||||
|
them as ints, and rebuilt its spare-record list whole to drop the last entry. It now keeps one
|
||||||
|
slot, converts with `int()`, and pops. `Vk.heap_bytes()` reads malloc's live bytes (macOS), and
|
||||||
|
`examples/rendering/steady.ludic` holds render3d to zero: 5000 buffer rounds and 600 whole frames,
|
||||||
|
0 bytes each (the unfixed allocator: 1,120,000 over the 5000).
|
||||||
67
examples/rendering/steady.ludic
Normal file
67
examples/rendering/steady.ludic
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
# steady.ludic - render3d allocates nothing in its steady state: a buffer released and made again
|
||||||
|
# (what a streamed or grown buffer does every few frames) and a whole frame, drawn over and over,
|
||||||
|
# leave malloc's live bytes where they were. Prints STEADY OK. The heap is read on the Mac
|
||||||
|
# (Vk.heap_bytes); elsewhere it reads 0 and the check passes on the run alone.
|
||||||
|
#
|
||||||
|
# bin/ludic build examples/rendering/steady.ludic --headless && ./build/steady_headless
|
||||||
|
program Steady {
|
||||||
|
numbers float
|
||||||
|
import "ludic.render3d/r3d.ludic"
|
||||||
|
|
||||||
|
property Marker { on: int = 1 }
|
||||||
|
model Anchor { Marker }
|
||||||
|
|
||||||
|
function scene_draw(render3d_st: mut Render3dState) -> void { }
|
||||||
|
function scene_draw_casters(render3d_st: mut Render3dState, light_vp: floats) -> void { }
|
||||||
|
function stream_fill(s: Stream, cx: int, cz: int, band: int) -> void { }
|
||||||
|
|
||||||
|
# bytes gained by the heap over n rounds of the buffer path, after a warm-up of its own
|
||||||
|
function buffer_rounds(render3d_st: mut Render3dState, b: int, n: int) -> long {
|
||||||
|
let before = Vk.heap_bytes()
|
||||||
|
for i in 0 .. n {
|
||||||
|
gpu_buffer_upload(render3d_st, b, 4096 + (i % 16) * 4096, null, GPU_DYNAMIC)
|
||||||
|
gpu_buffer_free(render3d_st, b)
|
||||||
|
}
|
||||||
|
return Vk.heap_bytes() - before
|
||||||
|
}
|
||||||
|
function frame_rounds(render3d_st: mut Render3dState, n: int) -> long {
|
||||||
|
let before = Vk.heap_bytes()
|
||||||
|
for i in 0 .. n {
|
||||||
|
r3d_frame(render3d_st, float(i) / 60.0)
|
||||||
|
r3d_present(render3d_st)
|
||||||
|
}
|
||||||
|
return Vk.heap_bytes() - before
|
||||||
|
}
|
||||||
|
|
||||||
|
handler Boot(render3d_st: mut Render3dState) phase Start {
|
||||||
|
spawn Anchor {}
|
||||||
|
r3d_on_draw(render3d_st, fn scene_draw)
|
||||||
|
r3d_on_casters(render3d_st, fn scene_draw_casters)
|
||||||
|
r3d_on_stream_fill(render3d_st, fn stream_fill)
|
||||||
|
r3d_plate_mode(render3d_st, true)
|
||||||
|
render3d_st.r3d_sky_path = "packages/ludic.lab/plate/sky.hdr"
|
||||||
|
if not r3d_init(render3d_st, 320, 180, "Steady") {
|
||||||
|
quit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let b = gpu_buffer_new(render3d_st)
|
||||||
|
buffer_rounds(render3d_st, b, 500)
|
||||||
|
let grew_b = buffer_rounds(render3d_st, b, 5000)
|
||||||
|
frame_rounds(render3d_st, 120)
|
||||||
|
let grew_f = frame_rounds(render3d_st, 600)
|
||||||
|
print(`steady: the buffer path gained {grew_b} bytes over 5000 rounds, the frame {grew_f} over 600`)
|
||||||
|
# a few KB of slack for what the system's own libraries keep (Metal's caches, the allocator)
|
||||||
|
var ok = grew_b < 16384
|
||||||
|
if not ok { print("steady: FAILED - releasing and making a buffer again leaves memory behind") }
|
||||||
|
if grew_f >= 65536 {
|
||||||
|
ok = false
|
||||||
|
print("steady: FAILED - a frame leaves memory behind")
|
||||||
|
}
|
||||||
|
if ok { print("STEADY OK") } else { print("STEADY FAILED") }
|
||||||
|
quit()
|
||||||
|
}
|
||||||
|
|
||||||
|
handler Present(render3d_st: mut Render3dState) phase Render {
|
||||||
|
r3d_present(render3d_st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -201,6 +201,7 @@ export state Render3dState {
|
||||||
gvk_inflight: int = -1
|
gvk_inflight: int = -1
|
||||||
gvk_nopool: bool = false
|
gvk_nopool: bool = false
|
||||||
gvk_bc_said: int = 0 # attachments refused for being compressed, said so far
|
gvk_bc_said: int = 0 # attachments refused for being compressed, said so far
|
||||||
|
gvk_map_slot: []pointer = null # gvk_mem_new's mapped-pointer slot, made once
|
||||||
gvk_has_bc: bool = false # textureCompressionBC: .dds textures are uploaded compressed
|
gvk_has_bc: bool = false # textureCompressionBC: .dds textures are uploaded compressed
|
||||||
# R3D_VK_PROF: Vulkan objects made and destroyed, reported every 120 frames
|
# R3D_VK_PROF: Vulkan objects made and destroyed, reported every 120 frames
|
||||||
gvk_mk_img: int = 0
|
gvk_mk_img: int = 0
|
||||||
|
|
|
||||||
|
|
@ -371,14 +371,19 @@ function gvk_mem_new(render3d_st: mut Render3dState, req: bytes, want: int, imag
|
||||||
if t < 0 and want == VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT { t = gvk_mem_type(render3d_st, allowed, 0) }
|
if t < 0 and want == VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT { t = gvk_mem_type(render3d_st, allowed, 0) }
|
||||||
if t < 0 { gvk_note(render3d_st, `r3d: vulkan: no memory type for properties {want}`); return 0 }
|
if t < 0 { gvk_note(render3d_st, `r3d: vulkan: no memory type for properties {want}`); return 0 }
|
||||||
let host = (want & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) != 0
|
let host = (want & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) != 0
|
||||||
let size = Text.to_int(string(Vk.get_i64(req, VkMemoryRequirements_size)))
|
let size = int(Vk.get_i64(req, VkMemoryRequirements_size))
|
||||||
var align = Text.to_int(string(Vk.get_i64(req, VkMemoryRequirements_alignment)))
|
var align = int(Vk.get_i64(req, VkMemoryRequirements_alignment))
|
||||||
if align < 1 { align = 1 }
|
if align < 1 { align = 1 }
|
||||||
var blk = -1
|
var blk = -1
|
||||||
var off = 0
|
var off = 0
|
||||||
let zero: long = 0
|
let zero: long = 0
|
||||||
let mp = new []pointer
|
# the mapped pointer comes back through a one-slot list made once, not one per allocation
|
||||||
push(mp, null)
|
if render3d_st.gvk_map_slot == null {
|
||||||
|
render3d_st.gvk_map_slot = new []pointer
|
||||||
|
push(render3d_st.gvk_map_slot, null)
|
||||||
|
}
|
||||||
|
let mp = render3d_st.gvk_map_slot
|
||||||
|
mp[0] = null
|
||||||
if size <= GVK_OWN_OVER {
|
if size <= GVK_OWN_OVER {
|
||||||
var kind = t * 2
|
var kind = t * 2
|
||||||
if image { kind += 1 }
|
if image { kind += 1 }
|
||||||
|
|
@ -420,8 +425,9 @@ function gvk_mem_new(render3d_st: mut Render3dState, req: bytes, want: int, imag
|
||||||
}
|
}
|
||||||
var a = 0
|
var a = 0
|
||||||
if len(render3d_st.gvk_al_spare) > 0 {
|
if len(render3d_st.gvk_al_spare) > 0 {
|
||||||
a = render3d_st.gvk_al_spare[len(render3d_st.gvk_al_spare) - 1]
|
let spare = render3d_st.gvk_al_spare
|
||||||
render3d_st.gvk_al_spare = gvk_list_drop_last(render3d_st.gvk_al_spare)
|
a = spare[len(spare) - 1]
|
||||||
|
List.pop(spare)
|
||||||
} else {
|
} else {
|
||||||
push(render3d_st.gvk_al_blk, -1); push(render3d_st.gvk_al_mem, zero); push(render3d_st.gvk_al_off, 0); push(render3d_st.gvk_al_len, 0); push(render3d_st.gvk_al_map, null)
|
push(render3d_st.gvk_al_blk, -1); push(render3d_st.gvk_al_mem, zero); push(render3d_st.gvk_al_off, 0); push(render3d_st.gvk_al_len, 0); push(render3d_st.gvk_al_map, null)
|
||||||
a = len(render3d_st.gvk_al_blk) - 1
|
a = len(render3d_st.gvk_al_blk) - 1
|
||||||
|
|
@ -430,12 +436,6 @@ function gvk_mem_new(render3d_st: mut Render3dState, req: bytes, want: int, imag
|
||||||
return a
|
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(render3d_st: Render3dState, a: int) -> long {
|
function gvk_mem_handle(render3d_st: Render3dState, a: int) -> long {
|
||||||
if render3d_st.gvk_al_blk[a] < 0 { return render3d_st.gvk_al_mem[a] }
|
if render3d_st.gvk_al_blk[a] < 0 { return render3d_st.gvk_al_mem[a] }
|
||||||
return render3d_st.gvk_blk_mem[render3d_st.gvk_al_blk[a]]
|
return render3d_st.gvk_blk_mem[render3d_st.gvk_al_blk[a]]
|
||||||
|
|
@ -484,7 +484,7 @@ function gvk_mem_free(render3d_st: mut Render3dState, a: int) -> void {
|
||||||
render3d_st.gvk_al_len[a] = 0; render3d_st.gvk_al_mem[a] = zero; render3d_st.gvk_al_map[a] = null; render3d_st.gvk_al_blk[a] = -1
|
render3d_st.gvk_al_len[a] = 0; render3d_st.gvk_al_mem[a] = zero; render3d_st.gvk_al_map[a] = null; render3d_st.gvk_al_blk[a] = -1
|
||||||
push(render3d_st.gvk_al_spare, a)
|
push(render3d_st.gvk_al_spare, a)
|
||||||
}
|
}
|
||||||
function gvk_mem_id(x: long) -> int { return Text.to_int(string(x)) }
|
function gvk_mem_id(x: long) -> int { return int(x) }
|
||||||
|
|
||||||
# ---- one-shot commands --------------------------------------------------------------------
|
# ---- one-shot commands --------------------------------------------------------------------
|
||||||
# Uploads, bakes and read-backs record into a command buffer, submit it and wait. The frame
|
# Uploads, bakes and read-backs record into a command buffer, submit it and wait. The frame
|
||||||
|
|
@ -669,7 +669,7 @@ function gvk_query_result(render3d_st: mut Render3dState, id: int, out: words) -
|
||||||
let size: long = 16
|
let size: long = 16
|
||||||
let stride: long = 8
|
let stride: long = 8
|
||||||
if Vk.get_query_pool_results(render3d_st.gvk_dev, render3d_st.gvk_qpool, id * 2, 2, size, data, stride, VK_QUERY_RESULT_64_BIT) != VK_SUCCESS { return false }
|
if Vk.get_query_pool_results(render3d_st.gvk_dev, render3d_st.gvk_qpool, id * 2, 2, size, data, stride, VK_QUERY_RESULT_64_BIT) != VK_SUCCESS { return false }
|
||||||
let ticks = Text.to_int(string(Vk.get_i64(data, 8) - Vk.get_i64(data, 0)))
|
let ticks = int(Vk.get_i64(data, 8) - Vk.get_i64(data, 0))
|
||||||
if ticks < 0 { return false }
|
if ticks < 0 { return false }
|
||||||
out[0] = int(float(ticks) * render3d_st.gvk_ts_period)
|
out[0] = int(float(ticks) * render3d_st.gvk_ts_period)
|
||||||
return true
|
return true
|
||||||
|
|
|
||||||
|
|
@ -678,7 +678,7 @@ function gvk_frame_init(render3d_st: mut Render3dState) -> bool {
|
||||||
let props = bytes(VkPhysicalDeviceProperties_sizeof)
|
let props = bytes(VkPhysicalDeviceProperties_sizeof)
|
||||||
Vk.get_physical_device_properties(render3d_st.gvk_pd, props)
|
Vk.get_physical_device_properties(render3d_st.gvk_pd, props)
|
||||||
let al = Vk.get_i64(props, VkPhysicalDeviceProperties_limits + VkPhysicalDeviceLimits_minUniformBufferOffsetAlignment)
|
let al = Vk.get_i64(props, VkPhysicalDeviceProperties_limits + VkPhysicalDeviceLimits_minUniformBufferOffsetAlignment)
|
||||||
render3d_st.gvk_ring_align = Text.to_int(string(al))
|
render3d_st.gvk_ring_align = int(al)
|
||||||
if render3d_st.gvk_ring_align < 16 { render3d_st.gvk_ring_align = 16 }
|
if render3d_st.gvk_ring_align < 16 { render3d_st.gvk_ring_align = 16 }
|
||||||
# MSAA: the most samples (up to the 4 the scene asks for) both a colour and a depth target can take
|
# MSAA: the most samples (up to the 4 the scene asks for) both a colour and a depth target can take
|
||||||
let lim = VkPhysicalDeviceProperties_limits
|
let lim = VkPhysicalDeviceProperties_limits
|
||||||
|
|
@ -908,7 +908,7 @@ function gvk_sc_make(render3d_st: mut Render3dState, p: int, nt: int) -> long {
|
||||||
}
|
}
|
||||||
let iw = VkDescriptorImageInfo_sizeof
|
let iw = VkDescriptorImageInfo_sizeof
|
||||||
for t in 0 .. nt {
|
for t in 0 .. nt {
|
||||||
let tex = Text.to_int(string(render3d_st.gvk_sc_tmp[t * 2] / 65536))
|
let tex = int(render3d_st.gvk_sc_tmp[t * 2] / 65536)
|
||||||
Vk.put_i64(iis, t * iw + VkDescriptorImageInfo_sampler, render3d_st.gvk_sc_tmp[t * 2 + 1])
|
Vk.put_i64(iis, t * iw + VkDescriptorImageInfo_sampler, render3d_st.gvk_sc_tmp[t * 2 + 1])
|
||||||
Vk.put_i64(iis, t * iw + VkDescriptorImageInfo_imageView, render3d_st.gvk_tex_view[tex])
|
Vk.put_i64(iis, t * iw + VkDescriptorImageInfo_imageView, render3d_st.gvk_tex_view[tex])
|
||||||
Vk.put_i32(iis, t * iw + VkDescriptorImageInfo_imageLayout, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL)
|
Vk.put_i32(iis, t * iw + VkDescriptorImageInfo_imageLayout, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL)
|
||||||
|
|
@ -1855,9 +1855,9 @@ function gvk_prof_frame(render3d_st: mut Render3dState) -> void {
|
||||||
render3d_st.gvk_prof_frames += 1
|
render3d_st.gvk_prof_frames += 1
|
||||||
if render3d_st.gvk_prof_frames < 120 { return }
|
if render3d_st.gvk_prof_frames < 120 { return }
|
||||||
let f = render3d_st.gvk_prof_frames
|
let f = render3d_st.gvk_prof_frames
|
||||||
let pipe = Text.to_int(string(render3d_st.gvk_us_pipe)) / f
|
let pipe = int(render3d_st.gvk_us_pipe) / f
|
||||||
let set = Text.to_int(string(render3d_st.gvk_us_set)) / f
|
let set = int(render3d_st.gvk_us_set) / f
|
||||||
let draw = Text.to_int(string(render3d_st.gvk_us_draw)) / f
|
let draw = int(render3d_st.gvk_us_draw) / f
|
||||||
print(`r3d: vulkan per frame: {render3d_st.gvk_n_draws / f} draws, {render3d_st.gvk_n_flush / f} flushes; pipelines {pipe / 1000}.{(pipe / 100) % 10} ms, sets {set / 1000}.{(set / 100) % 10} ms, inside draws {draw / 1000}.{(draw / 100) % 10} ms; {render3d_st.gvk_n_pipe_new} pipelines made`)
|
print(`r3d: vulkan per frame: {render3d_st.gvk_n_draws / f} draws, {render3d_st.gvk_n_flush / f} flushes; pipelines {pipe / 1000}.{(pipe / 100) % 10} ms, sets {set / 1000}.{(set / 100) % 10} ms, inside draws {draw / 1000}.{(draw / 100) % 10} ms; {render3d_st.gvk_n_pipe_new} pipelines made`)
|
||||||
# every draw asked for should have been made: a gap is a layer missing from the frame, which is how
|
# every draw asked for should have been made: a gap is a layer missing from the frame, which is how
|
||||||
# MoltenVK's refused skinned pipelines went unseen (the drawstats session found it by counting)
|
# MoltenVK's refused skinned pipelines went unseen (the drawstats session found it by counting)
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ import "vk_api.ludic"
|
||||||
# Once a frame: on macOS drains what MoltenVK and the layer autoreleased during the last one (a
|
# Once a frame: on macOS drains what MoltenVK and the layer autoreleased during the last one (a
|
||||||
# program pumping its own events has no other pool); nothing elsewhere.
|
# program pumping its own events has no other pool); nothing elsewhere.
|
||||||
extern function vk_frame_pool() = "lvk_frame_pool"
|
extern function vk_frame_pool() = "lvk_frame_pool"
|
||||||
|
# malloc's live bytes (macOS; 0 elsewhere): a test that a path allocates nothing, frame after frame
|
||||||
|
extern function vk_heap_bytes() -> long = "lvk_heap_bytes"
|
||||||
extern function vk_sl_prefer(on: int) = "lvk_sl_prefer"
|
extern function vk_sl_prefer(on: int) = "lvk_sl_prefer"
|
||||||
extern function vk_sl_active() -> int = "lvk_sl_active"
|
extern function vk_sl_active() -> int = "lvk_sl_active"
|
||||||
extern function vk_sl_init(pref: pointer, sdk_version: long) -> int = "lsl_slInit"
|
extern function vk_sl_init(pref: pointer, sdk_version: long) -> int = "lsl_slInit"
|
||||||
|
|
|
||||||
|
|
@ -250,3 +250,17 @@ push:
|
||||||
store ptr %p, ptr @lvk_pool
|
store ptr %p, ptr @lvk_pool
|
||||||
ret void
|
ret void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
; ---- the heap in use --------------------------------------------------------------------------
|
||||||
|
; malloc's live bytes across the default zones, for a test that proves a path allocates nothing in
|
||||||
|
; its steady state (render3d's examples/rendering/steady.ludic). malloc_statistics_t is four
|
||||||
|
; words: blocks_in_use (u32, padded), size_in_use, max_size_in_use, size_allocated.
|
||||||
|
declare void @malloc_zone_statistics(ptr, ptr)
|
||||||
|
define i64 @lvk_heap_bytes() {
|
||||||
|
entry:
|
||||||
|
%st = alloca [4 x i64], align 8
|
||||||
|
call void @malloc_zone_statistics(ptr null, ptr %st)
|
||||||
|
%p = getelementptr [4 x i64], ptr %st, i32 0, i32 1
|
||||||
|
%n = load i64, ptr %p
|
||||||
|
ret i64 %n
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -369,3 +369,9 @@ define void @lvk_frame_pool() {
|
||||||
entry:
|
entry:
|
||||||
ret void
|
ret void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
; the heap's live bytes are asked for on the Mac only (vk_mac.ll); 0 here, and a test skips its check
|
||||||
|
define i64 @lvk_heap_bytes() {
|
||||||
|
entry:
|
||||||
|
ret i64 0
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1054,6 +1054,7 @@ function cmd_dev_test() -> int {
|
||||||
headless_case("rendering/ui_render3d", "ok", "ui_render3d.ludic (ludic.ui's render3d backend builds against the renderer)")
|
headless_case("rendering/ui_render3d", "ok", "ui_render3d.ludic (ludic.ui's render3d backend builds against the renderer)")
|
||||||
headless_line_case("rendering/release", "RELEASE OK", "release.ludic (model_release: meshes go, a shared texture stays until its last user goes)")
|
headless_line_case("rendering/release", "RELEASE OK", "release.ludic (model_release: meshes go, a shared texture stays until its last user goes)")
|
||||||
headless_line_case("rendering/bc", "BC OK", "bc.ludic (a .dds beside a .png arrives BC7-compressed with every mip level)")
|
headless_line_case("rendering/bc", "BC OK", "bc.ludic (a .dds beside a .png arrives BC7-compressed with every mip level)")
|
||||||
|
headless_line_case("rendering/steady", "STEADY OK", "steady.ludic (a buffer made again and a whole frame leave malloc's live bytes where they were)")
|
||||||
migrate_component_case()
|
migrate_component_case()
|
||||||
migrate_foreign_case()
|
migrate_foreign_case()
|
||||||
reject_case("rejected/runtime_type_clash", "PadButton is the runtime's enum", "a program's type named like one of the runtime's is refused")
|
reject_case("rejected/runtime_type_clash", "PadButton is the runtime's enum", "a program's type named like one of the runtime's is refused")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue