Merge branch 'r3d/zero-leak' into lang/uifree

This commit is contained in:
Orkun ÇAKILKAYA 2026-09-28 11:21:22 +03:00
commit 0ec39f8e4e
9 changed files with 119 additions and 19 deletions

View 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).

View 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)
}
}

View file

@ -201,6 +201,7 @@ export state Render3dState {
gvk_inflight: int = -1
gvk_nopool: bool = false
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
# R3D_VK_PROF: Vulkan objects made and destroyed, reported every 120 frames
gvk_mk_img: int = 0

View file

@ -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 { 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 size = Text.to_int(string(Vk.get_i64(req, VkMemoryRequirements_size)))
var align = Text.to_int(string(Vk.get_i64(req, VkMemoryRequirements_alignment)))
let size = int(Vk.get_i64(req, VkMemoryRequirements_size))
var align = int(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)
# the mapped pointer comes back through a one-slot list made once, not one per allocation
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 {
var kind = t * 2
if image { kind += 1 }
@ -420,8 +425,9 @@ function gvk_mem_new(render3d_st: mut Render3dState, req: bytes, want: int, imag
}
var a = 0
if len(render3d_st.gvk_al_spare) > 0 {
a = render3d_st.gvk_al_spare[len(render3d_st.gvk_al_spare) - 1]
render3d_st.gvk_al_spare = gvk_list_drop_last(render3d_st.gvk_al_spare)
let spare = render3d_st.gvk_al_spare
a = spare[len(spare) - 1]
List.pop(spare)
} 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)
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
}
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 {
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]]
@ -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
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 --------------------------------------------------------------------
# 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 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 }
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 }
out[0] = int(float(ticks) * render3d_st.gvk_ts_period)
return true

View file

@ -678,7 +678,7 @@ function gvk_frame_init(render3d_st: mut Render3dState) -> bool {
let props = bytes(VkPhysicalDeviceProperties_sizeof)
Vk.get_physical_device_properties(render3d_st.gvk_pd, props)
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 }
# 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
@ -908,7 +908,7 @@ function gvk_sc_make(render3d_st: mut Render3dState, p: int, nt: int) -> long {
}
let iw = VkDescriptorImageInfo_sizeof
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_imageView, render3d_st.gvk_tex_view[tex])
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
if render3d_st.gvk_prof_frames < 120 { return }
let f = render3d_st.gvk_prof_frames
let pipe = Text.to_int(string(render3d_st.gvk_us_pipe)) / f
let set = Text.to_int(string(render3d_st.gvk_us_set)) / f
let draw = Text.to_int(string(render3d_st.gvk_us_draw)) / f
let pipe = int(render3d_st.gvk_us_pipe) / f
let set = int(render3d_st.gvk_us_set) / 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`)
# 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)

View file

@ -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
# program pumping its own events has no other pool); nothing elsewhere.
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_active() -> int = "lvk_sl_active"
extern function vk_sl_init(pref: pointer, sdk_version: long) -> int = "lsl_slInit"

View file

@ -250,3 +250,17 @@ push:
store ptr %p, ptr @lvk_pool
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
}

View file

@ -369,3 +369,9 @@ define void @lvk_frame_pool() {
entry:
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
}

View file

@ -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_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/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_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")