クラウドワークス エンジニアブログ

日本最大級のクラウドソーシング「クラウドワークス」の開発の裏側をお届けするエンジニアブログ

crowdworks.jp 最大の Rails アプリで YJIT を有効化した話

はじめに

crowdworks.jp のジャンヌチームで主にバックエンドの技術的負債の解消に取り組んでいる @kotarou1192 です。

crowdworks.jp を支える Rails アプリケーションで YJIT を有効化しました。
本記事では、有効化の背景と、1週間の観察で得られたパフォーマンスの変化を報告します。

YJIT とは

YJIT は Ruby 3.1 で実験的に導入された JIT コンパイラです。
Ruby 3.2 で本番利用可能な品質となり、Ruby 3.3 ではさらなるパフォーマンス改善が加えられています。
Lazy Basic Block Versioning (LBBV) という手法により、実行時にコードを Basic Block 単位で遅延的にネイティブコードへコンパイルすることで、インタプリタ実行と比較して高速化を実現します。

Ruby 3.3 における YJIT について、詳しくはこちらをご参照ください: https://docs.ruby-lang.org/en/3.3/yjit/yjit_md.html

なお、Ruby 4.0 では YJIT に続く次世代の JIT コンパイラとして ZJIT が実験的に導入されています。
ZJIT は SSA 中間表現を用いたメソッド単位の JIT コンパイラで、より高度な最適化を目指して開発が進められています。
現時点ではまだ YJIT ほどの性能には達しておらず、本番利用可能になるのは Ruby 4.1 以降が見込まれています。

参照: Ruby 4.0 リリースノート

有効化の背景

Rails 7.2 から、load_defaults "7.2" を設定しているアプリケーションにおいて、Ruby 3.3 以上であれば YJIT がデフォルトで有効化されるようになりました(rails/rails#49947, rails/rails#51894)。
新規・既存を問わず、Rails 7.2 のデフォルト設定を適用しているアプリケーションが対象です。

これは Rails コミュニティとして YJIT の安定性と効果を認めた結果といえます。

crowdworks.jp では昨年 Ruby 3.3 系へのアップグレード及び、Rails 7.2 系へのアップグレードが完了しています。
そのため YJIT を有効化できる条件は整っていました。
Rails 本体がデフォルトで有効化する方針を打ち出したことを踏まえ、改めて我々の環境でも有効化を進めることにしました。

対象環境

基本的に Rails の初期化を経て起動されるプロセス全てにおいて YJIT を有効化しました。

特に影響が大きいであろう以下の3つについては、パフォーマンスの監視を Datadog を用いて詳細に行いました。

対象 概要
Puma ユーザーからの HTTP リクエストを処理するサーバ
DelayedJob ワーカー RDB を用いて非同期ジョブを処理するワーカープロセス
Shoryuken ワーカー SQS を利用した非同期ジョブを処理するワーカープロセス

有効化の方法

Rails 7.2 の load_defaults において追加された config.yjit を変更しました。

具体的には、Rails 7.2 へのアップグレード時に

# in config/application.rb
config.yjit = false

と、挙動を変えないように打ち消しをしていたものを、削除(= Rails 7.2 のデフォルト設定である config.yjit = true に)しました。

Ruby の環境変数を用いる方法もありましたが、設定の切り替えが煩雑であることなどから、Rails の config/application.rb で管理することとしています。

また、Ruby の環境変数ではなく Ruby 3.3 で追加された RubyVM::YJIT.enable を使うと、Rails の初期化処理の最後で YJIT の有効化をするように書くこともできます。
ちなみに config.yjit = true などは内部的に RubyVM::YJIT.enable を呼んでいます。

rails/railties/lib/rails/application/finisher.rb at bb2bdef2925433a0c5db31b873f9faddf2e2e65d · rails/rails · GitHub

使い方によっては、一度しか実行されない初期化処理での JIT をスキップでき、結果的に処理効率が良くなる可能性があります。

そのような点でも、Ruby の環境変数ではなく config.yjit = true を使うことによるメリットがあるかもしれません。

計測結果

YJIT 有効化の前後 1週間で、各対象のパフォーマンスを比較しました。
※以下の表における各指標の値は、1週間を通した平均値です。

Puma

指標 有効化前 有効化後 変化
レスポンスタイム (p50) 38.2 ms 33.4 ms -12.6%
レスポンスタイム (p95) 382.9 ms 335.1 ms -12.5%
レスポンスタイム (avg) 94.6 ms 82.5 ms -12.8%

puma のレスポンスタイムのグラフ

DelayedJob

指標 有効化前 有効化後 変化
ジョブ実行時間 (p50) 109 ms 92 ms -15.6%
ジョブ実行時間 (p95) 878 ms 809 ms -7.9%
ジョブ実行時間 (avg) 410 ms 379 ms -7.6%

DelayedJob の処理時間のグラフ

Shoryuken

指標 有効化前 有効化後 変化
ジョブ実行時間 (p50) 69.7 ms 56.6 ms -18.8%
ジョブ実行時間 (p95) 198.0 ms 160.0 ms -19.2%
ジョブ実行時間 (avg) 127.9 ms 116.5 ms -8.9%

Shoryuken の処理時間のグラフ

Puma の上位パーセンタイル

ユーザー体験に直結する Puma について、上位パーセンタイル(p99, p99.9, max)の変化も確認しました。

上位パーセンタイルには、長年の開発で蓄積されたスロークエリや外部 WebAPI 呼び出しの遅延といった、YJIT では改善しようのない外部要因も含まれます。
その点を踏まえたうえで、どの程度の改善が見られたかを確認します。

指標 有効化前 有効化後 変化
レスポンスタイム (p99) 880 ms 770 ms -12.5%
レスポンスタイム (p99.9) 1,600 ms 1,420 ms -11.3%
レスポンスタイム (max) 52.51 s 50.39 s -4.0%

p99 / p99.9 は p50 / p95 と同程度の約 11〜12% の改善が見られました。
一方で max はほぼ横ばい(-4.0%)にとどまっています。
これは、max 付近のリクエストがスロークエリや外部 API 呼び出しなどの Ruby 実行速度以外の要因に支配されているためと考えられます。

所感

puma に関しては avg, p50, p95 全体を通して概ね 12〜13% 前後の改善ができており、YJIT の恩恵を感じました。
上位パーセンタイル(p99, p99.9, max)については、前述の通り I/O バウンドな処理(スロークエリ等)が支配的な領域であり、YJIT による改善が届きにくい部分として改めて浮き彫りになりました。

一方で DelayedJob は p50 付近では大きく改善されていますが、avg, p95 では改善率が伸び悩んでいます。
同様に Shoryuken でも p50, p95 では大きく改善されていますが、 avg の改善率はその半分にも満たない割合です。

DelayedJob および Shoryuken は、グラフを見ても分かる通り、かなりランダムに近い形で処理時間のスパイクが発生するプロセスです。
そのため、この1週間の計測においてたまたま I/O バウンドな処理が多く含まれていた等の理由で、サンプルデータに偏りが生まれている可能性も考えられます。

以上を踏まえると、もう少し長いスパンで DelayedJob および Shoryuken については監視を行い、より長期間での平均的な差を比べる必要がありそうです。
また、必要に応じて、どの時間にどういった内容のジョブが実行されていたのかといった解析も合わせて行うことで、より細かく調査ができると考えています。

まとめ

crowdworks.jp 最大の Rails アプリケーションで YJIT を有効化し、1 週間の観察を行いました。結果を振り返ります。

  • Puma -> レスポンスタイムが全体的に約 12〜13% 改善。上位パーセンタイル(p99, p99.9)でも同程度の改善が確認できた
  • DelayedJob -> ジョブ実行時間が約 8〜16% 改善
  • Shoryuken -> ジョブ実行時間が約 9〜19% 改善。3 対象の中で最も大きな改善幅

なお、YJIT の開発元である Shopify は、Ruby 3.3 の YJIT により本番コードが 15% 高速化したと報告しています(Ruby 3.3's YJIT Runs Shopify's Production Code 15% Faster)。
今回の Puma における約 12〜13% の改善はこれと概ね同程度であり、大規模 Rails アプリケーションにおける YJIT の効果として妥当な結果といえます。

いずれの対象においても、コード変更なしで安定した高速化が得られました。
有効化にあたって特別な対応は不要で、config.yjit = false の打ち消し設定を削除するだけで完了しています。

以上の結果から、Ruby 3.3 以上かつ Rails 7.2 以上を利用しているアプリケーションであれば、YJIT の有効化を検討する価値は十分にあるといえます。

一方で、今回の計測を通じてスロークエリの悪影響が改めて浮き彫りになりました。
YJIT は Ruby の実行速度を底上げしてくれる非常にありがたい存在ですが、最大のボトルネックはやはり RDBMS の操作にあると考えられます。
現在もエンジニア全員で定期的にスロークエリの改善に取り組んでいますが、データ構造起因の問題やデータ量の増加、仕様の複雑さなどから、なかなか改善が進みにくい状態にあります。
YJIT の恩恵を最大限に活かすためにも、スロークエリの改善には引き続き力を入れていく必要があると感じました。

一事例として、なにかの参考になれば幸いです。


crowdworks.jp では、大規模 Rails アプリケーションのパフォーマンス改善やモダナイズに一緒に取り組んでくれるエンジニアを募集しています。
YJIT の有効化のような「まだやれることがある」環境で、一緒にプロダクトを良くしていきませんか?

https://crowdworks.co.jp/careers/mid_career/

© 2016 CrowdWorks, Inc., All rights reserved.