衝撃的なほどシンプル: Momentoの.NETキャッシュクライアントのチューニング

.NETのgRPCクライアントを最適化した方法をご覧ください。

この投稿は、Momentoが開発者の体験をどのように考えているかを詳しく説明するシリーズの4番目のエントリーです。Momentoのサーバーレスキャッシュを使い始めるのは驚くほど簡単ですが、それは物語の半分に過ぎません。

楽しいユーザー・エクスペリエンスには、キャッシュとやりとりするコードを書いて実行することも含まれると私たちは考えており、私たちのクライアント・ライブラリを驚くほどシンプルにするには、意図的な努力が必要です。別の言い方をすれば、私たちのキャッシュ・クライアントは第一級の関心事なのです。

開発者エクスペリエンスに関する私たちの哲学の概要については、シリーズの最初の投稿をご覧ください: 驚くほどシンプル: キャッシュクライアントがあなたのために大変な仕事をします。また、JavaScriptPythonクライアントのチューニングについて掘り下げた他の2つの投稿もご覧ください。今回は、.NETキャッシュクライアントのチューニングで学んだことを探ります。

gRPCクライアントやパフォーマンス・ボトルネックの特定に関連したオタク的な話に興味があるなら、この先を読んでほしい!しかし…無料のサーバーレス・キャッシュをほんの数分で立ち上げて実行することにもっと興味があるのであれば…私のオタク的な話は遠慮していただき、楽しい部分に直行してください。入門ガイドをご覧いただければ、すぐに無料のキャッシュをセットアップできます。その後、私たちのGitHub orgにアクセスして、お好きな言語のMomentoクライアントをダウンロードしてください!

前回のショッキングリー・シンプルについて

このシリーズの過去の記事を読んでいない人のために、シーズン1の総括をしましょう:

・Momentoクライアントは、Googleが開発したリモートプロシージャコール用の高性能フレームワークであるgRPCを使用して構築されています。
・そのため、gRPCクライアントのチューニングは、各Momentoクライアントのチューニング作業の大部分を占めます。
・PythonやJavaScriptのように、デフォルトの実行環境がシングルCPUコアでしかユーザーコードを実行しない言語では、CPUが最初のボトルネックになることがよくあります。
・1つのクライアントノードで同時に実行されるリクエストの数を管理することは、スループットを最大化し、CPUのスラッシングを回避する最良の方法の1つであることが証明されています。
・JavaScriptでは、クライアントとサーバーの間に複数のgRPCチャネルを作成することで、スループットを向上させ、CPUをより良く利用することができます。
・gRPCレイヤがCで記述されているPythonでは、通常、複数のgRPCチャネルを使用することに意味のある性能上の利点はありません。

Momento .NETキャッシュ・クライアントのチューニング

以前のクライアントのチューニングで行ったように、C#の非同期タスクをループで大量に生成することで、非常に多くの同時リクエストのスパムをかけるという、非常に素朴なアプローチから始めます。

なぜかって?楽しいからです!

というのも、病的なケースでのパフォーマンスを示してくれるし、負荷がかかったときにどのリソースが最初のボトルネックになるかを知る重要な手がかりを与えてくれるからです。C#は、私たちがテストした中で、マシンのすべてのCPUコアを効果的に活用できる最初の言語なので、これは特に興味深い実験でした。PythonやJavaScriptでは、かなり早くCPUのボトルネックにぶつかることが予想されましたが、今回は何が予想されるのか、はるかに確信が持てませんでした。

この実験では、Momento サーバーはチャネルあたり 100 の同時ストリームしか許可しないということを学びました。つまり、複数の gRPC チャネルで実験を始めるまでは、5,000 の同時リクエストのうち 4,900 がブロックされることになります。

これが最初の興味深い観察につながりました!

すべてのgRPCライブラリが同じように作られているわけではない

このようなチューニング実験を行っているとき、私はしばしば lsof コマンドを使用して、クライアントとサーバー間で開いているコネクションの数を監視しています。PythonとJavaScriptのクライアントでは、作成したgRPCチャネルごとに、常に1つのコネクションが開いていることを確認しました。

‍これは C# の場合ではありません! .NET gRPC ライブラリは、サーバーへの接続を管理する方法において、かなり洗練されています。

クライアントが、サーバーへの単一のコネクションで多重化できる数よりも多くの同時リクエストを処理するようになると、クライアントは透過的に別のコネクションを開き、そのコネクションでリクエストの一部を送信し始めます。

そのため、同時リクエスト数が 100 以下の設定でコードを実行した場合、lsof 経由で開いているコネクションは 1 つだけでした。しかし、同時リクエスト数を101に増やすと、lsofは.NET gRPCライブラリによって魔法のように開かれた2番目のコネクションを表示しました!201リクエストで3番目のコネクションが現れ、301リクエストで4番目のコネクションが現れました。

すごい!PythonとJavaScriptでチャンネル数を注意深く管理し、テストしなければならなかったのに、.NETライブラリがすべてやってくれているのです。すてきだ!

これは素晴らしい!それとも?

この動作は本当に便利です。コネクションの作成や破棄を明示的に管理する必要がないのは素晴らしいことだし、特に、開いているコネクションのひとつにリクエストをルーティングする方法を心配する必要がないのは素晴らしいことです。しかし、マイナス面もあります。

この実装は、私が常に5,000の同時リクエストをクライアントにぶつけていた初期の病的なコードでは、gRPCライブラリがサーバーに50ものコネクションを開いていたことを意味します。多くのコネクションがオープンされ、多くのリクエストがワイヤー上にあることは、リターンが減少し、クライアントのすべてのチャネルを処理するオーバーヘッドが実際にパフォーマンスとスループットを低下させるポイントがあります。

言うまでもなく、サーバーは何の価値も提供しない余分な接続の束を処理することを余儀なくされます。一人のクライアントが50のコネクションを開くのは大したことではありませんが、理想的なコネクション数が5以下であり、すべてのクライアントがその10倍のコネクションを開く可能性がある場合、正当な理由もなく、すべての人のためにすべてを停滞させるだけです。

そこで次のステップは、どれだけの接続数(と同時リクエスト数)で最高のパフォーマンスが得られるかを突き止めることでした。‍

ノートパソコン環境: 同時リクエスト数の変化

以下は、同時リクエスト数を変化させながらラップトップ上でテストを実行した際のパフォーマンスを示すグラフです。

5,000人という大台のかなり手前で収穫が減少することは明らかであり、同様に、この環境でクライアントにこれほど多くのコネクションを作らせる正当な理由がないことも明らかです。

この問題に対処するため、Momento .NET キャッシュクライアントにコードを追加し、飛行中の同時リクエスト数の内部制限を設定できるようにしました。これは、.NETの通常の非同期タスクAPIを使用するため、Momentoクライアントのユーザーからは見えませんが、.NET gRPCライブラリがサーバーに作成する接続の最大数に上限を設けることができます。

このコード変更を行っても、ユーザーコードがMomentoクライアントに対して5,000の同時リクエストを発行するのを防ぐことはできません。一度にワイヤ上にある数を制限することはできますが、非同期コールを発行する数を制限することはできません。興味深いことに、5,000の同時非同期呼び出しでテストコードを再度実行しましたが、Momento SDKは一度に100の同時リクエストに制限しました。つまり、接続数に上限を設けることは、すべての面で勝利なのです。

スループットとリソース利用率の観測

予想通り、PythonやJavaScriptのときよりも、1つのC#プロセスでMomentoクライアントをはるかに多くのトラフィックで動かすことができました。実際、私のノートパソコンでは、CPUが最大になる前にネットワークI/Oがボトルネックになった。私のノートパソコンでは、CPUを約20%しか使わないのに、1秒間に11,000リクエストのスループットを達成することができた。単一のPythonやJavaScriptプロセスでは、6,000程度がピークでしょう(複数のプロセスでそれ以上になることは明らかですが)。

ラップトップからクラウドへ: チューニングの目標

さて、ラップトップ環境は興味深いが、より現実的な本番環境に移行してみましょう。Momentoサービスと同じリージョンで動作するEC2インスタンスです。この場合、ネットワークのレイテンシがほとんどなくなるので、クライアント側のレイテンシの数値はサーバー側の数値にかなり近くなると予想されます。(c6i.4xlargeインスタンスタイプを使用しているのは、このクラスでは小さいインスタンスよりも安定したネットワークパフォーマンスが観測されているためです)

まず、チューニングのためのレイテンシー目標を設定することから始めましょう:

クライアント側のp999レイテンシは20ms: これは、計算コストが非常に高いデータ(複雑なリレーショナル・データベース・スキーマの大きなJOINクエリなど)をキャッシュするアプリケーションや、アプリケーション自身のレイテンシ要件が甘い場合に妥当な目標です。
クライアント側のp999レイテンシは5ms: これは、アプリケーション自身のレイテンシ要件が絶対的に重要なアプリケーションにとって、より良いターゲットです。

クラウド環境: 同時リクエスト数の再検討

以下は、クラウド環境におけるクライアントのパフォーマンスを、さまざまな同時リクエスト数で示したグラフです:

これらのチャートから、200の同時リクエストで最初のレイテンシ目標(p999レイテンシ20ms以下)を達成できていることがわかります

レイテンシ要件がより厳しいアプリケーションでは、25の同時リクエストでp999レイテンシ5ms未満という目標を達成することができました。スループットは約26,000から約11,000に落ちますが、それでもレイテンシに極端に敏感な環境にとっては、これは正しいトレードオフでしょう。

このデータについて、JavaScriptPythonのSDKをチューニングしたときの結果と比較すると、もうひとつ興味深いことがわかります。各言語の20ms p999 latencyの目標に対するスループットの数値です:

  • JavaScript: 8,300 requests per second
  • Python (with uvloop): 9,100 requests per second
  • .NET: 26,000 requests per second

JavaScriptとPythonの数字は、.NETの数字と同列に比較できるものではありません。なぜなら、これらのランタイムでは、1つのプロセスは1つのCPUコアしか使用できないからです。そのため、.NETプロセスでより高いスループットが得られることは予想外ではない。c6i.4xlargeでこれらのテストを実行したため、16個のCPUコアを使用したが、Pythonの実行と比較して2.5倍のスループット向上しか得られませんでした。これは、複数のCPUを活用できるようになると、ボトルネックがCPUからネットワークI/Oにすぐに移動することを示しています。

結論

このデータにより、さまざまな環境とワークロードに対応するMomentoクライアントを構成するための適切なデフォルト値を確立することができました。Momento .NET SDKでは、これらの事前設定から選択できるようになりました: Configurations.Laptop、Configurations.InRegion.Default、Configurations.InRegion.LowLatencyです。もちろん、特定のニーズに合わせて構成をカスタマイズすることもできますが、90%のケースをカバーする構築済み構成を提供することで、ビジネス特有の作業に時間を割けるようにしたいと考えています。

私たちの.NETキャッシュクライアントを試してみて、ビルド済みの設定では必要なものが得られないような状況があれば、GitHubに課題を提出するか、私たちに連絡してお知らせください!不足しているものは何でも補いたいと思います。

Momentoは、クライアントソフトウェアをサーバー製品と同じように真剣に取り組んでいます。Momentoでは、このようなことを心配する必要はないというのが私たちの立場です。

まだの方は、ブラウザでMomento CLIのデモをチェックして、1分もかからずに無料でキャッシュを作成できることを確認してください!そして、Momentoを好きなプログラミング言語から使うための詳しい情報は、入門ガイドをご覧ください。
また、Momentoについて話したい場合は、Discordサーバーに参加してください!