インクリメンタルコンパイルの詳細
インクリメンタルコンパイルの仕組みは、本質的には、クエリシステム全体に対する驚くほど単純な拡張です。 これは、次の事実に依存しています。
- クエリは純粋関数である – 同じ入力が与えられれば、クエリは常に同じ結果を返す。
- クエリモデルは、個々の計算間の依存関係を明示する非巡回グラフとしてコンパイルを構造化する。
この章では、これらの性質をどのように利用してインクリメンタルにできるかを説明し、その後、バージョンの実装上の問題について説明します。
インクリメンタルなクエリ評価の基本アルゴリズム
クエリ評価モデル入門で説明したように、クエリの呼び出しは有向非巡回グラフを形成します。 前の章の例をもう一度示します。
list_of_all_hir_items <----------------------------- type_check_crate()
|
|
Hir(foo) <--- type_of(foo) <--- type_check_item(foo) <-------+
| |
+-----------------+ |
| |
v |
Hir(bar) <--- type_of(bar) <--- type_check_item(bar) <-------+
あるクエリから別のクエリへのすべてのアクセスはクエリコンテキストを経由する必要があるため、これらのアクセスを記録でき、したがって実際にこの依存関係グラフをメモリ内に構築できます。 依存関係追跡が有効になっている場合、コンパイルが完了すると、どのクエリが呼び出されたか(グラフのノード)、そして各呼び出しについて、そのクエリの結果を計算するためにどの他のクエリまたは入力が使われたか(グラフのエッジ)が分かります。
ここで、プログラムのソースコードを変更して、bar の HIR が以前とは異なるようになったとします。
私たちの目標は、変更によって実際に影響を受けるクエリだけを再計算し、それ以外のすべてのクエリについてはキャッシュ済みの結果を再利用することです。
依存関係グラフがあれば、まさにそれができます。
あるクエリ呼び出しについて、グラフはその結果の計算にどのデータが使われたかを正確に教えてくれるので、変更されたものに到達するまでエッジをたどるだけで済みます。
変更されたものに何も遭遇しなければ、そのクエリはキャッシュ内にすでにある結果と同じ結果に評価されるはずだと分かります。
上の type_of(foo) 呼び出しを例に取ると、その入力へのエッジをたどることで、キャッシュ済みの結果がまだ有効かどうかを確認できます。
唯一のエッジは Hir(foo) につながっており、これは変更の影響を受けていない入力です。
そのため、type_of(foo) のキャッシュ済みの結果はまだ有効であると分かります。
type_check_item(foo) については、話が少し異なります。再びエッジをたどり、type_of(foo) は問題ないことがすでに分かっています。
次に、まだ確認していない type_of(bar) に到達するので、type_of(bar) のエッジをたどると、変更された Hir(bar) に遭遇します。
したがって、type_of(bar) の結果はキャッシュ内にあるものとは異なる結果を返す可能性があり、推移的に、type_check_item(foo) の結果も変わっている可能性があります。
そのため、type_check_item(foo) を再実行します。すると今度は type_of(bar) が再実行され、Hir(bar) の最新バージョンを読み取るため、最新の結果が返されます。
また、type_of(bar) の結果が変わっている可能性があるため、type_check_item(bar) も再実行します。
基本アルゴリズムの問題: 偽陽性
前の段落を注意深く読むと、入力の 1 つが変更されたため、type_of(bar) は変わった可能性があると述べていることに気付くでしょう。
また、入力が変わったにもかかわらず、まったく同じ結果を返す可能性もあります。
整数の符号を計算するだけの単純なクエリの例を考えてみましょう。
IntValue(x) <---- sign_of(x) <--- some_other_query(x)
IntValue(x) が最初は 1000 で、その後 2000 に設定されたとします。
2 つの場合で IntValue(x) は異なっていますが、sign_of(x) はどちらの場合も結果 + を返します。
しかし、基本アルゴリズムに従うと、some_other_query(x) は変更された入力に推移的に依存しているため、(不必要に)再評価されなければなりません。
この場合、変更検出は「偽陽性」を生じます。なぜなら、some_other_query(x) がその変更された入力の影響を受けている可能性がある、と保守的に仮定しなければならないからです。
残念ながら、コンパイラ内の実際のクエリにはこのような例が数多くあり、入力への小さな変更が、出力バイナリの非常に大きな部分に影響する可能性がしばしばあります。 その結果、変更検出システムをより賢く、より正確にする必要がありました。
精度の向上: 赤緑アルゴリズム
「偽陽性」の問題は、変更検出とクエリの再評価を交互に行うことで解決できます。 あるキャッシュ済みの結果がまだ有効かどうかを調べる際に、グラフを入力までずっとたどる代わりに、再評価を余儀なくされた後で結果が実際に変わったかどうかを確認できます。
このアルゴリズムを赤緑アルゴリズムと呼びます。依存関係グラフ内のノードには、そのキャッシュ済みの結果がまだ有効であることを証明できた場合は緑色が割り当てられ、再評価後に結果が異なることが判明した場合は赤色が割り当てられるためです。
赤緑の変更追跡の中核は try-mark-green アルゴリズムに実装されています。これは、ご想像のとおり、与えられたノードを緑にマークしようとするものです。
```rust,ignore
fn try_mark_green(tcx, current_node) -> bool {
// `current_node` への入力を取得する、つまり `node` からの直接の
// エッジが指すノードを取得する。
let dependencies = tcx.dep_graph.get_dependencies_of(current_node);
// 次に、すべての入力に変更がないか確認する
for dependency in dependencies {
match tcx.dep_graph.get_node_color(dependency) {
Green => {
// この入力は以前にすでにチェック済みであり、
// 変更されていない。そのため、次の入力の確認へ進める
}
Red => {
// 変更された入力が見つかった。対応するクエリを
// 再実行せずに `current_node` を緑としてマークすることは
// できない。
return false
}
Unknown => {
// このノードを見るのは今回が初めてである。
// try_mark_green() を再帰的に呼び出して、緑として
// マークできるか試す。
if try_mark_green(tcx, dependency) {
// 入力を緑としてマークすることに成功したので、
// 次へ進む。
} else {
// 入力を緑としてマークすることは *できなかった*。
// これは、その値が変更されたかどうか分からないことを
// 意味する。それを調べるために、対応するクエリを今
// 再実行する!
tcx.run_query_for(dependency);
// ノードの色を再度取得して確認する。クエリを実行したことで、
// (キャッシュ内にある結果と異なる結果を返した場合は)
// 赤、または(同じ結果を返した場合は)緑のいずれかに
// 強制されている。
match tcx.dep_graph.get_node_color(dependency) {
Red => {
// 入力は赤であることが判明したため、
// `current_node` を緑としてマークすることは
// できない。
return false
}
Green => {
// クエリの再実行は功を奏した!結果は以前と同じなので、
// この特定の入力は `current_node` を無効化しない。
}
Unknown => {
// クエリの再実行後にノードに色が付いていないことは
// ありえない。
panic!("unreachable")
}
}
}
}
}
}
// ループ全体を通過できた場合、それはすべての入力が緑であることが
// 判明したことを意味する。すべての入力が変更されていないなら、
// `current_node` に対応するクエリ結果も変更されているはずがない。
tcx.dep_graph.mark_green(current_node);
true
}
注: 実際の実装は
compiler/rustc_middle/src/dep_graph/graph.rsにあります
赤緑マーキングを使用することで、変更検出における偽陽性がもたらす壊滅的な累積効果を避けることができます。
インクリメンタルモードでクエリが実行されるたびに、まずそれがすでに緑であるか確認します。
そうでない場合、そのクエリに対して try_mark_green() を実行します。
その後もまだ緑でない場合、実際にクエリプロバイダーを呼び出して結果を再計算します。
クエリの再計算では、さらに再帰的により多くのクエリが呼び出されることがあり、その結果、依存関係について再帰的に try_mark_green() アルゴリズムへ戻ってくる可能性があります。
現実世界: 永続化がすべてを複雑にする仕組み
上記のセクションでは、インクリメンタルコンパイルの基礎となるアルゴリズムを説明しました。しかし、コンパイラプロセスは完了後に終了し、結果キャッシュを持つクエリコンテキストもろとも消滅してしまうため、次回のコンパイルセッションで利用できるようにデータをディスクへ永続化する必要があります。 これには、まったく新しい一連の実装上の課題が伴います。
- クエリ結果キャッシュはディスクに保存されるため、変更比較のためにすぐ利用できるわけではありません。
- 後続のコンパイルセッションは、任意の変更が適用された新しいバージョンのコードで開始されます。
グローバルな逐次カウンターから生成されるあらゆる種類の ID やインデックス(例:
NodeId、DefIdなど)はずれている可能性があり、その結果、ディスクに永続化された結果は、同じ数値 ID やインデックスが新しいコンパイルセッションではまったく新しいものを指している可能性があるため、もはや直ちには利用できなくなります。 - ディスクへの永続化にはコストがかかるため、ごく小さな情報のすべてを実際にコンパイルセッション間でキャッシュすべきではありません。 高価な(逆)シリアライズ手順を通す必要がある複雑なものよりも、固定サイズの単純なデータが好まれます。
以下のセクションでは、コンパイラがこれらの問題をどのように解決しているかを説明します。
安定性の問題: コンパイルセッション間のギャップを埋める
前述のとおり、さまざまな ID(DefId など)は、コンパイル対象のソースコードの内容に依存する形でコンパイラによって生成されます。
ID の割り当ては通常、決定的です。
つまり、まったく同じコードを 2 回コンパイルした場合、同じものには同じ ID が割り当てられます。
しかし、たとえばファイルの途中に関数が追加されるなど、何かが変更された場合、どれかが以前と同じ ID を持つ保証はありません。
その結果、ディスク上のキャッシュ内のデータを、メモリ内で表現されるのと同じ方法で表現することはできません。
たとえば、TyKind::FnDef(DefId, &'tcx Substs<'tcx>) のような型情報の断片を(メモリ内で行っているように)そのまま保存し、そこに含まれる DefId が新しいコンパイルセッションで別の関数を指してしまった場合、問題が発生します。
この問題の解決策は、コンパイルセッション間でも有効なままの「安定した」形式の ID を見つけることです。
最も重要なケースである DefId については、これがいわゆる DefPath です。
各 DefId には対応する DefPath がありますが、数値 ID の代わりに、DefPath は識別対象の項目へのパス、たとえば std::collections::HashMap に基づいています。このような ID の利点は、無関係な変更の影響を受けないことです。
たとえば、std::collections に新しい関数を追加することはできますが、std::collections::HashMap は依然として std::collections::HashMap のままです。
DefId がそうでないのに対し、DefPath はソースコードへの変更をまたいで「安定」しています。
また、DefPath の 128 ビットハッシュ値にすぎない DefPathHash もあります。
両者は同じ情報を含んでおり、私たちは主に DefPathHash を使用します。これは Copy で自己完結しているため、扱いがより簡単だからです。
この安定識別子の原則は、ディスク上キャッシュ内のデータをソースコード変更に対して堅牢にするために使用されます。
DefId を保存する代わりに、DefPathHash を保存し、キャッシュから何かをデシリアライズするときに、DefPathHash を 現在の コンパイルセッション内の対応する DefId に対応付けます(これは単純なハッシュテーブル検索にすぎません)。
独自の DefId を持たない HIR コンポーネントを識別するために使われる
HirId も、そのような安定 ID の一種です。
これは(概念的には)DefPath と LocalId のペアであり、LocalId は
その「所有者」(例: hir::Item)の内部でローカルに何か(例: hir::Expr)を識別します。所有者が移動されても、
その内部の LocalId は同じままです。
クエリ結果の変更を確認する: StableHash と Fingerprint
赤緑マーキングを行うために、クエリの結果が前回のコンパイルセッションでの結果と比べて 変わったかどうかを確認する必要があることがよくあります。 ただし、これには 2 つの性能上の問題があります。
- 比較を行うためだけに、前回の結果をディスクから読み込むことは避けたいです。 新しい結果はすでに計算済みであり、それを使用することになります。 また、ディスクから結果を読み込むと、今後使われる可能性が低いデータで インターナーを「汚染」してしまいます。
- すべての結果をオンディスクキャッシュに保存したいわけではありません。 たとえば、上流クレートですでに利用可能なものをディスクへ永続化するのは、 労力の無駄になります。
コンパイラは、いわゆる Fingerprint を使うことでこれらの問題を回避します。
新しいクエリ結果が計算されるたびに、クエリエンジンはその結果の 128 ビットハッシュ値を
計算します。
このハッシュ値を「クエリ結果の Fingerprint」と呼びます。
ハッシュ化は「安定した方法」で行われます(また、そうしなければなりません)。
これは、コンパイルセッション間で変わる可能性があるもの(例: DefId)をハッシュ化するときには、
代わりにその安定した等価物(例: 対応する DefPath)をハッシュ化するという意味です。
StableHash インフラストラクチャ全体はそのためにあります。
これにより、2 つの異なるコンパイルセッションで計算された Fingerprint であっても比較可能になります。
次のステップは、これらのフィンガープリントを依存グラフとともに保存することです。 フィンガープリントは単にコピーされるバイト列なので、これは低コストです。 依存グラフとともにフィンガープリント一式を読み込むのも低コストです。
これで、赤緑マーキングが結果の変更有無を確認する必要がある地点に到達したときには、 (すでに読み込まれている)前回のフィンガープリントを新しい結果のフィンガープリントと比較するだけで済みます。
このアプローチはかなりうまく機能しますが、欠点がないわけではありません。
-
ハッシュ衝突が起こる可能性がわずかにあります。 つまり、2 つの異なる結果が同じフィンガープリントを持つ可能性があり、その場合システムは誤って 結果が変更されていないとみなし、更新の見逃しにつながります。
このリスクは、高品質なハッシュ関数と 128 ビット幅のハッシュ値を使うことで軽減しています。 これらの対策により、実用上のハッシュ衝突リスクは無視できます。
-
フィンガープリントの計算はかなり高コストです。 これは、インクリメンタルコンパイルが非インクリメンタルコンパイルより遅くなり得る 主な理由です。 優れた、したがって高コストなハッシュ関数を使わざるを得ず、さらにハッシュ化の際には 対象を安定した等価物へ対応付ける必要があります。
2 つの DepGraph の物語: 古いものと新しいもの
依存関係追跡の最初の説明では、実際に実装しようとするとすぐに頭を悩ませることになる いくつかの詳細が省かれています。 特に見落としやすいのは、実際には 2 つ の依存グラフを扱っているという点です。 つまり、前回のコンパイルセッション中に構築したものと、 現在のコンパイルセッション用に構築しているものです。
コンパイルセッションが開始されると、コンパイラは前回の依存グラフを
不変のデータとしてメモリに読み込みます。
そして、クエリが呼び出されると、
まずグラフ内の対応するノードを green としてマークしようとします。
これは実際には、前回 の dep-graph 内で、
現在 のセッションのクエリキーに対応するノードを green としてマークしようとしている、という意味です。
現在のクエリキーと前回の DepNode の間のこの対応付けは、どのように行うのでしょうか?
答えはここでも Fingerprint です。依存グラフ内のノードは、
クエリキーのフィンガープリントによって識別されます。
フィンガープリントはコンパイルセッションをまたいで安定しているため、
現在のセッションでフィンガープリントを計算することで、前回のセッションの依存グラフ内のノードを
見つけることができます。
与えられたフィンガープリントを持つノードが見つからない場合は、
そのクエリキーが前回のセッションにはまだ存在していなかったものを参照していることを意味します。
こうして前回の依存グラフ内の dep-node を見つけたら、その依存関係 (すなわち、前回のグラフ内の dep-node)を調べ、 try-mark-green アルゴリズムの残りを続行できます。 次に興味深いことが起こるのは、そのノードを green として正常にマークできたときです。 その時点で、古いグラフから新しいグラフへ、 ノードと、その依存先へのエッジをコピーします。 これは、新しい dep-graph が通常の依存関係追跡を通じて そのノードとエッジを獲得することができないため、必要になります。 追跡システムは、実際にクエリを実行している間にしかエッジを記録できません。しかし、 結果がすでにキャッシュされているにもかかわらずクエリを実行することこそ、まさに避けたいことです。
コンパイルセッションが終了すると、変更されていない部分はすべて 古い依存グラフから新しい依存グラフへコピーされており、一方で変更された部分は 追跡システムによって新しいグラフに追加されています。 この時点で、新しいグラフはクエリ結果キャッシュとともにディスクへシリアライズされ、 次回以降のコンパイルセッションで前回の dep-graph として機能できます。
何か忘れていませんか?: キャッシュの昇格
ここまで説明したシステムには、やや微妙な性質があります。dep-node のすべての入力が green であれば、対応するクエリ結果を計算したり読み込んだりすることなく、 その dep-node 自体を green としてマークできます。 この性質を推移的に適用すると、次の例のように、一部の中間結果が 実際にはディスクからまったく読み込まれない状況になることがよくあります。
input(A) <-- intermediate_query(B) <-- leaf_query(C)
コンパイラは、何らかの出力成果物を生成するために leaf_query(C) の値を必要とするかもしれません。
leaf_query(C) を green としてマークできる場合、コンパイラはオンディスクキャッシュからその結果を読み込みます。
しかし、intermediate_query(B) の結果は読み込まれません。
その結果、コンパイラがメモリ上のすべてのクエリ結果をディスクに書き込むことで
新しい 結果キャッシュを永続化する際、intermediate_query(B) は
メモリ上に存在しないため、新しい結果キャッシュから欠落することになります。
その後、別のコンパイルセッションで実際に intermediate_query(B) の結果が必要になった場合、
その直前までキャッシュ内に完全に有効な結果があったにもかかわらず、
再計算しなければならなくなります。
これを防ぐために、コンパイラは「キャッシュの昇格」と呼ばれることを行います。 新しい結果キャッシュを出力する前に、すべての green な dep-node を走査し、 それらのクエリ結果がメモリに読み込まれていることを確認します。 これにより、結果キャッシュが不必要に再び縮小することを防げます。
インクリメンタルコンパイルとコンパイラバックエンド
コンパイラバックエンド、つまり LLVM に関わる部分は、クエリシステムを使用していますが、 それ自体がクエリとして実装されているわけではありません。 その結果、依存関係追跡に自動的に参加することはありません。 しかし、追跡システムとの手動統合はかなり単純です。 コンパイラは、各コード生成ユニット (CGU) の初期 LLVM 版を生成するときにどのクエリが呼び出されるかを単に追跡し、 その結果として各 CGU に対する dep-node が作られます。 以降のコンパイルセッションでは、CGU の dep-node を green としてマークしようとします。 成功した場合、対応するディスク上のオブジェクトファイルとビットコードファイルがまだ有効であることが分かります。 成功しなかった場合は、CGU 全体を再コンパイルする必要があります。
これは通常のクエリに使用されるのと同じアプローチです。 主な違いは次のとおりです。
-
LLVM モジュールのフィンガープリントを簡単には計算できないこと (LLVM モジュールは不透明な C++ オブジェクトであるため)、
-
キャッシュされた値を扱うロジックが通常のクエリとはかなり異なること。 ここでは共通の結果キャッシュファイル内のシリアライズされた Rust 値ではなく、 ビットコードファイルとオブジェクトファイルを扱うためです。そして、
-
LLVM 周辺の操作は、計算時間とメモリ消費の観点で非常に高コストであるため、 何をいつ実行し、何をどのくらいの期間メモリ内に保持するかを 厳密に制御する必要があることです。
クエリシステムはおそらく、上記すべてに対処する汎用的な仕組みを備えるように拡張できるでしょうが、 これまでのところ、それによって削減できる手間よりも、実装の手間のほうが大きいように思われました。
クエリ修飾子
FIXME:
rustc_middle::query::modifiersをクエリ修飾子ドキュメントの置き場所にし、 他の有用な修飾子ドキュメントがまだ正確であることを確認したうえで、すべてそこへ移行する。
クエリシステムでは、クエリに修飾子を適用できます。 これらの修飾子は、インクリメンタルコンパイルに関して、 システムがそのクエリをどのように扱うかの特定の側面に影響します。
-
eval_always-eval_always属性を持つクエリは、 インクリメンタルコンパイル中に無条件で再実行されます。 すなわち、 システムはそのクエリの dep-node を green としてマークしようとすらしません。 この属性には 2 つの用途があります。-
eval_alwaysクエリは入力(ファイル、グローバル状態などから)を読み取ることができます。 また、ファイルへの書き込みやグローバル状態の変更のような副作用を生み出すこともできます。 -
一部のクエリは、その結果がソースコード全体に依存するため、 再評価される可能性が非常に高くなります。 この場合、
eval_alwaysは最適化として使用できます。 というのも、システムはそもそも依存関係の記録をスキップできるためです。
-
-
no_force- このクエリの dep-node は、クエリのキー型が復元可能であっても、決して「force」しません。 -
no_hash- クエリにno_hashを適用すると、そのクエリの結果の フィンガープリントを計算しないようにシステムへ指示します。 これには 2 つの結果があります。-
フィンガープリントを計算しないことで、かなりの時間を節約できます。 フィンガープリントの計算は、特に大きく複雑な値に対しては高コストだからです。
-
フィンガープリントがない場合、システムはクエリの結果が変更されたと 無条件に仮定しなければなりません。 その結果、
no_hashクエリに依存するものは常に再実行されます。
クエリに
no_hashを使用することが理にかなう状況は 2 つあります。-
クエリの結果が、その入力の 1 つが変わるたびに変化する可能性が非常に高い場合。 たとえば
|a, b, c| -> (a * b * c)のような関数です。このような場合、 入力の 1 つが red であれば、クエリを再計算しても常に red ノードが得られるため、 その手間をかけずに、即座に red をデフォルトとすることができます。 反例としては、|a| -> (a == 42)のような関数があります。 ここでは、aのほとんどの変更に対して結果は変化しません。 -
クエリの結果が大きなモノリシックなコレクション(例:
index_hir)であり、 そのコレクションから読み取る「射影クエリ」が存在する場合 (例:hir_owner)。このような場合、大きなコレクションは上記の条件を満たす可能性が高く (入力が変わればコレクション全体を再計算することを意味するため)、 射影クエリの結果はいずれにしてもハッシュ化されます。 コレクションクエリもハッシュ化すると、実質的に同じデータを 2 回ハッシュ化することになります。 つまり、コレクションをハッシュ化するときに 1 回、さらにすべての 射影クエリ結果をハッシュ化するときにもう 1 回です。no_hashにより、この冗長性を避けることができ、 射影クエリは「ファイアウォール」として機能し、その依存元を 無条件に red となるno_hashノードから保護します。
-
-
cache_on_disk- クエリの戻り値はディスクにキャッシュされ、 対応する dep-node が green であれば以降のセッションで読み込むことができます。separate_provide_extern修飾子も存在する場合、値は「ローカル」キーについてのみ ディスクにキャッシュされます。外部クレートの値は、 クレートメタデータから読み込めるべきであるためです。
射影クエリパターン
eval_always と no_hash を、いわゆる「射影クエリ」パターンで一緒に使用できることは興味深い点です。
コンパイラの入力全体に依存する 1 つのクエリ(例: インデックス化された HIR)と、
このモノリシックな値から個々の値を射影する別のクエリ
(例: 特定の DefId を持つ HIR アイテム)があることはよくあります。これらの射影クエリにより、
変更伝播の「ファイアウォール」を構築できます。なぜなら、モノリシックなクエリの結果が変わったとしても
(それは非常に起こりやすいことですが)、小さな射影はそれでもほとんどの場合 green としてマークできるからです。
+------------+
| | +---------------+ +--------+
| | <---------| projection(x) | <---------| foo(a) |
| | +---------------+ +--------+
| |
| monolithic | +---------------+ +--------+
| query | <---------| projection(y) | <---------| bar(b) |
| | +---------------+ +--------+
| |
| | +---------------+ +--------+
| | <---------| projection(z) | <---------| baz(c) |
| | +---------------+ +--------+
+------------+
monolithic_query の結果が変わり、そのため projection(x) の結果も変わったと仮定しましょう。
つまり、両方の dep-node が red としてマークされることになります。
その結果、foo(a) は再実行する必要がありますが、bar(b) と baz(c) は green としてマークできます。
しかし、foo、bar、baz が
monolithic_query に直接依存していたなら、それらはすべて再評価されなければならなかったでしょう。
このパターンは、eval_always と no_hash がなくても機能しますが、これら 2 つの
修飾子を使うことで不要なオーバーヘッドを回避できます。
モノリシックなクエリが
コンパイラの入力に対するどんな小さな変更でも変わる可能性が高い場合は、それを eval_always としてマークするのが理にかなっています。これにより、その依存関係追跡コストを取り除けます。
また、モノリシックなクエリを no_hash としてマークすることは常に理にかなっています。
なぜなら、可能な限り物事を green に保つ処理はプロジェクションが担っているからです。
現在のシステムの欠点
まだ改善できることは多くあります。
ディスク上のデータ構造の増分性
現在のシステムは、ディスク上のキャッシュと依存関係グラフをインプレースで更新できません。 その代わり、各コンパイルセッションで各ファイル全体を書き直す必要があります。 これによるオーバーヘッドは、総コンパイル時間の数パーセントです。
不要なデータ依存関係
クエリ結果として使われるデータ構造は、依存関係グラフから エッジを取り除くように分解できる可能性があります。 特に「span」情報は非常に変わりやすいため、 それをクエリ結果に含めると、その結果が再利用できなくなる可能性が高まります。 詳細については https://github.com/rust-lang/rust/issues/47389 を参照してください。