概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: An analysis of memory bloat in Active Record 5.2
- 原文公開日: 2018/06/01
- 著者: Sam Saffron -- Discourseの共同創業者であり、Stack Overflowでの開発経験もあります。
翻訳には含めませんでしたが、元記事のコメントも興味深い内容です。
Rails: Active Record 5.2のメモリ肥大化を探る(翻訳)
Active Recordの現在のパターンはリソースの大量消費につながります。以下はRails 5.2を用いた分析です。
Ruby 3x3計画は、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 3x3です🎊。
もうひとつ興味深いのは、Railsが提供しなければならないターボの利いたpluck
が、生SQLと比べてかなり遅い点です。実際Discourseでは、まさにこの理由でpluck
にモンキーパッチを当てました(これのRails 5.2版もあります)。
この肥大化が起きる理由
メモリのプロファイルを見てみると、この肥大化が起きる理由が複数見当たります。
- Railsのlazinessが微妙:ここでの1000個もの文字列アロケーションは普通は決して見かけないものです。これでは「lazyアロケーション」ではなく半端な「lazyキャスティング」です。
- どの行もオブジェクトアロケーションを3個ずつ追加している(記録とマジックのために): 追加されるのは
ActiveModel::Attribute::FromDatabase
、ActiveModel::AttributeSet
、ActiveModel::LazyAttributeHash
です。カラムへのインデックスを結果セットに保持する配列を1つ使い回せば、これらはいずれも不要になります。 - Railsは、取り出したデータが既に「正しいフォーマット」(数値など)である場合にも、キャストをヘルパーオブジェクトにディスパッチすることにこだわる: これにより余分な帳簿が生成されます。
- 使われるあらゆるカラム名がクエリで2度アロケーションされる: これは簡単にキャッシュして再利用可能でしょう(SELECTされたそれらのカラム名をクエリビルダが認識していれば、その結果セットを再度問い合わせる必要はありません)
ではどうするべきか
Active Recordの内部を注意深く点検し、行あたりのオブジェクトアロケーションを著しく削減する実装を検討する必要があるように思えます。また、PG gemのネイティブ型キャストを活用してデータベースから文字列を再度取得することを避け、単に数値に逆変換することも試すべきです。
本記事で評価に用いたスクリプトはGistでご覧いただけます。