Skip to content

필사 모드: ビルドはなぜこんなに遅いのか — コンパイル、リンク、キャッシュ、そしてモノレポ

日本語
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

はじめに — 進捗バーを眺める時間

開発者の一日のうち、ビルドを待つ時間は思ったより大きいものです。コードを1行直して結果を見るまで30秒、1分、ときには数分待ちます。この短い待ちが一日に何十回も積み重なると、集中が切れ、流れが壊れます。「ビルドが遅い」という不満はよくありますが、その遅さが正確にどこから来るのかを知っている開発者は意外と少ないのです。

ビルドが遅いのには必ず構造的な理由があります。何をコンパイルするのか、何をリンクするのか、何をキャッシュできるのか、何を並列で回せるのかが、すべて速度を決めます。この記事はビルドの各段階を分解してどこで時間が漏れるかを突き止め、増分ビルドとキャッシュ、モノレポ戦略、CIキャッシュ、そしてビルドを実際に測る方法までを整理します。漠然とした「遅い」という感覚を「ここがボトルネックだ」という診断に変えるのが目標です。

ビルドは1つの動作ではない

まず「ビルド」という一語が、実は複数段階のパイプラインだという点から押さえねばなりません。C/C++のようなコンパイル言語を例にとると、おおよそ次の段階を経ます。

ソースコード (.c, .cpp)
    |
    | 1. 前処理 (ヘッダ取り込み、マクロ展開)
    v
前処理済みソース
    |
    | 2. コンパイル (ソース -> オブジェクトファイル)
    v
オブジェクトファイル (.o)
    |
    | 3. リンク (オブジェクト + ライブラリ -> 実行ファイル)
    v
実行ファイル

各段階は性格が異なり、遅くなる理由も違います。だから「ビルドが遅い」を診断するとき、最初の問いは常に「どの段階が遅いのか」です。コンパイルが遅いのとリンクが遅いのはまったく別の問題であり、解法も違います。

コンパイル vs リンク — 異なるボトルネック

コンパイルは各ソースファイルを独立に機械語のオブジェクトファイルへ翻訳する段階です。核心となる性質は、ファイル単位で独立していることです。a.cppのコンパイルとb.cppのコンパイルは互いに無関係なので、複数のファイルを複数のコアで並列にコンパイルできます。コンパイルが遅い代表的な原因は次のとおりです。

  • 巨大なヘッダ: C++ではヘッダが各ソースファイルごとに再度前処理されます。重いヘッダを数百個のファイルが取り込むと、同じ内容を数百回パースします。
  • テンプレートとメタプログラミング: C++テンプレートはインスタンス化のたびにコードを生成するので、コンパイラの負担が大きいです。
  • 最適化レベル: -O2-O3のような高い最適化ははるかに多くの解析を要求し、遅くなります。

リンクはコンパイル済みのすべてのオブジェクトファイルとライブラリを1つにまとめ、最終的な実行ファイルを作る段階です。コンパイルと決定的に違うのは、リンクが本質的に全体的な作業だという点です。すべてのオブジェクトファイルが揃わないと始められず、シンボルを解決し配置する過程はおおむね単一スレッドで進みます。だからリンクは並列化が難しく、大きなプロジェクトではしばしば最後のボトルネックになります。

この違いが実務で重要な理由はこうです。コアを増やすとコンパイルは速くなりますが、リンクはあまり速くなりません。だから大型プロジェクトはより速いリンカ(たとえばlld、moldのような並列リンカ)を導入するか、リンク自体を減らす戦略(増分リンク、動的リンク)を使います。「コアをいくら増やしてもビルドがその分速くならない」なら、リンクが犯人であることが多いです。

コールドビルド vs ウォームビルド

ビルド速度を語るとき必ず区別せねばならないのが、コールド(cold)とウォーム(warm)です。

  • コールドビルド: 何もない状態から全体を最初からビルドすること。リポジトリを取ったばかり、あるいはビルド成果物をすべて消した後の最初のビルドです。
  • ウォームビルド: 以前のビルドの成果物が残っている状態で、変わった部分だけを再ビルドすること。

コールドビルドはもともと遅いです。すべてを最初からやるからです。本当に重要なのはウォームビルドです。開発中はコードを少し直して再ビルドすることを一日に何十回も繰り返しますが、この反復ビルドが速いかどうかが生産性を左右します。ウォームビルドを速くする核心技術こそ、次に見る増分ビルドとキャッシュです。

よくある間違いの1つが、コールドビルドの時間だけを見てビルドシステムを評価することです。開発者が一日中経験するのはウォームビルドなので、「1ファイルだけ直したときに再ビルドするのにどれだけかかるか」がはるかに重要な指標です。

増分ビルド — 変わっていないものはやり直さない

増分ビルド(incremental build)の原理は単純です。変わっていないものは作り直さない。 ビルドシステムは各成果物がどの入力に依存するか(依存グラフ)を知っていて、入力が変わっていなければ以前の成果物をそのまま使います。

伝統的なmakeはこれをファイルのタイムスタンプで判断します。ソースファイルの更新時刻がオブジェクトファイルより後なら「変わった」とみなして再コンパイルします。

a.cpp (更新: 10:05) --> a.o (生成: 10:03)
   => a.cppのほうが新しいのでa.oを再コンパイル

b.cpp (更新: 09:50) --> b.o (生成: 10:03)
   => b.oのほうが新しいのでb.oはスキップ

増分ビルドがきちんと動くには、依存グラフが正確でなければなりません。ここでよくある落とし穴がヘッダ依存です。a.cppconfig.hを取り込んでいて、config.hが変わったらa.cppも再コンパイルせねばなりません。この関係をビルドシステムが知らないと、「ヘッダを直したのに反映されない」というバグが生じます。だからコンパイラは各オブジェクトファイルがどのヘッダに依存するかを記録した依存情報を一緒に生成し、ビルドシステムがそれを読んでグラフを完成させます。

タイムスタンプ方式には限界もあります。ファイルを開いて保存しただけでも(内容が同じでも)タイムスタンプが変わり、不要な再ビルドが起きます。だから現代のビルドシステムはタイムスタンプの代わりに内容ハッシュで変更を判断する方向へ進みます。内容が実際に同じなら再ビルドしないのです。これが次に見るキャッシュにもつながります。

コンパイラキャッシュ — ccacheとsccache

増分ビルドが「このプロジェクトの中で変わっていないものをスキップする」ことなら、コンパイラキャッシュはもう一歩進みます。一度コンパイルした結果を保存しておき、同じ入力がまた来たらコンパイルせずに保存した結果を取り出します。

代表的なツールがccacheです。ccacheはコンパイラを包み、コンパイル要求が来ると入力(前処理済みソース、コンパイルオプション、コンパイラのバージョンなど)のハッシュを計算します。同じハッシュを以前に見たことがあれば、保存したオブジェクトファイルを即座に返します。

コンパイル要求
    |
    v
入力ハッシュ計算 (ソース内容 + オプション + コンパイラ)
    |
    +--> キャッシュにある (hit)  --> 保存した.oを即返す (コンパイルしない)
    |
    +--> キャッシュにない (miss) --> 実際にコンパイルし結果をキャッシュに保存

この方式の強みは、コールドビルドでも効果を出すことです。ビルド成果物をすべて消しても、ccacheのキャッシュは残っているので、再ビルド時に大半をキャッシュから取り出して使います。ブランチをあちこち移動して同じファイルを繰り返しコンパイルする状況で、特に大きく役立ちます。

sccacheはccacheのアイデアを拡張します。ローカルディスクだけでなくリモートストレージ(たとえばクラウドストレージ)にもキャッシュを置けるので、チーム全体やCIがキャッシュを共有します。1人がコンパイルした結果を、別の人がキャッシュヒットとして受け取るのです。Rustのエコシステムで特に広く使われています。

キャッシュが正しくあるには、キャッシュキーが正確でなければなりません。コンパイラのバージョン、オプション、ソース内容、取り込んだヘッダまですべてキーに反映されて初めて、条件が違うのに誤ったキャッシュを取り出す事故を防げます。キャッシュの正確さは「何をキーに入れるか」にかかっています。

タスクキャッシュ — Turbo、Nx、Bazel

コンパイラキャッシュが「コンパイル1回」をキャッシュするなら、上位レベルのビルドツールは「ビルドタスク全体」をキャッシュします。ここではJavaScriptモノレポや大規模な多言語プロジェクトで広く使われる3つのツールを見ます。

TurborepoとNxは主にJavaScript/TypeScriptモノレポのためのツールです。これらの核心は、各タスク(ビルド、テスト、リントなど)の入力をハッシュして結果をキャッシュすることです。パッケージAをビルドしたが、その入力(ソース、依存、設定)が1つも変わっていなければ、再実行せず以前の出力をそのまま復元します。コンソールログまでキャッシュして「まるでたった今実行したかのように」再生します。

turbo run build
    |
    v
各パッケージの入力ハッシュを計算
    |
    +--> ハッシュ同一 (cache hit)  --> 保存した出力を復元、実行を省略
    |
    +--> ハッシュ変更 (cache miss) --> 実際にビルドし出力をキャッシュ

BazelはGoogleが作った大規模ビルドシステムで、このアイデアを極限まで押し進めます。Bazelの哲学は再現可能性(hermeticity)です。すべてのビルドアクションの入力を完全に宣言し(宣言された入力以外は何も触れないように)、その入力のハッシュで出力を決定論的にキャッシュします。入力が同じなら出力が必ず同じであることを保証するので、ローカルとCI、さらにチーム全体がリモートキャッシュを安全に共有できます。Bazelはビルドアクション自体をリモートのマシンに分散実行することさえあります。

3つのツールの共通原理は1つです。入力をハッシュし、同じ入力には保存した出力を再利用する。 コンパイラキャッシュと同じ発想を、ファイル単位ではなくタスクやターゲット単位へ上げたものです。規模が大きくなるほど、この「やり直さない」の価値が大きくなります。

モノレポの問題 — 何を再ビルドするか

モノレポ(monorepo)は複数のプロジェクトを1つのリポジトリに置く方式です。コード共有と一貫性の管理に有利ですが、ビルドの観点から固有の問題を生みます。小さな変更1つが何を再ビルドさせるのか?

数百のパッケージが互いに依存するモノレポで、共有ライブラリを1つ直すと、それに依存するすべてのパッケージが影響を受けます。素朴に全部を再ビルドすると、小さな変更にも膨大な時間がかかります。逆に何も再ビルドしないと変更が反映されません。核心は「影響を受けたものだけを正確に」再ビルドすることです。

       shared-utils (変更された)
        /      |      \
   app-web  app-api  lib-auth
                        |
                    app-admin

  shared-utilsを直すと:
  影響を受けるもの -> app-web, app-api, lib-auth, app-admin (再ビルド)
  影響がないもの   -> 残りのパッケージ (スキップ)

これを「影響を受けた集合だけをビルド/テストする(affected)」と呼びます。NxのaffectedコマンドやTurborepoのフィルタがこれを自動化します。依存グラフを解析し、変わったファイルから到達可能なパッケージだけを選んでビルドしテストします。おかげで巨大なモノレポでも「自分が触れた部分とその影響範囲」だけを検証すればよいので、CI時間を劇的に減らせます。

モノレポの本当の難しさは、この依存グラフを正確に保つことです。グラフが実際より広ければ不要に多くビルドし、狭ければ影響を受けたものを見落として壊れたビルドが通ってしまいます。だからモノレポのビルドツールの品質は「依存グラフをどれだけ正確に知っているか」で決まります。

並列性の限界

「コアをもっと与えればビルドが速くなる」はおおむね正しいですが、無限ではありません。並列ビルドには根本的な限界があります。

1つ目、依存の連鎖です。BがAの成果物に依存するなら、Aが終わる前にBを始められません。こうした連鎖が長いと、コアがいくら多くてもその連鎖を順に通らねばなりません。この最長の依存連鎖をクリティカルパス(critical path)と呼び、ビルド時間の下限を決めます。いくら並列化してもクリティカルパスより速くはなれません。

2つ目、アムダールの法則です。ビルドに並列化できない部分(先に見たリンクなど)があると、その部分が全体の速度を引っ張ります。並列化できる部分をいくら速くしても、逐次部分はそのまま残り、全体の改善に上限が生じます。

  コア1個:  ████████████████████  (100秒)
  コア4個:  █████ + 逐次部分       (並列部分は1/4に縮むが
                                    逐次部分はそのまま)
  => 逐次部分(リンクなど)が大きいほど並列化の利得が小さくなる

3つ目、資源の競合です。コアを増やしてもディスクI/Oやメモリ帯域が限界なら、それが新しいボトルネックになります。特にオブジェクトファイルを多く読み書きするビルドはディスクがボトルネックになりやすいです。だから並列度をコア数まで闇雲に上げるのが常に最善とは限らず、実際に測って最適点を見つけねばなりません。

CIキャッシュ — 毎回最初からやらない

ローカルでは以前のビルド成果物が残ってウォームビルドが可能ですが、CIは違います。CIはたいてい綺麗な環境から始まるので、何の対策もなければ毎回コールドビルドです。だからCIを速くする核心は、キャッシュをセッション間で保存することです。

CIキャッシュには大きく2つの層があります。

  • 依存キャッシュ: node_modules、パッケージマネージャのキャッシュ、コンパイル済みのサードパーティライブラリなど、あまり変わらないもの。これを毎回取り直したりビルドしたりすると大きな時間の無駄なので、ロックファイル(lockfile)のハッシュをキーにキャッシュします。依存が変わらなければ丸ごと復元します。
  • ビルドキャッシュ: 先に見たccache/sccacheのキャッシュ、Turbo/Nx/BazelのタスクキャッシュをCIセッション間で共有します。リモートキャッシュを使えば、複数のCIジョブと開発者が同じキャッシュを分け合います。

CIキャッシュでもっとも重要なのがキャッシュキーの設計です。キーが狭すぎると(たとえばコミットハッシュ)毎回キャッシュミスになって無用の長物になり、広すぎると変わったものを見落として古い結果を使います。実務でよくあるパターンは、ロックファイルのハッシュを主キーにし、ブランチやOSを補助キーにして適切に再利用することです。

もう1つ注意すべき点は、キャッシュの復元と保存それ自体にも時間がかかることです。キャッシュが大きすぎると、ダウンロードして展開するのにかかる時間が、キャッシュで節約した時間を食ってしまうことがあります。だから「何をキャッシュする価値があるか」を吟味し、再生成が高価なものを中心にキャッシュする均衡が必要です。

ビルドをプロファイルする — 推測ではなく測定

ここまでビルドが遅くなる多くの原因を見ましたが、実際のプロジェクトでどれが犯人かは推測してはいけません。測定せねばなりません。ビルドプロファイリングの核心となる問いはこれです。時間はどこに使われているのか?

いくつか実用的なアプローチがあります。

  • 段階別の時間測定: 前処理、コンパイル、リンクのどこが長くかかるかを分けて見ます。リンクが大半なら、コアを増やしても無駄だという合図です。
  • ファイル別のコンパイル時間: どのソースファイルが特に長くかかるかを探します。たいてい重いヘッダやテンプレートを多く使う少数のファイルが全体の時間を支配します。
  • ビルドグラフの可視化: 多くの現代のビルドツールが各タスクの開始-終了時刻をタイムラインで見せます。ここでクリティカルパスと並列化できない区間が目に入ります。
  • キャッシュヒット率の確認: ccacheやタスクキャッシュのヒット率を見ます。ヒット率が低ければ、キャッシュキーが間違っているか、キャッシュがきちんと共有されていないのです。

コンパイラ自体もプロファイリングオプションを提供します。たとえばあるコンパイラは各コンパイル段階(パース、テンプレートインスタンス化、最適化)にかかった時間をレポートとして出してくれます。これを見れば「このファイルはテンプレートインスタンス化に時間の半分を使っている」といった具体的な診断ができます。

プロファイリングの原則は1つです。ビルドを速くする前に、遅い場所を正確に見つけなさい。ほとんどのビルドで時間は少数のボトルネックに集中しています。そのボトルネックをデータで見つけて狙うことが、漠然とあれこれ最適化するよりはるかに効果的です。

実務チェックリスト

遅いビルドに直面したとき、順に確認する項目を整理します。

  • どの段階が遅いか? コンパイルかリンクかから分けます。リンクならより速いリンカを検討します。
  • ウォームビルドが遅いか、コールドビルドが遅いか? ウォームが遅ければ、増分ビルドの依存グラフが不正確かもしれません。
  • コンパイラキャッシュを使っているか? ccache/sccacheの導入だけで反復ビルドが大きく速くなることが多いです。
  • モノレポなら影響を受けたものだけビルドしているか? 全部を再ビルドしていないか確認します。
  • 並列度は適切か? コア数、ディスク、メモリのどれがボトルネックかを測定します。
  • CIが毎回コールドで回っているか? 依存とビルドのキャッシュをセッション間で保存しているか見ます。
  • キャッシュキーは正しいか? 広すぎ・狭すぎでないか、ヒット率で検証します。
  • 測定したか? 推測の前に、プロファイリングでボトルネックをデータで確認します。

おわりに

ビルドが遅いのは運命ではなく、診断可能な問題です。「ビルド」はコンパイルとリンクという性格の違う段階からなり、各段階は増分ビルドとキャッシュでかなりの部分をスキップできます。ウォームビルドを速く保つことがコールドビルドの時間を減らすことよりたいてい重要で、モノレポでは「影響を受けたものだけ」を再ビルドする精密さが核心です。並列性は強力ですがクリティカルパスと逐次区間という限界があり、CIではキャッシュをセッション間で保存することが決定的です。

何より重要な原則は測定です。ビルド時間はたいてい少数のボトルネックに集中しているので、そのボトルネックをプロファイリングで正確に見つけて狙えば、少ない労力で大きな改善が得られます。次に進捗バーを眺めることになったら、「なぜ遅いのか」を漠然とした不満ではなく具体的な問いに変えてみてください。答えはたいていデータの中にあります。

参考資料

현재 단락 (1/113)

開発者の一日のうち、ビルドを待つ時間は思ったより大きいものです。コードを1行直して結果を見るまで30秒、1分、ときには数分待ちます。この短い待ちが一日に何十回も積み重なると、集中が切れ、流れが壊れます...

작성 글자: 0원문 글자: 8,390작성 단락: 0/113