ar_table の話(もっと良い提案を募集します)

Hash なのに Hash 値を使わない O(n) な Hash table

いきなり色々すっ飛ばして、hash.c に定義してある ar_table の話をする。

話の前提は Ruby 2.6 の改善を自慢したい - クックパッド開発者ブログ にまとめた transient heap。この話は今後、ここでもちゃんとまとめたい。theap の話は、本記事には直接関係しない。

簡単に言うと、ar_table は、Hash を実装するためのデータ構造であり、8 要素に限定した連想配列。8要素以上であれば、st_table を使う(これも、今後紹介する)ようにするので、とりあえず 8 要素以下の Hash オブジェクトのための特殊なデータ構造、と考えて欲しい。線形探索を行うので、探索コストは O(n)。

Hash の要素は key, value の2要素、に加えて key のハッシュ値の3要素。

それを格納するのが ar_table_entry (https://github.com/ruby/ruby/blob/ruby_2_6/internal.h#L816)。

typedef struct ar_table_entry {
    VALUE hash;
    VALUE key;
    VALUE record;
} ar_table_entry;

VALUE はまだ紹介していないけど、オブジェクトへのポインタ。ちなみに、ハッシュ値を格納する hashVALUE なのはバグで(実害はない)、本当は単なる数値であるべき。trunk では直っている。valuerecord なのは、単なる趣味だと思う(元にした実装があるんです、多分)。

ar_table は、見てわかるように ar_table_entry の配列 (https://github.com/ruby/ruby/blob/ruby_2_6/internal.h#L822)。

typedef struct ar_table_struct {
    ar_table_entry entries[RHASH_AR_TABLE_MAX_SIZE];
} ar_table;

凄い古典的な連想リストとして実装。探索 (lookup) を疑似 ruby コードで実装すると、こんな感じ:

def lookup(table, key)
  hash_value = key.hash

  table.size.times do |i|
    if table.entries[i].hash == hash_value &&
       table.entries[i].key == key
      return table.entries[i].record
   end
  end
  raise not_found
end

要するに、最初から辿っていく、というもの。挿入 (insert) は、空いてるエントリに key, value, ハッシュ値をセットするだけの、そのまんまなので省略。

削除 (delete) はちょっとだけ考えなければならなくて、

  • compaction する(後ろにあるエントリを詰める。削除時、ちょっと時間がかかるかもしれないが、8要素フルに使える)
  • skip エントリを入れておく(消されたのでスキップとする。ちょっとエントリが無駄になる)

の2つの選択肢がある。MRI 2.6 では、後者 skip エントリを入れるという実装になっている。今考えてみると、前者でも良かったかもしれない(delete よりも lookup のほうが圧倒的に多そうだし。実装が圧倒的に簡単になるし...。今から変えようかな。

なお、skip エントリかどうかは、ar_table_entry::hashRESERVED_HASH_VAL が入っているかどうかでチェックしている(https://github.com/ruby/ruby/blob/ruby_2_6/hash.c#L349)。

insert は翻って、8要素全部使っているが skip が含まれているなら compaction してから insert を行う (https://github.com/ruby/ruby/blob/ruby_2_6/hash.c#L677)。

これでアルゴリズムの詳細の説明が終わった。Hash テーブルといいながら、8要素以下であれば、単なる線形探索を行う O(n)。ただし、n <= 8 なので、探索時間なんてたいしてかからないだろう、というのが、実装当初の意図。ただ、本当に O(n) でいいの?

Hash なんだから、Hash 値を使って O(1) にしよう

たかだか8要素、されど8要素、ということで、8要素のハッシュ表をオープンアドレッシングで教科書通り作ってみる。

用語があやふやなので ハッシュテーブル - Wikipedia の用語を利用してみる。

ルート配列を用意しなければならない。そこで、8 エントリを 7 エントリにすることで、その空いた領域をルート配列にしよう。64bit CPU の場合は 8 x 3 = 24B だった。さて、ルート配列をどれくらい用意しよう。

7 要素なので、index は 3 bit あれば足りる。キリが良く 4 bit にしておこう。どれくらいのルート配列の長さがあれば良いか。とりあえずエントリ数の2倍 14、ちょっとキリが悪いので、16 エントリにしてみよう。すると、4bit * 16 = 64bit = 8B。まぁ、こんなもんだろう。8B にルート配列を突っ込めば良さそう。

と思って作ったのがこちら https://github.com/ko1/ruby/compare/hash_ar

さて、どれくらい速くなったかな、わくわく、ということで、ベンチマークを用意してみた。

require_relative 'benchmark/lib/load'

Benchmark.driver(repeat_count: 5){|x|
  x.executable name: 'clean', command: %w'../clean-trunk/miniruby'
  x.executable name: 'modify', command: %w'./miniruby'

  10.times{|i|
    x.prelude %Q{h#{i} = { #{i.times.map{|j| "#{j+1} => #{j+1}, "}.join } }}
  }

  [100, :symbol, 's', 'short_str', 'long ' * 100 + 'str'].each{|key|
    10.times{|i|
      x.report "x = h#{i}[#{key.inspect.to_s[0..10]}]", %Q{x = h#{i}[#{key.inspect}];}
    }
  }
  [100, :symbol, 's', 'short_str', 'long ' * 100 + 'str'].each{|key|
    10.times{|i|
      x.report "h#{i}[#{key.inspect.to_s[0..10]}] = true", %Q{h#{i}[#{key.inspect}] = true;}
    }
  }
}

何をやっているかというと、要素数が 0~9 のハッシュ h0h9 までを用意し、それぞれに対して、いろいろなキーで (1) 表引き(x = h0[100] のように。ただし、エントリは存在しない)、(2) セット(h0[100] = true のように。2度目以降は存在するキーに対してセット)を行う。

clean がパッチ適用前の MRI trunk で、modify が適用後。

実行結果(全データ、5回計測し、最良値):

# 表から取得(存在しないキーについて)
                            clean      modify
           x = h0[100]    71.382M     68.264M i/s -    111.733M times in 1.565281s 1.636780s
           x = h1[100]    64.569M     65.207M i/s -    103.362M times in 1.600807s 1.585127s
           x = h2[100]    58.040M     70.716M i/s -    100.978M times in 1.739816s 1.427940s
           x = h3[100]    57.785M     64.796M i/s -     99.286M times in 1.718206s 1.532288s
           x = h4[100]    53.154M     65.509M i/s -     95.571M times in 1.798011s 1.458906s
           x = h5[100]    52.951M     64.176M i/s -     94.424M times in 1.783222s 1.471311s
           x = h6[100]    50.729M     63.956M i/s -     94.091M times in 1.854785s 1.471170s
           x = h7[100]    52.343M     64.688M i/s -     94.679M times in 1.808842s 1.463625s
           x = h8[100]    48.283M     59.181M i/s -     80.856M times in 1.674611s 1.366249s
           x = h9[100]    59.493M     65.898M i/s -    103.146M times in 1.733747s 1.565252s
       x = h0[:symbol]    71.649M     76.317M i/s -    111.117M times in 1.550855s 1.455993s
       x = h1[:symbol]    66.340M     72.472M i/s -    106.463M times in 1.604811s 1.469017s
       x = h2[:symbol]    61.781M     67.467M i/s -    104.569M times in 1.692584s 1.549918s
       x = h3[:symbol]    59.895M     67.812M i/s -    101.895M times in 1.701233s 1.502618s
       x = h4[:symbol]    58.011M     68.718M i/s -     79.001M times in 1.361837s 1.149642s
       x = h5[:symbol]    54.835M     67.058M i/s -     83.361M times in 1.520197s 1.243109s
       x = h6[:symbol]    51.676M     69.619M i/s -     95.163M times in 1.841535s 1.366927s
       x = h7[:symbol]    51.936M     66.584M i/s -     91.941M times in 1.770270s 1.380817s
       x = h8[:symbol]    52.347M     63.100M i/s -     94.175M times in 1.799060s 1.492460s
       x = h9[:symbol]    61.492M     61.434M i/s -     97.341M times in 1.582983s 1.584480s
           x = h0["s"]    52.246M     47.940M i/s -     90.479M times in 1.731800s 1.887331s
           x = h1["s"]    45.926M     47.301M i/s -     66.259M times in 1.442724s 1.400785s
           x = h2["s"]    44.499M     47.372M i/s -     85.578M times in 1.923133s 1.806515s
           x = h3["s"]    45.103M     47.777M i/s -     85.701M times in 1.900123s 1.793763s
           x = h4["s"]    43.234M     47.875M i/s -     74.151M times in 1.715084s 1.548830s
           x = h5["s"]    42.249M     47.268M i/s -     79.976M times in 1.892973s 1.691973s
           x = h6["s"]    41.281M     47.383M i/s -     81.115M times in 1.964935s 1.711905s
           x = h7["s"]    40.827M     43.986M i/s -     58.734M times in 1.438612s 1.335291s
           x = h8["s"]    39.907M     43.387M i/s -     79.366M times in 1.988796s 1.829248s
           x = h9["s"]    41.744M     42.676M i/s -     83.145M times in 1.991773s 1.948295s
   x = h0["short_str"]    46.725M     45.238M i/s -     88.081M times in 1.885112s 1.947063s
   x = h1["short_str"]    44.019M     43.855M i/s -     84.102M times in 1.910584s 1.917716s
   x = h2["short_str"]    42.807M     44.158M i/s -     80.763M times in 1.886661s 1.828943s
   x = h3["short_str"]    42.550M     44.119M i/s -     82.935M times in 1.949125s 1.879820s
   x = h4["short_str"]    40.764M     44.179M i/s -     78.123M times in 1.916487s 1.768325s
   x = h5["short_str"]    40.123M     44.326M i/s -     78.880M times in 1.965977s 1.779536s
   x = h6["short_str"]    39.368M     44.127M i/s -     77.492M times in 1.968403s 1.756120s
   x = h7["short_str"]    39.351M     43.959M i/s -     78.870M times in 2.004282s 1.794183s
   x = h8["short_str"]    37.973M     40.953M i/s -     76.933M times in 2.025977s 1.878552s
   x = h9["short_str"]    40.919M     40.928M i/s -     80.092M times in 1.957326s 1.956906s
   x = h0["long long ]     7.609M      7.583M i/s -     20.516M times in 2.696223s 2.705468s
   x = h1["long long ]     7.549M      7.532M i/s -     20.622M times in 2.731816s 2.737974s
   x = h2["long long ]     7.522M      7.531M i/s -     20.596M times in 2.737924s 2.734750s
   x = h3["long long ]     7.492M      7.531M i/s -     20.516M times in 2.738329s 2.724281s
   x = h4["long long ]     7.487M      7.534M i/s -     20.238M times in 2.703273s 2.686341s
   x = h5["long long ]     7.444M      7.500M i/s -     19.177M times in 2.576132s 2.557040s
   x = h6["long long ]     7.425M      7.533M i/s -     20.351M times in 2.740651s 2.701387s
   x = h7["long long ]     7.397M      7.530M i/s -     20.290M times in 2.743029s 2.694600s
   x = h8["long long ]     7.387M      7.440M i/s -     20.270M times in 2.743931s 2.724420s
   x = h9["long long ]     7.503M      7.444M i/s -     20.052M times in 2.672382s 2.693812s
# セット
        h0[100] = true    46.083M     42.413M i/s -     83.860M times in 1.819765s 1.977203s
        h1[100] = true    43.511M     42.373M i/s -     84.211M times in 1.935364s 1.987362s
        h2[100] = true    41.062M     42.208M i/s -     81.507M times in 1.984945s 1.931053s
        h3[100] = true    39.654M     42.213M i/s -     77.406M times in 1.952063s 1.833701s
        h4[100] = true    39.428M     42.478M i/s -     74.194M times in 1.881773s 1.746667s
        h5[100] = true    42.973M     42.265M i/s -     80.608M times in 1.875779s 1.907174s
        h6[100] = true    40.662M     42.281M i/s -     76.306M times in 1.876611s 1.804727s
        h7[100] = true    38.067M     40.084M i/s -     70.484M times in 1.851591s 1.758405s
        h8[100] = true    48.033M     47.918M i/s -     89.954M times in 1.872740s 1.877238s
        h9[100] = true    48.293M     47.340M i/s -     89.425M times in 1.851723s 1.888990s
    h0[:symbol] = true    43.157M     42.848M i/s -     78.601M times in 1.821269s 1.834401s
    h1[:symbol] = true    43.926M     45.265M i/s -     88.841M times in 2.022519s 1.962692s
    h2[:symbol] = true    41.430M     42.967M i/s -     82.082M times in 1.981232s 1.910365s
    h3[:symbol] = true    40.009M     42.854M i/s -     80.231M times in 2.005313s 1.872203s
    h4[:symbol] = true    39.538M     43.023M i/s -     77.901M times in 1.970253s 1.810694s
    h5[:symbol] = true    41.638M     42.950M i/s -     75.416M times in 1.811218s 1.755912s
    h6[:symbol] = true    38.124M     42.889M i/s -     67.325M times in 1.765961s 1.569744s
    h7[:symbol] = true    37.903M     42.646M i/s -     76.329M times in 2.013815s 1.789841s
    h8[:symbol] = true    48.710M     44.633M i/s -     80.689M times in 1.656528s 1.807852s
    h9[:symbol] = true    51.595M     45.664M i/s -     90.569M times in 1.755380s 1.983365s
        h0["s"] = true    35.082M     35.235M i/s -     68.348M times in 1.948236s 1.939793s
        h1["s"] = true    34.785M     34.682M i/s -     69.035M times in 1.984618s 1.990507s
        h2["s"] = true    34.107M     34.471M i/s -     65.289M times in 1.914228s 1.894018s
        h3["s"] = true    33.682M     34.424M i/s -     68.677M times in 2.039002s 1.995034s
        h4["s"] = true    32.591M     34.496M i/s -     69.715M times in 2.139110s 2.020991s
        h5["s"] = true    32.173M     33.911M i/s -     63.276M times in 1.966719s 1.865913s
        h6["s"] = true    32.307M     34.608M i/s -     66.085M times in 2.045527s 1.909525s
        h7["s"] = true    31.360M     31.992M i/s -     67.392M times in 2.149006s 2.106543s
        h8["s"] = true    33.849M     33.663M i/s -     71.534M times in 2.113358s 2.124989s
        h9["s"] = true    33.690M     33.655M i/s -     68.199M times in 2.024321s 2.026436s
h0["short_str"] = true    33.163M     32.519M i/s -     70.550M times in 2.127370s 2.169500s
h1["short_str"] = true    32.358M     32.684M i/s -     67.495M times in 2.085887s 2.065060s
h2["short_str"] = true    32.791M     32.503M i/s -     68.566M times in 2.091024s 2.109524s
h3["short_str"] = true    31.632M     32.729M i/s -     66.508M times in 2.102565s 2.032062s
h4["short_str"] = true    30.384M     32.541M i/s -     66.774M times in 2.197653s 2.052010s
h5["short_str"] = true    30.526M     32.531M i/s -     66.923M times in 2.192355s 2.057222s
h6["short_str"] = true    31.069M     32.508M i/s -     64.041M times in 2.061248s 1.969987s
h7["short_str"] = true    29.933M     30.252M i/s -     64.463M times in 2.153544s 2.130880s
h8["short_str"] = true    31.347M     31.222M i/s -     67.977M times in 2.168574s 2.177243s
h9["short_str"] = true    31.765M     31.263M i/s -     61.937M times in 1.949834s 1.981173s
h0["long long ] = true     7.173M      7.168M i/s -     19.712M times in 2.747900s 2.749936s
h1["long long ] = true     7.155M      7.166M i/s -     19.670M times in 2.749118s 2.744699s
h2["long long ] = true     7.123M      7.159M i/s -     18.257M times in 2.563142s 2.550164s
h3["long long ] = true     7.115M      7.161M i/s -     19.520M times in 2.743328s 2.725762s
h4["long long ] = true     7.081M      7.162M i/s -     19.297M times in 2.725344s 2.694446s
h5["long long ] = true     7.059M      7.151M i/s -     19.410M times in 2.749527s 2.714388s
h6["long long ] = true     7.048M      7.163M i/s -     19.428M times in 2.756493s 2.712289s
h7["long long ] = true     7.040M      7.043M i/s -     19.244M times in 2.733740s 2.732542s
h8["long long ] = true     7.190M      7.181M i/s -     19.782M times in 2.751245s 2.754663s
h9["long long ] = true     7.186M      7.179M i/s -     18.897M times in 2.629533s 2.632356s

なんというか、正直ぱっとしない。

           x = h0[100]    71.382M     68.264M i/s -    111.733M times in 1.565281s 1.636780s
           x = h1[100]    64.569M     65.207M i/s -    103.362M times in 1.600807s 1.585127s
           x = h2[100]    58.040M     70.716M i/s -    100.978M times in 1.739816s 1.427940s
           x = h3[100]    57.785M     64.796M i/s -     99.286M times in 1.718206s 1.532288s
           x = h4[100]    53.154M     65.509M i/s -     95.571M times in 1.798011s 1.458906s
           x = h5[100]    52.951M     64.176M i/s -     94.424M times in 1.783222s 1.471311s
           x = h6[100]    50.729M     63.956M i/s -     94.091M times in 1.854785s 1.471170s
           x = h7[100]    52.343M     64.688M i/s -     94.679M times in 1.808842s 1.463625s
           x = h8[100]    48.283M     59.181M i/s -     80.856M times in 1.674611s 1.366249s
           x = h9[100]    59.493M     65.898M i/s -    103.146M times in 1.733747s 1.565252s

これを見ると、clean では、エントリ数に比例してちょっと遅く案っている(O(n))んだけど... modify のほう、O(1) といわれればそんな気もするんだけど、もうちょっと速いままにならないかな...。ルート配列を一度アクセスする分、どーしょーもないかなー。

ご意見募集

というわけで、スゲー単純なデータ構造の話なんだけど、もうちょっと「ぱっとした」値になりそうな気がするので、もしよかったら教えてください。アセンブラで AVX とかの命令使ってもいいけど、ほとんどのバイナリで使えなくなる可能性があるので、ちょっと...。

8要素とか、そういう単位なので、何かカッコいい方法が知られてるんじゃないかと期待しています。

ちなみに、8要素だと 8B * 3 * 8 = 192B かかるんだけど、これをもっと圧縮する何か素敵なアイディアがあれば...。これはさすがに無いかなー。hash値を 32bit しか格納しない(eql? されるかもしれない回数が増える)、とかくらいか。それでも 4B * 8 = 32B しか浮かない。いや、しかってだけでもないか。

なんというか、とてもショーモナイ話だし、実際ここが2倍に高速化しても、あまり実アプリでは意味ないんだけど...。弄りやすくはあります。

既存研究

  • hash 値だけを先頭に集めれば線形探索速いよね?
    • trunk に比べて速くなる場合があることは確認しているが、O(1) に比べてどうか?
  • ブルームフィルタはどう?
    • 結果ほとんど見えなかった