Prettier CLIのパフォーマンス徹底解説
このページは PageTurner AI で翻訳されました(ベータ版)。プロジェクト公式の承認はありません。 エラーを見つけましたか? 問題を報告 →
こんにちは、Fabioです。PrettierチームからCLI(コマンドラインインターフェース)の高速化を依頼されました。本記事では私が発見した最適化手法、その発見プロセス、新旧CLIの比較結果、そして次に最適化すべき領域について解説します。
インストール方法
開発中の新しいPrettier CLIがリリースされました。今すぐインストール可能です:
npm install prettier@next
基本的な後方互換性は保たれています:
prettier . --check # Like before, but faster
問題が発生した場合は、環境変数を使用して一時的に旧CLIを呼び出せます:
PRETTIER_LEGACY_CLI=1 prettier . --check
npx経由でも試せますが、npx自体が遅い点に注意:
npx prettier@next . --check
最終目標は〜100%の後方互換性を達成し、prettierパッケージの将来の安定版リリースで現行CLIを置き換えることです。
アーキテクチャ概要

Prettier CLIの動作は上図のように進行します:
-
ファイルに対して実行したいアクション(例: 適切にフォーマットされているかのチェック)を定義
-
アクション対象となる全ファイルの検出
-
.gitignoreと.prettierignoreファイルの解決(無視すべきファイルの判定) -
.editorconfigファイルの解決(EditorConfig固有のフォーマット設定適用) -
.prettierrcファイルとその他約10種類の解決(Prettier固有の設定適用) -
各ファイルが設定に準拠しているかの検証
-
最終結果をターミナルに出力
このアーキテクチャから導かれる主な気づきは3つあります:
-
作業量は対象ファイル数に比例しますが、CLI実行間で大半のファイルは変更されません(大規模リポジトリのコミットでは通常一部のファイルのみ変更)。前回実行時の作業結果を再利用できれば、現在の実行の大部分をスキップ可能です。
-
設定ファイルの解決は潜在的に高コストです。数千のフォルダがあるリポジトリでは各フォルダに設定ファイルが存在する可能性があり、例えば10個の
.editorconfigファイルが見つかる場合、各ファイルは特定のグロブパターンにマッチするファイルごとに異なる設定を定義するため、全設定をファイル単位で統合する必要があります。ただし実際には、数千フォルダのリポジトリでも.editorconfigファイルは1〜数個しか存在しないケースがほとんどで、過度なコストにはならないはずです。 -
特段斬新な気づきではありませんが、プログラムが実行する全ての処理が必要不可欠であり、かつ効率的に実行されるなら、結果的にプログラム全体も効率的に動作します。不要な作業は可能な限りスキップし、必要な処理は効率化に注力すべきです。
これらの気づきを基に、私はPrettierの新CLIを一から書き始めました。既存実装に後付けでパフォーマンス改善を施すよりも、最初からパフォーマンスを意識した設計で再構築する方が効率的だからです。
本記事では計測にBabelのモノレポを使用します。これは優れたベンチマークとなり、実際の比較的大規模なモノレポにおける改善効果を実感できるでしょう。
ファイルの高速検索
まず対象ファイルを見つける必要があります。現在のPrettier CLIではfast-globを使用しており、実装コードは以下のようになります:
import fastGlob from "fast-glob";
const files = await fastGlob("packages/**/*.{js,cjs,mjs}", {
ignore: ["**/.git", "**/.sl", "**/.svn", "**/.hg", "**/node_modules"],
absolute: true,
dot: true,
followSymbolicLinks: false,
onlyFiles: true,
unique: true,
});
Babelモノレポで実行すると、約3万ファイル中1万7千ファイルがグロブにマッチするのを約220msで検出できます。1万3千以上のフォルダを含むことを考慮すると妥当な速度と言えます。
簡単なプロファイリングで、発見されたファイルが"ignore"グロブにマッチするかチェックする処理にかなりの時間が費やされていることが判明しました。内部的に正規表現に変換され個別に照合されています。無視グロブを"**/{.git,.sl,.svn,.hg,node_modules}"に統合してみましたが、内部で再分割されるため効果はありませんでした。
無視グロブを完全に除外すると、ほぼ同数のファイルを約180msで検出でき、約20%の高速化を達成しました。より効率的なグロブ照合方法があればさらなる改善が可能です。
fast-globはpackages/**/*.{js,cjs,mjs}のようなグロブを扱う際、先頭部分が静的な要素であることを認識し、packagesフォルダ内でのみ**/*.{js,cjs.mjs}を検索する巧妙な最適化を行います。不要なフォルダのスキャンを完全に回避できるため、ファイル数が多い場合に効果的です。
この発想から着想を得て、無視グロブの静的部分が末尾にある特性を活かせる新たなライブラリを開発しました。同等のファイル検索コードは以下の通りです:
import readdir from "tiny-readdir-glob";
const { files } = await readdir("packages/**/*.{js,cjs,mjs}", {
ignore: "**/{.git,.sl,.svn,.hg,node_modules}",
followSymlinks: false,
});
そして、同じファイルを約130ミリ秒で見つけます。無視するグロブをコメントアウトして、そのオーバーヘッドがどれだけ追加されるかを確認するために試してみると、ほぼ同じ時間がかかるようです。そのため、このシナリオではコストを測定するのが難しいほど十分に最適化されました。
予想以上の高速化が実現できた理由はいくつかあります:
-
無視グロブだけでなく、メイングロブの
**/*.{js,cjs,mjs}部分も最適化されました -
グロブは検索開始元のルートフォルダからのファイル相対パスに対してマッチされるため、通常は多数の
path.relative呼び出しが発生します。しかし、グロブが**/.gitのような場合、相対パスを計算するかどうかは関係ありません(なぜなら、結局は文字列の末尾で子パスを探すことになるため)。そのため、これらのpath.relative呼び出しは完全にスキップしました。 -
Nodeの
fs.readdirで取得したファイル名と親パスをpath.joinではなく手動結合することで、絶対パス生成を高速化しました
さらに、この新しいライブラリは約90%小型化され、約65KBのminify済みコードが不要になりました。これによりCLI全体の起動が高速化されます。Babelよりもはるかに小さいリポジトリでは、新しいCLIは古いCLIがfast-globのパースを終えた時点ですべての対象ファイルを見つけ終えているかもしれません。
CLIのこの部分を書き直すことは、得られた速度向上に対してやや過剰に見えるかもしれませんが、書き直しの主な理由は実際には別にあります。すべてのファイルを見つけるには最初から0.5秒未満しかかからないため、ここでCLIを数秒高速化する余地はなさそうに思えます。しかし重要な点は、CLI全体ではフォーマット対象ファイルを見つけるだけでなく設定ファイルも見つける必要があることです。グロブにマッチしなかったものも含め、検出されたすべてのファイルとフォルダを把握していることは、後で数秒を削減できる貴重な情報です。tiny-readdir-globはその情報をほぼ追加コストなしで提供できるため、それだけの価値があると考えました。
このセクションから特に興味深い点をまとめると:
-
可能であれば、常にPrettierに探す拡張子を指定してください(例:
packages/**/*.{js,cjs,mjs})。このシナリオでpackages/**/*や単にpackagesを使用すると、13,000の余分なファイルを処理する必要があり、後でPrettierが破棄するコストが高くなります。 -
時間をかけて調査すれば、既に最適化されたライブラリでも、常に最適化の余地やパフォーマンスのための特殊ケースが残っています。
-
どんな情報が破棄されているか、あるいは再構築にコストがかかるようになっているかを考える価値があります。グロブライブラリは作業のために見つかったファイルとフォルダを把握していますが、その情報を呼び出し元に公開することで、場合によっては追加のパフォーマンスを実質無料で解放できます。
さらなる高速化の可能性:
- これはNodeの
fs.promises.readdirのパフォーマンスや、見つかったフォルダごとにPromiseを作成するオーバーヘッドがボトルネックになっているようです。コールバックスタイルのAPIの使用や、Node自体の最適化機会を調査する価値があるかもしれません。
設定の高速解決
これはおそらく新しいCLIで最も影響の大きい最適化であり、基本的には設定ファイルを可能な限り高速に見つけ、各フォルダの設定ファイル存在チェックを1回のみ行い、発見した設定ファイルのパースも1回限りとすることです。
現在のCLIの主要な問題点は、解決済み設定をファイルパスではなくフォルダパスでキャッシュすることです。例えばBabelのモノレポにはフォーマット対象ファイルが約17,000個ありますが、リポジトリ全体で.editorconfigファイルは1つだけです。このファイルは1回パースされるべきですが、実際には約17,000回パースされていました。さらに、これら17,000ファイルが同一フォルダにある場合、そのフォルダは約17,000回.editorconfigファイルの有無を確認されます。実際には、フォーマット対象ファイルがフォルダ階層の深い位置にあるほど、CLI全体が遅くなる傾向がありました。
この問題は主に2つのステップで解決されました:
-
解決済み設定ファイルをフォルダパスでキャッシュするため、各フォルダのファイル数や階層の深さは関係なくなりました。
-
約15種類のサポート対象設定ファイルについて、各フォルダへの問い合わせが実質0回になりました。前セクションでリポジトリ内の全ファイルを把握しているため、単純なルックアップで済むからです。これはファイルシステムへの問い合わせよりもはるかに高速です。小規模リポジトリでは影響は小さいものの、Babelの例では約13,000フォルダがあり、13,000×15回のファイルシステムチェックが積み重なっていました。
では、サポートされる各種設定タイプがどのように解決されるか、詳細に見ていきましょう。
EditorConfig設定の解決
前のステップでリポジトリ内のすべての.editorconfigファイルを解析済みであり、任意のターゲットファイルに関連する設定を定数時間で取得できると仮定すると、次に行いたいのは基本的に各ターゲットファイルに対してこれらを単一の設定オブジェクトにマージすることです。
このアイデアはすぐに却下されました。なぜならeditorconfigパッケージがこの処理を行う関数を提供していないからです。最も近いのはparseFromFilesという関数ですが、非推奨である上に、設定を文字列として要求するため呼び出しごとに自ら解析を行うようです。これは最初から避けたかったことであり、これらの設定は一度だけ解析したいのです。
そこでPrettierのニーズに合わせてパッケージを書き直しました。tiny-editorconfigはまさに必要なresolve関数を提供し、設定ファイルの発見ロジックを私たちに委ねています。これは独自の方法でこれらのファイルをキャッシュする必要があるため望ましい形です。
ついでにその背後にあるINIパーサーも書き直したところ、約9倍高速化されました。ほとんどのリポジトリには1つの.editorconfigファイルしかないため大した影響はありませんが、こうした小さなパーサーを書くのは楽しいものです。もしリポジトリに数千の.editorconfigファイルがあれば、追加のパフォーマンス向上を実感できるでしょう!
さらにこの新ライブラリは約82%小型化され、ミニファイ済みコードから約50KBが削除されました。これによりCLI全体の起動が高速化されます。またtiny-readdir-globと同じグロブライブラリを使用している一方、現行CLIではfast-globとeditorconfigが異なるライブラリを使っていたため、実際にはより多くのコードが効果的に削除されました。
以前はBabelのモノレポでこれらのファイルを解決するのに数秒かかっていましたが、現在は約100ms程度です。
さらなる高速化の可能性:
- 多くの場合、遭遇し得る任意のファイルパスに対して設定ファイルを事前解決できるはずです。例えば設定内のグロブに応じて、最大3つの解決済み設定に集約できる可能性があります(どのグロブにも一致しないファイル用、
**/*.jsグロブ一致用、**/*.mdグロブ一致用)。実装は複雑で速度向上効果も不明確ですが、将来の検討課題です。
Prettier設定の解決
.prettierrcファイルなどのPrettier固有設定についても、発見したすべての設定ファイルを解決済みで、任意のターゲットファイルに対して定数時間で取得できると仮定します。
これはEditorConfig固有設定と基本的に同様の状況であるため、ほぼ同じ対応を行います。設定マージロジックはCLI内部にハードコードします。スタンドアロンパッケージ化はエコシステムにほとんど有用性がないためです。
今後の主な検討課題は以下の通りです:
-
多数の異なる設定ファイルがサポートされています。Babelのモノレポでは、最初のステップで作成した既知パスオブジェクトに対し約15万回のルックアップが発生します。高コストではないものの無視できません。この数を大幅に削減できれば多少の高速化が期待できます。
-
設定ファイル解析に必要なパーサーの一部は比較的高コストです。
json5パーサーは最小のJSONCパーサーと比較して約100倍のコード量を必要とし、場合によっては解析速度も約50倍遅くなります。サポートフォーマットを減らせばCLIはより軽量になります。 -
例えば
.prettierrc.json5という名前の設定ファイルがリポジトリ内に存在するか一度だけチェックできれば、設定ファイルのチェック回数を桁違いに減らせます。なぜなら、その名前のファイルがリポジトリ内のどこにも存在しない場合、Babelの約13,000フォルダそれぞれに対して存在確認する必要がなくなるからです。使用中のglobライブラリが無償で提供してくれる「既知の全ファイル名リスト」も貴重な情報源となります。
無視設定の解決
最後に、.gitignoreと.prettierignoreファイルを解決し、どのファイルを無視すべきか判断する必要があります。ここでは、すべての無視ファイルを既に解決済みで、任意のターゲットファイルに対して定数時間で取得可能と仮定します。
ここでは大規模な最適化は行わず、主にnode-ignoreに依頼して、ファイルの無視判定関数を生成してもらっています。
小規模な最適化として、特定の場合にpath.relativeの呼び出しやignore関数自体の実行をスキップしています。無視ファイルは、無視ファイルが存在するフォルダからの相対パスと大まかに一致します。すべてのパスが正規化されていると分かっているため、ターゲットファイルの絶対パスが各無視ファイルの存在フォルダ絶対パスで始まらない場合、そのファイルは無視ファイルの管理範囲外にあると判断でき、node-ignoreが生成したignore関数を呼び出す必要がありません。
ただし、これらのファイル内のglobパターンと発見されたファイルの照合にはかなりの時間がかかるようです。数千ファイルに対して数百ミリ秒かかることもあります。無視ファイル内には多数のglobパターンが存在し、照合対象ファイルも大量にあるため、最悪ケースではglob照合の試行回数が爆発的に増加するからです。
.gitignoreや.prettierignoreの利点は、これらのファイルをパースしてファイルを照合する時間が、無視によって除外されるはずだった全ファイルを処理する時間よりも短いことが多い点です。つまり、コストが相殺されるのです。
さらなる高速化の可能性:
-
ほとんどのglobパターンを単一の複合globに統合し、エンジンで一括照合できる可能性があります。どのglobが一致したかは重要ではなく、いずれかが一致したかだけが問題だからです。
-
globの実行順序を変更し、コストが低く範囲が広いglobを先に実行すれば、平均的なglob照合時間を短縮できるかもしれません。ただし、無視対象外のファイルが大部分の場合、効果は限定的です。
-
ファイルパスと照合結果をキャッシュすることも考えられますが、キャッシュなしでも大幅な高速化が可能そうです。
キャッシュ
ここまでで全ファイルを発見し、すべての設定を解決しました。残る作業は各ターゲットファイルのフォーマットという潜在的に高コストな処理であり、ここでキャッシュが活躍します。
現行CLIもキャッシュ機能をサポートしていますが、オプトイン方式(明示的な--cacheフラグが必要)であり、前回の実行でファイルが_適切にフォーマットされていない_と判明した場合を記憶できません。適切にフォーマットされた場合のみ記憶するため、未フォーマットファイルは再度フォーマットしてチェックするという不要なオーバーヘッドが発生します。前回の実行結果を記憶しておけばこの処理をスキップできるのにです。
私たちの目標は、ファイルのフォーマット状態を記憶することで最大限の作業をスキップしつつ、合理的に小さなキャッシュファイルを生成し、キャッシュ機構自体によるオーバーヘッドを抑えることです。
新しいCLIではオプトアウト方式のキャッシュを採用しています。つまり、--no-cacheフラグで明示的に無効化しない限り、キャッシュは常に有効です。これによりデフォルトでより多くのユーザーがその恩恵を受けられます。またキャッシュはすべてを考慮に入れるためデフォルトで有効化されており、キャッシュがCLIに誤った情報を提供する状況は現実的に起こりえません。以下のいずれかが変更されると、キャッシュ全体または一部が自動的に無効化されます:Prettierのバージョン、解決済みのEditorConfig/Prettier/Ignore設定ファイルとそのパス、CLIフラグ経由で指定されたフォーマットオプション、各ファイルの実際の内容、および各ファイルのパス。
ここでの主な工夫は、各ファイルのキャッシュを解決済みフォーマット設定に_直接_依存させないことです。そうすると各対象ファイルごとに設定ファイルをマージし、結果のオブジェクトをシリアライズしてハッシュ化する必要が生じ、想定以上のコストがかかるためです。
新しいCLIでは代わりに、発見したすべての設定ファイルをパースした後、それらをシリアライズしてハッシュ化します。これは後でフォーマットするファイル数に関わらず一定時間で完了し、設定ファイルを_間接的に_考慮するためにキャッシュファイルに保存するハッシュ値は1つだけです。設定ファイルのパスと内容が変わらなければ、前回実行時と同じパスのファイルは必ず同じ解決済みフォーマット設定オブジェクトで処理されるため、この手法は安全です。唯一の潜在的問題は設定ファイルパース用の依存関係にバグがある場合ですが、最悪の場合でもPrettier本体のバージョンを問題のある依存関係と共に更新すれば対応できます。
具体的な数値で示すと、現在のCLIはキャッシュなしでBabelのモノレポをチェックするのに約29秒かかります。新しいCLIはキャッシュなし・非並列処理で約7.5秒です。キャッシュファイルが有効で存在する場合、現在のCLIは依然として約22秒かかるのに対し、新しいCLIは約1.3秒で完了します。この数値は今後の最適化でさらに半減できる可能性があります。
この長文で覚えておくべき核心は、CLIを最大限高速化したいならキャッシュファイルを保持することです。キャッシュファイルはデフォルトで./node_modules/.cache/prettierに保存され、--cache-location <path>フラグで保存場所を変更できます。繰り返します:パフォーマンスが重要な場合、実行間でキャッシュファイルを保持することが最も効果的な高速化手法です。
さらなる高速化の可能性:
-
Nodeのハッシュ計算高速化:Bunでは同じハッシュ計算が約3倍高速です。最適化の余地があると判断しNodeに報告しましたが、現時点で対応PRはなく複雑な課題のようです。
-
パース済み設定ファイルのキャッシュ化:現状はハッシュ値のみ保持していますが、ファイル自体をキャッシュする案もあります。ただし通常は数が少ないため、大きな影響はないでしょう。
-
コード削減と遅延ロード:完全キャッシュ時のパスをさらに高速化するため、不要コード削除や遅延ロードの余地があります。
フォーマット改善
これでパイプラインの最終段階に到達しました。フォーマット対象ファイルが確定したため、あとは実行するだけです。
コアフォーマット関数自体の最適化は深く追求しませんでした。ファイル数が少ない場合には既に十分な速度であり、CLIの遅延要因は主に設定解決の非効率性と過去作業の未活用にあると判断したためです。ただし次なる最適化対象として重要であり、簡易プロファイリングでは顕著な改善余地は見当たりませんでしたが。
ただし他のアプローチもいくつか試みました。
まず複数のファイルを並列でフォーマットできるようになりました。これがデフォルト動作となり、オプトアウトするための--no-parallelフラグも用意されています。--parallel-workers <int>フラグで使用するワーカー数をカスタマイズ可能です。10コアのマシンでBabelのモノレポをチェックする場合、並列化により実行時間が約7.5秒から約5.5秒に短縮されます。改善幅は控えめですが、より大規模なリポジトリや数百コアのCI環境では効果が顕著になる可能性があります。他の最適化との相乗効果も期待できるでしょう。
最後に、Prettierのフォーマット関数を@wasm-fmt/biome_fmt(BiomeのWASMコンパイル版フォーマッタ)で置き換えて簡易テストしたところ、Babelモノレポのチェック時間は非並列時約3.5秒、並列時約2.2秒となりました。Prettier標準フォーマッタと比較して約2倍の性能向上です。BiomeのフォーマッタをネイティブNodeモジュールとしてコンパイルすれば、さらなる改善が見込めるかもしれません。
さらなる高速化の可能性:
-
コアフォーマット処理自体には手を加えていませんが、少なくとも2倍の最適化余地があると推測されます。ただし実現には大規模な改修が必要かもしれません。改善の可能性は確かに存在します。
-
--parallelフラグはデフォルト有効ですが、処理ファイル数が少ない場合に各コアへのタスク分散が非効率になり、逆に遅延する可能性があります。ヒューリスティックに基づく動的プールサイズ調整で対処可能でしょう。現状では、高速な処理環境では若干のオーバーヘッドがあるものの、低速環境では顕著な改善効果があるためデフォルト有効としています。
ターミナルへの出力処理
最後のステップは、CLIが実行したコマンドの結果をターミナルに出力することです。
大幅な最適化の余地は少ないですが、いくつかの改善点がありました:
-
多数の小規模ファイルを処理する場合、現行CLIはフォーマット中のファイルパスを即時出力し、完了後に消去していました。数千ファイルでこの処理を行うと、
console.logの同期的な性質がメインスレッドをブロックし、驚くほどのオーバーヘッドが発生します。特に16ms以内に100回出力しても、画面更新回数は1-2回程度なので大半は無駄です。新CLIではフォーマット中のファイルログを出力しないことで、数百ミリ秒の削減を実現しました。 -
現行CLIは処理ファイル数に比例して
console.logを数千回呼び出すのに対し、新CLIはログをバッファリングし最終的に1回のconsole.logで出力します。これにより状況によっては顕著な高速化が期待できます。
この領域での主な改善点は、視覚的に興味深い進捗表示を実装し「体感速度」を向上させることです。実際のパフォーマンス以上に重要なユーザー体験向上のため、リソース消費を抑えた表示方法の検討が求められます。
ベンチマーク結果

最後に、Babelモノレポ(全ファイルフォーマット済み・9ファイルエラー状態)における新旧CLIの各種フラグ別実行時間比較を示します:
prettier packages --check # 29s
prettier packages --check --cache # 20s
prettier@next packages --check --no-cache --no-parallel # 7.3s
prettier@next packages --check --no-cache # 5.5s
prettier@next packages --check # 1.3s
デフォルトでは、同一コマンドの実行時間が約29秒から約1.3秒に短縮され、約22倍の高速化を実現しています。これは実行間でキャッシュファイルが保持されることが前提です。将来的には約50倍の高速化にさらに近づける可能性があります。
キャッシュファイルが保持されない場合、または明示的にキャッシュを無効化した場合、あるいは初回実行時では、並列化により実行時間が約29秒から約5.5秒(私の環境での計測値)に短縮され、約5倍の高速化となります。この改善も非常に有意義です。
改めて強調しますが、この改善はPrettierのformat関数自体を一切変更せずに達成されています。
Biomeとの比較結果
Biome(最先端のRustフォーマッタでありおそらくパフォーマンスの優勝者)の計測値と比較すると興味深い結果が得られました:
biome format packages
# Diagnostics not shown: 25938.
# Compared 28703 file(s) in 869ms
# Skipped 4770 file(s)
ここでBiomeは当社CLIより約11,000ファイル多いファイルのフォーマットをチェックしています。これはBiomeがまだ.gitignoreや.prettierignoreの解決を実装していないためと思われます。動作上のその他の重要な差異も存在する可能性があります(確証はありません)。
無視ファイルのサポートを手動で無効化し、Biomeの動作をより正確に模倣するよう当社CLIを修正した場合、次の数値が得られました:
prettier@next packages --check --no-cache # 15s
両ツールが完全に同一の処理をしているわけではないため、この比較は慎重に解釈する必要がありますが、Biomeが多数のファイルのフォーマットチェックを実行できる速度には注目すべきでしょう。おそらく当社が同等の速度を達成するにはキャッシュファイルが必要です。
ユーザーにとって大規模な高速化を実現するための様々なアプローチが存在します。
まとめ
新しいCLIはまだ開発中ですが、ぜひお試しいただけると幸いです!インストールは今日から可能です。
皆様の環境で新しいCLIがどの程度の高速化をもたらすか、ぜひ結果を共有ください。質問やさらなる高速化のアイデアがあれば、@PrettierCode または直接 @fabiospampinato までツイートをお願いします。
