Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

メモリを意識したRubyプログラミング(翻訳)

概要

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

メモリを意識したRubyプログラミング(翻訳)

-- メモリ使用量を節約する方法と戦略

Rubyのプログラミングでメモリが大量に消費されるのは当たり前で、避けられないと考えている人がたくさんいますが、メモリ使用量を削減するさまざまな方法や戦略が利用できます。本記事ではその中からいくつかをご紹介いたします。

Rubyの内部を常に意識する

TrueClassFalseClassNilClassIntegerFloatSymbolStringArrayHashStructといったRubyの主要なビルトインクラスは、実行パフォーマンスやメモリ使用量について高度に最適化されています。なお、ここではCRuby(MRI)を扱いますので、その他のRuby実装についてはほとんど該当しないと思われます。

Rubyの各オブジェクトへの参照は、内部(Cコード内など)ではVALUE型を経由します。これは、必要な情報をすべて含むCの構造体へのポインタです。

以下のすべての数値は64ビットLinuxプラットフォームで有効ですが、その他の64ビットシステムにも適用されるはずです。

niltruefalse、そして一部のInteger

クラスによっては、オブジェクト作成時にCの構造体へのメモリ割り当てが不要なものがあります。これは、オブジェクトがVALUEで直接表現されているためですNilClass型(nil値など)、TrueClass型(true値など)、FalseClassfalse値など)のオブジェクトがこれに該当します。

-262 〜262-1の範囲に収まる小さな整数も、同様にVALUE値で表されます。

これはどういうことなのでしょうか。これらのオブジェクトは最小限のメモリで表現できるため、これらの値を用いるときにメモリを気にする必要はないということです。

オブジェクトで使われているメモリ量を返すObjectSpace.memsize_ofメソッドを使ってこのことを確認できます。

2.4.2 > require 'objspace'
 => true
2.4.2 > ObjectSpace.memsize_of(nil)
 => 0
2.4.2 > ObjectSpace.memsize_of(true)
 => 0
2.4.2 > ObjectSpace.memsize_of(false)
 => 0
2.4.2 > ObjectSpace.memsize_of(2**62-1)
 => 0
2.4.2 > ObjectSpace.memsize_of(2**62)
 => 40

結果からわかるように、整数の値が大きくなりすぎた最後の例を除いてメモリはまったく追加されていません。VALUE構造体が必要になると、少なくとも40バイトのメモリがオブジェクトで使われます。

ArrayStructHashString

これらの4クラスのオブジェクトは一般的な方法ではなく特別(に用意された専用の)C構造体を使います。値によっては、追加メモリを割り当てずにこれらの構造体に保存できます。

Arrayの要素が3つ以内であればメモリ効率が向上します。これを超えてしまうと、要素1つにつき8バイトの追加メモリが必要になります。

2.4.2 > ObjectSpace.memsize_of([])
 => 40
2.4.2 > ObjectSpace.memsize_of([1])
 => 40
2.4.2 > ObjectSpace.memsize_of([1, 2])
 => 40
2.4.2 > ObjectSpace.memsize_of([1, 2, 3])
 => 40
2.4.2 > ObjectSpace.memsize_of([1, 2, 3, 4])
 => 72

Structも同様に、要素3つ以内ならメモリ効率が向上します。この場合Structのメモリは40バイトで済みます。

2.4.2 > X = Struct.new(:a, :b, :c)
 => X
2.4.2 > Y = Struct.new(:a, :b, :c, :d)
 => Y
2.4.2 > ObjectSpace.memsize_of(X.new)
 => 40
2.4.2 > ObjectSpace.memsize_of(Y.new)
 => 72

Hashの場合はやや異なります。もっとも重要なのは要素をまったく含まないハッシュは最小の40バイトで済む点です(したがってデフォルト値などで大きなペナルティは生じません)。

2.4.2 :044 > ObjectSpace.memsize_of({})
 => 40
2.4.2 :045 > ObjectSpace.memsize_of({a: 1})
 => 192
2.4.2 :046 > ObjectSpace.memsize_of({a: 1, b: 2, c: 3, d: 4})
 => 192
2.4.2 :047 > ObjectSpace.memsize_of({a: 1, b: 2, c: 3, d: 4, e: 5})
 => 288

上でわかるように、ハッシュの要素が4つ以内であれば192バイトで済みます。これが、空でないハッシュで必要な最小メモリサイズです。

最後のStringでは、23バイトまでの文字列なら文字列オブジェクトを表すRString構造体に直接保存できます。

2.4.2 :062 > ObjectSpace.memsize_of("")
 => 40
2.4.2 :063 > ObjectSpace.memsize_of("a"*23)
 => 40
2.4.2 :064 > ObjectSpace.memsize_of("a"*24)
 => 65

この知識は何の役に立つのでしょうか。このような制限に厳密に沿って設計しなければならないということではありませんが、最適な実装方法を選ぶときの決め手になることがあります。

普段使いのオブジェクト

あらゆる「普通の」オブジェクト(特殊なC構造体を使わないものなど)については、一般的なRObject構造体が使われます。これではメモリを意識しようがないと思われそうですが、そんなことはありません。この構造体にも「メモリ効率の高い」モードがあるのです。

arrayを使用する場合、arrayのメモリはエントリ(を指すVALUEポインタ)を保存するのに使われます。同様に、stringを使う場合、stringのメモリはstringを構成するバイトを保存するのに使われます。では一般的なオブジェクトの場合、メモリはどんな目的に使われるのでしょうか。インスタンス変数です。

インスタンス変数の値はそのオブジェクトのそばに保存されますが、インスタンス変数の名前は、関連するクラスオブジェクトのそばに保存されます(通常、1つのクラスからインスタンス化されたオブジェクトはどれも同じ名前のインスタンス変数を持つため)。
arrayの場合と同様、あるオブジェクトのインスタンス変数が3つ以内であればメモリは40バイトで済みますが、インスタンス変数が4つや5つの場合は80バイトになります。

2.4.2 > class X; def initialize(c); c.times {|i| instance_variable_set(:"@i#{i}", i)}; end; end
 => :initialize
2.4.2 :064 > ObjectSpace.memsize_of(X.new(0))
 => 40
2.4.2 :065 > ObjectSpace.memsize_of(X.new(1))
 => 40
2.4.2 :066 > ObjectSpace.memsize_of(X.new(2))
 => 40
2.4.2 :067 > ObjectSpace.memsize_of(X.new(3))
 => 40
2.4.2 :068 > ObjectSpace.memsize_of(X.new(4))
 => 80
2.4.2 :069 > ObjectSpace.memsize_of(X.new(5))
 => 80
2.4.2 :070 > ObjectSpace.memsize_of(X.new(6))
 => 96

戦略

クラスやシステムの設計

多くのオブジェクトを作成する必要のないアプリやライブラリの開発であれば、そこまでメモリを意識する必要はありません。しかし、オブジェクトを多数作成する必要があるなら、クラスやクラス間のやりとりを設計するときに本記事の情報を頭に置いておくとよいでしょう。

次の例で考えてみましょう。CSSのmargin値を表すクラスの作成が必要になったとします。CSSの仕様に基づき、値を1つから4つまで持てるようにするにはどうすればよいでしょうか。

  • 単にarrayを使う方法が考えられます。この方法では抽象化が不十分ですが、メモリは40バイトまたは72バイト(値が4つの場合)に収まるのでメモリに優しくなります。

  • ただし、実際にはほとんどのarrayメソッドはほとんど適用できないため利用に向いていないため、このarrayは別のクラスにラップすべきです。このクラスのオブジェクトは、arrayのサイズに応じて80バイトまたは112バイトのメモリを使います。

  • 初期化時に4つの値をインスタンス変数に保存するクラスを作成する方法も考えられます。この場合、オブジェクトは常に80バイトのメモリを使います。

  • 最後は、メンバを4つ持つStructをクラスの代わりに使う方法です。この場合オブジェクトのメモリは72バイトで済みます。

この例は無理やりかもしれませんが、次の2つの点がよく示されています。1つ目は、抽象化を犠牲にしてもよいのであれば、ビルトイン型を使うのが最善のメモリ節約方法であるということです。2つ目は、Rubyの内部を意識したクラス設計はメモリ使用量の節約に役立つということです(上の例では純粋なクラスの代わりにStructを使うことでオブジェクトごとのメモリを10%節約できました)。

オブジェクトの再利用

メモリ節約のもうひとつの方法は、可能な場合にオブジェクトを再利用することです。イミュータブルなオブジェクトなら再利用は簡単ですが、イミュータブルでないオブジェクトにも適用可能です。

オブジェクト再利用の典型例として、GUIテキストエディタを考えてみます。このテキストエディタでは、文字ごとの利用可能なビジュアル表示(グリフ)情報が必要です。このグリフ情報をキャッシュして再利用すれば、多くの場所から参照されてもグリフごとにインスタンスを1つ作るだけで済みます。

他の例としては、文字列のフリーズや複製防止があります。これは個別に指定することも、frozen_string_literal: trueマジックコメントを書いたRubyのソースファイル内でグローバルに指定することもできます。これによってインタプリタで文字列の複製が防止され、メモリ使用量を削減できます。Ruby 2.5からは、String#-@の結果を使って(-strなど)文字列の複製を防止することもできます。

メソッドやアルゴリズムの適切な利用

メモリ節約のための最善の方法は、追加オブジェクトを一切割り当てないことです。たとえば、あるarrayに含まれる各値をmapしなければならない場合、Array#mapArray#map!のいずれかを使えます。前者はarrayを新しく作るのに対し、後者はarrayを直接変更する点が異なります。後者は多くの場合、他の部分を書き換えずに簡単にコードに導入できます。もしArray#mapなどの変換メソッドが使われているホットスポットがあれば、メモリ効率の高い別のメソッドに置き換えられるかどうかを検討しましょう。

適切なアルゴリズムの選択も、メモリ使用量を大きく削減するうえで有効です。たとえば、暗号化されたPDFファイルをHexaPDF gemで変更する場合、暗号解除と再暗号化についてはデータストリームが不要になることがあります。その点を見極められれば、入力データストリームを単に出力ファイルにコピーすることでメモリ使用量を削減でき、処理速度も向上します。この方法で暗号化ファイルを最適化すると、HexaPDFでC++ライブラリよりもメモリが少なくて済みます

メモリ使用量を測定する

プログラムのメモリ割り当てを調べるgemはいろいろあります。もっともよく使われるgemは、allocation_tracermemory_profilerです。

どちらのgemも、プログラム全体を測定することも、プログラムの特定部分でオン/オフしてその箇所だけを測定することもできます。これらの方法でプログラムのホットスポットを特定し、その情報に対して操作を行えます。たとえば、私たちが数年前にkramdownというgemを開発したとき、破棄した文字列が大量にHTMLコンバーターのクラスに割り当てられていることに気づきました。改良された別のkramdownでこのホットスポットを変更したことで速度が向上し、メモリ使用量も削減できました。

これら2つのgemを初めて使う方のために、2とおりのファイルを用意しました。これらは、Rubyバイナリの-rスイッチでプリロードすることが前提です(ruby -I. -ralloc_tracer myscript.rbなど)。

BEGIN {
  require 'allocation_tracer'
  ObjectSpace::AllocationTracer.setup(%i{path line type})
  ObjectSpace::AllocationTracer.trace
}

END {
  require 'pp'
  results = ObjectSpace::AllocationTracer.stop
  results.reject {|k, v| v[0] < 10}.sort_by{|k, v| [v[0], k[0]]}.each do |k, v|
    puts "#{k[0]}:#{k[1]} - #{k[2]} - #{v[0]}"
  end
  puts "Sum: " + results.inject(0) {|sum, (k,v)| sum + v[0]}.to_s
  pp ObjectSpace::AllocationTracer.allocated_count_table
  pp :total => ObjectSpace::AllocationTracer.allocated_count_table.values.inject(:+)
}
BEGIN {
  require 'memory_profiler'
  MemoryProfiler.start
}

END {
  report = MemoryProfiler.stop
  report.pretty_print
}

この2つのファイルを使えば、プログラムを変更せずにメモリ使用量をプロファイリングできます。

まとめ

Rubyのライブラリやアプリ開発でメモリ使用量を削減する方法はたくさんあります。Rubyインタプリタ内部を少し知っておけば、Rubyのメモリ利用の仕組みと、その活用方法の理解に役立ちます。
また、Rubyコアメソッドがパフォーマンスやメモリに与える影響を知っておけば、適切なメソッドを選択するときに役立ちます。

関連記事

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

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

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

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

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


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。