PVG
26. Tracking Allocator — Program Video Games

26. Tracking Allocator

Memory Management and Tracking Allocators

Every game developer has encountered mysterious memory leaks - memory usage steadily increasing over time, crashes on level transitions, or systems consuming more resources than expected.

Tracking allocators transform invisible memory problems into visible, debuggable information.

The Fractal Code Cycle Applied to Memory Tracking

Recall our fundamental pattern:

Input → Processing → Output

For memory tracking:

  • Input: Allocation and deallocation requests from all systems

  • Processing: Record allocation locations, sizes, and lifetimes

  • Output: Reports on leaks, invalid frees, and allocation patterns

What Are Tracking Allocators Good For?

Leak Detection

  • Identify memory that's allocated but never freed

  • Track which systems are responsible for leaks

  • Distinguish between acceptable "permanent" allocations and actual leaks

Invalid Free Detection

  • Catch attempts to free memory that wasn't allocated

  • Detect double-frees before they cause crashes

  • Verify allocator assumptions

Memory Usage Profiling

  • Understand which systems consume the most memory

  • Track memory growth over time

  • Optimise allocation patterns

The Memory Leak We Fixed

Our game had a subtle but serious memory leak: every time we changed scenes, memory usage increased.

The culprit was in the trigger system. Each trigger contains a dynamic array:

Trigger :: struct {
    pos: Vec3,
    size: Vec3,
    rotation: Vec3,
    entities_inside: [dynamic]Entity_Handle,  // The problem
    on_enter: Event,
    on_exit: Event,
}

The Problem

Dynamic arrays in Odin have a special property: if you don't explicitly initialise them, they'll be initialised on the first append using context.allocator.

Our scene loading code was simply copying triggers from the scene definition:

// Old code - the bug
append(&ss.triggers, ..def.triggers[:])

When trigger_update ran and checked for entities inside triggers:

if !slice.contains(trigger.entities_inside[:], entity.handle) {
    append(&trigger.entities_inside, entity.handle)  // Initialises with context.allocator
}

At this point, context.allocator was the permanent allocator, not the scene allocator. When we changed scenes, the scene's memory arena was reset, but these dynamic arrays remained allocated in permanent storage - leaking memory with every scene change.

The Solution

Explicitly initialise dynamic arrays with the correct allocator:

// New code - the fix
for def_trigger in def.triggers {
    trigger := def_trigger
    trigger.entities_inside = make(type_of(trigger.entities_inside))  // Use scene allocator
    append(&ss.triggers, trigger)
}

Now the dynamic arrays are created with the scene allocator, and they're properly freed when the scene arena is reset.

Setting Up the Tracking Allocator

In main.odin, we wrap our permanent allocator with a tracking allocator:

when ODIN_DEBUG {
    context.logger = log.create_console_logger()
    
    permanent_arena: mem.Dynamic_Arena
    mem.dynamic_arena_init(&permanent_arena, alignment = runtime.MAP_CACHE_LINE_SIZE)
    context.allocator = mem.dynamic_arena_allocator(&permanent_arena)
    
    track: mem.Tracking_Allocator
    mem.tracking_allocator_init(&track, context.allocator)
    context.allocator = mem.tracking_allocator(&track)
    
    defer {
        if len(track.allocation_map) > 0 {
            fmt.eprintf("=== %v allocations not freed: ===\n", len(track.allocation_map))
            for _, v in track.allocation_map {
                fmt.eprintf("- %v bytes @ %v\n", v.size, v.location)
            }
        }
        if len(track.bad_free_array) > 0 {
            fmt.eprintf("=== %v incorrect frees: ===\n", len(track.bad_free_array))
            for entry in track.bad_free_array {
                fmt.eprintf("- %p @ %v\n", entry.memory, entry.location)
            }
        }
        mem.tracking_allocator_destroy(&track)
    }
}

This only runs in debug builds, avoiding any performance overhead in release builds.

Understanding the Output

When the program exits, the tracking allocator reports:

  • Allocations not freed: Memory that was allocated but never freed, including the source location

  • Incorrect frees: Attempts to free invalid memory

Not all "leaks" are problems. Permanent allocations (assets, global state) that last the entire program lifetime are acceptable. The key is distinguishing these from genuine leaks that grow over time.

Scene Definition Parsing Changes

We also improved scene_parse to avoid allocating temporary data in the permanent allocator:

scene_parse :: proc(data: []u8, allocator := context.allocator) -> (def: Scene_Definition) {
    temp_def: Scene_Definition
    
    // Parse into temporary allocator
    if err := json.unmarshal(data, &temp_def, .SJSON, context.temp_allocator); err != nil {
        // Error handling...
    }
    
    // Use the passed in allocator for permanent data
    context.allocator = allocator
    
    def.name = strings.clone(temp_def.name)
    
    def.objects = make(type_of(def.objects), len(temp_def.objects))
    def.static_geometry = make(type_of(def.static_geometry), len(temp_def.static_geometry))
    def.walkable_surfaces = make(type_of(def.walkable_surfaces), len(temp_def.walkable_surfaces))
    
    copy(def.objects, temp_def.objects)
    copy(def.static_geometry, temp_def.static_geometry)
    copy(def.walkable_surfaces, temp_def.walkable_surfaces)
    
    // Custom trigger parsing...
}

This separates temporary parsing data from permanent scene definitions, giving us better control over memory lifetime.

The Big Idea

Memory management isn't just about preventing crashes - it's about understanding ownership and lifetime.

Dynamic arrays, strings, and other growable structures need explicit allocator choices. The default behaviour (using context.allocator) is convenient but can lead to subtle bugs when allocators change during program execution.

Tracking allocators make these invisible problems visible, transforming frustrating memory leaks into straightforward fixes with clear source locations.