MessagePack for C# v2によるC#における最新のI/Oパイプライン最適化

MessagePack for C#のVersion 2を本日リリースしました。出る出る詐欺で、一年がかりでリリースまで漕ぎ着けました!とにかくめっちゃ時間かかった、死ぬほど私のリソースが取られていた、ので本当にリリースまで持ってこれてよかった……。めでたし。

今回はとてもOSSっぽく開発していて、メインの開発はMicrosoftのVisual StudioチームのPrincipal Software EngineerであるAndrew Arnottさんが書いています。私はそれに対してひたすら、APIデザインが好きじゃないだの、パフォーマンスが私の基準に満たしてないだの、文句つけまくる仕事をしていました。しかしコードのクオリティはさすがに非常に高くて、私だけだったらここには至れなかっただろうことを考えると、いい感じの共同開発ができたんじゃないかなあと思います。その結果として、一年前に掲示されたv2よりも、百億倍良くなってます。この一年間で磨きに磨き抜いたわけです。

最初のリリースから2年半が経ち、MessagePack for C#は今では ASP.NET のリポジトリに含まれていたり、Visual Studio内部で使われていたりと、もはや準標準バイナリシリアライザの地位を得ていたりします。さすがにここまで成長するとは想像してなかった。.NETに貢献し過ぎで偉い。Version 1はシリアライザのパフォーマンスの基準を大きく塗り替えて、新しい世代の水準を作り出したという偉業があったわけですが、今回のv2も大きな成果を出せると思っています。v2はI/Oパイプライン全体の最適化を見据えて、パイプラインの心臓部として正しく機能するためにはどうあるべきか、というのを指し示しました。今後のC#のアプリケーションのアーキテクチャは、ここで指し示した道に進んでいくことでしょう。

v1 -> v2においては破壊的変更多数なので、移行ガイドをmigration.mdにまとめてあるので、適当に読んでおくと良いでしょう、詳しい解説は特にしません。ライブラリ類は一斉にバージョン上げないと詰みます。Cysharpで作っているMagicOnionMasterMemoryは作業中なので、来週にはドバッと上げておきます多分予定。

パイプラインによるゼロコピー

MessagePack for C#の内部構造について、見るべきメソッドシグネチャは以下の2つです。

public static void Serialize<T>(IBufferWriter<byte> writer, T value, MessagePackSerializerOptions options = null, CancellationToken cancellationToken = default)
public static T Deserialize<T>(in ReadOnlySequence<byte> byteSequence, MessagePackSerializerOptions options = null, CancellationToken cancellationToken = default)

シリアライズにおいては IBufferWriter<byte>, デシリアライズにおいては ReadOnlySequence<byte> を入出力口に使うというのがポイントです。 どちらもSystem.Buffersに定義されている .NET Standard 2.1 世代のインターフェイスです。反面、v1ではともに byte[] ベースでした。

I/Oのパイプラインってなんぞや、というと、ようするに入出力へのbyte[]をどう扱うかということであり、C#的には入り口も出口も最終的にはネイティブがやり取りするので、その手前のもの(SocketAsyncEventArgs, ConslePal+Stream, FileStream, etc...)、を呼び出して処理するフレームワーク、が呼び出すシリアライザ。といった流れになっています。シリアライザは常に中心(Object -> byte[]変換)にいます。上の画像は一般的なやり取りの場合(あるいはv1の場合)で、RedisValue(StackExchange.Redis)がbyte[]を、ByteArrayContent(HttpClient)がbyte[]を、といったように、割と素のbyte[]を求められる局面は多い。その場合、シリアライザはnew byte[]した結果を返すことになり、それはフレームワークの処理を得て、入出力の源流へ再度コピーされ(されないこともある)ます。

ここにおける無駄は、byte[]のアロケーションと、コピーです。

と、いうわけで、byte[]のアロケーションを避けるパターンとして、シリアライザは作業領域として外部のバッファープール( System.Buffers で定義されたArrayPool<byte>は .NET Core 3.0ではクラスライブラリ内でも多用されています)を使用し、フレームワークから提供されるStreamに書き込むことで、必要なコストをコピーだけにする手法があります。v1でも一部実装していました。なお、Streamに対して直接Writeすることで自前バッファを使わないという手も理論上可能ですが、Writeによるオーバーヘッドが多いため性能が悪化します。また、どちらにせよStream内部でバッファを持っている場合もあります。さらに、非同期にも対応できませんし、では全てをWriteAsyncで処理すれば、更にオーバーヘッドが多くて全く性能がでません。つまり、性能の良いアプリケーションを作るには、バッファをどう扱うが大事です。v1の設計思想として、シリアライズの一単位をバッファとして取り扱えば良い、だから全てをbyte[]ベースで処理し、Streamへは一気に書き込めば良い。という指針がありました。そして、それは実際正しく機能して、当時存在したあらゆるシリアライザを引き離す性能を叩き出しました。

IBufferWriter<byte>を活用すると、作業に必要なバッファを元ソースに対して直接要求することができます。それによりバッファ管理を完全に元ソースに任せることができるため、シリアライザ内部の作業バッファからのコピーコストが消滅します。例えばソケット通信で使われるSocketAsyncEventArgsは通常使いまわされますが、それの持つ(byte[] Buffer)に直接書き込む、といったようなことが可能です。

Streamに対してはSystem.IO.Pipleinesの提供するPipeWriterIBufferWriter<byte>を実装し、最適なバッファ管理を代替してくれます。

ASP.NET Core 3.0からは従来の(Stream HttpResponse.Body)だけでなく、(PipeWriter HttpResponse.BodyWriter)も提供されるようになりました。MessagePack.AspNetCoreMvcFormatterは、.NETCoreApp 3.0の場合にはBodyWriterに対してシリアライズする実装を用意しています。

現在の.NETのフレームワークは、Streamを要求するものか、あるいはbyte[]を要求するものがほとんどです。しかし、フレームワークレベルでのIBufferWriter対応が進んでいけば、よりMessagePack for C# v2の真価が発揮されていくことでしょう。もちろん、byte[]を返すAPI(byte[] Serialize<T>(T value))でも、最適なバッファ管理によってアロケーションやコピーコストを抑えるようになっています。

理論とパフォーマンス

多くある誤解として、async/awaitにしたら速くなるわけでもないし、Span<byte>にしたから速くなるわけでもありません。そして、IBufferWriter<byte>ReadOnlySequence<byte>にしても速くなるわけではありません。理屈上コピーが減ったとしても、遅くなりえます。素朴に実装すればコピーしたほうが10倍速い、といった状況はありえます。

例えば [10, 100, 100] をシリアライズしたいと思ったとして、intが最大5バイト必要だとして、都度writter.GetSpanで取得した場合と、byte[]でどばっと取得した場合を比較すると……

// IBufferWriter<byte>
foreach(var v in values)
{
    var buffer = writer.GetSpan(5);
    var length = WriteInt(buffer, v); // WriteInt returns write length
    writer.Advance(length); 
}

// byte[]
var buffer = ArrayPool<byte>.Shared.Rent(64K).AsSpan();
var offset = 0;
foreach(var v in values)
{
    var length = WriteInt(buffer.Slice(index), v);
    offset += length;
}
// Return buffer...

というようなコードを書いた場合、どう見てもbyte[]ベースで素朴にやったほうが速そうです、というか速いです。ReadOnlySequence<byte>もそうで、内部は複雑な型のため、そのまま使ってSliceなどを多用すると、かなり遅くなります。よって、IBufferWriter<byte>によって得られたバッファを適切に管理する中間層、ReadOnlySequence<byte>によって得られたSegmentのバッファを適切に管理する中間層、の作り込みが必要になってきます。

v2の開発にあたっては、byte[]ベースで極限まで性能を高めたv1があるので、どれだけv1と比較して遅くなっていないか、を基準に随時ベンチマークを取ることによって、中間層の存在による性能低下を検知し、極力性能低下を抑えることに成功しました。

逆に言えば、純粋なシリアライザとしての性能はv1のほうが高速(な場合も多い/バッファ管理が賢くなったのでシリアライズ対象が大きい場合はv2が有利な場合もある)なのですが、パイプラインに組み込めることと、様々な工夫により、トータルでみるとv2のほうが実アプリケーションとしては有利になっています。

配列上のLZ4圧縮

v1 -> v2による性能向上の一つに、v1では64K以上のシリアライズではプールを使わず新規アロケーションをしていましたが、v2ではArrayPoolから取得する32Kのチャンクの連結リストを内部バッファとして使用しています(外部からIBufferWriterを渡さず、v2内部のバッファプールを使用する場合)。

image

byte[]を作る場合は、最後に連結して一塊に。Streamに書き込む場合は32K毎にWriteAsyncします。これによりバッファが溢れた場合に、List<T>のように二倍のサイズのバッファを新規に確保して書き込み、などせずに済んでいます。また、常に使用するバッファの大きさが85K以下で済むため、悪名高いLarge Object Heap(LOH)を消費する(ここに溜まるとGCの性能が極度に低下する)ことも避けられています。

そしてv2から新規搭載された新しい圧縮モードである MessagePackCompression.Lz4BlockArray では、この内部形式を利用して32K単位でLZ4圧縮をかけることにより、圧縮するために、一度、全部が一塊になった大きな配列を確保することを避けています。

image

実装上の工夫としては、MessagePackの拡張領域であるExtを使用することによって圧縮種別を判定可能にしていることと、Extは長さが必要なため、LZ4で圧縮されてサイズが縮むことを考えると事前に長さを計算することができない!ことを避けるために、Arrayを使用した上で、Arrayの最初の要素をExtにして判定用+LZ4のデシリアライズに必要な圧縮前の長さをここの部分に格納しています。これ、シリアライズもそうですが、デシリアライズ時もブロック単位で伸張できるので、大きなデータでも巨大配列を確保しないで済むという利点があります。

v1までの圧縮モードはMessagePackCompression.Lz4Blockとして残していますが、v2ではMessagePackCompression.Lz4BlockArrayを使用することをお薦めしています。既に圧縮済みのバイナリデータに関しては、Lz4BlockArrayでもLz4Blockをデシリアライズすることが可能です(逆も同様)。

ちなみにこの32Kというサイズを選んだのには、ちゃんと意味があります!まず、ArrayPoolの仕様で16K, 32K, 64K, 128Kの大きさで確保されます。20Kを要求した場合は32Kが、65Kを要求した場合は128Kが得られるという図式です。

そしてLZ4圧縮した場合、全く圧縮できなかった場合、ワーストケースでは要求サイズよりもほんの少し「大きく」なります。さて、そこでチャンクのサイズが64Kギリギリまで使用していて、LZ4圧縮をかけようとした場合は、圧縮後のサイズは圧縮完了まで不明のため、事前にワーストケースを想定し64.1K(仮)を要求し、結果として128Kが得られます。つまり、LOH行きです。厳密にはArrayPoolを使用しているため使い回されるので大丈夫ですが、プールサイズには上限を設けているので(全体で共有で32K * 100)、使い切った場合はアロケートされるので、そういうケースでもLOH行きを避けるためのサイズになっています。

真のコードジェネレーター

悪名高いド不安定なコードジェネレーターは大きく改善され、真の安定性と、ディレクトリ単位での指定と、ファイル単位での出力と、CIで使いやすい .NET Core (Local/Global) Toolsでのインストールと、XamarinやUWPで便利なMSBuild Taskの提供と、Unityでは初心者フレンドリーなEditor拡張を追加しました。

とにかくMac/Linuxでも安定して動作する!というのが大きい!やっと大手を振って人にお薦めできるようになりました。

まとめ

と、解説しましたが、実装の8割以上は前述のAArnottさんが行ったものなので、まずは本当にありがとうございます。そもそもv2のキッカケはプロトタイプ実装を掲示されて、Forkしていくパターンか一緒に実装するかのどちらかと言われて、(何ヶ月も返事を放置した末に)、一緒にやっていきましょうとしたのが元でした。その後も、一ヶ月質問を放置するとかメールスルーとか、そもそも開発の遅れは私のコミュニケーションによるものでは……、というようなところを粘り強く乗り越えてもらったお陰です。

いや、そうはいっても私もかなりしっかりやってますよ!?特にAPIのデザインは紆余曲折あってもめにもめた末に一周回って私が最初に掲示したデザインになってるし、性能面では延々と私が地道にベンチマーク取って掲示することで腰を上げてもらったり(その時点で大体どこをどう変えればいいのかは分かってるので、指摘しながら)、Unity周りはそもそも興味ないみたいなので油断するとすぐ壊れるところを直していったりと、はい。

これでパイプラインにおける心臓部分を手に入れることが出来たのですが、改めてまだそもそもパイプラインに血が流れてません。ASP.NET Coreだけでなく他のフレームワークやライブラリ郡(特にRedisと、ADO.NETはいい加減に10年前のレガシーモデルから卒業して新しい抽象層を提供して欲しい)も対応していかなければならないし、私の場合はMagicOnionが、トランスポート層に採用しているGoogleのgRPCがイマイチなせいでめちゃくちゃイマイチです。

というわけで、次回はMagicOnionのパイプライン化を最適化するために、通信層から手を入れる予定です。また、シリアライザはMessagePackだけではなく、JSONも重要なので、改めてUtf8Jsonの改修も行いたいと思っています。.NET Core 3で華々しくデビューしたMicrosoft公式実装のSystem.Text.JsonによるJsonSerializerの性能が極めて悪いので……。残念ながらMicrosoftは柔軟かつ性能の出るシリアライザの作り方を全く分かっていないのでしょう。

また、このパイプラインはサーバーの入口→出口だけで閉じるものではなく、ネットワークを超えてクライアント側(Unity)にまで届くものだと考えています。サーバー/クライアントを大きなパイプラインに見立てて、見えるところ通るところ全てを最適化することが「C#大統一理論」であり、真に強力なのだ。ということを実証していくのが当座の目標で、やっと最初の一歩が踏めました。C#凄いな、と心の底から世界中の人が思ってもらうためにも(そしてあわよくば採用してもらう!)、まだ足りてないものは山のようにあるので、どんどん潰していきましょう。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(C#)
April 2011
|
July 2024

Twitter:@neuecc GitHub:neuecc

Archive