Quantcast
Channel: hachi8833の記事一覧|TechRacho by BPS株式会社
Viewing full articles (2028 articles)
Browse article index

RubyのHashがメモリ食いである理由を探ってメモリ使用量を削減した話(翻訳)

$
0
0

概要

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

日本語タイトルは内容に即したものにしました。
原文の小文字で書かれたhashは原則として「ハッシュ」としています。

RubyのHashがメモリ食いである理由を探ってメモリ使用量を削減した話(翻訳)

既にご存知かと思いますが、Rubyの最適化の中でとりわけ私の興味を強くそそるのは「メモリ使用量」です。Rubyデプロイメントのほとんどはforkに依存しているので、いわゆるCopy-on-Writeのパフォーマンスを改善するのが一般に最も効果的なのですが、この方法は、アプリケーションヒープ内のうち比較的変化の少ない静的な部分にしか効きません。

メモリ使用量が著しく増加する主な要因の1つは、Webリクエストやジョブの処理中にアロケーションされ、処理後に解放される一時的なメモリ領域です。そのため、Rubyのさまざまなオブジェクトを軽量化する余地があるかどうかを探ってみることも有用です。

Rubyオブジェクトの中でメモリ食いの筆頭といえば、おそらくHashでしょう。Hashのインスタンスは、オプションハッシュやキーワード引数から、ログ出力やAPIレスポンスに至るまで、Rubyアプリケーションやライブラリで激しく使われています。

RubyのHashがあまりに便利なせいで、どうも使われすぎている場合があるようです。特にHashのメモリ効率の低さを考えるとなおさらです。

それでは、RubyのHashがどれほどメモリ食いなのか、そうなるまでの経緯、それについて何ができるかを詳しく見ていきましょう。

🔗 メモリ使用量を測定する

私の過去記事をいくつか読んだことがあれば、Rubyのメモリ使用量を調べるのに使えるAPIについて、おそらく既にご存知かと思います。

最もシンプルなものはObjectSpace.memsize_of(obj)です。

>> Ruby::DESCRIPTION
=> "ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [arm64-darwin25]"
>> require 'objspace'
>> ObjectSpace.memsize_of({})
=> 160

空ハッシュ{}はそれだけで160バイトも使っていることがわかります。しかし比較対象がないとあまり意味がないので、たとえばStructと比較してみましょう。

require 'objspace'
puts "Ruby: #{RUBY_VERSION}"

11.times do |size|
  struct_class = size.zero? ? Object : Struct.new(*size.times.map { |i| :"m_#{i}" })
  struct = ObjectSpace.memsize_of(struct_class.new)
  hash = ObjectSpace.memsize_of(Hash[size.times.map { |i| [i, i] }])
  diff = (hash.to_f / struct).round(1)
  puts "size: #{size} \tstruct: #{struct} \thash: #{hash} \tdiff: #{diff}x"
end

結果は以下の通りです。

Ruby 4.0.6
size:  0    struct:  40     hash: 160   diff: 4.0x
size:  1    struct:  40     hash: 160   diff: 4.0x
size:  2    struct:  40     hash: 160   diff: 4.0x
size:  3    struct:  40     hash: 160   diff: 4.0x
size:  4    struct:  80     hash: 160   diff: 2.0x
size:  5    struct:  80     hash: 160   diff: 2.0x
size:  6    struct:  80     hash: 160   diff: 2.0x
size:  7    struct:  80     hash: 160   diff: 2.0x
size:  8    struct:  80     hash: 160   diff: 2.0x
size:  9    struct: 160     hash: 544   diff: 3.4x
size: 10    struct: 160     hash: 544   diff: 3.4x

ご覧のように、Hashは、Struct1POROと比べて2倍から4倍ものメモリを食っています。

このことから、「POROでできることはHashではなくPOROで済ませるべき」と言い切って終わりにしてもよいくらいでしょう(実際これは良いアドバイスです)。
しかし本記事の目的は、Hashがどうしてこんなメモリ食いになったのかという経緯と、それについてどんな対策が可能かを探っていくことです。

いわゆるハッシュテーブルについて学んだことがある人なら、「ハッシュがメモリを食うのは当たり前でしょ?」と思うかもしれません。実際、ハッシュテーブルはより多くのメモリを必要とするものです。

しかしあまり知られていませんが、RubyのHashのインスタンスは、上の実行例でサイズが8になるまでは、厳密にはハッシュテーブルでは「ありません」。

それでは、Hashの実装の歴史を実際に詳しく振り返って、こうなった理由を理解していくことにしましょう。

🔗 オープンアドレッシング

Hashの実装は、Rubyの歴史の中で何度も大きな変化を遂げました。

私が覚えている最初の大きな変化は、Ruby 2.4でVladimir Makarovがハッシュテーブルを完全に書き直したときです(#12142)。ここで、それまでのより伝統的なハッシュテーブルの設計がオープンアドレッシング(open-addressing)に変更されました。
ここでは両者の設計の違いには深く立ち入りません(もっといい情報源が他にいくらでもあります)が、ここで指摘しておきたいのは、この変更によってハッシュが明らかに高速化された一方で、「ヘッダー」のサイズも著しく増加したということです。

ここで言うヘッダーは、エントリやbins2をトラッキングするC構造体のことです。変更前のst_table構造体のサイズは48Bでした。

#include <stdio.h>
#include <limits.h>

struct st_hash_type;
struct packed_entry;
struct st_table_entry;

typedef unsigned long long st_index_t;
#define ST_INDEX_BITS (sizeof(st_index_t) * CHAR_BIT)

struct st_table {
    const struct st_hash_type *type;
    st_index_t num_bins;
    unsigned int entries_packed : 1;
    st_index_t num_entries : ST_INDEX_BITS - 1;
    union {
      struct {
          struct st_table_entry **bins;
          void *private_list_head[2];
      } big;
      struct {
          struct st_packed_entry *entries;
          st_index_t real_entries;
      } packed;
    } as;
};

int main(int argc, char **argv)
{
    fprintf(stderr, "sizeof(struct st_table) = %ld\n", sizeof(struct st_table));
    return 0;
}
sizeof(struct st_table) = 48

Vladimirによる最初のパッチでは、構造体のサイズが88Bに増加しましたが、その後パッチが実際にマージされる頃には64Bの増加にとどまりました。
さらに、Nobuが後続のコミットで構造体のサイズを56Bにまで削減しました(5714a26)。

#include <stdio.h>

struct st_hash_type;
struct st_table_entry;

typedef unsigned long long st_index_t;

struct st_table {
    /* テーブルのキャッシュされた機能(詳しくはst.cを参照) */
    unsigned char entry_power, bin_power, size_ind;
    /* テーブルがリビルドされた回数 */
    unsigned int rebuilds_num;
    const struct st_hash_type *type;
    /* テーブルの現在のエントリ数 */
    st_index_t num_entries;
    /* キーアクセス用のbins配列 */
    st_index_t *bins;
    /* 配列エントリ内にあるエントリの開始インデックスと境界インデックス。
       entries_start と entries_bound のインターバル:
       [0,allocated_entries]  */
    st_index_t entries_start, entries_bound;
    /* サイズが 2^entry_power の配列  */
    struct st_table_entry *entries;
};

int main(int argc, char **argv)
{
    fprintf(stderr, "sizeof(struct st_table) = %ld\n", sizeof(struct st_table));
    return 0;
}
sizeof(struct st_table) = 56

新しい実装では巧妙な動的サイズの整数オフセットが使われていたため、メモリ使用量のベースラインがまだ16B余分にあったものの、小規模なHashのメモリ使用量はおおむね削減されました。

Ruby 2.3のときの結果は以下の通りです。

Ruby 2.3.8
size: 0     struct:  40     hash:  40   diff: 1.0x
size: 1     struct:  40     hash: 232   diff: 5.8x
size: 2     struct:  40     hash: 232   diff: 5.8x
size: 3     struct:  40     hash: 232   diff: 5.8x
size: 4     struct:  72     hash: 232   diff: 3.2x
size: 5     struct:  80     hash: 232   diff: 2.9x
size: 6     struct:  88     hash: 232   diff: 2.6x
size: 7     struct:  96     hash: 552   diff: 5.8x
size: 8     struct: 104     hash: 600   diff: 5.8x
size: 9     struct: 112     hash: 648   diff: 5.8x
size: 10    struct: 120     hash: 696   diff: 5.8x

Ruby 2.4では以下のように変わりました。

Ruby: 2.4.10
size: 0     struct:  40     hash:  40   diff: 1.0x
size: 1     struct:  40     hash: 192   diff: 4.8x
size: 2     struct:  40     hash: 192   diff: 4.8x
size: 3     struct:  40     hash: 192   diff: 4.8x
size: 4     struct:  72     hash: 192   diff: 2.7x
size: 5     struct:  80     hash: 288   diff: 3.6x
size: 6     struct:  88     hash: 288   diff: 3.3x
size: 7     struct:  96     hash: 288   diff: 3.0x
size: 8     struct: 104     hash: 288   diff: 2.8x
size: 9     struct: 112     hash: 480   diff: 4.3x
size: 10    struct: 120     hash: 480   diff: 4.0x

これは非常に望ましい変更ではあるものの、キーが数個の場合のメモリ使用量がまだ多いままでした。

🔗 配列テーブル

ハッシュに対する次の大きな変更は、Ruby 2.6で行われました。

実装者のYimin Zhaoは、Google Summer of Codeに参加した学生で、メンターのKoichi Sasadaの協力のもとで、ハッシュをトランジェントヒープ(transient heap: 過渡的なヒープ)で動かすよう調整する作業に取り組みました(#14858)。

トランジェントヒープについてはこのチケットに詳しく書かれていますが、要するに「ほとんどのオブジェクトは若いまま死ぬ」、つまり短命であるというアイデアを元にしています。また、当時のRubyオブジェクトのほとんどはmallocによってメモリを余分にアロケーションしていました。そこで、若いオブジェクトにはもっとシンプルなバンプポインタアロケータ(bump pointer allocator)3でメモリをアロケーションすることで処理を高速化できると考えたのです。

それに続いて、GCマーキング期間の最後に、トランジェントヒープにアロケーションされたメモリを指しているすべての残存オブジェクトに対して、改めて本物のmallocを使ってアロケーションをやり直し、元のバイトをそこにコピーします。
それが終わったら、トランジェントヒープ全体をリセットして自由に再利用できる状態にします。

この手法はコピーイングガベージコレクタ(copying garbage collector)から強くインスパイアされたものですが、当時のRuby VMに合わせる形で採用されました。

この流れで、Yimin ZhaoはRubyのHashをトランジェントヒープに統合する作業に取り組み始めました(#14989)。しかしこのとき問題となったのは、st_table構造体がHashクラスの基盤ではなかったことでした。st_table構造体はVMのあちこちで汎用のハッシュテーブルとしても使われており、あまつさえRubyのC APIでも公開されていて一部のC拡張で利用されていました。

そのため、このままではハッシュテーブルをトランジェントヒープに合わせてリファクタリングする作業は困難を極めそうでした。このとき、YiminとKoichiは別のアプローチを選びました。2人はさまざまなベンチマークを実施して、「ハッシュの80%はエントリ8個以下である」という結果を得ました(Googleスプレッドシート)。

このぐらい小さなテーブルであれば、わざわざ本物のハッシュテーブルにする必要もありません。Nが十分小さければ、O(n)の線形探索でもO(1)のハッシュテーブル探索より高速になることすらありえます。

このアイデアを元に、2人はRubyのHashクラスをリファクタリングして、エントリが8個までの場合は実質的にArrayとして動作するように変更しました。
これにより、ハッシュで常にst_table構造体を使う代わりに、サイズが8まではli_table(linear tableの略)を使うようになりました(8f675cd)。なお、li_tableは後にar_table(array tableの略)にリネームされました(e4c79d0)。

この時点で、エントリ1個が「キー」「値」「記録されたハッシュコード」で構成されるようになり、1エントリあたりのメモリは合計24Bとなりました。すなわち、192Bにすべてのオブジェクトの基本オーバーヘッドである40Bを加えると、232Bとなります。

Ruby: 2.6.10
size: 0     struct: 40  hash: 232   diff: 5.8x
size: 1     struct: 40  hash: 232   diff: 5.8x
size: 2     struct: 40  hash: 232   diff: 5.8x
size: 3     struct: 40  hash: 232   diff: 5.8x
size: 4     struct: 72  hash: 232   diff: 3.2x
size: 5     struct: 80  hash: 232   diff: 2.9x
size: 6     struct: 88  hash: 232   diff: 2.6x
size: 7     struct: 96  hash: 232   diff: 2.4x
size: 8     struct: 104     hash: 232   diff: 2.2x
size: 9     struct: 112     hash: 928   diff: 8.3x
size: 10    struct: 120     hash: 928   diff: 7.7x

こうして、小規模なハッシュがまたしても肥大化しました。

🔗 下位バイトハッシュコード

しかしRuby 2.7になると、Koichiがメモリサイズを削減する巧妙な手法を編み出しました(#15602)。エントリごとに8Bのハッシュコードを保存する代わりに、ハッシュコードの下位バイトだけを保存するよう変更したのです。トレードオフとして、ハッシュ衝突の確率が0.39%1/256)に増加しますが、小規模なテーブルであればおそらく十分でしょう。
この変更によってテーブルスキャンが大幅に高速化され、1エントリあたり7Bの節約、テーブル全体では56Bの節約となりました。これはかなりの削減と言えます。

Ruby: 2.7.8
size: 0     struct: 40  hash: 40    diff: 1.0x
size: 1     struct: 40  hash: 168   diff: 4.2x
size: 2     struct: 40  hash: 168   diff: 4.2x
size: 3     struct: 40  hash: 168   diff: 4.2x
size: 4     struct: 72  hash: 168   diff: 2.3x
size: 5     struct: 80  hash: 168   diff: 2.1x
size: 6     struct: 88  hash: 168   diff: 1.9x
size: 7     struct: 96  hash: 168   diff: 1.8x
size: 8     struct: 104     hash: 168   diff: 1.6x
size: 9     struct: 112     hash: 928   diff: 8.3x
size: 10    struct: 120     hash: 928   diff: 7.7x

🔗 可変幅アロケーション

次の大幅な変更は、Ruby 3.34で導入された可変幅アロケーション(VWA: Variable Width Allocation)の一般化でした。従来のRubyオブジェクトのアロケーションサイズは40Bスロットに固定されていました。それより大きいデータを保存する必要が生じたときは、個別のオブジェクトがシステムのmallocを用いてアロケーションを増やす必要がありました。

可変幅アロケーションが導入されたことで、アロケーションサイズをさまざまに変更できるようになりました。アロケーションできるサイズはすべて40の倍数で、40 * 2 ^ nの形になるので、4080160320640のように指定できます。これは、ar_tableを基盤とする小規模のハッシュに最適です。

mallocでアロケーションしたバッファにar_tableを保存する代わりに、スロット内でar_tableをインライン保存できるようになりました。これによって、従来 mallocバッファを指すのに使われていたポインタが不要になり、メモリ使用量がきっかり160Bになりました。

Ruby: 3.3.7
size: 0     struct: 40  hash: 160   diff: 4.0x
size: 1     struct: 40  hash: 160   diff: 4.0x
size: 2     struct: 40  hash: 160   diff: 4.0x
size: 3     struct: 40  hash: 160   diff: 4.0x
size: 4     struct: 80  hash: 160   diff: 2.0x
size: 5     struct: 80  hash: 160   diff: 2.0x
size: 6     struct: 80  hash: 160   diff: 2.0x
size: 7     struct: 80  hash: 160   diff: 2.0x
size: 8     struct: 80  hash: 160   diff: 2.0x
size: 9     struct: 160     hash: 544   diff: 3.4x
size: 10    struct: 160     hash: 544   diff: 3.4x

なお、上は実際にはハッシュ1個あたり8B強の節約です(ObjectSpace.memsize_ofは、Rubyがmallocでリクエストしたメモリ量だけを報告します)。アロケーションの戦略はアロケータごとに異なりますが、一般に何らかの形で、パディングによるメモリの無駄や、アロケータで余分なメタデータを必要とする可能性はあります。したがって、ハッシュ1個あたりの実際の節約量は、少なくとも24Bから32B程度と考えて差し支えないでしょう。

可変幅アロケーションには、もう1つメリットがありました。
従来は、GCが未使用のHashを見つけたときにfree()を呼び出してアロケーションメモリを解放する必要がありました。
可変幅アロケーションによって外部バッファが不要になったおかげで、GCはスロットに「解放可能(free)」とマーキングするだけで次の処理に進めるようになり、劇的に高速化しました。

Hashを含む多くの型が可変幅アロケーションに対応するようリファクタリングされたことで、トランジェントヒープはほぼ無用の長物となり、Ruby 3.3でPeter Zhuによって削除されました(#19730)。

ただし、この変更には1つ大きなデメリットがあります。スロットがar_tableを保存するのに必要なメモリを常に確保するようになったため、空ハッシュ{}のサイズが従来の4倍に膨れ上がってしまいました。

🔗 Hashのサイズを削減する

私は最近になって、小規模なハッシュのメモリ使用量を何とかしてもっとコンパクトにできないものかと考え始めました。
理屈のうえでは、Hashインスタンスの基本となるメモリフットプリントは24Bとなります。そのうち16BはRubyの汎用オブジェクトのヘッダーに、8Bはデフォルト値(ifnoneと呼ばれています)にそれぞれ割り当てられます。

ar_tableを利用するハッシュでは、さらにハッシュコードを保存するために固定の8Bを余分に必要とします。

ここから考えると、ハッシュのエントリ1個あたりで本当に必要なメモリサイズは16Bということになります。うち8Bはキー参照用、8Bは値参照用です。すなわち、小規模なハッシュで必要な理論上のメモリフットプリントは、32 + size * 2となります。

Ruby: (理想のバージョン)
size: 0     struct: 40  hash:  32
size: 1     struct: 40  hash:  48
size: 2     struct: 40  hash:  64
size: 3     struct: 40  hash:  80
size: 4     struct: 80  hash:  96
size: 5     struct: 80  hash: 112
size: 6     struct: 80  hash: 128
size: 7     struct: 80  hash: 144
size: 8     struct: 80  hash: 160
size: 9     struct: 160     hash: 544
size: 10    struct: 160     hash: 544

しかし当然ながら、以下の2つの制約があるのでそう簡単にはいきません。

  • 制約1: GCで指定されるメモリサイズを4080160のように切り上げる必要がある。
  • 制約2: Hashにキーが追加される可能性がある以上、(8個分の)スペースを使い切ったらst_tableに移行しなければならない。
    既に述べたようにst_tableのサイズは56Bなので、Hashインスタンスのサイズは24 + 56 => 80となり、80Bより小さくできそうにない。

しかし、本当にそうなのでしょうか?

🔗 状況が好転した

最適化とは、本質的に1度では終わらない反復的作業です。ある時点では複雑すぎて実現不可能だった最適化が、周囲の状況が変化することで可能になったり、むしろ簡単になることもあります。そして、近年のRubyではさまざまな変化が生じています。

変化その1:
Ruby 4.1.0devでスロットのサイズを従来よりも細かく調整可能になりました。既存のスロットサイズは40のべき乗だったのが、Matthew Valentine-Houseのおかげで32のべき乗になり、最大1024まで利用可能になりました(#16282)。これによって、上述の理想的な理論値にかなり近づけることが可能になりました。

変化その2:
最近このブログで汎用インスタンス変数を扱ったときに、set_table構造体のサイズを8B削減した話の中でも少し触れましたが、実はset_tableは本質的にst_tableのコピペだったので、両方の実装を揃えるためにst_tableにも同じ最適化を適用しました。これにより、Ruby 4.0からはst_tableの実際のサイズは48Bになりました。

すなわち、ここからさらに8B削ることに成功すれば、st_tableを利用するハッシュがスロットで64Bのメモリを使えるようになり、ひいてはar_tableを利用するハッシュもエントリ数2個分の容量で楽観的にアロケーションできるようになります。しかも引き続き必要に応じてst_tableに移行可能です。

変化その3:
Peter Zhuによって、4.1.0devのオブジェクトシェイプ(shape)に大幅な変更が加えられました(#17572)。このおかげで、スロットのサイズを常にオブジェクト自体に直接収容可能になりました。
これは私が長年実現したいと思っていた変更です。オブジェクトのスロットサイズを調べることは以前から可能でしたが、このリファクタリングが行われるまでは、スロットサイズを調べるコストがかなり高く、ホットスポット(=頻繁に実行される場所)では避ける方がよいとされていたのです。実際、文字列は頻繁にこのコードパスを通過していました。

しかしPeterが変更してくれたおかげでスロットサイズを調べる必要がなくなり、マイクロベンチマークで8%という大幅な高速化を実現できました(#17730)。

🔗 データの局所性

オブジェクトのスロットサイズの問い合わせが遅い原因のほとんどは、データの局所性(locality)によるものです。スロットサイズの情報はページのメタデータに保存されていて、そこにアクセスするには、最初にそのページ冒頭を指すアドレスを調べておく必要があったのです。

# gc/default.c

struct heap_page_header {
    struct heap_page *page;
};

struct heap_page {
    // 省略...
    uint64_t slot_size_reciprocal;
};

問題は、近年のプロセッサがデータ処理について驚くほど速くなった割りには、メモリのレイテンシの改善がそこまで追いついていないことです。プロセッサが必要とするデータを読み取ろうとしても、その時点でデータがキャッシュに乗っていなければ、プロセッサにとってかなり長く待たされるはめになります。

スロットサイズの情報がオブジェクトのヘッダーに収まるようになったことで、情報が既にプロセッサのキャッシュに乗っていることがほぼ保証されます。CPUのキャッシュラインは一般にx86_64で64B、Apple Siliconで128Bとなるので、オブジェクトの型情報のような隣接情報を読み込んだときに他の情報も一緒に読み込まれる可能性が高まり、実質的にコストゼロでスロットサイズ情報にアクセス可能になります。

話が脱線してきたので、このぐらいにしておきましょう。

🔗 イミュータブル性

私がこの問題に少しずつ取り組み始めた頃に最初に思いついたのは、最初にハッシュをfrozenにしてはどうかというものでした。小さなGCスロットにハッシュをアロケーションしようとすると、ハッシュのサイズを増やさなければならなくなる可能性があるというリスクに邪魔されがちです。

ハッシュをイミュータブルにすれば、容量不足の心配はなくなります。もちろん、都合よくハッシュをイミュータブルにできる機会はそう多くはありませんが、それでも皆さんが思っているよりは多いでしょう。

たとえば以下のハッシュを考えてみます。

some_method({ a: 1, b: 2 })

このコード例で引数に渡しているハッシュはミュータブルなので、最適化はできません。しかし実は、このコードスニペットには普通なら見えない第2の「隠れた」ハッシュが存在していて、しかもそのハッシュはfrozenなのです。

このコードを逆アセンブルしてみましょう。

puts RubyVM::InstructionSequence.compile('some_method({ a: 1, b: 2 })').disasm
== disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(1,27)>
0000 putself                                                          (   1)[Li]
0001 duphash                                {a: 1, b: 2}
0003 opt_send_without_block                 <calldata!mid:some_method, argc:1, FCALL|ARGS_SIMPLE>
0005 leave

ご覧の通り、Rubyはduphashインストラクションでバイトコードを生成しています。duphashという名前からわかるように、これは既存のハッシュのコピー(dup)を作成します。このハッシュはユーザー空間からは決して見えないようになっており、たとえObjectSpace.each_objectを使ってもこのハッシュへの参照は取得できません。
この隠れたハッシュにどうしてもアクセスしたければ、ObjectSpace.dump_allの出力から探し出すことです。

このような最適化が可能となるもう1つのケースは、以下のようにfreezeで明示的にfrozenにしたハッシュリテラルにすることです。

DEFAULT_OPTIONS = {
  host: "localhost",
  port: 3456,
}.freeze

この式を逆アセンブルすると以下のようになります。

>> puts RubyVM::InstructionSequence.compile('{ a: 1, b: 2 }.freeze').disasm
== disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(1,21)>
0000 opt_hash_freeze                        {a: 1, b: 2}, <calldata!mid:freeze, argc:0, ARGS_SIMPLE>(   1)[Li]
0003 leave

opt_hash_freezeインストラクションが使われていることがわかります。これは、私がÉtienne Barriéと組んで2年前にRubyに追加した最適化で(#20684)、"frozen string literals"の概念に似ています。
ハッシュリテラル全体がその場でfreezeされていることをコンパイラが検出すると、隠れたハッシュのコピーを作らずに、隠れたハッシュをfreezeしてから「公開」し、ゼロアロケーションもコピーも行わずにスタックに直接プッシュします。

もちろん、私もこの2つのケースでメモリをごっそり削減できることまでは期待していませんが、私見では、削減できる量がどんなに少なくても、実装が容易ならその価値はあると思います。
しかしもっと重要なのは、最初にハッシュをfrozenにしておくことです。これならアイデアを検証するのにも好都合ですし、すべてのar_tableが固定サイズであることを前提としない形へのリファクタリングを開始した後で、もっと難しい変更に取りかかれるようになります。

パッチそのものは比較的シンプルです(#16653)が、これをマージしたのは、プールサイズが32のべき乗になる前のタイミングでした。
関連するベンチマークを現時点のRuby 4.1.0devで実行してみると、frozenハッシュのサイズが64Bにまで削減され、空ハッシュ{}に至っては32Bにまで縮小されました。

require "objspace"

p ObjectSpace.memsize_of({}.freeze) # => 32
p ObjectSpace.memsize_of({a: 1}.freeze) # => 64
p ObjectSpace.memsize_of({a: 1, b: 2}.freeze) # => 64
p ObjectSpace.memsize_of({a: 1, b: 2, c: 3}.freeze) # => 80
p ObjectSpace.memsize_of({a: 1, b: 2, c: 3, d: 4}.freeze) # => 96
p ObjectSpace.memsize_of({a: 1, b: 2, c: 3, d: 4, e: 5, }.freeze) # => 128
p ObjectSpace.memsize_of({a: 1, b: 2, c: 3, d: 4, e: 5, f: 6}.freeze) # => 128
p ObjectSpace.memsize_of({a: 1, b: 2, c: 3, d: 4, e: 5, f: 6, g: 7}.freeze) # => 160
p ObjectSpace.memsize_of({a: 1, b: 2, c: 3, d: 4, e: 5, f: 6, g: 7, h: 8}.freeze) # => 160

🔗 構造体をさらに縮小する

さて、ミュータブルなハッシュについても同じ最適化を可能にするためには、st_table構造体のサイズをもっと縮小して、st_tableを使うハッシュをサイズ64Bのスロットに収まるようにする必要があります。さもないと80Bの壁を超えられなくなり、最適化のおいしい部分がごっそり損なわれてしまいます。

既に述べたように、st_table構造体の理論上のサイズは72Bであることが既にわかっているので、後はどの8Bを削ればよいかを見つけ出すことです。このときは複数のアイデアを検討しました。

st_table構造体を改めて見てみましょう。

struct RHash {
    struct RBasic basic;    // 16B
    const VALUE ifnone;     // 8B
};

struct st_table {
    unsigned char entry_power, bin_power, size_ind; // 1個につき1B、計3B
    unsigned int rebuilds_num;                      // 4B
    const struct st_hash_type *type;                // 8B
    st_index_t num_entries;                         // 8B
    st_index_t entries_start, entries_bound;        // 1個につき8B、計16B
    struct st_table_entry *entries;                 // 8B
};

最初に思いついたのは、ハッシュのデフォルト値(ifnone)を別の場所へ追いやることでした。デフォルト値を持つハッシュはほとんどないので、デフォルト値のためにすべてのハッシュで8Bも確保するのは無駄に思えました。

方法の1つとして、デフォルト値をハッシュのインスタンス変数にすることも考えましたが、汎用インスタンス変数の記事でも述べたように、Hashのような多くの型のインスタンス変数はグローバルなテーブルに保存され、しかもこのテーブルはRactorと同期する必要があります。つまりifnoneをインスタンス変数にすると、ハッシュのデフォルト値がRactorにとっての競合ポイントになってしまう可能性があります。
これでは前回苦労して積み上げた成果の一部が水の泡になってしまいそうだったので、この案は見送ることにしました。今後汎用インスタンス変数の競合が発生しなくなれば、このアイデアも現実的な選択肢になるかもしれません。

第2のアイデアは、st_table.typeを消し去るというものです。このtypeメンバーは2つの関数へのポインタを保持します。1つはハッシュ関数として、もう1つは比較関数として使われます。
st_tableを利用するハッシュの場合、このポインタは必ず2種類の値のうちどちらか一方になります。この値は、99%のケースではRubyオブジェクトのデフォルトのハッシュ関数になりますが、ごくまれにHash#compare_by_identityで作成される同一性ハッシュ(identity hash)が使われることがあり、この場合は別のポインタになります。

理屈のうえでは、これをst_tableに保存するよりも、必要に応じていつでも引数として渡す方がよほど合理的だと思われます。

しかし話はそう簡単にはいきません。
st_tableはC APIとして公開されていて、多くのネイティブgemで使われているため、このAPIが変更されれば当然gemも軒並み動かなくなってしまいます。

唯一見込みがあるとすれば、st.hを実質的にforkして、Ruby以外はアクセスできない内部専用のプライベート版を作ることでしょう。しかしそれでも問題が生じる可能性はあります。RubyのこのC APIが公開しているRHASH_TBLマクロは、st_tableへのポインタを返すことになっています。実際、現在でもこのマクロがar_tableを利用するハッシュで呼び出されたときは、ar_tablest_tableに変換しなければなりません。
そういうわけで、この案も実現は極めて困難です。

本来ならRHASH_TBLは非推奨化して削除すべきですが、相当時間がかかるでしょう。

さらに別のアイデアはJohn Hawthornが提案してくれたもので、これはnum_entriesentries_startentries_boundを32ビット整数にするというものです。これなら構造体を12B削れますが(アラインメントルールがあるので8B止まりの可能性が高い)が、その代わりハッシュのエントリ数が最大約40億件に制限されてしまいます。

公平のために申し上げておくと、ハッシュがそこまで巨大になればエントリのリストだけで96GiBものメモリが必要なので、Rubyのハッシュでそこまで巨大なデータを処理することがあるとは考えにくいとはいえ、こんな恣意的な制限が存在していることにはどうも違和感を覚えます。面白いことに、10年前にVladimir Makarovがパッチを提出したときにも同じことが議論されていました(#12142)。

そこでJohnとさらに話し合った結果、entries_startを縮小すれば、ハッシュテーブルの最大サイズに制約をかけずに済むことに気づきました。しかしその理由を説明するには、entries_startを縮小する目的について説明する必要があります。

既に述べたように、st_tableのエントリもentries配列内に連続して保存されます。

table->entries = {
  {.hash = 0x12, .key = 0x34, .value = 0x56},
  {.hash = 0x78, .key = 0x90, .value = 0x12},
  // 略...
}

これはまさに、Rubyのハッシュが挿入順序を保存している理由であり、HashHash#eachでイテレーションする操作が連続配列のイテレーションと(ほぼ)同じぐらいシンプルな理由でもあります。

ただしハッシュはエントリの追加だけでなく削除も可能である点に注意が必要です。ハッシュからエントリを削除すると、連続配列に隙間ができてしまいます。

単純に考えると、ハッシュからエントリを削除するときは、隙間を埋めるために以後の全エントリをずらしたうえで、オフセットをすべて更新する必要があります。この処理はかなりコスト高ですが、もっと重要なのは、計算量がハッシュテーブル本来のO(1)に収まらなくなってしまうことです。

そのため、どのエントリが存在し、どのエントリが削除されたかを把握するため、エントリのハッシュコードに特殊な0値を設定するようになっています。

/* 予約済みハッシュ値とその置き換え */
#define RESERVED_HASH_VAL (~(st_hash_t) 0)
#define RESERVED_HASH_SUBSTITUTION_VAL ((st_hash_t) 0)

static inline st_hash_t
normalize_hash_value(st_hash_t hash)
{
    /* RESERVED_HASH_VALは削除されたエントリ用。
    これを別の値にマッピングするが、
    このマッピングはごくまれにしか発生しない。*/
    return hash == RESERVED_HASH_VAL ? RESERVED_HASH_SUBSTITUTION_VAL : hash;
}

/* ポインタE_PTRで指定された削除済みエントリの
       マーキングやチェックを行うマクロ。 */
#define MARK_ENTRY_DELETED(e_ptr) ((e_ptr)->hash = RESERVED_HASH_VAL)
#define DELETED_ENTRY_P(e_ptr) ((e_ptr)->hash == RESERVED_HASH_VAL)

その結果として、イテレーションでRESERVED_HASH_VALをスキップする必要が生じます。それ自体は大した処理ではありませんが、何らかの理由でHashの冒頭から多数の要素をごっそりと削除する場合は、Hash#shiftメソッドだけが頼りなので、イテレーションで余分な負荷が大量に発生する可能性があります。

entries_startを追加したのは、このケースを高速化するためです。最初のエントリを削除すると、以後の処理を省略するためにstentries_startを再計算します。

/* 配列のエントリ内のインデックスNの要素を削除し、
   TABテーブルの冒頭からの要素を更新する */
static inline void
update_range_for_deleted(st_table *tab, st_index_t n)
{
    /* ここではentries_boundを更新しないこと。さもないとテーブルの再構築前に
    削除済みエントリによってすべてのbinが埋められてしまう可能性がある。 */
    if (tab->entries_start == n) {
        st_index_t start = n + 1;
        st_index_t bound = tab->entries_bound;
        st_table_entry *entries = tab->entries;
        while (start < bound && DELETED_ENTRY_P(&entries[start])) start++;
        tab->entries_start = start;
    }
}

つまり、構造体のこの部分は厳密には必須ではなく、存在しているとしても、本来の値より決して高くならないのであれば、100%正確である必要すらありません。
言い換えると、entries配列全体をアドレス指定できる必要はなく、実際に非常に大きな値になる可能性はほぼまったくないと合理的に仮定できます。

したがって、entries_startのサイズを小さくしても問題ありません。最悪のケースとして、エントリが多数削除された特殊なハッシュについては、次にエントリが挿入されてハッシュがリビルドされるまで速度が若干落ちる可能性があります。

しかし、縮小するだけでは不十分なのではと思われるかもしれません。実際私は、8Bを丸ごと確保する必要があったので、縮小しても解決になりませんでした。

皆さんはお気づきにならなかったかもしれませんが、上で示したst_table構造体には、実は未使用スペースがあったのです。

struct st_table {
    unsigned char entry_power, bin_power, size_ind; // それぞれ1B、計3B
    unsigned int rebuilds_num;                      // 4B

ここでは、unsigned char型の値3個に続いて、unsigned int型の値が1個配置されています。CPUには、アラインメントルールと呼ばれるさまざまな制約が存在していて、アーキテクチャごとに少しずつ異なるものの、基本的にはどのアーキテクチャでも、指定のサイズのワードをどのアドレスからでも読み取れるとは限らないことが重要です。このアドレスは、読み出すメモリサイズと一致していなければなりません。

たとえば、8Bの値(ポインタなど)は必ず8Bにアラインされている必要があります。つまりアドレスは8で割り切れる必要があるということです。さもないと、CPUアーキテクチャによってはまったく動作しなくなったり、本来よりも速度が落ちることがあります。

そのため、コンパイラは場合によって「パディング(=埋める)」、つまり構造体の異なるメンバーの間に暗黙の隙間を挿入することがあります。

上の例では、rebuilds_numのサイズは4Bなので、ほとんどのCPUアーキテクチャでは4にアラインする必要があります。しかしrebuilds_numの上には、サイズ1Bのメンバーが3個あるので、コンパイラは1Bのパディングを挿入します。このパディングは本質的に無駄なスペースです。

つまり、以下のようにentries_startのサイズを1Bにしたうえで、size_indの隣に移動すれば、まるまる8B浮かせることが可能になるわけです。

struct st_table {
    unsigned char entry_power, bin_power, size_ind, entries_start; // 各1B、計4B
    unsigned int rebuilds_num;                                     // 4B
    const struct st_hash_type *type;                               // 8B
    st_index_t num_entries;                                        // 8B
    st_index_t entries_bound;                                      // 8B
    struct st_table_entry *entries;                                // 8B
};

これでパッチはほぼ完成です。後は、entries_startが桁溢れせずに255で止まるようひと手間加えるだけです。完全なパッチは#18149を参照してください。

このささやかな変更によって、st_tableを利用するハッシュがサイズ64Bのスロットにうまく収まるようになりました。こうなったら、ar_tableを利用するハッシュをさらに小さなスロットにアロケーション可能にする作業にも張り合いが出るというものです。

しかし、そこからさらに進んで、より小さなサイズ40Bのスロットに収められるでしょうか?前述の通り、今後インスタンス変数が改善されてC APIの一部が非推奨化されれば、ifnonetypeを取り除けそうなので、後は残り8バイトを何とかして削るだけで済みます。私は十分実現可能だと見込んでいます。

しかし現時点では時期尚早です。まずはコードベースの他の部分が進化するのを待つ必要があります。

🔗 動的な配列テーブルサイズ

しかしいずれにしろ、st_tableをサイズ40Bのスロットに収めても、空ハッシュ{}のサイズを縮小できるだけです。しかしそれ以外については、64Bあれば最適なサイズに限りなく近づけられます。

現状の問題は、ar_tableが導入されて以来、すべてのコードがその固定サイズを前提に書かれてきたことです。そのため、サイズを動的に扱えるようにして固定サイズという前提の一部を見直すためのリファクタリングが少々必要になってきます。

私の未完のパッチ(e351efe)にはまだ修正の必要なバグがまだ残っているものの、正常系(happy path)では期待通り動いています。

Ruby: ruby 4.1.0dev (2026-08-05T19:51:05Z hash-dynamic-ar-bo.. 095039c7b1) +PRISM [arm64-darwin25]
size: 0     struct:  32     hash:  64   diff: 2.0x
size: 1     struct:  32     hash:  64   diff: 2.0x
size: 2     struct:  40     hash:  64   diff: 1.6x
size: 3     struct:  64     hash:  80   diff: 1.3x
size: 4     struct:  64     hash:  96   diff: 1.5x
size: 5     struct:  64     hash: 128   diff: 2.0x
size: 6     struct:  80     hash: 128   diff: 1.6x
size: 7     struct:  80     hash: 160   diff: 2.0x
size: 8     struct:  96     hash: 160   diff: 1.7x
size: 9     struct:  96     hash: 448   diff: 4.7x
size: 10    struct: 128     hash: 448   diff: 3.5x

しかしその一方で、最初により小さなスロットから始まったハッシュは、これまでよりも早いタイミングでst_tableに移行しなければならなくなります。そうなるとメモリ使用量は160Bを超えてしまい、しかも完全にはスロットに埋め込まれなくなるため、ガベージコレクタで回収するときの処理が増えてしまいます。

たとえば以下のケースを考えてみます。

hash = {a: 1, b: 2, c: 3}       # 80B
hash[:d] = 4
p ObjectSpace.memsize_of(hash)  # 176B

このハッシュのサイズは当初80Bですが、エントリ数4個分の容量を持つentries配列をアロケーションする必要が生じて、サイズの合計は176Bになってしまいます。よくあることですが、最適化によってある場所のメモリを節約したら、別の場所でメモリ使用量が増えたということです。

難しいのは、これがトータルでは節約なのか増加なのかを見極めることです。そしてこれは、実行しているコードの内容によって大きく変わるので、この問いに確実に答えるのは非常に困難もしくは不可能です。たとえ大多数のユーザーにとってはプラスに働くとしても、一部のコードベースではマイナスの影響が生じるようなケースは確実に存在します。

いろいろ申しましたが、実は最悪のケースであってもマイナスの影響は大したことはありません。この最適化は主に小規模なハッシュに関連しているからです。

この種の疑問に答えるには、ruby-benchスイートで測定するのが通例です。このスイートには現実のアプリケーションがいくつも含まれているので、結果に一定の確信を持てます。しかし、驚くほど良好な結果が得られる例外的なケースを別にすれば、この種の試みは結局のところ一種の「賭け」なのです。

ruby/ruby-bench - GitHub

🔗 最後に

最後のパッチに磨きをかけて測定し、マージするための時間はまだ4か月ほど残されているので、12月にリリースされるRuby 4.1.0でこのパッチを首尾よく含められれば、Rubyユーザーにとってかなりのメモリ削減効果が得られるでしょう。

しかしいずれにせよ、本記事は「ハッシュが実はいかにコスト高であるか」を皆さんに知らしめるよい機会にもなったと思います。

Rubyのハッシュには専用構文が用意されていて、スキーマレスな構造体のように手軽に扱えるおかげで、Ruby開発者はともするとハッシュを使いすぎてしまう傾向があります。
しかしデータ構造が事前に明らかになっているのであれば、Structか、インスタンス変数を含むクラスを定義する方が、一般的にメモリ使用量がずっと少なく済みますし、アクセスも遥かに高速です。

同様に、「ハッシュの配列」パターンはパフォーマンスが重要な部分では避けるべきです。それよりも、私の自信作であるActive Recordへのパッチ(#51744)で行っているように、フラットな配列で添字とマッピングする方が多くの場合良好な結果が得られます5

関連記事

RubyのRactorを解き放つ(3)汎用インスタンス変数テーブルの競合を解消する(翻訳)

Rubyのメモリ管理方法2: Ruby 3.1の文字列の可変幅アロケーション(翻訳)


  1. 原注: Data.defineもそうです。これはStructがトレンチコートを着たようなものです。 
  2. 訳注: binsはオープンアドレッシング方式のハッシュテーブルにおけるバケット配列を指します。 
  3. 訳注: バンプポインタアロケータとは、確保済み領域の末尾ポインタを単純に前方に進める(bump)だけでメモリを割り当てる方式のアロケータです。mallocと異なりアロケーションのコストが定数時間で済む一方、個別にfreeできないため、まとめてリセットする用途に限定されます。 
  4. 原注: 可変幅アロケーションが最初に導入されたのはRuby 3.2でしたが、Hashを含む多くの型が可変幅アロケーションのメリットを得られるようになったのはRuby 3.3からです。 
  5. 訳注: ハッシュの代わりにカラムインデックスを使うことでメモリ使用量を大幅に削減しています。 

The post RubyのHashがメモリ食いである理由を探ってメモリ使用量を削減した話(翻訳) first appeared on TechRacho.


Viewing full articles (2028 articles)
Browse article index

Latest Images

Trending Articles



Latest Images