ZLogger v2 による .NET 8活用事例 と Unity C# 11対応の紹介

C#用の新しい超高速&低アロケーションの.NET用ロギングライブラリ、ZLogger v2を公開しました。v1からは何もかもを完全に作り替えた、最新のC#に合わせた新設計になっています。対応プラットフォームは.NET 8が最良ですが .NET Standard 2.0 以上、また Unity 2022.2 以上にも対応しています。.NET / Unityどちらもテキストメッセージと構造化ログの両方に対応しています。

新設計のキーポイントはString Interpolationの全面採用によるクリーンなシンタックスとパフォーマンスの両立です。

logger.ZLogInformation($"Hello my name is {name}, {age} years old.");

といったように書いたコードは

if (logger.IsEnabled(LogLvel.Information))
{
    var handler = new ZLoggerInformationInterpolatedStringHandler(30, 2, logger);
    handler.AppendLiteral("Hello my name is ");
    handler.AppendFormatted<string>(name, 0, null, "name");
    handler.AppendLiteral(", ");
    handler.AppendFormatted<int>(age, 0, null, "age");
    handler.AppendLiteral(" years old.");
}

のようにコンパイル時に分解されます。フォーマット文字列を実行時ではなくコンパイル時に展開すること、パラメーターはAppendFormatted<T>の形でジェネリクスで受け取ることによりボクシングが発生しないなど、コードからも明らかに効率的なことが見てとれます。ちなみにコンストラクターの30は文字列の長さ、2はパラメーターの数を指していて、ここから必要な初期バッファ数を算出していることも効率化の一つに繋がっています。

String Interpolation自体はC# 6.0から搭載されている機能ですが、文法上同じながらC# 10.0から強化されたString Interpolationが搭載されていて、カスタムのString Interpolationを提供することも可能になりました。

こうして得られた文字列断片とパラメーターは、最終的にはCysharp/Utf8StringInterpolationを通して文字列化せずに、直接UTF8としてStreamに書き込むことによって、高速化と低アロケーションを実現しています。

また、Structured Loggingにおいても、System.Text.JsonのUtf8JsonWriterとタイトに結びつくことにより

// 例えば {"name":"foo",age:33} のようにUtf8JsonWriterに書き込む

// Source Generator版、実際どうなってるかのイメージがとても分かりやすい。
public void WriteJsonParameterKeyValues(Utf8JsonWriter writer, JsonSerializerOptions jsonSerializerOptions)
{
    writer.WriteString(_jsonParameter_name, this.name);
    writer.WriteNumber(_jsonParameter_age, this.age);
}

// StringInterpolation版、ちょっと遠回りな感じですがやってることは一緒。
public void WriteJsonParameterKeyValues(Utf8JsonWriter writer, JsonSerializerOptions jsonSerializerOptions)
{
    for (var i = 0; i < ParameterCount; i++)
    {
        ref var p = ref parameters[i];
        writer.WritePropertyName(p.Name.AsSpan());
        // MagicalBoxの説明は後述
        if (!magicalBox.TryReadTo(p.Type, p.BoxOffset, jsonWriter, jsonSerializerOptions))
        {
            // ....
        }
    }
}

やはり直接UTF8として書き込みます。Structured Loggingは最近のトレンドなので、色々な言語のロガーに実装されていますが、パフォーマンスを両立しつつ、ここまでクリーンなシンタックスで実現できているものは他にない!という感じなのでかなり良いのではないでしょうか。

では実際ベンチマーク結果でどれぐらい?というと、アロケーションは少なくとも圧倒的です。

image

アロケーションは、という歯切れの悪い言い方をしているのは、念入りに高速になるよう設定したNLogが思ったよりも速かったせいですね、ぐぬぬ……。

さて、ZLoggerの特徴のもう一つは、Microsoft.Extensions.Loggingの上に直接構築していることです。通常のロガーは独自のシステムを持っていて、Microsoft.Extensions.Loggingと繋げる場合はブリッジを通します。現実的なアプリケーションでは ASP .NETを使う場合などMicrosoft.Extensions.Loggingを避けることはほぼ不可能です。.NET 8からはOpenTelemetry対応の強化やAspireなど、ますますMicrosoft.Extensions.Loggingの重要性は増しています。ZLogger v1と異なり、v2ではScopeなど、Microsoft.Extensions.Loggingの全機能に対応しています。

そして例えばSerilogのブリッジライブラリの品質は(ソースコードも確認しましたが)かなり低く、実際のパフォーマンスの数字にも現れています。ZLoggerはそうしたオーバーヘッドが一切かかりません。

また、デフォルトの設定も非常に重要です。ほとんどのロガーの標準設定は、例えばファイルストリームに書く場合は都度Flushするなど、かなり遅い設定が標準になっています。それを高速化するにはasync, bufferedを適切に調整する必要があり、かつ、取りこぼさないように終了時に確実にFlushさせる必要があるのですが、かなり難しいので、ほとんど標準設定のままの人も多いのではないでしょうか?ZLoggerではデフォルトで最高速になるように調整してあり、かつ、Microsoft.ExtensionsのDIのライフサイクルで最後のFlushも自動でかかるようになっているので、ApplicationBuilderなどでアプリケーションを構築した場合は何も意識しなくても取りこぼしは発生しません。

なお、都度Flushのパフォーマンスはストレージの書き込み性能に強く依存するため、例えば最近のマシンのM.2 SSDは非常に高速なため、ローカルでベンチマークすると意外と遅くない、といったことを確認できるかもしれません。ただし、実際にアプリケーションを配置する、例えばクラウドサーバーのストレージ性能がそこまで高いことはないので、ローカルでの結果を過信しないほうがいいでしょう。

MagicalBox

ここからは、パフォーマンスを実現した幾つかのトリックを紹介します。v1から引き継いでいるのはSystem.Threading.Channelsを活用したasyncな非同期書き込みプロセスの作成と、IBufferWriter<byte>による効率的なbufferedの利用による、Streamへの書き込み最適化ですが、説明は割愛します。

JSON化のために、パラメーターはInterpolatedStringHandlerで、一時的に値として保持します。その場合に、<T>の値をどのように保持するか、という問題がでてきます。普通に考えると、List<object>といったようなobject型で保持することになります。

[InterpolatedStringHandler]
public ref struct ZLoggerInterpolatedStringHandler
{
    // あらゆる<T>の型を格納するためにobjectを使う、ボクシングが発生するので良くはない。
    List<object> parameters = new ();

    public void AppendFormatted<T>(T value, int alignment = 0, string? format = null, [CallerArgumentExpression("value")] string? argumentName = null)
    {
        parameters.Add((object)value);
    }
}

それを避けるために、ZLoggerではMagicalBoxという仕組みを用意しました。

[InterpolatedStringHandler]
public ref struct ZLoggerInterpolatedStringHandler
{
    // 魔法の箱に無限に詰め込む
    MagicalBox magicalBox;
    List<int> boxOffsets = new (); // 実際はこの辺は入念にキャッシュされています

    public void AppendFormatted<T>(T value, int alignment = 0, string? format = null, [CallerArgumentExpression("value")] string? argumentName = null)
    {
        if(magicalBox.TryWrite(value, out var offset)) // boxingが発生しない!
        {
            boxOffsets.Add(offset);
        }
    }
}

MagicalBoxはどんな型(unmanaged型に限る)でも、ボクシングなしに書き込むことができる。というコンセプトで、その実態はbyte[]にUnsafe.Write、offsetを元にUnsafe.Readするというだけの代物です。

internal unsafe partial struct MagicalBox
{
    byte[] storage;
    int written;

    public MagicalBox(byte[] storage)
    {
        this.storage = storage;
    }

    public bool TryWrite<T>(T value, out int offset)
    {
        if (RuntimeHelpers.IsReferenceOrContainsReferences<T>())
        {
            offset = 0;
            return false;
        }

        Unsafe.WriteUnaligned(ref storage[written], value);
        offset = written;
        written += Unsafe.SizeOf<T>();
        return true;
    }

    public bool TryRead<T>(int offset, out T value)
    {
        if (!RuntimeHelpers.IsReferenceOrContainsReferences<T>())
        {
            value = default!;
            return false;
        }

        value = Unsafe.ReadUnaligned<T>(ref storage[offset]);
        return true;
    }
}

この辺はMemoryPackでの実装経験が元になっていて、うまく機能しています。

なお、実際のコードではbyte[] storageの効率的な再利用や非ジェネリクスなRead対応、Enumへの特別対応が入ったりなど、もう少し複雑なコードになってはいます。さすがに。

カスタムフォーマット文字列

ZLoggerのString Interpolationのいいところは、パラメーター値にメソッド呼び出しを含めると、LogLevelのチェックが入った後に呼び出されるので無駄な実行を防げるところです。

// これは
logger.ZLogDebug($"Id {obj.GetId()}: Data: {obj.GetData()}.");

// このようにLogLevelが有効かどうかチェックした後にメソッドが呼ばれる
if (logger.IsEnabled(LogLvel.Debug))
{
    // snip...
    writer.AppendFormatterd(obj.GetId());
    writer.AppendFormatterd(obj.GetData());
}

しかし、メソッド呼び出しをStructured Loggingに出力した場合、ZLoggerはC# 10.0以降から追加されたCallerArgumentExpressionでパラメーター名を取得しているため、メソッド呼び出しの場合は "obj.GetId()" という微妙極まりない名前で出力されてしまいます。そこで、特殊なカスタムフォーマット文字列で別名を指定することができます。

// @name で別名を付けられる
logger.ZLogDebug($"Id {obj.GetId():@id}: Data: {obj.GetData():@data}.");

ZLoggerでは、String Interpolationの本来の式に従って、","でアラインメント、":"でフォーマット文字列を指定することができます。それに加えて特殊な指定として、フォーマット文字列を@から始めた場合はパラメーター名として出力します。

@によるパラメーター名指定とフォーマット文字列は併用することができます。

// Today is 2023-12-19.
// {"date":"2023-12-19T11:25:34.3642389+09:00"}
logger.ZLogDebug($"Today is {DateTime.Now:@date:yyyy-MM-dd}.");

もう一つ、共通の特殊なフォーマット文字列として"json"を指定するとJsonSerializeした形で出力できます(この辺はSerilogの持つ機能からインスパイアされました)

var position = new { Latitude = 25, Longitude = 134 };
var elapsed = 34;

// {"position":{"Latitude":25,"Longitude":134},"elapsed":34}
// Processed {"Latitude":25,"Longitude":134} in 034 ms.
logger.ZLogInformation($"Processed {position:json} in {elapsed:000} ms.");

特殊フォーマット文字列は、例えばログレベルやカテゴリー、日付を先頭/末尾に付与するためのPrefixFormatter/SuffixFormatterにも幾つか用意してあります。

logging.AddZLoggerConsole(options =>
{
    options.UsePlainTextFormatter(formatter =>
    {
        // 2023-12-19 02:46:14.289 [DBG]......
        formatter.SetPrefixFormatter($"{0:utc-longdate} [{1:short}]", (template, info) => template.Format(info.Timestamp, info.LogLevel));
    });
});

Timestampにはlongdate, utc-longdate, dateonlyなど。LogLevelにはshortで3文字ログレベル表記(先頭の長さが一致するのでエディタで開いた時に読みやすくなる)へと変換されます。これら組み込みの特殊フォーマット文字列は、パフォーマンス最適化という意味合いもあります。例えばLogLevelは以下のようなコードになっているので、手で書式を作るよりも、事前組み込みのUTF8文字列で書き込むことで、絶対的に効率がよくなっています。

static void AppendLogLevel(ref Utf8StringWriter<IBufferWriter<byte>> writer, ref LogLevel value, ref MessageTemplateChunk chunk)
{
    if (!chunk.NoAlignmentAndFormat)
    {
        if (chunk.Format == "short")
        {
            switch (value)
            {
                case LogLevel.Trace:
                    writer.AppendUtf8("TRC"u8);
                    return;
                case LogLevel.Debug:
                    writer.AppendUtf8("DBG"u8);
                    return;
                case LogLevel.Information:
                    writer.AppendUtf8("INF"u8);
                    return;
                case LogLevel.Warning:
                    writer.AppendUtf8("WRN"u8);
                    return;
                case LogLevel.Error:
                    writer.AppendUtf8("ERR"u8);
                    return;
                case LogLevel.Critical:
                    writer.AppendUtf8("CRI"u8);
                    return;
                case LogLevel.None:
                    writer.AppendUtf8("NON"u8);
                    return;
                default:
                    break;
            }
        }

        writer.AppendFormatted(value, chunk.Alignment, chunk.Format);
        return;
    }

    switch (value)
    {
        case LogLevel.Trace:
            writer.AppendUtf8("Trace"u8);
            break;
        case LogLevel.Debug:
            writer.AppendUtf8("Debug"u8);
            break;
        case LogLevel.Information:
            writer.AppendUtf8("Information"u8);
            break;
        case LogLevel.Warning:
            writer.AppendUtf8("Warning"u8);
            break;
        case LogLevel.Error:
            writer.AppendUtf8("Error"u8);
            break;
        case LogLevel.Critical:
            writer.AppendUtf8("Critical"u8);
            break;
        case LogLevel.None:
            writer.AppendUtf8("None"u8);
            break;
        default:
            writer.AppendFormatted(value);
            break;
    }
}

.NET 8 XxHash3 + Non-GC Heap

.NET 8からXxHash3が追加されました。最速のハッシュアルゴリズムであるXxHashの最新シリーズで、小さいデータから大きいデータまで、迷ったらほぼこれ一択で問題ないだろうという性能になっています。なお、利用にはNuGetからSystem.IO.Hashingが必要なので、逆に.NET 8ではなくNET Standard 2.0でも使えます。

ZLoggerでも複数箇所で使っているのですが、その中から一例として、String Interpolationの文字列リテラルからキャッシュを取り出す処理の例を。

// $"Hello my name is {name}, {age} years old." が生成する文字列リテラルの並び(LiteralList)
// ["Hello my name is ", "name", ", ", "age", " years old."]
// これからUTF8変換済みのキャッシュ(MessageSequence)を取り出すという処理
static readonly ConcurrentDictionary<LiteralList, MessageSequence> cache = new();

// 非.NET 8版
#if !NET8_0_OR_GREATER

struct LiteralList(List<string?> literals) : IEquatable<LiteralList>
{
    [ThreadStatic]
    static XxHash3? xxhash;

    public override int GetHashCode()
    {
        var h = xxhash;
        if (h == null)
        {
            h = xxhash = new XxHash3();
        }
        else
        {
            h.Reset();
        }

        var span = CollectionsMarshal.AsSpan(literals);
        foreach (var item in span)
        {
            h.Append(MemoryMarshal.AsBytes(item.AsSpan()));
        }

        // https://github.com/Cyan4973/xxHash/issues/453
        // XXH3 64bit -> 32bit, okay to simple cast answered by XXH3 author.
        return unchecked((int)h.GetCurrentHashAsUInt64());
    }

    public bool Equals(LiteralList other)
    {
        var xs = CollectionsMarshal.AsSpan(literals);
        var ys = CollectionsMarshal.AsSpan(other.literals);

        if (xs.Length == ys.Length)
        {
            for (int i = 0; i < xs.Length; i++)
            {
                if (xs[i] != ys[i]) return false;
            }
            return true;
        }

        return false;
    }
}

#endif

XxHash3はclassなので(System.HashCodeみたいにstructが良かったなあ)、ThreadStaticで使いまわしつつ、GetHashCodeを生成しています。XxHash3はulongの出力しかありませんが、作者によると、32bitに落とす場合は特にXORとかかけることもなく直接落として問題ないそうです。

ここまでが普通の使い方ですが、.NET 8版ではエクストリームな最適化を入れました。

#if NET8_0_OR_GREATER

struct LiteralList(List<string?> literals) : IEquatable<LiteralList>
{
    // literals are all const string, in .NET 8 it is allocated in Non-GC Heap so can compare by address.
    // https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-8/#non-gc-heap
    static ReadOnlySpan<byte> AsBytes(ReadOnlySpan<string?> literals)
    {
        return MemoryMarshal.CreateSpan(
            ref Unsafe.As<string?, byte>(ref MemoryMarshal.GetReference(literals)),
            literals.Length * Unsafe.SizeOf<string>());
    }

    public override int GetHashCode()
    {
        return unchecked((int)XxHash3.HashToUInt64(AsBytes(CollectionsMarshal.AsSpan(literals))));
    }

    public bool Equals(LiteralList other)
    {
        var xs = CollectionsMarshal.AsSpan(literals);
        var ys = CollectionsMarshal.AsSpan(other.literals);

        return AsBytes(xs).SequenceEqual(AsBytes(ys));
    }
}

#endif

List<string>?ReadOnlySpan<byte>に変換して、それでXxHash3.HashToUInt64やSeqeunceEqualを一発で呼んでます。見るからにこちらのほうが効率的なわけですが、しかし、そもそもList<string>?ReadOnlySpan<byte>に変換するのは合法なのか?と。この場合のstringの変換は、ReadOnlySpan<IntPtr>への変換という意味合いで、つまりヒープにあるstringのアドレスのリストへと変換しているという意図になります。

そこまではいいとして、問題はアドレスの比較は危険すぎないか、ということです。まず第一に、stringは文字列として同一であっても実態は別のアドレスにある場合も多い。第二に、ヒープにあるstringのアドレスは固定されていない、移動することがあるということです。辞書のキーとしてGetHashCodeやEqualsを求めるなら、アプリケーション実行中は完全に固定されていなければなりません。

ところが今回の利用例に着目すると、String Inteprolationで呼ばれるAppendLiteralはコンパイル時に handler.AppendLiteral("Hello my name is "); のように、必ず定数で渡されています。そのため同じ実体を指すことが保証されています。

[InterpolatedStringHandler]
public ref struct ZLoggerInterpolatedStringHandler
{
    public void AppendLiteral([ConstantExpected] string s)
}

一応保険として、.NET 8から有効化されているConstantExpectedによって、定数だけが渡されることを明示しています。

もう一つは、そうした定数の文字列は最初からインターン化されているのですが、そのインターン化された場所が移動しないことは.NET 8まで保証されていませんでした。ところが、.NET 8からはNon-GC Heapが導入されたため、移動しないことが保証されている、といえます。

// .NET 8からは定数のGC.GetGenerationの結果がint.MaxValue(Non-GC Heapにいる)
var str = "foo";
Console.WriteLine(GC.GetGeneration(str)); // 2147483647

これによって、C#だとどうしても避けられないUTF16 StringからUTF8 Stringへの変換を、限界まで高速化することができました。なお、Source Generator版ではこのルックアップコスト自体を削れているため、ベンチマーク結果が最速であった通り、より高速です。

.NET 8 IUtf8SpanFormattable

ZLoggerでは値を文字列を通さずUTF8に直接書き込むことをパフォーマンスの柱にしています。.NET 8からIUtf8SpanFormattableという、値の汎用的なUTF8への直接変換を可能にしたインターフェイスが追加されました。ZLoggerは.NET 8以前の.NET Standard 2.0にも対応させるために、intやdoubleなど基本的なプリミティブは特殊な対応によって、UTF8への直接書き込みを実現していますが、.NET 8の場合は対応範囲がより広がるため、できれば.NET 8がお薦めです。

なお、IUtf8SpanFormattableはフォーマット文字列のalignmentには関知しないため、分離しているライブラリであるCysharp/Utf8StringInterpolationでは.NET Standard 2.0対応と同時に、alignment対応の機能も追加されたライブラリとなっています。

.NET 8 TimeProvider

TimeProviderは.NET 8から追加された時間に関するAPIの抽象化(TimeZone, Timerなども含む)となっていて、ユニットテスト等でも非常に役に立つ、今後の必須クラスです。TimeProviderは.NET 8未満でもMicrosoft.Bcl.TimeProviderを通して提供されているため、.NET Standard 2.0やUnityでも利用可能です。

そこでZLoggerではZLoggerOptionsにTimerProviderを指定することで、ログ出力の時間を固定することができます。

// Microsoft.Extensions.TimeProvider.TestingのFakeTimeProviderを使うとよりよい
class FakeTime : TimeProvider
{
    public override DateTimeOffset GetUtcNow()
    {
        return new DateTimeOffset(1999, 12, 30, 11, 12, 33, TimeSpan.Zero);
    }

    public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
}

public class TimestampTest
{
    [Fact]
    public void LogInfoTimestamp()
    {
        var result = new List<string>();
        using var factory = LoggerFactory.Create(builder =>
        {
            builder.AddZLoggerInMemory((options, _) =>
            {
                options.TimeProvider = new FakeTime(); // TimeProviderをカスタムのものに設定
                options.UsePlainTextFormatter(formatter =>
                {
                    // Timestampを先頭に付与
                    formatter.SetPrefixFormatter($"{0} | ", (template, info) => template.Format(info.Timestamp));
                });
            }, x =>
            {
                x.MessageReceived += msg => result.Add(msg);
            });
        });

        var logger = factory.CreateLogger<TimestampTest>();
        logger.ZLogInformation($"Foo");

        Assert.Equal("1999-12-30 11:12:33.000 | Foo", result[0]);
    }
}

ログ出力の完全一致でのテストが必要……!などといった場合に有効に使うことができます。

Source Generator

Microsoft.Extensions.Loggingではハイパフォーマンスなログ出力のためにLoggerMessageAttributeとSource Generatorが標準で提供されています。

これは確かにUTF16文字列の生成では非常に優秀ですが、Structured Logging生成部分に関しては疑問符がつきます。

// このpartial methodは
[LoggerMessage(LogLevel.Information, "My name is {name}, age is {age}.")]
public static partial void MSLog(this ILogger logger, string name, int age, int other);

// このクラスを生成する
private readonly struct __MSLogStruct : global::System.Collections.Generic.IReadOnlyList<global::System.Collections.Generic.KeyValuePair<string, object?>>
{
    private readonly global::System.String _name;
    private readonly global::System.Int32 _age;

    public __MSLogStruct(global::System.String name, global::System.Int32 age)
    {
        this._name = name;
        this._age = age;
    }

    public override string ToString()
    {
        var name = this._name;
        var age = this._age;

        return $"My name is {name}, age is {age}."; // 文字列生成は高速そう(C# 10.0のString Interpolation Improvementsにベタ乗りなので言うことなし!)
    }

    public static readonly global::System.Func<__MSLogStruct, global::System.Exception?, string> Format = (state, ex) => state.ToString();

    public int Count => 4;

    // こちらがStrcuted Loggingのコードですが、ん……?
    public global::System.Collections.Generic.KeyValuePair<string, object?> this[int index]
    {
        get => index switch
        {
            0 => new global::System.Collections.Generic.KeyValuePair<string, object?>("name", this._name),
            1 => new global::System.Collections.Generic.KeyValuePair<string, object?>("age", this._age),
            2 => new global::System.Collections.Generic.KeyValuePair<string, object?>("other", this._other),
            3 => new global::System.Collections.Generic.KeyValuePair<string, object?>("{OriginalFormat}", "My name is {name}, age is {age}."),

            _ => throw new global::System.IndexOutOfRangeException(nameof(index)),  // return the same exception LoggerMessage.Define returns in this case
        };
    }

    public global::System.Collections.Generic.IEnumerator<global::System.Collections.Generic.KeyValuePair<string, object?>> GetEnumerator()
    {
        for (int i = 0; i < 4; i++)
        {
            yield return this[i];
        }
    }

    global::System.Collections.IEnumerator global::System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
}

[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Logging.Generators", "8.0.9.3103")]
public static partial void MSLog(this global::Microsoft.Extensions.Logging.ILogger logger, global::System.String name, global::System.Int32 age)
{
    if (logger.IsEnabled(global::Microsoft.Extensions.Logging.LogLevel.Information))
    {
        logger.Log(
            global::Microsoft.Extensions.Logging.LogLevel.Information,
            new global::Microsoft.Extensions.Logging.EventId(764917357, nameof(MSLog)),
            new __MSLogStruct(name, age),
            null,
            __MSLogStruct.Format);
    }
}

KeyValuePair<string, object?> ということで、基本的に普通に作るとボクシングは避けられません、しょーがない。

そこで、ZLoggerでは似たようなSource Generator属性であるZLoggerMessageAttributeを提供しています。これにより、UTF8最適化、ボクシングレスなJSONロギングを可能にしています。

// LoggerMessageをZLoggerMessageに変えるだけ
// なお、ZLoggerMessageのフォーマット文字列部分では、String Interpolation版と同じように@による別名やjsonによるJSON化も可能
[ZLoggerMessage(LogLevel.Information, "My name is {name}, age is {age}.")]
static partial void ZLoggerLog(this ILogger logger, string name, int age);

// このようなコードが生成される
readonly struct ZLoggerLogState : IZLoggerFormattable
{
    // JSON用にJsonEncodedTextを事前生成
    static readonly JsonEncodedText _jsonParameter_name = JsonEncodedText.Encode("name");
    static readonly JsonEncodedText _jsonParameter_age = JsonEncodedText.Encode("age");

    readonly string name;
    readonly int age;

    public ZLoggerLogState(string name, int age)
    {
        this.name = name;
        this.age = age;
    }

    public IZLoggerEntry CreateEntry(LogInfo info)
    {
        return ZLoggerEntry<ZLoggerLogState>.Create(info, this);
    }
    
    public int ParameterCount => 2;
    public bool IsSupportUtf8ParameterKey => true;
    public override string ToString() => $"My name is {name}, age is {age}.";

    // テキストメッセージはUTF8への直接書き込み
    public void ToString(IBufferWriter<byte> writer)
    {
        var stringWriter = new Utf8StringWriter<IBufferWriter<byte>>(literalLength: 21, formattedCount: 2, bufferWriter: writer);

        stringWriter.AppendUtf8("My name is "u8); // u8でリテラルは直接書き込み
        stringWriter.AppendFormatted(name, 0, null);
        stringWriter.AppendUtf8(", age is "u8);
        stringWriter.AppendFormatted(age, 0, null);
        stringWriter.AppendUtf8("."u8);            

        stringWriter.Flush();
    }

    // JSON出力の場合はUtf8JsonWriterに直接書き込むことで完全にボクシング避け
    public void WriteJsonParameterKeyValues(Utf8JsonWriter writer, JsonSerializerOptions jsonSerializerOptions, IKeyNameMutator? keyNameMutator = null)
    {
        // 型によって呼び出すメソッドは異なる(WriteString, WriteNumber, etc...)
        writer.WriteString(_jsonParameter_name, this.name);
        writer.WriteNumber(_jsonParameter_age, this.age);
    }

    // 以下にMessagePack対応などの拡張用のメソッドが実際には生成されますが省略
} 

static partial void ZLoggerLog(this global::Microsoft.Extensions.Logging.ILogger logger, string name, int age)
{
    if (!logger.IsEnabled(LogLevel.Information)) return;
    logger.Log(
        LogLevel.Information,
        new EventId(-1, nameof(ZLoggerLog)),
        new ZLoggerLogState(name, age),
        null,
        (state, ex) => state.ToString()
    );
}

Utf8JsonWriterに直接書く、また、キー名はJsonEncodedTextを事前に生成して持っておく、という仕様によってJSON化のパフォーマンスを最大化しています。

また、Structured LoggingはJSONに限らず他のフォーマットもありえます。例えばMessagePackを利用することで、より小さく、より高速にすることができるでしょう。ZLoggerでは、そうしたJSON特化のようなビルトインではないプロトコルへの出力においてもボクシングを避けるためのインターフェイスが定義されています。

public interface IZLoggerFormattable : IZLoggerEntryCreatable
{
    int ParameterCount { get; }

    // メッセージ出力に使う
    void ToString(IBufferWriter<byte> writer);

    // JSON出力に使う
    void WriteJsonParameterKeyValues(Utf8JsonWriter jsonWriter, JsonSerializerOptions jsonSerializerOptions, IKeyNameMutator? keyNameMutator = null);

    // それ以外の構造化ログ出力に使う
    ReadOnlySpan<byte> GetParameterKey(int index);
    ReadOnlySpan<char> GetParameterKeyAsString(int index);
    object? GetParameterValue(int index);
    T? GetParameterValue<T>(int index);
    Type GetParameterType(int index);
}

ちょっと変わったインターフェイスになっていますが、以下のようなループを回すことでボクシングの発生をなくせます。

for (var i in ParameterCount)
{
    var key = GetParameterKey(i);
    var value = GetParameterValue<int>();
}

こうした設計はADO.NETのIDataRecordの使い方と同じ設計です。また、Unityでもネイティブ→マネージドでの配列のアロケーションを避けるために、インデックス経由で取得することがよくあります。

Unity

UnityはUnity 2023の時点でも正式な対応C#のバージョンは9.0です。ZLoggerはC# 10.0以上のString Interpolationが大前提となっているので、普通は動きません。普通は。ところが、正式にアナウンスはされていないのですが Unity 2022.2 から同梱されているコンパイラのバージョンが上がっていて、内部的にはC# 10.0でコンパイル可能になっていることを発見しました。

csc.rsp ファイルによってコンパイラオプションを渡すことができるので、そこで明示的に言語バージョンを指定してあげると、C# 10.0の全ての文法が利用可能になります。

-langVersion:10

このままだと出力されるcsprojには依然として<LangVersion>9.0</LangVersion>が指定されているため、IDE上ではC# 10.0で書けません。そこでCysharp/CsprojModifierを用いて、LangVersionを上書きしてしまいましょう。以下のようなLangVersion.propsというファイルを作成して、CsprojModifierに混ぜてもらえば、IDE上でもC# 10.0として記述できるようになります。

<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
  <PropertyGroup>
    <LangVersion>10</LangVersion>
    <Nullable>enable</Nullable>
  </PropertyGroup>
</Project>

Unity向けにはAddZLoggerUnityDebugという拡張を追加してあるので、

// こんなグローバルのユーティリティーを用意してあげて
public static class LogManager
{
    static ILoggerFactory loggerFactory;

    public static ILogger<T> CreateLogger<T>() => loggerFactory.CreateLogger<T>();
    public static readonly Microsoft.Extensions.Logging.ILogger Global;

    static LogManager()
    {
        loggerFactory = LoggerFactory.Create(logging =>
        {
            logging.SetMinimumLevel(LogLevel.Trace);
            logging.AddZLoggerUnityDebug(); // log to UnityDebug
        });
        Global = loggerFactory.CreateLogger("Logger");

        Application.exitCancellationToken.Register(() =>
        {
            loggerFactory.Dispose(); // flush when application exit.
        });
    }
}

// 例えばこんな感じに使ってみる
public class NewBehaviourScript : MonoBehaviour
{
    static readonly ILogger<NewBehaviourScript> logger = LogManager.CreateLogger<NewBehaviourScript>();

    void Start()
    {
        var name = "foo";
        var hp = 100;
        logger.ZLogInformation($"{name} HP is {hp}.");
    }
}

なお、C# 10.0のString Interpolation性能向上の恩恵を受けれるのはZLogを使った場合のみの話で、通常のString生成にString Interpolationを使っても性能向上はしません。string生成の性能向上にはランタイムにDefaultInterpolatedStringHandlerが必要で、これは .NET 6 以上にのみ同梱されているからです。DefaultInterpolatedStringHandlerが存在しない場合は今まで通りのstring.Formatにフォールバックされるため、普通にボクシングされます。

JSONによる構造化ログや出力のカスタマイズ、ファイルへの出力などにも全て対応しています。

var loggerFactory = LoggerFactory.Create(logging =>
{
    logging.AddZLoggerFile("/path/to/logfile", options =>
    {
        options.UseJsonFormatter();
    });
});

そしてもう一つボーナスとして、Unity 2022.3.12f1 以上だとC#のコンパイラバージョンがもう少し上がっていて、-langVersion:previewを指定するとC# 11.0が使えます。また、ZLoggerのSource Generatorが自動で有効になっているので、[ZLoggerMessage]を使って生成することができます。

public static partial class LogExtensions
{
    [ZLoggerMessage(LogLevel.Debug, "Hello, {name}")]
    public static partial void Hello(this ILogger<NewBehaviourScript> logger, string name);
}

Source Generatorの生成するコードがC# 11.0を要求するので(UTF8 String Literalなどを多用しているため)、[ZLoggerMessage]が使えるのはUnity 2022.3.12f1 以上限定となります。

なお、Unityには同種の標準ロギングライブラリとしてcom.unity.loggingがリリースされています。同じように構造化ロギングやファイル出力が可能なほか、Source Generatorを使ってクラスそのものを自動生成して、メソッドのオーバーロードを引数に応じて生成することで値のボクシング避けをするといった、面白い設計をしていました。Burst云々という話がよく出てきますが、このSource Generatorの大胆な使い方のほうがパフォーマンスの肝だと思います。ZLoggerはC# 10.0のString Interpolationを活用しているわけですが、そういうアプローチでの回避策というのはちょっと考えたことがなかったので、かなり目から鱗です。パフォーマンス的にもかなり練られています。

ZLoggerのほうがString Interpolationによる書き味は上、パフォーマンスは、まぁいい勝負するんじゃないかなとは思いたいんですが、どうでしょうね……?

NuGetForUnity

今回Unityへのライブラリの配布はNuGetForUnityを採用しました。これは普通の.NETライブラリへの依存が多いものへの配布に非常に便利で、NuGetForUnityを入れたあとに、GUIでZLoggerをこうして入れると

image

こんなように依存関係も解決して、Assets/Packagesの下にDLLがばらまかれます。

image

Source Generatorが含まれている場合は、ちゃんとRoslynAnalyzerのラベルも付与してくれるので、そのままSource GeneratorもUnity上で有効化されています。

良い点としては、やはり大量のマネージドDLLの管理を一括でやってくれることと、動作がシンプル(基本的には解決したファイルをAssets/Packagesにばらまくだけ)なのでやっていることがイメージつきやすいところです。同じようなツールとしてUnityNuGetというものがあるのですが、そちらは独自レジストリに配布しているファイルを引っ張ってくるという作りなので、NuGetForUnity(MS公式のNuGetのレジストリから引っ張ってくる)のほうが素直な挙動で好ましく思います。

反面、UnityEngine.dllに依存したコードやUnityのバージョンで分岐するコードは配りにくいところがあります。ZLoggerではそのためのファイル(ZLoggerUnityDebugLoggerProvider)は別途git参照で引っ張ってくるハイブリッド方式での配布としました。かなりバランスの良いやり方かなと思うんですがどうでしょう……?特にCysharpのライブラリはコア部分はUnity非依存のものが多いので、問題なければ今後のCysharpのライブラリはこの形式での配布を基本としていきたいところです。

ライブラリ作者側としては、コア部分はDLLで配ることになるのでC# 12のままで良い(今まではUnityのためにC# 9にダウングレードさせて書いたりしてた)のが、とにかくとても楽ですね……!

まとめ

この記事はC# Advent Calendar 2023の12月3日分の記事となります。すごい遅刻ですが間に合ったので(?)よし。よくない。

なお、ZLogger v2の作成にあたっては VContainerVYaml で有名な @hadashiA さんに、アイディア出しから細かい実装、度重なる仕様のちゃぶ台返しに付き合ってもらいました。今回のv2は非常に完成度高くなったと思うのですが、自分一人ではここまで達しなかったので大変感謝です。

ともあれZLoggerは使いやすさでもパフォーマンスでも最強!のロガーに仕上がったと思いますので、是非使ってみてください。

他言語がメインの場合のRustの活用法 - csbindgenによるC# x Rust FFI実践事例

Rust.Tokyo 2023というRustのカンファレンスで、「他言語がメインの場合のRustの活用法 - csbindgenによるC# x Rust FFI実践事例」と題してcsbindgen周りの話をしてきました。

タイトルが若干かなり回りっくどい雰囲気になってしまいましたが、Rustのカンファレンスということで、あまりC#に寄り過ぎないように、という意識があったのですが、どうでしょう……?

会場での質問含めて何点かフォローアップを。

FFIとパフォーマンス

Rustは速い!FFIは速い!ということが常に当てはまるわけでもなく、例えばGoのcgoはかなり遅いという話があったりします。Why cgo is slow @ CapitalGo 2018。このことは直近のRustのasyncの話Why async Rust?でも触れられていて、cgoの遅さはGoroutine(Green Thread)が影響を及ぼしているところもある、とされています。.NET でもGreen Threadを実験的に実装してみたというレポートがついこないだ出ていたのですが、FFIの問題とか、まぁ諸々あってasync/awaitでいいじゃろ、という結論になっています。技術はなんでもトレードオフなので、過剰にGreen Threadを持ち上げるのもどうかな、とは思いますね。

で、C#のFFI速度ですが、こちらのTesting FFI Hot Loop Overhead - Java, C#, PHP, and Goという記事での比較ではFFIにおいては圧勝ということになっているので、まぁ、実際C#のFFIは速いほうということでいいんじゃないでしょーか(昔からWin32 APIを何かと叩く必要があったりとかいう事情もありますし)。

とはいえ、原則Pure C#実装のほうがいいなあ、という気持ちはめっちゃあります。パフォーマンスのためのネイティブライブラリ採用というのは、本当に限定的な局面だけではありますね。そんなわけで、その限定的な局面であるところのコンプレッションライブラリを鋭意開発中です、来月に乞うご期待。

Zig, C++

FFI目的でunsafeなRust中心になるぐらいならZigのほうがいいんじゃない?というのは一理ある。というか最初はそう思ってZigを試したんですが、今回は見送らせていただきます、と。一理ある部分に関しては一理あるんですが、それ以外のところではRustのほうが上だという判断で、総合的にはRustを採用すべきだと至りました。

具体的には資料の中のRustの利点、これは資料中ではC++との比較という体にしていますが、Zigとの比較という意味もあります。標準公式のパッケージマネージャーがないし、開発環境の乏しさは、たとえZigが言語的にRustよりイージーだとしても、体感は正直言ってRustよりもハードでした。コンパイルエラーもRustは圧倒的にわかりやすいんですが、Zigはめちゃくちゃ厳しい……。Rustはイージーとは言わないですが、開発環境の助けやcargoコマンドのシンプルさ、技術情報(本・ブログ・FAQ)の多さによって、入り口は意外と大変ではない、むしろ入りやすい部類とすら言える感じです。

また、ZigはZigでありC/C++ではない。これはRustも同じでRustはRustでC/C++ではない、つまりCとZig(Rust)を連動させるにはbindgenのようなものが必要なのですが、Zigのそれの安定性がかなり低い、パースできない.hが普通にチラホラある。rust-bindgenのIssue見ていると本当に色々なケースに対応させる努力を延々と続けていて、それがbindgenの信頼性(と実用性)に繋がっているわけで、Zigはまだまだその域には達していないな、と。

Cはまだいいとしても、C++のエコシステムを使うという点では、ZigもRustも難しい。セッションの中ではPhysX 5を例に出しましたが、物理エンジンはOSSどころだとBullet PhysicsJolt Physicsも、SDKそのままそのものはC++だけなんですよね。これをC++以外の言語に持ち込むのは非常に骨の折れる仕事が必要になってきます。Rustに関してはEmbark Studiosがphysx-rsを作ってくれたのである程度現実的ではありますが、何れにせよ大仕事が必要で、そのままでは持ち込めないというのが現実です。

physx-rsではC++のPhysXをRustで動かすために、まずC APIのPhysXを自動生成してそれ経由でRustから呼び出す、という話をしましたが、ZigもC++のものを呼び出すには、概ね同様のアプローチを取る必要があり、例えばZigでJolt Physicsを動かすzphysicsというプロジェクトでは、C++のJoltに対して、C APIで公開するJoltCという部分を作って、それ経由でZigから呼び出すという手法を取っています。

この辺のことはMagicPhysXを作る時にめちゃくちゃ迷走して色々作りかけてたので痛感しています。そう、最初はZigでBullet Physicsを動かしてC#から呼び出すMagicBulletというプロジェクトだったこともあったのだ……。

最後発C++後継系言語であるところのCarbon Languageは、C++におけるTypeScriptというのを標榜しているので、そうしたC++との連携を最優先に考えた言語になっているんじゃないかなー、と思います(触ってないので知らんですけど!)。C++の後継はRust(やZig)があるからいらんやろー、とはならない、C++の資産を活かしながらもモダンな言語仕様を使えるようにする、という絶妙な立ち位置を狙っているんじゃないかなー、と。どのぐらい盛り上がっていくのかわかりませんが……!

C++/CLI

C++/CLIは使わないんですか?という質問がありました。.NETとC++ライブラリの連携という点で、C++/CLIはたしかに良いソリューションで、C++のライブラリをC#のために公開するブリッジとしては最高に使いやすい代物でした。.NET Frameworkの時代までは。

C++/CLIの問題は「.NET Core の C++/CLI サポートは Windows のみ」ということで、特にライブラリがLinuxサポートしないというのはありえないので、.NET Core以降にC++/CLIを新規採用するのは基本ありえない、といった状態になっています。こういった問題があるので、 .NET Framework時代に作られていたC++ライブラリをC#で使える系ライブラリはほとんど使えなくなりました。例えばPhysX 4の.NETバインディングであるPhysX.NETは、C++/CLIでバインディングが作られているため、.NET 5対応はしていますが、サポートプラットフォームはWindowsのみです。

csbindgenは、そうした.NET / C連携での空白地帯にちょうどうまくはまったライブラリなのではないかと思います。C++連携については頑張るしかないですが、そこはしょうがないね……! ただ、Rustはエコシステムがうまく動いているので、Pure Rustライブラリであったり、RustでC++バインディングが作られているものを経由してC#バインディングを作る、といった手法でうまく回せる場合も多いんじゃないかなあー、というところがいいところです。それと、近年のライブラリ事情でいうと、物理エンジンみたいな老舗系はC++で作られていますが、例えば暗号通貨系のライブラリなんかは最初からRust実装だったりするものも多いので、RustからC#への持ち込み、のほうが今後の実用性としても高いんじゃないかと踏んでいます。

UTF8文字列生成を最適化するライブラリ Utf8StringInterpolation を公開しました

Utf8StringInterpolationという新しいライブラリを公開しました!UTF8文字列の生成と書き込みに特化していて、動作をカスタマイズした文字列補間式によるC#コンパイラの機能を活用した生成と、StringBuilder的な連続的な書き込みの両方をサポートします。

基本的な流れはこんな感じで、Stringを生成するのと同じように、UTF8を生成/書き込みできます。

using Utf8StringInterpolation;

// Create UTF8 encoded string directly(without encoding).
byte[] utf8 = Utf8String.Format($"Hello, {name}, Your id is {id}!");

// write to IBufferWriter<byte>(for example ASP.NET HttpResponse.BodyWriter)
Utf8String.Format(bufferWriter, $"Today is {DateTime.Now:yyyy-MM-dd}"); // support format

// like a StringBuilder
var writer = Utf8String.CreateWriter(bufferWriter);
writer.Append("My Name...");
writer.AppendFormat($"is...? {name}");
writer.AppendLine();
writer.Flush();

// Join, Concat methods
var seq = Enumerable.Range(1, 10);
byte[] utf8seq = Utf8String.Join(", ", seq);

Cysharpから公開している ZString と非常に近いのですが、ZStringがString(UTF16), UTF8をサポートしていたのに対して、UTF8側のみを取り出して強化したようなイメージになります。何が強化なのかというと、C# 10.0からImproved Interpolated Stringsとして、文字列補間式($"foo{bar}baz")のパフォーマンスが大きく向上しました。具体的には、コンパイラが文字列補間式の構造を分解して、値が埋め込まれている箇所はGenericsのまま渡すようになりボクシングが消滅しました。つまりZStringでやっていたことではあるのですが、ZStringはC# 10.0以前のものですからね……!逆に言えば、これによってZStringは半分は不要となったわけです。

もう半分、UTF8側に関しては依然として標準のサポートは薄い、というかほぼない状態です。しかし、Improved Interpolated Strings は文字列補間式での挙動を自由にカスタマイズできるという性質も追加されています。というわけで、文字列補間式を利用してUTF8を組み立てられるようにすればいいのではないか、というのがUtf8StringInterpolationのコンセプトであり、正しくZLoggerの後継として位置づけていることでもあります。

// こういう文字列補間式を渡すと:
// Utf8String.Format(ref Utf8StringWriter format)
Utf8String.Format($"Hello, {name}, Your id is {id}!");
// コンパイラが「コンパイル時」にこのような形に展開します。
var writer = new Utf8StringWriter(literalLength: 20, formattedCount: 2);
writer.AppendLiteral("Hello, ");
writer.AppendFormatted<string>(name);
writer.AppendLiteral(", You id is ");
writer.AppendFormatted<int>(id);
writer.AppendLiteral("!");

コンパイル時に展開してくれるというのは性能上非常に重要で、つまりString.Formatのように実行時に文字列式のパースをしないで済む、わけです。また、ボクシングなしに全ての値を書き込みに呼んでくれます。

[InterpolatedStringHandler]を付与しているref Utf8StringWriter$"{}"を渡すと、自動的に展開してくれるという仕様になっています。そのUtf8StringWriterは以下のような実装になっています。

// internal struct writer write value to utf8 directly without boxing.
[InterpolatedStringHandler]
public ref struct Utf8StringWriter<TBufferWriter> where TBufferWriter : IBufferWriter<byte>
{
    TBufferWriter bufferWriter; // when buffer is full, advance and get more buffer
    Span<byte> buffer;          // current write buffer

    public void AppendLiteral(string value)
    {
        // encode string literal to Utf8 buffer directly
        var bytesWritten = Encoding.UTF8.GetBytes(value, buffer);
        buffer = buffer.Slice(bytesWritten);
    }

    public void AppendFormatted<T>(T value, int alignment = 0, string? format = null)
        where T : IUtf8SpanFormattable
    {
        // write value to Utf8 buffer directly
        while (!value.TryFormat(buffer, out bytesWritten, format))
        {
            Grow();
        }
        buffer = buffer.Slice(bytesWritten);
    }
}

.NET 8 の場合は、値が IUtf8SpanFormattable という.NET 8から追加されたインターフェイスを実装している場合(intなど標準のプリミティブはほぼ実装されています)、直接TryFormatによりUTF8としてSpanに書き込みます。

さすがに .NET 8 にしか対応していません!というのはエクストリームすぎるので、 .NET Standard 2.1, .NET 6(.NET 7)では Utf8Formatter.TryFormat を使うことで、同様の性能を担保しています。

Builder vs Writer

ZStringのときはStringBuilderに引っ張られすぎていて、Builderとして内部でバッファを抱えるようにしていたのですが、ちょっとUTF8的な利用ではイマイチだということが徐々に分かってきました。今の .NET の基本は IBufferWriter<byte> である。というのはついこないだのCEDEC 2023での発表 モダンハイパフォーマンスC# 2023 Edition でかなり語らせていただいたのですが

BuilderというよりもWriterとして構築すべきだな、ということに至りました。そこで Utf8StringWriter は基本的にIBufferWriter<byte>を受け取ってそれに書き込むという仕様となりました。

public ref partial struct Utf8StringWriter<TBufferWriter>
    where TBufferWriter : IBufferWriter<byte>
{
    Span<byte> destination;
    TBufferWriter bufferWriter;
    int currentWritten;

    public Utf8StringWriter(TBufferWriter bufferWriter)
    {
        this.bufferWriter = bufferWriter;
        this.destination = bufferWriter.GetSpan();
    }

    public void Flush()
    {
        if (currentWritten != 0)
        {
            bufferWriter.Advance(currentWritten);
            currentWritten = 0;
        }
    }

バッファが足りなくなったときは拡大するのではなくて、Advanceして新たにGetSpanを呼んで新しいバッファを確保しにいくという形を取りました。StringBuilderと違ってFlushの概念が必要になってしまいましたが、パフォーマンス的には大きな向上を果たしています。

Flushが必要ということを除けば、StringBuilderのように扱うことができます。

var writer = Utf8String.CreateWriter(bufferWriter);

// call each append methods.
writer.Append("foo");
writer.AppendFormat($"bar {Guid.NewGuid()}");
writer.AppendLine();

// finally call Flush(or Dispose)
writer.Flush();

また、ちょっとStringBuilder的に使いたいだけの時に IBufferWriter<byte> を用意するのは面倒くさい!という場合のために、内部でプーリングを行っているバッファを使えるオーバーロードも用意しています。戻り値がバッファのコントローラーになっていて、ToArrayや他のIBufferWriter<byte>にコピーしたりReadOnlySpan<byte>の取得ができます。

// buffer must Dispose after used(recommend to use using)
using var buffer = Utf8String.CreateWriter(out var writer);

// call each append methods.
writer.Append("foo");
writer.AppendFormat($"bar {Guid.NewGuid()}");
writer.AppendLine();

// finally call Flush(no need to call Dispose for writer)
writer.Flush();

// copy to written byte[]
var bytes = buffer.ToArray();

// or copy to other IBufferWriter<byte>, get ReadOnlySpan<byte>
buffer.CopyTo(otherBufferWriter);
var writtenData = buffer.WrittenSpan;

その他、Format, Join, Concat メソッドなども IBufferWriter<byte> を受け取るオーバーロードと byte[]を返すオーバーロードの2種を用意しています。

.NET 8 と StandardFormat

値のフォーマット書式は、特にDateTimeでよく使うと思いますが、数値型などでも多くの書式が用意されています。.NET の数値、日付、列挙、その他の型の書式を設定する方法 や各種カスタム書式指定文字列は非常に便利です。

しかし、UTF8に値を直接書き込む手段として従来用意されていたUtf8Formatter.TryFormatでは、その標準的な書式指定文字列は使えませんでした!代わりに用意されたのがStandardFormatなのですが、恐ろしく限定的なことしかできず(例えば'G', 'D', or 'X'のような一文字charの指定しかできない)、使い物にならないといっても過言ではないぐらいでした。

ところが .NET 8 から追加された IUtf8SpanFormattable.TryFormat では、通常の書式指定文字列が帰ってきました!

// Utf8Formatter.TryFormat
static bool TryFormat (int value, Span<byte> destination, out int bytesWritten, System.Buffers.StandardFormat format = default);

// .NET 8 IUtf8SpanFormattable.TryFormat
bool TryFormat (Span<byte> utf8Destination, out int bytesWritten, ReadOnlySpan<char> format, IFormatProvider? provider);

パラメーターは非常に似ていますが、formatを文字列で受け取るようになっています。実際に比較してみるとこんな感じです。

Span<byte> dest = stackalloc byte[16];
int written = 0;

// ParseできなくてExceptionがthrowされるので表現できない
Utf8Formatter.TryFormat(123.456789, dest, out written, StandardFormat.Parse(".###"));

// 123.456
123.456123.TryFormat(dest, out written, ".###");


// カスタム書式文字列は指定できないので例外!サポートしてるのは `G`, `R`, `l`, `O` だけ!
Utf8Formatter.TryFormat(DateTime.Now, dest, out written, StandardFormat.Parse("yyyy-MM-dd"));

// もちろんちゃんと動作する
DateTime.Now.TryFormat(dest, out written, "yyyy-MM-dd");

Console.WriteLine(Encoding.UTF8.GetString(dest.Slice(0, written)));

良かった、やと普通の世界が到達した……!これは ZString や、それを内部に使っていた ZLoggerで最もフラストレーションを感じていた点です。

Utf8StringInterpolationは .NET 8 では全て IUtf8SpanFormattable で変換するようにしています。しかし、 .NET Standard 2.1, .NET 6, .NET 7では残念ながらUtf8Formatter利用となっているので、書式指定に関しては制限があります。数値に関してはターゲットプラットフォームによって動作したりしなかったりが発生します。

// .NET 8 supports all numeric custom format string but .NET Standard 2.1, .NET 6(.NET 7) does not.
Utf8String.Format($"Double value is {123.456789:.###}");

ただし、 DateTime, DateTimeOffset, TimeSpan に関しては Utf8Formatter を使わない処理をしているため、全てのターゲットプラットフォームでカスタム書式指定が利用可能です!

// DateTime, DateTimeOffset, TimeSpan support custom format string on all target plaftorms.
// https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings
Utf8String.Format($"Today is {DateTime.Now:yyyy-MM-dd}");

とにかくDateTimeの書式指定がまともに出来ないのはZString/ZLoggerで一番辛かったところなので、それを改善できてとても良かった……。ただしこの対応により、DateTimeの変換性能が落ちているため、性能が最大限引き出せるのは .NET 8 となります。

Unity

Unity対応はありません!いや、可能な限り私は .NET と Unityの両対応のライブラリを作りたいと思っていて、実際今までもそうしてきているわけですが、今回ばかりはどうにもならないのです。そもそもImproved Interpolated Stringsが C# 10.0 からで、Unityの現在のC#のバージョンは C# 9.0……!さすがにそれはどうにもならない。

C# 9.0で止まってから結構長いんですよね。別にランタイムのバージョンは上げなくてもいいから、コンパイラのバージョンだけ上げて欲しいと切実に思いますが、まぁC# 10.0にしたらDefaultInterpolatedStringHandlerがなくて動作しないじゃんとかなるだろうから、結局はランタイムのバージョンアップもセットでやらなければならない……。

Unityが C# 10.0 に対応したらすぐに対応させるつもりではあります!待ってます!

Next

さて、とはいえ、UTF8文字列を直接扱わなければならないケースというのは、別にそんなに多くはないでしょう。実際、私も本命はZLoggerの大型バージョンアップでの利用を考えています。ZLoggerは今まではZStringベースでしたが、根本からデザインをやり直した新しいものを開発中です。その中の文字列化にUtf8StringInterpolationを使っています。

といったように、アプリケーションの基盤レイヤーに差し込んであげると有効に機能するシチュエーションは色々あると思います。もちろん、直接使ってもらってもいいのですが……!?

.NET 8 UnsafeAccessor を活用したライブラリ PrivateProxy を公開しました

PrivateProxyというライブラリを公開しました。つまるところ、privateフィールド/プロパティ/メソッドにアクセスするライブラリなのですが、.NET 8 のUnsafeAccessorという新機能を活用することでNo Reflection、ハイパフォーマンス、AOTセーフになっています。

もちろん .NET 8 でしか動きません!ので、.NET 8が正式リリースされた頃に思い出して使ってみてください。エクストリームな人は今すぐ試しましょう。

雰囲気としては、privateメンバーにアクセスしたい型があったとして、[GeneratePrivateProxy(type)]をつけた型を用意します。

using PrivateProxy;

public class Sample
{
    int _field1;
    int PrivateAdd(int x, int y) => x + y;
}

[GeneratePrivateProxy(typeof(Sample))]
public partial struct SampleProxy;

すると、いい感じにアクセスできるようになります。

// You can access like this.
var sample = new Sample();
sample.AsPrivateProxy()._field1 = 10;

いいところとしては、 Source Generatorベースの生成なので、型がついていて入力補完も効くし、変数名を変更したらコンパイルエラーで検出可能です。

ここまではSource Generatorベースで作れば、今まででもやれないことはなかったのですが、UnsafeAccessorのいいところとして、objectが一切出てこないで元のメソッドそのままの型がそのまま使えることです。生成されたコードを見ると、こうなっています。

// Source Generator generate this type
partial struct SampleProxy(Sample target)
{
    [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_field1")]
    static extern ref int ___field1__(Sample target);

    [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "PrivateAdd")]
    static extern int __PrivateAdd__(Sample target, int x, int y);

    public ref int _field1 => ref ___field1__(target);
    public int PrivateAdd(int x, int y) => __PrivateAdd__(target, x, y);
}

public static class SamplePrivateProxyExtensions
{
    public static SampleProxy AsPrivateProxy(this Sample target)
    {
        return new SampleProxy(target);
    }
}

これによって refreadonly などの言語機能をそのまま反映できたり、mutableなstructの対応が自然にできたり、そして何よりパフォーマンスの低下も一切ありません。

使い道は、主にユニットテスト用になるとは思いますので、なのでパフォーマンスはそこまで重要ではないといえばないのですが、性能的にはアプリケーションの実行時に使っても問題ないものとなっています。

私は昔Chaining Assertionというユニットテスト用のライブラリを作っていたのですが、現在はFluent Assertionsという別のライブラリを使っています。ほとんどの機能は概ねなんとかなっているのですが、AsDynamic()を呼ぶと、移行はprivateフィールドやメソッドにアクセスし放題という機能は結構便利で、そしてそれがFluent Assertionsにないのは若干不便と思ってたんですね。今回、やっとそれの進化系を作れたのでメデタシメデタシです。

C# 12

ところで、冒頭のSampleProxyの書き方、地味にこれはC# 12の記法を使っています。どこだかわかりますか?

[GeneratePrivateProxy(typeof(Sample))]
public partial struct SampleProxy;

SampleProxy; の部分で、空のクラスを作る際に { } じゃなくて ; だけで済ませられるようになりました。これは地味ですがかなりいい機能で、というのもSource Generatorだと空クラスに割り当てることが多かったんですよね。そして、たった2文字が1文字に変わっただけ、ではあるのですが、 { } だとコードフォーマットに影響があります。改行して3行で表現するのか、後ろにつけるのか。そうした判断のブレが ; だとなくなります。だから2文字が1文字に変わっただけ、以上のインパクトがある、良い機能追加だと思います。

ref field

PrivateProxyはstaticメソッドにも対応していますし、そしてmutable structにも対応しています。

using PrivateProxy;

public struct MutableStructSample
{
    int _counter;
    void Increment() => _counter++;

    // static and ref sample
    static ref int GetInstanceCounter(ref MutableStructSample sample) => ref sample._counter;
}

// use ref partial struct
[GeneratePrivateProxy(typeof(MutableStructSample))]
public ref partial struct MutableStructSampleProxy;
var sample = new MutableStructSample();
var proxy = sample.AsPrivateProxy();
proxy.Increment();
proxy.Increment();
proxy.Increment();

// call private static method.
ref var counter = ref MutableStructSampleProxy.GetInstanceCounter(ref sample);

Console.WriteLine(counter); // 3
counter = 9999;
Console.WriteLine(proxy._counter); // 9999

mutable structの対応って結構難しい話で、というのもフィールドにstructを保持するとコピーが渡されることになるので、普通に書いていると変更が元のstructに反映されないんですね。この問題をPrivateProxyではC# 11 ref fieldで解決しました。

MutableStructSampleProxyはSource Generatorによって以下のようなコードが生成されます。

ref partial struct MutableStructSampleProxy
{
    ref MutableStructSample target;

    public MutableStructSampleProxy(ref MutableStructSample target)
    {
        this.target = ref target;
    }

    [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "Increment")]
    static extern void __Increment__(ref MutableStructSample target);
    
    public void Increment() => __Increment__(ref this.target);
}

public static class MutableStructSamplePrivateProxyExtensions
{
    public static MutableStructSampleProxy AsPrivateProxy(this ref MutableStructSample target)
    {
        return new MutableStructSampleProxy(ref target);
    }
}

AsPrivateProxy(これはthis refですが、拡張メソッドの場合、予備側はrefを書かなくていいので自然に使えます)で渡されたstructは、そのままずっとrefのまま保持されています。これにより、メソッド呼び出しでstructの状態に変更があった場合も問題なく変更が共有されています。

privateメソッドの単体テスト

プライベートメソッドのテストは書かないみたいな流儀も世の中にはありますが、私は何言ってんの?と思ってます。パブリックメソッドのテストでprivateメソッドの確認が内包されるから不要というなら、はぁー?だったら全部E2Eテストでいいんじゃないですかー?真の振る舞いがテストされますよー?

もちろんそれは非現実的で、E2Eでパブリックメソッドのロジックを全て通過させるのは手間とコストがかかりすぎる。というのと同じ話で、privateメソッドのエッジケースを全てチェックする時に、public経由だとやりにくいことは往々にある。場合によってはコード通すためにモックを仕込まなければならないかもしれない。そこまでいくとアホらしいですよね、privateメソッドを直接テストすればすむだけの話なのに?メソッドは小さければ小さいほど良いし、テストもしやすい。そしてprivateを内包したpublicよりもprivateそのもののほうが小さく、テストしやすい。

ようするにコスト面を考えて境界をどこに置くかというだけの話で。そして、privateメソッドのテストそのものは、C#の場合reflection呼び出しだと、変更コストがかかるなど、コスパは悪い部類に入ってしまう。でもそれはただの言語からの制約であって、だからプライベートメソッドのテストは不要みたいなしょーもない理屈をこねて絶対視するほどのことでもない。リフレクション使うとコスパ感が合わないから原則publicで済ませること、ぐらいだったらいいけど、変な教義立てるのはおかしいでしょ。

チェックしたいがためにprivateであるべきものをinternalにする(たまによくやる、internalならInternalsVisibleToをテストプロジェクトに指定することで、ユニットテストプロジェクトで参照できるようになる)こともありますが、あまりお行儀の良いことではない。そもそもinternalなので同一アセンブリ内では不要に可視レベル上がっちゃってるし。

と、いうわけで、PrivateProxyは比較的低コストでprivateメソッドをテスト対象にすることができるので、全然使っちゃっていいし、テストも書いちゃって良い、わけです。

UnsafeAccessor for InternalCall

UnsafeAccessor の使い道として、corelibの InternalCall を強引に呼べることはかなりいいこと(?)だと思ってます。例えば string の生成には大元に FastAllocateString というのがあって、通常ユーザーはこれを呼ぶことはできないのですが

[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name = "FastAllocateString")]
static extern string FastAllocateString(string _, int length);

var rawString = FastAllocateString(null!, 10);
var mutableSpan = MemoryMarshal.CreateSpan(ref MemoryMarshal.GetReference(rawString.AsSpan()), rawString.Length);

"abcde".CopyTo(mutableSpan);
"fghij".CopyTo(mutableSpan.Slice(5));

Console.WriteLine(rawString); // abcdefghij

といったようにUnsafeAccessorを通せばやりたい放題できます。

Stringに関しては、やりたい放題させないために、String.Createというメソッドが用意されていて、Actionのコールバックで変更して、不変のStringを返すというものが用意されてはいるのですが、ActionだとTStateにSpan<T>を渡せないとか、使えないケースがそれなりにあります、というか使いたいシチュエーションに限って使えないことが多い……。

なお、こういうFastAllocateStringを呼んでStringに変更をかけるというのは、dotnet/runtime#36989 Make string.FastAllocateString publicで見事に却下されています。つまり、やるな、ということです。

It is never safe or supported to mutate the contents of a returned string instance. If you mutate a string instance within your own library or application, you are entering unsupported territory. A future framework update could break you. Or - more likely - you'll encounter memory corruption that will be very painful for you or your customers to diagnose.

お怒りはご尤もです。しかしString.CreateでAction渡す口あるんだからそれでいいだろーというのはお粗末すぎだと思うんですよねー、どうせやってること一緒なんだから弄るの許可してよ、というのもそれはそれで理解してもらいたいです(私は弄りたいほうの人間なので!)。というわけで、自己責任で、やっていきましょう、つまりやっていくということです……!

まとめ

.NET 8でしか動きません!11月に .NET 8 がリリースされるので、その時まで忘れないでください!

Unity用のHTTP/2(gRPC) Client、YetAnotherHttpHandlerを公開しました

Cysharpから(主に)Unity用のHTTP/2, gRPC, MagicOnion用の通信ネットワーククライアントを公開しました。実装者は週刊.NET情報配信WeekRef.NETを運営している@mayukiさんです。

何故これが必要なのかの背景情報としては、Synamon’s Engineer blog - Unityでもgrpc-dotnetを使ったgRPCがしたい が詳しいのですが、まず、.NETには2つのgRPC実装があります。googleが提供してきたgRPCのネイティブバインディングのGrpc.Core(C-Core)と、Microsoftが提供しているPure C#実装のgrpc-dotnet。現在.NETのgRPCはサーバーもクライアントも完全にPure C#実装のほうに寄っていて、MagicOnionもサーバーはPure C#実装のものを使っています。

しかしクライアントに関しては、諸事情によりUnityでは動かない(TLS関連の問題など)ため、ずっとC-Coreを推奨してきました。更に、Unity用のビルドは元々experimentalだったうえに、とっくにメンテナンスモードに入り、そしてついに今年5月にサポート期限も切れて完全に宜しくない気配が漂っていました。また、古いx64ビルドなので最近のMac(M1, M2チップ)では動かないためUnity Editorで使うのにも難儀するといった問題も出てきていました。

と、いうわけで、CysharpではUnityで使うgRPCを推奨してきたということもあり、Unityで問題なく使えるgRPC実装としてYetAnotherHttpHandlerを開発・リリースしました。HttpClientの通信レイヤーであるHttpHandlerを差し替えるという形で実装してあるので、ほとんど通常の .NET でのgRPCと同様に扱えます。

内部実装としてはPure Rust実装のHTTP/2ライブラリhyperとPure RustのTLSライブラリrustlsを基盤として作ったネイティブライブラリに対して、Cysharp/csbindgenで生成したC#バインディングを通して通信する形になっています。

余談

YetAnotherHttpHandlerはgRPCやMagicOnionに限らず、Unityで自由に使える HTTP/2 Clientなので、アセットダウンロードの高速化にHTTP/2を用いる、といったような使い道も考えられます。既にモバイルゲームでも幾つかのタイトルでHTTP/2でアセットダウンロードしているタイトルは確認できていまして、例えばセガさんはCEDEC2021 ダウンロード時間を大幅減!~大量のアセットをさばく高速な実装と運用事例の共有~のような発表もされています。ネイティブプラグインを自前でビルドして持ち込むというのはだいぶ敷居が高い話でしたが、YetAnotherHttpHandlerを入れるだけでいいなら、だいぶやれるんじゃないか感も出てくるんじゃないでしょうか……?

AlterNats は 公式の NATS .NET Client v2 に引き継がれました

NATSのサードパーティー(alternative)クライアントであったAlterNatsは、公式に引き取られてNATS.NET V2となりました。v2の詳細に関してはNATS公式からのブログNATS .NET Client v2 Alpha Released with Core NATS Supportを参照ください。

NATS community members started to take note, and develop client libraries for NATS based on modern .NET APIs. One notable client library that emerged was the AlterNats library by Cysharp, which includes a fully asynchronous API, leverages Span , and supports client-side WebSockets from browsers in Blazor . NATS maintainers and AlterNats maintainers agreed that AlterNats would make a great starting point for NATS.Client v2!

NATSに関してはAlterNatsリリース時の記事 AlterNats - ハイパフォーマンスな.NET PubSubクライアントと、その実装に見る.NET 6時代のSocketプログラミング最適化のTips、或いはMagicOnionを絡めたメタバース構築のアーキテクチャについてに色々書きましたが、Cloud Native Computing Foundation配下のPubSubミドルウェアで、RedisなどでのPubSubに比べるとパフォーマンスを始めとして多くのメリットがあります。

ただしこういうものはサーバー実装も重要ですがクライアント実装も重要であり、そして当時のNATSの公式クライアント(v1)は正直酷かった!せっかくの素晴らしいミドルウェアが.NETでは活かされない、また、RedisでのPubSubには不満があり、そもそも.NETでのベストなPubSubのソリューションがないことに危機意識を感じていたので、独自に実装を進めたのがAlterNatsでした。

ただし、枯れたプロトコルならまだしも、進化が早いミドルウェアのクライアントが乱立しているのは決して良いことでもないでしょう。新機能への追随速度やメンテナンスの保証という点でも、サードパーティクライアントとして進んでいくよりも、公式に統合されることのほうが絶対に良いはずです。

というわけで今回の流れは大変ポジティブなことだし、野良実装にとって最高の道を辿れたんじゃないかと思っています。私自身は実装から一歩引きますが、使っていく上で気になるところがあれば積極的にPR上げていくつもりではあります。

なお、NATSに関しては来月CEDEC 2023でのセッションメタバースプラットフォーム「INSPIX WORLD」はPHPもC++もまとめてC#に統一!~MagicOnionが支えるバックエンド最適化手法~で触れる、かもしれません、多分。というわけでぜひ聞きに来てください……!

メタバース関連では、今年の5月にTGS VRなどを手掛けているambrさんのテックブログにてVRメタバースのリアルタイム通信サーバーの技術にMagicOnionとNATSを選んだ話という紹介もしていただいていました。

OSSとメンテナンスの引き継ぎ

権限の移管は何度か経験があって

は完全に手放しています。ほか、MagicOnionはCysharp名義に移ったうえで、現在の開発リードは私ではありません。また、最近ではMessagePack for C#はMessagePack-CSharp Organizationに移していて共同のOwner権限になっています。

どうしても常に100%の力を一つのOSSに注ぐことはできないので、本来はうまく移管していけるのが良いわけですが、いつもうまくできるわけじゃなくて、Utf8Jsonなんかはうまく移管できないままarchivedにしてしまっています。

やっぱ出した当時は自分が手綱を握っていたいという気持ちがとても強いわけですが、関心が徐々に薄れていくタイミングと他の人に渡せるタイミングがうまく噛み合わないと、死蔵になってしまうというところがあり、まぁ、難しいです。これだけやっていても上手くできないなあ、と……。

今回のは大変良い経験だったので、作ってメンテナンスを続ける、そしてその先についても考えてやっていきたいところですね。

ともあれ、良い事例を一つ作れた&素晴らしいライブラリをC#に一つ持ち込むことができたということで、とても気分がよいですです。

Microsoft MVP for Developer Technologies(C#)を再々々々々々々々々々々々受賞しました

13回目です!一年ごとに再審査での更新で、変わらずC#の最前線に立てています。

活動の中心は引き続きOSSですが、github/Cysharpでのスター数は変わらず他を圧倒していると思いますし、毎年の新規の公開数の勢いも変わらずで新しいアイディアを出し続けています。

今年はcsbindgenを起点にしてRustを活用してC#の活用幅をより広げていくことを狙っています。先日公開したMagicPhysXの他にも色々計画がって、かなり面白いインパクトが出せるんじゃないかと思っています。

MessagePack for C#もMessagePack-CSharp/MessagePack-CSharpと、organization名義に移したことで(変わらず私はOwnerなので権限を手放したわけではありません)より中立的に発展させていきます。直近ではSource Generator対応が予定されています(preview版を公開中)。

というわけで、これはもう満場一致でC#に貢献しているということでいいんじゃないでしょうかね……?

ここ最近は登壇していなかったのですが、去年はC#11 による世界最速バイナリシリアライザー「MemoryPack」の作り方というセッションをしました。その流れということで、今年の8月にCEDEC 2023にてモダンハイパフォーマンスC# 2023 Edition、それと共同講演でメタバースプラットフォーム「INSPIX WORLD」はPHPもC++もまとめてC#に統一!~MagicOnionが支えるバックエンド最適化手法~という2つの登壇予定があるので、ぜひ見に来てください。

MagicPhysX - .NET用のクロスプラットフォーム物理エンジン

MagicPhysXというライブラリを新しく公開しました!.NETで物理エンジンを動かすというもので、その名の通り、NVIDIA PhysX のC#バインディングとなっています。

使い道としては

  • GUIアプリケーションの3D部分
  • 自作ゲームエンジンへの物理エンジン組み込み
  • ディープラーニングのためのシミュレーション
  • リアルタイム通信におけるサーバーサイド物理

といったことが考えられます。

.NET用のPhysXバインディングは他にも存在しますが、C++/CLIでバインディングを生成している都合上Windowsでしか動かせなかったり、バージョンが最新ではない4.xベースだったりしますが、MagicPhysXは最新のPhysX 5ベースで、かつ、Windows, MacOS, Linuxの全てで動きます!(win-x64, osx-x64, osx-arm64, linux-x64, linux-arm64)。これはバインディングの作り方としてクロスプラットフォームコンパイルに強いRustと、Cysharp/csbindgenによってC#のバインディングの自動生成をしているからです。

先にアーキテクチャの話をしましょう。MagicPhysXはEmbarkStudiosによるphysx-rsをビルド元に使っています。

EmbarkStudiosはEA DICEでFrostbiteゲームエンジン(Battlefield)を作っていた人たちが独立して立ち上げたスタジオで、Rustによるゲームエンジンを作成中です。また、その過程で生まれたRustのライブラリをOSSとして積極的に公開しています。一覧はEmbark Studios Open Sourceにあります。必見!

PhysXのライブラリはC++で出来ていて、他の言語で使うことは考慮されていません。そのために他の言語に持ち込むためには、C++上で別言語で使うためのブリッジ部分を作った上で、バインディングを用意するという二度手間が必要になってきます。それはRustであっても例外ではありません。また、二度手間というだけではなく、PhysXのソースコードはかなり大きいため、その作業量も膨大です。

以前にcsbindgen - C#のためのネイティブコード呼び出し自動生成、或いはC#からのネイティブコード呼び出しの現代的手法についてで紹介しましたが、SWIGなどのC++からの自動生成、Rustであればcxxautocxxのような自動化プロジェクトも存在しますが、C++そのものの複雑さからいっても、求めるものを全自動で出力するのは難しかったりします。

physx-rsではAn unholy fusion of Rust and C++ in physx-rs (Stockholm Rust Meetup, October 2019)というセッションでPhysXをRustに持ち込むための手段の候補、実際に採用した手段についての解説があります。最終的に採用された手段について端的に言うと、PhysXに特化してコード解析してC APIを生成する独自ジェネレーターを用意した、といったところでしょうか。そしてつまり、physx-rsには他言語でもバインディング手段として使えるPhysXのC APIを作ってくれたということにもなります!

更にcsbindgenには、rsファイル内のextern "C"の関数からC#を自動生成する機能が備わっているので、Rustを経由することでC++のPhysXをC#に持ち込めるというビルドパイプラインとなりました。

そういう成り立ちであるため、MagicPhysXのAPIはPhysXのAPIそのものになっています。

using MagicPhysX; // for enable Extension Methods.
using static MagicPhysX.NativeMethods; // recommend to use C API.

// create foundation(allocator, logging, etc...)
var foundation = physx_create_foundation();

// create physics system
var physics = physx_create_physics(foundation);

// create physics scene settings
var sceneDesc = PxSceneDesc_new(PxPhysics_getTolerancesScale(physics));

// you can create PhysX primitive(PxVec3, etc...) by C# struct
sceneDesc.gravity = new PxVec3 { x = 0.0f, y = -9.81f, z = 0.0f };

var dispatcher = phys_PxDefaultCpuDispatcherCreate(1, null, PxDefaultCpuDispatcherWaitForWorkMode.WaitForWork, 0);
sceneDesc.cpuDispatcher = (PxCpuDispatcher*)dispatcher;
sceneDesc.filterShader = get_default_simulation_filter_shader();

// create physics scene
var scene = physics->CreateSceneMut(&sceneDesc);

var material = physics->CreateMaterialMut(0.5f, 0.5f, 0.6f);

// create plane and add to scene
var plane = PxPlane_new_1(0.0f, 1.0f, 0.0f, 0.0f);
var groundPlane = physics->PhysPxCreatePlane(&plane, material);
scene->AddActorMut((PxActor*)groundPlane, null);

// create sphere and add to scene
var sphereGeo = PxSphereGeometry_new(10.0f);
var vec3 = new PxVec3 { x = 0.0f, y = 40.0f, z = 100.0f };
var transform = PxTransform_new_1(&vec3);
var identity = PxTransform_new_2(PxIDENTITY.PxIdentity);
var sphere = physics->PhysPxCreateDynamic(&transform, (PxGeometry*)&sphereGeo, material, 10.0f, &identity);
PxRigidBody_setAngularDamping_mut((PxRigidBody*)sphere, 0.5f);
scene->AddActorMut((PxActor*)sphere, null);

// simulate scene
for (int i = 0; i < 200; i++)
{
    // 30fps update
    scene->SimulateMut(1.0f / 30.0f, null, null, 0, true);
    uint error = 0;
    scene->FetchResultsMut(true, &error);

    // output to console(frame-count: position-y)
    var pose = PxRigidActor_getGlobalPose((PxRigidActor*)sphere);
    Console.WriteLine($"{i:000}: {pose.p.y}");
}

// release resources
PxScene_release_mut(scene);
PxDefaultCpuDispatcher_release_mut(dispatcher);
PxPhysics_release_mut(physics);

つまり、そのままでは決して扱いやすくはないです。部分的に動かすだけではなく、本格的にアプリケーションを作るなら、ある程度C#に沿った高レベルなフレームワークを用意する必要があるでしょう。MagicPhysX内ではそうしたサンプルを用意しています。それによって上のコードはこのぐらいシンプルになります。

using MagicPhysX.Toolkit;
using System.Numerics;

unsafe
{
    using var physics = new PhysicsSystem(enablePvd: false);
    using var scene = physics.CreateScene();

    var material = physics.CreateMaterial(0.5f, 0.5f, 0.6f);

    var plane = scene.AddStaticPlane(0.0f, 1.0f, 0.0f, 0.0f, new Vector3(0, 0, 0), Quaternion.Identity, material);
    var sphere = scene.AddDynamicSphere(1.0f, new Vector3(0.0f, 10.0f, 0.0f), Quaternion.Identity, 10.0f, material);

    for (var i = 0; i < 200; i++)
    {
        scene.Update(1.0f / 30.0f);

        var position = sphere.transform.position;
        Console.WriteLine($"{i:D2} : x={position.X:F6}, y={position.Y:F6}, z={position.Z:F6}");
    }
}

ただしあくまでサンプルなので、参考にしてもらいつつも、必要な部分は自分で作ってもらう必要があります。

Unityのようなエディターがないと可視化されてなくて物理エンジンが正しい挙動になっているのか確認できない、ということがありますが、PhysXにはPhysX Visual Debuggerというツールが用意されていて、MagicPhysXでも設定することでこれと連動させることが可能です。

Dedicated Server

CysharpではMagicOnionLogicLooperといったサーバーサイドでゲームのロジックを動かすためのライブラリを開発しています。その路線から行って物理エンジンが必要なゲームでさえも通常の .NET サーバーで動かしたいという欲求が出てくるのは至極当然でしょう……(?)

UEやUnityのDedicated Serverの構成だとヘッドレスなUE/Unityアプリケーションをサーバー用ビルドしてホスティングすることになりますが、サーバー用のフレームワークではないので、あまり作りやすいとは言えないんですよね。通常用サーバー向けのライブラリとの互換性、ライフサイクルの違い、ランタイムとしてのパフォーマンスの低さ、などなど。

というわけで、MagicOnionのようなサーバー向けフレームワークを使ったほうがいいのですが、物理エンジンだけはどうにもならない。今までは……?

と、言いたいのですが、まずちゃんとしっかり言っておきたいのですが、現実的には少々(かなり)難しいでしょう!コライダーどう持ってくるの?とかAPIが違う(Unityの物理エンジンはPhysXですが、API的に1:1の写しではないので細かいところに差異がある)のでそもそも挙動を合わせられないし、でもこういう構成ならサーバーだけじゃなくクライアントでも動かしたい、そもそもそうじゃないとデバッガビリティが違いすぎる。

と、ようするに、もしゲーム自体にある程度、物理エンジンに寄せた挙動が必要なら、「物理エンジン大統一」が必須だと。MagicPhysXは残念ながらそうではありません。実のところ当初はそれを目指していました、Unityとほぼ同一挙動でほぼ同一APIになるのでシームレスに持ち込むことができるライブラリなのだ、と。しかし現状はそうではないということは留意してください。また、その当初予定である互換APIを作り込む予定もありません。

まとめ

このライブラリ、かなり迷走したプロジェクトでもあって、そもそも最初はBullet Physicsを採用する予定でした。ライブラリ名が先に決めてあってMagicBulletってカッコイイじゃん、みたいな。その後にJolt Physicsを使おうとして、これもバインディングをある程度作って動く状態にしたのですが、「物理エンジン大統一」のためにPhysXにすべきだろうな、という流れで最終的にPhysXを使って作ることにしました。

形になって良かったというのはありますが(そしてcsbindgenの実用性!)、「物理エンジン大統一」を果たせなかったのは少々残念ではあります。最初の完成予想図ではもっともっと革命的なもののはずだったのですが……!

とはいえ、PhysX 5をクロスプラットフォームで.NETに持ち込んだということだけでも十分に難易度が高く新しいことだと思っているので、試す機会があれば、是非触って見ください。

csbindgen - C#のためのネイティブコード呼び出し自動生成、或いはC#からのネイティブコード呼び出しの現代的手法について

ネイティブコードとC#を透過的に接続するために、RustのFFIからC#のDllImportコードを自動生成するライブラリを作成し、公開しました。Cysharp初のRustライブラリです!先週にプレビューを出していましたが、しっかりした機能強化とReadMeの充実をして正式公開、です!

めちゃくちゃスムーズにネイティブコードがC#から呼べるようになります。すごい簡単に。超便利。こりゃもうばんばんネイティブコード書きたくなりますね……!ただし書くコードはRustのみ対応です。いや、別にRustでいいでしょ、Rustはいいぞ……!

しかしまず前提として言っておくと、ネイティブコードは別に偉くもなければ、必ず速いというわけでもないので、極力書くのはやめましょう。C#で書くべき、です。高速なコードが欲しければ、ネイティブコードに手を出す前にC#で速くすることを試みたほうがずっと良いです。C#は十分高速に書くことのできる言語です!ネイティブコードを書くべきでない理由は山ほどありますが、私的に最大の避けたい理由はクロスプラットフォームビルドで、今の世の中、ターゲットにしなければならないプラットフォーム/アーキテクチャの組み合わせは、普通にやっていても10を超えてしまいます。win/linux/osx/iOS/Android x x86/x64/arm。C#では .NET のランタイムやUnityが面倒見てくれますが、ネイティブコードの場合はこれを自前で面倒みていく必要があります。そこそこ面倒みてくれるはずのUnityだって辛いのに、それにプラスして俺々ビルド生態系を加えるのはかなり厳しいものがある。

とはいえ、C#をメインに据えつつもネイティブコードを利用すべきシチュエーションもあるにはあります。

  • Android NDKや .NET unmanaged hosting APIなど、ネイティブAPIしか提供されていないものを使いたい場合
  • C で作られているネイティブライブラリを利用したい場合
  • ランタイムのライブラリの利用を避けたい場合、例えばUnityで .NET のSocket(Unityの場合 .NET のランタイムが古いのでパフォーマンスを出しにくい)を避けてネイティブのネットワークコードを書くのには一定の道理がある

NativeAOTという解決策もなくはないですが、まだそんなに現実的でもなければ、用途的にもこういうシチュエーションでは限定的でもあるので、そこは素直にネイティブコードを書いていくべき、でしょう。

そこでの最初の選択肢は当然C++なわけですが、いやー、C++のクロスプラットフォームビルドは大変だしなあ。となると、最近評判を聞くZigはどうだろうか、と試してみました、が、撤退。目指すコンセプトは大変共感するところがあるのですが(FFIなしのCライブラリとの統合や、安全だけど複雑さを抑えた文法など)、まだ、完成度が、かなり、厳しい……。

で、最後の選択肢がRustでした。FFIなしでの呼び出しではないもののcc cratecmake crateといったライブラリを使うと自然に統合されるし、bindgenによるバインディングの自動生成はよく使われているだけあってめっちゃ安定して簡単に生成できます。ていうかZigが全然安定感なかった(シームレスなCとの統合とは……)ので雲泥の差でびっくりした。開発環境もまぁまぁ充実してるしコマンド体系も現代的。クロスプラットフォームビルドも容易!そして難しいと評判で避けていた言語面でも、いや、全然いいね。仕組みが理屈で納得できるし、C#とは文法面でもあまり離れていないので、全然すんなりと入れました。もちろん難しいところも多々ありますが、ラーニングカーブはそんなに急ではない、少なくとも最近のモダンC#をやり込んでる人なら全然大丈夫でしょう……!

と、いうわけで、しかし主な用途はC#からの利用で、特にCライブラリの取り込みにRustを使おうと決めたわけですが、C#に対して公開するためのコードが膨大でキツかったので、自動化したかったんですね。DllImportの自動化はSWIGCppSharpというのもありますが、普通のC++をそのまま持ってこようとする思想は、複雑なコードを吐いてしまったりで正直イマイチだな、と。

csbindgenは、まず、面倒なところをRustのbindgenに丸投げです。複雑なC(C++)のコードを解析対処にするから複雑になるのであって、bindgenによって綺麗なRustに整形してもらって、生成対象にするのはそうしたFFI向けに整理されたRustのみを対象にすることで、精度と生成コードの単純さを担保しました。自分でネイティブコードを書く場合も、RustはFFI不可能な型を公開しようとすると警告も出してくれるので、必然的に生成しやすい綺麗なコードになっています。型もRustは非常に整理されているため、C#とマッピングしやすくなっています。C#もまた近年のnintやdelegate*、.NET 6からのCLongなどの追加によって自然なやり取りができるようになりました。csbindgenはそれら最新の言語機能を反映することで、自然で、かつパフォーマンスの良いバインディングコードを生成しています。

Getting Started

コンフィグにビルド時依存に追加してもらって、build.rsというコンパイル前呼び出し(Rustのコードでpre-build書ける機能やビルド時依存を追加できる機能はとても良い)に設定を入れるだけです、簡単!

[build-dependencies]
csbindgen = "1.2.0"
// extern "C" fnが書かれているlib.rsを読み取って DllImport["nativelib"]なコードを"NativeMethods.g.cs"に出力する
csbindgen::Builder::default()
    .input_extern_file("lib.rs")
    .csharp_dll_name("nativelib")
    .generate_csharp_file("../dotnet/NativeMethods.g.cs")
    .unwrap();

単純なコードを例に出すと、このx, yを受け取ってintを返す関数は

#[no_mangle]
pub extern "C" fn my_add(x: i32, y: i32) -> i32 {
    x + y
}

こういったC#コードを生成します。

// NativeMethods.g.cs
using System;
using System.Runtime.InteropServices;

namespace CsBindgen
{
    internal static unsafe partial class NativeMethods
    {
        const string __DllName = "nativelib";

        [DllImport(__DllName, EntryPoint = "my_add", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)]
        public static extern int my_add(int x, int y);
    }
}

直感的で単純な出力です、逆にそれがいい、むしろそれがいい。生成に対応している型はプリミティブ以外にもstructやunion、enum、関数やポインターなどRustのFFIで流せる型のほとんどには対応しています。

また、Rustのbindgenやcc/cmake crateを併用すると、CのライブラリをC#に簡単に持ちこむことができます。例えば圧縮ライブラリのlz4は、csbindgenでの生成の前にbindgenとccの設定も足してあげると

// lz4.h を読み込んで lz4.rs にRust用のbindingコードを出力する
bindgen::Builder::default()
    .header("c/lz4/lz4.h")
    .generate().unwrap()
    .write_to_file("lz4.rs").unwrap();

// cc(C Compiler)によってlz4.cを読み込んでコンパイルしてリンクする
cc::Build::new().file("lz4.c").compile("lz4");

// bindgenの吐いたコードを読み込んでcsファイルを出力する
csbindgen::Builder::default()
    .input_bindgen_file("lz4.rs")
    .rust_file_header("use super::lz4::*;")
    .csharp_entry_point_prefix("csbindgen_")
    .csharp_dll_name("liblz4")
    .generate_to_file("lz4_ffi.rs", "../dotnet/NativeMethods.lz4.g.cs")
    .unwrap();

これでC#から呼び出せるコードが簡単に生成できます。ビルドもRustで cargo build するだけでCのコードがリンクされてDLLに含まれています。

// NativeMethods.lz4.g.cs

using System;
using System.Runtime.InteropServices;

namespace CsBindgen
{
    internal static unsafe partial class NativeMethods
    {
        const string __DllName = "liblz4";

        [DllImport(__DllName, EntryPoint = "csbindgen_LZ4_compress_default", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)]
        public static extern int LZ4_compress_default(byte* src, byte* dst, int srcSize, int dstCapacity);

        // snip...
    }
}

試してもらうと、本当に簡単にCライブラリが持ち込みができて感動します。Rustやbindgenがとにかく偉い。

csbindgenはUnityでの利用も念頭においているので、よくあるiOSでのIL2CPPだけ __Internal にしたいみたいなシチュエーションでも

#if UNITY_IOS && !UNITY_EDITOR
    const string __DllName = "__Internal";
#else
    const string __DllName = "nativelib";
#endif

といったような生成ルールの変更がコンフィグに含めてあります。とても実用的で気が利いてます。

LibraryImport vs DllImport

.NET 7からLibraryImportという新しい呼び出しのためのソースジェネレーターが追加されました。これはDllImportのラッパーになっていて、DllImportは、本来ネイティブコードとやり取りできない型(例えば配列や文字列などの参照型はC#のヒープ上に存在するもので、ネイティブ側に渡せない)を裏で自動的にやってくれるという余計なお世話が含まれていて、それがややこしさや性能面、そしてNativeAOTビリティの欠如などの問題を含んでいたので、そういう型が渡された場合はLibraryImportの生成するC#コードで吸収した上で、byte* としてDllImportに渡すようなラッパーが生成されるようになっています。

つまり余計なお世話をする本来ネイティブコードとやり取りできない型を生成しないようにすればDllImportでも何の問題もないので、今回はDllImportでの生成を選んでいます。そのほうがUnityでも使いやすいし。

Win32のAPIをDllImportで簡単に呼び出せるようにするために暗黙的な自動変換を多数用意しておく、というのは時代背景的には理解できます。C#がWindowsのためだけの言語であり、時折Win32 APIの呼び出しが必須なこともあったのは事実であり、便利な側面もあったでしょう。しかし現在はWindowsのためだけの言語でもなく、またWin32 APIの呼び出しに関してはCsWin32というSource Generatorを活用した支援も存在します。

もう現代では、そうしたDllImportの古い設計を引きずって考える必要はない、頼るべきではないでしょう。つまり参照型を渡したり[In]や[Out]は使うべきではないし、変換を考慮した設計を練る必要もありません。実際 .NET 7ではそうしたDllImportの機能を使うとエラーにするDisableRuntimeMarshallingAttributeが追加されました。

ポインターに関しても今はあまり忌避するものではないと思っています。そもそもネイティブとの通信はunsafeだし、Spanによって比較的使いやすい型に変換することも容易なので。中途半端に隠蔽するぐらいなら、DllImportするレイヤーではポインターはポインターとして持っておきましょう。C#として使いやすくするのは、その外側できっちりやればいい話です、DllImportで吸収するものではない。というのが今風の設計思想であると考えています。なんだったら私はIntPtrよりvoid*のほうが好きだよ。

コールバックの相互受け渡し

C# -> Rust あるいは Rust -> C# でコールバックを渡し合ってみましょう。まずRust側はこんな風に書くとします。

#[no_mangle]
pub extern "C" fn csharp_to_rust(cb: extern "C" fn(x: i32, y: i32) -> i32) {
    let sum = cb(10, 20); // invoke C# method
    println!("{sum}");
}

#[no_mangle]
pub extern "C" fn rust_to_csharp() -> extern fn(x: i32, y: i32) -> i32 {
    sum // return rust method
}

extern "C" fn sum(x:i32, y:i32) -> i32 {
    x + y
}

C#のメソッドを受け取ったら、それを読んで表示(println)するだけ、あるいは足し算する関数をC#に渡すだけ、のシンプルなメソッドです。生成コードは以下のようなものになります。

[DllImport(__DllName, EntryPoint = "csharp_to_rust", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)]
public static extern void csharp_to_rust(delegate* unmanaged[Cdecl]<int, int, int> cb);

[DllImport(__DllName, EntryPoint = "rust_to_csharp", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)]
public static extern delegate* unmanaged[Cdecl]<int, int, int> rust_to_csharp();

delegate* unmanaged[Cdecl]<int, int, int> というのは、あまり見慣れない定義だと思うのですが、C# 9.0から追加された本物の関数ポインターになります。定義を手書きするのは少しややこしいですが、自動生成されるので特に問題なしでしょう(?)。使い勝手はかなりよく、普通の静的メソッドのように扱えます。

// ネイティブ側に渡したい静的メソッドはUnmanagedCallersOnlyを付ける必要がある
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
static int Sum(int x, int y) => x + y;

// &で関数ポインターを取得して渡す
NativeMethods.csharp_to_rust(&Sum);

// Rustからdelegate*を受け取る
var f = NativeMethods.rust_to_csharp();

// 受け取った関数ポインターは普通に呼び出せる
var v = f(20, 30);
Console.WriteLine(v); // 50

インスタンスメソッドを渡せないのか?というと渡せません。Cとの相互運用にそんなものはない。どうでもいい勝手な変換はしなくていい。第一引数にコンテキスト(void*)を受け取るコードを用意しておけばいいでしょう。

ところで、UnityもC# 9.0対応、しているし関数ポインターも使えるには使えるのですが、Extensible calling conventions for unmanaged function pointers is not supportedです。UnmanagedCallersOnlyAttributeもないしね。Unity Editor上では普通に動いちゃったりとかしますが、IL2CPPでは動かないのでちゃんと対応しましょう。csbindgenでは csharp_use_function_pointer(false) というオプションを設定すると、従来のデリゲートを使用したコードを出力します。

// csharp_use_function_pointer(false) の場合の出力結果、専用のデリゲートを一緒に吐き出すようになる
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate int csharp_to_rust_cb_delegate(int x, int y);

[DllImport(__DllName, EntryPoint = "csharp_to_rust", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)]
public static extern void csharp_to_rust(csharp_to_rust_cb_delegate cb);

[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate int rust_to_csharp_return_delegate(int x, int y);

[DllImport(__DllName, EntryPoint = "rust_to_csharp", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)]
public static extern rust_to_csharp_return_delegate rust_to_csharp();

// MonoPInvokeCallback属性を静的メソッドにつける(typeofでデリゲートを設定)
[MonoPInvokeCallback(typeof(NativeMethods.csharp_to_rust_cb_delegate))]
static int Sum(int x, int y) => x + y;

// そのまま渡す
NativeMethods.csharp_to_rust(Method);

// 受け取る関数ポインターに関しては .NET の場合と一緒
var f = NativeMethods.rust_to_csharp();
var v = f(20, 30);
Console.WriteLine(v); // 50

面倒くさい専用のデリゲートも同時に出力してくれるので、定義はそこそこ楽になります(Action/Funcといった汎用デリゲートを使うと場合によりクラッシュしてしまったので、必ずそれぞれのパラメーター専用のデリゲートを出力するようにしています)。概ねcsbindgenがよしなに動くように面倒見てあげるので、属性の違いだけ考えればほぼ問題はありません。

コンテキスト

多値返しみたいなのは、普通にStructを作ってくださいという話になって、その場合は、C#側でStructはコピーされて、Rust側のメモリからはすぐ消えるということになります。

#[no_mangle]
pub unsafe extern "C" fn return_tuple() -> MyTuple {
    MyTuple { is_foo: true, bar: 9999 }
}

#[repr(C)]
pub struct MyTuple {
    pub is_foo: bool,
    pub bar: i32,
}

もう少し寿命を長く、返却するStructをポインターで返して状態を持ちたい、という場合はRust的には少し工夫が必要です。

#[no_mangle]
pub extern "C" fn create_context() -> *mut Context {
    let ctx = Box::new(Context { foo: true });
    Box::into_raw(ctx)
}

#[no_mangle]
pub extern "C" fn delete_context(context: *mut Context) {
    unsafe { Box::from_raw(context) };
}

#[repr(C)]
pub struct Context {
    pub foo: bool,
    pub bar: i32,
    pub baz: u64
}
// C#側、Context*を受け取って
var context = NativeMethods.create_context();

// なにか色々したりずっと持っていたり

// 最後に明示的にfreeしにいく
NativeMethods.delete_context(context);

Box::new でヒープ上にデータを確保して、Box::into_rawでRust上でのメモリ管理から外します。Rustは通常だとスコープが外れると即座にメモリを返却する、のですが、寿命をRust管理外のC#に飛ばすので、素直に(?)unsafeにRust上の管理から外してしまうのが普通に素直でしょう。Rust側で確保しているメモリを開放する場合は、Box::from_rawでRust上の管理に戻します。そうするとスコープが外れたらメモリ返却という通常の動作をして、返却が完了します。

この辺はRustだから難しい!という話ではなく、C#でもfixedスコープを外れてポインタを管理したい場合には GCHandle.Allocc(obj, GCHandleType.Pinned) して手動でunsafeな管理しなければいけないので、完全に同じ話です。そう考えると、むしろ素直にC#と変わらない話でいいですね。

なお、C#上でこうしたコンテキストの管理をする場合に専用のSafeHandleを作って、それにラップするという流儀がありますが、大仰で、基本的にはそこまでやる必要はないと思ってます。No SafeHandle。そもそも境界越えというunsafeなことをしているのだから、最後まで自己責任でいいでしょう。

csbindgenは戻り値にstructが指定されていると、C#側にも同様のものを生成しに行ってしまいますが、Rust内だけで使うのでC#側には内容公開したくない、というか参照(Box)とかも含まれてるから公開できないし、みたいな場合もあると思います。その場合は c_void を返してください。

#[no_mangle]
pub extern "C" fn create_counter_context() -> *mut c_void {
    let ctx = Box::new(CounterContext {
        set: HashSet::new(),
    });
    Box::into_raw(ctx) as *mut c_void // voidで返す
}

#[no_mangle]
pub unsafe extern "C" fn insert_counter_context(context: *mut c_void, value: i32) {
    let mut counter = Box::from_raw(context as *mut CounterContext); // as で型を戻す
    counter.set.insert(value);
    Box::into_raw(counter); // contextを使い続ける場合はinto_rawを忘れないように
}

#[no_mangle]
pub unsafe extern "C" fn delete_counter_context(context: *mut c_void) {
    let counter = Box::from_raw(context as *mut CounterContext);
    for value in counter.set.iter() {
        println!("counter value: {}", value)
    }
}

// C#側には公開しない
pub struct CounterContext {
    pub set: HashSet<i32>,
}
// C#側では ctx = void* として受け取る
var ctx = NativeMethods.create_counter_context();
    
NativeMethods.insert_counter_context(ctx, 10);
NativeMethods.insert_counter_context(ctx, 20);

NativeMethods.delete_counter_context(ctx);

この辺、PhantomData<T>を使って格好良く処理する手法も一応あるんですが、正直複雑になるだけなので、素直に void* ベースでやり取りする、に倒したほうがむしろ健全でいいのではと思っています。どっちにしろunsafeな処理してるんだから素直にunsafeな業を受け入れるべき!

Stringと配列のマーシャリング

Stringと配列は、C#とRustでそれぞれ構造が違うので、そのままやり取りはできません。ポインタと長さ、つまりC#でいうところのSpanのみがやり取りできます。Span的な処理をするだけならゼロコピーですが、Stringや配列に変換したくなったら、C#とRust、どちらの側でも新規のアロケーションが発生します。これはネイティブコードを導入することの弱みで、Pure C#で通したほうが融通が効く(或いはパフォーマンスに有利に働く)ポイントですね。まあ、ともあれ、つまり基本はSpanです。DllImport上でStringを受けたり配列を受けたりしてはいけません、その手の自動変換にゆだねてはダメ!アロケーションも自己責任で明示的に。

さて、まずは文字列ですが、こういったケースでやり取りする文字列の種類は3つ、UTF8とUTF16とヌル終端文字列、です。UTF8はRustの文字列(RustのStringはVec<u8>)、C#の文字列はUTF16、そしてCのライブラリなどはヌル終端文字列を返してくることがあります。

今回は例なので明示的にRust上でヌル終端文字列を返してみます。

#[no_mangle]
pub extern "C" fn alloc_c_string() -> *mut c_char {
    let str = CString::new("foo bar baz").unwrap();
    str.into_raw()
}

#[no_mangle]
pub unsafe extern "C" fn free_c_string(str: *mut c_char) {
    unsafe { CString::from_raw(str) };
}
// null-terminated `byte*` or sbyte* can materialize by new String()
var cString = NativeMethods.alloc_c_string();
var str = new String((sbyte*)cString);
NativeMethods.free_c_string(cString);

C#上では new Stringでポインタ(sbyte*)を渡すとヌル終端を探してStringを作ってくれます。明示的にアロケーションしているという雰囲気がいいですね。ポインタはこの場合Rustで確保したメモリなので、C#のヒープ上にコピー(新規String作成)したなら、即返却してやりましょう。

Rustで確保したUTF8、byte[]、あるいはint[]などとにかく配列全般の話はもう少し複雑になってきます。Rustでの配列的なもの(Vec<T>)をC#に渡すにあたっては、ポインタと長さをC#に渡せばOKといえばOKなのですが、解放する時にそれだけだと困ります。Vec<T>の実態はポインタ、長さ、そしてキャパシティの3点セットになっているので、この3つを渡さなきゃいけないのですね。そして、都度3点セットを処理するのも面倒です、Rust的なメモリ管理を外したり戻したりの作業もあるし。

というわけでちょっと長くなりますが以下のようなユーティリティーを用意しましょう。これの元コードは(元)Rustの開発元であるMozillaのコードなので安全安心です……!

#[repr(C)]
pub struct ByteBuffer {
    ptr: *mut u8,
    length: i32,
    capacity: i32,
}

impl ByteBuffer {
    pub fn len(&self) -> usize {
        self.length.try_into().expect("buffer length negative or overflowed")
    }

    pub fn from_vec(bytes: Vec<u8>) -> Self {
        let length = i32::try_from(bytes.len()).expect("buffer length cannot fit into a i32.");
        let capacity = i32::try_from(bytes.capacity()).expect("buffer capacity cannot fit into a i32.");

        // keep memory until call delete
        let mut v = std::mem::ManuallyDrop::new(bytes);

        Self {
            ptr: v.as_mut_ptr(),
            length,
            capacity,
        }
    }

    pub fn from_vec_struct<T: Sized>(bytes: Vec<T>) -> Self {
        let element_size = std::mem::size_of::<T>() as i32;

        let length = (bytes.len() as i32) * element_size;
        let capacity = (bytes.capacity() as i32) * element_size;

        let mut v = std::mem::ManuallyDrop::new(bytes);

        Self {
            ptr: v.as_mut_ptr() as *mut u8,
            length,
            capacity,
        }
    }

    pub fn destroy_into_vec(self) -> Vec<u8> {
        if self.ptr.is_null() {
            vec![]
        } else {
            let capacity: usize = self.capacity.try_into().expect("buffer capacity negative or overflowed");
            let length: usize = self.length.try_into().expect("buffer length negative or overflowed");

            unsafe { Vec::from_raw_parts(self.ptr, length, capacity) }
        }
    }

    pub fn destroy_into_vec_struct<T: Sized>(self) -> Vec<T> {
        if self.ptr.is_null() {
            vec![]
        } else {
            let element_size = std::mem::size_of::<T>() as i32;
            let length = (self.length * element_size) as usize;
            let capacity = (self.capacity * element_size) as usize;

            unsafe { Vec::from_raw_parts(self.ptr as *mut T, length, capacity) }
        }
    }

    pub fn destroy(self) {
        drop(self.destroy_into_vec());
    }
}

Box::into_raw/from_rawのVec版という感じで、from_vecしたタイミングでメモリ管理から外すのと、destroy_into_vecするとメモリ管理を呼び側に戻す(何もしなければスコープを抜けて破棄される)といったような動作になっています。これはC#側でも(csbindgenによって)定義が生成されているので、メソッドを追加してやります。

// C# side span utility
partial struct ByteBuffer
{
    public unsafe Span<byte> AsSpan()
    {
        return new Span<byte>(ptr, length);
    }

    public unsafe Span<T> AsSpan<T>()
    {
        return MemoryMarshal.CreateSpan(ref Unsafe.AsRef<T>(ptr), length / Unsafe.SizeOf<T>());
    }
}

これでByteBuffer*で受け取ったものを即Spanに変換できるようになりました!というわけで、Rust上の通常のstring、byte[]、それとint[]の例を見てみると

#[no_mangle]
pub extern "C" fn alloc_u8_string() -> *mut ByteBuffer {
    let str = format!("foo bar baz");
    let buf = ByteBuffer::from_vec(str.into_bytes());
    Box::into_raw(Box::new(buf))
}

#[no_mangle]
pub unsafe extern "C" fn free_u8_string(buffer: *mut ByteBuffer) {
    let buf = Box::from_raw(buffer);
    // drop inner buffer, if you need String, use String::from_utf8_unchecked(buf.destroy_into_vec()) instead.
    buf.destroy();
}

#[no_mangle]
pub extern "C" fn alloc_u8_buffer() -> *mut ByteBuffer {
    let vec: Vec<u8> = vec![1, 10, 100];
    let buf = ByteBuffer::from_vec(vec);
    Box::into_raw(Box::new(buf))
}

#[no_mangle]
pub unsafe extern "C" fn free_u8_buffer(buffer: *mut ByteBuffer) {
    let buf = Box::from_raw(buffer);
    // drop inner buffer, if you need Vec<u8>, use buf.destroy_into_vec() instead.
    buf.destroy();
}

#[no_mangle]
pub extern "C" fn alloc_i32_buffer() -> *mut ByteBuffer {
    let vec: Vec<i32> = vec![1, 10, 100, 1000, 10000];
    let buf = ByteBuffer::from_vec_struct(vec);
    Box::into_raw(Box::new(buf))
}

#[no_mangle]
pub unsafe extern "C" fn free_i32_buffer(buffer: *mut ByteBuffer) {
    let buf = Box::from_raw(buffer);
    // drop inner buffer, if you need Vec<i32>, use buf.destroy_into_vec_struct::<i32>() instead.
    buf.destroy();
}

ByteBuffer自体の管理を外す(into_raw)が必要なのと、from_rawで戻したあとの中身のByteBufferもdestoryかinto_vecしなきゃいけないという、入れ子の管理になっているというのが紛らわしくて死にそうになりますが、ソウイウモノということで諦めましょう……。Drop traitを実装しておくことでクリーンナップ側の処理はもう少しいい感じにできる余地がありますが、Drop traitを実装しないことの理由もそれなりにある(と、Mozillaが言っている)ので、トレードオフになっています。

C#側では、とりあえずAsSpanして、あとはよしなにするという感じですね。

var u8String = NativeMethods.alloc_u8_string();
var u8Buffer = NativeMethods.alloc_u8_buffer();
var i32Buffer = NativeMethods.alloc_i32_buffer();
try
{
    var str = Encoding.UTF8.GetString(u8String->AsSpan());
    Console.WriteLine(str);

    Console.WriteLine("----");

    var buffer = u8Buffer->AsSpan();
    foreach (var item in buffer)
    {
        Console.WriteLine(item);
    }

    Console.WriteLine("----");

    var i32Span = i32Buffer->AsSpan<int>();
    foreach (var item in i32Span)
    {
        Console.WriteLine(item);
    }
}
finally
{
    NativeMethods.free_u8_string(u8String);
    NativeMethods.free_u8_buffer(u8Buffer);
    NativeMethods.free_i32_buffer(i32Buffer);
}

Rust側で確保したメモリはRust側で解放する!という基本に関しては忠実に守っていきましょう。この例だとC#側で処理したら即解放なので、いい感じにしてくれよ、なんだったらDllImportで暗黙的に自動処理最高、みたいな気になるかもしれませんが、もう少し長寿命で持つケースもあるので、やはりマニュアルでちゃんと解放していきましょう。ていうか暗黙的なアロケーションは一番最悪じゃないです???

最後に、C#で確保したメモリをRust側で使う場合の例をどうぞ。

#[no_mangle]
pub unsafe extern "C" fn csharp_to_rust_string(utf16_str: *const u16, utf16_len: i32) {
    let slice = std::slice::from_raw_parts(utf16_str, utf16_len as usize);
    let str = String::from_utf16(slice).unwrap();
    println!("{}", str);
}

#[no_mangle]
pub unsafe extern "C" fn csharp_to_rust_utf8(utf8_str: *const u8, utf8_len: i32) {
    let slice = std::slice::from_raw_parts(utf8_str, utf8_len as usize);
    let str = String::from_utf8_unchecked(slice.to_vec());
    println!("{}", str);
}


#[no_mangle]
pub unsafe extern "C" fn csharp_to_rust_bytes(bytes: *const u8, len: i32) {
    let slice = std::slice::from_raw_parts(bytes, len as usize);
    let vec = slice.to_vec();
    println!("{:?}", vec);
}
var str = "foobarbaz:あいうえお"; // JPN(Unicode)
fixed (char* p = str)
{
    NativeMethods.csharp_to_rust_string((ushort*)p, str.Length);
}

var str2 = Encoding.UTF8.GetBytes("あいうえお:foobarbaz");
fixed (byte* p = str2)
{
    NativeMethods.csharp_to_rust_utf8(p, str2.Length);
}

var bytes = new byte[] { 1, 10, 100, 255 };
fixed (byte* p = bytes)
{
    NativeMethods.csharp_to_rust_bytes(p, bytes.Length);
}

std::slice::from_raw_partsでSliceを作って、あとはよしなに処理したいことをします。関数を超えて長い寿命を持たせたいならコピー(String作りなりVec作るなり)は必須になってきます。Rust側で確保したメモリはRust側で解放する、のと同じように、C#側で確保したメモリはC#側で解放する、のが重要です。C#の場合はfixedスコープを抜けて参照を持っていない場合は、そのうちGCが処理してくれるでしょう、といった話ですね。

なお、fixedを超えてC#でもう少し長い寿命で持ち回したいときは GCHandle.Allocc(obj, GCHandleType.Pinned) して持ち回します。

Rust for C# Developer

Rustは、正直すごい気に入ってます。C#の次に気に入りました……!まぁ正直、これで全部やる、Webもなにもかも作る、みたいなのはヤバいかな、と思います。RustでWebやりたいって人はあれでしょ、型がついてて開発環境が充実していてエコシステムが回ってる言語がいいんでしょ?ちょうどいい言語があるんですよ、C#という。……。ではあるんですが、ネイティブが必要って局面で、やりたくないーって逃げたり、NativeAOTがなんとかしてくれるだのといった現実逃避したりせず、ちゃんと正面から向き合えるようになったということはいいことです。

で、実際RustはかなりC#erに馴染む道具だと思っていて、そもそもインターフェイスがないかわりにstructとジェネリクスとtrait(インターフェイスみたいなやつ)で処理するってのは、別にそれC#でもやってますよ!C#のパフォーマンス最速パターンってstructにインターフェイス実装してジェネリクスの型制約でインターフェイス指定してボクシング/仮想メソッド呼び出し回避でstruct投げ込むことですからね。ようはC#の最速パターンだけが強制されてるんだと思えば何も違和感がない。

インスタンスメソッドがないかわりに全部拡張メソッドみたいな雰囲気なのも、いやー、C#も、もはやインスタンスメソッドと拡張メソッド、どっちで実装すればいいかなーって切り分けに悩むこともあるし、C# 12候補のExtensionsなんてきたら完全にどこで実装すりゃいいのかわからんわ、ってなるので、拡張メソッド一択(impl, trait)ですよ、みたいなのはすっきり整理されていて逆にいい。

シンタックスも自然というかC系の多数派に寄り添った感じで親しみやすいし、ドットでメソッド繋げていくので、馴染み深いオブジェクト指向的な手触りが十分ある。それとミュータブルに寛容なところがいいですね。関数型にありがちなイミュータブル至上主義ではなく、どちらかというとメモリそこにあるんだからミュータブルやろ、みたいな雰囲気なのがとてもいい。無駄もないし。所有権周りが厳密なのでミュータブルであっても固めな手応えなのは、これでいいんだよというかこれで的な何かではある。

マクロはコンパイル時ExpresionTreeみたいなもので、proc-macroはSource Generatorみたいなものなので、何が可能になるかすぐに理解できるし、便利さもよくわかる。ていうかコンパイル時ExpressionTreeはC#にも欲しい(実行時だからコスト重いのであんま使わないのでコンパイル時に解決するならもっとばんばん使えるはずなんだよねえ)。ただ、自由度がとても高いせいでマクロに入ると入力補完が完全に効かなくなる。そして自由度が高いのでマクロでDSL的な流れに高級ライブラリほどなりがちで、完全にマニュアル引きが必要になってくるのが、見た目はキレイにおさまるけど書き味はよくないな、的な体感になるのがもったいない。その点でいうとC#はやっぱ入力補完最優先な言語で、一貫した安定感を提供しているのはとても良いですね。

キツいかなーと思うのは所有権がどうとかっていうよりも、ジェネリクスの見た目がキツい。C#だったらインターフェイスで動的ディスパッチで整理されているものが、ジェネリクスで静的ディスパッチに倒れているのでジェネリクスの出現率がめっちゃ高い。いや、だってC#でもジェネリクスでると読みやすさ的には一段落下がるわけじゃないですか、それが当たり前って感じだと、慣れとかって問題じゃなく見やすさレベルは下がる。更にその上にジェネリクスがネストするのが当たり前。C#だったらジェネリクスがネストしてるのは見やすさレベル最底辺なので極力出現しないようにしたいって感じなのですが、Rustだと日常茶飯事に出てくる。Option<Rc<RefCell<_>>>とかも全然普通に出現するのが、うーむ。理屈では納得いくから特に文句があるようでなにもないんですが。

なんだったらパターンマッチも別に好きじゃないしOptionもResultも好きじゃないしnullの何が悪いんだよぐらいの気持ちにならなくもないんですが、まぁそれはそれ。でも全体的には凄い良いですね、ほんと。

まとめ

ところでcsbindgenのReadMeのほうには更にもっといっぱい変換パターンを紹介していますので、是非そちらもチェックしてみてください。

ネイティブ呼び出しは定義の部分でも、二重定義がそもそもダルいうえに、かなり気を使わなきゃいけないことがなにげに多くて割と大変というか知識量と単純作業量を要求してくるのですが、csbindgenはその部分を完全自動化してくれます。自分でも使っててネイティブコードめっちゃ楽……!という気になります。事実楽。すごい。その後のメモリ管理に関しては、そこはまぁ思う存分悩んでくれという話になるのですが、もはや複雑な点がそれだけに落ち着いたという点では、やはり革命的に便利なのでは?という気になります。

Cのライブラリを持ってくるのも圧倒的に楽なので、私の中でもちょっと考え方が変わってきました。今までは割とPure C#実装至上主義、みたいなところがあったんですが、うまい切り分け、使い分けみたいなのを考えられるようになりました。そして、Cライブラリ利用がより自由になると、まさにCysharpの掲げる「C#の可能性を切り開いていく」ことにまた一つ繋がってしまったな、と。

まずはこの後に数個、csbindgenを活用したC#ライブラリを提供する予定があります!のですが、その前に、Rustかー、とは思わずに是非csbindgen、試してみてもらえると嬉しいです。

SimdLinq - LINQをそのままSIMD対応して超高速化するライブラリ

ついこないだのStructureOfArraysGenerator - C#でSoAを簡単に利用するためのSource Generatorは、SoAになってるとSIMDを適用しやすいよ、という話だったのですが、そもそもSIMD手書きはカジュアルにやらないし、気合い入れてSIMD書くシチュエーションなら構造も気合い入れて専用に設計するよなぁ。と、なると、カジュアルにSIMD使えるライブラリが必要で、まぁLINQですね、と。

これを入れると別にSoA関係なく、SIMDが適用できる状態(例えばint[]にSum)だと、自動的にSIMDが適用されるようになります。そして、実際めちゃくちゃ速い。

SIMDとLINQの組み合わせが威力を発揮するというのは、別に新しいことではなく、そもそも .NET 7でもPerformance Improvements in .NET 7 LINQで、幾つかのメソッドが内部でSIMD化されて高速化されていることが発表されています。しかし、 .NET 7のSIMD対応は非常に限定的なもので、具体的にはint[]Average,Min,Max、それとlong[]Min,Maxだけです。これには理由はなくはないのですが、本来SIMD対応できる範囲はもっと広いため、これでは非常にもったいない。

SimdLinqを適用できるメソッドは Sum, Average, Min, Max, MinMax, Contains, SequenceEqual、要素の型は byte, sbyte, short, ushort, int, uint, long, ulong, float, double、コレクションの型は T[], List<T>, Span<T>, ReadOnlySpan<T>, Memory<T>, ReadOnlyMemory<T> と理屈上SIMD化できるものを全て詰め込みました。特にSpan<T>/ReadOnlySpan<T>は通常のLINQでは使えない(メソッドが定義されていない)ので、有益です。また、Min, Maxを同時に取得するMinMaxというメソッドを独自に追加しています。

専用メソッドを呼ばせる(例えばSumSimd()とか)ようでは使いにくいと思ったので、現在のコードを何も弄らずとも、ライブラリ参照してglobal usingを設定すれば、全ての適用可能なメソッドに自動適用される仕組みにしました。これは同名メソッドを定義して、具象型のほうにオーバーロード解決が優先採用されることを利用しています。

使い方

なので、使い方もなにもなく、usingすれば勝手にSimdLinqになって高速化されます。

using SimdLinq; // enable SimdLinq extension methods

var array = Enumerable.Range(1, 100000).ToArray();

var sum = array.Sum(); // used SimdLinqExtensions.Sum

using忘れちゃうというのはあるので、そこでglobal usingです。csprojに

<ItemGroup>
    <Using Include="SimdLinq" />
</ItemGroup>

というのを仕込んでやれば、SimdLinqが使える場合はSimdLinqに、そうじゃないものは普通のLinqでオーバーロードが解決されるようになります。便利。

具体的にSimdLinqが適用されるメソッドは以下のものになります。

  • Sum for int, uint, long, ulong, float, double
  • LongSum for int, uint
  • Average for int, uint, long, ulong, float, double
  • Min for byte, sbyte, short, ushort, int, uint, long, ulong, float, double
  • Max for byte, sbyte, short, ushort, int, uint, long, ulong, float, double
  • MinMax for byte, sbyte, short, ushort, int, uint, long, ulong, float, double
  • Contains for byte, sbyte, short, ushort, int, uint, long, ulong, float, double
  • SequenceEqual for byte, sbyte, short, ushort, int, uint, long, ulong, float, double

互換性と安全性

.NET 7の標準に、このSimdLinqのようなアグレッシブなSIMD化が入らなかった理由は、互換性と安全性になります。え、安全じゃないの?というと怖くなるので、何が違うのかはしっかり把握しておきましょう。別に危険、というわけではないですが。

まずSumとAverage(Averageの中身はSumしたのをLengthで割るだけなので中身は実質Sum)ですが、LINQのSumはcheckedで、オーバーフローすると例外を吐きます。SimdLinqはuncheckedです、つまりオーバーフローするとそのままオーバーフローしたまま結果を返します。checkedのほうが挙動としてはいいんですが、SIMD演算がオーバーフローのチェックできないので、SimdLinqではuncheckedとして提供しています。オーバーフローに関しては自己責任で。さすがにbyteのSumとかだとすぐオーバーフローしちゃうので、SimdLinqのSumは32 bit以上の要素にだけ提供しています、つまりint, long, uint, ulong, double, float です。そもそも元々のLINQのSum(引数なし)もintからなので、その辺は一緒ということで。

そうしたオーバーフローの危険性を避けたい場合、独自拡張として LongSum というlongを戻り値にするSumメソッドを追加しています。内部的にlongで処理するため、(若干性能は落ちますが)オーバーフローしなくなります。

float/doubleの扱いは挙動の違いが若干あります。まず、通常のLINQのMin, MaxはNaNをチェックしますがSimdLinqはNaNをチェックしません。NaNチェックがあったほうが丁寧ですが、SIMDでそれは入れずらい&NaNが入ってくるケースってあまりないので現実的にすごい問題か、というとそうではないかな、と。

それとSumの場合に足し算の順序が変わって(LINQは前から順番に足しますが、SIMDだと並列に足すので)、浮動小数点演算だと足す順序が変わると微妙に誤差が出て同じ結果になりません。例えばLINQだと1.5710588FだけどSimdLinqだと1.5710589Fになる、といったような違いが出てきます。結果としては別にどっちでも良い(ある意味で別にどっちも厳密にはあってない)と思いますが、結果の互換性がないですよ、ということは留意してください。

まとめ

高速なLINQのAlternativeって、結構あります。LinqAFLinqFasterNetFabric.Hyperlinqなど。ただ、どれも大仰なんですよね、StructのIteratorを作ってー、とか。専用メソッドを呼ぶためにラップするのも手間だし、その割に凄い効果的というほどでもないから、依存を増やす割にはメリットも薄くなので、私自身は使おうとはあまり思ってませんでした。

そこでSimdLinqではLINQ全体を高速化させることを狙っているわけではなくて、SIMDが適用できるものだけピンポイントに、そしてソースコードには一切手を入れる必要のない"Drop-in replacement"になるようにデザインしました。また、SIMDのみに絞ったことで性能面に明らかに圧倒的な差をだして、あえて使う理由を作る、といったところですね。

ついでにそうなると欲張ってどんどん適用できる箇所を増やしたい、つまりはStructureOfArraysGeneratorだ、みたいなコンボも狙っています。エコシステム囲い込み!囲い込みはEvil!

そんなわけでSIMDシリーズ第一弾でした。今年はSIMD関連も幾つか出していくかもしれませんし、Source Generatorネタがめちゃくちゃ溜まってるので時間が無限大に必要です。まぁ、ともかくまずはSimdLinqを使って見てください!

StructureOfArraysGenerator - C#でSoAを簡単に利用するためのSource Generator

最近はSource Generatorブームが続いていて、去年末に2022年のC# (Incremental) Source Generator開発手法という記事を出しましたが、まずは今年第一弾のSource Generatorライブラリです。

これは何かというと、structure of arrays(SoA)を使いやすくするためのコードを生成するというものです。まずそもそもSoAですが、WikipediaのAoS and SoAという記事によるところ(日本語版はない)、CPUキャッシュを有効活用したりSIMDを適用させやすくなる構造だよ、と。通常C#の配列はarray of structures(AoS)になります。

上の通常の配列がAoSでXYZXYZXYZXYZといったように並んでいる構造ですが、下のStructureOfArraysGeneratorで生成したSoAの配列はXXXXYYYYZZZZという並び順になります。実際にシンプルなパフォーマンステスト(Vector3[10000]に対してYの最大値を求める)によるところ

そのまま書いても2倍、SIMDで書きやすい状態なのでSIMDで処理してしまえば10倍高速化されます。というわけで、パフォーマンスが求められるシチュエーションで非常に有用です。

このライブラリはZigという最近、日本でも注目されている言語(Node.jsの高速な代替として注目されているBunの実装言語)のMultiArrayListにインスパイアされました。Zigの作者 Andrew Kelley氏が講演した A Practical Guide to Applying Data-Oriented Design という素晴らしい講演があるので是非見て欲しいのですが

image

データ指向設計(Data-Oriented Design)はパフォーマンスを飛躍的に改善する魔法なのです。ん、それはどこかで聞いたような……?そう、UnityのDOTSです。Data-Oriented Technology Stackです。ECSです。……。まぁ、そんなわけで全体に導入するにはそうとうガラッと設計を変える必要があるので大変厳しくはあるのですが、講演での実例としてZig自身のコンパイラの事例が出てますが、まぁつまりは徹底的にやれば成果は出ます。

しかしまぁ徹底的にやらず部分的に使っても効果があるのはUnityで Job System + Burst ぐらいでいいじゃん、という気持ちになっていることからも明らかです。というわけで部分的なSoA構造の導入にお使いください、かつ、導入や利用の敷居は全然高くないように設計しました。

MultiArray

NuGetからインストール(Unityの場合はgit参照か.unitypackageで)するとAnalyzerとして参照されます。StructureOfArraysGeneratorは属性も含めて依存はなく全てのコードが生成コードに含まれる(属性はinternal attributeとして吐かれる)ので、不要なライブラリ依存が増えることはありません。

[MultiArray(Type)]を配列的に使いたいreadonly partial structにつけます。

using StructureOfArraysGenerator;

[MultiArray(typeof(Vector3))]
public readonly partial struct Vector3MultiArray
{
}

するとSource Generatorは内部的にはこういうコードを生成します。

partial struct Vector3MultiArray
{
    // constructor
    public Vector3MultiArray(int length)

    // Span<T> properties for Vector3 each fields
    public Span<float> X => ...;
    public Span<float> Y => ...;
    public Span<float> Z => ...;

    // indexer
    public Vector3 this[int index] { get{} set{} }

    // foreach
    public Enumerator GetEnumerator()
}

Structure of Arrays と言ってますが、StructureOfArraysGeneratorは Arrays は生成しません。内部的には単一の byte[] と各開始地点のオフセットのみを持っていて、生成されるプロパティによってSpan<T>のビューを返すという設計になっています。

使い方的には配列のように使えますが、Span<T>の操作、例えばref var item inによるforeachを使うと、より効率的に扱えます。

var array = new Vector3MultiArray(4);

array.X[0] = 10;
array[1] = new Vector3(1.1f, 2.2f, 3.3f);

// multiply Y
foreach (ref var item in v.Y)
{
    item *= 2;
}

// iterate Vector3
foreach (var item in array)
{
    Console.WriteLine($"{item.X}, {item.Y}, {item.Z}");
}

Yに2倍を掛ける処理などは、メモリ領域が連続していることにより、Vector3[]item.Y *= 2 などとして書くよりも高速に処理されます.

他にList<T>のようにAddできるMultiArrayListや、内部的にはbyte[]を持っているだけであることを生かしたMemoryPackでの超高速なシリアライズなどにも対応しています。気になったら是非ReadMeのほうを見てください。

.NET 7 時代のSIMD

.NETはSIMD対応が進んでいて、System.Runtime.Intrinsics.X86によって、直接ハードウェア命令を書くことが出来ます。

しかし、しかしですね、最近は .NET を Arm で動かすことが現実的になってきました。iOSやAndroidでけはなくMacのArm化、そしてAWS GravitonのようなArmサーバーはコスト面でも有利で、選択肢に十分入ります。そこでAvx.Addなんて書いていたらArmで動きません。勿論 System.Runtime.Intrinsics.Arm というクラスも公開されていて、Arm版のSIMDを手書きすることもできるんですが、分岐して似たようなものを二個書けというのか!という話です。

そこで、 .NET 7こそがC# SIMDプログラミングを始めるのに最適である理由 という記事があるのですが、確かに .NET 7 から追加された Vector256.LoadUnsafe がまずめちゃくくちゃイイ!馴染みが深い(?)Unsafeによる ref var T で書けます!そしてExpose cross-platform helpers for Vector64, Vector128, and Vector256により、Vector64/128/256<T>にプラットフォーム抽象化されたSIMD処理が書けるようになりました、やはり .NET 7から。

例えば .NET 7 でint[]のSumのSIMD化を書いてみます。

var array = Enumerable.Range(1, 100).ToArray();

ref var begin = ref MemoryMarshal.GetArrayDataReference(array);
ref var last = ref Unsafe.Add(ref begin, array.Length);

var vectorSum = Vector256<int>.Zero;
ref var current = ref begin;

// Vector256で処理できるだけ処理
ref var to = ref Unsafe.Add(ref begin, array.Length - Vector256<int>.Count);
while (Unsafe.IsAddressLessThan(ref current, ref to))
{
    // 直接足し算できて便利
    vectorSum += Vector256.LoadUnsafe(ref current);
    current = ref Unsafe.Add(ref current, Vector256<int>.Count);
}

// Vector256をintに戻す
 var sum = Vector256.Sum(vectorSum);

// 残りの分は単純処理
while (Unsafe.IsAddressLessThan(ref current, ref last))
{
    sum += current;
    current = ref Unsafe.Add(ref current, 1);
}

Console.WriteLine(sum); // 5050

まぁforがwhileのアドレス処理になっていたり、最後にはみ出た分を処理する必要がありますが、かなり自然にSIMDを扱えているといってもいいんじゃないでしょうか。(Unsafeに慣れていれば)かなり書きやすいです。いいね。

ところで .NET 7からLINQがSIMD対応してるからこんなの書く必要ないでしょ?というと、対応してません。LINQのSIMDはint[]のAverage, int[]のMin, Max, long[]のMin, Maxのみと、かなり限定的です。これは互換性の問題などなどがあり、まぁオマケみたいなものだと思っておきましょう。必要な局面があるなら自分で用意する方が無難です。

ともあれ、.NET 7 からは手書きX86 SIMDはArm対応が漏れやすいので、極力Vectorによって抽象化されたコードで書きましょう、ということになります。どうしてもVectorじゃ書けないところだけ、仕方なく書くという感じですね。

まとめ

反響全然ないだろうなあと想定していましたが、やはり反響全然ないです!まぁでも結構面白いライブラリになったと思うので、是非使ってください。それと、Incremental Source Generatorの作り方がMemoryPackの頃よりも習熟していて、コードがかなり洗練されたものになっているので、Source Generatorの作り方として参照するならMemoryPackのコードよりもこちらのコードのほうがお薦めです。

というわけで、まだまだSource Generatorネタはいっぱいあるので、今年は大量に量産します!

2022年を振り返る

今年はCysharpとしては、(控えめながら)露出があったので、何やってるかわからない、むしろ存在してるんですか?といったところから脱却したのではないでしょうか……?相変わらずホームページはペライチですけれど。そろそろいい加減、更新したい。

大きなところでは プリコネ!グランドマスターズのサーバー開発をCysharpが開発協力しました というわけで、結構長くCygamesと一緒に作っていたゲームがリリースされました。超期間限定だったのでもうプレイできませんが……!技術的な詳細はCygamesのほうから C#によるクライアント/サーバーの開発言語統一がもたらす高効率な開発体制 ~プリコネ!グランドマスターズ開発事例~ という形でCEDECで講演していますが、2022年現在の開発体制としてはかなり先端を走っている、かつ、とてもいい感じに仕上がっています。

MagicOnion採用タイトルも増えていて、特に今年リリースされたタイトルで一番大きなものはメメントモリでしょうか、MagicOnion, MessagePack for C#を採用していただいています

MagicOnionは12/28にv5をリリースしたばかりです!内部アーキテクチャの変更によるパフォーマンス向上や拡張性の確保、そしてMemoryPackへの対応といった、次世代に向けて大きく基盤整理されました。今後もSource Generatorフル対応などが控えています。

OSS関連も、振り返るとかなり充実していました。後半、既存OSSのメンテが滞り気味だったのは来年消化します……!

今年も自分のプログラミング能力の成長を実感できています。技術的に腐り始めたら一瞬!みたいな危機感はあるので、こうして毎年の成長を、ちゃんと対外的にも示し続けられているのはいいことかな。自分はできてると思ってるけど外から見るとやべぇ、みたいなパターンは往々にありますからね、常に実証と共にありたいです。

特にMemoryPackは次世代の基準を打ち立てられたのではないかと思います。GitHub Starも3ヶ月で1300到達、非常に良い感じです。作ってる最中は、私が今これをやりきらなきゃC#は10年遅れてしまうんだ、みたいな気持ちでヒィヒィいいながら書いてましたが、大言壮語な妄想ではなく実際いい感じのものを出せたのではないでしょうか。

Source Generatorの解説を 2022年のC# (Incremental) Source Generator開発手法 として書きましたが、改めてC#にとってSource Generatorはめちゃくちゃ重要なテクノロジーになると、今更ながらに理解しました。いや、2020年の終わりに UnitGenerator を作ってから(これは今も使ってます)、しかしそこまで突き詰めてこなかったんですよね、今の今まで。来年はSource Generator元年ということで、色々な分野で革命的なものを大量に投下したいと思ってます。今なんかアイディアが溢れてるんですよ……!

というわけで来年はいっぱいやることがある!今年に悔いが残るとすれば、Cysharpとして現在、自称革命的なサービス(?)を作ってるんですが、それの進捗があまり良くなかったことですかねえ。原因としては私がOSS関連でフラフラしててプロジェクトマネージャー/プロダクトオーナーとして1ミリも機能いてなかったせいなのですが!反省。PM的な話は昔からずっと反省し続けてるので一向に進歩してないですね……。そこに脳みそ注ぎ込む余力がないのだと言い訳してますが……。

そんなわけで、来年こそは革命的サービスもリリースするので期待していてください。OSS関連も革命的なものをどかどか投下する予定なので、引き続きCysharpは時代の最先端を全力疾走していきます。

2022年(2024年)のC# Incremental Source Generator開発手法

このブログでもSource GeneratorやAnalyzerの開発手法に関しては定期的に触れてきていて、新しめだと

という記事を出していますが、今回 MemoryPack の実装で比較的大規模にSource Generatorを使ってみたことで、より実践的なノウハウが手に入りました。また、開発環境も年々良くなっていることや、Unityのサポート状況も強化されているので、状況を一通りまとめてみようと思いました。Source Generatorは非常に強力で、今後必須の開発技法になるので(少なくとも私はもうIL書きません!)是非、この機会に手を出して頂ければです。

また、オリジナルは2022年状況の話でしたが、Unity関連でアップデートがあったので2024年の状況として、その辺りを書き換えました。

Microsoft.CodeAnalysis.CSharpのバージョン問題

Source Generatorを作成するには Microsoft.CodeAnalysis.CSharpを参照したライブラリを作ればいい、のですが、ここで大事なのはバージョンです。何も考えずに最新を入れると動かないという罠が待ってます。Source Generatorは、インストールされている .NET のバージョンや IDEのコンパイラバージョンと深く紐づいています。.NETのバージョンだけ上げてもダメで、特にVisual Studioの場合は.NETのバージョンと独立して、同梱されているコンパイラのバージョンがあり、それと合わせる必要があります。Unityの場合も同じく、Unityに含まれるC#コンパイラのバージョン(/Editor/Data/DotNetSdkRoslyn/Microsoft.CodeAnalysis.CSharp.dll)を精査する必要があります。使わているバージョンよりも高いバージョンのものを参照すると、動かないという理屈です。

Visual Studioのバージョンとの紐づきは .NET コンパイラ プラットフォーム パッケージ バージョン リファレンスを見れば分かりますが、現状の私のオススメは 4.3.0。これは最小サポートバージョンがVisual Studio 2022 Version 17.3ということで、VS2019は切り捨てでいいでしょう。VS2022使ってるなら、とりあえずそこまでアップデートしてくれ、ということで。古ければ古いほどカバーできる範囲が広がっていい!ようでいて、古ければ古いほど、新しい言語機能の解析ができないなどの問題があるので、お薦めはできません、むしろ何も問題がなければ新しければ新しいほどいいぐらいです。4.3.0がおすすめな最大の理由としては、SyntaxValueProvider.ForAttributeWithMetadataName という、後で説明しますが、Source Generator作成の際に必須とも言える便利メソッドが追加されていることです。C# 11, C# 12の新言語機能部分の解析が必要になる場合は、必然的にアップデートしなければなりませんが。

そしてもう一つ4.3.0がおすすめな理由としてUnityへの対応があります。Unityの場合は公式にC#コンパイラのバージョンが何であるかのリストはないので、自分で調べていく必要がありますが、とりあえずRoslyn analyzers and source generatorsという公式ドキュメントによると「must use Microsoft.CodeAnalysis 3.8」、というわけで3.8じゃないと動かないぞ、と脅しをかけてきてます。が、実際はちょくちょくアップデートされていて、バージョンを調べていくとUnity 2022.3.0にはRoslyn 4.1.0、そしてUnity 2022.3.12f1以降には4.3.0が搭載されているようです。実際、ちゃんと4.3.0で動きます。2024-01-19更新時点でのLTSはUnity 2022.3で、パッチバージョンあげてもらえば4.3.0になるので、これは4.3.0解禁ということでいいでしょう(?)

Microsoft.CodeAnalysis.CSharpのバージョンは大きく分けて 3.* と 4.* があり、3.* はv1の ISourceGenerator、4.* はv2である IIncrementalGenerator が使えます。

Incremental Generatorsは、性能面で大きく改善されている他、作りやすさも大きく上がっているため、現状は Incremental Generators で作ることを最優先で考えたほうがいいでしょう。登場の黎明期では、IDEのバージョン問題があったために、3.* と 4.* の両方のSource Generatorを作って一緒にNuGetパッケージングする、という(かなりややこしい)手法が取られたこともありましたが、もう .NET 7も登場した2022年、も終わろうとしている現在ですので、 3.* は切り捨ててしまってもいいと考えています。

ただしUnityは除く。と思っていましたがUnity 2022.2, Unity 2023.1 からは4.1.0のコンパイラが搭載されていますし、2022.3.12f1以降は4.3.0なので、もうUnity含めても3.* の切り捨てはアリだと考えています。

最小プロジェクトとデバッグ実行

Source Generator開発は、デバッグ環境をきっちり構築できていないとかなり大変です。なので環境構築をしっかりやってから挑みましょう。ここではWindowsのVisual Studio 2022を使った場合の説明のみしますが、他の環境でも、同等のことができるようにしておかないとめちゃくちゃ大変です。

まず「.NET Compiler Platform SDK」を入れましょう。標準では入ってないので。入れておかなくても開発はできるのですが、デバッグ起動ができなくなるため、ほぼ必須と思ってください。

image

次に、「netstandard2.0」のクラスライブラリプロジェクトを作成します。え、2022年にもなってnetstandard2.0なの?なんで?standard2.1やnet7じゃダメなの?という感じですが、そもそもVisual Studioが .NET Frameworkで動いているというしょっぱい事情があり、Source Generatorプロジェクトはnetstandard2.0で作る必要があるという制限があります。使えるクラスライブラリが少なくて辛い感もありますが我慢です。

<Project Sdk="Microsoft.NET.Sdk">

	<PropertyGroup>
		<TargetFramework>netstandard2.0</TargetFramework>
		<ImplicitUsings>enable</ImplicitUsings>
		<Nullable>enable</Nullable>

		<!-- LangVersionは明示的に書いておこう -->
		<LangVersion>11</LangVersion>
		<!-- Analyzer(Source Generator)ですという設定 -->
		<IsRoslynComponent>true</IsRoslynComponent>
		<AnalyzerLanguage>cs</AnalyzerLanguage>
	</PropertyGroup>

	<ItemGroup>
		<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.3.0" />
	</ItemGroup>

</Project>
using Microsoft.CodeAnalysis;

namespace SourceGeneratorSample;

[Generator(LanguageNames.CSharp)]
public partial class SampleGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // Providerシリーズ
        // context.AdditionalTextsProvider
        // context.AnalyzerConfigOptionsProvider
        // context.CompilationProvider
        // context.MetadataReferencesProvider
        // context.ParseOptionsProvider
        // context.SyntaxProvider

        // Registerシリーズ
        // context.RegisterImplementationSourceOutput
        // context.RegisterPostInitializationOutput
        // context.RegisterSourceOutput
    }
}

これで無のSource Generatorができたので(contextの解説は準備が一通り終わったらします)、次に、このGeneratorを参照するConsoleAppを適当に作成します。

<Project Sdk="Microsoft.NET.Sdk">

	<PropertyGroup>
		<OutputType>Exe</OutputType>
		<TargetFramework>net7.0</TargetFramework>
		<ImplicitUsings>enable</ImplicitUsings>
		<Nullable>enable</Nullable>
	</PropertyGroup>

	<ItemGroup>
		<ProjectReference Include="..\SourceGeneratorSample\SourceGeneratorSample.csproj">
			<OutputItemType>Analyzer</OutputItemType>
			<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
		</ProjectReference>
	</ItemGroup>

</Project>

Source Generatorのプロジェクト参照では、OutputItemTypeとReferenceOutputAssemblyの設定を追加で手書きしてください。

次にまたSource Generator側のプロジェクトに戻って、プロジェクトのプロパティから「デバッグ起動プロファイルUIを開く」を選んでください。

image

既にあるプロファイルは削除した上で、左上の「新しいプロファイルの作成」から「Roslyn Component」を選択。ここでRoslyn Componentが出てこない場合は、「.NET Compiler Platform SDK」を入れているかどうかの確認と、csprojに<IsRoslynComponent>true</IsRoslynComponent>を追加しているかどうかの確認をしてください。

image

そしてTarget Projectに、先ほど作成したSource Generatorを参照しているコンソールアプリプロジェクトを選びます。プロジェクトが選べない場合は、対象プロジェクトがSource GeneratorをAnalyzerとしてのプロジェクト参照をしているかどうかを確認してください。

image

これで準備が完了で、Source Generatorをデバッグ実行(F5)すると、対象コンソールアプリプロジェクトを引っ掛けた状態で起動するようになります。

image

あとは、ひたすら、Generatorのコードを書いていくだけです、めでたし。

ForAttributeWithMetadataName

細かい説明に行く前に、基本的な流れの説明を。Source Generatorは、通常、なにか適当な属性がついているpartial classやpartial methodを探して、それに対して追加のpartial class/methodを生成する、という流れになります。原理的には属性がついていなくてもいいですが、勝手に何かを生成されるとわけわかんなくて困るので、ユーザーに明示的に生成を指示させるような流れにすべき、ということで、起点は属性付与だけと考えていいでしょう。

そんなわけでSource Generatorでまずやることは、属性が付与されてるclass/methodを探し出すことなのですが、Roslyn 4.3.0からは SyntaxValueProvider.ForAttributeWithMetadataName というメソッドで一発で探し出すことができるようになりました。

というわけで、小さなサンプル用ジェネレーターとして、classのToStringをrecordのように自動実装するジェネレーターを作ってみます。

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace SourceGeneratorSample;

[Generator(LanguageNames.CSharp)]
public partial class SampleGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // PostInitializationOutputでSource Generatorでしか使わない属性を出力
        context.RegisterPostInitializationOutput(static context =>
        {
            // C# 11のRaw String Literal便利
            context.AddSource("SampleGeneratorAttribute.cs", """
namespace SourceGeneratorSample;

using System;

[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
internal sealed class GenerateToStringAttribute : Attribute
{
}
""");
        });

        var source = context.SyntaxProvider.ForAttributeWithMetadataName(
            "SourceGeneratorSample.GenerateToStringAttribute", // 引っ掛ける属性のフルネーム
            static (node, token) => true, // predicate, 属性で既に絞れてるので特別何かやりたいことがなければ基本true
            static (context, token) => context); // GeneratorAttributeSyntaxContextにはNode, SemanticModel(Compilation), Symbolが入ってて便利

        // 出力コード部分はちょっとごちゃつくので別メソッドに隔離
        context.RegisterSourceOutput(source, Emit);
    }

Initializeメソッドの行数の短さ!というわけで、Source Generator作り自体はかなり簡単になりました。ここまでがSourceGeneratorとして属性を引っ掛けて何かするための準備部分の全てであり、過去の諸々に比べると明らかに改善されています。

ただし、そうして抽出したところを加工して何かする部分は特に変わりないので、気合で頑張っていきましょう。↑のコードの続きは以下のものになります。

    static void Emit(SourceProductionContext context, GeneratorAttributeSyntaxContext source)
    {
        // classで引っ掛けてるのでTypeSymbol/Syntaxとして使えるように。
        // SemaintiModelが欲しい場合は source.SemanticModel
        // Compilationが欲しい場合は source.SemanticModel.Compilation から
        var typeSymbol = (INamedTypeSymbol)source.TargetSymbol;
        var typeNode = (TypeDeclarationSyntax)source.TargetNode;

        // ToStringがoverride済みならエラー出す
        if (typeSymbol.GetMembers("ToString").Length != 0)
        {
            context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.ExistsOverrideToString, typeNode.Identifier.GetLocation(), typeSymbol.Name));
            return;
        }

        // グローバルネームスペース対応漏れするとたまによく泣くので気をつける
        var ns = typeSymbol.ContainingNamespace.IsGlobalNamespace
            ? ""
            : $"namespace {typeSymbol.ContainingNamespace};";

        // 出力ファイル名として使うので雑エスケープ
        var fullType = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
            .Replace("global::", "")
            .Replace("<", "_")
            .Replace(">", "_");

        // Field/Propertyを抽出する
        var publicMembers = typeSymbol.GetMembers() // MethodがほしければOfType<IMethodSymbol>()などで絞る
            .Where(x => x is (IFieldSymbol or IPropertySymbol)
                         and { IsStatic: false, DeclaredAccessibility: Accessibility.Public, IsImplicitlyDeclared: false, CanBeReferencedByName: true })
            .Select(x => $"{x.Name}:{{{x.Name}}}"); // MyProperty:{MyProperty}

        var toString = string.Join(", ", publicMembers);

        // C# 11のRaw String Literalを使ってText Template的な置換(便利)
        // ファイルとして書き出される時対策として <auto-generated/> を入れたり
        // nullable enableしつつ、nullable系のwarningがウザいのでdisableして回ったりなどをテンプレコードとして入れておいたりする
        var code = $$"""
// <auto-generated/>
#nullable enable
#pragma warning disable CS8600
#pragma warning disable CS8601
#pragma warning disable CS8602
#pragma warning disable CS8603
#pragma warning disable CS8604

{{ns}}

partial class {{typeSymbol.Name}}
{
    public override string ToString()
    {
        return $"{{toString}}";
    }
}
""";

        // AddSourceで出力
        context.AddSource($"{fullType}.SampleGenerator.g.cs", code);
    }
}

// DiagnosticDescriptorは大量に作るので一覧性のためにもまとめておいたほうが良い
public static class DiagnosticDescriptors
{
    const string Category = "SampleGenerator";

    public static readonly DiagnosticDescriptor ExistsOverrideToString = new(
        id: "SAMPLE001",
        title: "ToString override",
        messageFormat: "The GenerateToString class '{0}' has ToString override but it is not allowed.",
        category: Category,
        defaultSeverity: DiagnosticSeverity.Error,
        isEnabledByDefault: true);
}

作り方のポイントとしては、Source Generator(Analyzer)で使うものにはSyntaxNodeとISymbolの二系統があって、SyntaxNodeは文字列としてのソースコードの構造を指していて、ISymbolはコンパイルされた状態での型の中間状態を指します。情報を取ったりするにはISymbolのほうが圧倒的にやりやすいので、基本的にはSymbolを辿って処理していきます。SyntaxNodeは、エラーの波線表示の位置を示したりする時のみに使うという感じですね。

では、これをビルドして、Visual Studioを、再起動します……!というのも、ConsoleApp1側ではSource Generatorを掴みっぱなしになってしまうので、プロジェクト参照でのSource Generatorの更新ができないからです。今回AttributeをGenerator側で追加しているので、再起動してそれの生成を含めてあげる必要があります。今後もConsoleApp1側での動作確認が必要な際は、定期的に再起動する羽目になります。ただしデバッグ起動では更新されたコードで動くので、大きな変動がなければそのまま作業を進められます。といった、IDEを再起動しなきゃいけないシチュエーションなのかしなくてもいいのか、の切り分けが求められます……。

ConsoleApp1側で以下のようなテスト型を用意して

using SourceGeneratorSample;

var mc = new MyClass() { Hoge = 10, Bar = "tako" };
Console.WriteLine(mc);

[GenerateToString]
public partial class MyClass
{
    public int Hoge { get; set; }
    public string? Bar { get; set; }
}

Source Generator側でデバッグ実行です。いったんの出力の確認でお薦めなのは、AddSourceの直前あたりにブレークポイント貼って見ることですかね。

image

そうして何度かデバッグ実行を繰り返して、理想となるコードが吐けるように調整していって、そして、最終的にそれで大丈夫かどうかはコンパイラ通さないとわからんので、Visual Studioを再起動してConsoleApp1側でコンパイル走らせて、みたいなことになりますね。この段階で問題が出ると、Visual Studio再起動祭りになるのでダルい!

問題なく吐けていれば、ソリューションエクスプローラーで生成コードを確認することができます。

image

以上、基本的な流れでした!C# 11のRaw String Literalsのお陰で別途テンプレートエンジンを用いなくても、テンプレート的な処理をC#のコード中に埋め込めるようになったのが、かなり楽になりました。(ただしif や for が埋め込めるわけではないので、複雑なものを書く場合はそれなりの工夫は必要)。

Source Generatorの良いところはAnalyzerも兼ねているところで、今回はToStringが既に定義されている場合はエラーにするという処理を入れているのですが

image

属性でどうこうする系ってどうしても今までは実行時エラーになりがちだったのですが、エディット時に間違って定義をばんばん教えてあげられるようになったのは親切度が相当上がっています。

IncrementalGeneratorInitializationContext詳解

Incremental Generatorの強みは複数のProviderを繋げてパイプラインを作れるところ、ではあるのですが、基本的なことは SyntaxProvider.ForAttributeWithMetadataName がほとんど全部やってくれるから、特に考えなくてもいいかな……。

ではあるんですが、細かい処理をしたい場合にはいくつか必要になりますので、Provider見ていきましょう。

  • AdditionalTextsProvider

AdditionalTextsProviderは、AdditionalFilesを読み取るのに使います。BannedApiAnalyzersなどでも活用されていますが、例えばコンフィグを渡したいケースなどに有用です。

例えば sampleGenerator.config.json を読み取りたい、といったケースを考えますと、ConsoleApp1側ではこういったcsprojとファイルを用意するとして

<ItemGroup>
	<AdditionalFiles Include="sampleGenerator.config.json" />
</ItemGroup>

AdditionalTextsProviderを使ってこんな風に読み取っていきます。

var configuration = context.AdditionalTextsProvider.Select((text, token) =>
    {
        if (text.Path.EndsWith("sampleGenerator.config.json")) return text.GetText(token);
        return null;
    })
    .Where(x => x != null)
    .Collect(); //雑Collect

// sampleにあったやつ
var types = context.SyntaxProvider.ForAttributeWithMetadataName(
      "SourceGeneratorSample.GenerateToStringAttribute",
      static (node, token) => true,
      static (context, token) => context)
    .Collect(); //雑Collect

var source = configuration.Combine(types);  // くっつける

context.RegisterSourceOutput(source, static (context, source) =>
{
    var configJson = source.Left.FirstOrDefault();
    var types = source.Right;
    foreach (var type in types)
    {
        // よしなに処理
    }
});

なるほどコードが増えた?

まず、Providerが触った直後のやつは IncrementalValuesProvider<T> になります。そしてCollectすると IncrementalValueProvider<ImmutableArray<T>> になります。違いはImmutableArray、ではなくて、 ValueProvider と ValuesProvider のほうです。ValueProviderの状態だと(IObservableみたいに)複数値が流れてくるのですが、ValuesProviderの状態だと、ImmutableArrayとして一塊になったものが一発流れてきます。

で、複数ProviderをCombineで繋いで、RegsiterSourceOutputに流し込むという流れになるわけですが、ValueとValuesが混在してるとCombineの型合わせがめちゃくちゃ大変です……!なんかよくわからんがCombineできない!の原因は型が合わないせいなのですね。というわけで雑にCollectしておくと合わせやすくなるので良いです。

というわけで、こんな感じで次のProvider行きましょう。

  • AnalyzerConfigOptionsProvider

GlobalOptionsと、AdditionalTextやSyntaxTreeに紐付けられたオプションを引っ張るGetOptionsがあります。例えばMemoryPackではcsprojのオプションから取り出すために使いました。

こういう記述をして

<ItemGroup>
    <CompilerVisibleProperty Include="MemoryPackGenerator_SerializationInfoOutputDirectory" />
</ItemGroup>
<PropertyGroup>
    <MemoryPackGenerator_SerializationInfoOutputDirectory>$(MSBuildProjectDirectory)\MemoryPackLogs</MemoryPackGenerator_SerializationInfoOutputDirectory>
</PropertyGroup>

こんな風に取り出すことができる( build_property. が接頭辞に必要)みたいな。

var outputDirProvider = context.AnalyzerConfigOptionsProvider
    .Select((configOptions, token) =>
    {
        if (configOptions.GlobalOptions.TryGetValue("build_property.MemoryPackGenerator_SerializationInfoOutputDirectory", out var path))
        {
            return path;
        }

        return (string?)null;
    });

csproj側があんま書きやすい感じじゃないので、AdditionalFilesでjsonを渡すのとどちらがいいのか、みたいなのは考えどころですね。こちらだとcsproj内のマクロが使える(出力パスとか)のはいいところかもしれません。

  • CompilationProvider

Compilationが拾える最重要Provider、のはずが ForAttributeWithMetadataName がくっつけてくれるので用無し。

  • MetadataReferencesProvider

読み込んでるDLLの情報が拾えます。

image

そんな使わないかも。

  • ParseOptionsProvider

csprojを解析した情報が取れます。例えば言語バージョンやプリプロセッサシンボルから、.NETのバージョンを取り出したりできます。

var parseOptions = context.ParseOptionsProvider.Select((parseOptions, token) =>
{
    var csOptions = (CSharpParseOptions)parseOptions;
    var langVersion = csOptions.LanguageVersion;
    var net7 = csOptions.PreprocessorSymbolNames.Contains("NET7_0_OR_GREATER");
    return (langVersion, net7);
});

つまり、言語バージョンや.NETのバージョン別の出し分けに使える、ということですね。細かくやると面倒くさいのであんまギチギチにやらないほうがいいとは思いますが、どうしてもそういう処理が必要なシチュエーションでは使えます。というか実際MemoryPackではこれで出し分けしています。scoped ref(C# 11)やfile scoped namespace(C# 10)、static abstract method(.NET 7)という切り分けですねー。

  • SyntaxProvider

ForAttributeWithMetadataName を叩くためのやつ。

  • RegisterPostInitializationOutput

ここからはRegisterシリーズですが、PostInitializeationOutputは、Source Generatorのためのマーカーとしてしか使わない属性をinternal classとして解析走らせる前に出力しておきたい、というやつですね。UnitGeneratorでは UnitOfAttribute をそういった形で吐き出しています(なので結果としてUnitGeneratorを使ったプロジェクトはUnitGeneratorへの依存DLLはなし、ということになる)。一方でMemoryPackで使ってる属性 MemoryPackableAttribute は、MemoryPack.Core.dllに含めているので、RegisterPostInitializationOutputは使っていません。どうせReader/Writerとかの他の依存が必要になるので、属性だけ依存なしにしてもしょーがないですからね。

  • RegisterSourceOutput

Providerを繋げて、実際にSource Generateさせるやつ。大事というか必須。

  • RegisterImplementationSourceOutput

ドキュメントが一切ない上に、なんか想定通りの動きをしていないような私の想定が悪いのか、まぁよくわからないけどよくわからないのでよくわからないです。というか、ビルド時のみ動くRegisterSourceOutput想定、といった内容らしいのですが現状はその辺が実装されていないので実際想定通り動作しない。でよいようです。つまり無視が一番。そのうちなんとかなると思って数年経ってもなんともなってないので、これはなんともならないでフィニッシュっぽい。

ユニットテスト

厳密にやるとキリがないので、そこそこゆるふわ感覚でやるようにしてます。もちろんTDDなんてしません。基本的な考え方としては、ユニットテストプロジェクトがAnalyzerとして開発中のSource Generatorプロジェクトをプロジェクト参照して、ソース生成されるようにしておいて、ユニットテストでは、その生成されたコードが期待通り動いているかのテストをする、みたいな雰囲気で良いんじゃないかと思います。生成ソースコードの中身をチェックして一致するか、みたいなのはちょっと手間が無駄にかかりすぎるので……。

テストプロジェクトはxUnitと、補助ライブラリとしてFluentAssertionを好んで使っています。また、GlobalUsingにテスト系の名前空間を突っ込んでおくと気持ち楽です。

<Project Sdk="Microsoft.NET.Sdk">

	<PropertyGroup>
		<TargetFramework>net7.0</TargetFramework>
		<ImplicitUsings>enable</ImplicitUsings>
		<Nullable>enable</Nullable>
		<IsPackable>false</IsPackable>
	</PropertyGroup>

	<ItemGroup>
		<PackageReference Include="FluentAssertions" Version="6.7.0" />
		<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.4.0" />
		<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.1" />
		<PackageReference Include="xunit" Version="2.4.2" />
		<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
			<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
			<PrivateAssets>all</PrivateAssets>
		</PackageReference>
	</ItemGroup>

	<ItemGroup>
		<ProjectReference Include="..\SourceGeneratorSample\SourceGeneratorSample.csproj">
			<OutputItemType>Analyzer</OutputItemType>
            <!-- ReferenceOutputAssemblyをtrueにする! -->
			<ReferenceOutputAssembly>true</ReferenceOutputAssembly>
		</ProjectReference>
	</ItemGroup>

	<ItemGroup>
		<Using Include="Xunit" />
		<Using Include="Xunit.Abstractions" />
		<Using Include="FluentAssertions" />
	</ItemGroup>

</Project>

後述しますが C# 11の内部コンパイルを行うために参照する Microsoft.CodeAnalysis.CSharp は 4.4.0 です。

namespace SourceGeneratorSample.Tests;

public class ToStringTest
{
    [Fact]
    public void Basic()
    {
        var mc = new MyClass() { Hoge = 33, Huga = 99 };
        mc.ToString().Should().Be("Hoge:33, Huga:99");
    }
}

[GenerateToString]
public partial class MyClass
{
    public int Hoge { get; set; }
    public int Huga { get; set; }
}

とりあえずこれをテストすればOK、と。なんか生成結果が更新されてない気がして無限にTestがこけるんだが?という時は、例によってVisual Studio再起動です。

Source Generatorのいいところとして、生成コードへのステップ実行も可能ということで、なんかよーわからん挙動だわーという時はデバッガでどんどん突っ込んでいくといいでしょう。

image

正常に動くケースはこれで概ねいいんですが、Analyzerとしてコンパイルエラーを出すようなケースをテストしたい場合は、もう一捻り必要です。対応としては CSharpGeneratorDriver というのが標準で用意されていて、それにソースコード渡せばいい、という話なのですが、少し手間なのは、元になるCSharpCompilationを作らなければいけない、というところで。この辺もよしなに見てくれる便利ジェネレーターユニットテストヘルパーライブラリみたいなのもありますが、原理原則を知るためにも、ここは手で書いてみましょう。

というわけで、こういうヘルパーを用意してみます。

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;

namespace MemoryPack.Tests.Utils;

public static class CSharpGeneratorRunner
{
    static Compilation baseCompilation = default!;

    [ModuleInitializer]
    public static void InitializeCompilation()
    {
        // running .NET Core system assemblies dir path
        var baseAssemblyPath = Path.GetDirectoryName(typeof(object).Assembly.Location)!;
        var systemAssemblies = Directory.GetFiles(baseAssemblyPath)
            .Where(x =>
            {
                var fileName = Path.GetFileName(x);
                if (fileName.EndsWith("Native.dll")) return false;
                return fileName.StartsWith("System") || (fileName is "mscorlib.dll" or "netstandard.dll");
            });

        var references = systemAssemblies
            // .Append(typeof(Foo).Assembly.Location) // 依存DLLがある場合はそれも追加しておく
            .Select(x => MetadataReference.CreateFromFile(x))
            .ToArray();

        var compilation = CSharpCompilation.Create("generatortest",
            references: references,
            options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));

        baseCompilation = compilation;
    }

    public static Diagnostic[] RunGenerator(string source, string[]? preprocessorSymbols = null, AnalyzerConfigOptionsProvider? options = null)
    {
        // NET 7 + C# 11
        if (preprocessorSymbols == null)
        {
            preprocessorSymbols = new[] { "NET7_0_OR_GREATER" };
        }
        var parseOptions = new CSharpParseOptions(LanguageVersion.CSharp11, preprocessorSymbols: preprocessorSymbols);
        var driver = CSharpGeneratorDriver.Create(new SourceGeneratorSample.SampleGenerator()).WithUpdatedParseOptions(parseOptions);
        if (options != null)
        {
            driver = (Microsoft.CodeAnalysis.CSharp.CSharpGeneratorDriver)driver.WithUpdatedAnalyzerConfigOptions(options);
        }

        var compilation = baseCompilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(source, parseOptions));

        driver.RunGeneratorsAndUpdateCompilation(compilation, out var newCompilation, out var diagnostics);

        // combine diagnostics as result.
        var compilationDiagnostics = newCompilation.GetDiagnostics();
        return diagnostics.Concat(compilationDiagnostics).ToArray();
    }
}

CSharpGeneratorDriver.Create して AddSyntaxTrees して RunGeneratorsAndUpdateCompilation して diagnostics を取り出す。というだけなのですが、Compilationを作るところに癖があります、というか Compilation に渡すDLLをかき集めるのが微妙に面倒くさいです。net7の依存関係のDLLを全部持ってくる、とかが一発でできないんですね。素直に typeof().Assembly.Location だけだと全然持ってこれないため、ディレクトリから漁ってくるという処理をいれています。

これを使ってテスト書くと、こんな感じでしょうか。

    [Fact]
    public void ERROR_SAMPLE001()
    {
        // C#11のRaw String Literals本当に便利
        var result = CSharpGeneratorRunner.RunGenerator("""
using SourceGeneratorSample;

[GenerateToString]
public partial class MyClass
{
    public int Hoge { get; set; }
    public int Huga { get; set; }

    public override string ToString()
    {
        return "hogemoge";
    }
}
""");

        result.Length.Should().Be(1);
        result[0].Id.Should().Be("SAMPLE001");
    }

厳密にやるなら、エラーの波線をどこに敷いているかのチェックをすべし、みたいな話もあるのですが、私的にはまぁ面倒くさいのでちゃんと狙ったエラーが出せてるかどうかをDiangnositcsのIdを拾うぐらいでいいかな、みたいな感じでやってます。

NuGetパッケージング

というわけで dotnet pack するわけですが、 追加でコンフィグ仕込む必要があります。

<Project Sdk="Microsoft.NET.Sdk">

	<PropertyGroup>
		<TargetFramework>netstandard2.0</TargetFramework>
		<ImplicitUsings>enable</ImplicitUsings>
		<Nullable>enable</Nullable>
		<LangVersion>11</LangVersion>

		<!-- NuGetPackのための追加をもりもり -->
		<IsRoslynComponent>true</IsRoslynComponent>
		<AnalyzerLanguage>cs</AnalyzerLanguage>
		<IncludeBuildOutput>false</IncludeBuildOutput>
		<DevelopmentDependency>true</DevelopmentDependency>
		<IncludeSymbols>false</IncludeSymbols>
		<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
	</PropertyGroup>

	<ItemGroup>
		<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.3.1" />
	</ItemGroup>

	<!-- 出力先を analyzers/dotnet/cs にする -->
	<ItemGroup>
		<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
	</ItemGroup>
</Project>

外部依存DLLがいたりすると(例えばJSON .NET使いたいとか!)もう少し面倒くさくなるので、外部依存DLLは使わないようにしましょう!というのが第一原則になります。どうしても使いたい場合は頑張ってください。

Unity対応

Incremental Generatorを前提にするなら、特に通常の.NET版とやることは変わりません。Unityのマニュアル通りにビルド済みdllを配置してRoslynAnalyzerとしてLabel設定したmetaを置いておけば、UPMのgit参照とかでも、特に何もせずに自動で認識されます。dllの配置場所はUnityの公式のジェネレーター(例えば com.unity.properties とか)がRuntime配下にいるので、Editorではなく、Runtime側に配置することとしています。

なお、Unity用限定のSource Generatorを作る場合でも、通常の .NET のライブラリとして扱い、普通に .NET ライブラリとしての開発環境やユニットテストプロジェクトを作ったほうが良いでしょう。普通に作るにもかなり環境をしっかり作らないと大変なので、Unity限定だから!みたいな気持ちで挑むとしんどみが爆発します。

また、ライブラリの配布としてNuGetForUnityを使ってNuGet経由で落としてきた場合は自動的にRoslynAnalyzerのLabelを張ってくれます、便利!

真のIncremental Generator

そして最後に、ではないですが重要なことがあり、Incremental Generatorは単純に作ってもIncrementalにはなりません。各ステップで通過するオブジェクトのEqualsを前回の生成結果と比較して、合致してれば同一生成結果扱いと判定して後続のステップをスキップする、という仕様になっています。

なので、ここで正しくEqualsが処理できないと、一文字打つたびに最終ステップに進み続けて毎回生成処理までしてしまうため、重たいSource Generatorが出来上がります。

そして単純に作ると、正しくEqualsが処理できない場合が多いです。ContextやCompilationは、毎回別物になるため、それが含まれていれば、それだけで比較は失敗します。TypeSymbolやSyntaxTreeも、同じようでいて別物扱いになります。そこで、取るべき戦略は、早い段階で実際にパース処理までしてしまって、プリミティブのみで構築されたrecordに変換することです。

例えばConsoleAppFrameworkでは以下のようなrecordを用意して

public record class Command
{
    public required bool IsAsync { get; init; }
    public required bool IsVoid { get; init; }
    public required string Name { get; init; }
    public required EquatableArray<CommandParameter> Parameters { get; init; }
    public required string Description { get; init; }
    public required MethodKind MethodKind { get; init; }
    public required DelegateBuildType DelegateBuildType { get; init; }
}

SytnaxProviderを抜けた段階でrecordを生成しています。

var runSource = context.SyntaxProvider
    .CreateSyntaxProvider((node, ct) =>
    {
        // このラムダ式内は超高頻度で呼び出されるため軽量なフィルタリングを心掛ける、ToString()などのアロケーションも禁止!
        if (node.IsKind(SyntaxKind.InvocationExpression))
        {
            var invocationExpression = (node as InvocationExpressionSyntax);
            if (invocationExpression == null) return false;

            var expr = invocationExpression.Expression as MemberAccessExpressionSyntax;
            if ((expr?.Expression as IdentifierNameSyntax)?.Identifier.Text == "ConsoleApp")
            {
                var methodName = expr?.Name.Identifier.Text;
                if (methodName is "Run" or "RunAsync")
                {
                    return true;
                }
            }

            return false;
        }

        return false;
    }, (context, ct) =>
    {
        // こちらではパース処理をしてEmit時に使う構造だけを抽出する
        // Diagnosticsとの絡みもあるので、各自工夫が必要
        var reporter = new DiagnosticReporter();
        var node = (InvocationExpressionSyntax)context.Node;
        var wellknownTypes = new WellKnownTypes(context.SemanticModel.Compilation);
        var parser = new Parser(reporter, node, context.SemanticModel, wellknownTypes, DelegateBuildType.MakeCustomDelegateWhenHasDefaultValueOrTooLarge, []);
        var isRunAsync = (node.Expression as MemberAccessExpressionSyntax)?.Name.Identifier.Text == "RunAsync";

        var command = parser.ParseAndValidateForRun();
        return new CommanContext(command, isRunAsync, reporter, node); // record CommandContextが上のCommandも持っている
    })
    .WithTrackingName("ConsoleApp.Run.0_CreateSyntaxProvider"); // annotate for IncrementalGeneratorTest

// 上記で生成しているCommanContextが一致している場合は生成不要ということで、Emitの発火はしない
context.RegisterSourceOutput(runSource, EmitConsoleAppRun);

これによりEqualsが正常に働き、同一内容ならばRegisterSourceOutputでのEmit処理まで行かなくなります。

こうしたrecordを使う場合の注意点は二つあり、一つは配列は参照比較で値比較にならないので、値比較になるようなラッパーを用意してあげるといいでしょう。上で上げたEqutableArray<T>は以下のような内容になっています。

public readonly struct EquatableArray<T> : IEquatable<EquatableArray<T>>, IEnumerable<T>
    where T : IEquatable<T>
{
    readonly T[]? array;

    public EquatableArray() // for collection literal []
    {
        array = [];
    }

    public EquatableArray(T[] array)
    {
        this.array = array;
    }

    public static implicit operator EquatableArray<T>(T[] array)
    {
        return new EquatableArray<T>(array);
    }

    public ref readonly T this[int index]
    {
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        get => ref array![index];
    }

    public int Length => array!.Length;

    public ReadOnlySpan<T> AsSpan()
    {
        return array.AsSpan();
    }

    public ReadOnlySpan<T>.Enumerator GetEnumerator()
    {
        return AsSpan().GetEnumerator();
    }

    IEnumerator<T> IEnumerable<T>.GetEnumerator()
    {
        return array.AsEnumerable().GetEnumerator();
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        return array.AsEnumerable().GetEnumerator();
    }

    public bool Equals(EquatableArray<T> other)
    {
        return AsSpan().SequenceEqual(other.AsSpan());
    }
}

もう一つはrecordは全フィールド比較になるため、無視したいデータをパイプライン上に保持できません、が、それだとDiagnosticsもやりづらいし、TypeSymbolなどを持っておきたい場合もあるでしょう。そこでrecordのプロパティとして保持できる、が、イコール比較では無視するようなラッパーも用意すると、回避策としては良いかもしれません。

public readonly struct IgnoreEquality<T>(T value) : IEquatable<IgnoreEquality<T>>
{
    public readonly T Value => value;

    public static implicit operator IgnoreEquality<T>(T value)
    {
        return new IgnoreEquality<T>(value);
    }

    public static implicit operator T(IgnoreEquality<T> value)
    {
        return value.Value;
    }

    public bool Equals(IgnoreEquality<T> other)
    {
        // always true to ignore equality check.
        return true;
    }
}

このあたりを駆使することで真にIncrementalなGeneratorを作ることが出来ます!また、大事なこととしてはIncrementalで動作しているのかどうかをテストで確認することです。WithTrackingNameというのがその助けになりますが、TrackingNameを取り出すためには、テスト用のGeneratorRunner側でも一工夫いります。そのことについてはneue cc - ConsoleAppFramework v5 - ゼロオーバーヘッド・Native AOT対応のC#用CLIフレームワークに具体例と共に詳しく書いてあるので、そちらを参照ください。

まとめ

C#に最初にこの手の機構が登場したのは2014年、 VS2015のRoslynでCode Analyzerを自作する(ついでにUnityコードも解析する) といった記事も書いていたのですが、まぁ正直めっっっちゃくちゃ作りづらかったんですね。

で、現代、この2022年のSource Generator開発はめっっっっちゃくちゃ作りやすくなってます。もちろん、Roslyn自体の知識が必要で、そしてRoslynはドキュメントが無なので、どちらかというとIntelliSenseから勘をどう働かせるかという勝負になっていて、それはそれで大変ではあるのですが、しかし本当に作りやすくなったな、と思います。もちろんそしてIL.Emitよりも遥かに作りやすいし、パフォーマンスも良い。もうEmitの時代は終わりです。もはや黒魔術を誇る時代でもないのです!動的コード生成の民主化!

というわけで、どしどしコード生成していきましょう……!私も今温めてるアイディアが3つぐらいあるので、どんどんリリースしていきたいと思ってます。

C# 11 による世界最速バイナリシリアライザー「MemoryPack」の作り方

と題して、.NET Conf 2022 Recap Event Tokyo というイベントで話してきました。

今回は久々の(数年ぶりの!)オフライン登壇イベントということで、なんだか新鮮な気分で、そして実際、オンライン登壇よりも目の前にオーディエンスがいたほうがいいなぁという思いを新たに。事前レコーディングやオンライン登壇だと、どうしてもライブ感のない、冷めた感じになっちゃうな、と。セッション単体の完成度で言ったら何度も取り直して完璧に仕上げた事前録画のほうがいい、かもしれませんが、でもそういうもんじゃあないかなあ、と。スタジオアルバムとライブアルバムみたいなもんですね。そしてスタジオアルバムに相当するのは念入りに書かれたブログ記事とかだったりするので、事前録画のセッションって、なんか中途半端に感じてしまったりはしますね。スタジオライブみたいな。あれってなんかいまいちじゃないですか、そういうことで。

MemoryPackは先程 v1.9.0 をリリースしました!日刊MemoryPackか?というぐらいに更新ラッシュをしていたのですが、バグというかは機能追加をめちゃくちゃやってました。性能面で究極のシリアライザーを目指した、というのはセッションスライドのほうにも書かせてもらっていますが、機能面でも究極のシリアライザーを目指しています、ということで、めちゃくちゃやれる幅が広がってます。GitHub Star数も既に1200と、めちゃくちゃ凄い勢いで伸びているので(過去最高の勢いです)、出す前は独自フォーマットのシリアライザーだと、どのぐらいまで使ってもらえるものだろうか?と不安に思ったところもあったのですが、割と自信もって押していける感じです。実際、性能も機能も凄い。

Formatterという名前付けについて

特に誰にも聞かれていないのですが説明しておきたいのが MemoryPackFormatter という名前を。Formatterって正直馴染みがないし(BinaryFormatterかよ?)、 IMemoryPackSerializer にしようかな、と当初は考えていたのですが最終的には(MessagePack for C#と同じの)Formatterに落ち着きました。理由は、エントリーポイントである MemoryPackSerializer と紛らわしいんですよね。 MemoryPackFormatterは自作でもしない限りは表に出て来ないし、上級向けのオプションなので、すっきりと名前で区別がついたほうが良いかな、という感じでつけてます。System.Text.Jsonの場合は JsonSerializerJsonConverterという分類で、同じような感じです。

候補になる名前としてはSerializerFormatterConverterEncoderCodecという感じでしょうか。単純で当たり前のチョイスのようでいて、ユーザーがなるべく悩まず直感的に理解できるように、しっかり考えて悩みながらつけてるんですよということで。それで出来上がった名前が、単純で当たり前のように思ってもらえれば正解なわけです。

イベント2

久々のオンフライン登壇!だったのですが、こういうのは始まると続くもので、今月は12/14に bitFlyer.C#/Azure 01というイベントに、Azureじゃないほうの枠として登壇する予定です。「AlterNatsにみる .NET 7世代のハイパフォーマンスSocketプログラミング技法」という内容ですがAlterNatsというよりかは、最新のC#でハイパフォーマンスなSocketプログラミングをどうすればいいか、ということに重点を置いた内容になってますので、C#の最適化に興味ある方は是非是非来てください。まだ席空いてますので……!

MemoryPackにみる .NET 7/C# 11世代のシリアライザー最適化技法

MemoryPackという、C#に特化することで従来のシリアライザーとは比較にならないほどのパフォーマンスを発揮する新しいシリアライザーを新しく開発しました。

高速なバイナリシリアライザーである MessagePack for C# と比較しても、通常のオブジェクトでも数倍、データが最適な場合は50~100倍ほどのパフォーマンスにもなります。System.Text.Jsonとでは全く比較になりません。当初は .NET 7 限定としてリリースしましたが、現在は .NET Standard 2.1(.NET 5, 6)やUnity、そしてTypeScriptにも対応しています。

シリアライザーのパフォーマンスは「データフォーマットの仕様」と「各言語における実装」の両輪で成り立っています。例えば、一般的にはバイナリフォーマットのほうがテキストフォーマット(JSONとか)よりも有利ですが、バイナリシリアライザーより速いJSONシリアライザといったものは有り得ます(Utf8Jsonでそれを実証しました)。では最速のシリアライザーとは何なのか?というと、仕様と実装を突き詰めれば、真の最速のシリアライザーが誕生します。

私は、今もですが、長年MessagePack for C#の開発とメンテナンスをしてきました。MessagePack for C#は .NET の世界で非常に成功したシリアライザーで、4000以上のGitHub Starと、Visual Studio内部や、SignalR, Blazor Serverのバイナリプロトコルなど、Microsoftの標準プロダクトにも採用されています。また、この5年間で1000近くのIssueをさばいてきました。そのため、シリアライザーの実装の詳細からユーザーのリアルなユースケース、要望、問題などを把握しています。Roslynを使用したコードジェネレーターによるAOT対応にも当初から取り組み、特にAOT環境(IL2CPP)であるUnityで実証してきました。更にMessagePack for C#以外にも ZeroFormatter(独自フォーマット)、Utf8Json(JSON) といった、これも多くのGitHub Starを獲得したシリアライザーを作成してきているため、異なるフォーマットの性能特性についても深く理解しています。シリアライザーを活用するシチュエーションにおいても、RPCフレームワークMagicOnionの作成、インメモリデータベースMasterMemory、そして複数のゲームタイトルにおけるクライアント(Unity)/サーバー、両方の実装に関わってきました。

ようするところ私は .NET のシリアライザー実装について最も詳しい人間の一人であり、MemoryPackはその知見がフルに詰め込まれた、なおかつ、 .NET 7 / C# 11という最新のランタイム/言語機能を使い倒したライブラリになっています。そりゃ速くて当然で異論はないですよね?

というだけではアレなので、実際なんで速いのかというのを理屈で説明していきます……!きっと納得してもらえるはず! C#の最適化のTipsとしてもどうぞ。

Incremental Source Generator

MemoryPackでは .NET 5/C# 9.0 から追加された Source Generator、それも .NET 6 で強化された Incremental Source Generatorを全面的に採用しています。使い方的には、対象型をpartialに変更する程度で、MessagePack for C#とあまり変わりません(というか極力同じAPIになるように揃えました)。

using MemoryPack;

[MemoryPackable]
public partial class Person
{
    public int Age { get; set; }
    public string Name { get; set; }
}

// usage
var v = new Person { Age = 40, Name = "John" };

var bin = MemoryPackSerializer.Serialize(v);
var val = MemoryPackSerializer.Deserialize<Person>(bin);

Source Generatorの最大の利点はAOTフレンドリーであることで、従来行っていたIL.Emitによる動的コード生成をせずとも、リフレクションを使用しない、各型に最適化されたシリアライザーコードを自動生成しています。それによりUnityのIL2CPPなどでも安全に動作させることが可能です。

MessagePack for C#では外部ツール(mpc.exe)経由でコード生成することでAOTセーフなシリアライズ処理を実現していましたが、言語機能と統合されたことによって、煩わしい生成プロセス不要で、自然な書き心地のまま高速なシリアライズ処理を可能にしました。

なお、Unity版の場合は言語/コンパイラバージョンの都合上、Incremental Source Generatorではなくて、古いSource Generatorを採用しています。

C#のためのバイナリ仕様

キャッチコピーは「Zero encoding」ということで、エンコードしないから速いんだ!という理論を打ち出しています。奇妙に思えて、実のところ別に特殊な話をしているわけではなくて、例えばRustのメジャーなバイナリシリアライザーであるbincodeなども似通った仕様を持っています。FlatBuffersも、without parsingな実装のために、メモリデータに近い内容を読み書きします。ただしMemoryPackはFlatBuffersなどと違い、特別な型を必要としない汎用的なシリアライザーであり、POCOに対してのシリアライズ/デシリアライズを行うものです。また、スキーマのメンバー追加へのバージョニング耐性やポリモーフィズムサポート(Union)も持ちます。さすがにメモリダンプしてるだけ、では全く実用にならないわけで、一般的なシリアライザーとして使えるための仕様として整えてあります。

varint encoding vs fixed

Int32は4バイトですが、例えばJSONでは数値を文字列として、1バイト~11バイト(例えば 1 であったり -2147483648 であったり)の可変長なエンコーディングが施されます。バイナリフォーマットでも、サイズの節約のために1~5バイトの可変長にエンコードされる仕様を持つものが多くあります。例えばProtocol Buffersの数値型は、値を7ビットに、後続があるかないかのフラグを1ビットに格納する可変長整数エンコーディングになっています(varint)。これにより数値が小さければ小さいほど、バイト数が少なくなります。逆にワーストケースでは本来の4バイトより大きい5バイトに膨れることになります。とはいえ現実的には小さい数値のほうが圧倒的に頻出するはずなので、とても理にかなった方式です。MessagePackCBORも同じように、小さい数値では最小で1バイト、大きい場合は最大5バイトになる可変長エンコーディングで処理されます。

つまり、固定長の場合よりも余計な処理が走ることになります。具体的なコードで比較してみましょう。可変長はprotobufで使われるvarint + ZigZagエンコーディング(負数と正数をまとめる)です。

// 固定長の場合
static void WriteFixedInt32(Span<byte> buffer, int value)
{
    ref byte p = ref MemoryMarshal.GetReference(buffer);
    Unsafe.WriteUnaligned(ref p, value);
}

// 可変長の場合
static void WriteVarInt32(Span<byte> buffer, int value) => WriteVarInt64(buffer, (long)value);

static void WriteVarInt64(Span<byte> buffer, long value)
{
    ref byte p = ref MemoryMarshal.GetReference(buffer);

    ulong n = (ulong)((value << 1) ^ (value >> 63));
    while ((n & ~0x7FUL) != 0)
    {
        Unsafe.WriteUnaligned(ref p, (byte)((n & 0x7f) | 0x80));
        p = ref Unsafe.Add(ref p, 1);
        n >>= 7;
    }
    Unsafe.WriteUnaligned(ref p, (byte)n);
}

固定長は、つまりC#のメモリをそのまま書き出している(Zero encoding)わけで、さすがにどう見ても固定長のほうが速いでしょう。

このことは配列に適用した場合、より顕著になります。

// https://sharplab.io/
Inspect.Heap(new int[]{ 1, 2, 3, 4, 5 });

image

C#のstructの配列は、データが直列に並びます。この時、structが参照型を持っていない場合(unmanaged type)は、データが完全にメモリ上に並んでいることになります。MessagePackとMemoryPackでコードでシリアライズ処理を比較してみましょう。

// 固定長の場合(実際には長さも書き込みます)
void Serialize(int[] value)
{
    // サイズが算出可能なので事前に一発で確保
    var size = (sizeof(int) * value.Length) + 4;
    EnsureCapacity(size);

    // 一気にメモリコピー
    MemoryMarshal.AsBytes(value.AsSpan()).CopyTo(buffer);
}

// 可変長の場合
void Serialize(int[] value)
{
    foreach (var item in value)
    {
        // サイズが不明なので都度バッファサイズのチェック
        EnsureCapacity(); // if (buffer.Length < writeLength) Resize();
        // 1要素毎に可変長エンコード
        WriteVarInt32(item);
    }
}

固定長の場合は、多くのメソッド呼び出しを省いて、メモリコピー一発だけで済ませることが可能です。

C#の配列はintのようなプリミティブ型だけではなく、これは複数のプリミティブを持ったstructでも同様の話で、例えば(float x, float y, float z)を持つVector3の配列の場合は、以下のようなメモリレイアウトになります。

image

float(4バイト)はMessagePackにおいて、固定長で5バイトです。追加の1バイトは、その値が何の型(IntなのかFloatなのかStringなのか...)を示す識別子が先頭に入ります。具体的には[0xca, x, x, x, x]といったように。いわばタグ付与エンコーディングを行っているわけです。MemoryPackのフォーマットは識別子を持たないため、4バイトをそのまま書き込みます。

ベンチマークで50倍の差だった、Vector3[10000]で考えてみましょう。

// 以下の型がフィールドにあるとする
// byte[] buffer
// int offset

void SerializeMemoryPack(Vector3[] value)
{
    // どれだけ複雑だろうとコピー一発で済ませられる
    var size = Unsafe.SizeOf<Vector3>() * value.Length;   
    if ((buffer.Length - offset) < size)
    {
        Array.Resize(ref buffer, buffer.Length * 2);
    }
    MemoryMarshal.AsBytes(value.AsSpan()).CopyTo(buffer.AsSpan(0, offset))
}

void SerializeMessagePack(Vector3[] value)
{
    // 配列の長さ x フィールドの数だけ繰り返す
    foreach (var item in value)
    {
        // X
        {
            // EnsureCapacity
            if ((buffer.Length - offset) < 5)
            {
                // 実際にはResizeではなくてbufferWriter.Advance()です
                Array.Resize(ref buffer, buffer.Length * 2);
            }
            var p = MemoryMarshal.GetArrayDataReference(buffer);
            Unsafe.WriteUnaligned(ref Unsafe.Add(ref p, offset), (byte)0xca);
            Unsafe.WriteUnaligned(ref Unsafe.Add(ref p, offset + 1), item.X);
            offset += 5;
        }
        // Y
        {
            if ((buffer.Length - offset) < 5)
            {
                Array.Resize(ref buffer, buffer.Length * 2);
            }
            var p = MemoryMarshal.GetArrayDataReference(buffer);
            Unsafe.WriteUnaligned(ref Unsafe.Add(ref p, offset), (byte)0xca);
            Unsafe.WriteUnaligned(ref Unsafe.Add(ref p, offset + 1), item.Y);
            offset += 5;
        }
        // Z
        {
            if ((buffer.Length - offset) < 5)
            {
                Array.Resize(ref buffer, buffer.Length * 2);
            }
            var p = MemoryMarshal.GetArrayDataReference(buffer);
            Unsafe.WriteUnaligned(ref Unsafe.Add(ref p, offset), (byte)0xca);
            Unsafe.WriteUnaligned(ref Unsafe.Add(ref p, offset + 1), item.Z);
            offset += 5;
        }
    }
}

MessagePackだと30000回のメソッド呼び出しが必要なところが(そしてそのメソッド内では、書き込みメモリが足りているかのチェックと、書き終わった後のオフセットの追加が(愚直に処理する場合)都度必要になる)、一回のメモリコピーだけになります。こうなると、処理時間が文字通り桁違いに変わってきて、冒頭のグラフの50倍~100倍の高速化の理由はここにあります。

もちろん、デシリアライズ処理もコピー一発になります。

// MemoryPackのデシリアライズ、コピーするだけ。
Vector3[] DeserializeMemoryPack(ReadOnlySpan<byte> buffer, int size)
{
    var dest = new Vector3[size];
    MemoryMarshal.Cast<byte, Vector3>(buffer).CopyTo(dest);
    return dest;
}

// ループで都度floatの読み取りが必要
Vector3[] DeserializeMessagePack(ReadOnlySpan<byte> buffer, int size)
{
    var dest = new Vector3[size];
    for (int i = 0; i < size; i++)
    {
        var x = ReadSingle(buffer);
        buffer = buffer.Slice(5);
        var y = ReadSingle(buffer);
        buffer = buffer.Slice(5);
        var z = ReadSingle(buffer);
        buffer = buffer.Slice(5);
        dest[i] = new Vector3(x, y, z);
    }
    return dest;
}

この辺は、MessagePackのフォーマットそのものの限界のため、仕様に従う限りは、圧倒的な速度差はどうやっても覆せません。ただしMessagePackの場合はext format familyという仕様があり、独自仕様としてこれらの配列だけ特別扱いして処理する(MessagePackとしての互換性はなくなりますが)ことも許されています。実際、MessagePack for C#ではUnity向けに UnsafeBlitResolver という、上記のような処理をする特別な拡張オプションを用意していました。

しかし恐らく、ほとんどの人が使っていないでしょう。別に普通にシリアライズできるものを、言語間運用製を壊す、C#だけの独自拡張オプションをわざわざ使おうとは、中々思わない、というのは分かります。そこがまた歯痒かったんですよね、明らかに遅いのに、明らかに速くできるのに、だからせっかく用意したのに、デフォルトではない限り使われない、しかしデフォルトは絶対に仕様に従うべきであり……。

string処理の最適化

MemoryPackではStringに関して、2つの仕様を持っています。UTF8か、UTF16か、です。C#のstringはUTF16のため、UTF16のままシリアライズすると、UTF8へのエンコード/デコードコストを省くことができます。

void EncodeUtf16(string value)
{
    var size = value.Length * 2;
    EnsureCapacity(size);

    // char[] -> byte[] -> Copy
    MemoryMarshal.AsBytes(value.AsSpan()).CopyTo(buffer);
}

string DecodeUtf16(ReadOnlySpan<byte> buffer, int length)
{
    ReadOnlySpan<char> src = MemoryMarshal.Cast<byte, char>(buffer).Slice(0, length);
    return new string(src);
}

ただし、MemoryPackのデフォルトはUTF8です。これは単純にペイロードのサイズの問題で、UTF16だとASCII文字が2倍のサイズになってしまうため、UTF8にしました(なお、日本語の場合はUTF16のほうがむしろ縮まる可能性が高いです)。

UTF8の場合でも、他のシリアライザにはない最適化をしています。

void WriteUtf8MemoryPack(string value)
{
    var source = value.AsSpan();
    var maxByteCount = (source.Length + 1) * 3;
    EnsureCapacity(maxByteCount);
    Utf8.FromUtf16(source, dest, out var _, out var bytesWritten, replaceInvalidSequences: false);
}

void WriteUtf8StandardSerializer(string value)
{
    var maxByteCount = Encoding.UTF8.GetByteCount(value);
    EnsureCapacity(maxByteCount);
    Encoding.UTF8.GetBytes(value, dest);
}

var bytes = Encoding.UTF8.GetBytes(value); は論外です、stringの書き込みで byte[] のアロケーションは許されません。しかし、多くのシリアライザはで使われている Encoding.UTF8.GetByteCount も避けるべきです、UTF8は可変長のエンコーディングであり、 GetByteCount は正確なエンコード後のサイズを算出するために、文字列を完全に走査します。つまり GetByteCount -> GetBytes は文字列を二度も走査することになります。

通常シリアライザーは余裕を持ったバッファの確保が許されています。そこでMemoryPackではUTF8エンコードした場合のワーストケースである文字列長の3倍の確保にすることで、二度の走査を避けています。

デコードの場合は、更に特殊な最適化を施しています。

string ReadUtf8MemoryPack(int utf16Length, int utf8Length)
{
    unsafe
    {
        fixed (byte* p = &buffer)
        {
            return string.Create(utf16Length, ((IntPtr)p, utf8Length), static (dest, state) =>
            {
                var src = MemoryMarshal.CreateSpan(ref Unsafe.AsRef<byte>((byte*)state.Item1), state.Item2);
                Utf8.ToUtf16(src, dest, out var bytesRead, out var charsWritten, replaceInvalidSequences: false);
            });
        }
    }
}

string ReadStandardSerialzier(int utf8Length)
{
    return Encoding.UTF8.GetString(buffer.AsSpan(0, utf8Length));
}

通常、byte[]からstringを取り出すには Encoding.UTF8.GetString(buffer) を使います。MessagePack for C#でもそうです。しかし、改めて、UTF8は可変長のエンコーディングであり、そこからUTF16としての長さは分かりません。そのためUTF8.GetStringだと、stringに変換するためのUTF16としての長さ算出が必要なので、中では文字列を二度走査しています。擬似コードでいうと

var length = CalcUtf16Length(utf8data);
var str = String.Create(length);
Encoding.Utf8.DecodeToString(utf8data, str);

といったことになっています。一般的なシリアライザの文字列フォーマットはUTF8であり、当たり前ですがUTF16へのデコードなどといったことは考慮されていないため、C#の文字列としての効率的なデコードのためにUTF16の長さが欲しくても、データの中にはありません。

しかしMemoryPackの場合はC#を前提においた独自フォーマットのため、文字列はUTF16-LengthとUTF8-Lengthの両方(8バイト)をヘッダに記録しています。そのため、String.Create<TState>(Int32, TState, SpanAction<Char,TState>)Utf8.ToUtf16の組み合わせにより、最も効率的なC# Stringへのデコードを実現しました。

ペイロードサイズについて

MemoryPackは固定長エンコーディングのため可変長エンコーディングに比べてどうしてもサイズが膨らむ場合があります。特にlongを可変長エンコードすると最小1バイトになるので、固定長8バイトに比べると大きな差となり得ます。しかし、MemoryPackはフィールド名を持たない(JSONやMessagePackのMap)ことやTagがないことなどから、JSONよりも小さいのはもちろん、可変長エンコーディングを持つprotobufやMsgPackと比較しても大きな差となることは滅多にないと考えています。

データは別に整数だけじゃないので、真にサイズを小さくしたければ、圧縮(LZ4やZStandardなど)を考えるべきですし、圧縮してしまえばあえて可変長エンコーディングする意味はほぼなくなります。より特化して小さくしたい場合は、列指向圧縮にしたほうがより大きな成果を得られる(Apache Parquetなど)ので、現代的には可変長エンコーディングを採用するほうがデメリットは大きいのではないか?と私は考えています。冒頭でも少し紹介しましたが、実際Rustのシリアライザーbincodeのデフォルトは固定長だったりします。

MemoryPackの実装と統合された効率的な圧縮については、現在BrotliEncode/Decodeのための補助クラスを標準で用意しています。しかし、性能を考えるとLZ4やZStandardを使えたほうが良いため、将来的にはそれらの実装も提供する予定です。

.NET 7 / C#11を活用したハイパフォーマンスシリアライザーのための実装

MemoryPackは .NET Standard 2.1向けの実装と .NET 7向けの実装で、メソッドシグネチャが若干異なります。.NET 7向けには、最新の言語機能を活用した、より性能を追求したアグレッシブな実装になっています。

まずシリアライザのインターフェイスは以下のような static abstract membersが活用されています。

public interface IMemoryPackable<T>
{
    // note: serialize parameter should be `ref readonly` but current lang spec can not.
    // see proposal https://github.com/dotnet/csharplang/issues/6010
    static abstract void Serialize<TBufferWriter>(ref MemoryPackWriter<TBufferWriter> writer, scoped ref T? value)
        where TBufferWriter : IBufferWriter<byte>;
    static abstract void Deserialize(ref MemoryPackReader reader, scoped ref T? value);
}

MemoryPackはSource Generatorを採用し、対象型が [MemortyPackable]public partial class Foo であることを要求するため、最終的に対象型は

[MemortyPackable]
partial class Foo : IMemoryPackable
{
    static void IMemoryPackable<Foo>.Serialize<TBufferWriter>(ref MemoryPackWriter<TBufferWriter> writer, scoped ref Foo? value) 
    {
    }
        
    static void IMemoryPackable<Foo>.Deserialize(ref MemoryPackReader reader, scoped ref Foo? value)
    {
    }
}

といったものを生成します。これにより、仮想メソッド経由呼び出しのコストを避けています。

public void WritePackable<T>(scoped in T? value)
    where T : IMemoryPackable<T>
{
    // IMemoryPackableが対象の場合、静的メソッドを直接呼び出しに行く
    T.Serialize(ref this, ref Unsafe.AsRef(value));
}

// 
public void WriteValue<T>(scoped in T? value)
{
    // IMemoryPackFormatter<T> を取得し、仮想メソッド経由で Serialize を呼び出す
    var formatter = MemoryPackFormatterProvider.GetFormatter<T>();
    formatter.Serialize(ref this, ref Unsafe.AsRef(value));
}

また、MemoryPackWriter/MemoryPackReader では ref field を活用しています。

public ref struct MemoryPackWriter<TBufferWriter>
    where TBufferWriter : IBufferWriter<byte>
{
    ref TBufferWriter bufferWriter;
    ref byte bufferReference;
    int bufferLength;

ref byte bufferReference, int bufferLength の組み合わせは、つまりSpan<byte>のインライン化です。また、TBufferWriterref TBufferWriterとして受け取ることにより、ミュータブルなstruct TBufferWriter : IBufferWrite<byte>を安全に受け入れて呼び出すことができるようになりました。

全ての型への最適化

例えばコレクションは IEnumerable<T> としてシリアライズ/デシリアライズすることで実装の共通化が可能ですが、MemoryPackでは全ての型に対して個別の実装をするようにしています。単純なところでは List<T>を処理するのに

public void Serialize(ref MemoryPackWriter writer, IEnumerable<T> value)
{
    foreach(var item in source)
    {
        writer.WriteValue(item);
    }
}

public void Serialize(ref MemoryPackWriter writer, List<T> value)
{
    foreach(var item in source)
    {
        writer.WriteValue(item);
    }
}

この2つでは全然性能が違います。IEnumerable<T>へのforeachは IEnumerator<T> を取得しますが、List<T>へのforeachは struct List<T>.Enumerator という最適化された専用の構造体のEnumeratorを取得するからです。

しかし、もっと最適化する余地があります。

public sealed class ListFormatter<T> : MemoryPackFormatter<List<T?>>
{
    public override void Serialize<TBufferWriter>(ref MemoryPackWriter<TBufferWriter> writer, scoped ref List<T?>? value)
    {
        if (value == null)
        {
            writer.WriteNullCollectionHeader();
            return;
        }

        writer.WriteSpan(CollectionsMarshal.AsSpan(value));
    }
}

// MemoryPackWriter.WriteSpan
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void WriteSpan<T>(scoped Span<T?> value)
{
    if (!RuntimeHelpers.IsReferenceOrContainsReferences<T>())
    {
        DangerousWriteUnmanagedSpan(value);
        return;
    }

    var formatter = GetFormatter<T>();
    WriteCollectionHeader(value.Length);
    for (int i = 0; i < value.Length; i++)
    {
        formatter.Serialize(ref this, ref value[i]);
    }
}

// MemoryPackWriter.DangerousWriteUnmanagedSpan
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void DangerousWriteUnmanagedSpan<T>(scoped Span<T> value)
{
    if (value.Length == 0)
    {
        WriteCollectionHeader(0);
        return;
    }

    var srcLength = Unsafe.SizeOf<T>() * value.Length;
    var allocSize = srcLength + 4;

    ref var dest = ref GetSpanReference(allocSize);
    ref var src = ref Unsafe.As<T, byte>(ref MemoryMarshal.GetReference(value));

    Unsafe.WriteUnaligned(ref dest, value.Length);
    Unsafe.CopyBlockUnaligned(ref Unsafe.Add(ref dest, 4), ref src, (uint)srcLength);

    Advance(allocSize);
}

まず、そもそも現代では List<T> の列挙は CollectionsMarshal.AsSpan(value) 経由で、Span<T>を取得して、それを列挙するのが最適です。それによってEnumerator経由というコストすら省くことが可能です。更に、Span<T>が取得できているなら、List<int>List<Vector3>の場合にコピーのみで処理することもできます。

Deserializeの場合にも、興味深い最適化があります。まず、MemoryPackのDeserializeは ref T? value を受け取るようになっていて、valueがnullの場合は内部で生成したオブジェクトを(普通のシリアライザと同様)、valueが渡されている場合は上書きするようになっています。これによってDeserialize時の新規オブジェクト生成というアロケーションをゼロにすることが可能です。コレクションの場合も、List<T>の場合はClear()を呼び出すことで再利用します。

その上で、特殊なSpanの呼び出しをすることにより、 List<T>.Add すら避けることに成功しました。

public sealed class ListFormatter<T> : MemoryPackFormatter<List<T?>>
{
    public override void Deserialize(ref MemoryPackReader reader, scoped ref List<T?>? value)
    {
        if (!reader.TryReadCollectionHeader(out var length))
        {
            value = null;
            return;
        }

        if (value == null)
        {
            value = new List<T?>(length);
        }
        else if (value.Count == length)
        {
            value.Clear();
        }

        var span = CollectionsMarshalEx.CreateSpan(value, length);
        reader.ReadSpanWithoutReadLengthHeader(length, ref span);
    }
}

internal static class CollectionsMarshalEx
{
    /// <summary>
    /// similar as AsSpan but modify size to create fixed-size span.
    /// </summary>
    public static Span<T?> CreateSpan<T>(List<T?> list, int length)
    {
        list.EnsureCapacity(length);

        ref var view = ref Unsafe.As<List<T?>, ListView<T?>>(ref list);
        view._size = length;
        return view._items.AsSpan(0, length);
    }

    // NOTE: These structure depndent on .NET 7, if changed, require to keep same structure.

    internal sealed class ListView<T>
    {
        public T[] _items;
        public int _size;
        public int _version;
    }
}

// MemoryPackReader.ReadSpanWithoutReadLengthHeader
public void ReadSpanWithoutReadLengthHeader<T>(int length, scoped ref Span<T?> value)
{
    if (length == 0)
    {
        value = Array.Empty<T>();
        return;
    }

    if (!RuntimeHelpers.IsReferenceOrContainsReferences<T>())
    {
        if (value.Length != length)
        {
            value = AllocateUninitializedArray<T>(length);
        }

        var byteCount = length * Unsafe.SizeOf<T>();
        ref var src = ref GetSpanReference(byteCount);
        ref var dest = ref Unsafe.As<T, byte>(ref MemoryMarshal.GetReference(value)!);
        Unsafe.CopyBlockUnaligned(ref dest, ref src, (uint)byteCount);

        Advance(byteCount);
    }
    else
    {
        if (value.Length != length)
        {
            value = new T[length];
        }

        var formatter = GetFormatter<T>();
        for (int i = 0; i < length; i++)
        {
            formatter.Deserialize(ref this, ref value[i]);
        }
    }
}

new List<T>(capacity)List<T>.EnsurceCapacity(capacity) によって、List<T>の抱える内部の配列のサイズを事前に拡大しておくことが可能です。これにより、都度拡大/コピーが内部で発生することを避けることができます。

その状態で CollectionsMarshal.CreateSpan を使うと、取得できるSpanは、長さ0のものです。なぜなら内部のsizeは変更されていないため、です。もし CollectionMarshals.AsMemoryがあれば、そこからMemoryMarshal.TryGetArrayのコンボで生配列を取得できて良いのですが、残念ながら Span からは元になっている配列を取得する手段がありません。そこで、Unsafe.Asで強引に型の構造を合わせて、List<T>._sizeを弄ることによって、拡大済みの内部配列を取得することができました。

そうすればunamanged型の場合はコピーだけで済ませてしまう最適化や、List<T>.Add(これは都度、配列のサイズチェックが入る)を避けた、Span<T>[index]経由での値の詰め込みが可能になり、従来のシリアライザのデシリアライズよりも遥かに高いパフォーマンスを実現しました。

List<T>への最適化が代表的ではありますが、他にも紹介しきれないほど、全ての型を精査し、可能な限りの最適化をそれぞれに施してあります。

まとめ

なぜ開発しようかと思ったかというと、MessagePack for C#に不満がでてきたから、です。残念ながら .NET「最速」とはいえないような状況があり、その理由としてバイナリ仕様が足を引っ張っているため、改善するのにも限界があることには随分前から気づいていました。また、実装面でもIL生成とRoslynを使った外部ツールとしてのコードジェネレーター(mpc)の、二種のメンテナンスがかなり厳しくなってきているということもありました。外部ツールとしてのコードジェネレーターはトラブルの種で、何かと環境によって動かないということが多発していて、Source Generatorにフル対応できるのなら、もはや廃止したいぐらいにも思っていました。

そこに .NET 7/C# 11 の ref fieldやstatic abstract methodを見た時、これをシリアライザー開発に応用したらパフォーマンスの底上げが可能になる、ついでにSource Generator化すれば、いっそIL生成も廃止してSource Generatorに一本化できるのではないか?それならもう、それをMessagPack for C#に適用する前に、パフォーマンス向上に問題のあるバイナリ仕様の限界も無視した、C#のためだけに究極の性能を実現するシリアライザーを作って、本当の最速を実証してしまえばいいのでは?と。

性能特化の実験的シリアライザーではなくて、実用性も重視したシリアライザーであるために、MessagePack for C#での経験も元にして、多くの機能も備えるようにしました。

* .NETのモダンI/O API対応(IBufferWriter<byte>, ReadOnlySpan<byte>, ReadOnlySequence<byte>)
* 既存オブジェクトへの上書きデシリアライズ
* ポリモーフィズムなシリアライズ(Union)
* PipeWriter/Readerを活用したストリーミングシリアライズ/デシリアライズ
* (やや限定的ながらも)バージョニング耐性
* TypeScriptコード生成
* Unity(2021.3)サポート

欠点としては、バージョニング耐性が、仕様上やや貧弱です。詳しくはドキュメントを参照してください。パフォーマンスをやや落としてバージョニング耐性を上げるオプションを追加することは検討しています。また、メモリコピーを多用するので、実行環境が little-endian であることを前提にしています。ただし現代のコンピューターはほぼすべて little-endian であるため、問題にはならないはずです。

パフォーマンスのために特化したstructを作ってメモリコピーする、といったことはC#の最適化のための手段として、そこまで珍しいわけではなく、やったことある人もいるのではないかと思います。そこからすると、あるいはこの記事を読んで、MemoryPackは一見ただのメモリコピーの塊じゃん、みたいな感じがあるかもしれませんが、汎用シリアライザーとして成立させるのはかなり大変で、そこをやりきっているのが新しいところです。

当初実現していなかった .NET 5/6(Standard 2.1)対応やUnity対応は完了したので、今後はMasterMemoryのSource Generator/MemoryPack対応や、MagicOnionのシリアライザ変更対応など、利用できる範囲をより広げることを考えています。Cysharpの C#ライブラリ のエコシステムの中心になると位置づけているので、今後もかなり力入れて成長させていこうと思っていますので、まずは、是非是非試してみてください!

Prev | | Next

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(.NET)
April 2011
|
July 2025

X:@neuecc GitHub:neuecc

Archive