Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Archevolt performance plan

Status: steps 0, 1, 2, 3, 4, 5, and 6 done. Step 0's baselines are in `benchmarks/baseline.md`, recorded on .NET 10. Step 3's before and after numbers, on .NET 11, are in `benchmarks/step3-table-storage.md`. Later steps record fresh before and after comparisons on the same SDK. Step 3 met its gate except for named queries, whose regression came from enumerator codegen. Step 2 rewrote that enumerator and met its gate. Its numbers are in `benchmarks/step2-named-query-enumerator.md`. Step 4 replaced directory references with integers and met the median/range gate after an unchanged-code drift control. Its measurements and the remaining variance in large random reads are in `benchmarks/step4-integer-entity-directory.md`. Step 5 made reservation lock-free, added batch spawn, and met its gate. Its numbers are in `benchmarks/step5-lock-free-reservation.md`. Step 6 cached column maps, interned archetypes by bitmask, and met its gate. Its numbers are in `benchmarks/step6-cached-column-maps.md`.
Status: steps 0, 1, 2, 3, 4, 5, 6, and 8 done. Step 7 remains. Step 0's baselines are in `benchmarks/baseline.md`, recorded on .NET 10. Step 3's before and after numbers, on .NET 11, are in `benchmarks/step3-table-storage.md`. Later steps record fresh before and after comparisons on the same SDK. Step 3 met its gate except for named queries, whose regression came from enumerator codegen. Step 2 rewrote that enumerator and met its gate. Its numbers are in `benchmarks/step2-named-query-enumerator.md`. Step 4 replaced directory references with integers and met the median/range gate after an unchanged-code drift control. Its measurements and the remaining variance in large random reads are in `benchmarks/step4-integer-entity-directory.md`. Step 5 made reservation lock-free, added batch spawn, and met its gate. Its numbers are in `benchmarks/step5-lock-free-reservation.md`. Step 6 cached column maps, interned archetypes by bitmask, and met its gate. Its numbers are in `benchmarks/step6-cached-column-maps.md`. Step 8 made both immediate spawns write bundles in place and met its gate. Its numbers are in `benchmarks/step8-in-place-spawn.md`.

The v1 implementation plan is complete. Every stage passed its acceptance gate, and the evidence is in `.audit/acceptance.md`. The [v1 plan](https://github.com/Stephanvs/archevolt/blob/ebacf07059fefbf9b182e40a290812be584dd9ee/PLAN.md) remains in history as the record of the original design, its alternatives, and its gates.

Expand Down Expand Up @@ -237,3 +237,10 @@ Follow-up, not yet a numbered step: [step3-partition-granularity.md](benchmarks/
- The world creates the empty archetype when it is built, so it always exists, at index 0.
- `TryGet<T>` binds the directory entry once through `EntityTable.TryLocate`. `IsAlive` now calls `TryLocate` too.
- [The step 6 measurements](benchmarks/step6-cached-column-maps.md) interleave before and after process runs instead of running each side in turn. `Get<T>` fell to 0.72 of its time at 1,000 entities and 0.77 at 100,000. Both round trips and both spawn cases improved at both sizes. At 1,000 entities, `Get<T>` now costs about 5.7 ns per call against Bevy's recorded 4.2 ns.

## Amendments after step 8

- Step 8 says the `BundleWriter` points at the row's destination columns. The writer keeps its staging form for `Commands` and `InsertBundle` and gains a table form. `BundleBinding.Columns` holds each member's column, so the table form writes without a lookup. The amendment after step 6 said the binding did not need its columns. It needs them now, because `WriteBundle` no longer runs for immediate spawns.
- The new row sits in the table, but not yet in the directory, while user code in the bundle's `Write` runs. The world counts as running for that window, so a `Write` that reaches the same world through other state cannot move or truncate the row. Step 8's text did not anticipate this.
- [The step 8 measurements](benchmarks/step8-in-place-spawn.md) show `Spawn` at 0.65 and 0.74 of its time, and `SpawnBatch` at 0.60 and 0.69, at 1,000 and 100,000 entities. Both are now below Bevy's recorded spawn and `spawn_batch` figures at both sizes.
- Step 7 is the only step left. "Sequence" still says it comes last.
87 changes: 87 additions & 0 deletions benchmarks/step8-in-place-spawn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Step 8: write immediate spawns in place

This file records [ROADMAP.md](../ROADMAP.md) step 8. `World.Spawn<TBundle>` and `World.SpawnBatch<TBundle>` no longer stage each bundle in a buffer and copy it into the table. The bundle's `Write` stores each member directly in its destination column.

## Change

- **Two targets for `BundleWriter`.** The writer either fills a staging payload, as before, or writes into one table row through a member-to-column map. In the table form, `Put<T>` stores the value in its column and stamps a tracked member's added and changed ticks. `Complete` still requires every declared member, in order.
- **Append first.** `Spawn` reserves the entity, appends its row, then runs the bundle's `Write` against that row. `SpawnBatch` reserves every entity and the table capacity first, as in step 5, then appends and writes each row in turn. The stack or pooled staging buffer is gone from both.
- **Rollback.** If `Write` or `Complete` throws, `Spawn` truncates the table to its previous count and releases the reservation. `SpawnBatch` already truncated every row of the batch and released every reservation. The change tick advances only after the write succeeds, in both.
- **No re-entry during the write.** The new row is in the table but not in the entity directory while `Write` runs, so another structural change to the same table could move or truncate it. The world now counts as running during the write, and every world operation called from inside `Write` throws through `RequireIdle`. The old staging path finished `Write` before it touched the table, so it did not need this.
- **Member columns on the binding.** `BundleBinding.Columns` holds the bundle archetype's column for each member, computed once when the world is built.
- `Commands.Spawn`, `Commands.InsertBundle`, and `World.InsertBundle` keep the staging path. A command must hold its payload until playback, and an insertion may move the entity first.

## Conditions

| Setting | Value |
| --- | --- |
| CPU | AMD Ryzen 9 5950X 16-Core Processor |
| OS | Windows 11 Pro 10.0.26300 |
| .NET | SDK 11.0.100-rc.3.26522.148, RyuJIT x86-64-v3, Release |
| BenchmarkDotNet | 0.16.0-preview.2, default job, one launch per process run |
| Power plan | BenchmarkDotNet selects High performance and restores the previous plan |
| Before | `1ea88b7`, the tip of step 6 |
| After | `0921d1a`, a working-tree snapshot of this change on `1ea88b7` |

Both versions ran in the same dedicated worktree. Each process run used this command:

```powershell
./scripts/baseline.ps1 -Runs 1 -Classes EntityStorageBenchmarks,EntityPopulationBenchmarks
```

The six process runs alternated in the order before, after, after, before, before, after, between 19:00 and 19:24 UTC on 30 October 2026. Each timing is the median of three per-process means in microseconds, with their range. `scripts/compare-baselines.ps1` classifies an after median outside the before range as faster or slower. Builds and tests did not run alongside the measurements. Bevy was not rerun.

## Measurements

| Case | Before µs [min, max] | After µs [min, max] | After/before | Verdict |
| --- | ---: | ---: | ---: | --- |
| `Spawn(Count=1000)` | 43.10 [41.44, 44.23] | 28.16 [26.49, 28.33] | 0.65 | faster |
| `Spawn(Count=100000)` | 5,404 [5,180, 6,348] | 3,999 [3,755, 4,749] | 0.74 | faster |
| `SpawnBatch(Count=1000)` | 26.73 [26.11, 26.95] | 16.16 [15.47, 17.60] | 0.60 | faster |
| `SpawnBatch(Count=100000)` | 3,193 [3,161, 3,748] | 2,201 [2,150, 2,580] | 0.69 | faster |
| `Despawn(Count=1000)` | 24.32 [24.02, 24.54] | 24.60 [23.80, 24.78] | 1.01 | slower |
| `Despawn(Count=100000)` | 3,669 [3,470, 5,116] | 3,831 [3,674, 4,036] | 1.04 | within |
| `Get(Count=1000)` | 5.969 [5.958, 6.056] | 6.032 [5.534, 6.050] | 1.01 | within |
| `Get(Count=100000)` | 1,362 [1,352, 1,407] | 1,434 [1,317, 1,482] | 1.05 | slower |
| `TagRoundTrip(Count=1000)` | 91.08 [89.69, 91.91] | 88.09 [87.26, 91.29] | 0.97 | faster |
| `TagRoundTrip(Count=100000)` | 9,040 [8,786, 10,260] | 9,232 [8,585, 9,588] | 1.02 | within |
| `WideRoundTrip(Count=1000)` | 95.57 [93.80, 95.88] | 97.93 [89.69, 101.1] | 1.02 | slower |
| `WideRoundTrip(Count=100000)` | 9,557 [9,424, 9,885] | 9,837 [9,730, 10,710] | 1.03 | within |

Both spawn forms gain about a third. Each member is now checked and stored in one pass, where it used to be checked into the buffer by `Put` and then copied out by a second walk in `WriteBundle`. The per-call staging buffer is gone too.

Despawn, `Get`, and the round trips do not run any changed code. Their benchmarks spawn only in `GlobalSetup` or `IterationSetup`, outside the measured operation. Their "faster" and "slower" verdicts sit within 5% of the before median, and every one of them overlaps the other side's range. They are run-to-run variance, not effects of this change.

Managed allocation per operation did not change in any case. `Spawn` allocates 16.05 KB and 1,610.49 KB, `SpawnBatch` 16.05 KB and 1,571.09 KB, and `Despawn` 8.02 KB and 1,024.18 KB, at 1,000 and 100,000 entities.

### Bevy reference, not rerun

`benchmarks/baseline.md` records Bevy 0.20.0 on 22 October 2026 on the same machine. Those medians are historical context only.

| Case | Archevolt after µs | Bevy reference µs |
| --- | ---: | ---: |
| Spawn, 1,000 | 28.16 | 36.46 |
| Spawn, 100,000 | 3,999 | 4,788 |
| Batch spawn, 1,000 | 16.16 | 24.08 |
| Batch spawn, 100,000 | 2,201 | 3,222 |

## Correctness

Two tests are new.

- `Spawn_that_fails_partway_leaves_no_row_or_reservation` spawns a bundle whose `Write` throws after its first member, and a bundle whose `Write` skips its second member so that `Complete` throws. A query afterwards sees only the earlier survivor, and the next spawn reuses the failed index at generation 3, which shows both reservations were released.
- `Bundle_write_cannot_spawn_into_the_world_it_is_writing_to` has a bundle whose `Write` calls `Spawn` on the same world. Both `Spawn` and `SpawnBatch` throw, and the world still holds only its earlier entity.

`Batch_spawn_that_fails_partway_leaves_no_row_or_reservation`, from step 5, passes unchanged. `scripts/verify.ps1` passed every check.

## Gate

| Gate item | Result |
| --- | --- |
| Step 0a's spawn and batch spawn cases improve at 1,000 and 100,000 entities | Met. |
| They allocate no more managed memory | Met. Allocation is unchanged. |
| Spawn, batch spawn, and command tests pass, including a partial write that leaves no row or reservation | Met. |

## Saved evidence

The interleaving script, its run list, the merged before and after summaries, and the comparison are kept locally under `artifacts/step8-in-place-spawn/`. The six raw runs are in the measurement worktree under `artifacts/baseline/20261030-190055` through `20261030-191946`.
2 changes: 1 addition & 1 deletion docs/guides/world-and-entities.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ sidebar:
| API | Behavior |
| --- | --- |
| `Spawn()` | Creates an entity with no components. |
| `Spawn<TBundle>(in bundle)` | Creates an entity with the registered bundle's components. |
| `Spawn<TBundle>(in bundle)` | Creates an entity with the registered bundle's components. If the bundle fails to write, no entity is created. |
| `SpawnBatch<TBundle>(bundles, entities)` | Creates one entity per bundle and writes its handle to the same position in `entities`, which must have the same length. If any bundle fails to write, no entity is created and `entities` is cleared. |
| `Insert<T>(entity, in value)` | Adds or replaces one registered component. |
| `InsertBundle<TBundle>(entity, in bundle)` | Adds or replaces bundle members and preserves unrelated components. |
Expand Down
39 changes: 33 additions & 6 deletions src/Archevolt/Storage/Descriptors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,19 +79,32 @@ public interface IBundle<TSelf> where TSelf : unmanaged, IBundle<TSelf>
static abstract void Write(in TSelf value, ref BundleWriter writer);
}

// Writes each member either into a staging payload or, when given a table, straight into the row's columns.
public ref struct BundleWriter
{
private readonly BundleDescriptor _descriptor;
private readonly Span<byte> _payload;
private readonly Table? _table;
private readonly int[]? _columns;
private readonly int _row;
private readonly ulong _tick;
private int _member;
private int _offset;

internal BundleWriter(BundleDescriptor descriptor, Span<byte> payload)
{
_descriptor = descriptor;
_payload = payload;
_member = 0;
_offset = 0;
}

// columns[i] is the table column of descriptor member i. Tracked members get tick as their added and changed tick.
internal BundleWriter(BundleDescriptor descriptor, Table table, int[] columns, int row, ulong tick)
{
_descriptor = descriptor;
_table = table;
_columns = columns;
_row = row;
_tick = tick;
}

public void Put<T>(in T value) where T : unmanaged
Expand All @@ -101,15 +114,29 @@ public void Put<T>(in T value) where T : unmanaged
var component = _descriptor.Components[_member];
if (component.TypeKey != TypeKey<T>.Value)
throw new InvalidOperationException("The bundle member type differs from its descriptor.");
if (!component.IsTag)
MemoryMarshal.Write(_payload.Slice(_offset, component.Size), in value);
_offset += component.Size;
if (_table is null)
{
if (!component.IsTag)
MemoryMarshal.Write(_payload.Slice(_offset, component.Size), in value);
_offset += component.Size;
}
else
{
int column = _columns![_member];
if (!component.IsTag)
_table.Values<T>(column)[_row] = value;
if (component.Tracked)
{
_table.Added(column)[_row] = _tick;
_table.Changed(column)[_row] = _tick;
}
}
_member++;
}

internal readonly void Complete()
{
if (_member != _descriptor.Components.Length || _offset != _payload.Length)
if (_member != _descriptor.Components.Length || (_table is null && _offset != _payload.Length))
throw new InvalidOperationException("The bundle did not write every declared member.");
}
}
Expand Down
54 changes: 24 additions & 30 deletions src/Archevolt/World.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,34 +70,34 @@ public Entity Spawn()
catch { Entities.Release(entity.Index); throw; }
}

// Writes the bundle straight into its new row. If the write fails, the row and the reservation are removed.
// The world counts as running during the write, so a bundle's Write cannot reach the world and move the row.
public Entity Spawn<TBundle>(in TBundle bundle) where TBundle : unmanaged, IBundle<TBundle>
{
RequireIdle();
if (!_bundles.TryGetValue(TypeKey<TBundle>.Value, out var binding))
throw new InvalidOperationException($"Bundle {typeof(TBundle).Name} is not registered.");
byte[]? rented = null;
Span<byte> payload = binding.Descriptor.PayloadBytes <= 1024
? stackalloc byte[binding.Descriptor.PayloadBytes]
: (rented = ArrayPool<byte>.Shared.Rent(binding.Descriptor.PayloadBytes)).AsSpan(0, binding.Descriptor.PayloadBytes);
var binding = GetBundle<TBundle>();
var table = binding.Archetype.Table;
ulong tick = checked(_tick + 1);
Entity entity = Entities.Reserve(Id);
int row = table.Count;
_running = true;
try
{
var writer = new BundleWriter(binding.Descriptor, payload);
table.Append(new EntityKey(entity.Index, entity.Generation));
var writer = new BundleWriter(binding.Descriptor, table, binding.Columns, row, tick);
TBundle.Write(in bundle, ref writer);
writer.Complete();
ulong tick = NextTick();
Entity entity = Entities.Reserve(Id);
try
{
ApplySpawn(entity, binding, payload, tick);
return entity;
}
catch { Entities.Release(entity.Index); throw; }
}
finally
catch
{
if (rented is not null)
ArrayPool<byte>.Shared.Return(rented);
table.Count = row;
Entities.Release(entity.Index);
throw;
}
finally { _running = false; }
_tick = tick;
Entities.Materialize(entity, binding.Archetype.Index, row);
return entity;
}

// Spawns bundles[i] as entities[i] with one reservation and one table growth for the whole batch.
Expand All @@ -112,23 +112,19 @@ public void SpawnBatch<TBundle>(ReadOnlySpan<TBundle> bundles, Span<Entity> enti
return;
var archetype = binding.Archetype;
var table = archetype.Table;
byte[]? rented = null;
Span<byte> payload = binding.Descriptor.PayloadBytes <= 1024
? stackalloc byte[binding.Descriptor.PayloadBytes]
: (rented = ArrayPool<byte>.Shared.Rent(binding.Descriptor.PayloadBytes)).AsSpan(0, binding.Descriptor.PayloadBytes);
ulong tick = checked(_tick + 1);
Entities.Reserve(Id, entities);
int first = table.Count;
_running = true;
try
{
table.Reserve(bundles.Length);
for (int i = 0; i < bundles.Length; i++)
{
var writer = new BundleWriter(binding.Descriptor, payload);
int row = table.Append(new EntityKey(entities[i].Index, entities[i].Generation));
var writer = new BundleWriter(binding.Descriptor, table, binding.Columns, row, tick);
TBundle.Write(in bundles[i], ref writer);
writer.Complete();
int row = table.Append(new EntityKey(entities[i].Index, entities[i].Generation));
WriteBundle(binding, payload, archetype, row, null, tick);
}
}
catch
Expand All @@ -139,11 +135,7 @@ public void SpawnBatch<TBundle>(ReadOnlySpan<TBundle> bundles, Span<Entity> enti
entities.Clear();
throw;
}
finally
{
if (rented is not null)
ArrayPool<byte>.Shared.Return(rented);
}
finally { _running = false; }
_tick = tick;
for (int i = 0; i < entities.Length; i++)
Entities.Materialize(entities[i], archetype.Index, first + i);
Expand Down Expand Up @@ -350,6 +342,8 @@ public void Dispose()

internal sealed record BundleBinding(BundleDescriptor Descriptor, Archetype Archetype, int[] Ids)
{
// Columns[i] is the bundle archetype's table column for member i, so an immediate spawn writes without lookup.
internal readonly int[] Columns = Array.ConvertAll(Ids, id => Archetype.Columns[id]);
internal readonly Dictionary<int, ArchetypeEdge> Destinations = [];
}
}
Expand Down
Loading