Skip to content

Instantly share code, notes, and snippets.

@guitarrapc
Last active May 11, 2025 14:54
Show Gist options
  • Save guitarrapc/cdf78100e9f16ab50b8000b423766b29 to your computer and use it in GitHub Desktop.
Save guitarrapc/cdf78100e9f16ab50b8000b423766b29 to your computer and use it in GitHub Desktop.
IReadOnlyList<T> foreach Memory Allocation and way to hack.

Summary

When you iterate List<T> and IReadOnlyList<T> with foreach statement, IL code will be expanded to following. While List<T>'s List<T>.Enumerator is struct, IEnumerator<T> e = list.GetEnumerator() means boxing struct (Enumerator) to class (IEnumerator<T>). This results foeach IReadOnlyList<T> creates 40B GC allocation.

// List<T>, 0B GC Alloc
List<T>.Enumerator e = list.GetEnumerator();
try { while (e.MoveNext()) {} }
finally { e.Dispose(); }

// IReadOnlyList<T>, 40B GC Alloc for boxing
IEnumerator<T> e = list.GetEnumerator();
try { while (e.MoveNext()) {} }
finally { e.Dispose(); }

To avoid allocation, you have 4 choices. 1 should be performant and ZLinq is the easiest way to accomplish 0 allocation.

  1. Use for statement, IReadOnlyList<T> can access with indexer. for (var i = 0; i < list.Count; i++) { … }
  2. Cast to concreate type before iteration. if (list is List<int> l) foreach (var x in l) { … }
  3. Prepare method which recieve concreate type of T. static void Sum<T, TCol>(TCol src) where TCol : IReadOnlyList<T> { foreach (var v in src) total += v; }
  4. Use struct collection instead of IReadOnlyList<T>. ReadOnlyArray for Unity foreach (var x in new UnityEngine.InputSystem.Utilities.ReadOnlyArray<Unit>(array)) { … }, ZLinq foreach (var x in list.AsValueEnumerable() { … }
Goal Recommended approach
Iterate at high speed with zero allocations for (int i = 0; i < list.Count; i++) { … list[i] … }IReadOnlyList exposes Count and the indexer (this[int]), so an indexed for loop is allocation-free.
You can change the parameter type Accept a concrete type such as List<T>, T[], or ImmutableArray<T> to avoid boxing.
Keep the abstract interface but minimize allocations Use (or implement) a library that returns value-type enumerators, e.g. UnityEngine.InputSystem.Utilities.ReadOnlyArray for Unity.
If you absolutely must use foreach Measure whether the single allocation (≈ 40 B) fits your performance budget; it’s fine if GC pauses remain acceptable.

Benchmark Result

// * Summary *

BenchmarkDotNet v0.14.0, Windows 11 (10.0.26100.3775) Unknown processor .NET SDK 9.0.203 [Host] : .NET 9.0.4 (9.0.425.16305), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI

IterationCount=3 LaunchCount=1 WarmupCount=3
Alloc Ratio=NA

Method Runtime Mean Error StdDev Ratio Allocated
ListEnumerator .NET 8.0 5.214 μs 0.7938 μs 0.0435 μs 1.00 -
IReadOnlyListEnumerator .NET 8.0 17.779 μs 2.4332 μs 0.1334 μs 3.41 40 B
IReadOnlyListFor .NET 8.0 10.017 μs 0.6630 μs 0.0363 μs 1.92 -
IReadOnlyListIs .NET 8.0 6.534 μs 1.7400 μs 0.0954 μs 1.25 -
IReadOnlyListZLinq .NET 8.0 23.727 μs 5.8406 μs 0.3201 μs 4.55 -
ListEnumerator .NET 9.0 6.479 μs 0.5099 μs 0.0280 μs 1.00 -
IReadOnlyListEnumerator .NET 9.0 17.943 μs 10.6696 μs 0.5848 μs 2.77 40 B
IReadOnlyListFor .NET 9.0 10.097 μs 0.5674 μs 0.0311 μs 1.56 -
IReadOnlyListIs .NET 9.0 6.393 μs 1.0105 μs 0.0554 μs 0.99 -
IReadOnlyListZLinq .NET 9.0 23.227 μs 3.3263 μs 0.1823 μs 3.59 -

// * Hints * HideColumnsAnalyser Summary -> Hidden columns: Job, RatioSD, Alloc Ratio

// * Legends * Mean : Arithmetic mean of all measurements Error : Half of 99.9% confidence interval StdDev : Standard deviation of all measurements Ratio : Mean of the ratio distribution ([Current]/[Baseline]) Allocated : Allocated memory per single operation (managed only, inclusive, 1KB = 1024B) 1 μs : 1 Microsecond (0.000001 sec)

// * Diagnostic Output - MemoryDiagnoser *

Unity Deep profile result

Unity 6000.1.2f1.

See code for UnityBenchmark.cs

image

BenchmarkRunner.Run<Bencmarks>();
[HideColumns(Column.Job, Column.RatioSD, Column.AllocRatio)]
[ShortRunJob(RuntimeMoniker.Net80)]
[ShortRunJob(RuntimeMoniker.Net90)]
[MemoryDiagnoser]
[ReturnValueValidator(failOnError: true)]
public class Bencmarks
{
private List<Unit> _list;
private IReadOnlyList<Unit> _readonlyList;
[GlobalSetup]
public void Setup()
{
_list = new List<Unit>(10000);
_readonlyList = _list;
for (var i = 0; i < 10000; ++i)
{
_list.Add(new Unit(i, $"unit_{i}", 10, 10));
}
}
[Benchmark(Baseline = true)]
public void ListEnumerator()
{
long value = 0;
foreach (var item in _list)
{
value += item.Health;
}
}
[Benchmark]
public void IReadOnlyListEnumerator()
{
long value = 0;
foreach (var item in _readonlyList)
{
value += item.Health;
}
}
[Benchmark]
public void IReadOnlyListFor()
{
long value = 0;
for (var i = 0; i < _readonlyList.Count; i++)
{
value += _readonlyList[i].Health;
}
}
[Benchmark]
public void IReadOnlyListIs()
{
if (_readonlyList is List<Unit> list)
{
long value = 0;
foreach (var item in list)
{
value += item.Health;
}
}
}
[Benchmark]
public void IReadOnlyListZLinq()
{
long value = 0;
foreach (var item in _readonlyList.AsValueEnumerable())
{
value += item.Health;
}
}
}
public class Unit
{
public readonly int Id;
public readonly string Name;
public readonly int Health;
public readonly int Attack;
public Unit(int id, string name, int health, int attack)
{
Id = id;
Name = name;
Health = health;
Attack = attack;
}
}
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Profiling;
using ZLinq;
namespace Foo
{
public class BenchmarkUnitForeach : MonoBehaviour
{
private List<Unit> _list = new List<Unit>(10000);
private IReadOnlyList<Unit> _readOnlyList => _list;
private UnityEngine.InputSystem.Utilities.ReadOnlyArray<Unit> _readOnlyArray;
public void Start()
{
for (var i = 0; i < 10000; ++i)
{
_list.Add(new Unit(i, $"unit_{i}", 10, 10));
}
_readOnlyArray = new UnityEngine.InputSystem.Utilities.ReadOnlyArray<Unit>(_list.ToArray());
}
public void Update()
{
// 0B GCAlloc
Debug.Log("List foreach");
Profiler.BeginSample("List foreach");
foreach (var item in _list)
{
}
Profiler.EndSample();
// 40B GCAlloc
Debug.Log("IReadOnlyList foreach");
Profiler.BeginSample("IReadOnlyList foreach");
foreach (var item in _readOnlyList)
{
}
Profiler.EndSample();
// 0B GCAlloc
Debug.Log("Is IReadOnlyList foreach");
Profiler.BeginSample("Is IReadOnlyList foreach");
if (_readOnlyList is List<Unit> list)
{
foreach (var item in list)
{
}
}
Profiler.EndSample();
// 0B GCAlloc
Debug.Log("For IReadOnlyList instead of foreach");
Profiler.BeginSample("For IReadOnlyList instead of foreach");
for (var i = 0; i < _readOnlyList.Count; i++)
{
_ = _readOnlyList[i];
}
Profiler.EndSample();
// 0B GCAlloc
Debug.Log("ReadOnlyArray foreach");
Profiler.BeginSample("ReadOnlyArray foreach");
foreach (var item in _readOnlyArray)
{
}
Profiler.EndSample();
// 0B GCAlloc
Debug.Log("ZLinq IReadOnlyList foreach");
Profiler.BeginSample("ZLinq ReadOnlyArray foreach");
foreach (var item in _readOnlyList.AsValueEnumerable())
{
}
Profiler.EndSample();
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment