いきなり結論
「お金を出して高いマシンを買えばいい」とか言わないで...
まずはビルドの並列化とccacheをやろう。
はじめに
BuildrootやYoctoのような規模のビルドを繰り返していると、少しでもビルドを早くしたいという欲望が出てくる。効果があるもの・大したことないもの含め、これまでいろいろな小細工をやってきた経験を雑記としてまとめておく。
大規模ビルドといっても、LinuxやらglibcやらgccやらllvmやらChromiumやらの単一プロジェクトはあまり想定していない。BuildrootやYoctoやAutomated Linux From Scratchといったたぐいの複数モジュールを順にコンパイルするディストリビューションのようなものを想定している。
ハードウェア
CPU
主にコンパイルをする場合はCPUがほぼ全てとも言える。並列化できない箇所がネックにならないようある程度のシングルスレッド性能は必要だが(アムダールの法則)、最近はコア数で殴るのがもっぱらの流れになっている。コア数と発熱とワットパフォーマンスと冷却性能と予算を考えて選ぼう。
RAM
スワップするほど少ないのは論外として、SSDが一般的になった現在ではディスクキャッシュ目的で大量に積むのはコスパがイマイチになってしまった。予算が限られるならCPUを優先したほうが良いと思う。RAM帯域はコンパイル目的ではこのへん見る限りでもそこまで気にしなくて良いかと思う。
時々ハマる人がいる点として、CPUスレッド数(makeの並列job数)が増えると比例して使用するRAMサイズが増える点が挙げられる。例えば4C4TなCPUから10C20TなCPUにしたのにRAMサイズ倍程度だとスワップしたりスワップでも足りずにメモリ不足でコケたりなんてことになってしまう。
ディスク
巨大なイメージ書き出しを繰り返したりしない限り、NVMeなSSDにするメリットは薄くSATAのSSDで十分に思う。HDDは、CMR時代ならそれほどネックにならないベンチマークを取ったことがあるけど、ステルススペックでSMRをぶっこむ今どきのHDDはあえて選ぶ必要はないかな。過去には4Kセクタ問題もありと、HDDはもはや容量足りたいときの保管庫程度に留めておいたほうが無難に感じる。
- 101.1% (SATA SSD CT480BX500SSD1 warm)
- 101.2% (SATA HDD SMR ST3000DM007 warm)
- 100.7% (NVME SSD WD SN520 cold)
- 100.7% (SATA SSD CT480BX500SSD1 cold)
- 104.9% (SATA HDD SMR ST3000DM007 cold)
16GiBのRAMがあればディスクキャッシュに載るのでSMR HDDでもそれほど遅くない模様(warm)、ブート直後のディスクキャッシュに入ってない状態だとHDDはさすがに少し遅い(cold)。ちなみにST3000DM007はデータシートにSMRと明記されてた。
組み込み用途だと巨大な書き込み用イメージファイルを作ることがよくある。中身がスカスカなのにddなどでゼロ埋めデータを作るとディスクがムダなだけにとどまらずディスクキャッシュでRAMもムダに使われてしまう。sparse file(hole)をうまく使ってムダを減らしたい箇所になる。
GPU
大昔はRAM帯域が食われるのを嫌って外付けをさしていたこともあるけど、今はほぼ誤差じゃないかな。測ってないから知らんけど。
ビルド環境
/tmp
Ubuntuではデフォルトで /tmp は / のパーティションになっている。/tmp に消えちゃ困るデータを置く人はいないだろう、ということで /tmp を tmpfsにすると少し性能が良くなる。
- 100.1% (/tmpをtmpfsに)
...はずだと思って計ったけど誤差だった...
HyperThreading
並列化を十分に生かせるならもちろんONにすればよいが、シングルスレッド性能が足を引っ張る場合はあえてOFFにするのもありになる。i5-7500はHTが使えないため全然別のi7-4790Kな環境(4C8T)での相対テスト結果になるがこんなかんじ。1.3%だけ性能が上がる。
- 100% (HT on make -j4)
- 98.7% (HT OFF make -j4)
- 85.1% (HT on make -j8)
make linux fast again
Make Linux fast againにアクセスするとこんなのが出てくる。
noibrs noibpb nopti nospectre_v2 nospectre_v1 l1tf=off nospec_store_bypass_disable no_stf_barrier mds=off tsx=on tsx_async_abort=off mitigations=off
主に脆弱性への対策としてLinux kernelに入った各種変更を無効化するcmdlineオプションになる。他人にログインさせたりWebサーバみたいなの動かしたりWebブラウザ動かしたりするマシンだとダメだけど、そうでないならこれを入れちゃうという手もある。Ubuntuだと/etc/default/grubのGRUB_CMDLINE_LINUX_DEFAULTに書いておけばよい。
- 97.8% (linux cmdlineをmake linux fast againのものへ)
terminal
Ubuntu-20.04だととりあえずgnome-terminalを使ってる人が多いと思うが、terminalの文字の描画って結構パフォーマンスを持っていかれる。代わりにXtermを使うというのがよく選ばれているが、もっと手っ取り早くなら別のマシンからsshで入って作業するのでもよい。
- 100.1% (Xterm)
- 98.67% ( &> /dev/nullでコンソール出力させない)
- 98.61% (別マシンからsshでログインして作業)
...xtermはあまり変わらなかった。昔比較したときはそこそこ差があった記憶があるのになぁ...
ビルドツール
git
ビルドプロセスの一環としてソースコードサーバからgitで取り寄せるなんてことがよくある。git cloneはデフォルトではリモートブランチの履歴もすべて持ってくるためサイズが増える。特定ブランチでしか作業しないのならsingle branch(-bと--single-branch)で、git作業をしないならshallow(--depth=1)でやるとよい。あとから作業が必要になってもgit fetchなどで対処できる(ハズ)
pathを変えて何度も新規にビルドする場合、毎回サーバからcloneし直すと無駄になるので、ローカルにmirrorを作るとよい。あらかじめgit clone --mirrorでベースになる場所を作っておき、git clone --referenceでローカルのmirrorを参照したレポジトリを作る流れになるかと思う。ビルドするだけならローカルのmirrorからgit cloneするのでもいいかもしれない。git cloneする前にローカルのmirrorレポジトリをgit fetchするのを忘れずに。
ビルドするだけでなく開発作業などでcommitやpushもするとなると他にもgitを使ったいろいろなやり方があると思う。いろいろありすぎて逆にどのやり方が無難かがわからないけど...
repo
gitをまとめて操作するrepoでも似たような考え方を適用できる。repo init --mirror、repo init --reference=/path/to/mirror、repo init --depth=1、repo sync -cあたり。
gzip
ビルドプロセスの一貫でgzipやxzを走らせることも時々ある。圧縮処理を並列にしてくれるpigz(gzipの代わり)やpixz(xzの代わり)が使えるレベルになってくれたので積極的に使おう。ただ、makeのjobserverとの連携方法がないので、CPUコアが遊んでるとわかっているタイミングでしか使いづらいかもしれない。なお解凍についても、完全並列ではないものの付属処理を別スレッド化してくれるので、多少の恩恵を受けられる。
毎回コマンドを打つのでもいいけど、~/binにsymlink入れておくとtarのzやJオプションでも自動で使ってくれるので便利。
zstdの並列化pzstdもあるらしいけど使ったことないのでここでは紹介だけ。pbzip2?bzip2なんてもう使わないよね...
コンパイラ
gccのオプションを変更することでコンパイル時間を短縮することもできる。が、生成されるコードを変えると再現性が失われバグの解析作業に響くので、オプションを変えるにしてもビルド種ごとに変更するようなのは避け、常に同じオプションを使うようにしておきたい。
-pipeあたりはメジャーだけど、コンパイルオプションを変更するより/tmpをtmpfsにするほうが簡単なので、私はあまり気にしてないな。
コンパイルが遅いことがわかっている箇所限定で-gを外したり-O2から-O1にしたりあたりがよくある例か。ただ、なんとかしたいと思うほどネックになってるのなら、そもそものコードの書き方や分割を見直したほうが良い。使いもしない無駄なコードを延々と吐いてコンパイルさせるジェネレータなんかには私も時々殺意を覚える。
リンカのLTOについても、時間がネックになるほどならば、不要とわかってるコードや関数を適切に除去するのが筋ではないかと思う。なんだかんだで不必要な処理をさせないようにする責任が開発者にはあると考えている。
GNU goldで処理が改善したとか、lldでリンク速度が向上したとか、リンク速度にこだわって開発中のmoldとか、知ってはいるんだけどあまり比較したことないからここでは紹介だけで。
ccache
本命。というよりこれとmake並列化がすべて?.ccacheを暖める必要あったりディスク容量食ったりするデメリットはあるが、同じようなビルドを複数回行うことがわかっているならば圧倒的に恩恵を受ける。
~/bin で ln -s /usr/bin/ccache gcc などとしてsymlinkで使うと、既存のビルドツリーに手を入れる必要がなくて便利。gccやllvm-clangベースならばクロスコンパイラでもほぼ問題なく使える。ccache -sの結果を見ながらキャッシュサイズが溢れてないかどうかは気にしておこう。
pathを変えて何度もビルドする場合、古くはCCACHE_BASEDIRを使う方法だったが、絶対パスに展開してビルドする箇所などでマクロ展開やデバッグ情報が変になることがあり、正直いまいちだった。最近は、mount --bindやdockerなどを使うことで、ビルド主体から見て常に同じpathになる状態でビルドする方法が使えるので、こちらのほうが良いと思う。
ccacheはその特性上、.ccacheをどのストレージにおいているか(+ディスクキャッシュ)の影響を大きく受ける。ので下記はその差も載せておく。
- 136.8% (ccache cold nvme) first build
- 138.6% (ccache cold hdd) first build
- 9.34% (ccache warm nvme) second build
- 9.63% (ccache warm hdd) second build
- 9.92% (ccache warm disk cold nvme) build after reboot
- 19.1% (ccache warm disk cold hdd) build after reboot
ccacheにキャッシュが溜まってない初回が40%近く長くなり、キャッシュが溜まった二回目が1/10で終わるのはいいとして、キャッシュが溜まっている状態のリブート直後ビルドはSSDに比べてHDDがかなり長くなっている。ccacheでビルド時間が短くなった反面、HDDアクセスの遅さは相変わらずなので、相対的にその差が際立っている。
...なんか適当にccacheサイトをななめ読みしてたらリモートをキャッシュに使えるとか書かれてるけどマジ?(https://ccache.dev/より)
Supports secondary storage on HTTP
distcc
十分に並列ビルド(make -jNなど)ができる環境にて、「横にあるCPUが遊んでるパソコンもコンパイルに使えればいいのに」と思うことはよくあるはず。そういうのを分散コンパイル(Ditributed Compile)といい、その手の歴史が長くて知名度が高いメジャーなやつとしてdistccがある。
ただdistccにも弱点があって、マスターとスレーブでgccバージョンが違ったら吐かれるコードが変わっちゃうよとか、マスターでスレイブのIPアドレス一覧を管理しないといけないよとか、マスターから見てどのスレーブが暇なのかわからんとか、などなど。
...というのが私みたいなおじさんの認識だったんだけど、distccのサイトを読むと、そのへんの弱点を補うようなツールも作られて来ていて、改めて評価してみてもいいのかもと思った。
似たようなコンセプトで弱点克服を狙ったIcecreamを5年ほど前に私が評価してみたときのメモによると、同じような構成のマシンを3台使っておよそ2倍の効果、だったらしい。整備・メンテする手間を考える、よほどヘビーにビルド繰り返さないと元を取れないかなという印象だった。
make
jobserver
make(GNU make)はjobserver機能を持っていて、make -jN (Nは並列数を入れる)とすることでtargetを作る処理をjobにして最大N個同時に並列で実行することをやってくれる。ただ、基本的な注意点として下記を守る必要がある。
- 子プロセスとしてさらにmakeを実行したい場合はMakefileに$(MAKE)と書く (ベタに"make"などと書くとそこでjobserver連携が途切れる)
- 子プロセスとして実行するとき $(MAKE) -jN などと並列数を再び指定はしないこと (書くとそこでjobserver連携が途切れるだけでなく、子プロセスが別のjobserver管理をしてしまう((N+N)並列になってしまう)
- targetの依存を正しく書く必要がある (書かないとタイミングに依存した並列実行で連携取れずエラーになったりファイルを壊したりしてしまう
- jobserver連携せずにマルチスレッド実行するコマンドが走るとNを超えた数のスレッドが動くことになりCPUの奪い合いで返って遅くなる
これらの話はある程度わかった上で使ってる人が多いとは思うけど、実際にはこれらを厳格に運用するのはかなり難しく、タイミング依存のビルドエラーやら、逆に過度な依存や並列禁止指定によってCPUが遊ぶ時間が長くなるなど、運用面で悩むことが多い。BuildrootやYoctoなどで既存のレシピに手を加えた結果エラーにドハマリする話を皆もよく聞くんじゃないかと思う。
とはいえ、CPUコア数がどんどん増えている昨今、その恩恵を受けるためにはmakeの並列化は避けて通れない。
-jN のNなしはやめよう
Nなしの make -j と実行することもできるが、この場合上限なしにjobを作って並列実行するため、たいていはメモリ不足やプロセス数上限でエラーになる。必ずマシンのCPUのスレッド数程度を入れるようにしておこう。
古いページに「Nはマシンのスレッド数の倍を指定しよう」みたいなことを主張したものが見つかる。が、それらはCPUが2や4程度の大昔の話で、今どきの10C20T環境で-j40しても全く恩恵はない。ベンチマークを取ってみるとわかるけど、+1するとほんの少し良くなることもある程度なので、欲張りすぎないようにしよう。
SUBDIRS
autotool(Makefile.am)のSUBDIRSに複数のサブディレクトリを書いても、(ディレクトリの中は並列に処理するが)、ディレクトリを複数同時処理するようなMakefileにはならない。ここを気にする場合は、SUBDIRSに頼らない書き方にするしかない。参考 → Makefile.am process SUBDIRS in parallel
.NOTPARALLEL
targetを.NOTPARALLELに入れると、.NOTPARALLELに入っているtarget同士は並列実行しなくなる。並列実行してほしいtargetと並列実行してほしくないtargetが複雑に混在するMakefileの場合に役に立つ、かもしれない。(GNU make manual)
ninja
makeははっきりいて遅い。子プロセス作りまくったりMakefileをパースするのに暗黙のルールや過去の互換やら気にしたり、.mkや.d(gcc -MDで生成)のファイル複数をincludeしてる環境なんかで結構悲惨なことになり、makeそのものにCPUを取られてしまう。という状況を悲観して、make以外のビルト向けツールがこれまでにいくつか作られてきた。
特にninjaはmakeの遅さを解決できるとして世の中でちょこちょこ使われてきている。Chromium のようにクソ多い数のファイルをクソ多い依存を気にしながらコンパイルする用途に向いている。
ただ、makeのjobserverと連携することができないので、ninjaでビルドする部分は上位ディレクトリからmakeがシングルジョブで呼び出し、シングルジョブで呼ばれたninjaがCPUスレッド数分だけ並列で配下をビルドする、みたいなやり方にする必要がある。
ninjaのjobserverサポートは議論されはいるんだけど、stableにするのが難しいのか、まだサポートされていない。Kitware forkのninjaなら使えるらしいが、どうなんだろ。事情に詳しくないので誰か教えて。
ninjaに限らず、そもそもGNU makeのjobserverに対応したツールはほとんど存在しない。大規模ビルドする立場からはpigzなども対応していると嬉しいだろうけど、まぁムリだろうな。かんたんに並列数を連携できる仕組みを備えた全く新しいビルドツールが広まるとかでない限り状況は変わらないんじゃないかと思う。
make parallel dependencyの罠
外部依存性が大きく変わることがないモジュールはともかく、商品に近いソフトだと、変更によりライブラリやヘッダの依存が頻繁に変わり、でもその都度ビルドの依存を適切に修正する追随作業が追いつかず、結果タイミングや依存によるビルドエラーが起こりがちになる。
個人的な意見になるが、これはもう諦めたほうが早いんじゃないかと考えている。つまり、依存をメンテする工数と並列化による恩恵とを天秤にかけ、完全な並列化は諦めたほうがよいと思う。複数のモジュールをまたいだ並列ビルドを諦め、モジュールは1つずつ順番にシーケンシャルにビルドし、その代わり1つ1つのモジュールの中は並列にビルドするようにする、という形までにとどめておく。モジュールの中でjobが減ってきたビルドの最後の方でCPUが遊ぶことになるが、それはもう諦めると。依存のメンテの工数ももちろんだが、不意にビルドがコケてCI/CDパイプがストップしてしまう状況を避ける意味もある。
効果を比較するためのテスト環境
Linux kernelをビルドするのにかかった時間をbash timeで計り、real timeが何%になったかで比較して記載している。
- CPU Core-i5 7500 (4core 4thread)
- RAM 16GiB (DDR4-2400の適当なやつ)
- SSD 128GB (NVMe WD SN520)
- Ubuntu 20.04.2 LTSベース
- (Linux-5.11.0-41-generic)
- (gcc version 9.3.0 (Ubuntu 9.3.0-17ubuntu1~20.04))
- Linux kernelのソースコードのv5.15タグをx86_64_defconfigでconfig
- O=../build でビルド用ディレクトリにMakefileを生成した状態からmake -j4でを作るのにかかった時間をbash timeで計測しを比較する
$ git checkout -B v5.15 v5.15
$ make -j4 O=../build x86_64_defconfig
$ cd ../build
$ time make -j4
(snip)
real 4m50.594