Skip to content

Add an archetype storage next to the sparse set - #169

Merged
ctreffs merged 12 commits into
masterfrom
2.0/archetype-storage
Oct 26, 2026
Merged

ctreffs merged 12 commits into
masterfrom
2.0/archetype-storage

Conversation

@ctreffs

@ctreffs ctreffs commented Oct 25, 2026 •

Copy link
Copy Markdown
Member

Step 13 of FirebladeECS 2.0. Stacked on #168.

  • Nexus(storage: .archetype) adds a second storage beside the sparse set, modeled on flecs:
    • one table per component set, with one column per component type;
    • an index mapping each entity to its table and row;
    • cached add/remove edges between tables.
  • Every public feature behaves the same on both storages: events, locks and traps, families with excludes, per-arity forEach and the pack fallback, spans, Single, FSM, cloning and snapshots. About 37 test files now run on both (@Test(arguments: Nexus.StorageKind.allCases)).
  • .default is still the sparse set. Step 14 switches it.
  • New DocC article: "Choosing a storage".
  • The benchmarks and Scripts/check-sil.sh now cover both storages.

Numbers (10k entities, release, p50 µs, 0 mallocs in every iteration case)

benchmark 1.x sparse archetype
Plain for, 1 component 480 156 165
Plain for, 2 components 1220 455 455
forEach, 1 read 13 10
forEach, 2 reads + 1 write 24 11
forEach, 2 reads, 100k shuffled 227 27
forEachComponentSpan, read + write 27 10
Pack fallback, 5 reads + 2 writes 2202 1795
Random get (10k lookups) 334 345
Add/remove churn (20k changes) 2179 4960
Create/destroy 1k (7 components) 1734 2507

On archetype, add/remove costs about 2.3x what it does on the sparse set, and create/destroy about 1.45x.

Behavior to review

  • Single-type spans across several tables. componentSpan(reading:/writing:) views a gathered copy, and the writing variant scatters it back, even when the body throws. The cost is one allocation and an O(N) copy. With one table the view is zero-copy, the same as on the sparse set.
  • forEachComponentSpan yields one batch per non-empty matching table.
  • Iteration order is not part of the contract: archetype iterates by table, the sparse set by member list.
  • Empty tables are kept and skipped; clear() drops all tables. A family builds its table query when it is created, which keeps the first iteration at 0 mallocs.
  • Plain Sequence iteration still switches on the storage once per entity. It still beats 1.x by about 2.7x.

Prior art (flecs)

In flecs, archetype tables are the default storage. The entity index maps each entity to its table and row, cached queries iterate per table, and sparse storage is an opt-in trait: "Sparse components are stored outside of tables, which means they do not have to be moved." Sources:

Verification

  • make test: 392 tests pass in debug (596 parameterized cases).
  • Release: 391 pass; one test is debug-only.
  • make lint and make docs-check-coverage pass, and Scripts/check-sil.sh exits 0.

https://claude.ai/code/session_011ppjd3fe8ytKkn4EL3337N

@ctreffs ctreffs added the run-ci Run CI on all platforms for a PR into any branch label Oct 25, 2026
@ctreffs
ctreffs added this pull request to stack #175 October 26, 2026 08:06
@ctreffs
ctreffs force-pushed the 2.0/archetype-storage branch 7 times, most recently from d96873f to 857ce7f Compare October 26, 2026 12:45
Base automatically changed from 2.0/macro-value-types to master October 26, 2026 12:50
An ArchetypeTable holds the entities of one component set with a hand-managed
column per component type. ArchetypeStorage keeps the tables, the
entity-to-(table,row) index, swap-remove row moves and cached add/remove edges
between tables. Nothing selects it yet.

Claude-Session: https://claude.ai/code/session_011ppjd3fe8ytKkn4EL3337N
Nexus(storage: .archetype) now selects ArchetypeStorage. Components are added,
replaced and removed by moving the entity's row between tables along cached
edges; get, modify, scoped access and withComponents work on the columns.
Iteration and spans still serve the sparse-set storage only. The default stays
the sparse set.

Claude-Session: https://claude.ai/code/session_011ppjd3fe8ytKkn4EL3337N
The archetype storage calls the bulk write body once per table that holds the
component type, so the event sums the calls instead of reporting the last one.

Claude-Session: https://claude.ai/code/session_011ppjd3fe8ytKkn4EL3337N
…type storage

Every fixed arity overload and the parameter pack fallback switch on the storage
once per call. The archetype path resolves the tables a family matches from a
cached query, resolves the columns once per table and loops over the rows. The
pack fallback keeps the never-specialize attribute on its helpers.

Claude-Session: https://claude.ai/code/session_011ppjd3fe8ytKkn4EL3337N
forEachComponentSpan passes one batch per matching table, viewing the table's
columns. The single-span componentSpan(reading:) and (writing:) view the column
when one table holds all components of the type. When several tables hold them
the span views a gathered copy, and the writing variant stores it back when the
body returns, also if it throws.

Claude-Session: https://claude.ai/code/session_011ppjd3fe8ytKkn4EL3337N
The tests that create a Nexus take the storage kind as an argument
(@test(arguments: Nexus.StorageKind.allCases)), so one body checks the
sparse-set and the archetype storage, including the exit tests for traps. The
direct storage tests run on the storage enum. New cases cover churn across
component sets against a model, forEach and bulk writes over several tables,
single and per-table spans, and table reuse.

Claude-Session: https://claude.ai/code/session_011ppjd3fe8ytKkn4EL3337N
The index of a component type is a protocol witness. Reading it inside the loop
over tables put a witness call into the loop that check-sil.sh inspects, so the
archetype paths of forEach(reading:writing:) and forEachComponentSpan read the
indices before the loop.

Claude-Session: https://claude.ai/code/session_011ppjd3fe8ytKkn4EL3337N
The archetype storage caches the tables a family matches. Building the list on
the first iteration allocated a query and grew its array inside that
iteration; creating the family does it instead, so every iteration starts with
a cached list.

Claude-Session: https://claude.ai/code/session_011ppjd3fe8ytKkn4EL3337N
A call site switches on the storage once, so its SIL holds the loops of both
storages. The script now also fails when a site's loops do not reach the
archetype code, and covers forEachComponentSpan with three more sites.

Claude-Session: https://claude.ai/code/session_011ppjd3fe8ytKkn4EL3337N
…e cases

The IterOnly benchmarks run on the sparse-set and the archetype storage, named
with the storage in brackets. StorageBenchmarks.swift ports the S2 spike cases:
iteration at several arities, filtered and fragmented families, 100k entities
in permuted order, string components, random access, add/remove churn and
entity creation.

Claude-Session: https://claude.ai/code/session_011ppjd3fe8ytKkn4EL3337N
Adds a DocC article that describes both storages, the flecs design the
archetype storage follows, the benchmark numbers and the differences a program
can observe (iteration order, single-type spans, per-table span batches).

Claude-Session: https://claude.ai/code/session_011ppjd3fe8ytKkn4EL3337N
@ctreffs
ctreffs force-pushed the 2.0/archetype-storage branch from 857ce7f to 23fd144 Compare October 26, 2026 12:50
@ctreffs
ctreffs marked this pull request as ready for review October 26, 2026 12:50
@ctreffs
ctreffs enabled stacked auto-merge (squash) October 26, 2026 13:00
stacked auto-merge was automatically disabled October 26, 2026 13:01

Replaced by a newer auto-merge request

@ctreffs
ctreffs enabled stacked auto-merge (squash) October 26, 2026 13:01
@ctreffs
ctreffs merged commit 108aa79 into master Oct 26, 2026
9 checks passed
@ctreffs
ctreffs deleted the 2.0/archetype-storage branch October 26, 2026 13:24
@ctreffs ctreffs mentioned this pull request Oct 26, 2026
3 of 14 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-ci Run CI on all platforms for a PR into any branch

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant