Repository: mlange-42/arche
Pull request #338 (2024-01-17 18:44:04)

Build tag `tiny` for max. 64 components
Builds with `-tags tiny` reduce the maximum number of component from 256 to 64.
Speeds up mask-related operations and copying of masks, e.g. in the event system.

Adds profiling and more benchmarks for events and listeners.

coveralls: ## Pull Request Test Coverage Report for [Build 7560349996](https://coveralls.io/builds/65132351)


* **0** of **0**   changed or added relevant lines in **0** files are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage remained the same at **100.0%**

---



|  Totals | [![Coverage Status](https://coveralls.io/builds/65132351/badge)](https://coveralls.io/builds/65132351) |
| :-- | --: |
| Change from base [Build 7558406615](https://coveralls.io/builds/65127643): |  0.0% |
| Covered Lines: | 5229 |
| Relevant Lines: | 5229 |

---
##### 💛  - [Coveralls](https://coveralls.io)


Patch:
diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml
index 588955d..0659bcb 100644
--- a/.github/workflows/benchmarks.yml
+++ b/.github/workflows/benchmarks.yml
@@ -27,6 +27,25 @@ jobs:
         run: |
           cd benchmark
           go test -benchmem -run=^$ -bench ^.*$ ./arche/...
+          
+  internal_tiny:
+    name: Internals (tiny)
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - name: Setup Go
+        uses: actions/setup-go@v3
+        with:
+          go-version: '1.21.x'
+      - name: Install dependencies
+        run: go get .
+      - name: Run internal benchmarks (tiny)
+        run: |
+          go test -tags tiny -benchmem -run=^$ -bench ^.*$ ./...
+      - name: Run Arche benchmarks (tiny)
+        run: |
+          cd benchmark
+          go test -tags tiny -benchmem -run=^$ -bench ^.*$ ./arche/...
   
   relations:
     name: Entity relations
@@ -43,6 +62,23 @@ jobs:
         run: |
           cd benchmark
           go test -benchmem -run=^$ -bench ^.*$ ./competition/relations/...
+ 
+
+  relations_tiny:
+    name: Entity relations (tiny)
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - name: Setup Go
+        uses: actions/setup-go@v3
+        with:
+          go-version: '1.21.x'
+      - name: Install dependencies
+        run: go get .
+      - name: Benchmark entity relations (tiny)
+        run: |
+          cd benchmark
+          go test -tags tiny -benchmem -run=^$ -bench ^.*$ ./competition/relations/...
   
   other_methods:
     name: vs. Array of Structs
@@ -74,12 +110,32 @@ jobs:
       - name: Benchmark Pos/Vel
         run: |
           cd benchmark
-          go test -benchmem -run=^$ -bench ^.*$ ./competition/pos_vel/... --count 12
+          go test -benchmem -run=^$ -bench ^.*$ ./competition/pos_vel/... --count 10
       - name: Benchmark Access 10 Components
         run: |
           cd benchmark
-          go test -benchmem -run=^$ -bench ^.*$ ./competition/many_components/... --count 12
+          go test -benchmem -run=^$ -bench ^.*$ ./competition/many_components/... --count 10
       - name: Benchmark Add/Remove Components
         run: |
           cd benchmark
-          go test -benchmem -run=^$ -bench ^.*$ ./competition/add_remove/... --count 12
+          go test -benchmem -run=^$ -bench ^.*$ ./competition/add_remove/... --count 10
+
+  competition_tiny:
+    name: ECS competition (tiny)
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - name: Setup Go
+        uses: actions/setup-go@v3
+        with:
+          go-version: '1.21.x'
+      - name: Install dependencies
+        run: go get .
+      - name: Benchmark Pos/Vel (tiny)
+        run: |
+          cd benchmark
+          go test -tags tiny -benchmem -run=^$ -bench ^.*$ ./competition/pos_vel/... --count 5
+      - name: Benchmark Access 10 Components (tiny)
+        run: |
+          cd benchmark
+          go test -tags tiny -benchmem -run=^$ -bench ^.*$ ./competition/many_components/... --count 5
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f7e0011..dd8ef2c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -27,6 +27,7 @@ This change was necessary to get the same performance as before, despite the mor
 * The world's entity state can be extracted and re-established via `World.DumpEntities()` and `World.LoadEntities()` (#319, #326)
 * Adds functions `ComponentIDs(*World)` and `ResourceIDs(*World)` to get all registered IDs (#329)
 * Adds methods `Mask.And`, `Mask.Or` and `Mask.Xor` (#335)
+* Adds build tag `tiny` to restrict to 64 components for an extra bit of performance (#338)
 
 ### Performance
 
diff --git a/benchmark/profile/events/main.go b/benchmark/profile/events/main.go
new file mode 100644
index 0000000..bb76786
--- /dev/null
+++ b/benchmark/profile/events/main.go
@@ -0,0 +1,93 @@
+package main
+
+// Profiling:
+// go build ./benchmark/profile/events
+// events
+// go tool pprof -http=":8000" -nodefraction=0.001 events cpu.pprof
+// go tool pprof -http=":8000" -nodefraction=0.001 events mem.pprof
+
+import (
+	"github.com/mlange-42/arche/ecs"
+	"github.com/mlange-42/arche/ecs/event"
+	"github.com/pkg/profile"
+)
+
+type position struct {
+	X int
+	Y int
+}
+
+type velocity struct {
+	X int
+	Y int
+}
+
+type rotation struct {
+	Angle int
+}
+
+func main() {
+
+	count := 100
+	iters := 1000
+	entities := 1000
+
+	stop := profile.Start(profile.CPUProfile, profile.ProfilePath("."))
+	run(count, iters, entities)
+	stop.Stop()
+
+	stop = profile.Start(profile.MemProfileAllocs, profile.ProfilePath("."))
+	run(count, iters, entities)
+	stop.Stop()
+}
+
+func run(rounds, iters, numEntities int) {
+	for i := 0; i < rounds; i++ {
+		world := ecs.NewWorld(
+			ecs.NewConfig().WithCapacityIncrement(1024),
+		)
+
+		posID := ecs.ComponentID[position](&world)
+		velID := ecs.ComponentID[velocity](&world)
+
+		builder := ecs.NewBuilder(&world, posID)
+		builder.NewBatch(1000)
+
+		pos := []ecs.ID{posID}
+		vel := []ecs.ID{velID}
+
+		filterPos := ecs.All(posID)
+		filterVel := ecs.All(velID)
+
+		var temp event.Subscription
+		listener := dummyListener{}
+		world.SetListener(&listener)
+
+		hasPos := true
+		for j := 0; j < iters; j++ {
+			if hasPos {
+				world.Batch().Exchange(filterPos, vel, pos)
+			} else {
+				world.Batch().Exchange(filterVel, pos, vel)
+			}
+			hasPos = !hasPos
+		}
+		_ = temp
+	}
+}
+
+type dummyListener struct {
+	temp event.Subscription
+}
+
+func (l *dummyListener) Notify(w *ecs.World, evt ecs.EntityEvent) {
+	l.temp = evt.EventTypes
+}
+
+func (l *dummyListener) Subscriptions() event.Subscription {
+	return event.Components
+}
+
+func (l *dummyListener) Components() *ecs.Mask {
+	return nil
+}
diff --git a/ecs/bitmask.go b/ecs/bitmask.go
index 31cfe9b..d984635 100644
--- a/ecs/bitmask.go
+++ b/ecs/bitmask.go
@@ -1,3 +1,5 @@
+//go:build !tiny
+
 package ecs
 
 import (
@@ -8,7 +10,6 @@ import (
 //
 // It is the maximum number of component types that may exist in any [World].
 const MaskTotalBits = 256
-const wordSize = 64
 
 // Mask is a 256 bit bitmask.
 // It is also a [Filter] for including certain components.
@@ -19,43 +20,6 @@ type Mask struct {
 	bits [4]uint64 // 4x 64 bits of the mask
 }
 
-// All creates a new Mask from a list of IDs.
-// Matches all entities that have the respective components, and potentially further components.
-//
-// See also [Mask.Without] and [Mask.Exclusive]
-//
-// If any [ID] is greater than or equal to [MaskTotalBits], it will not be added to the mask.
-func All(ids ...ID) Mask {
-	var mask Mask
-	for _, id := range ids {
-		mask.Set(id, true)
-	}
-	return mask
-}
-
-// Matches the mask as filter against another mask.
-func (b Mask) Matches(bits *Mask) bool {
-	return bits.Contains(&b)
-}
-
-// Without creates a [MaskFilter] which filters for including the mask's components,
-// and excludes the components given as arguments.
-func (b Mask) Without(comps ...ID) MaskFilter {
-	return MaskFilter{
-		Include: b,
-		Exclude: All(comps...),
-	}
-}
-
-// Exclusive creates a [MaskFilter] which filters for exactly the mask's components.
-// Matches only entities that have exactly the given components, and no other.
-func (b Mask) Exclusive() MaskFilter {
-	return MaskFilter{
-		Include: b,
-		Exclude: b.Not(),
-	}
-}
-
 // Get reports whether the bit at the given index [ID] is set.
 //
 // Returns false for bit >= [MaskTotalBits].
@@ -152,3 +116,23 @@ func (b *Mask) Xor(other *Mask) Mask {
 func (b *Mask) TotalBitsSet() int {
 	return bits.OnesCount64(b.bits[0]) + bits.OnesCount64(b.bits[1]) + bits.OnesCount64(b.bits[2]) + bits.OnesCount64(b.bits[3])
 }
+
+func (b *Mask) toTypes(reg *componentRegistry) []componentType {
+	count := int(b.TotalBitsSet())
+	types := make([]componentType, count)
+
+	idx := 0
+	for i := range b.bits {
+		if b.bits[i] == 0 {
+			continue
+		}
+		for j := 0; j < wordSize; j++ {
+			id := ID{id: uint8(i*wordSize + j)}
+			if b.Get(id) {
+				types[idx] = componentType{ID: id, Type: reg.Types[id.id]}
+				idx++
+			}
+		}
+	}
+	return types
+}
diff --git a/ecs/bitmask_common.go b/ecs/bitmask_common.go
new file mode 100644
index 0000000..35460a4
--- /dev/null
+++ b/ecs/bitmask_common.go
@@ -0,0 +1,40 @@
+package ecs
+
+const wordSize = 64
+
+// All creates a new Mask from a list of IDs.
+// Matches all entities that have the respective components, and potentially further components.
+//
+// See also [Mask.Without] and [Mask.Exclusive]
+//
+// If any [ID] is greater than or equal to [MaskTotalBits], it will not be added to the mask.
+func All(ids ...ID) Mask {
+	var mask Mask
+	for _, id := range ids {
+		mask.Set(id, true)
+	}
+	return mask
+}
+
+// Matches the mask as filter against another mask.
+func (b Mask) Matches(bits *Mask) bool {
+	return bits.Contains(&b)
+}
+
+// Without creates a [MaskFilter] which filters for including the mask's components,
+// and excludes the components given as arguments.
+func (b Mask) Without(comps ...ID) MaskFilter {
+	return MaskFilter{
+		Include: b,
+		Exclude: All(comps...),
+	}
+}
+
+// Exclusive creates a [MaskFilter] which filters for exactly the mask's components.
+// Matches only entities that have exactly the given components, and no other.
+func (b Mask) Exclusive() MaskFilter {
+	return MaskFilter{
+		Include: b,
+		Exclude: b.Not(),
+	}
+}
diff --git a/ecs/bitmask_tiny.go b/ecs/bitmask_tiny.go
new file mode 100644
index 0000000..cedc1d1
--- /dev/null
+++ b/ecs/bitmask_tiny.go
@@ -0,0 +1,112 @@
+//go:build tiny
+
+package ecs
+
+import (
+	"math/bits"
+)
+
+// MaskTotalBits is the size of Mask in bits.
+//
+// It is the maximum number of component types that may exist in any [World].
+const MaskTotalBits = 64
+
+// Mask is a 64 bit bitmask.
+// It is also a [Filter] for including certain components.
+//
+// Use [All] to create a mask for a list of component IDs.
+// A mask can be further specified using [Mask.Without] or [Mask.Exclusive].
+type Mask struct {
+	bits uint64 // 64 bits of the mask
+}
+
+// Get reports whether the bit at the given index [ID] is set.
+//
+// Returns false for bit >= [MaskTotalBits].
+func (b *Mask) Get(bit ID) bool {
+	mask := uint64(1 << bit.id)
+	return b.bits&mask == mask
+}
+
+// Set sets the state of the bit at the given index.
+//
+// Has no effect for bit >= [MaskTotalBits].
+func (b *Mask) Set(bit ID, value bool) {
+	if value {
+		b.bits |= (1 << bit.id)
+	} else {
+		b.bits &= ^(1 << bit.id)
+	}
+}
+
+// Not returns the inversion of this mask.
+func (b *Mask) Not() Mask {
+	return Mask{
+		bits: ^b.bits,
+	}
+}
+
+// IsZero returns whether no bits are set in the mask.
+func (b *Mask) IsZero() bool {
+	return b.bits == 0
+}
+
+// Reset the mask setting all bits to false.
+func (b *Mask) Reset() {
+	b.bits = 0
+}
+
+// Contains reports if the other mask is a subset of this mask.
+func (b *Mask) Contains(other *Mask) bool {
+	return b.bits&other.bits == other.bits
+}
+
+// ContainsAny reports if any bit of the other mask is in this mask.
+func (b *Mask) ContainsAny(other *Mask) bool {
+	return b.bits&other.bits != 0
+}
+
+// And returns the bitwise AND of two masks.
+func (b *Mask) And(other *Mask) Mask {
+	return Mask{
+		bits: b.bits & other.bits,
+	}
+}
+
+// Or returns the bitwise OR of two masks.
+func (b *Mask) Or(other *Mask) Mask {
+	return Mask{
+		bits: b.bits | other.bits,
+	}
+}
+
+// Xor returns the bitwise XOR of two masks.
+func (b *Mask) Xor(other *Mask) Mask {
+	return Mask{
+		bits: b.bits ^ other.bits,
+	}
+}
+
+// TotalBitsSet returns how many bits are set in this mask.
+func (b *Mask) TotalBitsSet() int {
+	return bits.OnesCount64(b.bits)
+}
+
+func (b *Mask) toTypes(reg *componentRegistry) []componentType {
+	if b.bits == 0 {
+		return []componentType{}
+	}
+
+	count := int(b.TotalBitsSet())
+	types := make([]componentType, count)
+
+	idx := 0
+	for j := 0; j < wordSize; j++ {
+		id := ID{id: uint8(j)}
+		if b.Get(id) {
+			types[idx] = componentType{ID: id, Type: reg.Types[id.id]}
+			idx++
+		}
+	}
+	return types
+}
diff --git a/ecs/pool.go b/ecs/pool.go
index e3c9e20..7d695e0 100644
--- a/ecs/pool.go
+++ b/ecs/pool.go
@@ -1,6 +1,7 @@
 package ecs
 
 import (
+	"fmt"
 	"math"
 )
 
@@ -116,7 +117,7 @@ func (p *bitPool) Get() uint8 {
 // Allocates and returns a new bit. For internal use.
 func (p *bitPool) getNew() uint8 {
 	if p.length >= MaskTotalBits {
-		panic("run out of the maximum of 256 bits")
+		panic(fmt.Sprintf("run out of the maximum of %d bits", MaskTotalBits))
 	}
 	b := uint8(p.length)
 	p.bits[p.length] = b
diff --git a/ecs/util.go b/ecs/util.go
index 74652a0..44778aa 100644
--- a/ecs/util.go
+++ b/ecs/util.go
@@ -98,26 +98,6 @@ func subscribes(trigger event.Subscription, added *Mask, removed *Mask, subs *Ma
 	return false
 }
 
-func maskToTypes(mask Mask, reg *componentRegistry) []componentType {
-	count := int(mask.TotalBitsSet())
-	types := make([]componentType, count)
-
-	idx := 0
-	for i := range mask.bits {
-		if mask.bits[i] == 0 {
-			continue
-		}
-		for j := 0; j < wordSize; j++ {
-			id := ID{id: uint8(i*wordSize + j)}
-			if mask.Get(id) {
-				types[idx] = componentType{ID: id, Type: reg.Types[id.id]}
-				idx++
-			}
-		}
-	}
-	return types
-}
-
 // Manages locks by mask bits.
 //
 // The number of simultaneous locks at a given time is limited to [MaskTotalBits].
diff --git a/ecs/world.go b/ecs/world.go
index e14b5ae..c87ac6c 100644
--- a/ecs/world.go
+++ b/ecs/world.go
@@ -13,6 +13,7 @@ import (
 // Registers the type if it is not already registered.
 //
 // The number of unique component types per [World] is limited to 256 ([MaskTotalBits]).
+// (64 with build tag `tiny`).
 //
 // Panics if called on a locked world and the type is not registered yet.
 //
@@ -216,7 +217,7 @@ func (w *World) NewEntity(comps ...ID) Entity {
 		bits := subscription(true, false, len(comps) > 0, false, newRel != nil, false)
 		trigger := w.listener.Subscriptions() & bits
 		if trigger != 0 && subscribes(trigger, &arch.Mask, nil, w.listener.Components(), nil, newRel) {
-			w.listener.Notify(w, EntityEvent{entity, arch.Mask, Mask{}, comps, nil, nil, newRel, Entity{}, bits})
+			w.listener.Notify(w, EntityEvent{Entity: entity, Added: arch.Mask, AddedIDs: comps, NewRelation: newRel, EventTypes: bits})
 		}
 	}
 	return entity
@@ -265,7 +266,7 @@ func (w *World) NewEntityWith(comps ...Component) Entity {
 		bits := subscription(true, false, len(comps) > 0, false, newRel != nil, false)
 		trigger := w.listener.Subscriptions() & bits
 		if trigger != 0 && subscribes(trigger, &arch.Mask, nil, w.listener.Components(), nil, newRel) {
-			w.listener.Notify(w, EntityEvent{entity, arch.Mask, Mask{}, ids, nil, nil, newRel, Entity{}, bits})
+			w.listener.Notify(w, EntityEvent{Entity: entity, Added: arch.Mask, AddedIDs: ids, NewRelation: newRel, EventTypes: bits})
 		}
 	}
 	return entity
@@ -296,7 +297,7 @@ func (w *World) newEntityTarget(targetID ID, target Entity, comps ...ID) Entity
 		bits := subscription(true, false, len(comps) > 0, false, true, !target.IsZero())
 		trigger := w.listener.Subscriptions() & bits
 		if trigger != 0 && subscribes(trigger, &arch.Mask, nil, w.listener.Components(), nil, &targetID) {
-			w.listener.Notify(w, EntityEvent{entity, arch.Mask, Mask{}, comps, nil, nil, &targetID, Entity{}, bits})
+			w.listener.Notify(w, EntityEvent{Entity: entity, Added: arch.Mask, AddedIDs: comps, NewRelation: &targetID, EventTypes: bits})
 		}
 	}
 	return entity
@@ -333,7 +334,7 @@ func (w *World) newEntityTargetWith(targetID ID, target Entity, comps ...Compone
 		bits := subscription(true, false, len(comps) > 0, false, true, !target.IsZero())
 		trigger := w.listener.Subscriptions() & bits
 		if trigger != 0 && subscribes(trigger, &arch.Mask, nil, w.listener.Components(), nil, &targetID) {
-			w.listener.Notify(w, EntityEvent{entity, arch.Mask, Mask{}, ids, nil, nil, &targetID, Entity{}, bits})
+			w.listener.Notify(w, EntityEvent{Entity: entity, Added: arch.Mask, AddedIDs: ids, NewRelation: &targetID, EventTypes: bits})
 		}
 	}
 	return entity
@@ -357,7 +358,7 @@ func (w *World) newEntities(count int, targetID ID, hasTarget bool, target Entit
 			for i = 0; i < cnt; i++ {
 				idx := startIdx + i
 				entity := arch.GetEntity(idx)
-				w.listener.Notify(w, EntityEvent{entity, arch.Mask, Mask{}, comps, nil, nil, newRel, Entity{}, bits})
+				w.listener.Notify(w, EntityEvent{Entity: entity, Added: arch.Mask, AddedIDs: comps, NewRelation: newRel, EventTypes: bits})
 			}
 		}
 	}
@@ -402,7 +403,7 @@ func (w *World) newEntitiesWith(count int, targetID ID, hasTarget bool, target E
 			for i = 0; i < cnt; i++ {
 				idx := startIdx + i
 				entity := arch.GetEntity(idx)
-				w.listener.Notify(w, EntityEvent{entity, arch.Mask, Mask{}, ids, nil, nil, newRel, Entity{}, bits})
+				w.listener.Notify(w, EntityEvent{Entity: entity, Added: arch.Mask, AddedIDs: ids, NewRelation: newRel, EventTypes: bits})
 			}
 		}
 	}
@@ -456,7 +457,7 @@ func (w *World) RemoveEntity(entity Entity) {
 		trigger := w.listener.Subscriptions() & bits
 		if trigger != 0 && subscribes(trigger, nil, &oldArch.Mask, w.listener.Components(), oldRel, nil) {
 			lock := w.lock()
-			w.listener.Notify(w, EntityEvent{entity, Mask{}, oldArch.Mask, nil, oldIds, oldRel, nil, oldArch.RelationTarget, bits})
+			w.listener.Notify(w, EntityEvent{Entity: entity, Removed: oldArch.Mask, RemovedIDs: oldIds, OldRelation: oldRel, OldTarget: oldArch.RelationTarget, EventTypes: bits})
 			w.unlock(lock)
 		}
 	}
@@ -525,7 +526,7 @@ func (w *World) removeEntities(filter Filter) int {
 		for j = 0; j < ln; j++ {
 			entity := arch.GetEntity(j)
 			if listen {
-				w.listener.Notify(w, EntityEvent{entity, Mask{}, arch.Mask, nil, oldIds, oldRel, nil, Entity{}, bits})
+				w.listener.Notify(w, EntityEvent{Entity: entity, Removed: arch.Mask, RemovedIDs: oldIds, OldRelation: oldRel, OldTarget: arch.RelationTarget, EventTypes: bits})
 			}
 			index := &w.entities[entity.id]
 			index.arch = nil
@@ -784,7 +785,11 @@ func (w *World) exchange(entity Entity, add []ID, rem []ID, relation ID, hasRela
 			added := arch.Mask.And(&changed)
 			removed := oldMask.And(&changed)
 			if subscribes(trigger, &added, &removed, w.listener.Components(), oldRel, newRel) {
-				w.listener.Notify(w, EntityEvent{entity, added, removed, add, rem, oldRel, newRel, oldTarget, bits})
+				w.listener.Notify(w,
+					EntityEvent{Entity: entity, Added: added, Removed: removed,
+						AddedIDs: add, RemovedIDs: rem, OldRelation: oldRel, NewRelation: newRel,
+						OldTarget: oldTarget, EventTypes: bits},
+				)
 			}
 		}
 	}
@@ -986,7 +991,7 @@ func (w *World) setRelation(entity Entity, comp ID, target Entity) {
 	if w.listener != nil {
 		trigger := w.listener.Subscriptions() & event.TargetChanged
 		if trigger != 0 && subscribes(trigger, nil, nil, w.listener.Components(), &comp, &comp) {
-			w.listener.Notify(w, EntityEvent{entity, Mask{}, Mask{}, nil, nil, &comp, &comp, oldTarget, event.TargetChanged})
+			w.listener.Notify(w, EntityEvent{Entity: entity, OldRelation: &comp, NewRelation: &comp, OldTarget: oldTarget, EventTypes: event.TargetChanged})
 		}
 	}
 }
@@ -1544,7 +1549,7 @@ func (w *World) createArchetypeNode(mask Mask, relation ID, hasRelation bool) *a
 		capInc = w.config.RelationCapacityIncrement
 	}
 
-	types := maskToTypes(mask, &w.registry)
+	types := mask.toTypes(&w.registry)
 
 	w.nodeData.Add(nodeData{})
 	w.nodes.Add(newArchNode(mask, w.nodeData.Get(w.nodeData.Len()-1), relation, hasRelation, capInc, types))
diff --git a/listener/callback.go b/listener/callback.go
index efcd518..7922948 100644
--- a/listener/callback.go
+++ b/listener/callback.go
@@ -19,6 +19,9 @@ type Callback struct {
 //
 // Subscribes to the specified events with changes on the specified components.
 // If no component IDs are given, it subscribes to all components.
+//
+// Note that this listener is slower than a custom [ecs.Listener] implementation that
+// does not use the indirection of a callback function.
 func NewCallback(callback func(*ecs.World, ecs.EntityEvent), events event.Subscription, components ...ecs.ID) Callback {
 	return Callback{
 		callback:      callback,


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 2111078..f79086d 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -21,10 +21,16 @@ jobs:
         run: go get .
       - name: Build Linux
         run: GOOS=linux GOARCH=amd64 go build ./...
+      - name: Build Linux (tiny)
+        run: GOOS=linux GOARCH=amd64 go build -tags tiny ./...
       - name: Build Windows
         run: GOOS=windows GOARCH=amd64 go build ./...
+      - name: Build Windows (tiny)
+        run: GOOS=windows GOARCH=amd64 go build -tags tiny ./...
       - name: Build MacOS
         run: GOOS=darwin GOARCH=amd64 go build ./...
+      - name: Build MacOS (tiny)
+        run: GOOS=darwin GOARCH=amd64 go build -tags tiny ./...
 
   test:
     name: Run tests
@@ -48,6 +54,24 @@ jobs:
       with:
         path-to-lcov: coverage.out
 
+  test_tiny:
+    name: Run tests (tiny)
+    runs-on: ubuntu-latest
+    steps:
+    - name: Set up Go
+      uses: actions/setup-go@v2
+      with:
+        go-version: '1.21.x'
+    - name: Check out code
+      uses: actions/checkout@v2
+    - name: Install dependencies
+      run: |
+        go get .
+    - name: Run Unit tests (tiny)
+      run: |
+        go test -v -covermode atomic -coverprofile="coverage.out" ./...
+        go tool cover -func="coverage.out"
+
   lint:
     name: Run linters
     runs-on: ubuntu-latest
@@ -69,8 +93,12 @@ jobs:
           fi
       - name: Lint with vet
         run: go vet ./...
+      - name: Lint with vet (tiny)
+        run: go vet -tags tiny ./...
       - name: Lint with staticcheck
         run: staticcheck ./...
+      - name: Lint with staticcheck (tiny)
+        run: staticcheck -tags tiny ./...
       - name: Lint with ineffassign
         run: ineffassign ./...
 
@@ -94,7 +122,7 @@ jobs:
         run: gorelease -base=${{ steps.latest-tag.outputs.tag }}
 
   examples:
-    name: Run examples
+    name: Run Examples
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v3
@@ -120,3 +148,31 @@ jobs:
           go run ./examples/resources
           go run ./examples/systems
           go run ./examples/world_stats
+
+  examples_tiny:
+    name: Run Examples (tiny)
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - name: Setup Go
+        uses: actions/setup-go@v3
+        with:
+          go-version: '1.21.x'
+      - name: Install dependencies
+        run: go get .
+      - name: Run examples
+        run: |
+          go run -tags tiny ./examples/base
+          go run -tags tiny ./examples/batch_ops
+          go run -tags tiny ./examples/events
+          go run -tags tiny ./examples/filter
+          go run -tags tiny ./examples/generic
+          go run -tags tiny ./examples/locked_world
+          go run -tags tiny ./examples/parallel
+          go run -tags tiny ./examples/random_access
+          go run -tags tiny ./examples/random_sampling
+          go run -tags tiny ./examples/readme
+          go run -tags tiny ./examples/relations
+          go run -tags tiny ./examples/resources
+          go run -tags tiny ./examples/systems
+          go run -tags tiny ./examples/world_stats
diff --git a/ecs/bitmask_test.go b/ecs/bitmask_test.go
index c8ab4e5..519ae7c 100644
--- a/ecs/bitmask_test.go
+++ b/ecs/bitmask_test.go
@@ -13,7 +13,8 @@ func all(ids ...ID) *Mask {
 }
 
 func TestBitMask(t *testing.T) {
-	mask := All(id(1), id(2), id(13), id(27), id(200))
+	big := uint8(MaskTotalBits - 2)
+	mask := All(id(1), id(2), id(13), id(27), id(big))
 
 	assert.Equal(t, 5, mask.TotalBitsSet())
 
@@ -21,12 +22,12 @@ func TestBitMask(t *testing.T) {
 	assert.True(t, mask.Get(id(2)))
 	assert.True(t, mask.Get(id(13)))
 	assert.True(t, mask.Get(id(27)))
-	assert.True(t, mask.Get(id(200)))
+	assert.True(t, mask.Get(id(big)))
 
 	assert.False(t, mask.Get(id(0)))
 	assert.False(t, mask.Get(id(3)))
-	assert.False(t, mask.Get(id(199)))
-	assert.False(t, mask.Get(id(201)))
+	assert.False(t, mask.Get(id(big-1)))
+	assert.False(t, mask.Get(id(big+1)))
 
 	mask.Set(id(0), true)
 	mask.Set(id(1), false)
@@ -52,13 +53,17 @@ func TestBitMask(t *testing.T) {
 }
 
 func TestBitMaskLogic(t *testing.T) {
-	assert.Equal(t, All(id(5)), all(id(0), id(5)).And(all(id(5), id(200))))
-	assert.Equal(t, All(id(0), id(5), id(200)), all(id(0), id(5)).Or(all(id(5), id(200))))
-	assert.Equal(t, All(id(0), id(200)), all(id(0), id(5)).Xor(all(id(5), id(200))))
+	big := uint8(MaskTotalBits - 2)
+
+	assert.Equal(t, All(id(5)), all(id(0), id(5)).And(all(id(5), id(big))))
+	assert.Equal(t, All(id(0), id(5), id(big)), all(id(0), id(5)).Or(all(id(5), id(big))))
+	assert.Equal(t, All(id(0), id(big)), all(id(0), id(5)).Xor(all(id(5), id(big))))
 }
 
 func TestBitMaskCopy(t *testing.T) {
-	mask := All(id(1), id(2), id(13), id(27), id(200))
+	big := uint8(MaskTotalBits - 2)
+
+	mask := All(id(1), id(2), id(13), id(27), id(big))
 	mask2 := mask
 	mask3 := &mask
 
@@ -111,13 +116,15 @@ func TestBitMask256(t *testing.T) {
 		assert.True(t, mask.Get(id(uint8(i))))
 	}
 
-	mask = All(id(1), id(2), id(13), id(27), id(63), id(64), id(65))
+	big := uint8(MaskTotalBits - 10)
+
+	mask = All(id(1), id(2), id(13), id(27), id(big), id(big+1), id(big+2))
 
-	assert.True(t, mask.Contains(all(id(1), id(2), id(63), id(64))))
-	assert.False(t, mask.Contains(all(id(1), id(2), id(63), id(90))))
+	assert.True(t, mask.Contains(all(id(1), id(2), id(big), id(big+1))))
+	assert.False(t, mask.Contains(all(id(1), id(2), id(big), id(big+5))))
 
-	assert.True(t, mask.ContainsAny(all(id(6), id(65), id(111))))
-	assert.False(t, mask.ContainsAny(all(id(6), id(66), id(90))))
+	assert.True(t, mask.ContainsAny(all(id(6), id(big+2), id(big+6))))
+	assert.False(t, mask.ContainsAny(all(id(6), id(big+3), id(big+5))))
 }
 
 func TestBitMask64(t *testing.T) {
diff --git a/ecs/event_test.go b/ecs/event_test.go
index dac4137..3b76ed3 100644
--- a/ecs/event_test.go
+++ b/ecs/event_test.go
@@ -112,12 +112,12 @@ func ExampleEntityEvent() {
 	world := ecs.NewWorld()
 
 	listener := TestListener{
-		Callback: func(world *ecs.World, evt ecs.EntityEvent) { fmt.Println(evt) },
+		Callback: func(world *ecs.World, evt ecs.EntityEvent) { fmt.Println(evt.Entity) },
 	}
 	world.SetListener(&listener)
 
 	world.NewEntity()
-	// Output: {{1 0} {[0 0 0 0]} {[0 0 0 0]} [] [] <nil> <nil> {0 0} 1}
+	// Output: {1 0}
 }
 
 func ExampleEntityEvent_Contains() {
diff --git a/ecs/id_map_test.go b/ecs/id_map_test.go
index fb4b9cc..ed885d0 100644
--- a/ecs/id_map_test.go
+++ b/ecs/id_map_test.go
@@ -7,17 +7,20 @@ import (
 )
 
 func TestIDMap(t *testing.T) {
+	big1 := uint8(MaskTotalBits - 20)
+	big2 := uint8(MaskTotalBits - 3)
+
 	m := newIDMap[*Entity]()
 
 	e0 := Entity{0, 0}
 	e1 := Entity{1, 0}
-	e121 := Entity{121, 0}
-	e200 := Entity{200, 0}
+	e121 := Entity{eid(big1), 0}
+	e200 := Entity{eid(big2), 0}
 
 	m.Set(0, &e0)
 	m.Set(1, &e1)
-	m.Set(121, &e121)
-	m.Set(200, &e200)
+	m.Set(big1, &e121)
+	m.Set(big2, &e200)
 
 	e, ok := m.Get(0)
 	assert.True(t, ok)
@@ -27,11 +30,11 @@ func TestIDMap(t *testing.T) {
 	assert.True(t, ok)
 	assert.Equal(t, e1, *e)
 
-	e, ok = m.Get(121)
+	e, ok = m.Get(big1)
 	assert.True(t, ok)
 	assert.Equal(t, e121, *e)
 
-	e, ok = m.Get(200)
+	e, ok = m.Get(big2)
 	assert.True(t, ok)
 	assert.Equal(t, e200, *e)
 
@@ -50,17 +53,20 @@ func TestIDMap(t *testing.T) {
 }
 
 func TestIDMapPointers(t *testing.T) {
+	big1 := uint8(MaskTotalBits - 20)
+	big2 := uint8(MaskTotalBits - 3)
+
 	m := newIDMap[Entity]()
 
 	e0 := Entity{0, 0}
 	e1 := Entity{1, 0}
-	e121 := Entity{121, 0}
-	e200 := Entity{200, 0}
+	e121 := Entity{eid(big1), 0}
+	e200 := Entity{eid(big2), 0}
 
 	m.Set(0, e0)
 	m.Set(1, e1)
-	m.Set(121, e121)
-	m.Set(200, e200)
+	m.Set(big1, e121)
+	m.Set(big2, e200)
 
 	e, ok := m.GetPointer(0)
 	assert.True(t, ok)
@@ -70,11 +76,11 @@ func TestIDMapPointers(t *testing.T) {
 	assert.True(t, ok)
 	assert.Equal(t, e1, *e)
 
-	e, ok = m.GetPointer(121)
+	e, ok = m.GetPointer(big1)
 	assert.True(t, ok)
 	assert.Equal(t, e121, *e)
 
-	e, ok = m.GetPointer(200)
+	e, ok = m.GetPointer(big2)
 	assert.True(t, ok)
 	assert.Equal(t, e200, *e)
 
diff --git a/ecs/world_benchmark_test.go b/ecs/world_benchmark_test.go
index d4f5106..f922aab 100644
--- a/ecs/world_benchmark_test.go
+++ b/ecs/world_benchmark_test.go
@@ -348,6 +348,31 @@ func BenchmarkWorldNewEntityEvent_1000(b *testing.B) {
 	builder.NewBatch(1000)
 	world.Batch().RemoveEntities(filterPos)
 
+	listener := dummyListener{}
+	world.SetListener(&listener)
+
+	for i := 0; i < b.N; i++ {
+		b.StartTimer()
+		for j := 0; j < 1000; j++ {
+			world.NewEntity(posID)
+		}
+		b.StopTimer()
+		world.Batch().RemoveEntities(filterPos)
+	}
+}
+
+func BenchmarkWorldNewEntityEventCallback_1000(b *testing.B) {
+	b.StopTimer()
+
+	world := NewWorld()
+
+	posID := ComponentID[Position](&world)
+	filterPos := All(posID)
+
+	builder := NewBuilder(&world, posID)
+	builder.NewBatch(1000)
+	world.Batch().RemoveEntities(filterPos)
+
 	var temp event.Subscription
 	listener := newTestListener(func(world *World, e EntityEvent) { temp = e.EventTypes })
 	world.SetListener(&listener)
@@ -473,6 +498,49 @@ func BenchmarkWorldExchangeEvent_1000(b *testing.B) {
 	world.Batch().Exchange(filterPos, vel, pos)
 	world.Batch().Exchange(filterVel, pos, vel)
 
+	listener := dummyListener{}
+	world.SetListener(&listener)
+
+	b.StartTimer()
+	hasPos := true
+	for i := 0; i < b.N; i++ {
+		if hasPos {
+			for _, e := range entities {
+				world.Exchange(e, vel, pos)
+			}
+		} else {
+			for _, e := range entities {
+				world.Exchange(e, pos, vel)
+			}
+		}
+		hasPos = !hasPos
+	}
+}
+
+func BenchmarkWorldExchangeEventCallback_1000(b *testing.B) {
+	b.StopTimer()
+
+	world := NewWorld()
+
+	posID := ComponentID[Position](&world)
+	velID := ComponentID[Velocity](&world)
+
+	builder := NewBuilder(&world, posID)
+	entities := make([]Entity, 0, 1000)
+	query := builder.NewBatchQ(1000)
+	for query.Next() {
+		entities = append(entities, query.Entity())
+	}
+
+	filterPos := All(posID)
+	filterVel := All(velID)
+
+	pos := []ID{posID}
+	vel := []ID{velID}
+
+	world.Batch().Exchange(filterPos, vel, pos)
+	world.Batch().Exchange(filterVel, pos, vel)
+
 	var temp event.Subscription
 	listener := newTestListener(func(world *World, e EntityEvent) { temp = e.EventTypes })
 	world.SetListener(&listener)
@@ -584,6 +652,41 @@ func BenchmarkWorldExchangeBatchEvent_1000(b *testing.B) {
 	world.Batch().Exchange(filterPos, vel, pos)
 	world.Batch().Exchange(filterVel, pos, vel)
 
+	listener := dummyListener{}
+	world.SetListener(&listener)
+
+	b.StartTimer()
+	hasPos := true
+	for i := 0; i < b.N; i++ {
+		if hasPos {
+			world.Batch().Exchange(filterPos, vel, pos)
+		} else {
+			world.Batch().Exchange(filterVel, pos, vel)
+		}
+		hasPos = !hasPos
+	}
+}
+
+func BenchmarkWorldExchangeBatchEventCallback_1000(b *testing.B) {
+	b.StopTimer()
+
+	world := NewWorld()
+
+	posID := ComponentID[Position](&world)
+	velID := ComponentID[Velocity](&world)
+
+	builder := NewBuilder(&world, posID)
+	builder.NewBatch(1000)
+
+	filterPos := All(posID)
+	filterVel := All(velID)
+
+	pos := []ID{posID}
+	vel := []ID{velID}
+
+	world.Batch().Exchange(filterPos, vel, pos)
+	world.Batch().Exchange(filterVel, pos, vel)
+
 	var temp event.Subscription
 	listener := newTestListener(func(world *World, e EntityEvent) { temp = e.EventTypes })
 	world.SetListener(&listener)
@@ -600,3 +703,19 @@ func BenchmarkWorldExchangeBatchEvent_1000(b *testing.B) {
 	}
 	_ = temp
 }
+
+type dummyListener struct {
+	temp event.Subscription
+}
+
+func (l *dummyListener) Notify(w *World, evt EntityEvent) {
+	l.temp = evt.EventTypes
+}
+
+func (l *dummyListener) Subscriptions() event.Subscription {
+	return event.All
+}
+
+func (l *dummyListener) Components() *Mask {
+	return nil
+}
diff --git a/ecs/world_examples_test.go b/ecs/world_examples_test.go
index f89e2e4..e7ba25e 100644
--- a/ecs/world_examples_test.go
+++ b/ecs/world_examples_test.go
@@ -278,13 +278,13 @@ func ExampleWorld_SetListener() {
 
 	listener := TestListener{
 		Callback: func(world *ecs.World, evt ecs.EntityEvent) {
-			fmt.Println(evt)
+			fmt.Println(evt.Entity)
 		},
 	}
 	world.SetListener(&listener)
 
 	world.NewEntity()
-	// Output: {{1 0} {[0 0 0 0]} {[0 0 0 0]} [] [] <nil> <nil> {0 0} 1}
+	// Output: {1 0}
 }
 
 func ExampleWorld_Stats() {
diff --git a/ecs/world_test.go b/ecs/world_test.go
index f9a0da9..84d0472 100644
--- a/ecs/world_test.go
+++ b/ecs/world_test.go
@@ -1489,6 +1489,7 @@ func TestWorldBatchRemove(t *testing.T) {
 		Removed:     All(rotID, relID),
 		RemovedIDs:  []ID{rotID, relID},
 		OldRelation: &relID,
+		OldTarget:   target1,
 		EventTypes:  event.EntityRemoved | event.ComponentRemoved | event.RelationChanged | event.TargetChanged,
 	}, events[len(events)-1])
 
@@ -1922,7 +1923,14 @@ func Test1000Archetypes(t *testing.T) {
 	ids[9] = ComponentID[testStruct9](&w)
 
 	for i := 0; i < 1024; i++ {
-		mask := Mask{[4]uint64{uint64(i), 0, 0, 0}}
+		mask := Mask{}
+		tempMask := uint64(i)
+		for bit := 0; bit < wordSize; bit++ {
+			m := uint64(1 << bit)
+			if tempMask&m == m {
+				mask.Set(id(uint8(bit)), true)
+			}
+		}
 		add := make([]ID, 0, 10)
 		for j := 0; j < 10; j++ {
 			id := id(uint8(j))