7 個のトリガーバイトを 12 GB/s で探す: Teddy を選んだ理由

Share

aozora は青空文庫記法の Rust パーサで、字句解析の最初のフェーズが「ソース全体から 7 種類のトリガーバイトを探す」というマルチパターンスキャンになっている。この記事は、その 1 フェーズに Intel Hyperscan 由来の Teddy アルゴリズムを採用した経緯と、対立候補に勝った算術的な根拠を整理したもの。

handbook の対応章: SIMD scanner backends

問題設定

aozora-pipeline の Phase 1 (字句解析の最初のフェーズ) は、ソース文字列の中から次の 7 文字の出現位置を全て列挙する。

|  《  》  ※  [  ]  全角空白

これらは青空文庫記法の構文トリガー (ルビ・注釈・字下げの開始/終了マーカ) で、出現する位置だけが分かれば後段のフェーズで「これは何の構文か」を解釈できる。

UTF-8 で見ると 7 文字 × 3 バイト = 21 バイト分のパターンを探すことになる。青空文庫の典型的な作品は 500KiB 前後で、本文は日本語が大半なので各文字が 3 バイト、トリガーバイトの出現密度は 1KB あたり 1 個前後しかない。ほとんどミスする探索 をいかに速く回すか、というのが性能上の主題になる。

handbook の章で言及している通り、この Phase 1 のスキャンが Phase 2 以降の解釈処理を 1 桁上回る時間を占めるので、SIMD で殴る価値が出る。

候補 1: memchr3 を 7 回回す

memchr クレートには複数バイトを同時に探す memchr3 がある (最大 3 バイトまで同時探索可能)。これで素直に書くと:

// 擬似コード
for trigger in TRIGGERS.chunks(3) {
    for offset in memchr3(trigger[0], trigger[1], trigger[2], src) {
        record(offset);
    }
}

7 パターン × 3 バイト = 21 バイトに対して memchr3 は 1 回で 3 バイトしか同時に扱えないから、最低でも 7 回 (各パターンに 1 パス) ソースを舐めなおすことになる。1 パスごとにソース全体を SIMD レーンで流す勘定なので、純粋に 読み込み回数が 7 倍 になる。

memchr 自身の packed multi-pattern API (新しめのもの) を使えば 1 パスにできるが、内部が SSE2 16 バイトレーンに固定されているため、aozora のコーパス計測では 5 GB/s 程度で頭打ちになる。

候補 2: Teddy

Teddy は Intel Hyperscan の中核アルゴリズムで、aho-corasick クレートの packed::teddy モジュールから直接呼べる。

挙動の概略:

  1. パターン群 (最大 64 パターンまで) から SIMD shuffle table を構築する
  2. 入力を AVX2 で 32 バイトずつ取り、shuffle table と照合する
  3. 候補となるオフセット集合を生成し、各候補で本物の一致かを最終確認する

aozora の用件と Teddy の得意領域がきれいに重なる。

  • パターン数が小さい (7 ≤ 64): Teddy は N ≤ 64 の小さい N で本領を発揮する
  • パターンが短い (UTF-8 で 3 バイト): 32 バイトレーンに 10 パターン分が同時に乗る
  • パターン同士に共通プレフィクスが無い: 全角句読点同士なので Boyer-Moore 系の suffix table は効かない。Teddy は構造に依存しない

ベンチマーク

aozora-bench の corpus probe (青空文庫全作品、AVX2 有効、Linux x86_64) で計測:

バックエンド ターゲット スループット 選択条件
Teddy x86_64 + AVX2 約 12 GB/s AVX2 が使える時の第 1 候補
Hoehrmann DFA ポータブル 約 3.5 GB/s x86_64 fallback、native arm64 等
Memchr-multi wasm32 約 1.2 GB/s wasm SIMD proposal が乗るまで

Teddy が memchr3 7 パスより約 3.5 倍、memchr の packed path より約 2.5 倍速い。

AVX2 が無い環境: Hoehrmann 系 DFA

x86_64 でも AVX2 が無い CPU や、Rosetta 経由で動く Docker コンテナ、Alpine ビルドのような場面ではフォールバックが必要。aozora は regex-automatadense::Builder でビルド時に高密度バイト DFA を生成しておき、ランタイムでこちらに切り替える。

DFA は遷移表を引くだけで分岐がない (Aho-Corasick の NFA + failure link 走査と違って 1 バイトあたり 1 回のテーブル参照で済む) ため、ポータブル経路でも 3.5 GB/s が出る。Aho-Corasick の標準実装に対しては 2 倍程度速い。

バックエンドの切り替えはコンパイル時 cfg! ではなく runtime detection。

pub fn best_scanner_name() -> &'static str {
    if is_x86_feature_detected!("avx2") {
        "teddy"
    } else if cfg!(target_arch = "wasm32") {
        "memchr-multi"
    } else {
        "hoehrmann-dfa"
    }
}

docker run --platform linux/amd64 を arm64 Mac で動かすと AVX2 が無い x86_64 が降ってくるので、コンパイル時固定だと SIGILL で落ちる。runtime dispatch にしておけば 1 バイナリで両方カバーできて、配布物は (linux-gnu, darwin-arm64, windows-msvc) の 3 種で済む。

整理

aozora の Phase 1 スキャナで Teddy を採用したのは、

  • パターン数 (7) が Teddy の得意領域 (N ≤ 64) に収まり、
  • パターン長 (3 バイト) が AVX2 32 バイトレーンに乗りやすく、
  • memchr3 7 パスや memchr packed path に対して算術的に 2.5–3.5 倍の差が出るため。

AVX2 が使えない環境向けには Hoehrmann 系 DFA をフォールバックとして同梱し、runtime dispatch で 1 バイナリにまとめている。

実装は aozora-scan クレートにある。handbook の scanner.md には Hoehrmann DFA を Aho-Corasick textbook NFA より選んだ理由や、samply で実際にどのバックエンドが走っているか確認する手順も書いてある。

Read more

外字と訓点を compile-time hash で解く

aozora は青空文庫の外字参照 (※[#「魚+師」、第3水準1-94-37] のような形) を約 14,000 件のテーブルで解決する。このテーブルを runtime の HashMap ではなく phf (perfect hash function) で持ち、コンパイル時に static 配列に焼き込んでいる。この記事はその選択の根拠と、JIS X 0213 → Unicode フォールバックの設計をまとめたもの。 handbook の対応章: Shift_JIS + 外字 resolver。 外字テーブルの形 外字エントリには 3 種類の解決結果があり、それぞれに対応する variant を GaijiEntry に持たせている。 static GAIJI_TABLE: phf::Map<

By Sakashita Yasunobu

青空文庫の .txt を HTML に変換する最短手順

青空文庫 で配布されている .txt ファイルを HTML に変換したい、という用途向けの手順。Rust の知識は要らない。コマンド 1 行で済む。 1. CLI バイナリを取ってくる aozora の Releases ページ から自分の OS 向けのアーカイブを落とす。 OS アーカイブ名 Linux x86_64 aozora-vX.Y.Z-x86_64-unknown-linux-gnu.tar.gz macOS arm64 aozora-vX.Y.Z-aarch64-apple-darwin.tar.gz Windows x86_64 aozora-vX.Y.Z-x86_64-pc-windows-msvc.zip SHA256SUMS も同梱されているので、

By Sakashita Yasunobu

50,000 ノードの AST を 16 回のアロケーションで: bumpalo 借用アリーナの実例

aozora の AST は bumpalo 単一アリーナの上に構築されている。Box<Node> を素直に並べた版に比べてパースが 6.4 倍速、ピーク RSS が 30% 減という結果が出ている。この記事は、その設計判断と Rust ライフタイムの取り回しを実装の視点から整理したもの。 handbook の対応章: Borrowed-arena AST。 問題設定 青空文庫の典型的な作品は約 500KiB のソースで、aozora がパースすると約 50,000 ノードの木に展開される。素直に Rust らしく書けば次のような形になる。 enum Node { Plain(String), Ruby { target: String, gloss: String }, Container { kind:

By Sakashita Yasunobu