From d3432e9c2567110b0210b81ff0b540399dde64f2 Mon Sep 17 00:00:00 2001 From: Christian Treffs Date: Sun, 18 Oct 2026 14:48:00 +0200 Subject: [PATCH 1/7] Iterate entities in ascending identifier order when populating families 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 --- Sources/FirebladeECS/Nexus+Entity.swift | 5 +-- Sources/FirebladeECS/Nexus+Internal.swift | 4 +-- .../NexusIterationOrderTests.swift | 31 +++++++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) create mode 100644 Tests/FirebladeECSTests/NexusIterationOrderTests.swift diff --git a/Sources/FirebladeECS/Nexus+Entity.swift b/Sources/FirebladeECS/Nexus+Entity.swift index e5bcd392..3f4c468b 100644 --- a/Sources/FirebladeECS/Nexus+Entity.swift +++ b/Sources/FirebladeECS/Nexus+Entity.swift @@ -123,7 +123,7 @@ extension Nexus { // MARK: - entities iterator extension Nexus { - /// An iterator over the entities managed by the Nexus. + /// An iterator over the entities managed by the Nexus, in ascending identifier order. public struct EntitiesIterator: IteratorProtocol { private var iterator: AnyIterator @@ -131,7 +131,8 @@ extension Nexus { /// - Parameter nexus: The nexus to iterate over. @usableFromInline init(nexus: Nexus) { - var iter = nexus.componentIdsByEntity.keys.makeIterator() + // Ascending identifiers keep the order independent of the per-process hash seed. + var iter = nexus.componentIdsByEntity.keys.sorted { $0.id < $1.id }.makeIterator() iterator = AnyIterator { guard let entityId = iter.next() else { return nil diff --git a/Sources/FirebladeECS/Nexus+Internal.swift b/Sources/FirebladeECS/Nexus+Internal.swift index b911e5bd..79be77dc 100644 --- a/Sources/FirebladeECS/Nexus+Internal.swift +++ b/Sources/FirebladeECS/Nexus+Internal.swift @@ -133,8 +133,8 @@ extension Nexus { /// - Parameter traits: The traits defining the family. func update(familyMembership traits: FamilyTraitSet) { // FIXME: iterating all entities is costly for many entities - var iter = componentIdsByEntity.keys.makeIterator() - while let entityId = iter.next() { + // Ascending identifiers keep the member order independent of the per-process hash seed. + for entityId in componentIdsByEntity.keys.sorted(by: { $0.id < $1.id }) { update(membership: traits, for: entityId) } } diff --git a/Tests/FirebladeECSTests/NexusIterationOrderTests.swift b/Tests/FirebladeECSTests/NexusIterationOrderTests.swift new file mode 100644 index 00000000..84770c8a --- /dev/null +++ b/Tests/FirebladeECSTests/NexusIterationOrderTests.swift @@ -0,0 +1,31 @@ +// +// NexusIterationOrderTests.swift +// FirebladeECSTests +// + +import Testing +@testable import FirebladeECS + +/// Swift seeds `Dictionary` hashing per process, so any order taken from a dictionary changes between runs. +/// These tests pin iteration orders to ascending entity identifiers instead. +@Suite struct NexusIterationOrderTests { + private static let entityCount = 256 + + @Test func familyCreatedAfterEntitiesIteratesInIdentifierOrder() { + let nexus = Nexus() + let expected = (0.. Date: Sun, 18 Oct 2026 15:27:42 +0200 Subject: [PATCH 2/7] Describe the family and entity order as deterministic, not ascending 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 --- ...erationOrderTests.swift => NexusDeterministicOrderTests.swift} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Tests/FirebladeECSTests/{NexusIterationOrderTests.swift => NexusDeterministicOrderTests.swift} (100%) diff --git a/Tests/FirebladeECSTests/NexusIterationOrderTests.swift b/Tests/FirebladeECSTests/NexusDeterministicOrderTests.swift similarity index 100% rename from Tests/FirebladeECSTests/NexusIterationOrderTests.swift rename to Tests/FirebladeECSTests/NexusDeterministicOrderTests.swift From 11430fec0aed6d0b0e4d9ba51504f7863bc9aa8b Mon Sep 17 00:00:00 2001 From: Christian Treffs Date: Sun, 18 Oct 2026 15:27:52 +0200 Subject: [PATCH 3/7] Reword order comments and tests to the deterministic guarantee Claude-Session: https://claude.ai/code/session_01UaLGUVakHTRSr8q7EMhME5 --- Sources/FirebladeECS/Nexus+Entity.swift | 4 ++-- Sources/FirebladeECS/Nexus+Internal.swift | 2 +- .../NexusDeterministicOrderTests.swift | 10 +++++----- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Sources/FirebladeECS/Nexus+Entity.swift b/Sources/FirebladeECS/Nexus+Entity.swift index 3f4c468b..8b478b8e 100644 --- a/Sources/FirebladeECS/Nexus+Entity.swift +++ b/Sources/FirebladeECS/Nexus+Entity.swift @@ -123,7 +123,7 @@ extension Nexus { // MARK: - entities iterator extension Nexus { - /// An iterator over the entities managed by the Nexus, in ascending identifier order. + /// An iterator over the entities managed by the Nexus, in a deterministic order. public struct EntitiesIterator: IteratorProtocol { private var iterator: AnyIterator @@ -131,7 +131,7 @@ extension Nexus { /// - Parameter nexus: The nexus to iterate over. @usableFromInline init(nexus: Nexus) { - // Ascending identifiers keep the order independent of the per-process hash seed. + // Sorting makes the order deterministic instead of following the per-process hash seed. var iter = nexus.componentIdsByEntity.keys.sorted { $0.id < $1.id }.makeIterator() iterator = AnyIterator { guard let entityId = iter.next() else { diff --git a/Sources/FirebladeECS/Nexus+Internal.swift b/Sources/FirebladeECS/Nexus+Internal.swift index 79be77dc..fc1a0a9f 100644 --- a/Sources/FirebladeECS/Nexus+Internal.swift +++ b/Sources/FirebladeECS/Nexus+Internal.swift @@ -133,7 +133,7 @@ extension Nexus { /// - Parameter traits: The traits defining the family. func update(familyMembership traits: FamilyTraitSet) { // FIXME: iterating all entities is costly for many entities - // Ascending identifiers keep the member order independent of the per-process hash seed. + // Sorting makes the member order deterministic instead of following the per-process hash seed. for entityId in componentIdsByEntity.keys.sorted(by: { $0.id < $1.id }) { update(membership: traits, for: entityId) } diff --git a/Tests/FirebladeECSTests/NexusDeterministicOrderTests.swift b/Tests/FirebladeECSTests/NexusDeterministicOrderTests.swift index 84770c8a..0d31910d 100644 --- a/Tests/FirebladeECSTests/NexusDeterministicOrderTests.swift +++ b/Tests/FirebladeECSTests/NexusDeterministicOrderTests.swift @@ -1,5 +1,5 @@ // -// NexusIterationOrderTests.swift +// NexusDeterministicOrderTests.swift // FirebladeECSTests // @@ -7,11 +7,11 @@ import Testing @testable import FirebladeECS /// Swift seeds `Dictionary` hashing per process, so any order taken from a dictionary changes between runs. -/// These tests pin iteration orders to ascending entity identifiers instead. -@Suite struct NexusIterationOrderTests { +/// These tests expect identifier order, which a hash-seeded order of 256 entities matches only by chance. +@Suite struct NexusDeterministicOrderTests { private static let entityCount = 256 - @Test func familyCreatedAfterEntitiesIteratesInIdentifierOrder() { + @Test func familyCreatedAfterEntitiesHasDeterministicOrder() { let nexus = Nexus() let expected = (0.. Date: Sun, 18 Oct 2026 15:44:25 +0200 Subject: [PATCH 4/7] Revive the benchmark package with the setup used in math 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 --- Benchmarks/Benchmarks/ECSBenchmark/Base.swift | 14 ++++---- .../ECSBenchmark/OneDimensionBenchmarks.swift | 2 +- Benchmarks/Package.resolved | 34 ++++++++++++------- Benchmarks/Package.swift | 20 +++++++---- 4 files changed, 43 insertions(+), 27 deletions(-) diff --git a/Benchmarks/Benchmarks/ECSBenchmark/Base.swift b/Benchmarks/Benchmarks/ECSBenchmark/Base.swift index 698ada21..8da3c683 100644 --- a/Benchmarks/Benchmarks/ECSBenchmark/Base.swift +++ b/Benchmarks/Benchmarks/ECSBenchmark/Base.swift @@ -7,16 +7,16 @@ import FirebladeECS -class EmptyComponent: Component {} +final class EmptyComponent: Component, @unchecked Sendable {} -class Name: Component { +final class Name: Component, @unchecked Sendable { var name: String init(name: String) { self.name = name } } -class Position: Component { +final class Position: Component, @unchecked Sendable { var x: Int var y: Int init(x: Int, y: Int) { @@ -25,21 +25,21 @@ class Position: Component { } } -class Velocity: Component { +final class Velocity: Component, @unchecked Sendable { var a: Float init(a: Float) { self.a = a } } -class Party: Component { +final class Party: Component, @unchecked Sendable { var partying: Bool init(partying: Bool) { self.partying = partying } } -class Color: Component { +final class Color: Component, @unchecked Sendable { var r: UInt8 = 0 var g: UInt8 = 0 var b: UInt8 = 0 @@ -60,7 +60,7 @@ class ExampleSystem { } } -final class SingleGameState: SingleComponent { +final class SingleGameState: SingleComponent, @unchecked Sendable { var shouldQuit: Bool = false var playerHealth: Int = 67 } diff --git a/Benchmarks/Benchmarks/ECSBenchmark/OneDimensionBenchmarks.swift b/Benchmarks/Benchmarks/ECSBenchmark/OneDimensionBenchmarks.swift index 01618afa..c630ec66 100644 --- a/Benchmarks/Benchmarks/ECSBenchmark/OneDimensionBenchmarks.swift +++ b/Benchmarks/Benchmarks/ECSBenchmark/OneDimensionBenchmarks.swift @@ -4,7 +4,7 @@ import FirebladeECS // derived from FirebladeECSPerformanceTests/TypedFamilyPerformanceTests.swift in the parent project -let benchmarks = { +let benchmarks: @Sendable () -> Void = { Benchmark("TraitMatching") { benchmark in let nexus = setUpNexus() let a = nexus.createEntity() diff --git a/Benchmarks/Package.resolved b/Benchmarks/Package.resolved index c3a42119..ba448fba 100644 --- a/Benchmarks/Package.resolved +++ b/Benchmarks/Package.resolved @@ -1,5 +1,15 @@ { + "originHash" : "8882d2354f8aa2a8ea99923bbc886afd520b4fb284e26d07954970392ab8ad28", "pins" : [ + { + "identity" : "benchmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ordo-one/benchmark", + "state" : { + "revision" : "def9afdba446b03efbbbcecb3b892e97e8294214", + "version" : "1.38.0" + } + }, { "identity" : "hdrhistogram-swift", "kind" : "remoteSourceControl", @@ -14,17 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ordo-one/malloc-interposer.git", "state" : { - "revision" : "9221411ac2e845a93ce65b9ca2ab84f8c1084b0d", - "version" : "1.4.0" - } - }, - { - "identity" : "package-benchmark", - "kind" : "remoteSourceControl", - "location" : "https://github.com/ordo-one/package-benchmark", - "state" : { - "revision" : "1d1b7a56ade88f69c0cef1fa423b320059315f1a", - "version" : "1.37.0" + "revision" : "f479398f73b14dbd1d25f4e01fd667171ddc9f18", + "version" : "1.6.0" } }, { @@ -54,6 +55,15 @@ "version" : "1.1.1" } }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "050f1a346fbbac0ca2cfb15a95274f7bd1cf0ccf", + "version" : "604.0.0" + } + }, { "identity" : "swift-system", "kind" : "remoteSourceControl", @@ -73,5 +83,5 @@ } } ], - "version" : 2 + "version" : 3 } diff --git a/Benchmarks/Package.swift b/Benchmarks/Package.swift index 0bfe7cda..683cdbd0 100644 --- a/Benchmarks/Package.swift +++ b/Benchmarks/Package.swift @@ -1,27 +1,33 @@ -// swift-tools-version: 5.8 +// swift-tools-version: 6.3 +// Runtime benchmarks for FirebladeECS, kept in a separate package so that the +// benchmark dependency never becomes part of FirebladeECS's dependency graph. +// +// Run from this directory: +// swift package benchmark import PackageDescription let package = Package( name: "ECSBenchmarks", platforms: [ - .iOS(.v16), - .macOS(.v13) + .iOS(.v17), + .macOS(.v14) ], dependencies: [ - .package(path: "../"), - .package(url: "https://github.com/ordo-one/package-benchmark", .upToNextMajor(from: "1.37.0")) + // The package identity of a path dependency is its directory name, i.e. the repository name. + .package(path: ".."), + .package(url: "https://github.com/ordo-one/benchmark", .upToNextMajor(from: "1.38.0")) ], targets: [ .executableTarget( name: "ECSBenchmark", dependencies: [ .product(name: "FirebladeECS", package: "ecs"), - .product(name: "Benchmark", package: "package-benchmark") + .product(name: "Benchmark", package: "benchmark") ], path: "Benchmarks/ECSBenchmark", plugins: [ - .plugin(name: "BenchmarkPlugin", package: "package-benchmark") + .plugin(name: "BenchmarkPlugin", package: "benchmark") ] ) ] From 016de3c500985139ae8bfe79fce537fbf66aef66 Mon Sep 17 00:00:00 2001 From: Christian Treffs Date: Sun, 18 Oct 2026 15:44:29 +0200 Subject: [PATCH 5/7] Benchmark family creation and entity iteration Both paths now sort entity identifiers for a deterministic order. Claude-Session: https://claude.ai/code/session_01UaLGUVakHTRSr8q7EMhME5 --- .../ECSBenchmark/OneDimensionBenchmarks.swift | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/Benchmarks/Benchmarks/ECSBenchmark/OneDimensionBenchmarks.swift b/Benchmarks/Benchmarks/ECSBenchmark/OneDimensionBenchmarks.swift index c630ec66..e888a397 100644 --- a/Benchmarks/Benchmarks/ECSBenchmark/OneDimensionBenchmarks.swift +++ b/Benchmarks/Benchmarks/ECSBenchmark/OneDimensionBenchmarks.swift @@ -195,4 +195,25 @@ let benchmarks: @Sendable () -> Void = { ) } } + + // One sample per run: the family must be new, so each run sets up its own nexus outside the measurement. + Benchmark("FamilyCreation", configuration: .init(scalingFactor: .one)) { benchmark in + let nexus = setUpNexus() + benchmark.startMeasurement() + blackHole(nexus.family(requiresAll: Position.self, Velocity.self, excludesAll: Party.self)) + benchmark.stopMeasurement() + } + + Benchmark("EntitiesIteration") { benchmark in + let nexus = setUpNexus() + for _ in benchmark.scaledIterations { + blackHole( + nexus + .makeEntitiesIterator() + .forEach { (entity: Entity) in + _ = entity + } + ) + } + } } From c3759be2137583906051962da16e7b69e711377a Mon Sep 17 00:00:00 2001 From: Christian Treffs Date: Sun, 18 Oct 2026 15:48:02 +0200 Subject: [PATCH 6/7] Add a make benchmark target like the one in math Claude-Session: https://claude.ai/code/session_01UaLGUVakHTRSr8q7EMhME5 --- Benchmarks/README.md | 8 ++++++-- Makefile | 8 +++++++- README.md | 2 ++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/Benchmarks/README.md b/Benchmarks/README.md index 0d525f65..1dd028f0 100644 --- a/Benchmarks/README.md +++ b/Benchmarks/README.md @@ -1,8 +1,12 @@ # Benchmarks for FirebladeECS -Originally seeded by replicating performance tests into a new form leveraging [package-benchmark](https://swiftpackageindex.com/ordo-one/package-benchmark/) [Documentation](https://swiftpackageindex.com/ordo-one/package-benchmark/main/documentation/benchmark). +Originally seeded by replicating performance tests into a new form leveraging [benchmark](https://github.com/ordo-one/benchmark) [Documentation](https://swiftpackageindex.com/ordo-one/package-benchmark/main/documentation/benchmark). -To run all the available benchmarks: +To run all the available benchmarks from the repository root: + + make benchmark + +or from this directory: swift package benchmark --format markdown diff --git a/Makefile b/Makefile index 46449da2..a98334a0 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ DOCS_VERSION_PATH ?= main # The full base path for hosting HOSTING_BASE_PATH ?= $(REPO_NAME)/$(DOCS_VERSION_PATH) -.PHONY: setup lint lint-fix test test-coverage testReadme build-debug build-release docs docs-preview docs-generate docs-coverage docs-check-coverage docs-check-links preview-analysis-docs generate-docs-githubpages pre-commit clean +.PHONY: setup lint lint-fix test test-coverage testReadme build-debug build-release docs docs-preview docs-generate docs-coverage docs-check-coverage docs-check-links preview-analysis-docs generate-docs-githubpages pre-commit clean benchmark # --- Setup --- @@ -118,6 +118,12 @@ generate-docs-githubpages: --transform-for-static-hosting \ --hosting-base-path '$(REPO_NAME)' +# --- Benchmarks --- + +# Runtime benchmarks live in a separate package (Benchmarks/) so the benchmark dependency stays out of FirebladeECS. +benchmark: + cd Benchmarks && swift package benchmark + # --- Workflows --- pre-commit: lint-fix test diff --git a/README.md b/README.md index c78050cd..d7ee91d8 100644 --- a/README.md +++ b/README.md @@ -304,6 +304,8 @@ Before commiting code please ensure to run: - `make precommit` +To measure runtime performance run `make benchmark` (see [Benchmarks/README.md](Benchmarks/README.md)). + This project is currently maintained by [Christian Treffs](https://github.com/ctreffs). See also the list of [contributors](https://github.com/fireblade-engine/ecs/contributors) who participated in this project. From 5525b222e4fed015e7a782d357b0d07e63dc092b Mon Sep 17 00:00:00 2001 From: Christian Treffs Date: Sun, 18 Oct 2026 16:19:11 +0200 Subject: [PATCH 7/7] Benchmark membership churn on an existing family Claude-Session: https://claude.ai/code/session_01UaLGUVakHTRSr8q7EMhME5 --- .../ECSBenchmark/OneDimensionBenchmarks.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/Benchmarks/Benchmarks/ECSBenchmark/OneDimensionBenchmarks.swift b/Benchmarks/Benchmarks/ECSBenchmark/OneDimensionBenchmarks.swift index e888a397..db344762 100644 --- a/Benchmarks/Benchmarks/ECSBenchmark/OneDimensionBenchmarks.swift +++ b/Benchmarks/Benchmarks/ECSBenchmark/OneDimensionBenchmarks.swift @@ -216,4 +216,26 @@ let benchmarks: @Sendable () -> Void = { ) } } + + // Membership churn on an existing family: components leave and rejoin, entities are created and destroyed. + Benchmark("FamilyChurn") { benchmark in + let nexus = setUpNexus() + let family = nexus.family(requiresAll: Position.self, Velocity.self, excludesAll: Party.self) + let entities = Array(family.entities.prefix(1000)) + for _ in benchmark.scaledIterations { + for entity in entities { + entity.remove(Velocity.self) + } + for entity in entities { + entity.set(Velocity(a: 3.14)) + } + let spawned = (0 ..< 1000).map { index in + nexus.createEntity(with: Position(x: index, y: index), Velocity(a: 1)) + } + for entity in spawned { + nexus.destroy(entity: entity) + } + blackHole(family.count) + } + } }