What is ReadOnlySpan<T> in C# and how fast is it comparing to strings?
#csharp #benchmarkI already wrote an article about the fastest way of extracting substrings in C#. Now I want to investigate Span structures more. Recently, Microsoft released the .NET 8 platform which has several new extension methods for ReadOnlySpan<T>
. So I want to compare the performance of MemoryExtensions
methods with counterparts in string class.
Benchmark
All the benchmarks use a JSON-file with a string array of size 135 892 elements. Each array element represents the different permissions for different folders. Strings has the following template:
<permission> for Folder: \\server-name\path\to\folder\
For example:
DENY Permission for Folder: \\server-name\path\to\folder\
My employer won’t be happy if I share the file, so you should trust me that this file contains such data. :)
In this benchmark, we’ll consider the following 8 methods:
string |
ReadOnlySpan<T> |
---|---|
Contains | Contains |
StartsWith | StartsWith |
IndexOf | IndexOf |
Replace | Replace |
Split | Split |
ToLowerInvariant | ToLowerInvariant |
Trim | Trim |
Substring | Slice |
In the context of Span-based methods, we also take into account the "Span + Allocation" scenario, which involves string allocation using the ToString method where applicable. This scenario arises when Span-based methods are initially used, but the ultimate outcome includes the allocation of a string through the ToString method.
As usual, for benchmarking, I used the BenchmarkDotNet library. The whole code of the benchmark class can be found here.
Results
Contains
The benchmark assesses whether each string in the provided collection contains the backslash character.
Benchmark | Mean execution time, μs | Execution time ratio | Gen 0 collections per 1000 operations | Allocated memory, bytes | Allocated ratio |
---|---|---|---|---|---|
String |
615.7 | – | 0 | 1 | – |
ReadOnlySpan<T> |
624.1 | -2% | 0 | 1 | – |
StartsWith
The benchmark evaluates whether each string in the given collection starts with the specified substring.
Benchmark | Mean execution time, μs | Execution time ratio | Gen 0 collections per 1000 operations | Allocated memory, bytes | Allocated ratio |
---|---|---|---|---|---|
String |
56 681.1 | – | 0 | 82 | – |
ReadOnlySpan<T> |
329.6 | -99.4% | 0 | 0 | -100% |
IndexOf
The benchmark retrieves the index of the substring "Folder" within each string in the given collection.
Benchmark | Mean execution time, μs | Execution time ratio | Gen 0 collections per 1000 operations | Allocated memory, bytes | Allocated ratio |
---|---|---|---|---|---|
String |
224 182.4 | – | 0 | 245 | – |
ReadOnlySpan<T> |
1 131.6 | -99.5% | 0 | 1 | -99.6% |
Split
This benchmark, designed as a synthetic test, utilizes the Split method to determine the maximum number of substrings between separators in each string within the provided collection.
Benchmark | Mean execution time, μs | Execution time ratio | Gen 0 collections per 1000 operations | Allocated memory, bytes | Allocated ratio |
---|---|---|---|---|---|
String |
16 241.3 | – | 4468.75 | 56 127 351 | – |
ReadOnlySpan<T> |
9 977.2 | -39% | 0 | 1060 | -100% |
Replace
These benchmarks focus on replacing backslashes with forward slashes in each string within the provided collection, using different methods for string manipulation.
Benchmark | Mean execution time, μs | Execution time ratio | Gen 0 collections per 1000 operations | Allocated memory, bytes | Allocated ratio |
---|---|---|---|---|---|
String |
3 156.8 | – | 2019.53 | 25 353 195 | – |
ReadOnlySpan<T> |
2 663.0 | -16% | 0 | 3 | -100% |
ReadOnlySpan<T> with allocation |
16 701.5 | +430% | 2015.62 | 25 353 204 | -0% |
ToLowerInvariant
These benchmarks focus on converting each string within the provided collection to lowercase, utilizing different methods for case transformation.
Benchmark | Mean execution time, μs | Execution time ratio | Gen 0 collections per 1000 operations | Allocated memory, bytes | Allocated ratio |
---|---|---|---|---|---|
String |
3 771.3 | – | 2019.53 | 25 353 195 | – |
ReadOnlySpan<T> |
3 884.3 | +3% | 0 | 3 | -100% |
ReadOnlySpan<T> with allocation |
17 938.6 | +374% | 2015.62 | 25 353 204 | -0% |
Trim
These benchmarks focus on trimming trailing backslashes from the specified substring in each string within the provided collection, utilizing various approaches to achieve the desired result.
Benchmark | Mean execution time, μs | Execution time ratio | Gen 0 collections per 1000 operations | Allocated memory, bytes | Allocated ratio |
---|---|---|---|---|---|
String |
687.4 | – | – | 553 | – |
ReadOnlySpan<T> |
469.1 | -32% | – | – | -99.8% |
ReadOnlySpan<T> with allocation |
2 968.1 | +332% | 2019.53 | 25 353 195 | +4 584 565% |
Substring
These benchmarks focus on extracting a substring between specific markers in each string within the provided collection, using different methods to achieve the desired substring extraction.
Benchmark | Mean execution time, μs | Execution time ratio | Gen 0 collections per 1000 operations | Allocated memory, bytes | Allocated ratio |
---|---|---|---|---|---|
String |
1 523.8 | – | 779.3 | 9784225 | – |
ReadOnlySpan<T> |
347.9 | -77% | – | – | -100% |
ReadOnlySpan<T> with allocation |
1 694.5 | +11% | 779.3 | 9784225 | +0% |
Conclusion
ReadOnlySpan<T>
without allocation
ReadOnlySpan execution ratio
As we can see from the figure above, almost all extension methods for ReadOnlySpan<T>
are faster than analogues from string class. The only exception is the ReadOnlySpan<T>.ToLower
method. I assume that It is because this method copies the characters from the source span into the destination.
ReadOnlySpan<T>
with allocation
ReadOnlySpan with allocation execution ratio
The string.Replace, string.ToLower, string.TrimEnd, string.Substring methods outperform the combination of ReadOnlySpan<T>
methods and ReadOnlySpan<T>.ToString
. This difference is likely due to efficient implementations of these methods. For example, invoking the string.TrimEnd
leads to a call of the Buffer.Memmove
method. It appears that the string allocation process using Buffer.Memmove
is more efficient than the implementation of ReadOnlySpan<T>.ToString
.
Memory consumption
Span-based methods exhibit superior memory efficiency, with zero memory allocations and no observed Gen 0 collections. String methods, particularly in operations like Split
, Replace
, and ToLower
, tend to incur more significant memory allocations and, in some cases, Gen 0 collections. Therefore, for memory-conscious applications, utilizing Span-based methods may offer performance advantages in terms of reduced memory footprint and improved garbage collection behavior.
Notice almost the same or even worse results for the “Span + Allocation” column. Despite the utilization of Span-based methods in the intermediate steps, the inclusion of string allocation in the final outcome appears to negate some of the memory efficiency gained by using Span. This indicates that, in this specific context, the allocation of strings during or after Span-based operations may mitigate the potential memory benefits associated with using Span.
Generation 0 collections per 1000 operations
Categories | String | Span | Span + Allocation |
---|---|---|---|
Contains |
0 | 0 | - |
StartsWith |
0 | 0 | - |
IndexOf |
0 | 0 | - |
Split |
4468.75 | 0 | - |
Replace |
2019.5313 | 0 | 2015.625 |
ToLower |
2019.5313 | 0 | 2015.625 |
Trim |
1333.3333 | 0 | 2019.5313 |
Substring |
779.3 | 0 | 779.3 |
Memory allocated, Mb
Categories | String | Span | Span + Allocation |
---|---|---|---|
Contains |
0.00 | 0.00 | - |
StartsWith |
0.00 | 0.00 | - |
IndexOf |
0.00 | 0.00 | - |
Split |
53.53 | 0.00 | - |
Replace |
24.18 | 0.00 | 24.18 |
ToLower |
24.18 | 0.00 | 24.18 |
Trim |
0.00 | 0.00 | 24.17 |
Substring |
9.33 | 0.00 | 9.33 |