hash vs. bloomfilter その2

当面はメモリを沢山積んだマシンでどうにかなってしまったのだけど、今後の事も考えてちょっとだけ深追いする事にした。

まず、既エントリーのキーであるかを参照するところがボトルネックであるようなので、とりいそぎハッシュ関数を疑ってみる。案の定 SHA1 を使っているので、そりゃ遅いだろう。ここは大体一様乱数になってればいいので、CRC32 にしてみる。ビット長を短くする分処理も簡単になるし、どうせ最後はエントリー数 m に合わせて %m してしまう。 簡単のため、最大エントリー数は十分に大きいという仮定をおくと以下のように BloomFilter をかける。

require 'bitset'
require 'zlib'

class BloomFilter
  def initialize(max_entries, num_hashes)
    @num_hashes = num_hashes
    @size = max_entries.to_i       
    @bitmap = BitSet.new(@size)
    @mask = BitSet.new(@size)
  end

  def insert(key)
    mask = make_mask(key)
    @bitmap |= mask
  end

  def has?(key)
    mask = make_mask(key)
    return ((@bitmap & mask) == mask);
  end

  def make_mask(key)
    @mask.clear
    1.upto(@num_hashes.to_i) do |i| 
      hash = Zlib.crc32(key, i)     # i = seed of CRC32
      @mask.set(hash % @size, 1)
    end
    return @mask
  end
end

def main 
  bf = BloomFilter.new(1000000, 4)
  num = 0
  while line = ARGF.gets
    data = line.chomp.split(/\s+/)
    if !bf.has?(data[0])
      num += 1
      bf.insert(data[0])
    end
  end
  print "#distinct elements = #{num}\n"
end

main

上記の場合、Bloomfilter そのものに使用するメモリ量は 1,000,000 bit (約122KB) で固定となる。ただしエントリー数はそれほど大きくない事を仮定している。
しかしこれでもどうも速度が遅すぎて困る。高々10,000エントリーを処理するのに22秒もかかってしまう。 

% ruby simple-bf.rb test.dat
14.90s user 7.13s system 98% cpu 22.343 total #element = 9652

ruby -r profile の出力をみると以下のようになっている。

%   cumulative   self              self     total
 time   seconds   seconds    calls  ms/call  ms/call  name
 23.16     8.45      8.45    20000     0.42     0.63  Integer#upto
 16.88    14.61      6.16    10000     0.62     0.62  BitSet#&
 12.55    19.19      4.58    10000     0.46     0.46  BitSet#|
  8.74    22.38      3.19    10000     0.32     0.32  BitSet#<=>
  6.58    27.36      2.40    20000     0.12     0.82  BloomFilter#make_mask
  3.89    28.78      1.42    80000     0.02     0.02  Zlib.crc32
  3.43    30.03      1.25    80000     0.02     0.02  BitSet#set
  3.37    31.26      1.23    20001     0.06     0.06  BitSet#new
  3.29    32.46      1.20    60148     0.02     0.02  Bignum#%
  3.12    33.60      1.14    10000     0.11     1.92  BloomFilter#has?
  2.19    34.40      0.80    10000     0.08     1.36  BloomFilter#insert
  1.53    34.96      0.56    10000     0.06     0.38  Comparable.==
  0.90    35.29      0.33    20000     0.02     0.02  Array#[]
  0.66    35.53      0.24    10000     0.02     0.02  String#split
  0.60    35.75      0.22    19852     0.01     0.01  Fixnum#%
  0.60    35.97      0.22    20001     0.01     0.01  Integer#to_i
  0.55    36.17      0.20    10000     0.02     0.02  String#chomp
  0.44    36.33      0.16    10001     0.02     0.02  ARGF.gets
  0.41    36.48      0.15     9652     0.02     0.02  Fixnum#+
以下略

upto のところはハッシュを複数かけるところなのでどうしようもないのだけど、どうも BitSet がボトルネックになっているみたい。BitSet の中身はビットストリングをバイト単位でわけて、ビットの位置をしていするとバイトのインデックスとバイト内でのビットのインデックスをそれぞれ計算して、しかるべきビット演算するだけなのだけど、初期化のところや位置の指定方法によってビットマップをリサイズするという処理がどうも無駄だということがわかった。必要な演算は基本的に & と | だけなのでそれ以外の機能を省いてしまうのと、リサイズの処理が入らないようにデータ型を決めうちにする方が良さげ。これはいずれやるとして、それでも劇的に速度は変わらないような気もしている。基本的に新しいレコードを読み込むたびにハッシュを k 回かけてマスクを作り、フィルターとマッチをかけるという作業があるので、その処理が軽くならない限りどうにもならない。複数の位置に対して同時にビットを立てる/比較する技があると早くなるんだけどな。単純にビットの数を数える場合はこういうのがあるのだけど、なんかないだろうか?ハードウェアで実装する場合は並列で処理するロジックを組めばいいのだろうけど。