Is Your Game Choking on GC Allocations? A Guide to Zero-Allocation Data Structures
Introduction
You’ve optimized your shaders, painstakingly batched draw calls, and even dipped your toes into Unity’s Burst Compiler and C# Job System. Yet, your game still experiences inexplicable hitches and stutters, particularly during intense moments. The silent assassin in many Unity games isn't the GPU struggling with pixels, but the Garbage Collector (GC) reclaiming memory from incessant, hidden allocations. Specifically, insidious GC.Alloc calls, often for temporary class-based data structures, can disrupt your carefully crafted frame budget, leading to frustrating performance spikes.
This tutorial will illuminate this common pitfall and provide a robust architectural solution: embracing struct types combined with Span<T> for transient data. By understanding and applying these principles, you can virtually eliminate many GC.Alloc calls from your high-frequency game logic, leading to buttery-smooth frame rates and a fundamentally cleaner, faster codebase.
The Culprit: Unnecessary Class Allocations
The core problem stems from the fundamental difference between class and struct in C#. A class is a reference type, always allocated on the managed heap. When you create an instance of a class using new, memory is allocated on the heap, and a reference to that memory is returned. When that instance is no longer reachable, it becomes garbage, waiting for the GC to collect it – an operation that can cause noticeable stalls.
Consider a common scenario: processing temporary query results or calculating scores for nearby entities every frame. If you use a class to represent these temporary data points and store them in a List<T>, you're incurring a double hit:
-
List<T>itself is a class, though often its internal array can be reused. - Each
Tinstance (ifTis aclass) added to the list is a separate heap allocation.
Let's illustrate with a typical (and problematic) pattern for calculating and processing scores for nearby interactable objects:
// PROBLEM: Class-based data structure
public class InteractableScore
{
public GameObject Target;
public float Score;
// Constructor or properties often implicitly lead to heap allocations
public InteractableScore(GameObject target, float score)
{
Target = target;
Score = score;
}
}
public class ScoreCalculator : MonoBehaviour
{
private List<InteractableScore> _currentScores = new List<InteractableScore>();
void Update()
{
_currentScores.Clear(); // Clears references, but allocated objects are now garbage
// Imagine GetNearbyInteractables() yields many results
foreach (var interactable in GetNearbyInteractables())
{
float score = CalculateScore(interactable);
_currentScores.Add(new InteractableScore(interactable, score)); // !!! EACH 'new' is a GC.Alloc !!!
}
ProcessScores(_currentScores);
}
private IEnumerable<GameObject> GetNearbyInteractables() { /* ... implementation ... */ return new List<GameObject>(); }
private float CalculateScore(GameObject obj) { /* ... implementation ... */ return 0f; }
private void ProcessScores(List<InteractableScore> scores) { /* ... implementation ... */ }
}
In this Update loop, if GetNearbyInteractables() returns 10 objects, you've just made 10 new InteractableScore() heap allocations every single frame. Over time, this rapid allocation and deallocation will trigger frequent GC cycles, causing noticeable stutter.
The Solution: Embrace Structs and Span
The solution lies in leveraging value types (struct) and efficient memory slicing (Span<T>). A struct is a value type, meaning its instances are typically allocated directly on the stack (for local variables) or inline within its containing type (if part of an array or another object). When a struct is copied, its entire data is copied, not just a reference. This means no heap allocation for the struct itself, and thus, no GC pressure from its creation.
For temporary collections of struct data, we combine struct with a pre-allocated array and Span<T>. An array, even of structs, is a reference type allocated on the heap once. By pre-allocating an array large enough to hold your maximum expected temporary data, you perform a single heap allocation at startup. Then, you fill this array with struct instances. Since structs are value types, assigning them to array elements does not create new heap allocations for the struct data itself. Finally, Span<T> provides a lightweight, zero-allocation "view" into a portion of this array, allowing you to process only the relevant data without copying or allocating new collections.
Here's the refactored, performance-optimized approach:
// SOLUTION: Struct-based data structure
public struct InteractableScoreData // It's a struct!
{
public GameObject Target;
public float Score;
}
public class ZeroAllocScoreCalculator : MonoBehaviour
{
// Pre-allocate the buffer ONCE at startup. This is a single heap allocation.
// Choose a size appropriate for your maximum expected interactables.
private InteractableScoreData[] _scoreBuffer = new InteractableScoreData[50];
void Update()
{
int currentScoreCount = 0; // Tracks how many valid scores we've added to the buffer
foreach (var interactable in GetNearbyInteractables())
{
if (currentScoreCount < _scoreBuffer.Length) // Prevent out-of-bounds
{
float score = CalculateScore(interactable);
// Assigning a struct to an array element does NOT allocate on the heap!
_scoreBuffer[currentScoreCount] = new InteractableScoreData
{
Target = interactable,
Score = score
};
currentScoreCount++;
}
}
// Use Span<T> to create a zero-allocation view of the valid data in our buffer
Span<InteractableScoreData> currentScores = _scoreBuffer.AsSpan(0, currentScoreCount);
// Process the scores using the Span
ProcessScores(currentScores);
}
private IEnumerable<GameObject> GetNearbyInteractables() { /* ... implementation ... */ return new List<GameObject>(); }
private float CalculateScore(GameObject obj) { /* ... implementation ... */ return 0f; }
// Updated to accept Span<T>
private void ProcessScores(Span<InteractableScoreData> scores)
{
foreach (ref readonly var scoreData in scores) // Using 'ref readonly' for even more efficiency
{
// Process scoreData directly from the buffer without copying
Debug.Log($"Target: {scoreData.Target.name}, Score: {scoreData.Score}");
}
}
}
In this refined code, the _scoreBuffer is allocated only once when the MonoBehaviour starts. In Update, no new heap memory is allocated for InteractableScoreData instances. Instead, the struct values are directly assigned into pre-existing slots in the array. Span<T> then provides an incredibly efficient way to iterate over the valid portion of this array without any additional memory allocations.
This approach not only prevents GC spikes but also significantly improves cache locality. Because struct instances in an array are laid out contiguously in memory, the CPU can process them much faster, leveraging its cache more effectively – a critical win for data-oriented design.
Conclusion
Eliminating insidious GC.Alloc calls is paramount for achieving consistent, high frame rates in Unity. By consciously choosing struct over class for transient, high-frequency data and combining this with pre-allocated buffers viewed through Span<T>, you can drastically reduce GC pressure. This isn't just about micro-optimizations; it's about adopting a fundamentally cleaner and faster architectural pattern for your game logic. Start treating memory as a finite resource, and your game's performance will thank you.
Top comments (0)