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

Share

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<&'static str, GaijiEntry> = phf_map! {
    "1-94-37" => GaijiEntry::JisX0213 { plane: 1, row: 94, cell: 37, codepoint: '⿰魚師' },
    "U+5F85"  => GaijiEntry::Direct   { codepoint: '待' },
    "魚+師のつくり" => GaijiEntry::Description { fallback: "[魚+師]" },
    // … 約 14,000 件
};

phfphf_codegen クレートでビルド時に最小完全ハッシュを計算してくれる。実体は static な配列 + ハッシュ関数で、

  • ルックアップは wyhash 1 ラウンド + 配列 1 添字 + strcmp 1 回
  • ベンチハーネス計測で 1 件あたり約 25 ns
  • collision のない perfect hash なので probe ループが無い
  • アロケーションが無い

候補 1: OnceLock<HashMap>

「初回アクセスで HashMap を構築して以降キャッシュする」のは Rust では一般的なパターン。これを 14,000 件の外字テーブルに適用すると、

  • 初回コスト: 14,000 件の HashMap<&str, GaijiEntry> 構築に約 5ms。これは小さい青空文庫作品のパース全体より長い
  • メモリ: load factor 余裕と RawTable メタデータで PHF 比 2–3 倍
  • 同期コスト: OnceLock は初期化後も毎回 atomic load が走る

外字解決は Phase 0 (sanitize) から Phase 1 (scan) の間で大量に呼ばれるホットパスなので、初期化コストもアトミック load も乗せたくない。

候補 2: JSON / TOML 等の外部アセット

「テーブルだけ別ファイルに分離して起動時にロード」も選択肢としてはある。これも採らなかった。

  • Document::new ごとにファイル I/O が走るとパース全体の時間予算を食う
  • 全バインディング (CLI、WASM、FFI、Python wheel) でアセットを別ファイル配布する必要が出てくる
  • リンカが「実際に参照されない外字」を dead code elimination できなくなる

なぜ PHF が向くか

外字テーブルは次の特性を全部満たしている。

  1. 大きい: 14,000 件は線形走査や Eytzinger 二分探索でも無視できない
  2. 静的: 内容はコンパイル時に確定する (青空文庫の仕様スナップショット由来)
  3. 完全に既知: 入力候補が全部書き出せるので、最小完全ハッシュが計算可能

これは PHF が想定している regime そのもの。ビルド時に phf_codegen が約 40 秒かけてハッシュ関数を構築し、生成物は static 配列として .rodata に置かれる。incremental ビルドではキャッシュされるので 2 回目以降は 0 秒。

解決の優先順序

外字参照の解決は 3 段階に分かれている。

pub fn resolve(reference: &str) -> Resolved {
    // 1. 直接 Unicode 指定 (U+XXXX)
    if let Some(c) = parse_unicode_form(reference) {
        return Resolved::Direct(c);
    }

    // 2. JIS X 0213 plane-row-cell triple
    if let Some(triple) = parse_jis_triple(reference) {
        if let Some(c) = JIS_TABLE.get(&triple) {
            return Resolved::Lookup(c);
        }
    }

    // 3. 名前ベースの記述 (curated subset)
    if let Some(fallback) = DESCRIPTION_TABLE.get(reference) {
        return Resolved::Fallback(fallback);
    }

    Resolved::Unknown
}

Direct が最優先なのは、原稿執筆者が明示的に Unicode codepoint を書いた場合に JIS テーブル側の解釈で上書きしてはならないため。Lookup が一般的なケース、Fallback は Unicode に対応する文字が存在しない約 120 字向けの説明文出力 (例: [魚+師])。どれにも当てはまらない参照は Unknown を返し、診断 W0006 が立つ。

アクセント分解 (おまけ)

古い青空文庫作品はアクセント付きラテン文字を ※[#…] ではなく独自の ASCII 表記で書いていた時期がある。

M[i!]cher  →  Micher
M[a!]ria   →  Maria
[ae]on     →  Aeon

仕様の全 114 件マッピングは Phase 0 (sanitize) で適用され、Phase 1 のトリガースキャンに到達する時点では Unicode 化済み。こちらはエントリ数が少ないので PHF ではなく Eytzinger 配置の sorted-set を使っている (このサイズ域では Eytzinger の方が PHF よりキャッシュ効率が出る)。

整理

外字テーブルに PHF を選んだ理由は、

  • テーブルが大きく (14,000 件)、静的で、完全に既知
  • ホットパスで呼ばれるので初期化コストやアトミック load を排除したい
  • 全バインディングが追加アセット無しで動く配布形態を保ちたい

ビルド時に 40 秒払うことで、runtime 側はゼロアロケーション・1 ハッシュ・1 strcmp の 25ns ルックアップになる。

実装は aozora-encoding クレートにある。handbook の encoding.md には Shift_JIS の encoding_rs 依存と JIS X 0213 plane-2 patch の話、それから decode chain 全体の図も載っている。

Read more

青空文庫の .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

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

aozora は青空文庫記法の Rust パーサで、字句解析の最初のフェーズが「ソース全体から 7 種類のトリガーバイトを探す」というマルチパターンスキャンになっている。この記事は、その 1 フェーズに Intel Hyperscan 由来の Teddy アルゴリズムを採用した経緯と、対立候補に勝った算術的な根拠を整理したもの。 handbook の対応章: SIMD scanner backends。 問題設定 aozora-pipeline の Phase 1 (字句解析の最初のフェーズ) は、ソース文字列の中から次の 7 文字の出現位置を全て列挙する。 | 《 》 ※ [ ] 全角空白 これらは青空文庫記法の構文トリガー (ルビ・注釈・字下げの開始/終了マーカ) で、出現する位置だけが分かれば後段のフェーズで「これは何の構文か」を解釈できる。 UTF-8 で見ると 7 文字 × 3 バイト

By Sakashita Yasunobu