2个版本
0.1.1 | 2024年8月12日 |
---|---|
0.1.0 | 2024年8月11日 |
#41 in 缓存
每月 235次下载
76KB
1.5K SLoC
增量查询
这个库原本不是库。它始于我想了解rustc的查询系统是如何工作的,然后我决定自己实现一个与之非常相似的系统。结果,我对它非常满意,并开始对其进行文档化,现在它就是这个库。
注意:这个crate使用了一个nightly特性:#![feature)]
,因此它目前只能在nightly上运行。
这是什么?
这个库的核心是查询的概念。就像在rustc中一样。查询就像一个普通函数,你可以像调用普通函数一样调用它。只是函数的参数和返回类型有一些限制。然而,当你调用它们时,与普通函数相比会发生更多的事情。
第一次,查询中的代码按正常方式运行,但输入和输出被缓存并散列。此外,这个查询内部调用的任何其他查询(及其递归调用)都会被跟踪。在后续调用相同查询且具有相同输入的情况下,将返回缓存的查询结果。
这个解释可能让人觉得这只是执行记忆化。然而,记忆化的函数必须是纯函数!否则,我们无法仅通过查看输入来判断输出是否相同。
相反,使用查询,函数不必是纯函数。纯函数仍然非常好!我们不必那么频繁地重新运行它们。然而,如果一个不纯的查询改变了它的结果,系统会注意到这一点,并且在需要其结果时,任何(纯)依赖于它的查询都会自动重新运行。此外,当一个不纯的查询重新运行,并且依赖的查询链更新,但我们注意到在任何时候结果都没有改变,那么重新运行的链就会被停止,以尽可能节省计算时间。
您可能会发现增量编译对这种查询非常有用。一个查询读取一个文件,另一个解析AST,还有另一个进行部分类型检查。Rustc有数百个查询。如果一个文件没有更改,读取文件查询会注意到。那么我们就不必再次运行解析查询,真是太棒了!类型检查可能依赖于多个文件,但如果它所依赖的文件没有更改,我们也不必重新运行它。
目前,这个库还不支持将查询缓存序列化到文件。将来可能会。如果它做到了,缓存将更加有用。
以下部分专门介绍这个库。即使您只对它的工作原理感兴趣,阅读它也可能很有用,这样您就能理解示例中使用的符号。
如何使用它?
查询的定义几乎像正常函数一样。唯一的限制是它们必须用define_query!
宏调用括起来。
use incremental_query::{Context, define_query};
# use rand::{thread_rng, Rng};
define_query! {
/// This is a query that retuns a random number.
#[rerun(always)]
fn random<'cx>(_cx: &Context<'cx>) -> u64 {
thread_rng().gen_range(0..u64::MAX)
}
}
注意:查询的签名有一些限制:函数必须有一个生命周期参数。此外,第一个参数必须是
Context
的引用。任何输入必须是实现了QueryParameter
的某些T
的引用。此外,尽管查询的输入是引用,但调用查询时必须提供相应的所有者值,而不是引用。所有者值存储在缓存中,而函数则获取现在在缓存中的这些值的引用。
要调用查询,现在您可以直接调用它
use incremental_query::{Context, Storage};
# use incremental_query::define_query;
# use rand::{thread_rng, Rng};
#
# define_query! {
# /// This is a query that retuns a random number.
# #[rerun(always)]
# fn random<'cx>(_cx: &Context<'cx>) -> u64 {
# thread_rng().gen_range(0..u64::MAX)
# }
# }
#
// instantiate a storage object that holds the cached data
let storage = Storage::new();
// create a context with it
let cx = Context::new(&storage);
// call the query
println!("{}", random(&cx));
调用查询时,您将获得声明返回类型的引用。这同样是因为实际的返回值现在存储在缓存中。您只是得到该缓存的一个引用。
函数纯度和代数
您可能已经注意到了在上述定义的查询上的#[rerun(always)]
属性。
没有这个属性,查询被期望是纯的。这意味着没有属性,查询不应从文件中读取,或生成随机数,或调用可能执行这些操作的另一个函数。然而,它仍然可以调用标记为不纯的其他查询。
查询可以通过两种方式标记为不纯。一种是通过#[rerun(always)]
,这将永远不假设其结果保持不变(实际上,库甚至不会缓存输出)。这些可能会对性能产生很大的影响,因此有一个替代方案:#[rerun(generation)]
。
上下文有一个代计数器。它从0
开始,您可以使用Context::next_generation
来更改它。每个缓存的查询也会存储它是在哪个代被缓存的。代查询将像往常一样被缓存,但当代号更改时,它们将重新运行。
因此,您可以将所有不纯查询变为代际查询,当您通过某种方式知道它们所依赖的内容可能已更改(例如磁盘上的文件已更新)时,您可以增加代际编号,结果将得到反映。
例如,在这里
# use incremental_query::{Context, Storage, define_query};
define_query! {
#[rerun(generation)]
fn boolean_query<'cx>(_cx: &Context<'cx>) -> bool {
// first return true, then in the 2nd generation false
# true
}
fn one<'cx>(_cx: &Context<'cx>) -> u64 {
1
}
fn two<'cx>(_cx: &Context<'cx>) -> u64 {
2
}
fn conditional<'cx>(cx: &Context<'cx>, ) -> u64 {
if *boolean_query(cx) {
*one(cx)
} else {
*two(cx)
}
}
}
let storage = Storage::new();
let cx = Context::new(&storage);
conditional(&cx);
conditional(&cx);
conditional(&cx);
cx.next_generation();
conditional(&cx);
conditional(&cx);
conditional(&cx);
boolean_query
被标记为代际查询。假设它在第一代返回了 true
,但在第二代返回了 false
。我们调用了 conditional
三次。前三次都会看到结果为 true
并输出 1
,因为代际数仍然是 0
,所以没有理由认为输出可能已更改。代际数更改后,conditional
在接下来的三次调用中都会返回 2
。总共,这里运行了两次 boolean_query
,分别是 one
和 two
,每次运行一次,而 conditional
运行两次。所有其他结果都已被缓存。
它是如何工作的?
您可能会注意到,接下来的许多解释与在 rustc 开发指南中解释的 salsa 和 rust 的查询系统有许多相似之处。这并非巧合,因为这个库最初是我想要实现那个算法。我将尽力在这里再次解释该算法,并可能对其进行一些简化。
如果您阅读了 rustc 开发指南,您可能想知道以下内容:它将“查询”一词用于两个不同的概念,并且可以互换使用。我花了一些时间才意识到这一点。首先,查询是对查询的定义,就像我在
define_query!
中放入的那样。然而,在指南中,它还指代查询的实例。当它说查询不能依赖于自己时,并不意味着某个type_of(a)
查询不能执行type_of(b)
。它的意思是,您不能创建一个循环,其中type_of(a)
递归地依赖于type_of(a)
。同样,当指南说查询被缓存时,它的意思是查询及其给定的输入被缓存。
当查询被调用时,它实际上立即委托给 Context
。然后,Context
可能会决定实际运行该函数。该 Context
包含一个缓存,但也包含一个依赖图,这是一个 有向无环图或 DAG,以及一个在运行查询时检测循环的数据结构。
在运行查询之前,它首先执行以下检查
- 对输入进行哈希处理(包括查询类型,以便为不同的查询生成唯一的哈希值)。
- 如果这个哈希从未见过,则在依赖图中添加一个新节点,并立即运行查询。缓存其输出,然后返回。
- 如果哈希值已经存在,则在依赖图中找到相应的节点。
- 如果该节点没有缓存的查询结果,则现在运行该查询并缓存结果。
- 如果没有缓存的查询结果,我们仍然没有完成,因为我们还需要确保缓存没有被无效化。存在三种查询类型:始终重新运行、代际和纯。
- 即使纯查询是纯的,其缓存结果仍然可能被无效化。这可能发生在纯查询的依赖项不是纯的情况下。我们使用 rustc 所称的
try_mark_green
算法来解决这个问题。关于这个算法的更多信息将在后面介绍。 - 当依赖项发生变化时,代际查询的缓存结果可以像纯查询一样被无效化。然而,当全局代际发生变化时,其结果也会过时。
- 始终重新运行的查询的缓存结果永远无效。实际上,我们甚至从未存储过其输出。
- 即使纯查询是纯的,其缓存结果仍然可能被无效化。这可能发生在纯查询的依赖项不是纯的情况下。我们使用 rustc 所称的
尝试标记绿色算法,或递归评估缓存有效性。
对于每个查询实例(即查询类型、其输入、哈希输入、可能的缓存输出和输出哈希)我们还存储一条额外的信息:它是红色还是绿色。
这个字段可以由各种事物更改。
- 当一个查询实际上执行时,并且其输出与上次执行不同(或者没有以前的输出),则查询变为红色。
- 当一个查询实际上执行时,并且其输出与上次执行相同,则查询变为绿色。
- 当一个查询用于缓存无效化并所有依赖项都为绿色时,它会被标记为绿色。
缓存无效化的过程如下。
- 如果查询是
rerun(always)
,则其缓存立即无效。 - 如果查询是
rerun(generational)
,则当代际发生变化时,其缓存立即无效。 - 如果在有效性检查的开始时查询已经标记为绿色,并且它不(传递性地)依赖于任何
rerun(always)
查询,并且代际编号没有变化,则其缓存立即有效。 - 对查询的所有依赖项递归地运行此检查。这可能意味着运行依赖查询之一,它可能通过上述 3 种方式之一更改其红色/绿色状态。
- 如果在递归运行此算法的所有依赖项之后,任何依赖项是红色,我们必须重新运行此查询,这可能会更改其红色/绿色状态。
- 如果在递归运行所有依赖查询之后,所有依赖项都被标记为绿色,我们不需要重新运行它,其缓存有效。实际上,我们还立即将此查询标记为绿色。
请注意,在这里,对于某些查询 Q
,如果我们必须重新运行一个依赖项,因为它可能已更改,但实际上并没有,那么这个依赖项将被标记为绿色,我们不需要重新运行 Q
本身。
跟踪依赖关系。
每个查询实例都有一个依赖项列表。每次查询 Q
被执行时,上下文实际上更新一个称为 curr
的字段,该字段表示当前的“父”查询。当 Q
运行自己的查询时,它将再次通过上下文,并更新此依赖项列表。
请注意,当查询重新运行时,因为某些依赖项已更改,我们必须擦除其依赖项列表。这是因为依赖项列表可能会更改!请参考 rustc 开发指南
# use incremental_query::{Context, Storage, define_query};
define_query! {
#[rerun(generation)]
fn boolean_query<'cx>(_cx: &Context<'cx>) -> bool {
// first return true, then in the 2nd generation false
# true
}
fn one<'cx>(_cx: &Context<'cx>) -> u64 {
1
}
fn two<'cx>(_cx: &Context<'cx>) -> u64 {
2
}
fn conditional<'cx>(cx: &Context<'cx>, ) -> u64 {
if *boolean_query(cx) {
*one(cx)
} else {
*two(cx)
}
}
}
let storage = Storage::new();
let cx = Context::new(&storage);
conditional(&cx);
cx.next_generation();
conditional(&cx);
在这里,在首次执行 conditional
时,依赖列表将包含 one
。然而,在第一次生成之后,依赖列表不再包含 one
,而是包含 two
。
许可证
根据您的选择,许可协议为 Apache许可证2.0版本 或 MIT许可证。
依赖项
~710KB