Have you ever wondered what happens behind the scenes when you use range expressions in C#? Range expressions are a convenient way to access a slice of an array or a span, using the syntax data[start..end]. For example, data[..1024] means the first 1024 elements of data, and data[1024..] means the rest of the elements.

Let’s take a look at a few examples and run benchmarks. The results might be surprising.

Benchmark setup

We focus on a few typical approaches to passing data

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65

[MemoryDiagnoser]
[MarkdownExporterAttribute.GitHub]
[CsvExporter]
[Orderer(SummaryOrderPolicy.FastestToSlowest, MethodOrderPolicy.Declared)]
public class Benchmark
{
    private const int KB = 1024;
    // 4 MB to fit single memory page
    private byte[] data = new byte[4 * KB * KB];


    [GlobalSetup]
    public void Setup()
    {
        new Random(42).NextBytes(data);
    }
    
    [Benchmark(Baseline = true)]
    public int Offsets() 
    {
        return  Utils.ComputeSum(data,0,KB)
              + Utils.ComputeSum(data,KB, KB)
              + Utils.ComputeSum(data,2*KB, KB)
              + Utils.ComputeSum(data,3*KB, KB);
    }

    [Benchmark]
    public int Span() 
    {
        return  Utils.ComputeSum(new Span<byte>(data, 0     , KB))
              + Utils.ComputeSum(new Span<byte>(data, KB    , KB))
              + Utils.ComputeSum(new Span<byte>(data, 2 * KB, KB))
              + Utils.ComputeSum(new Span<byte>(data, 3 * KB, KB));
    }

    [Benchmark]
    public int ArraySegment() 
    {
        var d = new ArraySegment<byte>(data);
        return  Utils.ComputeSum(d.Slice(0,KB))
              + Utils.ComputeSum(d.Slice(KB,KB))
              + Utils.ComputeSum(d.Slice(2*KB,KB))
              + Utils.ComputeSum(d.Slice(3*KB,KB));
    }

    [Benchmark]
    public int SpanRanges() 
    {
        Span<byte> d = data;
        return  Utils.ComputeSum(d[..KB])
              + Utils.ComputeSum(d[KB..(2*KB)])
              + Utils.ComputeSum(d[(2*KB)..(3 * KB)])
              + Utils.ComputeSum(d[(3*KB)..]);
    }
        
    [Benchmark]
    public int ArrayRanges() 
    {
        return  Utils.ComputeSum(data[..KB])
              + Utils.ComputeSum(data[KB..(2*KB)])
              + Utils.ComputeSum(data[(2*KB)..(3 * KB)])
              + Utils.ComputeSum(data[(3*KB)..]);
    }
}

⚡ The data processing code is intentionally oversimplified

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public sealed class Utils
{
    public static int ComputeSum(Span<byte> chunk)
    {
        int sum = 0;
        foreach (var value in chunk)
            sum += value;

        return sum;
    }
    public static int ComputeSum(ArraySegment<byte> chunk)
    {
        int sum = 0;
        foreach (var value in chunk)
            sum += value;

        return sum;
    }
    public static int ComputeSum(IEnumerable<byte> chunk)
    {
        int sum = 0;
        foreach (var value in chunk)
            sum += value;

        return sum;
    }
    public static int ComputeSum(byte[] data, int start, int count)
    {
        int sum = 0;
        for (int i = 0; i < count; i++)
            sum += data[start + i];

        return sum;
    }
}

Benchmark results

1
2
3
4
5
6

BenchmarkDotNet v0.13.6, Windows 11 (10.0.22621.1992/22H2/2022Update/SunValley2)
AMD Ryzen 7 3800X, 1 CPU, 16 logical and 8 physical cores
.NET SDK 7.0.203
  [Host]     : .NET 7.0.5 (7.0.523.17405), X64 RyuJIT AVX2
  DefaultJob : .NET 7.0.5 (7.0.523.17405), X64 RyuJIT AVX2
MethodMeanRatioGen0Gen1Gen2Allocated
Offsets1.771 μs1.00----
Span1.941 μs1.10----
ArraySegment2.883 μs1.63----
SpanRanges1,086.565 μs622.66---1 B
ArrayRanges3,632.562 μs2,050.90332.0313332.0313332.03134194506 B

Image alt

Analysis

Scenario 1 - Data access using an offset and length

1
2
3
4
5
6
7
[Benchmark]
public int Offsets() 
{
  // ...
  Utils.ComputeSum(data,0,KB);
  //
}

Pros:

  • ⚡ The fastest way to access the data
  • 👍 No compiler magic happens behind the scenes
  • 👍 No pressure on garbage collection system - Zero allocations

Cons:

  • 👎 Low-level approach hence tends to be more verbose and error-prone
  • 👎 Non-declarative and more difficult to maintain
  • 👎 No abstraction of sequential memory
MethodMeanRatioGen0Gen1Gen2Allocated
Offsets1.771 μs1.00----

Scenario 2 - Span<T> over a section of an array

1
2
3
4
5
6
7
[Benchmark]
public int Span() 
{
  // ...
  Utils.ComputeSum(new Span<byte>(data, 0     , KB));
  //
}

Pros:

  • 👍 Fast! Only marginally slower than the raw access to the array
  • 👍 No compiler magic happens behind the scenes
  • 👍 No pressure on garbage collection system - Zero allocations
  • 👍 Abstract away random access sequantial chunk of memory
  • 👍 Supports fast and ergonomic code on the processing side
  • 👍 Reduces code verbosity and improves consistency.

Cons:

  • 👎 Requires .net standard 2.1
MethodMeanRatioGen0Gen1Gen2Allocated
Span1.941 μs1.10----

Scenario 3 - ArraySegment<T> over a section of an array

1
2
3
4
5
6
7
8
[Benchmark]
public int ArraySegment() 
{
    var d = new ArraySegment<byte>(data);
    return  Utils.ComputeSum(d.Slice(0,KB))
          + Utils.ComputeSum(d.Slice(KB,KB))
          // ...
}

Pros:

  • 👍 Fast! Only marginally slower than the raw access to the array
  • 👍 No compiler magic happens behind the scenes
  • 👍 No pressure on garbage collection system - Zero allocations
  • 👍 Supports all versions of .net standard
  • 👍 Supports fast and ergonomic code on the processing side
  • 👍 Reduces code verbosity and improves consistency.

Cons:

  • 👎 Leaking abstraction of the underlying array
  • 👎 Pre Nullable<T> types API might require usage of the null forgiving operator in your code
MethodMeanRatioGen0Gen1Gen2Allocated
ArraySegment2.883 μs1.63----

Scenario 4 - Span<T> and a range expression

1
2
3
4
5
6
7
[Benchmark]
public int SpanRanges() 
{
    Span<byte> d = data;
    return  Utils.ComputeSum(d[..KB])
          + Utils.ComputeSum(d[KB..(2*KB)])
}

💥 Surprise 💥 benchmark shows x 622.66 slowdown ratio relative to the baseline.

MethodMeanRatioGen0Gen1Gen2Allocated
SpanRanges1,086.565 μs622.66---1 B

Analysis:

A compiler translates the syntax sugar above into the following code.

1
2
3
4
5
6
7
[Benchmark]
public int SpanRanges() 
{
    Span<byte> d = data;
    return  Utils.ComputeSum(d.Slice(0,1024)
          + Utils.ComputeSum(d.Slice(1024, 1024))
}

As you can see below, there is nothing in this code except boundaries check when the internal unmanaged _reference handle is accessed. I speculate that this is the main reason for the slowdown. You can read more information about this method here

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// System.Private.CoreLib, Version=7.0.0.0,
// Culture=neutral, PublicKeyToken=7cec85d7bea7798e
// System.Span<T>

using System.Runtime.CompilerServices;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Span<T> Slice(int start, int length)
{
	if ((ulong)((long)(uint)start + (long)(uint)length) > (ulong)(uint)_length)
	{
    		ThrowHelper.ThrowArgumentOutOfRangeException();
	}
	return new Span<T>(ref Unsafe.Add(ref _reference, 
                                    (nint)(uint)start), length);
}

Scenario 5 - range expression over arrays

⚠️ Warning This is the slowest and the least efficient way to access the data

1
2
3
4
5
6
7
[Benchmark]
public int ArrayRanges() 
{
    return  Utils.ComputeSum(data[..KB])
          + Utils.ComputeSum(data[KB..(2*KB)])
    // ...
}

This syntactic sugar is designed to make things easier to read or to express the data access pattern. But without proper support from the collection, it will lead to a performance disaster. It is expended into the call to the method public static T[] GetSubArray<T>(T[] array, Range range) of the class System.Runtime.CompilerServices.RuntimeHelper

Essentially, the snippet above is equivalent to the following:

1
2
3
4
5
6
7
[Benchmark]
public int ArrayRanges() 
{
    return Utils.ComputeSum(RuntimeHelpers.GetSubArray(data, ..1024))
           + Utils.ComputeSum(RuntimeHelpers.GetSubArray(data, 1024..2048))
          // ...
}

As you can see, the range expressions are replaced by calls to GetSubArray with the same range arguments. This means that every time you use a range expression, you create a new memory array, which may have some performance implications. If you want to avoid this overhead, you can use spans instead of arrays, lightweight references to a contiguous memory region. Spans support range expressions natively without creating new objects.

The memory allocation pattern is visible in the decompiled source code below.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18

// System.Private.CoreLib, Version=7.0.0.0, Culture=neutral,
// PublicKeyToken=7cec85d7bea7798e
// System.Runtime.CompilerServices.RuntimeHelpers
using System.Runtime.InteropServices;

public static T[] GetSubArray<T>(T[] array, Range range)
{
 //...

 T[] array2 = new T[num];
 
 Buffer.Memmove(ref MemoryMarshal.GetArrayDataReference(array2), 
                ref Unsafe.Add(
                    ref MemoryMarshal.GetArrayDataReference(array), 
                        elementOffset), (UIntPtr)(uint)num);
 return array2;
}

Pros:

  • 👍 It makes reading or expressing the data access pattern easier.
  • 👍 Supports fast and ergonomic code on the processing side

Cons:

  • 👎 Creates GC pressure!
  • 👎 💥 Might not be sutable for high performance scenarios.
  • 👎 Since the memory segments were larger than 78KB, the memory was allocated using the large object heap and was subject of Gen2 garbage collection. In other words, this is a well-known antipattern.
  • 👎 💥 Three orders of magnitude slower than the baseline approach
MethodMeanRatioGen0Gen1Gen2Allocated
ArrayRanges3,632.562 μs2,050.90332.0313332.0313332.03134194506 B

Conclusion

In conclusion, range expressions are a useful feature of C# that allows you to access slices of arrays or spans with a concise and readable syntax. However, they also rely on a helper method that creates new arrays under the hood, which may affect your performance. To optimize your code, you can use spans instead of arrays when possible, or avoid using range expressions in performance-critical scenarios.