# 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) once the device is idle; 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 { actor_draw(render3d_st) } function scene_draw_casters(render3d_st: mut Render3dState, light_vp: floats) -> void { } # a chunk of 20 to 51 instances, different for every cell function stream_fill(render3d_st: mut Render3dState, s: Stream, cx: int, cz: int, band: int) -> void { let n = 20 + ((cx * 7 + cz * 13 + band) & 31) for i in 0 .. n { stream_emit(render3d_st, s, float(cx) * 32.0 + float(i), 0.0, float(cz) * 32.0, 1.0, 0.0, 0.5, 0.0) } } # The heap once the device is idle and MoltenVK's completion handlers, which release a finished # command buffer on their own thread, have caught up: two reads 20 ms apart that agree. Read at # once, a machine busy with other work showed ~80 KB of buffers still being handed back. function settled(render3d_st: Render3dState) -> long { Vk.device_wait_idle(render3d_st.gvk_dev) var last = Vk.heap_bytes() for k in 0 .. 100 { Time.sleep_us(20000) let now = Vk.heap_bytes() if now == last { return now } last = now } return last } # 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 = settled(render3d_st) 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 settled(render3d_st) - before } function frame_rounds(render3d_st: mut Render3dState, n: int) -> long { let before = settled(render3d_st) for i in 0 .. n { r3d_frame(render3d_st, float(i) / 60.0) r3d_present(render3d_st) } return settled(render3d_st) - 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 = settled(render3d_st) for i in 0 .. n { let m = gltf_load(render3d_st, "packages/ludic.lab/plate", "plate.gltf", "plate") model_release(render3d_st, m) } return settled(render3d_st) - before } # bytes gained over n actors placed and released (actor_release): the records come round again function actor_rounds(render3d_st: mut Render3dState, m: Model, n: int) -> long { let before = settled(render3d_st) for i in 0 .. n { let a = actor_new(render3d_st, m) actor_place(a, float(i % 7), 0.0, 0.0, 0.0) actor_hide_part(a, "none", true) actor_release(render3d_st, a) } return settled(render3d_st) - before } # bytes gained while a frame draws more and more: 20 actors to 200, 30 frames at each count. # MoltenVK's command pooling kept every command a frame had recorded (~650 bytes a draw more). function ramp_rounds(render3d_st: mut Render3dState, m: Model) -> long { let acts = new []Actor for i in 0 .. 200 { let a = actor_new(render3d_st, m) a.cull = 0.0 a.visible = false actor_place(a, float(i % 20) - 10.0, 0.0, -float(i / 20), 0.0) push(acts, a) } for i in 0 .. 20 { acts[i].visible = true } frame_rounds(render3d_st, 30) let before = settled(render3d_st) for k in 2 .. 11 { for i in 0 .. k * 20 { acts[i].visible = true } frame_rounds(render3d_st, 30) } let grew = settled(render3d_st) - before for i in 0 .. 200 { actor_release(render3d_st, acts[i]) } return grew } # bytes gained while the camera crosses new ground for 300 cells: a streamed layer's cache holds # at most 256 chunks, so it fills, evicts and compacts on the way. Each chunk once brought its # own record and copy, made as the ground was found. function stream_rounds(render3d_st: mut Render3dState, m: Model) -> long { render3d_st.STREAM_MAX_CHUNKS = 256 let l = layer_new(render3d_st, m, 20000, false, 0.0, 0.0, 400.0) let st = stream_new(render3d_st, l, 32.0, 96.0, 32.0, 64.0, 80.0, 96.0) cam_set(render3d_st, 0.0, 3.0, 0.0, 0.0, -10.0) frame_rounds(render3d_st, 10) let before = settled(render3d_st) for step in 1 .. 301 { cam_set(render3d_st, float(step) * 32.0, 3.0, float(step % 7) * 32.0, 0.0, -10.0) frame_rounds(render3d_st, 2) } let grew = settled(render3d_st) - before # every chunk kept, after all that evicting and compacting, still holds its own cell's instances var wrong = 0 for i in 0 .. st.n { let c = st.chunks[i] let cx = c.key / 4 / 8192 - 4096 if c.count < 20 or float_from_bits(st.arena[c.off]) != float(cx) * 32.0 { wrong += 1 } } if wrong > 0 or st.n == 0 or l.count == 0 { print(`steady: FAILED - {wrong} of {st.n} kept chunks hold another cell's instances ({l.count} drawn)`) return 1000000 } return grew } # bytes gained over n parses of a glTF document, each freed whole (Json.free_all): strings too function parse_rounds(render3d_st: Render3dState, text: string, n: int) -> long { let before = settled(render3d_st) for i in 0 .. n { let doc = Json.parse(text) Json.free_all(doc) } return settled(render3d_st) - 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) # the least of three windows: a leak grows in every one, while a busy GPU's lag (a valley # self-test beside it once read 87 KB) does not repeat three times var grew_f = frame_rounds(render3d_st, 600) for w in 0 .. 2 { let g = frame_rounds(render3d_st, 600) if g < grew_f { grew_f = g } } model_rounds(render3d_st, 20) let grew_m = model_rounds(render3d_st, 200) let am = gltf_load(render3d_st, "packages/ludic.lab/plate", "plate.gltf", "plate") actor_rounds(render3d_st, am, 20) let grew_a = actor_rounds(render3d_st, am, 2000) let grew_r = ramp_rounds(render3d_st, am) let grew_s = stream_rounds(render3d_st, am) let text = Fs.read_text("packages/ludic.lab/plate/plate.gltf") parse_rounds(render3d_st, text, 20) let grew_p = parse_rounds(render3d_st, 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, an actor placed and released {grew_a} over 2000, a frame drawing 20 to 200 actors {grew_r}, a stream over 300 new cells {grew_s}`) # 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 >= 4096 { 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 and a release leave nothing: 0 over 200 (a document's strings were ~640 KB a model, a # texture's create infos and its .dds path ~700 bytes a round) if grew_m >= 4096 { ok = false print("steady: FAILED - a model loaded and let go leaves memory behind") } if grew_a >= 4096 { ok = false print("steady: FAILED - an actor placed and released leaves memory behind") } if grew_r >= 16384 { ok = false print("steady: FAILED - a frame drawing more than before leaves memory behind") } if grew_s >= 4096 { ok = false print("steady: FAILED - streaming new ground 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) } }