Skip to content

Make family and entity iteration order deterministic - #155

Merged
ctreffs merged 7 commits into
masterfrom
deterministic-family-order
Oct 18, 2026
Merged

ctreffs merged 7 commits into
masterfrom
deterministic-family-order

Conversation

@ctreffs

@ctreffs ctreffs commented Oct 18, 2026 •

Copy link
Copy Markdown
Member

A family created after its entities was filled by walking componentIdsByEntity.keys, and Nexus.EntitiesIterator did the same. Swift seeds Dictionary hashing per process, so the order changed between identical runs. In the Fireblade engine this made lockstep frame captures differ at coplanar faces (fireblade-engine/fireblade#287); with SWIFT_DETERMINISTIC_HASHING=1 the captures were pixel-identical.

Both paths now sort by identifier, as Nexus+Snapshot and Nexus+Clone already do. The guarantee is a deterministic order, one that depends only on the sequence of operations, not ascending order: UnorderedSparseSet removal swaps the last member into the gap. The cost is one sort per family creation and per entities iteration. Not adopted: OrderedDictionary (new dependency, O(n) removal on every destroy) and per-family ordering options (no consumer needs them).

  • New NexusDeterministicOrderTests (256 entities) fail before the change and pass after.
  • make test: 199 tests pass. make lint: no new warnings in touched files.

Not changed: update(familyMembership entityId:) still walks familyMembersByTraits in hash order. That affects only the order of FamilyMemberAdded events across families, not member order.

https://claude.ai/code/session_01UaLGUVakHTRSr8q7EMhME5

Families created after their entities and the Nexus entities iterator took
their order from a Dictionary, whose hash seed changes per process. Render
and simulation order then differed between otherwise identical runs.

Claude-Session: https://claude.ai/code/session_01UaLGUVakHTRSr8q7EMhME5
Family members stay in identifier order only until the first removal, which
swaps the last member into the gap. The guarantee is a deterministic order.

Claude-Session: https://claude.ai/code/session_01UaLGUVakHTRSr8q7EMhME5
@ctreffs ctreffs changed the title Iterate entities in ascending identifier order when populating families Make family and entity iteration order deterministic Oct 18, 2026
Benchmarks stopped building once FirebladeECS required macOS 14 and Sendable
components. Align the manifest with fireblade-engine/math (ordo-one/benchmark
1.38, tools 6.3) and make the fixtures and benchmark list Sendable.

Claude-Session: https://claude.ai/code/session_01UaLGUVakHTRSr8q7EMhME5
Both paths now sort entity identifiers for a deterministic order.

Claude-Session: https://claude.ai/code/session_01UaLGUVakHTRSr8q7EMhME5
@ctreffs

ctreffs commented Oct 18, 2026

Copy link
Copy Markdown
Member Author

Performance

Measured with the benchmark package (Benchmarks/, now on the same ordo-one/benchmark 1.38 setup as fireblade-engine/math), 10,000 entities with 5 components each, Apple Silicon, release build. Baselines: master sources vs this branch, same benchmark code.

Benchmark What it measures master p50 branch p50 Change
FamilyCreation first family(requiresAll:excludesAll:) call on a populated nexus (once per family) 2,943 µs 3,224 µs +281 µs (+9.5%), +6 mallocs
EntitiesIteration full makeEntitiesIterator() pass 26 ms 26 ms none measurable
TypedFamilyEntities per-frame family iteration (unchanged code) 28 ms 28 ms none

The only measurable cost is the sort when a family is first created; repeated family(...) calls return early in onFamilyInit, and per-frame iteration is untouched.

Reproduce from Benchmarks/:

git checkout master -- ../Sources/FirebladeECS/Nexus+Entity.swift ../Sources/FirebladeECS/Nexus+Internal.swift
swift package --allow-writing-to-package-directory benchmark baseline update master --filter 'FamilyCreation|EntitiesIteration|TypedFamilyEntities'
git checkout HEAD -- ../Sources
swift package --allow-writing-to-package-directory benchmark baseline update branch --filter 'FamilyCreation|EntitiesIteration|TypedFamilyEntities'
swift package benchmark baseline compare master branch

@ctreffs

ctreffs commented Oct 18, 2026

Copy link
Copy Markdown
Member Author

Membership churn

Added FamilyChurn: on an existing family over 10,000 entities, each iteration removes and re-adds Velocity on 1,000 members (they leave and rejoin) and creates and destroys 1,000 matching entities.

Benchmark master p50 branch p50 Change
FamilyChurn 33 ms 33 ms none (mallocs 260K / 260K)
FamilyCreation (re-run) 2,927 µs 3,222 µs +295 µs (+10%)
EntitiesIteration (re-run) 26 ms 26 ms none
TypedFamilyEntities (re-run) 28 ms 28 ms none

As expected from the code: component add/remove and destroy go through update(familyMembership: entityId), which this PR does not touch. The sorted path update(familyMembership: traits) has one caller, onFamilyInit, and runs once per family.

Reproduce: same steps as above with --filter 'FamilyChurn|FamilyCreation|EntitiesIteration|TypedFamilyEntities'.

@ctreffs
ctreffs enabled auto-merge (squash) October 18, 2026 14:23
@ctreffs
ctreffs merged commit c85607b into master Oct 18, 2026
13 checks passed
@ctreffs
ctreffs deleted the deterministic-family-order branch October 18, 2026 14:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant