From dda602c4a27c9e62c23e3f5d6c99dc85dcd84de0 Mon Sep 17 00:00:00 2001 From: Amp Date: Fri, 18 Sep 2026 05:26:26 +0000 Subject: [PATCH] core: span allocation with shared free list and geometric growth Amp-Thread-ID: https://ampcode.com/threads/T-01a0b2dd-db32-712d-99ce-12ce4a86d979 Co-authored-by: Heaust Azure --- core/index.js | 5 +- core/lib.rs | 24 ++++- core/render_data.rs | 186 ++++++++++++++++++-------------- core/render_data_tests.rs | 67 ++++++++++++ core/worker.js | 2 +- coredocs/guide/shared-memory.md | 2 +- coredocs/reference/worker.md | 4 +- 7 files changed, 199 insertions(+), 91 deletions(-) diff --git a/core/index.js b/core/index.js index 70fe8ed..cc8ee45 100644 --- a/core/index.js +++ b/core/index.js @@ -97,9 +97,10 @@ export class YawnCore { this.#arrays.delete(name); } - async allocateObject(name) { + /** Reserves `count` contiguous rows and resolves with the first row ID. */ + async allocateObject(name, count = 1) { await this.ready; - const { id, rows } = await this.#request("allocate-object", { name }); + const { id, rows } = await this.#request("allocate-object", { name, count }); this.#arrays.get(name).update(rows); return id; } diff --git a/core/lib.rs b/core/lib.rs index 913e1f9..880fa30 100644 --- a/core/lib.rs +++ b/core/lib.rs @@ -1,28 +1,46 @@ +//! The arena and allocator compile natively for `cargo test`; everything touching WebGPU is wasm-only. +#![cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] + +#[cfg(target_arch = "wasm32")] use std::cell::RefCell; +#[cfg(target_arch = "wasm32")] use std::rc::Rc; +#[cfg(target_arch = "wasm32")] use serde::Deserialize; +#[cfg(target_arch = "wasm32")] use wasm_bindgen::prelude::*; +#[cfg(target_arch = "wasm32")] #[path = "render_graph/compiler.rs"] mod compiler; +#[cfg(target_arch = "wasm32")] #[path = "renderer/wgpu.rs"] mod gpu; +#[cfg(target_arch = "wasm32")] #[path = "render_graph/gpu_resource.rs"] mod gpu_resource; +#[cfg(target_arch = "wasm32")] #[path = "render_graph/render_graph.rs"] mod graph; +#[cfg(target_arch = "wasm32")] #[path = "renderer/render.rs"] mod render; mod render_data; +#[cfg(target_arch = "wasm32")] #[path = "render_graph/store.rs"] mod store; +#[cfg(target_arch = "wasm32")] use gpu::Wgpu; +#[cfg(target_arch = "wasm32")] use render::RenderLoop; +#[cfg(target_arch = "wasm32")] use render_data::RenderData; +#[cfg(target_arch = "wasm32")] use store::Store; +#[cfg(target_arch = "wasm32")] #[derive(Deserialize)] struct RowRequest { name: String, @@ -31,6 +49,7 @@ struct RowRequest { format: String, } +#[cfg(target_arch = "wasm32")] #[wasm_bindgen] pub struct Core { data: Rc>, @@ -39,6 +58,7 @@ pub struct Core { render: Rc, } +#[cfg(target_arch = "wasm32")] #[wasm_bindgen] impl Core { #[wasm_bindgen(constructor)] @@ -128,11 +148,11 @@ impl Core { .map_err(JsError::new) } - pub fn allocate_object(&self, name: &str) -> Result { + pub fn allocate_object(&self, name: &str, count: u32) -> Result { let (id, grew) = self .data .borrow_mut() - .allocate_object(name) + .allocate_object(name, count) .map_err(JsError::new)?; let rows = self .data diff --git a/core/render_data.rs b/core/render_data.rs index 61b15f3..0a33cb4 100644 --- a/core/render_data.rs +++ b/core/render_data.rs @@ -1,9 +1,63 @@ -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap}; use serde::Serialize; const ALIGNMENT: u32 = 64; +/// First-fit interval allocator over `u32` offsets; adjacent free ranges coalesce on release. +#[derive(Default)] +struct FreeList(BTreeMap); + +impl FreeList { + fn allocate(&mut self, size: u32, align: u32) -> Option { + let (block, block_size, offset) = self.0.iter().find_map(|(&block, &block_size)| { + let offset = block.checked_add(align - 1)? & !(align - 1); + (offset.checked_add(size)? <= block + block_size).then_some((block, block_size, offset)) + })?; + self.0.remove(&block); + if offset > block { + self.0.insert(block, offset - block); + } + let end = offset + size; + if end < block + block_size { + self.0.insert(end, block + block_size - end); + } + Some(offset) + } + + /// Claims exactly `[offset, offset + size)` when that range is entirely free. + fn take(&mut self, offset: u32, size: u32) -> bool { + let Some(&available) = self.0.get(&offset) else { + return false; + }; + if available < size { + return false; + } + self.0.remove(&offset); + if available > size { + self.0.insert(offset + size, available - size); + } + true + } + + fn release(&mut self, mut offset: u32, mut size: u32) { + if let Some((&previous, &previous_size)) = self.0.range(..offset).next_back() { + if previous + previous_size == offset { + self.0.remove(&previous); + offset = previous; + size += previous_size; + } + } + if let Some((&next, &next_size)) = self.0.range(offset..).next() { + if offset + size == next { + self.0.remove(&next); + size += next_size; + } + } + self.0.insert(offset, size); + } +} + #[derive(Clone, Serialize)] pub struct Rows { pub name: String, @@ -14,18 +68,18 @@ pub struct Rows { pub bytes: u32, } +/// Row spans handed out by `allocate_object`, keyed by their first row. #[derive(Default)] struct Slots { - active: HashSet, - free: Vec, - next: u32, + free: FreeList, + spans: HashMap, } pub struct RenderData { arena: Box<[u8]>, arena_start: usize, base: u32, - free: BTreeMap, + free: FreeList, rows: HashMap, slots: HashMap, } @@ -42,7 +96,7 @@ impl RenderData { arena, arena_start: aligned - pointer, base: aligned as u32, - free: BTreeMap::from([(0, capacity)]), + free: FreeList(BTreeMap::from([(0, capacity)])), rows: HashMap::new(), slots: HashMap::new(), }; @@ -76,7 +130,14 @@ impl RenderData { if name == "signals" { return Err("ROWS_BUILTIN"); } - return self.grow_rows(current, rows); + let added = rows - current.rows; + let descriptor = self.grow_rows(current, rows)?; + self.slots + .get_mut(&descriptor.name) + .unwrap() + .free + .release(descriptor.rows - added, added); + return Ok(descriptor); } let bytes = rows.checked_mul(stride).ok_or("ARENA_OOM")?; let offset = self.reserve(bytes)?; @@ -91,7 +152,9 @@ impl RenderData { bytes, }; self.rows.insert(name, descriptor.clone()); - self.slots.insert(descriptor.name.clone(), Slots::default()); + let mut slots = Slots::default(); + slots.free.release(0, rows); + self.slots.insert(descriptor.name.clone(), slots); Ok(descriptor) } @@ -102,7 +165,7 @@ impl RenderData { if self .slots .get(name) - .is_some_and(|slots| !slots.active.is_empty()) + .is_some_and(|slots| !slots.spans.is_empty()) { return Err("ROWS_ACTIVE"); } @@ -128,39 +191,44 @@ impl RenderData { Some(&self.arena[start..start + rows.bytes as usize]) } - pub fn allocate_object(&mut self, name: &str) -> Result<(u32, bool), &'static str> { + /// Reserves `count` contiguous rows and returns the first one; grows geometrically when full. + pub fn allocate_object(&mut self, name: &str, count: u32) -> Result<(u32, bool), &'static str> { if name == "signals" { return Err("ROWS_BUILTIN"); } - let slots = self.slots.get(name).ok_or("ROWS_UNKNOWN")?; - let reused = slots.free.last().copied(); - let id = reused.unwrap_or(slots.next); - let next = id.checked_add(1).ok_or("OBJECT_LIMIT")?; - let capacity = self.rows[name].rows; - let grew = id >= capacity; - if grew { - let descriptor = self.rows[name].clone(); - self.grow_rows(descriptor, next)?; + if count == 0 { + return Err("ROWS"); } - let slots = self.slots.get_mut(name).unwrap(); - if reused.is_some() { - slots.free.pop(); - } else { - slots.next = next; - } - slots.active.insert(id); + let slots = self.slots.get_mut(name).ok_or("ROWS_UNKNOWN")?; + let mut grew = false; + let id = match slots.free.allocate(count, 1) { + Some(id) => id, + None => { + let descriptor = self.rows[name].clone(); + let rows = descriptor + .rows + .checked_mul(2) + .ok_or("OBJECT_LIMIT")? + .max(descriptor.rows.checked_add(count).ok_or("OBJECT_LIMIT")?); + let old_rows = descriptor.rows; + self.grow_rows(descriptor, rows)?; + grew = true; + let slots = self.slots.get_mut(name).unwrap(); + slots.free.release(old_rows, rows - old_rows); + slots.free.allocate(count, 1).unwrap() + } + }; + self.slots.get_mut(name).unwrap().spans.insert(id, count); Ok((id, grew)) } pub fn delete_object(&mut self, name: &str, id: u32) -> Result<(), &'static str> { let slots = self.slots.get_mut(name).ok_or("ROWS_UNKNOWN")?; - if !slots.active.remove(&id) { - return Err("OBJECT_UNKNOWN"); - } + let count = slots.spans.remove(&id).ok_or("OBJECT_UNKNOWN")?; + slots.free.release(id, count); let rows = self.rows[name].clone(); let start = self.arena_start + (rows.offset - self.base + id * rows.stride) as usize; - self.arena[start..start + rows.stride as usize].fill(0); - slots.free.push(id); + self.arena[start..start + (count * rows.stride) as usize].fill(0); Ok(()) } @@ -169,15 +237,7 @@ impl RenderData { let old_offset = descriptor.offset - self.base; let old_end = old_offset + descriptor.bytes; let extra = bytes - descriptor.bytes; - if self - .free - .get(&old_end) - .is_some_and(|available| *available >= extra) - { - let available = self.free.remove(&old_end).unwrap(); - if available > extra { - self.free.insert(old_end + extra, available - extra); - } + if self.free.take(old_end, extra) { self.arena[self.arena_start + old_end as usize ..self.arena_start + (old_end + extra) as usize] .fill(0); @@ -244,54 +304,14 @@ impl RenderData { } fn reserve(&mut self, bytes: u32) -> Result { - let (block, block_bytes, offset) = self - .free - .iter() - .find_map(|(&block, &block_bytes)| { - let offset = align(block)?; - (offset.checked_add(bytes)? <= block.checked_add(block_bytes)?).then_some(( - block, - block_bytes, - offset, - )) - }) - .ok_or("ARENA_OOM")?; - self.free.remove(&block); - if offset > block { - self.free.insert(block, offset - block); - } - let end = offset + bytes; - let block_end = block + block_bytes; - if end < block_end { - self.free.insert(end, block_end - end); - } - Ok(offset) + self.free.allocate(bytes, ALIGNMENT).ok_or("ARENA_OOM") } - fn release(&mut self, mut offset: u32, mut bytes: u32) { - if let Some((&previous, &previous_bytes)) = self.free.range(..offset).next_back() { - if previous + previous_bytes == offset { - self.free.remove(&previous); - offset = previous; - bytes += previous_bytes; - } - } - if let Some((&next, &next_bytes)) = self.free.range(offset..).next() { - if offset + bytes == next { - self.free.remove(&next); - bytes += next_bytes; - } - } - self.free.insert(offset, bytes); + fn release(&mut self, offset: u32, bytes: u32) { + self.free.release(offset, bytes); } } -fn align(value: u32) -> Option { - value - .checked_add(ALIGNMENT - 1) - .map(|value| value & !(ALIGNMENT - 1)) -} - #[cfg(test)] #[path = "render_data_tests.rs"] mod tests; diff --git a/core/render_data_tests.rs b/core/render_data_tests.rs index 81a6aad..110d9a6 100644 --- a/core/render_data_tests.rs +++ b/core/render_data_tests.rs @@ -30,3 +30,70 @@ fn skip_and_bundle_signals_hold_dirty_work() { data.loadout_ready(); assert!(data.begin_render()); } + +fn rows_of(data: &RenderData, name: &str) -> u32 { + data.rows(name).unwrap().rows +} + +#[test] +fn spans_reuse_coalesced_gaps_before_growing() { + let mut data = RenderData::new(4096).unwrap(); + data.create_rows("v".into(), 8, 16, "f32".into()).unwrap(); + + assert_eq!(data.allocate_object("v", 2).unwrap(), (0, false)); + assert_eq!(data.allocate_object("v", 3).unwrap(), (2, false)); + assert_eq!(data.allocate_object("v", 3).unwrap(), (5, false)); + assert_eq!(rows_of(&data, "v"), 8); + + // Freeing the two middle spans leaves one coalesced hole of 6 rows. + data.delete_object("v", 2).unwrap(); + data.delete_object("v", 5).unwrap(); + assert_eq!(data.delete_object("v", 5), Err("OBJECT_UNKNOWN")); + assert_eq!(data.allocate_object("v", 6).unwrap(), (2, false)); + assert_eq!(rows_of(&data, "v"), 8); + + // A span that does not fit doubles capacity instead of growing to the exact need. + assert_eq!(data.allocate_object("v", 1).unwrap(), (8, true)); + assert_eq!(rows_of(&data, "v"), 16); + assert_eq!(data.allocate_object("v", 40).unwrap(), (9, true)); + assert_eq!(rows_of(&data, "v"), 56); + assert_eq!(data.allocate_object("v", 0), Err("ROWS")); +} + +#[test] +fn deleting_a_span_zeroes_every_row_and_keeps_neighbours() { + let mut data = RenderData::new(4096).unwrap(); + data.create_rows("v".into(), 4, 16, "u32".into()).unwrap(); + let (first, _) = data.allocate_object("v", 2).unwrap(); + let (second, _) = data.allocate_object("v", 1).unwrap(); + let base = (data.rows("v").unwrap().offset - data.base) as usize + data.arena_start; + data.arena[base..base + 48].fill(0xAB); + + data.delete_object("v", first).unwrap(); + assert!(data.bytes("v").unwrap()[..32].iter().all(|byte| *byte == 0)); + assert!(data.bytes("v").unwrap()[32..48] + .iter() + .all(|byte| *byte == 0xAB)); + assert_eq!(data.delete_rows("v"), Err("ROWS_ACTIVE")); + data.delete_object("v", second).unwrap(); + data.delete_rows("v").unwrap(); +} + +#[test] +fn explicit_growth_feeds_the_span_allocator_and_relocation_keeps_content() { + let mut data = RenderData::new(4096).unwrap(); + data.create_rows("a".into(), 1, 16, "u32".into()).unwrap(); + data.create_rows("b".into(), 1, 16, "u32".into()).unwrap(); + assert_eq!(data.allocate_object("a", 1).unwrap(), (0, false)); + let base = (data.rows("a").unwrap().offset - data.base) as usize + data.arena_start; + data.arena[base..base + 16].fill(0xCD); + + let grown = data.create_rows("a".into(), 5, 16, "u32".into()).unwrap(); + assert_eq!(grown.rows, 5); + assert!(data.bytes("a").unwrap()[..16] + .iter() + .all(|byte| *byte == 0xCD)); + assert!(data.bytes("a").unwrap()[16..].iter().all(|byte| *byte == 0)); + assert_eq!(data.allocate_object("a", 4).unwrap(), (1, false)); + assert_eq!(data.allocate_object("a", 1).unwrap(), (5, true)); +} diff --git a/core/worker.js b/core/worker.js index 61d933c..f0f1ee3 100644 --- a/core/worker.js +++ b/core/worker.js @@ -29,7 +29,7 @@ addEventListener("message", async ({ data: message }) => { core.delete_rows(message.name); break; case "allocate-object": - result = JSON.parse(core.allocate_object(message.name)); + result = JSON.parse(core.allocate_object(message.name, message.count ?? 1)); break; case "delete-object": core.delete_object(message.name, message.id); diff --git a/coredocs/guide/shared-memory.md b/coredocs/guide/shared-memory.md index 123b9c1..8a7afce 100644 --- a/coredocs/guide/shared-memory.md +++ b/coredocs/guide/shared-memory.md @@ -29,7 +29,7 @@ The buffer stays the same, but a growing row array may relocate. Replace the cac ## Slots -Creating rows establishes capacity but does not allocate objects. `allocate-object` returns a zero-based row ID. IDs increase until capacity is exhausted; allocation then grows to `id + 1`. Deleted IDs are kept in a LIFO free list and reused. `delete-object` zeroes the entire row and releases the ID. It rejects an inactive/duplicate ID. A row array cannot be deleted while any IDs are active, while `signals` cannot be allocated or deleted, and an array used by the active graph cannot be deleted. +Creating rows establishes capacity but does not allocate objects. `allocate-object` reserves `count` contiguous rows (default 1) and returns the zero-based first row as the ID. Spans are placed first-fit into the lowest free gap, so deleted rows are reused before capacity grows; when nothing fits, capacity doubles (or grows to fit the span, whichever is larger) so repeated allocations rarely relocate. Rows added by an explicit `create-rows` growth join the same free list. `delete-object` zeroes the whole span and releases it. It rejects an inactive/duplicate ID. A row array cannot be deleted while any IDs are active, while `signals` cannot be allocated or deleted, and an array used by the active graph cannot be deleted. Capacity does not shrink. Deletion makes descriptors/views invalid for application use even though stale bytes may remain in memory. diff --git a/coredocs/reference/worker.md b/coredocs/reference/worker.md index 94ece18..7544ae9 100644 --- a/coredocs/reference/worker.md +++ b/coredocs/reference/worker.md @@ -8,8 +8,8 @@ All fields below are top-level beside `type` and `request`. Except for `init` an | `create-rows` | `name`, `rows`, `stride`, `format` | — | descriptor | Nonempty name; rows > 0; stride ≥16 and multiple of 16; format f32/u32/i32. Existing name may only retain format/stride and grow. | | `create-rows-batch` | `rows: Array<{name,rows,stride,format}>` | — | descriptor array in input order | Nonempty. Sequential, **not transactional**: earlier creations can survive a later failure. Active GPU resources refresh once after successful batch. | | `delete-rows` | `name` | — | `undefined` | Not `signals`, not active slots, not referenced by active graph. | -| `allocate-object` | `name` | — | `{ id, rows: descriptor }` | Not `signals`; may grow/relocate. | -| `delete-object` | `name`, `id: u32` | — | `undefined` | ID must currently be active; row is zeroed. | +| `allocate-object` | `name`, `count: u32 = 1` | — | `{ id, rows: descriptor }` | Not `signals`; `count` > 0; reserves `count` contiguous rows starting at `id`; may grow/relocate. | +| `delete-object` | `name`, `id: u32` | — | `undefined` | ID must be the first row of an active span; the whole span is zeroed. | | `compile-graph` | `serialized: string` | — | graph ID string | Parses and stores; same ID replaces previously stored graph. Does not activate it. | | `switch-loadout` | `id` | — | `undefined` | Builds GPU resources and activates stored graph. Clears `bundleDirty`, sets `sabDirty`. | | `upload-texture` | `name`, `mipLevel: u32`, `image: ImageBitmap` | `[image]` | `undefined` | Uploads immediately if active graph has the texture and caches source for future switches. Source extent must fit destination/mip. Sets dirty. |