.NET Performance Optimization: Using ValueStringBuilder for String Concatenation

.NET Performance Optimization: Using ValueStringBuilder for String Concatenation

The tip I want to share this time is used in string concatenation scenarios

Last updated 5/11/2022 7:13 AM
InCerry
14 min read
Category
.NET
Tags
.NET C# Performance Optimization

Preface

Today I’m going to share a tip for string concatenation scenarios. We often encounter situations where many short strings need to be concatenated, and in such cases it is highly unrecommended to use String.Concat — that is, using the += operator.

Currently the most recommended approach is to use StringBuilder to build these strings. But is there a faster, lower-memory way? That would be ValueStringBuilder, which I’d like to introduce today.

ValueStringBuilder

ValueStringBuilder is not a public API, but it is heavily used in .NET’s base class libraries. Because it is a value type, it is not allocated on the heap, so it avoids GC pressure.

Microsoft’s ValueStringBuilder can be used in two ways. One way is to provide your own block of memory for string construction. This means you can use stack space, heap space, or even unmanaged heap space. This is very GC-friendly and can significantly reduce GC pressure under high concurrency.

// Constructor: pass a Span<char> as the buffer
public ValueStringBuilder(Span<char> initialBuffer);

// Usage:
// Stack space
var vsb = new ValueStringBuilder(stackalloc char[512]);
// Regular array
var vsb = new ValueStringBuilder(new char[512]);
// Unmanaged heap
var length = 512;
var ptr = NativeMemory.Alloc((nuint)(512 * Unsafe.SizeOf<char>()));
var span = new Span<char>(ptr, length);
var vsb = new ValueStringBuilder(span);
.....
NativeMemory.Free(ptr); // Unmanaged memory must be freed after use

The other way is to specify a capacity, and it will get a buffer from the default ArrayPool<char> object pool. Because it uses an object pool, it is also relatively GC-friendly. It is very important to remember to return the pooled object.

// Pass the estimated capacity
public ValueStringBuilder(int initialCapacity)
{
    // Obtain the buffer from the object pool
    _arrayToReturnToPool = ArrayPool<char>.Shared.Rent(initialCapacity);
    ......
}

Now let’s compare the performance of +=, StringBuilder, and ValueStringBuilder.

// A simple class
public class SomeClass
{
    public int Value1; public int Value2; public float Value3;
    public double Value4; public string? Value5; public decimal Value6;
    public DateTime Value7; public TimeOnly Value8; public DateOnly Value9;
    public int[]? Value10;
}
// Benchmark class
[MemoryDiagnoser]
[HtmlExporter]
[Orderer(SummaryOrderPolicy.FastestToSlowest)]
public class StringBuilderBenchmark
{
    private static readonly SomeClass Data;
    static StringBuilderBenchmark()
    {
        var baseTime = DateTime.Now;
        Data = new SomeClass
        {
            Value1 = 100, Value2 = 200, Value3 = 333,
            Value4 = 400, Value5 = string.Join('-', Enumerable.Range(0, 10000).Select(i => i.ToString())),
            Value6 = 655, Value7 = baseTime.AddHours(12),
            Value8 = TimeOnly.MinValue, Value9 = DateOnly.MaxValue,
            Value10 = Enumerable.Range(0, 5).ToArray()
        };
    }

    // Using the familiar StringBuilder
    [Benchmark(Baseline = true)]
    public string StringBuilder()
    {
        var data = Data;
        var sb = new StringBuilder();
        sb.Append("Value1:"); sb.Append(data.Value1);
        if (data.Value2 > 10)
        {
            sb.Append(" ,Value2:"); sb.Append(data.Value2);
        }
        sb.Append(" ,Value3:"); sb.Append(data.Value3);
        sb.Append(" ,Value4:"); sb.Append(data.Value4);
        sb.Append(" ,Value5:"); sb.Append(data.Value5);
        if (data.Value6 > 20)
        {
            sb.Append(" ,Value6:"); sb.AppendFormat("{0:F2}", data.Value6);
        }
        sb.Append(" ,Value7:"); sb.AppendFormat("{0:yyyy-MM-dd HH:mm:ss}", data.Value7);
        sb.Append(" ,Value8:"); sb.AppendFormat("{0:HH:mm:ss}", data.Value8);
        sb.Append(" ,Value9:"); sb.AppendFormat("{0:yyyy-MM-dd}", data.Value9);
        sb.Append(" ,Value10:");
        if (data.Value10 is null or {Length: 0}) return sb.ToString();
        for (int i = 0; i < data.Value10.Length; i++)
        {
            sb.Append(data.Value10[i]);
        }

        return sb.ToString();
    }

    // StringBuilder with Capacity
    [Benchmark]
    public string StringBuilderCapacity()
    {
        var data = Data;
        var sb = new StringBuilder(20480);
        sb.Append("Value1:"); sb.Append(data.Value1);
        if (data.Value2 > 10)
        {
            sb.Append(" ,Value2:"); sb.Append(data.Value2);
        }
        sb.Append(" ,Value3:"); sb.Append(data.Value3);
        sb.Append(" ,Value4:"); sb.Append(data.Value4);
        sb.Append(" ,Value5:"); sb.Append(data.Value5);
        if (data.Value6 > 20)
        {
            sb.Append(" ,Value6:"); sb.AppendFormat("{0:F2}", data.Value6);
        }
        sb.Append(" ,Value7:"); sb.AppendFormat("{0:yyyy-MM-dd HH:mm:ss}", data.Value7);
        sb.Append(" ,Value8:"); sb.AppendFormat("{0:HH:mm:ss}", data.Value8);
        sb.Append(" ,Value9:"); sb.AppendFormat("{0:yyyy-MM-dd}", data.Value9);
        sb.Append(" ,Value10:");
        if (data.Value10 is null or {Length: 0}) return sb.ToString();
        for (int i = 0; i < data.Value10.Length; i++)
        {
            sb.Append(data.Value10[i]);
        }

        return sb.ToString();
    }

    // Directly using += for string concatenation
    [Benchmark]
    public string StringConcat()
    {
        var str = "";
        var data = Data;
        str += ("Value1:"); str += (data.Value1);
        if (data.Value2 > 10)
        {
            str += " ,Value2:"; str += data.Value2;
        }
        str += " ,Value3:"; str += (data.Value3);
        str += " ,Value4:"; str += (data.Value4);
        str += " ,Value5:"; str += (data.Value5);
        if (data.Value6 > 20)
        {
            str += " ,Value6:"; str += data.Value6.ToString("F2");
        }
        str += " ,Value7:"; str += data.Value7.ToString("yyyy-MM-dd HH:mm:ss");
        str += " ,Value8:"; str += data.Value8.ToString("HH:mm:ss");
        str += " ,Value9:"; str += data.Value9.ToString("yyyy-MM-dd");
        str += " ,Value10:";
        if (data.Value10 is not null && data.Value10.Length > 0)
        {
            for (int i = 0; i < data.Value10.Length; i++)
            {
                str += (data.Value10[i]);
            }
        }

        return str;
    }

    // ValueStringBuilder allocated on the stack
    [Benchmark]
    public string ValueStringBuilderOnStack()
    {
        var data = Data;
        Span<char> buffer = stackalloc char[20480];
        var sb = new ValueStringBuilder(buffer);
        sb.Append("Value1:"); sb.AppendSpanFormattable(data.Value1);
        if (data.Value2 > 10)
        {
            sb.Append(" ,Value2:"); sb.AppendSpanFormattable(data.Value2);
        }
        sb.Append(" ,Value3:"); sb.AppendSpanFormattable(data.Value3);
        sb.Append(" ,Value4:"); sb.AppendSpanFormattable(data.Value4);
        sb.Append(" ,Value5:"); sb.Append(data.Value5);
        if (data.Value6 > 20)
        {
            sb.Append(" ,Value6:"); sb.AppendSpanFormattable(data.Value6, "F2");
        }
        sb.Append(" ,Value7:"); sb.AppendSpanFormattable(data.Value7, "yyyy-MM-dd HH:mm:ss");
        sb.Append(" ,Value8:"); sb.AppendSpanFormattable(data.Value8, "HH:mm:ss");
        sb.Append(" ,Value9:"); sb.AppendSpanFormattable(data.Value9, "yyyy-MM-dd");
        sb.Append(" ,Value10:");
        if (data.Value10 is not null && data.Value10.Length > 0)
        {
            for (int i = 0; i < data.Value10.Length; i++)
            {
                sb.AppendSpanFormattable(data.Value10[i]);
            }
        }

        return sb.ToString();
    }
    // ValueStringBuilder allocated on the heap using ArrayPool
    [Benchmark]
    public string ValueStringBuilderOnHeap()
    {
        var data = Data;
        var sb = new ValueStringBuilder(20480);
        sb.Append("Value1:"); sb.AppendSpanFormattable(data.Value1);
        if (data.Value2 > 10)
        {
            sb.Append(" ,Value2:"); sb.AppendSpanFormattable(data.Value2);
        }
        sb.Append(" ,Value3:"); sb.AppendSpanFormattable(data.Value3);
        sb.Append(" ,Value4:"); sb.AppendSpanFormattable(data.Value4);
        sb.Append(" ,Value5:"); sb.Append(data.Value5);
        if (data.Value6 > 20)
        {
            sb.Append(" ,Value6:"); sb.AppendSpanFormattable(data.Value6, "F2");
        }
        sb.Append(" ,Value7:"); sb.AppendSpanFormattable(data.Value7, "yyyy-MM-dd HH:mm:ss");
        sb.Append(" ,Value8:"); sb.AppendSpanFormattable(data.Value8, "HH:mm:ss");
        sb.Append(" ,Value9:"); sb.AppendSpanFormattable(data.Value9, "yyyy-MM-dd");
        sb.Append(" ,Value10:");
        if (data.Value10 is not null && data.Value10.Length > 0)
        {
            for (int i = 0; i < data.Value10.Length; i++)
            {
                sb.AppendSpanFormattable(data.Value10[i]);
            }
        }

        return sb.ToString();
    }

}

The results are as follows.

From the results above, we can draw the following conclusions.

  • StringConcat is the slowest; this approach is never recommended.
  • Using StringBuilder is about 6.5 times faster than StringConcat; this is the recommended method.
  • StringBuilder with an initial capacity is about 25% faster than plain StringBuilder. As I mentioned in You Should Set Initial Capacity for Collection Types, setting an initial capacity is definitely a highly recommended practice.
  • Stack-allocated ValueStringBuilder is about 50% faster than StringBuilder, and even about 25% faster than StringBuilder with an initial capacity. Additionally, it has the lowest GC count.
  • Heap-allocated ValueStringBuilder is about 55% faster than StringBuilder, with slightly higher GC counts than the stack-allocated one.

From the above, we can see that ValueStringBuilder has excellent performance. Even when the buffer is allocated on the stack, it is 25% faster than StringBuilder.

Source Code Analysis

The source code of ValueStringBuilder is not long. Let’s share a few important methods. Partial source is as follows.

// Uses ref struct, so it can only be allocated on the stack
public ref struct ValueStringBuilder
{
    // If the buffer is allocated from ArrayPool, store it here
    // so it can be returned on Dispose
    private char[]? _arrayToReturnToPool;
    // Temporarily stores the externally provided buffer
    private Span<char> _chars;
    // Current string length
    private int _pos;

    // Externally provided buffer
    public ValueStringBuilder(Span<char> initialBuffer)
    {
        // Use the external buffer, so don’t take from the pool
        _arrayToReturnToPool = null;
        _chars = initialBuffer;
        _pos = 0;
    }

    public ValueStringBuilder(int initialCapacity)
    {
        // If capacity is provided externally, get from ArrayPool
        _arrayToReturnToPool = ArrayPool<char>.Shared.Rent(initialCapacity);
        _chars = _arrayToReturnToPool;
        _pos = 0;
    }

    // Returns the string’s Length. Since Length is readable and writable,
    // to reuse ValueStringBuilder just set Length to 0.
    public int Length
    {
        get => _pos;
        set
        {
            Debug.Assert(value >= 0);
            Debug.Assert(value <= _chars.Length);
            _pos = value;
        }
    }

    ......

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public void Append(char c)
    {
        // Adding a character is very efficient: just set it at the corresponding Span position
        int pos = _pos;
        if ((uint) pos < (uint) _chars.Length)
        {
            _chars[pos] = c;
            _pos = pos + 1;
        }
        else
        {
            // If buffer is insufficient, go to
            GrowAndAppend(c);
        }
    }

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public void Append(string? s)
    {
        if (s == null)
        {
            return;
        }

        // Appending a string is also very efficient
        int pos = _pos;
        // If the string length is 1, it can be appended like a single character
        if (s.Length == 1 && (uint) pos < (uint) _chars .Length)
        {
            _chars[pos] = s[0];
            _pos = pos + 1;
        }
        else
        {
            // For multiple characters, use the slower method
            AppendSlow(s);
        }
    }

    private void AppendSlow(string s)
    {
        // Appending a string: grow first if space is insufficient
        // then copy using Span, which is quite efficient
        int pos = _pos;
        if (pos > _chars.Length - s.Length)
        {
            Grow(s.Length);
        }

        s
#if !NETCOREAPP
                .AsSpan()
#endif
            .CopyTo(_chars.Slice(pos));
        _pos += s.Length;
    }

    // Special handling for objects that need formatting
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public void AppendSpanFormattable<T>(T value, string? format = null, IFormatProvider? provider = null)
        where T : ISpanFormattable
    {
        // ISpanFormattable is very efficient
        if (value.TryFormat(_chars.Slice(_pos), out int charsWritten, format, provider))
        {
            _pos += charsWritten;
        }
        else
        {
            Append(value.ToString(format, provider));
        }
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private void GrowAndAppend(char c)
    {
        // Grow for a single character, then append
        Grow(1);
        Append(c);
    }

    // Grow method
    [MethodImpl(MethodImplOptions.NoInlining)]
    private void Grow(int additionalCapacityBeyondPos)
    {
        Debug.Assert(additionalCapacityBeyondPos > 0);
        Debug.Assert(_pos > _chars.Length - additionalCapacityBeyondPos,
            "Grow called incorrectly, no resize is needed.");

        // Also double the capacity, and get buffer from the object pool by default
        char[] poolArray = ArrayPool<char>.Shared.Rent((int) Math.Max((uint) (_pos + additionalCapacityBeyondPos),
            (uint) _chars.Length * 2));

        _chars.Slice(0, _pos).CopyTo(poolArray);

        char[]? toReturn = _arrayToReturnToPool;
        _chars = _arrayToReturnToPool = poolArray;
        if (toReturn != null)
        {
            // If the original buffer was from the pool, it must be returned
            ArrayPool<char>.Shared.Return(toReturn);
        }
    }

    //
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public void Dispose()
    {
        char[]? toReturn = _arrayToReturnToPool;
        this = default; // For safety, clear the current object when disposing
        if (toReturn != null)
        {
            // Must remember to return to the object pool
            ArrayPool<char>.Shared.Return(toReturn);
        }
    }
}

From the source code above, we can summarize several characteristics of ValueStringBuilder:

  • Compared to StringBuilder, the implementation is very simple.
  • Everything is aimed at high performance, such as various uses of Span, various inlining parameters, and the use of object pools, etc.
  • Memory usage is very low: it is a struct type, and it is also a ref struct, meaning it will not be boxed and will not be allocated on the heap.

Applicable Scenarios

ValueStringBuilder is a high-performance string creation method. For different scenarios, there are different usage modes.

  1. Very frequent string concatenation with short string length: Use a stack-allocated ValueStringBuilder.

As we all know, ASP.NET Core has very high performance. In its internal library UrlBuilder, stack allocation is used because memory is reclaimed after the current method finishes, causing no GC pressure.

  1. Very frequent string concatenation with unpredictable string length: Use a ValueStringBuilder with a capacity specified via ArrayPool. For example, many places in the .NET BCL use this, such as the ToString implementation of dynamic methods. Allocating from the pool is not as efficient as stack allocation, but it still reduces memory usage and GC pressure.

  1. Very frequent string concatenation with controllable string length: Combine stack allocation and ArrayPool allocation. For example, in the regular expression parser class, if the string length is small, use stack space; if larger, use ArrayPool.

Scenarios to Watch Out For

  1. Cannot use ValueStringBuilder inside async/await. As we all know, ValueStringBuilder is a ref struct; it can only be allocated on the stack. async/await compiles into a state machine that splits the method before and after await, so ValueStringBuilder cannot be easily passed within the method. The compiler will give a warning.

  1. Cannot return ValueStringBuilder as a return value. Since it is allocated on the current stack, it will be released after the method ends; returning it would point to an unknown address. The compiler will also warn about this.

  1. If you need to pass ValueStringBuilder to another method, you must pass it by ref. Otherwise, value copying will create multiple instances. The compiler will not warn about this, but you must be very careful.

  1. If using stack allocation, it is safer to keep the buffer size under 5KB. The reason for this will be explained another time if the opportunity arises.

Summary

Today we shared a high-performance, nearly memory-free string concatenation struct called ValueStringBuilder. In most scenarios, it is recommended. However, be very careful about the scenarios mentioned above. If they don’t apply, you can still use the efficient StringBuilder for string concatenation.

Original source code link: https://github.com/InCerryGit/BlogCode-Use-ValueStringBuilder

Keep Exploring

Related Reading

More Articles