GDBM で学ぶ Extendible Hashing

最近仕事で GDBM を使う機会があり、GDBM で使われている extendible hashing に興味を持ったのでまとめてみました。
なお、今回対象にしている GDBM のバージョンは 1.12 です。

アジェンダ

GDBM とは

http://www.gnu.org.ua/software/gdbm/ 曰く

GNU dbm (or GDBM, for short) is a library of database functions that use extensible hashing and work similar to the standard UNIX dbm.

とのことです。
個人的には次のような特徴があると思っています。

Ruby からは次のように使うことができます。

require 'gdbm'

filename = '/tmp/gdbm.db'

GDBM.open(filename, 0666, GDBM::NEWDB) do |db|
  db['k1'] = 'value1'
  db['k2'] = 'value2'
end

db = GDBM.open(filename, 0666, GDBM::READER | GDBM::NOLOCK)
db['k1']  #=> 'value1'
db['k2']  #=> 'value1'
db.close

というわけで、ハッシュテーブル、extendible hashing についてざっくり説明し、GDBM で extendible hashing がどのように実装されているか簡単に見ていこうと思います。

ハッシュテーブルの基礎

ハッシュテーブルはキーのハッシュ値をインデックスに使用する配列で、ハッシュ値の衝突がなければ検索や挿入を O(1) で行えるのが特徴です。

value = hash_table[h(key)]
hash_table[h(another_key)] = another_value

実際にはハッシュ値を配列のサイズで割った時の余りがインデックスとして使われることが多いように思います。

value = hash_table[h(key) % hash_table.size]
hash_table[h(another_key) % hash_table.size] = another_value

ハッシュ関数

ハッシュ関数と言うと MD5 や SHA-1 等の暗号学的ハッシュ関数が有名ですが、速度の面からこれらのハッシュ関数がハッシュテーブルに使われることはありません。
例えば DJBX33A と呼ばれるハッシュ関数は Ruby で書くと次のようなシンプルな関数です。

def h(key)
  hash = 5381
  key.each_byte do |byte|
    hash = (33 * hash + byte) & 0xffffffff  # unsigned 32-bit int
  end
  hash
end

“abc” という文字列は上記のハッシュ関数に通すことで 193485963 に変換されます。

ただ、DJBX33A のようなハッシュ関数は Hash-flooding DoS(ハッシュ値の衝突を意図的大量に発生させて CPU 資源を枯渇させる攻撃)が可能なので、Ruby を始め、多くの言語で SipHash が採用されているようです。2

衝突処理

予め使用されるキーがわかっていて h(key) % hash_table.size の結果を一意にできるのであれば次のように単純な処理で挿入・検索を実現できます。

def insert(hash_table, key, value)
  hash_table[h(key) % hash_table.size] = value
end

def lookup(hash_table, key)
  hash_table[h(key) % hash_table.size]
end

そうでない場合、異なるキーでも同じインデックスになること(衝突)を考慮する必要があります。
衝突処理として、主なものに開番地法 (Open Addressing) と分離連鎖法 (Separate Chaining) があります。

開番地法は配列の空いている他の箇所を探す方法で、探し方には linear probing、quodratic probing, double hashing などがあります。linear probing は求めたインデックスからシーケンシャルに他の要素を探していく方法で、Ruby で表現すると次のようなイメージです。

def insert(hash_table, key, value)
  idx = h(key) % hash_table.size
  while entry = hash_table[idx]
    break if entry[:key] == key
    idx = (idx + 1) % hash_table.size
  end
  hash_table[idx] = { key: key, value: value }
end

def lookup(hash_table, key)
  idx = h(key) % hash_table.size
  while entry = hash_table[idx]
    return entry[:value] if entry[:key] == key
    idx = (idx + 1) % hash_table.length
  end
end

分離連鎖法は配列の各要素に連結リストを格納する方法で、Ruby で表現すると次のようなイメージです。

def insert(hash_table, key, value)
  idx = h(key) % hash_table.size
  entry = hash_table[idx]

  if entry.nil?
    hash_table[idx] = { key: key, value: value }
    return
  end

  while entry[:next]
    if entry[:key] == key
      entry[:value] = value
      return
    end
    entry = entry[:next]
  end
  entry[:next] = { key: key, value: value }
end

def lookup(hash_table, key)
  idx = h(key) % hash_table.size
  entry = hash_table[idx]
  while entry
    return entry[:value] if entry[:key] == key
    entry = entry[:next]
  end
end

全て衝突する場合、どちらの衝突処理も要素数 n に対して検索も挿入も O(n) になります。

リハッシュ

ハッシュテーブルの配列の密度が高くなってくると衝突回数が多くなります。ハッシュテーブルの密度の指標として、次の式で表される load factor が使われます。

load factor = ハッシュテーブルの要素数 n ÷ 配列のサイズ k

衝突回数を減らすため、load factor が一定値以上になれば配列のサイズを増やします。これをリハッシュと呼びます。3
リハッシュの方法にもいくつかありますが、配列のサイズを 2 倍にして全要素を新しい配列にコピーする場合、次のようなイメージです。

def rehash(hash_table)
  new_hash_table = Array.new(hash_table.size * 2)
  hash_table.each do |entry|
    next if entry.nil?
    insert(new_hash_table, entry[:key], entry[:value])
  end
  new_hash_table
end

今回扱う extendible hashing は一度に行うリハッシュのコストを下げる工夫のされたハッシュテーブルです。

以上がハッシュテーブルの基礎的な内容です。
より詳細に知りたい方は次の文献が非常に詳しく解説されていてるのでオススメです。

Extendible Hashing

extendible hashing は小さなハッシュテーブル(バケット)を束ねたハッシュテーブルであり、リハッシュがバケット単位で行われるので一度のリハッシュにかかるコストが低いのが特徴です。
各バケットへのポインタを格納したディレクトリとバケットから成ります。

ディレクトリのサイズが 4、バケットの数が 2、各バケットのサイズが 2 の extendible hash の構造は次のように表されます。

ディレクトリも各バケットも depth という概念を持っており、ここではそれぞれ directory depth、local depth と表現することにします。図中の左上に表示してある数が depth です。

directory depth は、ハッシュ値からディレクトリのインデックスを決定する際にハッシュ値の上位何ビットを使用するかを意味します。local depth は、そのバケットがハッシュ値の上位何ビットまで同じキーの要素を格納できるかを意味します。その性質上、directory depth >= local depth が成り立ちます。
図中の上のバケットはディレクトリのインデックス 00、01 の指し示すバケットであり、ハッシュ値の上位 1 ビットが 0 のキーの要素の格納先になっています。

Ruby で表現すると次のようなイメージです。BucketDirectory の定義は雰囲気で感じとってください。

directory = Directory.new(depth: 2)
bucket1 = Bucket.new(depth: 1)
bucket2 = Bucket.new(depth: 1)
directory.buckets = [bucket1, bucket1, bucket2, bucket2]

この extendible hash に次のキーの要素を格納することを考えます。ハッシュ値は unsigned 8-bit integer とします。

キー ハッシュ値
k1 01001011
k2 11010010
k3 00100000
k4 01110010
k5 01100011

まず k1 ですが、ディレクトリの depth である上位 2 ビットが 01 なので、directory[0b01] の指し示すバケットが格納先のバケットになります。バケットはハッシュテーブルなので、h(k1) % bucket.size がハッシュテーブルのインデックスになります。

Ruby で表現すると次のようなイメージです。Entry の定義は雰囲気で感じとってください。

bucket = directory.buckets[h(k1) >> (8 - directory.depth)]
bucket.entries[h(k1) % bucket.size] = Entry.new(key: k1, value: nil, hash_value: h(k1))

同様に、k2, k3 も挿入できます。

ここで、k4 を挿入しようとすると、directory[0b01] の指し示すバケットが満杯であることがわかります。そこで、ハッシュ値の上位 2 ビットが同じキーの要素を格納できるよう、バケットを分割します。これによって k4 の要素も挿入できるようになります。

バケットの分割は Ruby で表現すると次のようなイメージです。

bucket = directory.buckets[h(k4) >> (8 - directory.depth)]
if bucket.full? && directory.depth > bucket.depth
  new_depth = bucket.depth + 1
  buckets = [Bucket.new(depth: new_depth), Bucket.new(depth: new_depth)]
  bucket.entries.each do |entry|
    buckets[(entry.hash_value >> (8 - new_depth)) & 1] = entry
  end
  directory.buckets[0] = buckets[0]
  directory.buckets[1] = buckets[1]
end

次に、k5 を挿入しようとすると、またしても directory[0b01] の指し示すバケットが満杯であることがわかります。directory depth も local depth も 2 なので、これ以上バケットを分割することもできません。
そこで、ディレクトリを拡張して directory depth を 3 にします。具体的には、2 倍のサイズの配列を生成し、格納しているポインタをコピーします。ディレクトリを拡張するだけではバケットの数は増えません。

ディレクトリの拡張は Ruby で表現すると次のようなイメージです。

new_directory = Directory.new(depth: directory.depth * 2)
directory.buckets.each.with_index do |bucket, i|
  new_directory.buckets[2 * i] = bucket
  new_directory.buckets[2 * i + 1] = bucket
end
directory = new_directory

これによって、k5 の格納先のバケットも分割できるようになります。

以上が extendible hashing の原理です。バケットを分割する際、分割対象のバケットのリハッシュしか行われず、ディレクトリを拡張する際もディレクトリのポインタのコピーしか発生しないことがポイントです。
バケットでの衝突処理については触れませんでしたが、普通のハッシュテーブル同様、開番地法等による衝突処理が必要なので注意してください。

GDBM による Extendible Hashing

GDBM のコードはキャッシュ周りの処理やファイル上のどの位置に情報を保存するかを決める処理は複雑なものの、それ以外はコード量も少なく比較的読みやすいので、extendible hashing を学ぶには良い対象です。

ハッシュ関数

GDBM のハッシュ関数は hash.c に次のように定義されています。

int
_gdbm_hash (datum key)
{
  unsigned int value;	/* Used to compute the hash value.  */
  int   index;		/* Used to cycle through random values. */


  /* Set the initial value from key. */
  value = 0x238F13AF * key.dsize;
  for (index = 0; index < key.dsize; index++)
    value = (value + (key.dptr[index] << (index*5 % 24))) & 0x7FFFFFFF;

  value = (1103515243 * value + 12345) & 0x7FFFFFFF;  

  /* Return the value. */
  return((int) value);
}

1103515243 等でググってみたところ、他のコードで同様のハッシュ関数を使っているものはどれも GDBM から持ってきたとコメントが書いてあるので、GDBM 独自のハッシュ関数のようです。
最後の value = (1103515243 * value + 12345) & 0x7FFFFFFFrand の Example に出てくる式の RAND_MAX を signed 32-bit integer の最大値としたものとほぼ同じなので、線形合同法を意識した実装と思われます。

ユーザの任意の入力を GDBM に保存するということはないと思いますが、DJBX33A 同様、衝突するキーを容易に作成できるので、そのような用途で使うと Hash-flooding DoS が危険があります。
例えば、”02”, “P1”, “p0” はどれも同じハッシュ値になるので、これらの文字列を組み合わせることで同じハッシュ値になるキーを大量に作成可能です。

#include <stdio.h>
#include <gdbm.h>

int _gdbm_hash (datum key);

int main(int argc, char **argv) {
  datum keys[] = {
    { .dptr = "02", .dsize = 2 },
    { .dptr = "P1", .dsize = 2 },
    { .dptr = "p0", .dsize = 2 },
    { .dptr = "0202", .dsize = 4 },
    { .dptr = "P102", .dsize = 4 },
    { .dptr = "p002", .dsize = 4 },
  };
  for (int i = 0; i < sizeof(keys) / sizeof(datum); ++i) {
    printf("%s: %d\n", keys[i].dptr, _gdbm_hash(keys[i]));
  }

  return 0;
}

上記のコードの実行結果は次のとおりです。

02: 652613971
P1: 652613971
p0: 652613971
0202: 1148613021
P102: 1148613021
p002: 1148613021

ファイルヘッダのデータ構造

ヘッダの定義は gdbmdefs.h に次のように定義されています。

/* The dbm file header keeps track of the current location of the hash
   directory and the free space in the file.  */

typedef struct {
        int   header_magic;  /* Version of file. */
        int   block_size;    /* The optimal i/o blocksize from stat. */
        off_t dir;           /* File address of hash directory table. */
        int   dir_size;      /* Size in bytes of the table.  */
        int   dir_bits;      /* The number of address bits used in the table.*/
        int   bucket_size;   /* Size in bytes of a hash bucket struct. */
        int   bucket_elems;  /* Number of elements in a hash bucket. */
        off_t next_block;    /* The next unallocated block address. */
        avail_block avail;   /* This must be last because of the pseudo
                                array in avail.  This avail grows to fill
                                the entire block. */
      }  gdbm_file_header;

主要なメンバの意味は次のとおりです。

メンバ 概要
header_magic off_t のサイズによって異なるマジックナンバーになる。データベースファイルを作成した環境と使用する環境が異なるとマジックナンバーの不一致によりエラーになる場合がある。
block_size ファイルシステムのブロックサイズ
dir データベースファイル上の、ディレクトリの開始位置
dir_size ディレクトリの容量
dir_bits directory depth
bucket_size バケットの容量
bucket_elems バケットに格納できる要素数

ヘッダの情報から、データベースファイルを作成する環境と使用する環境は統一した方が良いことがわかります。

次のように要素数 0 のデータベースファイルを作成してヘッダ情報を確認してみましょう。

#include <gdbm.h>

int main(int argc, char **argv) {
  GDBM_FILE dbf = gdbm_open("tmp.db", 0, GDBM_NEWDB, 0666, 0);
  gdbm_close(dbf);
  return 0;
}

各値は次のようになっています。

% ruby -e 'puts File.binread("tmp.db", 32).unpack("IIQI*")'
324508367
4096
4096
4096
9
4096
166

これより、directory depth は初期状態で 9、ディレクトリの容量は 4096 バイト、バケットに格納できる要素数は 166 であることがわかります。
ディレクトリはバケットのポインタ(データベースファイル上のファイル位置)の配列なので、29 x sizeof(off_t) = 4096 です。

バケットのデータ構造

バケットの定義は gdbmdefs.h に次のように定義されています。

/* A bucket is a small hash table.  This one consists of a number of
   bucket elements plus some bookkeeping fields.  The number of elements
   depends on the optimum blocksize for the storage device and on a
   parameter given at file creation time.  This bucket takes one block.
   When one of these tables gets full, it is split into two hash buckets.
   The contents are split between them by the use of the first few bits
   of the 31 bit hash function.  The location in a bucket is the hash
   value modulo the size of the bucket.  The in-memory images of the
   buckets are allocated by malloc using a calculated size depending of
   the file system buffer size.  To speed up write, each bucket will have
   BUCKET_AVAIL avail elements with the bucket. */

typedef struct {
        int   av_count;            /* The number of bucket_avail entries. */
        avail_elem bucket_avail[BUCKET_AVAIL];  /* Distributed avail. */
        int   bucket_bits;         /* The number of bits used to get here. */
        int   count;               /* The number of element buckets full. */
        bucket_element h_table[1]; /* The table.  Make it look like an array.*/
      } hash_bucket;

主要なメンバの意味は次のとおりです。

メンバ 概要
bucket_bits バケットの depth
count バケット内の要素数
h_table バケットのハッシュテーブル(要素数が header->bucket_elems のフレキシブル配列)

よって、header->bucket_elemsbucket->count が一致するとバケットが満杯であることを意味します。
要素を挿入するのは gdbm_store 関数ですが、次の箇所でバケットを分割するかどうか判断しています。

if (dbf->bucket->count == dbf->header->bucket_elems)
  {
    /* Split the current bucket. */
    _gdbm_split_bucket (dbf, new_hash_val);
  }

バケット要素のデータ構造

バケット要素の定義は gdbmdefs.h に次のように定義されています。

/* The dbm hash bucket element contains the full 31 bit hash value, the
   "pointer" to the key and data (stored together) with their sizes.  It also
   has a small part of the actual key value.  It is used to verify the first
   part of the key has the correct value without having to read the actual
   key. */

typedef struct {
        int   hash_value;       /* The complete 31 bit value. */
        char  key_start[SMALL]; /* Up to the first SMALL bytes of the key.  */
        off_t data_pointer;     /* The file address of the key record. The
                                   data record directly follows the key.  */
        int   key_size;         /* Size of key data in the file. */
        int   data_size;        /* Size of associated data in the file. */
      } bucket_element;

メンバの意味は次のとおりです。

メンバ 概要
hash_value キーのハッシュ値
key_start キーの先頭 4 バイト。キーの比較の際に使うことで、実際の要素にアクセスする回数を減らしている。
data_pointer 実際の要素を格納しているデータベースファイル上のファイル位置
key_size キーのバイト数。data_pointer から key_size バイトがキーの内容。
data_size 値のバイト数。data_pointer + key_size から data_size バイトが値の内容。

衝突処理

gdbm_store 関数で既存の要素が見つからなかった場合、挿入先の h_table のインデックスを次の処理で探していることから、衝突処理は linear probing であることがわかります。(h_table に入っている要素の hash_value は -1 で初期化されています cf. _gdbm_new_bucket

/* Find space to insert into bucket and set elem_loc to that place. */
elem_loc = new_hash_val % dbf->header->bucket_elems;
while (dbf->bucket->h_table[elem_loc].hash_value != -1)
  elem_loc = (elem_loc + 1) % dbf->header->bucket_elems;

ディレクトリの拡張とバケットの分割

ディレクトリの拡張とバケットの分割処理は bucket.c の _gdbm_split_bucket 関数に相当します。
キャッシュや領域の確保・解放の処理があって非常に読みにくいですが、その辺の処理は読み飛ばします。

まず最初に、ディレクトリの拡張が必要かどうかをチェックし、必要であれば拡張します。dbf->bucket は分割対象のバケットです。

/* Double the directory size if necessary. */
if (dbf->header->dir_bits == dbf->bucket->bucket_bits)
  {
    dir_size = dbf->header->dir_size * 2;
    dir_adr  = _gdbm_alloc (dbf, dir_size);
    new_dir  = (off_t *) malloc (dir_size);
    if (new_dir == NULL) _gdbm_fatal (dbf, _("malloc error"));
    for (index = 0; index < GDBM_DIR_COUNT (dbf); index++)
      {
        new_dir[2*index]   = dbf->dir[index];
        new_dir[2*index+1] = dbf->dir[index];
      }

    (snip)
  }

次に、予め用意しておいた 2 つのバケット (hash_bucket *bucket[2]) に分割対象のバケットの内容をコピーします。new_bitsdbf->bucket->bucket_bits + 1 です。

/* Copy all elements in dbf->bucket into the new buckets. */
for (index = 0; index < dbf->header->bucket_elems; index++)
  {
    old_el = & (dbf->bucket->h_table[index]);
    select = (old_el->hash_value >> (31-new_bits)) & 1;
    elem_loc = old_el->hash_value % dbf->header->bucket_elems;
    while (bucket[select]->h_table[elem_loc].hash_value != -1)
      elem_loc = (elem_loc + 1) % dbf->header->bucket_elems;
    bucket[select]->h_table[elem_loc] = *old_el;
    bucket[select]->count += 1;
  }

最後に、ディレクトリのバケットポインタを更新します。dbf->bucket_dir は分割対象のバケットが格納されているディレクトリのインデックス、adr_0, adr_1 はそれぞれ bucket[0], bucket[1] の内容が格納される予定のファイル上の位置 (off_t) です。

/* Update the directory.  We have new file addresses for both buckets. */
dir_start1 = (dbf->bucket_dir >> (dbf->header->dir_bits - new_bits)) | 1;
dir_end = (dir_start1 + 1) << (dbf->header->dir_bits - new_bits);
dir_start1 = dir_start1 << (dbf->header->dir_bits - new_bits);
dir_start0 = dir_start1 - (dir_end - dir_start1);
for (index = dir_start0; index < dir_start1; index++)
  dbf->dir[index] = adr_0;
for (index = dir_start1; index < dir_end; index++)
  dbf->dir[index] = adr_1;

ちょっとわかりにくいので簡単に解説します。
まず、ディレクトリの要素のうち、更新しなければいけないのは分割対象のバケットを指していた要素です。
次の図の状態で local depth 1 のバケットを分割する場合、dbf->bucket_dir は 0b100, 0b101, 0b110, 0b111 のいずれか、dbf->header->dir_bits = 3, new_bits = 2 です。

よって、dbf->bucket_dir >> (dbf->header->dir_bits - new_bits) によって、0b10 または 0b11 が得られます。これに | 1 することで 0b11 が得られ、元の分だけビットシフトすることで dir_start1 = 0b110 が得られます。
これがわかれば dir_end, dir_start0 の求め方も難しくないでしょう。

データベースファイルは要素数が多いと肥大化する

バケット要素のデータ構造的に生のキーと値がデータベースファイルに保存されるので、格納するデータのサイズが大して変わらなければデータベースファイルのサイズも同じになることを期待するかもしれません。

ところが、次のように各ハッシュを 1 つの要素として JSON で保存する場合と、

GDBM.open(filename, 0666, GDBM::NEWDB) do |db|
  hashes.each.with_index do |hash, i|
    db[i.to_s] = hash.to_json
  end
end

次のように各ハッシュの各要素を 1 つの要素として保存する場合ではファイルサイズが大きく変わります。

GDBM.open(filename, 0666, GDBM::NEWDB) do |db|
  hashes.each.with_index do |hash, i|
    hash.each do |key, value|
      db["#{i}:#{key}"] = value.to_s
    end
  end
end

これは、要素数が増えることで大量のバケットが作成されることが原因です。バケット 1 つで 4 KB の容量を占めるので、最良のケースでも 166 要素につき 4 KB 容量が増えます。
よって、各ハッシュを 1 つの要素として JSON で保存する場合に 16 万要素でバケットの合計サイズが約 4 MB だったとしても、各ハッシュの各要素を 1 つの要素として保存することで 160 万要素になると約 40 MB に膨れ上がります。
データベースファイルのサイズを意識する場合はこのことも留意した方が良いでしょう。

おわりに

以上、GDBM が採用している extendible hashing の説明と GDBM での実装の説明でした。
Ruby では手軽に GDBM を導入できるので、データの取得速度がボトルネックになっている箇所には導入してみると良いと思います!

参考

  1. Ruby のインストール時に GDBM がインストールされている必要があります

  2. Ruby 2.4.0 では SipHash をハッシュ関数として使用し、double hashing による衝突処理を行っています

  3. ハッシュテーブルがスカスカになった時にサイズを縮小することもリハッシュと呼びます

広告
InnoDB の行レベルロックについて解説してみる