From 866037a0d7563befa6f141cd327c48ec339d52ac Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Mon, 28 Sep 2026 11:14:56 +0300 Subject: [PATCH] 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 --- changes/render3d-zero-per-frame.md | 9 +++ examples/rendering/steady.ludic | 67 +++++++++++++++++++++++ packages/ludic.render3d/env.ludic | 1 + packages/ludic.render3d/gpu_vk.ludic | 28 +++++----- packages/ludic.render3d/gpu_vk_draw.ludic | 10 ++-- runtime/native/vk.ludic | 2 + runtime/native/vk_mac.ll | 14 +++++ runtime/native/vk_win.ll | 6 ++ tools/ludic-cli/test.ludic | 1 + 9 files changed, 119 insertions(+), 19 deletions(-) create mode 100644 changes/render3d-zero-per-frame.md create mode 100644 examples/rendering/steady.ludic diff --git a/changes/render3d-zero-per-frame.md b/changes/render3d-zero-per-frame.md new file mode 100644 index 00000000..71b3f302 --- /dev/null +++ b/changes/render3d-zero-per-frame.md @@ -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). diff --git a/examples/rendering/steady.ludic b/examples/rendering/steady.ludic new file mode 100644 index 00000000..d0cec0d8 --- /dev/null +++ b/examples/rendering/steady.ludic @@ -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) + } +} diff --git a/packages/ludic.render3d/env.ludic b/packages/ludic.render3d/env.ludic index af0ffc47..777766e3 100644 --- a/packages/ludic.render3d/env.ludic +++ b/packages/ludic.render3d/env.ludic @@ -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 diff --git a/packages/ludic.render3d/gpu_vk.ludic b/packages/ludic.render3d/gpu_vk.ludic index ecbd280c..225e725b 100644 --- a/packages/ludic.render3d/gpu_vk.ludic +++ b/packages/ludic.render3d/gpu_vk.ludic @@ -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 diff --git a/packages/ludic.render3d/gpu_vk_draw.ludic b/packages/ludic.render3d/gpu_vk_draw.ludic index 2d4b4cc4..328eca85 100644 --- a/packages/ludic.render3d/gpu_vk_draw.ludic +++ b/packages/ludic.render3d/gpu_vk_draw.ludic @@ -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) diff --git a/runtime/native/vk.ludic b/runtime/native/vk.ludic index 24bca19b..cb5dc026 100644 --- a/runtime/native/vk.ludic +++ b/runtime/native/vk.ludic @@ -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" diff --git a/runtime/native/vk_mac.ll b/runtime/native/vk_mac.ll index 63bad56b..2f7732a4 100644 --- a/runtime/native/vk_mac.ll +++ b/runtime/native/vk_mac.ll @@ -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 +} diff --git a/runtime/native/vk_win.ll b/runtime/native/vk_win.ll index 2263e3e5..a3ae3f37 100644 --- a/runtime/native/vk_win.ll +++ b/runtime/native/vk_win.ll @@ -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 +} diff --git a/tools/ludic-cli/test.ludic b/tools/ludic-cli/test.ludic index f2a66f60..aff4aa76 100644 --- a/tools/ludic-cli/test.ludic +++ b/tools/ludic-cli/test.ludic @@ -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")