Start Debugging

How to iterate over two collections in lockstep without heap allocations in C#

Enumerable.Zip allocates 144-208 bytes per call and runs about 10x slower than a plain loop. For arrays and lists, a for loop over two spans sliced to the same length is allocation-free and the fastest option; for foreach syntax, a small ref struct zip enumerator gets within 25% of it. Measured on .NET 10.0.10 and .NET 11 RC 3.

Short answer: if both sources are arrays, lists, or anything you can get a ReadOnlySpan<T> from, take the shorter length, slice both spans to it, and run one for loop with a single index. That allocates nothing and is the fastest shape the JIT knows. If you want foreach (var (x, y) in ...) syntax without the cost, write a 20-line ref struct zip enumerator that checks both lengths in MoveNext. If the sources are not indexable (a HashSet<T>, a Dictionary<TKey, TValue>), drive their struct enumerators by hand with two GetEnumerator() calls on the concrete types. Avoid Enumerable.Zip in hot paths: on .NET 10.0.10 and .NET 11 RC 3 it allocates 144 to 208 bytes per call and, in the benchmark below, ran about 10x slower than the plain loop.

Everything in this post was measured with C# 14 on .NET 10.0.10 (SDK 10.0.302) and .NET 11.0.0-rc.3.26522.148 on an Apple M4. The allocation numbers were identical on both runtimes.

Where the bytes in Enumerable.Zip come from

Enumerable.Zip is the obvious answer to “loop over two sequences together”, and since .NET Core 3.0 it has a tuple-returning overload, so the code reads well:

// .NET 10, C# 14
long sum = 0;
foreach (var (x, y) in first.Zip(second))
    sum += (long)x * y;

The problem is that every piece of that pipeline is a reference type behind an interface. Zip takes two IEnumerable<T> parameters, so it calls GetEnumerator() through the interface on each source. For a List<T> that boxes its struct List<T>.Enumerator; for an array it allocates an array enumerator object. Then Zip itself returns an iterator object that holds both. Your foreach talks to that iterator through IEnumerator<(T1, T2)>, so every MoveNext and Current is an interface call that the JIT usually cannot inline.

I measured the steady-state allocations with GC.GetAllocatedBytesForCurrentThread() after letting each method tier up to Tier 1 with dynamic PGO, using two 1,000-element int sources:

ApproachAllocated per call (.NET 10.0.10 and 11 RC 3)
for loop with one index over arrays0 B
for loop over two sliced ReadOnlySpan<int>0 B
CollectionsMarshal.AsSpan on two List<int>0 B
ref struct zip enumerator with foreach0 B
Two List<int>.Enumerator driven manually0 B
Two IEnumerator<int>, sources typed as IEnumerable<int> over arrays64 B
Two IEnumerator<int>, sources typed as IEnumerable<int> over lists80 B
Enumerable.Zip tuple overload, arrays144 B
Enumerable.Zip with a static result selector, arrays160 B
Enumerable.Zip tuple overload, lists160 B
Enumerable.Zip with three sources, arrays208 B

The cost is per call, not per element, so it does not matter for a loop you run once at startup. It matters when the lockstep loop sits inside a request handler, a game tick, a parser, or anything else that runs millions of times. A few hundred bytes per call, multiplied by the call rate, is exactly the Gen0 churn that shows up in a trace.

Note the static lambda in the selector overload did not help: the delegate is cached, but the iterator and the two enumerators are still allocated.

The fastest version: one index, two sliced spans

When both sources are contiguous memory, you do not need enumerators at all. One index walks both:

// .NET 10, C# 14
static long DotProduct(ReadOnlySpan<int> a, ReadOnlySpan<int> b)
{
    int n = Math.Min(a.Length, b.Length);
    a = a[..n];
    b = b[..n];

    long sum = 0;
    for (int i = 0; i < a.Length; i++)
        sum += (long)a[i] * b[i];
    return sum;
}

int[] prices = [10, 20, 30];
int[] quantities = [3, 2, 1];
long total = DotProduct(prices, quantities); // 100

The slicing is not decoration. The loop condition is i < a.Length, which lets the JIT drop the bounds check on a[i]. After b = b[..n] with the same n, the JIT also knows b.Length == a.Length, so it can drop the check on b[i] too. Without the slice, b[i] keeps a bounds check on every iteration, because nothing in the loop proves b is at least as long as a. This is the same “make the length the loop bound” rule I covered in what Span is and when it actually makes code faster.

Arrays convert to ReadOnlySpan<T> implicitly, so call sites stay clean. For List<T>, take the span over its backing array with CollectionsMarshal.AsSpan:

// .NET 10, C# 14
using System.Runtime.InteropServices;

List<int> prices = [10, 20, 30];
List<int> quantities = [3, 2, 1];

long total = DotProduct(
    CollectionsMarshal.AsSpan(prices),
    CollectionsMarshal.AsSpan(quantities));

CollectionsMarshal.AsSpan is free: it returns a span over the list’s internal array, limited to Count. The catch is that the span does not know about the list anymore. If you Add to the list while holding the span and the list grows, the span keeps pointing at the old array, and you will read stale data with no exception. Do not mutate the list’s size while the span is alive. The List vs Span vs ReadOnlySpan comparison goes into the rest of that trade-off.

To write into one of the sequences while reading the other, make the target a Span<T> instead of a ReadOnlySpan<T>. The pattern does not change.

Keeping foreach syntax with a ref struct zip enumerator

The index loop is fast but it is not pretty, and when the body is more than one line, a[i] and b[i] get noisy. C#‘s foreach is pattern-based: it does not require IEnumerable<T>, only a GetEnumerator() method returning something with MoveNext() and Current. That something can be a ref struct, which means it can hold spans and can never be boxed:

// .NET 10, C# 14
public ref struct SpanZip<T1, T2>
{
    private readonly ReadOnlySpan<T1> _first;
    private readonly ReadOnlySpan<T2> _second;
    private int _index;

    public SpanZip(ReadOnlySpan<T1> first, ReadOnlySpan<T2> second)
    {
        _first = first;
        _second = second;
        _index = -1;
    }

    public SpanZip<T1, T2> GetEnumerator() => this;

    public bool MoveNext()
    {
        int i = _index + 1;
        if ((uint)i < (uint)_first.Length && (uint)i < (uint)_second.Length)
        {
            _index = i;
            return true;
        }
        return false;
    }

    public (T1 First, T2 Second) Current => (_first[_index], _second[_index]);
}

public static class SpanZipExtensions
{
    public static SpanZip<T1, T2> ZipSpan<T1, T2>(
        this ReadOnlySpan<T1> first, ReadOnlySpan<T2> second) => new(first, second);
}

Usage reads like LINQ:

// .NET 10, C# 14
int[] prices = [10, 20, 30];
int[] quantities = [3, 2, 1];

long total = 0;
foreach (var (price, quantity) in prices.ZipSpan(quantities))
    total += (long)price * quantity;

The returned tuple is a ValueTuple, a struct, so deconstructing it costs nothing on the heap. Calling a ReadOnlySpan<T> extension method directly on an int[], with the type arguments inferred, relies on C# 14’s first-class span conversions; on C# 13 you would write prices.AsSpan() first. With a List<T>, call it on CollectionsMarshal.AsSpan(list).

The MoveNext shape matters more than it looks. My first version sliced both spans to the common length in the constructor and only compared _index against _first.Length in MoveNext. That was correct, but it ran at almost exactly 2x the cost of the plain loop. Checking both lengths in MoveNext brought it down to 1.23x. My reading of the result is that once MoveNext and Current are inlined, the JIT sees both comparisons right before the two indexer calls and can use them to drop bounds checks; a slice done in another method does not give it that fact. Either way, the measured difference is large, so keep both checks.

Two restrictions come with ref struct: you cannot use SpanZip across an await or yield return, and you cannot store it in a field of a normal class. If the compiler complains about lifetime, the rules are the ones described in how C# decides ref-safe-context.

When the sources are not indexable

HashSet<T>, Dictionary<TKey, TValue>, Queue<T>, LinkedList<T> and friends cannot give you a span, but every one of them exposes a public struct enumerator when you call GetEnumerator() on the concrete type. Drive two of them yourself:

// .NET 10, C# 14
static long SumProducts(List<int> a, List<int> b)
{
    long sum = 0;
    using var ea = a.GetEnumerator();   // List<int>.Enumerator, a struct
    using var eb = b.GetEnumerator();
    while (ea.MoveNext() && eb.MoveNext())
        sum += (long)ea.Current * eb.Current;
    return sum;
}

This measured 0 B per call. The important detail is the static type of the parameters. Change List<int> to IEnumerable<int> and the exact same body allocates 80 B per call (two boxed List<int>.Enumerator instances), or 64 B when the runtime objects are arrays. The var hides this: ea is List<int>.Enumerator in one version and IEnumerator<int> in the other.

Struct enumerators also keep the collection’s own safety checks. List<T>.Enumerator throws InvalidOperationException if the list is modified during enumeration, which the span-based versions cannot do.

Order is the other trap here. A lockstep walk over two HashSet<T> or Dictionary<TKey, TValue> instances pairs elements by internal bucket order, which is not insertion order and is not stable across different instances with the same contents. Zip ordered collections only. If you are really trying to match items by key, that is a join or a dictionary lookup, not a zip.

What .NET 10’s stack allocation does and does not fix

.NET 10 taught the JIT to devirtualize array interface methods and, through conditional escape analysis, to stack-allocate enumerators “in certain scenarios”, as the What’s new in .NET 10 runtime page puts it. It is reasonable to hope that this makes the interface versions free. In my runs it did not:

The de-abstraction work is real and it does help single-sequence foreach over an array typed as IEnumerable<T>. If you want the background, .NET 10’s stack allocation of small arrays covers the related array work. It just does not cover the two-source case yet.

The numbers

BenchmarkDotNet 0.15.8, [MemoryDiagnoser], in-process toolchain, .NET 10.0.10 Arm64 RyuJIT, Apple M4, two arrays (or lists) of 1,000 int values, computing a sum of products:

MethodMeanRatioAllocated
for with one index over arrays247.9 ns1.000 B
for over two sliced ReadOnlySpan<int>249.7 ns1.010 B
CollectionsMarshal.AsSpan on two lists250.9 ns1.010 B
SpanZip with both lengths checked in MoveNext310.0 ns1.230 B
SpanZip sliced in the constructor (first draft)489.6 ns1.970 B
Two List<int>.Enumerator driven manually652.5 ns2.630 B
Two IEnumerator<int> over arrays1,621.2 ns6.5432 B
Enumerable.Zip with a static selector2,535.9 ns10.23160 B
Enumerable.Zip tuple overload2,613.6 ns10.54144 B

The SpanZip row with both checks comes from a second run of the same benchmark class, where the for baseline measured 251.4 ns, so the ratio is against that run’s baseline. The in-process toolchain was needed because BenchmarkDotNet cannot generate its usual separate project for a file-based dotnet run bench.cs app.

Two readings of the table. First, everything span-shaped lands within noise of the hand-written loop; CollectionsMarshal.AsSpan adds nothing measurable. Second, “allocation-free” and “fast” are different properties: the manual struct enumerators allocate nothing but still cost 2.6x, because List<T>.Enumerator.MoveNext carries a version check and a bounds check per element. If your concern is GC pressure, any zero-allocation row solves it. If your concern is throughput, use spans.

Choosing between them

If you are migrating a lot of LINQ to spans, remember that C# 14 changed how array arguments bind to span overloads; the implicit span conversions in C# 14 explain why a call that used to pick an IEnumerable<T> overload may now pick a span one.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back