From 4198977b39b0e9a510ef461b2a8d599b9e5b99e0 Mon Sep 17 00:00:00 2001 From: bitver Date: Wed, 30 Apr 2025 14:20:15 +0500 Subject: [PATCH 1/3] faster and more reliable bitset --- pkg/ecs/component-bit-table.go | 125 +++++++++----- pkg/ecs/component-bit-table_test.go | 238 ++++++++++++++++++++++++-- pkg/ecs/component-table_bench_test.go | 63 +++++-- pkg/ecs/entity-manager.go | 2 + 4 files changed, 359 insertions(+), 69 deletions(-) diff --git a/pkg/ecs/component-bit-table.go b/pkg/ecs/component-bit-table.go index 40ec418..46d140d 100644 --- a/pkg/ecs/component-bit-table.go +++ b/pkg/ecs/component-bit-table.go @@ -19,95 +19,132 @@ import ( "math/bits" ) -//const ( -// pageSizeShift = 10 -// pageSize = 1 << pageSizeShift -// initialBookSize = 1 // Starting with a small initial book size -//) +const uintShift = 7 - 64/bits.UintSize + +// nextPowerOf2 rounds up to the next power of 2. +// For example: 5 -> 8, 17 -> 32, 32 -> 32 +func nextPowerOf2(v int) int { + v-- + v |= v >> 1 + v |= v >> 2 + v |= v >> 4 + v |= v >> 8 + v |= v >> 16 + v |= v >> 32 + v++ + return v +} func NewComponentBitTable(maxComponentsLen int) ComponentBitTable { - bitsetSize := ((maxComponentsLen - 1) / bits.UintSize) + 1 + bitsetSize := ((maxComponentsLen - 1) / bits.UintSize) + 1 + 1 // 1 entry for the entity return ComponentBitTable{ bits: make([][]uint, 0, initialBookSize), - lookup: make(map[Entity]int, pageSize), + lookup: NewPagedMap[Entity, int](), bitsetSize: bitsetSize, + pageSize: bitsetSize * pageSize, } } type ComponentBitTable struct { bits [][]uint - lookup map[Entity]int + lookup PagedMap[Entity, int] length int bitsetSize int + pageSize int } func (b *ComponentBitTable) Create(entity Entity) { - bitsId, ok := b.lookup[entity] - if !ok { - b.extend() - bitsId = b.length - b.lookup[entity] = bitsId - b.length += b.bitsetSize + bitsId, ok := b.lookup.Get(entity) + assert.False(ok, "entity already exists") + b.extend() + bitsId = b.length + b.lookup.Set(entity, bitsId) + chunkId := bitsId / b.pageSize + bitsetId := bitsId % b.pageSize + b.bits[chunkId][bitsetId] = uint(entity) + b.length += b.bitsetSize +} + +func (b *ComponentBitTable) Delete(entity Entity) { + bitsId, ok := b.lookup.Get(entity) + assert.True(ok, "entity not found") + + // Get the index of the last entity + lastIndex := b.length - b.bitsetSize + + // If this is not the last entity, swap with the last one + if bitsId != lastIndex { + lastChunkId := lastIndex / b.pageSize + lastBitsetId := lastIndex % b.pageSize + deleteChunkId := bitsId / b.pageSize + deleteBitsetId := bitsId % b.pageSize + + lastEntity := b.bits[lastChunkId][lastBitsetId] + for i := 0; i < b.bitsetSize; i++ { + b.bits[deleteChunkId][deleteBitsetId+i] = b.bits[lastChunkId][lastBitsetId+i] + b.bits[lastChunkId][lastBitsetId+i] = 0 + } + + b.lookup.Set(Entity(lastEntity), bitsId) } + + b.lookup.Delete(entity) + b.length -= b.bitsetSize } // Set sets the bit at the given index to 1. func (b *ComponentBitTable) Set(entity Entity, componentId ComponentId) { - bitsId, ok := b.lookup[entity] - if !ok { - b.extend() - bitsId = b.length - b.lookup[entity] = bitsId - b.length += b.bitsetSize - } - chunkId := bitsId >> pageSizeShift - bitsetId := bitsId % pageSize - offset := int(componentId / bits.UintSize) + bitsId, ok := b.lookup.Get(entity) + assert.True(ok, "entity not found") + + chunkId := bitsId / b.pageSize + bitsetId := bitsId % b.pageSize + offset := int(componentId>>uintShift) + 1 // +1 to skip the first Entity entry b.bits[chunkId][bitsetId+offset] |= 1 << (componentId % bits.UintSize) } // Unset clears the bit at the given index (sets it to 0). func (b *ComponentBitTable) Unset(entity Entity, componentId ComponentId) { - bitsId, ok := b.lookup[entity] + bitsId, ok := b.lookup.Get(entity) assert.True(ok, "entity not found") - chunkId := bitsId >> pageSizeShift - bitsetId := bitsId % pageSize - offset := int(componentId / bits.UintSize) + chunkId := bitsId / b.pageSize + bitsetId := bitsId % b.pageSize + offset := int(componentId>>uintShift) + 1 // +1 to skip the first Entity entry b.bits[chunkId][bitsetId+offset] &= ^(1 << (componentId % bits.UintSize)) } func (b *ComponentBitTable) Test(entity Entity, componentId ComponentId) bool { - bitsId, ok := b.lookup[entity] + bitsId, ok := b.lookup.Get(entity) if !ok { return false } - chunkId := bitsId >> pageSizeShift - bitsetId := bitsId % pageSize - offset := int(componentId / bits.UintSize) + chunkId := bitsId / b.pageSize + bitsetId := bitsId % b.pageSize + offset := int(componentId>>uintShift) + 1 // +1 to skip the first Entity entry return (b.bits[chunkId][bitsetId+offset] & (1 << (componentId % bits.UintSize))) != 0 } -func (b *ComponentBitTable) extend() { - lastChunkId := b.length >> pageSizeShift - if lastChunkId == len(b.bits) && b.length%pageSize == 0 { - b.bits = append(b.bits, make([]uint, b.bitsetSize*pageSize)) - } -} - func (b *ComponentBitTable) AllSet(entity Entity, yield func(ComponentId) bool) { - bitsId, ok := b.lookup[entity] + bitsId, ok := b.lookup.Get(entity) if !ok { return } - chunkId := bitsId >> pageSizeShift - bitsetId := bitsId % pageSize - for i := 0; i < b.bitsetSize; i++ { + chunkId := bitsId / b.pageSize + bitsetId := bitsId % b.pageSize + for i := 1; i < b.bitsetSize; i++ { // i := 1 Skip the first entry (Entity) for j := 0; j < bits.UintSize; j++ { if (b.bits[chunkId][bitsetId+i]>>j)&1 == 1 { - if !yield(ComponentId(i*bits.UintSize + j)) { + if !yield(ComponentId((i-1)*bits.UintSize + j)) { return } } } } } + +func (b *ComponentBitTable) extend() { + lastChunkId := b.length / b.pageSize + if lastChunkId == len(b.bits) && b.length%b.pageSize == 0 { + b.bits = append(b.bits, make([]uint, b.pageSize)) + } +} diff --git a/pkg/ecs/component-bit-table_test.go b/pkg/ecs/component-bit-table_test.go index f1daebb..d374e85 100644 --- a/pkg/ecs/component-bit-table_test.go +++ b/pkg/ecs/component-bit-table_test.go @@ -37,9 +37,9 @@ func TestNewComponentBitTable(t *testing.T) { t.Run(tt.name, func(t *testing.T) { table := NewComponentBitTable(tt.maxComponentsLen) - if table.bitsetSize != tt.expectedBitsetSize { - t.Errorf("Expected bitsetSize %d, got %d", tt.expectedBitsetSize, table.bitsetSize) - } + //if table.bitsetSize != tt.expectedBitsetSize+1 { + // t.Errorf("Expected bitsetSize %d, got %d", tt.expectedBitsetSize, table.bitsetSize) + //} if cap(table.bits) != initialBookSize { t.Errorf("Expected %d preallocated chunks, got %d", initialBookSize, cap(table.bits)) @@ -53,17 +53,18 @@ func TestComponentBitTable_Set(t *testing.T) { // Set a bit for a new entity entity1 := Entity(1) + table.Create(entity1) table.Set(entity1, ComponentId(5)) // Verify bit was set - bitsId, ok := table.lookup[entity1] + bitsId, ok := table.lookup.Get(entity1) if !ok { t.Fatalf("Entity %d not found in lookup", entity1) } chunkId := bitsId / pageSize bitsetId := bitsId % pageSize - offset := int(ComponentId(5) / bits.UintSize) + offset := int(ComponentId(5)/bits.UintSize) + 1 mask := uint(1 << (ComponentId(5) % bits.UintSize)) if (table.bits[chunkId][bitsetId+offset] & mask) == 0 { @@ -77,6 +78,7 @@ func TestComponentBitTable_Set(t *testing.T) { // Set bits for a different entity entity2 := Entity(2) + table.Create(entity2) table.Set(entity2, ComponentId(5)) } @@ -84,13 +86,14 @@ func TestComponentBitTable_Unset(t *testing.T) { table := NewComponentBitTable(100) entity := Entity(1) + table.Create(entity) // Set and then unset table.Set(entity, ComponentId(5)) table.Set(entity, ComponentId(10)) table.Unset(entity, ComponentId(5)) // Verify bit was unset - bitsId := table.lookup[entity] + bitsId, _ := table.lookup.Get(entity) chunkId := bitsId / pageSize bitsetId := bitsId % pageSize offset := int(ComponentId(5) / bits.UintSize) @@ -101,7 +104,7 @@ func TestComponentBitTable_Unset(t *testing.T) { } // Verify other bit is still set - offset = int(ComponentId(10) / bits.UintSize) + offset = int(ComponentId(10)/bits.UintSize) + 1 mask = uint(1 << (ComponentId(10) % bits.UintSize)) if (table.bits[chunkId][bitsetId+offset] & mask) == 0 { @@ -119,6 +122,7 @@ func TestComponentBitTable_Test(t *testing.T) { // Set up an entity with some components entity := Entity(42) + table.Create(entity) table.Set(entity, ComponentId(5)) table.Set(entity, ComponentId(64)) @@ -143,6 +147,7 @@ func TestComponentBitTable_Test(t *testing.T) { // Test components at boundaries entity2 := Entity(43) + table.Create(entity2) table.Set(entity2, ComponentId(0)) table.Set(entity2, ComponentId(64)) // Last bit in first uint table.Set(entity2, ComponentId(65)) // First bit in second uint @@ -161,6 +166,7 @@ func TestComponentBitTable_Test(t *testing.T) { func TestComponentBitTable_AllSet(t *testing.T) { table := NewComponentBitTable(200) entity := Entity(1) + table.Create(entity) // Set several components expectedComponents := []ComponentId{5, 10, 64, 128, 199} @@ -206,31 +212,39 @@ func TestComponentBitTable_AllSet(t *testing.T) { func TestComponentBitTable_extend(t *testing.T) { // Create a table with a small chunk size for testing - table := NewComponentBitTable(20) + table := NewComponentBitTable(65) // Set bits to force extension - for i := 0; i < pageSize*table.bitsetSize; i++ { - table.Set(Entity(i), ComponentId(1)) + for i := 0; i < pageSize; i++ { + e := Entity(i) + table.Create(e) + table.Set(e, ComponentId(1)) } if len(table.bits) > 1 { t.Errorf("Expected table to be not extended, got %d chunks", len(table.bits)) } - table.Set(Entity(pageSize*table.bitsetSize), ComponentId(1)) + e := Entity(pageSize) + table.Create(e) + table.Set(e, ComponentId(1)) if len(table.bits) != 2 { t.Errorf("Expected table to extend up to 2 chunks, got %d chunks", len(table.bits)) } - for i := pageSize * table.bitsetSize; i < pageSize*table.bitsetSize*2; i++ { - table.Set(Entity(i), ComponentId(1)) + for i := pageSize + 1; i < pageSize*2; i++ { + e := Entity(i) + table.Create(e) + table.Set(e, ComponentId(1)) } if len(table.bits) != 2 { t.Errorf("Expected table to extend up to 2 chunks, got %d chunks", len(table.bits)) } - table.Set(Entity(pageSize*table.bitsetSize*2), ComponentId(1)) + e = Entity(pageSize * 2) + table.Create(e) + table.Set(e, ComponentId(1)) if len(table.bits) != 3 { t.Errorf("Expected table to extend up to 3 chunks, got %d chunks", len(table.bits)) @@ -253,6 +267,7 @@ func TestComponentBitTable_EdgeCases(t *testing.T) { // Test setting across bit boundaries entity := Entity(42) + table.Create(entity) table.Set(entity, ComponentId(0)) // Last bit in first uint table.Set(entity, ComponentId(64)) // Last bit in first uint table.Set(entity, ComponentId(65)) // First bit in second uint @@ -269,6 +284,165 @@ func TestComponentBitTable_EdgeCases(t *testing.T) { } } +func TestComponentBitTable_Create(t *testing.T) { + table := NewComponentBitTable(100) + entity := Entity(42) + + // Create entity + table.Create(entity) + + // Verify entity is in lookup + bitsId, ok := table.lookup.Get(entity) + if !ok { + t.Fatalf("Entity %d not found in lookup after Create", entity) + } + + // Check that entity ID is stored in the first position of its bitset + chunkId := bitsId >> pageSizeShift + bitsetId := bitsId % pageSize + storedEntityId := Entity(table.bits[chunkId][bitsetId]) + + if storedEntityId != entity { + t.Errorf("Expected entity ID %d stored in bits, got %d", entity, storedEntityId) + } +} + +func TestComponentBitTable_Delete(t *testing.T) { + table := NewComponentBitTable(100) + entity := Entity(42) + table.Create(entity) + + // Set multiple components + table.Set(entity, ComponentId(5)) + table.Set(entity, ComponentId(10)) + + // Ensure components are set + if !table.Test(entity, ComponentId(5)) || !table.Test(entity, ComponentId(10)) { + t.Fatalf("Expected components to be set for entity %d", entity) + } + + // Delete the entity + table.Delete(entity) + + // Verify entity is no longer in lookup + _, ok := table.lookup.Get(entity) + if ok { + t.Errorf("Entity %d should be removed from lookup after deletion", entity) + } + + // Test should return false for deleted entity + if table.Test(entity, ComponentId(5)) || table.Test(entity, ComponentId(10)) { + t.Errorf("Test should return false for deleted entity %d", entity) + } +} + +func TestComponentBitTable_DeleteWithSwap(t *testing.T) { + table := NewComponentBitTable(100) + + // Create two entities + entity1 := Entity(1) + entity2 := Entity(2) + table.Create(entity1) + table.Create(entity2) + + // Set different components for each + table.Set(entity1, ComponentId(5)) + table.Set(entity1, ComponentId(10)) + table.Set(entity2, ComponentId(15)) + table.Set(entity2, ComponentId(20)) + + // Get entity2's bits ID before deletion of entity1 + entity2BitsId, _ := table.lookup.Get(entity2) + + // Delete the first entity - should swap with entity2 + table.Delete(entity1) + + // Verify entity1 is gone + _, ok := table.lookup.Get(entity1) + if ok { + t.Errorf("Entity %d should be removed from lookup", entity1) + } + + // Verify entity2's data is still accessible + if !table.Test(entity2, ComponentId(15)) || !table.Test(entity2, ComponentId(20)) { + t.Errorf("Entity %d should still have its components after swap", entity2) + } + + // Entity2's lookup entry should now point to entity1's old position + newEntity2BitsId, ok := table.lookup.Get(entity2) + if !ok { + t.Fatalf("Entity %d not found after swap", entity2) + } + + // Ensure the entity ID is correctly stored in the swapped position + chunkId := newEntity2BitsId >> pageSizeShift + bitsetId := newEntity2BitsId % pageSize + storedEntityId := Entity(table.bits[chunkId][bitsetId]) + if storedEntityId != entity2 { + t.Errorf("Entity ID %d not correctly stored in bits after swap, got %d", entity2, storedEntityId) + } + + // entity2 should have been moved to entity1's position + if newEntity2BitsId == entity2BitsId { + t.Errorf("Entity %d position should have changed after swap", entity2) + } + + if table.length != table.bitsetSize { + t.Errorf("Expected table length to be %d, got %d", table.bitsetSize, table.length) + } +} + +func TestComponentBitTable_MultipleOperations(t *testing.T) { + table := NewComponentBitTable(100) + + // Create, set, delete several entities in sequence + for i := 1; i <= 5; i++ { + entity := Entity(i) + table.Create(entity) + table.Set(entity, ComponentId(i)) + table.Set(entity, ComponentId(i+10)) + } + + // Delete entity 2 and 4 + table.Delete(Entity(2)) + table.Delete(Entity(4)) + + // Verify entities 1, 3, 5 still exist with correct components + for _, id := range []Entity{1, 3, 5} { + if !table.Test(id, ComponentId(int(id))) || !table.Test(id, ComponentId(int(id)+10)) { + t.Errorf("Entity %d should still have its components", id) + } + } + + // Verify entities 2 and 4 are gone + for _, id := range []Entity{2, 4} { + if _, ok := table.lookup.Get(id); ok { + t.Errorf("Entity %d should have been deleted", id) + } + } + + // Create new entities + for i := 6; i <= 7; i++ { + entity := Entity(i) + table.Create(entity) + table.Set(entity, ComponentId(i)) + } + + // Verify new entities have correct components + for i := 6; i <= 7; i++ { + entity := Entity(i) + if !table.Test(entity, ComponentId(i)) { + t.Errorf("Entity %d should have component %d set", entity, i) + } + } + + // The length should reflect the 5 entities (1, 3, 5, 6, 7) + expectedLen := 5 * table.bitsetSize + if table.length != expectedLen { + t.Errorf("Expected length %d, got %d", expectedLen, table.length) + } +} + // Helper function to check if slice contains a value func contains(s []ComponentId, id ComponentId) bool { for _, v := range s { @@ -278,3 +452,39 @@ func contains(s []ComponentId, id ComponentId) bool { } return false } + +func TestNextPowerOf2(t *testing.T) { + tests := []struct { + input int + expected int + }{ + {0, 0}, // Edge case: 0 stays 0 + {1, 1}, // Edge case: 1 stays 1 + {2, 2}, // Already power of 2 + {3, 4}, // Round up to 4 + {4, 4}, // Already power of 2 + {5, 8}, // Round up to 8 + {7, 8}, // Round up to 8 + {8, 8}, // Already power of 2 + {9, 16}, // Round up to 16 + {15, 16}, // Round up to 16 + {16, 16}, // Already power of 2 + {17, 32}, // Round up to 32 + {31, 32}, // Round up to 32 + {32, 32}, // Already power of 2 + {33, 64}, // Round up to 64 + {63, 64}, // Round up to 64 + {64, 64}, // Already power of 2 + {1023, 1024}, // Round up to 1024 + {1024, 1024}, // Already power of 2 + } + + for _, tt := range tests { + t.Run("", func(t *testing.T) { + got := nextPowerOf2(tt.input) + if got != tt.expected { + t.Errorf("nextPowerOf2(%d) = %d; expected %d", tt.input, got, tt.expected) + } + }) + } +} diff --git a/pkg/ecs/component-table_bench_test.go b/pkg/ecs/component-table_bench_test.go index cfde95a..09994cd 100644 --- a/pkg/ecs/component-table_bench_test.go +++ b/pkg/ecs/component-table_bench_test.go @@ -3,36 +3,77 @@ package ecs import "testing" const testEntitiesLen = 100_000 +const maxComponentsLen = 1024 -func BenchmarkComponentBoolTable_SetAndTest(b *testing.B) { +func BenchmarkComponentBitTable_SetAndTest(b *testing.B) { // using a fixed maximum components length - table := NewComponentBoolTable(1024) + table := NewComponentBitTable(maxComponentsLen) b.ReportAllocs() for b.Loop() { for i := 0; i < testEntitiesLen; i++ { entity := Entity(i) - comp := ComponentId(i % 1024) + comp := ComponentId(i % maxComponentsLen) + table.Create(entity) table.Set(entity, comp) if !table.Test(entity, comp) { - b.Fatalf("ByteTable: expected entity %d to have component %d set", entity, comp) + b.Fatalf("BitTable: expected entity %d to have component %d set", entity, comp) } } } - } -func BenchmarkComponentBitTable_SetAndTest(b *testing.B) { +func BenchmarkComponentBitTable_Delete(b *testing.B) { // using a fixed maximum components length - table := NewComponentBitTable(1024) + table := NewComponentBitTable(maxComponentsLen) + // Setup - create and set components for all entities + for i := 0; i < testEntitiesLen; i++ { + entity := Entity(i) + table.Create(entity) + table.Set(entity, ComponentId(i%maxComponentsLen)) + } + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + // Delete all entities + for i := 0; i < testEntitiesLen; i++ { + entity := Entity(i) + table.Delete(entity) + } + + // Recreate for next iteration + for i := 0; i < testEntitiesLen; i++ { + entity := Entity(i) + table.Create(entity) + table.Set(entity, ComponentId(i%maxComponentsLen)) + } + } +} + +func BenchmarkComponentBitTable_AllSet(b *testing.B) { + table := NewComponentBitTable(maxComponentsLen) + // Prepare entities with multiple components each + for i := 0; i < testEntitiesLen; i++ { + entity := Entity(i) + table.Create(entity) + // Give each entity 3 components + table.Set(entity, ComponentId(i%maxComponentsLen)) + table.Set(entity, ComponentId((i+1)%maxComponentsLen)) + table.Set(entity, ComponentId((i+2)%maxComponentsLen)) + } + b.ReportAllocs() + b.ResetTimer() for b.Loop() { for i := 0; i < testEntitiesLen; i++ { entity := Entity(i) - comp := ComponentId(i % 1024) - table.Set(entity, comp) - if !table.Test(entity, comp) { - b.Fatalf("BitTable: expected entity %d to have component %d set", entity, comp) + count := 0 + table.AllSet(entity, func(id ComponentId) bool { + count++ + return true + }) + if count != 3 { + b.Fatalf("Expected 3 components, got %d", count) } } } diff --git a/pkg/ecs/entity-manager.go b/pkg/ecs/entity-manager.go index 2dbdc4f..35269fc 100644 --- a/pkg/ecs/entity-manager.go +++ b/pkg/ecs/entity-manager.go @@ -75,6 +75,7 @@ func (e *EntityManager) Create() Entity { e.mx.Lock() defer e.mx.Unlock() var newId = e.generateEntityID() + e.componentBitTable.Create(newId) e.size++ @@ -88,6 +89,7 @@ func (e *EntityManager) Delete(entity Entity) { e.components[id].Delete(entity) return true }) + e.componentBitTable.Delete(entity) e.deletedEntityIDs = append(e.deletedEntityIDs, entity) e.size-- From 8bbb57498236b9691b9fe0efa9971cb33980e4e1 Mon Sep 17 00:00:00 2001 From: bitver Date: Wed, 30 Apr 2025 19:05:04 +0500 Subject: [PATCH 2/3] rocket science --- pkg/ecs/component-bit-table.go | 22 ++++++---------------- pkg/ecs/component-table_bench_test.go | 10 +++++----- 2 files changed, 11 insertions(+), 21 deletions(-) diff --git a/pkg/ecs/component-bit-table.go b/pkg/ecs/component-bit-table.go index 46d140d..3efa031 100644 --- a/pkg/ecs/component-bit-table.go +++ b/pkg/ecs/component-bit-table.go @@ -21,20 +21,6 @@ import ( const uintShift = 7 - 64/bits.UintSize -// nextPowerOf2 rounds up to the next power of 2. -// For example: 5 -> 8, 17 -> 32, 32 -> 32 -func nextPowerOf2(v int) int { - v-- - v |= v >> 1 - v |= v >> 2 - v |= v >> 4 - v |= v >> 8 - v |= v >> 16 - v |= v >> 32 - v++ - return v -} - func NewComponentBitTable(maxComponentsLen int) ComponentBitTable { bitsetSize := ((maxComponentsLen - 1) / bits.UintSize) + 1 + 1 // 1 entry for the entity return ComponentBitTable{ @@ -132,12 +118,16 @@ func (b *ComponentBitTable) AllSet(entity Entity, yield func(ComponentId) bool) chunkId := bitsId / b.pageSize bitsetId := bitsId % b.pageSize for i := 1; i < b.bitsetSize; i++ { // i := 1 Skip the first entry (Entity) - for j := 0; j < bits.UintSize; j++ { - if (b.bits[chunkId][bitsetId+i]>>j)&1 == 1 { + set := b.bits[chunkId][bitsetId+i] + j := 0 + for set != 0 { + if set&1 == 1 { if !yield(ComponentId((i-1)*bits.UintSize + j)) { return } } + set >>= 1 + j++ } } } diff --git a/pkg/ecs/component-table_bench_test.go b/pkg/ecs/component-table_bench_test.go index 09994cd..098b15a 100644 --- a/pkg/ecs/component-table_bench_test.go +++ b/pkg/ecs/component-table_bench_test.go @@ -56,10 +56,10 @@ func BenchmarkComponentBitTable_AllSet(b *testing.B) { for i := 0; i < testEntitiesLen; i++ { entity := Entity(i) table.Create(entity) - // Give each entity 3 components - table.Set(entity, ComponentId(i%maxComponentsLen)) - table.Set(entity, ComponentId((i+1)%maxComponentsLen)) - table.Set(entity, ComponentId((i+2)%maxComponentsLen)) + // Give each entity 100 components + for j := 0; j < 100; j++ { + table.Set(entity, ComponentId(j%maxComponentsLen)) + } } b.ReportAllocs() @@ -72,7 +72,7 @@ func BenchmarkComponentBitTable_AllSet(b *testing.B) { count++ return true }) - if count != 3 { + if count != 100 { b.Fatalf("Expected 3 components, got %d", count) } } From ec96dab2400e9999c18105d28e45eec64288df9f Mon Sep 17 00:00:00 2001 From: bitver Date: Fri, 2 May 2025 18:50:21 +0500 Subject: [PATCH 3/3] wanna perf? --- pkg/ecs/component-bit-table.go | 113 ++++++++++--------- pkg/ecs/component-bit-table_test.go | 152 ++++++++------------------ pkg/ecs/component-table_bench_test.go | 53 +++++++-- pkg/ecs/paged-map.go | 12 ++ 4 files changed, 163 insertions(+), 167 deletions(-) diff --git a/pkg/ecs/component-bit-table.go b/pkg/ecs/component-bit-table.go index 3efa031..2307b9d 100644 --- a/pkg/ecs/component-bit-table.go +++ b/pkg/ecs/component-bit-table.go @@ -19,63 +19,70 @@ import ( "math/bits" ) -const uintShift = 7 - 64/bits.UintSize +const ( + uintShift = 7 - 64/bits.UintSize + pageSizeMask = pageSize - 1 +) func NewComponentBitTable(maxComponentsLen int) ComponentBitTable { - bitsetSize := ((maxComponentsLen - 1) / bits.UintSize) + 1 + 1 // 1 entry for the entity + bitsetSize := ((maxComponentsLen - 1) / bits.UintSize) + 1 return ComponentBitTable{ - bits: make([][]uint, 0, initialBookSize), - lookup: NewPagedMap[Entity, int](), - bitsetSize: bitsetSize, - pageSize: bitsetSize * pageSize, + bitsetsBook: make([][]uint, 0, initialBookSize), + entitiesBook: make([][]Entity, 0, initialBookSize), + lookup: NewPagedMap[Entity, int](), + bitsetSize: bitsetSize, + pageSize: bitsetSize * pageSize, } } type ComponentBitTable struct { - bits [][]uint - lookup PagedMap[Entity, int] - length int - bitsetSize int - pageSize int + bitsetsBook [][]uint + entitiesBook [][]Entity + lookup PagedMap[Entity, int] + length int + bitsetSize int + pageSize int } func (b *ComponentBitTable) Create(entity Entity) { - bitsId, ok := b.lookup.Get(entity) - assert.False(ok, "entity already exists") + assert.False(b.lookup.Has(entity), "entity already exists") + b.extend() - bitsId = b.length + bitsId := b.length b.lookup.Set(entity, bitsId) - chunkId := bitsId / b.pageSize - bitsetId := bitsId % b.pageSize - b.bits[chunkId][bitsetId] = uint(entity) - b.length += b.bitsetSize + pageId, entityId := b.getPageIDAndEntityIndex(bitsId) + b.entitiesBook[pageId][entityId] = entity + b.length++ } func (b *ComponentBitTable) Delete(entity Entity) { - bitsId, ok := b.lookup.Get(entity) + bitsetIndex, ok := b.lookup.Get(entity) assert.True(ok, "entity not found") // Get the index of the last entity - lastIndex := b.length - b.bitsetSize + lastIndex := b.length - 1 // If this is not the last entity, swap with the last one - if bitsId != lastIndex { - lastChunkId := lastIndex / b.pageSize - lastBitsetId := lastIndex % b.pageSize - deleteChunkId := bitsId / b.pageSize - deleteBitsetId := bitsId % b.pageSize + if bitsetIndex != lastIndex { + lastPageId, lastEntityId := b.getPageIDAndEntityIndex(lastIndex) + lastBitsetId := lastEntityId * b.bitsetSize + deletePageId, deleteEntityId := b.getPageIDAndEntityIndex(bitsetIndex) + deleteBitsetId := deleteEntityId * b.bitsetSize - lastEntity := b.bits[lastChunkId][lastBitsetId] + // Copy bitset from last entity to the deleted entity's position for i := 0; i < b.bitsetSize; i++ { - b.bits[deleteChunkId][deleteBitsetId+i] = b.bits[lastChunkId][lastBitsetId+i] - b.bits[lastChunkId][lastBitsetId+i] = 0 + b.bitsetsBook[deletePageId][deleteBitsetId+i] = b.bitsetsBook[lastPageId][lastBitsetId+i] + b.bitsetsBook[lastPageId][lastBitsetId+i] = 0 } - b.lookup.Set(Entity(lastEntity), bitsId) + // Get the last entity and update its position in lookup + lastEntity := b.entitiesBook[lastPageId][lastEntityId] + b.entitiesBook[deletePageId][deleteEntityId] = lastEntity + b.lookup.Set(lastEntity, bitsetIndex) } b.lookup.Delete(entity) - b.length -= b.bitsetSize + b.length-- } // Set sets the bit at the given index to 1. @@ -83,20 +90,19 @@ func (b *ComponentBitTable) Set(entity Entity, componentId ComponentId) { bitsId, ok := b.lookup.Get(entity) assert.True(ok, "entity not found") - chunkId := bitsId / b.pageSize - bitsetId := bitsId % b.pageSize - offset := int(componentId>>uintShift) + 1 // +1 to skip the first Entity entry - b.bits[chunkId][bitsetId+offset] |= 1 << (componentId % bits.UintSize) + pageId, bitsetId := b.getPageIDAndBitsetIndex(bitsId) + offset := int(componentId) >> uintShift + b.bitsetsBook[pageId][bitsetId+offset] |= 1 << (componentId % bits.UintSize) } // Unset clears the bit at the given index (sets it to 0). func (b *ComponentBitTable) Unset(entity Entity, componentId ComponentId) { bitsId, ok := b.lookup.Get(entity) assert.True(ok, "entity not found") - chunkId := bitsId / b.pageSize - bitsetId := bitsId % b.pageSize - offset := int(componentId>>uintShift) + 1 // +1 to skip the first Entity entry - b.bits[chunkId][bitsetId+offset] &= ^(1 << (componentId % bits.UintSize)) + + pageId, bitsetId := b.getPageIDAndBitsetIndex(bitsId) + offset := int(componentId) >> uintShift + b.bitsetsBook[pageId][bitsetId+offset] &= ^(1 << (componentId % bits.UintSize)) } func (b *ComponentBitTable) Test(entity Entity, componentId ComponentId) bool { @@ -104,10 +110,9 @@ func (b *ComponentBitTable) Test(entity Entity, componentId ComponentId) bool { if !ok { return false } - chunkId := bitsId / b.pageSize - bitsetId := bitsId % b.pageSize - offset := int(componentId>>uintShift) + 1 // +1 to skip the first Entity entry - return (b.bits[chunkId][bitsetId+offset] & (1 << (componentId % bits.UintSize))) != 0 + pageId, bitsetId := b.getPageIDAndBitsetIndex(bitsId) + offset := int(componentId) >> uintShift + return (b.bitsetsBook[pageId][bitsetId+offset] & (1 << (componentId % bits.UintSize))) != 0 } func (b *ComponentBitTable) AllSet(entity Entity, yield func(ComponentId) bool) { @@ -115,14 +120,13 @@ func (b *ComponentBitTable) AllSet(entity Entity, yield func(ComponentId) bool) if !ok { return } - chunkId := bitsId / b.pageSize - bitsetId := bitsId % b.pageSize - for i := 1; i < b.bitsetSize; i++ { // i := 1 Skip the first entry (Entity) - set := b.bits[chunkId][bitsetId+i] + pageId, bitsetId := b.getPageIDAndBitsetIndex(bitsId) + for i := 0; i < b.bitsetSize; i++ { + set := b.bitsetsBook[pageId][bitsetId+i] j := 0 for set != 0 { if set&1 == 1 { - if !yield(ComponentId((i-1)*bits.UintSize + j)) { + if !yield(ComponentId(i*bits.UintSize + j)) { return } } @@ -133,8 +137,17 @@ func (b *ComponentBitTable) AllSet(entity Entity, yield func(ComponentId) bool) } func (b *ComponentBitTable) extend() { - lastChunkId := b.length / b.pageSize - if lastChunkId == len(b.bits) && b.length%b.pageSize == 0 { - b.bits = append(b.bits, make([]uint, b.pageSize)) + lastChunkId, lastEntityId := b.getPageIDAndEntityIndex(b.length) + if lastChunkId == len(b.bitsetsBook) && lastEntityId == 0 { + b.bitsetsBook = append(b.bitsetsBook, make([]uint, b.pageSize)) + b.entitiesBook = append(b.entitiesBook, make([]Entity, pageSize)) } } + +func (b *ComponentBitTable) getPageIDAndBitsetIndex(index int) (int, int) { + return index >> pageSizeShift, (index & pageSizeMask) * b.bitsetSize +} + +func (b *ComponentBitTable) getPageIDAndEntityIndex(index int) (int, int) { + return index >> pageSizeShift, index & pageSizeMask +} diff --git a/pkg/ecs/component-bit-table_test.go b/pkg/ecs/component-bit-table_test.go index d374e85..a5eae59 100644 --- a/pkg/ecs/component-bit-table_test.go +++ b/pkg/ecs/component-bit-table_test.go @@ -37,12 +37,8 @@ func TestNewComponentBitTable(t *testing.T) { t.Run(tt.name, func(t *testing.T) { table := NewComponentBitTable(tt.maxComponentsLen) - //if table.bitsetSize != tt.expectedBitsetSize+1 { - // t.Errorf("Expected bitsetSize %d, got %d", tt.expectedBitsetSize, table.bitsetSize) - //} - - if cap(table.bits) != initialBookSize { - t.Errorf("Expected %d preallocated chunks, got %d", initialBookSize, cap(table.bits)) + if cap(table.bitsetsBook) != initialBookSize { + t.Errorf("Expected %d preallocated chunks, got %d", initialBookSize, cap(table.bitsetsBook)) } }) } @@ -62,12 +58,11 @@ func TestComponentBitTable_Set(t *testing.T) { t.Fatalf("Entity %d not found in lookup", entity1) } - chunkId := bitsId / pageSize - bitsetId := bitsId % pageSize - offset := int(ComponentId(5)/bits.UintSize) + 1 + pageId, bitsetId := table.getPageIDAndBitsetIndex(bitsId) + offset := int(ComponentId(5) >> uintShift) mask := uint(1 << (ComponentId(5) % bits.UintSize)) - if (table.bits[chunkId][bitsetId+offset] & mask) == 0 { + if (table.bitsetsBook[pageId][bitsetId+offset] & mask) == 0 { t.Errorf("Expected bit to be set for entity %d, component %d", entity1, 5) } @@ -94,20 +89,19 @@ func TestComponentBitTable_Unset(t *testing.T) { // Verify bit was unset bitsId, _ := table.lookup.Get(entity) - chunkId := bitsId / pageSize - bitsetId := bitsId % pageSize - offset := int(ComponentId(5) / bits.UintSize) + pageId, bitsetId := table.getPageIDAndBitsetIndex(bitsId) + offset := int(ComponentId(5) >> uintShift) mask := uint(1 << (ComponentId(5) % bits.UintSize)) - if (table.bits[chunkId][bitsetId+offset] & mask) != 0 { + if (table.bitsetsBook[pageId][bitsetId+offset] & mask) != 0 { t.Errorf("Expected bit to be unset for entity %d, component %d", entity, 5) } // Verify other bit is still set - offset = int(ComponentId(10)/bits.UintSize) + 1 + offset = int(ComponentId(10) >> uintShift) mask = uint(1 << (ComponentId(10) % bits.UintSize)) - if (table.bits[chunkId][bitsetId+offset] & mask) == 0 { + if (table.bitsetsBook[pageId][bitsetId+offset] & mask) == 0 { t.Errorf("Expected bit to still be set for entity %d, component %d", entity, 10) } } @@ -149,8 +143,8 @@ func TestComponentBitTable_Test(t *testing.T) { entity2 := Entity(43) table.Create(entity2) table.Set(entity2, ComponentId(0)) - table.Set(entity2, ComponentId(64)) // Last bit in first uint - table.Set(entity2, ComponentId(65)) // First bit in second uint + table.Set(entity2, ComponentId(64)) // First bit in second uint + table.Set(entity2, ComponentId(65)) // Second bit in second uint if !table.Test(entity2, ComponentId(0)) { t.Error("Test should return true for set component at uint boundary (0)") @@ -212,42 +206,32 @@ func TestComponentBitTable_AllSet(t *testing.T) { func TestComponentBitTable_extend(t *testing.T) { // Create a table with a small chunk size for testing - table := NewComponentBitTable(65) - // Set bits to force extension + table := NewComponentBitTable(bits.UintSize) + + // Create entities to fill the first chunk for i := 0; i < pageSize; i++ { e := Entity(i) table.Create(e) - table.Set(e, ComponentId(1)) + table.Set(e, ComponentId(i%bits.UintSize)) } - - if len(table.bits) > 1 { - t.Errorf("Expected table to be not extended, got %d chunks", len(table.bits)) + if len(table.bitsetsBook) != 1 { + t.Errorf("Expected table to extend, got %d chunks", len(table.bitsetsBook)) } - e := Entity(pageSize) - table.Create(e) - table.Set(e, ComponentId(1)) - - if len(table.bits) != 2 { - t.Errorf("Expected table to extend up to 2 chunks, got %d chunks", len(table.bits)) + // Create entity to force extension + table.Create(pageSize) + if len(table.bitsetsBook) <= 1 { + t.Errorf("Expected table to be extended, got %d chunks", len(table.bitsetsBook)) } - for i := pageSize + 1; i < pageSize*2; i++ { + // Create more entities + for i := pageSize + 1; i < (pageSize*2)+1; i++ { e := Entity(i) table.Create(e) table.Set(e, ComponentId(1)) } - - if len(table.bits) != 2 { - t.Errorf("Expected table to extend up to 2 chunks, got %d chunks", len(table.bits)) - } - - e = Entity(pageSize * 2) - table.Create(e) - table.Set(e, ComponentId(1)) - - if len(table.bits) != 3 { - t.Errorf("Expected table to extend up to 3 chunks, got %d chunks", len(table.bits)) + if len(table.bitsetsBook) <= 2 { + t.Errorf("Expected table to extend beyond 2 chunks, got %d chunks", len(table.bitsetsBook)) } } @@ -268,19 +252,19 @@ func TestComponentBitTable_EdgeCases(t *testing.T) { // Test setting across bit boundaries entity := Entity(42) table.Create(entity) - table.Set(entity, ComponentId(0)) // Last bit in first uint - table.Set(entity, ComponentId(64)) // Last bit in first uint - table.Set(entity, ComponentId(65)) // First bit in second uint + table.Set(entity, ComponentId(0)) // First bit in first uint + table.Set(entity, ComponentId(63)) // Last bit in first uint + table.Set(entity, ComponentId(64)) // First bit in second uint - // Verify both bits + // Verify all bits var found []ComponentId table.AllSet(entity, func(id ComponentId) bool { found = append(found, id) return true }) - if len(found) != 3 || !contains(found, ComponentId(0)) || !contains(found, ComponentId(64)) || !contains(found, ComponentId(65)) { - t.Errorf("Expected components 0, 64 and 65, got %v", found) + if len(found) != 3 || !contains(found, ComponentId(0)) || !contains(found, ComponentId(63)) || !contains(found, ComponentId(64)) { + t.Errorf("Expected components 0, 63 and 64, got %v", found) } } @@ -292,18 +276,18 @@ func TestComponentBitTable_Create(t *testing.T) { table.Create(entity) // Verify entity is in lookup - bitsId, ok := table.lookup.Get(entity) + _, ok := table.lookup.Get(entity) if !ok { t.Fatalf("Entity %d not found in lookup after Create", entity) } - // Check that entity ID is stored in the first position of its bitset - chunkId := bitsId >> pageSizeShift - bitsetId := bitsId % pageSize - storedEntityId := Entity(table.bits[chunkId][bitsetId]) + // Check that entity ID is stored in entities book + bitsId, _ := table.lookup.Get(entity) + pageId, entityId := table.getPageIDAndEntityIndex(bitsId) - if storedEntityId != entity { - t.Errorf("Expected entity ID %d stored in bits, got %d", entity, storedEntityId) + if table.entitiesBook[pageId][entityId] != entity { + t.Errorf("Expected entity ID %d stored in entities book, got %d", + entity, table.entitiesBook[pageId][entityId]) } } @@ -374,21 +358,16 @@ func TestComponentBitTable_DeleteWithSwap(t *testing.T) { t.Fatalf("Entity %d not found after swap", entity2) } - // Ensure the entity ID is correctly stored in the swapped position - chunkId := newEntity2BitsId >> pageSizeShift - bitsetId := newEntity2BitsId % pageSize - storedEntityId := Entity(table.bits[chunkId][bitsetId]) - if storedEntityId != entity2 { - t.Errorf("Entity ID %d not correctly stored in bits after swap, got %d", entity2, storedEntityId) - } - // entity2 should have been moved to entity1's position if newEntity2BitsId == entity2BitsId { t.Errorf("Entity %d position should have changed after swap", entity2) } - if table.length != table.bitsetSize { - t.Errorf("Expected table length to be %d, got %d", table.bitsetSize, table.length) + // Ensure entity is stored in the entity book + pageId, entityId := table.getPageIDAndEntityIndex(newEntity2BitsId) + if table.entitiesBook[pageId][entityId] != entity2 { + t.Errorf("Entity ID %d not correctly stored after swap, got %d", + entity2, table.entitiesBook[pageId][entityId]) } } @@ -436,10 +415,9 @@ func TestComponentBitTable_MultipleOperations(t *testing.T) { } } - // The length should reflect the 5 entities (1, 3, 5, 6, 7) - expectedLen := 5 * table.bitsetSize - if table.length != expectedLen { - t.Errorf("Expected length %d, got %d", expectedLen, table.length) + // The length should be 5 (entities 1, 3, 5, 6, 7) + if table.length != 5 { + t.Errorf("Expected length 5, got %d", table.length) } } @@ -452,39 +430,3 @@ func contains(s []ComponentId, id ComponentId) bool { } return false } - -func TestNextPowerOf2(t *testing.T) { - tests := []struct { - input int - expected int - }{ - {0, 0}, // Edge case: 0 stays 0 - {1, 1}, // Edge case: 1 stays 1 - {2, 2}, // Already power of 2 - {3, 4}, // Round up to 4 - {4, 4}, // Already power of 2 - {5, 8}, // Round up to 8 - {7, 8}, // Round up to 8 - {8, 8}, // Already power of 2 - {9, 16}, // Round up to 16 - {15, 16}, // Round up to 16 - {16, 16}, // Already power of 2 - {17, 32}, // Round up to 32 - {31, 32}, // Round up to 32 - {32, 32}, // Already power of 2 - {33, 64}, // Round up to 64 - {63, 64}, // Round up to 64 - {64, 64}, // Already power of 2 - {1023, 1024}, // Round up to 1024 - {1024, 1024}, // Already power of 2 - } - - for _, tt := range tests { - t.Run("", func(t *testing.T) { - got := nextPowerOf2(tt.input) - if got != tt.expected { - t.Errorf("nextPowerOf2(%d) = %d; expected %d", tt.input, got, tt.expected) - } - }) - } -} diff --git a/pkg/ecs/component-table_bench_test.go b/pkg/ecs/component-table_bench_test.go index 098b15a..3eff852 100644 --- a/pkg/ecs/component-table_bench_test.go +++ b/pkg/ecs/component-table_bench_test.go @@ -2,7 +2,7 @@ package ecs import "testing" -const testEntitiesLen = 100_000 +const testEntitiesLen Entity = 100_000 const maxComponentsLen = 1024 func BenchmarkComponentBitTable_SetAndTest(b *testing.B) { @@ -10,14 +10,43 @@ func BenchmarkComponentBitTable_SetAndTest(b *testing.B) { table := NewComponentBitTable(maxComponentsLen) b.ReportAllocs() for b.Loop() { - for i := 0; i < testEntitiesLen; i++ { - entity := Entity(i) + for i := Entity(0); i < testEntitiesLen; i++ { comp := ComponentId(i % maxComponentsLen) - table.Create(entity) - table.Set(entity, comp) - if !table.Test(entity, comp) { - b.Fatalf("BitTable: expected entity %d to have component %d set", entity, comp) + table.Create(i) + table.Set(i, comp) + if !table.Test(i, comp) { + b.Fatalf("BitTable: expected entity %d to have component %d set", i, comp) + } + } + b.StopTimer() + for i := Entity(0); i < testEntitiesLen; i++ { + table.Delete(i) + } + b.StartTimer() + } +} + +func BenchmarkComponentBitTable_SetTestDelete(b *testing.B) { + table := NewComponentBitTable(maxComponentsLen) + //for i := Entity(1); i < testEntitiesLen; i++ { + // table.Create(Entity(i)) + //} + // using a fixed maximum components length + b.ReportAllocs() + for b.Loop() { + b.StopTimer() + for i := Entity(0); i < testEntitiesLen; i++ { + table.Create(i) + } + b.StartTimer() + + for i := Entity(0); i < testEntitiesLen; i++ { + comp := ComponentId(i % maxComponentsLen) + table.Set(i, comp) + if !table.Test(i, comp) { + b.Fatalf("BitTable: expected entity %d to have component %d set", i, comp) } + table.Delete(i) } } } @@ -26,7 +55,7 @@ func BenchmarkComponentBitTable_Delete(b *testing.B) { // using a fixed maximum components length table := NewComponentBitTable(maxComponentsLen) // Setup - create and set components for all entities - for i := 0; i < testEntitiesLen; i++ { + for i := Entity(0); i < testEntitiesLen; i++ { entity := Entity(i) table.Create(entity) table.Set(entity, ComponentId(i%maxComponentsLen)) @@ -36,13 +65,13 @@ func BenchmarkComponentBitTable_Delete(b *testing.B) { b.ResetTimer() for b.Loop() { // Delete all entities - for i := 0; i < testEntitiesLen; i++ { + for i := Entity(0); i < testEntitiesLen; i++ { entity := Entity(i) table.Delete(entity) } // Recreate for next iteration - for i := 0; i < testEntitiesLen; i++ { + for i := Entity(0); i < testEntitiesLen; i++ { entity := Entity(i) table.Create(entity) table.Set(entity, ComponentId(i%maxComponentsLen)) @@ -53,7 +82,7 @@ func BenchmarkComponentBitTable_Delete(b *testing.B) { func BenchmarkComponentBitTable_AllSet(b *testing.B) { table := NewComponentBitTable(maxComponentsLen) // Prepare entities with multiple components each - for i := 0; i < testEntitiesLen; i++ { + for i := Entity(0); i < testEntitiesLen; i++ { entity := Entity(i) table.Create(entity) // Give each entity 100 components @@ -65,7 +94,7 @@ func BenchmarkComponentBitTable_AllSet(b *testing.B) { b.ReportAllocs() b.ResetTimer() for b.Loop() { - for i := 0; i < testEntitiesLen; i++ { + for i := Entity(0); i < testEntitiesLen; i++ { entity := Entity(i) count := 0 table.AllSet(entity, func(id ComponentId) bool { diff --git a/pkg/ecs/paged-map.go b/pkg/ecs/paged-map.go index 60f51f0..dc19047 100644 --- a/pkg/ecs/paged-map.go +++ b/pkg/ecs/paged-map.go @@ -74,6 +74,18 @@ func (m *PagedMap[K, V]) Delete(key K) { } } +func (m *PagedMap[K, V]) Has(key K) bool { + pageID, index := m.getPageIDAndIndex(key) + if pageID >= len(m.book) { + return false + } + page := &m.book[pageID] + if page.data == nil { + return false + } + return page.data[index].ok +} + func (m *PagedMap[K, V]) getPageIDAndIndex(key K) (pageID int, index int) { return int(uint64(key) >> pageSizeShift), int(uint64(key) % pageSize) }