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 <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-09-18 05:26:26 +00:00
co-authored by heaust
parent e7546e4b78
commit dda602c4a2
7 changed files with 199 additions and 91 deletions
+3 -2
View File
@@ -97,9 +97,10 @@ export class YawnCore {
this.#arrays.delete(name); 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; 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); this.#arrays.get(name).update(rows);
return id; return id;
} }
+22 -2
View File
@@ -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; use std::cell::RefCell;
#[cfg(target_arch = "wasm32")]
use std::rc::Rc; use std::rc::Rc;
#[cfg(target_arch = "wasm32")]
use serde::Deserialize; use serde::Deserialize;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
#[cfg(target_arch = "wasm32")]
#[path = "render_graph/compiler.rs"] #[path = "render_graph/compiler.rs"]
mod compiler; mod compiler;
#[cfg(target_arch = "wasm32")]
#[path = "renderer/wgpu.rs"] #[path = "renderer/wgpu.rs"]
mod gpu; mod gpu;
#[cfg(target_arch = "wasm32")]
#[path = "render_graph/gpu_resource.rs"] #[path = "render_graph/gpu_resource.rs"]
mod gpu_resource; mod gpu_resource;
#[cfg(target_arch = "wasm32")]
#[path = "render_graph/render_graph.rs"] #[path = "render_graph/render_graph.rs"]
mod graph; mod graph;
#[cfg(target_arch = "wasm32")]
#[path = "renderer/render.rs"] #[path = "renderer/render.rs"]
mod render; mod render;
mod render_data; mod render_data;
#[cfg(target_arch = "wasm32")]
#[path = "render_graph/store.rs"] #[path = "render_graph/store.rs"]
mod store; mod store;
#[cfg(target_arch = "wasm32")]
use gpu::Wgpu; use gpu::Wgpu;
#[cfg(target_arch = "wasm32")]
use render::RenderLoop; use render::RenderLoop;
#[cfg(target_arch = "wasm32")]
use render_data::RenderData; use render_data::RenderData;
#[cfg(target_arch = "wasm32")]
use store::Store; use store::Store;
#[cfg(target_arch = "wasm32")]
#[derive(Deserialize)] #[derive(Deserialize)]
struct RowRequest { struct RowRequest {
name: String, name: String,
@@ -31,6 +49,7 @@ struct RowRequest {
format: String, format: String,
} }
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen] #[wasm_bindgen]
pub struct Core { pub struct Core {
data: Rc<RefCell<RenderData>>, data: Rc<RefCell<RenderData>>,
@@ -39,6 +58,7 @@ pub struct Core {
render: Rc<RenderLoop>, render: Rc<RenderLoop>,
} }
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen] #[wasm_bindgen]
impl Core { impl Core {
#[wasm_bindgen(constructor)] #[wasm_bindgen(constructor)]
@@ -128,11 +148,11 @@ impl Core {
.map_err(JsError::new) .map_err(JsError::new)
} }
pub fn allocate_object(&self, name: &str) -> Result<String, JsError> { pub fn allocate_object(&self, name: &str, count: u32) -> Result<String, JsError> {
let (id, grew) = self let (id, grew) = self
.data .data
.borrow_mut() .borrow_mut()
.allocate_object(name) .allocate_object(name, count)
.map_err(JsError::new)?; .map_err(JsError::new)?;
let rows = self let rows = self
.data .data
+103 -83
View File
@@ -1,9 +1,63 @@
use std::collections::{BTreeMap, HashMap, HashSet}; use std::collections::{BTreeMap, HashMap};
use serde::Serialize; use serde::Serialize;
const ALIGNMENT: u32 = 64; const ALIGNMENT: u32 = 64;
/// First-fit interval allocator over `u32` offsets; adjacent free ranges coalesce on release.
#[derive(Default)]
struct FreeList(BTreeMap<u32, u32>);
impl FreeList {
fn allocate(&mut self, size: u32, align: u32) -> Option<u32> {
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)] #[derive(Clone, Serialize)]
pub struct Rows { pub struct Rows {
pub name: String, pub name: String,
@@ -14,18 +68,18 @@ pub struct Rows {
pub bytes: u32, pub bytes: u32,
} }
/// Row spans handed out by `allocate_object`, keyed by their first row.
#[derive(Default)] #[derive(Default)]
struct Slots { struct Slots {
active: HashSet<u32>, free: FreeList,
free: Vec<u32>, spans: HashMap<u32, u32>,
next: u32,
} }
pub struct RenderData { pub struct RenderData {
arena: Box<[u8]>, arena: Box<[u8]>,
arena_start: usize, arena_start: usize,
base: u32, base: u32,
free: BTreeMap<u32, u32>, free: FreeList,
rows: HashMap<String, Rows>, rows: HashMap<String, Rows>,
slots: HashMap<String, Slots>, slots: HashMap<String, Slots>,
} }
@@ -42,7 +96,7 @@ impl RenderData {
arena, arena,
arena_start: aligned - pointer, arena_start: aligned - pointer,
base: aligned as u32, base: aligned as u32,
free: BTreeMap::from([(0, capacity)]), free: FreeList(BTreeMap::from([(0, capacity)])),
rows: HashMap::new(), rows: HashMap::new(),
slots: HashMap::new(), slots: HashMap::new(),
}; };
@@ -76,7 +130,14 @@ impl RenderData {
if name == "signals" { if name == "signals" {
return Err("ROWS_BUILTIN"); 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 bytes = rows.checked_mul(stride).ok_or("ARENA_OOM")?;
let offset = self.reserve(bytes)?; let offset = self.reserve(bytes)?;
@@ -91,7 +152,9 @@ impl RenderData {
bytes, bytes,
}; };
self.rows.insert(name, descriptor.clone()); 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) Ok(descriptor)
} }
@@ -102,7 +165,7 @@ impl RenderData {
if self if self
.slots .slots
.get(name) .get(name)
.is_some_and(|slots| !slots.active.is_empty()) .is_some_and(|slots| !slots.spans.is_empty())
{ {
return Err("ROWS_ACTIVE"); return Err("ROWS_ACTIVE");
} }
@@ -128,39 +191,44 @@ impl RenderData {
Some(&self.arena[start..start + rows.bytes as usize]) 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" { if name == "signals" {
return Err("ROWS_BUILTIN"); return Err("ROWS_BUILTIN");
} }
let slots = self.slots.get(name).ok_or("ROWS_UNKNOWN")?; if count == 0 {
let reused = slots.free.last().copied(); return Err("ROWS");
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)?;
} }
let slots = self.slots.get_mut(name).unwrap(); let slots = self.slots.get_mut(name).ok_or("ROWS_UNKNOWN")?;
if reused.is_some() { let mut grew = false;
slots.free.pop(); let id = match slots.free.allocate(count, 1) {
} else { Some(id) => id,
slots.next = next; None => {
} let descriptor = self.rows[name].clone();
slots.active.insert(id); 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)) Ok((id, grew))
} }
pub fn delete_object(&mut self, name: &str, id: u32) -> Result<(), &'static str> { pub fn delete_object(&mut self, name: &str, id: u32) -> Result<(), &'static str> {
let slots = self.slots.get_mut(name).ok_or("ROWS_UNKNOWN")?; let slots = self.slots.get_mut(name).ok_or("ROWS_UNKNOWN")?;
if !slots.active.remove(&id) { let count = slots.spans.remove(&id).ok_or("OBJECT_UNKNOWN")?;
return Err("OBJECT_UNKNOWN"); slots.free.release(id, count);
}
let rows = self.rows[name].clone(); let rows = self.rows[name].clone();
let start = self.arena_start + (rows.offset - self.base + id * rows.stride) as usize; let start = self.arena_start + (rows.offset - self.base + id * rows.stride) as usize;
self.arena[start..start + rows.stride as usize].fill(0); self.arena[start..start + (count * rows.stride) as usize].fill(0);
slots.free.push(id);
Ok(()) Ok(())
} }
@@ -169,15 +237,7 @@ impl RenderData {
let old_offset = descriptor.offset - self.base; let old_offset = descriptor.offset - self.base;
let old_end = old_offset + descriptor.bytes; let old_end = old_offset + descriptor.bytes;
let extra = bytes - descriptor.bytes; let extra = bytes - descriptor.bytes;
if self if self.free.take(old_end, extra) {
.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);
}
self.arena[self.arena_start + old_end as usize self.arena[self.arena_start + old_end as usize
..self.arena_start + (old_end + extra) as usize] ..self.arena_start + (old_end + extra) as usize]
.fill(0); .fill(0);
@@ -244,54 +304,14 @@ impl RenderData {
} }
fn reserve(&mut self, bytes: u32) -> Result<u32, &'static str> { fn reserve(&mut self, bytes: u32) -> Result<u32, &'static str> {
let (block, block_bytes, offset) = self self.free.allocate(bytes, ALIGNMENT).ok_or("ARENA_OOM")
.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)
} }
fn release(&mut self, mut offset: u32, mut bytes: u32) { fn release(&mut self, offset: u32, bytes: u32) {
if let Some((&previous, &previous_bytes)) = self.free.range(..offset).next_back() { self.free.release(offset, bytes);
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 align(value: u32) -> Option<u32> {
value
.checked_add(ALIGNMENT - 1)
.map(|value| value & !(ALIGNMENT - 1))
}
#[cfg(test)] #[cfg(test)]
#[path = "render_data_tests.rs"] #[path = "render_data_tests.rs"]
mod tests; mod tests;
+67
View File
@@ -30,3 +30,70 @@ fn skip_and_bundle_signals_hold_dirty_work() {
data.loadout_ready(); data.loadout_ready();
assert!(data.begin_render()); 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));
}
+1 -1
View File
@@ -29,7 +29,7 @@ addEventListener("message", async ({ data: message }) => {
core.delete_rows(message.name); core.delete_rows(message.name);
break; break;
case "allocate-object": case "allocate-object":
result = JSON.parse(core.allocate_object(message.name)); result = JSON.parse(core.allocate_object(message.name, message.count ?? 1));
break; break;
case "delete-object": case "delete-object":
core.delete_object(message.name, message.id); core.delete_object(message.name, message.id);
+1 -1
View File
@@ -29,7 +29,7 @@ The buffer stays the same, but a growing row array may relocate. Replace the cac
## Slots ## 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. Capacity does not shrink. Deletion makes descriptors/views invalid for application use even though stale bytes may remain in memory.
+2 -2
View File
@@ -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` | `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. | | `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. | | `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. | | `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 currently be active; row is zeroed. | | `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. | | `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`. | | `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. | | `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. |