Skip to content

feat: cache archetype query matching results#23

Open
gauvainw wants to merge 1 commit into
mainfrom
feat/query-caching
Open

feat: cache archetype query matching results#23
gauvainw wants to merge 1 commit into
mainfrom
feat/query-caching

Conversation

@gauvainw

Copy link
Copy Markdown
Owner

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.matchCache stores map[componentMask][]*archetype
  • First matching() call for a given mask populates the cache
  • Subsequent calls return cached results in O(1)
  • Cache is invalidated (cleared) when getOrCreate() creates a new archetype

Why this is safe

  • Archetypes are append-only (never deleted or modified)
  • getOrCreate() is the only path that adds archetypes
  • The world lock protects concurrent access to the graph

Tests

  • TestQuery_CacheInvalidation — verifies cache is refreshed when archetype transitions create new archetypes
  • BenchmarkQuery_ManyArchetypes — query with 50+ archetypes where only 1 matches

How to test

go test -race -count=1 ./internal/ecs/
go test -bench=ManyArchetypes -benchmem ./internal/ecs/

Related issue

Closes #15

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 matchCache to archetypeGraph to memoize matching(requiredMask) results and clear it when new archetypes are created.
  • Add TestQuery_CacheInvalidation to ensure cached match results are refreshed after archetype creation.
  • Add BenchmarkQuery_ManyArchetypes intended 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.

Comment thread internal/ecs/archetype.go
Comment on lines 183 to 194
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

Copilot AI Mar 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +98 to +109
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)

Copilot AI Mar 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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})
}

Copilot uses AI. Check for mistakes.
Comment on lines +159 to +161
if q.Count() != 1 {
t.Fatalf("expected 1, got %d", q.Count())
}

Copilot AI Mar 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: archetype graph query caching

2 participants