Salsa 概述
⚠️ Salsa 仍在进展中 ⚠️
本页描述了未发布的 Salsa 2022 版本,该版本与旧版本大不相同。此处的代码有效,但仅在 github 和
salsa-2022
crate 中可用。
本文简要概述了 Salsa 程序的各个部分。要了解更详细的内容,请查看教程,该教程从头到尾介绍了整个项目的创建过程。
Salsa 的目标
Salsa 的目标是支持高效的 增量计算 (incremental recomputation) 。
例如, Salsa 被用在 rust-analyzer 中,以帮助它在你输入时快速重新编译你的程序。
Salsa 项目的基本理念是这样的:
let mut input = ...;
loop {
let output = your_program(&input);
modify(&mut input);
}
以某个值作为输入,你调用 your_program
,得到一个结果。一段时间后,你修改了输入并再次调用 your_program
。
我们的目标是 通过重用第一次调用的一些结果来加快第二次调用的速度。
当然,在现实中,你可以有许多输入,your_program
可能是在这些输入上定义的许多不同的方法和函数。
但这段代码仍然传达了几个重要的概念:
- Salsa 将“增量计算”(即函数
your_program
) 与一些定义输入的外循环分离出来 - Salsa 为你提供了定义
your_program
的工具 - Salsa 假定
your_program
是其输入的一个完全的确定性函数 (deterministic ,即每次给定相同的输入,输入的结果必须完全确定),否则整个设置没有意义 - 输入的变化总是发生在
your_program
之外, 作为这个主循环的一部分
数据库
每次运行程序时,Salsa 都会在 数据库 中记住每次计算的值。当更改输入时,它会查询该数据库来查找可以重用的值。
该数据库还用于实现驻留(interning,指制作一个值的规范版本,然后可以复制该值、进行廉价的相等比较)和其他方便的 Salsa 特性。
#[input]
每个 Salsa 程序都从一个输入 (input) 开始。
输入是定义程序起点的特殊的 struct。程序中的其他一切最终都是这些输入的确定性函数。
例如,在编译器中,可能有一个输入定义了磁盘上文件的内容:
#[salsa::input]
pub struct ProgramFile {
pub path: PathBuf,
pub contents: String,
}
你可以使用 new
方法创建一个输入。因为输入字段的值存储在数据库中,所以你也给了数据库一个 &mut
引用:
let file: ProgramFile = ProgramFile::new(
&mut db,
PathBuf::from("some_path.txt"),
String::from("fn foo() { }"),
);
Salsa 结构体只是一个整数
由 salsa::input
宏生成的 ProgramFile
结构实际上并不存储任何数据。它只是一个新类型
(newtype,一种 Rust 编程模式)的整数 id:
// Generated by the `#[salsa::input]` macro:
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ProgramFile(salsa::Id);
这意味着,当你有一个 ProgramFile
时,你可以很容易地复制它,并把它放在你喜欢的任何地方。
然而,要实际读取它的任何字段,你将需要使用数据库和一个 getter 方法。
读取字段和 return_ref
你可以使用 getter 方法1访问输入字段的值。由于这只是读字段,所以只需要一个对数据库的 &
引用:
let contents: String = file.contents(&db);
译者注:getter 名称就是 字段名
。
调用 getter 将从数据库中克隆值。有时这并不是你想要的,所以你可以给字段标注 #[return_ref]
属性,以表明它们应该从数据库中返回引用:
#[salsa::input]
pub struct ProgramFile {
pub path: PathBuf,
#[return_ref]
pub contents: String,
}
现在 file.content(&db)
将返回 &String
。
你也可以使用 data
方法访问整个结构:
file.data(&db)
写入字段
最后,你还可以使用 setter 方法2修改输入字段的值。因为这是在修改输入,所以 setter
需要对数据库的 &mut
引用:
file.set_contents(&mut db, String::from("fn foo() { /* add a comment */ }"));
译者注:setter 名称就是 set_字段名
。
#[tracked]
fn
一旦定义了输入,接下来要定义的就是 跟踪函数 (tracked function):
#[salsa::tracked]
fn parse_file(db: &dyn crate::Db, file: ProgramFile) -> Ast {
let contents: &str = file.contents(db);
...
}
当你调用一个跟踪函数时,salsa 将跟踪它访问了哪些输入(在本例中为 file.content(db)
)。
它还将记录返回值(在本例中为 Ast
)。
如果你调用一个跟踪函数两次, salsa 会检查输入是否已更改;如果没有更改,它可以返回存储的值 (memoized value) 。
Salsa 使用“红绿算法”决定何时需要重新执行跟踪函数,这就是 Salsa 这个名字的由来3。
译者注:“红绿”这种鲜艳的颜色搭配会让人想起 salsa 这种墨西哥辣番茄酱。
跟踪函数必须遵循特定的结构:
- 必须把对数据库的
&
引用作为第一个参数。- 注意,因为这是一个
&
引用,所以不可能在跟踪函数期间创建或修改输入!
- 注意,因为这是一个
- 必须将 "salsa struct" 作为第二个参数
- 在上述示例中,这是一个输入结构,但我们将简短地描述其他类型的 salsa 结构
- 可以接受其他参数,但如果不接受,速度会更快,而且更好。
跟踪函数可以返回任何可克隆 (clone-able) 的类型。需要克隆,因为在缓存值时,会从数据库中克隆出结果。
如果你希望从数据库返回引用,则跟踪函数也可以使用 #[return_ref]
进行标注。例如,如果 parse_file
函数被这样注释,那么调用者实际上会得到 &Ast
。
#[tracked]
struct
跟踪结构体 (tracked struct) 是在计算期间创建的中间结构。
与输入类似,它们的字段存储在数据库中,结构本身只包装一个 id。
与输入不同的是,它们只能在跟踪函数中创建,并且它们的字段的值一旦创建就永远不能更改。
所以只对 tracked struct 提供读取字段的 getter 方法,但没有 setter 方法。示例:
#[salsa::tracked]
struct Ast {
#[return_ref]
top_level_items: Vec<Item>,
}
就像输入一样,通过调用 Ast::new
来创建新值。
与输入不同的是,跟踪结构体的 new
只需要一个对数据库的 &
引用:
#[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]
字段
当跟踪函数因为其输入已更改而被重新执行时,函数新执行时创建的跟踪结构体对旧执行中创建的跟踪结构进行匹配,并比较它们的字段的值。
如果字段值没有更改,则不会重新执行仅读取这些字段的其他跟踪函数。
通常,跟踪结构体按照它们的创建顺序进行匹配。例如, parse_file
在旧执行中创建的第一个 Ast
会与 parse_file
在新执行中创建的第一个 Ast
进行匹配。
在上述示例中, parse_file
实际只创建了一个 Ast
,所以代码运行得很好。
然而,有时它的效果并不是很好。例如,假设我们有一个用于文件中条目的被跟踪结构体:
#[salsa::tracked]
struct Item {
name: Word, // 稍后会定义 Word
...
}
解析器首先创建名为 foo
的函数 Item
,然后再创建名为 bar
的函数 Item
。然后,使用者改变函数的排序,从而修改了输入。
尽管我们仍在创建相同数量的条目,但现在要以相反的顺序创建它们,因此朴素算法将匹配旧的 foo
结构和新的 bar
结构。
在 salsa 看来,好像 foo
函数被重命名为 bar
,而 bar
函数被重命名为 foo
。
我们虽然会得到正确的结果,但如果要明白它们只是被重新排序的话,我们可能需要做的更多的重新计算。
为了解决这个问题,可以将跟踪结构体中的字段标记为 #[id]
。然后,这些字段在执行时“匹配”结构体实例:
#[salsa::tracked]
struct Item {
#[id]
name: Word, // 稍后会定义 Word
...
}
#[tracked(specify)]
fn
有时,在定义一个跟踪函数的基础上,专门为某个特定的结构体指定它的值是很有用的。
例如,计算函数形式的默认方法可能是读取 AST,但你的语言中也有一些内置函数,并且你希望对其结果进行硬编码。
这也可用于模拟在创建跟踪结构体之后初始化字段。
为了支持这个用例,可以使用跟踪函联的 specify
关联方法。要启用此方法,需要在函数属性中添加
speciy
标志,以提醒用户其值有时可能是外部指定的。
#[salsa::tracked(specify)] // <-- specify flag required
fn representation(db: &dyn crate::Db, item: Item) -> Representation {
// read the user's input AST by default
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
}
只有将(除了第一个参数的数据库之外的)单个跟踪结构体作为参数的跟踪函数才可以这样指定。
#[interned]
struct
驻留结构体 (interned struct) 对于快速相等比较很有用。它们通常用于表示字符串或其他 primitive 的值。
例如,大多数编译器都会定义一个类型来表示用户标识符:
#[salsa::interned]
struct Word {
#[return_ref]
pub text: String,
}
与 #[input]
和 #[tracked]
所标注的结构体一样,Word
本身只是一个新类型的整数,实际数据存储在数据库中。
你可以使用 new
函数创建新的内部结构,就像使用输入和跟踪的结构体一样:
let w1 = Word::new(db, "foo".to_string());
let w2 = Word::new(db, "bar".to_string());
let w3 = Word::new(db, "foo".to_string());
当使用相同的字段值创建两个驻留结构体时,你肯定会得到相同的整型 id。因此,我们知道这里的
assert_eq!(w1, w3)
为 true
,并且 assert_ne!(w1, w2)
。
你可以使用像 word.text(db)
这样的 getter 来访问驻留结构体的字段。这些 getter 会考虑到 #[return_ref]
属性。
像跟踪结构体一样,驻留结构体的字段是不可变的。
#[accumulator]
最后一个 salsa 概念是 累加器 (accumulator)。
累加器是一种报告错误或其他“侧信道” (side channel) 信息的方法,这些信息独立于函数的主返回值。
要创建累加器,请赋予类型该属性:
#[salsa::accumulator]
pub struct Diagnostics(String);
它一定是一种某个类型的新类型,比如包装了 String
的 newtype。现在,在跟踪函数的执行期间,你可以推送这些值:
Diagnostics::push(db, "some_string".to_string())
然后,在执行之外,你可以请求某个特定跟踪函数累积的诊断集。
例如,假设一个类型检查程序,在类型检查期间,它会报告一些诊断:
#[salsa::tracked]
fn type_check(db: &dyn Db, item: Item) {
// ...
Diagnostics::push(db, "some error message".to_string())
// ...
}
然后,调用关联的 accumulated
函数来获取推送过的所有 String
的值:
let v: Vec<String> = type_check::accumulated::<Diagnostics>(db);