ESLintのMultithread LintingをTurborepoのCI環境で検証して導入を見送った理由

はじめに

こんにちは!カイポケのリニューアルプロジェクトを担当しているエンジニアのNobです。 普段はWebのフロントエンドが中心ですが、最近はモブプロをとおしてバックエンドのタスクにもチャレンジさせてもらっています。

プライベートでは2歳になったばかりの息子の子育てに奮闘中です。最近は話せる言葉が増えて少し会話が成り立つようになってきたり、遠くへの外出でも泣かなくなってきました。子育ては大変ですが、それ以上に子供からたくさんの笑顔と幸福感をもらっています。

さて、今回はESLintに追加されたMultithread Lintingについて、私が携わっているプロジェクトのCIへの導入を検討したので共有します!

Multithread Linting 機能の概要

ESLint v9.34.0で追加されたこの機能は、その名のとおりESLintによるチェック処理を並行して実行することによって高速化するものです。リリースとともに 公開された Blog によると、1.3倍から3倍ほど高速化した例があるようです。大規模なコードベースで開発している我々としても高速化が期待できそうなので試してみることにしました。

concurrency オプションの設定値

concurrencyに指定可能な値は以下のいずれかです。

  • 正の整数: 最大のスレッド数。任意の数を指定。
  • auto: CPUのコア数と対象ファイル数に基づいてESLintに並列数を決定させる。
  • off: Multithread Linting機能を無効にする。デフォルト。

auto がうまく機能しそうであれば、実行環境に合わせて値の調整をしなくて済むので手間が省けそうです。なので、まずは auto から試してみることにしましょう。

初回ベンチマーク: off vs auto

現在の私の開発端末のスペックは以下です。

  • MacBook Pro: 14インチ、 Nov 2024
  • CPU: Apple M4 Max
  • メモリ: 64GB

この端末で現在私が開発に携わっているプロジェクトを対象に、どのくらい処理速度に変化があるのか見てみましょう。まずはESLintの処理速度の変化を検証したいので、 ESLintのキャッシュを利用しない状態で比較してみます。

ベンチマークにはhyperfineというコマンドラインベンチマークツールを使用します。

hyperfine --warmup 1 --runs 3 -L concurrency off,auto "pnpm exec eslint --concurrency {concurrency} './**/*.{ts,tsx,graphql}'"
Benchmark 1: pnpm exec eslint --concurrency off './**/*.{ts,tsx,graphql}'
  Time (mean ± σ):     62.363 s ±  0.257 s    [User: 74.000 s, System: 10.615 s]
  Range (min … max):   62.110 s … 62.624 s    3 runs

Benchmark 2: pnpm exec eslint --concurrency auto './**/*.{ts,tsx,graphql}'
  Time (mean ± σ):     76.222 s ±  1.714 s    [User: 262.973 s, System: 138.288 s]
  Range (min … max):   74.245 s … 77.297 s    3 runs

Summary
  pnpm exec eslint --concurrency off './**/*.{ts,tsx,graphql}' ran
    1.22 ± 0.03 times faster than pnpm exec eslint --concurrency auto './**/*.{ts,tsx,graphql}'

試した結果、期待とは違って --concurrency auto を指定した場合の方が1.22倍時間がかかるようになってしまいました。なぜこのような結果になってしまうのか少し調べてみましょう。

auto モードの並列数決定ロジック

まずは --concurrency auto を指定した場合、並列数がどのような処理で決定されるのか調べてみます。 concurrency というキーワードでgrepしてESLintのコードを眺めてみると、どうやら このあたりの処理 で判断しているようです。

case "auto": {
    workerCount = Math.min(
        availableParallelism() >> 1,
        Math.ceil(fileCount / AUTO_FILES_PER_WORKER),
    );
    break;

周辺コードも含めて少し噛み砕いて見ていくと

  • availableParallelism() >> 1
    • この availableParallelism() はNode.jsの標準ライブラリの os.availableParallelism() であり、その実体は libuv の uv_available_parallelism() 。OSやCPUによる細かな違いはあるものの、シンプルに言えばプロセスが利用可能なCPUのコア数を返す。
    • availableParallelism() の結果を1ビット右にシフト(2で割って小数を切り捨て)した値となる。
  • Math.ceil(fileCount / AUTO_FILES_PER_WORKER)
    • fileCount は対象ファイル数。
    • AUTO_FILES_PER_WORKER35 という定数
      • この値はヒューリスティックな値であり、将来的により適切な値や算出処理に改善される可能性がある。

のように読みとることができます。最終的にはそれぞれの値のより小さい方が並列数として使用されることになります。

これを思い切って単純にすると Math.min(CPU のコア数の半数, ファイル数 / 35) となります。

ではここで ファイル数 / 35 が実際にどのくらいの値になるのか考えてみましょう。

  • 100 / 35 => 2.8…
  • 300 / 35 => 8.5…
  • 500 / 35 => 14.2…

のようになり、この値の比較対象がCPUのコア数の半数であることを考慮すると、対象ファイルが500以上あるような比較的大規模なプロジェクトではCPUのコア数の半数が並列数になると考えることができます。

検証端末での並列数計算結果

前述のとおり、並列数の算出にはCPUのコア数と対象のファイル数が関係してくるのでした。今回検証に使っているプロジェクトには4,000ファイル以上が存在しています。対象ファイル数が十分に多いため、並列数はCPUのコア数で決まると考えることができそうです。

検証に使っている開発端末のCPUはApple M4 Maxであり、この環境で availableParallelism() が返す値は16です。

node -p "require('node:os').availableParallelism()"
16

この値を2で割った値、つまり8が並列数として利用されることになります。実際に並列数として使用された値はESLintの実行時に --debug オプションを指定することで出力されるログからも確認することができます。

eslint:eslint Linting using 8 worker thread(s).

ここでApple M4 Maxのコアの内訳を見てみましょう。 12個がPerformanceコアで4個がEfficiencyコアです。 Performanceコアは高いクロック周波数で動作するCPU集約的なタスクが向いているコアで、 Efficiencyコアは低消費電力で動作する電力消費効率を重視したコアです。

一方Node.jsが利用しているlibuvの uv_available_parallelism() ではこれらのコアの違いを知ることはできません。 16個のうちの4個が他のコアよりも性能が低いことを考慮すると、 8という並列数は実際のスペックよりも高い値なのかもしれない、という仮説をたてることができます。

最適な並列数の探索

それでは実際に8以下の並列数で改めて検証してみましょう。

hyperfine --runs 1 -L concurrency off,2,3,4,5,6,7,auto "pnpm exec eslint --concurrency {concurrency} './**/*.{ts,tsx,graphql}'"
Benchmark 1: pnpm exec eslint --concurrency off './**/*.{ts,tsx,graphql}'
  Time (abs ≡):        64.260 s               [User: 75.702 s, System: 11.435 s]

Benchmark 2: pnpm exec eslint --concurrency 2 './**/*.{ts,tsx,graphql}'
  Time (abs ≡):        51.809 s               [User: 108.378 s, System: 22.637 s]

Benchmark 3: pnpm exec eslint --concurrency 3 './**/*.{ts,tsx,graphql}'
  Time (abs ≡):        48.950 s               [User: 135.421 s, System: 35.065 s]

Benchmark 4: pnpm exec eslint --concurrency 4 './**/*.{ts,tsx,graphql}'
  Time (abs ≡):        49.328 s               [User: 163.220 s, System: 50.156 s]

Benchmark 5: pnpm exec eslint --concurrency 5 './**/*.{ts,tsx,graphql}'
  Time (abs ≡):        52.281 s               [User: 187.664 s, System: 67.241 s]

Benchmark 6: pnpm exec eslint --concurrency 6 './**/*.{ts,tsx,graphql}'
  Time (abs ≡):        59.303 s               [User: 216.758 s, System: 91.124 s]

Benchmark 7: pnpm exec eslint --concurrency 7 './**/*.{ts,tsx,graphql}'
  Time (abs ≡):        67.345 s               [User: 236.109 s, System: 113.020 s]

Benchmark 8: pnpm exec eslint --concurrency auto './**/*.{ts,tsx,graphql}'
  Time (abs ≡):        72.838 s               [User: 263.605 s, System: 133.785 s]

Summary
  pnpm exec eslint --concurrency 3 './**/*.{ts,tsx,graphql}' ran
    1.01 times faster than pnpm exec eslint --concurrency 4 './**/*.{ts,tsx,graphql}'
    1.06 times faster than pnpm exec eslint --concurrency 2 './**/*.{ts,tsx,graphql}'
    1.07 times faster than pnpm exec eslint --concurrency 5 './**/*.{ts,tsx,graphql}'
    1.21 times faster than pnpm exec eslint --concurrency 6 './**/*.{ts,tsx,graphql}'
    1.31 times faster than pnpm exec eslint --concurrency off './**/*.{ts,tsx,graphql}'
    1.38 times faster than pnpm exec eslint --concurrency 7 './**/*.{ts,tsx,graphql}'
    1.49 times faster than pnpm exec eslint --concurrency auto './**/*.{ts,tsx,graphql}'

どうやら私の端末では明示的に --concurrency 3 を指定することで off の場合よりも1.31倍、 auto の場合よりも1.49倍高速なようです。

concurrency auto を指定すると遅くなってしまうものの、適切な並列数を明示的に指定することで良いパフォーマンスを得ることができそうです。

キャッシュ有効時の性能比較

これまではESLintのキャッシュが無効な状態で検証をしてきました。次はESLintのキャッシュが有効な状態はどのような結果になるか試してみます。

hyperfine --warmup 1 --runs 3 -L concurrency off,3 "pnpm exec eslint --cache --concurrency {concurrency} './**/*.{ts,tsx,graphql}'"
Benchmark 1: pnpm exec eslint --cache --concurrency off './**/*.{ts,tsx,graphql}'
  Time (mean ± σ):      2.516 s ±  0.044 s    [User: 2.006 s, System: 0.578 s]
  Range (min … max):    2.470 s …  2.557 s    3 runs

Benchmark 2: pnpm exec eslint --cache --concurrency 3 './**/*.{ts,tsx,graphql}'
  Time (mean ± σ):      4.574 s ±  0.037 s    [User: 6.710 s, System: 2.061 s]
  Range (min … max):    4.540 s …  4.614 s    3 runs

Summary
  pnpm exec eslint --cache --concurrency off './**/*.{ts,tsx,graphql}' ran
    1.82 ± 0.04 times faster than pnpm exec eslint --cache --concurrency 3 './**/*.{ts,tsx,graphql}'

今回の検証ではすべてのファイルに対するキャッシュが有効な状態で試しているので極端な例ではありますが、この場合は直列で実行したほうが良い結果を得ることができました。

ここまでの整理のために、同じオプションに加えてキャッシュが無効な場合も含めて比較してみましょう。

hyperfine --warmup 1 --runs 3 -L cache --cache,--no-cache -L concurrency off,3 "pnpm exec eslint {cache} --concurrency {concurrency} './**/*.{ts,tsx,graphql}'"
Benchmark 1: pnpm exec eslint --cache --concurrency off './**/*.{ts,tsx,graphql}'
  Time (mean ± σ):      2.417 s ±  0.026 s    [User: 1.886 s, System: 0.534 s]
  Range (min … max):    2.391 s …  2.442 s    3 runs

Benchmark 2: pnpm exec eslint --cache --concurrency 3 './**/*.{ts,tsx,graphql}'
  Time (mean ± σ):      4.508 s ±  0.031 s    [User: 6.682 s, System: 2.051 s]
  Range (min … max):    4.473 s …  4.530 s    3 runs

Benchmark 3: pnpm exec eslint --no-cache --concurrency off './**/*.{ts,tsx,graphql}'
  Time (mean ± σ):     59.652 s ±  0.536 s    [User: 73.527 s, System: 10.505 s]
  Range (min … max):   59.221 s … 60.253 s    3 runs

Benchmark 4: pnpm exec eslint --no-cache --concurrency 3 './**/*.{ts,tsx,graphql}'
  Time (mean ± σ):     49.708 s ±  0.678 s    [User: 134.778 s, System: 34.085 s]
  Range (min … max):   48.982 s … 50.323 s    3 runs

Summary
  pnpm exec eslint --cache --concurrency off './**/*.{ts,tsx,graphql}' ran
    1.86 ± 0.02 times faster than pnpm exec eslint --cache --concurrency 3 './**/*.{ts,tsx,graphql}'
   20.56 ± 0.35 times faster than pnpm exec eslint --no-cache --concurrency 3 './**/*.{ts,tsx,graphql}'
   24.68 ± 0.34 times faster than pnpm exec eslint --no-cache --concurrency off './**/*.{ts,tsx,graphql}'

この結果から、今回の環境では

  • キャッシュが有効なら直列のほうが速い
  • キャッシュが無効なら並列のほうが速い

ということが言えそうです。

CI 環境への適用判断

このプロジェクトではCIでESLintを実行しているので、最後にCIでESLintの concurrency オプションを追加するべきか検討してみます。

前提として、このプロジェクトはTurborepoが導入されています。Turborepoは複数のパッケージからなるモノレポにおいて、ビルドやテストなどのタスクを効率的に実行するツールです。修正したファイルの範囲によりますが、このプロジェクトは最大で9つのパッケージに対してESLintが実行されることになります。Turborepoでそれぞれのパッケージへのコマンドを並列実行することになるため、 ESLintの concurrency オプションを使うと過剰な並列数になってしまい、良いパフォーマンスを得られない可能性がありそうです。

実際に試してみましょう。以下ではturboコマンドを経由してfmtコマンドを実行していますが、これは概ね eslint --cache --fix './**/*.{ts,tsx,graphql}' であると思っていただいて構いません。

hyperfine --warmup 1 --runs 3 -L concurrency off,2,3 "pnpm exec turbo run --cache local:w,remote:w fmt -- --concurrency {concurrency}"
Benchmark 1: pnpm exec turbo run --cache local:w,remote:w fmt -- --concurrency off
  Time (mean ± σ):      4.561 s ±  0.290 s    [User: 2.431 s, System: 0.870 s]
  Range (min … max):    4.376 s …  4.895 s    3 runs

Benchmark 2: pnpm exec turbo run --cache local:w,remote:w fmt -- --concurrency 2
  Time (mean ± σ):      6.291 s ±  0.075 s    [User: 5.689 s, System: 1.793 s]
  Range (min … max):    6.208 s …  6.353 s    3 runs

Benchmark 3: pnpm exec turbo run --cache local:w,remote:w fmt -- --concurrency 3
  Time (mean ± σ):      6.322 s ±  0.012 s    [User: 7.296 s, System: 2.287 s]
  Range (min … max):    6.315 s …  6.335 s    3 runs

Summary
  pnpm exec turbo run --cache local:w,remote:w fmt -- --concurrency off ran
    1.38 ± 0.09 times faster than pnpm exec turbo run --cache local:w,remote:w fmt -- --concurrency 2
    1.39 ± 0.09 times faster than pnpm exec turbo run --cache local:w,remote:w fmt -- --concurrency 3

やはり直列で実行したほうがより良いパフォーマンスを得ることができそうです。

これらの処理はGitHub ActionsでGitHubのPull-Requestに対して実行されるようにしていますが、ほとんどのPull-Requestでは一度に大量のファイルを修正することはありません。つまり多くのケースではESLintのキャッシュは大多数のファイルで有効な状態であると考えることができます。

またこのワークフローはUbuntuの4コアで実行するようにしていますが、コア数が少ないためさらなる並列化によってより良いパフォーマンスが得られる見込みは薄そうです。

実際にこのワークフローでベンチマークした結果が以下です。

hyperfine --warmup 1 --runs 3 -L concurrency off,2,3,4,auto "pnpm exec turbo run --cache local:w,remote:w fmt -- --cache-strategy content --concurrency {concurrency}"
Benchmark 1: pnpm exec turbo run --cache local:w,remote:w fmt -- --cache-strategy content --concurrency off
  Time (mean ± σ):     13.241 s ±  0.038 s    [User: 41.594 s, System: 5.830 s]
  Range (min … max):   13.204 s … 13.280 s    3 runs

Benchmark 2: pnpm exec turbo run --cache local:w,remote:w fmt -- --cache-strategy content --concurrency 2
  Time (mean ± σ):     31.019 s ±  0.075 s    [User: 104.797 s, System: 13.536 s]
  Range (min … max):   30.954 s … 31.102 s    3 runs

Benchmark 3: pnpm exec turbo run --cache local:w,remote:w fmt -- --cache-strategy content --concurrency 3
  Time (mean ± σ):     39.606 s ±  0.037 s    [User: 136.718 s, System: 17.224 s]
  Range (min … max):   39.568 s … 39.643 s    3 runs

Benchmark 4: pnpm exec turbo run --cache local:w,remote:w fmt -- --cache-strategy content --concurrency 4
  Time (mean ± σ):     48.413 s ±  0.007 s    [User: 169.105 s, System: 21.288 s]
  Range (min … max):   48.406 s … 48.420 s    3 runs

Benchmark 5: pnpm exec turbo run --cache local:w,remote:w fmt -- --cache-strategy content --concurrency auto
  Time (mean ± σ):     27.108 s ±  0.056 s    [User: 91.045 s, System: 11.620 s]
  Range (min … max):   27.063 s … 27.170 s    3 runs

Summary
  pnpm exec turbo run --cache local:w,remote:w fmt -- --cache-strategy content --concurrency off ran
    2.05 ± 0.01 times faster than pnpm exec turbo run --cache local:w,remote:w fmt -- --cache-strategy content --concurrency auto
    2.34 ± 0.01 times faster than pnpm exec turbo run --cache local:w,remote:w fmt -- --cache-strategy content --concurrency 2
    2.99 ± 0.01 times faster than pnpm exec turbo run --cache local:w,remote:w fmt -- --cache-strategy content --concurrency 3
    3.66 ± 0.01 times faster than pnpm exec turbo run --cache local:w,remote:w fmt -- --cache-strategy content --concurrency 4

どうやらこのプロジェクトのESLintには concurrency オプションは追加せず、Turborepoによる並列化のみに留めたほうがよさそうです。

まとめ

以上、 ESLintのMultithread Lintingの導入を検討する際に検証したことでした!

今回検証したプロジェクトのCIには concurrency オプションの追加は見送りましたが、一定の条件下であれば concurrency オプションを使うことでESLintの処理を高速化することができます。しかしその効果は、対象のファイル数、実行環境、キャッシュの有無などによって大きく異なります。導入する前に対象の環境や用途に合うか検証することをオススメします。

他にもチーム内ではESLintの一部のルールをoxlintに置き換えて高速化を試みる動きもあります。良い結果が出てより高速なCI環境が手に入るといいですね!

それでは!