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#ライブラリ のエコシステムの中心になると位置づけているので、今後もかなり力入れて成長させていこうと思っていますので、まずは、是非是非試してみてください!

async/awaitのキャンセル処理やタイムアウトを効率的に扱うためのパターン&プラクティス

async/awaitの鬼門の一つとして、適切なキャンセル処理が挙げられます。別に基本的にはそんな難しいことではなく、CancellationTokenSourceを作る、CanellationTokenを渡す、OperationCanceledExceptionをハンドリングする。というだけの話です。けれど、Tokenに手動でコールバックをRegisterしたときとか、渡す口が空いてないものに無理やりなんとかするときとか、タイムアウトに使った場合の始末とか、ちょっと気の利いた処理をしたいような場面もあり、そうした時にどうすれば良いのか悩むこともあります。

こういうのはパターンと対応さえ覚えてしまえばいい話でもあるので、今回はAlterNatsの実装時に直面したパターンから、「外部キャンセル・タイムアウト・大元のDispose」が複合された状況での処理の記述方法と、適切な例外処理、そして最後にObjectPoolなども交えた効率的なゼロアロケーションでのCancellationTokenSourceのハンドリング手法を紹介します。

CreateLinkedTokenSourceを使ったパターン

何かのClientを実装してみる、ということにしましょう。キャンセル処理の最も単純なパターンは引数の末尾にCancellationTokenを用意して、内部のメソッドにひたすら伝搬させていくことです。きちんと伝搬させていけば、最奥の処理が適切にCancellationTokenをハンドリングしてキャンセル検知時にOperationCanceledExceptionを投げてくれます。CancellationTokenをデフォルト引数にするか、必ず渡す必要があるよう強制するかは、アプリケーションの性質次第です。アプリケーションに近いコードでは強制させるようにしておくと、渡し忘れを避けれるので良いでしょう。

class Client
{
    public async Task SendAsync(CancellationToken cancellationToken = default)
    {
        await SendCoreAsync(cancellationToken);
    }

    async Task SendCoreAsync(CancellationToken cancellationToken)
    {
        // nanika...
    }
}

非同期メソッドのキャンセルはCancellationTokenで処理するのが基本で、別途Cancelメソッドを用意する、といったことはやめておきましょう。実装が余計に複雑化するだけです。CancellationTokenを伝搬させるのが基本であり全てです。

任意のキャンセルの他に、タイムアウト処理を入れたい、というのは特に通信系ではよくあります。async/awaitでのタイムアウトの基本は、タイムアウトもキャンセル処理の一つである、ということです。CancellationTokenSourceにはCancelAfterという一定時間後にCancelを発火させるというメソッドが用意されているので、これを使ってCancellationTokenを渡せば、すなわちタイムアウトになります。

// Disposeすると内部タイマーがストップされるのでリークしない
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromMinutes(1));

await client.SendAsync(cts.Token);

UniTaskではCancelAfterSlimというメソッドが用意されているため、そちらを使うことをお薦めします。Cancelはスレッドプールを使いますが、CancelAfterSlimはPlayerLoop上で動くため、Unityフレンドリーな実装になっています。ただし内部タイマーのストップ手法がCancelAfterSlimの戻り値をDisposeする必要があるというように、実装に若干差異があります。

タイムアウト時間は大抵固定のため、ユーザーに都度CancelAfterを叩かせるというのは、だいぶ使いにくい設計です。そこで、CancelAfterの実行はSendAsyncメソッドの内部で行うことにしましょう。そうした内部のタイムアウト用CancellationTokenと、外部からくるCancellationTokenを合成して一つのCancellationTokenに変換するには、CancellationTokenSource.CreateLinkedTokenSourceが使えます。

class Client
{
    public TimeSpan Timeout { get; }

    public Client(TimeSpan timeout)
    {
        this.Timeout = timeout;
    }

    public async Task SendAsync(CancellationToken cancellationToken = default)
    {
        // 連結された新しいCancellationTokenSourceを作る
        using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        cts.CancelAfter(Timeout);

        await SendCoreAsync(cts.Token);
    }

    // snip...
}

CreateLinkedTokenSourceで生成されたCancellationTourceは連結されたいずれかがCancelされると、生成されたCancellationTokenSource自体もCancelされます。また、それ自体からもCancelが発火できます。

これで完成!なのですが、このままだと例外処理に問題があります。

OperationCanceledExceptionは CancellationTokenというプロパティを持っていて、これを元に呼び出し側はキャンセルの原因を判別することができます。一つ例を出しますが、以下のようにOperationCanceledExceptionをcatchしたうえで、更に判定を入れてコード分岐をかけることがあります。

try
{
    await client.SendAsync(token);
}
catch (OperationCanceledException ex) when (ex.CancellationToken == token)
{
    // Cancelの原因をTokenによって判定できる
}

例外を何も処理せずに全部おまかせでやると、投げられる OperationCanceledException.CancellationToken は CreateLinkedTokenSource で連結したTokenになってしまい、何の意味もない情報ですし、原因の判別に使うこともできません。

また、タイムアウトをOperationCanceledExceptionとして扱ってしまうことも問題です。OperationCanceledExceptionは特殊な例外で、既知の例外であるとしてロギングから抜いたりすることもままあります(例えばウェブサーバーでクライアントの強制切断(リクエスト中にブラウザ閉じたりとか)でキャンセルされることはよくあるけれど、それをいちいちエラーで記録していたらエラー祭りになってしまう)。タイムアウトは明らかな異常であり、そうしたキャンセルとは確実に区別して欲しいし、OperationCanceledExceptionではない例外になって欲しい。

これは .NET のHttpClientでも HttpClient throws TaskCanceledException on timeout #21965 としてIssueがあがり(TaskCanceledExceptionはOperationCanceledExceptionとほぼ同義です)、大激論(121コメントもある!)を巻き起こしました。HttpClientはタイムアウトだろうが手動キャンセルだろうが区別なくTaskCanceledExceptionを投げるのですが、原因は、実装が上の例の通りCreateLinkedTokenSourceで繋げたもので処理していて、そして、特に何のハンドリングもしていなかったからです。

結論としてこれはHttpClientの設計ミスなのですが、一度世の中に出したクラスの例外の型を変更することは .NET の互換性維持のポリシーに反するということで(実際、これを変更してしまうと影響は相当大きくなるでしょう)、お茶を濁した対応(InnerExceptionにTimeoutExceptionを仕込んで、判定はそちら経由で一応できなくもないようにした)となってしまったのですが、今から実装する我々は同じ轍を踏んではいけない。ということで、ちゃんと正しく処理するようにしましょう。

public async Task SendAsync(CancellationToken cancellationToken = default)
{
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
    cts.CancelAfter(Timeout);

    try
    {
        await SendCoreAsync(cts.Token);
    }
    catch (OperationCanceledException ex) when (ex.CancellationToken == cts.Token)
    {
        if (cancellationToken.IsCancellationRequested)
        {
            // 引数のCancellationTokenが原因なので、それを保持したOperationCanceledExceptionとして投げる
            throw new OperationCanceledException(ex.Message, ex, cancellationToken);
        }
        else
        {
            // タイムアウトが原因なので、TimeoutException(或いは独自の例外)として投げる
            throw new TimeoutException($"The request was canceled due to the configured Timeout of {Timeout.TotalSeconds} seconds elapsing.", ex);
        }
    }
}

やることは別に難しくはなく、OperationCanceledExceptionをcatchしたうえで、外から渡されたcancellationTokenがキャンセルされているならそれが原因、そうでないならタイムアウトが原因であるという判定をして、それに応じた例外を投げ直します。

最後に、Client自体がDisposeできるとして、それに反応するようなコードにしましょう。

class Client : IDisposable
{
    // IDisposableと引っ掛けて、Client自体がDisposeされたら実行中のリクエストも終了させるようにする
    readonly CancellationTokenSource clientLifetimeTokenSource;

    public TimeSpan Timeout { get; }

    public Client(TimeSpan timeout)
    {
        this.Timeout = timeout;
        this.clientLifetimeTokenSource = new CancellationTokenSource();
    }

    public async Task SendAsync(CancellationToken cancellationToken = default)
    {
        using var cts = CancellationTokenSource.CreateLinkedTokenSource(clientLifetimeTokenSource.Token, cancellationToken);
        cts.CancelAfter(Timeout);

        try
        {
            await SendCoreAsync(cts.Token);
        }
        catch (OperationCanceledException ex) when (ex.CancellationToken == cts.Token)
        {
            if (cancellationToken.IsCancellationRequested)
            {
                // 引数のCancellationTokenが原因なので、それを保持したOperationCanceledExceptionとして投げる
                throw new OperationCanceledException(ex.Message, ex, cancellationToken);
            }
            else if (clientLifetimeTokenSource.IsCancellationRequested)
            {
                // クライアント自体がDisposeされたのでOperationCanceledException、或いは独自の例外を投げる
                throw new OperationCanceledException("Client is disposed.", ex, clientLifetimeTokenSource.Token);
            }
            else
            {
                // タイムアウトが原因なので、TimeoutException(或いは独自の例外)として投げる
                throw new TimeoutException($"The request was canceled due to the configured Timeout of {Timeout.TotalSeconds} seconds elapsing.", ex);
            }
        }
    }

    async Task SendCoreAsync(CancellationToken cancellationToken)
    {
        // nanika...
    }

    public void Dispose()
    {
        clientLifetimeTokenSource.Cancel();
        clientLifetimeTokenSource.Dispose();
    }
}

差分はCreateLinkedTokenSourceで連結するトークンを増やすのと、例外処理時の分岐を増やすことだけです。

ゼロアロケーション化する

殆どの場合は上記のパターンで全く問題ないのですが、都度CreateLinkedTokenSourceで新しいCancellationTokenSourceを作るのが気になる、かもしれません。どちらにせよasyncメソッドが非同期で実行される場合には、非同期ステートマシン自体のアロケーションが発生するので実際のところ別に気にするほどのことではない。のですが、IValueTaskSourcePoolingAsyncValueTaskMethodBuilderを使ったアロケーションを避ける非同期実装を行っていた場合には、相当気になる問題になってきます。また、HTTP/1のREST呼び出しのような頻度では大したことないですが、これが例えばサーバーで大量の並列実行をさばく、クライアントではリアルタイム通信で毎フレーム通信する、といった用途だと、この辺も気を配りたくなってくるかもしれません。

なお、ここでは説明の簡略化のために、SendAsyncメソッド自体はasync Taskのままにします。

まずは外部キャンセルのない、タイムアウトだけのケースを見ていきます。タイムアウトは正常系の場合は発火しない、つまり殆どの場合は発火しないため、非発火時にはCancellationTokenSourceを使い回すようにしましょう。

class Client
{
    // SqlConnectionのようなメソッドを多重に呼ぶことを禁止しているクラスの場合はフィールドにCancellationTokenSourceを一つ
    // HttpClientのようにあちこちから多重に呼ばれる場合があるものはObjectPoolで保持する

    readonly ObjectPool<CancellationTokenSource> timeoutTokenSourcePool;

    public TimeSpan Timeout { get; }

    public Client(TimeSpan timeout)
    {
        this.Timeout = timeout;
        this.timeoutTokenSourcePool = ObjectPool.Create<CancellationTokenSource>();
    }

    public async Task SendAsync()
    {
        var timeoutTokenSource = timeoutTokenSourcePool.Get();
        timeoutTokenSource.CancelAfter(Timeout);

        try
        {
            await SendCoreAsync(timeoutTokenSource.Token);
        }
        finally
        {
            // Timeout処理が発火していない場合はリセットして再利用できる
            if (timeoutTokenSource.TryReset())
            {
                timeoutTokenSourcePool.Return(timeoutTokenSource);
            }
        }
    }
}

ObjectPoolの実装は色々ありますが、今回は説明の簡略化のためにMicrosoft.Extensions.ObjectPoolを使いました(NuGetからMicrosoft.Extensions.ObjectPoolを参照する必要あり)。タイムアウトが発動した場合は再利用不能なので、プールに戻してはいけません。なお、 CancellationTokenSource.TryResetは .NET 6 からのメソッドになります。それ以前の場合は CancelAfter(Timeout.InfiniteTimeSpan) を呼んでタイマー時間を無限大に引き伸ばす変更を入れる(内部的にはTimerがChangeされる)というハックがあります。

外部キャンセルが入る場合には、LinkedTokenを作らず、CancellationToken.UnsafeRegisterでタイマー用のCancellationTokenSourceをキャンセルするようにします。

public async Task SendAsync(CancellationToken cancellationToken = default)
{
    var timeoutTokenSource = timeoutTokenSourcePool.Get();

    CancellationTokenRegistration externalCancellation = default;
    if (cancellationToken.CanBeCanceled)
    {
        // 引数のCancellationTokenが発動した場合もTimeout用のCancellationTokenを発火させる
        externalCancellation = cancellationToken.UnsafeRegister(static state =>
        {
            ((CancellationTokenSource)state!).Cancel();
        }, timeoutTokenSource);
    }

    timeoutTokenSource.CancelAfter(Timeout);

    try
    {
        await SendCoreAsync(timeoutTokenSource.Token);
    }
    finally
    {
        // Registerの解除(TryResetの前に「必ず」先に解除すること)
        // CancellationTokenRegistration.Disposeは解除完了(コールバック実行中の場合は実行終了)までブロックして確実に待ちます
        externalCancellation.Dispose();
        if (timeoutTokenSource.TryReset())
        {
            timeoutTokenSourcePool.Return(timeoutTokenSource);
        }
    }
}

CancellationToken.UnsafeRegisterは .NET 6 からのメソッドでExecutionContextをCaptureしないため、より高効率です。それ以前の場合はRegisterを使うか、呼び出しの前後でExecutionContext.SuppressFlow/RestoreFlowするというハックが使えます(UniTaskのRegisterWithoutCaptureExecutionContextはこの実装を採用しています)。

CancellationTokenにコールバックを仕込む場合、レースコンディションが発生する可能性が出てきます。この場合だとTimeout用のCancellationTokenSourceをプールに戻した後にCancelが発生すると、最悪なことになります。それを防ぐために、CancellationTokenRegistration.DisposeをTryResetの前に必ず呼びましょう。CancellationTokenRegistration.Disposeの優れているところは、コールバックが実行中の場合は実行終了までブロックして確実に待ってくれます。これによりマルチスレッドのタイミング問題ですり抜けてしまうといったことを防いでくれます。

ブロックといいますが、コールバックに登録されたメソッドがすぐに完了する性質のものならば、lockみたいなものなので神経質になる必要はないでしょう。CancellationTokenRegistrationにはDisposeAsyncも用意されていますが、むしろそちらを呼ぶほうがオーバーヘッドであるため、無理にDisposeAsyncのほうを優先する必要はないと考えています。CancellationTokenRegistrationには他にUnregisterメソッドもあり、これはfire-and-forget的に解除処理したい場合に有効です。使い分けですね。

なお、CancellationTokenへのコールバックのRegister(UnsafeRegister)は、初回はコールバック登録用のスロットを生成するといったアロケーションがありますが、Dispose/Registerを繰り返す二回目以降はスロットを再利用してくれます。このへんも新規に(Linked)CancellationTokenSourceを作るより有利な点となりますね。

引き続き、Client自体の寿命に引っ掛けるCancellationTokenを追加した実装を見ていきましょう。といっても、単純にRegisterを足すだけです。

class Client : IDisposable
{
    readonly TimeSpan timeout;
    readonly ObjectPool<CancellationTokenSource> timeoutTokenSourcePool;
    readonly CancellationTokenSource clientLifetimeTokenSource;

    public TimeSpan Timeout { get; }

    public Client(TimeSpan timeout)
    {
        this.Timeout = timeout;
        this.timeoutTokenSourcePool = ObjectPool.Create<CancellationTokenSource>();
        this.clientLifetimeTokenSource = new CancellationTokenSource();
    }

    public async Task SendAsync(CancellationToken cancellationToken = default)
    {
        var timeoutTokenSource = timeoutTokenSourcePool.Get();

        CancellationTokenRegistration externalCancellation = default;
        if (cancellationToken.CanBeCanceled)
        {
            // 引数のCancellationTokenが発動した場合もTimeout用のCancellationTokenを発火させる
            externalCancellation = cancellationToken.UnsafeRegister(static state =>
            {
                ((CancellationTokenSource)state!).Cancel();
            }, timeoutTokenSource);
        }

        // Clientの寿命に合わせたものも同じように追加しておく
        var clientLifetimeCancellation = clientLifetimeTokenSource.Token.UnsafeRegister(static state =>
        {
            ((CancellationTokenSource)state!).Cancel();
        }, timeoutTokenSource);

        timeoutTokenSource.CancelAfter(Timeout);

        try
        {
            await SendCoreAsync(timeoutTokenSource.Token);
        }
        finally
        {
            // Registerの解除増量
            externalCancellation.Dispose();
            clientLifetimeCancellation.Dispose();
            if (timeoutTokenSource.TryReset())
            {
                timeoutTokenSourcePool.Return(timeoutTokenSource);
            }
        }
    }

    async Task SendCoreAsync(CancellationToken cancellationToken)
    {
        // snip...
    }

    public void Dispose()
    {
        clientLifetimeTokenSource.Cancel();
        clientLifetimeTokenSource.Dispose();
    }
}

例外処理も当然必要です!が、ここは最初の例のLinkedTokenで作ったときと同じです。

public async Task SendAsync(CancellationToken cancellationToken = default)
{
    var timeoutTokenSource = timeoutTokenSourcePool.Get();

    CancellationTokenRegistration externalCancellation = default;
    if (cancellationToken.CanBeCanceled)
    {
        externalCancellation = cancellationToken.UnsafeRegister(static state =>
        {
            ((CancellationTokenSource)state!).Cancel();
        }, timeoutTokenSource);
    }

    var clientLifetimeCancellation = clientLifetimeTokenSource.Token.UnsafeRegister(static state =>
    {
        ((CancellationTokenSource)state!).Cancel();
    }, timeoutTokenSource);

    timeoutTokenSource.CancelAfter(Timeout);

    try
    {
        await SendCoreAsync(timeoutTokenSource.Token);
    }
    catch (OperationCanceledException ex) when (ex.CancellationToken == timeoutTokenSource.Token)
    {
        // 例外発生時の対応はLinkedTokenで作ったときと特に別に変わらず

        if (cancellationToken.IsCancellationRequested)
        {
            throw new OperationCanceledException(ex.Message, ex, cancellationToken);
        }
        else if (clientLifetimeTokenSource.IsCancellationRequested)
        {
            throw new OperationCanceledException("Client is disposed.", ex, clientLifetimeTokenSource.Token);
        }
        else
        {
            throw new TimeoutException($"The request was canceled due to the configured Timeout of {Timeout.TotalSeconds} seconds elapsing.", ex);
        }
    }
    finally
    {
        externalCancellation.Dispose();
        clientLifetimeCancellation.Dispose();
        if (timeoutTokenSource.TryReset())
        {
            timeoutTokenSourcePool.Return(timeoutTokenSource);
        }
    }
}

ということで、↑のものが最終形となりました。

async/awaitで実装されている場合、Tokenのコールバックも一メソッド内で収まっているために挙動の見通しがだいぶ良くなります。async/awaitを封印してIValueTaskSourceを使った実装をする場合は、複数のコールバックを手で処理する必要があり、また登録、発火する箇所も複数箇所にちらばってしまうため、遥かに複雑性が増します。

AlterNatsではハイパフォーマンスSocketプログラミングとして実装を解説した記事で、IValueTaskSourceをChannel(キュー)に詰め込むとしていますが、キャンセル時にはManualResetValueTaskSourceCoreのSetExceptionを叩き、正常完了時にはSetResultの前でTryResetからのReturnするという、まぁ基本的な流れは一緒です。少し異なるのは、キャンセルで発火するのはawaitに紐付けられた継続処理だけで、実体はキューに残り続けていて、取り出し時にキャンセル状況をチェックして、何もしないようにする。といったことでしょうか。状況が複雑化する分、レースコンディション起因のバグが入り込みやすくなってしまうので、CancellationTokenRegistrationの挙動をしっかり把握して実装する必要があります。

まとめ

簡単かどうかでいうと、言われればなるほどそうですねーって感じですが、都度考えてやれって言われると結構難しいと思います。なので、こういうパターンなんですね、というのを頭に叩き込んでおくというのは重要だと思いますし、まぁとりあえず覚えてください。覚えれば、別にコード的に複雑というわけでもないので、易易と対処できるようになるはずです。

StackExchange.Redisも非同期メソッド、CancellationTokenを受け取ってなかったりしますし、パフォーマンスを追求しつつCancellationToken対応を入れるのは、かなり難しい問題だったりします。しかしこの .NET 6世代ではかなりメソッドも増えていて、やろうと思えばやりきれるだけの手札が揃っています。なので、パターン化して真正面から立ち向かいましょう……!

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

12回目。一年ごとに再審査があって7月に一斉更新されるシステムになっていて、今年も継続しました。

元々、私の活動はオンライン一人自己完結型なので、C#布教活動(?)的にコロナの影響は一切受けていないのですが、勉強会開催などコミュニティ構築型の人だと影響を受けやすいここ数年だったのではないかと思います。ただ、やはりアフターコロナで人との繋がりは極度に減ったし、人の入れ替わり、新しい台頭みたいなのも少なくなってきたなあ、というのが肌間ありますね。改めて、コミュニティを維持してくれている人のありがたさを知りました。というわけで、C#コミュニティ盛り上がっていって欲しい!のですが、私自身のスタンスは変わらず、OSSで世の中に存在感を出していくことだとは思っています。

好不調の並が割と激しくて、ここ数ヶ月何もやってないわーみたいなときもよくあるのですが、年を通すと毎年3, 4個は新規にOSSをリリースしているし、既存ライブラリのメンテナンスやテコ入れ大型リニューアルも数個やっていたりするので、年間通して見ればかなりハイパフォーマンスで、それを10年以上継続してるんだから中々なんじゃないですか?と自画自賛したくなったり。

そんなこんなの活動を続けてきた結果、CEDECという国内最大のゲーム業界のカンファレンスでもCEDEC AWARDS 2022のエンジニアリング部門で、「.NET/Unity開発の可能性を広げるオープンソースソフトウェアの提供」として優秀賞を受賞しました。C#は元々裏方で便利に使っていたというのはありましたが、表でもガンガン使っていけるよ、といったC#の存在感は、高めていけてるんじゃないかと思います。CysharpとしてOSSを通じてC#の可能性を広げるということがしっかり伝わってるというのがとても嬉しいですね!参加者投票で部門別最優秀賞が決まるらしいので、是非投票を……!

また、今年はプリコネ!グランドマスターズのサーバー開発をCysharpが開発協力しましたという記事で書きましたが、開発に関わっていた「プリコネ!グランドマスターズ」のリリースがありました。の事例発表をCEDEC 2022でC#によるクライアント/サーバーの開発言語統一がもたらす高効率な開発体制 ~プリコネ!グランドマスターズ開発事例~としてCygamesさんより発表があります。

  • クライアント/サーバーの開発言語統一によるメリット
  • MagicOnion(gRPC)を利用したリアルタイムサーバー実装手法と具体例
  • Blazorを使用したツールの開発例、開発プロジェクトおよびソリューション統合管理の手法

ということで、かなり面白い感じの内容になるのではないでしょうか、期待……!

C#がエンタープライズ業界(とは)で強いというのは重々承知だしAzureもシェア高くめっちゃ使われてるんだよ、という話は分かりはするのですが、to Cやスタートアップ企業で積極的に使われる言語になって欲しい、というのがずーっとの願いで、私自身もそうした業界に身をおいて、実績で示し続けて来たと思いますし、これからも引き続き道を示せるようにしていきたいです。

もちろん、ハイパフォーマンスなC#によって最前線での実力を見せていく、ということも変わらずに……!

AlterNats - ハイパフォーマンスな.NET PubSubクライアントと、その実装に見る.NET 6時代のSocketプログラミング最適化のTips、或いはMagicOnionを絡めたメタバース構築のアーキテクチャについて

タイトルはここぞとばかりに全盛りにしてみました!今回NATSの.NETクライアント実装としてAlterNatsというライブラリを新しく作成し、公開しました。

公式の既存クライアントの3倍以上、StackExchange.RedisのPubSubと比較して5倍以上高速であり、通常のPubSubメソッドは全てゼロアロケーションです。

image

そもそもNATSとはなんぞやか、というと、クラウドネイティブなPubSubのミドルウェアです。Cloud Native Computing Foundationのincubating projectなので、それなりの知名度と実績はあります。

PubSubというと、特にC#だとRedisのPubSub機能で行うのが、StackExchange.Redisという実績あるライブラリもあるし、AWSやAzure、GCPがマネージドサービスも用意しているしで、お手軽でいいのですが、盲目的にそれを使うのが良いのか少し疑問に思っていました。

RedisはKVS的な使い方がメインであり、PubSubはどちらかというとオマケ機能であるため

  • PubSub専用のモニタリングの欠如
  • PubSub用のクラスタリング対応
  • マネージドサービスでの価格体系のバランスの悪さ(PubSub特化ならメモリはあまりいらない)
  • そもそものパフォーマンス

といった点が具体的な懸念です。そして、NATSはPubSub専用に特化されているため、そのためのシステムが豊富に組まれているし、性能も申し分なさそうに思えました。しいて欠点を言えばマネージドサービスが存在しないのがネックですが、純粋なPubSubとしての利用ならば永続化処理について考える必要がないので、ミドルウェアとしては運用しやすい部類にはいるのではないかと思っています。(NATS自体はNATS JetStreamという機能によってAt-least / exactly onceの保証のあるメッセージングの対応も可能ですが、そこに対応させるにはストレージが必要になる場合もあります)

しかし調べていくうちに懸念となったのが公式クライアントであるnats.netで、あまり使いやすくないのですね。async/awaitにも対応していないし、古くさく、それどころかそもそも.NET的に奇妙に見えるAPIであり、そうなるとパフォーマンスに関しても疑問に思えてくる。

何故そうなっているかの理由はReadMeにも明記されていて、メンテナンス性のためにGoクライアント(ちなみにNATS Server自体はGoで書かれている)と同じようなコードベースになっている、と。そのためC#的ではない部分が多々あるし、GoとC#ではパフォーマンスを出すための書き方が全く異なるので、あまり良い状況ではなさそう。

それならば完全にC#に特化して独自に作ってしまうほうがいいだろうということで、作りました。公式クライアントと比べると全ての機能をサポートしているわけではない(JetStreamにも対応していないしLeaf Nodes運用で必須になるであろうTLSにも対応していません)のですが、PubSubのNATS Coreに特化して、まずは最高速を叩き出せるようにしました。PubSub利用する分には機能面での不足はないはずです。

AlterNatsは公式じゃないAlternativeなNATSクライアントという意味です。まんまですね。割と語感が良いので命名的には結構気に入ってます。

Getting Started

APIは、nats.netがあまりにもC#っぽくなくややこしい、ということを踏まえて、シンプルに、簡単に、C#っぽく書けるように調整しました。

// create connection(default, connect to nats://localhost:4222)
await using var conn = new NatsConnection();

// for subscriber. await register to NATS server(not means await complete)
var subscription = await conn.SubscribeAsync<Person>("foo", x =>
{
    Console.WriteLine($"Received {x}");
});

// for publisher.
await conn.PublishAsync("foo", new Person(30, "bar"));

// unsubscribe
subscription.Dipose();

// ---

public record Person(int Age, string Name);

Subscribeでhandlerを登録し、Publishでメッセージを飛ばす。データは全て自動でシリアライズされます(デフォルトではSystem.Text.Json、MessagePack for C#を用いたハイパフォーマンスなシリアライズも可能な拡張オプションも標準で用意してあります)

別のURLへの接続や、認証のための設定などを行うNatsOptions/ConnectOptionsはイミュータブルです。そのため、with式で構築するやり方を取っています。

// Options can configure `with` operator
var options = NatsOptions.Default with
{
    Url = "nats://127.0.0.1:9999",
    LoggerFactory = new MinimumConsoleLoggerFactory(LogLevel.Information),
    Serializer = new MessagePackNatsSerializer(),
    ConnectOptions = ConnectOptions.Default with
    {
        Echo = true,
        Username = "foo",
        Password = "bar",
    }
};

await using var conn = new NatsConnection(options);

NATSには標準で結果を受け取るプロトコルも用意されています。サーバー間の簡易的なRPCとして使うと便利なところもあるのではないかと思います。これもSubscribeRequestAsync/RequestAsyncという形で簡単に直感的に書けるようにしました(Request側は戻り値の型を指定する必要があるため、型指定が少しだけ冗長になります)

// Server
await conn.SubscribeRequestAsync("foobar", (int x) => $"Hello {x}");

// Client(response: "Hello 100")
var response = await conn.RequestAsync<int, string>("foobar", 100);

例では await usingですぐに破棄してしまっていますが、基本的にはConnectionはシングルトンによる保持を推奨しています。staticな変数に詰めてもいいし、DIでシングルトンとして登録してしまってもいいでしょう。接続は明示的にConnectAsyncすることもできますが、接続されていない場合は自動で接続を開くようにもなっています。

コネクションはスレッドセーフで、物理的にも一つのコネクションには一つの接続として繋がり、全てのコマンドは自動的に多重化されます。これにより裏側で自動的にバッチ化された高効率な通信を実現していますが、負荷状況に応じて複数のコネクションを貼った場合が良いケースもあります。AlterNatsではNatsConnectionPoolという複数コネクションを内包したコネクションも用意しています。また、クライアント側で水平シャーディングを行うためのNatsShardingConnectionもあるため、必要に応じて使い分けることが可能です。

内部のロギングはMicrosoft.Extensions.Loggingで管理されています。AlterNats.Hostingパッケージを使うと、Generic Hostと統合された形で適切なILoggerFactoryの設定と、シングルトンのサービス登録を行ってくれます。

DIでの取り出しは直接NatsConnectionを使わずに、INatsCommandを渡すことで余計な操作(コネクションの切断など)が出来ないようになります。

using AlterNats;

var builder = WebApplication.CreateBuilder(args);

// Register NatsConnectionPool, NatsConnection, INatsCommand to ServiceCollection
builder.Services.AddNats();

var app = builder.Build();

app.MapGet("/subscribe", (INatsCommand command) => command.SubscribeAsync("foo", (int x) => Console.WriteLine($"received {x}")));
app.MapGet("/publish", (INatsCommand command) => command.PublishAsync("foo", 99));

app.Run();

メタバースアーキテクチャ

CysharpではMagicOnionという .NET/Unity で使えるネットワークフレームワークを作っているわけですが、AlterNatsはこれと絡めることで、構成の幅を広げることができると考えています、というかむしろそのために作りました。

クライアントにUnity、サーバーにMagicOnionがいるとして、サーバーが一台構成なら、平和です、繋げるだけですもの。開発の最初とかローカルでは楽なのでこの状態でもいいですね。

image

しかし現実的にはサーバーは複数台になるので、そうなると色々なパターンが出てきます。よくあるのが、ロードバランサーを立ててそれぞれが別々のサーバーに繋がっているものを、更に後ろのPubSubサーバーを通して全サーバーに分配するパターン。

image

これはNode.jsのリアルタイムフレームワークであるSocket.IOのRedisアダプター、それの.NET版であるSignalRのRedisバックプレーン、もちろんMagicOnionにもあるのですが、このパターンはフレームワークでサポートされている場合も多いです。RedisのPubSubでできることはNATSでもできる、ということで、NATSでもできます。

これは各サーバーをステートレスにできるのと、スケールしやすいので、Chatなどの実装にはやりやすい。欠点はステートを持ちにくいので、クライアントにステートがあり、データのやり取りをするタイプしか実装できません。サーバー側にステートを持ったゲームロジックは持たせずらいでしょう(ステートそのものは各サーバーで共有できないため)。また、PubSubを通すことによるオーバーヘッドも気になるところかもしれません。

ロードバランサーを立てる場合、ロードバランサーのスティッキーセッションを活用して一台のサーバーに集約させるというパターンもあります(あるいは独自プロトコルでもリバースプロキシーを全面に立てて、カスタムなロジックで後ろの台を決定することもほぼ同様の話です)。ただし、色々なユーザーを同一サーバーに集約させたいようなケースでは、そのクッキーの発行誰がやるの、みたいなところは変わらずありますね。そこまで決めれるならIPアドレスを返して直繋ぎさせてしまってもいいんじゃないの?というのも真です。

そうした外側に対象のIPアドレスを教えてくれるサービスがいて、先にそれに問い合わせてから、対象のサーバーへ繋ぎに行くパターンは、古典的ですが安定です。

image

この場合は同一サーバーに繋ぎにいくためにサーバー内にインメモリでフルにステートを持たせることが出来ますし、いわゆるゲームループを中で動かして処理するようなこともできます。また、画面のないヘッドレスUnityなどをホストして、クライアントそのものをサーバー上で動かすこともできますね。

しかし、このパターンは素直なようでいて、実際VMだとやりやすいのですが、Kubernetesでやるのは難しかったりします。というのも、Kubernetesの場合は外部にIPが露出していないため、クラスター内の一台の特定サーバーに繋ぎにいくというのが難しい……!

このような場合に最近よく活用されているのがAgonesというGoogleが主導して作っているKubernetesの拡張で、まさにゲーム向きにKubernetesを使えるようにするためのシステムです。

ただし、これはこれで難点があって、Agonesが想定しているゲームサーバーは1プロセス1ゲームセッション(まさにヘッドレスUnityのような)のホスティングであるため、1つのプロセスに多数のゲームセッションをホストさせるような使い方はそのままだと出来ません。コンテナなので、仮想的なプロセスを複数立ち上げればいいでしょ、というのが思想なのはわからなくもないのですが、現実的には軽量なゲームサーバー(それこそMagicOnionで組んだりする場合)なら、1プロセスに多数のゲームセッションを詰め込めれるし、これをコンテナで分けて立ち上げてしまうとコスト面では大きな差が出てしまいます。

さて、Cysharpではステートフルな、特にゲームに向いたC#サーバーを構築するための補助ライブラリとしてLogicLooperというゲームループを公開しています。このライブラリはこないだリリースしたプリコネ!グランドマスターズでも使用していますが、従来MagicOnionと同居して使っていたLogicLooperを、剥がしたアーキテクチャはどうだろうか、という提案があります。(実際のプリコネ!グランドマスターズのアーキテクチャはMagicOnionと同居し、リバースプロキシーを使った方式を採用しているので(↑の画像のものに近い)、この案とは異なります)

image

パーツが増えて複雑になったように見えて、この構成には大きな利点があります。まず、同居しているものがなくなったので複雑になったようで実はシンプルになっています。それぞれがそれぞれの役割にフルに集中できるようになるため、パフォーマンスも良くなり、かつ、性能予測もしやすくなります。特にロジックをフルに回転させるLogicLooperがクライアントや接続数の影響を受けずに独立できているのは大きな利点です。

ゲーム全体のステートはLogicLooper自体が管理するため、クライアントとの接続を直接受けているMagicOnion自体はステートレスな状態です。そのため、インフラ的にもロードバランサーの下にMagicOnionを並べるだけで済みますし、サーバー間の接続に伴う面倒事は全てNATSに押し付けられるため、インフラ管理自体はかなりシンプルな構成が取れます。

また、MagicOnion自体はステートを持てるシステムであり、各ユーザーそれぞれのステートを持つのは容易です(サーバーを越えなければいい)。そこで、LogicLooperから届いたデータのうち、繋がってるユーザーに届ける必要がないデータは、MagicOnionの持つユーザーのステートを使ってカリング処理をして、そもそも転送しなかったり間引いたりして通信量を削減することで、ユーザーの体験が良くなります。

各ユーザーから届くデータを使ったステート更新/データ送信に関しては、LogicLooperがゲームループ状になっているので、ループの間に溜まったデータをもとにしてバッチ処理を行えばいいでしょう。バッチ化というと、通信「回数」の削減のためのコマンドを単純にまとめあげて一斉送信するものと、内容を見て処理内容を縮小するパターンが考えられますが、LogicLooperを使ったアプローチでは後者を効率的に行なえます。前者のコマンドの一斉送信に関しては、AlterNatsが裏側で自動パイプライニング化としてまとめているので(後で詳しく説明します)、そこに関しても効率化されています。

このアーキテクチャで気になるのがPubSub通信のオーバーヘッドですが、それに関しての解決策がAlterNatsで、究極的に高速なクライアントがあれば(さすがにインメモリには到底及ばないとはいえ)、そもそものクライアントとサーバーの間にもネットワークがいるわけで、経路のトータルで見れば実用的な範囲に収められる。という想定で作りました。

ところで、そして究極的な利点は、全てC#で組めるということです。どういうことかというと、MagicOnionもLogicLooperも汎用的なC#フレームワークです。特別なプラグインを差し込んで処理するというわけではなくて、ふつーのC#コードをふつーに書くことで、それぞれの箇所に、アプリケーション固有のコードを仕込んでいくことができる。これが、本当の大きな利点です。専用のC++ミドルウェアを作って挟んで最適化できるぞ!などといったシステムは、素晴らしいことですが、専門性が高く再現性が低い。MagicOnionとLogicLooper、そしてAlterNatsを活用したこの構成なら、C#エンジニアなら誰でも(容易に)できる構成です。Cysharpのメッセージは「C#の可能性を切り開いていく」ですが、誰もが実現できる世界を作っていくというのが目標でもあります。

なお、ワーカーとしてのLogicLooperを作るにWorker Serviceという.NET 6からのプロジェクトタイプが適切です。

ハイパフォーマンスSocketプログラミング

  • Socket API

C#で最も低レベルにネットワーク処理を扱えるクラスはSocketです。そして、非同期でハイパフォーマンスな処理を求めるならSocketAsyncEventArgsをうまく再利用しながらコールバックを仕込む必要があります。

これは非常に厄介で些か難易度も高いのですが、現在はasync/awaitの時代、ちゃんとawaitできる***Asyncメソッド郡が用意されています。しかし、使ってはいけないAPI、使ってはいけないオーバーロードも並んでいるので、その選別が必要です。SocketのAPIは歴史的事情もあり混沌としてしまっているのです……。

使うべきAPIを分かりやすく見分ける手段があります。それは戻り値が ValueTask のものを選ぶことです。

public ValueTask ConnectAsync(string host, int port, CancellationToken cancellationToken)
public ValueTask<int> ReceiveAsync(Memory<byte> buffer, SocketFlags socketFlags, CancellationToken cancellationToken)
public ValueTask<int> SendAsync(ReadOnlyMemory<byte> buffer, SocketFlags socketFlags, CancellationToken cancellationToken))

オーバーロードにはTask返しのものもあるので、気をつけてください。

// これらのAPIは使ってはいけない
public Task ConnectAsync(string host, int port)
public Task<int> ReceiveAsync(ArraySegment<byte> buffer, SocketFlags socketFlags)
public Task<int> SendAsync(ArraySegment<byte> buffer, SocketFlags socketFlags)

ValueTask返しのAPIは内部的には AwaitableSocketAsyncEventArgs というものがValueTaskの中身になるようになっていて、これがいい感じに使いまわされる(awaitされると内部に戻るようになっている)ことで、Taskのアロケーションもなく効率的な非同期処理を実現しています。SocketAsyncEventArgsの使いにくさとは雲泥の差なので、これは非常にお薦めできます。

また、同期APIはSpanを受け取れるのですが、非同期APIは(ステートをヒープに置く都合上)Memoryしか受け取れないことには注意してください。これはSocketプログラミングに限らず非同期系APIにおける一般的な話で、全体的に上手く組んでおかないと、Spanが使えないことが障壁になることがあります。必ず、Memoryで取り回せるようにしておきましょう。

  • テキストプロトコルのバイナリコード判定

NATSのプロトコルはテキストプロトコルになっていて、文字列処理で簡単に切り出すことができます。実際これはStreamReaderを使うことで簡単にプロトコルの実装ができます。ReadLineするだけですから。しかし、ネットワークに流れるのは(UTF8)バイナリデータであり、文字列化は無駄なオーバーヘッドとなるため、パフォーマンスを求めるなら、バイナリデータのまま処理する必要があります。

NATSでは先頭の文字列(INFO, MSG, PING, +OK, -ERRなど)によって流れてくるメッセージの種類が判定できます。文字列処理で空白でSplitして if (msg == "INFO") などとすればめちゃくちゃ簡単ですが、先にも言った通り文字列変換は意地でも通しません。INFOは[73, 78, 70, 79]なので、Slice(0, 4).SequenceEqual で判定するのは悪くないでしょう。ReadOnlySpan<byte>のSequenceEqualはめちゃくちゃ最適化されていて、長いものであれば必要であればSIMDとかも使って高速に同値判定します。LINQのSequenceEqualとは別物です!

しかし、もっと欲張って見てみましょう、プロトコルの識別子はサーバーから送られてくるものは全て4文字以内に収まっています。つまり、これはIntに変換しやすい状態です!というわけで、AlterNatsのメッセージ種判定コードはこうなっています。

// msg = ReadOnlySpan<byte>
if(Unsafe.ReadUnaligned<int>(ref MemoryMarshal.GetReference<byte>(msg)) == 1330007625) // INFO
{
}

これ以上速い判定はできないと思うので、理論上最速ということでいいでしょう。3文字の命令も、直後に必ずスペースや改行が来るので、それを含めた以下のような定数を使って判定に回しています。

internal static class ServerOpCodes
{
    public const int Info = 1330007625;  // Encoding.ASCII.GetBytes("INFO") |> MemoryMarshal.Read<int>
    public const int Msg = 541545293;    // Encoding.ASCII.GetBytes("MSG ") |> MemoryMarshal.Read<int>
    public const int Ping = 1196312912;  // Encoding.ASCII.GetBytes("PING") |> MemoryMarshal.Read<int>
    public const int Pong = 1196314448;  // Encoding.ASCII.GetBytes("PONG") |> MemoryMarshal.Read<int>
    public const int Ok = 223039275;     // Encoding.ASCII.GetBytes("+OK\r") |> MemoryMarshal.Read<int>
    public const int Error = 1381123373; // Encoding.ASCII.GetBytes("-ERR") |> MemoryMarshal.Read<int>
}

バイナリプロトコルなら特に何のひねりも必要なく実装できるので、バイナリプロトコルのほうが実装者に優しくて好きです……。

  • 自動パイプライニング

NATSプロトコルの書き込み、読み込みは全てパイプライン(バッチ)化されています。これはRedisのPipeliningの解説が分かりやすいですが、例えばメッセージを3つ送るのに、一つずつ送って、都度応答を待っていると、送受信における多数の往復がボトルネックになります。

メッセージの送信において、AlterNatsは自動でパイプライン化しています。System.Threading.Channelsを用いてメッセージは一度キューに詰め込まれ、書き込み用のループが一斉に取り出してバッチ化します。ネットワーク送信が完了したら、再び送信処理待ち中に溜め込まれたメッセージを一括処理していく、という書き込みループのアプローチを取ることで、最高速の書き込み処理を実現しました。

image

ラウンドトリップタイムの話だけではなく(そもそもNATSの場合はPublish側とSubscribe側が独立しているので応答待ちというのもないのですが)、システムコールの連続した呼び出し回数を削減できるという点でも効果が高いです。

なお、.NET最高速ロガーであるZLoggerでも同じアプローチを取っています。

  • 一つのオブジェクトに機能を盛る

Channelに詰め込む都合上、データを書き込みメッセージオブジェクトに入れてヒープに保持しておく必要があります。また、書き込み完了まで待つ非同期メソッドのためのPromiseも必要です。

await connection.PublishAsync(value);

こうしたAPIを効率よく実装するために、どうしても確保する必要のある一つのメッセージオブジェクト(内部的にはCommandと命名されている)に、あらゆる機能を同居して詰め込みましょう。

class AsyncPublishCommand<T> : ICommand, IValueTaskSource, IThreadPoolWorkItem, IObjectPoolNode<AsyncPublishCommand<T>>

internal interface ICommand
{
    void Write(ProtocolWriter writer);
}

internal interface IObjectPoolNode<T>
{
    ref T? NextNode { get; }
}

このオブジェクト(AsyncPublishCommand<T>)自体は、T dataを保持して、Socketにバイナリデータとして書き込むための役割(ICommand)をまずは持っています。

それに加えてIValueTaskSourceであることにより、このオブジェクト自身がValueTaskになります。

そしてawait時のコールバックとして、書き込みループを阻害しないためにThreadPoolに流す必要があります。そこで従来のThreadPool.QueueUserWorkItem(callback)を使うと、内部的には ThreadPoolWorkItem を生成してキューに詰め込むため、余計なアロケーションがあります。 .NET Core 3.0からIThreadPoolWorkItemを実装することで、内部のThreadPoolWorkItemの生成をなくすことができます。

最後に、同居させることで必要なオブジェクトが1つになりましたが、その1つをプーリングしてゼロアロケーション化します。オブジェクトプールはConcurrentQueue<T>などを使うと簡単に実装できますが、自分自身をStackのNodeにすることで、配列を確保しないで済むようにしています。また、Nodeの出し入れに関しては、今回のキャッシュの実装では正確に取り出せる必要性はないため、lockは使わず、マルチスレッドで競合が発生した場合はキャッシュミス扱いにして新規生成するようにしています。これはオブジェクトプーリングにおける性能バランスとしては、良いチョイスだと考えています。

internal sealed class ObjectPool<T>
    where T : class, IObjectPoolNode<T>
{
    int gate;
    int size;
    T? root;
    readonly int limit;

    public ObjectPool(int limit)
    {
        this.limit = limit;
    }

    public int Size => size;

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public bool TryPop([NotNullWhen(true)] out T? result)
    {
        // Instead of lock, use CompareExchange gate.
        // In a worst case, missed cached object(create new one) but it's not a big deal.
        if (Interlocked.CompareExchange(ref gate, 1, 0) == 0)
        {
            var v = root;
            if (!(v is null))
            {
                ref var nextNode = ref v.NextNode;
                root = nextNode;
                nextNode = null;
                size--;
                result = v;
                Volatile.Write(ref gate, 0);
                return true;
            }

            Volatile.Write(ref gate, 0);
        }
        result = default;
        return false;
    }

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public bool TryPush(T item)
    {
        if (Interlocked.CompareExchange(ref gate, 1, 0) == 0)
        {
            if (size < limit)
            {
                item.NextNode = root;
                root = item;
                size++;
                Volatile.Write(ref gate, 0);
                return true;
            }
            else
            {
                Volatile.Write(ref gate, 0);
            }
        }
        return false;
    }
}
  • Zero-copy Architecture

Publish/Subscribeするデータは通常、C#の型をJSONやMessagePackなどにシリアライズしたものを流します。この場合、どうしてもbyte[]でやり取りすることが多くなります、例えばStackExchange.RedisのRedisValueの中身は実質byte[]で、送信にせよ受信にせよ、byte[]を生成して保持することになります。

これを避けるために、ArrayPoolから出し入れしてごまかしてゼロアロケーションにする、みたいなのはありがちではありますが、それでもコピーのコストが発生していることには代わりありません。ゼロアロケーションは当然目指すところですが、ゼロコピーに向けても頑張りましょう!

AlterNatsのシリアライザーはWriteにIBufferWriter<byte>, ReadにReadOnlySequence<byte>を要求します。

public interface INatsSerializer
{
    int Serialize<T>(ICountableBufferWriter bufferWriter, T? value);
    T? Deserialize<T>(in ReadOnlySequence<byte> buffer);
}

public interface ICountableBufferWriter : IBufferWriter<byte>
{
    int WrittenCount { get; }
}
// 例えばMessagePack for C#を使う場合の実装
public class MessagePackNatsSerializer : INatsSerializer
{
    public int Serialize<T>(ICountableBufferWriter bufferWriter, T? value)
    {
        var before = bufferWriter.WrittenCount;
        MessagePackSerializer.Serialize(bufferWriter, value);
        return bufferWriter.WrittenCount - before;
    }

    public T? Deserialize<T>(in ReadOnlySequence<byte> buffer)
    {
        return MessagePackSerializer.Deserialize<T>(buffer);
    }
}

System.Text.JsonやMessagePack for C#のSerializeメソッドにはIBufferWriter<byte>を受け取るオーバーロードが用意されています。IBufferWriter<byte>経由でSocketに書き込むために用意しているバッファーにシリアライザが直接アクセスし、書き込みすることで、Socketとシリアライザ間でのbyte[]のコピーをなくします。

image

Read側では、ReadOnlySequence<byte>を要求します。Socketからのデータの受信は断片的な場合も多く、それをバッファのコピーと拡大ではなく、連続した複数のバッファを一塊として扱うことでゼロコピーで処理するために用意されたクラスがReadOnlySequence<T>です。

「ハイパフォーマンスの I/O をより簡単に行えるように設計されたライブラリ」であるSystem.IO.PipelinesPipeReaderで読み取ったものを扱うのが、よくあるパターンとなります。ただし、AlterNatsではPipelinesは使わずに独自の読み取り機構とReadOnlySequence<byte>を使用しました。

System.Text.JsonやMessagePack for C#のSerializeメソッドにはIBufferWriter<byte>を受け取るオーバーロードが用意されているため、それを直接渡すことができます。つまり、現代的なシリアライザはIBufferWriter<byte>ReadOnlySequence<byte>のサポートは必須です。これらをサポートしていないシリアライザはそれだけで失格です。

まとめ

プロトコルが単純で少ないのでちゃちゃっと作れると思いきや、まあ確かに雑にTcpClientとStreamReader/Writerでやれば秒殺だったのですが、プロトコルって量産部分でしかないので、そこがどんだけ量少なかろうと、基盤の作り込みは相応に必要で、普通に割と時間かかってしまった、のですが結構良い感じに作れたと思います。コード的にも例によって色々な工夫が盛り込まれていますので、是非ソースコードも読んでみてください。

クライアント側の実装によってパフォーマンスが大きく違うというのはシリアライザでもよくあり経験したことですが、NATSのパフォーマンスを論じるにあたって、その言語のクライアントは大丈夫ですか?というところがあり、そして、C#は大丈夫ですよ、と言えるものになっていると思います。

NATSの活用に関してはこれからやっていくので実例あるんですか?とか言われると知らんがな、というところですが(ところでMagicOnionはこないだのプリコネ!グランドマスターズだけではなく最近特によくあるので、実例めっちゃあります)、これから色々使っていこうかなと思っているので、まぁ是非AlterNatsと共に試してみてください。

プリコネ!グランドマスターズのサーバー開発をCysharpが開発協力しました

Cygamesから4/1にリリースされたプリコネ!グランドマスターズのサーバーサイドとインフラ開発をCysharpが開発協力しました。リアルタイム通信を含むオートバトラー系のゲームです。

image

image

Cysharpはサーバー側のアーキテクチャ設計と基盤実装、クラウドインフラ構築、一部サーバーロジック実装を担いました。リアルタイム通信部分だけではなくてAPIサーバーからマッチメイキング、インフラまで、構成されるあらゆる要素がC#で作られています!

  • クライアント (Unity)
  • API サーバー(MagicOnion)
  • バトルエンジンサーバー (リアルタイム通信; MagicOnion, LogicLooper)
  • マッチメイキングサーバー (リアルタイム通信; MagicOnion)
  • バッチ(ConsoleAppFramework)
  • デバッグ機能サーバー (Web; Blazor)
  • 管理画面サーバー (Web; Blazor)
  • インフラ (Infrastructure as Code; Pulumi + C#)

サーバー側アプリケーションは.NET 6をKubernetes上で動かしています。Unityクライアント側でもCysharpのOSSは7つクレジットされていますが、表記のないサーバー側専用のものを合わせたら10個以上使用しています。ここまで徹頭徹尾C#でやっているプロジェクトは世界的にも珍しいんじゃないでしょうか。中心的に活躍しているのはMagicOnionですが、サーバーサイドゲームループのためのLogicLooper、負荷テストのためのDFrameなども実戦投入されて、成果を出しました。サーバートラブルも特になく、しっかり安定稼働しました。という事後報告です。そして今日、もとより期間限定公開ということで一週間の配信期間が終了しました。

アーキテクチャ含めの詳しい話は後日どこかでできるといいですね……!今回、私は実装者としては裏方というか監督というかという感じなので、発表する際は別の人にお任せします……!

C#でのサーバー構成をまた一つ実証できて、参考になって欲しいのですが(そしてC#採用事例増えて欲しい!)、こういった構成を、Cysharpだから出来る、のではなくて、誰もが実現できる環境にしていきたいとも思っています。重要なパーツは積極的にOSS化していますし、実績も着実に積み重ねられています。が、しかしまだまだ難しい面も数多くあるということは認識しています。かといってmBaaSの方向でやっていくべき、とは思わないんですね。ロジックはゲームの差別化のための重要な要素であり、サーバーサイドでも書くべきで。だから注力しているのは書きやすくするための環境で、そのために足りないものを提供していっています。

ところでサーバーとクライアントの繋ぎ、あるいはサーバーとサーバーの繋ぎが、MagicOnionだけだと複雑で難しくなってしまうところがあるな、と思っていまして、ちょうど先月-今月はメッセージングライブラリの開発に注力しています。AlterNatsという名前でPreview公開していますが、これを挟むと色々改善されるんじゃないかなあ、と思っているので、少々お待ち下さい。そんな感じに、常により最善のC#アーキテクチャの探究と、OSSを通じた共有をまだまだ続けていきます。

DFrame - C#でテストシナリオを書く分散負荷テストフレームワーク

と、いうものをリリースしました。Web UIとなるDFrame.Controllerと、負荷テストシナリオをC#で書くDFrame.Workerの組み合わせで成り立っていて、DFrame.Workerをウェブ上のクラスターに配置することで(Controllerと接続するただの常駐アプリなので、配置先はオンプレでもVMでもコンテナでもKuberenetesでもなんでもいい)、1から数千のワーカーが連動して、大量のリクエストを発生させます。また、テストシナリオをプレーンなC#で記述できるということは、HTTP/1だけではなく、あらゆる種類の通信をカバーできます。WebSocket、HTTP/2、gRPC、MagicOnion、あるいはPhotonや自作のTCPトランスポート、更にはRedisやデータベースなどが対象になります。

DFrame.Workerは通常の.NETの他に、Unityにも対応しています!つまり、大量のHeadless Unity、あるいはデバイスファームに配置することで、Unityでしか動かないような独自通信フレームワークであっても負荷テストをかけることが可能です。

また、あまり注目されていませんが負荷テストツールにもパフォーマンスの違いは「かなり」あり、性能の良さは重要で、そこのところにもかなりチューニングしました。

image

Web UI(DFrame.Controller)はBlazor Serverで作られていて、分散ワーカーとの通信はMagicOnionで行っています。自動化のためのWeb APIの口もあるため、Blazor Server, ASP.NET Minimum API, MagicOnionのキメラ同居なアーキテクチャでC#でフル活用なのが設計的にも面白いポイントです。

C#で負荷テストシナリオを書く意義

負荷テストフレームワークは世の中に山のようにあります。代表的なものでもab, jMeter, k6, Artillery, Gatling, wrk, bombardier, Locust、k6やArtillery、GatlingなどはSaaSとしても提供していますし、クラウドサービス側も、Azure Load Testing(Managed jMeter)のようなマネージドサービスを出していますし、.NETでもdotnet/crankというものが存在していたりします。

DFrameはこの中でいうとアーキテクチャ含めLocustに近い(Controller-Worker構成やWebUIなど)のですが、その特徴の中で重要な点として挙げられているのが、シナリオをコードで書けること、です。よくわからんUIで設定させたり、複雑怪奇なXMLやYAMLやJSON書かせたりせず、プレーンなコードで書ける。これが大事。LocustはPythonですが、他にk6はJavaScriptで書けるようになっています。

じゃあLocustでいいじゃん、k6でいいじゃん、という話になるのですが、C#で書きたいんですね、シナリオを。これは別にただ単に自分の好きな言語で書きたいからというわけではなくて、サーバーあるいはクライアント言語と負荷試験シナリオ作成言語は同一のものであるべきだからです。例えばUnityのゲームを開発している場合(サーバーサイドの言語は何でもいい)、UnityのゲームはC#で記述されていますが、その場合C#でテストシナリオが書けるのなら

  • 最初からクライアントSDK(エンドポイントと型付きのRequest/Response)に相当するものがある
  • クライアントの実装と完全に等しいのでゲームのドメインロジックが最初からある

となります。それによりテストシナリオの記述の手間を大幅に削減できます。もちろん、Unity依存の部分を引き剥がすなどの追加の作業は必要ですが、完全に書き起こすなどといった無駄は発生しません。もしPythonでもJavaScriptでもLuaでも、とにかく異なる言語である場合は、比較にならないほどに作業量が膨大になってきます。

そして実際のクライアントコードとある程度共通になることで、サーバー/クライアント側の変化への追随が用意になります。それにより一回のリリースのための負荷テストではなく、継続的な負荷テスト環境を作っていけます。

また、プレーンなC#で記述できることで、冒頭にも書きましたがあらゆる通信の種類をカバーできるのは、通信プロトコルが多様化している昨今、大きな利点となります。

DFrameApp.Run

NuGetからDFrameをパッケージ参照したうえで、一行で起動します。テストシナリオ(Workload)の記述の行数もありますが、それでもこれだけで。

using DFrame;

DFrameApp.Run(7312, 7313); // WebUI:7312, WorkerListen:7313

public class SampleWorkload : Workload
{
    public override async Task ExecuteAsync(WorkloadContext context)
    {
        Console.WriteLine($"Hello {context.WorkloadId}");
    }
}

これで http://localhost:7312 をブラウザで開けば、SampleWorkloadがいます。

image

と、いうわけで、WorkloadのExecuteAsyncにコードを書くのが基本です。ExecuteAsync前の準備用としてSetupAsync、後始末としてTeardownAsyncもあります。単純なgRPCのテストを書くとこなります。

public class GrpcTest : Workload
{
    GrpcChannel? channel;
    Greeter.GreeterClient? client;

    public override async Task SetupAsync(WorkloadContext context)
    {
        channel = GrpcChannel.ForAddress("http://localhost:5027");
        client = new Greeter.GreeterClient(channel);
    }

    public override async Task ExecuteAsync(WorkloadContext context)
    {
        await client!.SayHelloAsync(new HelloRequest(), cancellationToken: context.CancellationToken);
    }

    public override async Task TeardownAsync(WorkloadContext context)
    {
        if (channel != null)
        {
            await channel.ShutdownAsync();
            channel.Dispose();
        }
    }
}

Concurrencyの数だけWorkloadが生成されて、Total Request / Workers / Concurrencyの数だけExecuteAsyncが実行されます。コードで書くと言っても別にそう複雑なこともなく、よくわからんDSLで書くわけでもないので、むしろ(C#が書けるなら)とても書きやすいでしょう。中身も見てのとおり単純なので、gRPCでもMagicOnionでも何でも実行できます。

引数を受け取ることも可能なので、任意のURLを渡すようなものも作れます。コンストラクタでは、パラメーター、あるいはDIでインジェクトしたインスタンスを受け取れます。

using DFrame;
using Microsoft.Extensions.DependencyInjection;

// use builder can configure services, logging, configuration, etc.
var builder = DFrameApp.CreateBuilder(7312, 7313);
builder.ConfigureServices(services =>
{
    services.AddSingleton<HttpClient>();
});
await builder.RunAsync();

public class HttpGetString : Workload
{
    readonly HttpClient httpClient;
    readonly string url;

    // HttpClient is from DI, URL is passed from Web UI
    public HttpGetString(HttpClient httpClient, string url)
    {
        this.httpClient = httpClient;
        this.url = url;
    }

    public override async Task ExecuteAsync(WorkloadContext context)
    {
        await httpClient.GetStringAsync(url, context.CancellationToken);
    }
}

image

WebUI画面にString urlの入力箇所が現れて、好きなURLを叩き込むことができるようになりました。

なお、単純なHTTPのGET/POST/PUT/DELETEをテストしたいという場合は、IncludesDefaultHttpWorkloadを有効にしてもらうと、内蔵のパラメーターを受け取るWorkloadが追加されます。

using DFrame;

var builder = DFrameApp.CreateBuilder(7312, 7313);
builder.ConfigureWorker(x =>
{
    x.IncludesDefaultHttpWorkload = true;
});
builder.Run();

分散テスト

Workerは起動時に指定したControllerのアドレスにHTTP/2(MagicOnion/gRPC)で繋ぎに行って、常駐します。という普通の(?)アプリケーションなので、ウェブサーバーを分散させるのと同様に複数のWorkerを立ち上げてもらえれば、自動的に繋がります。

構成としては、以下の画像のようにControllerとWorkerのプロジェクトを分けるのが正当派(?)ですが

同居させてしまって、起動時のコマンドライン引数でどちらかのモード(あるいは両方)が起動するようにすることも、ローカルでの開発がしやすくなるのでお薦めです。 DFrameApp.CreateBuilder にはそのための補助的な機構が用意されています。

using DFrame;

var builder = DFrameApp.CreateBuilder(5555, 5556); // portWeb, portListenWorker

if (args.Length == 0)
{
    // local, run both(host WebUI on http://localhost:portWeb)
    await builder.RunAsync();
}
else if (args[0] == "controller")
{
    // listen http://*:portWeb as WebUI and http://*:portListenWorker as Worker listen gRPC
    await builder.RunControllerAsync();
}
else if (args[0] == "worker")
{
    // worker connect to (controller) address.
    // You can also configure from appsettings.json via builder.ConfigureWorker((ctx, options) => { options.ControllerAddress = "" });
    await builder.RunWorkerAsync("http://foobar:5556");
}

ローカルでWorkerの.exeを複数実行する、とかでも手元でとりあえずのWorker connectionsが増える様は確認できます。

Workerを増やすと表がにぎやかになって楽しい。実行するWorkerの数はスライダーで調整できるので、各種パラメーターを台数1で調整したあとに、徐々に実行Workerを増やしていく、といった使い方も可能です。また、その辺を自動でやってくれるRepeatモード(TotalRequestとWorkerを完了後に指定数増やして繰り返す)も用意しました。jMeterでいうところのRamp-Upの代わりに使えればいいかな、という想定でもあります。

アーキテクチャ的に最初から分散前提で作られているというのもあり、増やしても性能が劣化しない、リニアに性能が向上していくように作りました。Controllerは単一なのでスケールしないのですが、なるべく多くのWorkerをぶら下げられるように工夫しています。Controller <-> WorkerはMagicOnionで通信しているので、DFrame自身がMagicOnionの負荷テストになっているのです。

パフォーマンス

多数ある負荷テストフレームワークですが、パフォーマンスはそれぞれかなり異なります。詳しくはk6のブログOpen source load testing tool review 2020に非常に詳細に書かれていますが、例えばとにかくwrkがぶっちぎって他の数十倍~数百倍速かったりする、と。パフォーマンスは当然ながらとても重要で、ワーガーの非力さでターゲットに負荷をかけきれなかったりします。それに対応するためクラスターを組んでいくにしても、多くの台数やより高いスペックのマシンが必要になって、色々と辛い。

というわけでパフォーマンスは高ければ高いほうがいいのですが、先のブログに書かれている通り、拡張性の口やレポート取り出しの口などは必要です。その点でWrkは機能を満たさないということで、ブログではなんか結果から取り除かれてますね(その対応がいいのかどうかはなんとも言えませんが、まぁk6自身のアピールのためでもあるのでしょうがないね)。ちなみにフレームワークのパフォーマンスの指標として使われているTechEmpower Web Framework Benchmarksの負荷クライアントはwrkのようです。

さて、で、DFrameはどうかというと、かなり良好です。というのも、DFrameはライブラリとして提供されて、実行時は全てがC#の実行ファイルとしてコンパイル済みの状態になるのですね。スクリプトを動的に読んで実行するから遅くなってしまう、みたいなことがない。比較的高速な言語であるC#をそのまま利用するので、その時点である程度はいける。理論上。理屈上。

と、いう甘い見込みのもと実際作っていくと、さすがにそこまでさっくりとはいかず、相応にチューニングが必要だったのですが、最終的にはかなりの数字が出るようになりました。比較としてabとk6で測ってみると

image

image

image

本来はターゲットとワーカーは別マシンにしないといけないのですが(ワーカーの負荷でCPUが跳ね上がる影響をサーバー側がモロに影響受けてしまうので)、それでもそれなりに数字は変動しますし動きはするしマシンパワーも結構強め(Ryzen 9 5950x)なので、ちょっと手抜きでlocalhost上の無を返すHTTP/1サーバーをターゲットに、32並列(-c 32, -32VUs, Concurrency=32)で実行。

abが、6287 req/sec、k6が125619 req/sec、DFrameが207634 req/secです。abは、厳しい、厳しい……。もっと出るはずと思っているんですが、私の環境(Windows)だと昔からこんな感じなので、性能的には信用できないかなぁ。Windowsだとダメだったりするのかもしないのかもしれませんね。DFrameの場合Concurrencyにまだ余裕があって、増やすとまだまだ伸びたのですが、k6は割と頭打ちでした。

また、画像は出してませんがLocustは残念ながらかなり遅い上にCPUを食いまくるという感じで(Pythonだしね……)、いくらクラスタ化が容易とはいえ、ここまで1ワーカーあたりの性能が低いと、ないかなあ、という感想です。JMeterはそこまで悪くはないですが、パフォーマンスに影響を与える地雷コンフィグを必死にかいくぐってなおそこそこ程度なのはしんどみ。

ちなみになんで圧倒的性能番長であるwrkと比較しないのかというと、Windowsで動かすのが大変だからです。すみません……。

自動化のためのREST API

最初はいいけど、毎回GUIでポチポチやるの面倒で、それはそれで嫌だよね。CIで定期的に回したりもできないし。というわけで、バッチ起動モード、はついていないのですが、代わりにREST APIが自動で有効になっています。例えば /api/connections で現在接続中のワーカーコネクション数が取れます。実行パラメーターなどはPostでJSONを投げる形になっています。

REST APIでJSONをやり取りするだけなので、どの言語から叩くことも可能ですが、C#の場合は DFrame.RestSdk パッケージにて型付けされたクライアントが用意されているので、手間なくはじめられます。

using DFrame.RestSdk;

var client = new DFrameClient("http://localhost:7312/");

// start request
await client.ExecuteRequestAsync(new()
{
    Workload = "SampleWorkload",
    Concurrency = 10,
    TotalRequest = 100000
});

// loadtest is running, wait complete.
await client.WaitUntilCanExecute();

// get summary and results[]
var result = await client.GetLatestResultAsync();

実行状況は全て連動しているので、REST APIから実行した進捗もWeb UI側でリアルタイムに状況確認できます。

Unityでも動く

Unityで動かしやすいかといったら全然そんなことないので、動かせるようにするのはもはや執念という感じではあるのですが、Unity対応しました。冒頭で書いたようにヘッドレスUnityを並べてコントロールする、みたいな用途は考えられます。まぁ、あと普通の負荷テストでも、通信部分のC#を普通の .NET に切り出すのが面倒だという場合に、ヘッドレスUnityでとりあえずビルドすることで何もしなくてもOK(そうか?)という策もあります。

Unityで動かす場合は、依存の解決(MagicOnion、gRPC、MessagePack for C#)が大変です!まぁ、それは置いておいて。それが出来ているなら、以下のようなMonoBehaviourに寿命をくっつけたインスタンスで起動させると良い感じです(MagicOnionというかネイティブgRPCは適切にコネクションをCloseしないとUnity Editorがフリーズするという酷い問題があるのですが、このコードは問題なくちゃんとクリーンアップしてくれるようになっています)。

public class DFrameWorker : MonoBehaviour
{
    DFrameWorkerApp app;

    [RuntimeInitializeOnLoadMethod]
    static void Init()
    {
        new GameObject("DFrame Worker", typeof(SampleOne));
    }

    private void Awake()
    {
        DontDestroyOnLoad(gameObject);
    }

    async void Start()
    {
        // setup your controller address
        app = new DFrameWorkerApp("localhost:7313");
        await app.RunAsync();
    }

    private void OnDestroy()
    {
        app.Dispose();
    }
}

[Preserve]
public class SampleWorkload : Workload
{
    public override Task ExecuteAsync(WorkloadContext context)
    {
        Debug.Log("Exec");
        return Task.CompletedTask;
    }

    public override Task TeardownAsync(WorkloadContext context)
    {
        Debug.Log("Teardown");
        return Task.CompletedTask;
    }
}

// Preserve for Unity IL2CPP

internal class PreserveAttribute : System.Attribute
{
}

Editor上の確認だとこんな具合です。

image

ライブラリかツールか

DFrame.Controller、他の設定を入れなければただのウェブアプリなので、ビルド済みのexeとしての提供も可能です。Locustなど他のツールも入れたら、とりあえず実行できる、のに比べると、必ず自分で組み込んでビルドしなきゃいけない。のは欠点に見える。

なのでビルド済みコンテナをDocker Hubかなんかで提供するという案もあったのですが、Workerはどうしても自分で組み込んでビルドする必要があるので、そこだけ省けても利点あるのかな?と考えて、最終的に却下しました。かわりに DFrameApp.Run の一行だけでController+Workerの同居が起動できるようにして、最初の一歩の面倒臭さをライブラリデザインの工夫で乗り切ることにしました。Controller自体も、Microsoft.NET.Sdk.Webではなく、コンソールアプリケーションのテンプレートのMicrosoft.NET.Sdkから起動できるようにしました。

DFrame.Controllerがライブラリとして提供されていることのメリットは、コンフィグが通常のコードやASP.NETの仕組みに乗っかったほうが圧倒的にシンプルになります。DIで好きなロガーを設定して、URLの指定やSSLなどもappsettings.jsonで行うのは、大量の複雑怪奇なコマンドラインオプションよりもずっと良いでしょう。

ログの永続化処理も、プラグイン的に用意するのではなく、普通にDIでインジェクトしてもらう(IExecutionResultHistoryProviderというものが用意されていて、これを実装したものをDIに登録してもらえば、結果をデータベースに入れたり時系列DBに入れたりして統計的な参照ができるようになります)ほうが、使いやすいはずです。

Blazor Server + MagicOnion

DFrame.ControllerはBlazor ServerとMagicOnion(grpc-dotnet)が同居した構成になっています。これは中々面白い構成で、Web UIとMagicOnion(Server側)が同じメモリを共有しているので、末端のMagicOnion(Client側)の変更をダイレクトにC#だけを通してブラウザにまで届けているんですね。逆もしかりで、APIからのアクセス含めて、全てがリアルタイムに伝搬して画面も同期しているのですが、普通にやるとかなり複雑怪奇になるはずが、かなりシンプルに実装できています。

と、いうわけで、Cysharpではこの組み合わせに可能性を感じていて、別のサービスも同種のアーキテクチャで絶賛制作中なので興味ありましたら以下略。

紆余曲折

最初のバージョンは2年ぐらい前に作っていました。コンセプトは「自己分裂する分散バッチフレームワーク」ということで、自分自身のコピーを動的に作って無限大に分散して実行していくというもので。分散のための基盤としてKubernetesを使って。クラウドネイティブ!かっこいい!そして、一応動くものはできていました。あとは仕上げていくだけ、といったところで、放置していました。完成させなきゃ、と思いつつ、内心薄々あんまいい感じではないな、と思っていたため手が進まず無限放置モードへ。そして時が流れ、社内でもがっつり使うことになり引っ張り出されてきたそれは、やはりあまりいい感じではなく。で、最終的に言われたんですね、そもそも分裂機能いらなくね?と。

それでようやく気づくわけです、コンセプトから完全に間違っているからうまくいくわけがない!

反省として良くなかった理由としては、まず、現代のクラウドコンピューターを過大に評価していた。「自己分裂する」のは、一瞬で無限大にスケールして即起動、そして終わったら即終了、ならば、まぁそれでいいんですが、現実のスケールする時間はそんなに立派じゃない。サーバーレスといいつつ、別に1リクエスト毎にコンテナが起動して処理するわけはなく、常駐してリクエストを待つ。そりゃそうだ、と。自己分裂のコンセプトだと、分裂コストが重たいのは否めない。

もう一つは分裂するためのコードがDFrame内に記述されている。Kuberentesをコントロールするコードがたっぷり入ってしまって。そのせいでコードサイズが膨らんでしまったし、使う方も複雑なコンフィグをDFrame側に埋めなきゃいけなくなってしまった。これは二重にイケてない。作るのも複雑で、使うのも複雑ですからね、いいところがない……。

と、いうわけで、最初のかっこいいコンセプトを否定して、自己分裂しない。単純に繋ぎに行くだけ。としたことで、頭を抱えてうまくいかないと感じていた行き詰まりは解消したのでした。

まとめ

もう少し早くに作って提供したかった、という後悔がめっちゃあるのですが、同時に .NET 6だから出来たという要素もめっちゃあるので(パラメーター渡しの仕組みなどはConsoleAppFramework v4の設計の経験からスムーズに実装できた)、しょーがない。という気もする。Blazor Serverなどの進化も必要だったし。

しかし↑で書いたとおり最初に立てたコンセプトが間違っていて、長いこと軌道修正できず放置してしまっていたというのは個人的には割と手痛い経験です……。まぁ、間違ったコンセプトのまま進行してしまうというのは別によくあるので、それはしょーがないものとして別にいいんですが、自力で気づいてパーッと作り上げられてたらなあ、みたいな、みたいな。。。

ともあれ、完成したものとしてはかなり良い感じで(私の出すものとしては珍しくUIもちゃんとついているし!←UI作業は他の人に助力を請うてます)、ちょっとニッチ感もありますが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