値型 StringBuilder の解説

# はじめに

以前に投稿した記事 [値型 StringBuilder を作ってみる](https://qiita.com/abetakahiro123/items/47307bdcb80d90047d25) の、あまり一般的ではないテクニックを紹介します。

※ この記事で表示しているパフォーマンス結果は .net9-preview7 を使用しています。

## `MethodImpl` 属性

これは[インライン化](https://ufcpp.net/study/csharp/structured/miscinlining/)に関する属性で、要は「インライン化されやすくする」ことを意味します。インライン化は複雑なので説明は割愛しますが、パフォーマンス向上につながりやすいです。

- インライン化は JIT コンパイル時に決定される。つまりコンパイル時ではなく実行時に決定される
- 実行環境に依存する。つまり実行する PC のスペック（CPU、レジスタ容量、実行時の CPU の使用率、メモリ）、.NET ランタイムのバージョンの影響を受ける
- 逆に AoT だとこの属性は意味がないかも

正直、私自身インライン化についてよく知りません。実験的なコードでパフォーマンスを出したいときは、この属性をベタベタ貼り付けています。使いすぎると実行時のコードが肥大化してかえって遅くなるとかがあるかもしれません。ビジネスのコードでは使わないほうが無難でしょう。

逆にインライン化しないようにする `MethodImpl(MethodImplOptions.NoInlining)` を使用している箇所もあります。これはめったに呼ばれないメソッドをインライン化しないことで高速化する、かなり珍しいテクニックです。標準ライブラリを眺めていたときに見つけました。  
使う場面はあまりありませんが、`StackStringBuilder` の例では内部バッファを大きくするメソッドにこの属性をつけています。`Append()` はよく呼ばれますが、その中で呼ばれる可能性のある `SizeUp()` はあまり呼ばれないため、インライン化しないようにします。

## `ArgumentOutOfRangeException.ThrowIfGreaterThan()`

これもインライン化に関係する最適化の一つです。コード中に `throw` があるメソッドはインライン化されないため、それを回避できます。

テストコード

```cs
p.AddTest("UseThrow", () =>
{
    int UseThrow(int value)
    {
        if (value > 1000000)
            throw new ArgumentOutOfRangeException(nameof(value), value, "Value must be less than or equal to 1000000.");
        return value * 2;
    }

    for (int n = 0; n < 10000; ++n)
        UseThrow(n);
});

p.AddTest("ThrowIfGreaterThan", () =>
{
    int ThrowIfGreaterThan(int value)
    {
        ArgumentOutOfRangeException.ThrowIfGreaterThan(value, 1000000);
        return value * 2;
    }

    for (int n = 0; n < 10000; ++n)
        ThrowIfGreaterThan(n);
});
```

パフォーマンス比較 **x86**

|Test|Score|%|CG0|
|---|---:|---:|---:|
|UseThrow|7,241|100.0%|0|
|ThrowIfGreaterThan|43,629|602.5%|0|

パフォーマンス比較 **x64**

|Test|Score|%|CG0|
|---|---:|---:|---:|
|UseThrow|15,245|100.0%|0|
|ThrowIfGreaterThan|10,474|68.7%|0|

- x86 / x64 で結果がずいぶん異なりますね・・・。パフォーマンス結果が逆転しています
- インライン化されているかどうかはスコアからは判断できず、なんとも言えません
- 基本的にポインタのサイズが大きい x64 のほうが絶対スコアは低くなる傾向があるのですが、このテストでは一部高速化しています
- 使える CPU 命令の違いとかがパフォーマンスに影響しているのかもしれません
- preview 版のコンパイラとランタイムを使用している影響もあるかもしれません

## `ref` 構造体許容制約

こちらは以前の記事 [【Preview】ref 構造体のジェネリック制限の緩和](https://qiita.com/abetakahiro123/items/8c8bba2f53737997cbd3) に詳しいです。BOX 化が起こらない使い方なら `ref` 構造体もジェネリックにできるようになりました。

## `ref this` 自身を参照で返す

構造体がメソッドやプロパティで、自身を参照で戻り値に指定するとエラーになります。これを回避するために一工夫します。

```cs
private struct MyStruct
{
    public int Value;

    // unsafe を指定するとエラーにならなくなる
    // ただし警告が出る
#pragma warning disable CS9084
    public unsafe ref MyStruct UnsafeThis => ref this;
#pragma warning restore CS9084

    public ref MyStruct UnsafeClassThis => ref System.Runtime.CompilerServices.Unsafe.AsRef(ref this);
}
```

メソッド呼び出しが入るか入らないかの違いがありますが、JIT コンパイルしたときのコードはだいたい同じだと思います。パフォーマンスもだいたい同じです。

パフォーマンス比較 **x86**

|Test|Score|%|CG0|
|---|---:|---:|---:|
|UnsafeThis|41,467|100.0%|0|
|UnsafeClassThis|42,234|101.8%|0|

パフォーマンス比較 **x64**

|Test|Score|%|CG0|
|---|---:|---:|---:|
|UnsafeThis|11,205|100.0%|0|
|UnsafeClassThis|10,268|91.6%|0|

## 構造体のメソッド等の `readonly` 修飾

構造体は [「隠れたコピー」(hidden copy)](https://ufcpp.net/study/csharp/resource/readonlyness/#avoid-copy) が発生する場合があり、これがパフォーマンスを損なう原因になります。  
構造体にメソッド・プロパティ・インデクサを追加するときは**可能な限り `readonly` 修飾をつける**とよいでしょう。

```cs
private struct Number(int number)
{
#pragma warning disable
    // わかりやすく差を出すために大きな構造体にする
    decimal _tmp1, _tmp2, _tmp3, _tmp4, _tmp5;
#pragma warning restore

    // インライン化しないようにする
    [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]
    public int GetNumber() => number;

    // インライン化しないようにする
    [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]
    public readonly int ReadOnlyGetNumber() => number;
}

p.AddTest("Number", () =>
{
    int GetNumber(Number value) => value.GetNumber();

    var sum = 0;
    for (int n = 0; n < 10000; ++n)
        sum += GetNumber(new Number(n));
});

p.AddTest("HiddenCopy", () =>
{
    // 隠れたコピーが発生する
    int GetNumber(in Number value) => value.GetNumber();

    var sum = 0;
    for (int n = 0; n < 10000; ++n)
        sum += GetNumber(new Number(n));
});

p.AddTest("ReadOnlyNumber", () =>
{
    // 隠れたコピーが発生しない
    int GetNumber(in Number value) => value.ReadOnlyGetNumber();

    var sum = 0;
    for (int n = 0; n < 10000; ++n)
        sum += GetNumber(new Number(n));
});

p.AddTest("RefNumber", () =>
{
    // 隠れたコピーが発生しない
    int GetNumber(ref Number value) => value.GetNumber();

    var sum = 0;
    for (int n = 0; n < 10000; ++n)
    {
        var number = new Number(n);
        sum += GetNumber(ref number);
    }
});
}
```

パフォーマンス比較 **x86**

|Test|Score|%|CG0|
|---|---:|---:|---:|
|Number|4,546|100.0%|0|
|HiddenCopy|3,381|**74.4%**|0|
|ReadOnlyNumber|4,321|95.1%|0|
|RefNumber|6,663|146.6%|0|

パフォーマンス比較 **x64**

|Test|Score|%|CG0|
|---|---:|---:|---:|
|Number|3,208|100.0%|0|
|HiddenCopy|2,479|**77.3%**|0|
|ReadOnlyNumber|2,979|92.9%|0|
|RefNumber|3,027|94.4%|0|

隠れたコピーが発生すると、少しパフォーマンスが悪くなっていますね。

## おわりに

今回 x86 / x64 でのパフォーマンスの違いを検証してみたのですが、スコアが逆転するケースがあり驚きました。やはりパフォーマンスは**推測するな、計測せよ**ですね。

<details>
<summary>テストコード</summary>

```cs
public class _ValueStringBuilderDescriptionTest
{
    static void SampleThrowIfGreaterThan(Performance p)
    {
        p.AddTest("UseThrow", () =>
        {
            int UseThrow(int value)
            {
                if (value > 1000000)
                    throw new ArgumentOutOfRangeException(nameof(value), value, "Value must be less than or equal to 1000000.");
                return value * 2;
            }

            var sum = 0;
            for (int n = 0; n < 10000; ++n)
                sum += UseThrow(n);
        });

        p.AddTest("ThrowIfGreaterThan", () =>
        {
            int ThrowIfGreaterThan(int value)
            {
                ArgumentOutOfRangeException.ThrowIfGreaterThan(value, 1000000);
                return value * 2;
            }

            var sum = 0;
            for (int n = 0; n < 10000; ++n)
                sum += ThrowIfGreaterThan(n);
        });
    }

    private struct MyStruct
    {
        public int Value;

        // unsafe を指定するとエラーにならなくなる
        // ただし警告が出る
#pragma warning disable CS9084
        public unsafe ref MyStruct UnsafeThis => ref this;
#pragma warning restore CS9084

        public ref MyStruct UnsafeClassThis => ref System.Runtime.CompilerServices.Unsafe.AsRef(ref this);
    }

    static void RefThis(Performance p)
    {
        p.AddTest("UnsafeThis", () =>
        {
            var sum = 0;
            for (int n = 0; n < 10000; ++n)
            {
                var value = new MyStruct { Value = n };
                sum += value.UnsafeThis.Value;
            }
        });

        p.AddTest("UnsafeClassThis", () =>
        {
            var sum = 0;
            for (int n = 0; n < 10000; ++n)
            {
                var value = new MyStruct { Value = n };
                sum += value.UnsafeClassThis.Value;
            }
        });
    }

    private struct Number(int number)
    {
#pragma warning disable
        // わかりやすく差を出すために大きな構造体にする
        decimal _tmp1, _tmp2, _tmp3, _tmp4, _tmp5;
#pragma warning restore

        // インライン化しないようにする
        [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]
        public int GetNumber() => number;

        // インライン化しないようにする
        [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]
        public readonly int ReadOnlyGetNumber() => number;
    }

    static void HiddenCopy(Performance p)
    {
        p.AddTest("Number", () =>
        {
            int GetNumber(Number value) => value.GetNumber();

            var sum = 0;
            for (int n = 0; n < 10000; ++n)
                sum += GetNumber(new Number(n));
        });

        p.AddTest("HiddenCopy", () =>
        {
            // 隠れたコピーが発生する
            int GetNumber(in Number value) => value.GetNumber();

            var sum = 0;
            for (int n = 0; n < 10000; ++n)
                sum += GetNumber(new Number(n));
        });

        p.AddTest("ReadOnlyNumber", () =>
        {
            // 隠れたコピーが発生しない
            int GetNumber(in Number value) => value.ReadOnlyGetNumber();

            var sum = 0;
            for (int n = 0; n < 10000; ++n)
                sum += GetNumber(new Number(n));
        });

        p.AddTest("RefNumber", () =>
        {
            // 隠れたコピーが発生しない
            int GetNumber(ref Number value) => value.GetNumber();

            var sum = 0;
            for (int n = 0; n < 10000; ++n)
            {
                var number = new Number(n);
                sum += GetNumber(ref number);
            }
        });
    }
}
```

</details>