Optimizing M3: How Uber Halved Our Metrics Ingestion Latency by (Briefly) Forking the Go Compiler — Part3

M3の最適化:UberがGoがコンパイをフォークすることでメトリック収集のレイテンシーを半減する方法とは — 第三章

gavin.zhou
8 min readFeb 3, 2020

今回の記事は長いので四回に分けて投稿しています。今回は第三回目です。Goランタイムについて詳しく書いています。

Understanding the Go runtime

しかし、一体この関数は何をしているのでしょうか?それを理解するためには、どのようにGoランタイムがgoroutineのスタックを管理するかを理解する必要があります。

Goの中の全てのgoroutineは2kibibyteのスタックを伴ってスタートします。もっと多くのアイテムやスタックフレームは分散され、必要なスタックスペースの量が割り当てられた量を超えると、ランタイムは、前のスタックの2倍のサイズの新しいスタックを割り当て、古いスタックから新しいスタックにすべてをコピーすることにより、スタックを(2の累乗で)増やします。

図8:runtime.morestack関数は、実行を一時停止し、新しいスタックを割り当て、古いスタックを新しいスタックにコピーしてから関数呼び出しを再開することで、より多くのスタックスペースを必要とするGoroutineのスタックを拡大します。

これにより新しいセオリーができました:既存のコードはスタックを拡大させる境界線のすぐ近くで実行されていました。そして cloneBytes ヘルパーメソッドに追加された呼び出しは、エッジを越えてプッシュし、追加のスタックの成長を引き起こしていました。

この成長は、CPUプロファイルと一致するリグレッションを引き起こすのに十分であり、マイクロベンチマークで問題を再現できない理由も説明しました。 マイクロベンチマークを実行したとき、呼び出しスタックは非常に浅かったのですが、本番環境では、Cloneメソッドは30回の関数呼び出しの深さで呼び出されました(図9を参照)。 結果として、パフォーマンスの不一致は、関数を呼び出している特定のコンテキスト内でのみ観察されます。

図9:本番環境において、スタックは30回以上の呼び出しでした。スタックの成長を促すきっかけになります。しかし、ベンチマークのおいては、そのスタックはとても浅く、デフォルトのスタックサイズを超えることはほとんどありませんでした。

この理論を検証するための迅速で簡単な方法が必要でした。 M3DB ingesterの仕組みは、メトリックをM3DBに書き込むという面倒な作業はすべて、このワーカープールの単一インスタンスによって作成されたgoroutinesによって実行されます。

その重要なコードは下の図10で示しています。

図10:Goでよく利用されるパターンは、チャネルを並行性をコントロールするためのセマフォとして使用することです。 作業はトークンが予約された後にのみ実行できるため、同時実行の合計量はトークンの数(つまり、チャネルのサイズ)によってコントロールされます。

書き込みの着信バッチごとに、新しいgoroutineを割り当てます。 workCh変数として示されている作業チャネルはワークチャンネルといい、セマフォとして機能し、一度にアクティブにできるgoroutineの最大数を制限します。 これにより、インジェスターはキューのように動作し、スパイキーなワークロードをバッファリングするため、M3DBインジェスターに送信されるメトリックの数が非常に扱いにくい数であっても、M3DBが受信する書き込みはより長い期間にわたってスムーズになります。

私たちの理論が正しければ、絶えず新しいものを生成する代わりにgoroutineを再利用することで問題を軽減できるはずです。 Goランタイムは最初に新しいgoroutineごとに2 kibibyteスタックを割り当て、必要に応じてそれらを成長させますが、kibibyteがガベージコレクションされるまで、拡張されたスタックの割り当てを解除することはありません。(これが実際にどのように機能するかの背後にある事実は少し複雑です。ランタイムがルーチンを小さなスタックに「移動」しようとするシナリオがありますが、統計的に言えば、与えられた関数呼び出しに対してgoroutineがそのスタックを成長する必要がある確率はずっと低いです)。

このセオリーをテストするため、私たちはすべてのgoroutineを事前に生成し、(ロック競合を減らすために)いくつかの異なる「作業チャネル」を使用して、各要求に対して新しいものを作成する代わりに、goroutineに作業を分散する 新しいワーカープールを作成しました。

図11:ワーカープールのこの実装は、異なるアプローチを取ります。 トークンを使用して生成されるgoroutineの数を制限する代わりに、すべてのgoroutineを前もって生成してから、チャネルを使用してそれらに作業を割り当てます。これにより、同時実行性は指定された制限に制限されますが、新しいgoroutineスタックを何度も何度も割り当てる必要がなくなります。

このアプローチは、既存の実装で発生していたスタックの過剰な成長を防ぐはずだと我々は仮定しました。それぞれのgoroutineは、問題のあるコードを最初に実行したときにスタックを増やす必要がありますが、後続の呼び出しでは、追加のヒープ割り当てとスタックコピーのコストをかけることなく、既に割り当てられたメモリにスタックフレームを拡張できなければなりません。

安全のために、私たちはそれぞれのgoroutineに小さな可能性を賭けました。過度に大きなスタックを持つgoroutineが永久にメモリに保持されるのを防ぐための作業を完了するたびに、終了し、それ自身の代替品を生成するという可能性です。この追加の予防措置は少し過剰だったかもしれません。

新しいワーカープールを使用してサービスをデプロイしたところ、以下の図12に示すように、runtime.morestack関数で費やされた時間が大幅に減少したことがわかりました。

図12:新しいワーカープールでは、runtime.morestackに費やされた時間は、パフォーマンス・レグレッションを導入する前よりもさらに短くなりました。

さらに、下の図13に示すように、エンドツーエンドのレイテンシーは、実際にレグレッションを導入する前よりもさらに低下しました。

図13:新しいワーカープールは非常に効果的だったため、パフォーマンスの問題を最初に引き起こしたコードを使用してサービスをデプロイした場合でも、エンドツーエンドのレイテンシーはレグレッションを導入する前よりも低くなりました。 つまり、新しいワーカープールを使用すると、goroutineスタックの成長のコストを心配することなく、安全にコードを書き込みができます。

面白いことに、新しいワーカープールの実装を使用し始めたら、cloneBytes()helperがインライン化されているかどうかに関係なく、パフォーマンスとして使用したClone()メソッドのバージョンは同じでした。 これは将来のエンジニアがこの問題を再導入する変更について心配する必要がないことを意味し、スタック成長理論が確実に信用できるものだという確信を得られたため、これにはとても期待が持てました

Orangesys.ioでは、kuberneteの運用、DevOps、監視のお手伝いをさせていただいています。ぜひ私たちにおまかせください。

--

--

No responses yet