#查询 #缓存 #rustc #输入 #增量 #重跑 #生成

nightly incremental-query

类似于rustc的增量编译算法的实现

2个版本

0.1.1 2024年8月12日
0.1.0 2024年8月11日

#41 in 缓存

Download history 19/week @ 2024-08-05 216/week @ 2024-08-12

每月 235次下载

MIT/Apache

76KB
1.5K SLoC

github crates.io docs.rs build status

增量查询

这个库原本不是库。它始于我想了解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,分别是 onetwo,每次运行一次,而 conditional 运行两次。所有其他结果都已被缓存。

它是如何工作的?

您可能会注意到,接下来的许多解释与在 rustc 开发指南中解释的 salsa 和 rust 的查询系统有许多相似之处。这并非巧合,因为这个库最初是我想要实现那个算法。我将尽力在这里再次解释该算法,并可能对其进行一些简化。

如果您阅读了 rustc 开发指南,您可能想知道以下内容:它将“查询”一词用于两个不同的概念,并且可以互换使用。我花了一些时间才意识到这一点。首先,查询是对查询的定义,就像我在 define_query! 中放入的那样。然而,在指南中,它还指代查询的实例。当它说查询不能依赖于自己时,并不意味着某个 type_of(a) 查询不能执行 type_of(b)。它的意思是,您不能创建一个循环,其中 type_of(a) 递归地依赖于 type_of(a)。同样,当指南说查询被缓存时,它的意思是查询及其给定的输入被缓存。

当查询被调用时,它实际上立即委托给 Context。然后,Context 可能会决定实际运行该函数。该 Context 包含一个缓存,但也包含一个依赖图,这是一个 有向无环图或 DAG,以及一个在运行查询时检测循环的数据结构。

在运行查询之前,它首先执行以下检查

  1. 对输入进行哈希处理(包括查询类型,以便为不同的查询生成唯一的哈希值)。
    • 如果这个哈希从未见过,则在依赖图中添加一个新节点,并立即运行查询。缓存其输出,然后返回。
  2. 如果哈希值已经存在,则在依赖图中找到相应的节点。
    • 如果该节点没有缓存的查询结果,则现在运行该查询并缓存结果。
  3. 如果没有缓存的查询结果,我们仍然没有完成,因为我们还需要确保缓存没有被无效化。存在三种查询类型:始终重新运行、代际和纯。
    • 即使纯查询是纯的,其缓存结果仍然可能被无效化。这可能发生在纯查询的依赖项不是纯的情况下。我们使用 rustc 所称的 try_mark_green 算法来解决这个问题。关于这个算法的更多信息将在后面介绍。
    • 当依赖项发生变化时,代际查询的缓存结果可以像纯查询一样被无效化。然而,当全局代际发生变化时,其结果也会过时。
    • 始终重新运行的查询的缓存结果永远无效。实际上,我们甚至从未存储过其输出。

尝试标记绿色算法,或递归评估缓存有效性。

对于每个查询实例(即查询类型、其输入、哈希输入、可能的缓存输出和输出哈希)我们还存储一条额外的信息:它是红色还是绿色。

这个字段可以由各种事物更改。

  1. 当一个查询实际上执行时,并且其输出与上次执行不同(或者没有以前的输出),则查询变为红色。
  2. 当一个查询实际上执行时,并且其输出与上次执行相同,则查询变为绿色。
  3. 当一个查询用于缓存无效化并所有依赖项都为绿色时,它会被标记为绿色。

缓存无效化的过程如下。

  1. 如果查询是 rerun(always),则其缓存立即无效。
  2. 如果查询是 rerun(generational),则当代际发生变化时,其缓存立即无效。
  3. 如果在有效性检查的开始时查询已经标记为绿色,并且它不(传递性地)依赖于任何 rerun(always) 查询,并且代际编号没有变化,则其缓存立即有效。
  4. 对查询的所有依赖项递归地运行此检查。这可能意味着运行依赖查询之一,它可能通过上述 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