CysharpのOSS Top10まとめ / Ulid vs .NET 9 UUID v7 / MagicOnion
- 2024-11-19
「CysharpのOSS群から見るModern C#の現在地」というタイトルでセッションしてきました。
作りっぱなし、というわけではないですが(比較的メンテナンスしてるとは思います!)、リリースから年月が経ったライブラリをどう思っているかは見えないところありますよね、というわけで、その辺を軽く伝えられたのは良かったのではないかと思います。
この中だと非推奨に近くなっているのがZStringとUlidでしょうか。
Ulid vs .NET 9 UUID v7
スライドにも書きましたが、ULIDをそこそこ使ってきての感想としては、「Guidではないこと」が辛いな、と。独自文字列形式とか要らないし。そんなわけで私はむしろUUID v7のほうを薦めたいレベルだったりはします。.NET 9からGuid.CreateVersion7()
という形で、標準で生成できるようになりました。
パフォーマンス的なところは些細なことなので問題ないのですが、 .NET 9未満との互換性が取れないのは厳しいところかもしれません。というわけで、自作のV7実装を用意してあげるといいでしょう。以下に置いておきますのでどうぞ(コードのベースはdotnet/runtimeのCCreateVersion7です)
public static class GuidEx
{
private const byte Variant10xxMask = 0xC0;
private const byte Variant10xxValue = 0x80;
private const ushort VersionMask = 0xF000;
private const ushort Version7Value = 0x7000;
public static Guid CreateVersion7() => CreateVersion7(DateTimeOffset.UtcNow);
public static Guid CreateVersion7(DateTimeOffset timestamp)
{
// 普通にGUIDを作る
Guid result = Guid.NewGuid();
// 先頭48bitをいい感じに埋める
var unix_ts_ms = timestamp.ToUnixTimeMilliseconds();
// GUID layout is int _a; short _b; short _c, byte _d;
Unsafe.As<Guid, int>(ref Unsafe.AsRef(ref result)) = (int)(unix_ts_ms >> 16); // _a
Unsafe.Add(ref Unsafe.As<Guid, short>(ref Unsafe.AsRef(ref result)), 2) = (short)(unix_ts_ms); // _b
ref var c = ref Unsafe.Add(ref Unsafe.As<Guid, short>(ref Unsafe.AsRef(ref result)), 3);
c = (short)((c & ~VersionMask) | Version7Value);
ref var d = ref Unsafe.Add(ref Unsafe.As<Guid, byte>(ref Unsafe.AsRef(ref result)), 8);
d = (byte)((d & ~Variant10xxMask) | Variant10xxValue);
return result;
}
// GuidにはTimestamp部分を取り出すメソッドがないので、これも用意してあげると便利
public static DateTimeOffset GetTimestamp(in Guid guid)
{
// エンディアンについては特に考慮してません
ref var p = ref Unsafe.As<Guid, byte>(ref Unsafe.AsRef(in guid));
var lower = Unsafe.ReadUnaligned<uint>(ref p);
var upper = Unsafe.ReadUnaligned<ushort>(ref Unsafe.Add(ref p, 4));
var time = (long)upper + (((long)lower) << 16);
return DateTimeOffset.FromUnixTimeMilliseconds(time);
}
}
UUID v7のよくあるユースケースはDBの主キーにGUID(UUID v4)の代わりに使う、ということです。UUID v4だとランダムに配置されるので断片化して、auto incrementの主キーに比べると色々と遅くなる。それがv7だとランダムの性質を持ちつつも配置場所はタイムスタンプベースなのでauto incrementと同様になるため性能劣化がない。
という理屈を踏まえたうえで、.NETのUUID v7事情を踏まえると単純に置き換えるだけで良い、とはなりません。
GUIDは内部的なバイナリデータとしてはリトルエンディアンで保持していて、出力時に切り分けるというデザインになっています(無指定の場合はlittleEndianでの出力)。
public readonly struct Guid
{
public byte[] ToByteArray()
public byte[] ToByteArray(bool bigEndian)
public bool TryWriteBytes(Span<byte> destination)
public bool TryWriteBytes(Span<byte> destination, bool bigEndian, out int bytesWritten)
}
String(char36)として格納するなら気にしなくてもいいのですが、GUID型やバイナリ型としてデータベースに格納する時は、UUID v7に関してはビッグエンディアンで書き出さないと、ソート可能にならない非常に都合が悪い。これのハンドリングは言語のデータベースドライバーライブラリの責務となっています。
代表的なライブラリを見ていくと、MySQLのmysqlconnector-netはコネクションストリングで GuidFormat=Binary16
を指定することでbig-endianでBINARY(16)に書き込む設定となります。
PostgreSQLの場合、npgsqlのGuidUuidConverterが常にbigEndianとして処理するようになっているようです。
ではMicrosoft SQL Serverはどうかというと、ばっちしlittle-endianです。ダメです。というわけで、性能を期待してCreateVersion7を使うと、逆に断片化して遅くなるような憂き目にあいます。
こちらはdotnet/SqlClientのdiscussions#2999で議論されているようなので、成り行きに注目ということで。今までとの互換性などを考えると一括でbigにしてしまえばいいじゃん、というわけにもいかないしで、中々素直にはいかないかもしれませんね……。
なお、このことは別に.NET 9がリリースされる前にもわかっていたことなのに(私でもダメだという状況は把握していた)、リリースされるまでアクションが全く起きないというところに、今のSQL Serverへのやる気を感じたりなかったり。
MagicOnion
イベントではCysharpの @mayuki さんからMagicOnionの入門セッションもありました!
MagicOnionも2016年の初リリース、2018年のリブート(v2)、googleのgRPC C Coreからgrpc-dotnetベースへの変更、クライアントのHttpClientベースへの変更など、内部的には色々変わってきたし機能面でも磨かれてきています。まだまだ次のアップデートが控えている、最前線で戦える強力なフレームワークとなっています!
.NET 9 AlternateLookup によるC# 13時代のUTF8文字列の高速なDictionary参照
- 2024-08-29
.NET 9 から辞書系のクラス、Dictionary
, ConcurrentDictionary
, HashSet
, FrozenDictionary
, FrozenSet
に GetAlternateLookup<TKey, TValue, TAlternate>()
というメソッドが追加されました。今までDictionaryの操作はTKey経由でしかできませんでした。それは当たり前、なのですが、困るのが文字列キーで、これはstringでも操作したいし、ReadOnlySpan<char>
でも操作したくなります。今まではReadOnlySpan<char>
しか手元にない場合はToStringでstring化が必須でした、ただたんにDictionaryの値を参照したいだけなのに!
その問題も、.NET 9から追加されたGetAlternateLookup
を使うと、辞書に別の検索キーを持たせることが出来るようになりました。
var dict = new Dictionary<string, int>
{
{ "foo", 10 },
{ "bar", 20 },
{ "baz", 30 }
};
var lookup = dict.GetAlternateLookup<ReadOnlySpan<char>>();
var keys = "foo, bar, baz";
// .NET 9 SpanSplitEnumerator
foreach (Range range in keys.AsSpan().Split(','))
{
ReadOnlySpan<char> key = keys.AsSpan(range).Trim();
// ReadOnlySpan<char>でstring keyの辞書のGet/Add/Removeできる
int value = lookup[key];
Console.WriteLine(value);
}
ところでSplitは、通常のstringのSplitは配列とそれぞれ区切られたstringをアロケーションしてしまいますが、.NET 8から、ReadOnlySpan<char>
に対して固定個数のSplitができるMemoryExtensions.Splitが追加されました。.NET 9では、更にSpanSplitEnumeratorを返すSplitが新たに追加されています。これにより一切の追加のアロケーションなく、元の文字列からReadOnlySpan<char>
を切り出すことができます。
そうして取り出したReadOnlySpan<char>
のキーで参照するために、GetAlternateLookup
が必要になってくるわけです。
使い道としては、例えばシリアライザーは頻繁にキーと値のルックアップが必要になります。私の開発しているMessagePack for C#では、高速でアロケーションフリーなデシリアライズのために、複数の戦略を採用しています。その一つはUTF8の文字列を8バイトずつのオートマトンとして扱うAutomataDictionary、この部分は更にIL EmitやSource Generatorではインライン化して埋め込まれて辞書検索もなくしています。もう一つはAsymmetricKeyHashTableという機構で、これは同一の対象を表す2つのキーで検索可能にしようというもので、内部的には byte[]
と ArraySegment<byte>
で検索できるような辞書を作っていました。
// MessagePack for C#のもの
internal interface IAsymmetricEqualityComparer<TKey1, TKey2>
{
int GetHashCode(TKey1 key1);
int GetHashCode(TKey2 key2);
bool Equals(TKey1 x, TKey1 y);
bool Equals(TKey1 x, TKey2 y); // TKey1とTKey2での比較
}
つまり、今までは、こうした別の検索キーを持った辞書が必要なシチュエーションでは、辞書そのものの自作が必要だったし、パフォーマンスのためには基礎的なデータ構造すら自作を厭わない必要がありましたが、.NET 9からはついに標準でそれが実現するようになりました。
AlternateLookupでも必要なのはIAlternateEqualityComparer<in TAlternate, T>
で、以下のような定義になっています。(IAsymmetricEqualityComparer
と似たような定義なので、また時代を10年先取りしてしまったか)
public interface IAlternateEqualityComparer<in TAlternate, T>
where TAlternate : allows ref struct
where T : allows ref struct
{
bool Equals(TAlternate alternate, T other);
int GetHashCode(TAlternate alternate);
T Create(TAlternate alternate);
}
C# 13から追加された言語機能 allows ref struct によってref struct、つまりSpan<T>
などをジェネリクスの型引数にすることができるようになりました。
基本的にはこれはIEqualityComparer<T>
とセットで実装する必要があります。実際、Dictionary.GetAlternateLookup
ではDictionaryのIEqualityComparer
がIAlternateEqualityComparer
を実装していないと実行時例外が出ます(コンパイル時チェックではありません!)また、EqualityComparerなのにCreate
があるのが少し奇妙ですが、これはAdd操作のために必要だからです。
現状、標準ではIAlternateEqualityComparer
はstring
用しかありません。stringで標準的に使われるEqualityComparerはIAlternateEqualityComparer
を実装していて、ReadOnlySpan<char>
で操作できますが、それ以外は用意されていません。
しかし、現代において現実的に必要なのはUTF8です、ReadOnlySpan<byte>
です。シリアライザーのルックアップで使う、と言いましたが、現代のシリアライザーの入力はUTF8です。ReadOnlySpan<char>
の出番なんてありません。というわけで、以下のようなIAlternateEqualityComparer
を用意しましょう!
public sealed class Utf8StringEqualityComparer : IEqualityComparer<byte[]>, IAlternateEqualityComparer<ReadOnlySpan<byte>, byte[]>
{
public static IEqualityComparer<byte[]> Default { get; } = new Utf8StringEqualityComparer();
// IEqualityComparer
public bool Equals(byte[]? x, byte[]? y)
{
if (x == null && y == null) return true;
if (x == null || y == null) return false;
return x.AsSpan().SequenceEqual(y);
}
public int GetHashCode([DisallowNull] byte[] obj)
{
return GetHashCode(obj.AsSpan());
}
// IAlternateEqualityComparer
public byte[] Create(ReadOnlySpan<byte> alternate)
{
return alternate.ToArray();
}
public bool Equals(ReadOnlySpan<byte> alternate, byte[] other)
{
return other.AsSpan().SequenceEqual(alternate);
}
public int GetHashCode(ReadOnlySpan<byte> alternate)
{
// System.IO.Hashing package, cast to int is safe for hashing
return unchecked((int)XxHash3.HashToUInt64(alternate));
}
}
byte[]
は標準では参照比較になってしまいますが、データの一致で比較したいので、ReadOnlySpan<T>.SequenceEqual
を使います。これは、特にTが幾つかのプリミティブの場合はSIMDを活用して高速な比較が実現されています。ハッシュコードの算出は、高速なアルゴリズムxxHashシリーズの最新版であるXXH3の.NET実装であるXxHash3を用いるのがベストでしょう。これはNuGetからSystem.IO.Hashing
をインポートする必要があります。64ビットで算出するため戻り値はulongですが、32ビット値が必要な場合はxxHashの作者より、ただたんに切り落とすだけで問題ないと言明されているため、intにキャストするだけで済まします。
使う場合の例は、こんな感じです。
// Utf8StringEqualityComparerを設定した辞書を作る
var dict = new Dictionary<byte[], bool>(Utf8StringEqualityComparer.Default)
{
{ "foo"u8.ToArray(), true },
{ "bar"u8.ToArray(), false },
{ "baz"u8.ToArray(), false }
};
var lookup = dict.GetAlternateLookup<ReadOnlySpan<byte>>();
// こんな入力があるとする
ReadOnlySpan<byte> json = """
{
"foo": 0,
"bar": 0,
"baz": 0
}
"""u8;
// System.Text.Json
var reader = new Utf8JsonReader(json);
while (reader.Read())
{
if (reader.TokenType == JsonTokenType.PropertyName)
{
// 切り出したKeyで検索できる
ReadOnlySpan<byte> key = reader.ValueSpan;
var flag = lookup[key];
Console.WriteLine(flag);
}
}
一つ注意なのは、string
とReadOnlySpan<byte>
でAlternateKeyを作ろうとするのはやめたほうが良いでしょう。それだと、常にエンコードが必要になり、悪いとこどりのようになってしまいます(Runeを使ってアロケーションレスで処理するにしても、どちらにせよバイナリ比較だけで済ませられるbyte[]
キーとは比較になりません)。どうしても両方の検索が必要なら、辞書を二つ用意するほうがマシです。
ともあれ、これは私にとっては念願の機能です!色々なバリエーションで、Span対応のためにジェネリクスにもできずに決め打ちで辞書を何度も作ってきました、汎用的に使えるようになったのは大歓迎です。allows ref struct
はジェネリクス定義での煩わしさもありますが(自動判定での付与でも良かったような?)、言語としては重要な進歩です。.NET 9, C# 13、使っていきましょう。現状はまだプレビューですが、11月に正式版がリリースされるはずです。
Microsoft MVP for Developer Technologies(.NET)を再々々々々々々々々々々々々受賞しました
- 2024-07-11
Microsoft MVPは一年ごとに再審査されるのですが、今年も更新しました。2011年から初めて14回目ということで、長い!のですが、引き続きC#の最前線に立ち続けられていると思います。以下、審査用書類に出した、審査期間での実績一覧です。
OSS New
- MagicPhysX
.NET PhysX 5 binding to all platforms(win, osx, linux) for 3D engine, deep learning, dedicated server of gaming. - PrivateProxy
Source Generator and .NET 8 UnsafeAccessor based high-performance strongly-typed private accessor for unit testing and runtime. - Utf8StringInterpolation
Successor of ZString; UTF8 based zero allocation high-peformance String Interpolation and StringBuilder. - R3
The new future of dotnet/reactive and UniRx. - Claudia
Unofficial Anthropic Claude API client for .NET. - Utf8StreamReader
Utf8 based StreamReader for high performance text processing.
OSS Update
- StructureOfArraysGenerator
Structure of arrays source generator to make CPU Cache and SIMD friendly data structure for high-performance code in .NET and Unity. - Ulid
Fast .NET C# Implementation of ULID for .NET and Unity. - ZLogger
Zero Allocation Text/Structured Logger for .NET with StringInterpolation and Source Generator, built on top of a Microsoft.Extensions.Logging. - ZString
Zero Allocation StringBuilder for .NET and Unity. - MessagePack-CSharp
Extremely Fast MessagePack Serializer for C#(.NET, .NET Core, Unity, Xamarin). - ObservableCollections
High performance observable collections and synchronized views, for WPF, Blazor, Unity. - UnitGenerator
C# Source Generator to create value-object, inspired by units of measure. - MemoryPack
Zero encoding extreme performance binary serializer for C# and Unity. - csbindgen
Generate C# FFI from Rust for automatically brings native code and C native library to .NET and Unity. - DFrame
Distributed load testing framework for .NET and Unity. - MessagePipe
High performance in-memory/distributed messaging pipeline for .NET and Unity. - UniTask
Provides an efficient allocation free async/await integration for Unity.
Speaker
- CEDEC 2023 モダンハイパフォーマンスC# 2023 Edition - Speaker Deck
- メタバースプラットフォーム 「INSPIX WORLD」はPHPもC++もまとめてC#に統一! ~MagicOnionが支えるバックエンド最適化手法~ - Speaker Deck
- 他言語がメインの場合のRustの活用法 - csbindgenによるC# x Rust FFI実践事例 - Speaker Deck
Book
世界中見てもこんだけ叩き出してる人間いないので、これだけやってれば、満場一致で更新でいいでしょう。はい。自分で言うのもあれですが。あれ。
期間中で言うとR3が大型タイトル(?)です。また、Updateのほうも大型リニューアルとしてZLogger v2は相当力の入ったものになっています。今年の範囲だと、こないだ出したConsoleAppFramework v5や、近いうちにリリースされる(はず)のMessagePack for C# v3といった計画も控えています。なお、MagicOnionは現在メンテナーじゃないので実績に含めてはいないのですが、引き続きアクティブに開発されています!
ところで、このサイトも地味に更新されていて(自作のC#製静的サイトジェネレーターで作られています、ハンドメイド!)、ついに全文検索が搭載されました!上のほうのインプットボックスがそれになっているので、ぜひ試してみてください。ちょっと引っ掛かり方が変な可能性も高いですが、そこは検索ライブラリの仕様なので、いつか改善されるでしょう。多分きっと。
ConsoleAppFramework v5 - ゼロオーバーヘッド・Native AOT対応のC#用CLIフレームワーク
- 2024-06-13
ConsoleAppFrameworkの完全に新しいバージョンをリリースしました。完全に設計しなおして実装も完全に作り直された、何もかもが新しいフレームワークになっています。設計指針として「Zero Dependency, Zero Overhead, Zero Reflection, Zero Allocation, AOT Safe」を掲げ、もちろん、他を圧倒的に引き離すパフォーマンスを実現しています。
これはコールドスタートアップ・ウォームアップなしでのベンチマークとなっていて、CLIアプリケーションでの実際での利用に最も即したものだと考えています。System.CommandLineと比較すれば280倍!メモリアロケーション量もほかのフレームワークの100~1000倍少なくなっています(表示されている400Bはほぼシステム自体のallocなのでフレームワーク自体は0です)。
このパフォーマンスは、全てをSource Generatorで生成することで実現しました。例えば以下のようなコード。
using ConsoleAppFramework;
// args: ./cmd --foo 10 --bar 20
ConsoleApp.Run(args, (int foo, int bar) => Console.WriteLine($"Sum: {foo + bar}"));
ConsoleAppFrameworkはSource GeneratorがRunで与えられているラムダ式の引数を解析して、Runメソッドそのものを生成します。
internal static partial class ConsoleApp
{
// Generate the Run method itself with arguments and body to match the lambda expression
public static void Run(string[] args, Action<int, int> command)
{
// code body
}
}
通常C#のSource Generatorは属性をクラスかメソッドに与えて、それを元に生成されますが、ConsoleAppFrameworkはメソッドの呼び出しを監視して生成のキーにしています。これはRustのマクロから発想を得ていて、RustにはAttribute-like macros and Function-like macrosといったような分類がありますが、今回のやりかたはFunction-likeなスタイルと言えるでしょう。
実際の生成されるコード全体は以下のようなものになります。
internal static partial class ConsoleApp
{
public static void Run(string[] args, Action<int, int> command)
{
if (TryShowHelpOrVersion(args, 2, -1)) return;
var arg0 = default(int);
var arg0Parsed = false;
var arg1 = default(int);
var arg1Parsed = false;
try
{
for (int i = 0; i < args.Length; i++)
{
var name = args[i];
switch (name)
{
case "--foo":
{
if (!TryIncrementIndex(ref i, args.Length) || !int.TryParse(args[i], out arg0)) { ThrowArgumentParseFailed("foo", args[i]); }
arg0Parsed = true;
break;
}
case "--bar":
{
if (!TryIncrementIndex(ref i, args.Length) || !int.TryParse(args[i], out arg1)) { ThrowArgumentParseFailed("bar", args[i]); }
arg1Parsed = true;
break;
}
default:
// omit...(case-insensitive compare codes)
ThrowArgumentNameNotFound(name);
break;
}
}
if (!arg0Parsed) ThrowRequiredArgumentNotParsed("foo");
if (!arg1Parsed) ThrowRequiredArgumentNotParsed("bar");
command(arg0!, arg1!);
}
catch (Exception ex)
{
Environment.ExitCode = 1;
if (ex is ValidationException or ArgumentParseFailedException)
{
LogError(ex.Message);
}
else
{
LogError(ex.ToString());
}
}
}
static partial void ShowHelp(int helpId)
{
Log("""
Usage: [options...] [-h|--help] [--version]
Options:
--foo <int> (Required)
--bar <int> (Required)
""");
}
}
特にひねりもなさそうなド直球ドシンプルなコードに見えるのではないでしょうか。それが大事です!単純なコードであればあるほど速い!フレームワークなのに単純、だから速い。というのが目指している姿です。余計なコードはいっさいなく、メソッド本体に全ての処理が集約されているので、フレームワークとしてゼロ・オーバーヘッド、最適化した手書きコードと同等の速度を実現しました。
CLIアプリケーションは通常、コールドスタートからの単発の実行になるため、動的コード生成(IL.EmitやExpression.Compile)やキャッシュ(ArrayPoolやDictionary生成による以降のマッチング高速化)が効きにくい分野です。それらを作ったほうがオーバーヘッドが大きいですから。かといってリフレクションなどをそのまま使うのは、それはそれで低速です。ConsoleAppFrameworkは全ての必要な処理をインライン生成することによって、単発実行での速度が圧倒的に高速化されています。
リフレクションもないのでNative AOTとの親和性も圧倒的に高く、コールドスタートアップ速度におけるC#の欠点は一切なくなります。
もう一つ特徴として、ConsoleApp
クラスを含めて、全てがSource Generatorによって生成されるために、ConsoleAppFramework自体も含めて依存が全くありません。
コンソールアプリケーションを作るシチュエーションは多用です。多数の依存を持った大きなバッチアプリケーションの場合もあれば、超単機能の小さなコマンドの場合もあります。小さなコマンドを作りたい時には、少しも追加の依存を入れたくはないでしょう。それこそ Microsoft.Extensions.Hosting
を参照すると、それだけで数十個の依存DLLが追加されてしまいます!ConsoleAppFrameworkなら、自身も含めて依存ゼロです。
依存ゼロの良いところは明らかにバイナリサイズが小さくなることです。特にNative AOTではバイナリサイズは気になるところですが、ConsoleAppFrameworkなら追加のコストはほぼゼロです。
そしてもちろん、単機能ではフレームワークとしては物足りない、ということで以下のような機能が実現されています。十分に充実した機能群は、他のフレームワークと比べても全く見劣りしないはずです。
- SIGINT/SIGTERM(Ctrl+C) handling with gracefully shutdown via
CancellationToken
- Filter(middleware) pipeline to intercept before/after execution
- Exit code management
- Support for async commands
- Registration of multiple commands
- Registration of nested commands
- Setting option aliases and descriptions from code document comment
System.ComponentModel.DataAnnotations
attribute-based Validation- Dependency Injection for command registration by type and public methods
Microsoft.Extensions
(Logging, Configuration, etc...) integration- High performance value parsing via
ISpanParsable<T>
- Parsing of params arrays
- Parsing of JSON arguments
- Help(
-h|--help
) option builder - Default show version(
--version
) option
生成されるコードはモジュール化されていて、コードが使用する機能によって変化し、常にその機能の実現において最小のコードが生成されるようになっています。それにより多機能と高速さを両立しています。また、どの機能も最速で実行できるよう念入りに調整してあるため、全機能が有効化されてもなお、他とは比較にならないほどに高速です。
余談ですが、デリゲートはデリゲート生成というアロケーションがあります。つまり真のゼロアロケーション・ゼロオーバーヘッドじゃないじゃん、と言うことができます。しかし、ちゃんとConsoleAppFrameworkは真のゼロアロケーションを実現する仕組みもちゃんと用意されています。以下のように静的関数をfunction pointerとして渡してください。
unsafe
{
ConsoleApp.Run(args, &Sum);
}
static void Sum(int x, int y) => Console.Write(x + y);
すると、以下のような delegate* managed<>
(あまり見慣れないと思いますが、managed function pointerという言語機能がC#には追加されているのです)の引数を持ったメソッドの実体を生成します。
public static unsafe void Run(string[] args, delegate* managed<int, int, void> command)
これならもう完全に文句なくゼロアロケーション・ゼロオーバーヘッドです!
実用的には別にデリゲートでも全く関係ないレベルですが、完全に完璧を目指す執拗な姿勢により、対応を入れました。これでどの角度からも絶対に文句は付けられないでしょう。
高速な値変換
文字列からC#の値に変換する最速の手段はなんでしょうか?intだったら int.TryParse
ですよね。では、他は?intは決め打ちだからいいとして、string -> T(あるいはobject)を汎用的にするには?というと少し難しい話になってきて、昔はTypeConverterというものが使われてきました。もちろん、パフォーマンスは悪いです。
あるいは最近はJsonSerializerが標準搭載されているから、それに丸投げしてみるというのもアリでしょう。もちろん、パフォーマンスは決して良くはありません。特にコールドスタートアップで考えるとJsonSerializerのキャッシュ処理が必要になってきて、単発実行においてはかなりのオーバーヘッドが足されてしまいます。
ConsoleAppFrameworkではIParsable, ISpanParsableを採用しています。これは .NET 7から追加され、C# 11で追加されたstatic abstract interfaceが使用されています。
public interface IParsable<TSelf> where TSelf : IParsable<TSelf>?
{
static abstract TSelf Parse(string s, IFormatProvider? provider);
static abstract bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(returnValue: false)] out TSelf result);
}
C# 11になってようやく汎用的な 「文字列 -> 値」変換処理が実現するようになったのです! ConsoleAppFrameworkでは .NET 8/C# 12 を最小実行可能環境としているため、問答無用で採用しました。HalfやInt128などの .NET 8で登場した新しい型や、自分で定義する方もIParsable<T>
を実装すればそれを使って高速に処理されます!
とはいえ、intなどの基本型はそもそもSource Generatorがintであることを知っているので、直接int.TryParseのように直接実行されるようになっていたりはします。
なお、値のバインディングに関してはparams arrayやデフォルト値にも対応しています。
ConsoleApp.Run(args, (
[Argument]DateTime dateTime, // Argument
[Argument]Guid guidvalue, //
int intVar, // required
bool boolFlag, // flag
MyEnum enumValue, // enum
int[] array, // array
MyClass obj, // object
string optional = "abcde", // optional
double? nullableValue = null, // nullable
params string[] paramsArray
) => { });
ちょうどC# 12からラムダ式にデフォルト値やparamsが使用できるようになりました、ということが反映されています。
ドキュメントコメントによる定義
DescriptionやAliasの追加は、今までは、あるいは他のフレームワークでは属性を使って記述していました。しかし、それは少しメソッドの各パラメーターに属性、更にかなり長めの文字列を付与するのは、メソッドとしてかなり読みづらくなります。
そこでConsoleAppFrameworkではドキュメントコメントを活用することにしました。
class Commands
{
/// <summary>
/// Display Hello.
/// </summary>
/// <param name="message">-m, Message to show.</param>
public static void Hello(string message) => Console.Write($"Hello, {message}");
}
これは
Usage: [options...] [-h|--help] [--version]
Display Hello.
Options:
-m|--message <string> Message to show. (Required)
というコマンドになります。ドキュメントコメントであれば、多くの引数があっても自然な見た目を保つことが可能です。この手法が取れるのはSource Generatorで生成するため.xmlは不要でコードから直接読み取れることの強みでもありますね。(ただしSource Generatorでドキュメントコメントをあらゆる環境で読み取れるようにするには若干のハックが必要でした)
複数コマンドの追加
ConsoleApp.Run
は単独コマンドのためのショートカットでしたが、複数のコマンドやネストされているサブコマンドの追加も可能です。例えば以下のような設定を行った場合の生成を例を見ていきます。
var app = ConsoleApp.Create();
app.Add("foo", () => { });
app.Add("foo bar", (int x, int y) => { });
app.Add("foo bar barbaz", (DateTime dateTime) => { });
app.Add("foo baz", async (string foo = "test", CancellationToken cancellationToken = default) => { });
app.Run(args);
このコードのAddは、まず以下のように展開されます。Source Generatorが全てのAddされるラムダ式の型を知っているので、それぞれ固有の型を持ったフィールドに割り当てます。
partial struct ConsoleAppBuilder
{
Action command0 = default!;
Action<int, int> command1 = default!;
Action<global::System.DateTime> command2 = default!;
Func<string, global::System.Threading.CancellationToken, Task> command3 = default!;
partial void AddCore(string commandName, Delegate command)
{
switch (commandName)
{
case "foo":
this.command0 = Unsafe.As<Action>(command);
break;
case "foo bar":
this.command1 = Unsafe.As<Action<int, int>>(command);
break;
case "foo bar barbaz":
this.command2 = Unsafe.As<Action<global::System.DateTime>>(command);
break;
case "foo baz":
this.command3 = Unsafe.As<Func<string, global::System.Threading.CancellationToken, Task>>(command);
break;
default:
break;
}
}
}
これによりDelegateを保持しておくための配列や、DelegateのままInvokeするリフレクション/ボクシングが防げています。
Runでは、string[] args
からコマンドを選択するために定数文字列のswitchが埋め込まれます。
partial void RunCore(string[] args)
{
if (args.Length == 0)
{
ShowHelp(-1);
return;
}
switch (args[0])
{
case "foo":
if (args.Length == 1)
{
RunCommand0(args, args.AsSpan(1), command0);
return;
}
switch (args[1])
{
case "bar":
if (args.Length == 2)
{
RunCommand1(args, args.AsSpan(2), command1);
return;
}
switch (args[2])
{
case "barbaz":
RunCommand2(args, args.AsSpan(3), command2);
break;
default:
RunCommand1(args, args.AsSpan(2), command1);
break;
}
break;
case "baz":
RunCommand3(args, args.AsSpan(2), command3);
break;
default:
RunCommand0(args, args.AsSpan(1), command0);
break;
}
break;
default:
ShowHelp(-1);
break;
}
}
C#で文字列から特定のコードにジャンプする最速の手段は、switchで文字列定数を使うことです。展開されるアルゴリズムは何度か修正されていて、C# 12ではPerformance: faster switch over string objects · Issue #56374 · dotnet/roslynとして、まず長さをチェックした後に、差が存在する1文字だけを絞るといった形でマッチさせます。
Dictionary<string, T>
からのマッチなどよりも高速で初期化時間もアロケーションもないのが、C#コンパイラの助けを借りれる強みであり、そうした処理ができるのはC#コードそのものを出力するSource Generator方式だけです。なので絶対に最速なわけです。
DIとCancellationTokenとライフタイム
引数にはコマンドのパラメーターとして有効になるもの以外に、DI経由で渡したいもの(例えばILogger<T>
やOption<T>
など)や、特別扱いする型としてConsoleAppContext
とCancellationToken
を定義することができます。
DIによる受取は、コンソールアプリケーションがASP.NETのプロジェクトなどと設定ファイルを共有したいようなシチュエーションで有効でしょう。そうした場合のために、 Microsoft.Extensions.Hosting と連動させることが可能です。
また、CancellationToken
を渡した場合は、SIGINT/SIGTERM/SIGKILL(Ctrl+C)をフックするコンソールアプリケーションとしてのライフタイム管理が働くようになります。
await ConsoleApp.RunAsync(args, async (int foo, CancellationToken cancellationToken) =>
{
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
Console.WriteLine($"Foo: {foo}");
});
上記のコードは以下のように展開されます。
using var posixSignalHandler = PosixSignalHandler.Register(ConsoleApp.Timeout);
var arg0 = posixSignalHandler.Token;
await Task.Run(() => command(arg0!)).WaitAsync(posixSignalHandler.TimeoutToken);
.NET 6から追加されたPosixSignalRegistrationを使って、SIGINT/SIGTERM/SIGKILLがフックし、CancellationTokenをキャンセルの状態にします。と同時に、即時終了を抑制します(通常Ctrl + Cを押すと即座にAbortされますが、Abortされなくなります)。
それによりアプリケーションがCancellationTokenを正常にハンドリングする余地を残しています。
ただしCancellationTokenをハンドリングしないと終了命令を無視するだけになってしまい、それはそれで困るので、強制的に終了するタイムアウト時間が設けられています。デフォルトでは5秒に設定されていますが、これは ConsoleApp.Timeout
プロパティで自由に変更できます。もし強制終了をオフにしたい場合は ConsoleApp.Timeout = Timeout.InfiniteTimeSpan
を指定すると良いでしょう。
Task.WaitAsyncは .NET 6 からです。TimeSpanを渡す以外に、CancellationTokenを渡すことも可能なので、単純な数秒後ではなく、WaitAsyncの発火するタイミングをPosixSignalRegistrationが発火した後にTimeout後、といった条件を作ることができました。
フィルターパイプライン
実行の前後をフックする仕組みとしてConsoleAppFrameworkではFilterを採用しています。ミドルウェアパターンとも呼ばれて、特にasync/awaitが使える言語ではよく見かけるパターンだと思います。
internal class NopFilter(ConsoleAppFilter next) : ConsoleAppFilter(next) // ctor needs `ConsoleAppFilter next` and call base(next)
{
// implement InvokeAsync as filter body
public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken)
{
try
{
/* on before */
await Next.InvokeAsync(context, cancellationToken); // invoke next filter or command body
/* on after */
}
catch
{
/* on error */
throw;
}
finally
{
/* on finally */
}
}
}
この設計パターンは本当に優れていて、実行をフックしたいような仕組みを用意したい場合は、このパターンを採用することを絶対にお薦めします。GoFの時代にasync/awaitがあったら、重要なデザインパターンとして載っていたことでしょう。
ReadMeにはフィルターでできることとして、実行時間のロギング・ExitCodeのカスタマイズ・多重実行禁止・認証処理などを紹介しています。Task InvokeAsync
一つで様々な処理を実現できる素晴らしさ。誰がこのパターンを最初に発見したんでしょうね?
フィルターの設計にも色々な手法があるのですが、ConsoleAppFrameworkでは最もパフォーマンスの出る方法を選びました。コンストラクターでNextを受け取ることと、コードジェネレート時に静的に全ての利用するフィルターが決定するので(動的な追加は許可していません)、全てを埋め込んで組み立てています。
app.UseFilter<NopFilter>();
app.UseFilter<NopFilter>();
app.UseFilter<NopFilter>();
app.UseFilter<NopFilter>();
app.UseFilter<NopFilter>();
// The above code will generate the following code:
sealed class Command0Invoker(string[] args, Action command) : ConsoleAppFilter(null!)
{
public ConsoleAppFilter BuildFilter()
{
var filter0 = new NopFilter(this);
var filter1 = new NopFilter(filter0);
var filter2 = new NopFilter(filter1);
var filter3 = new NopFilter(filter2);
var filter4 = new NopFilter(filter3);
return filter4;
}
public override Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken)
{
return RunCommand0Async(context.Arguments, args, command, context, cancellationToken);
}
}
これにより、中間の配列のアロケーションや、ラムダ式のキャプチャのアロケーションは発生せず、フィルターの個数 + 1(メソッド本体のラップ)の追加のアロケーションのみが追加のコストとなります。また、戻り値のTaskは、同期的に完了する場合はTask.Completed相当のものが使われることになるため、これをValueTaskにする必要はありません。
コンストラクターでNextを受け取ってbaseに渡すだけのコードも、primary constructorのお陰で簡単に書けるようになりました。
コマンドライン引数の構文について
コマンドライン引数はスペース区切りでstring[] args
に渡されるということ以外は、完全に自由です。なんとなく --
や-
がパラメーター識別子だと思われていますが、実際はなんでもいいし、なんだったらWindowsは/
が使われることも多かった。
とはいえ、ある程度標準的なルールは存在します。代表的なものはPOSIX規格と、その拡張であるGNU Coding Standardsでしょうか。ConsoleAppFrameworkでも、POSIX規格にある程度は従いつつ、GNU Coding Stadardsで定義されている --version
と --help
を組み込みのオプションとしています。名前も --lower-kebab-case
がデフォルトです。
「ある程度」というのは、つまり、完全に従っているというわけではありません。規格にせよ伝統的な慣習にせよ、古いルールは現代的な観点から許容すべきでないルールも少なくありません。例えば-x
と-X
が区別されて異なる挙動をするというのは絶対にナシでしょう。あるいは広く使われているものでもバンドリング、-fdx
は-f
, -d
, -x
と解釈されるといったものも、あまり良いとは思えません。バンドリングに関しては、パフォーマンス上でも、パース処理を複雑化させるため問題があります。
ConsoleAppFrameworkで優先しているのはパフォーマンスであるため、パフォーマンス上問題を引き起こす可能性のあるルールに関しては採用していません。大文字小文字の区別はしないようにしていますが、これは小文字のマッチングを先に行った後、フォールバックとしてcase-insensitiveのマッチングを行うため、実用上のパフォーマンスの低下は起こらないと考えています。
System.CommandLine のコマンド ライン構文の概要 - .NET | Microsoft Learnを見ると、System.CommandLineがかなり柔軟な構文解釈を可能にしていることがわかるでしょう。それはとても良いことです!良いことではあるのですが、パフォーマンス劣化を引き起こしているなら問題です。そして実際、System.CommandLineの性能はベンチマーク結果から明らかなとおり、非常に悪い。これはちょっといただけません。
迷走を続けているSystem.CommandLineは、どうやら再度分解されて実装を変更するようです。Resetting System.CommandLineということで、POSIX規格のパーサーとしての小さなコアを.NET 9 あるいは .NET 10で標準採用されることを目指している、ようです。
もしそれらが標準採用されたとしても、パフォーマンスの観点からは、ConsoleAppFrameworkを超えることは絶対にないでしょう。
v4からの互換性について
破壊的変更!破壊的変更を厭わないことはいいことです、イノベーションを妨げない、常に先端的であり続けるために必要なことです。C#の先端を走り続けるのはCysharpのアイデンティティでもあります。と、同時に、もちろん大迷惑なことです。今回の v4 -> v5 に関しては .NET Frameworkから.NET Coreに変わったような、 ASP.NET から ASP.NET Coreに変わったような、そんな変革なのでしょうがない、どうしても必要な変化だったのだ……。
ただし、実際のところは別にそこまで大きく変わっているわけではなかったりもします。名前変換処理(lower-kebab-case)のロジックは同じものを使っているため、名前がズレてしまうといったこともないので、コンパイルエラー出たメソッド名をマッピングするだけ、ではあります。そのぐらいのことはよくある、よね?
var app = ConsoleApp.Create(args); app.Run(); -> var app = ConsoleApp.Create(); app.Run(args);
app.AddCommand/AddSubCommand -> app.Add(string commandName)
app.AddRootCommand -> app.Add("")
app.AddCommands<T> -> app.Add<T>
app.AddSubCommands<T> -> app.Add<T>(string commandPath)
app.AddAllCommandType -> NotSupported(use Add<T> manually)
[Option(int index)] -> [Argument]
[Option(string shortName, string description)] -> Xml Document Comment
ConsoleAppFilter.Order -> NotSupported(global -> class -> method declrative order)
ConsoleAppOptions.GlobalFilters -> app.UseFilter<T>
全体的には、より単純化された、ようするに「良くなった」と思ってもらえる仕様変更だとは思います。
また、標準で Microsoft.Extensions.Hosting
に乗っからなくなったというのは大きな違いですが、これは一行追加するだけで解決します。Hostingの上に乗っかるというのは、つまりはHostingで生成するServiceProviderを使う、それだけのことなのだ、と。実際はLifetime管理もありますが、それはConsoleAppFrameworkが自前でやっているので、DIのためのServiceProviderだけ渡してやれば実用上の違いはありません。
using var host = Host.CreateDefaultBuilder().Build(); // use using for host lifetime
ConsoleApp.ServiceProvider = host.ServiceProvider;
v4ではConsoleAppBase
を継承させていましたが、v5ではPOCOでよくなりました。代わりにConsoleAppContext
やCancellationToken
に関してはコンストラクタインジェクションで受け取ってください。これも、C# 12のprimary constructorのお陰でそんなに手間じゃなくなりました。これもベースクラスを必要とする仕組みをやめた理由の一つになります。
真のIncremental Generator
Incremental Generatorって、ただたんに何も考えずに作るとIncrementalにならないのです。というのは知識として知ってはいたのですが、今まで見て見ぬふりをしていました!ありがたいことに指摘が入ったので、重い腰を上げてちゃんと抜本的な対応を取ることにしました。
まず最初にやらなければならないのは、Incrementalであるかどうかを視認できるようにすることです。普通に動かしていても内部状態は全く見えないので、ユニットテストで状態をチェックできるようにすることが大事です。例えばこんなユニットテストが書かれています。
[Fact]
public void RunLambda()
{
var step1 = """
using ConsoleAppFramework;
ConsoleApp.Run(args, int () => 0);
""";
var step2 = """
using ConsoleAppFramework;
ConsoleApp.Run(args, int () => 100); // body change
Console.WriteLine("foo"); // unrelated line
""";
var step3 = """
using ConsoleAppFramework;
ConsoleApp.Run(args, int (int x, int y) => 100); // change signature
Console.WriteLine("foo");
""";
var reasons = CSharpGeneratorRunner.GetIncrementalGeneratorTrackedStepsReasons("ConsoleApp.Run.", step1, step2, step3);
reasons[0][0].Reasons.Should().Be("New");
reasons[1][0].Reasons.Should().Be("Unchanged");
reasons[2][0].Reasons.Should().Be("Modified");
VerifySourceOutputReasonIsCached(reasons[1]);
VerifySourceOutputReasonIsNotCached(reasons[2]);
}
Incremental Generatorは trackIncrementalGeneratorSteps: true
というオプションを渡してDriverを動かすと、各ステップの状態の結果が見えるようになります。IncrementalStepRunReason
にはNew
, Unchanged
, Modified
, Cached
, Removed
という状態があり、最終出力の手前がUnchanged
かCached
なら、出力処理がスキップされます。
上のユニットテストではstep2では出力コードに変更のない箇所に変更が加わっただけなので、Unchangedです。なので最終段ではCachedになっていました。step3は再生成が必要な変更が加わっているのでModifiedとなり、ソースコード生成処理まで走ります。
IncrementalStepRunReason
はTrackedSteps
から取り出すことが出来るのですが、そのままだとちょっと読みづらすぎるので、確認しやすいように整形しています、というのがGetIncrementalGeneratorTrackedStepsReasons
というユーティリティメソッドです。
public static (string Key, string Reasons)[][] GetIncrementalGeneratorTrackedStepsReasons(string keyPrefixFilter, params string[] sources)
{
var parseOptions = new CSharpParseOptions(LanguageVersion.CSharp12); // 12
var driver = CSharpGeneratorDriver.Create(
[new ConsoleAppGenerator().AsSourceGenerator()],
driverOptions: new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true))
.WithUpdatedParseOptions(parseOptions);
var generatorResults = sources
.Select(source =>
{
var compilation = baseCompilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(source, parseOptions));
driver = driver.RunGenerators(compilation);
return driver.GetRunResult().Results[0];
})
.ToArray();
var reasons = generatorResults
.Select(x => x.TrackedSteps
.Where(x => x.Key.StartsWith(keyPrefixFilter) || x.Key == "SourceOutput")
.Select(x =>
{
if (x.Key == "SourceOutput")
{
var values = x.Value.Where(x => x.Inputs[0].Source.Name?.StartsWith(keyPrefixFilter) ?? false);
return (
x.Key,
Reasons: string.Join(", ", values.SelectMany(x => x.Outputs).Select(x => x.Reason).ToArray())
);
}
else
{
return (
Key: x.Key.Substring(keyPrefixFilter.Length),
Reasons: string.Join(", ", x.Value.SelectMany(x => x.Outputs).Select(x => x.Reason).ToArray())
);
}
})
.OrderBy(x => x.Key)
.ToArray())
.ToArray();
return reasons;
}
ごちゃごちゃしてよくわからないという感じですが、つまりそのままだと本当によくわからない代物ということで。Keyに関しては各ステップで .WithTrackingName("ConsoleApp.Run.0_CreateSyntaxProvider")
のような命名規則で付与しています。TrackedStepsがImmutableDictionary
のため列挙の順番が順不同でイマイチ確認しづらいので、番号振ってソートするようにしました。また、複数のRegisterSourceOutputが走っていると(ConsoleAppFrameworkではRun系とBuilder系の2種が動いてる)混線してわかりづらくなるため、keyPrefixとしてフィルタリングするようにしています。
注意すべき点とか、いい感じに作る方法とか、色々説明しておかなければならないことが多いのですが、めちゃくちゃ長くなるので、それはまたの機会ということで……!
まとめ
もともとConsoleAppFrameworkはCysharpの製品ラインでは珍しく、パフォーマンスを重視していたわけではない、という成り立ちがあります。どちらかというと機能面、当時それなりに珍しかったHostingと融合してCLIフレームワークを作るといったコンセプトの立証を主軸に作り上げ、そして一定の成果を挙げました。何回かの改修でHelpがリッチになったりMinimal APIっぽく書けるようになったりもしましたが、どうしても古くささが目立ってきました。
特にCoconaは、ConsoleAppFrameworkの影響を受けつつも、より柔軟で、より強力な機能を備えていてとても素晴らしいライブラリです。このままではConsoleAppFrameworkはただの劣化版ではないか、という意識もありました。自信をもってベストであると薦められないのは心苦しい。というかCoconaを作っているのはCysharpの同僚ですしですの。
なので、今回APIの幾つかは逆にCoconaからの影響を受けつつ([Argument]
など)、全く異なるキャラクターを持ったフレームワークとなるように腐心しました。パースについての項目で説明したように、ConsoleAppFramework v5は柔軟性をある程度犠牲にしているため、豊富な機能が必要ならば、System.CommandLineやCoconaを使用することをお薦めします。
また、パフォーマンスの観点から言うと、本体の実行時間が長ければ長いほどフレームワークのオーバーヘッドなんてどうでもよくはなります。10分、1分、いや、10秒ぐらいかかる処理であるなら、フレームワーク部分が1msだろうと50msだろうと誤差みたいなものでしょう。それはそもそもJITコンパイルにも言えることではありますが。とはいえ、Native AOTだのコールドスタートアップ速度だのがやいやい言われる昨今では、別にそんなもの無視できる程度の話だろう、と一刀両断できるわけでもなく、早いに越したことはないのは間違いないとも言えます。
パフォーマンスや依存性なしといったメリットはもちろんですが、アプローチや設計面でも特異で面白いものになっていると思いますので、是非お試しください!もちろん、実用性もめちゃくちゃ高く、文句なしに必須ライブラリと考えてもらってもいいのではないでしょうか!
R3のコードから見るC#パフォーマンス最適化技法実例とTimeProviderについて
- 2024-05-01
4/27に大阪で開催されたC#パフォーマンス勉強会で「R3のコードから見る実践LINQ実装最適化・コンカレントプログラミング実例」という題でセッションしてきました!
タイトル的にあまりLINQでもコンカレントでもなかったかな、とは思いますが、R3を題材に、具体的なコードをもとにした最適化技法の紹介という点では面白みはあったのではないかと思います。
Rxの定義
R3は、やや挑発的な内容を掲げていることもあり、R3は「Rxではない」みたいなことを言われることもあります。なるほど!では、そもそも何をもってRxと呼ぶのか、呼べるのか。私は「Push型でLINQ風のオペレーターが適用できればRx」というぐらいの温度感で考えています。もちろん、R3はそれを満たしています。
mutable struct
の扱いと同じく、あまり教条主義的にならず、時代に合わせて、柔軟により良いシステムを考えていきましょう。コンピュータープログラミングにおいて、伝統や歴史を守ることは別に大して重要なことではないはずです。
TimeProvider DeepDive
TimeProviderについて、セッションでも話しましたが、大事なことなのでもう少し詳しくいきましょう。TimeProviderにまず期待するところとしては、ほとんどがSystemClock.Now
、つまりオレオレDateTime.Now
生成器の代わりを求めているでしょう。それを期待しているとTimeProviderの定義は無駄に複雑に見えます。しかしTimeProvider
を分解してみると、これは4つの時間を司るクラスの抽象層になっています。
public abstract class TimeProvider
{
// TimeZoneInfo
public virtual TimeZoneInfo LocalTimeZone => TimeZoneInfo.Local;
// DateTimeOffset
public virtual DateTimeOffset GetUtcNow() => DateTimeOffset.UtcNow;
public DateTimeOffset GetLocalNow() =>
// Stopwatch
public virtual long TimestampFrequency => Stopwatch.Frequency;
public virtual long GetTimestamp() => Stopwatch.GetTimestamp();
public TimeSpan GetElapsedTime(long startingTimestamp, long endingTimestamp) =>
public TimeSpan GetElapsedTime(long startingTimestamp) => GetElapsedTime(startingTimestamp, GetTimestamp());
// System.Threading.Timer
public virtual ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) =>
}
public interface ITimer : IDisposable, IAsyncDisposable
{
bool Change(TimeSpan dueTime, TimeSpan period);
}
4つの時間を司るクラス、すなわちTimeZoneInfo、DateTimeOffset、Stopwatch、System.Threading.Timer。
この造りになっているからこそ、あらゆる時間にまつわる挙動を任意に変更することができるのです。
挙動を任意に変更するというとユニットテストでの時間のモックにばかり意識が向きますが(実際、FakeTimeProviderはとても有益です)、別にユニットテストに限らず、優れた時間の抽象化層として使うことができます。ということを実装とともに証明したのがR3で、特にR3ではCreateTimer
をかなり弄っていて、WPFではDispatcherTimerを使うことで自動的にUIスレッドにディスパッチしたり、UnityではPlayerLoopベースのタイマーとしてScaledとUnsacledでTimescaleの影響を受けるタイマー・受けないタイマーなどといった実行時のカスタマイズ性を実現しました。
セッションではStopwatchについてフォーカスしました。二点の時刻の経過時間を求めるのにDateTimeの引き算、つまり
DateTime now = DateTime.UtcNow;
/* do something... */
TimeSpan elapesed = DateTime.UtcNow - now;
といったコードを書くのはよくあることですが、これはバッドプラクティスです。DateTimeの取得はタダではありません。では、なるほどStopwatchですね?ということで
Stopwatch sw = Stopwatch.StartNew();
/* do something... */
TimeSpan elapsed = sw.Elapsed;
これは、Stopwatchがclassなのでアロケーションがあります。うまく使いまわしてあげる必要があります。 使いまわしができないシチュエーションのために、アロケーションを避けるためにstructのStopwatch、ValueStopwatchといったカスタム型を作ることもありますが、待ってください、そもそもStopwatchが不要です。
二点の経過時間を求めるなら、時計による時刻も不要で、その地点の何らかのタイムスタンプが取れればそれで十分なのです。
// .NET 7以降での手法(GetElapsedTimeが追加された)
long timestamp = Stopwatch.GetTimestamp();
/* do something... */
TimeSpan elapsed = Stopwatch.GetElapsedTime(timestamp);
このlongは、通常は高解像度タイムスタンプ、WindowsではQueryPerformanceCounterが使われています。TimeSpanでよく使うTicksではないことに注意してください。
ベンチマークを取ってみましょう。
using BenchmarkDotNet.Attributes;
using System.Diagnostics;
BenchmarkDotNet.Running.BenchmarkRunner.Run<TimestampBenchmark>();
public class TimestampBenchmark
{
[Benchmark]
public long Stopwatch_GetTimestamp()
{
return Stopwatch.GetTimestamp();
}
[Benchmark]
public DateTime DateTime_UtcNow()
{
return DateTime.UtcNow;
}
[Benchmark]
public DateTime DateTime_Now()
{
return DateTime.Now;
}
}
NowではUtcNowに加えてTimeZoneからのオフセット算出が入るために更にもう一段遅くなります。
ちなみに、2点間の時間の算出にDateTimeではなくTimestampを使うもう一つの利点としては、システム時間の変更の影響を受けないという点があります。dotnet/reactiveではISchedulerがDateTimeOffsetベースで作られていたため、ISchedulerインターフェイスそのものがこの問題の影響を避けられないために、内部的にゴチャゴチャしたハックが繰り返され、パフォーマンスの大幅な劣化にも繋がっていました。
なお、マイクロベンチマークを取るときは必ずBenchmarkDotNetを使ってください。(micro)benchmark is hard、です。Stopwatchで測られても、あらゆる要因から誤差が出まくるし、そもそも指標もよくわからないしで、数字を見ても何もわかりません。私はそういう数字の記事とかを見た場合、役に立たないと判断して無視します。
まとめ
セッション資料に盛り込めた最適化技法の紹介は極一部ではありますが、R3がどれだけ気合い入れて作られているかが伝わりましたでしょうか?10年の時を経て、私自身の成長とC#の成長が合わさり、UniRxからクオリティが桁違いです。
これからも足を止めずにやっていきますし、みなさんも是非モダンC#やっていきましょう……!(Unityも十分モダンC#の仲間入りで良いです!)
そういえばブログに貼り付けるのを忘れてたのですが3月末にはこんなセッションもしていました。
ええ、ええ。Unityもモダンですよ!大丈夫!
Redis互換の超高速インメモリデータストア「Garnet」にC# CustomCommandを実装してコマンドを拡張する
- 2024-03-19
MicrosoftからIntroducing Garnet – an open-source, next-generation, faster cache-store for accelerating applications and servicesという記事が今日公開されて、Garnetという新しいインメモリデータストアがOSSとして公開されました。Microsoft ResearchでFASTERを手掛けていたチームによるもので、FASTERはC#実装の高速なキーバリューストアでした。今回のGarnetはその発展形のようなもので、FASTERベースのストレージと、Redis互換のプロトコルによる、インメモリデータストアになっています。詳しくはGarnetのほうのブログA Brief History of Garnetで。GarnetもC#で作られています。
ベンチマークによると、Redisはもちろんのこと、DragonflyというRedis互換の世界最速のインメモリデータストア(を公式で謳ってる)Dragonflyよりも高速、だそうで。
このグラフ、そこまで大きな差がないように見えますが対数グラフになっていて、Redisが1,000.00 kops/sec に対して、100,000.00 kops/secって言ってます。100倍です!えー。
そもそもRedisの速度に関していうと、シングルスレッドベースであることなどから、たまによくそこまで速くはないというのは言われてきていて、先述のDragonflyはRedis互換で25倍高速とする「Dragonfly」が登場。2022年の最新技術でインメモリデータストアを実装などというリリースとともに、現代の技術で作り直せばもっともっと速くなる、とはされてきました。とはいえ、単純なGET/SETだけのメモリキャッシュとは比較にならない豊富なデータ型など利便性がとても高く、いうて別にそこまで遅いというわけでもないので、特に気にすることなく使われ続けているのではないでしょうか。
GarnetはC#で作られていますが、当然ながらC#専用ではなく、汎用的なRedisサーバーとして動作するため、既存のRedisクライアントで直接繋げることができます。RedisはそのプロトコルRedis serialization protocol(RESP)の仕様を公開しているため、互換サーバーが作りやすいというわけですね、素晴らしい……!
C#から使う場合はStackExchange.Redisと、Garnet同梱のGarent Clientのどちらかが使えます。パッとGarnet Clientを見た限り、現状現実的に使うならStackExchange.Redisですね。最低限は用意されているけれど、Redisクライアントとして使うには、しんどみがありそうです。ただ、性能面ではGarnet Clientのほうが良さそうです。StackExchange.Redisも、前身のBookSleeveから数えると初期設計が10年以上前のものになっているので、現代の観点から見ると設計は古く、パフォーマンス的にも、この実装は悪そうだな、と思えるところがかなりあります。なのでロマンを追いかけるならGarnet Clientを使うのも面白くはあります……!
C#でカスタムコマンドを実装する
普通にRedis互換サーバーとして立てて使うのもいいのですが、C#使いなら面白い点があって、Garnetをライブラリとして参照して(NuGet: Microsoft.Garnet)、アプリケーションに組み込んでのセルフホストができます。例えばロガーとしてZLoggerを差し込んでVerboseでログを出してみたりとか、ちょっと使いやすくていい感じです。ローカル開発とかだったらDockerでRedis動かして、などではなく、ソリューションにGarnetをそのまま組み込んで.NET Aspireで同時起動させるとかもいい感じでしょう。RedisはWindowsでは動かないので(大昔にMicrosoftがForkして動かせるようにしたプロジェクトがありましたが!)、ちゃんと動く互換サーバーが出てきたこと自体がとても嬉しかったりもします。
using Garnet;
using Microsoft.Extensions.Logging;
using ZLogger;
try
{
var loggerFactory = LoggerFactory.Create(x =>
{
x.ClearProviders();
x.SetMinimumLevel(LogLevel.Trace);
x.AddZLoggerConsole(options =>
{
options.UsePlainTextFormatter(formatter =>
{
formatter.SetPrefixFormatter($"[{0}]", (in MessageTemplate template, in LogInfo info) => template.Format(info.Category));
});
});
});
using var server = new GarnetServer(args, loggerFactory);
// Optional: register custom extensions
RegisterExtensions(server);
// Start the server
server.Start();
Thread.Sleep(Timeout.Infinite);
}
catch (Exception ex)
{
Console.WriteLine($"Unable to initialize server due to exception: {ex.Message}");
}
もう一つは、カスタムコマンドを実装できることです……!C#で……!
Redis上でちょっと複雑な実行をしたいことはよくあり、Redisの場合はLua Scriptで処理していましたが、GarnetではC#でカスタムコマンドを実装して組み込むことができます。LUAだとパフォーマンス上どうか、あるいはLUAではできないかなり複雑なことをしたい、といった場合に、パフォーマンス上のデメリットなく使えます。もっとさらに嬉しい点としては、サーバー側で用意した拡張コマンドは、RESPに従っているので、クライアントはC#専用ではなく、PHPからでもGoからでも呼べます。
というわけで、サンプルということで単純な、「SETLCLAMP」というSET時にclampするカスタムコマンドを早速作っていきましょう。作る前に、先に↑のコードで欠けてるRegisterExtensionsの部分を。
static void RegisterExtensions(GarnetServer server)
{
// ClampLongCustomCommandというカスタムコマンドをSETLCLAMPというコマンド名で登録する。
// これはMath.Clampを呼び出すので、パラメーター数は3(long value, long min, long max)
server.Register.NewCommand("SETLCLAMP", 3, CommandType.ReadModifyWrite, new ClampLongCustomCommand());
}
カスタムコマンドの登録自体は非常に簡単で、CustomRawStringFunctions
, CustomTransactionProcedure
または CustomObjectFactory
を実装したクラスをコマンド名と共に追加するだけです。
カスタムコマンドの実装も簡単……?まぁ、理解すればそれなりぐらいに。
using Garnet.server;
using System.Buffers;
using System.Buffers.Binary;
using Tsavorite.core;
sealed class ClampLongCustomCommand : CustomRawStringFunctions
{
// trueの場合はKeyが空の時の動作(GetInitialLength, InitilUpdate)を呼びに行く
public override bool NeedInitialUpdate(ReadOnlySpan<byte> key, ReadOnlySpan<byte> input, ref (IMemoryOwner<byte>, int) output) => true;
// UpdaterのSpan<byte> value(書き込みたいメモリデータ)の長さを決める
public override int GetInitialLength(ReadOnlySpan<byte> input)
{
// 今回はlongだけなので決め打ち8
return 8;
}
public override bool InitialUpdater(ReadOnlySpan<byte> key, ReadOnlySpan<byte> input, Span<byte> value, ref (IMemoryOwner<byte>, int) output, ref RMWInfo rmwInfo)
{
// inputに対してGetNextArgを連続して呼ぶとパラメーターの取得。これは定型句。
int offset = 0;
var arg1 = GetNextArg(input, ref offset);
var arg2 = GetNextArg(input, ref offset);
var arg3 = GetNextArg(input, ref offset);
// ClientはWriteInt64LittleEndianでシリアライズしてきてるので、Readでデシリアライズ
var v = BinaryPrimitives.ReadInt64LittleEndian(arg1);
var min = BinaryPrimitives.ReadInt64LittleEndian(arg2);
var max = BinaryPrimitives.ReadInt64LittleEndian(arg3);
var result = Math.Clamp(v, min, max);
// valueに対して値を書くことで値のセットになる
BinaryPrimitives.WriteInt64LittleEndian(value, result);
// 戻り値とかエラーを書きたい場合はoutputを使う(RespWriteUtilsに色々Utilityが揃ってる)
// WriteIntegerAsBulkStringなどを使うと"String"としての結果になることに注意
// 今回はlongをバイナリとして出力する
unsafe
{
var len = 8 + 6; // $8\r\n{value}\r\n
var pool = MemoryPool.Rent(len);
using var memory = pool.Memory.Pin();
var begin = (byte*)memory.Pointer;
var end = begin + len;
RespWriteUtils.WriteBulkString(value, ref begin, end);
output = (pool, len);
}
return true;
}
// 同じメモリ領域を再利用する(置換する値の長さが同値なら再利用可能)かどうかを決める
public override bool NeedCopyUpdate(ReadOnlySpan<byte> key, ReadOnlySpan<byte> input, ReadOnlySpan<byte> oldValue, ref (IMemoryOwner<byte>, int) output) => false;
// 置換時に再利用する場合
public override bool InPlaceUpdater(ReadOnlySpan<byte> key, ReadOnlySpan<byte> input, Span<byte> value, ref int valueLength, ref (IMemoryOwner<byte>, int) output, ref RMWInfo rmwInfo)
{
// 置換するvalueの長さが一緒(あるいは小さい)の場合は
// valueにはoldValueが入ってきてる。
// 今回は特に考慮しないのでそのまんま書く。
int offset = 0;
var v = BinaryPrimitives.ReadInt64LittleEndian(GetNextArg(input, ref offset));
var min = BinaryPrimitives.ReadInt64LittleEndian(GetNextArg(input, ref offset));
var max = BinaryPrimitives.ReadInt64LittleEndian(GetNextArg(input, ref offset));
var result = Math.Clamp(v, min, max);
BinaryPrimitives.WriteInt64LittleEndian(value, result);
unsafe
{
var len = 8 + 6; // $8\r\n{value}\r\n
var pool = MemoryPool.Rent(len);
using var memory = pool.Memory.Pin();
var begin = (byte*)memory.Pointer;
var end = begin + len;
RespWriteUtils.WriteBulkString(value, ref begin, end);
output = (pool, len);
}
return true;
}
// 置換時に別のメモリ領域を確保する場合
public override int GetLength(ReadOnlySpan<byte> value, ReadOnlySpan<byte> input) => 8;
public override bool CopyUpdater(ReadOnlySpan<byte> key, ReadOnlySpan<byte> input, ReadOnlySpan<byte> oldValue, Span<byte> newValue, ref (IMemoryOwner<byte>, int) output, ref RMWInfo rmwInfo) => throw new NotImplementedException();
// 読み込み処理用
public override bool Reader(ReadOnlySpan<byte> key, ReadOnlySpan<byte> input, ReadOnlySpan<byte> value, ref (IMemoryOwner<byte>, int) output, ref ReadInfo readInfo) => throw new NotImplementedException();
}
今回はRedisでいうところのStringベースで作るので CustomRawStringFunctions
を使います。RedisのStringは文字列型じゃなくて、どちらかというとバイナリ型で、バイナリシリアライズできるものなら、なんでも突っ込めるイメージです。私もゲームサーバーを作っていたときはMessagePackのバイナリを突っ込みまくってましたし、開発時には雑に画像データのバイナリを投げ込んで画像DB代わりに使ったりとかもありました。
オーバーライドするメソッドの数が多いことと、パラメーターがSpan<byte>
だらけで一瞬圧倒されちゃうんですが、冷静に追ってみるとそこまで難しいことは言ってないことに気づきます。追加時(Add)・置換時(Replace)が、最適化のため同じサイズか違うサイズかで2択、それとRead時用。といった別れ方をしています。
key, input, valueが全てReadOnlySpan<byte>
なのは、まぁそりゃそうでしょう(ここでstringとか出てきたら逆に良くない!)
inputをパラメーターに分解するのはGetNextArg
というヘルパーメソッドを使います。当然それも出てくるのはReadOnlySpan<byte>
なので、あとは適当に、もしJSONとかMessagePackとかMemoryPackでシリアライズしたデータだったらシリアライザを使って戻すのもいいし、プリミティブの値だったらBinaryPrimitives
が恐らく適役です。MemoryPackでValueTupleにまとめちゃうのがArgumentが分かれないので最速かつ簡単かもしれません。
結果はSpan<byte> value
に書きます。この出力先のSpanの長さは事前にGetLength
またはGetInitialLength
で求めておく必要があります。outputはクライアント側に戻すときの値で、RESPに則った形式で出力する必要があるので色々注意がいります。まずはRESPの仕様を簡単にでも頭に入れたほうがつまずかないで済むかもしれません、ここを分かってないとイマイチ書きづらいと思います。
と、いうわけで、バイナリ操作がそこそこ混ざることを除けば、それなりに素直に書けるのではないでしょうか。雰囲気は理解しました!ある程度なんでもは出来ますが(CustomTransactionProcedure
や CustomObjectFactory
でもまた色々出来る)、同期メソッドしかないように、DB呼んだりHTTP通信したりはご法度です。当たり前ですが。当たり前ですが。計算量もGarnetサーバーのCPUにストレートに影響を与えるので、そんなに無茶なことを書くことはないと思いますがお気をつけを。それでも、LUAを走らせるよりもずっと軽いんじゃないかなという予感はさせてくれます。実際これただのC#のメソッドそのものですしね。
クライアントから呼び出す場合は、こんなメソッドを用意してみます。
public static class GarnetClientExtensions
{
// RESPプロトコルにのっとってOpCodeを用意する
// RESPのBlukStringの仕様: https://redis.io/docs/reference/protocol-spec/#bulk-strings
// $<length>\r\n<data>\r\n
readonly static Memory<byte> OpCode_SETLCLAMP = Encoding.ASCII.GetBytes("$9\r\nSETLCLAMP\r\n");
public static async Task<long> ClampAsync(this GarnetClient client, Memory<byte> key, long value, long min, long max, CancellationToken cancellationToken = default)
{
var parameters = new byte[24];
var valSpan = parameters[0..8];
var minSpan = parameters[8..16];
var maxSpan = parameters[16..24];
BinaryPrimitives.WriteInt64LittleEndian(valSpan, value);
BinaryPrimitives.WriteInt64LittleEndian(minSpan, min);
BinaryPrimitives.WriteInt64LittleEndian(maxSpan, max);
// key + (value, min, max)
// 戻り値のMemoryResultはArrayPoolから借りてる状態なのでDisposeでReturnする
using var result = await client.ExecuteForMemoryResultWithCancellationAsync(OpCode_SETLCLAMP, new Memory<byte>[] { key, valSpan, minSpan, maxSpan }, cancellationToken);
return BinaryPrimitives.ReadInt64LittleEndian(result.Span);
}
}
サーバー側で用意した拡張コマンドは、ちゃんとRESPに従っているので、クライアントはC#専用ではありませんし、Garnet Client専用でもありません。StackExchange.Redisであれば、db.Execute("SETLCLAMP", ...)
で呼べます。
実際に動かしてみるとこんな感じです。
static async Task RunClientAsync(ILoggerFactory loggerFactory)
{
var logger = loggerFactory.CreateLogger("Client");
var client = new GarnetClient("localhost", 3278, logger: logger);
logger.ZLogInformation($"Client Connecting.");
await client.ConnectAsync();
logger.ZLogInformation($"Success Connect.");
var key = Encoding.UTF8.GetBytes("foo");
var v1 = await client.ClampAsync(key, 12345, min: 0, max: 100);
Console.WriteLine(v1); // 100
// String系のGET/SET/DELなどは普通に呼べる
using var v2 = await client.StringGetAsMemoryAsync(key);
Console.WriteLine(BinaryPrimitives.ReadInt64LittleEndian(v2.Span)); // 100
var isDelete = await client.KeyDeleteAsync(key);
Console.WriteLine(isDelete); // True
}
いいですね!
まとめ
さすがに公開されてまだ10時間経ってないぐらいなのでザックリとした理解なのですが、かなりいいんじゃないかと!
どうしてもMemachedとかRedisとかは、クラウドのマネージドサービスが用意されてないと嫌だー、という思考に陥りがちなのですが、C#でガリガリ拡張できるとなれば、まぁマネージドがなくてもしょうがないな!という気持ちになれ、る、でしょうかね……?
まぁそうじゃなくても、あまりマネージド指向になりすぎるのも良くないかな、とは思っています。私は最近はPubSubにNATSをお薦めしてクライアントも作ったりしてたわけですが、もちろんマネージドサービスはありません。で、だから、諦めます、というのは違うかな、と。もったいないと思うんですよね。
なので、必要あれば、いや、必要じゃなくても(?)気持ちがあるなら、自前に立てるというのも否定しちゃあいけないと思ってます。特にC#アプリケーションを作ったことがある人なら、C#で組み込んでホスティングすること自体は別に難しくもない、なんだったらいつもやってることの延長線上でいけますし。もちろん、そこからインフラ安定させるとかデータどうするなとかリカバリどうするとか、そういうのは別問題の話ではありますが……!
ともあれかなり面白いし使える予感があるので、やっていきましょう!
Claudia - Anthropic ClaudeのC# SDKと現代的なC#によるウェブAPIクライアントの作り方
- 2024-03-18
AI関連、競合は現れども、性能的にやはりOpenAI一強なのかなぁというところに現れたAnthropic Claude 3は、確かに明らかに性能がいい、GPT-4を凌駕している……!というわけで大いに気に入った(ついでに最近のOpenAIのムーブが気に入らない)ので、C#で使い倒していきたい!そこで、まずはSDKがないので非公式SDKを作りました。こないだまでプレビュー版を流していたのですが、今回v1.0.0として出します。ライブラリ名は、Claudeだから、Claudiaです!.NET全般で使えるのと、Unity(Runtime/Editor双方)でも動作確認をしているので、アイディア次第で色々活用できると思います。
今回のSDKを作るにあたっての設計指針の一番目は、公式のPython SDKやTypeScript SDKと限りなく似せること、です。というのもドキュメント類の解説はこれら公式SDKベースになるし、世の中的にもブログなどには公式SDKベースの記事が多く出回るでしょう。公式の充実したプロンプトライブラリも、APIリクエストで叩き込みたくなるかもしれない。
そんな時に、APIのスタイルが違うと、変換の認知負荷がかかります。些細なことですが、そういうところがすごく大事で引っ掛かってしまうので、徹底的に取り除きます。そのうえで、無理に動的な要素を入れず、C#らしさを崩さないというバランス取りが設計において重要です。
C#クライアントの見た目はこうです。
// C#
using Claudia;
var anthropic = new Anthropic();
var message = await anthropic.Messages.CreateAsync(new()
{
Model = "claude-3-opus-20240229",
MaxTokens = 1024,
Messages = [new() { Role = "user", Content = "Hello, Claude" }]
});
Console.WriteLine(message);
比較してTypeScriptの見た目はこうなっています。
// TypeScript
import Anthropic from '@anthropic-ai/sdk';
const anthropic = new Anthropic();
const message = await anthropic.messages.create({
model: 'claude-3-opus-20240229',
max_tokens: 1024,
messages: [{ role: 'user', content: 'Hello, Claude' }],
});
console.log(message.content);
かなり近い!でしょう。そのうえで、C#版はdynamic
やDictionary<string, object>
などは使わず、全て型付けされたものが指定されます。上記の例で使用しているC# 9.0で追加されたTarget-typed new expressionsや、C# 12で追加されたCollection expressionsの存在を前提として、うまくAPIを合わせています。
もともと、動的型付け言語のAPIのほうが(見た目は)簡潔で使いやすそう、という印象を抱くことは多いので、それと同レベルの簡潔さで、しっかりと型付けが効いて書けるというのは、現代のC#の大きな強みです。(そもそもTypeScriptの公式SDKに合わせようと思ったのは、私から見ても公式SDKのAPIスタイルはよくできていると思ったからです、仮にあまりにも酷かった場合は合わせようとはしなかったでしょう)
いかにも古典的なC#やJavaみたいな冗長な設計のAPIクライアントは、反省しましょう。現代のC#はここまでやれるのだから。
Streaming and Blazor
StreamingのAPIも用意されていて、Blazorと組み合わせれば簡単にリアルタイムに更新されるChat UIが作れます。コードは本当にたったのこれだけ、メソッド本体なんて10行ちょい!
[Inject]
public required Anthropic Anthropic { get; init; }
double temperature = 1.0;
string textInput = "";
string systemInput = SystemPrompts.Claude3;
List<Message> chatMessages = new();
async Task SendClick()
{
chatMessages.Add(new() { Role = Roles.User, Content = textInput });
var stream = Anthropic.Messages.CreateStreamAsync(new()
{
Model = Models.Claude3Opus,
MaxTokens = 1024,
Temperature = temperature,
System = string.IsNullOrWhiteSpace(systemInput) ? null : systemInput,
Messages = chatMessages.ToArray()
});
var currentMessage = new Message { Role = Roles.Assistant, Content = "" };
chatMessages.Add(currentMessage);
textInput = "";
StateHasChanged();
await foreach (var messageStreamEvent in stream)
{
if (messageStreamEvent is ContentBlockDelta content)
{
currentMessage.Content[0].Text += content.Delta.Text;
StateHasChanged();
}
}
}
全てのリクエスト/レスポンス型はSystem.Text.Json.JsonSerializerでシリアライズ可能なため、このList<Message>
をそのままシリアライズすれば保存、デシリアライズすれば読み込みになります。
Function Calling
ClaudiaはただのREST APIを叩くだけのSDK、ではありません。Source Generatorを活用して、Function Callingを簡単に定義するための仕組みを用意しました。
Function Callingができると何がいいか、というと、現状のLLMは単体だとできないことが幾つかあります。例えば計算は、それっぽい答えを返してくれる場合も多いし、Step-by-Stepで考えさせるなど、それっぽさの精度を上げることはできるけれど、正確な計算はできないという苦手分野だったりします(複雑な計算を投げると正しそうで間違ってる答えを出しやすい)。それなら計算が必要なら普通に計算機で計算して、その答えをもとに文章を作ればいいじゃん、と。あるいは現在日時を答えることもできません。ウェブページを指定して要約したり翻訳して欲しいとお願いしても、中身を見ることはできませんと言われます。それらを解決するのがFunction Callingです。
まずは一例ということで、指定したURLのウェブページをClaudeに返す関数を定義してみましょう。
public static partial class FunctionTools
{
/// <summary>
/// Retrieves the HTML from the specified URL.
/// </summary>
/// <param name="url">The URL to retrieve the HTML from.</param>
[ClaudiaFunction]
static async Task<string> GetHtmlFromWeb(string url)
{
using var client = new HttpClient();
return await client.GetStringAsync(url);
}
}
[ClaudiaFunction]
で定義した関数がSource Generatorによって色々生成されます。これを利用する場合、以下のようになります。
var input = new Message
{
Role = Roles.User,
Content = """
Could you summarize this page in three lines?
https://docs.anthropic.com/claude/docs/intro-to-claude
"""
};
var message = await anthropic.Messages.CreateAsync(new()
{
Model = Models.Claude3Haiku,
MaxTokens = 1024,
System = FunctionTools.SystemPrompt, // set generated prompt
StopSequences = [StopSequnces.CloseFunctionCalls], // set </function_calls> as stop sequence
Messages = [input],
});
var partialAssistantMessage = await FunctionTools.InvokeAsync(message);
var callResult = await anthropic.Messages.CreateAsync(new()
{
Model = Models.Claude3Haiku,
MaxTokens = 1024,
System = FunctionTools.SystemPrompt,
Messages = [
input,
new() { Role = Roles.Assistant, Content = partialAssistantMessage! } // set as Assistant
],
});
// The page can be summarized in three lines:
// 1. Claude is a family of large language models developed by Anthropic designed to revolutionize the way you interact with AI.
// 2. This documentation is designed to help you get the most out of Claude, with clear explanations, examples, best practices, and links to additional resources.
// 3. Claude excels at a wide variety of tasks involving language, reasoning, analysis, coding, and more, and the documentation covers key capabilities, getting started with prompting, and using the API.
Console.WriteLine(callResult);
Claudeへは二回のリクエストを行っています。まず、最初のClaudeへのリクエストでは、質問と共に利用可能な関数の一覧と説明を送り、関数を実行するのが最適だと判断されると、実行したい関数名とパラメーターが返されます。それを下に、手元で関数を実行し、結果をClaudeに渡すことで最終的に求める結果を得られます。
ではSource Generatorは何をやっているのかというと、まずはClaudeのシステム文に渡しているFunctionTools.SystemPrompt
を生成しているわけですが、その中身はこれです(一部省略)。
// ...前文は省略
<tools>
<tool_description>
<tool_name>GetHtmlFromWeb</tool_name>
<description>Retrieves the HTML from the specified URL.</description>
<parameters>
<parameter>
<name>url</name>
<type>string</type>
<description>The URL to retrieve the HTML from.</description>
</parameter>
</parameters>
</tool_description>
</tools>
XMLです。ClaudeはXMLタグを認識するようになっていて、システム的に明確に情報を与えたい場合はXMLタグを活用することがベストプラクティスとなっています。そこで、C#の関数からClaudeに渡すためのXMLを自動生成しています。これを手書きは、したくないでしょう……?
そしてClaudeはそのリクエストに対して、以下のような結果を返します。
<function_calls>
<invoke>
<tool_name>GetHtmlFromWeb</tool_name>
<parameters>
<url>https://docs.anthropic.com/claude/docs/intro-to-claude</url>
</parameters>
</invoke>
やはりXMLです(閉じタグが欠けているのはStopSequencesで止めているため。関数を呼びたい場合はこれ以上の情報は不要なので打ち止めておく)。これをパースして、関数(GetHtmlFromWeb)を実行し、Claudeに渡すためのメソッド FunctionTools.InvokeAsync
がSource Generatorによって生成されています。実際生成されているInvokeAsyncメソッドは以下のようなものです。
#pragma warning disable CS1998
public static async ValueTask<string?> InvokeAsync(MessageResponse message)
{
var content = message.Content.FirstOrDefault(x => x.Text != null);
if (content == null) return null;
var text = content.Text;
var tagStart = text .IndexOf("<function_calls>");
if (tagStart == -1) return null;
var functionCalls = text.Substring(tagStart) + "</function_calls>";
var xmlResult = XElement.Parse(functionCalls);
var sb = new StringBuilder();
sb.AppendLine(functionCalls);
sb.AppendLine("<function_results>");
foreach (var item in xmlResult.Elements("invoke"))
{
var name = (string)item.Element("tool_name")!;
switch (name)
{
case "GetHtmlFromWeb":
{
var parameters = item.Element("parameters")!;
var _0 = (string)parameters.Element("url")!;
BuildResult(sb, "GetHtmlFromWeb", await GetHtmlFromWeb(_0).ConfigureAwait(false));
break;
}
default:
break;
}
}
sb.Append("</function_results>"); // final assistant content cannot end with trailing whitespace
return sb.ToString();
static void BuildResult<T>(StringBuilder sb, string toolName, T result)
{
sb.AppendLine(@$" <result>
<tool_name>{toolName}</tool_name>
<stdout>{result}</stdout>
</result>");
}
}
#pragma warning restore CS1998
}
これを手書きは、あまりしたくはないでしょう。特に呼び出したい関数が増えれば増えるほど大変ですし。
これで呼び出し&生成したXMLを再度Claudeに、Assistantによる先頭の出力結果だと渡すことによって、望む答えを得ることができます。このテクニックはPrefill Claude's responseとして公式でもベストプラクティスの一つとして案内されているもので、Claudeによる返答を望む方向に導くのに有益です。例えば{
をprefill responseとして返すと、Claudeが結果をJSONとして出力する確率が飛躍的に上昇します。
API vs LangChain, SemanticKernel
大規模言語モデルを触るなら、生で使うよりもLangChainや、特にC#だとSemantic Kernelを使うというのを入り口にするのも定説ではありますが、やや疑問はあります。最近でもLangChainを使わないやLangChain は LLM アプリケーションの開発に採用すべきではないといった記事のようにLangChain不要論も出てきています。
そもそも、まぁこの記事はエンジニア向けに書いてるわけですが、一部の機能はあきらかに過剰でいらないんじゃないかと、保存用のプラグインとか。Semantic Kernelの大量にあるコネクターパッケージとかぞっとする感じで、コード書けないデータサイエンティストが継ぎ接ぎでやるならともかく、エンジニアは保存ぐらい自前でやったほうが絶対いいでしょ。TimePluginだのHttpPluginだのFileIOPluginだのも、正直馬鹿らしい、という感じしかないのでは。
どうせ最後に叩くのは生APIなら、真摯にAPIドキュメントを読め、と。ClaudeのAPIドキュメントのUser Guidesは分かりやすく素晴らしく、それもまたClaudeを支持したい理由の一つになります。しょうもない抽象化を通すぐらいならClaudeに特化して、特徴的なXMLによる指示の活かしかたを考えろ、と。
特にC#の人はSemantic Kernel至上主義になってると思われるので、いったんまずそっから離れて考えていくといいんじゃないです?
モダンウェブAPIクライアントの作り方
ここからはClaudiaの設計から見る現代的なAPIクライアントの設計方法の話をします。
まず、通信の基盤はHttpClientを使います。一択です。異論を挟む余地はない。Grpc.Net.ClientだってHTTP/2 gRPC通信にHttpClientを使っていますし、好むと好まざると全てのHTTP系の通信の基盤はHttpClientです。
ここでは、外からHttpMessageHandlerを受け取れるようにしておくといいでしょう。
public class Anthropic : IMessages, IDisposable
{
readonly HttpClient httpClient;
// DefaultRequestHeadersやBaseAddressを変更させてあげるためにpublicで公開しておく
public HttpClient HttpClient => httpClient;
public Anthropic()
: this(new HttpClientHandler(), true)
{
}
public Anthropic(HttpMessageHandler handler)
: this(handler, true)
{
}
public Anthropic(HttpMessageHandler handler, bool disposeHandler)
{
this.httpClient = new HttpClient(handler, disposeHandler);
}
public void Dispose()
{
httpClient.Dispose();
}
}
HttpClientというのは実はガワでしかなくて、実体はHttpMessageHandlerです。HttpMessageHandlerにはやれることが色々あって、DelegatingHandlerを実装してリクエストの前後をフックするような機能を仕込んだりも出来るし、Cysharp/YetAnotherHttpHandlerはHttpMessageHandlerの実装という形で通信処理を丸ごとRust実装に差し替えています。Unityでは.NETランタイムの通信実装じゃなくてUnityWebRequestを使いたいんだよなあ、といったような場合にはUnityWebRequestHttpMessageHandler.csを使えば、やはり通信処理が全てUnityによるものに差し替わります。
インターフェイスの切り方も工夫していきましょう。
client.Messages.CreateAsync
のように、MVCでいったら.Controller.Method
のように、2階層に整理された呼び出し方は直感的で使いやすい設計です。特に、入力補完に優しいのが嬉しい。そのためには、まずインターフェイスを切りますが、工夫として、それを明示的なインターフェイスの実装にして、インターフェイス自体はreturn this;
で返してやりましょう。
public interface IMessages
{
Task<MessageResponse> CreateAsync(MessageRequest request, RequestOptions? overrideOptions = null, CancellationToken cancellationToken = default);
IAsyncEnumerable<IMessageStreamEvent> CreateStreamAsync(MessageRequest request, RequestOptions? overrideOptions = null, CancellationToken cancellationToken = default);
}
public class Anthropic : IMessages, IDisposable
{
public IMessages Messages => this;
async Task<MessageResponse> IMessages.CreateAsync(MessageRequest request, RequestOptions? overrideOptions, CancellationToken cancellationToken)
{
// ...
}
async IAsyncEnumerable<IMessageStreamEvent> IMessages.CreateStreamAsync(MessageRequest request, RequestOptions? overrideOptions, [EnumeratorCancellation] CancellationToken cancellationToken)
{
// ...
}
}
これによって一個階層を下がる際のアロケーションがない(thisを返すため)ですし、明示的な実装になっているのでトップ階層では入力補完には現れないので、使いやすさと性能、ついでにいえば実装のしやすさ(全てのクライアントのフィールドにそのままアクセスできるため)の全てが満たされます。
ユーザーフレンドリーなリクエスト型生成
Anthropicのリクエスト型はかなり整理されて、型有り言語に優しい仕様になっているのですが、一部、single string or an array of content blocks
というものがあります。どっちか、とかそういうの微妙に困るわけですが、しかし、じゃあOption<Either<List<>>>
かなー、とか、そういうことではありません。そんな定義にしたらAPIクライアントの手触りは最悪になるでしょう。よく考えてみると、Anthropic APIのこの場合のstringは、長さ1のstring contentと同一です。
// こうじゃなくて
Content = [ new() { Type = "text", Text = "Hello, Claude" }]
// こう書きたい
Content = "Hello, Claude"
これは、良い仕様だと思います。杓子定規に Type = "text", Text = "..." と書かせるのはダルいでしょう。利用時の95%ぐらいはsingle string contentでしょうし(Typeはimageの場合もある、その場合はSourceにバイナリのbase64文字列を設定する。arrayなのは、画像とテキストを両方渡したりするため)。
その仕様をC#で実現しましょう。今回の場合、正規化するようなイメージでいいので、暗黙的変換で実装しました。
public record class Message
{
/// <summary>
/// user or assistant.
/// </summary>
[JsonPropertyName("role")]
public required string Role { get; set; }
/// <summary>
/// single string or an array of content blocks.
/// </summary>
[JsonPropertyName("content")]
public required Contents Content { get; set; }
}
public class Contents : Collection<Content>
{
public static implicit operator Contents(string text)
{
var content = new Content
{
Type = ContentTypes.Text,
Text = text
};
return new Contents { content };
}
}
Content[]
ではなくて独自のコレクションにして、それの文字列からの暗黙的変換でsingle string contentを生成する形にしました。別に最新のC#仕様でもなんでもなく昔からある手法ですし、闇雲な利用は厳禁ですが、こうしたところに利用するのはAPIクライアントの手触り向上に効果的です。
タイムアウト
タイムアウトは定番の処理なので、APIクライアントで簡単にユーザーが設定できるようにしておいたほうがいいでしょう。といっても、HttpClientがTimeoutプロパティを持っているので、通常はそれにセットしてあげるだけで十分です。しかし、Claudiaではあえて無効にしています。
public class Anthropic : IMessages, IDisposable
{
public TimeSpan Timeout { get; init; } = TimeSpan.FromMinutes(10);
public Anthropic(HttpMessageHandler handler, bool disposeHandler)
{
this.httpClient = new HttpClient(handler, disposeHandler);
this.httpClient.Timeout = System.Threading.Timeout.InfiniteTimeSpan;
}
}
Anthropicの公式クライアントがメソッド呼び出し毎にTimeout設定をオーバーライドできるという仕様を持っているため、それにならってオーバーライド可能に必要があったためです。HttpClientやそれに準ずるもの呼び出しはスレッドセーフであるべき(実際APIクライアントはSingletonで登録されたりする場合がある)なので、SendAsyncでHttpCleintのプロパティの値を弄るのはよくない。ので、HttpClientが持つTimeoutは無効にして、手動で処理するようにしています。
実装方法は、LinkedTokenSourceを生成し、CancelAfterによってタイムアウト時間後にキャンセルされるCancellationTokenを作り、HttpClient.SendAsyncに渡すだけです。なお、これはHttpClient.Timeoutがタイムアウト時間を持つ場合の内部実装と同じです。
// 実際のコードはリトライ処理と混ざっているため、若干異なります
async Task<TResult> RequestWithAsync<TResult>(HttpRequestMessage message, CancellationToken cancellationToken, RequestOptions? overrideOptions)
{
var timeout = overrideOptions?.Timeout ?? Timeout;
using (var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
{
cts.CancelAfter(timeout);
try
{
var result = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(ConfigureAwait);
return result;
}
catch (OperationCanceledException ex) when (ex.CancellationToken == cts.Token)
{
if (cancellationToken.IsCancellationRequested)
{
throw new OperationCanceledException(ex.Message, ex, cancellationToken);
}
else
{
throw new TimeoutException($"The request was canceled due to the configured Timeout of {Timeout.TotalSeconds} seconds elapsing.", ex);
}
throw;
}
}
}
実際にキャンセルされた場合(OperationCanceledExceptionが投げられる)のエラーハンドリングには注意しましょう。まず、LinkedTokenを剥がす必要があります。素通しだとOperationCanceledExceptionのTokenがLinkedTokenのままですが、これだと上流側でキャンセル原因の判定に使うことができません。キャンセル原因が渡されているCancellationTokenのキャンセルだった場合は、OperationCanceledExceptionを作り直してキャンセル理由のTokenを変更します。
タイムアウトだった場合はOperationCanceledExceptionではなく、TimeoutException
を投げてあげるのが良いでしょう。なお、HttpClientのタイムアウト実装を使った場合は歴史的事情でTaskCanceledException
を投げてくるようになっています(互換性のため変更したくても、もう変更できない、とのこと。あまり良い設計ではないと言えるので、そこは見習わなくていいでしょう)
リトライ
リトライをAPIクライアント自身が持つべきかどうかに関しては、少し議論があるかもしれません。しかし、単純に例外が出たらcatchしてリトライかければいいというものではなく、リトライ可なものと不可のものの判別がまず必要です。例えば認証に失敗しているとか、リクエストに投げるJSONが腐ってるといった場合は何度リトライしても無駄なのでリトライすべきものではないのですが、そうした細かい条件は、APIクライアント自身しか知り得ないので、リトライ処理を内蔵してしまうのは良いと思います。
Claudiaでは公式クライアントに準拠する形で、具体的には408 Request Timeout, 409 Conflict, 429 Rate Limit, and >=500 Internal errorsをリトライ対象にしています。認証失敗のPermissionError(403)やリクエスト内容が不正(InvalidRequestError(400))はリトライされません。たまによくあるOverloadedError(過負荷状態なので結果返せまんでしたエラー)は529で、これは何度か叩き直せば解消されるやつなのでリトライして欲しい、といったものはリトライされます。
リトライロジックも公式クライアントに準拠していて、レスポンスヘッダにretry-after-msやretry-afterがあればそれに従いつつ、ない場合(やretry-afterが規定よりも大きい場合)はジッター付きのExponential Backoffで間隔を制御しています。
キャンセル
クライアント側に.Cancel()
メソッドなどは持たせません。というのも、HttpClientと準拠させるとクライアントそのものは、ほぼシングルトンで使えて、各呼び出しに対して共有されることになります(場合によってはDIでシングルトンでインジェクトするかもしれませんし)。なので、全てに影響を与える.Cancel()
ではなくて、各呼び出しそれぞれにCancellationTokenを渡してね、という形を取ります。
Server Sent Eventsの超高速パース
Streamingでレスポンスを取得するAPIは、server-sent eventsという仕様で、ストリーミングで送信されてきます。具体的には以下のようなテキストメッセージが届きます。
event: message_start
data: {"type":"message_start","message":...}
event: content_block_start
data: {"type":"content_block_start","index":...}
event: イベント名, data: JSON, ...。といったことの繰り返しです。さて、改行区切りのテキストメッセージといったらStreamReaderでReadLine、というのは正解、ではあるのですがモダンC#的には不正解です。
ReadLineは文字列を生成します。イベント名の判定のために、あるいは最終的にdataのJSONはデシリアライズしてオブジェクトに変換するのですが、UTF8のデータから直接変換できるはずです。というわけで、ここは(ユーザーに渡すオブジェクトの生成以外は)ゼロアロケーションが狙えます。文字列を通しさえしなければ。というわけでStreamReaderの出番はありません。
具体的なコードを見ていきましょう。前半部(下準備)と後半部(パース部分)で分けます。
internal class StreamMessageReader
{
readonly PipeReader reader;
readonly bool configureAwait;
MessageStreamEventKind currentEvent;
public StreamMessageReader(Stream stream, bool configureAwait)
{
this.reader = PipeReader.Create(stream);
this.configureAwait = configureAwait;
}
public async IAsyncEnumerable<IMessageStreamEvent> ReadMessagesAsync([EnumeratorCancellation] CancellationToken cancellationToken)
{
READ_AGAIN:
var readResult = await reader.ReadAsync(cancellationToken).ConfigureAwait(configureAwait);
if (!(readResult.IsCompleted | readResult.IsCanceled))
{
var buffer = readResult.Buffer;
while (TryReadData(ref buffer, out var streamEvent))
{
yield return streamEvent;
if (streamEvent.TypeKind == MessageStreamEventKind.MessageStop)
{
yield break;
}
}
reader.AdvanceTo(buffer.Start, buffer.End);
goto READ_AGAIN;
}
}
まず、Streamは、System.IO.Pipelines.PipeReaderに渡しておきます。今回のStreamはネットワークからサーバー側がストリーミングで返してくる不安定なStreamなので、バッファ管理が大変です。PipeReader/PipeWriterは、若干癖がありますが、その辺の管理をよしなにやってくれるもので、現代のC#ではかなり重要なライブラリです。
基本の流れはバッファを読み込み(ReadAsync)、そのバッファでパース可能(行の末尾までないとパースできないので、改行コードが含まれているかどうか)な状態なら、1行毎にパース(TryReadData)してyield returnでオブジェクトを返す。バッファが足りなかったらAdvanceToで読み取った部分までマークしてから、再度ReadAsync、といった流れになります。
利用側はBlazorのサンプルで出していたのですが、await foreachで列挙するのが基本になります。
await foreach (var messageStreamEvent in Anthropic.Messages.CreateStreamAsync())
{
}
こういったネットワークの絡む処理のストリーミング処理にはIAsyncEnumerableが非常に向いていますし、データソース側も、非同期シーケンスをyield returnで返せるというのは、とても楽になりました。これがない時代には、もう戻るのは無理でしょう……。
次に後半部、PipeReaderによって分解されたバッファからパースする処理になります。
[SkipLocalsInit]
bool TryReadData(ref ReadOnlySequence<byte> buffer, [NotNullWhen(true)] out IMessageStreamEvent? streamEvent)
{
var reader = new SequenceReader<byte>(buffer);
Span<byte> tempBytes = stackalloc byte[64]; // alloc temp
while (reader.TryReadTo(out ReadOnlySequence<byte> line, (byte)'\n', advancePastDelimiter: true))
{
if (line.Length == 0)
{
continue; // next.
}
else if (line.FirstSpan[0] == 'e') // event
{
// Parse Event.
if (!line.IsSingleSegment)
{
line.CopyTo(tempBytes);
}
var span = line.IsSingleSegment ? line.FirstSpan : tempBytes.Slice(0, (int)line.Length);
var first = span[7]; // "event: [c|m|p|e]"
if (first == 'c') // content_block_start/delta/stop
{
switch (span[23]) // event: content_block_..[]
{
case (byte)'a': // st[a]rt
currentEvent = MessageStreamEventKind.ContentBlockStart;
break;
case (byte)'o': // st[o]p
currentEvent = MessageStreamEventKind.ContentBlockStop;
break;
case (byte)'l': // de[l]ta
currentEvent = MessageStreamEventKind.ContentBlockDelta;
break;
default:
break;
}
}
else if (first == 'm') // message_start/delta/stop
{
switch (span[17]) // event: message_..[]
{
case (byte)'a': // st[a]rt
currentEvent = MessageStreamEventKind.MessageStart;
break;
case (byte)'o': // st[o]p
currentEvent = MessageStreamEventKind.MessageStop;
break;
case (byte)'l': // de[l]ta
currentEvent = MessageStreamEventKind.MessageDelta;
break;
default:
break;
}
}
else if (first == 'p')
{
currentEvent = MessageStreamEventKind.Ping;
}
else if (first == 'e')
{
currentEvent = (MessageStreamEventKind)(-1);
}
else
{
// Unknown Event, Skip.
// throw new InvalidOperationException("Unknown Event. Line:" + Encoding.UTF8.GetString(line.ToArray()));
currentEvent = (MessageStreamEventKind)(-2);
}
continue;
}
else if (line.FirstSpan[0] == 'd') // data
{
// Parse Data.
Utf8JsonReader jsonReader;
if (line.IsSingleSegment)
{
jsonReader = new Utf8JsonReader(line.FirstSpan.Slice(6)); // skip data:
}
else
{
jsonReader = new Utf8JsonReader(line.Slice(6)); // ReadOnlySequence.Slice is slightly slow
}
switch (currentEvent)
{
case MessageStreamEventKind.Ping:
streamEvent = JsonSerializer.Deserialize<Ping>(ref jsonReader, AnthropicJsonSerialzierContext.Default.Options)!;
break;
case MessageStreamEventKind.MessageStart:
streamEvent = JsonSerializer.Deserialize<MessageStart>(ref jsonReader, AnthropicJsonSerialzierContext.Default.Options)!;
break;
// 中略(MessageDela, MessageStop, ContentBlockStart, ContentBlockDelta, ContentBlockStop, errorに対して同じようなDeserialize<T>
default:
// unknown event, skip
goto END;
}
buffer = buffer.Slice(reader.Consumed);
return true;
}
}
END:
streamEvent = default;
buffer = buffer.Slice(reader.Consumed);
return false;
}
event, dataの二行から、dataのJSONをデシリアライズしてオブジェクトを返したい。というのが処理のやりたいことです。bufferには必ずしも都合よくevent, dataの二行が入っているわけでもなくeventだけかもしれない、dataだけかもしれない、あるいはdataも途中で切れてる(そのままだと不完全なJSON)かもしれない。といったことを考慮して、中断・再開できる構造にしておく必要があります。
といっても、基本的には改行コードが存在してれば一行分のバッファは十分あるだろうということで、 while (reader.TryReadTo(out ReadOnlySequence<byte> line, (byte)'\n', advancePastDelimiter: true))
といったループを回して、これをStreamReader.ReadLineの代わりにしています。このreaderはSequenceReaderというReadOnlySequenceからの読み取りをサポートするユーティリティで、ref structのため、それ自体のアロケーションはありません。ReadOnlySequenceは性能良く正しく使うには、かなり落とし穴の多いクラスなので、こうしたユーティリティベースに実装したほうがお手軽かつ安全です。
まずeventのパースで、ここからdataがどの種類化を読み取っています。正攻法でやると if (span.SequenceEqual("content_block_start"))
といったように判定していくことになります。Span<byte>
へのSequenceEqualは高速な実装になっているので、まぁ悪くないといえば悪くないのですが、とはいえifの連打は如何なものか……。そこで、Claudiaでは実際には以下のような判定に簡略化しています。
var first = span[7]; // "event: [c|m|p|e]"
if (first == 'c') // content_block_start/delta/stop
{
switch (span[23]) // event: content_block_..[]
{
case (byte)'a': // st[a]rt
currentEvent = MessageStreamEventKind.ContentBlockStart;
break;
case (byte)'o': // st[o]p
currentEvent = MessageStreamEventKind.ContentBlockStop;
break;
case (byte)'l': // de[l]ta
currentEvent = MessageStreamEventKind.ContentBlockDelta;
break;
default:
break;
}
}
else if (first == 'm') // message_start/delta/stop
{
switch (span[17]) // event: message_..[]
{
case (byte)'a': // st[a]rt
currentEvent = MessageStreamEventKind.MessageStart;
break;
case (byte)'o': // st[o]p
currentEvent = MessageStreamEventKind.MessageStop;
break;
case (byte)'l': // de[l]ta
currentEvent = MessageStreamEventKind.MessageDelta;
break;
default:
break;
}
}
メッセージの種類はcontent_block_start/delta/stop, message_start/delta/stop, ping, errorの8種類。まず、先頭1文字でcontent系かmessage系かその他か判定できる。start/delta/stopに関しては3文字目を見ると判定できる。というわけで、1byteのチェックを2回行うだけで分類可能です。明らかに高速!なお、今後のメッセージ種類の追加でチェックが壊れる可能性がゼロではない(例えばcontent_block_ffowardとかが来るとcontent_block_stopと誤判定される)、という問題があることは留意する必要があります。Claudiaではいうて大丈夫だろ、という楽観視してますが。
なお、これは以前に発表したモダンハイパフォーマンスC# 2023でのコードのバリエーションと言えるでしょうか。
テキストプロトコルを見るとなんとかして判定をちょろまかしたいという欲求に抗うのは難しい……。なお、もし厳密な判定をしつつもif連打を避けたい場合は、まず長さチェックをいれます。長さで大雑把な分岐をかけてからSequenceEqualで正確なチェックをします。ようするところ、C#のstringへのswtichの最適化(コンパイラがそういう処理に変換している!)と同じことをやろうという話なだけですが。分岐数が多い場合はハッシュコードを取って分岐かけるとか、ようするにインラインDictionaryのようなものを実装するのもアリでしょう。
最後に、data行はJSON Deserializeです。ReadOnlySpan<byte>
またはReadOnlySequence<byte>
のままデシリアライズするにはUtf8JsonReaderを通す必要があります。なお、Utf8JsonReader
もref structなのでアロケーションには含めません。
これで、Stringを一切通さない処理ができました!StreamReaderを使えば超単純になるのに!という気はしなくもないですが、文字列化したら負けだと思っている病に罹患しているのでしょーがない……。
Source Generator vs Reflection
Function Callingの実装に、ClaudiaではSource Generatorを採用しました。リフレクションベースで作成することも可能では有りましたが、今回に関してはSource Generatorのほうが望ましい結果が得られました。まず、仮にリフレクションで実装したらどんな関数定義を要求されるだろうか、というところを、Semantic Kernel実装の場合との比較で見てください。
public static partial class FunctionTools
{
// Claudia Source Generator
/// <summary>
/// Retrieve the current time of day in Hour-Minute-Second format for a specified time zone. Time zones should be written in standard formats such as UTC, US/Pacific, Europe/London.
/// </summary>
/// <param name="timeZone">The time zone to get the current time for, such as UTC, US/Pacific, Europe/London.</param>
[ClaudiaFunction]
public static string TimeOfDay(string timeZone)
{
var time = TimeZoneInfo.ConvertTimeBySystemTimeZoneId(DateTime.UtcNow, timeZone);
return time.ToString("HH:mm:ss");
}
// Semantic Kernel
[KernelFunction]
[Description("Retrieve the current time of day in Hour-Minute-Second format for a specified time zone. Time zones should be written in standard formats such as UTC, US/Pacific, Europe/London.")]
public static string TimeOfDay([Description("The time zone to get the current time for, such as UTC, US/Pacific, Europe/London.")]string timeZone)
{
var time = TimeZoneInfo.ConvertTimeBySystemTimeZoneId(DateTime.UtcNow, timeZone);
return time.ToString("HH:mm:ss");
}
}
Function Callingでは、Claudeに関数の情報を与えなければならないので、メソッド・パラメーター共に説明が必須です。ClaudiaのSource Generator実装ではそれをドキュメントコメントから取得するようにしました。Semantic KernelではDescription属性から取ってきています。これはドキュメントコメントのほうが自然で書きやすいはずです。特にパラメーターへの属性は、書きやすさだけじゃなく、複数パラメーターがある場合にかなり読みづらくなります。
また、Source Generatorではアナライザーとして不足がある際にはコンパイルエラーにできます。
全てのパラメーターにドキュメントコメントが書かれていなければならない・対応していない型を利用している、などのチェックが全てコンパイル時どころかエディット時にリアルタイムに分かります。
難点は実装難易度がSource Generatorのほうが高いことと、ドキュメントコメントの利用にはかなり注意が必要です。
Roslyn上でドキュメントコメントを取得するには、ISymbol.GetDocumentationCommtentXml()
が最もお手軽なのですが、これが取得できるかどうかは<GenerateDocumentaionFile>
に左右されます。false
の場合は常にnullを返します。それだと使いにくすぎるので、ClaudiaではSyntaxNodeから取得しようとしたのですが、それも同じく<GenerateDocumentaionFile>
の影響を受けていました。
そこでしょうがなく、以下のような拡張メソッドを用意することで全ての状況でドキュメントコメントを取得することに成功しました(Triviaベースなので少し扱いづらいですが、取れないよりも遥かにマシ)
public static DocumentationCommentTriviaSyntax? GetDocumentationCommentTriviaSyntax(this SyntaxNode node)
{
if (node.SyntaxTree.Options.DocumentationMode == DocumentationMode.None)
{
var withDocumentationComment = node.SyntaxTree.Options.WithDocumentationMode(DocumentationMode.Parse);
var code = node.ToFullString();
var newTree = CSharpSyntaxTree.ParseText(code, (CSharpParseOptions)withDocumentationComment);
node = newTree.GetRoot();
}
foreach (var leadingTrivia in node.GetLeadingTrivia())
{
if (leadingTrivia.GetStructure() is DocumentationCommentTriviaSyntax structure)
{
return structure;
}
}
return null;
}
DocumentationModeの状態によってDocumentationCommentTriviaSyntax
が取れるかどうかが変わる(GenerateDocumentaionFile=false
の場合はNoneになる)ので、Noneの場合はDocumentationMode.Parse
をつけたうえでパースし直すことで取得できました。SyntaxNodeのままオプションを渡してCSharpSyntaxTreeを生成しても、パースし直してくれないのかDocumentationModeを変更しても無駄だったので、文字列化してからParseTextするようにしています。
JSON Serializer
リクエストもレスポンスもJSONです、今の世の中。そして、使うライブラリはSystem.Text.Json.JsonSerializer一択です。異論を挟む余地は、ありますが、ない。好むと好まざると、もはや使わなければならないわけです。
System.Text.Jsonの特徴としてはUTF8ベースで処理ができることなので、極力文字列を通さないようにしてあげると高い性能が見込めます。ReadOnlySpan<byte>
またはReadOnlySequence<byte>
をデシリアライズするには Utf8JsonReaderを通す必要があります。これはref structだからアロケーションがないので、そのままnewして使っていきましょう。ではWriterは?というと、Utf8JsonWriterはclassです。どうして……?なので、Writerに関してはアプリケーションの作りによりますが、フィールドに持って使い回せるのならフィールドに持っての使いまわし(Resetがあります)、持てない場合は[ThreadStatic]
から引っ張ってくるようにしましょう。
ライブラリで用意する場合は、利用する型が全て決まっているのでソース生成してあげると、パフォーマンスもよく、AOTセーフ度も上がるので望ましいはずです。Claudiaでも生成しています。
[JsonSourceGenerationOptions(
GenerationMode = JsonSourceGenerationMode.Default,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false)]
[JsonSerializable(typeof(MessageRequest))]
[JsonSerializable(typeof(Message))]
[JsonSerializable(typeof(Contents))]
[JsonSerializable(typeof(Content))]
[JsonSerializable(typeof(Metadata))]
[JsonSerializable(typeof(Source))]
[JsonSerializable(typeof(MessageResponse))]
[JsonSerializable(typeof(Usage))]
[JsonSerializable(typeof(ErrorResponseShape))]
[JsonSerializable(typeof(ErrorResponse))]
[JsonSerializable(typeof(Ping))]
[JsonSerializable(typeof(MessageStart))]
[JsonSerializable(typeof(MessageDelta))]
[JsonSerializable(typeof(MessageStop))]
[JsonSerializable(typeof(ContentBlockStart))]
[JsonSerializable(typeof(ContentBlockDelta))]
[JsonSerializable(typeof(ContentBlockStop))]
[JsonSerializable(typeof(MessageStartBody))]
[JsonSerializable(typeof(MessageDeltaBody))]
public partial class AnthropicJsonSerialzierContext : JsonSerializerContext
{
}
// 内部での利用時は全てこのJsonSerializerContextを指定している
JsonSerializer.SerializeToUtf8Bytes(request, AnthropicJsonSerialzierContext.Default.Options)
一つ引っ掛かったのが、JsonIgnoreCondition.WhenWritingNull
が、通常(リフレクションベース)だとNullable<T>
にも効いていたのですが、Source Generatorだと効かなくなってnullの時に無視してくれなくなったという挙動の差異がありました。しょうがないので、全ての対象の型のNullable<T>
プロパティに直接[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
を付与することで回避しました。
public record class MessageRequest
{
// ...
[JsonPropertyName("temperature")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public double? Temperature { get; set; }
}
正直Source Generator版の実装漏れの気がするんですが、まぁ回避できたので、とりあえずはいっか。。。
まとめ
OpenAI APIに対するAzure OpenAI Serviceのように、AWS環境の人はAmazon Bedrock経由のほうが使いやすい、というのがあるかもしれません。というわけで本日の先ほどのリリース(v1.0.1)でBedrock対応もしました!より一層利用しやすくなったはずです。
Anthorpic APIを使うにあたって、このClaudiaが、公式SDKや各言語の非公式SDKも含めて、最も使いやすいSDKになっているんじゃないかと自負します。ということは、C#が最もClaudeをAPI経由で使うのに捗る言語ということです!これはC#やるしかない!あるいはClaudeやるしかない!ということで、やっていきましょう……!
R3 - C#用のReactive Extensionsの新しい現代的再実装
- 2024-02-27
先日、新しいC#用のReactive Extensionsの実装としてR3を正式公開しました!R3はRx for .NETを第一世代、UniRxを第二世代とした場合の、第三世代のRxという意味で命名しています。Rxとしてのコア部分(ほぼdotnet/reactiveと同様)は.NET共通のライブラリとして提供し、各プラットフォーム特化のカスタムスケジューラーやオペレーターは別ライブラリに分けるという形により、全ての.NETプラットフォーム向けのコアライブラリと、各種フレームワーク Unity, Godot, Avalonia, WPF, WinForms, WinUI3, Stride, LogicLooper, MAUI, MonoGame 向けの拡張ライブラリを提供しています。
幾つかの破壊的変更を含むため、ドロップインリプレースメントではないですが、dotnet/reactiveやUniRxからの移行も現実的に可能な範囲に収めてあります。この辺は語彙や操作がLINQ的に共通化されているというRxの良いところで、そこのところは大きく変わりはありません。思ったよりも何も変わっていない、といったような印象すら抱けるかもしれませんが、そう思っていただければ、それはそれでR3の設計としては大成功ということになります。
なので基本的なところはRxですし、使えるところも変わりないです。よって、押さえておくべきことは、なぜ今R3という新たな実装が必要になったかということと、Rx for .NET, UniRxとの違いはどこかということです。(新規の人は何も考えず使ってください……!)
機能とか移行とかの話は、toRisouPさんにより既に優れた記事が上がっているので、今回は概念的なところを中心に紹介します……!
Rxの歴史と vs async/await
Rx使ってますか?という問いに、使ってません、と答える人も増えてきました。別にこれは.NETやUnityだけの話ではなく、JavaでもSwiftでもKotlinでも。明らかにプレゼンスが低下しています。なぜか?というと、それはもう簡単です。async/awaitが登場したから。.NETのReactive Extensionsが初登場したのは2009年。C# 3.0, .NET Framework 3.5の頃であり、対応プラットフォームもSilverlightやWindows Phoneといった、今はもう消滅したプラットフォームも並んでくるような時代。もちろん、async/await(初登場はC# 5.0, 2012年)も存在していません。まだTaskすら導入されていなかった頃です。余談ですがReactive Extensionsの"Extensions"は、先行して開発されていたParallel Extensions(Parallel LINQやTask Parallel Library, .NET Framework 4.0で追加された)から名前が取られたとされています。
Rxは、まず、言語サポートのない場合の非同期処理の決定版として、あらゆる言語に普及し一世を風靡しました。単機能なTaskやPromiseよりも、豊富なオペレーターを備えたRxのほうが使いやすいし遥かに強力!私も当時はTPLいらね、とRxに夢中になったものです。しかしasync/awaitが言語に追加されて以降の結果はご存じの通り。async/awaitこそが非同期処理の決定版として、これまたC#からあらゆる言語に普及し、非同期処理におけるスタンダードとなりました。(ちなみにF#こそが発祥だって言う人もいますが、国内海外問わず当時のF#コミュニティのC# async/awaitへの反発と難癖の数々はよーく覚えているので、あ、そうですか、ぐらいの感じです。awaitないしね)
async/awaitが普及したことにより、とりあえず非同期処理のためにRxを入れるという需要はなくなり、Rxの採用率は下がっていったのであった。UnityにおいてのRxのスタンダードであったUniRxの開発者である私も、別にそれに固執することはなく、むしろゲームエンジン(Unity)に特化したasync/awaitランタイムが必要であると素早く認知し、Unityにおいて必要な条件(C# 7.0)が揃ったタイミングで即座にUniTaskを開発し、今ではUniTaskは絶対に入れるけどUniRxは入れない、といった開発者も増えてきました。そしてそれは悪いことではなく、むしろ正しい感覚であると思います。
Rxの価値の再発見
そもそもRxって別に非同期処理のためだけのシステムではないですよね?LINQ to Everythingではあったけれど、むしろEverythingというのはノイズで、分離するものは分離したほうがいい、最適なものはそれを使ったほうがいい。Rxを非同期処理のために使うべきではないし、長さ1のObservableはTaskで表現したほうが、分かりやすさにおいてもパフォーマンスにおいても利点がある。そうなるとRxにはasync/awaitと統合されたAPIが必要で、それはObservableはモナドだからSelectManyにTaskを渡せることもできるだとか、そんなどうでもいいことではない。真剣にasync/awaitと共存するRxを考えてみると、手を加えなければならないAPIは多数ある。
単純にawaitできるだけでは現実のアプリケーション開発には少し足りない。そこで非同期/並列処理に関しては様々なライブラリが考案されてきました、RxだけではなくTPL Dataflowなど色々ありましたが、それらを好んで今から使おうとする人もいないでしょう。そして今は2024年、勝者は決まりました。言語サポートのIAsyncEnumerableとSystem.Threading.Channelsがベストです。また、これらはバックプレッシャーの性質も内包しているため、RxJavaなどにあるバックプレッシャーに関するオペレーターは.NETには不要でしょう。もう少し具体的なI/Oに関する処理が必要ならSystem.IO.Pipelinesを選べば、最大のパフォーマンスを発揮できます。
非同期LINQはあってもいいけれど、実際の非同期ストリームのシナリオからするとLINQ to Objectsと違い利用頻度も少ないので、別に積極的に導入したいというほどの代物ではない(なお、これは私はUniTaskにUniTaskAsyncEnumerableとLINQを自分で実装して提供している上での発言です)。Rxの夢の一つとして分散クエリ(IQbservable)がありましたが、それも、現代での勝者はGraphQLになるでしょう。分散システムという点ではKubernetesが普及し、RPCとしてはgRPCがスタンダードとして君臨し、Orleans, Akka.NET, SignalR, MagicOnionといったような選択肢のバリエーションもあります。
今は様々なテクノロジーが覇権を争った2009年ではない。現代でService Fabricを選ぶ人などいないように、今からそこに乗り出して勝ち筋を見出すのは難しい。そうした分散処理に進むことはRxの未来ではない。と、私は考えています。Rxを生み出したのがCloud Programmability Teamであるからといって、Cloudで活用できるようにすることが原点で正しいなどということもないだろう。もちろん、未来は複数あってもいいので、私が示すRxの未来の選択肢の一つがR3だと思ってもらえればよいです。
ではRxの価値はどこにあるのか、というと、原点に立ち返ってインメモリのメッセージングをLINQで処理するLINQ to Eventsにあると考えます。特にクライアントサイド、UIに対する処理は、現代でもRxが評価されているポイントであり、Rx Likeな、しかしより言語に寄り添い最適化されているKotlin FlowやSwift Combineといった選択肢が現役で存在しています。UIだけではなく、複雑で大量のイベントが飛び交うゲームアプリケーションにおいても、ゲームエンジン(Unity)で使われているUniRxの開発者として、非常に有益であることを実感しています。オブザーバーパターンやeventの有意義さは疑う余地のないところですし、そこでRxがbetter event、オブザーパーパターンの決定版として使えることもまた変わらないわけです。
R3での再構築
最初に、Rxとしてのインターフェイスを100%維持しながらレガシーAPIの削除や新APIの追加をすべきか、それとも根本から変更すべきかを悩みました。しかし(私が問題だと考えている)すべての問題を解決するには抜本的な変更が必要だし、Kotlin FlowやSwift Combineの成功事例もあるので、旧来のRxとの互換性に囚われず、.NET 8, C# 12という現代のC#環境に合わせて再構築された、完全に新しいRxであるべきという路線に決めました。
といっても、最終的にはインターフェイスにそこまで大きな違いはありません。
public abstract class Observable<T>
{
public IDisposable Subscribe(Observer<T> observer);
}
public abstract class Observer<T> : IDisposable
{
public void OnNext(T value);
public void OnErrorResume(Exception error);
public void OnCompleted(Result result); // Result is (Success | Failure)
}
パッと見だとOnErrorがOnErrorResumeになったことと、interfaceではなくてabstract classになったこと、ぐらいでしょうか。どうしても変更したかった点の一つがOnErrorで、パイプライン上で例外が起きると購読解除されるという挙動はRxにおけるbillion-dollar mistakeだと思っています。R3では例外はOnErrorResumeに流れて、購読解除されません。かわりにOnCompletedに、SuccessまたはFailureを表すResultが渡ってくるようになっていて、こちらでパイプラインの終了が表されています。
IObservable<T>/IObserver<T>
の定義はIEnumerble<T>/IEnumerator<T>
と密接に関わっていて、数学的双対であると称しているのですが、実用上不便なところがあり、その最たるものがOnErrorで停止することです。なぜ不便かというと、IEnumerable<T>
のforeachの例外発生とIObservable<T>
の例外発生では、ライフタイムが異なることに起因します。foreachの例外発生はそこでイテレーターの消化が終わり、必要があればtry-catchで処理して、大抵はリトライすることもないですが、ObservableのSubscribeは違います。イベントの購読の寿命は長く、例外発生でも停止しないで欲しいと思うことは不自然ではありません。通常のeventで例外が発生したとて停止することはないですが、Rxの場合はオペレーターチェーンの都合上、パイプライン中に例外が発生する可能性が常にあります(SelectやWhereすればFuncが例外を出す可能性がある)。イベントの代替、あるいは上位互換として考えると、例外で停止するほうが不自然になってしまいます。
そして、必要があればCatchしてRetryすればいい、というものではない!Rxにおいて停止したイベントを再購読するというのは非常に難しい!Observableにはeventと異なり、完了するという概念があります。完了したIObservableを購読すると即座にOnError | OnCompletedが呼ばれる、それにより自動的な再購読は、完了済みのシーケンスを再購読しにかかる危険性があります。もちろんそうなれば無限ループであり、それを判定し正しくハンドリングする術もない。Stack OverflowにはRx/Combine/FlowのUI購読で再購読するにはどうすればいいですか?のような質問が多数あり、そしてその回答は非常に複雑なコードの記述を要求していたりします。現実はRepeat/Retryだけで解決していない!
そこで、そもそも例外で停止しないように変更しました。OnErrorという命名のままでは従来の停止する動作と混同する可能性があるため、かわりにOnErrorResumeという名前に変えています。これで再購読に関する問題は全て解決します。更にこの変更には利点があり、停止する→停止しないの挙動変更は不可能ですが(Disposeチェーンが走ってしまうので状態を復元できないので全体の再購読以外に手段がない)、停止しない→停止するへの挙動変更は非常に簡単でパフォーマンスもよく実装できます。OnErrorResumeが来たらOnCompleted(Result.Failure)に変換するオペレーターを用意するだけですから(標準でOnErrorResumeAsFailureというオペレーターを追加してあります)。
Rx自体が複雑なコントラクトを持つ(OnErrorかOnCompletedはどちらか一つしか発行されない、など)わりに、インターフェースは実装上の保証がないので、従来のRxは正しく実装するのが難しいという問題がありました。SourceのSubscribeが遅延される場合は、先行して返却されるDisposableを正しくハンドリングする必要がある(SingleAssignmentDisposableを使う)などといったことも、正しく理解することは難しいでしょう。SubscribeのonNextで発生した例外はどこに行くのか、onErrorに行ってDisposeされるのか継続されるのか。その動作は特に規定されていないため実装次第で挙動はバラバラの場合もあります。R3ではasbtract class化することにより大部分のコントラクトを保証し、挙動の統一と、独自実装を容易にしました。
そしてabstract classにした最大の理由は、全ての購読を中央管理できるようにしたことです。全てのSubscribeは必ず基底クラスのSubscribe実装を通ります。これにより、購読のトラッキングが可能になりました。例えば以下のような形で表示できます。
これはUnity向けの拡張Windowですが、Godot用にも存在するほか、APIとして提供しているためログに出したり任意のタイミングで取得したり、独自の可視化を作ることも可能です
TaskにはParallel Debuggerがありますが(これもTaskが基底クラス側でs_asyncDebuggingEnabledの時に中央管理している)、Rxの購読の可視化は、それよりも遥かに重要でしょう。イベントの購読リークはつきもので、開発終盤に必死に探し回る羽目になりますが、R3ならもう不要です!圧倒的開発効率アップ!
R3ではこうした購読の管理、リーク防止については最重要視していて、Observable Trackerによる全ての購読の追跡の他に、概念として「全てのObservableは完了することができる」ようにしました。
Rxにおける購読の管理の基本はIDisposableをDisposeすることです。が、購読を解除する方法は実はそれだけではなく、OnError | OnCompletedが流れることでも解除されるようになっています(IObservableのコントラクトが保証しているわけではないですが実装上そうなっている、R3では必ずそうなるように基底クラス側で保証するようにした)。つまりシーケンスの上流(OnError | OnCompletedの発行)と下流(Dispose)、両面からハンドリングすることでリークをより確実に防ぐことができます。
対応として過剰に思うかもしれませんが、実際のアプリケーションを開発してきた経験からいうと、購読管理は過剰なぐらいがちょうどいい。そうした思想から、R3では、今までOnCompletedを発行する手段のなかったObservable.FromEventやObservable.Timer、EveryUpdateなども、OnCompletedを発行可能にしました。なお、発行方法はCancellationTokenを渡すことで、これもasync/await以降に多用(あるいは濫用)されるようになったCancellationTokenを活用する現代的なAPI設計です。また、こうした全てのObservableは完了する、という思想があるため、SubjectのDisposeも標準でOnCompletedを発行するように変更しました。
ISchedulerを再考する
Rxの時空を移動するマジックを実現する機構がISchedulerです。TimerやObserveOnに渡すことで、任意の場所(ThreadやDispatcher、PlayerLoopなど)・時間に値を移動させることができます。
public interface IScheduler
{
DateTimeOffset Now { get; }
IDisposable Schedule<TState>(TState state, Func<IScheduler, TState, IDisposable> action);
IDisposable Schedule<TState>(TState state, TimeSpan dueTime, Func<IScheduler, TState, IDisposable> action);
IDisposable Schedule<TState>(TState state, DateTimeOffset dueTime, Func<IScheduler, TState, IDisposable> action);
}
そして、実は破綻しています。Rxのソースコードを見たことがあるなら気づいているかもしれませんが、初期のうちから追加の別の定義が用意されています。例えばThreadPoolSchedulerは以下のようなインターフェイスを実装しています。
public interface ISchedulerLongRunning
{
IDisposable ScheduleLongRunning<TState>(TState state, Action<TState, ICancelable> action);
}
public interface ISchedulerPeriodic
{
IDisposable SchedulePeriodic<TState>(TState state, TimeSpan period, Func<TState, TState> action);
}
public interface IStopwatchProvider
{
IStopwatch StartStopwatch();
}
public abstract partial class LocalScheduler : IScheduler, IStopwatchProvider, IServiceProvider
{
}
public sealed class ThreadPoolScheduler : LocalScheduler, ISchedulerLongRunning, ISchedulerPeriodic
{
}
そして、以下のような呼び出しがなされています。
public static IStopwatch StartStopwatch(this IScheduler scheduler)
{
var swp = scheduler.AsStopwatchProvider();
if (swp != null)
{
return swp.StartStopwatch();
}
return new EmulatedStopwatch(scheduler);
}
private static IDisposable SchedulePeriodic_<TState>(IScheduler scheduler, TState state, TimeSpan period, Func<TState, TState> action)
{
var periodic = scheduler.AsPeriodic();
if (periodic != null)
{
return periodic.SchedulePeriodic(state, period, action);
}
var swp = scheduler.AsStopwatchProvider();
if (swp != null)
{
var spr = new SchedulePeriodicStopwatch<TState>(scheduler, state, period, action, swp);
return spr.Start();
}
else
{
var spr = new SchedulePeriodicRecursive<TState>(scheduler, state, period, action);
return spr.Start();
}
}
ようは生のISchedulerを使わないケースがそれなりにあります。なぜ使われないのか、というと、パフォーマンス上の問題で、IScheduler.Scheduleは単発の実行しか定義されていなくて、複数回の呼び出しは再帰的にScheduleを呼べばいいじゃんという発想なわけですが、都度IDisposableを生成するなどパフォーマンス的に問題がある。ので、それを回避するためにISchedulerPeriodicなどが用意されたのでした。
それなら、もうISchedulerではなく、実態をまともに反映されたものを使ったほうがいいんじゃないか?と思ったときに出てきたのが.NET 8で追加されたTimeProviderで、これならISchedulerが行っていたことをより効率的にできることを発見しました。
public abstract class TimeProvider
{
// use these.
public virtual ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period);
public virtual long GetTimestamp();
}
CreateTimerで生成されるITimerはISchedulerPeriodicで行える機能を十分持っているほか、ワンタイムの実行を繰り返す(Schedule<TState>(TState state, TimeSpan dueTime, Func<IScheduler, TState, IDisposable> action)
)のシナリオにおいても、ITimerを使いまわせるため、dotnet/reactiveのThreadPoolSchedulerよりも効率的です(ThreadPoolSchedulerは都度new Timer()
している)。
現在時間の取得に関しては、DateTimeOffset IScheduler.Now
のようにTimeProviderもDateTimeOffset TimeProvider.GetUtcNow()
がありますが、使っているのはlong GetTimestamp
だけです。というのも、オペレーターの実装に必要なのはTicksだけなので、わざわざDateTimeOffsetに包むようなオーバーヘッドはないほうが良いので、生のTicksを扱って時間を計算します。
DateTimeOffset.UtcNowはOSのシステム時刻の変更の影響を受ける可能性もあるので、そういう点でもDateTimeOffsetを介さないGetTimestamp(標準ではStopwatch.GetTimestamp()
からの高解像度タイマーが利用される)経由が良いでしょう。
ISchedulerのもう一つの問題として、同期的な処理を行うImmediateScheduler
やCurrentScheduler
がいます。これらにTimerやDelayなど時間系の処理を任せるとThread.Sleepするという、使うべきではない非同期コードのエミュレーションをするので、つまり、同期的なSchedulerは存在が悪なのでないほうがいいでしょう。R3では完全に消し、TimeProviderを指定するということは必ず非同期的な呼び出しであるということを徹底しました。
ImmediateScheduler
やCurrentScheduler
の問題はそれだけじゃなくて、そもそもパフォーマンスが致命的に悪いという問題があります。
Observable.Range(1, 10000).Subscribe()
の結果
CurrentScheduler
はともかく、ImmediateScheduler
の結果が悪いのは直観に反するかもしれません。dotnet/reactiveのImmediateScheduler
は、Scheduleされるたびにnew AsyncLockScheduler()
し、AsyncLockScheduler
が呼び出す基底クラスLocalScheduler
のコンストラクターがSystemClock.Register
し、それはlock
しnew WeakReference<LocalScheduler>(scheduler)
し、HashSet.Add
します。パフォーマンスが悪いのも当然です(ただし再帰的な呼び出し時には都度SingleAssignmentDisposable
を生成するだけに抑えられてはいます、それでも多いですが)
Rangeなんてめったに使わないから大丈夫と思いきや、実は意外なところでImmediateScheduler
はちょくちょく使われています。代表的なのがMerge
で、これはIScheduler
が無指定の場合はImmediateScheduler
を使うため、頻繁な購読を繰り返す作りになっていると、かなりの呼び出す回数になる可能性があります。実際、dotnet/reactiveをサーバーアプリケーションで使用した際に、MergeとImmediateSchedulerが原因でサーバーのメモリ使用量のかなりを占めたことがありました。その時はカスタムの軽量なスケジューラーを作成し、直接指定することで徹底的にImmediateScheduler
を避けることで何とかしました。Next dotnet/reactiveがあるなら、ImmediateScheduler
のパフォーマンスの改善は真っ先に行う必要があります。
SystemClock.Register
をしている理由としては、DateTimeOffset.UtcNow
とシステム時刻の変更の監視のためのようです。つまり、最初からDateTimeOffsetではなくlongを使えば、このような致命的なパフォーマンス低下も招きませんでした。これもまたISchedulerのインターフェイス定義の失敗理由の一つです。
ところで、TimeProviderの採用によって、Microsoft.Extensions.Time.Testing.FakeTimeProviderを使い、標準的な手法でユニットテストが容易になったことも嬉しいところでしょう。
FrameProvider
他のRxでは見かけないがUniRxで絶大な効果を発揮したものとして、フレームベースのオペレーター郡があります。一定フレーム後に実行するDelayFrame
や次フレームで実行するNextFrame
、毎フレーム発行するファクトリーであるEveryUpdate
や、毎フレーム値を監視するEveryValueChanged
など、ゲームエンジンで利用するにあたって便利なオペレーターが揃っています。
そこで気づいたのが、時間とフレームは概念的には似たものであり、ゲームエンジンだけでなく、UI処理ではメッセージループやレンダリングループという形で、様々なフレームワークに存在している。そこで、R3では新しくTimerProviderと対になるFrameProviderという形でフレームベースの処理を抽象化しました。これによってUnityだけに提供されていたフレームベースのオペレーターが、C#が動作するあらゆるフレームワーク(WinForms, WPF, WinUI3, MAUI, Godot, Avalonia, Stride, etc...)で動作せることができるようになりました。
public abstract class FrameProvider
{
public abstract long GetFrameCount();
public abstract void Register(IFrameRunnerWorkItem callback);
}
public interface IFrameRunnerWorkItem
{
// true, continue
bool MoveNext(long frameCount);
}
R3ではTimeProviderを要求するオペレーターがある場合、全てに対となる***Frameオペレーターを実装しました。
- Return <-> ReturnFrame
- Yield <-> YieldFrame
- Interval <-> IntervalFrame
- Timer <-> TimerFrame
- Chunk <-> ChunkFrame
- Debounce <-> DebounceFrame
- Delay <-> DelayFrame
- DelaySubscription <-> DelaySubscriptionFrame
- ObserveOn(TimeProvider) <-> ObserveOn(FrameProvider)
- Replay <-> ReplayFrame
- Skip <-> SkipFrame
- SkipLast <-> SkipLastFrame
- SubscribeOn(TimeProvider) <-> SubscribeOn(FrameProvider)
- Take <-> TakeFrame
- TakeLast <-> TakeLastFrame
- ThrottleFirst <-> ThrottleFirstFrame
- ThrottleFirstLast <-> ThrottleFirstLastFrame
- ThrottleLast <-> ThrottleLastFrame
- Timeout <-> TimeoutFrame
async/await Integration
まず、既存のRxにおいて良くない点である単一の値を返すObservableを徹底的に排除しました。これらはasync/awaitを使うべきで、単一の値を返したり、単一の値を期待して合成するようなオペレーターはバッドプラクティスに誘うノイズです。FirstはFirstAsyncになり、Task<T>
を返します。AsyncSubjectはなくなり、TaskCompletionSourceを使ってください。
そのうえで、現在のC#コードは日常的に非同期のコードが返ってきます、が、基本的にはRxは同期コードしか受け取りません。うっかりすればFireAndForget状態になるし、SelectManyに混ぜるだけでは十分とはいえません。そこで、Where/Select/Subscribeに特殊なメソッド群を用意しました。
- SelectAwait(this
Observable<T>
source,Func<T, CancellationToken, ValueTask<TResult>>
selector,AwaitOperation
awaitOperation = Sequential, ...) - WhereAwait(this
Observable<T>
source,Func<T, CancellationToken, ValueTask<Boolean>>
predicate,AwaitOperation
awaitOperation = Sequential, ...) - SubscribeAwait(this
Observable<T>
source,Func<T, CancellationToken, ValueTask>
onNextAsync,AwaitOperation
awaitOperation = Sequential, ...) - SubscribeAwait(this
Observable<T>
source,Func<T, CancellationToken, ValueTask>
onNextAsync,Action<Result>
onCompleted,AwaitOperation
awaitOperation = Sequential, ...) - SubscribeAwait(this
Observable<T>
source,Func<T, CancellationToken, ValueTask>
onNextAsync,Action<Exception>
onErrorResume,Action<Result>
onCompleted,AwaitOperation
awaitOperation = Sequential, ...)
public enum AwaitOperation
{
/// <summary>All values are queued, and the next value waits for the completion of the asynchronous method.</summary>
Sequential,
/// <summary>Drop new value when async operation is running.</summary>
Drop,
/// <summary>If the previous asynchronous method is running, it is cancelled and the next asynchronous method is executed.</summary>
Switch,
/// <summary>All values are sent immediately to the asynchronous method.</summary>
Parallel,
/// <summary>All values are sent immediately to the asynchronous method, but the results are queued and passed to the next operator in order.</summary>
SequentialParallel,
/// <summary>Send the first value and the last value while the asynchronous method is running.</summary>
ThrottleFirstLast
}
SelectAwait, WhereAwait, SubscribeAwaitは非同期メソッドを受け取り、その非同期メソッドが実行されている間に届く値に対する処理のパターンを6パターン用意しました。Sequentialはいったんキューにためて非同期メソッドが完了したら新しい値を送ります。Dropは実行中に届いた値は全て捨てます、これはイベントハンドリングで多重Submit防止などに使えます。SwitchはObservable<Observable>.Switch
と同様、Parallelは並列実行するものでObservable<Observable>.Merge
と同様、ですがわかりやすいでしょう。並列実行数も指定できます。SequentialParallelは並列実行しつつ、後続に流す値は届いた順序で保証します。ThrottleFirstLastは非同期メソッド実行中の最初の値と最後の値を送ります。
更に、以下の時間系のフィルタリングメソッドなども非同期メソッドを受け取るようになっています。
- Debounce(this
Observable<T>
source,Func<T, CancellationToken, ValueTask>
throttleDurationSelector, ...) - ThrottleFirst(this
Observable<T>
source,Func<T, CancellationToken, ValueTask>
sampler, ...) - ThrottleLast(this
Observable<T>
source,Func<T, CancellationToken, ValueTask>
sampler, ...) - ThrottleFirstLast(this
Observable<T>
source,Func<T, CancellationToken, ValueTask>
sampler, ...)
また、Chunkも同様に非同期メソッドを受け取るほか、SkipUntilには非同期メソッドと、Task, CancellationTokenを受け取れるようになっています。
- SkipUntil(this
Observable<T>
source,CancellationToken
cancellationToken) - SkipUntil(this
Observable<T>
source,Task
task) - SkipUntil(this
Observable<T>
source,Func<T, CancellationToken, ValueTask>
asyncFunc, ...) - TakeUntil(this
Observable<T>
source,CancellationToken
cancellationToken) - TakeUntil(this
Observable<T>
source,Task
task) - TakeUntil(this
Observable<T>
source,Func<T, CancellationToken, ValueTask>
asyncFunc, ...) - Chunk(this Observable
source, Func<T, CancellationToken, ValueTask> asyncWindow, ...)
例えばChunkの非同期関数版を使えば、固定時間ではなくてランダム時間でチャンクを生成するといった複雑な処理を、自然に簡単に書けるようになります。
Observable.Interval(TimeSpan.FromSeconds(1))
.Index()
.Chunk(async (_, ct) =>
{
await Task.Delay(TimeSpan.FromSeconds(Random.Shared.Next(0, 5)), ct);
})
.Subscribe(xs =>
{
Console.WriteLine(string.Join(", ", xs));
});
async/awaitは現代のC#に欠かせないコードですが、可能な限りスムーズにRxと統合されるように腐心しました。
Retry関連もasync/awaitを活用することで、よりベターなハンドリングができます。まず、以前のRxはパイプライン丸ごとのリトライしか出来ませんでしたが、async/awaitを受け入れられるR3なら、非同期メソッド実行単位でのリトライができます。
button.OnClickAsObservable()
.SelectAwait(async (_, ct) =>
{
var retry = 0;
AGAIN:
try
{
var req = await UnityWebRequest.Get("https://google.com/").SendWebRequest().WithCancellation(ct);
return req.downloadHandler.text;
}
catch
{
if (retry++ < 3) goto AGAIN;
throw;
}
}, AwaitOperation.Drop)
.SubscribeToText(text);
Repeatもasync/awaitと組み合わせることで実装できます。この場合、Repeatの条件に関する複雑なハンドリングがRxだけで完結させるよりも、容易にできるでしょう。
while (!ct.IsCancellationRequested)
{
await button.OnClickAsObservable()
.Take(1)
.ForEachAsync(_ =>
{
// do something
});
}
手続き的なコードは決して悪いことではないですし、場合によりRxのオペレーターだけで完結させるよりも可読性が高くなります。コーディングにおいて優先すべきは可読性の高さ(とパフォーマンス)です。より良いコードのためにも、Rxとasync/awaitをうまく連携させていきましょう。
CreateやCreateFromなどで、非同期メソッドからObservableを生成することもできます。ここから生成することで、オペレーターを無理やりこねくり回すよりも簡潔に記述することが可能かもしれません。
Create(Func<Observer<T>, CancellationToken, ValueTask> subscribe, ...)
CreateFrom(Func<CancellationToken, IAsyncEnumerable<T>> factory)
名前付けのルール
R3では幾つかのメソッドの名前がdotnet/rectiveやUniRxから変更されています。例えば以下のものです。
Buffer
->Chunk
StartWith
->Prepend
Distinct(selector)
->DistinctBy
Throttle
->Debounce
Sample
->ThrottleLast
この変更の理由について説明しましょう。
まず、.NETにおいてLINQスタイルのライブラリを作成する場合に最優先すべき名前はLINQ to Objects(Enumerable)に実装されているメソッド名です。Buffer
がなぜChunk
に変更されたかというと、.NET 6からEnumerable.Chunkが追加され、その機能がBufferと同じだからです。RxのほうがChunkの登場より遥か前なので、名前が違うのはどうにもならないのですが、何のしがらみもないのなら名称はLINQ to Objectsに合わせなければならない。よって、Chunk一択です。PrependやDistinctByも同様です。
Throttle
がDebounce
に変更されたことには抵抗があるかもしれません。これは、そもそも世の中のスタンダードはDebounce
だからです。Rx系でDebounce
をThrottle
という名前でやってるのはdotnet/reactiveだけです。世の中のRxの始祖はRxNetなのだから変えなきゃいけない謂われはない、と突っぱねることも正義ではあるんですが、もはや多勢に無勢の少数派なので、長いものに巻かれることもまた正しい。
Debounce
に変えた理由はそれだけではなく、ThrottleFirst
/ ThrottleLast
の存在もあります。これらはサンプリング期間の最初の値を採用する、または最後の値を採用する、というもので対になっています。で、(dotnet/reactiveの)Throttleは全然違う挙動なわけです、なのにThrottleという名前は混乱するでしょう。そももそもdotnet/reactiveにはThrottleFirstが存在せず、ThrottleLastに相当するSampleのみが存在するので大丈夫なのですが、ThrottleFirst/ThrottleLastを採用するなら、必然的に名前はDebounce
にせざるを得ません。どちらかというとdotnet/reactiveの機能不足が悪い。
Sample
に関してはFirst/Lastという名前と機能の対称性からThrottleLast
という名前に変更しました。dotnet/reactiveではFirstが存在しないのでSampleでも良かったのですが、ThrottleFirst
を採用するなら、必然的に名前はThrottleLast
になります。
Sample
の名前は残してThrottleLast
のエイリアスにするという折衷案もあるのですが(RxJavaなどはそうなっています)、同じ機能の別名があるとユーザーは混乱します。世の中にはsample
とthrottleLast
の違いってなんですか?みたいな質問がそれなりにあります。ただでさえ複雑なRx、無用な混乱を避けるためにもエイリアスは絶対にやめるべき。SelectをMap、WhereをFilterにマッピングするみたいなエイリアスは愚かの極みです。
プラットフォーム向けデフォルトスケジューラー
dotnet/reactiveにおいてデフォルトのスケジューラーはほとんど固定です。正確にはIPlatformEnlightenmentProvider
やIConcurrencyAbstractionLayer
というのものを適切に実装すれば、ある程度挙動を差し替えることも可能なのですが、無駄に複雑なうえに[EditorBrowsable(EditorBrowsableState.Never)]
で隠されているしで、まともに使うことはほとんど想定されていないように見えます。
しかし、TimerやDelayなどはWPFであればDispatcherTimerで、UnityではPlayerLoop上のTimerで動くと、自動的にメインスレッドにディスパッチしてくれるので、ほとんどの場合でObserveOnが不要になるので便利ですしパフォーマンス上も有利に働きます。
R3ではシンプルにデフォルトのTimeProvider/FrameProviderを差し替えられるようにしました。
public static class ObservableSystem
{
public static TimeProvider DefaultTimeProvider { get; set; } = TimeProvider.System;
public static FrameProvider DefaultFrameProvider { get; set; } = new NotSupportedFrameProvider();
}
アプリケーション起動時に差し替えれば、そのアプリケーション上でベストなスケジューラーがデフォルト利用されます。
// 例えばWPFの場合はDispatcher系がセットされるので自動的にUIスレッドに戻ってくる
public static class WpfProviderInitializer
{
public static void SetDefaultObservableSystem(Action<Exception> unhandledExceptionHandler)
{
ObservableSystem.RegisterUnhandledExceptionHandler(unhandledExceptionHandler);
ObservableSystem.DefaultTimeProvider = new WpfDispatcherTimerProvider();
ObservableSystem.DefaultFrameProvider = new WpfRenderingFrameProvider();
}
}
// Unityの場合はPlayerLoopベースのものが使用されるのでThreadPoolを避けれる
public static class UnityProviderInitializer
{
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
public static void SetDefaultObservableSystem()
{
SetDefaultObservableSystem(static ex => UnityEngine.Debug.LogException(ex));
}
public static void SetDefaultObservableSystem(Action<Exception> unhandledExceptionHandler)
{
ObservableSystem.RegisterUnhandledExceptionHandler(unhandledExceptionHandler);
ObservableSystem.DefaultTimeProvider = UnityTimeProvider.Update;
ObservableSystem.DefaultFrameProvider = UnityFrameProvider.Update;
}
}
dotnet/reactiveがデフォルトスケジューラーを変更できないのは、あまり、多種のプラットフォームをサポートしているとは言い難いでしょう。
internal static class SchedulerDefaults
{
internal static IScheduler ConstantTimeOperations => ImmediateScheduler.Instance;
internal static IScheduler TailRecursion => ImmediateScheduler.Instance;
internal static IScheduler Iteration => CurrentThreadScheduler.Instance;
internal static IScheduler TimeBasedOperations => DefaultScheduler.Instance;
internal static IScheduler AsyncConversions => DefaultScheduler.Instance;
}
特にAOTのシナリオやWeb向けパブリッシュ(WASM)では、ThreadPoolが使えなくて絶対に避けたいという状況もあります。そこでSchedulerDefaults.TimeBasedOperationsが実質ThreadPoolSchedulerに固定されているのは厳しいと言わざるを得ません。
Pull IAsyncEnumerable vs Push Observable
IAsyncEnumerable
(またはUniTaskのIUniTaskAsyncEnumerable
)は、Pullベースの非同期シーケンス。RxはPushベースの非同期シーケンス。似てます。LINQ的なことができるのも似てます。どちらを使うべきかがケースバイケースなのは当然だとして、じゃあそのケースってのはなんなのか、いつどちらを使えばいいのか。という判断基準は欲しいところです。
基本的には裏にバッファー(キュー)があるものはPullベースが向いていると思うので、ネットワーク系のシナリオなんかはIAsyncEnumerable
を使っていくといいんじゃないでしょーか。で、実際、System.IO.Pipelines
やSystem.Threading.Channels
によって自然と使う機会が出てきます。
Rxを使うべきところは、やはりイベント関連です。
どちらを使うべきかの判断の決め手は、源流のソースにとって自然な表現を選ぶべき、ということです。生のイベント、OnMoveであったりOnClickであったりなどは、完全にPushで、そこにバッファーはありません。ということは、Rxで扱うほうが自然です。間にキューを挟んでIAsyncEnumerable
で扱うこともできますが、不自然ですよね。あるいはキューを介さないことにより意図的に値をDropするという表現をすることもできますが、やはりそれも不自然です。不自然ということはたいていはパフォーマンスも良くないし、分かりやすくもない。つまり、良くない。だから、イベント関連はRxで扱いましょう。R3ならasync/awaitとの統合によって、非同期処理中のバッファリングや値のドロップなどは明示的にオペレーターで指定することができます。それは、分かりやすく、パフォーマンスも良い。R3を使っていきましょう。
C#パフォーマンス勉強会
ところで4/27にC#パフォーマンス勉強会という勉強会が大阪で(大阪で!)開催されます。私は「R3のコードから見る実践LINQ実装最適化・コンカレントプログラミング実例」というタイトルで、R3の!実装の!パフォーマンス上の工夫を!徹底的に解説しようと思っているので、参加できる方はぜひぜひです。関西へは滅多に行かないので貴重な機会ということなのでよろしくお願いします!
まとめ
色々言いましたが、オリジナルのRx.NETの作者達には感謝しかありません。改めて、やはりRxのアイディアの素晴らしさや、各種オペレーターの整理された機能には目を見張るものがあります。幾つかの部分の実装は古くなってしまっていますが、実装クオリティも高いと思います。私自身も最初期から使ってきたし、熱狂してきました。そして、現在のメンテナーにも感謝します。常に変わっていく環境の中で、多く使われているライブラリを維持することはとても大変なことです。
しかし、だからこそ、Rxの価値を復活させたかった。そして、再構築するならば、できるのは私しかいないと思った。最初期からのRxの歴史と実装を知っていて、自分でRxそのものの実装(UniRx)を行い、それが世の中に広く使われることで多くのユースケースや問題点を知り、自分自身もゲームタイトルの実装で大規模に使われるRxのアプリケーション側にも関わり、Rxと対となるasync/awaitの独自ランタイム(UniTask)を実装し、それも世の中に広く使われていることで、この領域に関してのあらゆる知見がある。
上のほうでも言いましたが、未来は複数あってもいいので、私が示すRxの未来の一つがR3だと思ってもらえればよいです。dotnet/reactiveにもまた別の進化と未来がある。かもしれません。
そのうえでR3は置き換えられるだけのポテンシャルと、可能性を見せることができたと思っています。実装には自信あり、です。今回UniRxの実績があったからというのもあり、プレビュー公開時から多くのフィードバックがもらえたことは嬉しかったです(UniTask初公開時は、Unityのコンパイラを実験的コンパイラに差し替える必要があるとかいうエクストリーム仕様だったせいか、しばらくの間は誰も使ってくれなかったというか意義を分かってくれなかったので……)。
移行に関するシナリオも最大限配慮したつもりではあるので、是非使ってみてください……!
.NETプロジェクトとUnityプロジェクトのソースコード共有最新手法
- 2024-01-15
MagicOnionのv6が先日リリースされました。
メジャーバージョンアップとして大きな違いは、Cysharp/YetAnotherHttpHandlerを正式リリースし、これを通信層の標準ライブラリ化しました。インストール手順も複雑で、サポートも切れていたgRPC C-Coreとはさようならです。正式リリースにあたってプレビューに存在していたクラッシュ問題などが解消されています。
もう一つはクライアント生成においてコマンドラインツールが削除され、Source Generatorベースになりました。
[MagicOnionClientGeneration(typeof(MyApp.Shared.Services.IGreeterService))]
partial class MagicOnionGeneratedClientInitializer {}
これだけでコンパイル時にジェネレートされます。コマンドラインツールには、インストールしている.NETのバージョンによって動作したりしなかったりや、生成ファイルの管理をどうするかや、ビルドプロセスの複雑化など、問題が多くありましたがSource Generator化によって全て解決しました。
残念ながらまだMessagePack for C#がコマンドラインツールを必要としているため、完全なコマンドラインツール不要化には至っていませんが、そちらの改善も着手中のため、近いうちにはアプリケーション全体の完全なSource Generator化が果たせるのではないかと思います。それに合わせてCysharp/MasterMemoryのSource Generator化も行いたいと思っています。
.NETプロジェクトとUnityプロジェクト間でのコード共有
MagicOnionに限らずですが、.NETとUnityとの間でソースコードをどのように共有すればいいのか問題があります。昔のやり方では、Unity側で実態を持っていて.NET側で参照を拾ってくるとか、.NET側のビルド時にUnity側にコピーをばらまく、シンボリックリンクで参照する、などといった方法を提案していたのですが、すべて正直イマイチでした。
というわけで令和最新版の方法を紹介します。先に結論をいうと、.NET側に普通の共有用クラスライブラリプロジェクトを作って、Unity側ではUPMのローカルパッケージ参照でソースコードを引っ張ってくるのが現状のベストだと考えています。ただしそのままやると幾つか面倒なことが発生するので、しっかりした手順をここに書いておきます。
まずは.NET側のプロジェクトとして、.NET Standard 2.0/2.1, LangVersion 9のクラスライブラリプロジェクトを作ります。
そしてDirectory.Build.props
を配置します。これは複数のcsprojにまたがって共有した設定が行えるやつなのですが、今回は単独のcsprojに適用する場合にも使います。そんなDirectory.Build.props
の中身はこれです。
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<!-- Unity ignores . prefix folder -->
<ArtifactsPath>$(MSBuildThisFileDirectory).artifacts</ArtifactsPath>
</PropertyGroup>
</Project>
最新手法と銘打った理由として.NET 8(以降に同梱されてるコンパイラ)は成果物の出力レイアウトを変更することができるようになりました。なぜこれが必要かというと、通常、ビルドするとbin, objがcsprojのディレクトリに吐かれるわけですが、Unityでパッケージ参照するとそのbin, objまで取り込んでしまって大問題なんですね。ArtifactsPathを設定することでbin, objの出力場所を変更できます、そしてUnityのアセットインポートにおける命名規則のうち.
か~
で始まってるファイルまたはフォルダは無視されます。というわけで、bin, objの出力場所を.artifacts
に変えることで、Unityから参照しても問題ない構成になりました。
もう少し作業が必要で、次にcsprojを開いて、以下の行を追加しておきます。
<ItemGroup>
<None Remove="**\package.json" />
<None Remove="**\*.asmdef" />
<None Remove="**\*.meta" />
</ItemGroup>
これは、Unityからパッケージ参照すると.metaが大量にばらまかれてウザいので、少なくともcsprojの見た目からは消しておきます。package.jsonとasmdefも同様に.NETプロジェクトとしては不要なので管理外へ。
というわけで最後に、package.jsonとasmdefをこのディレクトリに置いておきましょう。これがないとUnity側から正しく参照できないので。
{
"name": "com.cysharp.magiconion.samples.chatapp.shared.unity",
"version": "1.0.0",
"displayName": "ChatApp.Shared.Unity",
"description": "ChatApp.Shared.Unity",
"unity": "2019.1"
}
{
"name": "ChatApp.Shared.Unity",
"references": [
"MessagePack",
"MagicOnion.Abstractions"
],
"optionalUnityReferences": [],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": []
}
referencesとかはお好きな感じで。
これでほぼ準備は完了です!とはもうUnity側ではPackage Managerを開いてAdd package from diskで先ほどの共有プロジェクトのディレクトリを指定すればOK。
ただし、これで参照すると絶対パスが書かれているので、manifest.json
を開いて相対パスに手動で書き換えましょう。
{
"dependencies": {
"com.cysharp.magiconion.samples.chatapp.shared.unity": "file:../../ChatApp.Shared",
}
}
これでいい具合に取り扱うことができました!
さらに一歩進んで、サーバー側のslnでUnity側のcsprojも一緒に管理したいんだよなあ、とかやりたい場合はCysharp/SlnMergeを使うとよいでしょう。
単一slnで管理すると、Unity側での作業時に共有プロジェクトのコードを弄りやすくなりますし、サーバー/クライアントを超えたデバッグのステップ実行ができるようになるなど、かなり作りやすくなるので、あわせて是非設定しておくことをお薦めします。
Unity用ライブラリのNuGet配布のための開発時環境設定
先日R3というUniRxの進化版みたいなのをリリースしましたが、これはコアライブラリはNuGetで配布するようにしました。ちょっと前まで私はNuGet配布に関して否定的で、Unity向けにはソースコードをちゃんと配らないと、みたいに思ってたんですが、今はNuGet配布にたいして超ポジティブです。というか、逆にNuGet配布じゃないとマズいような状況もあるので、今後のものは全てNuGet配布にするほか、既存のものも随時NuGet配布に切り替えると思います。まずはMessagePack for C#が近いうちにそうなります……!
それはいいんですが、Unity用に開発している際に.NETライブラリとして作られているコードを参照したい、んですよね、というか参照できないとUnity向け拡張(R3.Unity)が作れないし。
で、じゃあ上のやり方みたいローカルパッケージ参照でソースコードを持ってきてやろう、と思ったんですが、ダメでした。というのもR3の本体はC# 12で書かれていたのだ……!DLLとして配布するので別に言語バージョンは問題ない(コンパイルしてIL化すると.NETのバージョンは関係ありますが言語バージョンは関係なくなる)ので、Unityで使うことが前提ながら普通にC# 12で書いていたので、ソースコードとしての参照はできない。
ビルド時の成果物をUnity側にコピーするようにしても、まぁいいっちゃあいいんですが、作業中のちょっと書き換える度にコミットされるのでリポジトリが無駄に膨らむから嫌だなー、と。
で、そこで、やはりローカルパッケージ参照です。ただし今回はpackage.json
のみで、asmdefは配りません。そしてbin/Debug/netstandard2.0
(2.1でもいい)にpackage.jsonを置いて、package.jsonとpackage.json.metaのみgitの管理下に置きます。
実際のリポジトリ: https://github.com/Cysharp/R3/tree/main/src/R3/bin/Debug/netstandard2.0
手元のフォルダの状況:
これを同じようにローカルパッケージ参照すると、開発用のdllだけをUnityに引っ張ってくることができました。別にパッケージの中にソースコードがなくてもいいわけですね……!
なお、普通のゲーム開発でもC# 12で書きたいんだよー、という人は、ソースコード参照じゃなくてこっちのやり方を使っても成立はします。全然、アリです。ただし、.NET側でビルドしないと反映されないとか、デバッグビルドとリリースビルドどっち参照させます?とかいうところを考えなきゃいけないので、まぁお好みで、というところでしょうか。
まとめ
というわけで、2024年になってようやく満足いく共有手法にたどり着けました。これはC#大統一理論元年……!
2023年を振り返る
- 2023-12-30
今年も相変わらずC#関連で色々やっていきました……!というわけなんですが、一番大きかったのはcsbindgenでしょうか。
私のスタンスとして、今までとにかくPure C#でなんとかする!という姿勢ではあったんですが、より柔軟にネイティブコードを取り入れていくという変化になりました。そのほうが、より「C#の可能性を切り開いていく」というCysharpのミッションにも近づけているわけで、かなり良い変化をもたらせたと思っています。発展して
といったような有意義なライブラリを生み出せるキッカケにもなれましたし。とはいえ改めてネイティブはネイティブで大変なのは変わらないので、C#最高、みたいな思いも強くなりましたが……!
私個人のスキルとしても、Rustを取り入れられるようになったのはかなり良かったことですね。Rustから学ぶことも多く、より良いC#のコードを書くことにも役立ちます。
OSSの後半戦ではZLogger v2は傑作だと思っています……!
思っているので、ちょっと普及活動頑張りたいと思っています、NLog、Serilogと同列に並んで検討対象になってくれると嬉しいのですけれどねー。まぁ、まずはReadMeから、ですが。なんとまだ工事中!よくない!
その他小粒の新規OSS郡もありました。
- SimdLinq - LINQをそのままSIMD対応して超高速化するライブラリ
- StructureOfArraysGenerator - C#でSoAを簡単に利用するためのSource Generator
- MagicPhysX - .NET用のクロスプラットフォーム物理エンジン
- UTF8文字列生成を最適化するライブラリ Utf8StringInterpolation を公開しました
こう見ると、今年もなんだかんだで色々やってはいましたね!
そして久々にCEDECでの発表もしてきました。
近年の総決算のつもりであったのですが、ちょっとシリアライザ的なもの(Stream)に偏りすぎではあった、かな?また5年後ぐらいには別の切り口で話せるといいかなーとは思ってます。
さて、ここ数年は「自称革命的なサービス(?)を来年こそはリリースする」と言い続けていたのですが、それは頓挫しました!革命的なサービスは出ない!しょーがない。csbindgenとかはそれの副産物なので、成果は無、というわけではないんですが、頓挫はやはり悲しくはあります。。。
そんなわけで、来年は別のネタを探しつつも、一つだけ、来年初頭というか1月の頭というか、あともう一週間ないぐらいに、大型のOSSを(プレビュー)リリースしたいと思って、ここ一ヶ月ぐらいは延々と集中してコード書いてます。かなり本気でやっているので、それは是非楽しみにしてください……!
ZLogger v2 による .NET 8活用事例 と Unity C# 11対応の紹介
- 2023-12-19
C#用の新しい超高速&低アロケーションの.NET用ロギングライブラリ、ZLogger v2を公開しました。v1からは何もかもを完全に作り替えた、最新のC#に合わせた新設計になっています。対応プラットフォームは.NET 8が最良ですが .NET Standard 2.0 以上、また Unity 2022.2 以上にも対応しています。.NET / Unityどちらもテキストメッセージと構造化ログの両方に対応しています。
新設計のキーポイントはString Interpolationの全面採用によるクリーンなシンタックスとパフォーマンスの両立です。
logger.ZLogInformation($"Hello my name is {name}, {age} years old.");
といったように書いたコードは
if (logger.IsEnabled(LogLvel.Information))
{
var handler = new ZLoggerInformationInterpolatedStringHandler(30, 2, logger);
handler.AppendLiteral("Hello my name is ");
handler.AppendFormatted<string>(name, 0, null, "name");
handler.AppendLiteral(", ");
handler.AppendFormatted<int>(age, 0, null, "age");
handler.AppendLiteral(" years old.");
}
のようにコンパイル時に分解されます。フォーマット文字列を実行時ではなくコンパイル時に展開すること、パラメーターはAppendFormatted<T>
の形でジェネリクスで受け取ることによりボクシングが発生しないなど、コードからも明らかに効率的なことが見てとれます。ちなみにコンストラクターの30は文字列の長さ、2はパラメーターの数を指していて、ここから必要な初期バッファ数を算出していることも効率化の一つに繋がっています。
String Interpolation自体はC# 6.0から搭載されている機能ですが、文法上同じながらC# 10.0から強化されたString Interpolationが搭載されていて、カスタムのString Interpolationを提供することも可能になりました。
こうして得られた文字列断片とパラメーターは、最終的にはCysharp/Utf8StringInterpolationを通して文字列化せずに、直接UTF8としてStreamに書き込むことによって、高速化と低アロケーションを実現しています。
また、Structured Loggingにおいても、System.Text.JsonのUtf8JsonWriterとタイトに結びつくことにより
// 例えば {"name":"foo",age:33} のようにUtf8JsonWriterに書き込む
// Source Generator版、実際どうなってるかのイメージがとても分かりやすい。
public void WriteJsonParameterKeyValues(Utf8JsonWriter writer, JsonSerializerOptions jsonSerializerOptions)
{
writer.WriteString(_jsonParameter_name, this.name);
writer.WriteNumber(_jsonParameter_age, this.age);
}
// StringInterpolation版、ちょっと遠回りな感じですがやってることは一緒。
public void WriteJsonParameterKeyValues(Utf8JsonWriter writer, JsonSerializerOptions jsonSerializerOptions)
{
for (var i = 0; i < ParameterCount; i++)
{
ref var p = ref parameters[i];
writer.WritePropertyName(p.Name.AsSpan());
// MagicalBoxの説明は後述
if (!magicalBox.TryReadTo(p.Type, p.BoxOffset, jsonWriter, jsonSerializerOptions))
{
// ....
}
}
}
やはり直接UTF8として書き込みます。Structured Loggingは最近のトレンドなので、色々な言語のロガーに実装されていますが、パフォーマンスを両立しつつ、ここまでクリーンなシンタックスで実現できているものは他にない!という感じなのでかなり良いのではないでしょうか。
では実際ベンチマーク結果でどれぐらい?というと、アロケーションは少なくとも圧倒的です。
アロケーションは、という歯切れの悪い言い方をしているのは、念入りに高速になるよう設定したNLogが思ったよりも速かったせいですね、ぐぬぬ……。
さて、ZLoggerの特徴のもう一つは、Microsoft.Extensions.Loggingの上に直接構築していることです。通常のロガーは独自のシステムを持っていて、Microsoft.Extensions.Loggingと繋げる場合はブリッジを通します。現実的なアプリケーションでは ASP .NETを使う場合などMicrosoft.Extensions.Loggingを避けることはほぼ不可能です。.NET 8からはOpenTelemetry対応の強化やAspireなど、ますますMicrosoft.Extensions.Loggingの重要性は増しています。ZLogger v1と異なり、v2ではScopeなど、Microsoft.Extensions.Loggingの全機能に対応しています。
そして例えばSerilogのブリッジライブラリの品質は(ソースコードも確認しましたが)かなり低く、実際のパフォーマンスの数字にも現れています。ZLoggerはそうしたオーバーヘッドが一切かかりません。
また、デフォルトの設定も非常に重要です。ほとんどのロガーの標準設定は、例えばファイルストリームに書く場合は都度Flushするなど、かなり遅い設定が標準になっています。それを高速化するにはasync, bufferedを適切に調整する必要があり、かつ、取りこぼさないように終了時に確実にFlushさせる必要があるのですが、かなり難しいので、ほとんど標準設定のままの人も多いのではないでしょうか?ZLoggerではデフォルトで最高速になるように調整してあり、かつ、Microsoft.ExtensionsのDIのライフサイクルで最後のFlushも自動でかかるようになっているので、ApplicationBuilderなどでアプリケーションを構築した場合は何も意識しなくても取りこぼしは発生しません。
なお、都度Flushのパフォーマンスはストレージの書き込み性能に強く依存するため、例えば最近のマシンのM.2 SSDは非常に高速なため、ローカルでベンチマークすると意外と遅くない、といったことを確認できるかもしれません。ただし、実際にアプリケーションを配置する、例えばクラウドサーバーのストレージ性能がそこまで高いことはないので、ローカルでの結果を過信しないほうがいいでしょう。
MagicalBox
ここからは、パフォーマンスを実現した幾つかのトリックを紹介します。v1から引き継いでいるのはSystem.Threading.Channelsを活用したasyncな非同期書き込みプロセスの作成と、IBufferWriter<byte>
による効率的なbufferedの利用による、Streamへの書き込み最適化ですが、説明は割愛します。
JSON化のために、パラメーターはInterpolatedStringHandlerで、一時的に値として保持します。その場合に、<T>
の値をどのように保持するか、という問題がでてきます。普通に考えると、List<object>
といったようなobject型で保持することになります。
[InterpolatedStringHandler]
public ref struct ZLoggerInterpolatedStringHandler
{
// あらゆる<T>の型を格納するためにobjectを使う、ボクシングが発生するので良くはない。
List<object> parameters = new ();
public void AppendFormatted<T>(T value, int alignment = 0, string? format = null, [CallerArgumentExpression("value")] string? argumentName = null)
{
parameters.Add((object)value);
}
}
それを避けるために、ZLoggerではMagicalBoxという仕組みを用意しました。
[InterpolatedStringHandler]
public ref struct ZLoggerInterpolatedStringHandler
{
// 魔法の箱に無限に詰め込む
MagicalBox magicalBox;
List<int> boxOffsets = new (); // 実際はこの辺は入念にキャッシュされています
public void AppendFormatted<T>(T value, int alignment = 0, string? format = null, [CallerArgumentExpression("value")] string? argumentName = null)
{
if(magicalBox.TryWrite(value, out var offset)) // boxingが発生しない!
{
boxOffsets.Add(offset);
}
}
}
MagicalBoxはどんな型(unmanaged型に限る)でも、ボクシングなしに書き込むことができる。というコンセプトで、その実態はbyte[]
にUnsafe.Write、offsetを元にUnsafe.Readするというだけの代物です。
internal unsafe partial struct MagicalBox
{
byte[] storage;
int written;
public MagicalBox(byte[] storage)
{
this.storage = storage;
}
public bool TryWrite<T>(T value, out int offset)
{
if (RuntimeHelpers.IsReferenceOrContainsReferences<T>())
{
offset = 0;
return false;
}
Unsafe.WriteUnaligned(ref storage[written], value);
offset = written;
written += Unsafe.SizeOf<T>();
return true;
}
public bool TryRead<T>(int offset, out T value)
{
if (!RuntimeHelpers.IsReferenceOrContainsReferences<T>())
{
value = default!;
return false;
}
value = Unsafe.ReadUnaligned<T>(ref storage[offset]);
return true;
}
}
この辺はMemoryPackでの実装経験が元になっていて、うまく機能しています。
なお、実際のコードではbyte[] storage
の効率的な再利用や非ジェネリクスなRead対応、Enumへの特別対応が入ったりなど、もう少し複雑なコードになってはいます。さすがに。
カスタムフォーマット文字列
ZLoggerのString Interpolationのいいところは、パラメーター値にメソッド呼び出しを含めると、LogLevelのチェックが入った後に呼び出されるので無駄な実行を防げるところです。
// これは
logger.ZLogDebug($"Id {obj.GetId()}: Data: {obj.GetData()}.");
// このようにLogLevelが有効かどうかチェックした後にメソッドが呼ばれる
if (logger.IsEnabled(LogLvel.Debug))
{
// snip...
writer.AppendFormatterd(obj.GetId());
writer.AppendFormatterd(obj.GetData());
}
しかし、メソッド呼び出しをStructured Loggingに出力した場合、ZLoggerはC# 10.0以降から追加されたCallerArgumentExpressionでパラメーター名を取得しているため、メソッド呼び出しの場合は "obj.GetId()" という微妙極まりない名前で出力されてしまいます。そこで、特殊なカスタムフォーマット文字列で別名を指定することができます。
// @name で別名を付けられる
logger.ZLogDebug($"Id {obj.GetId():@id}: Data: {obj.GetData():@data}.");
ZLoggerでは、String Interpolationの本来の式に従って、","でアラインメント、":"でフォーマット文字列を指定することができます。それに加えて特殊な指定として、フォーマット文字列を@から始めた場合はパラメーター名として出力します。
@によるパラメーター名指定とフォーマット文字列は併用することができます。
// Today is 2023-12-19.
// {"date":"2023-12-19T11:25:34.3642389+09:00"}
logger.ZLogDebug($"Today is {DateTime.Now:@date:yyyy-MM-dd}.");
もう一つ、共通の特殊なフォーマット文字列として"json"を指定するとJsonSerializeした形で出力できます(この辺はSerilogの持つ機能からインスパイアされました)
var position = new { Latitude = 25, Longitude = 134 };
var elapsed = 34;
// {"position":{"Latitude":25,"Longitude":134},"elapsed":34}
// Processed {"Latitude":25,"Longitude":134} in 034 ms.
logger.ZLogInformation($"Processed {position:json} in {elapsed:000} ms.");
特殊フォーマット文字列は、例えばログレベルやカテゴリー、日付を先頭/末尾に付与するためのPrefixFormatter/SuffixFormatterにも幾つか用意してあります。
logging.AddZLoggerConsole(options =>
{
options.UsePlainTextFormatter(formatter =>
{
// 2023-12-19 02:46:14.289 [DBG]......
formatter.SetPrefixFormatter($"{0:utc-longdate} [{1:short}]", (template, info) => template.Format(info.Timestamp, info.LogLevel));
});
});
Timestampにはlongdate
, utc-longdate
, dateonly
など。LogLevelにはshort
で3文字ログレベル表記(先頭の長さが一致するのでエディタで開いた時に読みやすくなる)へと変換されます。これら組み込みの特殊フォーマット文字列は、パフォーマンス最適化という意味合いもあります。例えばLogLevelは以下のようなコードになっているので、手で書式を作るよりも、事前組み込みのUTF8文字列で書き込むことで、絶対的に効率がよくなっています。
static void AppendLogLevel(ref Utf8StringWriter<IBufferWriter<byte>> writer, ref LogLevel value, ref MessageTemplateChunk chunk)
{
if (!chunk.NoAlignmentAndFormat)
{
if (chunk.Format == "short")
{
switch (value)
{
case LogLevel.Trace:
writer.AppendUtf8("TRC"u8);
return;
case LogLevel.Debug:
writer.AppendUtf8("DBG"u8);
return;
case LogLevel.Information:
writer.AppendUtf8("INF"u8);
return;
case LogLevel.Warning:
writer.AppendUtf8("WRN"u8);
return;
case LogLevel.Error:
writer.AppendUtf8("ERR"u8);
return;
case LogLevel.Critical:
writer.AppendUtf8("CRI"u8);
return;
case LogLevel.None:
writer.AppendUtf8("NON"u8);
return;
default:
break;
}
}
writer.AppendFormatted(value, chunk.Alignment, chunk.Format);
return;
}
switch (value)
{
case LogLevel.Trace:
writer.AppendUtf8("Trace"u8);
break;
case LogLevel.Debug:
writer.AppendUtf8("Debug"u8);
break;
case LogLevel.Information:
writer.AppendUtf8("Information"u8);
break;
case LogLevel.Warning:
writer.AppendUtf8("Warning"u8);
break;
case LogLevel.Error:
writer.AppendUtf8("Error"u8);
break;
case LogLevel.Critical:
writer.AppendUtf8("Critical"u8);
break;
case LogLevel.None:
writer.AppendUtf8("None"u8);
break;
default:
writer.AppendFormatted(value);
break;
}
}
.NET 8 XxHash3 + Non-GC Heap
.NET 8からXxHash3が追加されました。最速のハッシュアルゴリズムであるXxHashの最新シリーズで、小さいデータから大きいデータまで、迷ったらほぼこれ一択で問題ないだろうという性能になっています。なお、利用にはNuGetからSystem.IO.Hashing
が必要なので、逆に.NET 8ではなくNET Standard 2.0でも使えます。
ZLoggerでも複数箇所で使っているのですが、その中から一例として、String Interpolationの文字列リテラルからキャッシュを取り出す処理の例を。
// $"Hello my name is {name}, {age} years old." が生成する文字列リテラルの並び(LiteralList)
// ["Hello my name is ", "name", ", ", "age", " years old."]
// これからUTF8変換済みのキャッシュ(MessageSequence)を取り出すという処理
static readonly ConcurrentDictionary<LiteralList, MessageSequence> cache = new();
// 非.NET 8版
#if !NET8_0_OR_GREATER
struct LiteralList(List<string?> literals) : IEquatable<LiteralList>
{
[ThreadStatic]
static XxHash3? xxhash;
public override int GetHashCode()
{
var h = xxhash;
if (h == null)
{
h = xxhash = new XxHash3();
}
else
{
h.Reset();
}
var span = CollectionsMarshal.AsSpan(literals);
foreach (var item in span)
{
h.Append(MemoryMarshal.AsBytes(item.AsSpan()));
}
// https://github.com/Cyan4973/xxHash/issues/453
// XXH3 64bit -> 32bit, okay to simple cast answered by XXH3 author.
return unchecked((int)h.GetCurrentHashAsUInt64());
}
public bool Equals(LiteralList other)
{
var xs = CollectionsMarshal.AsSpan(literals);
var ys = CollectionsMarshal.AsSpan(other.literals);
if (xs.Length == ys.Length)
{
for (int i = 0; i < xs.Length; i++)
{
if (xs[i] != ys[i]) return false;
}
return true;
}
return false;
}
}
#endif
XxHash3はclassなので(System.HashCodeみたいにstructが良かったなあ)、ThreadStaticで使いまわしつつ、GetHashCodeを生成しています。XxHash3はulongの出力しかありませんが、作者によると、32bitに落とす場合は特にXORとかかけることもなく直接落として問題ないそうです。
ここまでが普通の使い方ですが、.NET 8版ではエクストリームな最適化を入れました。
#if NET8_0_OR_GREATER
struct LiteralList(List<string?> literals) : IEquatable<LiteralList>
{
// literals are all const string, in .NET 8 it is allocated in Non-GC Heap so can compare by address.
// https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-8/#non-gc-heap
static ReadOnlySpan<byte> AsBytes(ReadOnlySpan<string?> literals)
{
return MemoryMarshal.CreateSpan(
ref Unsafe.As<string?, byte>(ref MemoryMarshal.GetReference(literals)),
literals.Length * Unsafe.SizeOf<string>());
}
public override int GetHashCode()
{
return unchecked((int)XxHash3.HashToUInt64(AsBytes(CollectionsMarshal.AsSpan(literals))));
}
public bool Equals(LiteralList other)
{
var xs = CollectionsMarshal.AsSpan(literals);
var ys = CollectionsMarshal.AsSpan(other.literals);
return AsBytes(xs).SequenceEqual(AsBytes(ys));
}
}
#endif
List<string>?
をReadOnlySpan<byte>
に変換して、それでXxHash3.HashToUInt64やSeqeunceEqualを一発で呼んでます。見るからにこちらのほうが効率的なわけですが、しかし、そもそもList<string>?
をReadOnlySpan<byte>
に変換するのは合法なのか?と。この場合のstringの変換は、ReadOnlySpan<IntPtr>
への変換という意味合いで、つまりヒープにあるstringのアドレスのリストへと変換しているという意図になります。
そこまではいいとして、問題はアドレスの比較は危険すぎないか、ということです。まず第一に、stringは文字列として同一であっても実態は別のアドレスにある場合も多い。第二に、ヒープにあるstringのアドレスは固定されていない、移動することがあるということです。辞書のキーとしてGetHashCodeやEqualsを求めるなら、アプリケーション実行中は完全に固定されていなければなりません。
ところが今回の利用例に着目すると、String Inteprolationで呼ばれるAppendLiteralはコンパイル時に handler.AppendLiteral("Hello my name is ");
のように、必ず定数で渡されています。そのため同じ実体を指すことが保証されています。
[InterpolatedStringHandler]
public ref struct ZLoggerInterpolatedStringHandler
{
public void AppendLiteral([ConstantExpected] string s)
}
一応保険として、.NET 8から有効化されているConstantExpectedによって、定数だけが渡されることを明示しています。
もう一つは、そうした定数の文字列は最初からインターン化されているのですが、そのインターン化された場所が移動しないことは.NET 8まで保証されていませんでした。ところが、.NET 8からはNon-GC Heapが導入されたため、移動しないことが保証されている、といえます。
// .NET 8からは定数のGC.GetGenerationの結果がint.MaxValue(Non-GC Heapにいる)
var str = "foo";
Console.WriteLine(GC.GetGeneration(str)); // 2147483647
これによって、C#だとどうしても避けられないUTF16 StringからUTF8 Stringへの変換を、限界まで高速化することができました。なお、Source Generator版ではこのルックアップコスト自体を削れているため、ベンチマーク結果が最速であった通り、より高速です。
.NET 8 IUtf8SpanFormattable
ZLoggerでは値を文字列を通さずUTF8に直接書き込むことをパフォーマンスの柱にしています。.NET 8からIUtf8SpanFormattableという、値の汎用的なUTF8への直接変換を可能にしたインターフェイスが追加されました。ZLoggerは.NET 8以前の.NET Standard 2.0にも対応させるために、intやdoubleなど基本的なプリミティブは特殊な対応によって、UTF8への直接書き込みを実現していますが、.NET 8の場合は対応範囲がより広がるため、できれば.NET 8がお薦めです。
なお、IUtf8SpanFormattableはフォーマット文字列のalignmentには関知しないため、分離しているライブラリであるCysharp/Utf8StringInterpolationでは.NET Standard 2.0対応と同時に、alignment対応の機能も追加されたライブラリとなっています。
.NET 8 TimeProvider
TimeProviderは.NET 8から追加された時間に関するAPIの抽象化(TimeZone, Timerなども含む)となっていて、ユニットテスト等でも非常に役に立つ、今後の必須クラスです。TimeProviderは.NET 8未満でもMicrosoft.Bcl.TimeProviderを通して提供されているため、.NET Standard 2.0やUnityでも利用可能です。
そこでZLoggerではZLoggerOptionsにTimerProviderを指定することで、ログ出力の時間を固定することができます。
// Microsoft.Extensions.TimeProvider.TestingのFakeTimeProviderを使うとよりよい
class FakeTime : TimeProvider
{
public override DateTimeOffset GetUtcNow()
{
return new DateTimeOffset(1999, 12, 30, 11, 12, 33, TimeSpan.Zero);
}
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
}
public class TimestampTest
{
[Fact]
public void LogInfoTimestamp()
{
var result = new List<string>();
using var factory = LoggerFactory.Create(builder =>
{
builder.AddZLoggerInMemory((options, _) =>
{
options.TimeProvider = new FakeTime(); // TimeProviderをカスタムのものに設定
options.UsePlainTextFormatter(formatter =>
{
// Timestampを先頭に付与
formatter.SetPrefixFormatter($"{0} | ", (template, info) => template.Format(info.Timestamp));
});
}, x =>
{
x.MessageReceived += msg => result.Add(msg);
});
});
var logger = factory.CreateLogger<TimestampTest>();
logger.ZLogInformation($"Foo");
Assert.Equal("1999-12-30 11:12:33.000 | Foo", result[0]);
}
}
ログ出力の完全一致でのテストが必要……!などといった場合に有効に使うことができます。
Source Generator
Microsoft.Extensions.Loggingではハイパフォーマンスなログ出力のためにLoggerMessageAttributeとSource Generatorが標準で提供されています。
これは確かにUTF16文字列の生成では非常に優秀ですが、Structured Logging生成部分に関しては疑問符がつきます。
// このpartial methodは
[LoggerMessage(LogLevel.Information, "My name is {name}, age is {age}.")]
public static partial void MSLog(this ILogger logger, string name, int age, int other);
// このクラスを生成する
private readonly struct __MSLogStruct : global::System.Collections.Generic.IReadOnlyList<global::System.Collections.Generic.KeyValuePair<string, object?>>
{
private readonly global::System.String _name;
private readonly global::System.Int32 _age;
public __MSLogStruct(global::System.String name, global::System.Int32 age)
{
this._name = name;
this._age = age;
}
public override string ToString()
{
var name = this._name;
var age = this._age;
return $"My name is {name}, age is {age}."; // 文字列生成は高速そう(C# 10.0のString Interpolation Improvementsにベタ乗りなので言うことなし!)
}
public static readonly global::System.Func<__MSLogStruct, global::System.Exception?, string> Format = (state, ex) => state.ToString();
public int Count => 4;
// こちらがStrcuted Loggingのコードですが、ん……?
public global::System.Collections.Generic.KeyValuePair<string, object?> this[int index]
{
get => index switch
{
0 => new global::System.Collections.Generic.KeyValuePair<string, object?>("name", this._name),
1 => new global::System.Collections.Generic.KeyValuePair<string, object?>("age", this._age),
2 => new global::System.Collections.Generic.KeyValuePair<string, object?>("other", this._other),
3 => new global::System.Collections.Generic.KeyValuePair<string, object?>("{OriginalFormat}", "My name is {name}, age is {age}."),
_ => throw new global::System.IndexOutOfRangeException(nameof(index)), // return the same exception LoggerMessage.Define returns in this case
};
}
public global::System.Collections.Generic.IEnumerator<global::System.Collections.Generic.KeyValuePair<string, object?>> GetEnumerator()
{
for (int i = 0; i < 4; i++)
{
yield return this[i];
}
}
global::System.Collections.IEnumerator global::System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
}
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Logging.Generators", "8.0.9.3103")]
public static partial void MSLog(this global::Microsoft.Extensions.Logging.ILogger logger, global::System.String name, global::System.Int32 age)
{
if (logger.IsEnabled(global::Microsoft.Extensions.Logging.LogLevel.Information))
{
logger.Log(
global::Microsoft.Extensions.Logging.LogLevel.Information,
new global::Microsoft.Extensions.Logging.EventId(764917357, nameof(MSLog)),
new __MSLogStruct(name, age),
null,
__MSLogStruct.Format);
}
}
KeyValuePair<string, object?>
ということで、基本的に普通に作るとボクシングは避けられません、しょーがない。
そこで、ZLoggerでは似たようなSource Generator属性であるZLoggerMessageAttribute
を提供しています。これにより、UTF8最適化、ボクシングレスなJSONロギングを可能にしています。
// LoggerMessageをZLoggerMessageに変えるだけ
// なお、ZLoggerMessageのフォーマット文字列部分では、String Interpolation版と同じように@による別名やjsonによるJSON化も可能
[ZLoggerMessage(LogLevel.Information, "My name is {name}, age is {age}.")]
static partial void ZLoggerLog(this ILogger logger, string name, int age);
// このようなコードが生成される
readonly struct ZLoggerLogState : IZLoggerFormattable
{
// JSON用にJsonEncodedTextを事前生成
static readonly JsonEncodedText _jsonParameter_name = JsonEncodedText.Encode("name");
static readonly JsonEncodedText _jsonParameter_age = JsonEncodedText.Encode("age");
readonly string name;
readonly int age;
public ZLoggerLogState(string name, int age)
{
this.name = name;
this.age = age;
}
public IZLoggerEntry CreateEntry(LogInfo info)
{
return ZLoggerEntry<ZLoggerLogState>.Create(info, this);
}
public int ParameterCount => 2;
public bool IsSupportUtf8ParameterKey => true;
public override string ToString() => $"My name is {name}, age is {age}.";
// テキストメッセージはUTF8への直接書き込み
public void ToString(IBufferWriter<byte> writer)
{
var stringWriter = new Utf8StringWriter<IBufferWriter<byte>>(literalLength: 21, formattedCount: 2, bufferWriter: writer);
stringWriter.AppendUtf8("My name is "u8); // u8でリテラルは直接書き込み
stringWriter.AppendFormatted(name, 0, null);
stringWriter.AppendUtf8(", age is "u8);
stringWriter.AppendFormatted(age, 0, null);
stringWriter.AppendUtf8("."u8);
stringWriter.Flush();
}
// JSON出力の場合はUtf8JsonWriterに直接書き込むことで完全にボクシング避け
public void WriteJsonParameterKeyValues(Utf8JsonWriter writer, JsonSerializerOptions jsonSerializerOptions, IKeyNameMutator? keyNameMutator = null)
{
// 型によって呼び出すメソッドは異なる(WriteString, WriteNumber, etc...)
writer.WriteString(_jsonParameter_name, this.name);
writer.WriteNumber(_jsonParameter_age, this.age);
}
// 以下にMessagePack対応などの拡張用のメソッドが実際には生成されますが省略
}
static partial void ZLoggerLog(this global::Microsoft.Extensions.Logging.ILogger logger, string name, int age)
{
if (!logger.IsEnabled(LogLevel.Information)) return;
logger.Log(
LogLevel.Information,
new EventId(-1, nameof(ZLoggerLog)),
new ZLoggerLogState(name, age),
null,
(state, ex) => state.ToString()
);
}
Utf8JsonWriterに直接書く、また、キー名はJsonEncodedTextを事前に生成して持っておく、という仕様によってJSON化のパフォーマンスを最大化しています。
また、Structured LoggingはJSONに限らず他のフォーマットもありえます。例えばMessagePackを利用することで、より小さく、より高速にすることができるでしょう。ZLoggerでは、そうしたJSON特化のようなビルトインではないプロトコルへの出力においてもボクシングを避けるためのインターフェイスが定義されています。
public interface IZLoggerFormattable : IZLoggerEntryCreatable
{
int ParameterCount { get; }
// メッセージ出力に使う
void ToString(IBufferWriter<byte> writer);
// JSON出力に使う
void WriteJsonParameterKeyValues(Utf8JsonWriter jsonWriter, JsonSerializerOptions jsonSerializerOptions, IKeyNameMutator? keyNameMutator = null);
// それ以外の構造化ログ出力に使う
ReadOnlySpan<byte> GetParameterKey(int index);
ReadOnlySpan<char> GetParameterKeyAsString(int index);
object? GetParameterValue(int index);
T? GetParameterValue<T>(int index);
Type GetParameterType(int index);
}
ちょっと変わったインターフェイスになっていますが、以下のようなループを回すことでボクシングの発生をなくせます。
for (var i in ParameterCount)
{
var key = GetParameterKey(i);
var value = GetParameterValue<int>();
}
こうした設計はADO.NETのIDataRecordの使い方と同じ設計です。また、Unityでもネイティブ→マネージドでの配列のアロケーションを避けるために、インデックス経由で取得することがよくあります。
Unity
UnityはUnity 2023の時点でも正式な対応C#のバージョンは9.0です。ZLoggerはC# 10.0以上のString Interpolationが大前提となっているので、普通は動きません。普通は。ところが、正式にアナウンスはされていないのですが Unity 2022.2
から同梱されているコンパイラのバージョンが上がっていて、内部的にはC# 10.0でコンパイル可能になっていることを発見しました。
csc.rsp
ファイルによってコンパイラオプションを渡すことができるので、そこで明示的に言語バージョンを指定してあげると、C# 10.0の全ての文法が利用可能になります。
-langVersion:10
このままだと出力されるcsprojには依然として<LangVersion>9.0</LangVersion>
が指定されているため、IDE上ではC# 10.0で書けません。そこでCysharp/CsprojModifierを用いて、LangVersionを上書きしてしまいましょう。以下のようなLangVersion.props
というファイルを作成して、CsprojModifierに混ぜてもらえば、IDE上でもC# 10.0として記述できるようになります。
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<LangVersion>10</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
Unity向けにはAddZLoggerUnityDebug
という拡張を追加してあるので、
// こんなグローバルのユーティリティーを用意してあげて
public static class LogManager
{
static ILoggerFactory loggerFactory;
public static ILogger<T> CreateLogger<T>() => loggerFactory.CreateLogger<T>();
public static readonly Microsoft.Extensions.Logging.ILogger Global;
static LogManager()
{
loggerFactory = LoggerFactory.Create(logging =>
{
logging.SetMinimumLevel(LogLevel.Trace);
logging.AddZLoggerUnityDebug(); // log to UnityDebug
});
Global = loggerFactory.CreateLogger("Logger");
Application.exitCancellationToken.Register(() =>
{
loggerFactory.Dispose(); // flush when application exit.
});
}
}
// 例えばこんな感じに使ってみる
public class NewBehaviourScript : MonoBehaviour
{
static readonly ILogger<NewBehaviourScript> logger = LogManager.CreateLogger<NewBehaviourScript>();
void Start()
{
var name = "foo";
var hp = 100;
logger.ZLogInformation($"{name} HP is {hp}.");
}
}
なお、C# 10.0のString Interpolation性能向上の恩恵を受けれるのはZLogを使った場合のみの話で、通常のString生成にString Interpolationを使っても性能向上はしません。string生成の性能向上にはランタイムにDefaultInterpolatedStringHandlerが必要で、これは .NET 6 以上にのみ同梱されているからです。DefaultInterpolatedStringHandlerが存在しない場合は今まで通りのstring.Formatにフォールバックされるため、普通にボクシングされます。
JSONによる構造化ログや出力のカスタマイズ、ファイルへの出力などにも全て対応しています。
var loggerFactory = LoggerFactory.Create(logging =>
{
logging.AddZLoggerFile("/path/to/logfile", options =>
{
options.UseJsonFormatter();
});
});
そしてもう一つボーナスとして、Unity 2022.3.12f1
以上だとC#のコンパイラバージョンがもう少し上がっていて、-langVersion:preview
を指定するとC# 11.0が使えます。また、ZLoggerのSource Generatorが自動で有効になっているので、[ZLoggerMessage]
を使って生成することができます。
public static partial class LogExtensions
{
[ZLoggerMessage(LogLevel.Debug, "Hello, {name}")]
public static partial void Hello(this ILogger<NewBehaviourScript> logger, string name);
}
Source Generatorの生成するコードがC# 11.0を要求するので(UTF8 String Literalなどを多用しているため)、[ZLoggerMessage]
が使えるのはUnity 2022.3.12f1
以上限定となります。
なお、Unityには同種の標準ロギングライブラリとしてcom.unity.loggingがリリースされています。同じように構造化ロギングやファイル出力が可能なほか、Source Generatorを使ってクラスそのものを自動生成して、メソッドのオーバーロードを引数に応じて生成することで値のボクシング避けをするといった、面白い設計をしていました。Burst云々という話がよく出てきますが、このSource Generatorの大胆な使い方のほうがパフォーマンスの肝だと思います。ZLoggerはC# 10.0のString Interpolationを活用しているわけですが、そういうアプローチでの回避策というのはちょっと考えたことがなかったので、かなり目から鱗です。パフォーマンス的にもかなり練られています。
ZLoggerのほうがString Interpolationによる書き味は上、パフォーマンスは、まぁいい勝負するんじゃないかなとは思いたいんですが、どうでしょうね……?
NuGetForUnity
今回Unityへのライブラリの配布はNuGetForUnityを採用しました。これは普通の.NETライブラリへの依存が多いものへの配布に非常に便利で、NuGetForUnityを入れたあとに、GUIでZLoggerをこうして入れると
こんなように依存関係も解決して、Assets/Packagesの下にDLLがばらまかれます。
Source Generatorが含まれている場合は、ちゃんとRoslynAnalyzer
のラベルも付与してくれるので、そのままSource GeneratorもUnity上で有効化されています。
良い点としては、やはり大量のマネージドDLLの管理を一括でやってくれることと、動作がシンプル(基本的には解決したファイルをAssets/Packagesにばらまくだけ)なのでやっていることがイメージつきやすいところです。同じようなツールとしてUnityNuGetというものがあるのですが、そちらは独自レジストリに配布しているファイルを引っ張ってくるという作りなので、NuGetForUnity(MS公式のNuGetのレジストリから引っ張ってくる)のほうが素直な挙動で好ましく思います。
反面、UnityEngine.dllに依存したコードやUnityのバージョンで分岐するコードは配りにくいところがあります。ZLoggerではそのためのファイル(ZLoggerUnityDebugLoggerProvider)は別途git参照で引っ張ってくるハイブリッド方式での配布としました。かなりバランスの良いやり方かなと思うんですがどうでしょう……?特にCysharpのライブラリはコア部分はUnity非依存のものが多いので、問題なければ今後のCysharpのライブラリはこの形式での配布を基本としていきたいところです。
ライブラリ作者側としては、コア部分はDLLで配ることになるのでC# 12のままで良い(今まではUnityのためにC# 9にダウングレードさせて書いたりしてた)のが、とにかくとても楽ですね……!
まとめ
この記事はC# Advent Calendar 2023の12月3日分の記事となります。すごい遅刻ですが間に合ったので(?)よし。よくない。
なお、ZLogger v2の作成にあたっては VContainer や VYaml で有名な @hadashiA さんに、アイディア出しから細かい実装、度重なる仕様のちゃぶ台返しに付き合ってもらいました。今回のv2は非常に完成度高くなったと思うのですが、自分一人ではここまで達しなかったので大変感謝です。
ともあれZLoggerは使いやすさでもパフォーマンスでも最強!のロガーに仕上がったと思いますので、是非使ってみてください。
他言語がメインの場合のRustの活用法 - csbindgenによるC# x Rust FFI実践事例
- 2023-10-23
Rust.Tokyo 2023というRustのカンファレンスで、「他言語がメインの場合のRustの活用法 - csbindgenによるC# x Rust FFI実践事例」と題してcsbindgen周りの話をしてきました。
タイトルが若干かなり回りっくどい雰囲気になってしまいましたが、Rustのカンファレンスということで、あまりC#に寄り過ぎないように、という意識があったのですが、どうでしょう……?
会場での質問含めて何点かフォローアップを。
FFIとパフォーマンス
Rustは速い!FFIは速い!ということが常に当てはまるわけでもなく、例えばGoのcgoはかなり遅いという話があったりします。Why cgo is slow @ CapitalGo 2018。このことは直近のRustのasyncの話Why async Rust?でも触れられていて、cgoの遅さはGoroutine(Green Thread)が影響を及ぼしているところもある、とされています。.NET でもGreen Threadを実験的に実装してみたというレポートがついこないだ出ていたのですが、FFIの問題とか、まぁ諸々あってasync/awaitでいいじゃろ、という結論になっています。技術はなんでもトレードオフなので、過剰にGreen Threadを持ち上げるのもどうかな、とは思いますね。
で、C#のFFI速度ですが、こちらのTesting FFI Hot Loop Overhead - Java, C#, PHP, and Goという記事での比較ではFFIにおいては圧勝ということになっているので、まぁ、実際C#のFFIは速いほうということでいいんじゃないでしょーか(昔からWin32 APIを何かと叩く必要があったりとかいう事情もありますし)。
とはいえ、原則Pure C#実装のほうがいいなあ、という気持ちはめっちゃあります。パフォーマンスのためのネイティブライブラリ採用というのは、本当に限定的な局面だけではありますね。そんなわけで、その限定的な局面であるところのコンプレッションライブラリを鋭意開発中です、来月に乞うご期待。
Zig, C++
FFI目的でunsafeなRust中心になるぐらいならZigのほうがいいんじゃない?というのは一理ある。というか最初はそう思ってZigを試したんですが、今回は見送らせていただきます、と。一理ある部分に関しては一理あるんですが、それ以外のところではRustのほうが上だという判断で、総合的にはRustを採用すべきだと至りました。
具体的には資料の中のRustの利点、これは資料中ではC++との比較という体にしていますが、Zigとの比較という意味もあります。標準公式のパッケージマネージャーがないし、開発環境の乏しさは、たとえZigが言語的にRustよりイージーだとしても、体感は正直言ってRustよりもハードでした。コンパイルエラーもRustは圧倒的にわかりやすいんですが、Zigはめちゃくちゃ厳しい……。Rustはイージーとは言わないですが、開発環境の助けやcargoコマンドのシンプルさ、技術情報(本・ブログ・FAQ)の多さによって、入り口は意外と大変ではない、むしろ入りやすい部類とすら言える感じです。
また、ZigはZigでありC/C++ではない。これはRustも同じでRustはRustでC/C++ではない、つまりCとZig(Rust)を連動させるにはbindgenのようなものが必要なのですが、Zigのそれの安定性がかなり低い、パースできない.hが普通にチラホラある。rust-bindgenのIssue見ていると本当に色々なケースに対応させる努力を延々と続けていて、それがbindgenの信頼性(と実用性)に繋がっているわけで、Zigはまだまだその域には達していないな、と。
Cはまだいいとしても、C++のエコシステムを使うという点では、ZigもRustも難しい。セッションの中ではPhysX 5を例に出しましたが、物理エンジンはOSSどころだとBullet PhysicsもJolt Physicsも、SDKそのままそのものはC++だけなんですよね。これをC++以外の言語に持ち込むのは非常に骨の折れる仕事が必要になってきます。Rustに関してはEmbark Studiosがphysx-rsを作ってくれたのである程度現実的ではありますが、何れにせよ大仕事が必要で、そのままでは持ち込めないというのが現実です。
physx-rsではC++のPhysXをRustで動かすために、まずC APIのPhysXを自動生成してそれ経由でRustから呼び出す、という話をしましたが、ZigもC++のものを呼び出すには、概ね同様のアプローチを取る必要があり、例えばZigでJolt Physicsを動かすzphysicsというプロジェクトでは、C++のJoltに対して、C APIで公開するJoltCという部分を作って、それ経由でZigから呼び出すという手法を取っています。
この辺のことはMagicPhysXを作る時にめちゃくちゃ迷走して色々作りかけてたので痛感しています。そう、最初はZigでBullet Physicsを動かしてC#から呼び出すMagicBulletというプロジェクトだったこともあったのだ……。
最後発C++後継系言語であるところのCarbon Languageは、C++におけるTypeScriptというのを標榜しているので、そうしたC++との連携を最優先に考えた言語になっているんじゃないかなー、と思います(触ってないので知らんですけど!)。C++の後継はRust(やZig)があるからいらんやろー、とはならない、C++の資産を活かしながらもモダンな言語仕様を使えるようにする、という絶妙な立ち位置を狙っているんじゃないかなー、と。どのぐらい盛り上がっていくのかわかりませんが……!
C++/CLI
C++/CLIは使わないんですか?という質問がありました。.NETとC++ライブラリの連携という点で、C++/CLIはたしかに良いソリューションで、C++のライブラリをC#のために公開するブリッジとしては最高に使いやすい代物でした。.NET Frameworkの時代までは。
C++/CLIの問題は「.NET Core の C++/CLI サポートは Windows のみ」ということで、特にライブラリがLinuxサポートしないというのはありえないので、.NET Core以降にC++/CLIを新規採用するのは基本ありえない、といった状態になっています。こういった問題があるので、 .NET Framework時代に作られていたC++ライブラリをC#で使える系ライブラリはほとんど使えなくなりました。例えばPhysX 4の.NETバインディングであるPhysX.NETは、C++/CLIでバインディングが作られているため、.NET 5対応はしていますが、サポートプラットフォームはWindowsのみです。
csbindgenは、そうした.NET / C連携での空白地帯にちょうどうまくはまったライブラリなのではないかと思います。C++連携については頑張るしかないですが、そこはしょうがないね……! ただ、Rustはエコシステムがうまく動いているので、Pure Rustライブラリであったり、RustでC++バインディングが作られているものを経由してC#バインディングを作る、といった手法でうまく回せる場合も多いんじゃないかなあー、というところがいいところです。それと、近年のライブラリ事情でいうと、物理エンジンみたいな老舗系はC++で作られていますが、例えば暗号通貨系のライブラリなんかは最初からRust実装だったりするものも多いので、RustからC#への持ち込み、のほうが今後の実用性としても高いんじゃないかと踏んでいます。
UTF8文字列生成を最適化するライブラリ Utf8StringInterpolation を公開しました
- 2023-10-13
Utf8StringInterpolationという新しいライブラリを公開しました!UTF8文字列の生成と書き込みに特化していて、動作をカスタマイズした文字列補間式によるC#コンパイラの機能を活用した生成と、StringBuilder的な連続的な書き込みの両方をサポートします。
基本的な流れはこんな感じで、Stringを生成するのと同じように、UTF8を生成/書き込みできます。
using Utf8StringInterpolation;
// Create UTF8 encoded string directly(without encoding).
byte[] utf8 = Utf8String.Format($"Hello, {name}, Your id is {id}!");
// write to IBufferWriter<byte>(for example ASP.NET HttpResponse.BodyWriter)
Utf8String.Format(bufferWriter, $"Today is {DateTime.Now:yyyy-MM-dd}"); // support format
// like a StringBuilder
var writer = Utf8String.CreateWriter(bufferWriter);
writer.Append("My Name...");
writer.AppendFormat($"is...? {name}");
writer.AppendLine();
writer.Flush();
// Join, Concat methods
var seq = Enumerable.Range(1, 10);
byte[] utf8seq = Utf8String.Join(", ", seq);
Cysharpから公開している ZString と非常に近いのですが、ZStringがString(UTF16), UTF8をサポートしていたのに対して、UTF8側のみを取り出して強化したようなイメージになります。何が強化なのかというと、C# 10.0からImproved Interpolated Stringsとして、文字列補間式($"foo{bar}baz")のパフォーマンスが大きく向上しました。具体的には、コンパイラが文字列補間式の構造を分解して、値が埋め込まれている箇所はGenericsのまま渡すようになりボクシングが消滅しました。つまりZStringでやっていたことではあるのですが、ZStringはC# 10.0以前のものですからね……!逆に言えば、これによってZStringは半分は不要となったわけです。
もう半分、UTF8側に関しては依然として標準のサポートは薄い、というかほぼない状態です。しかし、Improved Interpolated Strings は文字列補間式での挙動を自由にカスタマイズできるという性質も追加されています。というわけで、文字列補間式を利用してUTF8を組み立てられるようにすればいいのではないか、というのがUtf8StringInterpolationのコンセプトであり、正しくZLoggerの後継として位置づけていることでもあります。
// こういう文字列補間式を渡すと:
// Utf8String.Format(ref Utf8StringWriter format)
Utf8String.Format($"Hello, {name}, Your id is {id}!");
// コンパイラが「コンパイル時」にこのような形に展開します。
var writer = new Utf8StringWriter(literalLength: 20, formattedCount: 2);
writer.AppendLiteral("Hello, ");
writer.AppendFormatted<string>(name);
writer.AppendLiteral(", You id is ");
writer.AppendFormatted<int>(id);
writer.AppendLiteral("!");
コンパイル時に展開してくれるというのは性能上非常に重要で、つまりString.Format
のように実行時に文字列式のパースをしないで済む、わけです。また、ボクシングなしに全ての値を書き込みに呼んでくれます。
[InterpolatedStringHandler]
を付与しているref Utf8StringWriter
に $"{}"
を渡すと、自動的に展開してくれるという仕様になっています。そのUtf8StringWriterは以下のような実装になっています。
// internal struct writer write value to utf8 directly without boxing.
[InterpolatedStringHandler]
public ref struct Utf8StringWriter<TBufferWriter> where TBufferWriter : IBufferWriter<byte>
{
TBufferWriter bufferWriter; // when buffer is full, advance and get more buffer
Span<byte> buffer; // current write buffer
public void AppendLiteral(string value)
{
// encode string literal to Utf8 buffer directly
var bytesWritten = Encoding.UTF8.GetBytes(value, buffer);
buffer = buffer.Slice(bytesWritten);
}
public void AppendFormatted<T>(T value, int alignment = 0, string? format = null)
where T : IUtf8SpanFormattable
{
// write value to Utf8 buffer directly
while (!value.TryFormat(buffer, out bytesWritten, format))
{
Grow();
}
buffer = buffer.Slice(bytesWritten);
}
}
.NET 8 の場合は、値が IUtf8SpanFormattable という.NET 8から追加されたインターフェイスを実装している場合(intなど標準のプリミティブはほぼ実装されています)、直接TryFormatによりUTF8としてSpanに書き込みます。
さすがに .NET 8 にしか対応していません!というのはエクストリームすぎるので、 .NET Standard 2.1, .NET 6(.NET 7)では Utf8Formatter.TryFormat を使うことで、同様の性能を担保しています。
Builder vs Writer
ZStringのときはStringBuilderに引っ張られすぎていて、Builderとして内部でバッファを抱えるようにしていたのですが、ちょっとUTF8的な利用ではイマイチだということが徐々に分かってきました。今の .NET の基本は IBufferWriter<byte>
である。というのはついこないだのCEDEC 2023での発表 モダンハイパフォーマンスC# 2023 Edition でかなり語らせていただいたのですが
BuilderというよりもWriterとして構築すべきだな、ということに至りました。そこで Utf8StringWriter
は基本的にIBufferWriter<byte>
を受け取ってそれに書き込むという仕様となりました。
public ref partial struct Utf8StringWriter<TBufferWriter>
where TBufferWriter : IBufferWriter<byte>
{
Span<byte> destination;
TBufferWriter bufferWriter;
int currentWritten;
public Utf8StringWriter(TBufferWriter bufferWriter)
{
this.bufferWriter = bufferWriter;
this.destination = bufferWriter.GetSpan();
}
public void Flush()
{
if (currentWritten != 0)
{
bufferWriter.Advance(currentWritten);
currentWritten = 0;
}
}
バッファが足りなくなったときは拡大するのではなくて、Advanceして新たにGetSpanを呼んで新しいバッファを確保しにいくという形を取りました。StringBuilderと違ってFlushの概念が必要になってしまいましたが、パフォーマンス的には大きな向上を果たしています。
Flushが必要ということを除けば、StringBuilderのように扱うことができます。
var writer = Utf8String.CreateWriter(bufferWriter);
// call each append methods.
writer.Append("foo");
writer.AppendFormat($"bar {Guid.NewGuid()}");
writer.AppendLine();
// finally call Flush(or Dispose)
writer.Flush();
また、ちょっとStringBuilder的に使いたいだけの時に IBufferWriter<byte>
を用意するのは面倒くさい!という場合のために、内部でプーリングを行っているバッファを使えるオーバーロードも用意しています。戻り値がバッファのコントローラーになっていて、ToArrayや他のIBufferWriter<byte>
にコピーしたりReadOnlySpan<byte>
の取得ができます。
// buffer must Dispose after used(recommend to use using)
using var buffer = Utf8String.CreateWriter(out var writer);
// call each append methods.
writer.Append("foo");
writer.AppendFormat($"bar {Guid.NewGuid()}");
writer.AppendLine();
// finally call Flush(no need to call Dispose for writer)
writer.Flush();
// copy to written byte[]
var bytes = buffer.ToArray();
// or copy to other IBufferWriter<byte>, get ReadOnlySpan<byte>
buffer.CopyTo(otherBufferWriter);
var writtenData = buffer.WrittenSpan;
その他、Format
, Join
, Concat
メソッドなども IBufferWriter<byte>
を受け取るオーバーロードと byte[]
を返すオーバーロードの2種を用意しています。
.NET 8 と StandardFormat
値のフォーマット書式は、特にDateTimeでよく使うと思いますが、数値型などでも多くの書式が用意されています。.NET の数値、日付、列挙、その他の型の書式を設定する方法 や各種カスタム書式指定文字列は非常に便利です。
しかし、UTF8に値を直接書き込む手段として従来用意されていたUtf8Formatter.TryFormatでは、その標準的な書式指定文字列は使えませんでした!代わりに用意されたのがStandardFormatなのですが、恐ろしく限定的なことしかできず(例えば'G', 'D', or 'X'のような一文字charの指定しかできない)、使い物にならないといっても過言ではないぐらいでした。
ところが .NET 8 から追加された IUtf8SpanFormattable.TryFormat では、通常の書式指定文字列が帰ってきました!
// Utf8Formatter.TryFormat
static bool TryFormat (int value, Span<byte> destination, out int bytesWritten, System.Buffers.StandardFormat format = default);
// .NET 8 IUtf8SpanFormattable.TryFormat
bool TryFormat (Span<byte> utf8Destination, out int bytesWritten, ReadOnlySpan<char> format, IFormatProvider? provider);
パラメーターは非常に似ていますが、formatを文字列で受け取るようになっています。実際に比較してみるとこんな感じです。
Span<byte> dest = stackalloc byte[16];
int written = 0;
// ParseできなくてExceptionがthrowされるので表現できない
Utf8Formatter.TryFormat(123.456789, dest, out written, StandardFormat.Parse(".###"));
// 123.456
123.456123.TryFormat(dest, out written, ".###");
// カスタム書式文字列は指定できないので例外!サポートしてるのは `G`, `R`, `l`, `O` だけ!
Utf8Formatter.TryFormat(DateTime.Now, dest, out written, StandardFormat.Parse("yyyy-MM-dd"));
// もちろんちゃんと動作する
DateTime.Now.TryFormat(dest, out written, "yyyy-MM-dd");
Console.WriteLine(Encoding.UTF8.GetString(dest.Slice(0, written)));
良かった、やと普通の世界が到達した……!これは ZString や、それを内部に使っていた ZLoggerで最もフラストレーションを感じていた点です。
Utf8StringInterpolationは .NET 8 では全て IUtf8SpanFormattable で変換するようにしています。しかし、 .NET Standard 2.1, .NET 6, .NET 7では残念ながらUtf8Formatter利用となっているので、書式指定に関しては制限があります。数値に関してはターゲットプラットフォームによって動作したりしなかったりが発生します。
// .NET 8 supports all numeric custom format string but .NET Standard 2.1, .NET 6(.NET 7) does not.
Utf8String.Format($"Double value is {123.456789:.###}");
ただし、 DateTime
, DateTimeOffset
, TimeSpan
に関しては Utf8Formatter
を使わない処理をしているため、全てのターゲットプラットフォームでカスタム書式指定が利用可能です!
// DateTime, DateTimeOffset, TimeSpan support custom format string on all target plaftorms.
// https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings
Utf8String.Format($"Today is {DateTime.Now:yyyy-MM-dd}");
とにかくDateTimeの書式指定がまともに出来ないのはZString/ZLoggerで一番辛かったところなので、それを改善できてとても良かった……。ただしこの対応により、DateTimeの変換性能が落ちているため、性能が最大限引き出せるのは .NET 8 となります。
Unity
Unity対応はありません!いや、可能な限り私は .NET と Unityの両対応のライブラリを作りたいと思っていて、実際今までもそうしてきているわけですが、今回ばかりはどうにもならないのです。そもそもImproved Interpolated Stringsが C# 10.0 からで、Unityの現在のC#のバージョンは C# 9.0……!さすがにそれはどうにもならない。
C# 9.0で止まってから結構長いんですよね。別にランタイムのバージョンは上げなくてもいいから、コンパイラのバージョンだけ上げて欲しいと切実に思いますが、まぁC# 10.0にしたらDefaultInterpolatedStringHandlerがなくて動作しないじゃんとかなるだろうから、結局はランタイムのバージョンアップもセットでやらなければならない……。
Unityが C# 10.0 に対応したらすぐに対応させるつもりではあります!待ってます!
Next
さて、とはいえ、UTF8文字列を直接扱わなければならないケースというのは、別にそんなに多くはないでしょう。実際、私も本命はZLoggerの大型バージョンアップでの利用を考えています。ZLoggerは今まではZStringベースでしたが、根本からデザインをやり直した新しいものを開発中です。その中の文字列化にUtf8StringInterpolationを使っています。
といったように、アプリケーションの基盤レイヤーに差し込んであげると有効に機能するシチュエーションは色々あると思います。もちろん、直接使ってもらってもいいのですが……!?
.NET 8 UnsafeAccessor を活用したライブラリ PrivateProxy を公開しました
- 2023-09-21
PrivateProxyというライブラリを公開しました。つまるところ、privateフィールド/プロパティ/メソッドにアクセスするライブラリなのですが、.NET 8 のUnsafeAccessorという新機能を活用することでNo Reflection、ハイパフォーマンス、AOTセーフになっています。
もちろん .NET 8 でしか動きません!ので、.NET 8が正式リリースされた頃に思い出して使ってみてください。エクストリームな人は今すぐ試しましょう。
雰囲気としては、privateメンバーにアクセスしたい型があったとして、[GeneratePrivateProxy(type)]
をつけた型を用意します。
using PrivateProxy;
public class Sample
{
int _field1;
int PrivateAdd(int x, int y) => x + y;
}
[GeneratePrivateProxy(typeof(Sample))]
public partial struct SampleProxy;
すると、いい感じにアクセスできるようになります。
// You can access like this.
var sample = new Sample();
sample.AsPrivateProxy()._field1 = 10;
いいところとしては、 Source Generatorベースの生成なので、型がついていて入力補完も効くし、変数名を変更したらコンパイルエラーで検出可能です。
ここまではSource Generatorベースで作れば、今まででもやれないことはなかったのですが、UnsafeAccessorのいいところとして、objectが一切出てこないで元のメソッドそのままの型がそのまま使えることです。生成されたコードを見ると、こうなっています。
// Source Generator generate this type
partial struct SampleProxy(Sample target)
{
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_field1")]
static extern ref int ___field1__(Sample target);
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "PrivateAdd")]
static extern int __PrivateAdd__(Sample target, int x, int y);
public ref int _field1 => ref ___field1__(target);
public int PrivateAdd(int x, int y) => __PrivateAdd__(target, x, y);
}
public static class SamplePrivateProxyExtensions
{
public static SampleProxy AsPrivateProxy(this Sample target)
{
return new SampleProxy(target);
}
}
これによって ref
や readonly
などの言語機能をそのまま反映できたり、mutableなstructの対応が自然にできたり、そして何よりパフォーマンスの低下も一切ありません。
使い道は、主にユニットテスト用になるとは思いますので、なのでパフォーマンスはそこまで重要ではないといえばないのですが、性能的にはアプリケーションの実行時に使っても問題ないものとなっています。
私は昔Chaining Assertionというユニットテスト用のライブラリを作っていたのですが、現在はFluent Assertionsという別のライブラリを使っています。ほとんどの機能は概ねなんとかなっているのですが、AsDynamic()
を呼ぶと、移行はprivateフィールドやメソッドにアクセスし放題という機能は結構便利で、そしてそれがFluent Assertionsにないのは若干不便と思ってたんですね。今回、やっとそれの進化系を作れたのでメデタシメデタシです。
C# 12
ところで、冒頭のSampleProxyの書き方、地味にこれはC# 12の記法を使っています。どこだかわかりますか?
[GeneratePrivateProxy(typeof(Sample))]
public partial struct SampleProxy;
SampleProxy;
の部分で、空のクラスを作る際に { }
じゃなくて ;
だけで済ませられるようになりました。これは地味ですがかなりいい機能で、というのもSource Generatorだと空クラスに割り当てることが多かったんですよね。そして、たった2文字が1文字に変わっただけ、ではあるのですが、 { }
だとコードフォーマットに影響があります。改行して3行で表現するのか、後ろにつけるのか。そうした判断のブレが ;
だとなくなります。だから2文字が1文字に変わっただけ、以上のインパクトがある、良い機能追加だと思います。
ref field
PrivateProxyはstaticメソッドにも対応していますし、そしてmutable structにも対応しています。
using PrivateProxy;
public struct MutableStructSample
{
int _counter;
void Increment() => _counter++;
// static and ref sample
static ref int GetInstanceCounter(ref MutableStructSample sample) => ref sample._counter;
}
// use ref partial struct
[GeneratePrivateProxy(typeof(MutableStructSample))]
public ref partial struct MutableStructSampleProxy;
var sample = new MutableStructSample();
var proxy = sample.AsPrivateProxy();
proxy.Increment();
proxy.Increment();
proxy.Increment();
// call private static method.
ref var counter = ref MutableStructSampleProxy.GetInstanceCounter(ref sample);
Console.WriteLine(counter); // 3
counter = 9999;
Console.WriteLine(proxy._counter); // 9999
mutable structの対応って結構難しい話で、というのもフィールドにstructを保持するとコピーが渡されることになるので、普通に書いていると変更が元のstructに反映されないんですね。この問題をPrivateProxyではC# 11 ref fieldで解決しました。
MutableStructSampleProxyはSource Generatorによって以下のようなコードが生成されます。
ref partial struct MutableStructSampleProxy
{
ref MutableStructSample target;
public MutableStructSampleProxy(ref MutableStructSample target)
{
this.target = ref target;
}
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "Increment")]
static extern void __Increment__(ref MutableStructSample target);
public void Increment() => __Increment__(ref this.target);
}
public static class MutableStructSamplePrivateProxyExtensions
{
public static MutableStructSampleProxy AsPrivateProxy(this ref MutableStructSample target)
{
return new MutableStructSampleProxy(ref target);
}
}
AsPrivateProxy(これはthis refですが、拡張メソッドの場合、予備側はrefを書かなくていいので自然に使えます)で渡されたstructは、そのままずっとrefのまま保持されています。これにより、メソッド呼び出しでstructの状態に変更があった場合も問題なく変更が共有されています。
privateメソッドの単体テスト
プライベートメソッドのテストは書かないみたいな流儀も世の中にはありますが、私は何言ってんの?と思ってます。パブリックメソッドのテストでprivateメソッドの確認が内包されるから不要というなら、はぁー?だったら全部E2Eテストでいいんじゃないですかー?真の振る舞いがテストされますよー?
もちろんそれは非現実的で、E2Eでパブリックメソッドのロジックを全て通過させるのは手間とコストがかかりすぎる。というのと同じ話で、privateメソッドのエッジケースを全てチェックする時に、public経由だとやりにくいことは往々にある。場合によってはコード通すためにモックを仕込まなければならないかもしれない。そこまでいくとアホらしいですよね、privateメソッドを直接テストすればすむだけの話なのに?メソッドは小さければ小さいほど良いし、テストもしやすい。そしてprivateを内包したpublicよりもprivateそのもののほうが小さく、テストしやすい。
ようするにコスト面を考えて境界をどこに置くかというだけの話で。そして、privateメソッドのテストそのものは、C#の場合reflection呼び出しだと、変更コストがかかるなど、コスパは悪い部類に入ってしまう。でもそれはただの言語からの制約であって、だからプライベートメソッドのテストは不要みたいなしょーもない理屈をこねて絶対視するほどのことでもない。リフレクション使うとコスパ感が合わないから原則publicで済ませること、ぐらいだったらいいけど、変な教義立てるのはおかしいでしょ。
チェックしたいがためにprivateであるべきものをinternalにする(たまによくやる、internalならInternalsVisibleToをテストプロジェクトに指定することで、ユニットテストプロジェクトで参照できるようになる)こともありますが、あまりお行儀の良いことではない。そもそもinternalなので同一アセンブリ内では不要に可視レベル上がっちゃってるし。
と、いうわけで、PrivateProxyは比較的低コストでprivateメソッドをテスト対象にすることができるので、全然使っちゃっていいし、テストも書いちゃって良い、わけです。
UnsafeAccessor for InternalCall
UnsafeAccessor の使い道として、corelibの InternalCall を強引に呼べることはかなりいいこと(?)だと思ってます。例えば string の生成には大元に FastAllocateString というのがあって、通常ユーザーはこれを呼ぶことはできないのですが
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name = "FastAllocateString")]
static extern string FastAllocateString(string _, int length);
var rawString = FastAllocateString(null!, 10);
var mutableSpan = MemoryMarshal.CreateSpan(ref MemoryMarshal.GetReference(rawString.AsSpan()), rawString.Length);
"abcde".CopyTo(mutableSpan);
"fghij".CopyTo(mutableSpan.Slice(5));
Console.WriteLine(rawString); // abcdefghij
といったようにUnsafeAccessorを通せばやりたい放題できます。
Stringに関しては、やりたい放題させないために、String.Createというメソッドが用意されていて、Actionのコールバックで変更して、不変のStringを返すというものが用意されてはいるのですが、ActionだとTStateにSpan<T>
を渡せないとか、使えないケースがそれなりにあります、というか使いたいシチュエーションに限って使えないことが多い……。
なお、こういうFastAllocateStringを呼んでStringに変更をかけるというのは、dotnet/runtime#36989 Make string.FastAllocateString publicで見事に却下されています。つまり、やるな、ということです。
It is never safe or supported to mutate the contents of a returned string instance. If you mutate a string instance within your own library or application, you are entering unsupported territory. A future framework update could break you. Or - more likely - you'll encounter memory corruption that will be very painful for you or your customers to diagnose.
お怒りはご尤もです。しかしString.CreateでAction渡す口あるんだからそれでいいだろーというのはお粗末すぎだと思うんですよねー、どうせやってること一緒なんだから弄るの許可してよ、というのもそれはそれで理解してもらいたいです(私は弄りたいほうの人間なので!)。というわけで、自己責任で、やっていきましょう、つまりやっていくということです……!
まとめ
.NET 8でしか動きません!11月に .NET 8 がリリースされるので、その時まで忘れないでください!
Unity用のHTTP/2(gRPC) Client、YetAnotherHttpHandlerを公開しました
- 2023-07-28
Cysharpから(主に)Unity用のHTTP/2, gRPC, MagicOnion用の通信ネットワーククライアントを公開しました。実装者は週刊.NET情報配信WeekRef.NETを運営している@mayukiさんです。
何故これが必要なのかの背景情報としては、Synamon’s Engineer blog - Unityでもgrpc-dotnetを使ったgRPCがしたい が詳しいのですが、まず、.NETには2つのgRPC実装があります。googleが提供してきたgRPCのネイティブバインディングのGrpc.Core(C-Core)と、Microsoftが提供しているPure C#実装のgrpc-dotnet。現在.NETのgRPCはサーバーもクライアントも完全にPure C#実装のほうに寄っていて、MagicOnionもサーバーはPure C#実装のものを使っています。
しかしクライアントに関しては、諸事情によりUnityでは動かない(TLS関連の問題など)ため、ずっとC-Coreを推奨してきました。更に、Unity用のビルドは元々experimentalだったうえに、とっくにメンテナンスモードに入り、そしてついに今年5月にサポート期限も切れて完全に宜しくない気配が漂っていました。また、古いx64ビルドなので最近のMac(M1, M2チップ)では動かないためUnity Editorで使うのにも難儀するといった問題も出てきていました。
と、いうわけで、CysharpではUnityで使うgRPCを推奨してきたということもあり、Unityで問題なく使えるgRPC実装としてYetAnotherHttpHandlerを開発・リリースしました。HttpClientの通信レイヤーであるHttpHandlerを差し替えるという形で実装してあるので、ほとんど通常の .NET でのgRPCと同様に扱えます。
内部実装としてはPure Rust実装のHTTP/2ライブラリhyperとPure RustのTLSライブラリrustlsを基盤として作ったネイティブライブラリに対して、Cysharp/csbindgenで生成したC#バインディングを通して通信する形になっています。
余談
YetAnotherHttpHandlerはgRPCやMagicOnionに限らず、Unityで自由に使える HTTP/2 Clientなので、アセットダウンロードの高速化にHTTP/2を用いる、といったような使い道も考えられます。既にモバイルゲームでも幾つかのタイトルでHTTP/2でアセットダウンロードしているタイトルは確認できていまして、例えばセガさんはCEDEC2021 ダウンロード時間を大幅減!~大量のアセットをさばく高速な実装と運用事例の共有~のような発表もされています。ネイティブプラグインを自前でビルドして持ち込むというのはだいぶ敷居が高い話でしたが、YetAnotherHttpHandlerを入れるだけでいいなら、だいぶやれるんじゃないか感も出てくるんじゃないでしょうか……?