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.