2を本日リリースしました。出る出る詐欺で、一年がかりでリリースまで漕ぎ着けました!とにかくめっちゃ時間かかった、死ぬほど私のリソースが取られていた、ので本当にリリースまで持ってこれてよかった......。Arnottさんが書いています。私はそれに対してひたすら、APIデザインが好きじゃないだの、パフォーマンスが私の基準に満たしてないだの、文句つけまくる仕事をしていました。コードのクオリティはさすがに非常に高くて、私だけだったらここには至れなかっただろうことを考えると、いい感じの共同開発ができたんじゃないかなあと思います。その結果として、一年前に掲示されたv2よりも、百億倍良くなってます。この一年間で磨きに磨き抜いたわけです。Studio内部で使われていたりと、もはや準標準バイナリシリアライザの地位を得ていたりします。ここまで成長するとは想像してなかった。.NETに貢献し過ぎで偉い。1はシリアライザのパフォーマンスの基準を大きく塗り替えて、新しい世代の水準を作り出したという偉業があったわけですが、今回のv2も大きな成果を出せると思っています。v2はI/Oパイプライン全体の最適化を見据えて、パイプラインの心臓部として正しく機能するためにはどうあるべきか、というのを指し示しました。今後のC#のアプリケーションのアーキテクチャは、ここで指し示した道に進んでいくことでしょう。v2においては破壊的変更多数なので、移行ガイドをmigration.mdにまとめてあるので、適当に読んでおくと良いでしょう、詳しい解説は特にしません。ライブラリ類は一斉にバージョン上げないと詰みます。Cysharpで作っているMagicOnionとMasterMemoryは作業中なので、来週にはドバッと上げておきます多分予定。C#の内部構造について、見るべきメソッドシグネチャは以下の2つです。入出力口に使うというのがポイントです。世代のインターフェイスです。etc...)、を呼び出して処理するフレームワーク、が呼び出すシリアライザ。いった流れになっています。byte[]変換)にいます。上の画像は一般的なやり取りの場合(あるいはv1の場合)で、RedisValue(StackExchange.Redis)がbyte[]を、ByteArrayContent(HttpClient)がbyte[]を、といったように、割と素のbyte[]を求められる局面は多い。byte[]した結果を返すことになり、それはフレームワークの処理を得て、入出力の源流へ再度コピーされ(されないこともある)ます。ここにおける無駄は、byte[]のアロケーションと、コピーです。3.0ではクラスライブラリ内でも多用されています)を使用し、フレームワークから提供されるStreamに書き込むことで、必要なコストをコピーだけにする手法があります。v1でも一部実装していました。Streamに対して直接Writeすることで自前バッファを使わないという手も理論上可能ですが、Writeによるオーバーヘッドが多いため性能が悪化します。どちらにせよStream内部でバッファを持っている場合もあります。非同期にも対応できませんし、では全てをWriteAsyncで処理すれば、更にオーバーヘッドが多くて全く性能がでません。性能の良いアプリケーションを作るには、バッファをどう扱うが大事です。v1の設計思想として、シリアライズの一単位をバッファとして取り扱えば良い、だから全てをbyte[]ベースで処理し、Streamへは一気に書き込めば良い。いう指針がありました。それは実際正しく機能して、当時存在したあらゆるシリアライザを引き離す性能を叩き出しました。IBufferWriter<byte>を活用すると、作業に必要なバッファを元ソースに対して直接要求することができます。それによりバッファ管理を完全に元ソースに任せることができるため、シリアライザ内部の作業バッファからのコピーコストが消滅します。Buffer)に直接書き込む、といったようなことが可能です。Streamに対してはSystem.IO.Pipleinesの提供するPipeWriterがIBufferWriter<byte>を実装し、最適なバッファ管理を代替してくれます。HttpResponse.BodyWriter)も提供されるようになりました。3.0の場合にはBodyWriterに対してシリアライズする実装を用意しています。現在の.NETのフレームワークは、Streamを要求するものか、あるいはbyte[]を要求するものがほとんどです。v2の真価が発揮されていくことでしょう。value))でも、最適なバッファ管理によってアロケーションやコピーコストを抑えるようになっています。理論とパフォーマンス多くある誤解として、async/awaitにしたら速くなるわけでもないし、Span<byte>にしたから速くなるわけでもありません。IBufferWriter<byte>やReadOnlySequence<byte>にしても速くなるわけではありません。理屈上コピーが減ったとしても、遅くなりえます。素朴に実装すればコピーしたほうが10倍速い、といった状況はありえます。buffer...というようなコードを書いた場合、どう見てもbyte[]ベースで素朴にやったほうが速そうです、というか速いです。ReadOnlySequence<byte>もそうで、内部は複雑な型のため、そのまま使ってSliceなどを多用すると、かなり遅くなります。よって、IBufferWriter<byte>によって得られたバッファを適切に管理する中間層、ReadOnlySequence<byte>によって得られたSegmentのバッファを適切に管理する中間層、の作り込みが必要になってきます。v2の開発にあたっては、byte[]ベースで極限まで性能を高めたv1があるので、どれだけv1と比較して遅くなっていないか、を基準に随時ベンチマークを取ることによって、中間層の存在による性能低下を検知し、極力性能低下を抑えることに成功しました。逆に言えば、純粋なシリアライザとしての性能はv1のほうが高速(な場合も多い/バッファ管理が賢くなったのでシリアライズ対象が大きい場合はv2が有利な場合もある)なのですが、パイプラインに組み込めることと、様々な工夫により、トータルでみるとv2のほうが実アプリケーションとしては有利になっています。v2による性能向上の一つに、v1では64K以上のシリアライズではプールを使わず新規アロケーションをしていましたが、v2ではArrayPoolから取得する32Kのチャンクの連結リストを内部バッファとして使用しています(外部からIBufferWriterを渡さず、v2内部のバッファプールを使用する場合)。byte[]を作る場合は、最後に連結して一塊に。Streamに書き込む場合は32K毎にWriteAsyncします。これによりバッファが溢れた場合に、List<T>のように二倍のサイズのバッファを新規に確保して書き込み、などせずに済んでいます。Heap(LOH)を消費する(ここに溜まるとGCの性能が極度に低下する)ことも避けられています。この内部形式を利用して32K単位でLZ4圧縮をかけることにより、圧縮するために、一度、全部が一塊になった大きな配列を確保することを避けています。実装上の工夫としては、MessagePackの拡張領域であるExtを使用することによって圧縮種別を判定可能にしていることと、Extは長さが必要なため、LZ4で圧縮されてサイズが縮むことを考えると事前に長さを計算することができない!ことを避けるために、Arrayを使用した上で、Arrayの最初の要素をExtにして判定用+LZ4のデシリアライズに必要な圧縮前の長さをここの部分に格納しています。これ、シリアライズもそうですが、デシリアライズ時もブロック単位で伸張できるので、大きなデータでも巨大配列を確保しないで済むという利点があります。v1までの圧縮モードはMessagePackCompression.Lz4Blockとして残していますが、v2ではMessagePackCompression.Lz4BlockArrayを使用することをお薦めしています。圧縮済みのバイナリデータに関しては、Lz4BlockArrayでもLz4Blockをデシリアライズすることが可能です(逆も同様)。128Kの大きさで確保されます。20Kを要求した場合は32Kが、65Kを要求した場合は128Kが得られるという図式です。LZ4圧縮した場合、全く圧縮できなかった場合、ワーストケースでは要求サイズよりもほんの少し「大きく」なります。そこでチャンクのサイズが64Kギリギリまで使用していて、LZ4圧縮をかけようとした場合は、圧縮後のサイズは圧縮完了まで不明のため、事前にワーストケースを想定し64.1K(仮)を要求し、結果として128Kが得られます。100)、使い切った場合はアロケートされるので、そういうケースでもLOH行きを避けるためのサイズになっています。Taskの提供と、Unityでは初心者フレンドリーなEditor拡張を追加しました。Mac/Linuxでも安定して動作する!というのが大きい!やっと大手を振って人にお薦めできるようになりました。まとめと、解説しましたが、実装の8割以上は前述のAArnottさんが行ったものなので、まずは本当にありがとうございます。そもそもv2のキッカケはプロトタイプ実装を掲示されて、Forkしていくパターンか一緒に実装するかのどちらかと言われて、(何ヶ月も返事を放置した末に)、一緒にやっていきましょうとしたのが元でした。その後も、一ヶ月質問を放置するとかメールスルーとか、そもそも開発の遅れは私のコミュニケーションによるものでは......、というようなところを粘り強く乗り越えてもらったお陰です。いや、そうはいっても私もかなりしっかりやってますよ!?特にAPIのデザインは紆余曲折あってもめにもめた末に一周回って私が最初に掲示したデザインになってるし、性能面では延々と私が地道にベンチマーク取って掲示することで腰を上げてもらったり(その時点で大体どこをどう変えればいいのかは分かってるので、指摘しながら)、Unity周りはそもそも興味ないみたいなので油断するとすぐ壊れるところを直していったりと、はい。これでパイプラインにおける心臓部分を手に入れることが出来たのですが、改めてまだそもそもパイプラインに血が流れてません。Coreだけでなく他のフレームワークやライブラリ郡(特にRedisと、ADO.NETはいい加減に10年前のレガシーモデルから卒業して新しい抽象層を提供して欲しい)も対応していかなければならないし、私の場合はMagicOnionが、トランスポート層に採用しているGoogleのgRPCがイマイチなせいでめちゃくちゃイマイチです。いうわけで、次回はMagicOnionのパイプライン化を最適化するために、通信層から手を入れる予定です。シリアライザはMessagePackだけではなく、JSONも重要なので、改めてUtf8Jsonの改修も行いたいと思っています。3で華々しくデビューしたMicrosoft公式実装のSystem.Text.JsonによるJsonSerializerの性能が極めて悪いので......。残念ながらMicrosoftは柔軟かつ性能の出るシリアライザの作り方を全く分かっていないのでしょう。このパイプラインはサーバーの入口→出口だけで閉じるものではなく、ネットワークを超えてクライアント側(Unity)にまで届くものだと考えています。サーバー/クライアントを大きなパイプラインに見立てて、見えるところ通るところ全てを最適化することが「C#大統一理論」であり、真に強力なのだ。いうことを実証していくのが当座の目標で、やっと最初の一歩が踏めました。C#凄いな、と心の底から世界中の人が思ってもらうためにも(そしてあわよくば採用してもらう!)、まだ足りてないものは山のようにあるので、どんどん潰していきましょう。ちなみに、byte配列で受け取るとメモリアロケーションが発生し、速度性の観点から見るとコストになります(参考:開発者さんのブログ記事)。C#にはそれを抑えるための仕組みがあるようですが、まだ使い方が分かっていません。分かったら記事にします。