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
+5 -1
View File
@@ -25,7 +25,11 @@ function rowFor(buffer, d, index) {
}
```
The buffer stays the same, but a growing row array may relocate. Replace the cached descriptor—and recreate cached views—after every `create-rows`, batch result, and `allocate-object` result. `allocate-object` always returns `{ id, rows: descriptor }`, even without growth. Never infer an address from a prior descriptor.
The buffer stays the same, but a growing row array may relocate. Replace the cached descriptor—and recreate cached views—after every `create-rows`, batch result, and `allocate-object` result. `allocate-object` always returns `{ id, rows: descriptor, commit }`, even without growth. Never infer an address from a prior descriptor.
### Relocation is owned by the writer
The worker never copies rows. When growth cannot extend in place it reserves a zeroed region, returns that descriptor with `commit: true` (`create-rows`, `allocate-object`) or the moved names in `commit` (`create-rows-batch`), and keeps rendering from—and the arena owning—the old rows. The main thread is the only writer, so it is the only party that knows its last write landed: on receiving the result it copies the old bytes into the new region, swaps its descriptor, and sends `commit-rows { names }`. The worker then publishes the new descriptor, frees the old rows, and rebuilds the GPU buffers bound to them. Until that commit arrives the worker queues every other request, so a staged relocation is a short, exclusive transaction and no SAB write made at any moment is lost. `index.js` does all of this inside `createRows`, `createRowsBatch`, and `allocateObject`; a raw client must copy and commit synchronously in the same task that received the result.
## Slots
+2 -1
View File
@@ -23,12 +23,13 @@ Worker failures are string codes in `{ request, error }`. GPU/browser validation
| `ROWS_BUILTIN` | Attempt to grow/delete/allocate from `signals`. |
| `ROWS_UNKNOWN` | Row name does not exist (also possible during frame upload). |
| `ROWS_ACTIVE` | Delete blocked by active object IDs or active graph reference. |
| `ROWS_PENDING` | A relocation is staged and uncommitted: growing or deleting that array again, or a `commit-rows` that names a non-staged array or leaves one staged. |
| `ARENA_OOM` | Byte multiplication overflow or no aligned free arena block. |
| `OBJECT_LIMIT` | Slot ID cannot increment past u32. |
| `OBJECT_UNKNOWN` | Delete ID is not active for that row array. |
| `FPS` | FPS exceeds 1000. |
On row growth, consume the returned descriptor before the next access. Batch creation is not rollback-safe. Do not retry allocation blindly after an uncertain transport outcome: it may allocate a second slot.
On row growth, consume the returned descriptor before the next access and commit relocations synchronously. In-place batch creations are not rolled back on a later failure. Do not retry allocation blindly after an uncertain transport outcome: it may allocate a second slot.
## Graph parse and planning
+4 -3
View File
@@ -5,10 +5,11 @@ All fields below are top-level beside `type` and `request`. Except for `init` an
| `type` | Fields | Transfer list | Success `result` | Constraints / effects |
|---|---|---|---|---|
| `init` | `canvas: OffscreenCanvas`, `arenaBytes: number` | `[canvas]` | `{ buffer: SharedArrayBuffer, rows: Descriptor[], features: string[] }` | Once only; initializes WASM/WebGPU. `features` lists optional device features that were available (`timestamp-query`, `indirect-first-instance`). |
| `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` | `name`, `rows`, `stride`, `format` | — | `{ rows: descriptor, commit: boolean }` | Nonempty name; rows > 0; stride ≥16 and multiple of 16; format f32/u32/i32. Existing name may only retain format/stride and grow. `commit: true` means the rows moved: copy the old bytes into the descriptor and send `commit-rows`. |
| `create-rows-batch` | `rows: Array<{name,rows,stride,format}>` | — | `{ rows: descriptor[] (input order), commit: string[] }` | Nonempty. Sequential; in-place creations/growths survive a later failure, staged relocations are discarded. `commit` lists the arrays that moved. Active GPU resources refresh once for in-place growth. |
| `commit-rows` | `names: string[]` | — | `undefined` | Publishes staged relocations after the caller copied them. Must name every staged array. Frees the old rows, refreshes bound GPU buffers once, sets `sabDirty`. While a relocation is staged the worker queues every other request until this arrives. |
| `delete-rows` | `name` | — | `undefined` | Not `signals`, not active slots, not referenced by active graph. |
| `allocate-object` | `name`, `count: u32 = 1` | — | `{ id, rows: descriptor }` | Not `signals`; `count` > 0; reserves `count` contiguous rows starting at `id`; may grow/relocate. |
| `allocate-object` | `name`, `count: u32 = 1` | — | `{ id, rows: descriptor, commit: boolean }` | Not `signals`; `count` > 0; reserves `count` contiguous rows starting at `id`; may grow. `commit` as in `create-rows`. |
| `delete-object` | `name`, `id: u32` | — | `undefined` | ID must be the first row of an active span; the whole span is zeroed. |
| `sync-rows` | `name`, `first: u32 = 0`, `count: u32` | — | `undefined` | Uploads rows `first .. first + count` of the array into every active GPU buffer bound to it (any `sync`) and sets `sabDirty`. `ROW_RANGE` when the span exceeds the array. No-op without an active loadout. |
| `compile-graph` | `serialized: string` | — | graph ID string | Parses and stores; same ID replaces previously stored graph. Does not activate it. |