Performance is always an important factor in software development. It is not something only the developers of a framework must consider. When the .NET team released the Span<>
struct they empowered developers to improve application performance, if used correctly. In this article, we will learn about Span in C#, how it is implemented, and how we can use it to increase performance.
Let’s start.
Understanding Span<T> in C#
First, let’s take a look at Span<>
and see how it is implemented in .NET. We will see why coding with span is limiting but improving performance.
A Span<>
is an allocation-free representation of contiguous regions of arbitrary memory. Span<>
is implemented as a ref struct
object that contains a ref
to an object T
and a length. This means that a Span in C# will always be allocated to stack memory, not heap memory. Let’s consider this simplified implementation of Span<>
:
public readonly ref struct Span<T> { private readonly ref T _pointer; private readonly int _length; }
This example implementation allows us to see the main components of a span. There is a reference to an object on the heap of type T
and a length to read.
Using Span<>
leads to performance increases because they are always allocated on the stack. Since garbage collection does not have to suspend execution to clean up objects with no references on the heap as often the application runs faster. Pausing an application to collect garbage is always an expensive operation and should be avoided if possible. Span<>
operations can be as efficient as operations on arrays. Indexing into a span does not require computation to determine the memory address to index to.
Another implementation of a Span in C# is ReadOnlySpan<>
. It is a struct exactly like Span<>
other than that its indexer returns a readonly ref T,
not a ref T
. This allows us to use ReadOnlySpan<>
to represent immutable data types such as String
.
Spans can use other value types such as int
, byte
, ref structs
, bool
, and enum
. Spans can not use types like object
, dynamic
, or interfaces
.
Span Limitations
Span’s implementation limits its use in code, but conversely, it provides span useful properties.
The compiler allocates reference type objects on the heap which means we cannot use spans as fields in reference types. More specifically ref struct
objects cannot be boxed like other value-type objects. For the same reason, lambda statements can not make use of spans either. Spans can not be used in asynchronous programming across await
and yield
boundaries.
Spans are not appropriate in all situations. Because we are allocating memory on the stack using spans, we must remember that there is less stack memory than heap memory. We must consider this when choosing to use spans over strings.
If we want to use a span-like class in asynchronous programming we could take advantage of Memory<>
and ReadOnlyMemory<>
. We can create a Memory<>
object from an array and slice it as we will see, we can do with a span. Once we can synchronously run code, we can get a span from a Memory<>
object.
How To Use ReadOnlySpan Instead of String
First, let’s discuss how we can use ReadOnlySpan<>
when operating over strings to get the performance benefits we are searching for.
The goal is to use span instead of string
as much as possible. The ideal situation is to have a minimum number string of which we can use spans to operate over.
Let’s consider an example where we have to parse a string by line:
public void ParseWithString() { var indexPrev = 0; var indexCurrent = 0; var rowNum = 0; foreach (char c in _hamletText) { if (c == '\n') { indexCurrent += 1; var line = _hamletText.Substring(indexPrev, indexCurrent - indexPrev); if (line.Equals(Environment.NewLine)) rowNum++; indexPrev = indexCurrent; continue; } indexCurrent++; } Console.WriteLine($"Number of empty lines in a file: {rowNum}"); }
First, let’s review this example using strings. We are trying to determine how many empty lines our file has. The text parse is stored in the _hamletText
string variable. We iterate over each char
in the test string. If we find a new line character \n
we use Substring()
to create a new string containing a line of text. If that line is an empty one, we increase our counter. The key here is that Substring()
will create a string
on the heap. The garbage collector will take time to destroy these strings.
Now, the same parsing process using ReadOnlySpan<>
:
public void ParseWithSpan() { var hamletSpan = _hamletText.AsSpan(); var indexPrev = 0; var indexCurrent = 0; var rowNum = 0; foreach (char c in hamletSpan) { if (c == '\n') { indexCurrent += 1; var slice = hamletSpan.Slice(indexPrev, indexCurrent - indexPrev); if (slice.Equals(Environment.NewLine, StringComparison.OrdinalIgnoreCase)) rowNum++; indexPrev = indexCurrent; continue; } indexCurrent++; } Console.WriteLine($"Number of empty lines in a file: {rowNum}"); }
Here the process is the same except that we do not create additional strings for each line. We convert the text string to a ReadOnlySpan<>
by calling the AsSpan()
method. Additionally, instead of Substring()
we use the Slice()
method. Slice()
returns a ReadOnlySpan<>
representing the substring. In this case, nothing has been allocated to the heap.
Recall that garbage collecting objects in memory pauses execution directly affecting application performance. Applying these principles to large production software, complete with logging sub-systems, we can see how these performance increases can compound.
ReadOnlySpan<>
includes many familiar functions we associate with String
. We can use Contains()
, EndsWith()
, StartsWith()
, IndexOf()
, LastIndexOf()
, ToString()
, and Trim()
.
How To Use Span Instead of Collections
Since spans can represent a contiguous section of memory, this means we can use them to operate over arrays and other collection types.
First, let’s consider the example with arrays:
int[] arr = new[] { 0, 1, 2, 3 }; Span<int> intSpan = arr; var otherSpan = arr.AsSpan();
We can see C# offers an implicit cast from T[]
to Span<T>
, but we are able to call AsSpan()
on arrays as well. Just like ReadOnlySpan<>
, Span<>
offers familiar functions to manipulate the span in an allocation-free way.
Now let’s look at a similar example with a collection such as List<>
:
List<int> intList = new() { 0, 1, 2, 3 }; var listSpan = CollectionsMarshal.AsSpan(intList);
We can see that it is not as simple to use spans with a collection as it was with arrays. In this case, we must use the CollectionMarshal.AsSpan()
method to get the collection as a span. To use the marshal we must import System.Runtime.InteropServices
.
Testing With A Benchmark
In this section, we will evaluate the results of a benchmark running the code from the String
and ReadOnlySpan<>
example.
Let’s discuss the results we might expect to receive from this benchmark comparison. As we know, using spans increases performance by not allocating new objects to the heap and using less memory. We would expect to see an improvement with run time and memory allocated.
Now, let’s look at the results:
| Method | Mean | Error | StdDev | Gen 0 | Allocated | |---------------- |---------:|---------:|---------:|--------:|----------:| | ParseWithSpan | 23.55 us | 0.109 us | 0.102 us | - | - | | ParseWithString | 36.12 us | 0.064 us | 0.054 us | 16.7236 | 70,192 B |
We can see from the data that ParseWithString()
took a mean time of 36.12μs and allocated 70.2kB (70,192B) of memory. ParseWithSpan()
had a mean time of 23.55μs and allocated no additional memory. The results are as we expected. The span function runs faster and allocates much less memory.
Conclusion
In this article, we have covered the implementation of spans, why their use improves performance, and how to use spans in our code. It is important to note that the .NET development team is actively supporting spans in their libraries and making their use for developers as simple as possible. The .NET team already has Span<>
support for DateTime
, TimeSpan
, Int32
, GUID
, StringBuilder
, System.Random
, and many others. It is important to understand how to use Span<>
because they will be more present in future .NET code and may become the standard in some cases.