🚅

Claude Fable 5 が教えてくれた FastEnum の高速化テクニック (3) - 引数なし .ToString() の利用

に公開

引き続き Claude Fable 5 を利用した FastEnum のパフォーマンスチューニングについて解説していきます。今回は引数なし .ToString() の利用についてです。該当の Pull-Request は以下。

https://github.com/xin9le/FastEnum/pull/96

Claude Fable 5 が教えてくれた高速化テクニックたち

  1. 文字列のハッシュ値生成
  2. 数値の範囲チェック
  3. 引数なし .ToString() の利用 (← イマココ

前提知識

.NET の enum は、未定義値を受け付けることができます。お行儀が良いとは言えませんが、以下のようなものです。

未定義値の利用
// こういう定義があるとして...
public enum Fruits
{
    Apple = 1,
}

// こんな利用ができる
var apple = (Fruits)1;
var undefined = (Fruits)2;

そしてこれらを .ToString() すると次のように出力されます。未定義値でも特に例外が発生したりはせず、数値として文字列化されます。これが標準的な挙動です。

Enum.ToString() の挙動
// これは暗黙的に .ToString() が呼び出される
Console.WriteLine(apple);      // Apple
Console.WriteLine(undefined);  // 2

改善前

FastEnum も先の挙動に準じた実装がされています。必要な部分だけをピックアップすると以下のような感じになっています。

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static string ToNumberString(T value)  // where T : struct, Enum
{
    // ここでは簡単のために byte としているが、実際には基底の数値型にキャストされる
    var x = Unsafe.BitCast<T, byte>(value);

    // format と provider を明示的に指定した呼び出し
    return x.ToString(format: null, provider: CultureInfo.InvariantCulture);
}

ここでポイントなのは provider 引数を与えている部分です。この引数を指定しないで .ToString() を呼び出すと Analyzer によって CA1305 警告が出力されます。...というのは現時点ではどうも大嘘っぽい!実装当時 (2024 年時点) の手元の Visual Studio では警告が出力されていました、ほんとに。そんなこんなで警告が出るのを嫌って渋々引数を明示的に与えていました。Analyzer 警告が出なくなった理由は今となっては完全に不明。

...で、そうこうしていたら Claude Fable 5 が切り込んできました。

改善後

一例として byte.ToString(string? format, IFormatProvider? provider) のオーバーロードの実装を確認してみましょう。すると以下のようになっています。

byte.ToString(string?, IFormatProvider?)
public readonly struct Byte
{
    public string ToString([StringSyntax(StringSyntaxAttribute.NumericFormat)] string? format, IFormatProvider? provider)
        => Number.FormatUInt32(m_value, format, provider);
}
Number.FormatUInt32(uint, string?, IFormatProvider?)
internal static partial class Number
{
    public static string FormatUInt32(uint value, string? format, IFormatProvider? provider)
    {
        if (string.IsNullOrEmpty(format))  // Fast path for default format
            return UInt32ToDecStr(value);
            
        return FormatUInt32Slow(value, format, provider);
    }
}

先に記載した通り format: null で呼び出しているので Fast Path 側の Number.UInt32ToDecStr() に到達することが分かります。では、次に引数なしの byte.ToString() のオーバーロードを見てみましょう。

byte.ToString()
public readonly struct Byte
{
    public override string ToString()
        => Number.UInt32ToDecStr(m_value);
}

直接 Number.UInt32ToDecStr() を呼び出していますね。利用している .ToString() のオーバーロードは違えど最終的な行き先は同一です。その差は途中に string.IsNullOrEmpty(format) による判定が一段入るかどうかのみで、しかも条件分岐が必ず true になることを考えると単純に無駄が増えていることになります。そしてこの条件分岐があることによってインライン展開が阻害され、パフォーマンスが低下していました。ということで以下のように実装を調整しました。

引数なし .ToString() 呼び出し
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static string ToNumberString(T value)  // where T : struct, Enum
{
    var x = Unsafe.BitCast<T, int>(value);
-   return x.ToString(format: null, provider: CultureInfo.InvariantCulture);
+   return x.ToString();
}

ちゃんと内部実装まで追って利用するオーバーロードを判断していれば防げた事案で、実に勿体なかったし、怠慢でした。ベンチマーク結果を見てみても 2 倍くらい速くなってます。インライン展開は偉大。

ベンチマーク結果
BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.8894/25H2/2025Update/HudsonValley2)
Intel Core Ultra 7 155H 3.00GHz, 1 CPU, 22 logical and 16 physical cores
.NET SDK 10.0.302
  [Host]     : .NET 10.0.10 (10.0.10, 10.0.1026.32716), X64 RyuJIT x86-64-v3
  DefaultJob : .NET 10.0.10 (10.0.10, 10.0.1026.32716), X64 RyuJIT x86-64-v3

| Method | Mean     | Error    | StdDev   | Ratio | RatioSD | Allocated | Alloc Ratio |
|------- |---------:|---------:|---------:|------:|--------:|----------:|------------:|
| Before | 22.98 ns | 0.520 ns | 0.778 ns |  1.00 |    0.05 |         - |          NA |
| After  | 10.45 ns | 0.269 ns | 0.276 ns |  0.46 |    0.02 |         - |          NA |
ベンチマークの実装
using System;
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using BenchmarkDotNet.Attributes;

namespace BenchmarkSuite;

public class Benchmarks
{
    private static readonly Fruits[] s_values = Enum.GetValues<Fruits>();

    [Benchmark(Baseline = true)]
    public string Before()
    {
        var _ = string.Empty;
        foreach (var x in s_values.AsSpan())
        {
            _ = toNumberString(x);
        }
        return _;

        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        static string toNumberString<T>(T value)
            where T : struct, Enum
        {
            var x = Unsafe.BitCast<T, byte>(value);
            return x.ToString(format: null, provider: CultureInfo.InvariantCulture);
        }
    }

    [Benchmark]
    public string After()
    {
        var _ = string.Empty;
        foreach (var x in s_values.AsSpan())
        {
            _ = toNumberString(x);
        }
        return _;

        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        static string toNumberString<T>(T value)
            where T : struct, Enum
        {
            var x = Unsafe.BitCast<T, byte>(value);
            return x.ToString();
        }
    }
}

public enum Fruits : byte
{
    Unknown = 0,
    Apple,
    Banana,
    Peach,
    Orange,
    Grape,
    Lemon,
    Melon,
    Strawberry,
    Cherry,
    WaterMelon,
    Pear,
    Pineapple,
}

Discussion