diff --git a/archetype.go b/archetype.go index 26decaf..aa47785 100644 --- a/archetype.go +++ b/archetype.go @@ -4,15 +4,76 @@ import ( "slices" ) -func (world *World) createArchetype(componentsIds ...ComponentId) *archetype { - archetypeKey := archetypeId(len(world.archetypes)) - archetype := archetype{ - Id: archetypeKey, +// FNV-1a 64-bit parameters, used to hash a set of component ids into an +// archetype key. +const ( + fnv64Offset uint64 = 14695981039346656037 + fnv64Prime uint64 = 1099511628211 +) + +// maxInlineComponents bounds the stack scratch used to sort a set of component +// ids before looking its archetype up. Larger sets fall back to a heap copy. +const maxInlineComponents = 16 + +// maxTransitionComponents is the largest number of components added in one +// call (AddComponents8): the ids of a transition fit inline in its cache entry. +const maxTransitionComponents = 8 + +// transitionCacheSize is the number of slots of the transition cache, a power +// of two so that a slot is a mask of the key. +const transitionCacheSize = 64 + +// transition remembers where adding a list of components to an archetype leads. +// The ids are kept as given by the caller, so a hit needs no sorting: two orders +// of the same set are two entries pointing at the same archetype. Archetypes are +// never destroyed, so an entry never goes stale. +type transition struct { + from archetypeId + ids [maxTransitionComponents]ComponentId + n uint8 + dest archetypeId +} + +// transitionSlot maps a transition to its slot in the cache. +func transitionSlot(fromId archetypeId, componentsIds []ComponentId) int { + hash := (fnv64Offset ^ uint64(fromId)) * fnv64Prime + for _, componentId := range componentsIds { + hash ^= uint64(componentId) + hash *= fnv64Prime + } + + return int(hash & (transitionCacheSize - 1)) +} + +// archetypeKey hashes a sorted set of component ids. Two different sets may +// share a key: a lookup always confirms the archetype Type before trusting it. +func archetypeKey(sorted []ComponentId) uint64 { + hash := fnv64Offset + for _, componentId := range sorted { + hash ^= uint64(componentId) + hash *= fnv64Prime + } + + return hash +} + +// createArchetype registers a new archetype holding exactly the given set of +// components. The slice must be sorted; the archetype owns it from now on. +func (world *World) createArchetype(componentsIds componentsIds) *archetype { + id := archetypeId(len(world.archetypes)) + world.archetypes = append(world.archetypes, archetype{ + Id: id, Type: componentsIds, + }) + + // The first archetype registered under a key owns it; a later archetype + // whose set collides on the same key is found by scan instead. + key := archetypeKey(componentsIds) + if _, taken := world.archetypesByKey[key]; !taken { + world.archetypesByKey[key] = id } - world.archetypes = append(world.archetypes, archetype) - return &world.archetypes[archetypeKey] + return &world.archetypes[id] } func (world *World) getArchetype(entityRecord entityRecord) *archetype { @@ -33,50 +94,82 @@ func (world *World) setArchetype(entityRecord entityRecord, archetype *archetype world.entities[entityRecord.Id.Index()] = entityRecord } +// getArchetypeForComponentsIds returns the archetype holding exactly the given +// set of components, whatever their order, creating it if needed. func (world *World) getArchetypeForComponentsIds(componentsIds ...ComponentId) *archetype { - for i, archetype := range world.archetypes { - if len(archetype.Type) != len(componentsIds) { - continue - } + var scratch [maxInlineComponents]ComponentId - count := 0 - for _, componentId := range componentsIds { - if slices.Contains(archetype.Type, componentId) { - count++ - } else { - break - } - } + return world.archetypeForSet(append(scratch[:0], componentsIds...)) +} + +// archetypeForSet resolves the archetype holding exactly the given set of +// components, creating it if needed. The set is a scratch copy: it is sorted in +// place and never retained, the archetype created on a miss owns its own copy. +func (world *World) archetypeForSet(set []ComponentId) *archetype { + slices.Sort(set) + + id, found := world.archetypesByKey[archetypeKey(set)] + if !found { + return world.createArchetype(slices.Clone(set)) + } + if slices.Equal(world.archetypes[id].Type, set) { + return &world.archetypes[id] + } - if count == len(archetype.Type) { + // Key collision: another set owns the key, scan for this one. + for i := range world.archetypes { + if slices.Equal(world.archetypes[i].Type, set) { return &world.archetypes[i] } } - return world.createArchetype(componentsIds...) + return world.createArchetype(slices.Clone(set)) } +// getNextArchetype returns the archetype reached by adding componentsIds to the +// archetype the entity lives in. func (world *World) getNextArchetype(entityRecord entityRecord, componentsIds ...ComponentId) *archetype { - // Fast path: a single-component transition (AddComponent, AddTag, ...) is - // resolved through the archetype graph, avoiding both the linear scan over - // all archetypes and the slice rebuild done below. + // A single-component transition is resolved through the archetype graph. if len(componentsIds) == 1 { return world.archetypeAfterAdd(entityRecord.archetypeId, componentsIds[0]) } + if len(componentsIds) <= maxTransitionComponents { + return world.archetypeAfterAddN(entityRecord.archetypeId, componentsIds) + } - var archetype *archetype - if entityRecord.archetypeId == 0 { - archetype = world.getArchetypeForComponentsIds(componentsIds...) - } else { - oldArchetype := world.getArchetype(entityRecord) - if oldArchetype != nil { - archetype = world.getArchetypeForComponentsIds(append(componentsIds, oldArchetype.Type...)...) - } else { - archetype = world.getArchetypeForComponentsIds(componentsIds...) - } + return world.archetypeAfterAddSet(entityRecord.archetypeId, componentsIds) +} + +// archetypeAfterAddN resolves a multi-component transition through the +// transition cache. A hit costs one key comparison; a miss resolves the set and +// fills the slot, which the next transition hashing to it overwrites. The whole +// key is compared, so a slot collision is a miss, never a wrong archetype. +func (world *World) archetypeAfterAddN(fromId archetypeId, componentsIds []ComponentId) *archetype { + var key [maxTransitionComponents]ComponentId + copy(key[:], componentsIds) + n := uint8(len(componentsIds)) + + t := &world.transitions[transitionSlot(fromId, componentsIds)] + if t.from == fromId && t.n == n && t.ids == key { + return &world.archetypes[t.dest] + } + + dest := world.archetypeAfterAddSet(fromId, componentsIds) + *t = transition{from: fromId, ids: key, n: n, dest: dest.Id} + + return dest +} + +// archetypeAfterAddSet resolves the archetype holding the components of the +// archetype fromId plus componentsIds, through the canonical key. +func (world *World) archetypeAfterAddSet(fromId archetypeId, componentsIds []ComponentId) *archetype { + var scratch [maxInlineComponents]ComponentId + set := append(scratch[:0], componentsIds...) + if int(fromId) < len(world.archetypes) { + set = append(set, world.archetypes[fromId].Type...) } - return archetype + return world.archetypeForSet(set) } // archetypeAfterAdd returns the archetype obtained by adding componentId to the @@ -86,11 +179,11 @@ func (world *World) archetypeAfterAdd(fromId archetypeId, componentId ComponentI return &world.archetypes[destId] } - // Cache miss: compute the destination once. getArchetypeForComponentsIds may - // create a new archetype and reallocate world.archetypes, so we resolve every - // archetype by index afterwards rather than holding a stale pointer. + // Cache miss: compute the destination once. archetypeForSet may create a new + // archetype and reallocate world.archetypes, so we resolve every archetype + // by index afterwards rather than holding a stale pointer. newType := append(slices.Clone(world.archetypes[fromId].Type), componentId) - destId := world.getArchetypeForComponentsIds(newType...).Id + destId := world.archetypeForSet(newType).Id world.linkArchetypes(fromId, destId, componentId) return &world.archetypes[destId] @@ -110,7 +203,7 @@ func (world *World) archetypeAfterRemove(fromId archetypeId, componentId Compone newType = append(newType, c) } } - destId := world.getArchetypeForComponentsIds(newType...).Id + destId := world.archetypeForSet(newType).Id // dest --add componentId--> from, and from --remove componentId--> dest. world.linkArchetypes(destId, fromId, componentId) diff --git a/archetype_test.go b/archetype_test.go index 900610b..02ebe43 100644 --- a/archetype_test.go +++ b/archetype_test.go @@ -93,3 +93,196 @@ func TestArchetypeGraph_EdgesAreReused(t *testing.T) { } } } + +// TestArchetypeSetIsOrderIndependent: the components of a set, given in any +// order, resolve to the same archetype, whether the entity is created with +// them or receives them afterwards. +func TestArchetypeSetIsOrderIndependent(t *testing.T) { + world := CreateWorld(16) + RegisterComponent[testComponent1](world, &ComponentConfig[testComponent1]{}) + RegisterComponent[testComponent2](world, &ComponentConfig[testComponent2]{}) + + e1, err := CreateEntityWithComponents2(world, testComponent1{}, testComponent2{}) + if err != nil { + t.Fatal(err) + } + e2, err := CreateEntityWithComponents2(world, testComponent2{}, testComponent1{}) + if err != nil { + t.Fatal(err) + } + e3 := world.CreateEntity() + if err := AddComponents2(world, e3, testComponent2{}, testComponent1{}); err != nil { + t.Fatal(err) + } + + a1, a2, a3 := world.entities[e1.Index()].archetypeId, world.entities[e2.Index()].archetypeId, world.entities[e3.Index()].archetypeId + if a1 != a2 || a1 != a3 { + t.Fatalf("{c1,c2} resolved to archetypes %d, %d and %d depending on the order", a1, a2, a3) + } + if got := len(world.archetypes); got != 2 { + t.Fatalf("expected the empty archetype and {c1,c2} only, got %d archetypes", got) + } + for _, e := range []EntityId{e1, e2, e3} { + if GetComponent[testComponent1](world, e) == nil || GetComponent[testComponent2](world, e) == nil { + t.Fatalf("entity %d lost a component", e) + } + } +} + +// TestArchetypeSetBeyondInlineScratch: a set larger than the inline scratch +// (many tags plus components) still resolves, and resolves once. +func TestArchetypeSetBeyondInlineScratch(t *testing.T) { + const tags = maxInlineComponents + 1 + + world := CreateWorld(16) + RegisterComponent[testComponent1](world, &ComponentConfig[testComponent1]{}) + RegisterComponent[testComponent2](world, &ComponentConfig[testComponent2]{}) + + build := func() EntityId { + e := world.CreateEntity() + for i := range tags { + if err := world.AddTag(TAGS_INDICES+TagId(i), e); err != nil { + t.Fatal(err) + } + } + if err := AddComponents2(world, e, testComponent1{}, testComponent2{}); err != nil { + t.Fatal(err) + } + + return e + } + e1, e2 := build(), build() + + a1, a2 := world.entities[e1.Index()].archetypeId, world.entities[e2.Index()].archetypeId + if a1 != a2 { + t.Fatalf("the same large set resolved to archetypes %d and %d", a1, a2) + } + if got := len(world.archetypes[a1].Type); got != tags+2 { + t.Fatalf("expected an archetype of %d components, got %d", tags+2, got) + } + for i := range tags { + if !world.HasTag(TAGS_INDICES+TagId(i), e1) { + t.Fatalf("entity lost tag %d", i) + } + } + if !world.HasComponents(e1, testComponent1Id, testComponent2Id) { + t.Fatal("entity lost its components") + } +} + +// TestArchetypeKeyCollisionFallsBackToScan: when two sets share a key, the +// second one is still resolved (by scan) to a single archetype of its own, and +// the first one keeps the key. +func TestArchetypeKeyCollisionFallsBackToScan(t *testing.T) { + world := CreateWorld(16) + RegisterComponent[testComponent1](world, &ComponentConfig[testComponent1]{}) + RegisterComponent[testComponent2](world, &ComponentConfig[testComponent2]{}) + + single := world.CreateEntity() + if err := AddComponent(world, single, testComponent1{}); err != nil { + t.Fatal(err) + } + owner := world.entities[single.Index()].archetypeId + + // Forge a collision: the key of {c1,c2} points at the {c1} archetype. + pair := []ComponentId{testComponent1Id, testComponent2Id} + world.archetypesByKey[archetypeKey(pair)] = owner + + e1, err := CreateEntityWithComponents2(world, testComponent1{}, testComponent2{}) + if err != nil { + t.Fatal(err) + } + e2, err := CreateEntityWithComponents2(world, testComponent2{}, testComponent1{}) + if err != nil { + t.Fatal(err) + } + + a1, a2 := world.entities[e1.Index()].archetypeId, world.entities[e2.Index()].archetypeId + if a1 == owner || a1 != a2 { + t.Fatalf("colliding set resolved to archetypes %d and %d (key owner %d)", a1, a2, owner) + } + if got := len(world.archetypes); got != 3 { + t.Fatalf("expected the empty archetype, {c1} and {c1,c2}, got %d archetypes", got) + } + if world.archetypesByKey[archetypeKey(pair)] != owner { + t.Fatal("the colliding archetype stole the key from its first owner") + } + if GetComponent[testComponent2](world, e1) == nil || GetComponent[testComponent1](world, single) == nil { + t.Fatal("a component was lost across the collision") + } +} + +// TestTransitionCache: a multi-component transition is cached after its first +// resolution; a slot whose key differs is never trusted and gets refilled; the +// same set in another order is another entry, but the same archetype. +func TestTransitionCache(t *testing.T) { + world := CreateWorld(16) + RegisterComponent[testComponent1](world, &ComponentConfig[testComponent1]{}) + RegisterComponent[testComponent2](world, &ComponentConfig[testComponent2]{}) + archetypeOf := func(e EntityId) archetypeId { return world.entities[e.Index()].archetypeId } + + e1, err := CreateEntityWithComponents2(world, testComponent1{}, testComponent2{}) + if err != nil { + t.Fatal(err) + } + slot := transitionSlot(0, []ComponentId{testComponent1Id, testComponent2Id}) + if entry := world.transitions[slot]; entry.from != 0 || entry.n != 2 || entry.dest != archetypeOf(e1) { + t.Fatalf("the transition should be cached after its first resolution, got %+v", entry) + } + + // Corrupt the key of the slot and point it at the wrong archetype: the + // lookup must miss, resolve correctly, and refill the slot. + world.transitions[slot].ids[0] = testComponent2Id + world.transitions[slot].dest = 0 + e2, err := CreateEntityWithComponents2(world, testComponent1{}, testComponent2{}) + if err != nil { + t.Fatal(err) + } + if archetypeOf(e2) != archetypeOf(e1) { + t.Fatalf("a slot whose key differs must not be trusted: got archetype %d, want %d", archetypeOf(e2), archetypeOf(e1)) + } + if world.transitions[slot].dest != archetypeOf(e1) { + t.Fatal("the miss should have refilled the slot") + } + + e3, err := CreateEntityWithComponents2(world, testComponent2{}, testComponent1{}) + if err != nil { + t.Fatal(err) + } + if archetypeOf(e3) != archetypeOf(e1) { + t.Fatal("the same set in another order should reach the same archetype") + } + if got := len(world.archetypes); got != 2 { + t.Fatalf("expected the empty archetype and {c1,c2} only, got %d", got) + } +} + +// TestTransitionCacheFromNonEmptyArchetype: the transition is keyed by the +// archetype the entity starts from, so adding the same components to entities +// living in different archetypes leads to different, correct archetypes. +func TestTransitionCacheFromNonEmptyArchetype(t *testing.T) { + world := CreateWorld(16) + RegisterComponent[testComponent1](world, &ComponentConfig[testComponent1]{}) + RegisterComponent[testComponent2](world, &ComponentConfig[testComponent2]{}) + + bare := world.CreateEntity() + tagged := world.CreateEntity() + if err := world.AddTag(TAGS_INDICES, tagged); err != nil { + t.Fatal(err) + } + for _, e := range []EntityId{bare, tagged} { + if err := AddComponents2(world, e, testComponent1{}, testComponent2{}); err != nil { + t.Fatal(err) + } + } + + if world.entities[bare.Index()].archetypeId == world.entities[tagged.Index()].archetypeId { + t.Fatal("entities starting from different archetypes must not share the destination") + } + if !world.HasComponents(bare, testComponent1Id, testComponent2Id) || world.HasTag(TAGS_INDICES, bare) { + t.Fatal("the bare entity should own the two components and no tag") + } + if !world.HasComponents(tagged, testComponent1Id, testComponent2Id) || !world.HasTag(TAGS_INDICES, tagged) { + t.Fatal("the tagged entity should own the two components and keep its tag") + } +} diff --git a/benchmark/data.go b/benchmark/data.go index 1b27090..85853ef 100644 --- a/benchmark/data.go +++ b/benchmark/data.go @@ -29,6 +29,31 @@ func (t testTag) GetComponentId() volt.ComponentId { return testTagId } +// Six more small components, to build "large" entities (8 components) like the +// create-large-entities scenario of go-ecs-benchmarks. +const ( + testC3Id = iota + 2 + testC4Id + testC5Id + testC6Id + testC7Id + testC8Id +) + +type testC3 struct{ v float64 } +type testC4 struct{ v float64 } +type testC5 struct{ v float64 } +type testC6 struct{ v float64 } +type testC7 struct{ v float64 } +type testC8 struct{ v float64 } + +func (testC3) GetComponentId() volt.ComponentId { return testC3Id } +func (testC4) GetComponentId() volt.ComponentId { return testC4Id } +func (testC5) GetComponentId() volt.ComponentId { return testC5Id } +func (testC6) GetComponentId() volt.ComponentId { return testC6Id } +func (testC7) GetComponentId() volt.ComponentId { return testC7Id } +func (testC8) GetComponentId() volt.ComponentId { return testC8Id } + func transformData(tr *testTransform) { tr.x += 1.0 tr.y += 2.0 diff --git a/benchmark/volt_test.go b/benchmark/volt_test.go index 77ff19e..671d5f3 100644 --- a/benchmark/volt_test.go +++ b/benchmark/volt_test.go @@ -177,3 +177,71 @@ func BenchmarkCreateRemoveVolt(b *testing.B) { b.ReportAllocs() } + +// BenchmarkCreateRemoveLargeVolt: remove+recreate cycle of entities carrying 8 +// components (the create-large-entities scenario of go-ecs-benchmarks). +func BenchmarkCreateRemoveLargeVolt(b *testing.B) { + world := volt.CreateWorld(ENTITIES_COUNT) + volt.RegisterComponent[testTransform](world, &volt.ComponentConfig[testTransform]{}) + volt.RegisterComponent[testTag](world, &volt.ComponentConfig[testTag]{}) + volt.RegisterComponent[testC3](world, &volt.ComponentConfig[testC3]{}) + volt.RegisterComponent[testC4](world, &volt.ComponentConfig[testC4]{}) + volt.RegisterComponent[testC5](world, &volt.ComponentConfig[testC5]{}) + volt.RegisterComponent[testC6](world, &volt.ComponentConfig[testC6]{}) + volt.RegisterComponent[testC7](world, &volt.ComponentConfig[testC7]{}) + volt.RegisterComponent[testC8](world, &volt.ComponentConfig[testC8]{}) + + create := func() volt.EntityId { + entityId, _ := volt.CreateEntityWithComponents8(world, testTransform{}, testTag{}, testC3{}, testC4{}, testC5{}, testC6{}, testC7{}, testC8{}) + return entityId + } + + entities := make([]volt.EntityId, ENTITIES_COUNT) + for i := range entities { + entities[i] = create() + } + + for b.Loop() { + for i, entityId := range entities { + world.RemoveEntity(entityId) + entities[i] = create() + } + } + + b.ReportAllocs() +} + +// BenchmarkCreateRemoveFragmentedVolt: the same remove+recreate cycle as +// BenchmarkCreateRemoveVolt, in a world that already holds 256 other +// archetypes (every subset of 8 tags). It exposes how the cost of resolving an +// entity's archetype scales with the number of archetypes in the world. +func BenchmarkCreateRemoveFragmentedVolt(b *testing.B) { + world := volt.CreateWorld(ENTITIES_COUNT) + volt.RegisterComponent[testTransform](world, &volt.ComponentConfig[testTransform]{}) + volt.RegisterComponent[testTag](world, &volt.ComponentConfig[testTag]{}) + + for subset := range 256 { + entityId := world.CreateEntity() + for bit := range 8 { + if subset&(1<