Better C# code performance with Span

Share this post

Performance is affected by many factors – starting from the application design or architecture to the way the application code was implemented. Deciding what data structures to use and how to use them is just as important as deciding the application architecture.  

The Optimize-Measure Loop

Just like any other improvement process, it is very important to rely on precise measurements and make small steps for improvement. Locate the piece of code you believe needs to be improved, measure the performance, implement small improvements then measure again, and so on. Doing small changes in small steps, helps you see and measure more accurately the changes you implemented since there aren’t a lot of files, classes, or methods that can potentially be affecting your current measurement cycle.

There are plenty of tools that allow us to measure application performance, my personal favorites are:  

  • dotMemory
  • dotTrace
  • Benchmark.Net  

The list of course goes on, but as I mentioned, the above are my own personal favorites.

Which improvement I will discuss?

There are many aspects of application performance that can be measured and profiled, some are general (like memory consumption) and some are application specific (for example how much time it takes for an image to get loaded). In this post, however, I will be focusing on Execution Time, Throughput, and memory allocations.

I will use Benchmark.Net to measure the aspects mentioned above.

Span<T>, ReadOnlySpan<T>

Span<T> is a new value type that was first introduced in C# 7.2, and it represents a continuous region of arbitrary memory, regardless if the memory is associated with a managed object or is on the stack. Its primary goal is to avoid allocating new objects in the heap. We can work with Span with objects like:

  • Arrays and subarrays
  • Strings and substring
  • Unmanaged memory buffers.

Since Span<T> is defined as ref struct under the hood, it is a value type, which means it resides in the stack, and not in the heap. It provides a type-safe (meaning, it does not allow objects of one type to access or look into memory allocated to other objects) access to continuous memory blocks that can be in the heap, stack even in the form of unmanaged memory.

Using Span we gain benefits by:

  • No memory will be allocated in the heap!
  • Less pressure on the garbage collector, since it does not need to track non-allocated objects.
  • The allocated memory is in the stack, which is super-fast.

Span<T> Provides read-write access to a block of memory, while ReadOnlySpan<T> provides read-only access to a memory block (Basically a view into the underlying memory, and not a way to allocate memory, create objects or modify the contents of the memory underneath).
We can use Span<T> with: Heap (Managed objects), Stack (via stackable), or with Native/Unmanaged objects.

Examples

In the following example, the task being measured is extracting some string data from a large array (for the sake of an example). To do so, I’ve tried to do the same login in two different methods: One using Substring and the other using Span<T>.

// data sample : 0a57514e-3fd3-4c7e-9195-99d84bd3ac67,12/10/2022,/orders/all,Peru

[Benchmark(Baseline = true)]
public void SubstringExtract() {
  for (int i = 0; i < N; i++) {
    var firstComma = data[i].IndexOf(',', 0);
    var secondComma = data[i].IndexOf(',', firstComma + 1);
    var line = data[i].Substring(firstComma + 1, secondComma - firstComma - 1);
  }
}

[Benchmark]
public void SpanExtract() {
  for (int i = 0; i < N; i++) {
    var firstComma = data[i].IndexOf(',', 0);
    var secondComma = data[i].IndexOf(',', firstComma + 1);
    var line = data[i].AsSpan().Slice(firstComma + 1, secondComma - firstComma - 1);
  }
}

The advantage of using Span over Substring, clearly grows as the input data grows (up to 50% improvement in the 50K lines example). We can also see that with Span, there is no memory allocation at all!

Let’s see another example, this time I will be iterating a large collection of items (enumerable) with and without Span:

[MemoryDiagnoser]
public class InterationBenchmark {
  static List < string > list;

  [Params(1000, 10000)]
  public int size {
    get;
    set;
  }

  [GlobalSetup]
  public void Setup() {
    list = Enumerable.Range(1, size).Select(x =>Guid.NewGuid().ToString()).ToList();
  }

  [Benchmark]
  public void For() {
    for (int i = 0; i < list.Count; i++) {
      var itm = list[i].AsSpan().Slice(10);

    }
  }

  [Benchmark]
  public void ForEach() {
    foreach(var item in list) {
      var itm = item.AsSpan().Slice(10);
    }
  }

  [Benchmark]
  public void CollectionsMarshal_AsSpan() {
    foreach(var item in CollectionsMarshal.AsSpan(list)) {
      var itm = item.AsSpan().Slice(10);
    }
  }
}

And the execution results are:

Since Span<T> is all about memory (continuous memory), a good example would be to check the continuation of Span to the performance by using Array methods, like Array.Copy.

Example when comparing Array.Copy use cases:

[MemoryDiagnoser]
public class BenmchMarkDemo_Arrays {
  private int[] _someArray;

  [Params(100, 1000, 10000)]
  public int Size {
    get;
    set;
  }

  [GlobalSetup]
  public void Setup() {
    _someArray = new int[Size];
    for (int i = 0; i < _someArray.Length; i++) {
      _someArray[i] = i;
    }
  }

  [Benchmark]
  public int[] Original() {
    return _someArray.Skip(Size / 2).Take(Size / 4).ToArray();
  }

  [Benchmark]
  public int[] ArrayCopy() {
    var copy = new int[Size / 4];
    Array.Copy(_someArray, Size / 2, copy, 0, Size / 4);
    return copy;
  }

  [Benchmark]
  public Span < int > Span() {
    return _someArray.AsSpan().Slice(Size / 2, Size / 4);
  }
}

Limitations of Span<T>

There are a few things to keep in mind when working with Span<\T>:

Span<T> is a ref-struct object, which is allocated in the stack rather than the heap. Ref struct has a number of limitations and restrictions that prevents them from being promoted to the heap. Having mentioned this, the following limits apply when working with Span<T>:

  • They cannot be boxed.
  • They cannot be assigned to variables of type Object, dynamic or to any other interface type.
  • They cannot be fields in reference types:
public class CannotDoThis {
        // Span is Stack-only, so it must not be stored in the heap.
        Span<byte> someField;
    }

Reference and Further readings

You may refer to the following links for the official documentation on Span<T>:

Final thoughts

Span<T> and ReadOnlySpan<T> Can provide a performance boost when used for the right task. However, they come with the price of writing more complex code (sometimes), which can be difficult to handle or manage in some cases. Should you now go and refactor your application to using Span? measure first! consider the scenarios and use cases, and keep in the mind the return of investment for moving to Span.

Keep learning, keep coding.

Cover Image by mibro from Pixabay