🚀

最速奪還への道 - FastEnum v2.0 開発奮闘記

2024/09/22に公開

FastEnum という C# / .NET における高速に動作する enum ユーティリティを作って GitHub 公開しています。今回その新バージョンである v2.0 をリリースしたので、そこに至るまでの苦悩の跡を残しておこうと思います。

https://github.com/xin9le/FastEnum

Benchmark

v2.0 の開発に至るまで

ここ数年の C# / .NET 事情

初版である v1.0 のリリースは 2019/9/6 で、ついこの間のようで 5 年も経ってしまいました。当時は最速と言ってよいパフォーマンスを叩き出していたと自負していますが、5 年も経つと C# / .NET 界隈もいろいろと技術の進歩があり事情が変わってきました。特に Source Generator の登場が大きく、利用者個々の実装に合わせた最適なソースコードをコンパイル前に出力することができるようになり、吐き出されたソースコードを含んだ形でのコンパイルが可能になりました。つまり、アプリケーション実行時ではなくコンパイル時にあらゆる解決ができる環境が整ったわけです。

広く公開するライブラリは、あらゆる利用者に対して汎用的な実装をすることになります。ライブラリがユーザーコードを把握できるのは基本的にアプリケーション実行時 (初回呼び出し時) になるため、コンパイル時にユーザーコードに対して最適化を行うものには決して太刀打ちできません。そんなこんなで最速とは言えない状況になり、FastEnum という名前を付けたにも関わらずそのアイデンティティをほぼ失ってしまっていました。

Source Generator 方式を採用する他ライブラリの使い勝手への疑問

長らく「なんとかしなければ」という気持ちを秘めつつ、どうしても重い腰を上げられずにいました。中でも大きな理由のひとつが、Source Generator 方式を採用している他のライブラリの使い勝手に対する疑問にありました。例えば、中でも GitHub Star 数が多い NetEscapades.EnumGenerators は以下のような使い方をします。

NetEscapades.EnumGenerators の使い方
[EnumExtensions]  // 属性をつける
public enum Fruits
{
    Apple = 1,
    Banana = 2,
}

// FruitsExtensions 型が自動生成される
var result = FruitsExtensions.Parse("Apple");

.NET 標準や FastEnum と比較してみると以下のような感じで、若干異質に見えるでしょう。

各ライブラリの利用方法の比較
var r1 = Enum.Parse<Fruits>("Apple");      // .NET 標準
var r2 = FastEnum.Parse<Fruits>("Apple");  // FastEnum
var r3 = FruitsExtensions.Parse("Apple");  // NetEscapades.EnumGenerators

ここから読み取れるように、FastEnum は .NET 標準から大きく乖離しない書き心地と高い移植性も目標にして開発してきました。なので、このような書き心地が広く受け入れられるものなのか疑問でした。

Source Generator によって自動生成される型の扱い

先のコード例にある FruitsExtensions のように、enum 型に対して属性を付与すると最適化されたコードが自動生成されます。この自動生成される型名や出力先の名前空間はどうするのが適切でしょうか?

当然ではあるのですが、既定ではライブラリ側が決めた一定のルールに従うことになります。稀に型名が被ってコンパイルエラーになってしまったり、所属させる名前空間を変更したくなることもあります。そうすると既定の挙動を override する仕組みが必要になります。NetEscapades.EnumGenerators をはじめとした Source Generator 方式を採用したライブラリは、この問題に概ね以下のように対応しています。

NetEscapades.EnumGenerators の例
[EnumExtensions(ExtensionClassNamespace = "MyNamespace", ExtensionClassName = "FruitsEx")]
public enum Fruits
{
    Apple = 1,
    Banana = 2,
}

このように、自分で型を定義しているのではないので、どうしても文字列ベースでの解決を図らざるを得ません。これもまた「心地よい」使い勝手なのかどうか疑問でした。

3rd Party 管理のアセンブリにある enum 型への対応

Source Generator は、概ね「特定の属性が付与されているかどうか」をトリガーとしてソースコード生成を行います。ここで疑問になるのが 3rd Party が定義した enum 型への対応です。ユーザーが独自に定義した enum 型に対しては属性をつけることができますが、HttpStatusCode のような .NET 標準の enum 型には属性を付与することができません。となると、当然別の解決方法が必要になります。

NetEscapades.EnumGenerators の例
[assembly: EnumExtensions<HttpStatusCode>]

NetEscapades.EnumGenerators の例では enum 自体に付けられないのを assembly 属性として回避しています。このような「一貫性のなさ」もまた心地よい使い勝手を損ねているのではないかと疑問に思っていました。

Source Generator 方式特有の制限

Source Generator こそがついに現れた銀の弾丸...のように感じるかもしれませんが、当然そんなことはなく、それ特有の制限があります。その最たるものが public もしくは internal な型にしか対応できないことです。というのも、Source Generator が出力するソースコードが定義済みの型とは別のファイルとして出力されるためです。

private 型には適用できない
public class MyClass
{
    [EnumExtensions]  // NG
    private enum Fruits
    {
        Apple = 1,
    }
}
file ローカル型にも適用できない
[EnumExtensions]  // NG
file enum Fruits
{
    Apple = 1,
}

このようにすべての enum 型に対して適用できないという制限があります。特定の enum 型だけ .NET 標準の Enum 型から脱却しきれないというのは決して使用感が良いとは言い切れないでしょう。

それでも Source Generator 方式の採用を決意

これまで書き連ねた通り、利便性 / 一貫性 / 美的感覚などの観点から Source Generator 方式には強い抵抗感がありました。「そこまでして速度を追求しなくても .NET 標準の Enum 型よりも大幅に高速な FastEnum こそが大半のユーザーにとっての最適解である」という気持ちもありました。

それでも失ったアイデンティティを取り戻したい気持ちを拭い去れず、どうにか良い落としどころを探りたいと Source Generator と向き合うことを決心しました。

v2.0 の実装方針

Source Generator 特有の課題への対応

先に述べた通り、Source Generator にはいくつかの特有の課題があります。それぞれどのように考えたのかを書いていきます。

1. 自動生成される型と 3rd Party 管理の enum 型の扱い

これについては「一貫性」を理由に「enum 型自体に属性を付けない」方針を採ることとしました。以下のような書き方で Source Generator によるコード生成を有効化します。

Source Generator によるコード生成を有効化
[FastEnum<Fruits>]  // 属性を付与
internal partial class FruitsOperation  // コード自動生成のための入れ物
{ }

こうすることにより、型名や出力先の名前空間を文字列ベースの解決に頼ることなくユーザーに決定させることができます。また 3rd Party 管理の enum 型に対しても同様の記述で対応できる一貫性があります。単純に enum 型に属性を付けるのと比べると記述しなければならないコード量が多くなりますが、混乱を招かないことの方がより重要だろうと判断しました。

2. 適用できるアクセシビリティが限られる問題

これについては Source Generator の仕様として受け入れるしかありません。ただし、Source Generator による高速化を適用できないだけで、すべての enum 型に対して FastEnum を適用できる状態ではありたいと強く意識しました。他の Source Generator 方式のライブラリはこれができていないので、十分な差別化になると考えたからです。

初期案

ということで v2.0 を作るにあたって自分なりの目標を掲げました。それが「既存 API を維持」することでした。リフレクションとキャッシュをベースとした従来の API と Source Generator 方式の API の Hybrid であることもまた、他ライブラリと比べた際のアイデンティティになります。

加えて、ライブラリのユーザーに大きな負担を強いることなく、ほぼ v2.0 にアップデートするだけで (もしくは微小な対応だけで) 高速化されるのが理想です。ということで、ザックリ以下のような実装を検討しました。

IFastEnumOperation.cs
public interface IFastEnumOperation<T>
    where T : struct, Enum
{
    // 他にもいろいろとメソッドが定義されるけど、とりあえずひとつだけ記載
    bool IsDefined(T value);
}
FastEnumOperationProvider.cs
public static class FastEnumOperationProvider
{
    public static void Register<T>(IFastEnumOperation<T> operation)
        where T : struct, Enum
        => Interlocked.Exchange(ref Cache<T>.s_operation, operation);

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    internal static IFastEnumOperation<T> Get<T>()
        where T : struct, Enum
        => Volatile.Read(ref Cache<T>.s_operation);

    private static class Cache<T>
        where T : struct, Enum
    {
        public static IFastEnumOperation<T> s_operation;

        static Cache()
        {
            // v1.x の挙動を維持するための実装を初期値として設定しておく
            s_operation = UnderlyingOperation.Create<T>();
        }
    }
}
FastEnum.cs
public static class FastEnum
{
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static bool IsDefined<T>(T value)
        where T : struct, Enum
    {
        var operation = FastEnumOperationProvider.Get<T>();
        return operation.IsDefined(value);
    }
}

ユーザーが利用する際には以下のようなコードを記述するだけで、その他は何も変わりません。

Source Generator による高速化を有効化
[FastEnum<Fruits>]  // 属性を付与
internal partial class FruitsOperation  // Source Generator のための箱
{ }
Source Generator によって自動生成されるコード
partial class FruitsOperation : IFastEnumOperation<Fruits>
{
    // モジュール初期化子を使って差し込んでおく
    [ModuleInitializer]
    public static void Initialize()
        => FastEnumOperationProvider.Register(new FruitsOperation());

    bool IFastEnumOperation<Fruits>.IsDefined(Fruits value)
    { /* 省略 */ }
}
呼び出し
// v1.x から一切の変更なし
var defined = FastEnum.IsDefined(Fruits.Apple);

このような実装にしておくことで、何もしないで v1.x から v2.0 に更新した既存ユーザーの挙動は完全に維持され、各々の環境に合わせた IFastEnumOperation<T> を Source Generator を利用して実装し FastEnumOperationProvider.Register() で差し込めばその挙動に差し替えることができます。実際、この機構は実装の美しさも含めて極めて上手く機能しました。ベンチマークを除いては...。

ベンチマーク

この機構を作った後にベンチマークを実施しました。

IsDefined<T>() benchmark
Benchmark code

今回作った機構を表しているのは図中の FastEnum_v2_VirtualCall の部分です。v1.0 の倍以上の速度が出ているので結果としては「よくやった」という感じでしょう。

しかしながら FastEnum_v2_DirectCall の結果には大きく水をあけられています。これは FastEnum_v2_VirtualCallIFastEnumOperation<T> インターフェースを経由したメソッド呼び出しになっているのが原因です。普段は全く意識する必要がなく、むしろ便利に使っている多態 (polymorphic) ですが、マイクロ最適化の世界ではこのたった 1 段の仮想関数テーブルを噛んでいるかどうかの差が大きく響いてきます。

仮想関数テーブルを噛まないようにするためには「インターフェースの実体となる型が確定している」必要があります。明示的に具象型を記述するのが最も明確な対応策ですが、今回のアプローチではそれがどうやってもできません。困った...。

public class Benchmark
{
    // 具象型を明示しているので高速
    private Operation _ope1 = new Operation();

    // 仮想関数テーブルを噛むので遅い
    private IOperation _ope2 = new Operation();

    // 具象型を明示していないが JIT 時最適化の対象となって高速
    private static readonly IOperation s_ope = new Operation();
}

ちなみに static readonly フィールドも JIT 時最適化の対象となって型が確定して仮想関数テーブルを噛みません。これは今回検証していく中での大きな学びのひとつでした。

これまで書いてきたように、初期案は「エレガントな解決」に思えたのですが、どうしても多態を使っていることに起因する仮想関数テーブルを挟んでしまう都合上最速にはなり得ないことがわかりました。最速を狙っているのに最速ではないアプローチで妥協するのはいかがなものか、という気持ちが強くなり、ボツ案にしました。

もしかしたらこれが「利便性 / 実装の美しさ / 速度」の落としどころとしての最適解だったかもしれませんが...w

修正案

ということで、仮想関数テーブルを挟まないことを条件として加えて別のアプローチを模索しました。そこで浮かんだのが「インターフェイスの静的抽象メンバー (= Static abstract members in interface)」を利用した手法です。あまり馴染みがないかもしれませんが、これは C# 11 / .NET 7 から正式搭載された新しめの言語機能です。これを利用したメソッド呼び出しは仮想関数テーブルのオーバーヘッドがなく極めて高速に動作します。修正案はザックリと以下のような感じの実装になっています。

IFastEnumBooster.cs
public interface IFastEnumBooster<T>
    where T : struct, Enum
{
    // 他にもいろいろとメソッドが定義されるけど、とりあえずひとつだけ記載
    static abstract bool IsDefined(T value);
}
FastEnum.cs
public static class FastEnum
{
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static bool IsDefined<TEnum, TBooster>(TEnum value)
        where TEnum : struct, Enum
        where TBooster : IFastEnumBooster<TEnum>
        => TBooster.IsDefined(value);  // interface に定義した static メソッドの呼び出し
}

ユーザー側が利用する際のコードは以下のようになります。

Source Generator による高速化を有効化
[FastEnum<Fruits>]  // 属性を付与
internal partial class FruitsBooster  // Source Generator のための箱
{ }
Source Generator によって自動生成されるコード
partial class FruitsBooster : IFastEnumBooster<Fruits>
{
    static bool IFastEnumBooster<Fruits>.IsDefined(Fruits value)
    { /* 省略 */ }
}
呼び出し
// v1.x の API とは別の overload を呼び出す必要がある
var v1 = FastEnum.IsDefined(Fruits.Apple);
var v2 = FastEnum.IsDefined<Fruits, FruitsBooster>(Fruits.Apple);  // こう利用する

見た通り Source Generator によって生成された加速装置 (= IFastEnumBooster<T>) を Generics 引数として設定してあげるだけです。言語仕様の都合上 Generics 引数の解決を簡略化できず若干冗長な感じになっているのが心苦しいのですが、「.NET 標準 API や v1.x 系の API と大きく乖離させない」という使い勝手とのトレードオフだと割り切りました。

既存 API (v1.x からある Source Generator を利用しないもの) も維持しているので、カリカリにチューニングしたいと思った箇所だけちょこちょこッと書き換えれば OK です。

ベンチマーク

結果は本記事の冒頭にも載せた通りで (下記に再掲)、.NET 標準はもちろんのこと他の GitHub Stars の多いライブラリたちを圧倒しています。

Benchmark

また、芸が細か過ぎてここでは書ききれませんが、もちろん単純に Source Generator 化しただけではありません。内部実装もひとつひとつ細かくベンチマークを繰り返してチューニングしています。そう言った地道な細かい積み上げで成り立っています。

IFastEnumBooster<T> という名前

初期案では IFastEnumOperation<T> という名前でした。「enum の操作」を定義する部分という意思で命名していましたが、それを修正案では IFastEnumBooster<T> にリネームしました。当然 (?)「Booster = 補助推進装置」ってなんだよって思うわけですが、ふたつを並べたときに

  • Booster の方が加速している感が出てない?
  • 何もしなくても十分速いけど「さらに加速させる目的でやっている」ことが明確では?

という気持ちになったためです。名前は概念や意図を明確に表すべきなので、なかなか良いネーミングができたのではないかと思います。

Operation か Booster か問題
var x1 = FastEnum.IsDefined<Fruits, FruitsOperation>(Fruits.Apple);
var x2 = FastEnum.IsDefined<Fruits, FruitsBooster>(Fruits.Apple);  // なんか速そう

まとめ

今回 v2.0 で追加した API は「インターフェイスの静的抽象メンバー」という言語機能が増えたおかげで実現できた表現で、(初期案と比べると妥協しつつも) 今はとても気に入っています。もしかしたら数年後にはまた別の言語機能の搭載によってより洗練された表現にできるかもしれませんが、現時点ではここまでということで。

今の僕にできるアイディア (?) を詰め込んだ FastEnum v2.0 を是非使ってみてください。もし他にもパフォーマンス面や表現面での改善余地があれば、是非 Issue や Pull-Request などで投げていただけると嬉しいです。お待ちしております。

Discussion