String Interpolation and StringBuilder in .NET6

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.