RSS

gRPC-Go パフォーマンス改善

過去数ヶ月間、gRPC-Goのパフォーマンス改善に取り組んできました。これには、ネットワーク利用率の向上、CPU使用率とメモリ割り当ての最適化が含まれます。最近の取り組みのほとんどは、gRPC-Goのフロー制御の刷新に焦点を当ててきました。いくつかの最適化と新機能により、特に高レイテンシネットワークにおいて、大幅な改善を達成することができました。高レイテンシネットワークと大きなメッセージを扱うユーザーは、桁違いのパフォーマンス向上が見込まれます。ベンチマーク結果は最後に示します。

このブログでは、パフォーマンス改善のためにこれまでに行ってきた作業(時系列順)をまとめ、近い将来の計画を示します。

最近実装された最適化

大きなメッセージ受信時のストリームウィンドウ拡張

コードリンク

これは、大きなメッセージのパフォーマンスを向上させるためにgRPC-Cで使用されている最適化です。アプリケーションが受信側でアクティブな読み込みを行っている場合、ストリームレベルのフロー制御を効果的にバイパスして、メッセージ全体を要求できるという考え方です。これは大きなメッセージに非常に役立ちます。アプリケーションはすでに読み込みを行うことをコミットしており、十分なメモリを割り当てているため、ウィンドウが少なくなったときにウィンドウ更新を送信するのではなく、プロアクティブな大きなウィンドウ更新(必要に応じて)を送信してメッセージ全体を取得するのが理にかなっています。

この最適化だけでも、高レイテンシネットワークでの大きなメッセージにおいて10倍の改善が見られました。

アプリケーションの読み込みと接続フロー制御の分離

コードリンク

gRPC-JavaおよびgRPC-Cコアチームとのいくつかの議論の後、gRPC-Goの接続レベルのフロー制御は、接続上のウィンドウ更新がアプリケーションがそこからデータを読み取ったかどうかに依存するという点で、過度に制限的であることがわかりました。ストリームレベルのフロー制御はアプリケーションの読み込みに依存していても構いませんが、接続レベルのフロー制御についてはそうではありません。その理由は以下の通りです。1つの接続は複数のストリーム(RPC)によって共有されます。もし、1つ以上のストリームが遅く読み込むか、まったく読み込まない場合、その接続上の他のストリームのパフォーマンスが低下したり、完全に停止したりします。これは、その遅いまたはアクティブでないストリームがデータを読み取るまで、接続上のウィンドウ更新を送信しないためです。したがって、接続のフロー制御をアプリケーションの読み込みから分離することが理にかなっています。

ただし、これには少なくとも2つの疑問が生じます。

  1. クライアントは、1つがなくなったときに新しいストリームを作成することで、サーバーに好きなだけデータを送信できるのではないでしょうか?

  2. ストリームレベルのフロー制御で十分なのに、なぜ接続レベルのフロー制御が必要なのですか?

最初の質問への答えは短くて単純です。「いいえ」。サーバーは、同時に処理したいストリームの数を制限するオプションを持っています。したがって、一見問題のように思えるかもしれませんが、実際にはそうではありません。

接続レベルのフロー制御の必要性

ストリームレベルのフロー制御は、送信者がデータを送りすぎるのを抑制するのに十分であることは事実です。しかし、接続レベルのフロー制御がない(または無制限の接続レベルウィンドウを使用する)と、ストリームで問題が発生した場合に、新しいストリームを開くと、すべてが速くなるように見えます。これはストリームの数が制限されているため、そう遠くまで行くことはできません。しかし、ネットワークの帯域幅遅延積(BDP)に設定された接続レベルのフロー制御ウィンドウは、ネットワークから現実的に絞り出せるパフォーマンスの上限を設定します。

ウィンドウ更新のバンドル

コードリンク

ウィンドウ更新自体の送信にはコストがかかります。フラッシュ操作が必要であり、これによりシステムコールが発生します。システムコールはブロッキングであり、遅いです。したがって、ストリームレベルのウィンドウ更新を送信する際には、同じフラッシュシステムコールを使用して接続レベルのウィンドウ更新を送信できるかどうかを確認することも理にかなっています。

BDP推定と動的フロー制御ウィンドウ

コードリンク

この機能は最新のものであり、ある意味で最も期待されていた最適化機能であり、高レイテンシネットワークにおけるgRPCとHTTP/1.1のパフォーマンスの最終的なギャップを埋めるのに役立ちました。

帯域幅遅延積(BDP)は、ネットワーク接続の帯域幅にその往復遅延を掛けたものです。これは、完全な利用率が達成された場合に、特定の瞬間に「ワイヤー上」にあることができるバイト数を効果的に示します。

BDPを計算し、それに応じて適応するためのアルゴリズムは、最初に@ejonaによって提案され、その後gRPC-CコアとgRPC-Javaの両方によって実装されました(Javaではまだ有効になっていません)。そのアイデアはシンプルで強力です。受信側がデータフレームを受信するたびに、BDPピン(BDP推定器のみが使用するユニークなデータを持つピン)を送信します。その後、受信側は、そのピンのackを受信するまで、受信したバイト数(BDPピンをトリガーしたバイト数を含む)をカウントし始めます。約1.5 RTT(Round-Trip Time)の間に受信された全バイトの合計は、実効BDP * 1.5の近似値です。これが現在のウィンドウサイズに近い場合(たとえば、ウィンドウサイズの3分の2以上)、ウィンドウサイズを増やす必要があります。サンプリングしたBDPの2倍(受信バイトの合計)をストリーミングおよび接続の両方のウィンドウサイズに設定します。

このアルゴリズム自体は、BDP推定値を無限に増加させる可能性があります。ウィンドウの増加は、より多くのバイトをサンプリングすることになり、それがさらにウィンドウを増加させることになります。この現象はバッファブロートと呼ばれ、gRPC-CコアとgRPC-Javaの以前の実装で発見されました。これに対する解決策は、各サンプルごとに帯域幅を計算し、それがこれまで記録された最大帯域幅よりも大きいかどうかを確認することです。もしそうであれば、ウィンドウサイズを増やします。帯域幅は、サンプルをRTT * 1.5で割ることで計算できます(サンプルが1.5往復分であったことを思い出してください)。サンプリングされたバイト数の増加に伴って帯域幅が増加しない場合、この変化はウィンドウサイズの増加によるものであり、ネットワーク自体の性質を実際には反映していないことを示しています。

大陸間でVM上で実験を実行している間に、時折、都合の悪いタイミングで、異常に速いping-ackが発生し、ウィンドウサイズが増加することがわかりました。これは、そのようなping-ackがRTTの低下を認識させ、高い帯域幅値を計算させるためです。もし、そのバイトのサンプルがウィンドウの3分の2よりも大きかった場合、ウィンドウサイズを増やします。しかし、このping-ackは異常であり、ネットワークRTTの認識を全体的に変えるべきではありませんでした。したがって、ネットワークは時間とともに変化する可能性があるため、最近のRTTをより重視し、過去のRTTをあまり重視しないように、定数で重み付けされたRTTの実行平均を保持します。

実装中に、ウィンドウサイズをサンプルサイズから計算する際の乗数など、いくつかのチューニングパラメータを実験し、成長と正確さのバランスが取れた最適な設定を選択しました。

ほとんどの場合4MBに上限が設定されているTCPのフロー制御に常に縛られていることを考慮して、ウィンドウサイズの成長を同じ数(4MB)に制限します。

BDP推定と動的にウィンドウサイズを調整する機能はデフォルトで有効になっており、接続および/またはストリームウィンドウサイズに手動で値を設定することで無効にできます。

近い将来の取り組み

現在、CPU利用率を改善することでスループットを向上させることに取り組んでいます。以下の取り組みはその一環です。

フラッシュシステムコールの削減

トランスポートレイヤーに、同じゴルーチンがさらにデータを送信する場合でも、書き込みごとにフラッシュシステムコールを発生させてしまうバグがあることに気づきました。これらの書き込みの多くをバッチ処理して、1回のフラッシュのみを使用できます。実際、これはコード自体に大きな変更ではありません。

不要なフラッシュをなくすための取り組みの一環として、最近、単項およびサーバーストリーミングRPCのヘッダーとデータの書き込みをクライアント側で1回のフラッシュに統合しました。コードへのリンクはこちらです:コード

ユーザーの@petermatticによってこのPRで提案された、もう1つの関連アイデアは、単項RPCへのサーバー応答を1回のフラッシュにまとめることです。現在、それについても検討中です。

メモリ割り当ての削減

ワイヤから読み取られた各データフレームに対して、新しいメモリ割り当てが行われます。gRPCレイヤーでも、解凍およびデコードのための新しいメッセージごとに同様です。これらの割り当ては過剰なガベージコレクションサイクルを引き起こし、コストがかかります。メモリバッファを再利用することで、このGC圧力を軽減できます。現在、そのためのアプローチをプロトタイピングしています。リクエストはさまざまなサイズのバッファを必要とするため、1つのアプローチは、固定サイズ(2のべき乗)の個別のメモリプールを維持することです。したがって、ワイヤからxバイトを読み取る際に、xより大きい最も近い2のべき乗を見つけ、キャッシュからバッファを再利用するか、必要に応じて新しいバッファを割り当てることができます。golangのsync.Poolを使用するため、ガベージコレクションを気にする必要はありません。ただし、これにコミットする前に十分なテストを実行する必要があります。

結果

  • 実際のネットワークでのベンチマーク

    • サーバーとクライアントは、異なる大陸の2つのVMで起動されました。RTTは約152msです。
    • クライアントはペイロードを持つRPCを実行し、サーバーは空のメッセージで応答しました。
    • 各RPCにかかった時間が測定されました。
    • コードリンク
メッセージサイズgRPCHTTP 1.1
1 KB約152 ms約152 ms
10 KB約152 ms約152 ms
10 KB約152 ms約152 ms
1 MB約152 ms約152 ms
10 MB約622 ms約630 ms
100 MB約5秒約5秒
  • シミュレートされたネットワークでのベンチマーク
    • サーバーとクライアントは同じマシンで起動され、異なるネットワークレイテンシがシミュレートされました。
    • クライアントは1MBのペイロードを持つRPCを実行し、サーバーは空のメッセージで応答しました。
    • 各RPCにかかった時間が測定されました。
    • 以下の表は、最初の10回のRPCにかかった時間を示しています。
    • コードリンク
レイテンシゼロのネットワーク
gRPCHTTP 2.0HTTP 1.1
5.097809ms16.107461ms18.298959ms
4.46083ms4.301808ms7.715456ms
5.081421ms4.076645ms8.118601ms
4.338013ms4.232606ms6.621028ms
5.013544ms4.693488ms5.83375ms
3.963463ms4.558047ms5.571579ms
3.509808ms4.855556ms4.966938ms
4.864618ms4.324159ms6.576279ms
3.545933ms4.61375ms6.105608ms
3.481094ms4.621215ms7.001607ms
RTT 16msのネットワーク
gRPCHTTP 2.0HTTP 1.1
118.837625ms84.453913ms58.858109ms
36.801006ms22.476308ms20.877585ms
35.008349ms21.206222ms19.793881ms
21.153461ms20.940937ms22.18179ms
20.640364ms21.888247ms21.4666ms
21.410346ms21.186008ms20.925514ms
19.755766ms21.818027ms20.553768ms
20.388882ms21.366796ms21.460029ms
20.623342ms20.681414ms20.586908ms
20.452023ms20.781208ms20.278481ms
RTT 64msのネットワーク
GRPCHTTP 2.0HTTP 1.1
455.072669ms275.290241ms208.826314ms
195.43357ms70.386788ms70.042513ms
132.215978ms70.01131ms71.19429ms
69.239273ms70.032237ms69.479335ms
68.669903ms70.192272ms70.858937ms
70.458108ms69.395154ms71.161921ms
68.488057ms69.252731ms71.374758ms
68.816031ms69.628744ms70.141381ms
69.170105ms68.935813ms70.685521ms
68.831608ms69.728349ms69.45605ms