DEV Community

Cover image for Your Game's GC Spikes? Blame Yourself.
Chathura Rathnayaka
Chathura Rathnayaka

Posted on

Your Game's GC Spikes? Blame Yourself.

Mastering Allocation-Free Data Processing with Span and Memory in Unity

Introduction

Tired of unpredictable stuttering and frustrating garbage collection (GC) spikes in your high-performance Unity game? Those erratic frame drops often stem from subtle, repeated memory allocations, even within performance-critical systems. While Unity’s Job System and Burst Compiler aim for allocation-free execution, common C# patterns can inadvertently introduce GC pressure. This tutorial will guide you through mastering Span<T> and Memory<T>, two powerful C# features indispensable for achieving truly allocation-free, Burst-compatible code, particularly when working with NativeArray<T> and unmanaged memory within Unity's Data-Oriented Technology Stack (DOTS).

Code Layout/Walkthrough: Eliminating GC Spikes with Zero-Allocation Views

A frequent culprit for GC spikes is the unnecessary creation of temporary arrays or sub-arrays. Functions that need to process a segment of a larger data block often resort to methods like Array.Copy or LINQ extensions like Skip().Take().ToArray(). While convenient, each of these operations allocates new memory on the heap. In a tight game loop or within high-frequency jobs, repeated allocations quickly accumulate, triggering the garbage collector and causing noticeable frame rate instability.

Enter Span<T> and Memory<T>. These types provide a modern, allocation-free way to reference existing contiguous blocks of memory, whether managed (like byte[]) or unmanaged (like NativeArray<T> elements). They allow you to define a "view" or "slice" into a larger data structure without copying any data. Span<T> is a ref struct, meaning it can only live on the stack and cannot be boxed or used in async methods, offering extreme performance for synchronous operations. Memory<T> is a managed struct that wraps a Span<T>, allowing it to be used in scenarios where Span<T>'s stack-only limitations are prohibitive (e.g., heap allocation, asynchronous operations, or when storing a reference).

For Unity developers utilizing the Job System, NativeArray<T> is fundamental. Slicing a NativeArray<T> conventionally by creating a new NativeArray<T> would involve allocations and potential safety overhead. With Span<T>, you can create a direct, zero-allocation view:

using Unity.Collections;
using Unity.Jobs;
using System; // For Span<T>

// Define a job that processes data
public struct MyDataProcessorJob : IJob
{
    [ReadOnly] public NativeArray<int> InputData;
    public NativeArray<int> OutputData;

    public void Execute()
    {
        // 1. Create a Span<int> for a specific segment of InputData.
        //    InputData.Slice() returns a NativeArray<int>, which has an implicit
        //    conversion to Span<int>. Crucially, no new int[] or NativeArray<int>
        //    is allocated for this view.
        Span<int> inputSegment = InputData.Slice(10, 20); // View elements from index 10, length 20

        // 2. Create a Span<int> for a corresponding segment of OutputData.
        Span<int> outputSegment = OutputData.Slice(0, 20);

        // 3. Process the segments using an allocation-free pure function.
        //    Passing Span<int> by 'ref' avoids copying the Span struct itself.
        ProcessDataSlice(ref inputSegment, ref outputSegment);
    }

    /// <summary>
    /// An example pure function that operates directly on memory segments
    /// without any allocations. Ideal for Burst-compiled contexts.
    /// </summary>
    /// <param name="input">A read-only view of the input data segment.</param>
    /// <param name="output">A writable view of the output data segment.</param>
    private static void ProcessDataSlice(ref Span<int> input, ref Span<int> output)
    {
        // Ensure segments are of compatible length, or handle mismatch
        int length = Math.Min(input.Length, output.Length);

        for (int i = 0; i < length; i++)
        {
            output[i] = input[i] * 2 + 5; // Example complex processing
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

In this example, inputSegment and outputSegment are not copies of the underlying data. They are simply lightweight, stack-allocated references pointing to specific parts of the existing InputData and OutputData memory blocks. The ProcessDataSlice function is a "pure" function in this context – it operates directly on these provided memory views without allocating anything itself. Passing Span<int> by ref further optimizes performance by preventing the Span struct from being copied on the stack during the function call. This pattern aligns perfectly with Burst's optimization goals, leading to highly predictable and incredibly fast code execution within your jobs.

Conclusion

Embracing Span<T> and Memory<T> is more than just a "nice-to-have" optimization; it's a fundamental shift towards writing truly high-performance, allocation-free C# code in Unity. By referencing rather than copying memory segments, especially within your Job System architecture, you effectively eliminate a major source of garbage collection pressure. This directly translates to stable frame times, significantly reduced stuttering, and a consistently smoother player experience. Stop chasing ghost allocations and start leveraging these modern C# features to unlock your game's full, predictable performance potential.

Top comments (0)