はなちるのマイノート

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

【C#】Incremental SourceGeneratorを利用した爆速Enumユーティリティライブラリを作成した話

はじめに

先日、SourceGeneratorを利用したパフォーマンスの良いEnumユーティリティを提供するライブラリを公開しました。
github.com

記事の後半で詳しく紹介しますが、パフォーマンスが良いと知られていたFastEnumよりも高速に動作しゼロアロケーションを達成しています。
GitHub - xin9le/FastEnum: The world fastest enum utilities for C#/.NET

パフォーマンス比較

今回はこのライブラリの実装についてツラツラと書いていきたいと思います。

背景

まず前提として、.NETに用意されているAPIは遅いです。

public enum Weather
{
    Sun,
    Cloud,
    Rain,
    Snow
}
// Sun,Cloud,Rain,Snow
Weather[] values = Enum.GetValues<Weather>();

// Sun,Cloud,Rain,Snow
string[] names = Enum.GetNames<Weather>();

// Rain
string? name = Enum.GetName(Weather.Rain);

// Cloud
string toString = Weather.Cloud.ToString();

// True
bool defined = Enum.IsDefined(Weather.Sun);

// Sun
Weather parse = Enum.Parse<Weather>("Sun");

// True
// Sun
bool tryParse = Enum.TryParse<Weather>("Sun", out Weather value);

対してxin9leさんのFastEnumでは、Static Type Cachingという手法を用いてキャッシュを行うことでパフォーマンス向上を狙っています。

// Sun,Cloud,Rain,Snow
IReadOnlyList<Weather> values = FastEnum.GetValues<Weather>();

// Sun,Cloud,Rain,Snow
IReadOnlyList<string> names = FastEnum.GetNames<Weather>();

// Rain
string? name = FastEnum.GetName(Weather.Rain);

// Cloud
string toString = Weather.Cloud.FastToString();

// True
bool defined = FastEnum.IsDefined(Weather.Sun);

// Sun
Weather parse = FastEnum.Parse<Weather>("Sun");

// True
// Sun
bool tryParse = FastEnum.TryParse<Weather>("Sun", out Weather value);

github.com
www.hanachiru-blog.com

以下はFastEnumのReadmeに記載されているパフォーマンス比較で、かなり処理速度が向上していることが伺えます。またゼロアロケーションです。

FastEnumのReadmeより引用

一応補足しておきますが、APIの返り値の型が完全に一致しているわけではないですし、Static Type Cachingの性質上一度メモリ上に確保されてしまうとアプリケーションを終了するまで占有しつづけてしまうといったデメリットも存在することはご留意ください。

ただ実用する上ではFastEnumを使ったとしてもそこまで問題にならないケースが殆どだと思いますし、積極的にFastEnumを利用していくと良いと思います。

RapidEnum

私が今回作成したRapidEnumIncremental SourceGeneratorを用いてコンパイルのタイミングでenumの解析を行い、コード生成を行います。

// [RapidEnum]という属性をpublic or internalなenumに付与してあげると...
[RapidEnum]
public enum Weather
{
    Sun,
    Cloud,
    Rain,
    Snow
}

// ↓のコードがコンパイルのタイミングで自動生成される
public static partial class WeatherEnumExtensions
{
  [MethodImpl(MethodImplOptions.AggressiveInlining)]
  public static string ToStringFast(this global::Sample.Weather value)
  {
      return value switch
      {
          Sample.Weather.Sun => nameof(Sample.Weather.Sun),
          Sample.Weather.Cloud => nameof(Sample.Weather.Cloud),
          Sample.Weather.Rain => nameof(Sample.Weather.Rain),
          Sample.Weather.Snow => nameof(Sample.Weather.Snow),
          _ => value.ToString()
      };
  }
  
  private static readonly ReadOnlyCollection<global::Sample.Weather> CacheValues = new ReadOnlyCollection<global::Sample.Weather>(new[]
  {
      Sample.Weather.Sun,
      Sample.Weather.Cloud,
      Sample.Weather.Rain,
      Sample.Weather.Snow,
  });
  
  [MethodImpl(MethodImplOptions.AggressiveInlining)]
  public static IReadOnlyList<global::Sample.Weather> GetValues() => CacheValues;
  
 ...
}
// 利用する時は生成されたコードを利用する
// Sun,Cloud,Rain,Snow
IReadOnlyList<Weather> values = WeatherEnumExtensions.GetValues();

生成されたコードを見ると高速な理由が分かると思いますが、ただのswitchがあるだけです。あとはReadOnlyCollectionをキャッシュしておいて、それを返すだけ。めっちゃシンプルです。

これは今までランタイムでenumの解析を行っていたのを、コンパイルのタイミングに解析のタイミングをずらしたことによって得られた恩恵です。

利用する側に定義をする

enum[RapidEnum]を利用すると定義側にユーティリティを作成するのに対し、[RapidEnumWithType]を用いることで利用側にユーティリティを定義することができます。これによって、サードパーティ製のライブラリに定義されているenumなどの任意のenumに対して利用可能です。

// System.DateTimeKind has Unspecified, Utc, Local
// public static partial class  or  internal static partial classのどちらかに[RapidEnumWithType(対象enumの型)]を付与する
[RapidEnumWithType(typeof(DateTimeKind))]
public static partial class DateTimeKindEnumExtensions
{
}
// Unspecified,Utc,Local
IReadOnlyList<DateTimeKind> values = DateTimeKindEnumExtensions.GetValues();

[RapidEnum][RapidEnumWithType]の違いは、定義側・利用者側のどちらにユーティリティを定義するかの違いです。パフォーマンスに差があるわけではありません。

結果

.NET APIよりも約十倍 ~ 数万倍パフォーマンスが良いという結果が得られました。またFastEnumより高速です。

パフォーマンス比較

考察

パフォーマンスが良いと書きましたが、RapidEnumには色々と制約があるので一概に比較できるものではないことは補足させてください。

まずFastEnumと異なり、publicinternalenumにしか適応できないです。というのもあくまでISGによりユーティリティクラスが生えてくるので、そのクラスから利用できない型にはアクセスできないのが理由です。

また属性をつけないといけないめんどくささがあるのと、生成されるクラス名([RapidEnumWithType]は自分で設定したクラス名が適応)が分かりづらいという点もつらいです。

// クラス名は一応自由に設定できる
[RapidEnumWithType(typeof(DateTimeKind))]
public static partial class HogeHoge
{
}
// Unspecified,Utc,Local
IReadOnlyList<DateTimeKind> values = HogeHoge.GetValues();

今後の展望

実際に使うとやはり属性を付与するのがめんどくさいですよね...。

SourceGeneratorなので一定仕方がない面があるのですが、CySharpさんのConsoleAppFrameworkはメソッドのシグネチャを見てコード生成を行う大胆な手法を取り入れています。
neue.cc

つまりメソッドが定義されているからそれを呼ぶのではなく、メソッドを利用しようとするとそのメソッドが生えてくるという世界観です。

// Genericを見てコード生成を行う案
public enum Hoge 
{
    A,
    B
}
_ = RapidEnum<Hoge>.Hoge.GetNames();

// ↓が生成される
public static partial class RapidEnum<T>
{
    public static partial class Hoge
    {
        public static IReadOnlyList<string> GetNames()
        {
            // 処理を行う
        }
    }
} 

genericを利用するのでバイナリサイズに気をつけないといけないのは勿論ですが、Nested Classesを利用することでパフォーマンスを保ちます。

一応static type cachingを利用すれば一つのclassに集約できそうな気もしているのですが、うまくいけるかは自信がないです...(チャレンジしてみたいですが)

構想自体はあるのですが、結局はインテリセンスが快適に動作するのかあたりが焦点だと思います。また勝手にコード生成されるのは如何なものかという意見もあるのは重々承知してます。(それは絶対やめておけみたいな意見もちょこちょこ聞いています)

ひとまずR&D的な意味合いで、experimental機能として実装してみたいという温度感ですね。