Repository: mlange-42/arche (go)

Issue:
Cached queries?
Performance of queries could be improved by registering them to the world, and updating their list of archetypes when new archetypes are created.

Currently, queries check archetypes using the filter on every iteration pass. Basically, this check is required only when new archetypes are created.


Pull request:
Cached filters
Introduces cached filters.

Filters are registered to the world, and the world keeps track of the relevant archetypes. When a query is created from a cached filter, the managed list of archetypes is used, and no mask checks are required during iteration.

However, this brings a performance advantage only for this case:
* many archetypes
* only a few entities to iterate, that are only in a few archetypes (or a single one)

Not sure if this is really worth the additional API and code.

Fixes #177

### Benchmarks

```
BenchmarkFilterUncached_1of16_100-2        	 3118692	       384.0 ns/op	       0 B/op	       0 allocs/op
BenchmarkFilterUncached_1of16_1_000-2      	  507350	      2312 ns/op	       0 B/op	       0 allocs/op
BenchmarkFilterUncached_1of16_10_000-2     	   55030	     22013 ns/op	       0 B/op	       0 allocs/op
BenchmarkFilterCached_1of16_100-2          	 4105543	       294.9 ns/op	       0 B/op	       0 allocs/op
BenchmarkFilterCached_1of16_1_000-2        	  529378	      2222 ns/op	       0 B/op	       0 allocs/op
BenchmarkFilterCached_1of16_10_000-2       	   54590	     22141 ns/op	       0 B/op	       0 allocs/op
BenchmarkFilterUncached_1of64_100-2        	 1766755	       677.8 ns/op	       0 B/op	       0 allocs/op
BenchmarkFilterUncached_1of64_1_000-2      	  459115	      2607 ns/op	       0 B/op	       0 allocs/op
BenchmarkFilterUncached_1of64_10_000-2     	   54856	     22341 ns/op	       0 B/op	       0 allocs/op
BenchmarkFilterCached_1of64_100-2          	 4016372	       294.6 ns/op	       0 B/op	       0 allocs/op
BenchmarkFilterCached_1of64_1_000-2        	  539553	      2233 ns/op	       0 B/op	       0 allocs/op
BenchmarkFilterCached_1of64_10_000-2       	   54658	     21707 ns/op	       0 B/op	       0 allocs/op
BenchmarkFilterUncached_1of256_100-2       	  650193	      1842 ns/op	       0 B/op	       0 allocs/op
BenchmarkFilterUncached_1of256_1_000-2     	  317648	      3767 ns/op	       0 B/op	       0 allocs/op
BenchmarkFilterUncached_1of256_10_000-2    	   51051	     23442 ns/op	       0 B/op	       0 allocs/op
BenchmarkFilterCached_1of256_100-2         	 4092333	       295.7 ns/op	       0 B/op	       0 allocs/op
BenchmarkFilterCached_1of256_1_000-2       	  535809	      2229 ns/op	       0 B/op	       0 allocs/op
BenchmarkFilterCached_1of256_10_000-2      	   55857	     21792 ns/op	       0 B/op	       0 allocs/op
BenchmarkFilterUncached_1of1024_100-2      	  183807	      6509 ns/op	       0 B/op	       0 allocs/op
BenchmarkFilterUncached_1of1024_1_000-2    	  142125	      8458 ns/op	       0 B/op	       0 allocs/op
BenchmarkFilterUncached_1of1024_10_000-2   	   42786	     28018 ns/op	       0 B/op	       0 allocs/op
BenchmarkFilterCached_1of1024_100-2        	 4111138	       291.6 ns/op	       0 B/op	       0 allocs/op
BenchmarkFilterCached_1of1024_1_000-2      	  540202	      2229 ns/op	       0 B/op	       0 allocs/op
BenchmarkFilterCached_1of1024_10_000-2     	   54086	     21874 ns/op	       0 B/op	       0 allocs/op
```

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3b396a6..1093169 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,7 +18,8 @@
 * Generic mappers (`Map1`, ...) have methods `NewEntities`, `NewEntitiesWith` and `RemoveEntities` for batch operations (#151)
 * Batch-creation methods (ID-based and generic) have variants like `NewEntitiesQuery` that return a query over the created entities (#152)
 * Notification during batch-creation is delayed until the resp. query is closed (#157)
-* Match-remove methods (`RemoveEntities()`) return the number of removed entities (#173)
+* Batch-remove methods (`RemoveEntities()`) return the number of removed entities (#173)
+* Filters can be cached and tracked by the `World` to speed up queries when there are many archetypes (#178)
 
 ### Performance
 
diff --git a/README.md b/README.md
index bb19849..e0175b7 100644
--- a/README.md
+++ b/README.md
@@ -111,7 +111,7 @@ Nevertheless, *Arche* can also be used for game development.
 
 The `ecs.World` object is a pure and minimal ECS implementation in the sense of a data store
 for entities and components, with query and iteration capabilities.
-The core package `ecs` consists of less than 1500 lines of easy-to-read, clean and well-documented Go code.
+The core package `ecs` consists of only 1500 lines of easy-to-read, clean and well-documented Go code.
 
 There is neither an update loop nor systems.
 These should be implemented by the user.
diff --git a/benchmark/arche/common/components.go b/benchmark/arche/common/components.go
index f5370c4..4bd06a8 100644
--- a/benchmark/arche/common/components.go
+++ b/benchmark/arche/common/components.go
@@ -21,6 +21,7 @@ type TestStruct6 struct{ Val int32 }
 type TestStruct7 struct{ Val int32 }
 type TestStruct8 struct{ Val int32 }
 type TestStruct9 struct{ Val int32 }
+type TestStruct10 struct{ Val int32 }
 
 func RegisterAll(w *ecs.World) []ecs.ID {
 	_ = TestStruct0{1}
@@ -33,8 +34,9 @@ func RegisterAll(w *ecs.World) []ecs.ID {
 	_ = TestStruct7{1}
 	_ = TestStruct8{1}
 	_ = TestStruct9{1}
+	_ = TestStruct10{1}
 
-	ids := make([]ecs.ID, 10)
+	ids := make([]ecs.ID, 11)
 	ids[0] = ecs.ComponentID[TestStruct0](w)
 	ids[1] = ecs.ComponentID[TestStruct1](w)
 	ids[2] = ecs.ComponentID[TestStruct2](w)
@@ -45,6 +47,7 @@ func RegisterAll(w *ecs.World) []ecs.ID {
 	ids[7] = ecs.ComponentID[TestStruct7](w)
 	ids[8] = ecs.ComponentID[TestStruct8](w)
 	ids[9] = ecs.ComponentID[TestStruct9](w)
+	ids[10] = ecs.ComponentID[TestStruct10](w)
 
 	return ids
 }
diff --git a/benchmark/profile/many_arch/main.go b/benchmark/profile/many_arch/main.go
index 741fce6..a7e37f1 100644
--- a/benchmark/profile/many_arch/main.go
+++ b/benchmark/profile/many_arch/main.go
@@ -50,7 +50,7 @@ func run(rounds, iters, entities int) {
 			}
 		}
 
-		filter := ecs.All(6)
+		var filter ecs.Filter = ecs.All(6)
 		for j := 0; j < iters; j++ {
 			query := world.Query(filter)
 			for query.Next() {
diff --git a/ecs/cache.go b/ecs/cache.go
new file mode 100644
index 0000000..f88f635
--- /dev/null
+++ b/ecs/cache.go
@@ -0,0 +1,97 @@
+package ecs
+
+type cacheEntry struct {
+	ID         ID
+	Filter     Filter
+	Archetypes pagedPointerArr32[archetype]
+}
+
+// Cache provides [Filter] caching to speed up queries.
+//
+// Access it using [World.Cache].
+//
+// For registered filters, the relevant archetypes are tracked internally,
+// so that there are no mask checks required during iteration.
+// This is particularly helpful to avoid query iteration slowdown by a very high number of archetypes.
+// If the number of archetypes exceeds approx. 50-100, uncached filters experience a slowdown.
+// The relative slowdown increases with lower numbers of entities queried (below 10.000).
+// Cached filters avoid this slowdown.
+//
+// The overhead of tracking cached filters is very low, as updates are required only when new archetypes are created.
+type Cache struct {
+	bitPool       bitPool
+	indices       []int
+	filters       []cacheEntry
+	getArchetypes func(f Filter) pagedPointerArr32[archetype]
+}
+
+// newCache creates a new [Cache].
+func newCache() Cache {
+	indices := make([]int, MaskTotalBits)
+	for i := 0; i < MaskTotalBits; i++ {
+		indices[i] = MaskTotalBits
+	}
+	return Cache{
+		bitPool: bitPool{},
+		indices: indices,
+		filters: []cacheEntry{},
+	}
+}
+
+// Register a [Filter].
+//
+// Use the returned [CachedFilter] to construct queries:
+//
+//	filter := world.Cache().Register(ecs.All(posID, velID))
+//	query := world.Query(&filter)
+func (c *Cache) Register(f Filter) CachedFilter {
+	id := c.bitPool.Get()
+	c.filters = append(c.filters,
+		cacheEntry{
+			ID:         id,
+			Filter:     f,
+			Archetypes: c.getArchetypes(f),
+		})
+	c.indices[id] = len(c.filters) - 1
+	return CachedFilter{f, id}
+}
+
+// Unregister a filter.
+//
+// Returns the original filter.
+func (c *Cache) Unregister(f *CachedFilter) Filter {
+	idx := c.indices[f.id]
+	if idx >= MaskTotalBits {
+		panic("no filter for id found to unregister")
+	}
+	filter := c.filters[idx].Filter
+	c.indices[f.id] = MaskTotalBits
+
+	last := len(c.filters) - 1
+	if idx != last {
+		c.filters[idx], c.filters[last] = c.filters[last], c.filters[idx]
+		c.indices[c.filters[idx].ID] = idx
+	}
+	c.filters[last] = cacheEntry{}
+	c.filters = c.filters[:last]
+
+	return filter
+}
+
+func (c *Cache) get(f *CachedFilter) *cacheEntry {
+	idx := c.indices[f.id]
+	if idx < MaskTotalBits {
+		return &c.filters[idx]
+	}
+	panic("no filter for id found")
+}
+
+func (c *Cache) addArchetype(arch *archetype) {
+	ln := len(c.filters)
+	for i := 0; i < ln; i++ {
+		e := &c.filters[i]
+		if e.Filter.Matches(arch.Mask) {
+			e.Archetypes.Add(arch)
+		}
+	}
+}
diff --git a/ecs/paged.go b/ecs/paged.go
index eedd07c..3c8177a 100644
--- a/ecs/paged.go
+++ b/ecs/paged.go
@@ -31,3 +31,33 @@ func (p *pagedArr32[T]) Get(index int) *T {
 func (p *pagedArr32[T]) Len() int {
 	return p.len
 }
+
+// pagedPointerArr32 is a paged collection working with pages of length 32 arrays.
+//
+// Implements [archetypes].
+type pagedPointerArr32[T any] struct {
+	pages   [][fixedPageSize]*T
+	len     int
+	lenLast int
+}
+
+// Add adds a value to the paged array.
+func (p *pagedPointerArr32[T]) Add(value *T) {
+	if p.len == 0 || p.lenLast == fixedPageSize {
+		p.pages = append(p.pages, [fixedPageSize]*T{})
+		p.lenLast = 0
+	}
+	p.pages[len(p.pages)-1][p.lenLast] = value
+	p.len++
+	p.lenLast++
+}
+
+// Get returns the value at the given index.
+func (p *pagedPointerArr32[T]) Get(index int) *T {
+	return p.pages[index/fixedPageSize][index%fixedPageSize]
+}
+
+// Len returns the current number of items in the paged array.
+func (p *pagedPointerArr32[T]) Len() int {
+	return p.len
+}
diff --git a/ecs/query.go b/ecs/query.go
index 95c9f12..2ad5f7c 100644
--- a/ecs/query.go
+++ b/ecs/query.go
@@ -37,6 +37,17 @@ func (f dummyFilter) Matches(bits Mask) bool {
 	return f.Value
 }
 
+// CachedFilter is a filter that is cached by the world.
+type CachedFilter struct {
+	filter Filter
+	id     ID
+}
+
+// Matches matches a filter against a mask.
+func (f *CachedFilter) Matches(bits Mask) bool {
+	return f.filter.Matches(bits)
+}
+
 // Query is an iterator to iterate entities, filtered by a [Filter].
 //
 // Create queries through the [World] using [World.Query].
@@ -165,35 +176,20 @@ func (q *Query) Close() {
 
 // nextArchetype proceeds to the next archetype, and returns whether this was successful/possible.
 func (q *Query) nextArchetype() bool {
-	if mask, ok := q.filter.(Mask); ok {
-		return q.nextArchetypeMask(mask)
+	switch q.filter.(type) {
+	case *CachedFilter:
+		return q.nextArchetypeCached()
+	default:
+		return q.nextArchetypeFilter()
 	}
-	if mask, ok := q.filter.(*MaskFilter); ok {
-		return q.nextArchetypeMaskFilter(mask)
-	}
-	return q.nextArchetypeFilter()
-}
-
-func (q *Query) nextArchetypeMask(f Mask) bool {
-	len := int(q.archetypes.Len())
-	for i := q.index + 1; i < len; i++ {
-		a := q.archetypes.Get(i)
-		if f.Matches(a.Mask) && a.Len() > 0 {
-			q.index = i
-			q.archetypeIter = newArchetypeIter(a)
-			return true
-		}
-	}
-	q.index = len
-	q.world.closeQuery(q)
-	return false
 }
 
-func (q *Query) nextArchetypeMaskFilter(f *MaskFilter) bool {
+// nextArchetypeCached is called if the query is cached.
+func (q *Query) nextArchetypeCached() bool {
 	len := int(q.archetypes.Len())
 	for i := q.index + 1; i < len; i++ {
 		a := q.archetypes.Get(i)
-		if f.Matches(a.Mask) && a.Len() > 0 {
+		if a.Len() > 0 {
 			q.index = i
 			q.archetypeIter = newArchetypeIter(a)
 			return true
@@ -204,6 +200,7 @@ func (q *Query) nextArchetypeMaskFilter(f *MaskFilter) bool {
 	return false
 }
 
+// nextArchetypeFilter is called if the query is not cached.
 func (q *Query) nextArchetypeFilter() bool {
 	len := int(q.archetypes.Len())
 	for i := q.index + 1; i < len; i++ {
diff --git a/ecs/resources.go b/ecs/resources.go
index fd52146..d82a683 100644
--- a/ecs/resources.go
+++ b/ecs/resources.go
@@ -6,6 +6,7 @@ import (
 )
 
 // Resources manage a world's resources.
+//
 // Access it using [World.Resources].
 type Resources struct {
 	registry  componentRegistry[ResID]
diff --git a/ecs/world.go b/ecs/world.go
index bcae634..ea1530e 100644
--- a/ecs/world.go
+++ b/ecs/world.go
@@ -47,15 +47,16 @@ func AddResource[T any](w *World, res *T) {
 
 // World is the central type holding [Entity] and component data, as well as resources.
 type World struct {
-	config     Config                    // World configuration.
-	listener   func(e *EntityEvent)      // Component change listener.
-	resources  Resources                 // World resources.
-	entities   []entityIndex             // Mapping from entities to archetype and index.
-	entityPool entityPool                // Pool for entities.
-	archetypes pagedArr32[archetype]     // The archetypes.
-	graph      pagedArr32[archetypeNode] // The archetype graph.
-	locks      lockMask                  // World locks.
-	registry   componentRegistry[ID]     // Component registry.
+	config      Config                    // World configuration.
+	listener    func(e *EntityEvent)      // Component change listener.
+	resources   Resources                 // World resources.
+	entities    []entityIndex             // Mapping from entities to archetype and index.
+	entityPool  entityPool                // Pool for entities.
+	archetypes  pagedArr32[archetype]     // The archetypes.
+	graph       pagedArr32[archetypeNode] // The archetype graph.
+	locks       lockMask                  // World locks.
+	registry    componentRegistry[ID]     // Component registry.
+	filterCache Cache                     // Cache for registered filters.
 }
 
 // NewWorld creates a new [World] from an optional [Config].
@@ -80,15 +81,16 @@ func fromConfig(conf Config) World {
 	entities := make([]entityIndex, 1, conf.CapacityIncrement)
 	entities[0] = entityIndex{arch: nil, index: 0}
 	w := World{
-		config:     conf,
-		entities:   entities,
-		entityPool: newEntityPool(conf.CapacityIncrement),
-		registry:   newComponentRegistry(),
-		archetypes: pagedArr32[archetype]{},
-		graph:      pagedArr32[archetypeNode]{},
-		locks:      lockMask{},
-		listener:   nil,
-		resources:  newResources(),
+		config:      conf,
+		entities:    entities,
+		entityPool:  newEntityPool(conf.CapacityIncrement),
+		registry:    newComponentRegistry(),
+		archetypes:  pagedArr32[archetype]{},
+		graph:       pagedArr32[archetypeNode]{},
+		locks:       lockMask{},
+		listener:    nil,
+		resources:   newResources(),
+		filterCache: newCache(),
 	}
 	node := w.createArchetypeNode(Mask{})
 	w.createArchetype(node, false)
@@ -450,13 +452,6 @@ func (w *World) Exchange(entity Entity, add []ID, rem []ID) {
 	}
 }
 
-// Resources of the world.
-//
-// Resources are component-like data that is not associated to an entity, but unique to the world.
-func (w *World) Resources() *Resources {
-	return &w.resources
-}
-
 // Reset removes all entities and resources from the world.
 //
 // Does NOT free reserved memory, remove archetypes, clear the registry etc.
@@ -484,7 +479,7 @@ func (w *World) Reset() {
 //
 // Example:
 //
-//	filter := All(idA, idB).Without(idC)
+//	filter := ecs.All(idA, idB).Without(idC)
 //	query := world.Query(filter)
 //	for query.Next() {
 //	    pos := (*position)(query.Get(posID))
@@ -497,9 +492,21 @@ func (w *World) Reset() {
 // Locks the world to prevent changes to component compositions.
 func (w *World) Query(filter Filter) Query {
 	l := w.lock()
+	if cached, ok := filter.(*CachedFilter); ok {
+		archetypes := &w.filterCache.get(cached).Archetypes
+		return newQuery(w, cached, l, archetypes)
+	}
+
 	return newQuery(w, filter, l, &w.archetypes)
 }
 
+// Resources of the world.
+//
+// Resources are component-like data that is not associated to an entity, but unique to the world.
+func (w *World) Resources() *Resources {
+	return &w.resources
+}
+
 // Batch creates a [Batch] processing helper.
 //
 // It provides the functionality to create and remove large numbers of entities in batches,
@@ -508,6 +515,16 @@ func (w *World) Batch() *Batch {
 	return &Batch{w}
 }
 
+// Cache returns the [Cache] of the world, for registering filters.
+//
+// See [Cache] for details.
+func (w *World) Cache() *Cache {
+	if w.filterCache.getArchetypes == nil {
+		w.filterCache.getArchetypes = w.getArchetypes
+	}
+	return &w.filterCache
+}
+
 // IsLocked returns whether the world is locked by any queries.
 func (w *World) IsLocked() bool {
 	return w.locks.IsLocked()
@@ -762,9 +779,24 @@ func (w *World) createArchetype(node *archetypeNode, forStorage bool) *archetype
 	arch := w.archetypes.Get(w.archetypes.Len() - 1)
 	arch.Init(node, w.config.CapacityIncrement, forStorage, types...)
 	node.archetype = arch
+
+	w.filterCache.addArchetype(arch)
 	return arch
 }
 
+// Returns all archetypes that match the given filter. Used by [Cache].
+func (w *World) getArchetypes(filter Filter) pagedPointerArr32[archetype] {
+	arches := pagedPointerArr32[archetype]{}
+	len := int(w.archetypes.Len())
+	for i := 0; i < len; i++ {
+		a := w.archetypes.Get(i)
+		if filter.Matches(a.Mask) {
+			arches.Add(a)
+		}
+	}
+	return arches
+}
+
 // componentID returns the ID for a component type, and registers it if not already registered.
 func (w *World) componentID(tp reflect.Type) ID {
 	return w.registry.ComponentID(tp)
diff --git a/generic/compiled.go b/generic/compiled.go
index 93d31ba..55efcbc 100644
--- a/generic/compiled.go
+++ b/generic/compiled.go
@@ -6,9 +6,11 @@ import (
 
 // compiledQuery is a helper for compiling a generic filter into a [ecs.Filter].
 type compiledQuery struct {
-	filter   ecs.MaskFilter
-	Ids      []ecs.ID
-	compiled bool
+	maskFilter   ecs.MaskFilter
+	cachedFilter ecs.CachedFilter
+	filter       ecs.Filter
+	Ids          []ecs.ID
+	compiled     bool
 }
 
 // Compile compiles a generic filter.
@@ -17,10 +19,11 @@ func (q *compiledQuery) Compile(w *ecs.World, include, optional, exclude []Comp)
 		return
 	}
 	q.Ids = toIds(w, include)
-	q.filter = ecs.MaskFilter{
+	q.maskFilter = ecs.MaskFilter{
 		Include: toMaskOptional(w, q.Ids, optional),
 		Exclude: toMask(w, exclude),
 	}
+	q.filter = &q.maskFilter
 	q.compiled = true
 }
 
diff --git a/generic/generate/query.go.txt b/generic/generate/query.go.txt
index bd60821..55a93a2 100644
--- a/generic/generate/query.go.txt
+++ b/generic/generate/query.go.txt
@@ -49,7 +49,7 @@ func (q *Filter{{ .Index }}{{ .Types }}) Without(mask ...Comp) *Filter{{ .Index
 func (q *Filter{{ .Index }}{{ .Types }}) Query(w *ecs.World) Query{{ .Index }}{{ .Types }} {
 	q.compiled.Compile(w, q.include, q.optional, q.exclude)
 	return Query{{ .Index }}{{ .Types }}{
-		Query: w.Query(&q.compiled.filter),
+		Query: w.Query(q.compiled.filter),
 		{{ .IDAssign }}
 	}
 }
@@ -60,7 +60,27 @@ func (q *Filter{{ .Index }}{{ .Types }}) Query(w *ecs.World) Query{{ .Index }}{{
 // For the intended generic use, however, generate a generic query with [Filter{{ .Index }}.Query].
 func (q *Filter{{ .Index }}{{ .Types }}) Filter(w *ecs.World) ecs.MaskFilter {
 	q.compiled.Compile(w, q.include, q.optional, q.exclude)
-	return q.compiled.filter
+	return q.compiled.maskFilter
+}
+
+// Register the filter for caching.
+//
+// See [ecs.Cache] for details on filter caching.
+func (q *Filter{{ .Index }}{{ .Types }}) Register(w *ecs.World) {
+	q.compiled.Compile(w, q.include, q.optional, q.exclude)
+	q.compiled.cachedFilter = w.Cache().Register(q.compiled.filter)
+	q.compiled.filter = &q.compiled.cachedFilter
+}
+
+// Unregister the filter from caching.
+//
+// See [ecs.Cache] for details on filter caching.
+func (q *Filter{{ .Index }}{{ .Types }}) Unregister(w *ecs.World) {
+	if cf, ok := q.compiled.filter.(*ecs.CachedFilter); ok {
+		q.compiled.filter = w.Cache().Unregister(cf)
+	} else {
+		panic("can't unregister a filter that is not cached")
+	}
 }
 
 // Query{{ .Index }} is a generic query iterator for {{ .NumberStr }} components.
diff --git a/generic/query_generated.go b/generic/query_generated.go
index 24af94f..09c52bf 100644
--- a/generic/query_generated.go
+++ b/generic/query_generated.go
@@ -43,7 +43,7 @@ func (q *Filter0) Without(mask ...Comp) *Filter0 {
 func (q *Filter0) Query(w *ecs.World) Query0 {
 	q.compiled.Compile(w, q.include, q.optional, q.exclude)
 	return Query0{
-		Query: w.Query(&q.compiled.filter),
+		Query: w.Query(q.compiled.filter),
 	}
 }
 
@@ -53,7 +53,27 @@ func (q *Filter0) Query(w *ecs.World) Query0 {
 // For the intended generic use, however, generate a generic query with [Filter0.Query].
 func (q *Filter0) Filter(w *ecs.World) ecs.MaskFilter {
 	q.compiled.Compile(w, q.include, q.optional, q.exclude)
-	return q.compiled.filter
+	return q.compiled.maskFilter
+}
+
+// Register the filter for caching.
+//
+// See [ecs.Cache] for details on filter caching.
+func (q *Filter0) Register(w *ecs.World) {
+	q.compiled.Compile(w, q.include, q.optional, q.exclude)
+	q.compiled.cachedFilter = w.Cache().Register(q.compiled.filter)
+	q.compiled.filter = &q.compiled.cachedFilter
+}
+
+// Unregister the filter from caching.
+//
+// See [ecs.Cache] for details on filter caching.
+func (q *Filter0) Unregister(w *ecs.World) {
+	if cf, ok := q.compiled.filter.(*ecs.CachedFilter); ok {
+		q.compiled.filter = w.Cache().Unregister(cf)
+	} else {
+		panic("can't unregister a filter that is not cached")
+	}
 }
 
 // Query0 is a generic query iterator for zero components.
@@ -115,7 +135,7 @@ func (q *Filter1[A]) Without(mask ...Comp) *Filter1[A] {
 func (q *Filter1[A]) Query(w *ecs.World) Query1[A] {
 	q.compiled.Compile(w, q.include, q.optional, q.exclude)
 	return Query1[A]{
-		Query: w.Query(&q.compiled.filter),
+		Query: w.Query(q.compiled.filter),
 		id0:   q.compiled.Ids[0],
 	}
 }
@@ -126,7 +146,27 @@ func (q *Filter1[A]) Query(w *ecs.World) Query1[A] {
 // For the intended generic use, however, generate a generic query with [Filter1.Query].
 func (q *Filter1[A]) Filter(w *ecs.World) ecs.MaskFilter {
 	q.compiled.Compile(w, q.include, q.optional, q.exclude)
-	return q.compiled.filter
+	return q.compiled.maskFilter
+}
+
+// Register the filter for caching.
+//
+// See [ecs.Cache] for details on filter caching.
+func (q *Filter1[A]) Register(w *ecs.World) {
+	q.compiled.Compile(w, q.include, q.optional, q.exclude)
+	q.compiled.cachedFilter = w.Cache().Register(q.compiled.filter)
+	q.compiled.filter = &q.compiled.cachedFilter
+}
+
+// Unregister the filter from caching.
+//
+// See [ecs.Cache] for details on filter caching.
+func (q *Filter1[A]) Unregister(w *ecs.World) {
+	if cf, ok := q.compiled.filter.(*ecs.CachedFilter); ok {
+		q.compiled.filter = w.Cache().Unregister(cf)
+	} else {
+		panic("can't unregister a filter that is not cached")
+	}
 }
 
 // Query1 is a generic query iterator for one components.
@@ -197,7 +237,7 @@ func (q *Filter2[A, B]) Without(mask ...Comp) *Filter2[A, B] {
 func (q *Filter2[A, B]) Query(w *ecs.World) Query2[A, B] {
 	q.compiled.Compile(w, q.include, q.optional, q.exclude)
 	return Query2[A, B]{
-		Query: w.Query(&q.compiled.filter),
+		Query: w.Query(q.compiled.filter),
 		id0:   q.compiled.Ids[0],
 		id1:   q.compiled.Ids[1],
 	}
@@ -209,7 +249,27 @@ func (q *Filter2[A, B]) Query(w *ecs.World) Query2[A, B] {
 // For the intended generic use, however, generate a generic query with [Filter2.Query].
 func (q *Filter2[A, B]) Filter(w *ecs.World) ecs.MaskFilter {
 	q.compiled.Compile(w, q.include, q.optional, q.exclude)
-	return q.compiled.filter
+	return q.compiled.maskFilter
+}
+
+// Register the filter for caching.
+//
+// See [ecs.Cache] for details on filter caching.
+func (q *Filter2[A, B]) Register(w *ecs.World) {
+	q.compiled.Compile(w, q.include, q.optional, q.exclude)
+	q.compiled.cachedFilter = w.Cache().Register(q.compiled.filter)
+	q.compiled.filter = &q.compiled.cachedFilter
+}
+
+// Unregister the filter from caching.
+//
+// See [ecs.Cache] for details on filter caching.
+func (q *Filter2[A, B]) Unregister(w *ecs.World) {
+	if cf, ok := q.compiled.filter.(*ecs.CachedFilter); ok {
+		q.compiled.filter = w.Cache().Unregister(cf)
+	} else {
+		panic("can't unregister a filter that is not cached")
+	}
 }
 
 // Query2 is a generic query iterator for two components.
@@ -283,7 +343,7 @@ func (q *Filter3[A, B, C]) Without(mask ...Comp) *Filter3[A, B, C] {
 func (q *Filter3[A, B, C]) Query(w *ecs.World) Query3[A, B, C] {
 	q.compiled.Compile(w, q.include, q.optional, q.exclude)
 	return Query3[A, B, C]{
-		Query: w.Query(&q.compiled.filter),
+		Query: w.Query(q.compiled.filter),
 		id0:   q.compiled.Ids[0],
 		id1:   q.compiled.Ids[1],
 		id2:   q.compiled.Ids[2],
@@ -296,7 +356,27 @@ func (q *Filter3[A, B, C]) Query(w *ecs.World) Query3[A, B, C] {
 // For the intended generic use, however, generate a generic query with [Filter3.Query].
 func (q *Filter3[A, B, C]) Filter(w *ecs.World) ecs.MaskFilter {
 	q.compiled.Compile(w, q.include, q.optional, q.exclude)
-	return q.compiled.filter
+	return q.compiled.maskFilter
+}
+
+// Register the filter for caching.
+//
+// See [ecs.Cache] for details on filter caching.
+func (q *Filter3[A, B, C]) Register(w *ecs.World) {
+	q.compiled.Compile(w, q.include, q.optional, q.exclude)
+	q.compiled.cachedFilter = w.Cache().Register(q.compiled.filter)
+	q.compiled.filter = &q.compiled.cachedFilter
+}
+
+// Unregister the filter from caching.
+//
+// See [ecs.Cache] for details on filter caching.
+func (q *Filter3[A, B, C]) Unregister(w *ecs.World) {
+	if cf, ok := q.compiled.filter.(*ecs.CachedFilter); ok {
+		q.compiled.filter = w.Cache().Unregister(cf)
+	} else {
+		panic("can't unregister a filter that is not cached")
+	}
 }
 
 // Query3 is a generic query iterator for three components.
@@ -373,7 +453,7 @@ func (q *Filter4[A, B, C, D]) Without(mask ...Comp) *Filter4[A, B, C, D] {
 func (q *Filter4[A, B, C, D]) Query(w *ecs.World) Query4[A, B, C, D] {
 	q.compiled.Compile(w, q.include, q.optional, q.exclude)
 	return Query4[A, B, C, D]{
-		Query: w.Query(&q.compiled.filter),
+		Query: w.Query(q.compiled.filter),
 		id0:   q.compiled.Ids[0],
 		id1:   q.compiled.Ids[1],
 		id2:   q.compiled.Ids[2],
@@ -387,7 +467,27 @@ func (q *Filter4[A, B, C, D]) Query(w *ecs.World) Query4[A, B, C, D] {
 // For the intended generic use, however, generate a generic query with [Filter4.Query].
 func (q *Filter4[A, B, C, D]) Filter(w *ecs.World) ecs.MaskFilter {
 	q.compiled.Compile(w, q.include, q.optional, q.exclude)
-	return q.compiled.filter
+	return q.compiled.maskFilter
+}
+
+// Register the filter for caching.
+//
+// See [ecs.Cache] for details on filter caching.
+func (q *Filter4[A, B, C, D]) Register(w *ecs.World) {
+	q.compiled.Compile(w, q.include, q.optional, q.exclude)
+	q.compiled.cachedFilter = w.Cache().Register(q.compiled.filter)
+	q.compiled.filter = &q.compiled.cachedFilter
+}
+
+// Unregister the filter from caching.
+//
+// See [ecs.Cache] for details on filter caching.
+func (q *Filter4[A, B, C, D]) Unregister(w *ecs.World) {
+	if cf, ok := q.compiled.filter.(*ecs.CachedFilter); ok {
+		q.compiled.filter = w.Cache().Unregister(cf)
+	} else {
+		panic("can't unregister a filter that is not cached")
+	}
 }
 
 // Query4 is a generic query iterator for four components.
@@ -467,7 +567,7 @@ func (q *Filter5[A, B, C, D, E]) Without(mask ...Comp) *Filter5[A, B, C, D, E] {
 func (q *Filter5[A, B, C, D, E]) Query(w *ecs.World) Query5[A, B, C, D, E] {
 	q.compiled.Compile(w, q.include, q.optional, q.exclude)
 	return Query5[A, B, C, D, E]{
-		Query: w.Query(&q.compiled.filter),
+		Query: w.Query(q.compiled.filter),
 		id0:   q.compiled.Ids[0],
 		id1:   q.compiled.Ids[1],
 		id2:   q.compiled.Ids[2],
@@ -482,7 +582,27 @@ func (q *Filter5[A, B, C, D, E]) Query(w *ecs.World) Query5[A, B, C, D, E] {
 // For the intended generic use, however, generate a generic query with [Filter5.Query].
 func (q *Filter5[A, B, C, D, E]) Filter(w *ecs.World) ecs.MaskFilter {
 	q.compiled.Compile(w, q.include, q.optional, q.exclude)
-	return q.compiled.filter
+	return q.compiled.maskFilter
+}
+
+// Register the filter for caching.
+//
+// See [ecs.Cache] for details on filter caching.
+func (q *Filter5[A, B, C, D, E]) Register(w *ecs.World) {
+	q.compiled.Compile(w, q.include, q.optional, q.exclude)
+	q.compiled.cachedFilter = w.Cache().Register(q.compiled.filter)
+	q.compiled.filter = &q.compiled.cachedFilter
+}
+
+// Unregister the filter from caching.
+//
+// See [ecs.Cache] for details on filter caching.
+func (q *Filter5[A, B, C, D, E]) Unregister(w *ecs.World) {
+	if cf, ok := q.compiled.filter.(*ecs.CachedFilter); ok {
+		q.compiled.filter = w.Cache().Unregister(cf)
+	} else {
+		panic("can't unregister a filter that is not cached")
+	}
 }
 
 // Query5 is a generic query iterator for five components.
@@ -565,7 +685,7 @@ func (q *Filter6[A, B, C, D, E, F]) Without(mask ...Comp) *Filter6[A, B, C, D, E
 func (q *Filter6[A, B, C, D, E, F]) Query(w *ecs.World) Query6[A, B, C, D, E, F] {
 	q.compiled.Compile(w, q.include, q.optional, q.exclude)
 	return Query6[A, B, C, D, E, F]{
-		Query: w.Query(&q.compiled.filter),
+		Query: w.Query(q.compiled.filter),
 		id0:   q.compiled.Ids[0],
 		id1:   q.compiled.Ids[1],
 		id2:   q.compiled.Ids[2],
@@ -581,7 +701,27 @@ func (q *Filter6[A, B, C, D, E, F]) Query(w *ecs.World) Query6[A, B, C, D, E, F]
 // For the intended generic use, however, generate a generic query with [Filter6.Query].
 func (q *Filter6[A, B, C, D, E, F]) Filter(w *ecs.World) ecs.MaskFilter {
 	q.compiled.Compile(w, q.include, q.optional, q.exclude)
-	return q.compiled.filter
+	return q.compiled.maskFilter
+}
+
+// Register the filter for caching.
+//
+// See [ecs.Cache] for details on filter caching.
+func (q *Filter6[A, B, C, D, E, F]) Register(w *ecs.World) {
+	q.compiled.Compile(w, q.include, q.optional, q.exclude)
+	q.compiled.cachedFilter = w.Cache().Register(q.compiled.filter)
+	q.compiled.filter = &q.compiled.cachedFilter
+}
+
+// Unregister the filter from caching.
+//
+// See [ecs.Cache] for details on filter caching.
+func (q *Filter6[A, B, C, D, E, F]) Unregister(w *ecs.World) {
+	if cf, ok := q.compiled.filter.(*ecs.CachedFilter); ok {
+		q.compiled.filter = w.Cache().Unregister(cf)
+	} else {
+		panic("can't unregister a filter that is not cached")
+	}
 }
 
 // Query6 is a generic query iterator for six components.
@@ -667,7 +807,7 @@ func (q *Filter7[A, B, C, D, E, F, G]) Without(mask ...Comp) *Filter7[A, B, C, D
 func (q *Filter7[A, B, C, D, E, F, G]) Query(w *ecs.World) Query7[A, B, C, D, E, F, G] {
 	q.compiled.Compile(w, q.include, q.optional, q.exclude)
 	return Query7[A, B, C, D, E, F, G]{
-		Query: w.Query(&q.compiled.filter),
+		Query: w.Query(q.compiled.filter),
 		id0:   q.compiled.Ids[0],
 		id1:   q.compiled.Ids[1],
 		id2:   q.compiled.Ids[2],
@@ -684,7 +824,27 @@ func (q *Filter7[A, B, C, D, E, F, G]) Query(w *ecs.World) Query7[A, B, C, D, E,
 // For the intended generic use, however, generate a generic query with [Filter7.Query].
 func (q *Filter7[A, B, C, D, E, F, G]) Filter(w *ecs.World) ecs.MaskFilter {
 	q.compiled.Compile(w, q.include, q.optional, q.exclude)
-	return q.compiled.filter
+	return q.compiled.maskFilter
+}
+
+// Register the filter for caching.
+//
+// See [ecs.Cache] for details on filter caching.
+func (q *Filter7[A, B, C, D, E, F, G]) Register(w *ecs.World) {
+	q.compiled.Compile(w, q.include, q.optional, q.exclude)
+	q.compiled.cachedFilter = w.Cache().Register(q.compiled.filter)
+	q.compiled.filter = &q.compiled.cachedFilter
+}
+
+// Unregister the filter from caching.
+//
+// See [ecs.Cache] for details on filter caching.
+func (q *Filter7[A, B, C, D, E, F, G]) Unregister(w *ecs.World) {
+	if cf, ok := q.compiled.filter.(*ecs.CachedFilter); ok {
+		q.compiled.filter = w.Cache().Unregister(cf)
+	} else {
+		panic("can't unregister a filter that is not cached")
+	}
 }
 
 // Query7 is a generic query iterator for seven components.
@@ -773,7 +933,7 @@ func (q *Filter8[A, B, C, D, E, F, G, H]) Without(mask ...Comp) *Filter8[A, B, C
 func (q *Filter8[A, B, C, D, E, F, G, H]) Query(w *ecs.World) Query8[A, B, C, D, E, F, G, H] {
 	q.compiled.Compile(w, q.include, q.optional, q.exclude)
 	return Query8[A, B, C, D, E, F, G, H]{
-		Query: w.Query(&q.compiled.filter),
+		Query: w.Query(q.compiled.filter),
 		id0:   q.compiled.Ids[0],
 		id1:   q.compiled.Ids[1],
 		id2:   q.compiled.Ids[2],
@@ -791,7 +951,27 @@ func (q *Filter8[A, B, C, D, E, F, G, H]) Query(w *ecs.World) Query8[A, B, C, D,
 // For the intended generic use, however, generate a generic query with [Filter8.Query].
 func (q *Filter8[A, B, C, D, E, F, G, H]) Filter(w *ecs.World) ecs.MaskFilter {
 	q.compiled.Compile(w, q.include, q.optional, q.exclude)
-	return q.compiled.filter
+	return q.compiled.maskFilter
+}
+
+// Register the filter for caching.
+//
+// See [ecs.Cache] for details on filter caching.
+func (q *Filter8[A, B, C, D, E, F, G, H]) Register(w *ecs.World) {
+	q.compiled.Compile(w, q.include, q.optional, q.exclude)
+	q.compiled.cachedFilter = w.Cache().Register(q.compiled.filter)
+	q.compiled.filter = &q.compiled.cachedFilter
+}
+
+// Unregister the filter from caching.
+//
+// See [ecs.Cache] for details on filter caching.
+func (q *Filter8[A, B, C, D, E, F, G, H]) Unregister(w *ecs.World) {
+	if cf, ok := q.compiled.filter.(*ecs.CachedFilter); ok {
+		q.compiled.filter = w.Cache().Unregister(cf)
+	} else {
+		panic("can't unregister a filter that is not cached")
+	}
 }
 
 // Query8 is a generic query iterator for eight components.


Test patch:
diff --git a/benchmark/arche/iterate/arche_test.go b/benchmark/arche/iterate/arche_test.go
index fc35bcd..14bd399 100644
--- a/benchmark/arche/iterate/arche_test.go
+++ b/benchmark/arche/iterate/arche_test.go
@@ -103,6 +103,29 @@ func runArcheQuery(b *testing.B, count int) {
 	}
 }
 
+func runArcheQueryCached(b *testing.B, count int) {
+	b.StopTimer()
+	world := ecs.NewWorld()
+
+	posID := ecs.ComponentID[c.Position](&world)
+	rotID := ecs.ComponentID[c.Rotation](&world)
+
+	world.Batch().NewEntities(count, posID, rotID)
+
+	cf := world.Cache().Register(ecs.All(posID, rotID))
+	var filter ecs.Filter = &cf
+
+	for i := 0; i < b.N; i++ {
+		b.StopTimer()
+		query := world.Query(filter)
+		b.StartTimer()
+		for query.Next() {
+			pos := (*c.Position)(query.Get(posID))
+			pos.X = 1.0
+		}
+	}
+}
+
 func runArcheFilter(b *testing.B, count int) {
 	b.StopTimer()
 	world := ecs.NewWorld()
@@ -231,6 +254,43 @@ func runArcheQuery1kArch(b *testing.B, count int) {
 	}
 }
 
+func runArcheQuery1kArchCached(b *testing.B, count int) {
+	b.StopTimer()
+	world := ecs.NewWorld()
+	c.RegisterAll(&world)
+
+	perArch := 2 * count / 1000
+
+	for i := 0; i < 1024; i++ {
+		mask := i
+		add := make([]ecs.ID, 0, 10)
+		for j := 0; j < 10; j++ {
+			id := ecs.ID(j)
+			m := 1 << j
+			if mask&m == m {
+				add = append(add, id)
+			}
+		}
+		for j := 0; j < perArch; j++ {
+			entity := world.NewEntity()
+			world.Add(entity, add...)
+		}
+	}
+
+	cf := world.Cache().Register(ecs.All(6))
+	var filter ecs.Filter = &cf
+
+	for i := 0; i < b.N; i++ {
+		b.StopTimer()
+		query := world.Query(filter)
+		b.StartTimer()
+		for query.Next() {
+			pos := (*c.TestStruct6)(query.Get(6))
+			pos.Val = 1
+		}
+	}
+}
+
 func runArcheFilter1kArch(b *testing.B, count int) {
 	b.StopTimer()
 	world := ecs.NewWorld()
@@ -265,6 +325,79 @@ func runArcheFilter1kArch(b *testing.B, count int) {
 	}
 }
 
+func runArcheQuery1Of1kArch(b *testing.B, count int) {
+	b.StopTimer()
+	world := ecs.NewWorld()
+	c.RegisterAll(&world)
+
+	perArch := 2 * count / 1000
+
+	for i := 0; i < 1024; i++ {
+		mask := i
+		add := make([]ecs.ID, 0, 10)
+		for j := 0; j < 10; j++ {
+			id := ecs.ID(j)
+			m := 1 << j
+			if mask&m == m {
+				add = append(add, id)
+			}
+		}
+		for j := 0; j < perArch; j++ {
+			entity := world.NewEntity()
+			world.Add(entity, add...)
+		}
+	}
+	world.Batch().NewEntities(count, 10)
+
+	for i := 0; i < b.N; i++ {
+		b.StopTimer()
+		query := world.Query(ecs.All(10))
+		b.StartTimer()
+		for query.Next() {
+			pos := (*c.TestStruct6)(query.Get(10))
+			pos.Val = 1
+		}
+	}
+}
+
+func runArcheQuery1Of1kArchCached(b *testing.B, count int) {
+	b.StopTimer()
+	world := ecs.NewWorld()
+	c.RegisterAll(&world)
+
+	perArch := 2 * count / 1000
+
+	for i := 0; i < 1024; i++ {
+		mask := i
+		add := make([]ecs.ID, 0, 10)
+		for j := 0; j < 10; j++ {
+			id := ecs.ID(j)
+			m := 1 << j
+			if mask&m == m {
+				add = append(add, id)
+			}
+		}
+		for j := 0; j < perArch; j++ {
+			entity := world.NewEntity()
+			world.Add(entity, add...)
+		}
+	}
+	world.Batch().NewEntities(count, 10)
+
+	cf := world.Cache().Register(ecs.All(10))
+	var filter ecs.Filter = &cf
+
+	for i := 0; i < b.N; i++ {
+		b.StopTimer()
+		query := world.Query(filter)
+		b.StartTimer()
+		for query.Next() {
+			pos := (*c.TestStruct10)(query.Get(10))
+			pos.Val = 1
+		}
+	}
+}
+
 func runArcheWorldGet(b *testing.B, count int) {
 	b.StopTimer()
 	world := ecs.NewWorld()
@@ -357,6 +490,18 @@ func BenchmarkArcheIterQueryID_100_000(b *testing.B) {
 	runArcheQuery(b, 100000)
 }
 
+func BenchmarkArcheIterQueryIDCached_1_000(b *testing.B) {
+	runArcheQueryCached(b, 1000)
+}
+
+func BenchmarkArcheIterQueryIDCached_10_000(b *testing.B) {
+	runArcheQueryCached(b, 10000)
+}
+
+func BenchmarkArcheIterQueryIDCached_100_000(b *testing.B) {
+	runArcheQueryCached(b, 100000)
+}
+
 func BenchmarkArcheIterFilter_1_000(b *testing.B) {
 	runArcheFilter(b, 1000)
 }
@@ -441,6 +586,18 @@ func BenchmarkArcheIter1kArchID_100_000(b *testing.B) {
 	runArcheQuery1kArch(b, 100000)
 }
 
+func BenchmarkArcheIter1kArchIDCached_1_000(b *testing.B) {
+	runArcheQuery1kArchCached(b, 1000)
+}
+
+func BenchmarkArcheIter1kArchIDCached_10_000(b *testing.B) {
+	runArcheQuery1kArchCached(b, 10000)
+}
+
+func BenchmarkArcheIter1kArchIDCached_100_000(b *testing.B) {
+	runArcheQuery1kArchCached(b, 100000)
+}
+
 func BenchmarkArcheFilter1kArchID_1_000(b *testing.B) {
 	runArcheFilter1kArch(b, 1000)
 }
@@ -452,3 +609,27 @@ func BenchmarkArcheFilter1kArchID_10_000(b *testing.B) {
 func BenchmarkArcheFilter1kArchID_100_000(b *testing.B) {
 	runArcheFilter1kArch(b, 100000)
 }
+
+func BenchmarkArcheIter1Of1kArch_1_000(b *testing.B) {
+	runArcheQuery1Of1kArch(b, 1000)
+}
+
+func BenchmarkArcheIter1Of1kArch_10_000(b *testing.B) {
+	runArcheQuery1Of1kArch(b, 10000)
+}
+
+func BenchmarkArcheIter1Of1kArch_100_000(b *testing.B) {
+	runArcheQuery1Of1kArch(b, 100000)
+}
+
+func BenchmarkArcheIter1Of1kArchCached_1_000(b *testing.B) {
+	runArcheQuery1Of1kArchCached(b, 1000)
+}
+
+func BenchmarkArcheIter1Of1kArchCached_10_000(b *testing.B) {
+	runArcheQuery1Of1kArchCached(b, 10000)
+}
+
+func BenchmarkArcheIter1Of1kArchCached_100_000(b *testing.B) {
+	runArcheQuery1Of1kArchCached(b, 100000)
+}
diff --git a/benchmark/arche/iterate/ggecs_test.go b/benchmark/arche/iterate/ggecs_test.go
index 67e5ae9..6189386 100644
--- a/benchmark/arche/iterate/ggecs_test.go
+++ b/benchmark/arche/iterate/ggecs_test.go
@@ -18,6 +18,7 @@ const (
 	TestStruct7ID
 	TestStruct8ID
 	TestStruct9ID
+	TestStruct10ID
 	PositionComponentID
 	RotationComponentID
 )
@@ -115,6 +116,53 @@ func runGameEngineEcs1kArch(b *testing.B, count int) {
 	}
 }
 
+func runGameEngineEcs1Of1kArch(b *testing.B, count int) {
+	b.StopTimer()
+	world := ecs.NewWorld(1024)
+	world.Register(ecs.NewComponentRegistry[c.TestStruct0](TestStruct0ID))
+	world.Register(ecs.NewComponentRegistry[c.TestStruct1](TestStruct1ID))
+	world.Register(ecs.NewComponentRegistry[c.TestStruct2](TestStruct2ID))
+	world.Register(ecs.NewComponentRegistry[c.TestStruct3](TestStruct3ID))
+	world.Register(ecs.NewComponentRegistry[c.TestStruct4](TestStruct4ID))
+	world.Register(ecs.NewComponentRegistry[c.TestStruct5](TestStruct5ID))
+	world.Register(ecs.NewComponentRegistry[c.TestStruct6](TestStruct6ID))
+	world.Register(ecs.NewComponentRegistry[c.TestStruct7](TestStruct7ID))
+	world.Register(ecs.NewComponentRegistry[c.TestStruct8](TestStruct8ID))
+	world.Register(ecs.NewComponentRegistry[c.TestStruct9](TestStruct9ID))
+	world.Register(ecs.NewComponentRegistry[c.TestStruct10](TestStruct10ID))
+
+	perArch := 2 * count / 1000
+
+	for i := 0; i < 1024; i++ {
+		mask := i
+		add := make([]uint, 0, 10)
+		for j := 0; j < 10; j++ {
+			id := uint(j)
+			m := 1 << j
+			if mask&m == m {
+				add = append(add, id)
+			}
+		}
+		for j := 0; j < perArch; j++ {
+			_ = world.NewEntity(add...)
+		}
+	}
+	for i := 0; i < count; i++ {
+		world.NewEntity(TestStruct10ID)
+	}
+
+	mask := ecs.MakeComponentMask(TestStruct10ID)
+	b.StartTimer()
+
+	for i := 0; i < b.N; i++ {
+		query := world.Query(mask)
+		for query.Next() {
+			t1 := (*c.TestStruct10)(query.Component(TestStruct10ID))
+			t1.Val = 1
+		}
+	}
+}
+
 func BenchmarkGGEcsIter_1_000(b *testing.B) {
 	runGameEngineEcs(b, 1000)
 }
@@ -150,3 +198,15 @@ func BenchmarkGGEcsIter1kArch_10_000(b *testing.B) {
 func BenchmarkGGEcsIter1kArch_100_000(b *testing.B) {
 	runGameEngineEcs1kArch(b, 100000)
 }
+
+func BenchmarkGGEcsIter1Of1kArch_1_000(b *testing.B) {
+	runGameEngineEcs1Of1kArch(b, 1000)
+}
+
+func BenchmarkGGEcsIter1Of1kArch_10_000(b *testing.B) {
+	runGameEngineEcs1Of1kArch(b, 10000)
+}
+
+func BenchmarkGGEcsIter1Of1kArch_100_000(b *testing.B) {
+	runGameEngineEcs1Of1kArch(b, 100000)
+}
diff --git a/ecs/cache_test.go b/ecs/cache_test.go
new file mode 100644
index 0000000..6ab01a9
--- /dev/null
+++ b/ecs/cache_test.go
@@ -0,0 +1,244 @@
+package ecs
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestFilterCache(t *testing.T) {
+	cache := newCache()
+	cache.getArchetypes = getArchetypes
+
+	all1 := All(0, 1)
+	all2 := All(0, 1, 2)
+
+	f1 := cache.Register(all1)
+	f2 := cache.Register(all2)
+	assert.Equal(t, 0, int(f1.id))
+	assert.Equal(t, 1, int(f2.id))
+
+	e1 := cache.get(&f1)
+	e2 := cache.get(&f2)
+
+	assert.Equal(t, f1.filter, e1.Filter)
+	assert.Equal(t, f2.filter, e2.Filter)
+
+	ff1 := cache.Unregister(&f1)
+	ff2 := cache.Unregister(&f2)
+
+	assert.Equal(t, all1, ff1)
+	assert.Equal(t, all2, ff2)
+
+	assert.Panics(t, func() { cache.Unregister(&f1) })
+	assert.Panics(t, func() { cache.get(&f1) })
+}
+
+func getArchetypes(f Filter) pagedPointerArr32[archetype] {
+	return pagedPointerArr32[archetype]{}
+}
+
+func benchmarkCachedFilters(b *testing.B, arches int, count int, cached bool) {
+	b.StopTimer()
+	world := NewWorld()
+
+	ids := []ID{
+		ComponentID[testStruct0](&world),
+		ComponentID[testStruct1](&world),
+		ComponentID[testStruct2](&world),
+		ComponentID[testStruct3](&world),
+		ComponentID[testStruct4](&world),
+		ComponentID[testStruct5](&world),
+		ComponentID[testStruct6](&world),
+		ComponentID[testStruct7](&world),
+		ComponentID[testStruct8](&world),
+		ComponentID[testStruct9](&world),
+		ComponentID[testStruct10](&world),
+	}
+	id := ids[10]
+
+	perArch := 25
+
+	for i := 0; i < arches; i++ {
+		mask := i
+		add := make([]ID, 0, 10)
+		for j := 0; j < 10; j++ {
+			id := ids[j]
+			m := 1 << j
+			if mask&m == m {
+				add = append(add, id)
+			}
+		}
+		for j := 0; j < perArch; j++ {
+			world.NewEntity(add...)
+		}
+	}
+
+	world.Batch().NewEntities(count, id)
+
+	var filter Filter = All(id)
+	if cached {
+		f := world.Cache().Register(filter)
+		filter = &f
+	}
+
+	b.StartTimer()
+
+	for i := 0; i < b.N; i++ {
+		query := world.Query(filter)
+		for query.Next() {
+			st := (*testStruct0)(query.Get(id))
+			st.Val++
+		}
+	}
+}
+
+// 1 of 1
+func BenchmarkFilterUncached_1of1_100(b *testing.B) {
+	benchmarkCachedFilters(b, 1, 100, false)
+}
+
+func BenchmarkFilterUncached_1of1_1_000(b *testing.B) {
+	benchmarkCachedFilters(b, 1, 1000, false)
+}
+
+func BenchmarkFilterUncached_1of1_10_000(b *testing.B) {
+	benchmarkCachedFilters(b, 1, 10000, false)
+}
+
+func BenchmarkFilterCached_1of1_100(b *testing.B) {
+	benchmarkCachedFilters(b, 1, 100, true)
+}
+
+func BenchmarkFilterCached_1of1_1_000(b *testing.B) {
+	benchmarkCachedFilters(b, 1, 1000, true)
+}
+
+func BenchmarkFilterCached_1of1_10_000(b *testing.B) {
+	benchmarkCachedFilters(b, 1, 10000, true)
+}
+
+// 1 of 4
+func BenchmarkFilterUncached_1of4_100(b *testing.B) {
+	benchmarkCachedFilters(b, 4, 100, false)
+}
+
+func BenchmarkFilterUncached_1of4_1_000(b *testing.B) {
+	benchmarkCachedFilters(b, 4, 1000, false)
+}
+
+func BenchmarkFilterUncached_1of4_10_000(b *testing.B) {
+	benchmarkCachedFilters(b, 4, 10000, false)
+}
+
+func BenchmarkFilterCached_1of4_100(b *testing.B) {
+	benchmarkCachedFilters(b, 4, 100, true)
+}
+
+func BenchmarkFilterCached_1of4_1_000(b *testing.B) {
+	benchmarkCachedFilters(b, 4, 1000, true)
+}
+
+func BenchmarkFilterCached_1of4_10_000(b *testing.B) {
+	benchmarkCachedFilters(b, 4, 10000, true)
+}
+
+// 1 of 16
+func BenchmarkFilterUncached_1of16_100(b *testing.B) {
+	benchmarkCachedFilters(b, 16, 100, false)
+}
+
+func BenchmarkFilterUncached_1of16_1_000(b *testing.B) {
+	benchmarkCachedFilters(b, 16, 1000, false)
+}
+
+func BenchmarkFilterUncached_1of16_10_000(b *testing.B) {
+	benchmarkCachedFilters(b, 16, 10000, false)
+}
+
+func BenchmarkFilterCached_1of16_100(b *testing.B) {
+	benchmarkCachedFilters(b, 16, 100, true)
+}
+
+func BenchmarkFilterCached_1of16_1_000(b *testing.B) {
+	benchmarkCachedFilters(b, 16, 1000, true)
+}
+
+func BenchmarkFilterCached_1of16_10_000(b *testing.B) {
+	benchmarkCachedFilters(b, 16, 10000, true)
+}
+
+// 1 of 64
+func BenchmarkFilterUncached_1of64_100(b *testing.B) {
+	benchmarkCachedFilters(b, 64, 100, false)
+}
+
+func BenchmarkFilterUncached_1of64_1_000(b *testing.B) {
+	benchmarkCachedFilters(b, 64, 1000, false)
+}
+
+func BenchmarkFilterUncached_1of64_10_000(b *testing.B) {
+	benchmarkCachedFilters(b, 64, 10000, false)
+}
+
+func BenchmarkFilterCached_1of64_100(b *testing.B) {
+	benchmarkCachedFilters(b, 64, 100, true)
+}
+
+func BenchmarkFilterCached_1of64_1_000(b *testing.B) {
+	benchmarkCachedFilters(b, 64, 1000, true)
+}
+
+func BenchmarkFilterCached_1of64_10_000(b *testing.B) {
+	benchmarkCachedFilters(b, 64, 10000, true)
+}
+
+// 1 of 256
+func BenchmarkFilterUncached_1of256_100(b *testing.B) {
+	benchmarkCachedFilters(b, 256, 100, false)
+}
+
+func BenchmarkFilterUncached_1of256_1_000(b *testing.B) {
+	benchmarkCachedFilters(b, 256, 1000, false)
+}
+
+func BenchmarkFilterUncached_1of256_10_000(b *testing.B) {
+	benchmarkCachedFilters(b, 256, 10000, false)
+}
+
+func BenchmarkFilterCached_1of256_100(b *testing.B) {
+	benchmarkCachedFilters(b, 256, 100, true)
+}
+
+func BenchmarkFilterCached_1of256_1_000(b *testing.B) {
+	benchmarkCachedFilters(b, 256, 1000, true)
+}
+
+func BenchmarkFilterCached_1of256_10_000(b *testing.B) {
+	benchmarkCachedFilters(b, 256, 10000, true)
+}
+
+// 1 of 1024
+func BenchmarkFilterUncached_1of1024_100(b *testing.B) {
+	benchmarkCachedFilters(b, 1024, 100, false)
+}
+
+func BenchmarkFilterUncached_1of1024_1_000(b *testing.B) {
+	benchmarkCachedFilters(b, 1024, 1000, false)
+}
+
+func BenchmarkFilterUncached_1of1024_10_000(b *testing.B) {
+	benchmarkCachedFilters(b, 1024, 10000, false)
+}
+
+func BenchmarkFilterCached_1of1024_100(b *testing.B) {
+	benchmarkCachedFilters(b, 1024, 100, true)
+}
+
+func BenchmarkFilterCached_1of1024_1_000(b *testing.B) {
+	benchmarkCachedFilters(b, 1024, 1000, true)
+}
+
+func BenchmarkFilterCached_1of1024_10_000(b *testing.B) {
+	benchmarkCachedFilters(b, 1024, 10000, true)
+}
diff --git a/ecs/query_test.go b/ecs/query_test.go
index e9becd0..0f6d4ea 100644
--- a/ecs/query_test.go
+++ b/ecs/query_test.go
@@ -107,6 +107,49 @@ func TestQuery(t *testing.T) {
 	assert.Equal(t, 0, cnt)
 }
 
+func TestQueryCached(t *testing.T) {
+	w := NewWorld()
+
+	posID := ComponentID[position](&w)
+	velID := ComponentID[velocity](&w)
+
+	filterPos := w.Cache().Register(All(posID))
+	filterPosVel := w.Cache().Register(All(posID, velID))
+
+	q := w.Query(&filterPos)
+	assert.Equal(t, 0, q.Count())
+	q.Close()
+
+	q = w.Query(&filterPosVel)
+	assert.Equal(t, 0, q.Count())
+	q.Close()
+
+	w.Batch().NewEntities(10, posID)
+	w.Batch().NewEntities(10, velID)
+	w.Batch().NewEntities(10, posID, velID)
+
+	q = w.Query(&filterPos)
+	assert.Equal(t, 20, q.Count())
+	q.Close()
+
+	q = w.Query(&filterPosVel)
+	assert.Equal(t, 10, q.Count())
+	q.Close()
+
+	w.Batch().NewEntities(10, posID)
+
+	q = w.Query(&filterPos)
+	assert.Equal(t, 30, q.Count())
+
+	for q.Next() {
+	}
+
+	filterVel := w.Cache().Register(All(velID))
+	q = w.Query(&filterVel)
+	assert.Equal(t, 20, q.Count())
+	q.Close()
+}
+
 func TestQueryCount(t *testing.T) {
 	w := NewWorld()
 
diff --git a/ecs/types_test.go b/ecs/types_test.go
index 0497b59..4276c99 100644
--- a/ecs/types_test.go
+++ b/ecs/types_test.go
@@ -26,3 +26,4 @@ type testStruct6 struct{ val int32 }
 type testStruct7 struct{ val int32 }
 type testStruct8 struct{ val int32 }
 type testStruct9 struct{ val int32 }
+type testStruct10 struct{ val int32 }
diff --git a/ecs/world_test.go b/ecs/world_test.go
index fc4e6b9..0110a3e 100644
--- a/ecs/world_test.go
+++ b/ecs/world_test.go
@@ -865,6 +865,7 @@ func Test1000Archetypes(t *testing.T) {
 	_ = testStruct7{1}
 	_ = testStruct8{1}
 	_ = testStruct9{1}
+	_ = testStruct10{1}
 
 	w := NewWorld()
 
@@ -920,6 +921,7 @@ func TestTypeSizes(t *testing.T) {
 	printTypeSize[Resources]()
 	printTypeSizeName[reflect.Value]("reflect.Value")
 	printTypeSize[EntityEvent]()
+	printTypeSize[Cache]()
 }
 
 func printTypeSize[T any]() {
diff --git a/generic/query_test.go b/generic/query_test.go
index c537771..aa0953e 100644
--- a/generic/query_test.go
+++ b/generic/query_test.go
@@ -101,7 +101,10 @@ func TestQuery0(t *testing.T) {
 			With(T[testStruct8]()).
 			Without(T[testStruct9]())
 
-	assert.Equal(t, ecs.All(8).Without(9), filter.Filter(&w))
+	f := ecs.All(8).Without(9)
+	assert.Equal(t, f, filter.Filter(&w))
+	filter.Register(&w)
+
 	query := filter.Query(&w)
 	for query.Next() {
 		_ = query.Entity()
@@ -109,6 +112,8 @@ func TestQuery0(t *testing.T) {
 	}
 	assert.Equal(t, 1, cnt)
 
+	filter.Unregister(&w)
+	assert.Panics(t, func() { filter.Unregister(&w) })
 }
 
 func TestQuery1(t *testing.T) {
@@ -136,7 +141,10 @@ func TestQuery1(t *testing.T) {
 			With(T[testStruct8]()).
 			Without(T[testStruct9]())
 
-	assert.Equal(t, ecs.All(0, 8).Without(9), filter.Filter(&w))
+	f := ecs.All(0, 8).Without(9)
+	assert.Equal(t, f, filter.Filter(&w))
+	filter.Register(&w)
+
 	query := filter.Query(&w)
 	for query.Next() {
 		c0 := query.Get()
@@ -144,6 +152,9 @@ func TestQuery1(t *testing.T) {
 		cnt++
 	}
 	assert.Equal(t, 1, cnt)
+
+	filter.Unregister(&w)
+	assert.Panics(t, func() { filter.Unregister(&w) })
 }
 
 func TestQuery2(t *testing.T) {
@@ -173,7 +184,9 @@ func TestQuery2(t *testing.T) {
 			With(T[testStruct8]()).
 			Without(T[testStruct9]())
 
-	assert.Equal(t, ecs.All(0, 8).Without(9), filter.Filter(&w))
+	f := ecs.All(0, 8).Without(9)
+	assert.Equal(t, f, filter.Filter(&w))
+	filter.Register(&w)
 
 	for i := 0; i < 10; i++ {
 		cnt := 0
@@ -186,6 +199,9 @@ func TestQuery2(t *testing.T) {
 		}
 		assert.Equal(t, 1, cnt)
 	}
+
+	filter.Unregister(&w)
+	assert.Panics(t, func() { filter.Unregister(&w) })
 }
 
 func TestQuery3(t *testing.T) {
@@ -220,7 +236,10 @@ func TestQuery3(t *testing.T) {
 			With(T[testStruct8]()).
 			Without(T[testStruct9]())
 
-	assert.Equal(t, ecs.All(0, 2, 8).Without(9), filter.Filter(&w))
+	f := ecs.All(0, 2, 8).Without(9)
+	assert.Equal(t, f, filter.Filter(&w))
+	filter.Register(&w)
+
 	query := filter.Query(&w)
 	for query.Next() {
 		c1, c2, c3 := query.Get()
@@ -230,6 +249,9 @@ func TestQuery3(t *testing.T) {
 		cnt++
 	}
 	assert.Equal(t, 1, cnt)
+
+	filter.Unregister(&w)
+	assert.Panics(t, func() { filter.Unregister(&w) })
 }
 
 func TestQuery4(t *testing.T) {
@@ -268,7 +290,10 @@ func TestQuery4(t *testing.T) {
 			With(T[testStruct8]()).
 			Without(T[testStruct9]())
 
-	assert.Equal(t, ecs.All(0, 2, 3, 8).Without(9), filter.Filter(&w))
+	f := ecs.All(0, 2, 3, 8).Without(9)
+	assert.Equal(t, f, filter.Filter(&w))
+	filter.Register(&w)
+
 	query := filter.Query(&w)
 	for query.Next() {
 		c1, c2, c3, c4 := query.Get()
@@ -279,6 +304,9 @@ func TestQuery4(t *testing.T) {
 		cnt++
 	}
 	assert.Equal(t, 1, cnt)
+
+	filter.Unregister(&w)
+	assert.Panics(t, func() { filter.Unregister(&w) })
 }
 
 func TestQuery5(t *testing.T) {
@@ -321,7 +349,10 @@ func TestQuery5(t *testing.T) {
 			With(T[testStruct8]()).
 			Without(T[testStruct9]())
 
-	assert.Equal(t, ecs.All(0, 2, 3, 4, 8).Without(9), filter.Filter(&w))
+	f := ecs.All(0, 2, 3, 4, 8).Without(9)
+	assert.Equal(t, f, filter.Filter(&w))
+	filter.Register(&w)
+
 	query := filter.Query(&w)
 	for query.Next() {
 		c1, c2, c3, c4, c5 := query.Get()
@@ -333,6 +364,9 @@ func TestQuery5(t *testing.T) {
 		cnt++
 	}
 	assert.Equal(t, 1, cnt)
+
+	filter.Unregister(&w)
+	assert.Panics(t, func() { filter.Unregister(&w) })
 }
 
 func TestQuery6(t *testing.T) {
@@ -379,7 +413,10 @@ func TestQuery6(t *testing.T) {
 			With(T[testStruct8]()).
 			Without(T[testStruct9]())
 
-	assert.Equal(t, ecs.All(0, 2, 3, 4, 5, 8).Without(9), filter.Filter(&w))
+	f := ecs.All(0, 2, 3, 4, 5, 8).Without(9)
+	assert.Equal(t, f, filter.Filter(&w))
+	filter.Register(&w)
+
 	query := filter.Query(&w)
 	for query.Next() {
 		c1, c2, c3, c4, c5, c6 := query.Get()
@@ -392,6 +429,9 @@ func TestQuery6(t *testing.T) {
 		cnt++
 	}
 	assert.Equal(t, 1, cnt)
+
+	filter.Unregister(&w)
+	assert.Panics(t, func() { filter.Unregister(&w) })
 }
 
 func TestQuery7(t *testing.T) {
@@ -445,7 +485,10 @@ func TestQuery7(t *testing.T) {
 			With(T[testStruct8]()).
 			Without(T[testStruct9]())
 
-	assert.Equal(t, ecs.All(0, 2, 3, 4, 5, 6, 8).Without(9), filter.Filter(&w))
+	f := ecs.All(0, 2, 3, 4, 5, 6, 8).Without(9)
+	assert.Equal(t, f, filter.Filter(&w))
+	filter.Register(&w)
+
 	query := filter.Query(&w)
 	for query.Next() {
 		c1, c2, c3, c4, c5, c6, c7 := query.Get()
@@ -459,6 +502,9 @@ func TestQuery7(t *testing.T) {
 		cnt++
 	}
 	assert.Equal(t, 1, cnt)
+
+	filter.Unregister(&w)
+	assert.Panics(t, func() { filter.Unregister(&w) })
 }
 
 func TestQuery8(t *testing.T) {
@@ -508,7 +554,10 @@ func TestQuery8(t *testing.T) {
 			With(T[testStruct8]()).
 			Without(T[testStruct9]())
 
-	assert.Equal(t, ecs.All(0, 2, 3, 4, 5, 6, 7, 8).Without(9), filter.Filter(&w))
+	f := ecs.All(0, 2, 3, 4, 5, 6, 7, 8).Without(9)
+	assert.Equal(t, f, filter.Filter(&w))
+	filter.Register(&w)
+
 	query := filter.Query(&w)
 	for query.Next() {
 		c1, c2, c3, c4, c5, c6, c7, c8 := query.Get()
@@ -523,6 +572,9 @@ func TestQuery8(t *testing.T) {
 		cnt++
 	}
 	assert.Equal(t, 1, cnt)
+
+	filter.Unregister(&w)
+	assert.Panics(t, func() { filter.Unregister(&w) })
 }
 
 func TestQueryGeneric(t *testing.T) {