Allocation Strategies

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:

  1. Performance: General allocators are slower than specialized ones
  2. Fragmentation: Long-running programs can fragment memory
  3. Unpredictability: Allocation time can vary wildly
  4. 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 KnownSize 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:

  1. Permanent: Lives for the entire program (level data, asset registries)
  2. Transient: Lives for a known period (per-frame data, per-request data)
  3. Scratch: Very short-lived (temporary calculations, string building)

Choosing an Allocator

AllocatorBest ForAllocFreeIndividual Free?
ArenaBulk allocations with shared lifetimeO(1)O(1) resetNo
StackScoped allocations (RAII pattern)O(1)O(1)Yes (LIFO only)
PoolMany same-sized objectsO(1)O(1)Yes (any order)
Free ListVariable sizes, any orderO(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...........]

                              offset

All allocations are contiguous. Reset moves offset back to start.

Stack Memory Layout

[hdr][data][hdr][data][hdr][data][FREE SPACE...]

                              offset

Each 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

  1. Pre-allocate pools: Create pools at startup, not during gameplay
  2. Size arenas generously: Running out mid-frame is worse than wasting some memory
  3. Profile before optimizing: Measure where allocations actually hurt
  4. Prefer arena over free list: When in doubt, batch allocations by lifetime
  5. Watch for pool exhaustion: Have a fallback or warning when pools run low

Further Reading

These allocator implementations are based on Ginger Bill’s Memory Allocation Strategies series, an excellent deep dive into allocation techniques.