C#'s LINQ (Language Integrated Query) is an incredibly expressive tool that allows developers to write complex data queries, filtering, and sorting in a single, readable line of code. However, in the realm of real-time game engines like Unity, where keeping frame rates high and CPU latency low is critical, using LINQ inside frequently executed loops like Update or FixedUpdate can lead to severe performance degradation.
If you have ever analyzed your game in the Unity Profiler and noticed periodic, millisecond-long CPU spikes (stutters) even during idle frames, the culprit is often GC Alloc (Garbage Collection Allocation) triggered by LINQ queries.
This article dives deep into how LINQ queries allocate memory under the hood, demonstrates the exact performance cost using benchmarks, and provides clean refactoring strategies to transition to allocation-free (zero-allocation) C# code.
1. The Root Cause: 3 Hidden Memory Allocations in LINQ
Why does using LINQ trigger memory allocations on the managed heap? There are three primary mechanisms at play:
[LINQ Query Execution]
│
├─> (1) Delegate Instance Creation (Func<T, bool>) ──> Managed Heap Allocation
├─> (2) Variable Capture (Closure) ──> Anonymous DisplayClass Object Instantiation
└─> (3) Iterator (IEnumerator) Boxing ──> Runtime State Object Allocation
Figure: Hidden memory allocations triggered by executing a single LINQ query
(1) Delegate Object Instantiation
Most LINQ methods (e.g., Where, Select) take a lambda expression as an argument. The compiler converts this lambda expression into a delegate object (like Func<T, TResult>). Because delegates are reference types in C#, a new delegate instance is instantiated on the managed heap every time the query is constructed, generating garbage.
(2) Closure Allocations (Variable Capture)
If your lambda expression references local variables declared outside its scope, the compiler creates a temporary wrapper class known as a \"display class\" to store these captured variables. This is called a closure. Each time the method runs, a new instance of this display class must be allocated on the heap to store the updated local values.
// Closure example: The local variable 'targetType' is captured inside the lambda
public Enemy FindTarget(EnemyType targetType)
{
return enemies.FirstOrDefault(e => e.Type == targetType); // Instantiates a display class, triggering GC Alloc!
}
(3) Iterator and Boxing Allocations
To support deferred execution, LINQ queries return custom iterator objects that implement IEnumerable. Instantiating these query structures allocates reference types on the heap. Furthermore, traversing them via a foreach loop can trigger boxing allocations when casting structural enumerators to generic interfaces, generating additional heap garbage.
2. Refactoring Code: Converting LINQ to Allocation-Free C#
Let us look at a common gameplay scenario: \"Scanning nearby enemies within attack range and selecting the one with the lowest health (HP).\" We will compare a standard LINQ implementation against an optimized, zero-allocation alternative.
Anti-Pattern: Concise but Allocation-Heavy LINQ Version
using System.Linq;
using System.Collections.Generic;
using UnityEngine;
public class TargetFinder : MonoBehaviour
{
[SerializeField] private List<Enemy> enemies = new List<Enemy>();
[SerializeField] private float attackRange = 10f;
private void Update()
{
// Executing LINQ inside Update generates garbage every frame (DO NOT DO THIS)
Enemy closestEnemy = enemies
.Where(e => Vector3.Distance(transform.position, e.transform.position) <= attackRange)
.OrderBy(e => e.Hp)
.FirstOrDefault();
if (closestEnemy != null)
{
// Execute attack logic
}
}
}
While this code is extremely concise, running it every frame inside Update continuously pollutes the heap. The lambda delegates, the closure capturing transform.position, the sorting arrays generated by OrderBy, and the iterator instance all contribute to continuous garbage generation.
Recommended Pattern: Zero-Allocation for-loop Implementation
using System.Collections.Generic;
using UnityEngine;
public class TargetFinderOptimized : MonoBehaviour
{
[SerializeField] private List<Enemy> enemies = new List<Enemy>();
[SerializeField] private float attackRange = 10f;
private void Update()
{
Enemy bestTarget = null;
float minHp = float.MaxValue;
Vector3 myPos = transform.position;
float sqrRange = attackRange * attackRange;
int count = enemies.Count;
for (int i = 0; i < count; i++)
{
Enemy e = enemies[i];
if (e == null) continue;
// Optimization: Use sqrMagnitude to avoid expensive square-root calculations
float sqrDist = (e.transform.position - myPos).sqrMagnitude;
if (sqrDist <= sqrRange)
{
if (e.Hp < minHp)
{
minHp = e.Hp;
bestTarget = e;
}
}
}
if (bestTarget != null)
{
// Execute attack logic
}
}
}
This refactored code performs **zero heap allocations** (GC Alloc: 0 B). It executes entirely on the stack using value types and traverses the data via a fast index-based for loop, which is also highly cache-friendly for the CPU.
3. Performance Benchmark: LINQ vs. for-loop
We measured the performance difference when filtering and finding the minimum value across a list of 1,000 game entities.
- Test Environment: Unity 2022.3 LTS / C# (Mono) / Windows 11
- Item Count: 1,000 active GameObjects
| Execution Method | Execution Time (per frame) | GC Alloc (per frame) |
|---|---|---|
| LINQ Query (Where + OrderBy) | 0.84 ms | 1.2 KB |
| Optimized for-loop | 0.04 ms | 0 B (Allocation-Free) |
Result: The optimized for loop is **20 times faster** and generates **zero garbage**. Allocating 1.2 KB per frame might seem tiny, but at 60 frames per second, this accumulates to **72 KB per second and 4.3 MB per minute**. Once the heap fills up, the garbage collector pauses the main thread to clean up, causing noticeable stutters and input lag.
4. Alternative Zero-Allocation LINQ Patterns
If you prefer the declarative, clean syntax of LINQ but need to maintain strict performance budgets, consider these options:
1. Avoid Closures Using Static Local Functions
If you must use LINQ-like helpers, write static local functions that pass necessary variables as parameters, or avoid referencing external scope altogether. This prevents the compiler from allocating a DisplayClass instance on the heap.
2. Struct-Based LINQ Libraries
For large projects, consider importing zero-allocation alternative libraries like StructLinq or NetFabric.Hyperlinq. These libraries rewrite LINQ operations using value types (structs) and compile-time generics, allowing the compiler to inline code and achieve speeds comparable to manual for loops without garbage allocations.
3. Establish Strict Execution Policies
LINQ is not completely banned in Unity. Using LINQ inside initialization methods like Awake, Start, or loading screens is perfectly acceptable, as a minor heap allocation there does not disrupt gameplay. Establish clear guidelines for your development team: LINQ is allowed for one-time initialization, but strictly prohibited inside game-loop updates (Update, FixedUpdate, coroutines, and event handlers).
5. Summary
- Keep LINQ Out of Hot Paths: Using LINQ inside Update loops leads to GC Allocations that trigger garbage collection pauses, directly harming player experience.
- Index-Based Loops are Safest: Traversing arrays or lists using a standard
forloop maintains zero heap allocations and is highly optimized by the compiler. - Understand the Lifecycles: Prioritize clean, readable LINQ code for startup initialization, and swap to optimized, allocation-free structures for runtime systems.