Just How Much Garbage Does LINQ Create?
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 |
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.
#1 by Douoht on July 17th, 2018 ·
Well this is quite interesting.
Have you tried neuecc’s https://github.com/neuecc/LINQ-to-GameObject-for-Unity ??
#2 by Martin Koslof on July 2nd, 2019 ·
Hi Jackson, I’ve starting going back and reading all your posts, you’ve got some great ideas! I am currently doing a performance critical application (1 second is too much latency) and I know how much garbage can be an issue with LINQ. I have started hand writing loops to try to limit the issue, but we have some very “LINQish” aspects such as joins, groupby, etc. These are not SQL LINQ queries, but specifically LINQ objects and mostly List and IEnumerables. I was looking at your “iterator” library and it looks really interesting. Have you implemented a garbage free/highly optimization method for groupby and joins? I’d be curious to see how you did that, since I probably need something like this and I would to see an example I can build off
#3 by jackson on July 4th, 2019 ·
Hi Martin, I’m glad you’re enjoying my posts and the iterator library. Unfortunately, there are no such functions in the iterator library yet. If you’d like to write them, feel free to submit a pull request on GitHub.
#4 by KS on August 3rd, 2022 ·
Hi Jackson, I’ve just catched what Implementation of Where() are same as Union() in posted code.
#5 by Daniel on March 18th, 2023 ·
This is not surprising. The nature of the worst offenders more or less guarantees allocation. You cannot sort a read-only sequence without someplace to store the reordered results, and you cannot create a dictionary without someplace to put that. We don’t supply either to LINQ, ergo allocation. I would look at it just like choosing to create a new array. Of course there will be allocation, but will there be garbage? If you do not throw away the dictionary or ordered enumerable you get back, how much work does the garbage collector have to do? That would be interesting to see.
As for the minor operations all having a single allocation, I think that is almost inevitable. I think the only way around allocation when working with interfaces is to use constrained generics with struct implementers, and C# is not great at inferring generic type parameters unless the case is drop dead simple. I just can’t see anyone deciding it is a good idea to force every LINQ call to include (at least) two explicit generic type parameters, so we have what we have.
It would be interesting to see–using that same array–if implementing minor operations like Min and Max using a foreach loop would include the same allocation. I am not very familiar with Mono or il2cpp, but I know that vanilla C# has optimizations in place to make use of struct enumerators when they are available. List has one of these, but I do not see one exposed on T[]. This wouldn’t help with LINQ, sadly, but it would still be interesting to see whether there are special optimizations in place for arrays. I do remember reading that arrays in C# are quite special beasts, but I do not know the details.
#6 by fred on May 30th, 2024 ·
Why does First has alloc while Single doesn’t? Seems a bit counterintuitive.