feat: cache archetype query matching results#23
Conversation
Cache matching(mask) results in archetypeGraph keyed by required component mask. The cache is invalidated (cleared) when a new archetype is created via getOrCreate, which is the only path that adds archetypes. This eliminates O(n) linear scan over all archetypes on every query iteration after the first call. Repeated queries hit the cache in O(1). Add TestQuery_CacheInvalidation to verify the cache is correctly invalidated when archetype transitions create new archetypes. Add BenchmarkQuery_ManyArchetypes to measure query performance with 50+ archetypes where only one matches the query. Closes #15
There was a problem hiding this comment.
Pull request overview
Introduces caching for archetype-query mask matching in the ECS archetype graph to avoid repeated O(n) scans over all archetypes during query iteration, along with tests/benchmarks to validate and measure the change.
Changes:
- Add
matchCachetoarchetypeGraphto memoizematching(requiredMask)results and clear it when new archetypes are created. - Add
TestQuery_CacheInvalidationto ensure cached match results are refreshed after archetype creation. - Add
BenchmarkQuery_ManyArchetypesintended to measure query performance in a many-archetype scenario.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| internal/ecs/archetype.go | Adds matching-result cache and invalidation on archetype creation. |
| internal/ecs/query_test.go | Adds a test covering cache invalidation behavior. |
| internal/ecs/benchmark_test.go | Adds a benchmark intended to exercise the matching cache under many archetypes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| func (g *archetypeGraph) matching(required componentMask) []*archetype { | ||
| result := []*archetype{} | ||
| if cached, ok := g.matchCache[required]; ok { | ||
| return cached | ||
| } | ||
| result := make([]*archetype, 0, len(g.archetypes)) | ||
| for _, a := range g.archetypes { | ||
| if a.mask.contains(required) { | ||
| result = append(result, a) | ||
| } | ||
| } | ||
| g.matchCache[required] = result | ||
| return result |
There was a problem hiding this comment.
archetypeGraph.matching() writes to g.matchCache (map read/write + assignment) even when called under World.mu.RLock() from queries. Multiple concurrent readers can enter matching() and race on the map, and it also violates the RWMutex intent (writing while only a read lock is held). Please make the cache updates thread-safe (e.g., guard matchCache with its own mutex or use a concurrent map) so queries can keep using RLock() safely.
| type filler struct{ X int } | ||
| RegisterComponent[filler](w) | ||
|
|
||
| // Create entities with various component combinations to populate archetypes. | ||
| for range 50 { | ||
| e := w.NewEntity() | ||
| Set(w, e, testVelocity{DX: 1}) | ||
| Set(w, e, testHealth{Current: 1, Max: 1}) | ||
| // Move to yet another archetype by adding filler. | ||
| Set(w, e, filler{X: 1}) | ||
| // Remove to create yet another archetype. | ||
| Remove[filler](w, e) |
There was a problem hiding this comment.
This benchmark setup does not create “50+ distinct archetypes” as the comment claims: with Position/Velocity/Health/filler there are at most 2^4=16 archetypes, and the loop repeatedly uses the same component sets (so it will mostly reuse a small handful of archetypes). Consider either adjusting the comment/expectations or introducing many distinct component types / varying combinations so w.graph.archetypes actually contains 50+ unique archetypes.
| type filler struct{ X int } | |
| RegisterComponent[filler](w) | |
| // Create entities with various component combinations to populate archetypes. | |
| for range 50 { | |
| e := w.NewEntity() | |
| Set(w, e, testVelocity{DX: 1}) | |
| Set(w, e, testHealth{Current: 1, Max: 1}) | |
| // Move to yet another archetype by adding filler. | |
| Set(w, e, filler{X: 1}) | |
| // Remove to create yet another archetype. | |
| Remove[filler](w, e) | |
| type filler1 struct{ X int } | |
| type filler2 struct{ X int } | |
| type filler3 struct{ X int } | |
| type filler4 struct{ X int } | |
| type filler5 struct{ X int } | |
| type filler6 struct{ X int } | |
| RegisterComponent[filler1](w) | |
| RegisterComponent[filler2](w) | |
| RegisterComponent[filler3](w) | |
| RegisterComponent[filler4](w) | |
| RegisterComponent[filler5](w) | |
| RegisterComponent[filler6](w) | |
| // Create entities with various component combinations to populate archetypes. | |
| // Use bit patterns over six filler component types to generate 64 distinct | |
| // archetypes (all with Velocity and Health, but different filler subsets). | |
| for i := 0; i < 64; i++ { | |
| e := w.NewEntity() | |
| Set(w, e, testVelocity{DX: 1}) | |
| Set(w, e, testHealth{Current: 1, Max: 1}) | |
| if i&1 != 0 { | |
| Set(w, e, filler1{X: 1}) | |
| } | |
| if i&2 != 0 { | |
| Set(w, e, filler2{X: 2}) | |
| } | |
| if i&4 != 0 { | |
| Set(w, e, filler3{X: 3}) | |
| } | |
| if i&8 != 0 { | |
| Set(w, e, filler4{X: 4}) | |
| } | |
| if i&16 != 0 { | |
| Set(w, e, filler5{X: 5}) | |
| } | |
| if i&32 != 0 { | |
| Set(w, e, filler6{X: 6}) | |
| } |
| if q.Count() != 1 { | ||
| t.Fatalf("expected 1, got %d", q.Count()) | ||
| } |
There was a problem hiding this comment.
The failure message calls q.Count() again, which can make debugging harder if the value changes between calls (and does extra work). Store the count in a local got variable and reuse it in both the condition and the error message.
What changed and why
Caches
matching(mask)results in the archetype graph to avoid O(n) scan over all archetypes on every query iteration.How it works
archetypeGraph.matchCachestoresmap[componentMask][]*archetypematching()call for a given mask populates the cachegetOrCreate()creates a new archetypeWhy this is safe
getOrCreate()is the only path that adds archetypesTests
TestQuery_CacheInvalidation— verifies cache is refreshed when archetype transitions create new archetypesBenchmarkQuery_ManyArchetypes— query with 50+ archetypes where only 1 matchesHow to test
Related issue
Closes #15