🐈‍⬛

Ruby / Rails のパフォーマンスTips3選

2024/12/20に公開

はじめに

この記事はSmartHR Advent Calendar 2024シリーズ2の20日目です。

SmartHRでプロダクトエンジニアをしている @kazukun です。
2024年はHRアナリティクスという分析機能の開発に従事しました。
その際、主にパフォーマンスまわりで実装に気をつけたポイントをおさらいします。

前提条件

開発用PC(MacBook Pro)で以下の環境を用意しています。

  • Ruby: 3.3.3
  • Rails: 7.1.3.4
  • PostgreSQL: 15

本記事で使用するテーブル群です。

各テーブルのレコード数です。

User.count
# => 100000

Department.count
# => 5175

UserDepartment.count
# => 100000

UserCustomFieldTemplate.count
# => 4

UserCustomField.count
# => 400000

ベンチマークやクエリ発行回数の取得のために、以下のメソッドを使用します。

# ベンチマーク取得用
def measure_benchmark(**tasks)
  Benchmark.bm do |x|
    tasks.each do |task_name, task_proc|
      x.report(task_name) { task_proc.call }
    end
  end
end

# クエリ発行回数取得用
def count_queries(&block)
  query_count = 0

  ActiveSupport::Notifications.subscribed(lambda { |*args|
    payload = args.last
    query_count += 1 unless payload[:name].in? %w(SCHEMA TRANSACTION)
  }, "sql.active_record") do
    block.call
  end

  query_count
end

sortよりsort_byを使おう

たとえば、ユーザーの一覧を柔軟に並べ替えたい、という仕様があったとします。
ここでの「柔軟に」とは、複数の並べ替え項目を設定でき、それぞれ昇順と降順を選べる、という意味です。

sortを使った場合

この要件を叶えるため、まず Enumerable#sort で実装してみます。以下のようなコードです。
sortブロック内で毎回sort_keysメソッドが評価されるので、パフォーマンス的に怪しそうですね。

class UsersSorter
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :users
  attribute :orders

  def sort_by_order_keys
    users.sort do |user_a, user_b|
      sort_keys(user_a, user_b, 0) <=> sort_keys(user_a, user_b, 1)
    end
  end

  private

  def sort_keys(user, other_user, operand)
    orders.map do |order|
      code = order.code
      target_user = (order.asc ? [user, other_user] : [other_user, user])[operand]
      infinity = order.asc ? Float::INFINITY : -Float::INFINITY

      case code
      when "employee_number", "name", "birthday"
        default_sort_key(target_user, code, infinity)
      when "gender"
        default_sort_key(target_user, :gender_before_type_cast, infinity)
      when /\Acustom_.+/
        template_id = code.delete_prefix("custom_")
        user_custom_field = target_user.user_custom_fields.detect { _1.user_custom_field_template_id == template_id }
        field_type = template_id_field_type_hash[template_id]
        default_sort_key(user_custom_field, :"#{field_type}_value", infinity)
      else
        raise "Unexpected code: #{code}"
      end
    end + [[user.id, other_user.id][operand]]
  end

  def template_id_field_type_hash
    @template_id_field_type_hash ||= UserCustomFieldTemplate.pluck(:id, :field_type).to_h
  end

  def default_sort_key(target_record, attr, infinity)
    target_record&.__send__(attr) || infinity
  end
end

このコードが実際にどのくらいパフォーマンスなのか、計測してみます。
並べ替え条件は、第1ソートが性別(user.gender)の降順、第2ソートが社員番号(user.employee_number)の降順、第3ソートが数値カスタム項目(user_custom_field.decimal_value)の昇順です。

users = User.preload(user_custom_fields: :user_custom_field_template).load

Order = Struct.new(:code, :asc)
orders = [
  Order.new(code: "gender", asc: false),
  Order.new(code: "employee_number", asc: false),
  Order.new(code: "custom_#{UserCustomFieldTemplate.decimal[0].id}", asc: true),
]

sorter = UsersSorter.new(users:, orders:)

measure_benchmark("sort:": -> { sorter.sort_by_order_keys })
#         user     system      total        real
# sort:  49.313671   0.578563  49.892234 ( 50.285167)

結果、約50秒かかりました。Userのレコード数は10万件ですが、それを考慮しても改善は必須です。

sort_byを使った場合

パフォーマンス改善のために、Enumerable#sort_by で実装を修正してみます。
prepare_sort_keysメソッドを一度だけ評価し、以降はキャッシュした結果を使うのがポイントです。
また、複数のデータ型で昇順と降順を組み合わせて並べ替えできるように、ソートのキーを数値もしくは数値の配列に変換しています。

class UsersSorter
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :users
  attribute :orders

  def sort_by_order_keys
    users.sort_by { |user| prepare_sort_keys[user.id] }
  end

  private

  def prepare_sort_keys
    @prepare_sort_keys ||= users.each_with_object({}) do |user, hash|
      hash[user.id] = orders.map do |order|
        code = order.code
        infinity = order.asc ? Float::INFINITY : -Float::INFINITY

        key = case code
              when "employee_number", "name"
                string_sort_key(user, code, infinity)
              when "birthday"
                date_sort_key(user, code, infinity)
              when "gender"
                default_sort_key(user, :gender_before_type_cast, infinity)
              when /\Acustom_.+/
                template_id = code.delete_prefix("custom_")
                user_custom_field = user.user_custom_fields.detect { _1.user_custom_field_template_id == template_id }
                field_type = template_id_field_type_hash[template_id]
                sort_method_prefix = { string: :string, text: :string, date: :date, decimal: :default }[field_type.to_sym]
                __send__(:"#{sort_method_prefix}_sort_key", user_custom_field, :"#{field_type}_value", infinity)
              else
                raise "Unexpected code: #{code}"
              end

        order.asc ? key : invert_sort_key(key)
      end + [user.id]
    end
  end

  def invert_sort_key(key)
    case key
    in [*nums] if nums.all?(Numeric)
      nums.map(&:-@)
    in Numeric
      -key
    else
      key
    end
  end

  def template_id_field_type_hash
    @template_id_field_type_hash ||= UserCustomFieldTemplate.pluck(:id, :field_type).to_h
  end

  def string_sort_key(target_record, attr, infinity)
    target_record&.__send__(attr).to_s.chars.map(&:ord).presence || [infinity]
  end

  def date_sort_key(target_record, attr, infinity)
    target_record&.__send__(attr)&.to_time&.to_i || infinity
  end

  def default_sort_key(target_record, attr, infinity)
    target_record&.__send__(attr) || infinity
  end
end

修正版のコードで、実際にどのくらいパフォーマンスが改善するか、計測してみます。
並べ替え条件はsortと同じく、第1ソートが性別(user.gender)の降順、第2ソートが社員番号(user.employee_number)の降順、第3ソートが数値カスタム項目(user_custom_field.decimal_value)の昇順です。

users = User.preload(user_custom_fields: :user_custom_field_template).load

Order = Struct.new(:code, :asc)
orders = [
  Order.new(code: "gender", asc: false),
  Order.new(code: "employee_number", asc: false),
  Order.new(code: "custom_#{UserCustomFieldTemplate.decimal[0].id}", asc: true),
]

sorter = UsersSorter.new(users:, orders:)

measure_benchmark("sort_by:": -> { sorter.sort_by_order_keys })
#            user     system      total        real
# sort_by:  2.640865   0.112585   2.753450 (  2.770077)

結果、3秒弱まで改善できました。sortに比べてsort_byが高速なのがわかりますね。

ancestryと仲良くなろう

つづいて、階層構造を持つデータについてです。
たとえば、指定された部署IDをもとに、その配下の部署IDも含めて取得したいケースを考えましょう。
ちなみに階層構造は、ancestry というgemを使用して実現しています。

subtree_idsを使った場合

指定された部署ID群およびその配下の部署ID群の取得は、ancestryが提供する subtree_ids を使って実現できそうです。

class Department < ApplicationRecord
  has_ancestry primary_key_format: /[-A-Fa-f0-9]{36}/, ancestry_format: :materialized_path2

  class << self
    def find_subtree_ids(department_ids)
      where(department_id: department_ids).map(&:subtree_ids).flatten
    end
  end
end

とてもシンプルに実装できましたね。このメソッドを使用して、クエリ発行回数を計測してみます。

target_department_ids = Department.first(100).pluck(:id)

count_queries do
  Department.find_subtree_ids(target_department_ids).uniq
end
# => 101

指定した部署ID数が増えるにつれ、クエリ発行回数も増えていきます。もう少し改善できそうですね。

arrangeを使った場合

ancestryが提供する arrange メソッドで同じことを実現できます。
arrangeで階層構造をHash形式で取得し、それを再帰的に処理することで、配下部署を含めた部署IDを取得しています。

class Department < ApplicationRecord
  has_ancestry primary_key_format: /[-A-Fa-f0-9]{36}/, ancestry_format: :materialized_path2

  class << self
    def find_subtree_ids(department_ids)
      # 部署の階層構造をHashで取得
      dep_subtrees = arrange

      # 指定されたサブツリーから、対象の部署IDおよびその配下の部署IDのみを抽出するlambda
      extract_target_dep_ids = lambda do |subtrees, target_ids, is_target = false|
        subtrees.each_with_object([]) do |(parent, children), results|
          is_descendant = is_target || target_ids.include?(parent.id)
          results.push(parent.id) if is_descendant
          results.concat(extract_target_dep_ids.call(children, target_ids, is_descendant))
        end
      end

      # lambdaを呼び出す
      extract_target_dep_ids.call(dep_subtrees, department_ids)
    end
  end
end

改善したメソッドのクエリ発行回数を計測してみましょう。

target_department_ids = Department.first(100).pluck(:id)

count_queries do
  Department.find_subtree_ids(target_department_ids)
end
# => 1

指定する部署IDがどれだけ増えても、クエリ発行回数は1回に限定できます。

Hashを活用して計算量を削減しよう

配列のままデータを扱うのか、ハッシュに変換してから扱うのか、でもパフォーマンスに影響が出ることがあります。
以下のコードでは、idとnameを持つユーザーデータを100万件生成しています。また、対象となる4種類のIDの配列(10件分、100件、1000件、10000件)を保持しています。

SampleUser = Struct.new(:id, :name)

users = (1..1_000_000).each_with_object([]) do |i, arr|
  arr << SampleUser.new(SecureRandom.uuid, "user-#{i}")
end

target_user_ids_10 = users.sample(10).pluck(:id)
target_user_ids_100 = users.sample(100).pluck(:id)
target_user_ids_1000 = users.sample(1000).pluck(:id)
target_user_ids_10000 = users.sample(10000).pluck(:id)

それぞれのID群に紐づくユーザー名を、Enumerable#detectEnumerable#filterHash#[]を使ってそれぞれ取得し、パフォーマンスにどのくらい差が出るか見てみましょう。

detectを使った場合

measure_benchmark(
  "detect 対象ID数10件:": -> do
    target_user_ids_10.each do |id|
      users.detect { _1.id == id }.name
    end
  end,
  "detect 対象ID数100件:": -> do
    target_user_ids_100.each do |id|
      users.detect { _1.id == id }.name
    end
  end,
  "detect 対象ID数1000件:": -> do
    target_user_ids_1000.each do |id|
      users.detect { _1.id == id }.name
    end
  end,
  "detect 対象ID数10000件:": -> do
    target_user_ids_10000.each do |id|
      users.detect { _1.id == id }.name
    end
  end,
)
#                       user     system      total        real
# detect 対象ID数10件:  0.591503   0.006023   0.597526 (  0.625935)
# detect 対象ID数100件:  7.234028   0.038343   7.272371 (  7.396259)
# detect 対象ID数1000件:  69.891964   0.374523   70.266487 ( 71.383391)
# detect 対象ID数10000件:  683.485529   3.602129   687.087658 (695.061762)

今回のケースではdetectを使うと非効率であることがわかります。
対象のID数が増えるにつれ、急激にパフォーマンスが悪化していますね。

filterを使った場合

measure_benchmark(
  "filter 対象ID数10件:": -> do
    users.filter { target_user_ids_10.include?(_1.id) }.map(&:name)
  end,
  "filter 対象ID数100件:": -> do
    users.filter { target_user_ids_100.include?(_1.id) }.map(&:name)
  end,
  "filter 対象ID数1000件:": -> do
    users.filter { target_user_ids_1000.include?(_1.id) }.map(&:name)
  end,
  "filter 対象ID数10000件:": -> do
    users.filter { target_user_ids_10000.include?(_1.id) }.map(&:name)
  end,
)
#                       user     system      total        real
# filter 対象ID数10件:  0.229769   0.001474   0.231243 (  0.231815)
# filter 対象ID数100件:  0.982028   0.005082   0.987110 (  0.989089)
# filter 対象ID数1000件:  8.534463   0.043995   8.578458 (  8.606246)
# filter 対象ID数10000件:  97.834058   0.583361   98.417419 ( 99.134713)

detectほどではありませんが、そこまで効率のよい探索方法ではありませんね。
対象のID数に比例して、パフォーマンスは悪化しています。

[]を使った場合

measure_benchmark(
  "[] 対象ID数10件:": -> do
    users_hash = users.pluck(:id, :name).to_h
    target_user_ids_10.each do |id|
      users_hash[id]
    end
  end,
  "[] 対象ID数100件:": -> do
    users_hash = users.pluck(:id, :name).to_h
    target_user_ids_100.each do |id|
      users_hash[id]
    end
  end,
  "[] 対象ID数1000件:": -> do
    users_hash = users.pluck(:id, :name).to_h
    target_user_ids_1000.each do |id|
      users_hash[id]
    end
  end,
  "[] 対象ID数10000件:": -> do
    users_hash = users.pluck(:id, :name).to_h
    target_user_ids_10000.each do |id|
      users_hash[id]
    end
  end,
)
#                   user     system      total        real
# [] 対象ID数10件:  0.486727   0.009793   0.496520 (  0.497423)
# [] 対象ID数100件:  0.478129   0.006832   0.484961 (  0.487415)
# [] 対象ID数1000件:  0.504693   0.006387   0.511080 (  0.513816)
# [] 対象ID数10000件:  0.487489   0.006453   0.493942 (  0.494768)

detectやfilterと比較すると効率が良いことがわかります。
対象ID数が増加してもパフォーマンスは悪化しませんね。

おわりに

以上、Ruby / Railsのパフォーマンスに関するTipsを3点ご紹介させていただきました。
基礎的な内容が多かったと思いますが、参考になる点が少しでもあれば幸いです。
皆さんも、よいRuby / Railsライフを!👋

Discussion