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

Share

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: ContainerKind, children: Vec<Node> },
    // …
}

このまま 50,000 ノードを構築すると、

  • 約 50,000 回の個別 heap allocation が走る
  • ノードあたり 16 バイト前後のアロケータ管理メタデータが付く
  • ノードがメモリ上にランダム配置される (prefetch が効かない)
  • drop 時に 50,000 回の個別 free が必要になる

パース処理そのものより malloc / free の方が時間を食う、という素直な律速状態になる。

アリーナ版の設計

aozora は Documentbumpalo::Bump アリーナと Box<str> ソース文字列の 2 つを所有し、AozoraTree<'a> はその両方から借用する。

let source = std::fs::read_to_string("kokoro.txt")?;
let doc = aozora::Document::new(source);
let tree = doc.parse();   // tree は &doc の lifetime で生きる

// tree.to_html(), tree.serialize(), tree.diagnostics() が借用走査
let html = tree.to_html();

// doc が drop されたら tree は使えなくなる (コンパイル時にエラー)

ノード型は次のような形で、テキスト部は全てソース文字列のスライスへの参照、container は arena 内の box への参照になる。

pub enum AozoraNode<'src> {
    Plain(&'src str),
    Ruby(Ruby<'src>),
    Bouten(Bouten<'src>),
    Tcy(Tcy<'src>),
    Gaiji(Gaiji<'src>),
    Container(&'src Container<'src>),    // arena box
    BreakNode(BreakNode),
    // 30+ variants
}

AozoraNode 全体が Copy (参照と小さな primitive のタグ付き union)。木をたどるときに & を取らずにそのままコピーで済むので、ホットパスから参照解決の indirection が消える。

アロケーションの数

bumpalo は 4KiB 程度のページを単位に bump 確保する。50,000 ノードを置くのに必要なページは概ね 16 枚で、

  • bump allocation 約 16 回 (4KiB ページごとにオーバーフロー時にだけ追加)
  • drop 時の free は Bump::reset 1 回のみ (全ページを一括返却)
  • ノードはレキサが流す順序通りに並ぶので、レンダラが走査する順序と一致する

Box<Node> 版との差は青空文庫全作品を回す corpus sweep ベンチで:

  • パース時間: 約 6.4 倍速
  • ピーク RSS: 約 30% 減

差は 累積する 性質のもので、CLI でも WASM でも C ABI でも Python バインディングでも同じだけ得られる。

bumpalo を選んだ理由

候補は他にもあった。aozora が選ばなかった理由を整理しておく。

クレート 採用しなかった理由
typed-arena 型ごとにアリーナ (Arena<Ruby>Arena<Bouten>、…) aozora は 30+ ノード型がある。30 個のアリーナを管理するのは運用上厳しく、各型に &'a ライフタイム引数が伝搬する
slotmap index で間接参照 (SlotMap::get) 走査ごとに key → slot → node の indirection が入り、ベンチで約 25% のレンダ性能劣化。Copy キー前提なので可変長文字フィールドは intern し直しになる
id-arena / index_vec 同上 同じ間接参照コスト
手書き bump 最大限のコントロール 正しく書けるが、bumpalo は既に成熟している。再発明する利得が無い
bumpalo 単一アリーナ、型消去、bump.alloc(T) で任意型 aozora の「Document あたり 1 アリーナ、&'a T の lifetime」要件と一致

bumpalo::collections::Vec<'bump, T> も提供されていて、container の子ノード列もアリーナ内に置ける (子も親と同じ lifetime になる)。

drop と lifetime

Document を drop すると、

  1. Box<str> ソースが drop される (1 free)
  2. Bump アリーナのバッキングバッファが drop される (16 ページ分まとめて 1 free)

合計 2 回の free でツリー全体が消える。ノードごとの destructor は無い。

トレードオフは ツリーが Document より長く生きられない こと。Vec<AozoraNode<'_>> を関数の戻り値にすることはできず、コンパイル時に弾かれる。

fn bad() -> AozoraTree<'static> {
    let doc = aozora::Document::new("…".into());
    doc.parse()        // ERROR: cannot return value referencing local
}

利用側の典型パターンは次の 3 つ。

  • 即時レンダリングして捨てる: tree.to_html()String を返すので lifetime が切れる
  • 1 回走査して自前 IR に書き出す: 多くのエディタバックエンドはこの形
  • Document 自体を関数境界より上で持ち、内側で parse() を呼び直す

「所有された木」が本当に必要な consumer 向けには aozora::owned が将来追加予定だが、変換は trivial なので 1.0 まではあえて出さない方針。

整理

50,000 ノード規模の AST に bumpalo 借用アリーナを選ぶと、

  • アロケーションが 50,000 回から 16 回に
  • drop が 50,000 回から 1 回に
  • メモリレイアウトが走査順に並び、prefetch が効く
  • パース時間 6.4 倍速、ピーク RSS 30% 減

代償として「木は Document より長生きできない」が乗るが、レンダリング後に捨てる消費パターンならコストにならない。aozora の AST 設計はこの利得とトレードオフのバランスの上に乗っている。

実装は aozora-syntax (型定義と arena ラッパ) と aozora-pipeline (実際のアロケーション) にある。handbook の arena.md には bumpalo の代替候補との比較表や、ライフタイム借用の安全性検証も書いてある。

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

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