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
14 changes: 7 additions & 7 deletions Benchmarks/Benchmarks/ECSBenchmark/Base.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand All @@ -60,7 +60,7 @@ class ExampleSystem {
}
}

final class SingleGameState: SingleComponent {
final class SingleGameState: SingleComponent, @unchecked Sendable {
var shouldQuit: Bool = false
var playerHealth: Int = 67
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}
}
}
34 changes: 22 additions & 12 deletions Benchmarks/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 13 additions & 7 deletions Benchmarks/Package.swift
Original file line number Diff line number Diff line change
@@ -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")
]
)
]
Expand Down
8 changes: 6 additions & 2 deletions Benchmarks/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
8 changes: 7 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---

Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
5 changes: 3 additions & 2 deletions Sources/FirebladeECS/Nexus+Entity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -123,15 +123,16 @@ 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<Entity>

/// Creates a new iterator for the given 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
Expand Down
4 changes: 2 additions & 2 deletions Sources/FirebladeECS/Nexus+Internal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
31 changes: 31 additions & 0 deletions Tests/FirebladeECSTests/NexusDeterministicOrderTests.swift
Original file line number Diff line number Diff line change
@@ -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..<Self.entityCount).map { index in
nexus.createEntity(with: Position(x: index, y: index)).identifier
}

let family = nexus.family(requiresAll: Position.self)

#expect(family.entities.map(\.identifier) == expected)
}

@Test func entitiesHaveDeterministicOrder() {
let nexus = Nexus()
let expected = (0..<Self.entityCount).map { _ in nexus.createEntity().identifier }

#expect(nexus.makeEntitiesIterator().map(\.identifier) == expected)
}
}
Loading