はなちるのマイノート

Unityをメインとした技術ブログ。自分らしくまったりやっていきたいと思いますー!

【C#】StringBuilderとDefaultInterpolatedStringHandlerの処理速度・メモリ確保量を比べてみる

はじめに

今回はStringBuilderDefaultInterpolatedStringHandlerの処理速度・メモリ確保量を比べてみたいと思います。

learn.microsoft.com

learn.microsoft.com


概要

実はDefaultInterpolatedStringHandlerStringBuilderのように扱うことができます。

ただし全てが同じ機能というわけではありませんので注意してください。単純にStringBuilder.AppendStringBuilder.AppendFormatStringBuilder.ToStringをするくらいなら実現できます。

// Hello. John! Age : 20
var builder = new StringBuilder();
builder.Append("Hello, John!\n");
builder.AppendFormat("Age : {0}", 20);
Console.WriteLine(builder.ToString());

// Hello. John! Age : 20
var handler = new DefaultInterpolatedStringHandler(0, 0);
handler.AppendLiteral("Hello, John!\n");
handler.AppendLiteral("Age : ");
handler.AppendFormatted(20);
Console.WriteLine(handler.ToStringAndClear());

もうちょい詳しく

前回DefaultInterpolatedStringHandlerについての記事を書いたのですが、本来は以下のように引数を渡します。

public DefaultInterpolatedStringHandler (int literalLength, int formattedCount);

DefaultInterpolatedStringHandler Constructor (System.Runtime.CompilerServices) | Microsoft Learn

  • literalLength : 「{}」(interpolation expressions)を除いた部分の文字列長
  • formattedCount : 「{}」(interpolation expressions)の個数

// string.Format("{0}, {1}, {2}", a, b, c)をDefaultInterpolatedStringHandlerで実現する
// literalLength : 「{}」(interpolation expressions)を除いた部分の文字列長
// formattedCount : 「{}」(interpolation expressions)の個数
var handler = new DefaultInterpolatedStringHandler(4, 3);
handler.AppendFormatted(a);
handler.AppendLiteral(", ");
handler.AppendFormatted(b);
handler.AppendLiteral(", ");
handler.AppendFormatted(c);

_ = handler.ToStringAndClear();

https://www.hanachiru-blog.com/entry/2024/03/15/120000

引数のliteralLengthformattedCountArrayPool<char>.Shared.Rentの引数として使われ、あくまで文字列の必要最低限な長さを記述します。

public DefaultInterpolatedStringHandler(int literalLength, int formattedCount)
{
  this._provider = (IFormatProvider) null;
  this._chars = (Span<char>) (this._arrayToReturnToPool = ArrayPool<char>.Shared.Rent(DefaultInterpolatedStringHandler.GetDefaultLength(literalLength, formattedCount)));
  this._pos = 0;
  this._hasCustomFormatter = false;
}

github.com

/// <summary>
/// Retrieves a buffer that is at least the requested length.
/// </summary>
/// <param name="minimumLength">The minimum length of the array needed.</param>
/// <returns>
/// An array that is at least <paramref name="minimumLength"/> in length.
/// </returns>
/// <remarks>
/// This buffer is loaned to the caller and should be returned to the same pool via
/// <see cref="Return"/> so that it may be reused in subsequent usage of <see cref="Rent"/>.
/// It is not a fatal error to not return a rented buffer, but failure to do so may lead to
/// decreased application performance, as the pool may need to create a new buffer to replace
/// the one lost.
/// </remarks>
public abstract T[] Rent(int minimumLength);

runtime/src/libraries/System.Private.CoreLib/src/System/Buffers/ArrayPool.cs at 0935105e91450a1bad02b5b2f83be52bea2bcf59 · dotnet/runtime · GitHub

バッファが足りなくなった場合はArrayPoolから新たな配列を確保してあげることで実現してるわけですね。

/// <summary>Grow the size of <see cref="_chars"/> to at least the specified <paramref name="requiredMinCapacity"/>.</summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)] // but reuse this grow logic directly in both of the above grow routines
private void GrowCore(uint requiredMinCapacity)
{
    // We want the max of how much space we actually required and doubling our capacity (without going beyond the max allowed length). We
    // also want to avoid asking for small arrays, to reduce the number of times we need to grow, and since we're working with unsigned
    // ints that could technically overflow if someone tried to, for example, append a huge string to a huge string, we also clamp to int.MaxValue.
    // Even if the array creation fails in such a case, we may later fail in ToStringAndClear.

    uint newCapacity = Math.Max(requiredMinCapacity, Math.Min((uint)_chars.Length * 2, string.MaxLength));
    int arraySize = (int)Math.Clamp(newCapacity, MinimumArrayPoolLength, int.MaxValue);

    char[] newArray = ArrayPool<char>.Shared.Rent(arraySize);
    _chars.Slice(0, _pos).CopyTo(newArray);

    char[]? toReturn = _arrayToReturnToPool;
    _chars = _arrayToReturnToPool = newArray;

    if (toReturn is not null)
    {
        ArrayPool<char>.Shared.Return(toReturn);
    }
}

runtime/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/DefaultInterpolatedStringHandler.cs at 0935105e91450a1bad02b5b2f83be52bea2bcf59 · dotnet/runtime · GitHub

実験

BenchmarkDotNetを用いてStringBuilderDefaultInterpolatedStringHandlerの処理速度・メモリ確保量を比べてみようと思います。

github.com

public class Program
{
    public static void Main(string[] args)
    {
        BenchmarkRunner.Run<TestClass>();
    }
    
    [MemoryDiagnoser(false)]
    public class TestClass
    {
        private const string s1 = "AAAAAAAAA";
        private const string s2 = "BBBBBBBBB";
        private const string s3 = "CCCCCCCCC";
        private const int a = 10;
        private const int b = 20;
        private const int c = 30;
        private const string x = "10";
        private const string y = "20";
        private const string z = "30";
        
        [Benchmark]
        public void StringBuilderTest1()
        {
            // Mean : 37.21ns
            // GC.Alloc : 288B
            var builder = new StringBuilder();
            builder.Append(s1);
            builder.Append(s2);
            builder.Append(s3);
            _ = builder.ToString();
        }
        
        [Benchmark]
        public void StringBuilderTest2()
        {
            // Mean : 51.55ns
            // GC.Alloc : 224B
            var builder = new StringBuilder();
            builder.AppendFormat("{0}, {1}, {2}", a, b, c);
            _ = builder.ToString();
        }

        [Benchmark]
        public void StringBuilderTest3()
        {
            // Mean : 49.58ns
            // GC.Alloc : 152B
            var builder = new StringBuilder();
            builder.AppendFormat("{0}, {1}, {2}", x, y, z);
            _ = builder.ToString();
        }
    
        [Benchmark]
        public void DefaultInterpolatedStringHandlerTest1()
        {
            // Mean : 22.09ns
            // GC.Alloc : 80B
            var handler = new DefaultInterpolatedStringHandler(0, 0);
            handler.AppendLiteral(s1);
            handler.AppendLiteral(s2);
            handler.AppendLiteral(s3);
            
            _ = handler.ToStringAndClear();
        }
        
        [Benchmark]
        public void DefaultInterpolatedStringHandlerTest2()
        {
            // Mean : 26.79ns
            // GC.Alloc : 48B
            var handler = new DefaultInterpolatedStringHandler(0, 0);
            handler.AppendFormatted(a);
            handler.AppendLiteral(", ");
            handler.AppendFormatted(b);
            handler.AppendLiteral(", ");
            handler.AppendFormatted(c);

            _ = handler.ToStringAndClear();
        }

        [Benchmark]
        public void DefaultInterpolatedStringHandlerTest3()
        {
            // Mean : 22.49ns
            // GC.Alloc : 48B
            var handler = new DefaultInterpolatedStringHandler(0, 0);
            handler.AppendFormatted(x);
            handler.AppendLiteral(", ");
            handler.AppendFormatted(y);
            handler.AppendLiteral(", ");
            handler.AppendFormatted(z);

            _ = handler.ToStringAndClear();
        }
    }
}

結果

Method Mean Error StdDev Allocated
StringBuilderTest1 37.21 ns 0.494 ns 0.462 ns 288 B
StringBuilderTest2 51.55 ns 0.435 ns 0.385 ns 224 B
StringBuilderTest3 49.58 ns 0.342 ns 0.320 ns 152 B
DefaultInterpolatedStringHandlerTest1 22.09 ns 0.175 ns 0.164 ns 80 B
DefaultInterpolatedStringHandlerTest2 26.79 ns 0.226 ns 0.200 ns 48 B
DefaultInterpolatedStringHandlerTest3 22.49 ns 0.210 ns 0.196 ns 48 B

Test1Test2Test3がそれぞれ同じ処理をしていて対応しているのですが、全て処理速度・メモリ確保量ともにDefaultInterpolatedStringHandlerが勝っています。