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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<ItemGroup>
<PackageVersion Include="Arch" Version="2.1.0" />
<PackageVersion Include="AwesomeAssertions" Version="9.6.0" />
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
<PackageVersion Include="BenchmarkDotNet" Version="0.16.0-preview.2" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" />
<PackageVersion Include="Silk.NET.Input" Version="2.23.0" />
<PackageVersion Include="Silk.NET.OpenGL" Version="2.23.0" />
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 3 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion benchmarks/Archevolt.Benchmarks/ScenarioBenchmarks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -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]
Expand Down
4 changes: 2 additions & 2 deletions benchmarks/baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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/<UTC timestamp>/`. `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/<UTC timestamp>/`. `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 <dir> -After <dir>` 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.

Expand Down
125 changes: 125 additions & 0 deletions benchmarks/step3-table-storage.md
Original file line number Diff line number Diff line change
@@ -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<T>` | 1,000 | 8.278 [8.176–9.215] | 8.345 [8.206–8.376] | 1.01 |
| `Get<T>` | 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<T>`, nothing changed | 100,000 | 102.3 [102.2–104.7] | 92.53 [91.50–95.64] | 0.90 |
| Write, then `Changed<T>` 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<T>` | 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.
8 changes: 4 additions & 4 deletions docs/concepts/architecture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand All @@ -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.

Expand Down
Loading