Rubyのヒープをビジュアル表示する(翻訳)

概要

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

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

関連記事

Rubyのメモリ割り当て方法とcopy-on-writeの限界(翻訳)

Rails: Puma/Unicorn/Passengerの効率を最大化する設定(翻訳)

[インタビュー] Aaron Patterson(前編): GitHubとRails、日本語学習、バーベキュー(翻訳)

[インタビュー] Aaron Patterson(後編): Rack 2、HTTP/2、セキュリティ、WebAssembly、後進へのアドバイス(翻訳)

Ruby on RailsによるWEBシステム開発、Android/iPhoneアプリ開発、電子書籍配信のことならお任せください この記事を書いた人と働こう! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833

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

hachi8833の書いた記事

BPSアドベントカレンダー

週刊Railsウォッチ

インフラ

BigBinary記事より

ActiveSupport探訪シリーズ