Skip to content

Instantly share code, notes, and snippets.

@ladeak
Last active August 27, 2021 09:54
Show Gist options
  • Save ladeak/4f477686c7c3c272f5c4e67cc81402aa to your computer and use it in GitHub Desktop.
Save ladeak/4f477686c7c3c272f5c4e67cc81402aa to your computer and use it in GitHub Desktop.
string-interpolation-stringbuilder.md

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.

String Interpolation and StringBuilder

This post looks into different approaches and their performance characteristics for appendnng interpolated like strings. I use BenchmarkDotNet to benchmark the different solutions. In case of all benchmarks I have 3 string properties A, B and C each holding one of these values:

  • hello
  • world
  • how are you

The values of these variables are set in a Global setup step of BencharmarkDotNet. In all benchmarks the three string are concatenate with some punctuations.

Implementation

String Interpolation

First approach is using the standard string interpolation feature available in C# 6. This solution is fairly straightforward, just concatenate the three string and add the punctuations. Although the solution seems sightly artificial, I have seen numerous equivalent examples from the real world. In my opinion this code is the cleanest among the ones presented in the post, as well as the most readable.

[Benchmark]
public string StringInterpolation() => $"{A} {B}, {C}?";

StringBuilder Append

Another global property declared is a StringBuilder typed Builder. In this second approach I am using the StringBuilder type to append each part of the string together. Intentionally using the Append() method for each individual part to assemble the final string. I would like to avoid any inherently interpolated string during this process. As the builder object is being reused across multiple benchmark iterations, Clear() method is cleared at the beginning of each benchmark to remove previous state. I choose this approach because it resembles more closely with the real life use-cases, that I would replicate in this performance test. In this case the allocation of the StringBuilder is saved for the global setup step, as well as the capacity of it can be set to a well-known value. The ToString() is used to return the concatenated string result from StringBuilder. This solution is clearly lengthier and less readable compared to the previous one.

[Benchmark]
public string AppendAllParts()
{
    Builder.Clear();
    Builder.Append(A);
    Builder.Append(' ');
    Builder.Append(B);
    Builder.Append(", ");
    Builder.Append(C);
    Builder.Append('?');
    return Builder.ToString();
}

StringBuilder AppendFormat

StringBuilder type has another method suited for the task, AppendFormat(). AppendFormat works really similar to string.Format. I choose the overload that takes a format string, and 3 arguments. Each numbered format item in format string is replaced by the string representation of the corresponding object argument. Note that this overload internally creates a struct type, ParamsArray, to pass the 3 input arguments to the internal append method without further allocation.

[Benchmark]
public string AppendFormat()
{
    Builder.Clear();
    Builder.AppendFormat("{0} {1}, {2}?", A, B, C);
    return Builder.ToString();
}

StringBuilder Append Interpolated String

The following implementation has come up several times in all sorts of applications. StringBuilder is used to append a larger string, but for some of the Append() calls, an interpolated string is appended. The C# compiler internally uses string.Concat to concatenate the individual parts of the interpolated string, then it can pass the appended string to Builder. From the benchmarks it will be clear, that this solution consumes the most amount of memory, due to the fact that an intermediate string object must be created for the Append() operation, but right after, it is thrown away.

[Benchmark]
public string AppendInterpolatedFormat()
{
    Builder.Clear();
    Builder.Append($"{A} {B}, {C}?");
    return Builder.ToString();
}

String Create

While the implementations above shall work for .NET 5 and .NET Framework as well, the next example uses an API that is only available with .NET Core 2.1 or above. String.Create gives an opportunity to specify a well-known string length, some arguments and a callback, SpanAction<char,TState>. The callback is invoked with two arguments:

  1. a Span<char> buffer where the string's characters may be written to
  2. and a state which is the passed in arguments.

Inside the callback the string's characters can be set during the creation of the string. As strings are immutable, this approach gives a really efficient way for creating strings without any additional allocations or memory copies. Hence it is one of the fastest solutions as benchmarks will show.

[Benchmark]
public string StringCreate()
{
    var parts = (A, B, C);
    return string.Create(25, parts, (buffer, state) =>
    {
        var (a, b, c) = state;
        a.AsSpan().CopyTo(buffer);
        buffer = buffer.Slice(a.Length);
        buffer[0] = ' ';
        buffer = buffer.Slice(1);
        b.AsSpan().CopyTo(buffer);
        buffer = buffer.Slice(b.Length);
        buffer[0] = ',';
        buffer[1] = ' ';
        buffer = buffer.Slice(2);
        c.AsSpan().CopyTo(buffer);
        buffer = buffer.Slice(c.Length);
        buffer[0] = '?';
    });
}

ArrayPool

The final solution uses an ArrayPool<char> to rent a buffer for the required string to build. The different parts of the whole string is copied into the buffer. Once the final string is assembled a new string object is created from the value stored in the buffer. The advantage of this solution to string.Create is that we don't need to know the exact size of the string upfront, just an upper bound, for a big enough buffer. However this solution comes at a performance cost, as the shared pool of objects needs to be accessed for the rental of the buffer, as well as an additional copy is required (compared to string.Create) for the creation of the final string object. Using a pool of char[], the heap allocation of the buffer is dispersed over the multiple iterations of the benchmark.

[Benchmark]
public string StringArrayPoolCreate()
{
    var destination = ArrayPool<char>.Shared.Rent(25);
    try
    {
        var buffer = destination.AsSpan();
        A.AsSpan().CopyTo(buffer);
        buffer = buffer.Slice(A.Length);
        buffer[0] = ' ';
        buffer = buffer.Slice(1);
        B.AsSpan().CopyTo(buffer);
        buffer = buffer.Slice(B.Length);
        buffer[0] = ',';
        buffer[1] = ' ';
        buffer = buffer.Slice(2);
        C.AsSpan().CopyTo(buffer);
        buffer = buffer.Slice(C.Length);
        buffer[0] = '?';
        return new string(destination.AsSpan(0, 25));
    }
    finally
    {
        ArrayPool<char>.Shared.Return(destination);
    }
}

Benchmarks

I ran the benchmark tests with a preview build of .NET 6, the latest release available at the time of writing this post. Compared to the execution times, I used a memory diagnoser to analyze the heap allocated memory.

// * Summary *

BenchmarkDotNet=v0.13.0, OS=Windows 10.0.19043.1110 (21H1/May2021Update)
Intel Core i5-1035G4 CPU 1.10GHz, 1 CPU, 8 logical and 4 physical cores
.NET SDK=6.0.100-preview.5.21302.13
  [Host]     : .NET 6.0.0 (6.0.21.30105), X64 RyuJIT
  DefaultJob : .NET 6.0.0 (6.0.21.30105), X64 RyuJIT


|                   Method |     Mean |    Error |   StdDev |  Gen 0 | Gen 1 | Gen 2 | Allocated |
|------------------------- |---------:|---------:|---------:|-------:|------:|------:|----------:|
|      StringInterpolation | 49.72 ns | 1.044 ns | 1.282 ns | 0.0459 |     - |     - |     144 B |
|           AppendAllParts | 31.18 ns | 0.694 ns | 0.950 ns | 0.0229 |     - |     - |      72 B |
|             AppendFormat | 83.96 ns | 1.749 ns | 2.671 ns | 0.0229 |     - |     - |      72 B |
| AppendInterpolatedFormat | 70.04 ns | 0.590 ns | 0.552 ns | 0.0688 |     - |     - |     216 B |
|             StringCreate | 19.81 ns | 0.234 ns | 0.219 ns | 0.0229 |     - |     - |      72 B |
|    StringArrayPoolCreate | 44.29 ns | 0.390 ns | 0.365 ns | 0.0229 |     - |     - |      72 B |

From memory point of view the interpolated string solutions come at higher allocated memory. This is due to how string.Concat comes with the extra expense for allocating a string array on the heap. AppendInterpolatedFormat performs even worse as it needs to allocate the interpolated string on the heap too, so that it can be appended to a larger string built. 144 + 72 = 216, the allocated memory shows that it exactly uses the final string's size as an extra compared to StringInterpolation. As mentioned earlier AppendFormat avoids the allocation of string[] by using a struct type, ParamsArray.

72 B is the minimum required for the string, as 25 chars require a space of 50 B, 8 B required for object header, 8 B required for MT pointer, 4 B required for the string length, and on a x64 for machine we have a 2 byte padding at the end of the object.

From execution time point of view StringCreate is the fastest as it requires the least amount of memory copies. AppendAllParts is the second fastest solution in this comparison. On the other end of the scale AppendFormat and AppendInterpolatedFormat are performing multiple times worse.

Note, that this comparison might be slightly confusing as StringBuilder is capable of building larger strings, in this post I have only considered the use cases for an interpolated string to be built.

Conclusion

As a conclusion I suggest to always measure the performance of code. For hot paths always target the best performing code, while for non-performance critical sections avoid using poorly performing API-s.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment