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

Rails: memory_profilerでRuby文字列の重複を削減する(翻訳)

概要

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

日本語タイトルは内容に即したものにしました。

Rails: memory_profilerでRuby文字列の重複を削減する(翻訳)

皆さんのRailsアプリで文字列ががっつり重複している可能性があります。本記事ではそうした重複を取り除く(以下dedup)方法について説明します。


RubyやRailsで非常によくある問題のひとつが、メモリ使用量です。アプリをサイトにホスティングすると、たいていの場合パフォーマンスよりもメモリの方がボトルネックになります。Discourseではアプリのチューニングにかなりの時間を費やし、メモリが1GBもあればDiscourseを十分セルフホスティングできるようにしました。

私は、メモリ使用量のデバッグに役立つmemory_profiler gemを作成しました。これを使えばアプリのメモリ使用量を簡単に取れるようになります。皆さんのRailsアプリでもmemory_profiler gemを試してみることを強くおすすめします。こんなに多くの成果が、すぐ手の届くところにぶらさがっていることに驚くことでしょう。1日もかければ、最適化されていないアプリのメモリ使用量を20〜30%節約できることも珍しくありません。

memory_profilerは、メモリ使用量を以下の2つのセクションに分けてレポートを生成します。

  • Allocated memory(割り当てられたメモリ): 測定中のブロックで割り当てられたメモリ量

  • Retained memory(保持メモリ): ブロックの測定完了直後の使用中のメモリ量

以下のコード例で説明します。

def get_obj
   allocated_object1 = "hello "
   allocated_object2 = "world"
   allocated_object1 + allocated_object2
end

retained_object = nil

MemoryProfiler.report do
   retained_object = get_obj
end.pretty_print

上を実行すると以下のレポートが出力されます。

[a lot more text]
Allocated String Report
-----------------------------------
         1  "hello "
         1  blog.rb:3

         1  "hello world"
         1  blog.rb:5

         1  "world"
         1  blog.rb:4

Retained String Report
-----------------------------------
         1  "hello world"
         1  blog.rb:5

私たちは一般的に、プロセスのメモリ使用量を削減する場合はretained memoryの削減に注力し、ホットコードパス(実行頻度の高いコード)を最適化する場合はallocated memoryの削減に注力することにしています。

本記事では、retained memory、特にStringの最適化に注力することを目標とします。

Railsアプリのメモリプロファイラレポートを出力する方法

Railsをプロファイリングするために、起動時に以下のスクリプトを用います。

if ENV['RAILS_ENV'] != "production"
  exec "RAILS_ENV=production ruby #{__FILE__}"
end

require 'memory_profiler'

MemoryProfiler.report do
  # ファイルは/scriptsディレクトリに出力する前提(適宜調整)
  require File.expand_path("../../config/environments", __FILE__)

  # Railsルーターのウォームアップが必要
  Rails.application.routes.recognize_path('abc') rescue nil

  # ローカライズ関連で用いるyamlをmasterプロセスに読み込む
  I18n.t(:posts)

  # 全モデルを読み込んでActiveRecordの内部キャッシュをウォームアップする
  (ActiveRecord::Base.connection.tables - %w[schema_migrations versions]).each do |table|
    table.classify.constantize.first rescue nil
  end
end.pretty_print

これで以下のようなレポートを取れます(Gist)。

# memory_profile.txt
Total allocated: 200134661 bytes (2120673 objects)
Total retained:  33789989 bytes (291785 objects)

allocated memory by gem
-----------------------------------
  72565994  activesupport-5.1.4
  17893868  actionpack-5.1.4
  17293551  psych
  12181501  activerecord-5.1.4
  11900863  activemodel-5.1.4

なお上の結果は切り詰めてあります(オリジナル

メモリ使用量最適化作業を始めた早々に、保持メモリの中でStringが極めて高い割合を占めていることがわかりました。memory_profilerには、Stringの使用量削減に便利なように専用のStringセクションを設けてあります。

上のレポートの場合、以下のように出力されます。

Retained String Report
-----------------------------------
       942  "format"
       940  /home/sam/.rbenv/versions/2.5.0/lib/ruby/gems/2.5.0/gems/actionpack-5.1.4/lib/action_dispatch/journey/nodes/node.rb:83
         1  /home/sam/.rbenv/versions/2.5.0/lib/ruby/gems/2.5.0/gems/actionpack-5.1.4/lib/action_controller/log_subscriber.rb:3
         1  /home/sam/.rbenv/versions/2.5.0/lib/ruby/gems/2.5.0/gems/activemodel-5.1.4/lib/active_model/validations/validates.rb:115

       941  ":format"
       940  /home/sam/.rbenv/versions/2.5.0/lib/ruby/gems/2.5.0/gems/actionpack-5.1.4/lib/action_dispatch/journey/scanner.rb:49
         1  /home/sam/.rbenv/versions/2.5.0/lib/ruby/gems/2.5.0/gems/activesupport-5.1.4/lib/active_support/dependencies.rb:292
... 省略 ...

"format"という文字列がRubyヒープに940箇所もばらまかれていることがわかりました。これらの文字列はいずれも「root化」されているので、ヒープからGCされずに居座っています。Railsがこれを940個も必要としていることから考えて、コントローラのどのparamsがこれを取得しているのかはすぐに特定できます。

RubyのRVALUE(Rubyヒープ上のスロットを指す一意のobject_id)は、x86では40バイトを消費します。「format」という文字列はかなり短いので、余分なポインタやmallocがなくても1つのRVALUEに収まります。それなのに「format」というたったひとつの文字列を保存するのに37,600バイトも使われています。これは明らかに無駄なので、私たちからRailsにプルリク(#32016)を送らないとですね(訳注: 既にmergeされています)。

他にもいくつか無駄が見つかりました。

  1. フルGCが実行されるたびにRubyヒープ上のあらゆるオブジェクトがスキャンされる。このスキャンはプロセスが最終的に死ぬまでずっと繰り返される。

  2. メモリの小さなチャンクがプロセスのアドレス空間に収まりきらないため、時間とともにメモリ断片化が生じ、RVALUEヒープ同士のギャップによってRVALUEの40バイトの実際のインパクトが増大する可能性がある。

  3. Rubyヒープが増大するに連れて、ただちに増大が加速する(対応方法については#12967を参照)

  4. あるRubyヒープ内で1つのRVALUERVALUEを500個ほど含んでいると、再利用されないことがある。

  5. オブジェクト数が増加すると、CPUキャッシュの効率が低下してスワップ発生率の増加などにつながる。

文字列をdedupする方法

ご興味のある方向けに、Ruby 2.5以降で文字列のdedupに使える手法のおおよそのニュアンスをお伝えするgistを作成しました。少々時間をかけてじっくり読むことをおすすめします。

require "active_support"
require "active_support/core_ext/string/output_safety"
require "objspace"

def assert_same_object(x, y)
  raise unless x.object_id == y.object_id
end

def assert_not_same_object(x, y)
  raise unless x.object_id != y.object_id

説明の欲しい方向けに、ここで使える方法をいくつかご紹介します。

1. 定数を使う

# 変更前
def give_me_something
   "something"
end

# 変更後
SOMETHING = "something".freeze

def give_me_something
   SOMETHING
end
  • メリット:
    • すべてのバージョンのRubyで使える
  • デメリット:
    • 冗長かつ美しくない
    • freezeのマジックを付け忘れると、Ruby 2.3以降では文字列を正しく使い回せない可能性がある

2. # frozen_string_literal: trueマジックコメントを付ける

# 修正前
def give_me_something
   "something"
end

# 修正後

# frozen_string_literal: true
def give_me_something
   "something"
end

Ruby 2.3ではfrozen_string_literal: trueプラグマが導入されました。ファイルの冒頭行に# frozen_string_literal: trueと記述すると、Rubyのファイルの扱い方が変わります

単純な文字列リテラルはすべてfreezeおよびdedupされます。

式展開文字列もすべてfreezeされますが、dedupはされません。たとえば、x = "#{y}"はfrozenかつdedupされない文字列となります。

私はこれがRubyのデフォルトとなるべきだと思いますし、Railsを含む多くのプロジェクトでも受け入れられるべきだとも思います。Ruby 3.0ではこれがデフォルトになればと願っています。

  • メリット:
    • 非常に使いやすい
    • 見苦しくない
    • 長期的な最適化の自由度が落ちない
  • デメリット:
    • 既存のファイルへの適用が面倒になる可能性(テストスイートをしっかり書いておくことを強く推奨)

落とし穴

気をつけたい落とし穴がいくつかあります。最大の落とし穴はString.newのデフォルトエンコーディングです。

buffer = String.new
buffer.encoding => Encoding::ASCII-8BIT

# vs

# @+によるStringのunfreezeはRuby 2.3以降で導入された
buffer = +""
buffer.encoding => Encoding::UTF-8

文字列に何かを追加するとエンコーディングがその場で変わるので、通常はまったく問題になりません。しかし、空文字列への参照をサードパーティのライブラリに渡すと大惨事になりますので、"".dup+""を使う良い習慣をつけましょう。

3. 動的に文字列をdedupする

Ruby 2.5から、文字列をdedupする新しい手法が導入されました。これはEric Wongによって#13007で導入されました。

Matzの発言を引用します。

さしあたって、rb_fstringの呼び出しに#-を使うことにしよう。
ユーザーがもっとわかりやすいメソッド名を望んでいるようなら、後で議論しよう。
個人的にはfstringとしたくない。

つまり、String#-メソッドを使うと文字列を動的にdedupできるのです。

a = "hello"
b = "hello"
puts ((-a).object_id == (-b).object_id) # Ruby 2.5ではtrue(通常は)

この構文はRuby 2.3以降で利用できますが、この最適化を利用できるのはRuby 2.5以降のみです。

この手法は、dedupされる文字列は引き続きGCの対象になるという意味で安全です。

この機能は、dedupされた文字列を保持する以下のハッシュテーブルに依存します。これはある時期からRubyに存在しています。

static VALUE
 register_fstring(VALUE str)
 {
VALUE ret;
st_table *frozen_strings = rb_vm_fstring_table();

do {
    ret = str;
    st_update(frozen_strings, (st_data_t)str,
          fstr_update_callback, (st_data_t)&ret);
} while (ret == Qundef);

assert(OBJ_FROZEN(ret));
assert(!FL_TEST_RAW(ret, STR_FAKESTR));
assert(!FL_TEST_RAW(ret, FL_EXIVAR));
assert(!FL_TEST_RAW(ret, FL_TAINT));
assert(RBASIC_CLASS(ret) == rb_cString);
return ret;
}

以前、このテーブルは"string".freezeの最適化とハッシュキーの自動dedupに用いられていました。この機能はRuby 2.5で初めて一般に知られました。

これは、重複のあるコンテンツ(Railsのルーティングなど)を持つ入力のパースや、動的なルックアップテーブルの生成で実に有用です。

しかし、いいことばかりではありません🌹(訳注: not all rosesにかけたシャレ)。

まず、(訳注: #-という書式が)あまりに見苦しいという美的観点からこの構文への批判が一部で巻き起こり、利用を拒否する人もいます。

さらに、この手法には落とし穴がどっさりあります。これについてはgistにみっちりと記載しています。

#14478が修正されるまでは文字列をunfreezeする前にdedupする必要がある

訳注: 現在は反映されています

yuck = "yuck"
yuck.freeze
yuck_deduped = -+yuck

文字列がtaintされている場合は「一部」しかdedupできない

これは、文字列が長い場合にVMによって共有文字列が作成されるにもかかわらず、RVALUEを引き続き保持するということです。

love = "love"
love.taint
(-love).object_id == love.object_id

# dedupするためにはコピーが必要
deduped = -love.dup.untaint

困るのは、この修正を適用したい場所の多くで、taintされた文字列を残すかどうかという選択を迫られることです。古典的な例としては、RailsのPostgreSQLアダプタが"character varying"制限付き可変長文字列)のコピーを142個持つことがDiscourseの上述のレポートで判明しています。場合によってはこの制約のために、dedupしたい文字列で無意味なコピーを余分に持つ羽目になることがあります(この機能を使うためにuntaintするのは絶対イヤだという人がこの宇宙に3人はいるので)。

個人的には、この嫌らしいtaintがらみのコードをRubyのコードベースから完全に消し去れればいいのにと思っています🔥。そうできれば、どちらもずっとシンプルかつ安全かつ高速になることでしょう。

文字列にインスタンス変数が定義されていると一部しかdedupできない

# html_safeは文字列にインスタンス変数を設定するのでdedupできない
str = -"<html>test</html>".html_safe

この特殊な制限は回避できないので、Rubyの力でどうにかできるのかどうか私には何とも言えません。htmlの断片をdedupしようとすると、文字列は共有できても、完全なdedupはできないというつらみが生じます。

参考

皆さんが本記事を用いてアプリのメモリ使用量をうまく削減できることをお祈り申し上げます。

関連記事

Ruby: メソッドを最速でプロファイリングする方法(翻訳)

Ruby: mallocでマルチスレッドプログラムのメモリが倍増する理由(翻訳)


CONTACT

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