I 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

content 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

content 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

Further reading

  1. MemoryExtensions Class.
  2. Spanification.
  3. Pro .NET Memory Management.