🙆‍♀️

Monorepo下におけるJest実行方法のちょっとした改善

2021/10/02に公開1

Monorepo化されたリポジトリのJestによるテストが遅い問題があり、原因調査とその対応を行なったので、それについての知見をまとめる。

環境

  • yarn + lerna によって複数 node.js プロジェクトを monorepo 管理している。
├── packages
│   ├── package-a
│   ├── package-b
│   ├── package-c
│   └── ...
lerna.json
{
  ...
  "npmClient": "yarn",
  "packages": ["packages/*"],
  "useWorkspaces": true,
  ...
}
  • 各プロジェクトにはJestによるテストを定義。
各プロジェクトのpackage.json
"scripts": {
  "test": "jest"
}

テスト実行方法(改善前)

  • ローカル環境
    monorepo のルートディレクトリにて lerna run test --parallel を実行し、各プロジェクトのテストを並列実行していた。
ルートのpackage.json
"scripts": {
  "test: "lerna run test --parallel"
}
  • CircleCI環境
    こちらも lerna run を使って実行していたのは同様。ただし直列に実行。
config.yml
jobs:
  test:
    resource_class: medium+
    docker:
      - image: circleci/node:14.17.5
    steps:
      - run: npx lerna run test -- -w 1

先に結論

  • テスト実行コマンドについて
    • Jestの並列実行はCPU負荷が爆増するのでやめる。
    • lerna runコマンドは実行が遅いので使わない。
  • Jestの実行オプション--maxWorkersについて
    • ローカル環境下では--maxWorkers=49%を指定する。
    • CircleCI環境下では--maxWorkers=<CPU数-1>を指定する。

テスト実行コマンドについて

Why:Jest並列実行はCPU負荷が爆増するのでやめる。

  • Jestはデフォルトでは実行時に、使用可能なCPU数のうちcli用に1つスレッドを使用し、残りの数だけ test workerプロセスを生成する。
    • 今使用している MacBook Pro は 2.6 GHz 6コアIntel Core i7なので、Jest1つ実行につき
    • 6 - 1 = 5 ではなく
    • 6 x 2 - 1 = 11個の子プロセスを生成する。
      • なぜか?
        • Intel CPU は Hyper Threading Technology 機能により物理CPU1つ当たり論理CPU2つと認識されるため
  • そのためlerna run --parallelで実行すると、子プロセスが大量に起動してCPU負荷が爆増する。
  • 以下は同時に6つJestを実行した時のプロセスの状況(キャプチャしきれなかったが、論理的には6x11=66個分の子プロセス(processChild)が起動する可能性があった)
  • lerna のドキュメントにも記載あるとおりspawning dozens of subprocessesが発生しており、Jestと非常に相性が悪かった。

Note: It is advised to constrain the scope of this command when using the --parallel flag, as spawning dozens of subprocesses may be harmful to your shell's equanimity (or maximum file descriptor limit, for example). YMMV

Why:lerna runコマンドは実行が遅いので使わない。

$ lerna run test
lerna notice cli v4.0.0
lerna info Executing command in 6 packages: "yarn run test -w=50%"
lerna info run Ran npm script 'test' in '@example/package-a' in 4.2s:
$ jest
lerna info run Ran npm script 'test' in '@example/package-b' in 6.2s:
$ jest
lerna info run Ran npm script 'test' in '@example/package-c' in 5.1s:
$ jest
lerna info run Ran npm script 'test' in '@example/package-d' in 13.3s:
$ jest
lerna info run Ran npm script 'test' in '@example/package-e' in 46.6s:
$ cross-env NODE_ICU_DATA='./../../node_modules/full-icu' jest
lerna info run Ran npm script 'test' in '@example/package-f' in 87.8s:
$ jest
lerna success run Ran npm script 'test' in 6 packages in 99.2s:
  • npm-run-all の run-sを使って直列実行した場合
[test:package-a] Time:        1.833 s, estimated 2 s
[test:package-b] Time:        5.014 s
[test:package-c] Time:        2.714 s, estimated 4 s
[test:package-d] Time:        6.902 s, estimated 12 s
[test:paclage-e] Time:        22.27 s, estimated 42 s
[test:package-f] Time:        63.431 s, estimated 83 s
  • 何度か計測したが、いずれも lerna run を使わないほうが実行時間が短いことがわかった。
  • よって、上記 issue は確からしいとの結論に至った。

Jestの実行オプション--maxWorkersについて

Why:ローカル環境下では--maxWorkers=49%を指定する。

  • 先に説明した通り、Jestは、デフォルトでは使用可能なCPU数-1の数だけ子プロセスを生成してテストを実行する挙動になっている
  • --maxWorkersオプションにより生成する子プロセスを指定することができる
  • 普段開発するマシンには大体 Intel CPU が使われているとの前提に立つと、Jestが起動できる子プロセスの最大数は論理CPU数(物理CPU数x2)-1となる
  • 自分の MacBook Pro は 2.6 GHz 6コアIntel Core i7 なので、以下のように最大で11個の子プロセスが起動する。
    • しかしながら、htopでCPU使用率を見てわかったが、Jest実行中、論理CPU全てが満遍なく使用されているけでなく、結局物理CPU数しか使用されていなさそうであることが判明した。
  • このため、実態としては、キャパを超える子プロセスが生成されており、実行効率が落ちている可能性があると推測し、hyperfine によるベンチマークをとって検証してみることにした。
  • 結果は以下の通りで、--maxWorkers オプションに物理CPU数-1をセットしたときが、最も実行時間が短いことがわかった
> hyperfine --min-runs 3 --warmup 1 \
> 'yarn test' \ # オプションなし=論理CPU数-1=11
> 'yarn test --maxWorkers=5' # 物理CPU数-1
> 'yarn test --maxWorkers=6' # 物理CPU数に同じ
Benchmark #1: yarn test
  Time (mean ± σ):     164.565 s ± 23.080 s    [User: 617.144 s, System: 134.560 s]
  Range (min … max):   136.118 s … 207.320 s    10 runs
 
Benchmark #2: yarn test --maxWorkers=5
  Time (mean ± σ):     115.166 s ± 11.702 s    [User: 464.872 s, System: 97.122 s]
  Range (min … max):   101.766 s … 136.344 s    10 runs
 
Benchmark #3: yarn test --maxWorkers=6
  Time (mean ± σ):     127.485 s ± 11.874 s    [User: 495.945 s, System: 106.117 s]
  Range (min … max):   111.117 s … 149.736 s    10 runs
 
Summary
  'yarn test --maxWorkers=5' ran
    1.11 ± 0.15 times faster than 'yarn test --maxWorkers=6'
    1.43 ± 0.25 times faster than 'yarn test'
  • 以上より、Jestは--maxWorkers=<物理CPU数-1>を指定して実行するのがベストとの結果となった
    • ただし使用マシンによって物理CPU数は異なるため、数値指定は嬉しくない。
    • 数値の代わりにパーセンテージ指定が行えるので49%を指定するとよい。
      • 物理CPU6の場合(論理CPU12)5が最適値であり、物理CPU8(論理CPU16)であれば7が最適値。
      • 50%ではちょうど物理CPU数に同じになってしまうが、最適値にするためにはさらに-1する必要があるため、49%をセットしてあげればよい(結果として物理CPU数-1になるようなパーセンテージ指定であれば48%でも47%...でもなんでもよい)
      • Jest の worker 数を算出するロジック を見てみるとよくわかる

Why:CircleCI環境下では--maxWorkers=<CPU数-1>を指定する。

  • CircleCI では Docker Executor の medium+ を使っている
    • resource_class によってCPU数はことなり、medium+ だと vCPU=3
    • おそらくだが、Intel CPU のような Hyper Threading の機能は持ち合わせていないので、論理CPUがいくつなどと考える必要はない。
  • つまり、JestをDocker Executor上で実行する場合、最大で3-1=2個まで子プロセスを起動することができるはず
  • よって単純に--maxWorkers=<CPU数-1>を指定すれば良い。
  • ちなみに、resouce_class を変更できるのはパフォーマンスプランに加入している場合のみなので、通常はmediumを使う事になる。
    • mediumはvCPU=2 なので2-1=1個の子プロセスを起動することができ、--maxWorkers=1となる。

テスト実行方法(改善後)

以上の調査を踏まえて、以下のように変更した。

  • ローカル環境
    lerna runの代わりにnpm-run-allのrun-sを使い、かつ--maxWorkers=49%を指定
ルートのpackage.json
"scripts": {
  "test": "run-s -l test:*",
  "test:package-a": "yarn workspace @example/package-a test --maxWorkers=49%",
  "test:package-b": "yarn workspace @example/package-b test --maxWorkers=49%",
  "test:package-c": "yarn workspace @example/package-c test --maxWorkers=49%",
  "test:package-d": "yarn workspace @example/package-d test --maxWorkers=49%",
  "test:package-e": "yarn workspace @example/package-e test --maxWorkers=49%",
  "test:package-f": "yarn workspace @example/package-f test --maxWorkers=49%"
}

これによりテスト実行中にマシンが重くなる事象は改善された

  • CircleCI環境
    こちらもlerna runを使うのをやめた。--maxWorkers=2を指定
config.yml
jobs:
  test:
    resource_class: medium+
    docker:
      - image: circleci/node:14.17.5
    steps:
      - run: yarn workspace @example/package-a test --maxWorkers=2
      - run: yarn workspace @example/package-b test --maxWorkers=2
      - run: yarn workspace @example/package-c test --maxWorkers=2
      - run: yarn workspace @example/package-d test --maxWorkers=2
      - run: yarn workspace @example/package-e test --maxWorkers=2
      - run: yarn workspace @example/package-f test --maxWorkers=2

これにより、テスト実行時間が2分半ほど短縮した。

まとめ

  • lerna runコマンドは遅いというのは確からしいので使わないようにする。
  • Jestの並列実行はWorker数が爆増する恐れがあるので、基本直列に実行するようにする。
  • 実行環境のCPU数とJestのWorker数の関係性を理解した上で、--maxWorkersオプションに適切な値をセットする必要がある。

所感

今回は、そこまでドラスティックな改善にはならなかったが、使用するコマンドと、Jestのオプションを調整するだけでもそれなりに実行時間を短縮することができるとわかったのは良かった。

Jestの実行が遅い問題に対して、よく--runInBandオプションをつけると速くなる、といった事例が出てくるが、どうしてそうなるか疑問に思っていたが、今回の検証でCPU数とJestのWorker数の関係性を理解したことによって、どうしてそのようなことになるのか納得がいった。Jestについて1つ詳しくなった。

また、もう1つの収穫として、闇雲に対処するのではなく、htopでモニタリングしたり、hyperfineでベンチマークを取ったりと、定量的に取り組むことができたのは良かった。

参考

Discussion

kageyamakageyama

非常に分かりやすく参考にさせていただきました。
ちなみに、CircleCI 側でも maxWorkers=49% と指定しても同様の効果が得られると思ったのですが、物理CPU数が明確に分かっている理由以外で、明示的に<CPU数-1>を指定する理由はなにかありますでしょうか?
例えばCircleCI側でCPU数が変動した時、<CPU数-1>指定だとymlファイルを編集する必要があるけれど、%指定だと変更がいらないので何かと%の方が楽かな?と思っておりました。