2021年5月2日日曜日

2020年2月10日 実装言語を「Go」から「Rust」に変更、ゲーマー向けチャットアプリ「Discord」の課題とは

https://www.atmarkit.co.jp/ait/spv/2002/10/news038.html

シェアしました。

ゲーマー向けチャットアプリケーション「Discord」では、基盤サービスの一つである「Read States」が十分に高速化できない問題が明らかになった。開発チームは既存のコードをさらに改善することで対応しようとした。だが、Rust言語で再実装したところ、最適化を施す以前からパフォーマンスが向上した。なぜだろうか。開発チームがその理由を語る。

 ゲーマー向けの無料音声テキストチャットアプリケーション「Discord」を開発、提供するDiscordは2020年2月5日(米国時間)、アプリケーションを支える基盤サービスの一つである「Read States」をRust言語で再実装し、その結果サービスのパフォーマンスが大幅に向上したと公式ブログで明らかにした。

 Read StatesサービスはこれまでGo言語で実装されていた。それにもかかわらず、なぜRead StatesをRustで再実装しようとしたのか、どのように再実装したのか、再実装によってどのようにパフォーマンスが向上したかを解説した。

Rustで再実装した背景とは

 Read Statesサービスの目的は、Discordユーザーがどのチャンネルのどのメッセージを読んだのかを追跡することだ。つまり、ユーザーがDiscordに接続したり、メッセージを送信したり、メッセージを読んだりするたびに、Read Statesにアクセスする。

 このため、Read Statesはアクセス頻度が非常に高い。同社は、Discordのエクスペリエンスが極めて軽快であることを目指している。

 だが、Goで実装していたRead Statesサービスは、高速性が必要だという要件には十分対応できていなかった。平均すれば高速に動作していたものの、数分ごとに平均応答時間が急に大きくなり、ユーザーエクスペリエンスを損なっていた。調査したところ、これはGoの中核機能であるメモリモデルとガベージコレクタ(GC)に起因することが分かった。

Goで性能が低下したのはなぜか

 Goで記述したサービスがパフォーマンス目標を達成できなかった理由を理解するには、まず、サービスのデータ構造やスケール、アクセスパターン、アーキテクチャについて確認する必要がある。

 Discordで読み取り状態情報を保存するのに使うデータ構造を、以下では便宜的に「Read State」と呼ぶ。Discordには数十億のRead Stateがある。ユーザーとチャンネルの組み合わせごとに1つのRead Stateが必要だからだ。各Read Stateは幾つかのカウンターを持つ。例えば、カウンターの一つは、ユーザーがチャンネル内に持つメンション数を表す。

 これらのカウンターをバラバラに更新すると整合性が取れなくなる。アトミックに更新しなければならない。さらに頻繁に0にリセットしなければならない。

 アトミックカウンターを高速に更新するために、各Read Statesサーバには、Read StateのLRU(Least Recently Used)キャッシュがある。各キャッシュには数百万人のユーザー、数千万のRead Stateが含まれる。そして、キャッシュの更新頻度は毎秒数十万回に及ぶ。

 データの永続性を確保するために、Discordは分散データベース管理システム「Apache Cassandra」のクラスタでキャッシュを補完している。キャッシュキーのエビクション(不要なデータを追い出すこと)の際に、Read Stateをデータベースにコミットする。加えて、Read Stateの更新時に必ず、30秒間のデータベースコミットを入れている。こうしてデータベースへ毎秒数万回の書き込みが起こる。

 Goで実装したRead Statesサービスのパフォーマンスを次のグラフに示した。最大メンション数(右下)が不規則に変動しているにもかかわらず、CPU使用率(左上)や平均応答時間(右上)にははっきりした挙動が現れており、約2分ごとに、CPU使用率と平均応答時間がスパイク状に急増していることが分かる。

タップで拡大
 
Go版のRead Statesサービスの主な挙動(出典:Discord

 キャッシュキーのエビクションを実行しても、Go版のRead Statesサービスでは、すぐにはメモリ領域を解放しない。その代わりに、ガベージコレクタを頻繁に実行して、もはや参照がなくなったメモリ領域を探し出して解放する。つまり、メモリ領域が使用されなくなった直後に解放するのではなく、本当に使用されていないかどうかをガベージコレクタが判断できるまで、少しの間待つことになる。さらにガベージコレクションの実行中、空きメモリ領域を判断するためにさまざまな処理を進める必要があるため、プログラムの速度が低下する可能性がある。

 このような理由から、先ほどのグラフに現れていた平均応答時間の急上昇は明らかにガベージコレクションのパフォーマンスのためだろうと考えた。

 だが、DiscordのGoコードは非常に効率的に記述されていた。メモリの割り当て回数も十分に少なくなっている。つまりガベージコレクションに問題があるというのはおかしいとDiscordの開発者は判断した。

 そこでGo自体のソースコードを調査したところ、少なくとも2分ごとにガベージコレクションを強制的に実行することが分かった。それなら、ガベージコレクタの挙動を変えたらどうなるだろうか。オンザフライで「GC Percent」の値を変えてみたところ、どのような値を設定しても挙動は変わらなかった。理由はDiscordの側にあった。ガベージコレクションが頻繁に発生するようにメモリを迅速に割り当てることができなかったからだ。

 さらに調査を続けた結果、2秒ごとに巨大なスパイクが生じる、別の理由があることが分かった。メモリ領域が本当に参照されなくなったのかどうかをガベージコレクタが判断する際、LRUキャッシュ全体をスキャンする必要があったのだ。

 つまり動作を高速にするには、LRUキャッシュを小さくすればよい。LRUキャッシュのサイズを変更するための別の設定をサービスに追加し、サーバごとに多数のパーティション化されたLRUキャッシュを持つようにアーキテクチャを変更した。すると、ガベージコレクションによるスパイクが実際に小さくなった。

 だが、この解決策には副作用がある。LCUキャッシュのサイズを小さくすると、ユーザーの読み取り状態がキャッシュ内に含まれる確率が下がり、データベースを参照するために遅延が生じてしまった。

 Rustを導入する以前は、LCUキャッシュサイズと遅延のバランスを取って、何とか、2秒ごとのスパイクが小さくなるように運用していた。

Rustはなぜ高速なのか

 Rustではガベージコレクションが不要だ。同社がRead StatesサービスをRustで実装しようと考えたのはこれが理由だ。Rustを使えば、Goで実装した場合に生じた平均応答時間の急増は見られないだろう。

 Rustは、「メモリオーナーシップ」という考え方を取り入れた比較的ユニークなメモリ管理アプローチを採用している。メモリに所有権があるため、あるメモリ領域に対する読み書き権限を追跡でき、そのメモリ領域が不要になると、直ちに解放する。Rustはコンパイル時にメモリルールを強制するので、メモリバグはほぼ発生しない。Cのように手動でメモリ領域を追跡する必要はなく、コンパイラが全てを取り仕切る。

 これらのことから、Read StatesサービスのRust版では、LRUキャッシュからのRead Stateのエビクションが行われると、Read Stateが使っていたメモリ領域は直ちに解放される。Rustは、Read Stateのメモリが使われなくなったことを認識し、直ちに解放するものの、メモリを解放すべきかどうかを判断するランタイムプロセスも存在しない。

「非同期Rust」の完成度は低かったものの、コミュニティーの協力を得た

 Read Statesサービスを再実装した当時、Rustの安定版では非同期Rustのサポートが不十分だった。だが、ネットワークサービスでは、非同期プログラミングが必須だ。非同期Rustを有効にしたコミュニティー版のライブラリが幾つか存在したものの、利用に際して複雑な手順が必要であり、不具合が生じた場合のエラーメッセージは非常に分かりにくかった。

 幸いなことに、Rustチームは、非同期プログラミングが容易になるよう精力的に取り組んでおり、Rustのナイトリーチャネルで、非同期プログラミング機能が強化された不安定版が入手できるようになった。

 Discordはナイトリーリリースを導入し、問題が発生した際にはRustチームと協力して対処した。その結果、現時点では、Rustの安定版は非同期Rustをサポートしている。

最適化しなくても性能を発揮したRust版

 Read Statesサービスのコードを書き換える作業は、かなりスムーズに進んだとDiscordの開発チームは評価している。まずGoからRustにコードを大まかに変換し、スリム化が有効な箇所ではスリム化を行った。

 例えば、Rustは優れた型システムを備えており、どのような型でも受け入れるジェネリクスを幅広くサポートしている。このため、ジェネリックを備えていないGoでは必要だった該当するコードが不要になった。さらに、Rustのメモリモデルを生かすことで、メモリ保護に関するGoコードの一部も不要になった。

 ロードテストを開始したところ、すぐに良好な結果が得られた。Rust版のRead Statesサービスの平均応答時間はGo版並みだった。さらにスパイクが生じない。

 注目すべきは、Rust版では当初、基本的な最適化しか行っていなかったことだ。すなわち、基本的な最適化を施しただけのRust版が、手動で徹底的にチューニングされていたGo版のパフォーマンスを上回ったことになる。これは、Rustで効率的なプログラムを書くのがいかに簡単かを示すよい実例だ。

 だが、Discordの開発チームはこの結果に飽き足りず、プロファイリングとパフォーマンス最適化を進めた。少し最適化しただけで、Rust版は平均応答時間やCPU使用率、メモリ使用量など、全てのパフォーマンス指標でGo版よりも良い結果を示した。

 具体的な最適化の内容を示すと、次のようになる。

  1. LRUキャッシュのHashMapをBTreeMapに変更した(メモリ使用量の最適化のため)
  2. 初期のメトリクスライブラリを、モダンなRustの並行性を使用したものに変更した
  3. メモリコピーの回数を削減した

 これらの最適化によって満足のいく結果が得られたため、DiscordはRead StatesサービスのRust版リリースを決めた。

 ロードテストを経ていたため、リリース時にも問題はほぼ起きなかった。Read Statesサービスをまずシングルカナリアノードに展開し、幾つかのエッジケースが見つかったため、修正を施した。その後間もなく、サービスの本格稼働を開始した。

 次にRead StatesサービスのGo版とRust版でCPU使用率と平均応答時間を比較したグラフを示す。紫がGo版、青がRust版だ。Rust版はCPU使用率の変動がほとんどなく、平均応答時間にスパイクのないことが分かる。

Rust版とGo版の挙動を比較したグラフ(出典:Discord

キャッシュ容量を元に戻す

 Discordのサービスを置き換えてから数日後、LRUキャッシュ容量を元に戻す必要があると開発チームは判断した。Go版では、先ほどの説明のように、LRUキャッシュのサイズを増やすと、ガベージコレクションに時間を要する。だが、Rust版ではガベージコレクションは必要ない。そこでLCUキャッシュサイズの上限を引き上げて、さらにパフォーマンスを向上できると考えた。

 ボックス部分のメモリ容量を増やし、より少ないメモリで済むようにデータ構造を最適化し、Read Stateが800万個入るサイズへとLCUキャッシュの容量を増やした。

 その結果、CPU使用率は10ポイント程度減少し、平均応答時間は10分の1程度にまで下がった。

Rustのメリットは何か

 Discordはソフトウェアスタックの多くの部分でRustを使用している。ゲームSDKやGo Liveのビデオキャプチャーとエンコード、Elixir NIF、幾つかのバックエンドサービスなどだ。新しいプロジェクトやソフトウェアコンポーネントを検討する際は、Rustが使えないかどうかを吟味して、他のプログラミング言語よりも優れた結果を期待できる場合は利用しているという。

 Rustをさまざまなプロジェクトに利用して分かったことは、パフォーマンス以外のメリットも大きいことだという。

 型安全性と借用チェッカー(borrow checker)は特に役に立つ。なぜなら製品の要件が変わったときなどに、コードのリファクタリングが容易になるからだ。Rustのエコシステムとツールにも助けられており、コミュニティーの活動も活発だという。


0 コメント:

コメントを投稿