From 55e02fd6993b0845a9c47477aacc64e0ad6ed327 Mon Sep 17 00:00:00 2001 From: Stephan van Stekelenburg Date: Fri, 30 Oct 2026 23:38:41 +0100 Subject: [PATCH] perf: write spawned bundles in place --- ROADMAP.md | 9 ++- benchmarks/step8-in-place-spawn.md | 87 +++++++++++++++++++++++++++ docs/guides/world-and-entities.mdx | 2 +- src/Archevolt/Storage/Descriptors.cs | 39 ++++++++++-- src/Archevolt/World.cs | 54 ++++++++--------- tests/Archevolt.Tests/StorageTests.cs | 60 ++++++++++++++++++ 6 files changed, 213 insertions(+), 38 deletions(-) create mode 100644 benchmarks/step8-in-place-spawn.md diff --git a/ROADMAP.md b/ROADMAP.md index 28b8c90..8d2699d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. @@ -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` 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` 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` 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. diff --git a/benchmarks/step8-in-place-spawn.md b/benchmarks/step8-in-place-spawn.md new file mode 100644 index 0000000..b80b22d --- /dev/null +++ b/benchmarks/step8-in-place-spawn.md @@ -0,0 +1,87 @@ +# Step 8: write immediate spawns in place + +This file records [ROADMAP.md](../ROADMAP.md) step 8. `World.Spawn` and `World.SpawnBatch` 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` 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`. diff --git a/docs/guides/world-and-entities.mdx b/docs/guides/world-and-entities.mdx index ff7841a..32aaf71 100644 --- a/docs/guides/world-and-entities.mdx +++ b/docs/guides/world-and-entities.mdx @@ -12,7 +12,7 @@ sidebar: | API | Behavior | | --- | --- | | `Spawn()` | Creates an entity with no components. | -| `Spawn(in bundle)` | Creates an entity with the registered bundle's components. | +| `Spawn(in bundle)` | Creates an entity with the registered bundle's components. If the bundle fails to write, no entity is created. | | `SpawnBatch(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(entity, in value)` | Adds or replaces one registered component. | | `InsertBundle(entity, in bundle)` | Adds or replaces bundle members and preserves unrelated components. | diff --git a/src/Archevolt/Storage/Descriptors.cs b/src/Archevolt/Storage/Descriptors.cs index 7339a29..3c09b92 100644 --- a/src/Archevolt/Storage/Descriptors.cs +++ b/src/Archevolt/Storage/Descriptors.cs @@ -79,10 +79,15 @@ public interface IBundle where TSelf : unmanaged, IBundle 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 _payload; + private readonly Table? _table; + private readonly int[]? _columns; + private readonly int _row; + private readonly ulong _tick; private int _member; private int _offset; @@ -90,8 +95,16 @@ internal BundleWriter(BundleDescriptor descriptor, Span 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(in T value) where T : unmanaged @@ -101,15 +114,29 @@ public void Put(in T value) where T : unmanaged var component = _descriptor.Components[_member]; if (component.TypeKey != TypeKey.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(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."); } } diff --git a/src/Archevolt/World.cs b/src/Archevolt/World.cs index 88839c9..6138beb 100644 --- a/src/Archevolt/World.cs +++ b/src/Archevolt/World.cs @@ -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(in TBundle bundle) where TBundle : unmanaged, IBundle { RequireIdle(); - if (!_bundles.TryGetValue(TypeKey.Value, out var binding)) - throw new InvalidOperationException($"Bundle {typeof(TBundle).Name} is not registered."); - byte[]? rented = null; - Span payload = binding.Descriptor.PayloadBytes <= 1024 - ? stackalloc byte[binding.Descriptor.PayloadBytes] - : (rented = ArrayPool.Shared.Rent(binding.Descriptor.PayloadBytes)).AsSpan(0, binding.Descriptor.PayloadBytes); + var binding = GetBundle(); + 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.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. @@ -112,23 +112,19 @@ public void SpawnBatch(ReadOnlySpan bundles, Span enti return; var archetype = binding.Archetype; var table = archetype.Table; - byte[]? rented = null; - Span payload = binding.Descriptor.PayloadBytes <= 1024 - ? stackalloc byte[binding.Descriptor.PayloadBytes] - : (rented = ArrayPool.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 @@ -139,11 +135,7 @@ public void SpawnBatch(ReadOnlySpan bundles, Span enti entities.Clear(); throw; } - finally - { - if (rented is not null) - ArrayPool.Shared.Return(rented); - } + finally { _running = false; } _tick = tick; for (int i = 0; i < entities.Length; i++) Entities.Materialize(entities[i], archetype.Index, first + i); @@ -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 Destinations = []; } } diff --git a/tests/Archevolt.Tests/StorageTests.cs b/tests/Archevolt.Tests/StorageTests.cs index 68cd064..9a7ce23 100644 --- a/tests/Archevolt.Tests/StorageTests.cs +++ b/tests/Archevolt.Tests/StorageTests.cs @@ -359,6 +359,66 @@ public void Batch_spawn_that_fails_partway_leaves_no_row_or_reservation() Assert.Throws(() => world.SpawnBatch([new(new(1), new(1))], new Entity[2])); } + private readonly record struct Partial(Position Position, Velocity Velocity) : IBundle + { + public static BundleDescriptor Descriptor { get; } = BundleDescriptor.Create( + ComponentDescriptor.Of(tracked: true), ComponentDescriptor.Of()); + public static void Write(in Partial value, ref BundleWriter writer) => writer.Put(value.Position); + } + + [Fact] + public void Spawn_that_fails_partway_leaves_no_row_or_reservation() + { + using var world = new WorldBuilder().AddBundle().AddBundle().Build(); + Entity survivor = world.Spawn(new Fragile(new Position(7), new Velocity(7))); + + Assert.Throws(() => world.Spawn(new Fragile(new Position(1), new Velocity(-1)))); + Assert.Throws(() => world.Spawn(new Partial(new Position(2), new Velocity(2)))); + + var seen = new List(); + using var all = Collect(world, QueryShape.Create("all").Read(0).Build(), seen); + all.Run(TestContext.Current.CancellationToken); + Assert.Equal([7], seen); + Assert.Equal(7, world.Get(survivor).Value); + + Entity next = world.Spawn(new Fragile(new Position(3), new Velocity(3))); + Assert.Equal(1, next.Index); + Assert.Equal(3u, next.Generation); + Assert.Equal(3, world.Get(next).Value); + Assert.Equal(3, world.Get(next).Value); + } + + private readonly record struct Reentrant(Position Position) : IBundle + { + [ThreadStatic] internal static World? Target; + public static BundleDescriptor Descriptor { get; } = BundleDescriptor.Create(ComponentDescriptor.Of()); + public static void Write(in Reentrant value, ref BundleWriter writer) + { + writer.Put(value.Position); + Target?.Spawn(new Reentrant(new Position(-1))); + } + } + + [Fact] + public void Bundle_write_cannot_spawn_into_the_world_it_is_writing_to() + { + using var world = new WorldBuilder().AddBundle().Build(); + Entity survivor = world.Spawn(new Reentrant(new Position(7))); + Reentrant.Target = world; + try + { + Assert.Throws(() => world.Spawn(new Reentrant(new Position(1)))); + Assert.Throws(() => world.SpawnBatch([new(new(2)), new(new(3))], new Entity[2])); + } + finally { Reentrant.Target = null; } + + var seen = new List(); + using var all = Collect(world, QueryShape.Create("all").Read(0).Build(), seen); + all.Run(TestContext.Current.CancellationToken); + Assert.Equal([7], seen); + Assert.Equal(7, world.Get(survivor).Value); + } + [Fact] public void Bundle_insertion_over_members_already_present_overwrites_them_without_moving_other_rows() {