依赖关系解析的魔力
依赖关系解析是程序员通常认为理所当然的事情。无论是它 cargo , npm 还是您使用的任何包管理器,当这个黑匣子自己找出应该安装的特定软件包集时,没有人会感到惊讶。
不过,对我来说,这是一项令人着迷的壮举。当一台机器解决这样的抽象问题时,感觉很神奇,而我作为用户的输入最少!因此,当 Prefix.dev 的好人聘请我为 Conda 包生态系统创建一个开源依赖项求解器时,我很高兴。并不是说我是该主题的专家(远非如此!),但我确实知道我的 Rust 并且我是一个快速学习者。事实上,5周后,新的求解器终于可以作为响尾蛇 1 项目中的实验选项使用!
功劳归功劳,我能够在 Prefix 对响尾蛇的出色工作以及对 @wolfv 的不懈测试的基础上再接再厉,他一次又一次地设法打破了我的求解器......直到它终于奏效!
现在,如果你有兴趣,你可以跟着我进入依赖解析兔子洞。那里有很多有趣的东西可以看!
1. 背景
如果您想直接转到 SAT 求解器的内容,请随时跳过本节。
响尾蛇不是已经有了依赖关系解决器吗?
是的,它是libsolv的一个分支,但事实证明维护和扩展具有挑战性,因为它是用非常低级的C编写的。
所以你“用 Rust 重写了它”?
是的,感谢 NumFOCUS 的慷慨资助。我们调用了生成的库 libsolv_rs
,因为它主要是libsolv C库的一个端口,尽管它目前只支持解决Conda包。
依赖关系解析是什么意思?
您可能已经看到过 Cargo.toml
类似文件的东西,如下所示(大多数包管理器都有自己的自定义配置格式,但想法是相同的):
[package]
name = "foo"
version = "0.1.0"
edition = "2021"
[dependencies]
rand = "*"
在这里,我们指定我们的项目需要 rand
软件包(crate,用 Rust 的说法),并且我们对任何版本都感兴趣。当我运行 cargo build
这个项目时,cargo 将 rand
依赖关系解析为最高可用版本,在撰写本文时为 0.8.5
.它还将为 的依赖项、它们的依赖项等找到 rand
合适的版本(然后,已解析依赖项的整个列表将存储在 中 Cargo.lock
)。
从依赖需求(例如安装任何 rand
版本的)到具体的软件包树(例如安装 rand
版本 0.8.5
,及其对 x
版本的 0.2
依赖关系等)的过程称为依赖解析。在这个例子中,它可能看起来微不足道,但肯定不是!(如果你不相信我,请继续😉阅读)
2. 新的依赖关系求解器
我们已经提到了“求解器”这个词,但我们的意思是什么?在一般意义上,我会说求解器是一个程序,它接收数学问题作为输入,并将解决方案作为输出返回。这使它成为一个求解器,对吧?它的生活目标是解决。
在依赖关系解析的世界中,求解器是基于一些顶级依赖关系规范构造具体依赖关系树的引擎。在许多情况下,像我们一样,在幕后使用实际的 SAT 求解器(更多内容见下文)。这使得名称解析器更加合适!
从必需的依赖项到 SAT
让我们从一个例子开始。我有一个名为 的包,它 foo 0.0.1
取决于 bar
(任何版本都可以)。为了能够运行我的包,我需要安装的版本 bar
,包括其传递依赖项。然后,求解器应该告诉我要安装的 bar
确切版本及其依赖项。下面是“依赖关系解决问题”的非正式定义:
我的软件包
foo 0.0.1
依赖于任何bar
版本的包存储库中
bar
有两个可用的版本:bar 0.1.0
,这取决于baz 0.1.0
bar 0.2.0
,这取决于baz 0.2.0
baz 0.2.0
最近已从包存储库中删除我想知道应该安装哪些具体的软件包,以便
foo
的依赖项可用
上面的描述对于计算机来说太非正式了,但我们可以将其转换为布尔满足性问题(通常缩写为 SAT)。为此,您需要创建一个表示问题的布尔子句列表(其想法是每个子句的计算结果应为 true)。下面是从上述描述派生 2 的子句列表,其中每个变量对应于可以安装的包:
(foo 0.0.1)
:必须始终安装根包foo 0.0.1
(foo 0.0.1 => bar 0.1.0 ∨ bar 0.2.0)
:如果已安装,则必须foo 0.0.1
至少安装一个用于其bar
依赖项的软件包(bar 0.1.0 => baz 0.1.0)
:如果已安装,则bar 0.1.0
baz 0.1.0
也必须安装(bar 0.2.0 => baz 0.2.0)
:如果已安装,则bar 0.2.0
baz 0.2.0
也必须安装(¬bar 0.1.0 ∨ ¬bar 0.2.0)
:您最多可以bar
安装一个版本的(¬baz 0.1.0 ∨ ¬baz 0.2.0)
:您最多可以baz
安装一个版本的(¬baz 0.2.0)
:您无法安装baz 0.2.0
,因为它不在软件包存储库中
求解器应该如何处理子句?它必须找到每个变量的值(真或假),使每个子句(从 1 到 7)的计算结果为 true。求解器完成后,依赖关系树由已设置为 true 的所有变量组成。让我们计算出前面的条款,看看它的实际效果:
true
分配给foo 0.1.0
,每个子句 1(没有其他方法可以使子句 1 的计算结果为 true)- 根据
baz 0.2.0
子句 7 分配给false
(没有其他方法可以使子句 7 的计算结果为 true) - 根据
bar 0.2.0
子句 4 赋值给 (鉴于前面的变量赋值false
,没有其他方法可以使子句 4 的计算结果为 true) - 根据
bar 0.1.0
子句 2 赋值 (鉴于前面的变量赋值true
,没有其他方法可以使子句 2 的计算结果为 true) - 根据
baz 0.1.0
子句 3 赋值 (鉴于前面的变量赋值true
,没有其他方法可以使子句 3 的计算结果为 true) - 完成,因为所有子句的计算结果都为 true
那么依赖关系树中包含哪些包呢?那些被设置为true: foo 0.1.0
-> bar 0.1.0
-> baz 0.1.0
。
SAT解决
由于我们能够在布尔子句(如上面列出的子句)中表达依赖解析问题,因此我们可以将它们 3 提供给 SAT 求解算法以获得解决方案。
但是,上面的示例并不代表解决实际依赖关系问题所涉及的复杂性。到目前为止,我们只研究了当子句“强迫”我们这样做时为变量赋值的过程(即赋值是必需的,因为否则子句的计算结果为 false)。这也称为单位传播,是 SAT 求解器中使用的核心算法之一(有效地实现它需要一些聪明的技巧,例如观看文字,如果您阅读 MiniSAT 论文,您会发现)。
但是,单元传播不足以推动SAT求解过程完成。大多数实际方案都允许多种解决方案,因此通常没有设置变量值的“逻辑必要性”。你可以把它比作解决数独:有时你知道,一个数字必然应该进入一个特定的单元格。其他时候你必须猜测并希望你的猜测是正确的(你可能会发现你的猜测实际上是错误的,因为它导致了未来的不一致,在这种情况下你需要回溯)。
考虑到这一点,以下是我们使用的真实算法:
初始单元传播。总是至少有几个值可以在开始时传播,例如应该安装根包的事实(即它的变量应该设置为 true)。如果传播在此阶段遇到冲突的分配 4 ,这意味着原始需求无法满足(例如,用户想要安装不存在或彼此不兼容的软件包)。
求解器循环:
- 停止条件:如果所有子句的计算结果都为 true,我们就完成了。
- 设置:任意为尚未赋值的变量赋值。
- 传播:执行单元传播。如果传播完成且没有冲突,请返回到 1。
- 学习和回溯:找到导致冲突的作业组合并从中学习,这样我们以后就不会重复它们。如果冲突不可恢复,则意味着原始问题无法解决。如果可恢复,请回溯并返回到求解器循环的步骤 1。
同样,Rust 代码有很好的文档记录,并利用了可以指导您完成它的抽象,所以如果您想确切地了解底层工作原理,请不要犹豫。
有关学习的更多细节
对于求解者来说,从它看到的冲突中“学习”意味着什么?它如何能够找到冲突的原因?对于这篇已经很长的文章来说,这些问题的答案太长了,但如果你决心找出答案,这里有一些提示:我们使用的 SAT 求解算法称为冲突驱动条款学习 (CDCL)。MiniSAT论文提供了很好的介绍,甚至还有关于学习和回溯主题的幻灯片。
性能如何?
我们还没有进行基准测试,也没有努力优化任何东西。早期采用者报告说,解析器比libsolv慢,尽管仍然可以接受接近。一旦我们有了基准并开始迭代性能,我们希望更接近!
同样重要的是要注意,SAT 求解器的性能不是由低级技巧主导的,而是由所使用的算法主导的。SAT求解器已经在计算机科学领域研究了几十年,CDCL已经将自己确立为一种有效的算法,即使原始问题是NP完全的(它在几毫秒或几秒钟内找到解决方案,至少在依赖关系解决的特定领域)。我们真的站在巨人的肩膀上。
3. 尾声:在火车和求解器上
回到我在引言中提到的魅力,我不能不引用切斯特顿的“星期四的人”来结束这篇文章。在本书的这一片段中,诗人加布里埃尔·赛姆(Gabriel Syme)发表了关于火车奇迹的赞美演讲,你也可以将其应用于一般的计算机!
罕见的、奇怪的事情是达到目标;最恶心的,显而易见的事情是错过它。当人用一支狂箭击中远处的鸟时,我们觉得这是史诗般的。当一个狂野的引擎的人袭击一个遥远的车站时,这不是也是史诗般的吗?混沌是沉闷的;因为在混乱中,火车确实可以去任何地方,去贝克街或巴格达。但人是魔术师,他的全部魔力就在这里,他确实说维多利亚,瞧!是维多利亚。
这是我对报价的替代结束:
但是程序员是一个魔术师,他的全部魔力就在于此,他确实说“给我依赖树
x
y
,z
”,瞧!它是y
、z
、 的x
依赖树。
- 您可以在包管理器的诞生中阅读有关 rattler 的更多信息。↩︎
- 如果您想了解如何将依赖关系
libsolv_rs
转换为布尔子句,请查看此处。该代码:)有大量文档。↩︎ - 求解器要求子句仅
¬
包含 and∨
运算符,因为由所有单个子句生成的公式必须采用合取范式 (CNF)。幸运的是,可以转换为(¬a ∨ b)
,(a => b)
因此我们对布尔子句的翻译与传统的 SAT 求解器兼容。↩︎ - 当先前赋值的变量
A
在某个点赋值,并在以后的某个点x
赋值时,就会触发冲突¬x
。这表明求解者正在探索一个导致矛盾的分支,并且应该回溯。↩