2007-12-15

正規表現はお好き?

積んであった Beautiful Code を読んでみる. 第一章はカーニハンによる正規表現の話. 数十行のコードで簡単な正規表現を実装してみせる. パターン文字列を内部表現に変換せずマッチに使うぜ, コードも短い, ビューティホー! ...という主張なのだが, それはほんとにビューティホーなのか. UNIX 人の感覚にはついていけない.

それにしても彼らは正規表現が好きだ. いつものその話ばかりしている. artu はいうまでもなく プログラミング作法 にも正規表現が出てきた. まったくこのマンネリめ. そう斜に構えつつ読み直してみると, 案外ラディカルな話も載っているのに気付く. 9.7 "オンザフライコンパイル" より:

Ken Thompson はまさにこの方法によって 1967 年に IBM7094 上に正規表現を実装した. 彼のバージョンは, 正規表現に含まれる様々な処理を小さなブロック単位の7094バイナリ命令とし生成し, それらを互いに組合せたものを通常の関数のように呼び出すことによって プログラムを実行するようになっていた.

正規表現で JIT! かっこいい. しかも 40 年前. UNIX の先人はエラかった. ちょっとくらいマンネリでもいい. 私もその敏腕にあやかりたい.

llre : LLVM で JIT つき正規表現

そんなわけで JIT つき正規表現をつくってみた. (hg, ブラウズ, スナップショット)

JIT はしたいけど x86 の仕様を調べるのが辛い現代っ子の私は LLVM を頼った. LLVM からとりだした関数ポインタを呼び出しマッチが動く瞬間はちょっと感動. Thompson, オレやったよ.

ベンチマーク

JIT でどのくらい速くなったかベンチマークをとってみよう. まずテスト用に作ったインタプリタと比較する. これより遅いとまずい.

こんなかんじで:

# benchmark.sh
PATTERN_LLGREP="\/\/\ *(TODO|FIXME)" # コメント行に TODO か FIXME が含まれる
for i in `jot 10 1`; do
  # -i でインタプリタモードに.
  find ~/src/boost_1_33_1/ -name "*.cpp" -or -name "*.hpp" | xargs ./llgrep -i -q -v "${PATTERN_LLGREP}"
done

ためす:

$time sh benchmark.sh
real    0m58.354s
user    0m38.798s
sys     0m5.435s

JIT で動かすと...

$vi benchmark.sh # -i オプションを消す
$time sh benchmark.sh
real    0m36.370s
user    0m16.869s
sys     0m5.423s

1.6 倍か. うーん...遅くはないけど苦労の割に速くないなあ. ただ boost のファイルは全部で 6000 近くあるから, ファイル開閉のオーバーヘッドが気になる. そこで, まず対象のファイルをひとつにまとめる.

$find ~/src/boost_1_33_1/ -name "*.cpp" -or -name "*.hpp" | xargs cat >> boost_cat.cpp
$wc boost_cat.cpp
  875578 2892653 28791016 boost_cat.cpp

87 万行, 28MB のファイルができた. これで測りなおそう.

スクリプトは同じ.

PATTERN_LLGREP="\/\/\ *(TODO|FIXME)"
A_FILE=boost_cat.cpp
for i in `jot 10 1`; do
  ./llgrep -i -q -v "${PATTERN_LLGREP}" ${A_FILE}
done

インタプリタ:

$ time sh ./benchmark.sh
real    0m25.514s
user    0m25.093s
sys     0m0.391s

JIT:

$ time sh ./benchmark.sh
real    0m3.556s
user    0m3.166s
sys     0m0.377s

7.2 倍! このくらい速いなら JIT した甲斐もあるよね.

grep との比較

ついでに grep とも比べてみた.

正規表現を grep 用になおして...

PATTERN_GREP="// *\(TODO\|FIXME\)"
A_FILE=boost_cat.cpp
for i in `jot 10 1`; do
   grep -q "${PATTERN_GREP}" ${A_FILE}
done

実行.

$ time sh ./benchmark.sh
real    0m0.102s
user    0m0.050s
sys     0m0.045s

うは! 35倍って... ちなみに xargs 版は 9 倍.

$ time sh ./benchmark.sh
real    0m4.098s
user    0m2.416s
sys     0m1.398s

なんでこんなに速いんだ. 精神的ダメージのあまり GNU grep のコードを覗いてみることに. (私はマカーなので BSD grep で実験しているけれど, 試してみると GNU grep も同じくらい速かった.)

grep は正規表現を DFA にコンパイルし, 状態マシンとして動作する. dragon book をコピっただけの llre も大枠では同じアルゴリムに倣っている. なのに性能差 35 倍... 実行時のホットスポットである状態遷移ループを見てみよう.

/* from dfa.c : マルチバイト対応部などは省略 */
size_t
dfaexec (struct dfa *d, char const *begin, size_t size, int *backref)
{
   ...
  s = 0;
  p = (unsigned char const *) begin;
  end = p + size;
  trans = d->trans; /* trasn はアルファベットを索引とする dfa の遷移表 */

  for (;;)
    {
      while ((t = trans[s])) /* このループがメインの状態遷移 */
        s = t[*p++];

      /* 遷移配列がない: 遷移できないハズレ状態を引いた? */

      if (s < 0)
        {
          /*
           * 一度もマッチすることなくハズレ状態に遷移した場合はリジェクト.
           * 最初の状態から再開. (終端なら失敗)
           * p はこのままでいいんだろうか?
           * llre は保守的に前回の一文字次まで戻している...
           */
          if (p == end)
            {
              return (size_t) -1;
            }
          s = 0;
        }
      else if ((t = d->fails[s]))
        {
          /* 直前は受理状態だった? */
          if (d->success[s] & sbit[*p])
            { /* offset を返す.
               * ポインタは進みすぎてる気がするけど grep 的には問題ない
               */
              if (backref)
                *backref = (d->states[s].backref != 0);
              return (char const *) p - begin;
            }

          /* 潜在的な受理状態が続く:
           * d->fails がもうひとつの遷移表として使われている (ややこしすぎる...)
           */
          s = t[*p++];
        }
      else
        { /* 遷移配列がまだない. 再構築してリトライ */
          build_state(s, d);
          trans = d->trans;
        }
    }
}

中心となるループが単純な二段の配列ルックアップに収まっている. なかなかビューティホー. llre のループは lex が出力するコードのような二段の switch 文. ここだけ見てもだいぶ差がありそうだとわかる. ビューティー度も劣る. また llre では文字列の先頭からマッチを判断する match() を実装し, grep のように部分一致を探す search() は match() を使って実装している. grep は search() に特化しており無駄がない. こりゃ 35 倍速くても不思議じゃないかも...

grep はこのほかにも細かな高速化を行っている. たとえばパターンが単一文字列の時は Boyer-Moore を, 複数文字列のセットの時は trie を使う. 長い時間をかけて鍛えられたコードの貫禄がある.

正規表現 VM たち

ついでに他の実装も覗こうと 鬼車, PCRE, ORO を眺めた. 偶然なのか, これらには共通の特徴がある. どれも正規表現をバイトコードにコンパイルし, 内部の VM がそれを実行するのだ. おどろき. 正規表現というのは DFA に変換して状態マシンにするものだと思っていた. 状態マシンが入力文字列をイテレートして処理を進めるのに対し, VM 実装はバイトコードの命令列が処理を駆動する. スタック(の配列)があって PC(のローカル変数)もある. アーキテクチャが全然違う.

正規表現 VM にどんな命令セットを用意すべきか私には見当もつかない. それでも実際の opcode をみると, 色々と納得するところはある. perl や ruby など, 世の中の正規表現はプリミティブな正規表現を大きく拡張している. そうした拡張は理論上どれもプリミティブな表現に変換できる構文糖だとされている. しかし実際には変換なしに解釈した方がずっと効率よく動かせるものもある.

たとえば "[:alpha:]" は "(a|b|c|....)" に変換できるが, テーブルを引く関数 isalpha() を使った方が速いだろう. それに対象の文字セットが大きいと, "(あ|い|...)" と変換するのがそもそも現実的でない. "." なんて目もあてられない.

もう一つの例としては "x+" がある. これを "xx*" に変換すると "x" を複製する必要がある. "+" をそのまま解釈できれば内部表現を小さくできるかもしれない. {m,n} もおなじ.

このように, 拡張を直接解釈する潜在的なメリットは大きい. しかし状態マシンにこれらの拡張を組み込もうとすると事態が複雑になるだろうと察しがつく. DFA のアルファベット集合をどう表現するか, firstpos や followpos の計算は? そんなややこしい問題を抱えながら DFA に変換するくらいなら, いっそ正規表現を言語とみなしてコンパイルしてしまおう. そう考えるのはアリかもと思えてくる.

命令主導の制御構造と相性のいい高速化もある. たとえば鬼車には OP_EXACTN という opcode があり, operand で指定された長さの文字列と入力の文字列をマッチする.


/* regexec.c:match_at() */
...
   case OP_EXACTN:  MOP_IN(OP_EXACTN);
     GET_LENGTH_INC(tlen, p);
     DATA_ENSURE(tlen);
     while (tlen-- > 0) {
       if (*p++ != *s++) goto fail;
     }
     sprev = s - 1;
     MOP_OUT;
     continue;
...

これは文字単位でループを駆動する状態マシンだとやりにくい仕組みだと思う. 鬼車にはこの亜種で OP_EXACT1 ... OP_EXACT5 が用意されており, 五文字までの文字列は比較のループがアンロールされていたりする. がんばってるなー.

なお VM ベースのエンジンは基本的に NFA 的なマッチを行う. 後方参照や部分マッチのとりだしを実現するにはその方が楽だからだという. Russ Cox による "Regular Expression Matching Can Be Simple And Fast" という記事では特定のパターンで見せる大きな性能低下を根拠に NFA ベースの実装を批判し, NFA と DFA のハイブリッドなアルゴリズムを紹介している.

この記事では PCRE 6.4 を遅いライブラリのひとつとして槍玉に上げている. 私の見た最新版の PCRE 7.4 ではこの批判を受けて ... かどうかはさておき, DFA のハイブリッドアルゴリズムも実装していた. (pcre_dfa_exec.c) この実装は一つ面白い特徴がある: pcre_dfa_match() は NFA 実装である pcre_exec() と同じバイトコードを解釈する. 言ってみれば PCRE には一つのバイトコードを動かす二種類の VM が入っている. Cox の記事は NFA の状態グラフを使って DFA を実行する話だった. PCRE はそれをうまく VM の仕組みに取り入れている. ビューティホーだと思う.

最速の正規表現ライブラリがあるなら

これだけ VM スタイルの実装が幅を利かせているところをみると, JIT つき正規表現もそう悪くない気がしてくる. VM の高速化といえば JIT でしょ. 評価ループが消えるだけでなく, 細かなオプションでの分岐や文字コード関係の多態もコンパイル時に解決できる. VM の JIT 化には夢があるなあ. 35 倍の大敗を喫した llre だけれど, これは JIT が悪いのではなく私の実装がへぼいだけ...だよね, Thompson?