LINQ’s CPU performance is quite poor, but how is it with memory? Does every LINQ function always create tons of garbage for the GC to collect, or are there exceptions that aren’t so bad? Today’s article tests out lots of LINQ functions to find out!

There are a lot of LINQ functions. Most of them have at least two overloads, greatly increasing their total number. For today’s test, we’ll use one overload of each to keep things simple and concise. Performance may vary between overloads, but this variance should be small.

Today’s test uses a trivial class with nearly all of the LINQ functions:

class Element
{
    public float Value;
}

The whole test is in one MonoBehaviour with just a few fields created in Awake and used by most of the tested LINQ functions:

Element[] array;
Element singleElement;
IOrderedEnumerable<Element> orderedEnumerable;
 
void Awake()
{
    array = new [] { new Element { Value = 1 } };
    singleElement = new Element { Value = 4 };
}

Next, we have a set of simple functions that the LINQ functions use:

Element ReturnIdentity(Element a)
{
    return a;
}
 
Element ReturnSecond(Element a, Element b)
{
    return b;
}
 
bool ReturnTrue(Element a)
{
    return true;
}
 
float ReturnValue(Element a)
{
    return a.Value;
}
 
Element ReturnFirst(Element a, IEnumerable<Element> b)
{
    return a;
}
 
IEnumerable<Element> ReturnArray(Element a)
{
    return array;
}

These are all cached as delegate fields in Awake to avoid counting delegate creation when testing individual LINQ functions:

Func<Element, Element> returnIdentityDelegate;
Func<Element, Element, Element> returnSecondDelegate;
Func<Element, bool> returnTrueDelegate;
Func<Element, float> returnValueDelegate;
Func<Element, IEnumerable<Element>, Element> returnFirstDelegate;
Func<Element, IEnumerable<Element>> returnArrayDelegate;
 
void Awake()
{
    returnIdentityDelegate = ReturnIdentity;
    returnSecondDelegate = ReturnSecond;
    returnTrueDelegate = ReturnTrue;
    returnValueDelegate = ReturnValue;
    returnFirstDelegate = ReturnFirst;
    returnArrayDelegate = ReturnArray;
}

To tie this all together, there is an int testIndex that the Update function performs a switch on to run each test two frames apart. This makes it clear in the profiler when each test is run, even when the names of the tests aren’t shown. A sphere is created to visually notify that all tests are complete.

void Update()
{
    switch (testIndex)
    {
        case 0: Aggregate(); break;
        case 2: All(); break;
        case 4: Any(); break;
 
        // many more cases...
 
        case 106: GameObject.CreatePrimitive(PrimitiveType.Sphere); break;
    }
 
    testIndex++;
}

Finally, there are the actual test functions. Each one tests one LINQ function:

Element Aggregate()
{
    return array.Aggregate(returnSecondDelegate);
}
 
bool All()
{
    return array.All(returnTrueDelegate);
}
 
bool Any()
{
    return array.Any(returnTrueDelegate);
}
 
// many more functions...

In total, the test looks like this:

using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
 
public class TestScript : MonoBehaviour
{
    class Element
    {
        public float Value;
    }
 
    Func<Element, Element> returnIdentityDelegate;
    Func<Element, Element, Element> returnSecondDelegate;
    Func<Element, bool> returnTrueDelegate;
    Func<Element, float> returnValueDelegate;
    Func<Element, IEnumerable<Element>, Element> returnFirstDelegate;
    Func<Element, IEnumerable<Element>> returnArrayDelegate;
 
    Element[] array;
    Element singleElement;
    IOrderedEnumerable<Element> orderedEnumerable;
    int testIndex;
 
    void Awake()
    {
        returnIdentityDelegate = ReturnIdentity;
        returnSecondDelegate = ReturnSecond;
        returnTrueDelegate = ReturnTrue;
        returnValueDelegate = ReturnValue;
        returnFirstDelegate = ReturnFirst;
        returnArrayDelegate = ReturnArray;
 
        array = new [] { new Element { Value = 1 } };
        singleElement = new Element { Value = 4 };
    }
 
    void Update()
    {
        switch (testIndex)
        {
            case 0: Aggregate(); break;
            case 2: All(); break;
            case 4: Any(); break;
            case 6: Append(); break;
            case 8: AsEnumerable(); break;
            case 10: Average(); break;
            case 12: Cast(); break;
            case 14: Concat(); break;
            case 16: Contains(); break;
            case 18: Count(); break;
            case 20: DefaultIfEmpty(); break;
            case 22: Distinct(); break;
            case 24: ElementAt(); break;
            case 26: ElementAtOrDefault(); break;
            case 28: Empty(); break;
            case 30: Except(); break;
            case 32: First(); break;
            case 34: FirstOrDefault(); break;
            case 36: GroupBy(); break;
            case 38: GroupJoin(); break;
            case 40: Intersect(); break;
            case 42: Join(); break;
            case 44: Last(); break;
            case 46: LastOrDefault(); break;
            case 48: LongCount(); break;
            case 50: Max(); break;
            case 52: Min(); break;
            case 54: OfType(); break;
            case 56: OrderBy(); break;
            case 58: OrderByDescending(); break;
            case 60: Prepend(); break;
            case 62: Range(); break;
            case 64: Repeat(); break;
            case 66: Reverse(); break;
            case 68: Select(); break;
            case 70: SelectMany(); break;
            case 72: SequenceEqual(); break;
            case 74: Single(); break;
            case 76: SingleOrDefault(); break;
            case 78: Skip(); break;
            case 80: SkipWhile(); break;
            case 82: Sum(); break;
            case 84: Take(); break;
            case 86: TakeWhile(); break;
            case 88: ThenBy(); break;
            case 90: ThenByDescending(); break;
            case 92: ToArray(); break;
            case 94: ToDictionary(); break;
            case 96: ToList(); break;
            case 98: ToLookup(); break;
            case 100: Union(); break;
            case 102: Where(); break;
            case 104: Zip(); break;
 
            case 106: GameObject.CreatePrimitive(PrimitiveType.Sphere); break;
        }
        testIndex++;
    }
 
    Element Aggregate()
    {
        return array.Aggregate(returnSecondDelegate);
    }
 
    bool All()
    {
        return array.All(returnTrueDelegate);
    }
 
    bool Any()
    {
        return array.Any(returnTrueDelegate);
    }
 
    IEnumerable<Element> Append()
    {
        return array.Append(singleElement);
    }
 
    IEnumerable<Element> AsEnumerable()
    {
        return array.AsEnumerable();
    }
 
    float Average()
    {
        return array.Average(returnValueDelegate);
    }
 
    IEnumerable<Element> Cast()
    {
        return array.Cast<Element>();
    }
 
    IEnumerable<Element> Concat()
    {
        return array.Concat(array);
    }
 
    bool Contains()
    {
        return array.Contains(singleElement);
    }
 
    int Count()
    {
        return array.Count(returnTrueDelegate);
    }
 
    IEnumerable<Element> DefaultIfEmpty()
    {
        return array.DefaultIfEmpty();
    }
 
    IEnumerable<Element> Distinct()
    {
        return array.Distinct();
    }
 
    Element ElementAt()
    {
        return array.ElementAt(0);
    }
 
    Element ElementAtOrDefault()
    {
        return array.ElementAtOrDefault(0);
    }
 
    IEnumerable<Element> Empty()
    {
        return Enumerable.Empty<Element>();
    }
 
    IEnumerable<Element> Except()
    {
        return array.Except(array);
    }
 
    Element First()
    {
        return array.First(returnTrueDelegate);
    }
 
    Element FirstOrDefault()
    {
        return array.FirstOrDefault(returnTrueDelegate);
    }
 
    IEnumerable<IGrouping<float, Element>> GroupBy()
    {
        return array.GroupBy(returnValueDelegate);
    }
 
    IEnumerable<Element> GroupJoin()
    {
        return array.GroupJoin(
            array,
            returnValueDelegate,
            returnValueDelegate,
            returnFirstDelegate,
            null);
    }
 
    IEnumerable<Element> Intersect()
    {
        return array.Intersect(array);
    }
 
    IEnumerable<Element> Join()
    {
        return array.Join(
            array,
            returnIdentityDelegate,
            returnIdentityDelegate,
            returnSecondDelegate);
    }
 
    Element Last()
    {
        return array.Last(returnTrueDelegate);
    }
 
    Element LastOrDefault()
    {
        return array.LastOrDefault(returnTrueDelegate);
    }
 
    long LongCount()
    {
        return array.LongCount(returnTrueDelegate);
    }
 
    float Max()
    {
        return array.Max(returnValueDelegate);
    }
 
    float Min()
    {
        return array.Min(returnValueDelegate);
    }
 
    IEnumerable<Element> OfType()
    {
        return array.OfType<Element>();
    }
 
    IOrderedEnumerable<Element> OrderBy()
    {
        orderedEnumerable = array.OrderBy(returnValueDelegate);
        return orderedEnumerable;
    }
 
    IOrderedEnumerable<Element> OrderByDescending()
    {
        return array.OrderByDescending(returnValueDelegate);
    }
 
    IEnumerable<Element> Prepend()
    {
        return array.Prepend(singleElement);
    }
 
    IEnumerable<int> Range()
    {
        return Enumerable.Range(1, 3);
    }
 
    IEnumerable<int> Repeat()
    {
        return Enumerable.Repeat(1, 3);
    }
 
    IEnumerable<Element> Reverse()
    {
        return array.Reverse();
    }
 
    IEnumerable<Element> Select()
    {
        return array.Select(returnIdentityDelegate);
    }
 
    IEnumerable<Element> SelectMany()
    {
        return array.SelectMany(returnArrayDelegate);
    }
 
    bool SequenceEqual()
    {
        return array.SequenceEqual(array);
    }
 
    Element Single()
    {
        return array.Single();
    }
 
    Element SingleOrDefault()
    {
        return array.SingleOrDefault();
    }
 
    IEnumerable<Element> Skip()
    {
        return array.Skip(0);
    }
 
    IEnumerable<Element> SkipWhile()
    {
        return array.SkipWhile(returnTrueDelegate);
    }
 
    float Sum()
    {
        return array.Sum(returnValueDelegate);
    }
 
    IEnumerable<Element> Take()
    {
        return array.Take(0);
    }
 
    IEnumerable<Element> TakeWhile()
    {
        return array.TakeWhile(returnTrueDelegate);
    }
 
    IOrderedEnumerable<Element> ThenBy()
    {
        return orderedEnumerable.ThenBy(returnValueDelegate);
    }
 
    IOrderedEnumerable<Element> ThenByDescending()
    {
        return orderedEnumerable.ThenByDescending(returnValueDelegate);
    }
 
    Element[] ToArray()
    {
        return array.ToArray();
    }
 
    Dictionary<Element, Element> ToDictionary()
    {
        return array.ToDictionary(
            returnIdentityDelegate,
            returnIdentityDelegate);
    }
 
    List<Element> ToList()
    {
        return array.ToList();
    }
 
    ILookup<Element, Element> ToLookup()
    {
        return array.ToLookup(
            returnIdentityDelegate,
            returnIdentityDelegate);
    }
 
    IEnumerable<Element> Union()
    {
        return array.Union(array);
    }
 
    IEnumerable<Element> Where()
    {
        return array.Union(array);
    }
 
    IEnumerable<Element> Zip()
    {
        return array.Zip(array, returnSecondDelegate);
    }
 
    Element ReturnIdentity(Element a)
    {
        return a;
    }
 
    Element ReturnSecond(Element a, Element b)
    {
        return b;
    }
 
    bool ReturnTrue(Element a)
    {
        return true;
    }
 
    float ReturnValue(Element a)
    {
        return a.Value;
    }
 
    Element ReturnFirst(Element a, IEnumerable<Element> b)
    {
        return a;
    }
 
    IEnumerable<Element> ReturnArray(Element a)
    {
        return array;
    }
}

Using Unity 2018.2.0f2, I set the following configuration in Edit > Project Settings > Player:

  • Scripting Runtime Version: .NET 4.x Equivalent
  • Scripting Backend: IL2CPP
  • Api Compatibility Level: .NET Standard 2.0

Then I made a standalone macOS build with the following settings:

  • Development Build: Enabled
  • Autoconnect Profiler: Enabled
  • Script Debugging: Enabled
  • Wait For Managed Debugger: Disabled
  • Scripts Only Build: Disabled

The profiler quickly captures all the test data upon launching the standalone build. It’s then a simple matter of scrubbing through frames recording the GC Alloc column of the Hierarchy view of the CPU Usage profiler and the GC Allocations per Frame of the Memory profiler. Here are the results I got:

Function GC Alloc Size GC Alloc Count
Aggregate 332 32
All 76 2
Any 32 1
Append 80 1
AsEnumerable 0 0
Average 106 2
Cast 0 0
Concat 154 3
Contains 112 1
Count 32 1
DefaultIfEmpty 80 1
Distinct 88 1
ElementAt 0 0
ElementAtOrDefault 0 0
Empty 32 1
Except 104 1
First 32 1
FirstOrDefault 32 1
GroupBy 280 5
GroupJoin 394 6
Intersect 104 1
Join 176 1
Last 32 1
LastOrDefault 32 1
LongCount 32 1
Max 64 1
Min 64 1
OfType 64 1
OrderBy 512 13
OrderByDescending 56 1
Prepend 80 1
Range 84 2
Repeat 48 1
Reverse 80 1
Select 64 1
SelectMany 88 1
SequenceEqual 176 5
Single 0 0
SingleOrDefault 0 0
Skip 72 1
SkipWhile 88 1
Sum 64 1
Take 72 1
TakeWhile 80 1
ThenBy 56 1
ThenByDescending 56 1
ToArray 40 1
ToDictionary 434 6
ToList 112 3
ToLookup 272 5
Union 104 1
Where 104 1
Zip 104 1

LINQ GC Alloc Count

LINQ GC Alloc Size

A handful of LINQ functions don’t allocate any garbage at all. There are just 6 of these: AsEnumerable, Cast, ElementAt, ElementAtOrDefault, Single, and SingleOrDefault.

29 functions allocate just one object of about 32-88 bytes in size. This includes simple functions like Min and Max as well as common functions like Select. There are two more functions—All and Range—that allocate a double-digit byte count, but spread over two allocations.

The worst offenders allocate over 100 bytes. There are 17 of these functions. In the worst case—OrderBy—a whopping 0.5 KB can be allocated, but ToDictionary is nearly as bad with 434 bytes allocated. 10 of these functions use more than one allocation while the remaining 7 only allocate once.

With this information in mind, it’s easy to recommend that LINQ shouldn’t be used on a frame-by-frame basis as GC allocations should generally be kept to a minimum if not outright banned in most games. There are some exceptions that don’t create any garbage, but these are small functions of minimal use. In LINQ’s place is most likely manually-written loops. These can be trivially written and placed inside “helper” functions. For a more complete solution that’s more like LINQ but without the garbage creation, check out the iterator library.