core: writer-owned relocation

The worker no longer copies rows when an array cannot grow in place. It
reserves a zeroed region, returns the staged descriptor with a commit
flag, and queues every other request until the main thread (the only
writer) has copied the old bytes and sent commit-rows. Writes that raced
the old worker-side copy were lost; lights in the glTF viewer regularly
ended up at the origin because of it.

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 06:40:44 +00:00
co-authored by heaust
parent 31d3ae21ff
commit 92638e6c9f
8 changed files with 278 additions and 96 deletions
+79 -28
View File
@@ -27,15 +27,19 @@ impl FreeList {
/// 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 {
let Some((&block, &block_size)) = self.0.range(..=offset).next_back() else {
return false;
};
if available < size {
if offset + size > block + block_size {
return false;
}
self.0.remove(&offset);
if available > size {
self.0.insert(offset + size, available - size);
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);
}
true
}
@@ -58,7 +62,7 @@ impl FreeList {
}
}
#[derive(Clone, Serialize)]
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct Rows {
pub name: String,
pub rows: u32,
@@ -82,6 +86,8 @@ pub struct RenderData {
free: FreeList,
rows: HashMap<String, Rows>,
slots: HashMap<String, Slots>,
/// Relocated descriptors the writer has not committed yet; `rows` keeps the old storage live.
pending: HashMap<String, Rows>,
}
impl RenderData {
@@ -99,19 +105,22 @@ impl RenderData {
free: FreeList(BTreeMap::from([(0, capacity)])),
rows: HashMap::new(),
slots: HashMap::new(),
pending: HashMap::new(),
};
data.create_rows("signals".into(), 1, 32, "f32".into())?;
data.write_signal(5, 1.0);
Ok(data)
}
/// Creates or grows an array. The flag is set when the rows moved: the returned descriptor
/// is then staged and the writer must copy the old bytes into it and `commit` it.
pub fn create_rows(
&mut self,
name: String,
rows: u32,
stride: u32,
format: String,
) -> Result<Rows, &'static str> {
) -> Result<(Rows, bool), &'static str> {
if name.is_empty()
|| rows == 0
|| stride < 16
@@ -125,19 +134,19 @@ impl RenderData {
return Err("ROWS");
}
if rows <= current.rows {
return Ok(current);
return Ok((current, false));
}
if name == "signals" {
return Err("ROWS_BUILTIN");
}
let added = rows - current.rows;
let descriptor = self.grow_rows(current, rows)?;
let (descriptor, moved) = self.grow_rows(current, rows)?;
self.slots
.get_mut(&descriptor.name)
.unwrap()
.free
.release(descriptor.rows - added, added);
return Ok(descriptor);
return Ok((descriptor, moved));
}
let bytes = rows.checked_mul(stride).ok_or("ARENA_OOM")?;
let offset = self.reserve(bytes)?;
@@ -155,13 +164,16 @@ impl RenderData {
let mut slots = Slots::default();
slots.free.release(0, rows);
self.slots.insert(descriptor.name.clone(), slots);
Ok(descriptor)
Ok((descriptor, false))
}
pub fn delete_rows(&mut self, name: &str) -> Result<(), &'static str> {
if name == "signals" {
return Err("ROWS_BUILTIN");
}
if self.pending.contains_key(name) {
return Err("ROWS_PENDING");
}
if self
.slots
.get(name)
@@ -192,7 +204,12 @@ impl RenderData {
}
/// 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> {
/// The growth flags mirror `create_rows`: `(grew, moved)`.
pub fn allocate_object(
&mut self,
name: &str,
count: u32,
) -> Result<(u32, bool, bool), &'static str> {
if name == "signals" {
return Err("ROWS_BUILTIN");
}
@@ -201,6 +218,7 @@ impl RenderData {
}
let slots = self.slots.get_mut(name).ok_or("ROWS_UNKNOWN")?;
let mut grew = false;
let mut moved = false;
let id = match slots.free.allocate(count, 1) {
Some(id) => id,
None => {
@@ -211,7 +229,7 @@ impl RenderData {
.ok_or("OBJECT_LIMIT")?
.max(descriptor.rows.checked_add(count).ok_or("OBJECT_LIMIT")?);
let old_rows = descriptor.rows;
self.grow_rows(descriptor, rows)?;
moved = self.grow_rows(descriptor, rows)?.1;
grew = true;
let slots = self.slots.get_mut(name).unwrap();
slots.free.release(old_rows, rows - old_rows);
@@ -219,7 +237,35 @@ impl RenderData {
}
};
self.slots.get_mut(name).unwrap().spans.insert(id, count);
Ok((id, grew))
Ok((id, grew, moved))
}
/// The descriptor the writer should use: staged rows win over committed ones.
pub fn staged(&self, name: &str) -> Option<&Rows> {
self.pending.get(name).or_else(|| self.rows.get(name))
}
/// Publishes staged relocations after the writer copied the old bytes into them.
pub fn commit(&mut self, names: &[String]) -> Result<(), &'static str> {
for name in names {
let staged = self.pending.remove(name).ok_or("ROWS_PENDING")?;
let old = self.rows.insert(name.clone(), staged).unwrap();
self.release(old.offset - self.base, old.bytes);
}
if !self.pending.is_empty() {
return Err("ROWS_PENDING");
}
Ok(())
}
/// Drops every staged relocation, for example when a batch fails half-way.
pub fn discard_pending(&mut self) {
for staged in std::mem::take(&mut self.pending).into_values() {
self.release(staged.offset - self.base, staged.bytes);
let rows = self.rows[&staged.name].rows;
let slots = self.slots.get_mut(&staged.name).unwrap();
assert!(slots.free.take(rows, staged.rows - rows));
}
}
pub fn delete_object(&mut self, name: &str, id: u32) -> Result<(), &'static str> {
@@ -232,31 +278,36 @@ impl RenderData {
Ok(())
}
fn grow_rows(&mut self, mut descriptor: Rows, rows: u32) -> Result<Rows, &'static str> {
/// Extends in place when the next arena block is free; otherwise stages a zeroed region for
/// the writer to fill and `commit`, because only the writer knows when its last write landed.
fn grow_rows(&mut self, mut descriptor: Rows, rows: u32) -> Result<(Rows, bool), &'static str> {
if self.pending.contains_key(&descriptor.name) {
return Err("ROWS_PENDING");
}
let bytes = rows.checked_mul(descriptor.stride).ok_or("ARENA_OOM")?;
let old_offset = descriptor.offset - self.base;
let old_end = old_offset + descriptor.bytes;
let extra = bytes - descriptor.bytes;
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);
} else {
let moved = !self.free.take(old_end, extra);
if moved {
let offset = self.reserve(bytes)?;
let start = self.arena_start + offset as usize;
self.arena[start..start + bytes as usize].fill(0);
self.arena.copy_within(
self.arena_start + old_offset as usize..self.arena_start + old_end as usize,
start,
);
self.release(old_offset, descriptor.bytes);
descriptor.offset = self.base + offset;
} else {
self.arena[self.arena_start + old_end as usize
..self.arena_start + (old_end + extra) as usize]
.fill(0);
}
descriptor.rows = rows;
descriptor.bytes = bytes;
self.rows
.insert(descriptor.name.clone(), descriptor.clone());
Ok(descriptor)
if moved {
&mut self.pending
} else {
&mut self.rows
}
.insert(descriptor.name.clone(), descriptor.clone());
Ok((descriptor, moved))
}
pub fn update_signals(&mut self, delta: f32, frame: u32, elapsed: f32, fps: u32) {