⚖️

[Rails] ActiveRecordオブジェクトを生成せずに節約されたメモリを計測する

2023/12/13に公開

はじめに

ActiveRecordの機能が必要ではない時は可能な限りHashを利用してActiveRecordオブジェクトを生成しない方がパフォーマンスが良かったりメモリを食わないためよさそうだと思う場面が多いと思いますが、反面実際本当にそうなんだろうか?と思うこともあります

本記事では実際にメモリがどのくらい節約されるのか計測していきます

Rubyでメモリ使用量を計測する方法

Rubyではあるオブジェクトのメモリ使用量をObjectSpace.memsize_ofを使って計測できます
バージョン1.9から入っているようです
https://docs.ruby-lang.org/ja/latest/method/ObjectSpace/m/memsize_of.html

require 'objspace'
some_object = SomeObject.new
ObjectSpace.memsize_of(some_object)
#=> 168 (byte)

ObjectSpace.memsize_ofActiveRecordHashを計測する

この方法でActiveRecordオブジェクトとその値だけを取り出してHashにしたものを計測したところ以下のようになりました

user = User.first
ObjectSpace.memsize_of(user)
#=> 128 (bytes)

user_attributes = user.attributes # Hash化
ObjectSpace.memsize_of(user_attributes)
#=> 1760 (bytes)

Hashの方がメモリ使用量が少ないという想定とは真逆な結果となりました
Hashなどの組み込みクラスに対してユーザー定義クラスのインスタンス場合、計測対象が想定していたものと違うのではと思いユーザー定義クラスに対して以下の実験をしました

インスタンスがObjectSpace.memsize_ofで想定通り計測されているか実験

# 1000字の文字列を計測
str = "a" * 1000
ObjectSpace.memsize_of(str)
#=> 1041 (bytes)
# インスタンス変数に1000字の文字列が保存されるクラスのインスタンスを計測
class Hoge1
  def initialize
    @str = "a" * 1000
  end
end
hoge1 = Hoge1.new
ObjectSpace.memsize_of(hoge1)
#=> 40 (bytes)

インスタンス変数の分メモリ使用量が増えることを期待していましたが増えていませんでした

色々調べているとこんな記事を見つけました
Rubyプロセスの使用メモリ量の計測

ObjectSpace#memsize_ofメソッドを使うと対象のオブジェクトが消費しているメモリ量を取得できます。
ただし、対象のオブジェクトが他のオブジェクトを保有している場合は、ポインタのメモリ量しか計測しないため、「対象のオブジェクトに関連する全てのオブジェクトのメモリ量ではない」ということに注意する必要があります。
関連する全てのオブジェクトのメモリ量を取得するには、reachable_objects_fromメソッドを使って関連するオブジェクトをたどる必要があります。詳細は以下のリンクを参照してください。

要するに上記のhoge1は「クラスのアドレス」と「インスタンス変数のアドレス」あたりを保持しているけどクラスの実装(メソッドなど)やインスタンス変数の値自体は直接保持していないようです

実際にインスタンス変数の数を増やしてみるとその数に応じて徐々にインスタンスのメモリ使用量が増えていました

class Hoge2
  def initialize
    @str_1 = "1" * 1000
    ...
    @str_9 = "9" * 1000
  end
end
hoge = Hoge2.new
ObjectSpace.memsize_of(hoge2)
#=> 128

ObjectSpace.reachable_objects_fromhoge2を見てみます

ObjectSpace.reachable_objects_from(hoge2)
#=> [
#   #<Class:#<Hoge:0x0000ffff9ca0c188>>,
#   "11...11",
#   ...,
#   "99...99",
# ]

reachable_objects_fromは想定通りの値を返してくれています

"reachable_objects"を含めて https://www.atdot.net/~ko1/diary/201212.html#d8 を参考に再度計算するメソッドを利用することにします

def memsize_of_all_reachable_objects_from(obj, exclude_class = Module)
  # ...
  # 実装は https://www.atdot.net/~ko1/diary/201212.html#d8 を参照してください
  # ...
  objs.inject(0){|r, (_, o)| r += ObjectSpace.memsize_of(o)}
end

ObjectSpace.reachable_objects_fromを利用して計測する

hoge1 = Hoge1.new
memsize_of_all_reachable_objects_from(hoge1)
#=> 1081 (bytes)

hoge2 = Hoge2.new
memsize_of_all_reachable_objects_from(hoge2)
#=> 9497 (bytes)

それぞれいい感じに期待付近の値が返ってきました、様様です


それではActiveRecordHashも計測してみます

user = User.first
memsize_of_all_reachable_objects_from(user)
#=> 15249 (bytes)

user_attributes = user.attributes
memsize_of_all_reachable_objects_from(user_attributes)
#=> 5109 (bytes)

一応1/3ほどに節約できているようです!(思ったより少ない)

psコマンドを利用して計測する

再度こちら(Rubyプロセスの使用メモリ量の計測)を参考に計測

まずprint_memory_usage_before_and_after_diffを用意します

def memory_usage
  rss = `ps -o rss= -p #{Process.pid}`.to_i * 0.001
  vsz = `ps -o vsz= -p #{Process.pid}`.to_i * 0.001
  [rss, vsz]
end

def print_memory_usage_before_and_after_diff
  before_rss, before_vsz = memory_usage
  yield
  after_rss, after_vsz = memory_usage
  puts "rss: #{after_rss - before_rss}MB increased"
  puts "vsz: #{after_vsz - before_vsz}MB increased"
end

RSSはResident Set Sizeの頭字語で、プロセスが確保している物理メモリサイズ
VSZはVirtual Memory Sizeの頭字語で、プロセスが確保している仮想メモリサイズ
らしいです

ではActiveRecordHashを計測します
プロセス全体のメモリを計測するため他の要因が入ってきてしまうので、影響を小さくするためにオブジェクト数を各々1000にします
それぞれ実行するたびにプロセスを切って初期状態で何度か実行しました

active_record_objects = []
print_memory_usage_before_and_after_diff do
  1000.times do
    active_record_objects << User.first
  end
end
#=> rss: 6.656000000000006MB increased
#   vsz: 5.744000000000028MB increased
hash_objects = []
print_memory_usage_before_and_after_diff do
  column_names = User.column_names
  1000.times do  
    user_values = User.limit(1).pluck(*column_names)
    hash_objects << column_names.zip(user_values).to_h
  end
end
#=> rss: 6.783999999999992MB increased
#   vsz: 6.432000000000016MB increased

結果Hashの方がメモリを若干食っているようです
何度か実行しましたが大体上記のような数値でした
Hashオブジェクトを生成するコードの書き方が悪いかもしてないですがその上で上回って欲しかった...

おわりに

結局どっちが総合的に良いのかですが、、
ここで言うまでもなくActiveRecordの機能を利用することがないならHashで十分だと思います
メモリ使用量に関しては2つの計測方法で片方はHashの勝ち、もう片方はほぼ同等だったのでHashの勝ち越しです
その上今回はメモリ使用量を計測しましたが、演算速度とか他にも指標はあるためです
ActiveRecordとHashでオブジェクト生成するのに必要な演算量、どちらが多いかはほぼほぼActiveRecordだと思います

本記事を書くにあたって色々調べて勉強になったし、さらに深入りする入り口をいくつか見つけたので下に貼っておきます
最近バイナリ解析に興味がわいているのでメモリ使用量40(bytes)とかの中身を覗いたりしてみたいなとか
もし気力まかせに行動に至った時は記事にしてみますのでまたいつか

参考文献

Discussion