String Interpolation and StringBuilder in .NET6
09/26/2021
4 minutes
In a previous post I have looked into what are the performance characteristics of creating interpolated string in .NET 5 and early previews of .NET 6. With .NET 6 preview 7 or better a new approach is used by the C# compiler with support from the BCL to build interpolated strings.
Fortunately the new way provides a faster approach for the most convenient ways for assembling strings, however it makes my previous post completely obsolete.
For details about the new way
DefaultInterpolatedStringHandler
builds strings, read: https://devblogs.microsoft.com/dotnet/string-interpolation-in-c-10-and-net-6/
Re-running the benchmarks from the previous post, shows much better memory usage:
BenchmarkDotNet=v0.13.0, OS=Windows 10.0.19043.1165 (21H1/May2021Update) Intel Core i5-1035G4 CPU 1.10GHz, 1 CPU, 8 logical and 4 physical cores .NET SDK=6.0.100-preview.7.21379.14 [Host] : .NET 6.0.0 (6.0.21.37719), X64 RyuJIT DefaultJob : .NET 6.0.0 (6.0.21.37719), X64 RyuJIT | Method | Mean | Error | StdDev | Gen 0 | Gen 1 | Gen 2 | Allocated | Code Size | |------------------------- |---------:|---------:|---------:|-------:|------:|------:|----------:|----------:| | StringInterpolation | 54.13 ns | 1.125 ns | 1.613 ns | 0.0229 | - | - | 72 B | 2,132 B | | AppendAllParts | 29.78 ns | 0.653 ns | 0.936 ns | 0.0229 | - | - | 72 B | 1,110 B | | AppendFormat | 77.67 ns | 1.608 ns | 2.201 ns | 0.0229 | - | - | 72 B | 3,351 B | | AppendInterpolatedFormat | 43.19 ns | 0.934 ns | 1.181 ns | 0.0229 | - | - | 72 B | 1,167 B | | StringCreate | 18.38 ns | 0.423 ns | 0.633 ns | 0.0229 | - | - | 72 B | 730 B | | StringArrayPoolCreate | 42.78 ns | 0.914 ns | 1.502 ns | 0.0229 | - | - | 72 B | 2,800 B |
The two main improvements compared to the previous benchmark are: StringInterpolation
and AppendInterpolatedFormat
cases. Both of them allocate less memory, and AppendInterpolatedFormat
has a significant improvement in execution time.
StringInterpolation
Let's examine the first example. I used interpolated strings feature:
public string StringInterpolation() { return $"{A} {B}, {C}?"; }
Previously, C# compiler compiled the following code for the interpolated string example:
public string StringInterpolation() { return A + " " + B + ", " + C + "?"; }
For the code above, the IL instructions create a new string array for A, B, C and the constant values. Then invokes string.Concat
method to produce the result string.
From .NET 6, this changes to the following:
public string StringInterpolation() { DefaultInterpolatedStringHandler defaultInterpolatedStringHandler = new DefaultInterpolatedStringHandler(4, 3); defaultInterpolatedStringHandler.AppendFormatted(A); defaultInterpolatedStringHandler.AppendLiteral(" "); defaultInterpolatedStringHandler.AppendFormatted(B); defaultInterpolatedStringHandler.AppendLiteral(", "); defaultInterpolatedStringHandler.AppendFormatted(C); defaultInterpolatedStringHandler.AppendLiteral("?"); return defaultInterpolatedStringHandler.ToStringAndClear(); }
In this case a non-allocating ref struct DefaultInterpolatedStringHandler
is used to concatenate the string parts. This avoids all allocations, other than the one required for the desired string object. The compiler also rewrites the code to append the individual code parts as one would do with a StringBuilder
type. In this example the compiler uses AppendFormatted
and AppendLiteral
methods to concatenate strings. The two constructor parameters of DefaultInterpolatedStringHandler
are int literalLength, int formattedCount
.
public DefaultInterpolatedStringHandler(int literalLength, int formattedCount)
Literal length denotes the number of literal chars appended, here these are " ", ", ", "?"
which sums up as 4 chars. Formatted count is the number of formatted strings appended. DefaultInterpolatedStringHandler
rents a char array from an arraypool, to buffer the intermediate results. The default calculation for length of the buffer, uses 11 chars as the default length per formatted strings + literal length. The value is also maximized by 256 chars. It is also specific to .NET release, hence it might change in future releases. Considering the maximum, for longer strings StringBuilder
with a default capacity might be still beneficial based on the use case.
The C# compiler's new approach of string interpolation costs as much of memory as the alternatives.
AppendInterpolatedFormat
One of the worst performing code (previous to .NET6) was appending an interpolated string with a string builder's Append
method taking a string argument. Although this seems very convenient, it was the worst performing solution from memory and from execution time point of view as well. Fortunately, .NET6 changes this.
public string AppendInterpolatedFormat() { Builder.Clear(); Builder.Append($"{A} {B}, {C}?"); return Builder.ToString(); }
The above code is compiled to use another interpolated string handler: AppendInterpolatedStringHandler
. The idea behind is quite the same as for the DefaultInterpolatedStringHandler
, except this type works hand-in-hand with a StringBuilder
object passed as a constructor argument.
public string AppendInterpolatedFormat() { Builder.Clear(); StringBuilder builder = Builder; StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(4, 3, builder); handler.AppendFormatted(A); handler.AppendLiteral(" "); handler.AppendFormatted(B); handler.AppendLiteral(", "); handler.AppendFormatted(C); handler.AppendLiteral("?"); builder.Append(ref handler); return Builder.ToString(); }
AppendInterpolatedStringHandler
struct is nested type of StringBuilder
. It appends the formatted strings and literals directly to the string builder object. A careful reader might wonder what the builder.Append(ref handler);
line does, if the string is appended to the string builder directly. Currently it just returns the string builder without a side effect.
With this solution, a comparable performance is reached as manually appending the string parts. AppendInterpolatedStringHandler
will try to Format the appended value into the remaining empty space of the string builder using the ISpanFormattable
interface. This means that if someone creates a new type which is to be serialized as a string, it worth implementing this interface, which becomes public with .NET6.