ファイルシステム走査の最適化で大規模プロジェクトの性能を改善
Tailwind CSS v4のOxideスキャナにおいて、ファイルシステム走査を最適化することで、大規模コードベースでの処理速度が最大4倍に向上しました。複数回のファイルシステム走査の排除、並列ウォーカーの導入、そしてmtime追跡の遅延により、特に開発モードでの再スキャン性能が大幅に改善されています。
背景
Tailwind CSSのOxideスキャナは、プロジェクト内のファイルを走査してクラス候補を抽出します。この処理は、scanner.scan()でクラス候補を取得し、scanner.filesで監視対象ファイル一覧を取得するという2段階で行われていました。
小規模なコードベースではmtime(ファイルの最終更新時刻)追跡により不要な再スキャンを回避できていましたが、数千ファイルを持つ大規模プロジェクトでは、ファイルシステムを走査してmtimeを確認するだけでも無視できないオーバーヘッドが発生していました。#19616では、ディレクトリチェックが2回実行されることで約2倍の速度低下が報告されています。
技術的な変更
本PRは3つの主要な最適化を実装しています。
1. 重複するファイルシステム走査の排除
変更前の動作:
let scanner = new Scanner({ sources })
let candidates = scanner.scan() // 1回目のファイルシステム走査
let files = scanner.files // 2回目のファイルシステム走査
scanner.scan()の後にscanner.filesにアクセスすると、ファイル一覧を取得するために再度ファイルシステム全体を走査していました。
変更後の動作:
最初のscan()呼び出しで収集したファイルパス情報を保持し、filesプロパティへのアクセス時に再利用するようになりました。これにより、462ファイルのコードベースでも1.43倍、5000ファイルのコードベースでは1.92倍の高速化を達成しています。
2. 並列ファイルシステムウォーカーの導入
crates/oxide/src/scanner/mod.rsに並列処理機能が追加されました。ただし、並列ウォーカーには20-50msの起動オーバーヘッドがあるため、すべてのケースで有効というわけではありません。
実装された戦略:
- 初回スキャン: 同期ウォーカーを使用(小規模プロジェクトで高速)
- 2回目以降のスキャン: 並列ウォーカーに切り替え(大規模プロジェクトの開発モードで高速)
この切り替えにより、5000ファイルのコードベースでの開発モード再スキャンが2.19倍高速化しました。@tailwindcss/cliでは変更されたファイルを直接再スキャンできますが、@tailwindcss/postcssでは変更検知のためにファイルシステム走査が必要なため、この最適化が特に効果的です。
3. mtime追跡の遅延初期化
変更後の動作フロー(開発モード):
-
初回スキャン: ファイルシステムを走査してファイルパスを収集。
mtimeは追跡しない -
2回目のスキャン: 並列ウォーカーでファイルシステムを走査し、この時点から
mtime追跡を開始。すべてのファイルを再スキャン -
3回目以降:
mtimeが変更されたファイルのみを再スキャン
2回目のスキャンで全ファイルを再スキャンするトレードオフはありますが、初回ビルドの高速化を優先した設計判断です。本番ビルド(1回のみのスキャン)ではmtime追跡のオーバーヘッドが完全に排除されます。
4. 細かな最適化
Cursorの構造体サイズ削減:
crates/oxide/src/cursor.rsでCursor構造体が最適化されました。
変更前:
pub struct Cursor<'a> {
pub input: &'a [u8],
pub pos: usize,
pub at_start: bool,
pub at_end: bool,
pub prev: u8,
pub curr: u8,
pub next: u8,
}
変更後:
#[derive(Debug, Clone, Copy)]
pub struct Cursor<'a> {
pub input: &'a [u8],
pub pos: usize,
}
impl<'a> Cursor<'a> {
#[inline(always)]
pub fn curr(&self) -> u8 {
self.input.get(self.pos).copied().unwrap_or(0x00)
}
}
フィールド数を7から2に削減し、prev、curr、nextはメソッド呼び出しで計算するように変更されました。Cursorはクラス候補抽出中に頻繁に使用されるため、構造体のサイズ削減がメモリアクセス効率の向上につながります。
不要なアロケーションの削減:
pre_process_input関数のシグネチャが変更され、所有権を持つVec<u8>を受け取るようになりました。
変更前:
fn pre_process_input(content: &[u8], extension: &str) -> Vec<u8> {
let mut result = content.to_vec(); // 常にコピーが発生
// ...
}
変更後:
fn pre_process_input(content: Vec<u8>, extension: &str) -> Vec<u8> {
let mut result = content; // 92%のケースでコピー不要
// ...
}
呼び出し側で既にVec<u8>を持っている場合(全体の約92%)、.to_vec()による不要なメモリコピーが排除されます。
ソースパターンの事前正規化:
resolve_globs関数で使用されるソースパターンが、ウォーカーのフィルタ呼び出しごとではなく、事前に正規化されるようになりました。これにより、ファイルシステム走査中の繰り返し計算が削減されます。
設計判断
本PRでは、シンプルさと性能のバランスを重視した設計判断が複数行われています。
段階的な並列化の採用: 初回スキャンは同期、2回目以降は並列という切り替え方式により、小規模プロジェクトでのオーバーヘッドを避けつつ、大規模プロジェクトでの性能向上を実現しています。将来的にはコードベースサイズのヒント機能の追加も検討されています。
2回目スキャンでの全ファイル再スキャン: mtime追跡を遅延初期化することで初回ビルドを高速化する代わりに、開発モードの2回目スキャンでは変更の有無に関わらず全ファイルを再スキャンします。開発モードでのみ発生し、3回目以降は最適化されるため、許容可能なトレードオフと判断されています。
構造体サイズとメソッド呼び出しのトレードオフ: Cursor構造体のフィールドを削減し、メソッド経由で値を計算する方式に変更しました。メソッド呼び出しのコストよりも、構造体サイズ削減によるメモリアクセス効率の向上が優先されています。#[inline(always)]属性により、リリースビルドではメソッド呼び出しのオーバーヘッドが排除されます。
ベンチマーク結果
tailwindcss.comコードベース(462ファイル、13,200クラス候補):
- 初回ビルド: 1.12倍高速化
- 初回ビルド +
.filesアクセス: 1.43倍高速化 - 開発モード再スキャン +
.filesアクセス: 1.19倍高速化
合成5000ファイルコードベース(5,000ファイル、5,005クラス候補):
- 初回ビルド: 1.15倍高速化
- 初回ビルド +
.filesアクセス: 1.92倍高速化 - 開発モード再スキャン: 2.19倍高速化
- 開発モード再スキャン +
.filesアクセス: 4.03倍高速化
大規模コードベースほど効果が顕著で、特に開発モードでの.filesアクセスを含むケースで最大の性能向上が確認されています。
まとめ
本PRは、ファイルシステム走査の重複排除、並列処理の段階的導入、mtime追跡の遅延初期化という3つの主要な最適化により、Oxideスキャナの性能を大幅に改善しました。構造体サイズの削減やアロケーションの最適化といった細かな改善も積み重ねられています。大規模プロジェクトの開発モードで最大4倍の高速化を達成しながら、既存のパブリックAPIを変更せず後方互換性を維持した実装となっています。