From 7e9fa4473c728883ae25030ff36d71bd699516db Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Mon, 28 Sep 2026 13:26:59 +0300 Subject: [PATCH] render3d: a stream's cache is made with the stream - records and an arena - and evicts in place stream_update made a Chunk and a words copy per new chunk (fn_stream_update +39 blocks / 13.2 KB a window with the hiker in the drifting boat). The pool holds STREAM_MAX_CHUNKS records; the arena is twice the layer's cap; eviction compacts it; a chunk that cannot be kept is gathered from the scratch. stream_clear_all frees records, lists and streams. steady: 300 new cells, cap 256: 156 KB before, 0 / -4.9 KB after, and every kept chunk's data checked against its cell. Co-Authored-By: Claude Opus 5.5 --- changes/stream-arena.md | 9 ++++ examples/rendering/steady.ludic | 42 +++++++++++++++++- packages/ludic.render3d/env.ludic | 1 + packages/ludic.render3d/stream.ludic | 66 +++++++++++++++++++++------- 4 files changed, 99 insertions(+), 19 deletions(-) create mode 100644 changes/stream-arena.md diff --git a/changes/stream-arena.md b/changes/stream-arena.md new file mode 100644 index 00000000..715dba3c --- /dev/null +++ b/changes/stream-arena.md @@ -0,0 +1,9 @@ +bump: patch +type: fix +**Streaming new ground allocates nothing.** A streamed layer's cache made a record and a copy of +its instances for every chunk the camera found, so the heap grew for as long as there was ground +nobody had stood near - a boat drifting across the lake grew it every frame it crossed a cell. Each +stream now makes its whole cache when it is created: a record per chunk it can keep and an arena of +twice what its layer draws, compacted in place when it evicts. Replacing the world +(`stream_clear_all`) now gives every record, list and stream back. `examples/rendering/steady.ludic` +crosses 300 new cells with eviction running: 156 KB before, 0 after, every kept chunk still its own. diff --git a/examples/rendering/steady.ludic b/examples/rendering/steady.ludic index 747cfa73..e9951510 100644 --- a/examples/rendering/steady.ludic +++ b/examples/rendering/steady.ludic @@ -13,7 +13,11 @@ program Steady { function scene_draw(render3d_st: mut Render3dState) -> void { actor_draw(render3d_st) } function scene_draw_casters(render3d_st: mut Render3dState, light_vp: floats) -> void { } - function stream_fill(s: Stream, cx: int, cz: int, band: int) -> 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 @@ -93,6 +97,35 @@ program Steady { 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) @@ -131,10 +164,11 @@ program Steady { 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}`) + 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") } @@ -160,6 +194,10 @@ program Steady { 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() } diff --git a/packages/ludic.render3d/env.ludic b/packages/ludic.render3d/env.ludic index 7599cccb..90480345 100644 --- a/packages/ludic.render3d/env.ludic +++ b/packages/ludic.render3d/env.ludic @@ -691,6 +691,7 @@ export state Render3dState { stream_walks: int = 0 # streams that walked their whole ring this frame stream_debug_n: int = 0 stream_scratch: floats = null + stream_loose: Chunk = null # the record for a chunk no stream's cache can keep this walk stream_deadline: long = 0 gsl_on: bool = false # slInit succeeded: the interposer is the loader, the plugins are in gsl_dlss_ok: bool = false # what this adapter can run, from slIsFeatureSupported diff --git a/packages/ludic.render3d/stream.ludic b/packages/ludic.render3d/stream.ludic index a1696134..b006ea59 100644 --- a/packages/ludic.render3d/stream.ludic +++ b/packages/ludic.render3d/stream.ludic @@ -16,7 +16,7 @@ property Chunk { key: int = 0, # packed (cx, cz, band) used: int = 0, # the walk that last wanted it (for eviction) - data: words, # INST_FLOATS per instance + off: int = -1, # where its instances start in the stream's arena (words); -1 not kept count: int = 0, ymin: float = 0.0, # height range of its instances (float bits), for the frustum test ymax: float = 0.0 @@ -36,7 +36,10 @@ property Stream { kind: int = 0, # the scene's generator selector for this stream min_band: int = 0, # bands below this belong to another (nearer) stream view_gen: int = -1, # sc_view_gen the layer was last gathered for (the view turned -> regather) - htab: words # open-addressed key -> chunk index + 1 (0 = empty) + htab: words, # open-addressed key -> chunk index + 1 (0 = empty) + arena: words, # every kept chunk's instances, in the order of `chunks` + top: int = 0, # words of the arena in use + spare: []Chunk # records not in use: all of them are made with the stream } # microseconds spent per frame, split so the hitch can be attributed (R3D_PROF=1) @@ -47,13 +50,24 @@ function stream_new(render3d_st: mut Render3dState, layer: Layer, size: float, r if r3d_env_has(render3d_st, "R3D_STREAM_CAP") { render3d_st.STREAM_MAX_CHUNKS = Text.to_int(r3d_env(render3d_st, "R3D_STREAM_CAP")) } render3d_st.stream_no_evict = r3d_env_has(render3d_st, "R3D_NOEVICT") } + # the fill's scratch and the record for a chunk the cache cannot keep, both once for every stream + if render3d_st.stream_scratch == null { render3d_st.stream_scratch = floats(STREAM_CHUNK_MAX * INST_FLOATS) } + if render3d_st.stream_loose == null { render3d_st.stream_loose = new Chunk } let s = new Stream s.layer = layer; s.size = size; s.reach = reach layer.streamed = true layer.grounded = true s.bands = floats(4) s.bands[0] = b0; s.bands[1] = b1; s.bands[2] = b2; s.bands[3] = b3 + # Everything the cache will ever hold is made here: a record per chunk it can keep and an arena + # of twice what the layer draws. Each chunk once had its own record and copy, made as the camera + # found new ground, so the heap grew for as long as there was ground nobody had stood near. s.chunks = new []Chunk + s.spare = new []Chunk + let live = s.chunks + for i in 0 .. render3d_st.STREAM_MAX_CHUNKS { push(live, new Chunk) } + while len(live) > 0 { push(s.spare, List.pop(live)) } + s.arena = words(Math.max(layer.cap, 4096) * 2 * INST_FLOATS) s.keys = words(render3d_st.STREAM_MAX_CHUNKS) s.htab = words(STREAM_HASH) for i in 0 .. STREAM_HASH { s.htab[i] = 0 } @@ -100,7 +114,6 @@ const STREAM_CHUNK_MAX: int = 262144 function stream_emit(render3d_st: mut Render3dState, s: Stream, x: float, y: float, z: float, scale: float, yaw: float, seed: float, wind: float) -> void { let c = s.cur if c.count >= STREAM_CHUNK_MAX { return } - if render3d_st.stream_scratch == null { render3d_st.stream_scratch = floats(STREAM_CHUNK_MAX * INST_FLOATS) } if c.count == 0 { c.ymin = y; c.ymax = y } else { c.ymin = Math.min(c.ymin, y); c.ymax = Math.max(c.ymax, y) } let o = c.count * INST_FLOATS render3d_st.stream_scratch[o] = x; render3d_st.stream_scratch[o + 1] = y; render3d_st.stream_scratch[o + 2] = z; render3d_st.stream_scratch[o + 3] = scale @@ -164,19 +177,26 @@ function stream_evict(render3d_st: mut Render3dState, s: Stream) -> void { # everything wanted by the walk in progress stays whatever the threshold says # compacted in place, and an evicted chunk goes whole: a new list per eviction and the chunks' # own records were never given back + # the arena is compacted in the same pass: a kept chunk's instances only ever move down var w = 0 + var top = 0 var i = 0 while i < s.n { let c = s.chunks[i] if c.used >= t or c.used == render3d_st.stream_walk_no { + let n = c.count * INST_FLOATS + if c.off != top { for k in 0 .. n { s.arena[top + k] = s.arena[c.off + k] } } + c.off = top + top += n s.chunks[w] = c w += 1 } else { - if c.data != null { free(c.data) } - free(c) + c.off = -1 + push(s.spare, c) } i += 1 } + s.top = top let ch = s.chunks while len(ch) > w { List.pop(ch) } s.n = w @@ -221,9 +241,12 @@ function stream_update(render3d_st: mut Render3dState, s: Stream, cam_x: float, # The cell underfoot and its neighbours are never deferred: they are what you # are looking at, and a hole there is the grass vanishing as you walk into it. let urgent = band == 0 and ring <= 1 + var loose = false if c == null and (first or urgent or gl_now_us() < render3d_st.stream_deadline) { - c = new Chunk - c.key = key + if len(s.spare) == 0 and not render3d_st.stream_no_evict { stream_evict(render3d_st, s) } + let sp = s.spare + if len(sp) > 0 { c = List.pop(sp) } else { c = render3d_st.stream_loose } + c.key = key; c.count = 0; c.off = -1; c.used = 0 s.cur = c let t0 = gl_now_us() r3d_stream_fill(render3d_st, s, cx, cz, band) @@ -231,15 +254,17 @@ function stream_update(render3d_st: mut Render3dState, s: Stream, cam_x: float, render3d_st.stream_us_gen = render3d_st.stream_us_gen + dt prof_chunk(render3d_st, s.kind, band, c.count, dt) if render3d_st.r3d_debug and band == 0 and render3d_st.stream_debug_n < 40 { render3d_st.stream_debug_n += 1; print(`stream kind {s.kind} band {band} chunk {cx},{cz}: {c.count} instances`) } - if c.count > 0 { c.data = words(c.count * INST_FLOATS); mem_copy(c.data, render3d_st.stream_scratch, c.count * INST_FLOATS * 4) } - if s.n >= render3d_st.STREAM_MAX_CHUNKS and not render3d_st.stream_no_evict { stream_evict(render3d_st, s) } - # If the walk in progress wants more chunks than the cache can hold, there - # is nothing to evict and this one is used and dropped, as every chunk used - # to be. The cap has to exceed one walk's ring for the cache to work at all. - if s.n < render3d_st.STREAM_MAX_CHUNKS { + let need = c.count * INST_FLOATS + if s.top + need > len(s.arena) and not render3d_st.stream_no_evict { stream_evict(render3d_st, s) } + # If the walk in progress wants more than the cache can hold, there is nothing to + # evict and this one is drawn from the scratch and dropped, as every chunk used to be. + if c != render3d_st.stream_loose and s.n < render3d_st.STREAM_MAX_CHUNKS and s.top + need <= len(s.arena) { + if need > 0 { mem_copy(mem_off(data_of(s.arena), s.top * 4), data_of(render3d_st.stream_scratch), need * 4) } + c.off = s.top + s.top += need push(s.chunks, c); s.keys[s.n] = key; stream_remember(s, key, s.n); s.n += 1 c.used = render3d_st.stream_walk_no - } + } else { loose = true } prof_gen_add(render3d_st, c.count + 512) } @@ -255,10 +280,13 @@ function stream_update(render3d_st: mut Render3dState, s: Stream, cam_x: float, if c != null and c.count > 0 and l.count + c.count <= l.cap and stream_chunk_visible(render3d_st, s, cx, cz, c) { let tg = gl_now_us() layer_room(l, l.count + c.count) - mem_copy(mem_off(l.inst, l.count * INST_FLOATS * 4), data_of(c.data), c.count * INST_FLOATS * 4) + var src = data_of(render3d_st.stream_scratch) + if c.off >= 0 { src = mem_off(data_of(s.arena), c.off * 4) } + mem_copy(mem_off(l.inst, l.count * INST_FLOATS * 4), src, c.count * INST_FLOATS * 4) l.count += c.count render3d_st.stream_us_gather = render3d_st.stream_us_gather + (gl_now_us() - tg) } + if loose and c != render3d_st.stream_loose { push(s.spare, c) } } } cx += 1 @@ -313,10 +341,14 @@ function stream_clear_all(render3d_st: mut Render3dState) -> void { if render3d_st.stream_all == null { return } for i in 0 .. len(render3d_st.stream_all) { let s = render3d_st.stream_all[i] - if s.chunks != null { for c in 0 .. len(s.chunks) { if s.chunks[c].data != null { free(s.chunks[c].data) } } } + for c in 0 .. len(s.chunks) { free(s.chunks[c]) } + for c in 0 .. len(s.spare) { free(s.spare[c]) } + free(s.chunks); free(s.spare); free(s.arena) if s.keys != null { free(s.keys) } if s.htab != null { free(s.htab) } if s.bands != null { free(s.bands) } + free(s) } - render3d_st.stream_all = null + let all = render3d_st.stream_all + List.clear(all) }