Memory allocation is one of the most critical decisions in game development and real-time software. The wrong allocation strategy can cause stutters, fragmentation, and unpredictable performance. Conjure provides a collection of allocators designed for different scenarios, letting you choose the right tool for each job.
Why Custom Allocators?
The default system allocator (memory.alloc / memory.free) is general-purpose - it handles any size, any lifetime, any pattern. But this flexibility comes at a cost:
- Performance: General allocators are slower than specialized ones
- Fragmentation: Long-running programs can fragment memory
- Unpredictability: Allocation time can vary wildly
- Cache misses: Related objects may be scattered in memory
Custom allocators solve these problems by trading flexibility for performance. When you know something about your allocation patterns, you can choose an allocator that exploits that knowledge.
The Mental Model
Before diving into allocators, it’s helpful to think about memory in terms of two questions:
| Size Known | Size Unknown | |
|---|---|---|
| Lifetime Known | ~95% of allocations | ~4% of allocations |
| Lifetime Unknown | ~1% of allocations | <1% of allocations |
The vast majority of allocations in games have known size and known lifetime. This is where custom allocators shine.
Lifetime Categories
Think of allocations in three categories:
- Permanent: Lives for the entire program (level data, asset registries)
- Transient: Lives for a known period (per-frame data, per-request data)
- Scratch: Very short-lived (temporary calculations, string building)
Choosing an Allocator
| Allocator | Best For | Alloc | Free | Individual Free? |
|---|---|---|---|---|
| Arena | Bulk allocations with shared lifetime | O(1) | O(1) reset | No |
| Stack | Scoped allocations (RAII pattern) | O(1) | O(1) | Yes (LIFO only) |
| Pool | Many same-sized objects | O(1) | O(1) | Yes (any order) |
| Free List | Variable sizes, any order | O(n) | O(n) | Yes (any order) |
Decision Tree
Do all allocations share the same lifetime?
├─ Yes → Can you free them all at once?
│ ├─ Yes → Use Arena
│ └─ No, need LIFO frees → Use Stack
└─ No → Are all allocations the same size?
├─ Yes → Use Pool
└─ No → Use Free List (or consider redesigning)Quick Start Examples
Arena: Per-Frame Allocations
The arena allocator is perfect for data that only needs to exist for one frame:
import "conjure/allocators/arena" as arena
// Create a 1MB arena for frame allocations
var frameArena = arena.new(1024 * 1024)
func gameLoop() {
while running {
// Allocate freely during the frame
var particles = arena.alloc(&frameArena, particleCount * 64)
var renderCommands = arena.alloc(&frameArena, commandCount * 32)
updateGame()
render()
// Reset at frame end - all allocations freed instantly
arena.reset(&frameArena)
}
}Pool: Entity Management
The pool allocator is ideal for game entities where you have many objects of the same type:
import "conjure/allocators/pool" as pool
struct Enemy {
active bool
x f32
y f32
health i32
}
// Create a pool for up to 1000 enemies
var enemyPool = pool.newForType[Enemy](1000)
func spawnEnemy(x f32, y f32) *Enemy {
var e = *Enemy(pool.alloc(&enemyPool))
if e == null {
return null // Pool exhausted
}
e.active = true
e.x = x
e.y = y
e.health = 100
return e
}
func killEnemy(e *Enemy) {
e.active = false
pool.free(&enemyPool, e) // Return to pool instantly
}Stack: Scoped Temporary Data
The stack allocator is useful when you need to free in reverse order:
import "conjure/allocators/stack" as stack
var tempStack = stack.new(256 * 1024) // 256KB
func processLevel(level *Level) {
// Allocate temporary data for this function
var tempBuffer = stack.alloc(&tempStack, 4096)
defer stack.free(&tempStack, tempBuffer) // Freed when function returns
// Nested call also uses the stack
processEntities(level.entities)
// tempBuffer is automatically freed by defer
}Free List: When You Need Flexibility
Use the free list when you genuinely don’t know sizes or lifetimes:
import "conjure/allocators/freelist" as freelist
var generalAlloc = freelist.new(4 * 1024 * 1024) // 4MB
func loadAsset(path string) rawptr {
var size = getAssetSize(path)
var data = freelist.alloc(&generalAlloc, size)
readAssetInto(path, data)
return data
}
func unloadAsset(data rawptr) {
freelist.free(&generalAlloc, data)
}Combining Allocators
Real games often use multiple allocators together:
import "conjure/allocators/arena" as arena
import "conjure/allocators/pool" as pool
// Permanent: Lives for entire game session
var levelArena = arena.new(64 * 1024 * 1024) // 64MB for level data
// Transient: Reset every frame
var frameArena = arena.new(1024 * 1024) // 1MB for per-frame data
// Object pools: For frequently spawned/destroyed entities
var bulletPool = pool.newForType[Bullet](10000)
var particlePool = pool.newForType[Particle](50000)
func loadLevel(levelId i32) {
arena.reset(&levelArena) // Clear previous level
// Load level geometry, textures, etc. into levelArena
var geometry = arena.alloc(&levelArena, geometrySize)
var lightData = arena.alloc(&levelArena, lightDataSize)
// ... more level data
}
func gameFrame() {
// Frame-local allocations
var visibleEntities = arena.alloc(&frameArena, entityCount * 8)
var renderBatch = arena.alloc(&frameArena, batchSize)
// ... game logic using pools for entities ...
// End of frame
arena.reset(&frameArena)
}Memory Layout Visualization
Understanding how each allocator organizes memory helps you choose correctly:
Arena Memory Layout
[allocated][allocated][allocated][FREE SPACE...........]
↑
offsetAll allocations are contiguous. Reset moves offset back to start.
Stack Memory Layout
[hdr][data][hdr][data][hdr][data][FREE SPACE...]
↑
offsetEach allocation has a header to track the previous offset for LIFO frees.
Pool Memory Layout
[block][block][block][block][block][block]
↓ ↑ ↓ ↑
used └──────┴── free list ──┘All blocks are the same size. Free list links unused blocks.
Free List Memory Layout
[alloc][FREE][alloc][FREE..........][alloc]
↓ ↓
└─ free list ─┘Variable-sized blocks. Free list tracks available regions.
Common Patterns
Temporary Arena with Savepoint
For nested temporary allocations that may need to be partially rolled back:
var scratch = arena.new(64 * 1024)
func buildString() string {
var save = arena.savepoint(&scratch)
// Build a temporary string
var buffer = arena.alloc(&scratch, 1024)
// ... append to buffer ...
// Copy result to permanent storage
var result = copyString(buffer)
// Discard temporary allocations
arena.restore(save)
return result
}Double-Buffered Arenas
For async systems where you need previous frame’s data:
var arenas [2]arena.Arena
var currentArenaIndex = 0
func init() {
arenas[0] = arena.new(1024 * 1024)
arenas[1] = arena.new(1024 * 1024)
}
func currentArena() *arena.Arena {
return &arenas[currentArenaIndex]
}
func previousArena() *arena.Arena {
return &arenas[1 - currentArenaIndex]
}
func swapArenas() {
currentArenaIndex = 1 - currentArenaIndex
arena.reset(currentArena())
}Performance Tips
- Pre-allocate pools: Create pools at startup, not during gameplay
- Size arenas generously: Running out mid-frame is worse than wasting some memory
- Profile before optimizing: Measure where allocations actually hurt
- Prefer arena over free list: When in doubt, batch allocations by lifetime
- Watch for pool exhaustion: Have a fallback or warning when pools run low
Further Reading
- Arena Allocator Reference - Complete API documentation
- Stack Allocator Reference - Complete API documentation
- Pool Allocator Reference - Complete API documentation
- Free List Allocator Reference - Complete API documentation
These allocator implementations are based on Ginger Bill’s Memory Allocation Strategies series, an excellent deep dive into allocation techniques.