🦀

同じRust製のBiomeとOxlintで、なぜ速度差が大きいのか

に公開

はじめに

こんにちは、デザインエンジニアのyamarinです。
BiomeとOxlintは、同じRust製でどちらも速い印象がありますね。

estie(エスティ)ではfrontendの静的解析ツールにBiomeを採用しており、Oxlintどんなもんかなと大体同じ条件で試してみたところ、条件次第でOxlintの方が約4.9倍速い結果となりました。

同じRustなのになぜ差が出るのか気になったので、調べてみました!

実測: 負荷を揃えて測る

私の担当プロダクト(TS/TSX約800ファイル)で測った結果がこちらです。

計測条件 Biome 2.5.3 Oxlint 1.77 差
通常lint(ルール構成を対応させ、両者ともTS/JSのみ・複数のファイルを跨ぐ解析なし) 0.78秒 0.16秒 約4.9倍
型認識lint(両者とも同じ6ルール) 0.38秒 5.98秒 逆転(Biomeが約16倍)
盛り盛り設定同士(Biomeは複数のファイルを跨ぐ解析+CSS) 2.18秒 0.46秒 約4.7倍

計測は以下のように行いました。

# npx経由だと起動だけで1秒以上乗るので、ローカルバイナリを直接叩く
# 両者とも交互に8〜10回実行し、その中央値を採用
$ time ./node_modules/.bin/biome lint --config-path=./biome-bench ./src
$ time ./node_modules/.bin/oxlint -c .oxlintrc-bench.json --quiet ./src

ちなみにoxc公式のベンチマーク(bench-linter、VS Codeのコードベース)だと差は2.5〜3.3倍です。私の計測が4.7倍と大きく出ているのは対象コードベースもルール構成も違うためだと考えられます。

oxcとは?

Oxc(The Oxidation Compiler、読み方: オックス)は、Rust製のJavaScript/TypeScript向け高性能ツール群の総称です。名前の由来は「Oxidation(酸化)はRust(錆)を作る化学反応」という言葉遊びから来ています。

Oxcは単体のツールではなく、パーサ・リンタ(Oxlint)・フォーマッタ(oxfmt)・トランスフォーマ・ミニファイア・リゾルバなど、共通のAST/アリーナ基盤の上に構築された複数ツールの集合体です。この記事で比較しているOxlintは、その中のリンタ部分にあたります。

OxcはVoidZeroのツールチェーン構想の一部で、ViteのバンドラであるRolldownのパース処理にも使われています。採用例として、Nuxt・Preact・Shopify・ByteDanceなどが名前を挙げられています。

参考

型認識lint(type-aware lint。型情報が必要なルール群)だけはOxlintの方が遅くなりました。これは後半で説明します。

理由1: Biomeの方が解析範囲が広い

両者は1回の実行で見る範囲が違います。

1回の実行で見るもの Biome(全部盛り構成) Oxlint
単一ファイルのJS/TS lint ○ ○
importグラフ(循環import検出) ○(型onlyの循環も) ○(import/no-cycle)
言語を跨ぐ解析(CSSクラス×JSXの未使用検出など) ○ ✗
CSS・JSON等のJS/TS以外のlint ○ ✗
フォーマッタと構文木を共有 ○ ✗(oxfmtは別ツール・別パーサ)

Biome v2はプロジェクトスキャナを持っていて、ファイルを跨いだ解析や、JS/TS以外も含めた解析ができます。

この機能はopt-inで、公式ブログも「スキャナには遅さという荷物がついてくる」と率直に書いています。メンテナ自身もDiscussionで「Biome 2がマルチファイル機能で遅くなったことは把握している」と認めていて、その後もスキャナの高速化が続いています(CHANGELOG)。

Oxlintは複数のファイルを跨ぐ解析をJS/TSのimportグラフに限定していて、CSSも見ません。見る範囲が狭いぶん速い、というのが1つ目の理由です。

では実際にどこで時間が増えているのか、スキャナを必要とする3ルール(noImportCycles・noPrivateImports・noUndeclaredDependencies)とCSS/SCSSをそれぞれon/offして測ってみました。

スキャナ CSS/SCSS Biomeの実行時間 増分
ON あり(=盛り盛り設定) 2.14秒 +1.40秒
ON なし 0.80秒 +0.06秒
OFF あり 0.82秒 +0.08秒
OFF なし 0.74秒 —

スキャナだけなら+0.06秒、CSSだけでも+0.08秒でした。

一方、両方を有効にすると一気に2.14秒まで伸びています。少なくとも今回のコードベースでは、スキャナを有効にした状態でCSS/SCSSまで解析するケースがかなり効いていそうです。

ただ、スキャナもCSSも切った状態でもBiomeは0.74秒、Oxlintは0.16秒です。

なので、通常lintの差は解析範囲だけでは説明できません。次はJS/TSをlintする部分そのものを見ていきます。

理由2: 実行モデルの違い

oxcはパーサとsemantic解析の高速化に最も力を入れており、個々のルールは事前計算されたデータ構造を参照するだけで済む設計です。

https://oxc.rs/docs/learn/performance
https://github.com/oxc-project/oxc/tree/main/crates/oxc_linter

多くのルールは単一のAST走査に相乗りします(この事前計算は1回の実行の中の話で、実行を跨いだキャッシュではありません)。

ルールの実行コストがゼロになるわけではなく、実測でもルールを増やせばちゃんと増えます。

Oxlintのルール構成 有効ルール数 実行時間(同一リポジトリ)
デフォルト構成 111 0.11秒
Biome設定に対応させた構成 約440 0.16秒
全部盛り構成(pedantic・restriction込み) 約630 0.46秒

Biomeのアナライザも同じくクエリベースの単一走査で、実行モデルの流れはほぼ同じです。並べてみるとこうなります。

なお、ルールを1つだけ(Biome、Oxlint共通のnoDebugger)にしてもBiome 0.29秒・Oxlint 0.10秒で、すでに約3倍の差があります。ルールの実行以前、構文木を作る段階から差が生まれているということです。

流れが同じなのに差が出るのは、構築と走査のコストが違うからです。
CST/ASTの構築では、oxcがアリーナに一括確保するのに対し、Biomeはノードを個別に確保して空白・コメント込みの全テキストを保持します。

ルール実行時の走査では、oxcがアリーナ内を直接参照で辿るのに対し、Biomeはred nodeの遅延生成と参照カウントの追跡を挟みます。このコスト差がどこから来るのかは、次のメモリ戦略の話になります。

アリーナとは?

アリーナ(arena)は、多数のオブジェクトを1つの大きな連続領域にまとめて確保し、まとめて解放するメモリ管理方式です。

通常のメモリ確保は、何かを作るたびにOSへ「メモリをちょっとくれ」と個別に依頼します。何百万個のASTノードを作るたびにこれをやると、依頼のたびのオーバーヘッドが積み重なり、確保された場所もメモリ上に散らばります。

アリーナ方式では、最初に大きな区画をまとめて確保し、「次はここから」というポインタを1本だけ持ちます。新しいノードが必要になったら、OSに頼み直さずポインタを必要な分だけ前に進めるだけで確保が完了します(この動作をbump allocation、ポインタを弾くように進める確保と呼びます)。

通常の個別確保 アリーナ確保
確保 都度OSに依頼 ポインタを進めるだけ
解放 ノードごとに個別 アリーナごと一括
メモリ上の配置 散らばる 確保順に隣接(連続領域に確保されるため)

トレードオフは、アリーナの中身が「まとめて生きて、まとめて死ぬ」運命を共有することです。1つのノードだけを先に解放することはできません。だからこそ、lintが終わったら木ごと捨てる使い捨て前提の用途(oxcのAST)には向きますが、一部だけ変更して残りは使い続けたい用途(BiomeのロスレスCST、エディタでの編集やフォーマッタとの共有)には、個別確保+参照カウントの方が向いています。

参考

理由3: メモリ戦略の違い

理由2で見た構築・走査のコスト差は、パーサの出力形式の違いから来ています。

oxc(アリーナAST) Biome(ロスレスCST)
確保 ポインタ加算のみ ノード個別(green nodeは共有)
解放 アリーナごと一括 参照カウント
保持する情報 lintに必要なASTのみ 空白・コメント含む全テキスト
用途 lint後に使い捨て フォーマッタ・リンタで共用、編集・エラーに強い

BiomeのCST実装はred-green treeという2層構造で、rust-analyzerのrowan由来、遡るとRoslyn由来の設計です。構造共有や遅延生成で最適化されていますが、アリーナの一括確保と比べると、確保回数もポインタ追跡も保持するデータ量も多くなります。

red-green treeとは?

red-green treeは、C#コンパイラのRoslynが考案した構文木の持ち方で、1つの木を2層に分けて持ちます。

green nodeが本体のデータです。ノードの種類・子・自分が占めるテキストの長さだけを持ち、ファイル内の絶対位置や親への参照はあえて持ちません。位置や親を知らないノードは文脈から独立になるので、1 + 1の2つの1のような同一の部分木をメモリ上1個に共有できます(イミュータブル+参照カウント)。コードを編集したときも、変更箇所からrootまでの経路だけ作り直せば、残りは古い木とそのまま共有できます。

red nodeは、ルールやエディタ機能が木を辿るときに、その場で遅延生成される使い捨てのビューです。green nodeを包んで、絶対位置と親への参照を付け足します。

green node red node
実体 構文木の本体データ greenを包むビュー
生成 パース時に構築 走査で降りるときに遅延生成
持つ情報 種類・子・自分が占めるテキストの長さ 絶対位置・親への参照
特性 不変・参照カウントで構造共有 使い捨て

この2層構造は、編集のたびに差分だけ更新したいエディタやフォーマッタには理想的です。一方でlintのように1回走査して捨てる用途では、走査のたびにred nodeの生成と参照カウントの追跡が挟まります。oxcのアリーナASTは最初から絶対位置も親参照も焼き込んだノードを連続メモリに並べるので、このひと手間がありません。

参考

型認識lintではBiomeの方が速い理由

型認識lint(no-floating-promisesなど)を有効にすると、Biomeの方が速い結果となりました。

アプローチ 型情報の取り方 速度
ESLint+typescript-eslint tscの完全な型検査 最も遅い(大規模だと数十秒〜分単位。モノレポ規模ではメモリ不足で実行できない報告も)
Oxlint+tsgolint typescript-go(tsgo)で本物の型検査を高速実行 5.98秒
Biome tsc非依存の独自型推論で近似 0.38秒(ただし網羅率はnoFloatingPromisesルールでBiome自身の公表によりtypescript-eslint比約85%、v2.0時点は約75%)

tsgolintはtypescript-goを使う別バイナリで、実際に型検査を行うぶん時間がかかります(それでもESLint+typescript-eslint比では大幅に速いです)。Biomeは型推論を自前で実装する第三の方式で、実行時間が短い代わりに検出できる範囲が限定的です。

3ツールとも型情報の扱い方が違っていて、実行時間はその設計で決まっています。

まとめ

BiomeとOxlintの速度差は、Rustかどうかではなく、次の3つの設計の違いから来ていました。

  1. 仕事量: 複数のファイルを跨ぐ解析やCSSを見るかどうか
  2. 実行モデル: 事前計算したsemanticデータの上をルールが走る(1ルールあたりのコストが小さい)
  3. メモリ戦略: 使い捨てのアリーナAST vs 共有するロスレスCST

ツールごとに高速な処理・低速な処理が存在することが分かりました。

参考リンク

実測・ベンチマーク

理由1(仕事量)

理由2(実行モデル)

理由3(メモリ戦略)

  • oxc_allocator README — bump allocationの設計
  • red-green tree関連は本文中のexpand内の参考リンクを参照

型情報のコスト

estie テックブログ

Discussion