Ruby 3.3.0+YJIT本番運用カンパニーになりました - Timee Product Team Blog

Timee Product Team Blog

タイミー開発者ブログ

Ruby 3.3.0+YJIT本番運用カンパニーになりました

こんにちは。バックエンドエンジニアの須貝(@sugaishun)です。

今回はタイミーが本番運用しているRailsアプリケーションに対してRuby3.3.0へのアップデートを行った(YJITは引き続き有効なまま)のでその結果をご紹介したいと思います。

昨年弊社のid:euglena1215が書いたエントリーのRuby3.3.0版です。

tech.timee.co.jp

前提

タイミーのWebアプリケーションとしての特性は基本的には昨年と変わりありません。ですので、昨年の内容をそのまま引用させてもらいます。

タイミーを支えるバックエンドの Web API は多くのケースで Ruby の実行よりも DB がボトルネックの一般的な Rails アプリケーションです。JSON への serialize は active_model_serializers を利用しています。

今回の集計では API リクエストへのパフォーマンス影響のみを集計し、Sidekiq, Rake タスクといった非同期で実行される処理は集計の対象外としています。

今回はRuby3.2.2+YJITからRuby3.3.0+YJITへアップデートを行い、パフォーマンスの変化を確認しました。

結果

以下のグラフはAPIリクエスト全体のレスポンスタイムの50-percentileです。

グレーの点線がアップデート前の週で、青い線がアップデート後の週になります。集計した期間ではアップデート前後の平均でレスポンスタイムが10%高速化していました。

今回も前回にならってレスポンスが遅く、時間あたりのリクエスト数が多いエンドポイントに注目し、タイミーのWeb APIのうち3番目に合計の処理時間が長いエンドポイントへのパフォーマンス影響を確認しました。

以下のグラフは3番目に合計の処理時間が長いエンドポイントのレスポンスタイムの50-percentileです。こちらも同様にグレーの点線がアップデート前の週で、青い線がアップデート後の週になります。集計した期間ではアップデート前後の平均でレスポンスタイムが約12%高速化していました。

またECSの起動タスク数にも良い変化がありました。

タイミーではCPU使用率が一定の割合になるようにタスク配置する設定をしているのですが、リリース後は起動タスク数が減りました。リリース前後1週間の比較で下記のように変化しています。

  • 平均で33.1 tasks → 30.36 tasks
  • 最大値で58.6 tasks → 53.0 tasks

このあたりはYJITの効果でリクエストに対するCPU負荷が下がった影響ではないかと推測しています。メモリ上に配置した機械語を実行するJITならでは、という感じがします。コスト的にどれだけインパクトがあるか具体的な数値は出せていませんが、パフォーマンス以外のメリットもありそうです。

と、ここまでは良かった点です。

以降では自分たちが遭遇した事象について述べたいと思います。

一部のAPIでメモリ使用率が増加

タイミーのRailsアプリケーションはモノリスですが、ECS上ではネイティブアプリ向けのAPIとクライアント様の管理画面向けのAPIはそれぞれ別のサービスとして稼働しています。前者のネイティブアプリ向けAPIでは特に問題なかったのですが、後者の管理画面向けAPIではメモリ使用量の最大値が約3倍超(20%弱→65%程度)になりました。以下のグラフの赤いラインがRuby3.3.0にアップデートしたタイミングになります。

さすがに3倍超は困ったなと思い、YJITのREADMEを読んだところ下記のようにありました。

Decreasing --yjit-exec-mem-size

The --yjit-exec-mem-size option specifies the JIT code size, but YJIT also uses memory for its metadata, which often consumes more memory than JIT code. Generally, YJIT adds memory overhead by roughly 3-4x of --yjit-exec-mem-size in production as of Ruby 3.3. You should multiply that by the number of worker processes to estimate the worst case memory overhead.

We use --yjit-exec-mem-size=64 for Shopify's Rails monolith, which is Ruby 3.3's default, but smaller values like 32 MiB or 48 MiB might make sense for your application. While doing so, you may want to monitor RubyVM::YJIT.runtime_stats[:ratio_in_yjit] as explained above.

メタデータ(yjit_alloc_size)の増え方が想定以上なのではと思いつつ、ddtrace*1ではyjit_alloc_sizeは送信していないようなので、code_region_size を確認。実際、YJITのcode_region_size(JITコードのサイズ)とメモリ使用率はほぼ同じ動きをしていました。以下のグラフの左がcode_region_sizeで、右がメモリ使用率です。

というわけで--yjit-exec-mem-sizeをデフォルトの64MiBから32MiBに減らしたところ、メモリ使用量はアップデート前より少し増えた程度の水準まで抑えることができました。なお、この変更によるパフォーマンスへの影響は見られませんでした。

以下の右のグラフがメモリ使用率で、赤いラインが--yjit-exec-mem-sizeの変更をリリースしたタイミングになります。実際にメモリ使用率が下がっているのが見て取れます。

今回のメモリ使用量の大幅な増加は完全に予想外で、ステージング環境でもメモリ使用量の変化はウォッチしていましたが、特に大幅な増加は見られず本番環境で初めて発覚する事態になりました。すでにRuby3.2系でYJITを有効にしているプロダクトでも、Ruby3.3.0+YJITにアップデートされる際には--yjit-exec-mem-sizeの値には注意したほうが良さそうです。

まとめ

Ruby3.2ですでにYJITを有効にしているプロダクトでもRuby3.3.0にアップデートしてパフォーマンスの改善が見られました(YJITの影響なのかそれ以外の最適化による恩恵なのかまでは検証しておりません)。

すでにYJITを有効にしていた場合でもRuby3.3.0へのアップデートでメモリの使用量が大幅に増加する可能性があるので注意しましょう。

調査の際には下記の(主にk0kubunさんの)ドキュメントに非常に助けられました。この場を借りてお礼を申し上げます。

github.com

k0kubun.hatenablog.com

gihyo.jp

本エントリーがこれからRuby3.3.0+YJITへアップデートされる方のお役に立てば幸いです。

*1:タイミーはDatadogを使っています。ddtraceはDatadogにメトリクスを送信するgemです