この記事は Ruby on Rails Advent Calendar 2023 の23日目の記事です。
はじめに
ライブストリーミング事業本部の Voice Pococha チームでバックエンド開発をしている渡部( @yayamochi )です。
Voice Pocochaは24時間、いつでもどこでも手軽に音声ライブ配信が楽しめるサービスです。
リアルタイムでのコミュニケーションが非常に重要なサービスであるため、パフォーマンスの改善は日々欠かせません。
Voice PocochaのバックエンドはRuby on Rails(7.0.4)で構成されており、今回Ruby3.2で本格導入されたYJITを本番環境で有効化し、パフォーマンス改善を試みました。
この記事ではYJITの導入方法や、それに伴った効果、トラブルについて紹介したいと思います。
YJITの概要
YJIT(Yet Anothor Ruby JIT) とは、Shopifyが開発した新しいJIT(実行時)コンパイラです。
YJITでは、「Lazy Basic Block Versioning」という型チェック手法を採用しており、このアルゴリズムによって、必要最小限のコンパイルを行うことで高速化を実現しています。 この手法について詳しく知りたい方は、 わかりやすく解説された記事 がありましたのでぜひ参照してみてください。
Ruby3.2のリリースノートによると、YJITによる速度向上が明確に提示されています。 (引用元: Ruby 3.2.0 リリース )
最新のYJITのベンチマークについては こちらのサイト で公開されています。
ただし、YJITコンパイラはメモリ内にマシンコードを生成し、プログラムの動作の統計情報などをメモリ内に保存するため、YJITを無効にした場合よりも多くのメモリを使用する特性があります。このため、設定によってYJITに割り当てるメモリの上限を制限することができます。
YJITの有効化・パラメーターの設定について
公式ドキュメント に基づき、YJITを有効化し、設定を追加していきます。 YJITはRuby3.2ではデフォルトで無効化されているため、明示的に有効化する必要があります。 Voice PocochaはAmazon ECSを使用しており、Rubyの実行環境はDockerコンテナ上で動いています。そのため環境変数をDockerfile上に設定することで有効化をしていきます。
# RubyのDockerfile
ENV RUBYOPT="--yjit"
また、YJITにはパフォーマンスを最適に調整するための様々なオプションがあります。 Performance Tips for Production Deployments や、 Reducing YJIT Memory Usage を参照すると、特に重要なパラメーターは —yjit-exec-mem-size と —yjit-call-threshold になりそうです。
–yjit-call-threshold
YJIT が関数のコンパイルを開始するまでの呼び出し回数で、この値を小さくすればするほどあまり呼ばれない関数までコンパイルされるため、メモリをより多く使用することになる。一方大きくすればするほどコンパイルが遅くなるため、トレードオフをみながら調整する必要がある。
–yjit-exec-mem-size
YJITのために割り当てられるメモリサイズで、指定メモリまで達するとGCが走り、JITコンパイルが最初からやり直しになる。
上記の設定が必要な場合はRUBYOPTの環境変数に追記していきます。
私達のサービスは1ワーカーあたりのリクエストの処理数が1万~10万件ほどだったのですが、これらのパラメーターの最適値の予想がつきませんでした。
そのため、--yjit-call-threshold
の値はデフォルトよりも多めの「100」をセットし、YJIT有効化後の統計値を取り、徐々に調整していくことにしました。
最終的には以下のパタメーターでリリースしていくことに決定しました。
ENV RUBYOPT="--yjit --yjit-call-threshold=100"
各種パラメーターの最適化の手法に関しては、 こちらの記事 が非常にわかりやすいので、ぜひ参照してみてください。
また、上記のパラメータを調整するために、以下のような監視用のmoduleを作成しました。 サンプリングレートを設定し、どの程度YJITでメモリを使用しているかを計測しています。 Voice Pocochaでは、ApplicationControllerのbefore_actionのコールバックと、ActiveJobのaround_performのコールバックに下記のメソッドを仕込みました。
module YjitLogger
def record_yjit_log
return unless RubyVM::YJIT.enabled?
return if sampling_rate.zero?
Rails.logger.info "[YJIT] #{RubyVM::YJIT.runtime_stats}" if Random.rand(1 / sampling_rate).to_i.zero?
end
# 実際には本番やQA用環境によってsampling rateの値を変えて計測しています。
def sampling_rate
0.01
end
end
今回のリリースでは、「メモリを使用しすぎず、少数の工数でパフォーマンスが向上できること」を達成できればよかったため、上記のログのみの確認に絞りました。より詳細な情報を取得するためには、--yjit-stats
オプションを付与して監視を行い調整を行うことも可能です。
YJIT導入による効果
Voice PocochaではECS上で、いくつかのServiceを管理しています。 今回は主にユーザーへの影響がでやすいAPIとSidekiq(非同期処理)のServiceのパフォーマンスについて紹介します。
API Service
CPUやメモリの変化
CPUには変化がなく、メモリがリリース直後から急激に増加しており、メモリ使用率平均値が40%台から50%台まで上昇しています。
レスポンスタイムの変化
Voice Pocochaでは好きな配信者にアイテムを送ることによって配信を盛り上げることができるのですが、そのアイテムを一覧で取得するAPIが重いため、これがYJIT有効化によってどう改善されたか確認します。
※ Voice Pocochaは夜12時に近づくにつれて、サービスの負荷が高まっていくという性質をもっているため、一週間前の同じ時間帯のレスポンスタイムを比較しています。
CloudWatch Insightsで同じ時間帯(10:00~10:59)のレスポンスにかかった時間の中央値を比較しました。 YJITを有効化しただけで、337.529(ms) → 241.313(ms)と約1.4倍ほど高速化されました。
Sidekiq Service
CPUやメモリの変化
SidekiqのServiceに関しては、CPUの負荷の水準が多少下がっているようにみえます。メモリに関してはAPIと同様に増加しています。
Sidekiqに関しては、元々メモリ使用率が70%台と高い状態だったのですが、サービスの負荷が高まるにつれてメモリ使用率が90%台にまで上昇してしまい、YJIT有効化後の翌日の午前6時頃にOutOfMemoryでTaskが落ちてしまうという事象がおきました。
YJITの有効化によってメモリが高まることは理解していましたが、ドキュメント上に
YJIT adds memory overhead by roughly 3-4x of
--yjit-exec-mem-size
in production as of Ruby 3.2.
と記載があるため、もっとメモリに余裕を持っておくべきでした。
こちらに関しては、元々Sidekiq側でのメモリが不足しがちな状況だったため、タスク定義でメモリサイズを変更し、50~60%台に安定するように調整することによって根本対応を行いました。
リリース後の統計情報の確認について
リリース前に追加したYJITの統計情報のログを確認します。
YJITのドキュメント上によると、JITのコードサイズはcode_region_size
で取得することができそうです。
When JIT code size (RubyVM::YJIT.runtime_stats[:code_region_size]) reaches this value, YJIT stops compiling new code. Increasing the executable memory size means more code can be optimized by YJIT, at the cost of more memory usage.
統計情報が溜まった数時間後に、実際に出力されたログは以下のような形になりました。
"[YJIT] {:inline_code_size=>4228072, :outlined_code_size=>4227645, :freed_page_count=>0, :freed_code_size=>0, :live_page_count=>517, :code_gc_count=>0, :code_region_size=>8466432, :object_shape_count=>9820}"
Voice PocochaではJITによって使用しているメモリサイズが約7~9MBほどで想定よりも使用されていませんでした。
ドキュメントをもとに想定メモリサイズを計算すると以下になりそうです。
想定メモリサイズ=JITによるメモリサイズ × 3~4 × ワーカー数
ECSタスクのメモリのサイズを調整したことにより、想定メモリサイズにも余裕があったため、—yjit-call-thresholdをデフォルト値の30に下げました。 結果として、YJIT用のメモリサイズが少し増大するだけで、パフォーマンスに大きな変化が発生しなかったため、現在はRuby3.2で定義されているデフォルト値でYJITの運用を続けています。
今後のYJIT
Ruby3.3以上では、デフォルトでYJITが有効化されます。また、さらなるYJITのメモリ使用量削減や、高速化等のパフォーマンス向上が報告されています。
YJITのオプションのデフォルト値などはRubyのアプデにより定期的に変更されており、Ruby3.3でも--yjit-code-gc
が無効になったりするなど変化があり、Rubyのアップデートの際にはパフォーマンスには注視が必要そうです。
まとめ
YJITを有効化することによって、大きくレスポンスタイムの向上を確認することができました。
ただし、最適なパラメータ設定は手探りであり、決め打ちの設定値を本番運用の中で調整をすることになりました。同様のアプローチにおいては、リソース的・時間的に余裕を持ってリリースのうえ、メモリ使用率を継続的に監視しておく必要がありそうです。
また、YJITは後続のバージョンでさらなるパフォーマンスアップが報告されているので、我々も定期的にアップデートをしてパフォーマンス改善を行っていく予定です。
Voice Pocochaでは、スモールなエンジニアチームで新規開発のみならず、日々のパフォーマンスの改善や、開発体験の向上に邁進しております。これらの取り組みを一緒に推進してくれる仲間を募集しておりますので、ぜひ こちらのページ からお声がけください!
最後まで読んでいただき、ありがとうございます!
この記事をシェアしていただける方はこちらからお願いします。