[Elm] CI環境でのElmビルド・テストを高速化

# 追記(2018/08/20)

本記事で紹介したワークアラウンドを適用済みのElm platformおよびelm-test CLIをグローバルにインストール済みのdocker imageを作成しました。

- [ymtszw/elm_for_ci - Docker Hub](https://hub.docker.com/r/ymtszw/elm_for_ci/)

このイメージを使えば、CircleCIなどのdocker-based CIでなら、checkout後即`elm-test`でテストできます。
本記事の前提とは異なりグローバルインストールなので、`package.json`を用意していろいろ書く必要もなくなります。

Elm packageを開発・CIする場合にはかなり便利だと自負しておりますので使ってみてください。Alpine Linuxベースでイメージサイズも小さめです。

---

- Elm 0.18
- [node-test-runner][ntr] 0.18.12 (elm-testを実際に実行するためのnpm package)
- CircleCI 2.0
- 本記事の内容は[Elmの薄い本にもチョロっと書いてあった][usui]が、Travisでも有効で、高速化に意味があることを確認した。
    - Issueの方はまだcloseされておらず、根本対策もまだ決まってない様子

[ntr]: https://github.com/rtfeldman/node-test-runner
[eve]: https://github.com/stoeffel/elm-verify-examples
[usui]: https://giisyu.gitbooks.io/elm_usui_book/content/src/develop/ciSetting.html

# 前提

Elmはローカルインストールするものとする。つまりnode_modules/以下に`npm`で[elm platform][ep]を落としてくる。[^1]`yarn`を使っていても同様。ここではElm packageを開発するようなケースを考え、具体的なwebアプリをビルドするためのwebpack等の存在は前提としないが、存在していてもテスト部分に関しては基本的に同じのはず。

[ep]: https://www.npmjs.com/package/elm
[^1]: これ自体はrequirementってわけではなく、CI環境のユーザ用にElmをglobalインストールしても同じようなことはできる。スクリプトは適宜書き換えが必要。

package.jsonはこんな感じになる。

```json:package.json
{
  ...
  "devDependencies": {
    "elm": "^0.18.0",
    "elm-test": "^0.18.10"
  },
  "scripts": {
    "test": "elm-test",
  },
  ...
}
```

Elm特有のコンパイル生成物がたくさんあるので、それらはCI環境ではキャッシュしたい。elm-stuff/がメインだが、elm-testではtests/以下にもう一つのサブElmプロジェクトが作られるので、そちらのelm-stuff/もキャッシュする。elm-stuff/build-artifacts/に絞ってもいいのだが、この辺は若干調査不足なのでどうするのがベストなのかは未確認。

これらを考えるとCIの設定は以下のような感じ（Travisの例）：

```yaml:.travis.yml
sudo: false           # dockerコンテナ内で実行される。立ち上がりが速い
os:
  - linux             # TravisのOSXイメージはたいていおっそいので省いている
language: node_js
node_js:
  - "8"               # LTSをターゲットにしてるだけで、深い意味はない。6と8の2ジョブでもいい
cache:
  directories:
    - "node_modules"
    - "elm-stuff"
    - "tests/elm-stuff"
```

が、**この設定ではElmソースのコンパイルが妙に遅いことが知られている**。具体的にはelm-make部分(elm-compiler)がやたらと遅い。

# [Elm compilation is incredibly slow on CI platforms · Issue #1473 · elm-lang/elm-compiler][slow]
[slow]: https://github.com/elm-lang/elm-compiler/issues/1473#issuecomment-245704142

問題はこのIssueで報告されている。色々確認を挟んだあと、

> obmarg commented on 3 Sep 2016
It could be the case that travis & circle are reporting way more cores than are actually usable. I just checked /proc/cpuinfo in both environments, and they list 32 cores. The travis documentation specifically says you'll have 2 cores for your builds. I can't find any documentation for circle, but I'm pretty sure I don't have exclusive access to all 32 of those cores.

`/proc/cpuinfo`と同等の情報をソースにしてcpuコア数を判定していると、CI環境上では実際に利用可能な量より大幅に過大評価されるので、それが原因になるのではないか、という指摘がなされた。

[例えばTravisCI][travisSpec]では、

[travisSpec]: https://docs.travis-ci.com/user/reference/overview/

![travisSpecImg](https://qiita-image-store.s3.amazonaws.com/0/76980/4d4f8a6a-90ab-6198-b342-633e6a099bfe.png)

どの環境でも同時に使用可能なコア数は2までに制限されている（CircleCIでは同等の情報が見当たらなかった）

結局この指摘は以下の確認によりビンゴだった模様。Evanもconfirmしている。

> I've been doing a bit of work to try and confirm that this false number of CPUs is actually causing this problem. I had a look into how the getNumProcessors works, and discovered [libsysconfcpus][sysconfcpus], which lets you override the number of CPUs reported by sysconf (which getNumProcessors uses under the hood).

> I then built & ran that on my CI environment:

> ```
$ rm -R elm-stuff/build-artifacts/*
$ time sysconfcpus -n 1  elm-make
Success! Compiled 47 modules.

> real    0m2.215s
user    0m2.195s
sys     0m0.024s
$ rm -R elm-stuff/build-artifacts/*
$ time elm-make
Success! Compiled 47 modules.

> real    9m21.660s
user    15m38.880s
sys     2m47.578s
```
> So it does look like the CPU count detection is the problem. Seems like a command line option (or similar) might be a reasonable idea?

この問題に関するelm−compilerの修正（コア数を手動設定するオプションなど）はまだ導入されてはいない模様。[そもそも、使用可能なコアが実際にたくさんある場合（過大報告されているというわけではない場合）もパフォーマンスが出ていない][fund]という報告もあるので、根は深い可能性がある。

[fund]: https://github.com/elm-lang/elm-compiler/issues/1473#issuecomment-337823146

現状では、上記の確認でも使われている**[sysconfcpus][sysconfcpus]を利用して、elm-compilerが使用するコア数が少なくなる（2コア以下になる）ようだまくらかしてやる**というワークアラウンドが広く使われているので、Elm開発者は少なくとも0.18の間はこの対策をしておくと**クラウドCI環境でのビルド/テストが数倍以上高速化する**。

[sysconfcpus]: http://www.kev.pulo.com.au/libsysconfcpus/

# 高速化設定のサンプル(Travis & Circle CI)

以下のGistに、本記事の前提で上記Issueで紹介されているワークアラウンドを適用するスクリプトをまとめた。（筆者のElmレポジトリで実際に使用中）

https://gist.github.com/ymtszw/bfef21f2297ec08041a45953538f417f

本質的な部分はreplace_elm_make.shで、**elm-makeをsysconfcpusを噛ませて呼ぶようにする**ことで、elm-makeを経由するコンパイル全てにワークアラウンドを適用する。

解説がてら現在のバージョンをダンプする。

## ensure_libsysconfcpus.sh

```sh:ensure_libsysconfcpus.sh
#!/usr/bin/env bash
set -eu
cwd=$(pwd)
if [ ! -d sysconfcpus/bin ]; then
  git clone https://github.com/obmarg/libsysconfcpus.git
  pushd libsysconfcpus
    ./configure --prefix="${cwd}/sysconfcpus"
    make && make install
  popd
fi
```

- `./configure`の`--prefix`は絶対パスを渡す必要があり、TravisでもCircleでも確実にCWDを絶対パスで手に入れる苦肉の策として`pwd`を使った。もっといい案があったらこっそり教えて下さい。
    - はじめ`$TRAVIS_BUILD_DIR`および`$CIRCLE_BUILD_DIRECTORY`を引数経由で与えるやり方でやろうとしていたが、CircleCIで`working_directory`が`~/repo`のようになっていると、`configure`がtildeを展開できず、エラーになる。どっかで確実に展開されるよう工夫すればいけるかも。

## replace_elm_make.sh

```sh:replace_elm_make.sh
#!/usr/bin/env bash
# replace normal elm-make with sysconfcpus-prefixed elm-make
# epic build time improvement - see https://github.com/elm-lang/elm-compiler/issues/1473#issuecomment-245704142
set -euo pipefail
ncore=${1:-1}
if ! grep "sysconfcpus -n ${ncore}" "$(npm bin)/elm-make"; then
  if [ ! -f "$(npm bin)/elm-make-old" ]; then
    mv "$(npm bin)/elm-make" "$(npm bin)/elm-make-old"
  fi
  cat << EOF > "$(npm bin)/elm-make"
#!/usr/bin/env bash
set -eu
echo "Running elm-make with sysconfcpus -n ${ncore}"
$(pwd)/sysconfcpus/bin/sysconfcpus -n ${ncore} "$(npm bin)/elm-make-old" "\$@"
EOF
  chmod +x "$(npm bin)/elm-make"
fi
```

- `npm bin`は`node_modules/.bin/`への絶対パスを返す。
- node_modules/はキャッシュされるので、このスクリプトはべき等である必要がある。`grep `で現在のnode_modules/.bin/elm-makeの中身を調べたり、elm-make-oldの存在をチェックしたりしているのはそのため。

## .travis.yml.sample

```yaml:.travis.yml.sample
sudo: false
os:
  - linux
language: node_js
node_js:
  - "6"
  - "8"
cache:
  directories:
    - "sysconfcpus"
    - "node_modules"
    - "elm-stuff"
    - "tests/elm-stuff"
before_install:
  - ./scripts/ci/ensure_libsysconfcpus.sh
before_script:
  - ./scripts/ci/replace_elm_make.sh 2
```

- `install`と`script`を明示的に`npm install`と`npm test`のように指定しておいてもいいが端折っている。
- nodeのLTSに対してビルドしているのは筆者の主義。
- Travisでは2コア使えるはず。

## .circleci/config.yml.sample

```yaml+jinja:.circleci/config.yml.sample
# .circleci/config.yml for CircleCI 2.0
version: 2
jobs:
  node6:
    docker:
      - image: circleci/node:6
    working_directory: ~/repo
    steps:
      - checkout
      - restore_cache:
          keys:
          - v6-dependencies-{{ checksum "package.json" }}-{{ checksum "elm-package.json" }}
          - v6-dependencies-
      - run: ./scripts/ci/ensure_libsysconfcpus.sh
      - run: npm install
      - run: ./scripts/ci/replace_elm_make.sh 1
      - run: npm test
      - save_cache:
          paths:
            - sysconfcpus
            - node_modules
            - elm-stuff
            - tests/elm-stuff
          key: v6-dependencies-{{ checksum "package.json" }}-{{ checksum "elm-package.json" }}
  node8:
    docker:
      - image: circleci/node:8
    working_directory: ~/repo
    steps:
      - checkout
      - restore_cache:
          keys:
          - v8-dependencies-{{ checksum "package.json" }}-{{ checksum "elm-package.json" }}
          - v8-dependencies-
      - run: ./scripts/ci/ensure_libsysconfcpus.sh
      - run: npm install
      - run: ./scripts/ci/replace_elm_make.sh 1
      - run: npm test
      - save_cache:
          paths:
            - sysconfcpus
            - node_modules
            - elm-stuff
            - tests/elm-stuff
          key: v8-dependencies-{{ checksum "package.json" }}-{{ checksum "elm-package.json" }}

workflows:
  version: 2
  build:
    jobs:
      - node6
      - node8
```

- `npm test`の中でtests/ディレクトリ以下のコンパイルも走るので、`save_cache`は`steps`の最後に持っていった。
- CircleCIでは何コア使えるのかがわからないので、とりあえず1。
- cache keyにchecksumを使える便利な機能があるので、"package.json"と"elm-package.json"双方をソースに使用した。
    - この2つに含まれる情報を前提に"tests/elm-package.json"が生成されることを考えると、"tests/elm-package.json"のchecksumは不要。

# 結果

最近書いた[ymtszw/elm-xml-decode][sample]のビルドで試した。doctestのコード生成もしているので、elm-testのみの場合と比べると平均して遅いが、かなり効果は出ている。

[sample]: https://github.com/ymtszw/elm-xml-decode

## TravisCI

適用前（6分前後）：

![travisBefore](https://qiita-image-store.s3.amazonaws.com/0/76980/1cfd3a89-9ca3-fa6a-3f29-d5135331b964.png)

適用後（2分前後）：

![travisAfter](https://qiita-image-store.s3.amazonaws.com/0/76980/7b94bfb1-c3b2-58d4-ad32-bc1a844fe604.png)

3倍くらい速くなった。が、Travisはそもそもコンテナの立ち上がりが結構遅い。立ち上がってからはまあ速い。結構分散が大きい。

## CircleCI

適用前（2分前後）：

![circleBefore](https://qiita-image-store.s3.amazonaws.com/0/76980/b24d81af-a095-859e-b633-3e52af869518.png)

適用後（40秒前後）：

![circleAfter](https://qiita-image-store.s3.amazonaws.com/0/76980/14bfd3ec-113c-282b-cd77-9456b2da0aec.png)

2〜3倍速くなった。CircleCI 2.0のほうがコンテナの立ち上がりは圧倒的に速い。立ち上がってからのパフォーマンスはちょっと読めないところがあるが、平均してTravisよりだいぶ速い。

いずれにせよこのワークアラウンドによってElmのビルド・テストは相当速くなるので、0.18の間はやっておきましょう。