Implementing ISpanFormattable

I have recently realized that implementing the ISpanFormattable interface enables a type to participate in string interpolation feature of C# in a more efficient way. This realization made me curious how much more efficient it is to implement the corresponding public bool TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> format = default, IFormatProvider? provider = default) method to the standard ToString(). In this blog post I use .NET8 and C# 12.

Implementing ISpanFormattable is not automatically justified for all types, but ones that participate in frequent serialization, such as string interpolation. One can search the types that implement this interface, and it contains most of the primitive and value types (int, Guid, DateTime, etc).

Fortunately I just have one such type on hand that lends itself for the investigation. In a previous blog post, I iterated through a couple of different implementation of FractionalDouble type.

While understanding the details of the FractionalDouble format is less relevant for this post, here is a quick summary:

The type was used to process market data updates of U.S. Treasury Notes. The pricing of U.S. Treasury Notes is detailed on the cmegroup site.

For further details the reader is encouraged to review the previous post. In this post I look at two types: FractionalClassic, which does not implement the ISpanFormattable and CopyFractional, which implements it.

Single Interpolated Expression

In this first case the interpolated strings only contain a single interpolated expression. In this case I compare three subcases:

NonSpanFormattableInterpolatedString uses FractionalClassic that only overrides the ToString() method.

var fractional = new FractionalClassic(99, 14, 2);
return $"The value is: {fractional}";

SpanFormattableInterpolatedString uses CopyFractional that implements ISpanFormattable, hence the interpolated string builder uses the TryFormat() method.

var fractional = new CopyFractional() { Integer = 99, Fractions128 = 58 };
return $"The value is: {fractional}";

SpanFormattableToString uses CopyFractional with the interpolated expression using the overridden ToString() method, that internally uses the TryFormat() method.

var fractional = new CopyFractional() { Integer = 99, Fractions128 = 58 };
return $"The value is: {fractional.ToString()}";

For the performance benchmarks I used BenchmarkDotNet nuget package version 0.13.11. The table below concludes the raw results:

| Method                                | Mean      | Error    | StdDev   | Gen0   | Allocated |
|-------------------------------------- |----------:|---------:|---------:|-------:|----------:|
| NonSpanFormattableInterpolatedString  | 106.16 ns | 1.277 ns | 1.066 ns | 0.0216 |     136 B |
| SpanFormattableInterpolatedString     |  25.72 ns | 0.206 ns | 0.193 ns | 0.0102 |      64 B |
| SpanFormattableToString               |  13.66 ns | 0.290 ns | 0.271 ns | 0.0166 |     104 B |

NonSpanFormattableInterpolatedString uses the FractionalClassic solution. This solution performs the worst of all, not only it is multiple times slower, but allocates the most amount of memory.

SpanFormattableInterpolatedString uses CopyFractional as $"The value is: {fractional}". This solution provides the best use of memory. For a 20-char string it allocates only the memory required for the string itself (including the object header and MT and a size field in the string itself). However, this is not the fastest solution based on the mean execution time.

SpanFormattableToString also uses CopyFractional, but it calls the ToString() inside the interpolated string. This solution results an additional string allocation for the formatted string, which is then simply concatenated for the final result. While this solution is faster it incurs an undesired extra allocation on the heap.

Multiple Interpolated Expressions

The above results raise the question: is using the ToString() always faster? To answer to this question, I extended the performance benchmarks with the following two tests:

LongSpanFormattableInterpolatedString uses three instances of CopyFractional and the interpolated string builder uses the TryFormat() method.

var fractional0 = new CopyFractional() { Integer = 99, Fractions128 = 58 };
var fractional1 = new CopyFractional() { Integer = 99, Fractions128 = 58 };
var fractional2 = new CopyFractional() { Integer = 99, Fractions128 = 58 };
return $"The value is: {fractional0}, before that it was {fractional1} and it is going to be {fractional2}.";

LongSpanFormattableToString uses three instances of CopyFractional with the invoking the ToString() method in the interpolated expression.

var fractional0 = new CopyFractional() { Integer = 99, Fractions128 = 58 };
var fractional1 = new CopyFractional() { Integer = 99, Fractions128 = 58 };
var fractional2 = new CopyFractional() { Integer = 99, Fractions128 = 58 };
return $"The value is: {fractional0.ToString()}, before that it was {fractional1.ToString()} and it is going to be {fractional2.ToString()}.";

The performance results are concluded by the following table:

| Method                                | Mean      | Error    | StdDev   | Gen0   | Allocated |
|-------------------------------------- |----------:|---------:|---------:|-------:|----------:|
| LongSpanFormattableInterpolatedString |  42.95 ns | 0.311 ns | 0.276 ns | 0.0280 |     176 B |
| LongSpanFormattableToString           |  51.12 ns | 0.569 ns | 0.505 ns | 0.0471 |     296 B |

Here using the TryFormat() method gets significantly more beneficial. Not only does it allocate less on the heap, but it also shows a better mean execution time.

Conclusion

A type that participates in serialization such as string interpolation may benefit significantly when implementing the ISpanFormattable interface. The above table shows that such implementation provides the most memory efficient solution, as well it may provide the fastest solution. However, a net benefit for the mean execution time will vary greatly on the number of instances and the way the type implements the TryFormat() method.