Rubyでのメモリの問題の調査:決定的なガイド

公開: 2022-03-11

幸運なRuby開発者の中には、メモリの問題に遭遇することのない人もいると思いますが、残りの人にとっては、メモリ使用量が手に負えなくなっている場所を探して修正するのは非常に困難です。 幸い、最新のRuby(2.1以降)を使用している場合は、一般的な問題に対処するために利用できる優れたツールとテクニックがいくつかあります。 その感情の中で私は一人かもしれませんが、記憶の最適化は楽しくてやりがいがあると言うこともできます。

Rubyでのメモリの問題の追跡

バグが厄介だと思った場合は、メモリの問題を探すまで待ち​​ます。
つぶやき

すべての形式の最適化と同様に、コードが複雑になる可能性が高いため、測定可能で大幅な向上がない限り、実行する価値はありません。

ここで説明するすべては、正規のMRI Rubyバージョン2.2.4を使用して行われますが、他の2.1以降のバージョンも同様に動作するはずです。

メモリリークではありません!

メモリの問題が発見されると、メモリリークがあるという結論に簡単にジャンプできます。 たとえば、Webアプリケーションでは、サーバーを起動した後、同じエンドポイントへの呼び出しを繰り返すと、要求ごとにメモリ使用量が増え続けることがあります。 確かに、正当なメモリリークが発生する場合がありますが、実際にはリークではない同じ外観のメモリの問題が、それらの数を大幅に上回っていると思います。

(考案された)例として、ハッシュの大きな配列を繰り返し構築して破棄するRubyコードを少し見てみましょう。 まず、この投稿の例全体で共有されるコードを次に示します。

 # common.rb require "active_record" require "active_support/all" require "get_process_mem" require "sqlite3" ActiveRecord::Base.establish_connection( adapter: "sqlite3", database: "people.sqlite3" ) class Person < ActiveRecord::Base; end def print_usage(description) mb = GetProcessMem.new.mb puts "#{ description } - MEMORY USAGE(MB): #{ mb.round }" end def print_usage_before_and_after print_usage("Before") yield print_usage("After") end def random_name (0...20).map { (97 + rand(26)).chr }.join end

そしてアレイビルダー:

 # build_arrays.rb require_relative "./common" ARRAY_SIZE = 1_000_000 times = ARGV.first.to_i print_usage(0) (1..times).each do |n| foo = [] ARRAY_SIZE.times { foo << {some: "stuff"} } print_usage(n) end

get_process_mem gemは、現在のRubyプロセスで使用されているメモリを取得するための便利な方法です。 私たちが見ているのは、上記で説明したのと同じ動作であり、メモリ使用量が継続的に増加しています。

 $ ruby build_arrays.rb 10 0 - MEMORY USAGE(MB): 17 1 - MEMORY USAGE(MB): 330 2 - MEMORY USAGE(MB): 481 3 - MEMORY USAGE(MB): 492 4 - MEMORY USAGE(MB): 559 5 - MEMORY USAGE(MB): 584 6 - MEMORY USAGE(MB): 588 7 - MEMORY USAGE(MB): 591 8 - MEMORY USAGE(MB): 603 9 - MEMORY USAGE(MB): 613 10 - MEMORY USAGE(MB): 621

ただし、さらに反復を実行すると、最終的にはプラトーになります。

 $ ruby build_arrays.rb 40 0 - MEMORY USAGE(MB): 9 1 - MEMORY USAGE(MB): 323 ... 32 - MEMORY USAGE(MB): 700 33 - MEMORY USAGE(MB): 699 34 - MEMORY USAGE(MB): 698 35 - MEMORY USAGE(MB): 698 36 - MEMORY USAGE(MB): 696 37 - MEMORY USAGE(MB): 696 38 - MEMORY USAGE(MB): 696 39 - MEMORY USAGE(MB): 701 40 - MEMORY USAGE(MB): 697

このプラトーに達することは、実際のメモリリークではないこと、またはメモリリークが非常に小さいため、残りのメモリ使用量と比較して表示されないことの特徴です。 直感的ではないかもしれないのは、最初の反復後もメモリ使用量が増え続ける理由です。 結局、それは大きなアレイを構築しましたが、すぐにそれを破棄し、同じサイズの新しいアレイを構築し始めました。 前の配列によって解放されたスペースを使用するだけではいけませんか? 私たちの問題を説明する答えはノーです。 ガベージコレクターを調整する以外に、いつ実行するかを制御することはできませんbuild_arrays.rbの例に表示されているのは、古い破棄されたオブジェクトのガベージコレクションの前に行われる新しいメモリ割り当てです。

アプリのメモリ使用量が急増しても、慌てないでください。 アプリは、メモリリークだけでなく、さまざまな理由でメモリが不足する可能性があります。

これは、Rubyに固有のある種の恐ろしいメモリ管理の問題ではありませんが、一般的にガベージコレクションされた言語に適用できることを指摘しておく必要があります。 これを安心させるために、Goを使用して基本的に同じ例を再現し、同様の結果を確認しました。 ただし、この種のメモリの問題を簡単に作成できるRubyライブラリがあります。

分割統治

したがって、大量のデータを処理する必要がある場合、問題に大量のRAMを投入する運命にあるのでしょうか。 ありがたいことに、そうではありません。 build_arrays.rbの例を使用して配列サイズを小さくすると、配列サイズにほぼ比例するメモリ使用量のプラトーが減少することがわかります。

これは、作業を細かく分割して処理し、一度に存在するオブジェクトが多すぎないようにすることができれば、メモリフットプリントを大幅に削減できることを意味します。 残念ながら、それは多くの場合、よりメモリ効率の高い方法で、きれいでクリーンなコードを取得し、同じことを実行するより多くのコードに変換することを意味します。

メモリ使用量のホットスポットの分離

実際のコードベースでは、メモリの問題の原因は、 build_arrays.rbの例ほど明白ではない可能性があります。 実際に掘り下げて修正しようとする前にメモリの問題を特定することは、問題の原因について誤った推測をするのが簡単であるため、不可欠です。

私は通常、メモリの問題を追跡するために2つのアプローチを使用します。コードをそのままにしてプロファイラーをラップすることと、コードのさまざまな部分を無効/有効にしながらプロセスのメモリ使用量を監視することです。 ここではプロファイリングにmemory_profilerを使用しますが、ruby-profも人気のあるオプションであり、derailed_benchmarksにはRails固有の優れた機能がいくつかあります。

大量のメモリを使用するコードを次に示します。ここでは、どのステップがメモリ使用量を最も押し上げているかがすぐにはわからない場合があります。

 # people.rb require_relative "./common" def run(number) Person.delete_all names = number.times.map { random_name } names.each do |name| Person.create(name: name) end records = Person.all.to_a File.open("people.txt", "w") { |out| out << records.to_json } end

get_process_memを使用すると、作成されているPersonレコードが多い場合に、大量のメモリを使用していることをすばやく確認できます。

 # before_and_after.rb require_relative "./people" print_usage_before_and_after do run(ARGV.shift.to_i) end

結果:

 $ ruby before_and_after.rb 10000 Before - MEMORY USAGE(MB): 37 After - MEMORY USAGE(MB): 96

コードを見ると、大量のメモリを使用するのに適していると思われる複数の手順があります。文字列の大きな配列を作成する、Active Recordリレーションで#to_aを呼び出して、Active Recordオブジェクトの大きな配列を作成する(良いアイデアではありません) 、ただしデモンストレーション目的で実行されます)、およびActiveRecordオブジェクトの配列をシリアル化します。

次に、このコードのプロファイルを作成して、メモリ割り当てが発生している場所を確認できます。

 # profile.rb require "memory_profiler" require_relative "./people" report = MemoryProfiler.report do run(1000) end report.pretty_print(to_file: "profile.txt")

ここでrunするために供給される数は前の例の1/10であることに注意してください。これは、プロファイラー自体が大量のメモリを使用し、すでに高いメモリ使用量を引き起こしているコードをプロファイリングするときに実際にメモリの枯渇につながる可能性があるためです。

結果ファイルはかなり長く、gem、ファイル、および場所のレベルでのメモリとオブジェクトの割り当てと保持が含まれています。 探索する情報は豊富にありますが、ここにいくつかの興味深いスニペットがあります。

 allocated memory by gem ----------------------------------- 17520444 activerecord-4.2.6 7305511 activesupport-4.2.6 2551797 activemodel-4.2.6 2171660 arel-6.0.3 2002249 sqlite3-1.3.11 ... allocated memory by file ----------------------------------- 2840000 /Users/bruz/.rvm/gems/ruby-2.2.4/gems/activesupport-4.2.6/lib/activ e_support/hash_with_indifferent_access.rb 2006169 /Users/bruz/.rvm/gems/ruby-2.2.4/gems/activerecord-4.2.6/lib/active _record/type/time_value.rb 2001914 /Users/bruz/code/mem_test/people.rb 1655493 /Users/bruz/.rvm/gems/ruby-2.2.4/gems/activerecord-4.2.6/lib/active _record/connection_adapters/sqlite3_adapter.rb 1628392 /Users/bruz/.rvm/gems/ruby-2.2.4/gems/activesupport-4.2.6/lib/activ e_support/json/encoding.rb

Active Record内で最も多くの割り当てが行われていることがわかります。これは、 records配列内のすべてのオブジェクトをインスタンス化するか、 #to_jsonでシリアル化することを指しているようです。 次に、これらの容疑者を無効にしながら、プロファイラーなしでメモリ使用量をテストできます。 recordsの取得を無効にすることはできませんが、シリアル化の手順を実行することはできます。そのため、最初にシリアル化を無効にしてみましょう。

 # File.open("people.txt", "w") { |out| out << records.to_json }

結果:

 $ ruby before_and_after.rb 10000 Before: 36 MB After: 47 MB

それは確かにほとんどのメモリが進んでいるように見えます。メモリの前後のデルタはスキップすることで81%低下します。 また、大量のレコードを強制的に作成するのをやめた場合に何が起こるかを確認できます。

 # records = Person.all.to_a records = Person.all # File.open("people.txt", "w") { |out| out << records.to_json }

結果:

 $ ruby before_and_after.rb 10000 Before: 36 MB After: 40 MB

これにより、メモリ使用量も削減されますが、シリアル化を無効にするよりも1桁少なくなります。 したがって、この時点で、私たちは最大の原因を知っており、このデータに基づいて何を最適化するかを決定できます。

ここでの例は考案されたものですが、アプローチは一般的に適用可能です。 プロファイラーの結果は、問題が存在するコード内の正確な場所を示していない可能性があり、誤解される可能性もあるため、コードのセクションのオンとオフを切り替えながら実際のメモリ使用量を確認してフォローアップすることをお勧めします。 次に、メモリ使用量が問題になる一般的なケースと、それらを最適化する方法について説明します。

デシリアライズ

メモリの問題の一般的な原因は、XML、JSON、またはその他のデータシリアル化形式から大量のデータを逆シリアル化することです。 JSON.parseやActiveSupportのHash.from_xmlなどのメソッドを使用すると非常に便利ですが、ロードするデータが大きい場合、メモリにロードされる結果のデータ構造は膨大になる可能性があります。

データのソースを制御できる場合は、フィルタリングやページネーションのサポートを追加するなど、受信するデータの量を制限することができます。 ただし、それが外部ソースであるか、制御できない場合は、ストリーミングデシリアライザーを使用することもできます。 XMLの場合、Oxは1つのオプションであり、JSONの場合、yajl-rubyも同様に動作するように見えますが、私はあまり経験がありません。

メモリが限られているからといって、大きなXMLまたはJSONドキュメントを安全に解析できないわけではありません。 ストリーミングデシリアライザーを使用すると、これらのドキュメントから必要なものを段階的に抽出しながら、メモリフットプリントを低く抑えることができます。

これは、 Hash#from_xmlを使用して1.7MBのXMLファイルを解析する例です。

 # parse_with_from_xml.rb require_relative "./common" print_usage_before_and_after do # From http://www.cs.washington.edu/research/xmldatasets/data/mondial/mondial-3.0.xml file = File.open(File.expand_path("../mondial-3.0.xml", __FILE__)) hash = Hash.from_xml(file)["mondial"]["continent"] puts hash.map { |c| c["name"] }.join(", ") end
 $ ruby parse_with_from_xml.rb Before - MEMORY USAGE(MB): 37 Europe, Asia, America, Australia/Oceania, Africa After - MEMORY USAGE(MB): 164

1.7MBのファイルの場合は111MB! これは明らかにうまくスケールアップするつもりはありません。 これがストリーミングパーサーのバージョンです。

 # parse_with_ox.rb require_relative "./common" require "ox" class Handler < ::Ox::Sax def initialize(&block) @yield_to = block end def start_element(name) case name when :continent @in_continent = true end end def end_element(name) case name when :continent @yield_to.call(@name) if @name @in_continent = false @name = nil end end def attr(name, value) case name when :name @name = value if @in_continent end end end print_usage_before_and_after do # From http://www.cs.washington.edu/research/xmldatasets/data/mondial/mondial-3.0.xml file = File.open(File.expand_path("../mondial-3.0.xml", __FILE__)) continents = [] handler = Handler.new do |continent| continents << continent end Ox.sax_parse(handler, file) puts continents.join(", ") end
 $ ruby parse_with_ox.rb Before - MEMORY USAGE(MB): 37 Europe, Asia, America, Australia/Oceania, Africa After - MEMORY USAGE(MB): 37

これにより、メモリの増加はごくわずかになり、非常に大きなファイルを処理できるようになります。 ただし、トレードオフとして、以前は必要なかった28行のハンドラーコードがあり、エラーが発生しやすいようです。本番環境で使用する場合は、いくつかのテストを行う必要があります。

シリアル化

メモリ使用量のホットスポットの分離に関するセクションで見たように、シリアル化には高いメモリコストがかかる可能性があります。 これが以前のpeople.rbの重要な部分です。

 # to_json.rb require_relative "./common" print_usage_before_and_after do File.open("people.txt", "w") { |out| out << Person.all.to_json } end

データベース内の100,000レコードでこれを実行すると、次のようになります。

 $ ruby to_json.rb Before: 36 MB After: 505 MB

ここで#to_jsonを呼び出す際の問題は、すべてのレコードのオブジェクトをインスタンス化してから、JSONにエンコードすることです。 一度に1つのレコードオブジェクトのみが存在する必要があるようにJSONをレコードごとに生成すると、メモリ使用量が大幅に削減されます。 人気のあるRubyJSONライブラリはどれもこれを処理していないようですが、一般的に推奨されるアプローチは、JSON文字列を手動で作成することです。 これを行うための優れたAPIを提供するjson-write-streamgemがあり、この例を次のように変換します。

 # json_stream.rb require_relative "./common" require "json-write-stream" print_usage_before_and_after do file = File.open("people.txt", "w") JsonWriteStream.from_stream(file) do |writer| writer.write_object do |obj_writer| obj_writer.write_array("people") do |arr_writer| Person.find_each do |people| arr_writer.write_element people.as_json end end end end end

繰り返しになりますが、最適化によってより多くのコードが得られたことがわかりますが、結果はそれだけの価値があるようです。

 $ ruby json_stream.rb Before: 36 MB After: 56 MB

怠惰であること

2.0以降でRubyに追加された優れた機能は、列挙子を遅延させる機能です。 これは、列挙子でメソッドをチェーンするときのメモリ使用量を改善するのに最適です。 怠惰ではないコードから始めましょう:

 # not_lazy.rb require_relative "./common" number = ARGV.shift.to_i print_usage_before_and_after do names = number.times .map { random_name } .map { |name| name.capitalize } .map { |name| "#{ name } Jr." } .select { |name| name[0] == "X" } .to_a end

結果:

 $ ruby not_lazy.rb 1_000_000 Before: 36 MB After: 546 MB 

ここで何が起こるかというと、チェーンの各ステップで、列挙子のすべての要素を繰り返し処理し、チェーン内の後続のメソッドが呼び出される配列を生成します。 この怠惰なものを作成するとどうなるか見てみましょう。これには、時から取得する列挙子にlazy呼び出しを追加する必要がtimesます。

 # lazy.rb require_relative "./common" number = ARGV.shift.to_i print_usage_before_and_after do names = number.times.lazy .map { random_name } .map { |name| name.capitalize } .map { |name| "#{ name } Jr." } .select { |name| name[0] == "X" } .to_a end

結果:

 $ ruby lazy.rb 1_000_000 Before: 36 MB After: 52 MB

最後に、多くの余分なコードを追加することなく、メモリ使用量を大幅に増やす例です。 最後に結果を蓄積する必要がなかった場合、たとえば、各アイテムがデータベースに保存されて忘れられた場合、メモリ使用量はさらに少なくなることに注意してください。 チェーンの最後で怠惰な列挙可能な評価を行うには、 forceへの最後の呼び出しを追加するだけです。

この例についてもう1つ注意すべき点は、チェーンはlazyの前のtimesの呼び出しで開始することです。これは、呼び出されるたびに整数を生成する列挙子を返すだけなので、メモリをほとんど使用しません。 したがって、チェーンの先頭で大きな配列の代わりに列挙型を使用できる場合は、それが役立ちます。

すべてを巨大な配列とマップに保持することは便利ですが、実際のシナリオでは、それを行う必要はめったにありません。

ある種の処理パイプラインに遅延フィードするための列挙型を構築する実際のアプリケーションの1つは、ページ付けされたデータを処理することです。 したがって、すべてのページを要求して1つの大きな配列に配置するのではなく、すべてのページネーションの詳細をうまく隠す列挙子を介してそれらを公開することができます。 これは次のようになります。

 def records Enumerator.new do |yielder| has_more = true page = 1 while has_more response = fetch(page) response.records.each { |record| yielder << record } page += 1 has_more = response.has_more end end end

結論

Rubyでのメモリ使用量の特性評価を行い、メモリの問題を追跡するためのいくつかの一般的なツールと、それらを改善するためのいくつかの一般的なケースと方法を調べました。 私たちが調査した一般的なケースは、決して包括的ではなく、私が個人的に遭遇した種類の問題によって大きく偏っています。 ただし、最大のメリットは、コードがメモリ使用量にどのように影響するかを考えることです。

関連: Rubyの並行性と並列性:実用的なチュートリアル