Rails: Active Record 5.2のメモリ肥大化を探る(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

翻訳には含めませんでしたが、元記事のコメントも興味深い内容です。

Rails: Active Record 5.2のメモリ肥大化を探る(翻訳)

Active Recordの現在のパターンはリソースの大量消費につながります。以下はRails 5.2を用いた分析です。


Ruby 3×3計画は、Matz率いるRubyコミュニティの高貴な目標のひとつです。このアイデアは、Rubyインタプリタを3倍高速にできる現代的な最適化を広範囲に渡って用いることであり、野心的かつ崇高かつエキサイティングな目標です。このムーブメントは、just-in-timeコンパイラメモリ肥大化の軽減にかかわる革新的な作業といったRubyコアにおける多数の興味深い実験につながりました。もしRubyが高速化とメモリ削減を実現したら、パフォーマンスの恩恵を誰でも無償で受けられることになり、それこそが私たちが望んでいるものです。

しかし大きな問題は、Rubyがさらなる高速化を達成できるマジック以外のものを当てにできないことです。Rubyがコードの奥深くに潜む(低速な)バブルソートを魔法のように修正できるようになる予定はありません。Active Recordの内部には、世のRubyアプリのほとんどを大きく高速化するために修正されるべき多くの無駄が潜んでいます。Rubyの最大消費者は結局Railsであり、そのRailsはActive Record次第なのです。

悲しいことに、Active RecordのパフォーマンスはRails 2の時代からそれほど向上していません。実際、遅い場合やめちゃくちゃ遅い場合が少なからずあるのです。

無駄の多いActive Record

まずはごく小さなサンプルコードから始めたいと思います。

Topicに含まれる30カラムのテーブルがあるとしましょう。

以下を実行すると、Active Recordのアロケーションがどのぐらいになるかおわかりでしょうか。

a = []
Topic.limit(1000).each do |u|
   a << u.id
end
Total allocated: 3835288 bytes (26259 objects)

上を、同じぐらい非効率な生SQL版と比較してみましょう。

sql = -"select * from topics limit 1000"
ActiveRecord::Base.connection.raw_connection.async_exec(sql).column_values(0)
Total allocated: 8200 bytes (4 objects)

驚くべき無駄の量です。これは次の2つからなります。

  • 著しいメモリ使用量
  • パフォーマンスの低下

訳注: 上のa << u.idという書き方には問題があります。詳しくは週刊Railsウォッチをご覧ください。

しかしこれがActive Recordの残念な点

ここで、私が「遅い」Active Recordコードを書いて、大きく最適化された生SQLコードと比較するのはインチキだという反応がたちどころに起こるでしょう。

次のように書くべきと言われるでしょう。

a = []
Topic.select(:id).limit(1000).each do |u|
  a << u.id
end

この場合の結果は以下のようになるでしょう。

Total allocated: 1109357 bytes (11097 objects)

次のように書けばさらによい結果になるとも言われるでしょう。

Topic.limit(1000).pluck(:id)

この場合の結果は以下のようになるでしょう。

Total allocated: 221493 bytes (5098 objects)

ここまでを取り急ぎまとめてみます。

  • 「生SQL版」で割り当てられるのはわずか4オブジェクトであり、1000個のIntegerを直接返すことができます。これらはRubyのヒープに個別に割り当てられることもありませんし、GC対象スロットになることも多くありません。
  • 「ネイティブ版」Active Recordでは26259個のオブジェクトが割り当てられます。
  • 「わずかに最適化された」Active Recordでは、11097個のオブジェクトが割り当てられます。
  • 「大きく最適化された」Active Recordでは、5098個のオブジェクトが割り当てられます。

上のいずれについても、差は4桁を上回ります。

ナイーブな実装とlazyな実装でのオブジェクトアロケーション数の差

Active RecordがSequelを大きく上回っていると謳っている機能のひとつに、組み込みのlazinessがあります。

何らかの理由で背後のActive Recordで余分なSELECTが行われていた場合、Active Recordのカラムは実際に使われるまでは日付に「キャスト」されることはありません。Sequelはこの点を認識しており、かつ慎重を期しています。

いいえ、Sequelは型変換を遅延実行(defer)しません。型変換はモデルレベルではなく、データセットの取得レベルで発生します。Sequelはその代わりにlazy_attributesプラグインを提供します。これはクエリの途中ではカラムをSELECTせず、必要に応じて新しいクエリを実行…(略)

Sequelが信じられないほど高速かつ高効率であるにもかかわらず、極めて慎重に検討しなければActive RecordからSequelへの移行が途方もなく困難なのは、この理由に尽きます。

高効率なlazyセレクタの「最速」サンプルというものはありません。今回の場合、idを1000個消費しているので、アロケーションが1020個程度に収まる極めて効率の高い実装であれば、Topicオブジェクトのアロケーションは仕方がないと予想するでしょう。26000個ものアロケーションは想定していません。

それをとりあえず試しに実装してみたのが以下のコードです(以下は本記事のアイデアを証明するために書いたものであり、productionレベルのシステム向けではありません)。

$conn = ActiveRecord::Base.connection.raw_connection

class FastBase

  class Relation
    include Enumerable

    def initialize(table)
      @table = table
    end

    def limit(limit)
      @limit = limit
      self
    end

    def to_sql
      sql = +"SELECT #{@table.columns.join(',')} from #{@table.get_table_name}"
      if @limit
        sql << -" LIMIT #{@limit}"
      end
      sql
    end

    def each
      @results = $conn.async_exec(to_sql)
      i = 0
      while i < @results.cmd_tuples
        row = @table.new
        row.attach(@results, i)
        yield row
        i += 1
      end
    end

  end

  def self.columns
    @columns
  end

  def attach(recordset, row_number)
    @recordset = recordset
    @row_number = row_number
  end

  def self.get_table_name
    @table_name
  end

  def self.table_name(val)
    @table_name = val
    load_columns
  end

  def self.load_columns
    @columns = $conn.async_exec(<<~SQL).column_values(0)
      SELECT COLUMN_NAME FROM information_schema.columns
      WHERE table_schema = 'public' AND
        table_name = '#{@table_name}'
    SQL

    @columns.each_with_index do |name, idx|
      class_eval <<~RUBY
        def #{name}
          if @recordset && !@loaded_#{name}
            @loaded_#{name} = true
            @#{name} = @recordset.getvalue(@row_number, #{idx})
          end
          @#{name}
        end

        def #{name}=(val)
          @loaded_#{name} = true
          @#{name} = val
        end
      RUBY
    end
  end

  def self.limit(number)
    Relation.new(self).limit(number)
  end
end

class Topic2 < FastBase
  table_name :topics
end

続いて以下を用いて測定します。

a = []
Topic2.limit(1000).each do |t|
   a << t.id
end
a
Total allocated: 84320 bytes (1012 objects)

つまり、オブジェクトアロケーション数が1012個の同じようなAPIを管理できるということです。オブジェクトアロケーション数が26000個にも達するAPIではなく。

それは果たして問題か

簡単なベンチマークを取ってみればわかります。

Calculating -------------------------------------
               magic    256.149  (± 2.3%) i/s -      1.300k in   5.078356s
                  ar     75.219  (± 2.7%) i/s -    378.000  in   5.030557s
           ar_select    196.601  (± 3.1%) i/s -    988.000  in   5.030515s
            ar_pluck      1.407k (± 4.5%) i/s -      7.050k in   5.020227s
                 raw      3.275k (± 6.2%) i/s -     16.450k in   5.043383s
             raw_all    284.419  (± 3.5%) i/s -      1.421k in   5.002106s

Railsが75回繰り返す間に、上でmagicと書かれている私たちの実装では256回の繰り返しを実行しています。この実装は、繰り返しにおいてRailsの実装よりも著しく改善されており、速度の向上とメモリ割り当ての著しい削減によるプロセスメモリの削減の両面で成果を出しています。余分なSELECTという理想的とは言い難い手法を用いているにもかかわらず、です。実際私たちの実装は、慎重に1カラムだけをSELECTしたRailsすら上回るほど高速です。

言ってみれば、Rubyにまったく手を加えずに達成できるRails 3×3です🎊。

もうひとつ興味深いのは、Railsが提供しなければならないターボの利いたpluckが、生SQLと比べてかなり遅い点です。実際Discourseでは、まさにこの理由でpluckにモンキーパッチを当てました(これのRails 5.2版もあります)。

この肥大化が起きる理由

メモリのプロファイルを見てみると、この肥大化が起きる理由が複数見当たります。

  1. Railsのlazinessが微妙:ここでの1000個もの文字列アロケーションは普通は決して見かけないものです。これでは「lazyアロケーション」ではなく半端な「lazyキャスティング」です。
  2. どの行もオブジェクトアロケーションを3個ずつ追加している(記録とマジックのために): 追加されるのはActiveModel::Attribute::FromDatabaseActiveModel::AttributeSetActiveModel::LazyAttributeHashです。カラムへのインデックスを結果セットに保持する配列を1つ使い回せば、これらはいずれも不要になります。
  3. Railsは、取り出したデータが既に「正しいフォーマット」(数値など)である場合にも、キャストをヘルパーオブジェクトにディスパッチすることにこだわる: これにより余分な帳簿が生成されます。
  4. 使われるあらゆるカラム名がクエリで2度アロケーションされる: これは簡単にキャッシュして再利用可能でしょう(SELECTされたそれらのカラム名をクエリビルダが認識していれば、その結果セットを再度問い合わせる必要はありません)

ではどうするべきか

Active Recordの内部を注意深く点検し、行あたりのオブジェクトアロケーションを著しく削減する実装を検討する必要があるように思えます。また、PG gemのネイティブ型キャストを活用してデータベースから文字列を再度取得することを避け、単に数値に逆変換することも試すべきです。

本記事で評価に用いたスクリプトはGistでご覧いただけます。

関連記事

Ruby/Railsのプロ開発者としての5年間を振り返る(翻訳)

Rails: ActiveRecord::Relationで生SQLは避けよう(翻訳)

デザインも頼めるシステム開発会社をお探しならBPS株式会社までどうぞ 開発エンジニア積極採用中です! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833 コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。 これまでにRuby on Rails チュートリアル第2版の半分ほど、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れてそれぞれ一部を翻訳。 かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。 実は最近Go言語が好き。 仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

週刊Railsウォッチ

インフラ

ActiveSupport探訪シリーズ