Slide 1

Slide 1 text

モダンハイパフォーマンスC# 2023 Edition CEDEC 2023 2023-08-23 Yoshifumi Kawai / Cysharp, Inc.

Slide 2

Slide 2 text

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を獲得

Slide 3

Slide 3 text

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.

Slide 4

Slide 4 text

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#で最 高のパフォーマンスを叩き出す技術を紹介します。

Slide 5

Slide 5 text

C#の現状

Slide 6

Slide 6 text

C#とは IDEフレンドリー静的型付け言語 IDE支援を強く念頭においた言語仕様により 入力補完やリアルタイムエラー検出の精度が高い 型のメリット ・リアルタイムのコンパイルエラー検出 ・入力補完 ・リファクタリング支援(名前一括置換など) ・型 == ドキュメント

Slide 7

Slide 7 text

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

Slide 8

Slide 8 text

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期) 毎年小刻みに機能追加 大胆な新機能は入らなく なったものの、地道に良く なっている&パフォーマン スに響く機能の追加が多い

Slide 9

Slide 9 text

.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のことしか考えて なかった期

Slide 10

Slide 10 text

Linuxと.NET サーバー実装言語としての高い性能 十分実用的でサーバーとしてLinuxを選ぶのがもはや普通 ベンチマーク結果で性能も証明(Plaintext1位, C#, .NET, Linux) C++やRustと比べても遜色のない性能 実践的なベンチマークという わけでもないので、この結果 が全てではないですが、ポテ ンシャルのベースラインは十 分にあるという証明

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

Memory

Slide 13

Slide 13 text

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リリース 当時の他の競合と比べて圧倒的な速度

Slide 14

Slide 14 text

最速のシリアライザについて考える 例えばvalue = int(999)をシリアライズ 理想的な最速コード: Unsafe.WriteUnaligned(ref MemoryMarshal.GetReference(dest), value); Span dest ldarg .0 ldarg .1 unaligned. 0x01 stobj !!T ret ようするにメモリコピー

Slide 15

Slide 15 text

最速のシリアライザについて考える 例えばvalue = int(999)をシリアライズ 理想的な最速コード: Unsafe.WriteUnaligned(ref MemoryMarshal.GetReference(dest), value); Span 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#の全体的なパフォーマンス向上に繋がっています。

Slide 16

Slide 16 text

既存のシリアライザの場合 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で書き込む

Slide 17

Slide 17 text

既存のシリアライザの場合 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 としてのバイナリ仕様の都合上、なにをどうやっても 「理想的な最速コード」よりも遅くなる……

Slide 18

Slide 18 text

MemoryPack Zero encoding extreme fast binary serializer https://github.com/Cysharp/MemoryPack/ 究極的に高速なシリアライザーを目指して2022-09にリリース JSONと比較すると数倍、最適な対象だと数百倍の性 能差。MessagePack for C#相手でも圧倒的に勝る。 • C#に最適化した バイナリ仕様 • C#11をフル活用 した最新の設計

Slide 19

Slide 19 text

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#として保証されている

Slide 20

Slide 20 text

IsReferenceOrContainsReferences シーケンシャルに並べたコンパクトな仕様 [MemoryPackable] public partial class Person { public long Id { get; set; } public int Age { get; set; } public string? Name { get; set; } } 参照型の場合はシーケンシャルに書き込ん でいく。単純な構造の中にバージョニング 耐性など、性能とバランスを取りながら仕 様を詰めた。

Slide 21

Slide 21 text

T[] where T : unmanaged C#の配列は要素がunmanaged型(参照型を含まない struct)の場合、全て直列に並ぶ new int[] { 1, 2, 3, 4, 5 } var srcLength = Unsafe.SizeOf() * value.Length; var allocSize = srcLength + 4; ref var dest = ref GetSpanReference(allocSize); ref var src = ref Unsafe.As(ref GetArrayDataReference(value)); Unsafe.WriteUnaligned(ref dest, value.Length); Unsafe.CopyBlockUnaligned(ref Unsafe.Add(ref dest, 4), ref src, (uint)srcLength); Advance(allocSize); Serialize == メモリコピー

Slide 22

Slide 22 text

T[] where T : unmanaged Vector3[]など複合型になればなるほど有利 Vector3(float x, float y, float z)[10000] 通常のシリアライザーは各フィールド毎に Write/Readするため、10000個だと10000*3 の処理が必要。MemoryPackはコピー1回。 そりゃ200倍高速なのも当然の話です……!

Slide 23

Slide 23 text

I/O Write

Slide 24

Slide 24 text

I/Oアプリケーション高速化の三箇条 アロケーションを抑える コピーを抑える 非同期I/Oを優先する // ダメな例 byte[] result = Serialize(value); response.Write(result); 都度byte[]のアロケーション なにかにWrite == 恐らくコピーの発生 同期Write

Slide 25

Slide 25 text

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

Slide 26

Slide 26 text

I/OといったらStreamだ……? async Task WriteToStreamAsync(Stream stream) { // Queue while (messages.TryDequeue(out var message)) { await stream.WriteAsync(message.Encode()); } }

Slide 27

Slide 27 text

Streamがダメな理由1 async Task WriteToStreamAsync(Stream stream) { // Queue while (messages.TryDequeue(out var message)) { await stream.WriteAsync(message.Encode()); } } 細かいI/Oの連発は、たとえ非同期I/Oであろうとも遅い! async/awaitは万能ではない!

Slide 28

Slide 28 text

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されない。

Slide 29

Slide 29 text

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()); } } }

Slide 30

Slide 30 text

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が溢れてな ければ)同期的な呼び出し。同期なのに非同期呼び出しは無駄。

Slide 31

Slide 31 text

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 buffer); Stream(やSocket)には歴史的事情により、同名で似たようなパラメー ターで、Taskを返すAPIとValueTaskを返すAPIがある。Taskを返すAPIを 使ってしまうと、無駄にTaskのアロケーションを発生させてしまう可能 性があるので、必ずValueTask呼び出しを使うこと。 幸いBufferedStreamの場合は同期の場合はTask.CompletedTaskを返すのでアロ ケーション自体は起こらない。とはいえawaitの呼び出しコストというのはあ る。それに関してはValueTaskであっても同様で、無駄は無駄。

Slide 32

Slide 32 text

async Task WriteToStreamAsync(Stream stream) { var buffer = ArrayPool.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.Shared.Return(buffer); } } そもそもmessage.Encode()でbyte[] 返してたんじゃないか疑惑。 EncodeToにするなら、大きめの バッファとってBufferedStreamの代 わりにすれば無駄なく…… (Sampleなのでバッファ溢れとかは起こらないということにします のでチェックとか拡大とかは省きます) 非同期Writeを一回だけにする

Slide 33

Slide 33 text

Stream is Bad 重要なのは同期バッファと非同期読み書き Streamの抽象化は同期と非同期が混同している(実際の挙 動が同期的(例えばBufferedStream)であっても、書き込み が起こる可能性(Bufferが溢れた場合)を考慮して、常に非 同期呼び出しを使わざるを得ない Streamの実体が不明なので、安全を持って、あるいは作 業用として各Streamは自身のバッファを頻繁に抱える (例えばGZipStreamはnewするだけで8K、Bufferedは4K、 MemoryStreamも細かく確保していくなど)

Slide 34

Slide 34 text

Stream is Dead Streamを避ける StreamがI/Oの第一級クラスだった時代は終わった File処理としてRandomAccess(Scatter Gather I/O API) 直接内部バッファを呼び出すためのIBufferWriter バッファーと流量制御のSystem.IO.Pipeline Streamを避けて処理するためのクラスが出現してきた Streamというオーバーヘッドを通さないことがハイパ フォーマンス処理の第一歩になる とはいえ、.NETの根っこにいるのでStreamを完全に避けるの は不可能。NetworkStreamやFileStreamを避けきるのは難し いし、ConsoleStreamやSslStreamには代替がない。最後の読 み書きまで触らないといった対応でなんとかしよう。

Slide 35

Slide 35 text

IBufferWriter 書き込み用同期バッファーの抽象化 public interface IBufferWriter { void Advance(int count); Memory GetMemory(int sizeHint = 0); Span GetSpan(int sizeHint = 0); } await SendAsync() Network buffer IBufferWriter requests slice Serializer write to slice Finally write buffer slice to network void Serialize(IBufferWriter writer, T value) 根本のバッファーを直接取得し書き込むこと で、アロケーションだけではなくバッファ間 のコピーもなくすことができる

Slide 36

Slide 36 text

MemoryPackSerializer.Serialize public static partial class MemoryPackSerializer { public static void Serialize(in TBufferWriter bufferWriter, in T? value) where TBufferWriter : IBufferWriter public static byte[] Serialize(in T? value) public static ValueTask SerializeAsync(Stream stream, T? value) } これが一番基本で、パフォーマンスを出せる

Slide 37

Slide 37 text

実例:MemoryPackのSerializeの流れ このMemoryPackWriterが大事!

Slide 38

Slide 38 text

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

Slide 39

Slide 39 text

public void WriteUnmanaged(scoped in T1 value1) where T1 : unmanaged { var size = Unsafe.SizeOf(); ref var spanRef = ref GetSpanReference(size); Unsafe.WriteUnaligned(ref spanRef, value1); Advance(size); } MemoryPackWriter 書き込み用バッファー管理 あるいはIBufferWriterのバッファーのキャッシュ public ref partial struct MemoryPackWriter where TBufferWriter : IBufferWriter { 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 { Span GetSpan(int sizeHint = 0); void Advance(int count); } IBuferWriterへの頻繁な GetSpan/Advanceの呼び出しは遅いため、 MemoryPackWriter内で余裕をもって確保して おき、BufferWriterへの呼び出し回数を抑える NOTE: IBufferWriterを実装する際に、GetSpanで返すバッファーのサ イズはsizeHintで切り詰めたものではなく、内部で持っているであ ろう実際のバッファーサイズそのものを返そう。切り詰めると GetSpanの呼び出しを頻繁にせざるを得なくなるため、パフォーマ ンスが低下する要因になる。

Slide 40

Slide 40 text

Writeを最適化する メソッド呼び出し回数の削減 少なければ少ないほどいい public ref partial struct MemoryPackWriter where TBufferWriter : IBufferWriter { ref TBufferWriter bufferWriter; ref byte bufferReference; int bufferLength; ref byte GetSpanReference(int sizeHint); void Advance(int count); public MemoryPackWriter(ref TBufferWriter writer) } 固定サイズのメンバーが連続している場合は呼び 出しを固めることで、GetSpanReference/Advance の呼び出し回数を抑える

Slide 41

Slide 41 text

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

Slide 42

Slide 42 text

他のオーバーロード public static partial class MemoryPackSerializer { public static void Serialize(in TBufferWriter bufferWriter, in T? value) public static byte[] Serialize(in T? value) public static ValueTask SerializeAsync(Stream stream, T? value) } ReusableLinkedArrayBufferWriterを 内部的に通してSerialize var bufferWriter = ReusableLinkedArrayBufferWriterPool.Rent(); var writer = new MemoryPackWriter(ref bufferWriter); writer.WriteValue(value); writer.Flush(); await bufferWriter.WriteToAndResetAsync(stream); return bufferWriter.ToArrayAndReset();

Slide 43

Slide 43 text

ReusableLinkedArrayBufferWriter byte[] byte[] byte[] ArrayPool.Shared.Rent GetSpan() 最後に連結した配列(又はStreamへの書き込み)が欲しいだけな ら、一塊のメモリ領域でなくても良いので、Listのような 拡大コピーではなくて、連結したチャンクで内部バッファを表 現する。これによりコピー回数を減らすことができる。 public sealed class ReusableLinkedArrayBufferWriter : IBufferWriter { List buffers; } struct BufferSegment { byte[] buffer; int written; } NOTE: バッファが足りなくなった場合、連結だからといって(ある いはLOHを気にして)固定サイズのものを連結させるのではなく、 2倍サイズのものを生成して(借りて)連結すること。そうでないと、 書き込み結果が大きい場合に連結リストの要素数が多くなりすぎて しまってパフォーマンスが悪化してしまう。

Slide 44

Slide 44 text

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.Shared.Return 最終サイズが分かっているので、最後の結果だけを newしてコピー、あるいはStreamにWriteする。終わっ た作業用配列は不要なのでPoolにReturnする。

Slide 45

Slide 45 text

Improve LINQ ToArray 連結といえばEnumerable.ToArray 要素数不定なIEnumerable を 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なら確定可能な場 合がある、などメソッドチェーンの状況に応じて細かい分岐がある。

Slide 46

Slide 46 text

with InlineArray(C# 12) Poolのアグレッシブな利用を避ける ランタイムに入れるものなのでPoolの多用は控えた ReusableなLinkedArrayが使えない代わりに C# 12のInlineArrayを採用 大雑把に言うとstackalloc T[]を可能にする(つまりT[][]) [InlineArray(29)] struct ArrayBlock { private T[] array; } List(的なもの)だと余計なallocateがあるので提案 しづらかった。スタック領域にT[][]を確保するので連 結リストそのもののアロケーションをなくせた。 ただしInlineArrayはコンパイル時指定の固定サイズしか許 されていない。そこでサイズとして「29」を採用した……。

Slide 47

Slide 47 text

29の根拠 4からスタートして2倍サイズを繰り返すと29で 最大値(.NETの配列のサイズはint.MaxValue、より も少しだけ小さい 2147483591)に到達する IEnumerableのToArrayは必ず1要素ずつ追加 されていくので、隙間が生じることなく、値が 全て埋まってから次の配列を連結することが保 証できるので、InlineArray(29)で足りなくなるこ とは絶対にない

Slide 48

Slide 48 text

I/O Read

Slide 49

Slide 49 text

No Stream Again 性能は同期バッファと非同期読み書きで決まる I/Oとデシリアライズを混ぜない 都度ReadAsyncを呼んでいるようでは遅すぎる MemoryPackSerializer.Deserialize(Stream)は、最初に ReadOnlySequenceを構築してからデシリアライズプロセスに流す public static partial class MemoryPackSerializer { public static T? Deserialize(ReadOnlySpan buffer) public static int Deserialize(in ReadOnlySequence buffer, ref T? value) public static async ValueTask DeserializeAsync(Stream stream) } NOTE: I/Oとデシリアライズを混ぜないということは、長さ不定、あるいは省 バッファな真のストリーミングデシリアライズができないということでもある。 MemoryPackでは、代わりにウィンドウ幅でバッファリングして IAsyncEnumerableを返すデシリアライズを補助機構として用意した。 読み込み済みの同期バッファのみをターゲットにする

Slide 50

Slide 50 text

ReadOnlySequence 連結されたT[]、のようなもの System.IO.Pipelinesと組み合わせてバッファ処理を任せることにより、自 由な位置でSliceできる連結されたT[]のように扱える ただしReadOnlySequenceのSliceは必ずしも高速ではないの で、Slice呼び出しの回数を低減する工夫が必要

Slide 51

Slide 51 text

Deserializeの流れ このMemoryPackReaderが大事!

Slide 52

Slide 52 text

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

Slide 53

Slide 53 text

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

Slide 54

Slide 54 text

Reader I/O in Application

Slide 55

Slide 55 text

効率的なReadは難しい 不完全な読み込みが発生することへの対処 常にEnd of Streamまで全読みが許されるわけではない while (true) { var read = await socket.ReceiveAsync(buffer); var span = buffer.AsSpan(read); // あとはこれをparseしてどうこうする } ここで読み込んだ量は1メッセージの ブロックに満たない可能性がある 再度ReceiveAsyncを読んでbufferに詰めると して、bufferを超えてしまう場合は? Resizeを繰り返すと無限に大きくなってしまうが、 0に戻せるタイミングが来ることを保証できるか?

Slide 56

Slide 56 text

ReadOnlySequenceを返すReaderを作る 不完全なブロックを連結する 1メッセージのサイズが分かるなら(プロトコルとして ヘッダにLength書いてある)少なくとも幾つのサイズ以 上の読み込み(ReadAtLeast)という命令に転換できる async Task ReadLoopAsync() { while (true) { ReadOnlySequence buffer = await socketReader.ReadAtLeastAsync(4); // do anything } } ReadOnlySequenceになっていれば、対応しているも のに流し込むことができる。例えば現代的なシリアライザー は基本的にReadOnlySequenceに対応している。 NOTE: ReadOnlySequenceに対応していないシリアライザーはレガシーなので 投げ捨てましょう。もちろんMessagePack for C#, MemoryPackは対応しています。 NOTE: この辺をよしなにやってくれるのがSystem.IO.Pipelinesです。

Slide 57

Slide 57 text

先頭にメッセージの種類があって、それを元に何かする といったプロトコルがあるとして、そのメッセージ種が 文字列の場合(テキストプロトコル、例えばRedisやNATS はテキストプロトコルを採用している)どう判定するか 種類を判別する async Task ReadLoopAsync() { while (true) { ReadOnlySequence buffer = await socketReader.ReadAtLeastAsync(4); var code = GetCode(buffer); if (code == ServerOpCodes.Msg) { //… } } } 文字列に変換することでシンプルに判定できる。 Enumにでも変換してあげると後続で使いやすい。 これはNATSの例ですが、記号やスペースも含めて4 文字に合わせるという工夫でReadAtLeastAsync(4)で 確実に判定できるよう工夫した。 ServerOpCodes GetCode(ReadOnlySequence 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() }; }

Slide 58

Slide 58 text

先頭にメッセージの種類があって、それを元に何かする といったプロトコルがあるとして、そのメッセージ種が 文字列の場合(テキストプロトコル、例えばRedisやNATS はテキストプロトコルを採用している)どう判定するか 種類を判別する async Task ReadLoopAsync() { while (true) { ReadOnlySequence buffer = await socketReader.ReadAtLeastAsync(4); var code = GetCode(buffer); if (code == ServerOpCodes.Msg) { //… } } } 文字列に変換することでシンプルに判定できる。 Enumにでも変換してあげると後続で使いやすい。 これはNATSの例ですが、記号やスペースも含めて4 文字に合わせるという工夫でReadAtLeastAsync(4)で 確実に判定できるよう工夫した。 ServerOpCodes GetCode(ReadOnlySequence 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() }; }

Slide 59

Slide 59 text

先頭にメッセージの種類があって、それを元に何かする といったプロトコルがあるとして、そのメッセージ種が 文字列の場合(テキストプロトコル、例えばRedisやNATS はテキストプロトコルを採用している)どう判定するか 種類を判別する async Task ReadLoopAsync() { while (true) { ReadOnlySequence buffer = await socketReader.ReadAtLeastAsync(4); var code = GetCode(buffer); if (code == ServerOpCodes.Msg) { //… } } } 文字列に変換することでシンプルに判定できる。 Enumにでも変換してあげると後続で使いやすい。 これはNATSの例ですが、記号やスペースも含めて4 文字に合わせるという工夫でReadAtLeastAsync(4)で 確実に判定できるよう工夫した。 ServerOpCodes GetCode(ReadOnlySequence 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化はアロケーション 絶対に絶対に避ける!!!

Slide 60

Slide 60 text

Take2 ReadOnlySpanで比較 async Task ReadLoopAsync() { while (true) { ReadOnlySequence buffer = await socketReader.ReadAtLeastAsync(4); var code = GetCode(buffer); if (code == ServerOpCodes.Msg) { //… } } } C# 11 UTF-8リテラル(u8)によって定数 的にReadOnlySpanを取得する。 マッチ頻度の高いものをif文の先頭に 持ってくればifチェックのコストも下 げられる。またReadOnlySpan のSequenceEqualは(LINQのものと違っ て)かなり高速に比較してくれる。 ServerOpCodes GetCode(ReadOnlySequence 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(); }

Slide 61

Slide 61 text

Take2 ReadOnlySpanで比較 async Task ReadLoopAsync() { while (true) { ReadOnlySequence buffer = await socketReader.ReadAtLeastAsync(4); var code = GetCode(buffer); if (code == ServerOpCodes.Msg) { //… } } } C# 11 UTF-8リテラル(u8)によって定数 的にReadOnlySpanを取得する。 マッチ頻度の高いものをif文の先頭に 持ってくればifチェックのコストも下 げられる。またReadOnlySpan のSequenceEqualは(LINQのものと違っ て)かなり高速に比較してくれる。 ServerOpCodes GetCode(ReadOnlySequence 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(); }

Slide 62

Slide 62 text

先頭4文字をint化して判定 // msg = ReadOnlySpan if (Unsafe.ReadUnaligned(ref MemoryMarshal.GetReference(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から直接int化 文字列化して比較は論外ですが、せっかく4バ イトでいけるのでint化した比較が一番速い NOTE: まぁバイナリプロトコルで先頭1バイトが種類を表してる、みたい なのが一番いいんですけどね……。テキストプロトコルよくない。

Slide 63

Slide 63 text

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

Slide 64

Slide 64 text

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

Slide 65

Slide 65 text

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

Slide 66

Slide 66 text

ホットパスのawaitインライン化 async Task ReadLoopAsync() { while (true) { ReadOnlySequence 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 buffer) { ループの9割がMsgの受信(ほかはPINGとかERRORとかめったに 来ない)のため、Msgだけインライン化して最高効率を狙う それ以外の場合はメソッドを分けるが、 .NET 6 からの PoolingAsyncValueTaskMethodBuilderとマークすることにより非同 期ステートマシンがプールされ再利用されるようになる

Slide 67

Slide 67 text

Optimize for All Types

Slide 68

Slide 68 text

Source Generator based [MemoryPackable]な個々の型に最適 化されたSerializeとDeserializeのコー ドをコンパイル時に自動生成する static abstract members はC#11から!

Slide 69

Slide 69 text

IL.Emit vs SourceGenerator IL.Emit 実行時に型情報を使って動的にAssemblyを生成する .NET初期から使えるIL黒魔術 動的生成が許されていない環境で使えない(iOSやWASM, NativeAOTなど) SourceGenerator コンパイル時にASTを使ってC#コードを生成してコンパイル .NET 6あたりから本格的に使われだしてきた 純粋なC#コードなのであらゆる環境で使える .NETの動作する環境が多様化したこと、スタートアップ速度のペナルティがな いことから、可能な限りSourceGeneratorに寄せていくのが望ましい 実行時の情報が使えないのは、特にGenerics周りで同様のコード生成が難しい こともありますが、工夫して乗り越えていきましょう……

Slide 70

Slide 70 text

全ての型に個別の最適化 例えばコレクションの処理はIEnumerableに対するFormatter一つ でほぼ賄えるが、コレクションそれぞれに最適な実装を一つ一つ 作ってあげると、最もパフォーマンスの出る処理を走らせることが できる。インターフェイスへの実装は既知ではない型に対する処理 を求められた時のみ。

Slide 71

Slide 71 text

高速に配列を列挙 C#の配列の要素へのアクセスは通常、境界値チェッ クが入る。しかしJITコンパイラが境界を超えないこと を感知できる場合(例えば.Lengthでforループを回した 時)例外的に境界値チェックが外れる 配列(あるいはSpan)のforeachはコンパイル時にIL レベルでforと同様に変換されるので、全く一緒

Slide 72

Slide 72 text

境界値チェック外し Cysharp/Ulid より 最初にSpanの末尾にアクセスし、 以降はそれ以下のインデックスで アクセスすることによりJITコンパ イラに安全だということを教える

Slide 73

Slide 73 text

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

Slide 74

Slide 74 text

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

Slide 75

Slide 75 text

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

Slide 76

Slide 76 text

String / UTF8 SIMD FFI(DllImport/LibraryImport) Channel More async/await

Slide 77

Slide 77 text

Conclusion

Slide 78

Slide 78 text

C#の可能性を切り開いていく 言語は進化する、技法も進化する C#には大きなポテンシャルがあり、そして競争の激しいプログラ ミング言語の分野で今も前線を走っている 強力なエコシステムを築く OSSが中心となる現代ではエコシステムの活況さが重要 言語/ランタイムの進化とOSSは両輪 MicrosoftやUnityにだけ委ねてればいい時代ではない Performance is a feature もちろん、ゲームそのものにも性能はとても大事 ぜひ、皆でC#のパワーを引き出していきましょう……!

Slide 79

Slide 79 text

No content