はじめに
この記事では、Ruby/Railsのメモ化 をハンズオンで確認します。
「メモ化をすると内部で何が起きるのか?」という疑問に対して、低レイヤ(参照・割り当て・GC)の視点で手を動かして確かめます。
背景・動機
Rails で I/O(DB/HTTP)を伴う処理にメモ化が効くのは直感的に分かります。同じ結果を再利用できれば、DB への複数回アクセスを避けられるからです。
一方で、I/O がない純計算でも「毎回新しいオブジェクトを確保→破棄(GC)」を繰り返すと、割り当て回数や GC 負荷が増えて遅くなります。
内部で何が起きているのかを具体的に理解するため、今回の記事を書きました。
それでは、手を動かして確認していきます。
実例・やってみたこと
サンプルコードを書いて、3つの観点から確認します。
- 同じ参照か?: Object_id
- 割り当て回数:GC
- 速度:Benchmark
サンプルコードは以下になります。
require 'benchmark' def build_obj # I/Oの代わりに「確保がちょっと重い」処理を模擬 Array.new(10_000) { rand }.sum "x" * 10_000 end def no_memo build_obj end def memorized @memo ||= build_obj end puts "=== object_id ===" 3.times { p [:no_memo, no_memo.object_id] } 3.times { p [:memo, memorized.object_id] } puts "=== allocations (GC.stat) ===" GC.start before = GC.stat(:total_allocated_objects) 1000.times { no_memo } after = GC.stat(:total_allocated_objects) puts "no_memo allocations: #{after - before}" GC.start before = GC.stat[:total_allocated_objects] 1000.times { memorized } after = GC.stat[:total_allocated_objects] puts "memorized allocations: #{after - before}" puts "=== speed (Benchmark.bm) ===" Benchmark.bm do |x| x.report("no_memo x10000") { 10_000.times { no_memo } } x.report("memorized x10000"){ 10_000.times { memoized } } end
結果は以下のようになりました。
=== object_id === [:no_memo, 60] [:no_memo, 80] [:no_memo, 100] [:memo, 120] [:memo, 120] [:memo, 120] === allocations (GC.stat) === no_memo allocations: 2002 memorized allocations: 2 === speed (Benchmark.bm) === user system total real no_memo x10000 4.498254 0.085579 4.583833 ( 4.584453) memorized x10000 0.000541 0.000005 0.000546 ( 0.000545)
結果からわかること
- object_id:
- no_memoは毎回異なる。(毎回新規に割り当てがされている)
- memorizedは毎回同じ。(最初に割り当てられた参照を繰り返している)
- GC:
- memorizedのallocations(割り当て回数)が圧倒的に少ない。
- Benchmark:
- memoirzed の方が速い(割り当てとGCが減るため)
学び・気付き
- メモ化の本質は、「同じ参照を返す」ことによって、割り当て削減やGC負荷を減らすこと。
- メモリ消費がゼロにはならない (1つは必ず必要)
- I/Oがなくても、効果がある。重い計算(複雑ロジック)や大きなオブジェクトがあるのなら、メモ化で改善できる。
まとめ
とりあえずメモ化すれば良いと思っていましたが、内部がどのように動作しているかを調べることで、なぜメモ化が必要なのかを理解した上で、処理を書くことができるようになったかなと思います。