Ruby 3.3でのアップデートも要チェック!まちのコインでYJITを有効化したはなし

SREチームの長田です。 今回はRubyのJITコンパイラであるYJITのはなしです。

カヤックが開発・運用している地域通貨サービス「まちのコイン」は、Ruby on Railsを使用しています。 このまちのコインにてYJITを有効化し、その結果どのような影響があったのかを紹介します。

coin.machino.co

YJITとは

YJITは RubyのJITコンパイラです。 Ruby 3.1までは実験的な機能という位置づけでしたが、 Ruby 3.2から実用段階となりました。

Basic Block Versioning (BBV)を採用した遅延コンパイルにより、コード実行の高速化を図っているようです。 YJITそのものの話題については、今回は割愛させていただきます。

まちのコインの状況

まちのコインでは昨年6月末頃に Ruby 3.1.x から Ruby 3.2.x にアップデートを行いました。 その時点でYJITを利用できるようにはなっていたわけですが、当時はまだ「有効にしたらどうなるかねー」という会話があった程度でした。

その後他社の本番環境でのYJIT有効化事例が見られるようになり、 そろそろうちでも有効にしてみるかーとようやく動き始めたのが昨年の12月頃でした。

YJIT有効化・・・の前に

まずは計測できる状態にしなければ効果が測れないので、YJITのstatsをメトリクスとして取得するようにしました。

YJITのstatsは RubyVM::YJIT.runtime_stats で取得できます。 アプリケーションがリクエストを受けるたびにこれをログ出力しています。 必要に応じて一定の確率でログ出力するなどしてログの量を抑制すると良いでしょう。

Rails.logger.info(
    {
        app_mode: ENV.fetch('APP_MODE', nil),
        **RubyVM::YJIT.runtime_stats,
    },
)

app_mode はアプリケーションの動作モードです。 まちのコインではひとつのRailsアプリがいくつかのモードで動作しているので、 これを区別するためにログに含めています。

RailsアプリはECS上で動作しているので、出力したログは Amazon CloudWatch Logs に集約されます。 これに対して Amazon CloudWatch Metric Filter を設定し、でメトリクスとして集計・投稿しています。

以下に Terraform を使った設定例を示します。 今回は継続的に観察するメトリクスとして code_region_sizeyjit_alloc_size を選びました。

resource "aws_cloudwatch_log_metric_filter" "app-ruby-yjit" {
  for_each = toset([
    "code_region_size",
    "yjit_alloc_size",
  ])

  name           = "ruby-yjit-${each.key}"
  log_group_name = aws_cloudwatch_log_group.ecs-task["app"].name
  pattern        = "{$.${each.key} = *}"

  metric_transformation {
    namespace = "App/RubyYJIT"
    name      = each.key
    value     = "$.${each.key}"
    unit      = "Bytes"

    dimensions = {
      app_mode = "$.app_mode"
    }
  }
}

他のメトリクスについても必要があれば for_each で処理するリストに追加することになります。 CloudWatch に送信していないメトリクスを一時的に眺めたい場合は、CloudWatch Logs Insights でログを集計しています。

これで計測の準備ができたので、次はいよいよ有効化です。

YJITの有効化

まずは有効にして様子を見てみないとなんともならんということで、 RUBY_YJIT_ENABLE=1 を設定してデプロイしてみたところ、 APIのレスポンスタイムが2倍程度に悪化しました

悪化の原因はYJIT有効化によるメモリ使用量の増加でした。 まちのコインのRailsアプリはHTTPサーバーとしてunicornを使用しています *1。 YJIT有効化後にunicornのworkerが使用するメモリ量が増え、 unicorn worker killer に頻繁にkillされるようになり、 workerプロセスの再起動が頻発。 結果としてAPIレスポンスタイムが悪化した、というものでした。

対応として、unicorn worker killerの閾値調整と、ECS Taskに割り当てるメモリ量を増やしました。 元の閾値が必要以上に小さかったということも影響していたようです。 対応の結果、APIレスポンスタイムが平均 9%程度短縮 されました。 パラメータ変更のみで10%近く短縮できたのは大きいですね。

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

また、CPU使用率にも若干の減少が見られました。

CPU使用率のグラフ

Ruby 3.3.0 へのアップデート

昨年12月にリリースされた Ruby 3.3.0。 リリースノート によると YJITのパフォーマンスおよびメモリ使用量が大幅に改善したとのことだったので、早速試してみることにしました。

Ruby 3.2系から3.3系への変更は、インターフェイスの変更が含まれていないこともあり、ほぼ工数ゼロで移行完了しました。 しかし、 APIレスポンスタイムの短縮という観点では、ほとんど効果は見られませんでした

unircorn の after_fork でYJITを有効化

Ruby 3.3 からYJITに追加された機能として、ランタイムでの有効化があります。

  • RubyVM::YJIT.enable を追加し、実行時にYJITを有効にできるようにしました
    • コマンドライン引数や環境変数を変更せずにYJITを開始できます。Rails 7.2はこの方法を使用して デフォルトでYJITを有効にします。
    • これはまた、アプリケーションの起動が完了した後にのみYJITを有効にするために使用できます。YJITの他のオプションを使用しながら起動時にYJITを無効にしたい場合は、--yjit-disable を使用できます。

Ruby 3.3.0 リリース より

YJITの有効化をアプリケーション起動時ではなく、 unicorn workerのfork後に行うことでメモリ使用量を抑えられる場合があるとのことでしたので、 config/unicorn.rb に以下のようなコードを追加しました。

after_fork do |_server, _worker|
  if rails_env == 'production'
    RubyVM::YJIT.enable
    Rails.logger.info('YJIT enabled')
  end
end

結果としては、20MB程度だった code_region_size が15MB程度に減少しました (オレンジの線がmax、青の線がaverage)。

code_region_sizeのグラフ

しかし、もともとの code_region_size が少なかったため、 ECS Task全体で見たときのメモリ使用量減少についてはあまり効果がありませんでした。

--yjit-exec-mem-size の調整

--yjit-exec-mem-size はYJITが生成するコード量を制限する設定値です。 YJITのドキュメントには以下のように説明されています ((--yjit-exec-mem-size のデフォルト値は、以降のバージョンで48MiBに変更されるようです。 https://github.com/ruby/ruby/pull/9685))

--yjit-exec-mem-size=N: size of the executable memory block to allocate, in MiB (default 64 MiB)

前述のとおり、code_region_size は15MB程度と小さかったため、調整は行いませんでした。

より小さな値を設定すればその分メモリ量は削減できるはずですが、 例えば --yjit-exec-mem-size=8 として半分に制限したとしても、 現状のECS Taskあたりのunicorn worker数16 * 8MiB = 128MiB 程度しか削減できないことになります。

--yjit-call-threshold の調整

--yjit-call-threshold はYJITがコンパイルするメソッドの呼び出し回数の閾値です。

こちらもYJITのドキュメントを参照すると、以下のように説明されています。

--yjit-call-threshold=N: number of calls after which YJIT begins to compile a function. It defaults to 30, and it's then increased to 120 when the number of ISEQs in the process reaches 40,000.

デフォルト値は30ですが、説明の後半にあるようにISEQs(コンパイルされたバイトシーケンス量)が40,000に達すると 自動で120に引き上げられるようです。 コンパイル量が一定以上になると新たにコンパイルされにくくなるということですね ((YJITのstatsには compiled_iseq_count というISEQsを得られるものがあり、 この値と照らし合わせて、自動引き上げの閾値である40,000を超えているかどうかを確認するとよさそうです。 まちのコインの場合は5500〜6500程度でした))。

こちらもは挙動の確認をしたかったので30→60→120と変化させて観察してみたのですが、 code_region_size に影響が出るほどではありませんでした。 こちらも元の code_region_size が小さいため、観測できるほどの変化がなかったようです。

まとめ

YJITを有効化するだけで平均9%のレスポンスタイム短縮効果が得られたのは、非常にコスパの良いチューニングでした。 他社事例のように10%単位での高速化は見られませんでしたが、それでも十分な結果と言えるでしょう。

YJIT有効化部分を読んで、「そんなにいきなり有効にして大丈夫なの?」と思った方もいるかもしれません。 実際のところ厳密な回帰テストなどは行わず、本番環境適用前の検証はCIの通過と、 クライアントアプリとの疎通確認環境での簡単な動作確認のみでした。

検証を簡略化できた理由として、 「エラーバジェットの残量が十分にあった」こと、 「不具合が発生したとしてもすぐにロールバックできる仕組みがある」こと、 の2点が挙げられます。

「YJITの有効化」で触れたとおり、有効化直後はレスポンスタイムの悪化がありましたが、 エラーバジェットを枯渇させるほどのものではありませんでした。 今回の件は、エラーバジェットを正しく使えた事例としても意義があったと思います。

これからもエラーバジェットをうまく使って、新しい技術や最新のアップデートを取り入れていきたいと考えています。

参考資料


カヤックでは試行錯誤が好きなエンジニアを募集しています! hubspot.kayac.com

*1:最近のRailsでは、デフォルトのHTTPサーバーとしてpumaが使われています。 まちのコインではマルチテナントをデータベースレベルで実現するためにApartmentを使用しており、 これがマルチスレッド方式であるpumaとの相性が悪く、pumaではなくunicornを採用したという歴史があります。 まちのコインのApartmentについては こちらの記事 を御覧ください。