From 1740917f252f248a1426937da7ec796766f864df Mon Sep 17 00:00:00 2001 From: Stephan van Stekelenburg Date: Tue, 27 Oct 2026 22:14:39 +0100 Subject: [PATCH 1/5] test(benchmarks): add named-query traversal case Step 3's gate compares the named query over an unread 256-byte payload with the chunked baseline. The traversal matrix measured only [Each]. Co-Authored-By: Claude Opus 5.5 --- benchmarks/Archevolt.Benchmarks/ScenarioBenchmarks.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/benchmarks/Archevolt.Benchmarks/ScenarioBenchmarks.cs b/benchmarks/Archevolt.Benchmarks/ScenarioBenchmarks.cs index 6daca63..ed2cb01 100644 --- a/benchmarks/Archevolt.Benchmarks/ScenarioBenchmarks.cs +++ b/benchmarks/Archevolt.Benchmarks/ScenarioBenchmarks.cs @@ -7,6 +7,7 @@ public class TraversalMatrixBenchmarks { private World _world = null!; private BoundSchedule _schedule = null!; + private BoundSchedule _query = null!; [Params(1_000, 100_000, 1_000_000)] public int Count { get; set; } [Params(1, 16)] public int Archetypes { get; set; } [Params(false, true)] public bool Wide { get; set; } @@ -18,10 +19,13 @@ public void Setup() _world = MatrixPopulation.Build(); MatrixPopulation.Populate(_world, Count, Archetypes, Wide); _schedule = EachMovement.Create(_world); + _query = QueryMovement.Create(_world); _schedule.Run(); + _query.Run(); } [Benchmark] public void GeneratedEach() => _schedule.Run(); - [GlobalCleanup] public void Cleanup() { _schedule?.Dispose(); _world?.Dispose(); } + [Benchmark] public void GeneratedQuery() => _query.Run(); + [GlobalCleanup] public void Cleanup() { _schedule?.Dispose(); _query?.Dispose(); _world?.Dispose(); } } [MemoryDiagnoser, MinColumn, MaxColumn, MedianColumn] From f12fbeb26230e74d428350e38baf5c8e5bf7feb8 Mon Sep 17 00:00:00 2001 From: Stephan van Stekelenburg Date: Tue, 27 Oct 2026 22:17:54 +0100 Subject: [PATCH 2/5] build(benchmarks): move BenchmarkDotNet to 0.16.0-preview.2 BenchmarkDotNet 0.15.8 cannot run on .NET 11. Its SDK validator throws NotImplementedException from GetRuntimeVersion for the unrecognised net11.0 runtime moniker, so no benchmark starts. 0.16.0-preview.2 runs on 11.0.0-rc.2 and exports the same JSON fields scripts/baseline.ps1 reads. Co-Authored-By: Claude Opus 5.5 --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 3b977a6..7fd3704 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,7 +5,7 @@ - + From ffabaeb2ea9686d26f1621c9a7837d31c982b677 Mon Sep 17 00:00:00 2001 From: Stephan van Stekelenburg Date: Tue, 27 Oct 2026 22:18:29 +0100 Subject: [PATCH 3/5] build(benchmarks): add baseline comparison script Each roadmap step lands with before and after numbers. The script joins two baseline.ps1 summaries and marks each case faster, slower, or within the before range. Co-Authored-By: Claude Opus 5.5 --- scripts/compare-baselines.ps1 | 55 +++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 scripts/compare-baselines.ps1 diff --git a/scripts/compare-baselines.ps1 b/scripts/compare-baselines.ps1 new file mode 100644 index 0000000..ef7236a --- /dev/null +++ b/scripts/compare-baselines.ps1 @@ -0,0 +1,55 @@ +#Requires -Version 7.2 +<# +.SYNOPSIS +Compares two scripts/baseline.ps1 result directories case by case. + +.DESCRIPTION +Joins the summary.csv of a before run and an after run on harness and case. For each case it +reports both medians and ranges, the after/before ratio, and a verdict. "faster" and "slower" +mean the after median lies outside the before range. "within" means it lies inside it, which +the measurement rules treat as no change. Cases present on one side only are listed as such. + +Writes comparison.md next to the after run's summary and prints it. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $Before, + [Parameter(Mandatory)] [string] $After +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +[Threading.Thread]::CurrentThread.CurrentCulture = [Globalization.CultureInfo]::InvariantCulture + +function Read-Summary([string] $Directory) { + $rows = @{} + foreach ($row in Import-Csv -LiteralPath (Join-Path $Directory 'summary.csv')) { + $rows["$($row.Harness)|$($row.Case)"] = $row + } + $rows +} + +$old = Read-Summary $Before +$new = Read-Summary $After +$lines = @("Before: $Before", '', "After: $After", '', + '| Harness | Case | Before µs [range] | After µs [range] | After/before | Verdict |', + '| --- | --- | ---: | ---: | ---: | --- |') +foreach ($key in @($old.Keys + $new.Keys | Sort-Object -Unique)) { + $b = $old[$key] + $a = $new[$key] + $harness, $case = $key -split '\|', 2 + if (-not $b -or -not $a) { + $only = if ($b) { 'before only' } else { 'after only' } + $lines += "| $harness | ``$case`` | | | | $only |" + continue + } + $ratio = [double]$a.MedianUs / [double]$b.MedianUs + $verdict = if ([double]$a.MedianUs -lt [double]$b.MinUs) { 'faster' } + elseif ([double]$a.MedianUs -gt [double]$b.MaxUs) { 'slower' } + else { 'within' } + $lines += [string]::Format('| {0} | `{1}` | {2:G4} [{3:G4}–{4:G4}] | {5:G4} [{6:G4}–{7:G4}] | {8:F2} | {9} |', + $harness, $case, [double]$b.MedianUs, [double]$b.MinUs, [double]$b.MaxUs, + [double]$a.MedianUs, [double]$a.MinUs, [double]$a.MaxUs, $ratio, $verdict) +} +$lines | Set-Content -LiteralPath (Join-Path $After 'comparison.md') +$lines From e8621ff0ed973dc157cffd47857d4163281dd875 Mon Sep 17 00:00:00 2001 From: Stephan van Stekelenburg Date: Tue, 27 Oct 2026 22:25:35 +0100 Subject: [PATCH 4/5] refactor(storage): replace 16 KiB chunks with per-archetype tables Each archetype now owns one Table: a single 64-byte-aligned native slab holding the entity column and every value and tick column, doubling when full and never cleared. Tables keep their capacity when emptied; World.TrimExcess() releases it. NativeBytes sums table allocations. Parallel Each partitions by contiguous row ranges across matched tables, so a serial query sees one block per non-empty table. ChunkView becomes BlockView, SystemExecutionException reports FirstRow/RowCount, and the generators emit `block` locals. Removes ChunkPool, ChunkLayout, NativeChunk, the directory's chunk reference, and WorldBuilder.WithChunkBytes. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- docs/concepts/architecture.mdx | 8 +- docs/guides/execution.mdx | 6 +- docs/guides/systems.mdx | 2 +- docs/guides/world-and-entities.mdx | 5 +- docs/index.mdx | 2 +- src/Archevolt.Generators/QueryGeneration.cs | 10 +- src/Archevolt.Generators/SystemGeneration.cs | 8 +- src/Archevolt/Commands/Commands.cs | 25 ++- src/Archevolt/Entities/Entity.cs | 9 +- src/Archevolt/Execution/BoundSchedule.cs | 24 +- src/Archevolt/Queries/QueryState.cs | 86 +++---- src/Archevolt/Storage/Chunks.cs | 211 ------------------ src/Archevolt/Storage/Tables.cs | 167 ++++++++++++++ src/Archevolt/Systems/Systems.cs | 16 +- src/Archevolt/World.cs | 54 +++-- .../GeneratorTests.cs | 18 +- tests/Archevolt.Tests/CommandTests.cs | 14 +- tests/Archevolt.Tests/QueryRuntimeTests.cs | 32 +-- .../Archevolt.Tests/ScheduleExecutionTests.cs | 26 +-- tests/Archevolt.Tests/StorageTests.cs | 115 +++++++++- 21 files changed, 452 insertions(+), 388 deletions(-) delete mode 100644 src/Archevolt/Storage/Chunks.cs create mode 100644 src/Archevolt/Storage/Tables.cs diff --git a/README.md b/README.md index 81bb2ff..44216b0 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ Build, run, mutate, and dispose a world on its owner thread. Idle component read Dispose every bound schedule before disposing its world. `schedule.Plan` exposes dependencies and batches. Opt into per-system execution measurements with `schedule.EnableTiming()` and inspect `schedule.Timings` between runs. -Serial execution is the default. Enable a bounded worker pool with `.UseParallelExecution(maxConcurrency: 4)` on `WorldBuilder`. This limit includes the owner thread. Opt eligible systems into chunk partitioning with `[Each(Partitioned = true)]`. Resource and component conflicts determine which systems can run together. +Serial execution is the default. Enable a bounded worker pool with `.UseParallelExecution(maxConcurrency: 4)` on `WorldBuilder`. This limit includes the owner thread. Opt eligible systems into row-range partitioning with `[Each(Partitioned = true)]`. Resource and component conflicts determine which systems can run together. `[TrackChanges]` opts components and resources into tracking. Writable access stamps a change even when the replacement value is equal. Added and changed filters use each system instance's last successful execution. diff --git a/docs/concepts/architecture.mdx b/docs/concepts/architecture.mdx index 06b5c30..aef57af 100644 --- a/docs/concepts/architecture.mdx +++ b/docs/concepts/architecture.mdx @@ -14,7 +14,7 @@ The runtime owns entity allocation, native storage, query caches, parameter bind | Generated at compile time | Handled at run time | | --- | --- | | Named query rows, enumerators, and shapes | Which archetypes currently match a query | -| Direct calls to your system methods | Chunk traversal and partitioning | +| Direct calls to your system methods | Table traversal and row-range partitioning | | Bundle descriptors and module manifests | World registration and validation | | Declarative schedule plans | Binding, barriers, and command playback | @@ -24,13 +24,13 @@ Diagnostics `AV001` through `AV010` report contract violations during compilatio 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. Archetypes contain canonical sorted component IDs and native structure-of-arrays chunks. 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 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. ## Safe access -`SystemBody` holds bindings for one system instance. `SystemBinder` validates bindings against the descriptor. `SystemRun` is a runtime-created ref struct carrying the instance, invocation epoch, tick interval, and assigned chunk range. +`SystemBody` holds bindings for one system instance. `SystemBinder` validates bindings against the descriptor. `SystemRun` is a runtime-created ref struct carrying the instance, invocation epoch, tick interval, and assigned row range. -`QueryState` belongs to one instance. The owner refreshes its matching-archetype cache before dispatch. A chunk exposes bounded readonly spans, bounded untracked writable spans, and tracked accessors that stamp before returning writable references. +`QueryState` belongs to one instance. The owner refreshes its matching-archetype cache before dispatch. A block view covers a row range of one table and exposes bounded readonly spans, bounded untracked writable spans, and tracked accessors that stamp before returning writable references. Column binding checks component identity, access mode, and tracking policy. Equal byte sizes do not establish type identity, and callers cannot supply arbitrary span lengths. diff --git a/docs/guides/execution.mdx b/docs/guides/execution.mdx index ed04e7c..a96bb09 100644 --- a/docs/guides/execution.mdx +++ b/docs/guides/execution.mdx @@ -9,7 +9,7 @@ sidebar: Serial execution is the default. `WorldBuilder.UseParallelExecution(maxConcurrency)` enables the world-owned reusable worker pool. The positive limit includes the owner thread. -A bound schedule borrows this pool, so disposing one schedule does not stop other schedules. Resource and component conflicts determine which systems can run together, and `[Each(Partitioned = true)]` opts eligible systems into [chunk partitioning](/guides/systems#partitioning). +A bound schedule borrows this pool, so disposing one schedule does not stop other schedules. Resource and component conflicts determine which systems can run together, and `[Each(Partitioned = true)]` opts eligible systems into [row-range partitioning](/guides/systems#partitioning). ```csharp using var world = new WorldBuilder() @@ -24,11 +24,11 @@ Parallel dispatch has material overhead on tiny workloads. Measure before enabli ## Running and cancelling -`schedule.Run(CancellationToken)` completes synchronously. Cancellation stops new work between systems or chunk ranges. Active user code must return before join completes. +`schedule.Run(CancellationToken)` completes synchronously. Cancellation stops new work between systems or row ranges. Active user code must return before join completes. ## Faults -The owner reports execution failures after active workers join. `AggregateException` contains `SystemExecutionException` entries with the instance, system name, and chunk range. +The owner reports execution failures after active workers join. `AggregateException` contains `SystemExecutionException` entries with the instance, system name, and row range (`FirstRow`, `RowCount`). Failure or cancellation sets `IsFaulted`. Completed writes, local changes, message cursors, and earlier command playback remain. Unapplied commands are discarded. diff --git a/docs/guides/systems.mdx b/docs/guides/systems.mdx index b802b1a..9f0d49e 100644 --- a/docs/guides/systems.mdx +++ b/docs/guides/systems.mdx @@ -33,7 +33,7 @@ public static partial class MotionKernel ## Partitioning -`[Each]` executes one traversal by default. `[Each(Partitioned = true)]` opts into splitting matched chunks across the world's execution budget, which matters once [parallel execution](/guides/execution) is enabled. +`[Each]` executes one traversal by default. `[Each(Partitioned = true)]` opts into splitting the matched rows into contiguous ranges, one per worker in the world's execution budget, which matters once [parallel execution](/guides/execution) is enabled. Partitioned methods accept component refs, `Entity`, and readonly unmanaged resources, including optional resources without owner-thread affinity. Locals, commands, messages, managed resources, mutable resources, and named queries are excluded. An unsafe partition request is reported as [`AV010`](/reference/diagnostics). diff --git a/docs/guides/world-and-entities.mdx b/docs/guides/world-and-entities.mdx index 66103a1..5c8a127 100644 --- a/docs/guides/world-and-entities.mdx +++ b/docs/guides/world-and-entities.mdx @@ -20,7 +20,8 @@ sidebar: | `Contains(entity)` | Reports whether the handle identifies a live entity in this world. | | `Has(entity)` | Tests component membership on a live entity. | | `Get(entity)` / `TryGet(entity, out value)` | Returns a component copy. Tags use `Has`. | -| `NativeBytes` | Reports native bytes held by the world's chunk allocator. | +| `NativeBytes` | Reports native bytes held by the world's component tables, including retained capacity. | +| `TrimExcess()` | Shrinks every table to its live rows and frees the tables that are empty. | ## Entity identity @@ -32,7 +33,7 @@ World inspection and immediate mutation require an idle world and its owner thre ## Capacity -`Reserve(entities, commandBytesPerProducer, messagesPerType)` reserves initial capacities. Capacities can grow. `WithChunkBytes(bytes)` sets the target chunk size, with a minimum of 64 bytes. Large component layouts can require a larger allocation for a single row. +`Reserve(entities, commandBytesPerProducer, messagesPerType)` reserves initial capacities. Capacities can grow. Each archetype stores its components in one table that doubles its capacity when full. A table keeps its capacity when entities leave, so later spawns and moves into it do not allocate. Call `TrimExcess()` on an idle world to release that retained memory. ## Disposal diff --git a/docs/index.mdx b/docs/index.mdx index 17e2487..d5cf49b 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -34,7 +34,7 @@ The generated loop calls `Motion.Move` directly for every entity that has both c - **Typed queries.** Named queries produce concrete rows with `ref` and `ref readonly` access. Nothing is boxed or looked up by type at run time. - **Declarative schedules.** Order systems with `Order`, `Before`, `After`, stages, and sets. The planner adds conservative ordering where access conflicts. -- **Native storage.** Components live in dense, aligned, structure-of-arrays chunks grouped by archetype. +- **Native storage.** Components live in dense, aligned, structure-of-arrays tables, one per archetype. - **Opt-in parallelism.** Serial execution is the reference behavior. Enable a bounded worker pool and mark eligible systems as partitioned. - **Change tracking, resources, messages.** Track added and changed components, share resources, and send messages with independent reader cursors. - **AOT-friendly.** The package runs under JIT, trimming, and NativeAOT. diff --git a/src/Archevolt.Generators/QueryGeneration.cs b/src/Archevolt.Generators/QueryGeneration.cs index 19efbc0..31ac905 100644 --- a/src/Archevolt.Generators/QueryGeneration.cs +++ b/src/Archevolt.Generators/QueryGeneration.cs @@ -62,14 +62,14 @@ static string Element(TermModel term, string row) => term.Mode == "Write" && ter static string BlockColumn(TermModel term) => term.Mode == "Read" ? $"block.Read<{term.Component.Type}>({term.Index})" : term.Component.Tracked ? $"block.Tracked<{term.Component.Type}>({term.Index}).WriteAll()" : $"block.Write<{term.Component.Type}>({term.Index})"; - static void BindColumns(StringBuilder text, IReadOnlyList terms, string targetPrefix = "", string chunk = "chunk", bool declarations = false) + static void BindColumns(StringBuilder text, IReadOnlyList terms, string targetPrefix = "", bool declarations = false) { foreach (var term in terms.Where(static term => term.Mode is "Read" or "Write" or "Optional")) { var variable = targetPrefix + ColumnName(term); if (declarations) text.Append(ColumnType(term)).Append(' ').Append(variable).AppendLine(";"); - if (term.Mode == "Optional") text.Append(chunk).Append(".TryRead<").Append(term.Component.Type).Append(">(").Append(term.Index).Append(", out ").Append(variable).AppendLine(");"); - else text.Append(variable).Append(" = ").Append(chunk).Append('.').Append(term.Mode == "Read" ? "Read" : term.Component.Tracked ? "Tracked" : "Write") + if (term.Mode == "Optional") text.Append("block.TryRead<").Append(term.Component.Type).Append(">(").Append(term.Index).Append(", out ").Append(variable).AppendLine(");"); + else text.Append(variable).Append(" = block.").Append(term.Mode == "Read" ? "Read" : term.Component.Tracked ? "Tracked" : "Write") .Append('<').Append(term.Component.Type).Append(">(").Append(term.Index).AppendLine(");"); } } @@ -101,9 +101,9 @@ static GeneratedUnit GenerateQuery(INamedTypeSymbol symbol, CancellationToken ca text.AppendLine("public bool MoveNext()\n{\nwhile (true)\n{\nif (++_row < _count)\n{"); if (filters.Length != 0) text.Append("if (!(").Append(string.Join(" && ", filters.Select((_, i) => $"_cursor.Window.Contains(_ticks{i}[_row])"))).AppendLine(")) continue;"); - text.AppendLine("return true;\n}\nif (!_cursor.Next(out var chunk)) return false;\n_count = chunk.Count;\n_row = -1;\n_entities = chunk.Entities;"); + text.AppendLine("return true;\n}\nif (!_cursor.Next(out var block)) return false;\n_count = block.Count;\n_row = -1;\n_entities = block.Entities;"); BindColumns(text, terms); - for (int i = 0; i < filters.Length; i++) text.Append("_ticks").Append(i).Append(" = chunk.").Append(filters[i].Mode).Append('(').Append(filters[i].Index).AppendLine(");"); + for (int i = 0; i < filters.Length; i++) text.Append("_ticks").Append(i).Append(" = block.").Append(filters[i].Mode).Append('(').Append(filters[i].Index).AppendLine(");"); text.AppendLine("}\n}"); text.Append("public Row Current => new(").Append(string.Join(", ", values.Select(ColumnName).Concat(new[] { "_entities", "_row" }))).AppendLine(");\n}"); text.AppendLine("public readonly ref struct Row\n{\nreadonly global::Archevolt.EntityColumn _entities;\nreadonly int _row;"); diff --git a/src/Archevolt.Generators/SystemGeneration.cs b/src/Archevolt.Generators/SystemGeneration.cs index 6c2875b..cd147f7 100644 --- a/src/Archevolt.Generators/SystemGeneration.cs +++ b/src/Archevolt.Generators/SystemGeneration.cs @@ -204,12 +204,12 @@ static GeneratedUnit GenerateSystem(IMethodSymbol method, CancellationToken canc text.Append("var ").Append(parameter.Name).Append(" = _message").Append(parameter.Index).Append(parameter.Kind == "MessageWriter" ? ".Write(run);" : ".Read(run);").AppendLine(); if (model.Form == SystemForm.Each) { - text.AppendLine("var cursor = _query0.Begin(run);\nwhile (cursor.Next(out var chunk))\n{"); + text.AppendLine("var cursor = _query0.Begin(run);\nwhile (cursor.Next(out var block))\n{"); BindColumns(text, model.Queries[0], declarations: true); var filters = model.Queries[0].Where(static term => term.Mode is "Added" or "Changed").ToArray(); - for (int index = 0; index < filters.Length; index++) text.Append("var ticks").Append(index).Append(" = chunk.").Append(filters[index].Mode).Append('(').Append(filters[index].Index).AppendLine(");"); - if (model.Parameters.Any(static parameter => parameter.Kind == "Entity")) text.AppendLine("var entities = chunk.Entities;"); - text.AppendLine("int count = chunk.Count;\nfor (int row = 0; row < count; row++)\n{"); + for (int index = 0; index < filters.Length; index++) text.Append("var ticks").Append(index).Append(" = block.").Append(filters[index].Mode).Append('(').Append(filters[index].Index).AppendLine(");"); + if (model.Parameters.Any(static parameter => parameter.Kind == "Entity")) text.AppendLine("var entities = block.Entities;"); + text.AppendLine("int count = block.Count;\nfor (int row = 0; row < count; row++)\n{"); if (filters.Length != 0) text.Append("if (!(").Append(string.Join(" && ", filters.Select((_, index) => $"cursor.Window.Contains(ticks{index}[row])"))).AppendLine(")) continue;"); text.Append(Escape(method.Name)).Append('(').Append(string.Join(", ", model.Parameters.Select(parameter => parameter.Kind switch { diff --git a/src/Archevolt/Commands/Commands.cs b/src/Archevolt/Commands/Commands.cs index b76e769..d839aa1 100644 --- a/src/Archevolt/Commands/Commands.cs +++ b/src/Archevolt/Commands/Commands.cs @@ -217,9 +217,9 @@ private void ApplySpawn(Entity entity, BundleBinding binding, ReadOnlySpan ref var reservation = ref Entities.Entry(entity.Index); if (entity.WorldId != Id || reservation.Generation != entity.Generation || reservation.State != EntityState.Reserved) throw new InvalidOperationException("Spawn reservation is no longer valid."); - var location = binding.Archetype.Append(_pool, new EntityKey(entity.Index, entity.Generation)); - WriteBundle(binding, payload, location.Chunk, location.Row, null, tick); - Entities.Materialize(entity, binding.Archetype, location.Chunk, location.Row); + int row = binding.Archetype.Table.Append(new EntityKey(entity.Index, entity.Generation)); + WriteBundle(binding, payload, binding.Archetype, row, null, tick); + Entities.Materialize(entity, binding.Archetype, row); } private void ApplyBundle(Entity entity, BundleBinding binding, ReadOnlySpan payload, ulong tick) @@ -234,19 +234,19 @@ private void ApplyBundle(Entity entity, BundleBinding binding, ReadOnlySpan payload, NativeChunk chunk, int row, Archetype? source, ulong tick) + private static void WriteBundle(BundleBinding binding, ReadOnlySpan 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(chunk.Archetype.Ids, binding.Ids[i]); + int column = Array.BinarySearch(target.Ids, binding.Ids[i]); var component = binding.Descriptor.Components[i]; if (!component.IsTag) - payload.Slice(offset, component.Size).CopyTo(chunk.ValueBytes(column, row)); - Stamp(chunk, column, row, tick, source is null || !source.Has(binding.Ids[i])); + payload.Slice(offset, component.Size).CopyTo(target.Table.ValueBytes(column, row)); + Stamp(target.Table, column, row, tick, source is null || !source.Has(binding.Ids[i])); offset += component.Size; } } @@ -260,10 +260,11 @@ private void ApplyInsert(Entity entity, int id, ReadOnlySpan payload, ulon bool added = !entry.Archetype!.Has(id); if (added) Move(entity, Transition(entry.Archetype!, id, add: true)); - int column = Array.BinarySearch(entry.Archetype!.Ids, id); + var table = entry.Archetype!.Table; + int column = Array.BinarySearch(entry.Archetype.Ids, id); if (!Registry.Components[id].IsTag) - payload.CopyTo(entry.Chunk!.ValueBytes(column, entry.Row)); - Stamp(entry.Chunk!, column, entry.Row, tick, added); + payload.CopyTo(table.ValueBytes(column, entry.Row)); + Stamp(table, column, entry.Row, tick, added); } private bool ApplyRemove(Entity entity, int id, ulong tick, bool optional) @@ -291,7 +292,7 @@ private bool ApplyDespawn(Entity entity, ulong tick, bool optional) ref var entry = ref Entities.Entry(entity.Index); foreach (var component in entry.Archetype!.Components) RecordRemoval(component.TypeKey, entity, tick); - entry.Archetype!.Remove(entry.Chunk!, entry.Row, Entities); + entry.Archetype!.Table.SwapRemove(entry.Row, Entities); Entities.Release(entity.Index); return true; } diff --git a/src/Archevolt/Entities/Entity.cs b/src/Archevolt/Entities/Entity.cs index 273666f..73b9af8 100644 --- a/src/Archevolt/Entities/Entity.cs +++ b/src/Archevolt/Entities/Entity.cs @@ -23,7 +23,6 @@ internal struct EntityEntry internal uint Generation; internal EntityState State; internal Archetype? Archetype; - internal NativeChunk? Chunk; internal int Row; } @@ -77,20 +76,19 @@ internal bool IsAlive(Entity entity, long world) => && Entry(entity.Index).Generation == entity.Generation && Entry(entity.Index).State == EntityState.Alive; - internal void Materialize(Entity entity, Archetype archetype, NativeChunk chunk, int row) + internal void Materialize(Entity entity, Archetype archetype, int row) { ref var entry = ref Entry(entity.Index); if (entry.State != EntityState.Reserved || entry.Generation != entity.Generation) throw new InvalidOperationException("The entity reservation is no longer valid."); - Relocate(entity.Index, archetype, chunk, row); + Relocate(entity.Index, archetype, row); entry.State = EntityState.Alive; } - internal void Relocate(int index, Archetype archetype, NativeChunk chunk, int row) + internal void Relocate(int index, Archetype archetype, int row) { ref var entry = ref Entry(index); entry.Archetype = archetype; - entry.Chunk = chunk; entry.Row = row; } @@ -112,7 +110,6 @@ private void ReleaseCore(int index) { ref var entry = ref Entry(index); entry.Archetype = null; - entry.Chunk = null; entry.Row = 0; if (entry.Generation == uint.MaxValue) entry.State = EntityState.Retired; diff --git a/src/Archevolt/Execution/BoundSchedule.cs b/src/Archevolt/Execution/BoundSchedule.cs index 3118d09..a73168c 100644 --- a/src/Archevolt/Execution/BoundSchedule.cs +++ b/src/Archevolt/Execution/BoundSchedule.cs @@ -208,13 +208,13 @@ private void RunBatch(PlanStep step) } else if (instance.Descriptor.Partitionable && _world.MaxConcurrency > 1) { - int chunks = instance.Queries[0]!.TotalChunks; - int partitions = Math.Max(1, Math.Min(chunks, _world.MaxConcurrency)); + int rows = instance.Queries[0]!.TotalRows; + int partitions = Math.Max(1, Math.Min(rows, _world.MaxConcurrency)); _remaining[node] = partitions; for (int partition = 0; partition < partitions; partition++) { - int first = (int)((long)chunks * partition / partitions); - int end = (int)((long)chunks * (partition + 1) / partitions); + int first = (int)((long)rows * partition / partitions); + int end = (int)((long)rows * (partition + 1) / partitions); _work[workCount++] = new WorkItem(node, first, end - first); } } @@ -279,14 +279,14 @@ private void Execute(WorkItem work) if (timed) Interlocked.Increment(ref _timingAccumulators[work.Node].BodyInvocations); try { - instance.Body.Execute(new SystemRun(instance, work.FirstChunk, work.ChunkCount)); + instance.Body.Execute(new SystemRun(instance, work.FirstRow, work.RowCount)); } catch (Exception exception) { if (timed) Interlocked.Increment(ref _timingAccumulators[work.Node].BodyFailures); Interlocked.Exchange(ref _failed[work.Node], 1); Interlocked.Exchange(ref _batchFailed, 1); - throw new SystemExecutionException(instance.Key, instance.Descriptor.Name, work.FirstChunk, work.ChunkCount, exception); + throw new SystemExecutionException(instance.Key, instance.Descriptor.Name, work.FirstRow, work.RowCount, exception); } finally { @@ -315,7 +315,7 @@ public void Dispose() _world.Schedules.Remove(this); } - private readonly record struct WorkItem(int Node, int FirstChunk, int ChunkCount); + private readonly record struct WorkItem(int Node, int FirstRow, int RowCount); private struct TimingAccumulator { internal int BodyInvocations; @@ -346,17 +346,17 @@ public abstract class ConditionBody : SystemBody public sealed class SystemExecutionException : Exception { - internal SystemExecutionException(string instanceKey, string systemName, int firstChunk, int chunkCount, Exception inner) + internal SystemExecutionException(string instanceKey, string systemName, int firstRow, int rowCount, Exception inner) : base($"System '{instanceKey}' failed.", inner) { InstanceKey = instanceKey; SystemName = systemName; - FirstChunk = firstChunk; - ChunkCount = chunkCount; + FirstRow = firstRow; + RowCount = rowCount; } public string InstanceKey { get; } public string SystemName { get; } - public int FirstChunk { get; } - public int ChunkCount { get; } + public int FirstRow { get; } + public int RowCount { get; } } diff --git a/src/Archevolt/Queries/QueryState.cs b/src/Archevolt/Queries/QueryState.cs index 1e08842..a15f8ae 100644 --- a/src/Archevolt/Queries/QueryState.cs +++ b/src/Archevolt/Queries/QueryState.cs @@ -51,10 +51,10 @@ public QueryCursor Begin(SystemRun run) return new QueryCursor(this, run); } - public bool TryLocate(SystemRun run, Entity entity, out ChunkView chunk, out int row) + public bool TryLocate(SystemRun run, Entity entity, out BlockView block, out int row) { run.Require(Owner); - chunk = default; + block = default; row = 0; if (run.IsPartitioned) throw new InvalidOperationException("Entity lookup is not allowed in a partitioned invocation."); @@ -64,20 +64,20 @@ public bool TryLocate(SystemRun run, Entity entity, out ChunkView chunk, out int foreach (var match in Matches) if (match.Archetype == entry.Archetype) { - chunk = new ChunkView(this, match, entry.Chunk!, run); + block = new BlockView(this, match, 0, match.Archetype.Table.Count, run); row = entry.Row; - return chunk.MatchesRow(row); + return block.MatchesRow(row); } return false; } - internal int TotalChunks + internal int TotalRows { get { int count = 0; foreach (var match in Matches) - count = checked(count + match.Archetype.Chunks.Count); + count = checked(count + match.Archetype.Table.Count); return count; } } @@ -90,16 +90,14 @@ public ref struct QueryCursor private readonly QueryState? _state; private readonly SystemRun _run; private int _match; - private int _chunk; - private int _ordinal; + private long _offset; internal QueryCursor(QueryState state, SystemRun run) { _state = state; _run = run; _match = 0; - _chunk = 0; - _ordinal = 0; + _offset = 0; } public TickWindow Window => _run.Window; @@ -112,48 +110,49 @@ public QueryCursor Enumerate() return new QueryCursor(_state, _run); } - public bool Next(out ChunkView chunk) + public bool Next(out BlockView block) { - chunk = default; + block = default; if (_state is null) throw new InvalidOperationException("A default query has no active execution."); _run.Require(_state.Owner); + long first = _run.FirstRow; + long end = first + _run.RowCount; while (_match < _state.Matches.Count) { - var match = _state.Matches[_match]; - if (_chunk < match.Archetype.Chunks.Count) - { - var native = match.Archetype.Chunks[_chunk++]; - int ordinal = _ordinal++; - if (ordinal < _run.FirstChunk) - continue; - if ((long)ordinal >= (long)_run.FirstChunk + _run.ChunkCount) - return false; - chunk = new ChunkView(_state, match, native, _run); - return true; - } - _match++; - _chunk = 0; + var match = _state.Matches[_match++]; + long tableStart = _offset; + _offset += match.Archetype.Table.Count; + if (tableStart >= end) + return false; + long start = Math.Max(first, tableStart); + long stop = Math.Min(end, _offset); + if (start >= stop) + continue; + block = new BlockView(_state, match, (int)(start - tableStart), (int)(stop - start), _run); + return true; } return false; } } -public readonly ref struct ChunkView +public readonly ref struct BlockView { private readonly QueryState? _state; private readonly QueryMatch? _match; - private readonly NativeChunk? _chunk; + private readonly Table? _table; private readonly SystemRun _run; + private readonly int _start; private readonly int _count; - internal ChunkView(QueryState state, QueryMatch match, NativeChunk chunk, SystemRun run) + internal BlockView(QueryState state, QueryMatch match, int start, int count, SystemRun run) { _state = state; _match = match; - _chunk = chunk; + _table = match.Archetype.Table; _run = run; - _count = chunk.Count; + _start = start; + _count = count; } public int Count => _count; @@ -163,48 +162,49 @@ public ReadOnlySpan Read(int term) where T : unmanaged int column = Column(term, QueryAccess.Read); if (column < 0) throw new InvalidOperationException("The optional component is absent."); - return _chunk!.Values(column); + return _table!.Values(column).Slice(_start, _count); } public bool TryRead(int term, out ReadOnlySpan values) where T : unmanaged { int column = Column(term, QueryAccess.Read | QueryAccess.Optional); - values = column < 0 ? default : _chunk!.Values(column); + values = column < 0 ? default : _table!.Values(column).Slice(_start, _count); return column >= 0; } public Span Write(int term) where T : unmanaged { int column = Column(term, QueryAccess.Write); - if (_chunk!.Archetype.Components[column].Tracked) + if (_table!.Components[column].Tracked) throw new InvalidOperationException("Tracked values require the stamping accessor."); - return _chunk.Values(column); + return _table.Values(column).Slice(_start, _count); } public TrackedColumn Tracked(int term) where T : unmanaged { int column = Column(term, QueryAccess.Write); - if (!_chunk!.Archetype.Components[column].Tracked) + if (!_table!.Components[column].Tracked) throw new InvalidOperationException("The component is not tracked."); - return new TrackedColumn(_chunk.Values(column), _chunk.Changed(column), _run.Window.Now); + return new TrackedColumn(_table.Values(column).Slice(_start, _count), + _table.Changed(column).Slice(_start, _count), _run.Window.Now); } public ReadOnlySpan Added(int term) => Ticks(term, QueryAccess.Added); public ReadOnlySpan Changed(int term) => Ticks(term, QueryAccess.Changed); - public EntityColumn Entities { get { Validate(); return new(_chunk!.Entities, _state!.Owner.World.Id); } } + public EntityColumn Entities { get { Validate(); return new(_table!.Entities.Slice(_start, _count), _state!.Owner.World.Id); } } public bool MatchesRow(int row) { Validate(); - if ((uint)row >= (uint)_chunk!.Count) + if ((uint)row >= (uint)_count) throw new ArgumentOutOfRangeException(nameof(row)); for (int i = 0; i < _state!.Shape.Terms.Length; i++) { var access = _state.Shape.Terms[i].Access; int column = _match!.Columns[i]; - if ((access & QueryAccess.Added) != 0 && !_run.Window.Contains(_chunk.Added(column)[row])) + if ((access & QueryAccess.Added) != 0 && !_run.Window.Contains(_table!.Added(column)[_start + row])) return false; - if ((access & QueryAccess.Changed) != 0 && !_run.Window.Contains(_chunk.Changed(column)[row])) + if ((access & QueryAccess.Changed) != 0 && !_run.Window.Contains(_table!.Changed(column)[_start + row])) return false; } return true; @@ -227,13 +227,13 @@ private ReadOnlySpan Ticks(int term, QueryAccess kind) if ((uint)term >= (uint)_state!.Shape.Terms.Length || (_state.Shape.Terms[term].Access & kind) == 0) throw new InvalidOperationException("The requested tick access was not declared."); int column = _match!.Columns[term]; - return kind == QueryAccess.Added ? _chunk!.Added(column) : _chunk!.Changed(column); + return (kind == QueryAccess.Added ? _table!.Added(column) : _table!.Changed(column)).Slice(_start, _count); } private void Validate() { if (_state is null) - throw new InvalidOperationException("A default chunk has no active execution."); + throw new InvalidOperationException("A default block has no active execution."); _run.Require(_state.Owner); } } diff --git a/src/Archevolt/Storage/Chunks.cs b/src/Archevolt/Storage/Chunks.cs deleted file mode 100644 index 58a2e5f..0000000 --- a/src/Archevolt/Storage/Chunks.cs +++ /dev/null @@ -1,211 +0,0 @@ -using System.Runtime.InteropServices; - -namespace Archevolt; - -internal sealed unsafe class ChunkPool : IDisposable -{ - private readonly Dictionary> _free = []; - private readonly HashSet _owned = []; - internal long AllocatedBytes { get; private set; } - - internal nint Rent(int bytes) - { - nint address; - if (_free.TryGetValue(bytes, out var available) && available.Count != 0) - address = available.Pop(); - else - { - long allocatedBytes = checked(AllocatedBytes + bytes); - address = (nint)NativeMemory.AlignedAlloc((nuint)bytes, 64); - if (address == 0) - throw new OutOfMemoryException(); - try { _owned.Add(address); } - catch { NativeMemory.AlignedFree((void*)address); throw; } - AllocatedBytes = allocatedBytes; - } - NativeMemory.Clear((void*)address, (nuint)bytes); - return address; - } - - internal void Return(nint address, int bytes) - { - if (!_free.TryGetValue(bytes, out var available)) - _free.Add(bytes, available = new Stack()); - if (available.Count < 64) - available.Push(address); - else - { - _owned.Remove(address); - NativeMemory.AlignedFree((void*)address); - AllocatedBytes -= bytes; - } - } - - public void Dispose() - { - foreach (nint address in _owned) - NativeMemory.AlignedFree((void*)address); - _owned.Clear(); - _free.Clear(); - AllocatedBytes = 0; - } -} - -internal sealed class ChunkLayout -{ - internal readonly int Capacity; - internal readonly int Bytes; - internal readonly int[] Values; - internal readonly int[] Added; - internal readonly int[] Changed; - - internal ChunkLayout(ComponentDescriptor[] components, int targetBytes) - { - long rowBytes = 8; - foreach (var component in components) - rowBytes = checked(rowBytes + component.Size + (component.Tracked ? 16 : 0)); - int capacity = (int)Math.Max(1, targetBytes / rowBytes); - Values = new int[components.Length]; - Added = new int[components.Length]; - Changed = new int[components.Length]; - while (true) - { - int offset = Align(checked(capacity * 8)); - for (int i = 0; i < components.Length; i++) - { - var component = components[i]; - Values[i] = component.IsTag ? -1 : offset; - if (!component.IsTag) - offset = Align(checked(offset + checked(capacity * component.Size))); - Added[i] = component.Tracked ? offset : -1; - if (component.Tracked) - offset = Align(checked(offset + checked(capacity * 8))); - Changed[i] = component.Tracked ? offset : -1; - if (component.Tracked) - offset = Align(checked(offset + checked(capacity * 8))); - } - if (offset <= targetBytes || capacity == 1) - { - Capacity = capacity; - Bytes = offset; - return; - } - capacity--; - } - } - - private static int Align(int value) => checked(value + 63) & ~63; -} - -internal sealed unsafe class NativeChunk : IDisposable -{ - private readonly ChunkPool _pool; - private nint _address; - internal readonly Archetype Archetype; - internal int Count; - - internal NativeChunk(Archetype archetype, ChunkPool pool) - { - Archetype = archetype; - _pool = pool; - _address = pool.Rent(archetype.Layout.Bytes); - } - - internal Span Entities => new((void*)_address, Count); - internal Span Values(int column) where T : unmanaged => - new((byte*)_address + Archetype.Layout.Values[column], Count); - internal Span Added(int column) => new((byte*)_address + Archetype.Layout.Added[column], Count); - internal Span Changed(int column) => new((byte*)_address + Archetype.Layout.Changed[column], Count); - internal Span ValueBytes(int column, int row) => - new((byte*)_address + Archetype.Layout.Values[column] + row * Archetype.Components[column].Size, - Archetype.Components[column].Size); - - internal void CopyRowTo(int source, NativeChunk target, int destination) - { - for (int i = 0; i < Archetype.Ids.Length; i++) - { - int targetColumn = Array.BinarySearch(target.Archetype.Ids, Archetype.Ids[i]); - if (targetColumn < 0) - continue; - if (!Archetype.Components[i].IsTag) - ValueBytes(i, source).CopyTo(target.ValueBytes(targetColumn, destination)); - if (Archetype.Components[i].Tracked) - { - target.Added(targetColumn)[destination] = Added(i)[source]; - target.Changed(targetColumn)[destination] = Changed(i)[source]; - } - } - } - - public void Dispose() - { - if (_address == 0) - return; - _pool.Return(_address, Archetype.Layout.Bytes); - _address = 0; - Count = 0; - } -} - -internal sealed class Archetype -{ - internal readonly int Index; - internal readonly int[] Ids; - internal readonly ulong[] Mask; - internal readonly ComponentDescriptor[] Components; - internal readonly ChunkLayout Layout; - internal readonly List Chunks = []; - internal readonly Dictionary AddTransitions = []; - internal readonly Dictionary RemoveTransitions = []; - - internal Archetype(int index, int[] ids, ComponentRegistry registry, int targetBytes) - { - 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[ids[i] / 64] |= 1UL << (ids[i] % 64); - Components[i] = registry.Components[ids[i]]; - } - Layout = new ChunkLayout(Components, targetBytes); - } - - internal bool Has(int component) => (Mask[component / 64] & (1UL << (component % 64))) != 0; - - internal (NativeChunk Chunk, int Row) Append(ChunkPool pool, EntityKey key) - { - NativeChunk chunk; - if (Chunks.Count == 0 || Chunks[^1].Count == Layout.Capacity) - { - chunk = new NativeChunk(this, pool); - try { Chunks.Add(chunk); } - catch { chunk.Dispose(); throw; } - } - else - chunk = Chunks[^1]; - int row = chunk.Count++; - chunk.Entities[row] = key; - return (chunk, row); - } - - internal void Remove(NativeChunk chunk, int row, EntityTable entities) - { - var last = Chunks[^1]; - int lastRow = last.Count - 1; - if (chunk != last || row != lastRow) - { - last.CopyRowTo(lastRow, chunk, row); - EntityKey moved = last.Entities[lastRow]; - chunk.Entities[row] = moved; - entities.Relocate(moved.Index, this, chunk, row); - } - last.Count--; - if (last.Count == 0) - { - Chunks.RemoveAt(Chunks.Count - 1); - last.Dispose(); - } - } -} diff --git a/src/Archevolt/Storage/Tables.cs b/src/Archevolt/Storage/Tables.cs new file mode 100644 index 0000000..ad13983 --- /dev/null +++ b/src/Archevolt/Storage/Tables.cs @@ -0,0 +1,167 @@ +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; + private nint _slab; + private nint _entities; + internal readonly ComponentDescriptor[] Components; + internal int Count; + internal int Capacity { get; private set; } + internal long Bytes { get; private set; } + + internal Table(int[] ids, ComponentDescriptor[] components) + { + _ids = ids; + Components = components; + _values = new nint[components.Length]; + _added = new nint[components.Length]; + _changed = new nint[components.Length]; + } + + internal Span Entities => new((void*)_entities, Count); + internal Span Values(int column) where T : unmanaged => new((void*)_values[column], Count); + internal Span Added(int column) => new((void*)_added[column], Count); + internal Span Changed(int column) => new((void*)_changed[column], Count); + internal Span ValueBytes(int column, int row) => + new((byte*)_values[column] + (nint)row * Components[column].Size, Components[column].Size); + + internal int Append(EntityKey key) + { + if (Count == Capacity) + Reallocate(Math.Max(8, checked(Capacity * 2))); + int row = Count++; + Entities[row] = key; + return row; + } + + internal void SwapRemove(int row, EntityTable entities) + { + int last = Count - 1; + if (row != last) + { + for (int i = 0; i < Components.Length; i++) + { + if (!Components[i].IsTag) + ValueBytes(i, last).CopyTo(ValueBytes(i, row)); + if (Components[i].Tracked) + { + Added(i)[row] = Added(i)[last]; + Changed(i)[row] = Changed(i)[last]; + } + } + EntityKey moved = Entities[last]; + Entities[row] = moved; + entities.Entry(moved.Index).Row = row; + } + Count--; + } + + internal void CopyRowTo(int source, Table target, int destination) + { + for (int i = 0; i < _ids.Length; i++) + { + int targetColumn = Array.BinarySearch(target._ids, _ids[i]); + if (targetColumn < 0) + continue; + if (!Components[i].IsTag) + ValueBytes(i, source).CopyTo(target.ValueBytes(targetColumn, destination)); + if (Components[i].Tracked) + { + target.Added(targetColumn)[destination] = Added(i)[source]; + target.Changed(targetColumn)[destination] = Changed(i)[source]; + } + } + } + + internal void Trim() + { + if (Count != Capacity) + Reallocate(Count); + } + + private void Reallocate(int capacity) + { + long bytes = Place(capacity, 0, move: false); + // Not cleared: every caller writes each value and tick of an appended row before anything reads it. + nint slab = bytes == 0 ? 0 : (nint)NativeMemory.AlignedAlloc(checked((nuint)bytes), 64); + Place(capacity, slab, move: true); + NativeMemory.AlignedFree((void*)_slab); + _slab = slab; + Capacity = capacity; + Bytes = bytes; + } + + private long Place(int capacity, nint slab, bool move) + { + long offset = 0; + Column(ref _entities, sizeof(EntityKey)); + for (int i = 0; i < Components.Length; i++) + { + if (!Components[i].IsTag) + Column(ref _values[i], Components[i].Size); + if (Components[i].Tracked) + { + Column(ref _added[i], sizeof(ulong)); + Column(ref _changed[i], sizeof(ulong)); + } + } + return offset; + + void Column(ref nint column, int size) + { + if (move) + { + nint target = slab + (nint)offset; + Buffer.MemoryCopy((void*)column, (void*)target, (long)capacity * size, (long)Count * size); + column = target; + } + offset = checked(offset + (long)capacity * size + 63) & ~63L; + } + } + + public void Dispose() + { + NativeMemory.AlignedFree((void*)_slab); + _slab = 0; + _entities = 0; + Array.Clear(_values); + Array.Clear(_added); + Array.Clear(_changed); + Count = 0; + Capacity = 0; + Bytes = 0; + } +} + +internal sealed class Archetype +{ + internal readonly int Index; + internal readonly int[] Ids; + internal readonly ulong[] Mask; + internal readonly ComponentDescriptor[] Components; + internal readonly Table Table; + internal readonly Dictionary AddTransitions = []; + internal readonly Dictionary RemoveTransitions = []; + + internal Archetype(int index, int[] ids, 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[ids[i] / 64] |= 1UL << (ids[i] % 64); + Components[i] = registry.Components[ids[i]]; + } + Table = new Table(ids, Components); + } + + internal bool Has(int component) => (Mask[component / 64] & (1UL << (component % 64))) != 0; +} diff --git a/src/Archevolt/Systems/Systems.cs b/src/Archevolt/Systems/Systems.cs index afb8ceb..d6f4682 100644 --- a/src/Archevolt/Systems/Systems.cs +++ b/src/Archevolt/Systems/Systems.cs @@ -191,17 +191,17 @@ public readonly ref struct SystemRun private readonly SystemInstance? _instance; private readonly ulong _epoch; public TickWindow Window { get; } - internal int FirstChunk { get; } - internal int ChunkCount { get; } - internal bool IsPartitioned => FirstChunk != 0 || ChunkCount != int.MaxValue; + internal int FirstRow { get; } + internal int RowCount { get; } + internal bool IsPartitioned => FirstRow != 0 || RowCount != int.MaxValue; - internal SystemRun(SystemInstance instance, int firstChunk = 0, int chunkCount = int.MaxValue) + internal SystemRun(SystemInstance instance, int firstRow = 0, int rowCount = int.MaxValue) { _instance = instance; _epoch = instance.Epoch; Window = new TickWindow(instance.LastRun, instance.ThisRun); - FirstChunk = firstChunk; - ChunkCount = chunkCount; + FirstRow = firstRow; + RowCount = rowCount; } internal void Require(SystemInstance? owner) @@ -259,14 +259,14 @@ internal void Prepare() query?.Refresh(); } - internal SystemRun Begin(int firstChunk = 0, int chunkCount = int.MaxValue) + internal SystemRun Begin() { if (Active) throw new InvalidOperationException("The system instance is already executing."); Epoch = checked(Epoch + 1); ThisRun = World.NextTick(); Active = true; - return new SystemRun(this, firstChunk, chunkCount); + return new SystemRun(this); } internal void Complete(bool success) diff --git a/src/Archevolt/World.cs b/src/Archevolt/World.cs index c11e58b..613042e 100644 --- a/src/Archevolt/World.cs +++ b/src/Archevolt/World.cs @@ -8,8 +8,6 @@ public sealed partial class World : IDisposable private static readonly object s_identityGate = new(); private static long s_nextWorld; private readonly int _ownerThread = Environment.CurrentManagedThreadId; - private readonly int _chunkBytes; - private readonly ChunkPool _pool = new(); private readonly Dictionary _archetypeByKey = new(StringComparer.Ordinal); private readonly Dictionary _bundles = []; private ulong _tick; @@ -27,7 +25,10 @@ public long NativeBytes get { RequireIdle(); - return _pool.AllocatedBytes; + long bytes = 0; + foreach (var archetype in Archetypes) + bytes += archetype.Table.Bytes; + return bytes; } } @@ -35,7 +36,6 @@ internal World(WorldBuilder builder) { lock (s_identityGate) Id = s_nextWorld = checked(s_nextWorld + 1); - _chunkBytes = builder.ChunkBytes; MaxConcurrency = builder.MaxConcurrency; CommandCapacity = builder.CommandBytes; Registry = new ComponentRegistry(builder.Components); @@ -61,8 +61,8 @@ public Entity Spawn() try { var archetype = GetArchetype([]); - var location = archetype.Append(_pool, new EntityKey(entity.Index, entity.Generation)); - Entities.Materialize(entity, archetype, location.Chunk, location.Row); + int row = archetype.Table.Append(new EntityKey(entity.Index, entity.Generation)); + Entities.Materialize(entity, archetype, row); return entity; } catch { Entities.Release(entity.Index); throw; } @@ -147,7 +147,7 @@ public bool TryGet(Entity entity, out T value) where T : unmanaged int column = Array.BinarySearch(entry.Archetype!.Ids, id); if (column < 0) return false; - value = entry.Chunk!.Values(column)[entry.Row]; + value = entry.Archetype.Table.Values(column)[entry.Row]; return true; } @@ -182,6 +182,13 @@ public bool Despawn(Entity entity) return ApplyDespawn(entity, NextTick(), optional: true); } + public void TrimExcess() + { + RequireIdle(); + foreach (var archetype in Archetypes) + archetype.Table.Trim(); + } + internal void RequireIdle() { RequireOwner(); @@ -227,7 +234,7 @@ private Archetype GetArchetype(int[] ids) string key = string.Join(",", ids); if (_archetypeByKey.TryGetValue(key, out var result)) return result; - result = new Archetype(Archetypes.Count, ids, Registry, _chunkBytes); + result = new Archetype(Archetypes.Count, ids, Registry); _archetypeByKey.Add(key, result); Archetypes.Add(result); return result; @@ -247,22 +254,21 @@ private Archetype Transition(Archetype source, int id, bool add) private void Move(Entity entity, Archetype destination) { ref var entry = ref Entities.Entry(entity.Index); - var source = entry.Archetype!; - var sourceChunk = entry.Chunk!; + var source = entry.Archetype!.Table; int sourceRow = entry.Row; - var location = destination.Append(_pool, new EntityKey(entity.Index, entity.Generation)); - sourceChunk.CopyRowTo(sourceRow, location.Chunk, location.Row); - source.Remove(sourceChunk, sourceRow, Entities); - Entities.Relocate(entity.Index, destination, location.Chunk, location.Row); + int row = destination.Table.Append(new EntityKey(entity.Index, entity.Generation)); + source.CopyRowTo(sourceRow, destination.Table, row); + source.SwapRemove(sourceRow, Entities); + Entities.Relocate(entity.Index, destination, row); } - private static void Stamp(NativeChunk chunk, int column, int row, ulong tick, bool added) + private static void Stamp(Table table, int column, int row, ulong tick, bool added) { - if (!chunk.Archetype.Components[column].Tracked) + if (!table.Components[column].Tracked) return; if (added) - chunk.Added(column)[row] = tick; - chunk.Changed(column)[row] = tick; + table.Added(column)[row] = tick; + table.Changed(column)[row] = tick; } public void Dispose() @@ -275,9 +281,7 @@ public void Dispose() throw new InvalidOperationException("Dispose all bound schedules before disposing their world."); _workers?.Dispose(); foreach (var archetype in Archetypes) - foreach (var chunk in archetype.Chunks) - chunk.Dispose(); - _pool.Dispose(); + archetype.Table.Dispose(); _disposed = true; DisposeResources(); } @@ -294,7 +298,6 @@ public sealed partial class WorldBuilder internal readonly List Components = []; internal readonly List Bundles = []; internal readonly List Resources = []; - internal int ChunkBytes = 16 * 1024; internal int EntityCapacity; internal int MaxConcurrency = 1; internal int CommandBytes = 4096; @@ -354,12 +357,5 @@ public WorldBuilder Reserve(int entities = 0, int commandBytesPerProducer = 0, i return this; } - public WorldBuilder WithChunkBytes(int bytes) - { - ArgumentOutOfRangeException.ThrowIfLessThan(bytes, 64); - ChunkBytes = bytes; - return this; - } - public World Build() => new(this); } diff --git a/tests/Archevolt.Generators.Tests/GeneratorTests.cs b/tests/Archevolt.Generators.Tests/GeneratorTests.cs index 3f2ca34..ca00e4c 100644 --- a/tests/Archevolt.Generators.Tests/GeneratorTests.cs +++ b/tests/Archevolt.Generators.Tests/GeneratorTests.cs @@ -45,7 +45,7 @@ public static class Acceptance public static int Run() { Systems.RequireOverlap = {{(requireOverlap ? "true" : "false")}}; - using var world = new WorldBuilder().UseParallelExecution(4).WithChunkBytes(256).AddModule(Game.Module).Build(); + using var world = new WorldBuilder().UseParallelExecution(4).AddModule(Game.Module).Build(); var entities = new Entity[10000]; for (int index = 0; index < entities.Length; index++) entities[index] = world.Spawn(new Particle(new(index))); using var simulation = Simulation.Create(world); simulation.Run(); @@ -94,7 +94,7 @@ public static class Acceptance public static int Run() { Systems.RequireOverlap = {{(requireOverlap ? "true" : "false")}}; - using var world = new WorldBuilder().UseParallelExecution(4).WithChunkBytes(256).AddModule(Game.Module).Build(); + using var world = new WorldBuilder().UseParallelExecution(4).AddModule(Game.Module).Build(); var entities = new Entity[10000]; for (int index = 0; index < entities.Length; index++) entities[index] = world.Spawn(new Particle(new(index))); using var simulation = Simulation.Create(world); simulation.Run(); @@ -516,6 +516,7 @@ public void EachBlock_moves_every_block_and_skips_excluded_archetypes() [Component] public readonly record struct Position(int Value); [Component] public readonly record struct Velocity(int Value); [Component] public readonly struct Frozen { } + [Component] public readonly struct Marked { } [Bundle] public readonly partial record struct Particle(Position Position, Velocity Velocity); public static partial class Motion { @@ -527,18 +528,19 @@ private static void Integrate(scoped Span positions, scoped ReadOnlySp for (int i = 0; i < positions.Length; i++) positions[i] = new(positions[i].Value + velocities[i].Value); } } - [EcsModule, IncludeSystems(typeof(Motion)), IncludeBundle] public static partial class Game { } + [EcsModule, IncludeSystems(typeof(Motion)), IncludeBundle, IncludeComponent] public static partial class Game { } [Schedule, Run(typeof(Motion), "Integrate")] public sealed partial class Simulation { } public static class Acceptance { public static int Run() { - using var world = new WorldBuilder().WithChunkBytes(256).AddModule(Game.Module).Build(); + using var world = new WorldBuilder().AddModule(Game.Module).Build(); var entities = new Entity[10000]; for (int i = 0; i < entities.Length; i++) entities[i] = world.Spawn(new Particle(new(i), new(i % 7 + 1))); world.Insert(entities[5000], new Frozen()); + world.Insert(entities[7000], new Marked()); using var simulation = Simulation.Create(world); simulation.Run(); - if (Motion.Blocks < 2) return -1; + if (Motion.Blocks != 2) return -1; if (world.Get(entities[5000]).Value != 5000) return -2; int checksum = 0; for (int i = 0; i < entities.Length; i++) checksum += world.Get(entities[i]).Value; @@ -614,7 +616,7 @@ public static class Acceptance { public static int Run() { - using var world = new WorldBuilder().WithChunkBytes(256).AddModule(Game.Module).Build(); + using var world = new WorldBuilder().AddModule(Game.Module).Build(); for (int i = 0; i < 1000; i++) world.Spawn(new Particle(new(i))); using var toucher = Toucher.Create(world); using var looker = Looker.Create(world); using var observer = Observer.Create(world); observer.Run(); Systems.Seen = 0; @@ -696,7 +698,7 @@ public static class Acceptance { public static int Run() { - using var world = new WorldBuilder().WithChunkBytes(256).AddModule(Game.Module).Build(); + using var world = new WorldBuilder().AddModule(Game.Module).Build(); for (int i = 0; i < 1000; i++) world.Spawn(new Particle(new(i))); using var drifter = Drifter.Create(world); using var observer = Observer.Create(world); observer.Run(); Systems.Sum = 0; @@ -795,7 +797,7 @@ public static class Acceptance { public static int Run() { - using var world = new WorldBuilder().WithChunkBytes(256).AddModule(Game.Module).Build(); + using var world = new WorldBuilder().AddModule(Game.Module).Build(); var entities = new Entity[1000]; for (int i = 0; i < entities.Length; i++) entities[i] = world.Spawn(new Particle(new(i))); using var simulation = Simulation.Create(world); simulation.Run(); diff --git a/tests/Archevolt.Tests/CommandTests.cs b/tests/Archevolt.Tests/CommandTests.cs index 3bea067..2409f39 100644 --- a/tests/Archevolt.Tests/CommandTests.cs +++ b/tests/Archevolt.Tests/CommandTests.cs @@ -66,12 +66,12 @@ public void Deferred_spawn_is_visible_through_transitive_skipped_dependency_and_ { consumerSaw = 0; var cursor = query.Begin(run); - while (cursor.Next(out var chunk)) - for (int row = 0; row < chunk.Count; row++) - if (chunk.MatchesRow(row)) + while (cursor.Next(out var block)) + for (int row = 0; row < block.Count; row++) + if (block.MatchesRow(row)) { consumerSaw++; - consumerValue = chunk.Read(0)[row].Value; + consumerValue = block.Read(0)[row].Value; } }; var producer = SystemDescriptor.Create("producer", b => new CommandBody(b, produce, query: true)).Query(added).Commands().Build(); @@ -214,9 +214,9 @@ public void Parallel_producers_reserve_unique_handles_across_entity_page_growth( private static int Count(QueryCursor cursor) { int result = 0; - while (cursor.Next(out var chunk)) - for (int row = 0; row < chunk.Count; row++) - if (chunk.MatchesRow(row)) + while (cursor.Next(out var block)) + for (int row = 0; row < block.Count; row++) + if (block.MatchesRow(row)) result++; return result; } diff --git a/tests/Archevolt.Tests/QueryRuntimeTests.cs b/tests/Archevolt.Tests/QueryRuntimeTests.cs index feb8937..d299e27 100644 --- a/tests/Archevolt.Tests/QueryRuntimeTests.cs +++ b/tests/Archevolt.Tests/QueryRuntimeTests.cs @@ -33,10 +33,10 @@ protected override void Run(SystemRun run) { var step = _step.Read(run); var cursor = _query.Begin(run); - while (cursor.Next(out var chunk)) + while (cursor.Next(out var block)) { - var positions = chunk.Tracked(0); - var velocities = chunk.Read(1); + var positions = block.Tracked(0); + var velocities = block.Read(1); for (int row = 0; row < positions.Length; row++) { ref Position position = ref positions.Write(row); @@ -63,7 +63,7 @@ protected override void Run(SystemRun run) } private static World CreateWorld() => new WorldBuilder().AddBundle().AddComponent() - .AddComponent(tag: true).AddResource().WithChunkBytes(256).Build(); + .AddComponent(tag: true).AddResource().Build(); [Fact] public void Bound_query_observes_new_archetypes_and_tag_membership_on_the_next_run() @@ -122,9 +122,9 @@ public void Tracking_observes_equal_writes_preserves_added_and_ignores_structura QueryAction touch = (run, query) => { var cursor = query.Begin(run); - while (cursor.Next(out var chunk)) + while (cursor.Next(out var block)) { - var reference = chunk.Tracked(0).At(0); + var reference = block.Tracked(0).At(0); if (acquire) { ref Position position = ref reference.Value; @@ -145,9 +145,9 @@ public void Tracking_observes_equal_writes_preserves_added_and_ignores_structura private static int Count(QueryCursor cursor) { int count = 0; - while (cursor.Next(out var chunk)) - for (int row = 0; row < chunk.Count; row++) - if (chunk.MatchesRow(row)) + while (cursor.Next(out var block)) + for (int row = 0; row < block.Count; row++) + if (block.MatchesRow(row)) count++; return count; } @@ -163,11 +163,11 @@ public void Optional_only_query_matches_absent_values_and_binds_presence_per_arc QueryAction observe = (run, query) => { var cursor = query.Begin(run); - while (cursor.Next(out var chunk)) + while (cursor.Next(out var block)) { - bool has = chunk.TryRead(0, out var values); - for (int row = 0; row < chunk.Count; row++) - observed[chunk.Entities[row]] = has ? values[row].Value : null; + bool has = block.TryRead(0, out var values); + for (int row = 0; row < block.Count; row++) + observed[block.Entities[row]] = has ? values[row].Value : null; } }; var shape = QueryShape.Create("optional").Optional(0).Build(); @@ -188,9 +188,9 @@ public void Execution_capabilities_reject_undeclared_access_and_immediate_mutati QueryAction check = (run, query) => { var cursor = query.Begin(run); - Assert.True(cursor.Next(out var chunk)); - try { chunk.Write(0); } catch (InvalidOperationException) { rejected++; } - try { chunk.Read(0); } catch (InvalidOperationException) { rejected++; } + Assert.True(cursor.Next(out var block)); + try { block.Write(0); } catch (InvalidOperationException) { rejected++; } + try { block.Read(0); } catch (InvalidOperationException) { rejected++; } try { query.Begin(default); } catch (InvalidOperationException) { rejected++; } Assert.Throws(() => world.Insert(entity, new Position(99))); Assert.Throws(() => world.Dispose()); diff --git a/tests/Archevolt.Tests/ScheduleExecutionTests.cs b/tests/Archevolt.Tests/ScheduleExecutionTests.cs index 6d6af88..1ea6c8a 100644 --- a/tests/Archevolt.Tests/ScheduleExecutionTests.cs +++ b/tests/Archevolt.Tests/ScheduleExecutionTests.cs @@ -220,7 +220,7 @@ public void Managed_resource_systems_execute_exclusively_on_the_owner() [InlineData(4)] public void Partitioned_each_updates_every_entity_once_and_uses_one_tick_per_invocation(int budget) { - using var world = new WorldBuilder().AddComponent(tracked: true).WithChunkBytes(128).UseParallelExecution(budget).Build(); + using var world = new WorldBuilder().AddComponent(tracked: true).UseParallelExecution(budget).Build(); var entities = Enumerable.Range(0, 200).Select(i => { var entity = world.Spawn(); @@ -590,11 +590,11 @@ protected override bool Evaluate(SystemRun run) { int count = 0; var cursor = query.Begin(run); - while (cursor.Next(out var chunk)) + while (cursor.Next(out var block)) { - var positions = chunk.Read(0); - for (int row = 0; row < chunk.Count; row++) - if (chunk.MatchesRow(row) && positions[row].Value == 7) count++; + var positions = block.Read(0); + for (int row = 0; row < block.Count; row++) + if (block.MatchesRow(row) && positions[row].Value == 7) count++; } counts?.Add(count); return result && count > 0; @@ -606,8 +606,8 @@ protected override void Run(SystemRun run) { int count = 0; var cursor = query.Begin(run); - while (cursor.Next(out var chunk)) - for (int row = 0; row < chunk.Count; row++) if (chunk.MatchesRow(row)) count++; + while (cursor.Next(out var block)) + for (int row = 0; row < block.Count; row++) if (block.MatchesRow(row)) count++; counts.Add(count); } } @@ -636,10 +636,10 @@ private sealed class TransformBody(QueryState query, Func transform) : protected override void Run(SystemRun run) { var cursor = query.Begin(run); - while (cursor.Next(out var chunk)) + while (cursor.Next(out var block)) { - var values = chunk.Write(0); - for (int row = 0; row < chunk.Count; row++) values[row] = new Position(transform(values[row].Value)); + var values = block.Write(0); + for (int row = 0; row < block.Count; row++) values[row] = new Position(transform(values[row].Value)); } } } @@ -650,10 +650,10 @@ protected override void Run(SystemRun run) ticks.Add(run.Window.Now); Assert.True(together.SignalAndWait(Timeout)); var cursor = query.Begin(run); - while (cursor.Next(out var chunk)) + while (cursor.Next(out var block)) { - var values = chunk.Tracked(0); - for (int row = 0; row < chunk.Count; row++) + var values = block.Tracked(0); + for (int row = 0; row < block.Count; row++) { ref var value = ref values.Write(row); value = new Position(value.Value + 1); diff --git a/tests/Archevolt.Tests/StorageTests.cs b/tests/Archevolt.Tests/StorageTests.cs index 4586f4d..94464a5 100644 --- a/tests/Archevolt.Tests/StorageTests.cs +++ b/tests/Archevolt.Tests/StorageTests.cs @@ -1,5 +1,6 @@ namespace Archevolt.Tests; +[Trait("Category", "Storage")] public sealed class StorageTests { private readonly record struct Position(int Value); @@ -26,11 +27,121 @@ private sealed class Expected(int position, int velocity) internal bool Frozen; } + private sealed class CollectPositions(SystemBinder binder, List seen) : SystemBody + { + private readonly QueryState _query = binder.Query(0); + protected override void Run(SystemRun run) + { + var cursor = _query.Begin(run); + while (cursor.Next(out var block)) + { + var positions = block.Read(0); + for (int row = 0; row < block.Count; row++) + if (block.MatchesRow(row)) + seen.Add(positions[row].Value); + } + } + } + + private static BoundSchedule Collect(World world, QueryShape shape, List seen) => + BoundSchedule.Bind(world, SystemDescriptor.Create(shape.Name, b => new CollectPositions(b, seen)).Query(shape).Build()); + + [Fact] + public void Table_growth_preserves_values_and_change_ticks_of_existing_rows() + { + using var world = new WorldBuilder().AddBundle().Build(); + var changed = new List(); + var added = new List(); + using var changes = Collect(world, QueryShape.Create("changed").Read(0).Changed(0).Build(), changed); + using var additions = Collect(world, QueryShape.Create("added").Read(0).Added(0).Build(), added); + var entities = new List(); + for (int i = 0; i < 5; i++) + entities.Add(world.Spawn(new Particle(new Position(i), new Velocity(-i)))); + changes.Run(TestContext.Current.CancellationToken); + additions.Run(TestContext.Current.CancellationToken); + Assert.Equal([0, 1, 2, 3, 4], changed.Order()); + Assert.Equal([0, 1, 2, 3, 4], added.Order()); + changed.Clear(); + added.Clear(); + + world.Insert(entities[3], new Position(1003)); + for (int i = 5; i < 1000; i++) + entities.Add(world.Spawn(new Particle(new Position(i), new Velocity(-i)))); + changes.Run(TestContext.Current.CancellationToken); + additions.Run(TestContext.Current.CancellationToken); + + Assert.Equal(Enumerable.Range(5, 995).Append(1003), changed.Order()); + Assert.Equal(Enumerable.Range(5, 995), added.Order()); + for (int i = 0; i < entities.Count; i++) + { + Assert.Equal(i == 3 ? 1003 : i, world.Get(entities[i]).Value); + Assert.Equal(-i, world.Get(entities[i]).Value); + } + } + + [Fact] + public void Emptied_table_retains_native_memory_until_trimmed() + { + using var world = new WorldBuilder().AddBundle().AddComponent().Build(); + Entity survivor = world.Spawn(new Particle(new Position(-1), new Velocity(-2))); + world.Insert(survivor, new Extra(42)); + var entities = Enumerable.Range(0, 1000).Select(i => world.Spawn(new Particle(new Position(i), new Velocity(i * 2)))).ToArray(); + long full = world.NativeBytes; + + foreach (var entity in entities) + Assert.True(world.Despawn(entity)); + Assert.Equal(full, world.NativeBytes); + entities = Enumerable.Range(0, 1000).Select(i => world.Spawn(new Particle(new Position(i), new Velocity(i * 2)))).ToArray(); + Assert.Equal(full, world.NativeBytes); + foreach (var entity in entities) + Assert.True(world.Despawn(entity)); + + world.TrimExcess(); + long trimmed = world.NativeBytes; + Assert.InRange(trimmed, 1, full - 1); + Assert.Equal(-1, world.Get(survivor).Value); + Assert.Equal(-2, world.Get(survivor).Value); + Assert.Equal(42, world.Get(survivor).Value); + + var respawned = Enumerable.Range(0, 20).Select(i => world.Spawn(new Particle(new Position(i + 100), new Velocity(i)))).ToArray(); + Assert.True(world.NativeBytes > trimmed); + Assert.Equal(Enumerable.Range(100, 20), respawned.Select(e => world.Get(e).Value)); + Assert.Equal(Enumerable.Range(0, 20), respawned.Select(e => world.Get(e).Value)); + Assert.Equal(-1, world.Get(survivor).Value); + } + + [Fact] + public void Trimming_a_world_with_no_live_entities_releases_all_native_memory() + { + using var world = new WorldBuilder().AddBundle().AddComponent().AddComponent(tag: true).Build(); + var entities = new List(); + for (int i = 0; i < 300; i++) + { + Entity entity = i % 3 == 0 ? world.Spawn() : world.Spawn(new Particle(new Position(i), new Velocity(i))); + if (i % 5 == 0) + world.Insert(entity, new Extra(i)); + if (i % 7 == 0) + world.Insert(entity, default(Frozen)); + entities.Add(entity); + } + long held = world.NativeBytes; + foreach (var entity in entities) + Assert.True(world.Despawn(entity)); + Assert.Equal(held, world.NativeBytes); + + world.TrimExcess(); + + Assert.Equal(0, world.NativeBytes); + Entity again = world.Spawn(new Particle(new Position(5), new Velocity(6))); + Assert.Equal(5, world.Get(again).Value); + Assert.Equal(6, world.Get(again).Value); + } + [Fact] - public void Structural_trace_matches_dictionary_across_chunk_migration_and_swap_removal() + public void Structural_trace_matches_dictionary_across_table_growth_migration_and_swap_removal() { using var world = new WorldBuilder().AddBundle().AddComponent() - .AddComponent(tag: true).WithChunkBytes(256).Build(); + .AddComponent(tag: true).Build(); var expected = new Dictionary(); var random = new Random(82637); for (int i = 0; i < 128; i++) From d26ee0e72672b62f53466ecd9670bcbaf0eddf7d Mon Sep 17 00:00:00 2001 From: Stephan van Stekelenburg Date: Wed, 28 Oct 2026 01:51:38 +0100 Subject: [PATCH 5/5] docs(benchmarks): record step 3 table storage measurements Before and after runs on .NET 11 RC2 from clean worktrees. Tag and wide round trips run at 0.34-0.36x and [EachBlock] movement now beats Bevy's for_each. Named queries regress at 1,000 and 100,000 entities. Tier-1 listings show the cause is the enumerator staying on the stack when PGO leaves QueryCursor.Next out of line. With TieredPGO off, both sides get the same code and the tables are never slower. Step 2's gate now requires the named query to beat its chunked numbers. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +- benchmarks/baseline.md | 4 +- benchmarks/step3-table-storage.md | 125 ++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 benchmarks/step3-table-storage.md diff --git a/ROADMAP.md b/ROADMAP.md index 04ec719..4c5ab34 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # Archevolt performance plan -Status: step 0 done. Its baselines are in `benchmarks/baseline.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. +Status: steps 0, 1, and 3 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`. They are the baseline for later steps. Step 3 met its gate except for named queries, whose regression comes from enumerator codegen that step 2 rewrites. 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. This plan covers the next stage. It makes Archevolt faster at dense iteration, structural change, and entity lifecycle. Each change is grounded in measured Archevolt costs or in what Bevy changed between v0.17.3, which v1 was designed against, and v0.20.0, the current release. @@ -94,6 +94,8 @@ This step has no Bevy precedent. Bevy's `for` loop has the same per-row shape. Gate: named query within 1.2 times `[Each]` on the 100,000-entity movement case, and the wide-component case no slower. Disassembly shows no per-row span reconstruction or `Row` copy. +Step 3 showed that the enumerator's speed depends on the PGO profile. When tiered PGO leaves `QueryCursor.Next` out of line, the enumerator stays on the stack and each row costs about 2 ns instead of 1.2 ns. One table per archetype made that the common case. Step 2's gate therefore also requires the named-query traversal cases to beat their chunked numbers in `benchmarks/step3-table-storage.md` at 1,000 and 100,000 entities, under the default runtime configuration. + ## Step 3. Table storage with retained capacity Replace 16 KiB chunks with one growing slab per archetype. An unread wide component no longer shortens the spans a hot query sees. Serial queries see one span per table. Parallel execution keeps logical blocks within a table. diff --git a/benchmarks/baseline.md b/benchmarks/baseline.md index 283f81c..e2ba65f 100644 --- a/benchmarks/baseline.md +++ b/benchmarks/baseline.md @@ -57,7 +57,7 @@ These differences remain after mirroring the workload: - **Change ticks.** Bevy stores 32-bit added and changed ticks for every component and writes them on spawn, insert, and mutable access. Archevolt stores 64-bit ticks for `[TrackChanges]` components only. Of the components here, only `TrackedPosition` is tracked. - **Spawn.** Building an Archevolt world creates the archetype of every registered bundle. A Bevy world creates the particle archetype and table on its first spawn, inside the measured operation. - **Batch spawn.** `World::spawn_batch` reserves table storage for the iterator's size hint and allocates all entities with one `alloc_many` call. -- **Storage.** Archevolt stores components in 16 KiB chunks. An unread `WidePayload` shares the chunk, so each chunk holds fewer rows. Bevy stores each component in its own contiguous table column, so the payload does not change the `Position` and `Velocity` columns. +- **Storage.** Archevolt stores components in 16 KiB chunks. An unread `WidePayload` shares the chunk, so each chunk holds fewer rows. Bevy stores each component in its own contiguous table column, so the payload does not change the `Position` and `Velocity` columns. Step 3 replaced the chunks with one table per archetype, so this difference no longer holds after it. - **Entity handle.** Archevolt's `Entity` is 16 bytes and carries a world ID. Bevy's `Entity` is 8 bytes. - **Slices.** `bevy_contiguous` writes through `ContiguousMut`, which marks every row of the slice as changed. - **Statistics.** BenchmarkDotNet reports a mean per launch. Criterion reports a mean per run. Both time one operation per fresh world for spawn and despawn, and dispose or drop the world outside the timing. @@ -70,7 +70,7 @@ Run from the repository root with PowerShell 7.2 or newer: ./scripts/baseline.ps1 ``` -The script builds the benchmarks, runs the BenchmarkDotNet cases above and `ReferenceComparisonBenchmarks` with three launches, and runs `cargo bench` three times with `-C target-cpu=native`. Criterion runs under the High performance power plan, which BenchmarkDotNet also selects, and the script restores the previous plan. Results go to `artifacts/baseline//`. `summary.md` lists the median of the three per-process means and their range. `-Runs 5` takes more samples. `-Quick` shortens both harnesses to check the script, and its numbers are not baselines. +The script builds the benchmarks, runs the BenchmarkDotNet cases above and `ReferenceComparisonBenchmarks` with three launches, and runs `cargo bench` three times with `-C target-cpu=native`. Criterion runs under the High performance power plan, which BenchmarkDotNet also selects, and the script restores the previous plan. Results go to `artifacts/baseline//`. `summary.md` lists the median of the three per-process means and their range. `-Runs 5` takes more samples. `-Quick` shortens both harnesses to check the script, and its numbers are not baselines. `./scripts/compare-baselines.ps1 -Before -After ` joins two result directories and marks each case faster, slower, or within the before range. BenchmarkDotNet finds the project to build by searching the directory that holds `Archevolt.slnx`. It stops when two files named `Archevolt.Benchmarks.csproj` exist below that directory, for example in a copy of the repository under `artifacts/`. Run the script from a clean clone in that case. diff --git a/benchmarks/step3-table-storage.md b/benchmarks/step3-table-storage.md new file mode 100644 index 0000000..3596c8f --- /dev/null +++ b/benchmarks/step3-table-storage.md @@ -0,0 +1,125 @@ +# Step 3: table storage + +This file records the before and after measurements for [ROADMAP.md](../ROADMAP.md) step 3. Step 3 replaced 16 KiB chunks with one growing native slab per archetype. Tables keep their capacity until `World.TrimExcess()`, and parallel runs split matched rows into contiguous ranges. + +## Conditions + +| Setting | Value | +| --- | --- | +| CPU | AMD Ryzen 9 5950X 16-Core Processor | +| OS | Windows 11 Pro 10.0.26300 | +| .NET | SDK 11.0.100-rc.2.26504.105, runtime 11.0.0-rc.2.26504.105, Release | +| BenchmarkDotNet | 0.16.0-preview.2, default job, three launches | +| Rust | rustc 1.98.0, LLVM 22.1.8, `-C target-cpu=native` | +| Bevy | `bevy_ecs` 0.20.0 | +| Before | `ffabaeb`, chunked storage | +| After | `e8621ff`, table storage | + +Both sides ran `./scripts/baseline.ps1` from clean worktrees on 2026-10-27, one after the other, with nothing else running. `./scripts/compare-baselines.ps1` produced the verdicts. "faster" and "slower" mean the after median lies outside the before range. Each cell is the median of three per-process means in microseconds, with the range in brackets. + +The step 0 numbers in [baseline.md](baseline.md) were recorded on .NET 10. They are not comparable with these. + +The Bevy code is identical on both sides, so its two runs measure machine drift. Most Bevy cases moved by less than 5%, and a few by up to 24%, for example `tracked_write/1000` at 1.19 and `traversal/archetypes_1_payload_0/1000` at 1.24. Archevolt changes smaller than that are not evidence. + +## Gate + +| Gate item | Result | +| --- | --- | +| Named query with an unread 256-byte component is faster than the chunked baseline | Met at 1,000,000 entities. Not met at 100,000, or at 1,000 with one archetype. | +| Tag round trip is faster than the chunked baseline | Met at both sizes. | +| No ordinary query regresses outside the baseline range | Not met. The named query at 1,000 entities with one archetype, `WriteThenChangedAll`, and `OptionalHalfPresent` at 100,000 regress. | +| Growth, migration, disposal, tracking, and four-worker tests pass | Met. 155 of 155 tests pass, and `scripts/verify.ps1` passes. | + +Every regression is a named query. `[Each]` and `[EachBlock]` improved in every case. + +### Named query, traversal matrix + +| Entities | Archetypes | Payload | Before | After | After/before | +| ---: | ---: | --- | ---: | ---: | ---: | +| 1,000 | 1 | none | 1.180 [1.167–1.189] | 2.065 [2.059–2.065] | 1.75 | +| 1,000 | 1 | 256 B | 1.353 [1.352–1.409] | 2.027 [2.020–2.062] | 1.50 | +| 1,000 | 16 | none | 1.464 [1.463–1.476] | 1.354 [1.349–1.366] | 0.92 | +| 1,000 | 16 | 256 B | 1.726 [1.715–1.778] | 1.398 [1.378–1.428] | 0.81 | +| 100,000 | 1 | none | 208.0 [111.2–209.0] | 198.7 [194.2–200.3] | 0.96 | +| 100,000 | 1 | 256 B | 161.5 [160.6–163.7] | 198.9 [190.3–199.3] | 1.23 | +| 100,000 | 16 | none | 206.5 [206.5–209.1] | 200.9 [192.9–201.6] | 0.97 | +| 100,000 | 16 | 256 B | 175.6 [175.3–177.7] | 196.6 [193.8–197.8] | 1.12 | +| 1,000,000 | 1 | none | 2,339 [2,319–2,356] | 1,968 [1,952–2,036] | 0.84 | +| 1,000,000 | 1 | 256 B | 3,935 [3,896–4,005] | 1,919 [1,916–1,995] | 0.49 | +| 1,000,000 | 16 | none | 2,354 [2,311–2,392] | 1,982 [1,928–2,004] | 0.84 | +| 1,000,000 | 16 | 256 B | 4,582 [4,524–4,595] | 2,007 [2,004–2,016] | 0.44 | + +### Why the named query regressed + +The named query runs in one of two JIT shapes. In the fast shape, `Movement.Query` inlines `QueryCursor.Next` and the `BlockView` accessors, and the enumerator's fields live in registers. In the slow shape, `Next` stays a call. The enumerator is then address-exposed, so each row reloads four span fields from the stack, stores `_row` back to the stack, and zeroes a 72-byte stack temporary. The fast shape costs about 1.2 ns per row and the slow shape about 2 ns. + +The Tier-1 listings of all twelve traversal processes, captured with `--envVars DOTNET_JitDisasm:Query`, show which shape each case received: + +| Case | Before | After | +| --- | --- | --- | +| 1,000, one archetype, with or without payload | registers | stack | +| 1,000, 16 archetypes, with or without payload | registers | registers | +| 100,000, no payload | stack | stack | +| 100,000, 256 B payload | registers | stack | + +The timings follow the shape in every case. That includes the chunked 100,000-entity payload cases, which were faster than the same cases without the payload. The inference is that tiered PGO inlines `Next` only when the block transition is hot. Small chunks made it hot. One table per archetype makes it cold. + +With `DOTNET_TieredPGO=0`, every process on both sides compiles the register shape. Then the tables are never slower. Each cell is the mean of three launches under BenchmarkDotNet's short job, in microseconds: + +| Case | Before | After | +| --- | ---: | ---: | +| Named query, 1,000, 1 archetype, none | 2.900 | 2.837 | +| Named query, 1,000, 1 archetype, 256 B | 3.507 | 2.986 | +| Named query, 100,000, 1 archetype, none | 278.6 | 300.5 (median 270.4, one outlier launch) | +| Named query, 100,000, 1 archetype, 256 B | 380.5 | 269.5 | +| Named query, 1,000,000, 16 archetypes, 256 B | 6,876 | 2,655 | +| `WriteThenChangedAll`, 100,000 | 482.5 | 430.1 | +| `OptionalHalfPresent`, 100,000 | 238.2 | 212.5 | + +So the regression comes from enumerator codegen that depends on the PGO profile, not from table storage. Step 2 rewrites that enumerator. Marking `QueryCursor.Next` `AggressiveInlining` took the 1,000-entity case from 2.07 to 1.57 µs and left the 100,000-entity cases unchanged, so this step does not include it. + +## Other Archevolt cases + +| Case | Entities | Before | After | After/before | +| --- | ---: | ---: | ---: | ---: | +| Tag round trip | 1,000 | 277.0 [275.6–290.9] | 99.57 [96.53–103.0] | 0.36 | +| Tag round trip | 100,000 | 29,130 [28,890–29,440] | 9,893 [9,816–10,860] | 0.34 | +| Wide round trip | 1,000 | 292.2 [287.2–297.6] | 106.1 [104.3–107.3] | 0.36 | +| Wide round trip | 100,000 | 31,570 [31,100–31,910] | 11,270 [11,040–11,280] | 0.36 | +| Spawn | 1,000 | 57.03 [55.77–58.91] | 49.04 [48.52–49.53] | 0.86 | +| Spawn | 100,000 | 7,499 [7,163–7,537] | 6,412 [6,143–6,429] | 0.86 | +| Despawn | 1,000 | 45.07 [44.50–45.43] | 29.26 [27.79–33.72] | 0.65 | +| Despawn | 100,000 | 6,168 [6,093–6,186] | 4,929 [4,284–4,943] | 0.80 | +| `Get` | 1,000 | 8.278 [8.176–9.215] | 8.345 [8.206–8.376] | 1.01 | +| `Get` | 100,000 | 1,958 [1,957–2,089] | 1,676 [1,672–1,688] | 0.86 | +| `[Each]` traversal, 1 archetype, none | 100,000 | 119.1 [118.0–121.2] | 99.14 [98.32–99.28] | 0.83 | +| `[Each]` traversal, 1 archetype, 256 B | 100,000 | 174.8 [173.3–177.6] | 98.47 [96.26–103.1] | 0.56 | +| `[Each]` traversal, 16 archetypes, 256 B | 1,000,000 | 4,809 [4,691–4,992] | 1,020 [999.8–1,021] | 0.21 | +| Tracked write | 100,000 | 115.2 [111.5–115.5] | 97.78 [97.16–98.38] | 0.85 | +| `Changed`, nothing changed | 100,000 | 102.3 [102.2–104.7] | 92.53 [91.50–95.64] | 0.90 | +| Write, then `Changed` over all | 100,000 | 218.7 [216.2–220.7] | 244.1 [243.9–245.4] | 1.12 | +| Optional term on half | 100,000 | 136.5 [123.4–136.6] | 148.1 [133.3–148.8] | 1.09 | +| Movement `GeneratedEach` | 100,000 | 120.6 [119.4–122.4] | 98.12 [96.50–100.4] | 0.81 | +| Movement `GeneratedBlock` | 100,000 | 110.7 [109.2–111.2] | 84.46 [83.78–85.53] | 0.76 | +| Movement `GeneratedBlockVector` | 100,000 | 28.74 [28.71–29.28] | 20.62 [20.51–20.67] | 0.72 | +| Movement `Handwritten` | 100,000 | 85.27 [83.96–87.03] | 84.51 [84.07–85.09] | 0.99 | + +The swap-remove that fills a despawned or moved row now copies columns index for index within one table. Step 6 planned that change, so part of the round-trip gain belongs to step 6. The cross-archetype copy still binary-searches each column. + +## Against Bevy, after step 3 + +The Bevy medians come from the after run. Treat them as context, per the measurement rules. + +| Case | Entities | Archevolt | Bevy | Archevolt/Bevy | +| --- | ---: | ---: | ---: | ---: | +| Movement, `GeneratedBlockVector` vs `bevy_for_each` | 100,000 | 20.62 | 23.78 | 0.87 | +| Movement, `GeneratedBlockVector` vs `bevy_contiguous` | 100,000 | 20.62 | 24.66 | 0.84 | +| Traversal `[Each]`, 1 archetype, 256 B | 100,000 | 98.47 | 24.63 | 4.00 | +| Tag round trip | 100,000 | 9,893 | 11,090 | 0.89 | +| Wide round trip | 100,000 | 11,270 | 11,520 | 0.98 | +| Despawn | 100,000 | 4,929 | 9,893 | 0.50 | +| Spawn | 100,000 | 6,412 | 4,774 | 1.34 | +| `Get` | 100,000 | 1,676 | 830.3 | 2.02 | +| Tracked write | 100,000 | 97.78 | 28.70 | 3.41 | + +The step 1 slice form now beats Bevy's `for_each` on the movement case. PR #5 measured it 21% slower on chunked storage.