概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: Visualizing Your Ruby Heap
- 原文公開日: 2017/09/27
- 著者: Aaron Patterson
- サイト: http://tenderlovemaking.com/
Rubyのヒープをビジュアル表示する(翻訳)
前回の記事では、Rubyのオブジェクトがどのようにメモリ上に展開されるかについて軽く触れました。そのときの情報を元に、今回はRubyヒープのダンプを取ってそのヒープの配置や断片化をビジュアル表示するプログラムを書くことにします。
Rubyオブジェクトのレイアウトをざっと復習
単なる復習: Rubyオブジェクトは固定幅です。つまり、あらゆるRubyオブジェクトのサイズは同一(40バイト)になります。オブジェクトは実際にはmalloc
で割り当てられるのではなく、ページの内部に配置されます。
1つのRubyプロセスには多数のページが含まれ、1つのページには多数のオブジェクトが含まれます。
このオブジェクトはどのページに属するのか?
多くのオブジェクトが1つのページに割り当てられます。各ページは2^14バイト(訳注: 16,384バイト)です。複数のRubyオブジェクトは同時に割り当てられるのではなく、GCが1つのページ(アリーナとも呼ばれます)を割り当てます。
ページのサイズは正確な2^14バイトではありません。あるページを割り当てるとき、OSのメモリページに沿ってページを配置したいので、malloc
のトータルサイズは4 KB(OSのページサイズ)の倍数よりやや小さい値にする必要があります。malloc
システムコールには若干オーバーヘッドがあるため、連続するOSページにRubyのページを隙間なく収納できるよう、実際にmalloc
するサイズを総量から差し引かなければなりません。paddingに使うサイズはsizeof(size_t) * 5
なので、1ページの実際のサイズは(2 ^ 14) - (sizeof(size_t) * 5)
になります。
各ページには、ページ情報の一部を含むヘッダが1つずつあります。ヘッダのサイズはsizeof(void *)
です。
つまり、1つのページに保存できるRubyオブジェクトの最大サイズは((2 ^ 14) - (sizeof(size_t) * 5) - sizeof(void *)) / 40
になります。
1ページあたりのオブジェクト数には上限があるため、1つのRubyオブジェクトのアドレスの下位14ビットにビットマスクを適用し(ページサイズは2^14バイトなので、言い換えると14ビットシフトして1ビット残ります)、オブジェクトが実際に配置されるページを算出します。そのビットマスクは~0 << 14
です。
あるRubyオブジェクトのアドレスが0x7fcc6c845108
の場合、バイナリをASCIIアートで表すと以下のようになります。
11111111100110001101100100001000101000100001000
^---------- ページ アドレス --------^- object id ^
上図の「object id」の部分は、昔ながらのRuby object idではなく、単にそのページ上の個別のオブジェクトを表すビットの一部です。アドレス全体は昔ながらの「object id」と考えられます。
これらの数値をRubyのコードに切り出してみましょう。
require 'fiddle'
SIZEOF_HEAP_PAGE_HEADER_STRUCT = Fiddle::SIZEOF_VOIDP
SIZEOF_RVALUE = 40
HEAP_PAGE_ALIGN_LOG = 14
HEAP_PAGE_ALIGN = 1 << HEAP_PAGE_ALIGN_LOG # 2 ^ 14
HEAP_PAGE_ALIGN_MASK = ~(~0 << HEAP_PAGE_ALIGN_LOG) # ページアドレス取得用マスク
REQUIRED_SIZE_BY_MALLOC = Fiddle::SIZEOF_SIZE_T * 5 # mallocで必要なpadding
HEAP_PAGE_SIZE = HEAP_PAGE_ALIGN - REQUIRED_SIZE_BY_MALLOC # 実ページサイズ
HEAP_PAGE_OBJ_LIMIT = (HEAP_PAGE_SIZE - SIZEOF_HEAP_PAGE_HEADER_STRUCT) / SIZEOF_RVALUE
先ほど触れた部分を改めて説明します。Rubyページは、malloc
で隙間なく配置されます。言い換えると、あるRubyページが割り当てられるときのアドレスは2^14で割ることができ、ページのサイズは2^14よりごくわずか小さくなります。
それでは、あるオブジェクトアドレスを渡すと、そのオブジェクトが配置されたページのアドレスを返す関数を書いてみましょう。
def page_address_from_object_address object_address
object_address & ~HEAP_PAGE_ALIGN_MASK
end
それでは3つのオブジェクトアドレスのページアドレスを出力してみます。
p page_address_from_object_address(0x7fcc6c8367e8) # => 140515970596864
p page_address_from_object_address(0x7fcc6c836838) # => 140515970596864
p page_address_from_object_address(0x7fcc6c847b88) # => 140515970662400
この出力から、最初の2つのオブジェクトは同じページにあるが、3番目のオブジェクトは別のページにあることがわかります。
このページにオブジェクトはいくつあるか?
Rubyオブジェクトもアライン(align)されますが、既存のページの内部でアラインされます。アラインされるのは40バイト目(これはそのオブジェクトのサイズでもあります)。つまり、あらゆるRubyオブジェクトが持つ各アドレスはすべて40で割れることが保証されます(これは、数値のようにヒープに割り当てられないオブジェクトについては真ではありません)。
Rubyオブジェクトは決して(訳注: OSによって)割り当てられず、割り当て済みの1つのページ内部に置かれます。そのページは2^14に沿ってアラインされますが、2^14で割れるすべての数が40でも割れるとは限りません。つまり、あるページには他のページよりも多くのオブジェクトが保存される場合があるということです。40でも割れるページには、そうでないオブジェクトより1つ多くオブジェクトが保存されます。
ページアドレスを渡すと、そこに保存できるオブジェクトの数とオブジェクトの場所を算出し、ページの情報を表すオブジェクトを1つ返す関数を書いてみましょう。
Page = Struct.new :address, :obj_start_address, :obj_count
def page_info page_address
limit = HEAP_PAGE_OBJ_LIMIT # ページあたりの最大オブジェクト数
# ページには情報を持つヘッダーが1つあるので、その分も考慮する
obj_start_address = page_address + SIZEOF_HEAP_PAGE_HEADER_STRUCT
# オブジェクトの開始アドレスがRubyオブジェクトのサイズで割り切れない場合、
# SIZEOF_RVALUEで割り切れる最初のアドレスを見つけるのに必要な
# paddingの算出が必要
if obj_start_address % SIZEOF_RVALUE != 0
delta = SIZEOF_RVALUE - (obj_start_address % SIZEOF_RVALUE)
obj_start_address += delta # Move forward to first address
# このページに実際に保存されているオブジェクト数を算出
limit = (HEAP_PAGE_SIZE - (obj_start_address - page_address)) / SIZEOF_RVALUE
end
Page.new page_address, obj_start_address, limit
end
これでオブジェクトが保存されているページの情報を得られるようになったので、先の例で使ったオブジェクトアドレスのページ情報を調べてみましょう。
page_address = page_address_from_object_address(0x7fcc6c8367e8)
p page_info(page_address)
# => #<struct Page address=140515970596864, obj_start_address=140515970596880, obj_count=408>
page_address = page_address_from_object_address(0x7fcc6c836838)
p page_info(page_address)
# => #<struct Page address=140515970596864, obj_start_address=140515970596880, obj_count=408>
page_address = page_address_from_object_address(0x7fcc6c847b88)
p page_info(page_address)
# => #<struct Page address=140515970662400, obj_start_address=140515970662440, obj_count=407>
同じページにある最初の2つのオブジェクトでは、そのページに408個のオブジェクトを保存できます。3番目のオブジェクトは別のページにあり、そのページには407個のオブジェクトしか保存できません。
それらしく見えないかもしれませんが、ヒープの内容をビジュアル表示するのに必要となる、重要な情報の断片はこれですべて揃いました。
データ取得
あるヒープをビジュアル表示するには、実際にビジュアル表示するためのヒープが必要です。ObjectSpace
を使ってヒープをJSONファイルにダンプし、上のコードとJSONパーサー、そしてChunkyPNGを用いてグラフを生成します。
次がテストプログラムです。
require 'objspace'
x = 100000.times.map { Object.new }
GC.start
File.open('heap.json', 'w') { |f|
ObjectSpace.dump_all(output: f)
}
ここで行っているのは、大量のオブジェクト割り当てとGCの後、heap.json
というJSONファイルにヒープをダンプするだけです。JSONドキュメントの各行はRubyヒープの1つのオブジェクトに相当します。
今度はJSONファイルを処理するプログラムを書きましょう。ここでは、ページ内にあるオブジェクトをトラックできるようにPage
クラスを変更し、JSONドキュメント全体を列挙して、各オブジェクトを対応するページに追加します。
class Page < Struct.new :address, :obj_start_address, :obj_count
def initialize address, obj_start_address, obj_count
super
@live_objects = []
end
def add_object address
@live_objects << address
end
end
# ページをトラックする
pages = {}
File.open("heap.json") do |f|
f.each_line do |line|
object = JSON.load line
# rootをスキップ(今日はやりたくないので:)
if object["type"] != "ROOT"
# オブジェクトのアドレスは基数16で文字列として保存される
address = object["address"].to_i(16)
# ページのアドレスを取得する
page_address = page_address_from_object_address(address)
# ページを取得するか新しいページを作成する
page = pages[page_address] ||= page_info(page_address)
page.add_object address
end
end
end
ヒープをビジュアル表示する
これで、処理プログラムによってオブジェクトは自身が所属するページごとに分割されました。今度はこのデータをヒープのビジュアル表示に変えましょう。残念なことに、ここでは小さな問題が1つあります。ヒープのダンプから得られる情報は、システムで実際に生存しているオブジェクトの情報です。ヒープの空白領域をどうやってビジュアル表示すればよいのでしょうか。
ヒープの空白部分の割り出しに使える情報が少しばかりあります。1つ目はオブジェクトのアドレスが40で割り切れるということ、2つ目はストレージの最初のアドレスを取得できること(Page#obj_start_address
)。3つ目は1つのページに保存できるオブジェクト数を取得できること(Page#obj_count
)です。そこで、obj_start_address
から開始してSIZEOF_RVALUE
ずつ増やせば、JSONファイルから読み取ったアドレスが存在するかどうかがわかるはずです。JSONファイルからアドレスを読み取れれば、それは生存しているオブジェクトであることがわかります。読み取れなければ、そこは空白のスロットということになります。
それでは、ページ上で取得可能なオブジェクトアドレスをすべて列挙するメソッドをPage
オブジェクトに1つ追加しましょう。:full
がyieldされたらオブジェクトは存在し、:empty
がyieldされたらオブジェクトは存在しません。
class Page < Struct.new :address, :obj_start_address, :obj_count
def each_slot
return enum_for(:each_slot) unless block_given?
objs = @live_objects.sort
obj_count.times do |i|
expected = obj_start_address + (i * SIZEOF_RVALUE)
if objs.any? && objs.first == expected
objs.shift
yield :full
else
yield :empty
end
end
end
end
これで、ページからページへ空白スロットをすべてのスロットから区別できるようになりました。ChunkyPNG
でPNGファイルを生成しましょう。PNGの各カラムは1つのページを表し、各ページ内の2×2ピクセルの正方形は1つのオブジェクトを表します。オブジェクトが存在する場合はオブジェクトを赤く塗り、空白の場合はそのままにします。
require 'chunky_png'
pages = pages.values
# オブジェクトを2x2ピクセルの正方形で表すので、
# PNGの高さはオブジェクトの最大数の2倍になり、
# 幅はページ数の2倍になる
height = HEAP_PAGE_OBJ_LIMIT * 2
width = pages.size * 2
png = ChunkyPNG::Image.new(width, height, ChunkyPNG::Color::TRANSPARENT)
pages.each_with_index do |page, i|
i = i * 2
page.each_slot.with_index do |slot, j|
# スロットが埋まっている場合は赤くする
if slot == :full
j = j * 2
png[i, j] = ChunkyPNG::Color.rgba(255, 0, 0, 255)
png[i + 1, j] = ChunkyPNG::Color.rgba(255, 0, 0, 255)
png[i, j + 1] = ChunkyPNG::Color.rgba(255, 0, 0, 255)
png[i + 1, j + 1] = ChunkyPNG::Color.rgba(255, 0, 0, 255)
end
end
end
png.save('heap.png', :interlace => true)
このコードを実行後、heap.png
というファイルが出力されるはずです。私が生成したファイルは次のとおりです。
この例ではヒープがすべて埋まっているので今ひとつです。今度は比較的空のプロセスからヒープをダンプして様子を見てみましょう。
$ ruby -robjspace -e'File.open("heap.json", "wb") { |t| ObjectSpace.dump_all(output: t) }'
このヒープを処理すれば、次のような出力になります。
これでおしまいです。お読みいただきありがとうございました。
完全なコードはここにアップしています。
<3<3<3<3<3
関連記事
[インタビュー] Aaron Patterson(後編): Rack 2、HTTP/2、セキュリティ、WebAssembly、後進へのアドバイス(翻訳)