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..db344762 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() @@ -195,4 +195,47 @@ let benchmarks = { ) } } + + // 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 + } + ) + } + } + + // 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) + } + } } 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") ] ) ] 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. diff --git a/Sources/FirebladeECS/Nexus+Entity.swift b/Sources/FirebladeECS/Nexus+Entity.swift index e5bcd392..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. + /// An iterator over the entities managed by the Nexus, in a deterministic 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() + // 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 { return nil diff --git a/Sources/FirebladeECS/Nexus+Internal.swift b/Sources/FirebladeECS/Nexus+Internal.swift index b911e5bd..fc1a0a9f 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() { + // 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 new file mode 100644 index 00000000..0d31910d --- /dev/null +++ b/Tests/FirebladeECSTests/NexusDeterministicOrderTests.swift @@ -0,0 +1,31 @@ +// +// NexusDeterministicOrderTests.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 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 familyCreatedAfterEntitiesHasDeterministicOrder() { + let nexus = Nexus() + let expected = (0..