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-automata の dense::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

Dependabotの対応が面倒なのでRenovateを導入する

Dependabotくんはある程度の依存関係の更新を通知してくれるが、山のようにprojectを抱えているとなんとも痒いところに手が届かない。 ということで、Renovateを使ってみる。 Build software better, togetherGitHub is where people build software. More than 150 million people use GitHub to discover, fork, and contribute to over 420 million projects.GitHub ここからGitHub Appを入れる。 あとは、指示に従ってポチポチするだけで、超かんたんだった。 感想 自動化最高。

By Sakashita Yasunobu

nodejsのバグを見つけたのでPRを投げてみた

概要 要するに、node --testで特定のバイナリを標準出力に書くと、テストランナーが固まったり、異常終了したりするバグです。 バグ自体は既知のものだったようで、修正がされていました。 test_runner: infinite loop in FileTest#drainRawBuffer when child stdout contains FF 0F followed by large size bytes · Issue #62693 · nodejs/nodeVersion v25.9.0 (also reproduced on v24.14.1 LTS, v22.21.0 LTS, v23.4.0) Platform Darwin 25.

By Sakashita Yasunobu
青空文庫のParser

青空文庫のParser

青空文庫のParserを作ってます。 GitHub - P4suta/aozora: Pure-functional Rust parser for 青空文庫記法 (Aozora Bunko notation): ルビ, 傍点, 縦中横, 外字, 返り点, indent containers, page breaks.Pure-functional Rust parser for 青空文庫記法 (Aozora Bunko notation): ルビ, 傍点, 縦中横, 外字, 返り点, indent containers, page breaks. - P4suta/aozoraGitHubP4suta Rustで作ってますが、配布物はcargoのほかにnpm/pypiでも入手できます Documentation * Playground — try it in

By Sakashita Yasunobu