Performance Considerations When Using Fabrica-Util’s Bitmap for Game State Tracking

Fabrica-util's Bitmap trades raw speed for thread-safety and ease of use, making it ideal for moderate-contention game state tracking but requiring careful architectural decisions under high concurrency.

The bitmap package in go-pantheon/fabrica-util provides a compact, thread-safe data structure for tracking boolean game states such as occupied tiles, active entities, or triggered flags. While its API is straightforward, understanding the implementation details in bitmap/bitmap.go is essential to avoid bottlenecks in performance-critical game loops. Below is a comprehensive analysis of the architectural trade-offs and optimization strategies.

Memory Layout and Footprint

The Bitmap stores bits in a contiguous slice of bytes ([]byte), allocating exactly ceil(size/8) bytes where size is the bit count specified in NewBitmap. For a typical 10,000-cell game grid, the memory footprint is approximately 1.25 KB—negligible compared to entity or texture data.

The struct caches the original size in an int64 field to optimize bounds checking:

type Bitmap struct {
    mutex sync.Mutex
    bits  []byte
    size  int64
}

This design eliminates the need to compute slice length during operations, but the byte-slice indirection means every access requires pointer arithmetic.

Thread-Safety Overhead and Contention

Every public method acquires a single coarse-grained mutex (b.mutex.Lock()) for the entire operation duration. The lock protects the byte slice across Set, Clear, IsSet, MSet, and Count operations.

In bitmap/bitmap.go, the Set method demonstrates this pattern:

func (b *Bitmap) Set(index int64) {
    b.validateIndex(index)
    b.mutex.Lock()
    defer b.mutex.Unlock()
    b.bits[index/8] |= 1 << (index % 8)
}

This serialization becomes a bottleneck under heavy contention—such as thousands of goroutines updating a shared world map each frame. Notably, read operations (IsSet) also acquire the lock, eliminating any lock-free fast path for read-heavy workloads.

Mitigation Strategies

  • Batch updates: Use MSet to modify multiple bits under a single lock acquisition rather than calling Set repeatedly.
  • Sharding: Partition large game worlds into multiple independent Bitmap instances (e.g., one per map region) so goroutines operate on different mutexes.
  • Lock-free alternatives: For read-heavy scenarios, consider implementing a custom bitmap using sync/atomic word-level operations, as the current package does not provide lock-free variants.

Bounds Checking Costs

All methods invoke validateIndex(index), which panics on out-of-range access. While the panic itself is cheap when indexes are valid, the branch and function call overhead occurs on every operation. In tight loops processing entity updates, pre-validate indexes using lookup tables or spatial hashing to avoid redundant checks.

Bulk Operations with MSet

The MSet method optimizes batch updates by validating indexes outside the critical section before acquiring the lock:

func (b *Bitmap) MSet(indexes []int64) {
    // Validation occurs without holding the lock
    for _, index := range indexes { 
        b.validateIndex(index) 
    }
    
    b.mutex.Lock()
    defer b.mutex.Unlock()
    for _, index := range indexes {
        b.bits[index/8] |= 1 << (index % 8)
    }
}

Performance tip: When processing entity death lists or batch terrain modifications, prefer MSet over multiple Set calls to minimize lock overhead.

Counting Set Bits Performance

The Count method performs a linear scan of the entire byte slice using math/bits.OnesCount8:

func (b *Bitmap) Count() int64 {
    b.mutex.Lock()
    defer b.mutex.Unlock()
    var count int64
    for _, byteVal := range b.bits {
        count += int64(bits.OnesCount8(byteVal))
    }
    return count
}

Complexity is O(N) in the number of bytes. For large bitmaps (e.g., 10⁶+ bits), calling Count every frame becomes expensive. Optimization strategies include caching the count after modifications, using dirty flags to skip unchanged bitmaps, or counting only every N frames for approximate metrics.

CPU Cache and Access Patterns

The contiguous byte slice provides excellent cache locality for sequential scans (as in Count), but random accesses (Set/IsSet) may cause cache misses if index distribution is sparse. Sharding by spatial locality—keeping hot regions in separate bitmap instances—improves cache line utilization and reduces false sharing between goroutines.

Error Handling and Panics

The API uses panics for misuse (negative sizes, out-of-range indexes) rather than returning errors. While this simplifies the API, uncontrolled panics can crash game servers when processing untrusted client input. Wrap high-level entry points in recover() blocks when indexes cannot be guaranteed valid, though this adds minor overhead to the call stack.

Summary

  • Memory efficiency: Bitmap uses size/8 bytes, making it suitable for dense boolean state tracking.
  • Lock granularity: A single mutex serializes all operations; use MSet for batches or shard bitmaps for high concurrency.
  • Validation overhead: Bounds checking occurs on every access; pre-validate indexes in performance-critical paths.
  • Counting cost: Count scans the entire structure linearly; cache results or count intermittently for large bitmaps.
  • Cache optimization: Contiguous storage helps sequential access but shard by region to improve random access locality.
  • Stability: Panics on invalid input require defensive recover() wrappers in production game servers.

Frequently Asked Questions

Is fabrica-util's Bitmap lock-free?

No, the implementation uses a sync.Mutex to protect all operations. Every read and write acquires the same lock, making it unsuitable for high-contention scenarios without sharding or batching strategies.

How much memory does a 10,000 cell bitmap use?

Approximately 1.25 KB (10,000 bits ÷ 8 bits per byte). The overhead of the struct itself is negligible, consisting of a mutex, slice header, and size field.

When should I use MSet vs individual Set calls?

Use MSet when updating multiple bits known in advance (e.g., clearing a list of destroyed entity IDs). It validates all indexes outside the critical section and acquires the lock only once, significantly reducing contention compared to sequential Set calls.

Can I use Bitmap for real-time game state without performance issues?

Yes, for moderate concurrency (dozens of goroutines) and small-to-medium state sizes (under 100,000 bits). For massively concurrent simulations or real-time strategy games with thousands of simultaneous updates, implement sharding or consider lock-free atomic bitmaps based on the bit-twiddling patterns in bitmap/bitmap.go.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →