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
    ...
}
}

假设解析器第一次先创建名为 fooItem, 接着又创建一个名为 barItem。 后来用户修改输入,把两个函数的顺序调换了。 尽管结构体数量没变, 但现在它们的创建顺序正好反过来, 于是朴素算法就会把旧的 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);
}