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
11 changes: 10 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, and 5 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`.
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`.

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 @@ -228,3 +228,12 @@ Follow-up, not yet a numbered step: [step3-partition-granularity.md](benchmarks/
- The owner flushes pending claims before command playback, before commands are discarded, and when a run exits. Idle-world reservation and release assume that no claim is pending.
- `SpawnBatch` still stages each bundle and binary-searches each member's column, once per entity. Step 6 removes the search. Step 8 originally assumed that batch spawn already wrote in place, so its scope and gate now name `SpawnBatch` too.
- At 100,000 entities, batch spawn takes 3.7 ms against Bevy's recorded 3.2 ms, and individual spawn 5.9 ms against 4.8 ms. Despawn is about twice as fast as Bevy's recorded figure at both sizes.

## Amendments after step 6

- Step 6 asks for a dense component-to-column lookup for `Get<T>` and bundle writes. `Archetype.Columns` serves every caller: `Get<T>`, `TryGet<T>`, `WriteBundle`, `ApplyInsert`, and query matching. The amendment after step 4 suggested that `BundleBinding` hold its destination columns. A dense lookup already costs one array read, so the binding does not hold them.
- Transition edges stay in the per-archetype dictionaries, now as `ArchetypeEdge` values that carry the target and its column map. Bundle insertions cache the same edge type per source archetype. A tag or wide round trip still pays two dictionary lookups and two row moves.
- Archetypes derive their sorted component IDs from their masks. A transition flips one bit of a cloned mask instead of building an ID list with LINQ.
- 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.
82 changes: 82 additions & 0 deletions benchmarks/step6-cached-column-maps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Step 6: cached column maps and bitmask archetype keys

This file records [ROADMAP.md](../ROADMAP.md) step 6. Structural moves, bundle writes, inserts, and `Get<T>` no longer binary-search an archetype's component IDs. Archetypes are interned by their component bitmask instead of a joined string.

## Change

- **Dense column lookup.** Each `Archetype` holds `Columns`, one entry per registered component, with the component's table column or -1. `Get<T>`, `TryGet<T>`, `WriteBundle`, `ApplyInsert`, and query matching read it instead of calling `Array.BinarySearch`.
- **Column maps on transition edges.** An add or remove transition caches an `ArchetypeEdge`: the target archetype and, for each source column, the target column or -1. A bundle insertion caches the same edge per source archetype in `BundleBinding.Destinations`. `Table.CopyRowTo` copies through that map with no lookup. Swap-removal already copied index for index within one table, so it is unchanged.
- **Bitmask interning.** The world keys archetypes by their `ulong[]` component mask. A transition clones the source mask and sets or clears one bit. A bundle destination ORs two masks. An archetype derives its sorted IDs from its mask. The LINQ ID lists that transitions built on a cache miss are gone.
- **Empty archetype.** The world creates the empty archetype when it is built, so `Spawn()` no longer builds a key string on every call.
- **One directory read in `TryGet`.** `EntityTable.TryLocate` checks the handle and returns the archetype ID and row from one entry binding. `IsAlive` calls it and discards the location.

## 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 | `8a9dd22`, the tip of step 5 |
| After | The same commit with this change applied |

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 21:00 and 21:19 UTC on 29 October 2026. Earlier steps ran three launches of one side and then three of the other. Interleaving spreads machine drift across both sides. 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 |
| --- | ---: | ---: | ---: | --- |
| `Get(Count=1000)` | 7.847 [7.695, 8.090] | 5.663 [5.629, 5.751] | 0.72 | faster |
| `Get(Count=100000)` | 1,702 [1,698, 1,801] | 1,316 [1,315, 1,324] | 0.77 | faster |
| `TagRoundTrip(Count=1000)` | 90.92 [90.11, 104.0] | 86.64 [85.62, 88.72] | 0.95 | faster |
| `TagRoundTrip(Count=100000)` | 9,095 [8,965, 9,364] | 8,700 [8,513, 8,707] | 0.96 | faster |
| `WideRoundTrip(Count=1000)` | 96.73 [96.32, 98.02] | 92.30 [92.14, 93.46] | 0.95 | faster |
| `WideRoundTrip(Count=100000)` | 10,210 [9,973, 10,250] | 9,182 [9,086, 9,446] | 0.90 | faster |
| `Spawn(Count=1000)` | 47.30 [46.20, 47.80] | 42.33 [41.23, 42.68] | 0.89 | faster |
| `Spawn(Count=100000)` | 5,856 [5,598, 6,046] | 5,140 [5,064, 5,179] | 0.88 | faster |
| `SpawnBatch(Count=1000)` | 29.47 [29.27, 31.01] | 26.39 [25.89, 27.26] | 0.90 | faster |
| `SpawnBatch(Count=100000)` | 3,647 [3,594, 3,713] | 3,227 [3,208, 3,240] | 0.88 | faster |
| `Despawn(Count=1000)` | 24.15 [23.66, 25.74] | 23.83 [23.21, 24.14] | 0.99 | within |
| `Despawn(Count=100000)` | 4,679 [3,573, 4,843] | 4,480 [3,563, 4,851] | 0.96 | within |

`Get` gains the most, because it loses both a binary search and a second directory read. The round trips gain less, because each one still pays two row moves, two swap-removals, and two transition-dictionary lookups. Spawn and batch spawn gain from `WriteBundle`'s direct column lookup. Despawn does not touch any changed path.

Managed allocation per operation did not change in any case. `Get`, `TagRoundTrip`, and `WideRoundTrip` allocate nothing. `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 |
| --- | ---: | ---: |
| `Get<T>`, 1,000 | 5.663 | 4.171 |
| `Get<T>`, 100,000 | 1,316 | 832.1 |
| Batch spawn, 100,000 | 3,227 | 3,222 |

## Correctness

The structural reference-model trace test, `Structural_trace_matches_dictionary_across_table_growth_migration_and_swap_removal`, passes unchanged. So does `Membership_extends_beyond_one_machine_word`, which interns archetypes whose masks span two words. `Warmed_structural_moves_allocate_no_managed_memory` still passes.

One test is new. `Bundle_insertion_over_members_already_present_overwrites_them_without_moving_other_rows` inserts a bundle whose members the entity already has. That takes the edge whose target is its own source, writes in place, and must leave a second row in the same table untouched.

`scripts/verify.ps1` passed every check.

## Gate

| Gate item | Result |
| --- | --- |
| Step 0a's tag and wide-component round trips improve | Met at both sizes. |
| `Get<T>` improves | Met at both sizes. |
| The reference-model trace tests pass | Met. |

## Saved evidence

The interleaving script, its run list, the merged before and after summaries, and the comparison are preserved locally under `artifacts/step6-cached-column-maps/`. The six raw runs are in the measurement worktree under `artifacts/baseline/20261029-210050` through `20261029-211644`.
2 changes: 1 addition & 1 deletion docs/concepts/architecture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ The runtime owns entity allocation, native storage, query caches, parameter bind

An entity contains its world identity, slot, and generation. A paged entity table records free, reserved, alive, and retired states.

A frozen registry assigns world-local component IDs. Each archetype holds canonical sorted component IDs and one native structure-of-arrays table. A table is a single 64-byte-aligned allocation that doubles when full and keeps its capacity when rows leave. `World.TrimExcess()` releases that retained capacity. Moving an entity between archetypes copies retained values and ticks, initializes additions, and repairs the moved entity after swap removal.
A frozen registry assigns world-local component IDs. Each archetype holds canonical sorted component IDs, a component-to-column lookup, and one native structure-of-arrays table. The world interns archetypes by their component bitmask. Each cached add, remove, or bundle transition stores the column mapping for its move, so a structural change copies columns without searching for them. A table is a single 64-byte-aligned allocation that doubles when full and keeps its capacity when rows leave. `World.TrimExcess()` releases that retained capacity. Moving an entity between archetypes copies retained values and ticks, initializes additions, and repairs the moved entity after swap removal.

## Safe access

Expand Down
24 changes: 14 additions & 10 deletions src/Archevolt/Commands/Commands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -229,22 +229,25 @@ private void ApplyBundle(Entity entity, BundleBinding binding, ReadOnlySpan<byte
RequireAlive(entity);
ref var entry = ref Entities.Entry(entity.Index);
var source = Archetypes[entry.ArchetypeId];
if (!binding.Destinations.TryGetValue(source.Index, out var destination))
if (!binding.Destinations.TryGetValue(source.Index, out var edge))
{
destination = GetArchetype(source.Ids.Union(binding.Ids).Order().ToArray());
binding.Destinations.Add(source.Index, destination);
var mask = (ulong[])source.Mask.Clone();
for (int word = 0; word < mask.Length; word++)
mask[word] |= binding.Archetype.Mask[word];
edge = source.EdgeTo(GetArchetype(mask));
binding.Destinations.Add(source.Index, edge);
}
if (source != destination)
Move(entity, destination);
WriteBundle(binding, payload, destination, entry.Row, source, tick);
if (edge.Target != source)
Move(entity, edge);
WriteBundle(binding, payload, edge.Target, entry.Row, source, tick);
}

private static void WriteBundle(BundleBinding binding, ReadOnlySpan<byte> payload, Archetype target, int row, Archetype? source, ulong tick)
{
int offset = 0;
for (int i = 0; i < binding.Ids.Length; i++)
{
int column = Array.BinarySearch(target.Ids, binding.Ids[i]);
int column = target.Columns[binding.Ids[i]];
var component = binding.Descriptor.Components[i];
if (!component.IsTag)
payload.Slice(offset, component.Size).CopyTo(target.Table.ValueBytes(column, row));
Expand All @@ -263,11 +266,12 @@ private void ApplyInsert(Entity entity, int id, ReadOnlySpan<byte> payload, ulon
bool added = !archetype.Has(id);
if (added)
{
archetype = Transition(archetype, id, add: true);
Move(entity, archetype);
var edge = Transition(archetype, id, add: true);
Move(entity, edge);
archetype = edge.Target;
}
var table = archetype.Table;
int column = Array.BinarySearch(archetype.Ids, id);
int column = archetype.Columns[id];
if (!Registry.Components[id].IsTag)
payload.CopyTo(table.ValueBytes(column, entry.Row));
Stamp(table, column, entry.Row, tick, added);
Expand Down
8 changes: 7 additions & 1 deletion src/Archevolt/Entities/Entity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,17 @@ internal void Flush()
Entry(Append()).State = EntityState.Reserved;
}

internal bool IsAlive(Entity entity, long world)
internal bool IsAlive(Entity entity, long world) => TryLocate(entity, world, out _, out _);

internal bool TryLocate(Entity entity, long world, out int archetypeId, out int row)
{
archetypeId = 0;
row = 0;
if (entity.WorldId != world || (uint)entity.Index >= (uint)Volatile.Read(ref _count))
return false;
ref var entry = ref Entry(entity.Index);
archetypeId = entry.ArchetypeId;
row = entry.Row;
return entry.Generation == entity.Generation && entry.State == EntityState.Alive;
}

Expand Down
2 changes: 1 addition & 1 deletion src/Archevolt/Queries/QueryState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ internal void Refresh()
foreach (int id in _excluded)
if (archetype.Has(id)) { matches = false; break; }
if (matches)
Matches.Add(new QueryMatch(archetype, ComponentIds.Select(id => Array.BinarySearch(archetype.Ids, id)).ToArray()));
Matches.Add(new QueryMatch(archetype, ComponentIds.Select(id => archetype.Columns[id]).ToArray()));
}
}

Expand Down
61 changes: 45 additions & 16 deletions src/Archevolt/Storage/Tables.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
using System.Numerics;
using System.Runtime.InteropServices;

namespace Archevolt;

internal sealed unsafe class Table : IDisposable
{
private readonly int[] _ids;
private readonly nint[] _values;
private readonly nint[] _added;
private readonly nint[] _changed;
Expand All @@ -15,9 +15,8 @@ internal sealed unsafe class Table : IDisposable
internal int Capacity { get; private set; }
internal long Bytes { get; private set; }

internal Table(int[] ids, ComponentDescriptor[] components)
internal Table(ComponentDescriptor[] components)
{
_ids = ids;
Components = components;
_values = new nint[components.Length];
_added = new nint[components.Length];
Expand Down Expand Up @@ -69,11 +68,11 @@ internal void SwapRemove(int row, EntityTable entities)
Count--;
}

internal void CopyRowTo(int source, Table target, int destination)
internal void CopyRowTo(int source, Table target, int destination, int[] targetColumns)
{
for (int i = 0; i < _ids.Length; i++)
for (int i = 0; i < targetColumns.Length; i++)
{
int targetColumn = Array.BinarySearch(target._ids, _ids[i]);
int targetColumn = targetColumns[i];
if (targetColumn < 0)
continue;
if (!Components[i].IsTag)
Expand Down Expand Up @@ -153,22 +152,52 @@ internal sealed class Archetype
internal readonly ulong[] Mask;
internal readonly ComponentDescriptor[] Components;
internal readonly Table Table;
internal readonly Dictionary<int, Archetype> AddTransitions = [];
internal readonly Dictionary<int, Archetype> RemoveTransitions = [];
internal readonly int[] Columns;
internal readonly Dictionary<int, ArchetypeEdge> AddTransitions = [];
internal readonly Dictionary<int, ArchetypeEdge> RemoveTransitions = [];

internal Archetype(int index, int[] ids, ComponentRegistry registry)
internal Archetype(int index, ulong[] mask, ComponentRegistry registry)
{
Index = index;
Ids = ids;
Mask = new ulong[(registry.Components.Length + 63) / 64];
Components = new ComponentDescriptor[ids.Length];
for (int i = 0; i < ids.Length; i++)
Mask = mask;
var ids = new List<int>();
for (int word = 0; word < mask.Length; word++)
for (ulong bits = mask[word]; bits != 0; bits &= bits - 1)
ids.Add(word * 64 + BitOperations.TrailingZeroCount(bits));
Ids = ids.ToArray();
Components = new ComponentDescriptor[Ids.Length];
Columns = new int[registry.Components.Length];
Array.Fill(Columns, -1);
for (int i = 0; i < Ids.Length; i++)
{
Mask[ids[i] / 64] |= 1UL << (ids[i] % 64);
Components[i] = registry.Components[ids[i]];
Components[i] = registry.Components[Ids[i]];
Columns[Ids[i]] = i;
}
Table = new Table(ids, Components);
Table = new Table(Components);
}

internal bool Has(int component) => (Mask[component / 64] & (1UL << (component % 64))) != 0;

internal ArchetypeEdge EdgeTo(Archetype target)
{
var columns = new int[Ids.Length];
for (int i = 0; i < Ids.Length; i++)
columns[i] = target.Columns[Ids[i]];
return new ArchetypeEdge(target, columns);
}
}

// TargetColumns[i] is the target table's column for this archetype's column i, or -1 when the target lacks it.
internal sealed record ArchetypeEdge(Archetype Target, int[] TargetColumns);

internal sealed class ArchetypeMaskComparer : IEqualityComparer<ulong[]>
{
internal static readonly ArchetypeMaskComparer Instance = new();
public bool Equals(ulong[]? x, ulong[]? y) => x.AsSpan().SequenceEqual(y);
public int GetHashCode(ulong[] mask)
{
var hash = new HashCode();
hash.AddBytes(MemoryMarshal.AsBytes(mask.AsSpan()));
return hash.ToHashCode();
}
}
Loading