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 early preview build of .NET 6 (without latest DefaultInterpolatedStringHandler), 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.