コンテンツにスキップ

ビルドと依存関係:依存グラフ・増分ビルドの正しさ・版の解決

対象読者: グラフアルゴリズム(有向グラフ・BFS・DFS)と、P と NP・多項式時間帰着の基本、Git のコミット履歴の仕組みを学んだ人。make などのビルドツールの内部は仮定しません。

前提となる記事:グラフアルゴリズム:BFS・DFS・ダイクストラ法を正しさから理解する、P≠NP 予想とは何か:多項式時間・証明書・帰着で計算の壁を測る、バージョン管理システム Git:Merkle DAG としての履歴と共同開発フロー

生 Markdown

ソースを少し直しただけなのに、ビルドが古い結果のまま変わらない。あるいは、念のため全部消して作り直したら数十分かかった。そんな経験はないでしょうか。この記事では、ビルドを「何を読んで何を作るかの対応」として整理し、一部だけ作り直した結果が全部作り直した結果と同じになる条件と、作り直す範囲をどこまで小さくできるかを確かめます。読み終えると、ビルドが信用できなくなる原因と、並列にして速くなる限界、ライブラリの版を固定する理由を説明できるようになります。

  • ビルドは「入力ファイルの集合から成果物への関数」です。タスクの出力が宣言した依存だけで決まる(密閉性)なら、依存グラフのどのトポロジカル順で実行しても結果は同じになります(定理 4.1)。
  • 有限の有向グラフがトポロジカル順をもつことと、有向閉路をもたないことは同値で、順序は O(∣V∣+∣E∣)O(|V| + |E|) 時間で求まります(定理 3.2)。
  • 変更された入力から依存グラフで到達できるタスクだけを再実行する増分ビルドは、密閉性のもとでクリーンビルドと同じ結果になります(定理 4.2)。宣言の漏れが 1 つあるだけで、この保証は消えます。
  • 内容ハッシュ(ハッシュ関数でファイルの中身から計算した短い指紋)で比べると、再実行した出力が前回と同じときに下流を止められます(命題 4.5)。出力の内容を見ない方法では、到達可能な集合より小さくは削れません(命題 4.7)。
  • 仕事量の合計 WW、最長の依存鎖 LL のビルドを pp 並列で実行する時間は max⁡(W/p,L)\max(W/p, L) 以上、貪欲な割り当てで W/p+L(1−1/p)W/p + L(1 - 1/p) 以下です(定理 6.1)。並列度を W/LW/L より上げても、時間は半分にもなりません。
  • 「各パッケージに 1 つの版を選んですべての制約を満たす」問題は NP 完全です(定理 8.2)。ロックファイルは、解決の結果を内容ハッシュつきで固定して、取得を入力の関数にするための記録です(命題 8.4)。

1. 動機:make clean を打つのはなぜか

Section titled “1. 動機:make clean を打つのはなぜか”

ビルドが変な結果を出したとき、多くの人は成果物をすべて消して最初から作り直します。それで直ることが多いのは事実ですが、これは「増分ビルドの結果を信用していない」という表明でもあります。信用できない理由を突き止めずに作り直しを習慣にすると、プロジェクトが大きくなったときに 1 回のビルドが数十分になり、テストを走らせる回数が減ります。

この記事の問いは 3 つです。

  1. 一部だけを作り直した結果が、全部を作り直した結果と一致するのはどんなときか。
  2. 一致を保ったまま、作り直す範囲をどこまで小さくできるか。
  3. 自分のコードの外にある依存(ライブラリの版)を、同じ結果になるように固定するにはどうするか。

1970 年代後半に Stuart Feldman が作った Make は、1 の問いに「ファイルの依存関係を書かせ、更新時刻を比べる」という答えを与えました。この方式は今も広く使われていますが、依存の書き漏らしと時刻の比較という 2 つの弱点をもちます。その後のビルドシステム(Bazel、Buck、Nix、Shake など)は、依存の宣言を強制すること(密閉性)と、時刻ではなく内容のハッシュで比べることで、この弱点を塞いできました。以下では、これらの設計がどの定理に支えられているのかを順に確かめます。

テストとの関係も先に述べておきます。テストの理論 と 性質ベーステスト で作ったテストは、変更のたびに走らせて初めて役に立ちます。「変更のたびに、必要なものだけを、誰の手元でも同じ結果になるように」走らせる土台がビルドシステムで、それを共有の場所で実行する仕組みが次章の 継続的インテグレーション です。

グラフの用語(頂点・有向辺・入次数・歩道・閉路・到達可能)は 定義 2.1[グラフアルゴリズム] と 定義 2.2[グラフアルゴリズム] のとおりに使います。有向閉路をもたない有向グラフを DAG(directed acyclic graph)と呼びます。

定義 2.1(ビルドの模型)↗

次の組をビルドの記述という。

  • 有限集合 II(入力。人が書くソースファイルの名前)と有限集合 TT(タスク。生成されるファイルの名前と同一視する)。I∩T=∅I \cap T = \emptyset とし、N=I∪TN = I \cup T とおく。
  • 値の集合 V\mathcal{V}(ファイルの内容になりうるバイト列の全体)。
  • 各タスク t∈Tt \in T に、宣言された依存 D(t)⊆ND(t) \subseteq N。

有向グラフ G=(N,E)G = (N, E)、E={(u,t):t∈T, u∈D(t)}E = \{(u, t) : t \in T,\ u \in D(t)\} を依存グラフという。辺 (u,t)(u, t) は「tt を作るのに uu を読む」ことを表す。以下では依存グラフが DAG であるものだけを考える。

写像 x:I→Vx : I \to \mathcal{V} を入力の状態、写像 s:N→Vs : N \to \mathcal{V} をストア(作業ディレクトリの中身)という。タスク tt を実行するとは、ストア ss の tt の値だけを書き換えて新しいストアにする操作である。

入力は入次数 00 の頂点です(DD はタスクにしか定めていないので、入力へ向かう辺はありません)。コンパイルの例では、II が .c と .h、TT が .o と実行ファイル、D(main.o)={main.c,util.h}D(\texttt{main.o}) = \{\texttt{main.c}, \texttt{util.h}\} といった具合です。

次の密閉性は、「タスクの出力は、宣言した依存の中身だけで決まる」という約束です。たとえば main.o を作るコンパイルが、宣言していない config.h を読んでいると、config.h を書き換えても main.o は作り直されません。この失敗は §4.2 の例(宣言の漏れ)で詳しく見ます。

定義 2.2(密閉性)↗

ビルドの記述が密閉的(hermetic)であるとは、各タスク t∈Tt \in T に写像 ft:VD(t)→Vf_t : \mathcal{V}^{D(t)} \to \mathcal{V} があり、どのストア ss のもとで、いつ、どの計算機で tt を実行しても、tt に書き込まれる値が

ft(s∣D(t))f_t\bigl(s|_{D(t)}\bigr)

であることをいう。ここで s∣D(t)s|_{D(t)} は ss の D(t)D(t) への制限である。すなわち、タスクの出力は宣言された依存の現在の値だけの関数である。

密閉性は、プログラミング言語の言葉でいえば、タスクが参照透過な式であること(定義 6.3[プログラミング言語論])に当たります。密閉性を破るものは、宣言していないファイルの読み込み(インクルードの漏れ)、環境変数、現在時刻、乱数、ネットワークの先にあるもの、並列に走る別のタスクの途中結果などです。

定義 2.3(トポロジカル順)↗

有限の有向グラフ G=(V,E)G = (V, E)、∣V∣=n|V| = n のトポロジカル順とは、全単射 σ:V→{1,…,n}\sigma : V \to \{1, \ldots, n\} であって、すべての辺 (u,v)∈E(u, v) \in E について σ(u)<σ(v)\sigma(u) < \sigma(v) を満たすものをいう。

定義 2.4(クリーンビルドと増分ビルド)↗

入力の状態 xx に対し、ストアの入力の部分を xx にしてから、すべてのタスクを依存グラフのトポロジカル順に 1 回ずつ実行することをクリーンビルドという。前回のビルドの結果が残っているストアから始めて、タスクの一部だけを実行することを増分ビルドという。

a.mdb.mdstyle.cssparse_aparse_bindexpage_apage_bsite入力(破線)成果物
§5 のコード例の依存グラフ。辺は左(読まれる側)から右(読む側)へ向かう。a.md だけを変えたときに到達できるタスクは parse_a・index・page_a・page_b・site の 5 つ

3. トポロジカル順:実行の順序はいつ存在するか

Section titled “3. トポロジカル順:実行の順序はいつ存在するか”

タスクを実行するには、その依存がすでに出来ていなければなりません。全タスクについてこれを満たす実行順がトポロジカル順です。

次の補題は、Kahn の算法が途中で行き詰まったとき、残った頂点の中に閉路があると言うための部品です。直感的には、「どの頂点にも矢印が入ってくる」グラフで矢印を逆にたどり続けると、頂点の数は有限なので、いつか同じ頂点に戻ってくる、ということです。

補題 3.1(閉路のないグラフには入次数 0 の頂点がある)✓

G=(V,E)G = (V, E) を有限の有向グラフ、V≠∅V \ne \emptyset とする。すべての頂点の入次数が 11 以上ならば、GG は有向閉路をもつ。

証明(補題 3.1)

n=∣V∣n = |V| とおきます。頂点 v0v_0 を 1 つ取ります。v0v_0 の入次数は 11 以上なので、(v1,v0)∈E(v_1, v_0) \in E となる v1v_1 があります。v1v_1 についても同じで、(v2,v1)∈E(v_2, v_1) \in E となる v2v_2 があります。これを繰り返して v0,v1,…,vnv_0, v_1, \ldots, v_n を作ります。

添字の集合 {0,1,…,n}\{0, 1, \ldots, n\} は n+1n + 1 個の元をもち、VV は nn 個なので、写像 i↦vii \mapsto v_i は単射ではありません(鳩の巣原理、定理 7.5[数え上げと母関数])。そこで vi=vjv_i = v_j、i<ji < j となる組のうち j−ij - i が最小のものを取ります。このとき vi,vi+1,…,vj−1v_i, v_{i+1}, \ldots, v_{j-1} は相異なります。もし va=vbv_a = v_b(i≤a<b≤j−1i \le a < b \le j - 1)なら b−a<j−ib - a < j - i となって最小性に反するからです。

列 (vj,vj−1,…,vi)(v_j, v_{j-1}, \ldots, v_i) は、隣り合う 2 つが辺 (vk+1,vk)∈E(v_{k+1}, v_k) \in E で結ばれた長さ j−i≥1j - i \ge 1 の歩道で、始点と終点が等しく、終点を除く頂点が相異なります。これは有向閉路です。

定理 3.2(トポロジカル順の存在と計算)✓

G=(V,E)G = (V, E) を有限の有向グラフとする。

(a) GG がトポロジカル順をもつことと、GG が有向閉路をもたないことは同値である。

(b) 次の算法(Kahn の算法)は隣接リスト表現のもとで O(∣V∣+∣E∣)O(|V| + |E|) 時間で止まる。GG が有向閉路をもたなければ全頂点を出力し、出力した順がトポロジカル順になる。有向閉路をもてば、出力されない頂点が残る。

各頂点 vv の計数 c[v]c[v] を入次数で初期化し、c[v]=0c[v] = 0 の頂点をすべて待ち行列に入れる。待ち行列が空でないかぎり、頂点 uu を 1 つ取り出して出力し、uu から出る各辺 (u,v)(u, v) について c[v]c[v] を 11 減らし、00 になったら vv を待ち行列に入れる。

(c) GG が有向閉路をもたないとき、深さ優先探索の終了時刻 f[v]f[v] の大きい順に頂点を並べたものはトポロジカル順である。これも O(∣V∣+∣E∣)O(|V| + |E|) 時間で求まる。

Kahn の算法の証明の方針は、「出力した頂点を取り除いたと思って、残りの入次数を数え直している」ことを不変条件 (K1)〜(K3) で言い直すことです。(K1) は計数が残りの辺の数と合っていること、(K2) は待ち行列が「いま取り出せる頂点」ちょうどであること、(K3) は出力が辺の向きに反していないことを表します。

§2 の依存グラフに Kahn の算法を実際に走らせると、次のように進みます。各頂点の右上の c が計数で、0 になった頂点が待ち行列に入る様子を見てください。

入力 3 つが待ち行列に入った状態から始まり、頂点を 1 つ取り出すたびに出る辺の先の計数が減り、0 になった頂点が待ち行列に加わって、最後に site が出力されるまでの動画
Kahn の算法を §2 の依存グラフで 1 ステップずつ実行したもの。橙が今回取り出した頂点、青が待ち行列にいる頂点、灰色が出力済みの頂点。出力のたびに、出る辺の先の計数が 1 減る
証明(定理 3.2)

(a) の「もつ ⇒ 閉路がない」。 σ\sigma をトポロジカル順とし、有向閉路 (v0,v1,…,vk)(v_0, v_1, \ldots, v_k)、k≥1k \ge 1、vk=v0v_k = v_0 があったとします。各辺 (vi,vi+1)(v_i, v_{i+1}) について σ(vi)<σ(vi+1)\sigma(v_i) < \sigma(v_{i+1}) なので、σ(v0)<σ(v1)<⋯<σ(vk)=σ(v0)\sigma(v_0) < \sigma(v_1) < \cdots < \sigma(v_k) = \sigma(v_0) となり矛盾します。

(b)。 出力済みの頂点の集合を OO と書きます。算法の各反復の前後で、次の 3 つが成り立つことを示します。

  • (K1) すべての v∉Ov \notin O について、c[v]=#{u∉O:(u,v)∈E}c[v] = \#\{u \notin O : (u, v) \in E\}。
  • (K2) 待ち行列の中身は、OO に属さず c[v]=0c[v] = 0 である頂点の全体と一致する。同じ頂点が 2 度入ることはない。
  • (K3) 出力された列において、どの頂点も、自分へ向かう辺の始点がすべて出力された後に出力されている。

初期状態では O=∅O = \emptyset で、c[v]c[v] は入次数なので (K1) が成り立ち、(K2) は初期化のとおり、(K3) は列が空なので成り立ちます。反復で uu を取り出すとき、(K2) より c[u]=0c[u] = 0、(K1) より uu へ向かう辺の始点はすべて OO に属します。したがって uu を出力しても (K3) は保たれます。uu を OO に加えると、(K1) の右辺は uu から辺が出ている先の v∉Ov \notin O についてちょうど 11 減り、算法も同じ vv の c[v]c[v] を 11 減らします(自己ループ (u,u)(u, u) があるとすると c[u]≥1c[u] \ge 1 なので uu は取り出されません。よって v≠uv \ne u です)。c[v]c[v] が 00 になるのは、正の値から 11 減らした瞬間の 1 度だけなので、頂点が待ち行列に入るのは高々 1 度で、(K2) が保たれます。

各頂点が待ち行列に入るのは高々 1 度なので、反復は高々 ∣V∣|V| 回で止まります。入次数の計算は全辺を 1 度走査すればよく、uu を取り出したときの仕事は uu から出る辺の本数に比例します。隣接リスト表現では全辺の走査が Θ(∣V∣+∣E∣)\Theta(|V| + |E|) 時間なので(命題 2.6[グラフアルゴリズム] (b))、全体で O(∣V∣+∣E∣)O(|V| + |E|) 時間です。

止まったとき待ち行列は空です。R=V∖OR = V \setminus O とおきます。R=∅R = \emptyset なら全頂点が出力されており、(K3) より、出力の順番を σ\sigma とすれば各辺 (u,v)(u, v) について σ(u)<σ(v)\sigma(u) < \sigma(v) です。R≠∅R \ne \emptyset なら、(K2) より RR のどの頂点も c[v]≥1c[v] \ge 1 で、(K1) より RR の中に自分へ向かう辺の始点をもちます。GG を RR に制限したグラフはすべての頂点の入次数が 11 以上なので、補題 3.1 より有向閉路をもち、それは GG の有向閉路でもあります。対偶をとると、GG が有向閉路をもたなければ R=∅R = \emptyset です。逆に GG が有向閉路をもてば、(a) の前半よりトポロジカル順は存在しないので、全頂点が出力されることはありません。

(a) の「閉路がない ⇒ もつ」。 (b) で、閉路がなければ算法の出力がトポロジカル順になることを示しました。

(c)。 深さ優先探索は、頂点を白(未発見)・灰色(走査中)・黒(走査完了)に塗り分け、頂点 vv の走査が終わった時刻を f[v]f[v] に記録するものとします(補題 4.1[グラフアルゴリズム] と同じ設定)。GG が有向閉路をもたないとき、各辺 (u,v)∈E(u, v) \in E について f[v]<f[u]f[v] < f[u] を示せば十分です。ff の大きい順に 1,2,…1, 2, \ldots と番号を振ったものを σ\sigma とすれば、σ(u)<σ(v)\sigma(u) < \sigma(v) となるからです。

辺 (u,v)(u, v) が調べられるのは uu の走査中、つまり uu が灰色のときです。その瞬間の vv の色で場合を分けます。

  • vv が白のとき。vv は uu が灰色である間に発見されるので、補題 4.1[グラフアルゴリズム] (b) より f[v]<f[u]f[v] < f[u] です。
  • vv が灰色のとき。(u,v)(u, v) は後退辺として記録されます。定理 4.2[グラフアルゴリズム] より、後退辺が記録されるのは GG が有向閉路をもつときに限るので、この場合は起こりません。
  • vv が黒のとき。vv の走査はすでに終わっているので、f[v]f[v] は現在の時刻より前です。uu はまだ灰色なので f[u]f[u] は現在の時刻より後です。よって f[v]<f[u]f[v] < f[u] です。

深さ優先探索の時間は O(∣V∣+∣E∣)O(|V| + |E|) で(定理 4.2[グラフアルゴリズム])、頂点を走査が終わった順にリストの先頭へ挿入していけば、整列の手間なしに ff の大きい順の列が得られます。

(b) と (c) は同じ問題への 2 つの答えですが、ビルドシステムでの使われ方が違います。Kahn の算法は「いま実行できるタスクの集合」(待ち行列)を明示的にもつので、並列実行に向きます。深さ優先探索は、欲しい成果物から依存をさかのぼるので、「この成果物に必要なタスクだけを作る」のに向きます。閉路があった場合、Kahn の算法は残った頂点の集合を、深さ優先探索は後退辺とその時点の灰色頂点の列(閉路そのもの)を報告できます。

例 3.3(依存が巡回する場合:LaTeX の複数回の実行)

LaTeX で相互参照のある文書を組むとき、処理系は補助ファイル(.aux)から前回の実行で記録した番号を読み、組版しながら同じ補助ファイルに今回の番号を書き出します。「補助ファイルを作るタスク」が補助ファイル自身に依存するので、依存グラフには自己ループがあり、定理 3.2 (a) よりトポロジカル順は存在しません。

実務では、補助ファイルの内容 aa を 1 回の実行で g(a)g(a) に写す関数 gg を考え、ak+1=g(ak)a_{k+1} = g(a_k) を内容が変わらなくなるまで繰り返します。これは不動点 g(a)=ag(a) = a を反復で探しているのであって、DAG の上のビルドとは別の問題です。番号の桁数が変わってページ割りがずれ、それがまた番号を変える、という形で反復が止まらない文書を作ることもできます。閉路を含む依存は、この記事の定理の適用範囲の外にあります。

4. クリーンビルドと増分ビルドの正しさ

Section titled “4. クリーンビルドと増分ビルドの正しさ”

4.1. クリーンビルドは関数である

Section titled “4.1. クリーンビルドは関数である”

定理 4.1(クリーンビルドの一意性)✓

密閉的なビルドの記述(依存グラフは DAG)と入力の状態 x:I→Vx : I \to \mathcal{V} に対し、次が成り立つ。

(a) 写像 vx:N→Vv_x : N \to \mathcal{V} で

vx(i)=x(i)  (i∈I),vx(t)=ft(vx∣D(t))  (t∈T)v_x(i) = x(i)\ \ (i \in I), \qquad v_x(t) = f_t\bigl(v_x|_{D(t)}\bigr)\ \ (t \in T)

を満たすものがただ 1 つ存在する。

(b) ストアの入力の部分を xx にしてから、すべてのタスクを依存グラフのトポロジカル順に 1 回ずつ実行すると、トポロジカル順の選び方によらず、最後のストアは vxv_x に一致する。

vxv_x を入力 xx に対するビルドの結果と呼ぶ。

証明(定理 4.1)

依存グラフは DAG なので、定理 3.2 (a) よりトポロジカル順が存在します。

(b) の実行が (a) の条件を満たす写像を与えること。 トポロジカル順 σ\sigma を 1 つ固定し、その順に実行します。「σ\sigma の順で kk 番目までの頂点 uu について、uu が入力なら s(u)=x(u)s(u) = x(u)、タスクなら実行済みで、以後 s(u)s(u) は書き換えられない」ことに注意します。入力はどのタスクの実行でも書き換えられず、各タスクは 1 回しか実行されないからです。

タスク tt を実行する時点で、D(t)D(t) の各元 uu は辺 (u,t)(u, t) の始点なので σ(u)<σ(t)\sigma(u) < \sigma(t) を満たし、uu がタスクなら実行済みです。したがって tt に書き込まれる値は、密閉性(定義 2.2)より ft(s∣D(t))f_t(s|_{D(t)}) で、右辺に現れる s(u)s(u) は以後変わりません。全タスクの実行後のストアを sσs^{\sigma} と書くと、sσ(i)=x(i)s^{\sigma}(i) = x(i)、sσ(t)=ft(sσ∣D(t))s^{\sigma}(t) = f_t(s^{\sigma}|_{D(t)}) が成り立ちます。これで (a) の存在が示されました。

(a) の一意性。 v,v′v, v' がともに (a) の条件を満たすとします。「σ(u)=k\sigma(u) = k である頂点 uu について v(u)=v′(u)v(u) = v'(u)」を kk についての完全帰納法(定理 4.3[証明の技術])で示します。σ(u)<k\sigma(u) < k である頂点では一致していると仮定します。σ(u)=k\sigma(u) = k の頂点 uu が入力なら v(u)=x(u)=v′(u)v(u) = x(u) = v'(u) です。タスクなら、D(u)D(u) の元はすべて σ\sigma の値が kk より小さいので v∣D(u)=v′∣D(u)v|_{D(u)} = v'|_{D(u)} で、v(u)=fu(v∣D(u))=fu(v′∣D(u))=v′(u)v(u) = f_u(v|_{D(u)}) = f_u(v'|_{D(u)}) = v'(u) です。

(b)。 別のトポロジカル順 τ\tau で実行した結果 sτs^{\tau} も、上の議論より (a) の条件を満たします。一意性より sσ=sτ=vxs^{\sigma} = s^{\tau} = v_x です。

この定理が言っているのは、密閉性のもとでは「ビルドの結果」が入力 xx だけで決まり、実行の順序・並列度・計算機に依存しないということです。(N,D,f)(N, D, f) は動的計画法の部分問題族(定義 3.1[動的計画法])と同じ形をしていて、ビルドは「依存グラフに沿って表を埋める」計算の一種です。

前回のビルドのあとで入力の一部を編集したとします。編集された入力から影響を受けうるタスクは、依存グラフの上で到達できるタスクです。

定理 4.2(増分ビルドの正しさ)✓

密閉的なビルドの記述(依存グラフ GG は DAG)と、入力の状態 x,x′x, x' を考える。C={i∈I:x(i)≠x′(i)}C = \{i \in I : x(i) \ne x'(i)\} を変更された入力の集合とし、

R={t∈T:ある c∈C から t へ G の歩道がある}R = \{t \in T : \text{ある } c \in C \text{ から } t \text{ へ } G \text{ の歩道がある}\}

とおく。ストアが vxv_x(xx に対するビルドの結果)である状態から、入力の部分を x′x' に書き換え、RR のタスクだけを GG のトポロジカル順に 1 回ずつ実行する。このとき最後のストアは vx′v_{x'} に一致する。

証明のねらいは、「変更が届くのは、辺をたどって行ける範囲 RR だけ」と言うことです。第 1 段では、RR の外のタスクは読んでいるものがすべて前回と同じなので、値も同じだと示します。第 2 段では、RR の中のタスクを依存の順に作り直せば、どれも新しい正しい値になると示します。

証明(定理 4.2)

GG のトポロジカル順 σ\sigma を固定します。

第 1 段:RR の外のタスクの値は変わらない。 「σ(t)=k\sigma(t) = k かつ t∈T∖Rt \in T \setminus R ならば vx′(t)=vx(t)v_{x'}(t) = v_x(t)」を kk についての完全帰納法(定理 4.3[証明の技術])で示します。t∈T∖Rt \in T \setminus R とし、u∈D(t)u \in D(t) を取ります。

  • uu が入力のとき、u∉Cu \notin C です。もし u∈Cu \in C なら、辺 (u,t)(u, t) が CC の元から tt への歩道になり、t∈Rt \in R となってしまうからです。よって x′(u)=x(u)x'(u) = x(u)、すなわち vx′(u)=vx(u)v_{x'}(u) = v_x(u) です。
  • uu がタスクのとき、u∉Ru \notin R です。もし u∈Ru \in R なら、CC の元から uu への歩道に辺 (u,t)(u, t) を継ぎ足して t∈Rt \in R となるからです。σ(u)<σ(t)\sigma(u) < \sigma(t) なので、帰納法の仮定より vx′(u)=vx(u)v_{x'}(u) = v_x(u) です。

したがって vx′∣D(t)=vx∣D(t)v_{x'}|_{D(t)} = v_x|_{D(t)} で、定理 4.1 (a) の等式より

vx′(t)=ft(vx′∣D(t))=ft(vx∣D(t))=vx(t)v_{x'}(t) = f_t\bigl(v_{x'}|_{D(t)}\bigr) = f_t\bigl(v_x|_{D(t)}\bigr) = v_x(t)

です。

第 2 段:再実行したタスクは正しい値になる。 増分ビルドの開始時のストア ss は、入力では x′=vx′∣Ix' = v_{x'}|_I、RR の外のタスクでは vx(t)=vx′(t)v_x(t) = v_{x'}(t)(第 1 段)です。RR のタスクを σ\sigma の順に実行します。「RR のタスクのうち σ\sigma の順で jj 番目に実行されるタスク tt について、実行後 s(t)=vx′(t)s(t) = v_{x'}(t) となる」ことを jj についての完全帰納法で示します。tt を実行する時点で、u∈D(t)u \in D(t) は、入力であるか、RR の外のタスクであるか、RR のタスクで σ(u)<σ(t)\sigma(u) < \sigma(t) を満たすので先に実行されているかのいずれかです。どの場合も s(u)=vx′(u)s(u) = v_{x'}(u) です(最後の場合は帰納法の仮定)。密閉性より、tt に書き込まれる値は ft(s∣D(t))=ft(vx′∣D(t))=vx′(t)f_t(s|_{D(t)}) = f_t(v_{x'}|_{D(t)}) = v_{x'}(t) です。

以上より、最後のストアはすべての頂点で vx′v_{x'} に一致します。

証明で密閉性を使ったのは、「依存の値が同じなら出力も同じ」という 1 点です。ここが崩れる例を見ます。

例 4.3(宣言の漏れ:ヘッダファイルを書き忘れた Makefile)

C の小さなプロジェクトで、main.c が config.h を取り込んでいるのに、Makefile には次のように書いたとします。

app: main.o util.o
cc -o app main.o util.o
main.o: main.c
cc -c main.c
util.o: util.c
cc -c util.c

宣言された依存は D(main.o)={main.c}D(\texttt{main.o}) = \{\texttt{main.c}\} ですが、コンパイラは実際には config.h も読みます。config.h の定数を書き換えると、C={config.h}C = \{\texttt{config.h}\} から宣言上の依存グラフで到達できるタスクはなく、R=∅R = \emptyset です。増分ビルドは何も再実行せず、古い定数で作られた main.o が残ります。クリーンビルドは新しい定数で作るので、2 つの結果は一致しません。

この記述は密閉的ではありません。main.o の出力が D(main.o)D(\texttt{main.o}) に属さない config.h に依存しているからです。定理 4.2 の仮定が満たされていないので、結論が成り立たなくても定理には反しません。直し方は、依存に config.h を足すか、コンパイラに依存の一覧を出力させて(cc -MMD)それを Makefile に取り込むことです。

逆に、依存を多めに宣言するのは安全です。

命題 4.4(過剰な宣言は正しさを損なわない)✓

密閉的なビルドの記述 (I,T,D,f)(I, T, D, f) に対し、D′(t)⊇D(t)D'(t) \supseteq D(t) をすべての tt について満たす D′D' で、D′D' の依存グラフも DAG であるものを取る。このとき (I,T,D′)(I, T, D') も、ft′(a)=ft(a∣D(t))f'_t(a) = f_t(a|_{D(t)}) によって密閉的であり、ビルドの結果 vxv_x は DD のときと同じである。変更 CC に対する再実行の集合は R′⊇RR' \supseteq R となる。

証明(命題 4.4)

タスク tt の出力は ft(s∣D(t))f_t(s|_{D(t)}) で、これは s∣D′(t)s|_{D'(t)} から D(t)D(t) への制限を経て決まるので、ft′(s∣D′(t))=ft(s∣D(t))f'_t(s|_{D'(t)}) = f_t(s|_{D(t)}) と書けます。よって D′D' についても 定義 2.2 の条件が成り立ちます。DD についてのビルドの結果 vxv_x は vx(t)=ft(vx∣D(t))=ft′(vx∣D′(t))v_x(t) = f_t(v_x|_{D(t)}) = f'_t(v_x|_{D'(t)}) を満たすので、定理 4.1 (a) の一意性より、D′D' についてのビルドの結果でもあります。D′D' の依存グラフは DD の依存グラフに辺を足したものなので、CC から到達できるタスクの集合は小さくなりません。

宣言の漏れは結果を誤らせ、過剰な宣言は再実行を増やすだけです。この非対称性が、「迷ったら依存に入れる」「ビルドシステムの側で宣言していないファイルを読めなくする(サンドボックス)」という設計の根拠です。

4.3. 早期打ち切り:内容で比べる

Section titled “4.3. 早期打ち切り:内容で比べる”

定理 4.2 の RR は、「変更が届きうる」タスクの集合です。実際には、コメントだけを直したソースを再コンパイルしても同じ出力が得られることがあり、そのときは下流を作り直す必要がありません。これを使うのが早期打ち切り(early cutoff)です。

命題 4.5(内容の比較による早期打ち切り)✓

定理 4.2 の設定で、次の手続きを考える。ストアが vxv_x である状態から入力の部分を x′x' に書き換え、すべてのタスクを依存グラフのトポロジカル順に 1 つずつ見ていく。タスク tt について、D(t)D(t) のすべての元 uu で現在の値 s(u)s(u) が前回の値 vx(u)v_x(u) に等しければ tt を実行せず、そうでなければ実行する。このとき次が成り立つ。

(a) 最後のストアは vx′v_{x'} に一致する。

(b) 実行されるタスクはすべて RR に属する。

(c) 値の比較を、H(s(u))=H(vx(u))H(s(u)) = H(v_x(u)) という内容ハッシュの比較で置き換えても、HH がビルドに現れる値の集合の上で単射であるかぎり、(a)(b) は変わらない。

特に、あるタスク tt を再実行した出力が前回と同じ vx(t)v_x(t) であれば、tt だけを通って変更が届くタスクは再実行されない。

証明(命題 4.5)

トポロジカル順 σ\sigma を固定し、「σ\sigma の順で kk 番目までの頂点 uu を処理した時点で、それらの uu について s(u)=vx′(u)s(u) = v_{x'}(u) が成り立ち、以後変わらない」ことを kk についての完全帰納法(定理 4.3[証明の技術])で示します。入力については s(u)=x′(u)=vx′(u)s(u) = x'(u) = v_{x'}(u) です。タスク tt を見るとき、D(t)D(t) の元はすべて処理済みなので、帰納法の仮定より s∣D(t)=vx′∣D(t)s|_{D(t)} = v_{x'}|_{D(t)} です。

  • tt を実行しない場合。条件より s∣D(t)=vx∣D(t)s|_{D(t)} = v_x|_{D(t)} なので、vx′∣D(t)=vx∣D(t)v_{x'}|_{D(t)} = v_x|_{D(t)} です。よって vx′(t)=ft(vx′∣D(t))=ft(vx∣D(t))=vx(t)v_{x'}(t) = f_t(v_{x'}|_{D(t)}) = f_t(v_x|_{D(t)}) = v_x(t) で、これはストアに残っている値 s(t)s(t) です。
  • tt を実行する場合。密閉性より、書き込まれる値は ft(s∣D(t))=ft(vx′∣D(t))=vx′(t)f_t(s|_{D(t)}) = f_t(v_{x'}|_{D(t)}) = v_{x'}(t) です。

どちらの場合も s(t)=vx′(t)s(t) = v_{x'}(t) となり、(a) が示されました。

(b) 「実行されるタスクは RR に属する」を、同じく σ\sigma の順についての完全帰納法で示します。tt が実行されるなら、s(u)≠vx(u)s(u) \ne v_x(u) となる u∈D(t)u \in D(t) があります。uu が入力なら x′(u)≠x(u)x'(u) \ne x(u)、すなわち u∈Cu \in C で、辺 (u,t)(u, t) により t∈Rt \in R です。uu がタスクなら、uu は実行されています(実行されなかったタスクの値は vx(u)v_x(u) のままだからです)。帰納法の仮定より u∈Ru \in R で、CC の元から uu への歩道に辺 (u,t)(u, t) を継ぎ足して t∈Rt \in R です。

(c) HH が単射なら、H(a)=H(b)H(a) = H(b) と a=ba = b は同値なので、手続きの分岐はまったく同じになります。

最後の主張は手続きの定義そのものです。tt の出力が vx(t)v_x(t) のままなら、tt を依存にもつタスク t′t' の判定で u=tu = t は「変わっていない」と数えられ、ほかの依存も変わっていなければ t′t' は実行されません。

HH の単射性は、暗号学的ハッシュ関数の衝突困難性(定義 2.1[バージョン管理システム Git])で置き換えて使います。bb ビットのハッシュで NN 個の値を扱うとき、どこかで衝突が起きる確率は N2/2b+1N^2 / 2^{b+1} 以下です(命題 2.2[バージョン管理システム Git])。SHA-256 で N=109N = 10^{9} なら 1018/2257<10−5910^{18} / 2^{257} < 10^{-59} で、ハードウェアの誤りの確率よりはるかに小さい値です。

例 4.6(更新時刻による判定が誤る場合)

Make の規則は「tt が存在しないか、D(t)D(t) のどれかの更新時刻が tt の更新時刻より新しければ、tt を実行する」です。この規則には 3 つの弱点があります。

  1. 打ち切れない。 タスク uu を再実行すると、出力の内容が同じでも uu の更新時刻は新しくなります。uu を依存にもつ tt は必ず再実行され、その下流もすべて再実行されます。更新時刻は「内容が変わったか」ではなく「書き込まれたか」しか伝えないからです。
  2. 古い時刻の付いた新しい内容。 時計が遅れている計算機で編集したファイルを持ち込んだ場合や、更新時刻を保存する展開(tar の既定の動作、cp -p)で別の版のソースを上書きした場合、内容は変わっているのに更新時刻が成果物より古くなり、再実行されません。
  3. 同じ時刻の書き込み。 更新時刻の分解能が 1 秒のファイルシステムで、成果物を作った直後の同じ 1 秒のうちにソースをもう一度書き換えると、2 つの更新時刻は等しく、「新しい」とは判定されません。

2 と 3 は、変更が検出されないので、古い成果物が残ります。内容ハッシュによる判定は、時計を使わないので、この 3 つのいずれも起こしません。代わりに、判定のたびにファイルを読んでハッシュを計算する手間がかかります。実装では「大きさと更新時刻が前回と同じならハッシュの計算を省く」という折衷がよく使われますが、これは 2 と 3 の弱点を一部引き継ぎます。

早期打ち切りは出力の内容を見ています。内容を見ない方法では、RR より小さくはできません。

命題 4.7(内容を見ない方法では到達可能集合が最小)✓

V\mathcal{V} を有限バイト列の全体とし、ビルドの記述の形 (I,T,D)(I, T, D)(依存グラフは DAG)を固定する。変更された入力の集合 C⊆IC \subseteq I だけから、再実行するタスクの集合 ρ(C)⊆T\rho(C) \subseteq T を決める規則 ρ\rho を考える。ρ\rho が、密閉的になるようなすべての f=(ft)t∈Tf = (f_t)_{t \in T} と、{i:x(i)≠x′(i)}=C\{i : x(i) \ne x'(i)\} = C を満たすすべての x,x′x, x' について「vxv_x から始めて入力を x′x' に書き換え、ρ(C)\rho(C) のタスクをトポロジカル順に実行すると vx′v_{x'} になる」を満たすならば、すべての CC について ρ(C)⊇R\rho(C) \supseteq R である(RR は 定理 4.2 のもの)。

証明(命題 4.7)

ある CC について t∗∈R∖ρ(C)t^{*} \in R \setminus \rho(C) があったとして、規則が誤るような f,x,x′f, x, x' を構成します。

NN の元に通し番号を付けておき、有限個のバイト列の並び (a1,…,am)(a_1, \ldots, a_m) を 1 つのバイト列に単射に符号化する写像 enc\mathrm{enc} を取ります(各 aja_j の前にその長さを固定幅で書いて連結すればよい)。各タスク tt について

ft(a)=enc((a(u))u∈D(t))(D(t) の元は通し番号の順に並べる)f_t(a) = \mathrm{enc}\bigl((a(u))_{u \in D(t)}\bigr) \qquad (\text{$D(t)$ の元は通し番号の順に並べる})

と定めます。これは依存の値だけの関数なので、密閉的な記述を与えます。入力は、x(i)=0x(i) = \texttt{0}(すべての ii)、x′(i)=1x'(i) = \texttt{1}(i∈Ci \in C)、x′(i)=0x'(i) = \texttt{0}(i∉Ci \notin C)とします。

トポロジカル順 σ\sigma を固定し、「t∈Rt \in R ならば vx′(t)≠vx(t)v_{x'}(t) \ne v_x(t)」を σ(t)\sigma(t) についての完全帰納法(定理 4.3[証明の技術])で示します。t∈Rt \in R なら、ある c∈Cc \in C から tt への歩道 (u0=c,u1,…,uk=t)(u_0 = c, u_1, \ldots, u_k = t)、k≥1k \ge 1 があります。最後の辺の始点 u=uk−1u = u_{k-1} は D(t)D(t) に属します。k=1k = 1 なら u=c∈Cu = c \in C で、vx′(u)=1≠0=vx(u)v_{x'}(u) = \texttt{1} \ne \texttt{0} = v_x(u) です。k≥2k \ge 2 なら uu は cc から到達できるタスクなので u∈Ru \in R で、σ(u)<σ(t)\sigma(u) < \sigma(t) と帰納法の仮定より vx′(u)≠vx(u)v_{x'}(u) \ne v_x(u) です。どちらの場合も、並び (vx′(u′))u′∈D(t)(v_{x'}(u'))_{u' \in D(t)} と (vx(u′))u′∈D(t)(v_x(u'))_{u' \in D(t)} は uu の成分で異なり、enc\mathrm{enc} は単射なので vx′(t)≠vx(t)v_{x'}(t) \ne v_x(t) です。

t∗t^{*} は再実行されないので、増分ビルドの後もストアの値は vx(t∗)v_x(t^{*}) のままです。t∗∈Rt^{*} \in R なので vx(t∗)≠vx′(t∗)v_x(t^{*}) \ne v_{x'}(t^{*}) で、結果は vx′v_{x'} に一致しません。これは ρ\rho についての仮定に反します。

したがって、「依存グラフと変更されたファイルの一覧」だけで再実行の範囲を決める方式(定理 4.2)はそれ以上削れず、さらに削るには 命題 4.5 のように出力の内容を見るしかありません。

内容ハッシュと早期打ち切りをもつ最小のビルド系を書いて、§4 の命題が予言する再実行の集合と比べます。題材は、2 つの文書 a.md・b.md とスタイルシートからページを作る小さなサイトです(§2 の図)。文書は「環境 id の並び | 本文」という 1 行で表し、parse_* は id の並びだけを取り出し、index はそれを集め、page_* は本文・索引・スタイルから 1 ページを作ります。タスクは純粋関数で、状態(前回の値と、前回の依存のハッシュ)は JSON にできる辞書です。

# Python 3.9.6(標準ライブラリのみ)
import hashlib, json
def h(value): # 内容ハッシュ
return hashlib.sha256(value.encode()).hexdigest()[:12]
# タスク = (宣言した依存の並び, 純粋関数)。定義した順がトポロジカル順になっている
TASKS = {
"parse_a": (["a.md"], lambda d: "ids=" + d["a.md"].split("|")[0]),
"parse_b": (["b.md"], lambda d: "ids=" + d["b.md"].split("|")[0]),
"index": (["parse_a", "parse_b"], lambda d: d["parse_a"] + ";" + d["parse_b"]),
"page_a": (["a.md", "index", "style.css"], lambda d: "A[" + d["a.md"] + "][" + d["index"] + "][" + d["style.css"] + "]"),
"page_b": (["b.md", "index", "style.css"], lambda d: "B[" + d["b.md"] + "][" + d["index"] + "][" + d["style.css"] + "]"),
"site": (["page_a", "page_b"], lambda d: d["page_a"] + "+" + d["page_b"]),
}
def build(inputs, state):
"""state: {"values": 名前→値, "keys": タスク→前回の依存ハッシュの並び}。再実行したタスクの一覧を返す"""
values, keys, ran = dict(inputs), {}, []
for name, (deps, fn) in TASKS.items():
key = [h(values[u]) for u in deps]
if state["keys"].get(name) == key: # 依存の内容が前回と同じ → 再実行しない
values[name] = state["values"][name]
else:
values[name] = fn({u: values[u] for u in deps})
ran.append(name)
keys[name] = key
state["values"], state["keys"] = values, keys
return ran
state = {"values": {}, "keys": {}}
src = {"a.md": "thm-x,def-y|本文その一", "b.md": "lem-z|本文その二", "style.css": "serif"}
print("1 回目 :", build(src, state))
print("変更なし :", build(src, state))
src["a.md"] = "thm-x,def-y|本文を書き直した" # id の並びは同じで本文だけ変える
print("a.md の本文 :", build(src, state))
src["a.md"] = "thm-x,def-y,cor-w|本文を書き直した" # id を 1 つ足す
print("a.md の id :", build(src, state))
src["style.css"] = "sans"
print("style.css :", build(src, state))
clean = {"values": {}, "keys": {}}
build(src, clean)
print("クリーンビルドと一致:", clean["values"] == state["values"])
1 回目 : ['parse_a', 'parse_b', 'index', 'page_a', 'page_b', 'site']
変更なし : []
a.md の本文 : ['parse_a', 'page_a', 'site']
a.md の id : ['parse_a', 'index', 'page_a', 'page_b', 'site']
style.css : ['page_a', 'page_b', 'site']
クリーンビルドと一致: True

読み方は次のとおりです。

  • a.md から到達できるタスクは parse_a・index・page_a・page_b・site の 5 つで、これが 定理 4.2 の RR です。
  • 本文だけを変えた 3 回目は、parse_a を再実行した出力(id の並び)が前回と同じなので、index と page_b が実行されません。実行された 3 つは RR の部分集合で、命題 4.5 (b) のとおりです。page_a は a.md を直接読むので再実行されます。
  • id を足した 4 回目は parse_a の出力が変わり、index を経て page_b まで届きます。別の文書の id の追加が page_b に届くのは、ページが索引(相互参照の解決の結果)を読むからです。この構造は 事例研究 でもう一度現れます。
  • 最後に、増分ビルドを 4 回重ねたストアが、同じ入力のクリーンビルドの結果と一致しています(命題 4.5 (a))。

次に、宣言していない依存を入れます。page が、依存に書いていない footer.txt を読むようにしたものです(h と build は上と同じ)。

ENV = {"footer.txt": "(c) 2025"} # タスクが宣言せずに読むファイル
TASKS = {
"page": (["a.md"], lambda d: d["a.md"] + " / " + ENV["footer.txt"]), # footer.txt を宣言していない
"site": (["page"], lambda d: "<" + d["page"] + ">"),
}
state = {"values": {}, "keys": {}}
src = {"a.md": "本文"}
build(src, state)
ENV["footer.txt"] = "(c) 2026" # 宣言されていない依存だけを変える
print("増分ビルドで再実行:", build(src, state))
clean = {"values": {}, "keys": {}}
build(src, clean)
print("増分 :", state["values"]["site"])
print("クリーン:", clean["values"]["site"])
print("一致:", state["values"] == clean["values"])
増分ビルドで再実行: []
増分 : <本文 / (c) 2025>
クリーン: <本文 / (c) 2026>
一致: False

増分ビルドは何も再実行せず、古いフッタが残りました。例 4.3 と同じ現象です。上のコードのように、タスクに渡す辞書を宣言した依存だけに限っておけば、宣言していない名前を d[...] で読もうとした時点で KeyError になります。大域変数や環境を通る読み込みは、この仕組みでは防げません。本物のビルドシステムがサンドボックスを使うのはそのためです。

6. 並列ビルドの時間:仕事量と最長の依存鎖

Section titled “6. 並列ビルドの時間:仕事量と最長の依存鎖”

定理 4.1 より、結果は実行順によらないので、依存のないタスクは同時に実行できます。並列度を上げるとどこまで速くなるかを評価します。

定理 6.1(並列ビルドの時間の上下界)✓

依存グラフが DAG であるビルドの記述で、各タスク tt の所要時間を wt>0w_t > 0 とする。W=∑t∈TwtW = \sum_{t \in T} w_t を仕事量、タスクだけを通る有向の道 (t1,…,tk)(t_1, \ldots, t_k) についての ∑jwtj\sum_j w_{t_j} の最大値 LL を最長の依存鎖(クリティカルパス)の長さとする。

p≥1p \ge 1 台のワーカーによるスケジュールとは、各タスク tt に開始時刻 βt≥0\beta_t \ge 0 を割り当て、tt が区間 [βt,βt+wt)[\beta_t, \beta_t + w_t) の間 1 台を占有し、(i) どの時刻にも実行中のタスクは pp 個以下、(ii) u∈D(t)∩Tu \in D(t) \cap T ならば βu+wu≤βt\beta_u + w_u \le \beta_t、を満たすものをいう。完了時刻を Θ=max⁡t(βt+wt)\Theta = \max_t (\beta_t + w_t) とする。

(a) どのスケジュールも Θ≥max⁡(W/p, L)\Theta \ge \max(W/p,\ L) を満たす。

(b) スケジュールが貪欲である、すなわち「依存がすべて完了していてまだ開始されていないタスクがある時刻には、pp 台すべてが実行中である」ならば、

Θ≤Wp+L(1−1p)\Theta \le \frac{W}{p} + L\Bigl(1 - \frac{1}{p}\Bigr)

が成り立つ。

(c) 貪欲なスケジュールの完了時刻は、最適なスケジュールの完了時刻の (2−1/p)(2 - 1/p) 倍以下である。

(b) の証明を時間軸の絵で言うと次のとおりです。まず、最後に終わったタスクから、いつも「最後に終わった依存」を選んでさかのぼり、1 本の依存鎖を作ります。鎖のタスクが動いている時間は少なくとも 1 台が働いていて、その合計は LL 以下です。鎖のタスクが動いていない隙間の時間は、貪欲なので全 pp 台が埋まっています。この 2 種類の時間で仕事量 WW を数えると、完了時刻の上界が出ます。

証明(定理 6.1)

時刻 τ\tau に実行中のタスクの個数を m(τ)m(\tau) と書きます。mm は区分的に定数で、0≤m(τ)≤p0 \le m(\tau) \le p、∫0Θm(τ) dτ=∑twt=W\int_0^{\Theta} m(\tau)\, d\tau = \sum_t w_t = W です(各タスク tt は長さ wtw_t の区間で 11 ずつ寄与します)。

(a)。 m(τ)≤pm(\tau) \le p より W=∫0Θm≤p ΘW = \int_0^{\Theta} m \le p\,\Theta、すなわち Θ≥W/p\Theta \ge W/p です。また、LL を与える道 (t1,…,tk)(t_1, \ldots, t_k) では tj∈D(tj+1)t_j \in D(t_{j+1}) なので、条件 (ii) より βtj+wtj≤βtj+1\beta_{t_j} + w_{t_j} \le \beta_{t_{j+1}} です。これを順に使うと βtk+wtk≥βt1+∑jwtj≥L\beta_{t_k} + w_{t_k} \ge \beta_{t_1} + \sum_j w_{t_j} \ge L となり、Θ≥L\Theta \ge L です。

(b)。 最後に完了するタスクを t1t_1 とします(βt1+wt1=Θ\beta_{t_1} + w_{t_1} = \Theta)。tjt_j まで決めたとき、D(tj)∩T=∅D(t_j) \cap T = \emptyset なら止め、そうでなければ D(tj)∩TD(t_j) \cap T の元のうち完了時刻が最も遅いものを tj+1t_{j+1} とします。タスクは有限個で、tj+1t_{j+1} から tjt_j へ辺があるので、DAG の上でこの操作は有限回で止まります。止まったときの添字を kk とすると、(tk,tk−1,…,t1)(t_k, t_{k-1}, \ldots, t_1) はタスクだけを通る有向の道で、∑j=1kwtj≤L\sum_{j=1}^{k} w_{t_j} \le L です。

区間 [0,Θ)[0, \Theta) を、鎖のタスクの実行区間 Aj=[βtj,βtj+wtj)A_j = [\beta_{t_j}, \beta_{t_j} + w_{t_j})(j=1,…,kj = 1, \ldots, k)と、その隙間

Bk=[0,βtk),Bj=[βtj+1+wtj+1, βtj)(j=1,…,k−1)B_k = [0, \beta_{t_k}), \qquad B_j = [\beta_{t_{j+1}} + w_{t_{j+1}},\ \beta_{t_j}) \quad (j = 1, \ldots, k-1)

に分けます。条件 (ii) より βtj+1+wtj+1≤βtj\beta_{t_{j+1}} + w_{t_{j+1}} \le \beta_{t_j} なので、これらは互いに交わらず、合わせると [0,Θ)[0, \Theta) になります。

隙間 BjB_j の時刻 τ\tau では、m(τ)=pm(\tau) = p です。実際、j≤k−1j \le k - 1 のとき、tj+1t_{j+1} は tjt_j の依存のうち最後に完了するものなので、τ≥βtj+1+wtj+1\tau \ge \beta_{t_{j+1}} + w_{t_{j+1}} では tjt_j の依存はすべて完了しています。τ<βtj\tau < \beta_{t_j} なので tjt_j はまだ開始されていません。貪欲であることから、この時刻には pp 台すべてが実行中です。j=kj = k のときは、tkt_k がタスクの依存をもたないので、時刻 00 から依存はすべて完了しており、同じ結論になります。

鎖の実行区間 AjA_j の時刻 τ\tau では、少なくとも tjt_j が実行中なので m(τ)≥1m(\tau) \ge 1 です。a=∑jwtja = \sum_j w_{t_j}(AjA_j の長さの合計)とおくと、隙間の長さの合計は Θ−a\Theta - a で、

W=∫0Θm(τ) dτ ≥ p (Θ−a)+1⋅a.W = \int_0^{\Theta} m(\tau)\, d\tau \ \ge\ p\,(\Theta - a) + 1 \cdot a .

これを Θ\Theta について解くと Θ≤W/p+a(1−1/p)\Theta \le W/p + a(1 - 1/p) で、a≤La \le L より主張が従います。

(c)。 最適なスケジュールの完了時刻を Θ∗\Theta^{*} とすると、(a) より W/p≤Θ∗W/p \le \Theta^{*} かつ L≤Θ∗L \le \Theta^{*} です。(b) に代入して Θ≤Θ∗+Θ∗(1−1/p)=(2−1/p) Θ∗\Theta \le \Theta^{*} + \Theta^{*}(1 - 1/p) = (2 - 1/p)\,\Theta^{*} です。

(a) と (b) を並べると、並列化の効き方が見えます。p≤W/Lp \le W/L の範囲では下界が W/pW/p で、台数にほぼ比例して速くなります。p≥W/Lp \ge W/L では下界が LL になり、(b) の上界は L(2−1/p)L(2 - 1/p) 以下、したがって 2L2L 未満なので、そこから先は台数をいくら増やしても時間は半分にもなりません。W/LW/L は「このビルドが使い切れる並列度」の目安で、ビルドを速くしたければ、台数を足す前に最長の依存鎖を短くする(大きなタスクを分ける、不要な依存を外す)のが先です。所要時間がすべて 11 の場合の同じ型の評価(上界は ⌊W/p⌋+L\lfloor W/p \rfloor + L の形)は 命題 4.2[並列化と GPU] にあります。

貪欲なスケジュールが最適とは限らないことは、演習(演習 9.2)で確かめます。

乱数で作った依存グラフで、貪欲なスケジュールの長さを両側の評価と比べます。

# Python 3.9.6(標準ライブラリのみ)/乱数種 20261008
import heapq, random
rng = random.Random(20261008)
n = 300
w = [rng.randint(1, 10) for _ in range(n)] # タスクの所要時間
deps = [[u for u in range(max(0, v - 30), v) if rng.random() < 0.06] for v in range(n)]
users = [[] for _ in range(n)]
for v in range(n):
for u in deps[v]:
users[u].append(v)
W = sum(w)
longest = [0] * n # v で終わる依存鎖の所要時間の最大
for v in range(n):
longest[v] = w[v] + max((longest[u] for u in deps[v]), default=0)
L = max(longest)
def greedy(p):
missing = [len(d) for d in deps]
ready = [v for v in range(n) if missing[v] == 0] # 番号の小さい順に取り出す
heapq.heapify(ready)
running, now, idle = [], 0, p # running: (終了時刻, タスク)
while ready or running:
while ready and idle > 0: # 空きがあれば実行可能なタスクを始める
v = heapq.heappop(ready)
heapq.heappush(running, (now + w[v], v))
idle -= 1
now, v = heapq.heappop(running) # 次の終了まで時刻を進める
idle += 1
for x in users[v]:
missing[x] -= 1
if missing[x] == 0:
heapq.heappush(ready, x)
return now
print(f"n = {n}, 辺 = {sum(map(len, deps))}, W = {W}, L = {L}, W/L = {W / L:.2f}")
print(" p 下界 max(W/p, L) 貪欲 上界 W/p + L(1-1/p) 貪欲/下界")
for p in (1, 2, 4, 8, 16, 32, 64):
lo, t, up = max(W / p, L), greedy(p), W / p + L * (1 - 1 / p)
assert lo <= t <= up
print(f"{p:3d} {lo:14.1f} {t:10d} {up:16.1f} {t / lo:13.3f}")
n = 300, 辺 = 505, W = 1538, L = 206, W/L = 7.47
p 下界 max(W/p, L) 貪欲 上界 W/p + L(1-1/p) 貪欲/下界
1 1538.0 1538 1538.0 1.000
2 769.0 772 872.0 1.004
4 384.5 387 539.0 1.007
8 206.0 214 372.5 1.039
16 206.0 206 289.2 1.000
32 206.0 206 247.6 1.000
64 206.0 206 226.8 1.000

貪欲なスケジュールの長さは、すべての pp で定理の両側の評価の間にあります。W/L=7.47W/L = 7.47 を超える p=8p = 8 以降は下界が L=206L = 206 に張り付き、p=16p = 16 以上では完了時刻が変わりません。この 1 つのグラフでは貪欲な方法が下界にほぼ届いていますが、それは定理が保証することではありません。保証されるのは上界までです。

7. キャッシュと再現可能なビルド

Section titled “7. キャッシュと再現可能なビルド”

増分ビルドは「自分の前回の結果」を再利用します。同じ考えを「誰かがどこかで作った結果」に広げたものがビルドキャッシュです。再利用してよいかどうかを決めるのがキャッシュのキーです。

定義 7.1(再現可能なビルドとキャッシュのキー)↗

ビルドが再現可能であるとは、同じ入力の状態 xx から、いつ・どの計算機で実行しても、ビット単位で同じ成果物が得られることをいう。

各タスク tt に、その定義の直列化 δt\delta_t(実行するコマンド、道具の版、オプションなど、ftf_t と依存の並びを決める情報を 1 つのバイト列にしたもの)が与えられているとする。enc\mathrm{enc} をバイト列の並びの単射な符号化、HH をハッシュ関数として、ストア ss のもとでのタスク tt のキャッシュのキーを

κ(t,s)=H(enc(δt, (H(s(u)))u∈D(t)))\kappa(t, s) = H\Bigl(\mathrm{enc}\bigl(\delta_t,\ (H(s(u)))_{u \in D(t)}\bigr)\Bigr)

と定める。キャッシュは、キーから出力の値への対応表である。

密閉的なビルドは、定理 4.1 より再現可能です。再現可能でないビルドは、密閉性のどこかが破れています。成果物に埋め込まれた日時、ファイルの列挙順、作業ディレクトリの絶対パス、並列実行の順序などが典型です。

命題 7.2(キャッシュのキーの健全性)✓

2 つのビルドの記述(同じものでもよい)がともに密閉的で、定義の直列化が等しいタスクは同じ依存の並びと同じ関数をもつ(δt=δt′\delta_t = \delta_{t'} ならば ft=ft′f_t = f_{t'})とする。HH は、現れる値と符号化の全体の上で単射とする。

(a) κ(t,s)=κ(t′,s′)\kappa(t, s) = \kappa(t', s') ならば、ストア ss のもとで tt を実行した出力と、ストア s′s' のもとで t′t' を実行した出力は等しい。したがって、キャッシュに κ(t,s)\kappa(t, s) の項目があればその値を使ってよく、結果は再実行した場合と変わらない。

(b) 入力の状態 xx に対し、キーを再帰的に

kx(i)=H(enc(input,x(i)))  (i∈I),kx(t)=H(enc(δt, (kx(u))u∈D(t)))  (t∈T)k_x(i) = H\bigl(\mathrm{enc}(\texttt{input}, x(i))\bigr)\ \ (i \in I), \qquad k_x(t) = H\Bigl(\mathrm{enc}\bigl(\delta_t,\ (k_x(u))_{u \in D(t)}\bigr)\Bigr)\ \ (t \in T)

と定める。kx(t)=kx′(t′)k_x(t) = k_{x'}(t') ならば、ビルドの結果について vx(t)=vx′(t′)v_x(t) = v_{x'}(t') である。

証明(命題 7.2)

(a)。 HH と enc\mathrm{enc} は単射なので、κ(t,s)=κ(t′,s′)\kappa(t, s) = \kappa(t', s') から δt=δt′\delta_t = \delta_{t'} と、依存の並びの各成分について H(s(uj))=H(s′(uj′))H(s(u_j)) = H(s'(u'_j)) が従います。再び HH の単射性より s(uj)=s′(uj′)s(u_j) = s'(u'_j) です。仮定より ft=ft′f_t = f_{t'} で、引数の並びも等しいので、密閉性(定義 2.2)より 2 つの出力は等しくなります。

(b)。 頂点 uu の深さを、uu で終わる歩道の長さの最大値とします(DAG なので有限で、入力の深さは 00、タスクの深さは依存の深さの最大値に 11 を足したものです)。「kx(u)=kx′(u′)k_x(u) = k_{x'}(u') ならば vx(u)=vx′(u′)v_x(u) = v_{x'}(u')」を、uu の深さについての完全帰納法(定理 4.3[証明の技術])で示します。

HH と enc\mathrm{enc} の単射性より、kx(u)=kx′(u′)k_x(u) = k_{x'}(u') ならばハッシュする前の並びが等しくなります。uu が入力なら、先頭の成分が input\texttt{input} なので u′u' も入力で(タスクの δ\delta は input\texttt{input} と異なるように取っておきます)、x(u)=x′(u′)x(u) = x'(u')、すなわち vx(u)=vx′(u′)v_x(u) = v_{x'}(u') です。uu がタスクなら、δu=δu′\delta_u = \delta_{u'} で、依存の並びの各成分について kx(uj)=kx′(uj′)k_x(u_j) = k_{x'}(u'_j) です。uju_j の深さは uu の深さより小さいので、帰納法の仮定より vx(uj)=vx′(uj′)v_x(u_j) = v_{x'}(u'_j) です。fu=fu′f_u = f_{u'} なので、定理 4.1 (a) の等式より vx(u)=fu((vx(uj))j)=fu′((vx′(uj′))j)=vx′(u′)v_x(u) = f_u\bigl((v_x(u_j))_j\bigr) = f_{u'}\bigl((v_{x'}(u'_j))_j\bigr) = v_{x'}(u') です。

(b) のキーは、Git のオブジェクト ID と同じ作り方をしています。子のキーを含めて直列化したものをハッシュするので、キーが一致すれば、そこからさかのぼれる入力とタスクの定義がすべて一致します。これは 定理 3.3[バージョン管理システム Git] と同じ構造の主張で、証明も同じ帰納法です。

2 つのキーには使い分けがあります。(b) のキーは、何も実行しなくても入力だけから計算できるので、最終成果物のキーを先に計算して、遠隔のキャッシュから中間生成物を作らずに取ってこられます。(a) のキーは依存の値を見るので、命題 4.5 の早期打ち切りが効きます(§5 のコードは (a) の方式です)。参照透過な式の同じ出現を 1 つの値で置き換えてよいという 命題 6.4[プログラミング言語論] を、プロセスと計算機をまたいで行っているのがビルドキャッシュだと言えます。

例 7.3(キーに入れ忘れた入力:コンパイラの版)

δt\delta_t を「cc -O2 -c main.c」というコマンド文字列だけにしたとします。計算機のコンパイラを新しい版に上げると、同じソースから違う機械語が出るようになりますが、キーは変わりません。キャッシュは古いコンパイラで作ったオブジェクトを返し、新しく作ったオブジェクトと混ざった実行ファイルができます。

これは 命題 7.2 の仮定「δt\delta_t が等しければ ftf_t が等しい」が破れた例です。コンパイラは、このタスクの宣言されていない依存でした。直し方は、コンパイラの実行ファイルの内容ハッシュ(か、少なくとも版の文字列)を δt\delta_t に入れることです。同じ理由で、キャッシュに影響しうる環境変数・対象のアーキテクチャ・ビルドシステム自身の版もキーに入れます。

例 7.4(コンテナのレイヤキャッシュは鎖の上の増分ビルド)

コンテナイメージ(定義 4.1[Docker と Kubernetes])は命令の列 I1,…,InI_1, \ldots, I_n から作られ、ii 番目のレイヤは i−1i - 1 番目までの結果と IiI_i から決まります。依存グラフは 1 本の鎖 L1→L2→⋯→LnL_1 \to L_2 \to \cdots \to L_n で、各 LiL_i はさらに自分の命令(とコピーするファイル)を入力にもちます。

jj 番目の命令を変えると、そこから到達できるタスクは Lj,Lj+1,…,LnL_j, L_{j+1}, \ldots, L_n です。定理 4.2 の RR がちょうど後ろの切片になり、これは「キャッシュが効くのは前置部分だけ」という 命題 4.2[Docker と Kubernetes] と同じ内容です。変わりにくい命令(依存ライブラリの導入)を前に、変わりやすい命令(自分のソースのコピー)を後ろに置くという定石は、鎖の上で RR を小さくする工夫です。

注意 7.5

ビルドシステムの設計を「実行の順序をどう決めるか(スケジューラ)」と「再実行するかどうかをどう判定するか(再構築の判定)」の 2 軸で分類し、Make・Shake・Bazel・Excel などを同じ枠組みで説明したのが、Mokhov・Mitchell・Peyton Jones の論文です(参考文献)。この記事の更新時刻・内容ハッシュ・2 種類のキーは、その分類の「再構築の判定」の代表例に当たります。実行して初めて依存が分かるタスク(動的な依存)や、多数の計算機でタスクを分担する分散ビルドは、この記事では扱いません。

8. 外部の依存:版の解決とロックファイル

Section titled “8. 外部の依存:版の解決とロックファイル”

ここまでは、自分のリポジトリの中の依存でした。外部のライブラリには版があり、「どの版を使うか」を決める問題が加わります。

定義 8.1(版の制約と依存の解決)↗

次の組を依存の解決問題の入力という。

  • パッケージの有限集合 PP と、各 a∈Pa \in P の版の有限集合 Va⊆NV_a \subseteq \mathbb{N}。
  • 各パッケージ aa の各版 v∈Vav \in V_a がもつ制約の有限集合 Req(a,v)\mathrm{Req}(a, v)。制約は組 (b,S)(b, S)(b∈Pb \in P、S⊆VbS \subseteq V_b)で、「bb が必要で、その版は SS に属すること」を表す。
  • 作ろうとしているプログラム自身の制約の有限集合 Req0\mathrm{Req}_0(根の制約)。

部分写像 ρ:P⇀N\rho : P \rightharpoonup \mathbb{N} で、ρ(a)∈Va\rho(a) \in V_a であり、

  • (R1) (b,S)∈Req0(b, S) \in \mathrm{Req}_0 ならば、bb は ρ\rho の定義域に属し ρ(b)∈S\rho(b) \in S、
  • (R2) aa が ρ\rho の定義域に属し (b,S)∈Req(a,ρ(a))(b, S) \in \mathrm{Req}(a, \rho(a)) ならば、bb は ρ\rho の定義域に属し ρ(b)∈S\rho(b) \in S、

を満たすものを解決という。ρ\rho は各パッケージに高々 1 つの版を選ぶ。入力(SS は元を列挙して表す)が与えられたとき、解決が存在するかを問う判定問題を RESOLVE\mathrm{RESOLVE} と書く。

定理 8.2(版の解決は NP 完全)✓

RESOLVE\mathrm{RESOLVE} は NP 完全である。

NP 困難性の証明は、3SAT の論理式をパッケージの入力に読み替えます。変数 xix_i をパッケージ XiX_i(版 1 が偽、版 2 が真)に、節 CjC_j をパッケージ KjK_j(版 rr は「節の rr 番目のリテラルで節を満たす」という選択)に対応させます。KjK_j の版を選ぶと、そのリテラルが真になるような版を XiX_i に要求するので、すべての節が満たされることと解決があることが一致します。§9 の演習の論理式での具体例を、次の図に描きました。

K1K2X1X2X3版1版2版3版1版2版3212121
演習の論理式(2 つの節)から作る入力。K1・K2 から出る 3 本の線は、上から順に版 1・2・3 を選んだときの制約で、線の先の数字は X に要求される版(1 が偽、2 が真)
証明(定理 8.2)

RESOLVE∈NP\mathrm{RESOLVE} \in \mathrm{NP}。 証明書として解決 ρ\rho そのもの(パッケージと版の組の一覧)を取ります。その長さは入力の長さ以下です。検証器は、(R1) を根の制約ごとに、(R2) を ρ\rho の定義域の各 aa と Req(a,ρ(a))\mathrm{Req}(a, \rho(a)) の各制約ごとに調べます。1 つの制約の検査は SS の列挙の中から ρ(b)\rho(b) を探すだけなので、全体は入力の長さの多項式時間です。定義 4.1[P≠NP 予想とは何か] より RESOLVE∈NP\mathrm{RESOLVE} \in \mathrm{NP} です。

NP 困難性。 3SAT\mathrm{3SAT}(定義 4.3[P≠NP 予想とは何か])から多項式時間多対一帰着(定義 5.1[P≠NP 予想とは何か])を作ります。変数 x1,…,xnx_1, \ldots, x_n の上の 3CNF 論理式 φ=C1∧⋯∧Cm\varphi = C_1 \land \cdots \land C_m、Cj=(ℓj,1∨ℓj,2∨ℓj,3)C_j = (\ell_{j,1} \lor \ell_{j,2} \lor \ell_{j,3}) に対し、次の入力 F(φ)F(\varphi) を作ります。

  • 各変数 xix_i にパッケージ XiX_i を置き、版を VXi={1,2}V_{X_i} = \{1, 2\} とする。版 22 が「真」、版 11 が「偽」を表す。どちらの版も制約をもたない。
  • 各節 CjC_j にパッケージ KjK_j を置き、版を VKj={1,2,3}V_{K_j} = \{1, 2, 3\} とする。版 rr の制約は 1 つだけで、ℓj,r=xi\ell_{j,r} = x_i なら (Xi,{2})(X_i, \{2\})、ℓj,r=¬xi\ell_{j,r} = \lnot x_i なら (Xi,{1})(X_i, \{1\}) とする。
  • 根の制約は (Kj,{1,2,3})(K_j, \{1, 2, 3\})(j=1,…,mj = 1, \ldots, m)とする。

パッケージは n+mn + m 個、制約は 4m4m 個で、F(φ)F(\varphi) は φ\varphi の長さの多項式時間で書き出せます。「φ\varphi が充足可能   ⟺  \iff F(φ)F(\varphi) に解決がある」を示します。

(⇒\Rightarrow)α\alpha が φ\varphi を充足するとします。ρ(Xi)=2\rho(X_i) = 2(α(xi)\alpha(x_i) が真のとき)、ρ(Xi)=1\rho(X_i) = 1(偽のとき)と定めます。各節 CjC_j には α\alpha のもとで真になるリテラルがあるので、その番号の 1 つを rjr_j として ρ(Kj)=rj\rho(K_j) = r_j と定めます。(R1) は ρ(Kj)∈{1,2,3}\rho(K_j) \in \{1, 2, 3\} なので成り立ちます。(R2) を確かめます。KjK_j の版 rjr_j の制約は、ℓj,rj=xi\ell_{j, r_j} = x_i なら (Xi,{2})(X_i, \{2\}) で、ℓj,rj\ell_{j,r_j} が真なので α(xi)\alpha(x_i) は真、よって ρ(Xi)=2\rho(X_i) = 2 です。ℓj,rj=¬xi\ell_{j,r_j} = \lnot x_i なら (Xi,{1})(X_i, \{1\}) で、α(xi)\alpha(x_i) は偽なので ρ(Xi)=1\rho(X_i) = 1 です。XiX_i は制約をもちません。よって ρ\rho は解決です。

(⇐\Leftarrow)ρ\rho が解決であるとします。割り当て α\alpha を「XiX_i が ρ\rho の定義域に属し ρ(Xi)=2\rho(X_i) = 2 のとき α(xi)\alpha(x_i) は真、それ以外は偽」と定めます。各 jj について、(R1) より KjK_j は定義域に属します。r=ρ(Kj)r = \rho(K_j) とおくと、(R2) より、ℓj,r=xi\ell_{j,r} = x_i なら ρ(Xi)=2\rho(X_i) = 2 なので α(xi)\alpha(x_i) は真で、リテラル ℓj,r\ell_{j,r} は真です。ℓj,r=¬xi\ell_{j,r} = \lnot x_i なら ρ(Xi)=1\rho(X_i) = 1 なので α(xi)\alpha(x_i) は偽で、リテラル ℓj,r\ell_{j,r} はやはり真です。どの節にも真のリテラルがあるので、α\alpha は φ\varphi を充足します。この向きで、ρ\rho が各 XiX_i に1 つの版しか選べないことを使っています。

以上で 3SAT≤pRESOLVE\mathrm{3SAT} \le_{\mathrm{p}} \mathrm{RESOLVE} です。3SAT\mathrm{3SAT} は NP 完全なので(系 6.5[P≠NP 予想とは何か])、すべての L∈NPL \in \mathrm{NP} について L≤p3SATL \le_{\mathrm{p}} \mathrm{3SAT} で、帰着の推移律(定理 5.2[P≠NP 予想とは何か] の 3)より L≤pRESOLVEL \le_{\mathrm{p}} \mathrm{RESOLVE} です。定義 6.1[P≠NP 予想とは何か] より RESOLVE\mathrm{RESOLVE} は NP 完全です。

難しさの源は、「1 つのパッケージに 1 つの版」という排他的な選択と、版ごとに異なる制約の組み合わせです。どちらかを外すと問題は易しくなります。

命題 8.3(多項式時間で解ける 2 つの変種)✓

(a) (版の共存を許す)組の集合 M⊆{(a,v):a∈P, v∈Va}M \subseteq \{(a, v) : a \in P,\ v \in V_a\} で、根の制約 (b,S)(b, S) ごとに (b,v′)∈M(b, v') \in M となる v′∈Sv' \in S があり、(a,v)∈M(a, v) \in M と (b,S)∈Req(a,v)(b, S) \in \mathrm{Req}(a, v) ごとに (b,v′)∈M(b, v') \in M となる v′∈Sv' \in S があるものを多重解決という。多重解決が存在するかどうかは多項式時間で判定できる。

(b) (下限だけの制約)すべての制約が (b,{v∈Vb:v≥m})(b, \{v \in V_b : v \ge m\})、m∈Vbm \in V_b の形(これを (b,≥m)(b, \ge m) と書く)であるとする。このとき解決は必ず存在し、制約の総数について線形の時間で 1 つ求まる。

証明(命題 8.3)

(a)。 M0M_0 をすべての組 (a,v)(a, v) の集合とします。MkM_k の中に、ある制約 (b,S)∈Req(a,v)(b, S) \in \mathrm{Req}(a, v) について「(b,v′)∈Mk(b, v') \in M_k となる v′∈Sv' \in S がない」ような組 (a,v)(a, v) があれば、それを 1 つ取り除いて Mk+1M_{k+1} とします。なければ止めて、そのときの集合を M∗M^{*} とします。取り除ける回数は組の総数以下で、1 回の探索は入力の長さの多項式時間なので、全体も多項式時間です。

止まったとき、M∗M^{*} は多重解決の 2 番目の条件を満たします。次に、任意の多重解決 MM について M⊆MkM \subseteq M_k を kk についての帰納法(定理 3.2[証明の技術])で示します。M⊆M0M \subseteq M_0 です。M⊆MkM \subseteq M_k とし、MkM_k から取り除かれる組を (a,v)(a, v)、その理由になった制約を (b,S)(b, S) とします。もし (a,v)∈M(a, v) \in M なら、MM が多重解決であることから (b,v′)∈M⊆Mk(b, v') \in M \subseteq M_k となる v′∈Sv' \in S があり、取り除かれる理由に反します。よって (a,v)∉M(a, v) \notin M で、M⊆Mk+1M \subseteq M_{k+1} です。

したがって、多重解決が存在すればそれは M∗M^{*} に含まれ、根の制約についての条件は集合を大きくしても保たれるので、M∗M^{*} も根の制約の条件を満たします。逆に M∗M^{*} が根の制約の条件を満たせば、M∗M^{*} 自身が多重解決です。よって「M∗M^{*} が根の制約の条件を満たすか」を調べれば判定できます。

(b)。 頂点を「根」とすべての組 (a,v)(a, v) とし、根から根の制約 (b,≥m)(b, \ge m) ごとに (b,m)(b, m) へ、(a,v)(a, v) から制約 (b,≥m)∈Req(a,v)(b, \ge m) \in \mathrm{Req}(a, v) ごとに (b,m)(b, m) へ辺を引いた有向グラフを考えます。根から到達できる組の集合を QQ とし、QQ に版が現れるパッケージ bb について

ρ(b)=max⁡{m:(b,m)∈Q}\rho(b) = \max\{m : (b, m) \in Q\}

と定めます。QQ は幅優先探索で、頂点と辺の数について線形の時間で求まります(定理 3.2[グラフアルゴリズム]、命題 3.3[グラフアルゴリズム])。

ρ\rho が解決であることを確かめます。(R1): 根の制約 (b,≥m)(b, \ge m) について (b,m)∈Q(b, m) \in Q なので、bb は定義域に属し ρ(b)≥m\rho(b) \ge m です。(R2): aa が定義域に属するとき、ρ(a)\rho(a) は最大値を与える元なので (a,ρ(a))∈Q(a, \rho(a)) \in Q です。制約 (b,≥m)∈Req(a,ρ(a))(b, \ge m) \in \mathrm{Req}(a, \rho(a)) について、(a,ρ(a))(a, \rho(a)) から (b,m)(b, m) へ辺があるので (b,m)∈Q(b, m) \in Q で、bb は定義域に属し ρ(b)≥m\rho(b) \ge m です。

(a) は、依存ごとに別の版を入れ子で持てる方式(Node.js の node_modules が原則として採る方式)に、(b) は Go のモジュールが採る「最小の版の選択」に対応します。(b) の ρ\rho は、要求された下限のうち最大のものを選ぶので、「誰かが明示的に要求した版」しか選ばれず、新しい版が公開されただけでは結果が変わりません。一方、上限や除外を書ける方式(多くの言語のパッケージ管理)は、定理 8.2 の難しさを引き受ける代わりに、既知の不具合のある版を避けるといった表現力を得ています。実務の解決器は、SAT ソルバと同じ探索の技法(後戻りと、衝突からの学習)を使います。

命題 8.4(ロックファイルによる取得の決定性)✓

解決 ρ\rho と、選ばれた各版の配布物の内容ハッシュを記録した表

ℓ={(a, ρ(a), ha):a は ρ の定義域に属する}\ell = \{(a,\ \rho(a),\ h_a) : a \text{ は } \rho \text{ の定義域に属する}\}

をロックファイルという。取得の手続きを「各 (a,v,h)∈ℓ(a, v, h) \in \ell について、配布元から aa の版 vv の内容 cc を取り、H(c)=hH(c) = h ならば採用し、そうでなければ失敗して止まる」と定める。HH が現れる内容の上で単射ならば、取得が成功したときに得られる内容の族 (ca)a(c_a)_a は ℓ\ell だけで決まり、取得の時刻・配布元の状態・取得する計算機によらない。

証明(命題 8.4)

2 回の取得がともに成功し、パッケージ aa についてそれぞれ cac_a、ca′c'_a を得たとします。手続きの定義より H(ca)=ha=H(ca′)H(c_a) = h_a = H(c'_a) で、HH の単射性より ca=ca′c_a = c'_a です。hah_a は ℓ\ell に書かれた値なので、cac_a は ℓ\ell だけで決まります。

取得した内容を入力 II に含めれば、定理 4.1 より、ビルドの結果は「自分のソースとロックファイル」の関数になります。ロックファイルをリポジトリに入れておけば、過去のコミットを取り出して同じ依存で作り直せます。

例 8.5(ロックファイルがないと、同じ宣言から時刻によって違う解が出る)

宣言が「lib の版 1.2 以上、2 未満」だけだとします。月曜に配布元にある lib の版が 1.2.0 と 1.2.1 なら、新しいものを優先する解決器は 1.2.1 を選びます。火曜に 1.3.0 が公開されると、同じ宣言から 1.3.0 が選ばれます。月曜に通ったテストが火曜に落ちても、自分のリポジトリには差分がありません。解決器は「宣言と、その時刻の配布元の状態」の関数であって、宣言だけの関数ではないからです。

Python では、宣言(requirements.in や pyproject.toml)から、推移的な依存をすべて版まで固定しハッシュを付けた一覧を生成し、導入時にハッシュの照合を要求する(pip の --require-hashes)のが、この命題の手続きに当たります。仮想環境は、取得した内容を置く場所をプロジェクトごとに分けて、計算機に元から入っているパッケージという宣言されていない依存を断つ仕組みです。版の番号だけを記録してハッシュを記録しない場合、同じ版番号で内容が差し替えられたことを検出できません。

最後に、版の番号に意味を持たせる約束を、契約の言葉で述べます。

定義 8.6(意味的バージョニング)↗

ライブラリの版を主版と副版の組 (M,m)(M, m) で表す(修正版は省く)。各版は、公開する操作の集合 OpM,m\mathrm{Op}_{M,m} と、各操作 oo の契約(事前条件 PoM,mP^{M,m}_o と事後条件 QoM,mQ^{M,m}_o)をもつ。意味的バージョニングの約束とは、同じ主版 MM の中で m≤m′m \le m' ならば

  • OpM,m⊆OpM,m′\mathrm{Op}_{M,m} \subseteq \mathrm{Op}_{M,m'}(操作はなくならない)、
  • 各 o∈OpM,mo \in \mathrm{Op}_{M,m} について PoM,m⇒PoM,m′P^{M,m}_o \Rightarrow P^{M,m'}_o かつ QoM,m′⇒QoM,mQ^{M,m'}_o \Rightarrow Q^{M,m}_o(事前条件は強めず、事後条件は弱めない)

が成り立つことをいう。主版が違う版の間には何も約束しない。

命題 8.7(菱形の依存と高いほうの副版)✓

プログラムが A と B に依存し、A はライブラリ C の版 (M,mA)(M, m_A) の契約だけを使って、B は同じ主版の (M,mB)(M, m_B) の契約だけを使って、それぞれ自分の正しさ(C の操作を呼び出すコードについての Hoare の三つ組)を証明しているとする。C が意味的バージョニングの約束(定義 8.6)を守っているならば、C の版を (M,max⁡(mA,mB))(M, \max(m_A, m_B)) の 1 つにしても、A と B の証明した三つ組はどちらも成り立つ。

証明(命題 8.7)

m∗=max⁡(mA,mB)m^{*} = \max(m_A, m_B) とおきます。mA≤m∗m_A \le m^{*} なので、約束より、A が使う各操作 o∈OpM,mAo \in \mathrm{Op}_{M, m_A} は OpM,m∗\mathrm{Op}_{M, m^{*}} にあり、PoM,mA⇒PoM,m∗P^{M,m_A}_o \Rightarrow P^{M,m^{*}}_o かつ QoM,m∗⇒QoM,mAQ^{M,m^{*}}_o \Rightarrow Q^{M,m_A}_o です。これは、版 (M,m∗)(M, m^{*}) の実装の契約が、版 (M,mA)(M, m_A) の契約に対して「事前条件が弱く、事後条件が強い」という、振る舞いの部分型の条件そのものです。定理 7.2[契約による設計] より、上位の契約(版 (M,mA)(M, m_A) の契約)だけを使って証明された呼び出し側の三つ組は、実装を下位のもの(版 (M,m∗)(M, m^{*}))に差し替えても成り立ちます。B についても mB≤m∗m_B \le m^{*} から同じ議論が成り立ちます。

この命題は、命題 8.3 (b) の「下限のうち最大のものを選ぶ」解決が意味をもつ理由でもあります。ただし、約束が守られているかどうかを機械は保証しません。利用者が契約に書かれていない振る舞い(例外のメッセージの文面、返る列の順序、実行時間)に頼っていれば、副版の更新でも壊れます。主版が違う場合は約束がないので、1 つの版にまとめることはできず、共存させる(命題 8.3 (a))か、どちらかの利用者を直すしかありません。

演習 9.1易

§2 の図(§5 のコード例)の依存グラフについて、次に答えよ。

  1. Kahn の算法(定理 3.2 (b))の出力を 1 つ書け。
  2. style.css だけを変更したときの再実行の集合 RR(定理 4.2)を求めよ。
  3. b.md の本文だけを変更し、環境 id の並びは変えなかった。早期打ち切り(命題 4.5)のもとで実行されるタスクを答えよ。
解答
  1. 入次数 00 の頂点は 3 つの入力である。待ち行列を先入れ先出しにし、名前の順に入れると、出力の 1 つは a.md, b.md, style.css, parse_a, parse_b, index, page_a, page_b, site である。a.md を出力すると parse_a の計数が 00 になり、b.md で parse_b が 00 になる。style.css を出力しても page_a・page_b の計数は残り(index と文書を待つ)、parse_a・parse_b の出力で index が 00、index の出力で page_a・page_b が 00、最後に site が 00 になる。
  2. style.css から辺が出ている先は page_a と page_b、そこから site に届く。R={page_a,page_b,site}R = \{\texttt{page\_a}, \texttt{page\_b}, \texttt{site}\}。§5 の実行結果の 5 行目と一致する。
  3. parse_b は b.md が変わったので実行され、出力(id の並び)は前回と同じである。index の依存 parse_a・parse_b はどちらも前回と同じ値なので実行されない。page_a の依存 a.md・index・style.css はどれも変わっていないので実行されない。page_b は b.md を直接読むので実行され、出力が変わる。site は page_b が変わったので実行される。答えは parse_b, page_b, site である。到達できる集合 {parse_b,index,page_a,page_b,site}\{\texttt{parse\_b}, \texttt{index}, \texttt{page\_a}, \texttt{page\_b}, \texttt{site}\} の真部分集合になっている。

演習 9.2標準

ワーカーが p=2p = 2 台で、依存のない 3 つのタスク A・B・C の所要時間が wA=wB=1w_A = w_B = 1、wC=2w_C = 2 であるとする。

  1. WW、LL と、定理 6.1 の (a) の下界、(b) の上界を求めよ。
  2. 上界に一致する完了時刻をもつ貪欲なスケジュールと、下界に一致するスケジュールをそれぞれ 1 つ挙げよ。
  3. このことから、(c) の倍率 2−1/p2 - 1/p について何が言えるか。
解答
  1. W=1+1+2=4W = 1 + 1 + 2 = 4。依存がないので道は 1 つのタスクだけからなり、L=max⁡(1,1,2)=2L = \max(1, 1, 2) = 2。下界は max⁡(4/2,2)=2\max(4/2, 2) = 2、上界は 4/2+2(1−1/2)=34/2 + 2(1 - 1/2) = 3。
  2. 時刻 00 に A と B を始めるスケジュールを考える。時刻 00 から 11 までは 2 台とも実行中である。時刻 11 に 2 台が空き、未開始で依存が完了しているタスクは C だけなので、C を始める。C の実行中、もう 1 台は空いているが、未開始のタスクはないので貪欲の条件に反しない。完了時刻は 1+2=31 + 2 = 3 で、上界に一致する。一方、時刻 00 に C を 1 台目で、A を 2 台目で始め、時刻 11 に B を 2 台目で始めると、完了時刻は 22 で、下界に一致する(これも貪欲である)。
  3. 最適な完了時刻は 22 で、貪欲なスケジュールの中に完了時刻 33 のものがある。比は 3/2=2−1/23/2 = 2 - 1/2 で、p=2p = 2 のとき (c) の倍率は改善できない。貪欲であること(空きを作らないこと)だけでは最適にならず、実行可能なタスクのうちどれを先に始めるかという優先順位が効く。長いタスクや、長い依存鎖の先頭にあるタスクを先に始めるのが定石である。

演習 9.3標準

φ=(x1∨¬x2∨x3)∧(¬x1∨x2∨¬x3)\varphi = (x_1 \lor \lnot x_2 \lor x_3) \land (\lnot x_1 \lor x_2 \lor \lnot x_3) に対して、定理 8.2 の証明の入力 F(φ)F(\varphi) を書き下し、解決を 1 つ挙げよ。また、版の共存を許した場合(命題 8.3 (a) の多重解決)には、この構成が充足可能性を反映しなくなることを、充足不能な論理式の例で説明せよ。

解答

パッケージは X1,X2,X3X_1, X_2, X_3(版は {1,2}\{1, 2\}、制約なし)と K1,K2K_1, K_2(版は {1,2,3}\{1, 2, 3\})。制約は

  • K1K_1 の版 11: (X1,{2})(X_1, \{2\})、版 22: (X2,{1})(X_2, \{1\})、版 33: (X3,{2})(X_3, \{2\})、
  • K2K_2 の版 11: (X1,{1})(X_1, \{1\})、版 22: (X2,{2})(X_2, \{2\})、版 33: (X3,{1})(X_3, \{1\})、

根の制約は (K1,{1,2,3})(K_1, \{1,2,3\}) と (K2,{1,2,3})(K_2, \{1,2,3\}) である。

α(x1)=α(x2)=\alpha(x_1) = \alpha(x_2) = 真、α(x3)=\alpha(x_3) = 偽 は φ\varphi を充足する(第 1 節は x1x_1、第 2 節は x2x_2 が真)。対応する解決は ρ(X1)=2\rho(X_1) = 2、ρ(X2)=2\rho(X_2) = 2、ρ(X3)=1\rho(X_3) = 1、ρ(K1)=1\rho(K_1) = 1、ρ(K2)=2\rho(K_2) = 2 である。K1K_1 の版 11 の制約 (X1,{2})(X_1, \{2\}) と、K2K_2 の版 22 の制約 (X2,{2})(X_2, \{2\}) がどちらも満たされている。

共存を許すと、すべての組 (a,v)(a, v) を集めた集合がつねに多重解決になる。KjK_j の版 rr の制約 (Xi,{2})(X_i, \{2\}) または (Xi,{1})(X_i, \{1\}) は、XiX_i の両方の版が入っているので満たされるからである。たとえば充足不能な (x1∨x1∨x1)∧(¬x1∨¬x1∨¬x1)(x_1 \lor x_1 \lor x_1) \land (\lnot x_1 \lor \lnot x_1 \lor \lnot x_1) から作った入力にも多重解決がある。「x1x_1 が真でも偽でもある」ことが許されてしまうためで、帰着の逆向き(解決から割り当てを作る段)が壊れる。排他的な選択が NP 困難性の源であることが、ここからも分かる。

  • A. Mokhov, N. Mitchell, S. Peyton Jones, “Build systems à la carte”, Proceedings of the ACM on Programming Languages 2 (ICFP) (2018), Article 79. https://doi.org/10.1145/3236774 — スケジューラと再構築の判定の 2 軸によるビルドシステムの分類。早期打ち切りと 2 種類のキーの整理はこの論文に基づきます。
  • S. I. Feldman, “Make — a program for maintaining computer programs”, Software: Practice and Experience 9(4) (1979), 255–265 — 依存の宣言と更新時刻の比較という方式の原典。
  • A. B. Kahn, “Topological sorting of large networks”, Communications of the ACM 5(11) (1962), 558–562 — 定理 3.2 (b) の算法。
  • R. L. Graham, “Bounds on multiprocessing timing anomalies”, SIAM Journal on Applied Mathematics 17(2) (1969), 416–429 — 貪欲なスケジュールが最適の (2−1/p)(2 - 1/p) 倍以内であることを示した論文(最初の証明は同じ著者の 1966 年の論文にあります)。
  • R. Cox, “Version SAT” (2016), https://research.swtch.com/version-sat および “Minimal Version Selection” (2018), https://research.swtch.com/vgo-mvs — 版の解決の NP 完全性と、下限だけの制約による多項式時間の方式。
  • T. Preston-Werner, “Semantic Versioning 2.0.0”, https://semver.org/ — 意味的バージョニングの仕様。
  • Reproducible Builds project, https://reproducible-builds.org/ — ビット単位で同じ成果物を得るための非決定性の源の一覧と対策。

このテーマの 100 本ノック(50 問)

執筆: Claude Code(Opus 5.5)、Claude Code(Sonnet 5.5)・監査 2 回(Claude Code)・最終 2026-10-12

この記事の誤りを報告する ・運営: 夢現技研合同会社 ・料金 ・利用条件 ・特定商取引法に基づく表記

© 2026 夢現技研合同会社 ・コード例は MIT ライセンスです。