Salsa 概述
这一页会简要介绍一个 Salsa 程序由哪些部分组成。 如果你想看更细致的讲解,可以继续阅读教程, 它会从头到尾带你实现一个完整项目。
Salsa 的目标
Salsa 的目标是支持高效的增量重计算(incremental recomputation)。 例如,rust-analyzer 就使用了 Salsa, 帮助它在你输入代码时快速重新编译程序。
一个 Salsa 程序的基本形态大致如下:
#![allow(unused)] fn main() { let mut input = ...; loop { let output = your_program(&input); modify(&mut input); } }
你先拥有一份输入值, 调用程序得到结果。 过一段时间后,输入发生变化,你再调用程序一次。 我们的目标,是通过复用第一次调用中的部分结果,让第二次调用更快。
当然,现实中的程序通常会有很多输入,
而 your_program 也可能是定义在这些输入上的许多方法和函数的组合。
不过这张图依然表达了几个关键点:
- Salsa 把“增量计算”(也就是
your_program)与定义输入的外层循环分离开来。 - Salsa 提供了定义
your_program所需的工具。 - Salsa 假设
your_program是其输入的纯确定性函数,否则这一整套机制就没有意义。 - 输入的修改总是发生在
your_program之外,也就是这个主循环的一部分。
数据库
每次运行程序时,Salsa 都会把每一步计算的值记录在数据库中。 当输入发生变化时,它会查询这个数据库,看看哪些值可以复用。 数据库还会用于实现 interning (也就是把某个值规范化为唯一副本,使其可以被廉价复制并快速比较相等) 以及其他一些很方便的 Salsa 特性。
输入
每个 Salsa 程序都从输入(input)开始。 输入是一些特殊结构体,用来定义程序的起点。 程序中其余一切最终都必须是这些输入的某个确定性函数。
例如,在编译器中,可能会有一个输入表示磁盘上某个文件的内容:
#![allow(unused)] fn main() { #[salsa::input] pub struct ProgramFile { pub path: PathBuf, pub contents: String, } }
你可以通过 new 方法来创建一个输入。
由于输入字段的值会被存储进数据库,
因此你还要把数据库的一个 & 引用传进去:
#![allow(unused)] fn main() { let file: ProgramFile = ProgramFile::new( &db, PathBuf::from("some_path.txt"), String::from("fn foo() { }"), ); }
这里并不需要可变引用, 因为创建一个新输入不会影响数据库中已有的 tracked 数据。
Salsa 结构体本质上只是整数
由 salsa::input 宏生成出来的 ProgramFile 结构体本身其实并不存数据。
它只是一个 newtype 的整数 id:
#![allow(unused)] fn main() { // Generated by the `#[salsa::input]` macro: #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct ProgramFile(salsa::Id); }
这意味着一旦你拿到一个 ProgramFile,
就可以很方便地复制、传递、随处存放。
不过如果你想真正读取它的字段值,
还是必须借助数据库和 getter 方法。
读取字段与 returns(ref)
你可以通过 getter 方法访问输入字段的值。
由于这里只是读取字段,所以只需要数据库的 & 引用:
#![allow(unused)] fn main() { let contents: String = file.contents(&db); }
调用这个访问器时,值会从数据库中被克隆出来。
有时候这不是你想要的,
于是你可以在字段上加 #[returns(ref)] 标注,
告诉 Salsa 应当返回一个指向数据库内部的引用,而不是克隆值:
#![allow(unused)] fn main() { #[salsa::input] pub struct ProgramFile { pub path: PathBuf, #[returns(ref)] pub contents: String, } }
现在 file.contents(&db) 返回的就是 &String。
你也可以通过 data 方法访问整个结构体:
#![allow(unused)] fn main() { file.data(&db) }
写入输入字段
最后,你也可以通过 setter 方法修改输入字段的值。
因为这会真正修改输入,并可能使依赖于它的派生数据失效,
所以 setter 需要数据库的 &mut 引用:
#![allow(unused)] fn main() { file.set_contents(&mut db).to(String::from("fn foo() { /* add a comment */ }")); }
注意,set_contents 返回的是一个 “builder”。
这样你就可以进一步设置持久性等高级概念。
tracked 函数
定义好输入之后,下一步通常就是定义tracked 函数:
#![allow(unused)] fn main() { #[salsa::tracked] fn parse_file(db: &dyn crate::Db, file: ProgramFile) -> Ast { let contents: &str = file.contents(db); ... } }
调用 tracked 函数时,Salsa 会跟踪它访问了哪些输入
(在这个例子里,也就是 file.contents(db))。
它也会把返回值缓存下来(这里是 Ast)。
如果你调用同一个 tracked 函数两次,
Salsa 会检查它的输入是否发生变化;
如果没有,就可以直接返回缓存值。
Salsa 用来判断 tracked 函数是否需要重新执行的算法,
就叫作红绿算法,
也正是 “Salsa” 这个名字的来源之一。
tracked 函数必须遵守一些结构约束:
- 第一个参数必须是数据库的
&引用。- 也正因为它只是
&引用,所以在 tracked 函数内部不可能修改输入。
- 也正因为它只是
- 第二个参数必须是某种 “Salsa 结构体”。在这里它是一个 input struct,不过稍后我们还会看到别的类型。
- 可以有额外参数,但通常没有会更快,也更灵活。
tracked 函数可以返回任何可克隆的类型。
因为当值被缓存后,结果需要从数据库里被克隆出来。
如果你更希望返回指向数据库内部的引用,
也可以给 tracked 函数加上 #[returns(ref)] 标注
(例如,如果 parse_file 这样标注,那么调用者拿到的将是 &Ast)。
tracked 结构体
tracked 结构体是在计算过程中创建出来的中间结构体。 和 input 一样,它们的字段也存储在数据库中, 而结构体本身只是一个 id 的包装。 不同之处在于: 它们只能在 tracked 函数内部创建, 而且一旦创建,它们的字段值就不能再变化 (至少在当前修订里不会变)。 Salsa 只会为它们生成字段 getter,不会生成 setter。 例如:
#![allow(unused)] fn main() { #[salsa::tracked] struct Ast<'db> { #[returns(ref)] top_level_items: Vec<Item>, } }
和 input 一样,新值通过 Ast::new 创建。
不过 tracked struct 的 new 只需要数据库的 & 引用:
#![allow(unused)] fn main() { #[salsa::tracked] fn parse_file(db: &dyn crate::Db, file: ProgramFile) -> Ast { let contents: &str = file.contents(db); let parser = Parser::new(contents); let mut top_level_items = vec![]; while let Some(item) = parser.parse_top_level_item() { top_level_items.push(item); } Ast::new(db, top_level_items) // <-- create an Ast! } }
#[id] 字段
当某个 tracked 函数因为输入变化而重新执行时, 它在新执行过程中创建出的 tracked struct, 会和旧执行中创建出的对应结构体进行匹配, 并比较字段值。 如果字段值没有变化, 那么那些只读取这些字段的其他 tracked 函数就不需要重新执行。
默认情况下,tracked struct 是按创建顺序进行匹配的。
例如,旧执行里 parse_file 创建的第一个 Ast,
会与新执行里创建的第一个 Ast 进行匹配。
在我们的例子中,parse_file 只会创建一个 Ast,所以这样完全没问题。
但有时效果就没那么好。
比如,假设我们还为文件中的条目定义了一个 tracked struct:
#![allow(unused)] fn main() { #[salsa::tracked] struct Item { name: Word, // 稍后会定义 Word ... } }
假设解析器第一次先创建名为 foo 的 Item,
接着又创建一个名为 bar 的 Item。
后来用户修改输入,把两个函数的顺序调换了。
尽管结构体数量没变,
但现在它们的创建顺序正好反过来,
于是朴素算法就会把旧的 foo 与新的 bar 匹配起来。
对 Salsa 来说,这看起来就像 foo 被重命名成了 bar,
而 bar 又被重命名成了 foo。
结果当然仍然是正确的,
但如果我们能理解它们只是“调换顺序”而不是“改名”,
就可以少做很多不必要的重计算。
为了解决这个问题,你可以把某些字段标记为 #[id]。
这些字段随后就会被用来在不同执行之间“匹配”结构体实例:
#![allow(unused)] fn main() { #[salsa::tracked] struct Item { #[id] name: Word, // 稍后会定义 Word ... } }
为某些结构体专门指定 tracked 函数结果
有时你会希望定义一个 tracked 函数, 但又想对某些特定结构体的结果进行特殊指定。 例如,默认情况下你可能通过读取 AST 来计算某个函数的表示形式, 但语言里又有一批内建函数, 你想直接把它们的结果硬编码进去。
这也可以用来模拟一种场景: tracked struct 创建之后,再初始化其中某个字段。
为了支持这种用例,
你可以使用 tracked 函数对应的 specify 方法。
要启用它,需要在函数属性上加 specify 标志,
提醒使用者:这个函数的值有时会由外部指定。
#![allow(unused)] fn main() { #[salsa::tracked(specify)] // <-- specify flag required fn representation(db: &dyn crate::Db, item: Item) -> Representation { // 默认通过读取用户输入的 AST 来计算 let ast = ast(db, item); // ... } fn create_builtin_item(db: &dyn crate::Db) -> Item { let i = Item::new(db, ...); let r = hardcoded_representation(); representation::specify(db, i, r); // <-- use the method! i } }
只有那种除数据库参数外、只接收单个 tracked struct 作为参数的 tracked 函数,
才支持 specify。
interned 结构体
最后一种 Salsa 结构体是 interned 结构体。 它们非常适合做快速相等比较, 常见用法是表示字符串或其他基础值。
例如,大多数编译器都会定义一种类型来表示用户标识符:
#![allow(unused)] fn main() { #[salsa::interned] struct Word { #[returns(ref)] pub text: String, } }
和 input / tracked struct 一样,
Word 本身只是一个 newtype 的整数,
真正的数据仍然存放在数据库里。
你可以像创建 input 和 tracked struct 一样,
通过 new 创建一个 interned 结构体:
#![allow(unused)] fn main() { let w1 = Word::new(db, "foo".to_string()); let w2 = Word::new(db, "bar".to_string()); let w3 = Word::new(db, "foo".to_string()); }
如果你用相同字段值创建两个 interned struct,
那就一定会得到同一个整数 id。
因此这里我们知道 assert_eq!(w1, w3) 为真,
而 assert_ne!(w1, w2) 也同样成立。
你可以通过 getter 访问 interned struct 的字段,
例如 word.text(db)。
这些 getter 同样会遵守 #[returns(ref)] 的语义。
和 tracked struct 一样,interned struct 的字段也是不可变的。
累加器
Salsa 最后一个值得介绍的概念是累加器(accumulator)。 累加器是一种汇报错误, 或其他“旁路信息”(side channel information)的方法, 这类信息独立于函数的主返回值之外。
要创建一个累加器,你需要把某个类型声明成 accumulator:
#![allow(unused)] fn main() { #[salsa::accumulator] pub struct Diagnostics(String); }
它必须是某种值的 newtype,例如这里的 String。
之后,在 tracked 函数执行期间,你就可以往里 push 这些值:
#![allow(unused)] fn main() { Diagnostics::push(db, "some_string".to_string()) }
稍后,在函数执行之外, 你就可以查询某个特定 tracked 函数累积到的诊断集合。 例如,假设我们有一个类型检查器, 并且在类型检查期间它会汇报一些诊断信息:
#![allow(unused)] fn main() { #[salsa::tracked] fn type_check(db: &dyn Db, item: Item) { // ... Diagnostics::push(db, "some error message".to_string()) // ... } }
之后我们就可以调用关联的 accumulated 函数,
把所有被 push 进去的 String 都取出来:
#![allow(unused)] fn main() { let v: Vec<String> = type_check::accumulated::<Diagnostics>(db); }