$30 off During Our Annual Pro Sale. View Details »

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. モダンハイパフォーマンスC#
    2023 Edition
    CEDEC 2023
    2023-08-23 Yoshifumi Kawai / Cysharp, Inc.

    View Slide

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

    View Slide

  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.

    View Slide

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

    View Slide

  5. C#の現状

    View Slide

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

    View Slide

  7. 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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  11. 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

    View Slide

  12. Memory

    View Slide

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

    View Slide

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

    View Slide

  15. 最速のシリアライザについて考える
    例えば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#の全体的なパフォーマンス向上に繋がっています。

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  21. 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 == メモリコピー

    View Slide

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

    View Slide

  23. I/O Write

    View Slide

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

    View Slide

  25. 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)
    これで三箇条を実践できましたか……?

    View Slide

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

    View Slide

  27. 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は万能ではない!

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  31. 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であっても同様で、無駄は無駄。

    View Slide

  32. 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を一回だけにする

    View Slide

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

    View Slide

  34. 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には代替がない。最後の読
    み書きまで触らないといった対応でなんとかしよう。

    View Slide

  35. 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)
    根本のバッファーを直接取得し書き込むこと
    で、アロケーションだけではなくバッファ間
    のコピーもなくすことができる

    View Slide

  36. 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)
    }
    これが一番基本で、パフォーマンスを出せる

    View Slide

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

    View Slide

  38. 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を受
    け取っておく

    View Slide

  39. 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の呼び出しを頻繁にせざるを得なくなるため、パフォーマ
    ンスが低下する要因になる。

    View Slide

  40. 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
    の呼び出し回数を抑える

    View Slide

  41. 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();

    View Slide

  42. 他のオーバーロード
    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();

    View Slide

  43. 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倍サイズのものを生成して(借りて)連結すること。そうでないと、
    書き込み結果が大きい場合に連結リストの要素数が多くなりすぎて
    しまってパフォーマンスが悪化してしまう。

    View Slide

  44. 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する。

    View Slide

  45. 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なら確定可能な場
    合がある、などメソッドチェーンの状況に応じて細かい分岐がある。

    View Slide

  46. 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」を採用した……。

    View Slide

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

    View Slide

  48. I/O Read

    View Slide

  49. 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を返すデシリアライズを補助機構として用意した。
    読み込み済みの同期バッファのみをターゲットにする

    View Slide

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

    View Slide

  51. Deserializeの流れ
    このMemoryPackReaderが大事!

    View Slide

  52. 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で前に進める

    View Slide

  53. 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では行っている。

    View Slide

  54. Reader I/O in Application

    View Slide

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

    View Slide

  56. 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です。

    View Slide

  57. 先頭にメッセージの種類があって、それを元に何かする
    といったプロトコルがあるとして、そのメッセージ種が
    文字列の場合(テキストプロトコル、例えば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()
    };
    }

    View Slide

  58. 先頭にメッセージの種類があって、それを元に何かする
    といったプロトコルがあるとして、そのメッセージ種が
    文字列の場合(テキストプロトコル、例えば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()
    };
    }

    View Slide

  59. 先頭にメッセージの種類があって、それを元に何かする
    といったプロトコルがあるとして、そのメッセージ種が
    文字列の場合(テキストプロトコル、例えば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化はアロケーション
    絶対に絶対に避ける!!!

    View Slide

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

    View Slide

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

    View Slide

  62. 先頭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バイトが種類を表してる、みたい
    なのが一番いいんですけどね……。テキストプロトコルよくない。

    View Slide

  63. 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をデシリアライズして
    コールバックなど)をする

    View Slide

  64. 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をデシリアライズして
    コールバックなど)をする

    View Slide

  65. 非同期ステートマシン生成に注意
    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しても非同期ステートマシン生成はない
    という工夫が可能

    View Slide

  66. ホットパスの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とマークすることにより非同
    期ステートマシンがプールされ再利用されるようになる

    View Slide

  67. Optimize for All Types

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  73. 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

    View Slide

  74. 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を避けることができる。

    View Slide

  75. 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でもメモリコピーでデシ
    リアライズする処理が可能になった。

    View Slide

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

    View Slide

  77. Conclusion

    View Slide

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

    View Slide

  79. View Slide