Upgrade to Pro — share decks privately, control downloads, hide ads and more …

CEDEC 2023 モダンハイパフォーマンスC# 2023 Edition

CEDEC 2023 モダンハイパフォーマンスC# 2023 Edition

CEDEC 2023

Yoshifumi Kawai

August 23, 2023
Tweet

More Decks by Yoshifumi Kawai

Other Decks in Technology

Transcript

  1. About Speaker 河合 宜文 / Kawai Yoshifumi / @neuecc Cysharp,

    Inc. - CEO/CTO 株式会社Cygamesの子会社として2018年9月設立 C#関連の研究開発/OSS/コンサルティングを行う Microsoft MVP for Developer Technologies(C#) 2011- CEDEC AWARDS 2022エンジニアリング部門優秀賞 50以上のOSSライブラリ開発(UniRx, UniTask, MessagePack C#, etc...) C#では世界でもトップレベルのGitHub Starを獲得
  2. OSS for high performance MemoryPack ★2007 Zero encoding extreme performance

    binary serializer for C# and Unity. AlterNats ★271 An alternative high performance NATS client for .NET. MessagePipe ★1062 High performance in-memory/distributed messaging pipeline for .NET and Unity. Ulid ★629 Fast .NET C# Implementation of ULID for .NET and Unity. MessagePack ★4836 Extremely Fast MessagePack Serializer for C#(.NET, Unity). MagicOnion ★3308 Unified Realtime/API framework for .NET platform and Unity. UniTask ★5901 Provides an efficient allocation free async/await integration for Unity. ZString ★1524 Zero Allocation StringBuilder for .NET and Unity.
  3. OSS for high performance MemoryPack ★2007 Zero encoding extreme performance

    binary serializer for C# and Unity. AlterNats ★271 An alternative high performance NATS client for .NET. MessagePipe ★1062 High performance in-memory/distributed messaging pipeline for .NET and Unity. Ulid ★629 Fast .NET C# Implementation of ULID for .NET and Unity. MessagePack ★4836 Extremely Fast MessagePack Serializer for C#(.NET, Unity). MagicOnion ★3308 Unified Realtime/API framework for .NET platform and Unity. UniTask ★5901 Provides an efficient allocation free async/await integration for Unity. ZString ★1524 Zero Allocation StringBuilder for .NET and Unity. 様々なジャンルで、競合の追随を許さないハイパフォー マンスを追求したライブラリを公開してきました。 このセッションではそれらの経験を元に、現代のC#で最 高のパフォーマンスを叩き出す技術を紹介します。
  4. Java/Delphi 2002 C# 1.0 2005 C# 2.0 2008 C# 3.0

    2010 C# 4.0 2012 C# 5.0 Generics LINQ Dynamic async/await Roslyn(self-hosting C# Compiler) Tuple Span null safety async streams record class code-generator 2015 C# 6.0 2017 C# 7.0 2019 C# 8.0 2020 C# 9.0 2021 C# 10.0 常に進化を続けてきた 2022 C# 11.0 global using record struct ref field struct abstract members
  5. Java/Delphi 2002 C# 1.0 2005 C# 2.0 2008 C# 3.0

    2010 C# 4.0 2012 C# 5.0 Generics LINQ Dynamic async/await Roslyn(self-hosting C# Compiler) Tuple Span null safety async streams record class code-generator 2015 C# 6.0 2017 C# 7.0 2019 C# 8.0 2020 C# 9.0 2021 C# 10.0 常に進化を続けてきた 2022 C# 11.0 global using record struct ref field struct abstract members 数年置きに大型のアップデート をした頃(Anders Hejlsberg期) 毎年小刻みに機能追加 大胆な新機能は入らなく なったものの、地道に良く なっている&パフォーマン スに響く機能の追加が多い
  6. .NET Framework -> Cross platform .NET Framework 1.0 2005 2008

    2010 .NET Core 2.0 2017 .NET Core 3.0 2020 2002 .NET Framework 2.0 .NET Framework 3.5 2012 2016 .NET Framework 4 .NET Framework 4.5 .NET Core 1.0 .NET 5 2019 Linuxへの本格対応の開始 多くのランタイム(.NET Framework, Core, mono, Xamarin) の統合 Windowsのことしか考えて なかった期
  7. gRPCとPerformance gRPC == 高速、ではない 実装によって性能は異なり、鍛 えられていない実装はイマイチ C#は、RustやGo, C++などの トップ層と同レベルのパフォー マンスを叩き出している

    0 50000 100000 150000 200000 250000 300000 350000 gRPC Implementation performance(2CPUs) Requests/sec(higher is better) https://github.com/LesnyRumcajs/grpc_bench/discussions/354
  8. MessagePack for C# #1 Binary Serializer in .NET https://github.com/MessagePack-CSharp/MessagePack-CSharp .NETで最も支持されている(4836☆)バイナリシリアライザー

    直接使ったことはなくても間接的に 使ったことはきっとあるはず……! • Visual Studio 2022の内部通信 • SignalRのMessagePack Hub • Blazor Serverプロトコル(BlazorPack) 2017-03-13リリース 当時の他の競合と比べて圧倒的な速度
  9. 最速のシリアライザについて考える 例えばvalue = int(999)をシリアライズ 理想的な最速コード: Unsafe.WriteUnaligned(ref MemoryMarshal.GetReference(dest), value); Span<byte> dest

    ldarg .0 ldarg .1 unaligned. 0x01 stobj !!T ret ようするにメモリコピー C# 7.2以降、連続したメモリ領域を扱う Spanを積極的に活用する必要がある UnsafeクラスはILでは書けるがC#では書 けないプリミティブな処理を提供する これらによってC#の言語的な成約が外れ、 生の動作をコントロールしやすくなった NOTE: Spanの処理自体はポインタでも十分近いものが書けはするんですが、Spanだ とC#として自然に扱えるので、メソッドの内部だけではなくて、public APIのメ ソッドシグネチャに頻繁に現れるようになりました。そのお陰で、「生」っぽい処 理が、メソッドをまたがって、クラスを、アセンブリを超えて引き回していくこと がパターン化され、近年のC#の全体的なパフォーマンス向上に繋がっています。
  10. 既存のシリアライザの場合 MessagePack JSON // uint16 msgpack code Unsafe.WriteUnaligned(ref dest[0], (byte)0xcd);

    // Write value as BigEndian var temp = BinaryPrimitives.ReverseEndianness((ushort)value); Unsafe.WriteUnaligned(ref dest[1], temp); Utf8Formatter.TryFormat(value, dest, out var bytesWritten); JSONはstringではなくてUTF8のバイナリと して直接読み書きすることで高速化する MessagePackの仕様に従って先頭に型識別子と、 値をBigEndianで書き込む
  11. 既存のシリアライザの場合 MessagePack JSON // uint16 msgpack code Unsafe.WriteUnaligned(ref dest[0], (byte)0xcd);

    // Write value as BigEndian var temp = BinaryPrimitives.ReverseEndianness((ushort)value); Unsafe.WriteUnaligned(ref dest[1], temp); Utf8Formatter.TryFormat(value, dest, out var bytesWritten); JSONはstringではなくてUTF8のバイナリと して直接読み書きすることで高速化する MessagePackの仕様に従って先頭に型識別子と、 値をBigEndianで書き込む MessagePack for C#は確かに速い。しかしMessagePack としてのバイナリ仕様の都合上、なにをどうやっても 「理想的な最速コード」よりも遅くなる……
  12. Zero encoding 可能な限り徹底的にメモリコピーのみ public struct Point3D { public int X;

    public int Y; public int Z; } new Point3D { X = 1, Y = 2, Z = 3 } 参照型を含まない構造体(not IsReferenceOrContainsReferences) の値はメモリ上に一列に並ぶことがC#として保証されている
  13. IsReferenceOrContainsReferences シーケンシャルに並べたコンパクトな仕様 [MemoryPackable] public partial class Person { public long

    Id { get; set; } public int Age { get; set; } public string? Name { get; set; } } 参照型の場合はシーケンシャルに書き込ん でいく。単純な構造の中にバージョニング 耐性など、性能とバランスを取りながら仕 様を詰めた。
  14. T[] where T : unmanaged C#の配列は要素がunmanaged型(参照型を含まない struct)の場合、全て直列に並ぶ new int[] {

    1, 2, 3, 4, 5 } var srcLength = Unsafe.SizeOf<T>() * value.Length; var allocSize = srcLength + 4; ref var dest = ref GetSpanReference(allocSize); ref var src = ref Unsafe.As<T, byte>(ref GetArrayDataReference(value)); Unsafe.WriteUnaligned(ref dest, value.Length); Unsafe.CopyBlockUnaligned(ref Unsafe.Add(ref dest, 4), ref src, (uint)srcLength); Advance(allocSize); Serialize == メモリコピー
  15. T[] where T : unmanaged Vector3[]など複合型になればなるほど有利 Vector3(float x, float y,

    float z)[10000] 通常のシリアライザーは各フィールド毎に Write/Readするため、10000個だと10000*3 の処理が必要。MemoryPackはコピー1回。 そりゃ200倍高速なのも当然の話です……!
  16. I/Oアプリケーション高速化の三箇条 アロケーションを抑える コピーを抑える 非同期I/Oを優先する // ダメな例 byte[] result = Serialize(value);

    response.Write(result); 都度byte[]のアロケーション なにかにWrite == 恐らくコピーの発生 同期Write
  17. I/OといったらStreamだ……? async Task WriteToStreamAsync(Stream stream) { // Queue<Message> while (messages.TryDequeue(out

    var message)) { await stream.WriteAsync(message.Encode()); } } I/Oが絡む場合のアプリケーションの最終的な出力先は通常は ネットワーク(Socket/NetworkStream)かファイル(FileStream) これで三箇条を実践できましたか……?
  18. Streamがダメな理由1 async Task WriteToStreamAsync(Stream stream) { // Queue<Message> while (messages.TryDequeue(out

    var message)) { await stream.WriteAsync(message.Encode()); } } 細かいI/Oの連発は、たとえ非同期I/Oであろうとも遅い! async/awaitは万能ではない!
  19. Stream is beautiful……? async Task WriteToStreamAsync(Stream stream) { // ならばBufferedStreamでバッファ足せばいいね?

    using (var buffer = new BufferedStream(stream)) { while (messages.TryDequeue(out var message)) { await buffer.WriteAsync(message.Encode()); } } } Streamの「機能面」での優れた抽象化は、デコレーターパ ターンにより自由に機能を追加していけることでもある。 例えばGZipStreamに包めば圧縮を、CryptoStreamに包めば 暗号化を加えたりすることができる。 今回はバッファを足したいのでBufferedStreamに包む。 これによりWriteAsyncでも即座にI/Oにwriteされない。
  20. Stream is beautiful……? async Task WriteToStreamAsync(Stream stream) { // ならばBufferedStreamでバッファ足せばいいね?

    using (var buffer = new BufferedStream(stream)) { while (messages.TryDequeue(out var message)) { await buffer.WriteAsync(message.Encode()); } } }
  21. Streamがダメな理由2 async Task WriteToStreamAsync(Stream stream) { // ならばBufferedStreamでバッファ足せばいいね? using (var

    buffer = new BufferedStream(stream)) { while (messages.TryDequeue(out var message)) { await buffer.WriteAsync(message.Encode()); } } } Streamが既にBufferedだったら「アロケーション を抑える」に反して無駄アロケーション bufferedであることにより、ほとんどの場合(bufferが溢れてな ければ)同期的な呼び出し。同期なのに非同期呼び出しは無駄。
  22. Streamがダメな理由2 async Task WriteToStreamAsync(Stream stream) { // ならばBufferedStreamでバッファ足せばいいね? using (var

    buffer = new BufferedStream(stream)) { while (messages.TryDequeue(out var message)) { await buffer.WriteAsync(message.Encode()); } } } Streamが既にBufferedだったら「アロケーション を抑える」に反して無駄アロケーション bufferedであることにより、ほとんどの場合(bufferが溢れてな ければ)同期的な呼び出し。同期なのに非同期呼び出しは無駄。 Public Task WriteAsync (byte[] buffer, int offset, int count); public ValueTask WriteAsync (ReadOnlyMemory<byte> buffer); Stream(やSocket)には歴史的事情により、同名で似たようなパラメー ターで、Taskを返すAPIとValueTaskを返すAPIがある。Taskを返すAPIを 使ってしまうと、無駄にTaskのアロケーションを発生させてしまう可能 性があるので、必ずValueTask呼び出しを使うこと。 幸いBufferedStreamの場合は同期の場合はTask.CompletedTaskを返すのでアロ ケーション自体は起こらない。とはいえawaitの呼び出しコストというのはあ る。それに関してはValueTaskであっても同様で、無駄は無駄。
  23. async Task WriteToStreamAsync(Stream stream) { var buffer = ArrayPool<byte>.Shared.Rent(4096); try

    { var slice = buffer.AsMemory(); var totalWritten = 0; { while (messages.TryDequeue(out var message)) { var written = message.EncodeTo(slice.Span); totalWritten += written; slice = slice.Slice(written); } } await stream.WriteAsync(buffer.AsMemory(0, totalWritten)); } finally { ArrayPool<byte>.Shared.Return(buffer); } } そもそもmessage.Encode()でbyte[] 返してたんじゃないか疑惑。 EncodeToにするなら、大きめの バッファとってBufferedStreamの代 わりにすれば無駄なく…… (Sampleなのでバッファ溢れとかは起こらないということにします のでチェックとか拡大とかは省きます) 非同期Writeを一回だけにする
  24. Stream is Dead Streamを避ける StreamがI/Oの第一級クラスだった時代は終わった File処理としてRandomAccess(Scatter Gather I/O API) 直接内部バッファを呼び出すためのIBufferWriter<T>

    バッファーと流量制御のSystem.IO.Pipeline Streamを避けて処理するためのクラスが出現してきた Streamというオーバーヘッドを通さないことがハイパ フォーマンス処理の第一歩になる とはいえ、.NETの根っこにいるのでStreamを完全に避けるの は不可能。NetworkStreamやFileStreamを避けきるのは難し いし、ConsoleStreamやSslStreamには代替がない。最後の読 み書きまで触らないといった対応でなんとかしよう。
  25. IBufferWriter<byte> 書き込み用同期バッファーの抽象化 public interface IBufferWriter<T> { void Advance(int count); Memory<T>

    GetMemory(int sizeHint = 0); Span<T> GetSpan(int sizeHint = 0); } await SendAsync() Network buffer IBufferWriter requests slice Serializer write to slice Finally write buffer slice to network void Serialize<T>(IBufferWriter<byte> writer, T value) 根本のバッファーを直接取得し書き込むこと で、アロケーションだけではなくバッファ間 のコピーもなくすことができる
  26. MemoryPackSerializer.Serialize public static partial class MemoryPackSerializer { public static void

    Serialize<T, TBufferWriter>(in TBufferWriter bufferWriter, in T? value) where TBufferWriter : IBufferWriter<byte> public static byte[] Serialize<T>(in T? value) public static ValueTask SerializeAsync<T>(Stream stream, T? value) } これが一番基本で、パフォーマンスを出せる
  27. public void WriteUnmanaged<T1>(scoped in T1 value1) where T1 : unmanaged

    { var size = Unsafe.SizeOf<T1>(); ref var spanRef = ref GetSpanReference(size); Unsafe.WriteUnaligned(ref spanRef, value1); Advance(size); } MemoryPackWriter 書き込み用バッファー管理 あるいはIBufferWriter<byte>のバッファーのキャッシュ public ref partial struct MemoryPackWriter<TBufferWriter> where TBufferWriter : IBufferWriter<byte> { ref TBufferWriter bufferWriter; ref byte bufferReference; int bufferLength; ref byte GetSpanReference(int sizeHint); void Advance(int count); public MemoryPackWriter(ref TBufferWriter writer) } public interface System.Buffers.IBufferWriter<T> { Span<T> GetSpan(int sizeHint = 0); void Advance(int count); } 1. 例えばintとか書く場合 2. 必要な最大バッファを要求 3. 書き込んだ分を申告 ctorでTBufferWriterを受 け取っておく
  28. public void WriteUnmanaged<T1>(scoped in T1 value1) where T1 : unmanaged

    { var size = Unsafe.SizeOf<T1>(); ref var spanRef = ref GetSpanReference(size); Unsafe.WriteUnaligned(ref spanRef, value1); Advance(size); } MemoryPackWriter 書き込み用バッファー管理 あるいはIBufferWriter<byte>のバッファーのキャッシュ public ref partial struct MemoryPackWriter<TBufferWriter> where TBufferWriter : IBufferWriter<byte> { ref TBufferWriter bufferWriter; ref byte bufferReference; int bufferLength; ref byte GetSpanReference(int sizeHint); void Advance(int count); public MemoryPackWriter(ref TBufferWriter writer) } public interface System.Buffers.IBufferWriter<T> { Span<T> GetSpan(int sizeHint = 0); void Advance(int count); } IBuferWriter<byte>への頻繁な GetSpan/Advanceの呼び出しは遅いため、 MemoryPackWriter内で余裕をもって確保して おき、BufferWriterへの呼び出し回数を抑える NOTE: IBufferWriterを実装する際に、GetSpanで返すバッファーのサ イズはsizeHintで切り詰めたものではなく、内部で持っているであ ろう実際のバッファーサイズそのものを返そう。切り詰めると GetSpanの呼び出しを頻繁にせざるを得なくなるため、パフォーマ ンスが低下する要因になる。
  29. Writeを最適化する メソッド呼び出し回数の削減 少なければ少ないほどいい public ref partial struct MemoryPackWriter<TBufferWriter> where TBufferWriter

    : IBufferWriter<byte> { ref TBufferWriter bufferWriter; ref byte bufferReference; int bufferLength; ref byte GetSpanReference(int sizeHint); void Advance(int count); public MemoryPackWriter(ref TBufferWriter writer) } 固定サイズのメンバーが連続している場合は呼び 出しを固めることで、GetSpanReference/Advance の呼び出し回数を抑える
  30. Serializeの完了 public static partial class MemoryPackSerializer { public static void

    Serialize<T, TBufferWriter>(in TBufferWriter bufferWriter, in T? value) where TBufferWriter : IBufferWriter<byte> public static byte[] Serialize<T>(in T? value) public static ValueTask SerializeAsync<T>(Stream stream, T? value) } Flush(元のIBufferWriterのAdvanceを呼んで、実際の書き込ん だ領域を同期的に確定させる)すればシリアライズ処理終了 var writer = new MemoryPackWriter<TBufferWriter>(ref bufferWriter); writer.WriteValue(value); writer.Flush();
  31. 他のオーバーロード public static partial class MemoryPackSerializer { public static void

    Serialize<T, TBufferWriter>(in TBufferWriter bufferWriter, in T? value) public static byte[] Serialize<T>(in T? value) public static ValueTask SerializeAsync<T>(Stream stream, T? value) } ReusableLinkedArrayBufferWriterを 内部的に通してSerialize var bufferWriter = ReusableLinkedArrayBufferWriterPool.Rent(); var writer = new MemoryPackWriter<ReusableLinkedArrayBufferWriter>(ref bufferWriter); writer.WriteValue(value); writer.Flush(); await bufferWriter.WriteToAndResetAsync(stream); return bufferWriter.ToArrayAndReset();
  32. ReusableLinkedArrayBufferWriter byte[] byte[] byte[] ArrayPool<byte>.Shared.Rent GetSpan() 最後に連結した配列(又はStreamへの書き込み)が欲しいだけな ら、一塊のメモリ領域でなくても良いので、List<T>のような 拡大コピーではなくて、連結したチャンクで内部バッファを表 現する。これによりコピー回数を減らすことができる。

    public sealed class ReusableLinkedArrayBufferWriter : IBufferWriter<byte> { List<BufferSegment> buffers; } struct BufferSegment { byte[] buffer; int written; } NOTE: バッファが足りなくなった場合、連結だからといって(ある いはLOHを気にして)固定サイズのものを連結させるのではなく、 2倍サイズのものを生成して(借りて)連結すること。そうでないと、 書き込み結果が大きい場合に連結リストの要素数が多くなりすぎて しまってパフォーマンスが悪化してしまう。
  33. ToArray / WriteTo byte[] byte[] byte[] var result = new

    byte[a.Length + b.count + c.Length]; a.CopyTo(result); b.CopyTo(result); c.CopyTo(result); await stream.WriteAsync(a); await stream.WriteAsync(b); await stream.WriteAsync(c); ArrayPool<byte>.Shared.Return 最終サイズが分かっているので、最後の結果だけを newしてコピー、あるいはStreamにWriteする。終わっ た作業用配列は不要なのでPoolにReturnする。
  34. Improve LINQ ToArray 連結といえばEnumerable.ToArray 要素数不定なIEnumerable<T> を T[] に変換する 従来は溢れたら内部のT[]を拡大していたけれど、今回と 同じように連結したChunkからT[]を得られるのでは?

    dotnet/runtimeにPR出しました https://github.com/dotnet/runtime/pull/90459 30~60%の劇的な性能向上 順調にいけば.NET 9に入るかも? NOTE: なおLINQのToArrayは既に様々な最適化が施されていて、要素数 を可能な限り推定して、推定可能な場合は固定サイズの配列を確保す るようになっている。サイズの推定は単純な is ICollectionだけではなく、 Enumerable.Rangeならサイズが確定している、Takeなら確定可能な場 合がある、などメソッドチェーンの状況に応じて細かい分岐がある。
  35. with InlineArray(C# 12) Poolのアグレッシブな利用を避ける ランタイムに入れるものなのでPoolの多用は控えた ReusableなLinkedArrayが使えない代わりに C# 12のInlineArrayを採用 大雑把に言うとstackalloc T[]を可能にする(つまりT[][])

    [InlineArray(29)] struct ArrayBlock<T> { private T[] array; } List<T[]>(的なもの)だと余計なallocateがあるので提案 しづらかった。スタック領域にT[][]を確保するので連 結リストそのもののアロケーションをなくせた。 ただしInlineArrayはコンパイル時指定の固定サイズしか許 されていない。そこでサイズとして「29」を採用した……。
  36. No Stream Again 性能は同期バッファと非同期読み書きで決まる I/Oとデシリアライズを混ぜない 都度ReadAsyncを呼んでいるようでは遅すぎる MemoryPackSerializer.Deserialize(Stream)は、最初に ReadOnlySequence<byte>を構築してからデシリアライズプロセスに流す public static

    partial class MemoryPackSerializer { public static T? Deserialize<T>(ReadOnlySpan<byte> buffer) public static int Deserialize<T>(in ReadOnlySequence<byte> buffer, ref T? value) public static async ValueTask<T?> DeserializeAsync<T>(Stream stream) } NOTE: I/Oとデシリアライズを混ぜないということは、長さ不定、あるいは省 バッファな真のストリーミングデシリアライズができないということでもある。 MemoryPackでは、代わりにウィンドウ幅でバッファリングして IAsyncEnumerable<T>を返すデシリアライズを補助機構として用意した。 読み込み済みの同期バッファのみをターゲットにする
  37. public ref partial struct MemoryPackReader { ReadOnlySequence<byte> bufferSource; ref byte

    bufferReference; int bufferLength; ref byte GetSpanReference(int sizeHint); void Advance(int count); public MemoryPackReader( in ReadOnlySequence<byte> source) public MemoryPackReader( ReadOnlySpan<byte> buffer) } public void ReadUnmanaged<T1>(out T1 value1) where T1 : unmanaged { var size = Unsafe.SizeOf<T1>(); ref var spanRef = ref GetSpanReference(size); value1 = Unsafe.ReadUnaligned(ref spanRef); Advance(size); } MemoryPackReader 読み込み用バッファー管理 ReadOnlySequence<byte>をソースにする public readonly struct ReadOnlySequence<T> { ReadOnlySpan<T> FirstSpan { get; } ReadOnlySequence<T> Slice(long start); } 1. 例えばintとか読む場合 2. 必要な最大バッファを要求 3. 読み込んだ分を申告 MemoryPackWriterと似たような感じに、GetSpanReference で必要なバッファを受け取って、Advanceで前に進める
  38. public ref partial struct MemoryPackReader { ReadOnlySequence<byte> bufferSource; ref byte

    bufferReference; int bufferLength; ref byte GetSpanReference(int sizeHint); void Advance(int count); public MemoryPackReader( in ReadOnlySequence<byte> source) public MemoryPackReader( ReadOnlySpan<byte> buffer) } public void ReadUnmanaged<T1>(out T1 value1) where T1 : unmanaged { var size = Unsafe.SizeOf<T1>(); ref var spanRef = ref GetSpanReference(size); value1 = Unsafe.ReadUnaligned(ref spanRef); Advance(size); } MemoryPackReader 読み込み用バッファー管理 ReadOnlySequence<byte>をソースにする public readonly struct ReadOnlySequence<T> { ReadOnlySpan<T> FirstSpan { get; } ReadOnlySequence<T> Slice(long start); } ReadOnlySequence<byte>への頻繁なSliceの呼 び出しは遅いため、MemoryPackReader内で FirstSpanとしてブロック全部を確保しておいて、 ReadOnlySequenceへの呼び出し回数を抑える NOTE: Readの要求がFirstSpanを超えることは当然ある。MemoryPackのデシリア ライズには連続したメモリ領域が必要なので、poolから借りたテンポラリ領域 にコピーし、それをref byte bufferReferenceに割り当てるといった処理を実際の MemoryPackでは行っている。
  39. 効率的なReadは難しい 不完全な読み込みが発生することへの対処 常にEnd of Streamまで全読みが許されるわけではない while (true) { var read

    = await socket.ReceiveAsync(buffer); var span = buffer.AsSpan(read); // あとはこれをparseしてどうこうする } ここで読み込んだ量は1メッセージの ブロックに満たない可能性がある 再度ReceiveAsyncを読んでbufferに詰めると して、bufferを超えてしまう場合は? Resizeを繰り返すと無限に大きくなってしまうが、 0に戻せるタイミングが来ることを保証できるか?
  40. ReadOnlySequenceを返すReaderを作る 不完全なブロックを連結する 1メッセージのサイズが分かるなら(プロトコルとして ヘッダにLength書いてある)少なくとも幾つのサイズ以 上の読み込み(ReadAtLeast)という命令に転換できる async Task ReadLoopAsync() { while

    (true) { ReadOnlySequence<byte> buffer = await socketReader.ReadAtLeastAsync(4); // do anything } } ReadOnlySequence<byte>になっていれば、対応しているも のに流し込むことができる。例えば現代的なシリアライザー は基本的にReadOnlySequence<byte>に対応している。 NOTE: ReadOnlySequence<byte>に対応していないシリアライザーはレガシーなので 投げ捨てましょう。もちろんMessagePack for C#, MemoryPackは対応しています。 NOTE: この辺をよしなにやってくれるのがSystem.IO.Pipelinesです。
  41. 先頭にメッセージの種類があって、それを元に何かする といったプロトコルがあるとして、そのメッセージ種が 文字列の場合(テキストプロトコル、例えばRedisやNATS はテキストプロトコルを採用している)どう判定するか 種類を判別する async Task ReadLoopAsync() { while

    (true) { ReadOnlySequence<byte> buffer = await socketReader.ReadAtLeastAsync(4); var code = GetCode(buffer); if (code == ServerOpCodes.Msg) { //… } } } 文字列に変換することでシンプルに判定できる。 Enumにでも変換してあげると後続で使いやすい。 これはNATSの例ですが、記号やスペースも含めて4 文字に合わせるという工夫でReadAtLeastAsync(4)で 確実に判定できるよう工夫した。 ServerOpCodes GetCode(ReadOnlySequence<byte> buffer) { var span = GetSpan(buffer); var str = Encoding.UTF8.GetString(span); return str switch { "INFO" => ServerOpCodes.Info, "MSG " => ServerOpCodes.Msg, "PING" => ServerOpCodes.Ping, "PONG" => ServerOpCodes.Pong, "+OK¥r" => ServerOpCodes.Ok, "-ERR" => ServerOpCodes.Error, _ => throw new InvalidOperationException() }; }
  42. 先頭にメッセージの種類があって、それを元に何かする といったプロトコルがあるとして、そのメッセージ種が 文字列の場合(テキストプロトコル、例えばRedisやNATS はテキストプロトコルを採用している)どう判定するか 種類を判別する async Task ReadLoopAsync() { while

    (true) { ReadOnlySequence<byte> buffer = await socketReader.ReadAtLeastAsync(4); var code = GetCode(buffer); if (code == ServerOpCodes.Msg) { //… } } } 文字列に変換することでシンプルに判定できる。 Enumにでも変換してあげると後続で使いやすい。 これはNATSの例ですが、記号やスペースも含めて4 文字に合わせるという工夫でReadAtLeastAsync(4)で 確実に判定できるよう工夫した。 ServerOpCodes GetCode(ReadOnlySequence<byte> buffer) { var span = GetSpan(buffer); var str = Encoding.UTF8.GetString(span); return str switch { "INFO" => ServerOpCodes.Info, "MSG " => ServerOpCodes.Msg, "PING" => ServerOpCodes.Ping, "PONG" => ServerOpCodes.Pong, "+OK¥r" => ServerOpCodes.Ok, "-ERR" => ServerOpCodes.Error, _ => throw new InvalidOperationException() }; }
  43. 先頭にメッセージの種類があって、それを元に何かする といったプロトコルがあるとして、そのメッセージ種が 文字列の場合(テキストプロトコル、例えばRedisやNATS はテキストプロトコルを採用している)どう判定するか 種類を判別する async Task ReadLoopAsync() { while

    (true) { ReadOnlySequence<byte> buffer = await socketReader.ReadAtLeastAsync(4); var code = GetCode(buffer); if (code == ServerOpCodes.Msg) { //… } } } 文字列に変換することでシンプルに判定できる。 Enumにでも変換してあげると後続で使いやすい。 これはNATSの例ですが、記号やスペースも含めて4 文字に合わせるという工夫でReadAtLeastAsync(4)で 確実に判定できるよう工夫した。 ServerOpCodes GetCode(ReadOnlySequence<byte> buffer) { var span = GetSpan(buffer); var str = Encoding.UTF8.GetString(span); return str switch { "INFO" => ServerOpCodes.Info, "MSG " => ServerOpCodes.Msg, "PING" => ServerOpCodes.Ping, "PONG" => ServerOpCodes.Pong, "+OK¥r" => ServerOpCodes.Ok, "-ERR" => ServerOpCodes.Error, _ => throw new InvalidOperationException() }; } String化はアロケーション 絶対に絶対に避ける!!!
  44. Take2 ReadOnlySpan<byte>で比較 async Task ReadLoopAsync() { while (true) { ReadOnlySequence<byte>

    buffer = await socketReader.ReadAtLeastAsync(4); var code = GetCode(buffer); if (code == ServerOpCodes.Msg) { //… } } } C# 11 UTF-8リテラル(u8)によって定数 的にReadOnlySpan<byte>を取得する。 マッチ頻度の高いものをif文の先頭に 持ってくればifチェックのコストも下 げられる。またReadOnlySpan<byte> のSequenceEqualは(LINQのものと違っ て)かなり高速に比較してくれる。 ServerOpCodes GetCode(ReadOnlySequence<byte> buffer) { var span = GetSpan(buffer); if (span.SequenceEqual("MSG "u8)) return ServerOpCodes.Msg; if (span.SequenceEqual("PONG"u8)) return ServerOpCodes.Pong; if (span.SequenceEqual("INFO"u8)) return ServerOpCodes.Info; if (span.SequenceEqual("PING"u8)) return ServerOpCodes.Ping; if (span.SequenceEqual("+OK¥r"u8)) return ServerOpCodes.Ok; if (span.SequenceEqual("-ERR"u8)) return ServerOpCodes.Error; throw new InvalidOperationException(); }
  45. Take2 ReadOnlySpan<byte>で比較 async Task ReadLoopAsync() { while (true) { ReadOnlySequence<byte>

    buffer = await socketReader.ReadAtLeastAsync(4); var code = GetCode(buffer); if (code == ServerOpCodes.Msg) { //… } } } C# 11 UTF-8リテラル(u8)によって定数 的にReadOnlySpan<byte>を取得する。 マッチ頻度の高いものをif文の先頭に 持ってくればifチェックのコストも下 げられる。またReadOnlySpan<byte> のSequenceEqualは(LINQのものと違っ て)かなり高速に比較してくれる。 ServerOpCodes GetCode(ReadOnlySequence<byte> buffer) { var span = GetSpan(buffer); if (span.SequenceEqual("MSG "u8)) return ServerOpCodes.Msg; if (span.SequenceEqual("PONG"u8)) return ServerOpCodes.Pong; if (span.SequenceEqual("INFO"u8)) return ServerOpCodes.Info; if (span.SequenceEqual("PING"u8)) return ServerOpCodes.Ping; if (span.SequenceEqual("+OK¥r"u8)) return ServerOpCodes.Ok; if (span.SequenceEqual("-ERR"u8)) return ServerOpCodes.Error; throw new InvalidOperationException(); }
  46. 先頭4文字をint化して判定 // msg = ReadOnlySpan<byte> if (Unsafe.ReadUnaligned<int>(ref MemoryMarshal.GetReference<byte>(msg)) == 1330007625)

    // INFO { } internal static class ServerOpCodes { public const int Info = 1330007625; // "INFO" public const int Msg = 541545293; // "MSG " public const int Ping = 1196312912; // "PING" public const int Pong = 1196314448; // "PONG" public const int Ok = 223039275; // "+OK¥r" public const int Error = 1381123373; // "-ERR" } 後続(スペースや¥r)と合わせると、ちょうど NATSのOpCodeは全て4バイト(int)で判定可能な ので、事前にint化した定数郡を作っておく ReadOnlySpan<byte>から直接int化 文字列化して比較は論外ですが、せっかく4バ イトでいけるのでint化した比較が一番速い NOTE: まぁバイナリプロトコルで先頭1バイトが種類を表してる、みたい なのが一番いいんですけどね……。テキストプロトコルよくない。
  47. async/awaitとインライン化 async Task ReadLoopAsync() { while (true) { ReadOnlySequence<byte> buffer

    = await socketReader.ReadAtLeastAsync(4); var code = GetCode(buffer); await DispatchCommandAsync(code, buffer); } } async ValueTask DispatchCommandAsync(int code, ReadOnlySequence<byte> buffer) { } Socketからデータを読み込む部分(実際のコード はもう少し複雑なので、処理部分と分離したい) このメソッドで、メッセージを詳細にパースし て、実際の処理(payloadをデシリアライズして コールバックなど)をする
  48. async/awaitとインライン化 async Task ReadLoopAsync() { while (true) { ReadOnlySequence<byte> buffer

    = await socketReader.ReadAtLeastAsync(4); var code = GetCode(buffer); await DispatchCommandAsync(code, buffer); } } async ValueTask DispatchCommandAsync(int code, ReadOnlySequence<byte> buffer) { } Socketからデータを読み込む部分(実際のコード はもう少し複雑なので、処理部分と分離したい) このメソッドで、メッセージを詳細にパースし て、実際の処理(payloadをデシリアライズして コールバックなど)をする
  49. 非同期ステートマシン生成に注意 async Task ReadLoopAsync() { while (true) { ReadOnlySequence<byte> buffer

    = await socketReader.ReadAtLeastAsync(4); var code = GetCode(buffer); await DispatchCommandAsync(code, buffer); } } async ValueTask DispatchCommandAsync(int code, ReadOnlySequence<byte> buffer) { } ループ内なら新規の非同期ステートマシ ン生成はないので、awaitし放題 async宣言で作った非同期メソッドで実際に非同期処理した場合、呼び出し毎に 非同期ステートマシンが生成されてしまうので余計なアロケーションがある 実態がIValueTaskSourceのasyncメソッドなら直 接awaitしても非同期ステートマシン生成はない という工夫が可能
  50. ホットパスのawaitインライン化 async Task ReadLoopAsync() { while (true) { ReadOnlySequence<byte> buffer

    = await socketReader.ReadAtLeastAsync(4); var code = GetCode(buffer); if (code == ServerOpCodes.Msg) { await DoAnything(); await DoAnything(); } else { await DispatchCommandAsync(code, buffer); } } } [AsyncMethodBuilderAttribute(typeof(PoolingAsyncValueTaskMethodBuilder))] async ValueTask DispatchCommandAsync(int code, ReadOnlySequence<byte> buffer) { ループの9割がMsgの受信(ほかはPINGとかERRORとかめったに 来ない)のため、Msgだけインライン化して最高効率を狙う それ以外の場合はメソッドを分けるが、 .NET 6 からの PoolingAsyncValueTaskMethodBuilderとマークすることにより非同 期ステートマシンがプールされ再利用されるようになる
  51. IL.Emit vs SourceGenerator IL.Emit 実行時に型情報を使って動的にAssemblyを生成する .NET初期から使えるIL黒魔術 動的生成が許されていない環境で使えない(iOSやWASM, NativeAOTなど) SourceGenerator コンパイル時にASTを使ってC#コードを生成してコンパイル

    .NET 6あたりから本格的に使われだしてきた 純粋なC#コードなのであらゆる環境で使える .NETの動作する環境が多様化したこと、スタートアップ速度のペナルティがな いことから、可能な限りSourceGeneratorに寄せていくのが望ましい 実行時の情報が使えないのは、特にGenerics周りで同様のコード生成が難しい こともありますが、工夫して乗り越えていきましょう……
  52. Optimize for List<T> / Read public sealed class ListFormatter<T> :

    MemoryPackFormatter<List<T?>> { public override void Serialize<TBufferWriter>( ref MemoryPackWriter<TBufferWriter> writer, scoped ref List<T?>? value) { if (value == null) { writer.WriteNullCollectionHeader(); return; } var span = CollectionsMarshal.AsSpan(value); var formatter = GetFormatter<T>(); WriteCollectionHeader(span.Length); for (int i = 0; i < span.Length; i++) { formatter.Serialize(ref this, ref span[i]); } } } List<T>の最速イテレート手法は CollectionsMarshal.AsSpan
  53. Optimize for List<T> / Write public override void Deserialize(ref MemoryPackReader

    reader, scoped ref List<T?>? value) { if (!reader.TryReadCollectionHeader(out var length)) { value = null; return; } value = new List<T?>(length); CollectionsMarshal.SetCount(value, length); var span = CollectionsMarshal.AsSpan(value); var formatter = GetFormatter<T>(); for (int i = 0; i < length; i++) { formatter.Deserialize(ref this, ref span[i]); } } List<T>.Addをチマチマやるのは遅い。Spanと して扱えるようにすることで、List<T>のデシ リアライズ速度を配列と同等にした new List(capacity)だけだと、内部のサイズは0のため、 CollectionsMarshal.AsSpanしても長さ0のSpanが得ら れるだけで意味がない。 .NET 8 から追加されたCollectionsMarshal.SetCount によって強引に内部サイズを変更することでSpanを 取り出しAddを避けることができる。
  54. ListFormatterの実際のコード public override void Deserialize(ref MemoryPackReader reader, scoped ref List<T?>?

    value) { if (!reader.TryReadCollectionHeader(out var length)) { value = null; return; } value = new List<T?>(length); CollectionsMarshal.SetCount(value, length); var span = CollectionsMarshal.AsSpan(value); if (!RuntimeHelpers.IsReferenceOrContainsReferences<T>()) { var byteCount = length * Unsafe.SizeOf<T>(); ref var src = ref reader.GetSpanReference(byteCount); ref var dest = ref Unsafe.As<T, byte>(ref MemoryMarshal.GetReference(span)!); Unsafe.CopyBlockUnaligned(ref dest, ref src, (uint)byteCount); reader.Advance(byteCount); } else { var formatter = GetFormatter<T>(); for (int i = 0; i < length; i++) { formatter.Deserialize(ref this, ref span[i]); } } } MemoryPackはunamanged型のT[]はメモリコピーのみ で処理できるバイナリ仕様になっている。Span<T> を取り出すことで、List<T>でもメモリコピーでデシ リアライズする処理が可能になった。