From a465984c52a04b827c4cde28df734c96f2b2c0d7 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Mon, 28 Sep 2026 11:36:42 +0300 Subject: [PATCH] fix(render3d, runtime): a glTF document is freed whole, layouts found by number, block records reused - Json.free_all (value_free_all): a parsed tree's nodes, lists and strings. render3d frees each glTF document that way at the next load; the names kept out of it are copies (a primitive's material, a skin's joints, an animation clip's name in ludic.anim) - a model's strings were ~640 KB left behind per load - jp_number made a digits list per decimal in a document and never freed it - gvk_layout_id matches a mesh's layout as numbers in a scratch made once, against the layouts known end to end; a new mesh no longer builds a key string - gvk_mem_new puts a new block into the record of one given back rather than appending, so a buffer made again every few frames no longer grows the block lists steady.ludic adds a glTF parsed and freed whole 200 times: 0 bytes (38,400 before the digits fix), beside the buffer path and the frame, still 0. A model loaded and let go still keeps ~2 KB a round (texture and buffer handles are not reused yet); it is bounded at 8 KB a round. Co-Authored-By: Claude Opus 5.5 --- examples/rendering/steady.ludic | 37 +++++++++++++++++- packages/ludic.anim/read.ludic | 2 +- packages/ludic.render3d/env.ludic | 6 ++- packages/ludic.render3d/gltf.ludic | 5 ++- packages/ludic.render3d/gpu_vk.ludic | 13 ++++++- packages/ludic.render3d/gpu_vk_draw.ludic | 47 +++++++++++++++++++---- packages/ludic.render3d/skin.ludic | 3 +- runtime/native/namespaces.ludic | 1 + runtime/native/value.ludic | 16 ++++++++ 9 files changed, 114 insertions(+), 16 deletions(-) diff --git a/examples/rendering/steady.ludic b/examples/rendering/steady.ludic index d0cec0d8..441ff5da 100644 --- a/examples/rendering/steady.ludic +++ b/examples/rendering/steady.ludic @@ -33,6 +33,26 @@ program Steady { return Vk.heap_bytes() - before } + # bytes gained over n loads and releases of one model: its parse, its meshes and its textures + function model_rounds(render3d_st: mut Render3dState, n: int) -> long { + let before = Vk.heap_bytes() + for i in 0 .. n { + let m = gltf_load(render3d_st, "packages/ludic.lab/plate", "plate.gltf", "plate") + model_release(render3d_st, m) + } + return Vk.heap_bytes() - before + } + + # bytes gained over n parses of a glTF document, each freed whole (Json.free_all): strings too + function parse_rounds(text: string, n: int) -> long { + let before = Vk.heap_bytes() + for i in 0 .. n { + let doc = Json.parse(text) + Json.free_all(doc) + } + return Vk.heap_bytes() - before + } + handler Boot(render3d_st: mut Render3dState) phase Start { spawn Anchor {} r3d_on_draw(render3d_st, fn scene_draw) @@ -49,7 +69,12 @@ program Steady { 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`) + model_rounds(render3d_st, 20) + let grew_m = model_rounds(render3d_st, 200) + let text = Fs.read_text("packages/ludic.lab/plate/plate.gltf") + parse_rounds(text, 20) + let grew_p = parse_rounds(text, 200) + print(`steady: the buffer path gained {grew_b} bytes over 5000 rounds, the frame {grew_f} over 600, a glTF parsed and freed {grew_p} over 200, a model loaded and let go {grew_m} over 200`) # 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") } @@ -57,6 +82,16 @@ program Steady { ok = false print("steady: FAILED - a frame leaves memory behind") } + if grew_p >= 16384 { + ok = false + print("steady: FAILED - a parsed document freed whole leaves memory behind") + } + # a load keeps a few records of its own for now (handles are not reused); a document's strings + # left behind were ~640 KB a model, so a bound of 8 KB a round is what this holds + if grew_m >= 200 * 8192 { + ok = false + print("steady: FAILED - a model loaded and let go leaves memory behind") + } if ok { print("STEADY OK") } else { print("STEADY FAILED") } quit() } diff --git a/packages/ludic.anim/read.ludic b/packages/ludic.anim/read.ludic index 07d0bffe..44e55649 100644 --- a/packages/ludic.anim/read.ludic +++ b/packages/ludic.anim/read.ludic @@ -62,7 +62,7 @@ function read_one(anim_st: AnimState, render3d_st: mut Render3dState, a: Val) -> } if n_keep == 0 { return null } let c = new Clip - c.name = nm + c.name = nm[0 .. len(nm)] # a copy: the document goes, strings and all, at the next load c.n = n_keep c.cnode = words(n_keep) c.cpath = words(n_keep) diff --git a/packages/ludic.render3d/env.ludic b/packages/ludic.render3d/env.ludic index 777766e3..79a05baa 100644 --- a/packages/ludic.render3d/env.ludic +++ b/packages/ludic.render3d/env.ludic @@ -202,6 +202,11 @@ export state Render3dState { 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_lay_sig: words = null # gvk_layout_id's scratch: one layout as numbers + gvk_lay_seen: words = null + gvk_lay_flat: []int = null # every layout known, end to end; gvk_lay_off / _len find each + gvk_lay_off: []int = null + gvk_lay_len: []int = null 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 @@ -331,7 +336,6 @@ export state Render3dState { gvk_n_flush: int = 0 gvk_prof_frames: int = 0 gvk_n_pipe_new: int = 0 # pipelines made this profile window - gvk_layout_keys: []string = null gvk_pc_prog: []int = null gvk_pc_layout: []int = null gvk_pc_state: []int = null diff --git a/packages/ludic.render3d/gltf.ludic b/packages/ludic.render3d/gltf.ludic index c23879a4..cb72f67f 100644 --- a/packages/ludic.render3d/gltf.ludic +++ b/packages/ludic.render3d/gltf.ludic @@ -144,7 +144,8 @@ function gltf_prim(render3d_st: mut Render3dState, p: Val) -> Prim { if un != null { pr.nrm = gltf_texture(render3d_st, un, false) } if ua != null { pr.arm = gltf_texture(render3d_st, ua, false) } var mname: string = null - if value_has(mat, "name") != 0 { mname = value_as_str(value_get(mat, "name")) } + # a copy: the document goes, strings and all, at the next load + if value_has(mat, "name") != 0 { let s = value_as_str(value_get(mat, "name")); mname = s[0 .. len(s)] } pr.name = mname if mname != null { if ud != null { gltf_mat_remember(render3d_st, mname, pr.diff, pr.nrm, pr.arm) } @@ -168,7 +169,7 @@ function gltf_load(render3d_st: mut Render3dState, dir: string, file: string, no if text == null { print(`gltf: cannot read {dir}/{file}`); return null } # the last file's tree goes first (it is read only until the next load: ludic.anim's read.ludic), # and the text once parsed - nothing gives memory back on its own (plan 23 of maroon-lake) - if render3d_st.gltf_doc != null { Json.free(render3d_st.gltf_doc) } + if render3d_st.gltf_doc != null { Json.free_all(render3d_st.gltf_doc) } render3d_st.gltf_doc = Json.parse(text) free(text) let buffers = value_get(render3d_st.gltf_doc, "buffers") diff --git a/packages/ludic.render3d/gpu_vk.ludic b/packages/ludic.render3d/gpu_vk.ludic index 225e725b..35192776 100644 --- a/packages/ludic.render3d/gpu_vk.ludic +++ b/packages/ludic.render3d/gpu_vk.ludic @@ -408,8 +408,17 @@ function gvk_mem_new(render3d_st: mut Render3dState, req: bytes, want: int, imag if host { bsize = GVK_BLOCK_HOST } let mem = gvk_mem_raw(render3d_st, t, bsize, host, mp) if mem != 0 { - push(render3d_st.gvk_blk_mem, mem); push(render3d_st.gvk_blk_kind, kind); push(render3d_st.gvk_blk_size, bsize); push(render3d_st.gvk_blk_map, mp[0]) - blk = len(render3d_st.gvk_blk_mem) - 1; off = 0 + # into the record of a block given back, when there is one: a buffer made again every few + # frames empties a block and makes the next, and each made a new record for good + blk = -1 + for e in 0 .. len(render3d_st.gvk_blk_kind) { if blk < 0 and render3d_st.gvk_blk_kind[e] == -1 and render3d_st.gvk_blk_mem[e] == 0 { blk = e } } + if blk < 0 { + push(render3d_st.gvk_blk_mem, mem); push(render3d_st.gvk_blk_kind, kind); push(render3d_st.gvk_blk_size, bsize); push(render3d_st.gvk_blk_map, mp[0]) + blk = len(render3d_st.gvk_blk_mem) - 1 + } else { + render3d_st.gvk_blk_mem[blk] = mem; render3d_st.gvk_blk_kind[blk] = kind; render3d_st.gvk_blk_size[blk] = bsize; render3d_st.gvk_blk_map[blk] = mp[0] + } + off = 0 gvk_fr_put(render3d_st, blk, size, bsize - size) } } diff --git a/packages/ludic.render3d/gpu_vk_draw.ludic b/packages/ludic.render3d/gpu_vk_draw.ludic index 328eca85..8879af01 100644 --- a/packages/ludic.render3d/gpu_vk_draw.ludic +++ b/packages/ludic.render3d/gpu_vk_draw.ludic @@ -1875,7 +1875,7 @@ function gvk_prof_frame(render3d_st: mut Render3dState) -> void { function gvk_prof_made(render3d_st: mut Render3dState) -> void { let r = render3d_st print(`r3d: vulkan made/destroyed: images {r.gvk_mk_img}/{r.gvk_mk_x_img}, views {r.gvk_mk_view}/{r.gvk_mk_x_view}, buffers {r.gvk_mk_buf}/{r.gvk_mk_x_buf}, memory {r.gvk_mk_mem}/{r.gvk_mk_x_mem}, samplers {r.gvk_mk_smp}, sets {r.gvk_mk_set}, pools {r.gvk_mk_dpool}, cmds {r.gvk_mk_cmd}`) - print(`r3d: vulkan caches: layer views {gvk_len_i(r.gvk_layer_views)}, pipelines {gvk_len_s(r.gvk_pipe_keys)}, layouts {gvk_len_s(r.gvk_layout_keys)}, pipe cache {gvk_len_i(r.gvk_pc_prog)}, retired {gvk_len_l(r.gvk_retired_buf)}, buffers {gvk_len_l(r.gvk_buf)}, allocs {r.gvk_n_allocs}`) + print(`r3d: vulkan caches: layer views {gvk_len_i(r.gvk_layer_views)}, pipelines {gvk_len_s(r.gvk_pipe_keys)}, layouts {gvk_len_i(r.gvk_lay_off)}, pipe cache {gvk_len_i(r.gvk_pc_prog)}, retired {gvk_len_l(r.gvk_retired_buf)}, buffers {gvk_len_l(r.gvk_buf)}, allocs {r.gvk_n_allocs}`) render3d_st.gvk_mk_img = 0; render3d_st.gvk_mk_x_img = 0; render3d_st.gvk_mk_view = 0; render3d_st.gvk_mk_x_view = 0 render3d_st.gvk_mk_buf = 0; render3d_st.gvk_mk_x_buf = 0; render3d_st.gvk_mk_mem = 0; render3d_st.gvk_mk_x_mem = 0 render3d_st.gvk_mk_smp = 0; render3d_st.gvk_mk_set = 0; render3d_st.gvk_mk_dpool = 0; render3d_st.gvk_mk_cmd = 0 @@ -1893,13 +1893,44 @@ function gvk_len_l(xs: []long) -> int { if xs == null { return 0 }; return len( function gvk_layout_id(render3d_st: mut Render3dState, m: Mesh) -> int { if m == null or m.attrs == null { return 1 } if m.vk_layout > 0 { return m.vk_layout } - let key = gvk_layout_key(m) - if render3d_st.gvk_layout_keys == null { render3d_st.gvk_layout_keys = new []string } - var id = 0 - for i in 0 .. len(render3d_st.gvk_layout_keys) { if id == 0 and render3d_st.gvk_layout_keys[i] == key { id = i + 2 } } - if id == 0 { push(render3d_st.gvk_layout_keys, key); id = len(render3d_st.gvk_layout_keys) + 1 } - m.vk_layout = id - return id + # the layout as numbers in a buffer made once - per attribute its index, its buffer's order of + # first use and its six fields - matched against the layouts known: a new mesh costs nothing, and + # only a layout never seen before is kept (a key string per mesh was never given back) + if render3d_st.gvk_lay_sig == null { + render3d_st.gvk_lay_sig = words(GPU_MAX_ATTRS * 8); render3d_st.gvk_lay_seen = words(GPU_MAX_ATTRS) + render3d_st.gvk_lay_flat = new []int; render3d_st.gvk_lay_off = new []int; render3d_st.gvk_lay_len = new []int + } + let sig = render3d_st.gvk_lay_sig + let seen = render3d_st.gvk_lay_seen + var n = 0 + var ns = 0 + for i in 0 .. m.n_attrs { + let o = i * GPU_ATTR_W + if m.attrs[o + 1] == 0 { continue } + var bi = -1 + for q in 0 .. ns { if seen[q] == m.attrs[o] and bi < 0 { bi = q } } + if bi < 0 { bi = ns; seen[ns] = m.attrs[o]; ns += 1 } + sig[n] = i; sig[n + 1] = bi + for k in 1 .. 7 { sig[n + 1 + k] = m.attrs[o + k] } + n += 8 + } + let flat = render3d_st.gvk_lay_flat + for e in 0 .. len(render3d_st.gvk_lay_off) { + if render3d_st.gvk_lay_len[e] == n { + let at = render3d_st.gvk_lay_off[e] + var same = true + var k = 0 + while same and k < n { if flat[at + k] != sig[k] { same = false }; k += 1 } + if same { + m.vk_layout = e + 2 + return m.vk_layout + } + } + } + push(render3d_st.gvk_lay_off, len(flat)); push(render3d_st.gvk_lay_len, n) + for k in 0 .. n { push(flat, sig[k]) } + m.vk_layout = len(render3d_st.gvk_lay_off) + 1 + return m.vk_layout } function gvk_blend_index(f: int) -> int { if f == GL_ZERO { return 0 } diff --git a/packages/ludic.render3d/skin.ludic b/packages/ludic.render3d/skin.ludic index 2c9ecf11..11ffc569 100644 --- a/packages/ludic.render3d/skin.ludic +++ b/packages/ludic.render3d/skin.ludic @@ -87,7 +87,8 @@ function skin_load(render3d_st: mut Render3dState, idx: int) -> Skin { for i in 0 .. n { let nd = value_at(nodes, i) var nm: string = "" - if value_has(nd, "name") != 0 { nm = value_as_str(value_get(nd, "name")) } + # a copy: the document goes, strings and all, at the next load + if value_has(nd, "name") != 0 { let s = value_as_str(value_get(nd, "name")); nm = s[0 .. len(s)] } push(sk.names, nm) skin_jv3(sk.rest_t, i * 3, nd, "translation", 0.0, 0.0, 0.0) skin_jv3(sk.rest_s, i * 3, nd, "scale", 1.0, 1.0, 1.0) diff --git a/runtime/native/namespaces.ludic b/runtime/native/namespaces.ludic index 40b14a9f..08a5695f 100644 --- a/runtime/native/namespaces.ludic +++ b/runtime/native/namespaces.ludic @@ -489,6 +489,7 @@ namespace Json { alias encode(value) = json_encode alias parse(text) = json_parse alias free(value) = json_free + alias free_all(value) = json_free_all } namespace Xml { alias parse(text) = xml_parse diff --git a/runtime/native/value.ludic b/runtime/native/value.ludic index 39a98db2..3929cc2c 100644 --- a/runtime/native/value.ludic +++ b/runtime/native/value.ludic @@ -384,6 +384,21 @@ function value_free(v: Val) -> void { free(v) } function json_free(v: Val) -> void { value_free(v) } +# ... and its strings too - every string value and object key the parser made - for a tree whose +# reader copied what it keeps (render3d's glTF documents): a parse left otherwise gives back the +# nodes and keeps every name, ~640 KB a model +function value_free_all(v: Val) -> void { + if v == null { return } + if v.tag == 4 and v.txt != null { free(v.txt) } + if v.kids != null { + for i in 0 .. len(v.kids) { value_free_all(v.kids[i]) } + if v.keys != null { for i in 0 .. len(v.keys) { if v.keys[i] != null { free(v.keys[i]) } } } + free(v.kids) + free(v.keys) + } + free(v) +} +function json_free_all(v: Val) -> void { value_free_all(v) } function jp_value(p: JP) -> Val { jp_skip_ws(p) @@ -467,6 +482,7 @@ function jp_number(p: JP) -> Val { frac = (digits[di] * 65536 + frac) / 10 di -= 1 } + free(digits) # one list per decimal in the document, never given back var raw = ip * 65536 + frac raw = jp_exponent(p, raw) if neg != 0 { raw = -raw }