使用 scalac 编译 Scala 代码很慢,但 sbt 通常可以加快速度。通过了解 sbt 的工作原理,你甚至可以了解如何进一步提高编译速度。修改具有许多依赖项的源文件可能只需要重新编译这些源文件(例如可能需要 5 秒),而不是所有依赖项(例如可能需要 2 分钟)。通常你可以控制哪种情况适合你,并通过一些编码实践来加快开发速度。
提高 Scala 编译性能是 sbt 的主要目标,因此它提供的加速是使用它的主要动机之一。sbt 的很大一部分代码和开发工作都与加快编译速度的策略有关。
为了减少编译时间,sbt 使用两种策略
通过适当地组织源代码,你可以最大程度地减少代码更改的影响范围。sbt 无法精确确定哪些依赖项需要重新编译;目标是计算一个保守的近似值,这样无论何时需要重新编译文件,它都会被重新编译,即使我们可能重新编译了额外的文件。
sbt 以源文件粒度跟踪源代码依赖关系。对于每个源文件,sbt 跟踪直接依赖它的文件;如果文件中类、对象或特性的 **接口** 发生更改,则所有依赖该源文件的都必须重新编译。目前,sbt 使用以下算法来计算依赖于给定源文件的源文件
名称哈希优化从 sbt 0.13.6 开始默认启用。
sbt 使用的启发式算法意味着以下对用户可见的结果,这些结果决定了对类的更改是否会影响其他类。
以上关于方法的所有讨论也适用于字段和一般成员;类似地,对类的引用也扩展到对象和特性。
本节详细介绍了增量编译器的实现。它首先概述了增量编译器试图解决的问题,然后讨论了导致当前实现的设计选择。
增量编译的目标是检测对源文件或类路径的更改,并确定一组小的文件来重新编译,这样它将产生与完整批处理编译结果相同的最终结果。在应对更改时,增量编译器必须实现两个相互矛盾的目标
第一个目标是使重新编译速度更快,这是增量编译器存在的唯一目的。第二个目标是关于正确性,它设定了要重新编译的文件集的大小下限。确定该集合是增量编译器试图解决的核心问题。我们将在概述中深入探讨这个问题,以了解是什么使实现增量编译器成为一项具有挑战性的任务。
让我们考虑这个非常简单的例子
// A.scala
package a
class A {
def foo(): Int = 12
}
// B.scala
package b
class B {
def bar(x: a.A): Int = x.foo()
}
假设这两个文件都已经被编译,用户更改 `A.scala` 使其看起来像这样
// A.scala
package a
class A {
def foo(): Int = 23 // changed constant
}
增量编译的第一步是编译已修改的源文件。这是增量编译器必须编译的最小文件集。已修改版本的 `A.scala` 将成功编译,因为更改常量不会引入类型检查错误。增量编译的下一步是确定对 `A.scala` 的更改是否可能影响其他文件。在上面的示例中,只有方法 `foo` 返回的常量发生了更改,这不会影响其他文件的编译结果。
让我们考虑对 `A.scala` 的另一个更改
// A.scala
package a
class A {
def foo(): String = "abc" // changed constant and return type
}
与之前一样,增量编译的第一步是编译修改过的文件。在本例中,我们编译了A.scala
,编译将成功完成。第二步是再次确定对A.scala
的更改是否会影响其他文件。我们看到foo
公共方法的返回值类型已更改,因此这可能会影响其他文件的编译结果。实际上,B.scala
包含对foo
方法的调用,因此必须在第二步中进行编译。B.scala
的编译将因B.bar
方法中的类型不匹配而失败,并且该错误将报告给用户。在本例中,增量编译将在此处终止。
让我们确定在上面演示的示例中做出决策所需的两个主要信息。增量编译器算法需要
这两条信息都是从 Scala 编译器中提取的。
增量编译器以多种方式与 Scala 编译器交互
API 提取阶段从 Trees、Types 和 Symbols 中提取信息,并将其映射到增量编译器在api.specification 文件中描述的内部数据结构中。这些数据结构允许以独立于 Scala 编译器版本的方式表达 API。此外,这种表示形式是持久的,因此它被序列化到磁盘上,并在编译运行甚至 sbt 运行之间重复使用。
API 提取阶段包含两个主要组成部分
负责映射 Types 和 Symbols 的逻辑在API.scala 中实现。随着 Scala 反射的引入,我们有了 Types 和 Symbols 的多个变体。增量编译器使用在scala.reflect.internal
包中定义的变体。
此外,还存在一个可能并不明显的设计选择。当映射对应于类或特性的类型时,所有继承的成员都会被复制,而不是该类/特性中的声明。这样做的原因是,它极大地简化了对 API 表示形式的分析,因为与类相关的所有信息都存储在一个地方,因此无需查找父类型表示形式。这种简化是有代价的:相同的信息被反复复制,导致性能下降。例如,每个类都将具有java.lang.Object
的成员,以及有关其签名的完整信息。
增量编译器(按目前实现)不需要有关 API 的非常详细的信息。增量编译器只需要知道自上次索引 API 以来 API 是否已更改。为此,哈希和就足够了,并且可以节省大量内存。因此,API 表示形式在处理单个编译单元后立即被散列,并且只有哈希和被持久存储。
在早期版本中,增量编译器不会散列。这导致内存消耗非常高,序列化/反序列化性能很差。
散列逻辑在HashAPI.scala 文件中实现。
增量编译器提取给定编译单元依赖(引用)的所有 Symbols,然后尝试将其映射回相应的源/类文件。将 Symbol 映射回源文件是通过使用源文件派生的 Symbols 设置的sourceFile
属性来执行的。将 Symbol 映射回(二进制)类文件更棘手,因为 Scala 编译器不会跟踪从二进制文件派生的 Symbols 的来源。因此,使用简单的启发式方法,它将限定的类名映射到相应的类路径条目。此逻辑在依赖阶段实现,该阶段可以访问完整的类路径。
通过执行树遍历来获得给定编译单元依赖的 Symbols 集。树遍历检查所有可能引入依赖关系(引用另一个 Symbol)的树节点,并收集分配给它们的 Symbols。在类型检查阶段,Symbols 会被 Scala 编译器分配给树节点。
增量编译器过去依赖于CompilationUnit.depends
来收集依赖关系。但是,名称散列需要更精确的依赖关系信息。有关详细信息,请查看#1002.
通过检查CompilationUnit.icode
属性的内容来提取生成的类文件的集合,该属性包含后端将作为 JVM 类文件发出的所有 ICode 类。
让我们考虑以下示例
// A.scala
class A {
def inc(x: Int): Int = x+1
}
// B.scala
class B {
def foo(a: A, x: Int): Int = a.inc(x)
}
假设这两个文件都被编译,并且用户更改了A.scala
,使其看起来像这样
// A.scala
class A {
def inc(x: Int): Int = x+1
def dec(x: Int): Int = x-1
}
一旦用户点击保存并要求增量编译器重新编译其项目,它将执行以下操作
A.scala
,因为源代码已更改(第一次迭代)A.scala
的 API 结构,并检测到它已更改B.scala
依赖于A.scala
,并且由于A.scala
的 API 结构已更改,因此B.scala
也必须重新编译(B.scala
已失效)B.scala
,因为它在步骤 3 中由于依赖关系更改而失效B.scala
的 API 结构,发现它没有改变,所以我们就完成了总之,我们将调用 Scala 编译器两次:一次是重新编译A.scala
,然后是重新编译B.scala
,因为A
有一个新的方法dec
。
但是,可以很容易地看出,在这个简单的场景中,B.scala
的重新编译是不必要的,因为在A
类中添加dec
方法与B
类无关,因为它没有使用它,并且它在任何方面都没有受到它的影响。
在两个文件的情况下,我们重新编译太多并不好。但是,在实践中,依赖关系图相当密集,因此一个人可能最终会在对整个项目中的几乎所有文件都无关紧要的更改后重新编译整个项目。这正是当 Play 项目的路由被修改时发生的事情。路由和反向路由的性质是,每个模板和每个控制器都依赖于这两个类(Routes
和ReversedRoutes
)中定义的一些方法,但对特定路由定义的更改通常只影响所有模板和控制器的一小部分。
名称散列背后的想法是利用这一观察结果,使失效算法更智能地识别可能影响少量文件的更改。
如果给定源文件X.scala
的 API 更改不会影响文件Y.scala
的编译结果,即使Y.scala
依赖于X.scala
,那么该更改就可以被称为无关。
从这个定义中,可以很容易地看出,只有针对给定依赖关系才能声明更改无关。相反,如果更改不影响另一个文件的编译结果,则可以针对给定文件中的 API 的给定更改声明两个源文件之间的依赖关系无关。从现在开始,我们将重点关注检测无关依赖关系。
一个非常天真的解决检测无关依赖关系问题的方法是,我们会跟踪Y.scala
中所有使用的方法,因此,如果X.scala
中的方法被添加/删除/修改,我们只需检查它是否在Y.scala
中使用,如果没有,那么我们认为在本例中Y.scala
对X.scala
的依赖关系无关。
仅仅为了让您了解一下,如果您考虑这种策略,会很快出现的问题,让我们考虑一下这两个场景。
我们将看到,另一个源文件中未使用的方法可能会如何影响其编译结果。让我们考虑这种结构
// A.scala
abstract class A
// B.scala
class B extends A
让我们在A
类中添加一个抽象方法
// A.scala
abstract class A {
def foo(x: Int): Int
}
现在,一旦我们重新编译A.scala
,我们可以说,由于A.foo
在B
类中没有使用,那么我们不需要重新编译B.scala
。但是,事实并非如此,因为B
没有实现新引入的抽象方法,并且应该报告错误。
因此,简单地查看使用的方法来确定给定依赖关系是否相关是不够的。
这里,我们将看到另一个新引入方法(还没有在任何地方使用)影响其他文件编译结果的情况。这一次,不会涉及继承,但我们将使用富化模式(隐式转换)来代替。
让我们假设我们有以下结构
// A.scala
class A
// B.scala
class B {
class AOps(a: A) {
def foo(x: Int): Int = x+1
}
implicit def richA(a: A): AOps = new AOps(a)
def bar(a: A): Int = a.foo(12) // this is expanded to richA(a).foo so we are calling AOPs.foo method
}
现在,让我们在A
中直接添加一个foo
方法
// A.scala
class A {
def foo(x: Int): Int = x-1
}
现在,一旦我们重新编译A.scala
并检测到在A
类中定义了一个新方法,我们需要考虑这是否与B.scala
对A.scala
的依赖关系相关。请注意,在B.scala
中,我们没有使用A.foo
(它在编译B.scala
时不存在),但我们使用的是AOps.foo
,并且不清楚AOps.foo
与A.foo
有什么关系。需要检测到这样一个事实,即对AOps.foo
的调用是由于隐式转换richA
导致的,该隐式转换是由于我们在之前没有在A
上找到foo
而插入的。
这种类型的分析会很快让我们进入 Scala 类型检查器的实现复杂性,并且在一般情况下不可行。
以上所有内容都假设我们实际上拥有有关 API 结构和使用方法的完整信息,以便我们可以利用它。但是,如散列 API 表示形式 中所述,我们没有存储 API 的完整表示形式,而只是存储了它的哈希和。此外,依赖关系是在源文件级别跟踪的,而不是在类/方法级别跟踪的。
可以想象,重新设计当前系统以跟踪更多信息将是一项非常庞大的工作。此外,增量编译器过去用于保留整个 API 结构,但由于导致的内存要求过高,它已切换到哈希算法。
正如我们在上一章中所见,直接跟踪源文件中使用内容的更多信息的方法很快就会变得很棘手。人们希望找到一种更简单、更不精确的方法,但仍然可以比现有实现带来更大的改进。
这个想法是不跟踪所有使用的成员,也不精确地判断某个成员的更改何时会影响其他文件的编译结果。我们将仅跟踪使用的简单名称,并跟踪所有具有相同简单名称的成员的哈希值。简单名称仅指项或类型的非限定名称。
让我们首先看看这种简化的策略如何解决丰富模式问题。我们将通过模拟名称哈希算法来做到这一点。让我们从原始代码开始
// A.scala
class A
// B.scala
class B {
class AOps(a: A) {
def foo(x: Int): Int = x+1
}
implicit def richA(a: A): AOps = new AOps(a)
def bar(a: A): Int = a.foo(12) // this is expanded to richA(a).foo so we are calling AOPs.foo method
}
在编译这两个文件时,我们将提取以下信息
usedNames("A.scala"): A
usedNames("B.scala"): B, AOps, a, A, foo, x, Int, richA, AOps, bar
nameHashes("A.scala"): A -> ...
nameHashes("B.scala"): B -> ..., AOps -> ..., foo -> ..., richA -> ..., bar -> ...
usedNames
关系跟踪给定源文件中提到的所有名称。nameHashes
关系提供一组成员的哈希值,这些成员如果具有相同的简单名称,则会被放到一个桶中。除了上面介绍的信息之外,我们仍然跟踪 B.scala
对 A.scala
的依赖关系。
现在,如果我们在 A
类中添加一个 foo
方法
// A.scala
class A {
def foo(x: Int): Int = x-1
}
并重新编译,我们将获得以下(更新后的)信息
usedNames("A.scala"): A, foo
nameHashes("A.scala"): A -> ..., foo -> ...
增量编译器会比较更改前后名称的哈希值,并检测到 foo
的哈希值已更改(已添加)。因此,它会查看所有依赖于 A.scala
的源文件(在本例中只是 B.scala
),并检查 foo
是否作为使用的名称出现。它确实存在,因此它会按预期重新编译 B.scala
。
现在您可以看到,如果我们在 A
中添加另一个方法,比如 xyz
,那么 B.scala
不会被重新编译,因为 B.scala
中没有提到 xyz
这个名称。因此,如果您具有合理不冲突的名称,您应该能够从标记为无关的许多源文件之间的依赖关系中获益。
这种简单的基于名称的启发式方法能够承受“丰富模式”测试,这非常好。但是,名称哈希无法通过继承的另一个测试。为了解决这个问题,我们需要仔细研究继承引入的依赖关系与成员引用引入的依赖关系。
名称哈希算法背后的核心假设是,如果用户添加/修改/删除类的成员(例如方法),那么除非其他类使用该特定成员,否则其他类的编译结果不会受到影响。继承及其各种覆盖检查使整个情况更加复杂;如果您将它与引入新字段以继承自特征的类的混合组合一起使用,那么您很快就会意识到继承需要特殊处理。
这个想法是,目前,只要涉及继承,我们就将切换回旧方案。因此,我们将成员引用引入的依赖关系与继承引入的依赖关系分别跟踪。所有继承引入的依赖关系都不受名称哈希分析的影响,因此永远不会被标记为无关。
继承引入的依赖关系背后的直觉非常简单:它是类/特征通过继承另一个类/特征而引入的依赖关系。所有其他依赖关系称为成员引用依赖关系,因为它们是通过引用(选择)另一个类的成员(方法、类型别名、内部类、val 等)而引入的。请注意,为了继承一个类,您需要引用它,因此继承引入的依赖关系是成员引用依赖关系的严格子集。
这是一个说明这种区别的例子
// A.scala
class A {
def foo(x: Int): Int = x+1
}
// B.scala
class B(val a: A)
// C.scala
trait C
// D.scala
trait D[T]
// X.scala
class X extends A with C with D[B] {
// dependencies by inheritance: A, C, D
// dependencies by member reference: A, C, D, B
}
// Y.scala
class Y {
def test(b: B): Int = b.a.foo(12)
// dependencies by member reference: B, Int, A
}
有两点需要注意
X
不通过继承依赖于 B
,因为 B
作为类型参数传递给 D
;我们
只考虑作为 X
父类的类型
Y
确实 依赖于 A
,即使源文件中没有明确提及 A
;我们
选择 A
中定义的方法 foo
,这足以引入依赖关系
总之,我们希望处理继承及其引入的问题的方法是,分别跟踪所有继承引入的依赖关系,并使用更严格的方法来使依赖关系失效。本质上,只要存在继承依赖关系,它就会对父类型的任何(即使是最小的)更改做出反应。
到目前为止,我们略过了如何实际计算名称哈希。
如前所述,所有定义都按其简单名称分组,然后作为一个桶进行哈希。如果一个定义(例如一个类)包含其他定义,那么这些嵌套定义不会对哈希值有贡献。嵌套定义将对由其名称选择的桶的哈希值有贡献。
了解哪些对类的更改需要重新编译其客户端,这出奇地棘手。对 Java 有效的规则要简单得多(即使它们也包含一些微妙的点);尝试将它们应用于 Scala 将证明令人沮丧。这里列出了一些令人惊讶的要点,只是为了说明这些想法;此列表并非旨在涵盖所有内容。
super.methodName
的调用会解析为对名为 fullyQualifiedTraitName$$super$methodName
的抽象方法的调用;这些方法仅在使用时才存在。因此,添加对特定方法名的 super.methodName
的第一个调用会更改接口。目前,这还没有得到处理,请参阅 #466。sealed
的 case 类层次结构允许检查模式匹配的完整性。因此,使用 case 类的模式匹配必须依赖于完整的层次结构 - 这是依赖关系无法在类级别轻松跟踪的原因之一(请参阅 Scala 问题 SI-2559 了解示例)。有关在类级别跟踪依赖关系的详细讨论,请检查 #1104。如果您看到虚假的增量重新编译,或者您想了解对提取的接口的哪些更改会导致增量重新编译,那么 sbt 0.13 就拥有合适的工具。
为了调试接口表示及其在您修改和重新编译源代码时的更改,您需要执行以下两件事
apiDebug
选项。sbt.extraClasspath
系统属性的文档。警告
启用
apiDebug
选项会显着增加内存消耗,并降低增量编译器的性能。其根本原因是,为了生成有关接口差异的有意义的调试信息,增量编译器必须保留接口的完整表示,而不是像默认情况下那样只保留哈希值。仅在调试增量编译器问题时保持此选项启用。
以下是一段完整的文字记录,展示了如何在您的项目中启用接口调试。首先,我们下载 diffutils
jar 并将其传递给 sbt
curl -O https://java-diff-utils.googlecode.com/files/diffutils-1.2.1.jar
sbt -Dsbt.extraClasspath=diffutils-1.2.1.jar
[info] Loading project definition from /Users/grek/tmp/sbt-013/project
[info] Set current project to sbt-013 (in build file:/Users/grek/tmp/sbt-013/)
> set incOptions := incOptions.value.withApiDebug(true)
[info] Defining *:incOptions
[info] The new value will be used by compile:incCompileSetup, test:incCompileSetup
[info] Reapplying settings...
[info] Set current project to sbt-013 (in build file:/Users/grek/tmp/sbt-013/)
假设您在 Test.scala
中有以下源代码
class A {
def b: Int = 123
}
编译它,然后更改 Test.scala
文件,使其如下所示
class A {
def b: String = "abc"
}
然后再次运行 compile
。现在,如果您运行 last compile
,您应该在调试日志中看到以下几行
> last compile
[...]
[debug] Detected a change in a public API:
[debug] --- /Users/grek/tmp/sbt-013/Test.scala
[debug] +++ /Users/grek/tmp/sbt-013/Test.scala
[debug] @@ -23,7 +23,7 @@
[debug] ^inherited^ final def ##(): scala.this#Int
[debug] ^inherited^ final def synchronized[ java.lang.Object.T0 >: scala.this#Nothing <: scala.this#Any](x$1: <java.lang.Object.T0>): <java.lang.Object.T0>
[debug] ^inherited^ final def $isInstanceOf[ java.lang.Object.T0 >: scala.this#Nothing <: scala.this#Any](): scala.this#Boolean
[debug] ^inherited^ final def $asInstanceOf[ java.lang.Object.T0 >: scala.this#Nothing <: scala.this#Any](): <java.lang.Object.T0>
[debug] def <init>(): this#A
[debug] -def b: scala.this#Int
[debug] +def b: java.lang.this#String
[debug] }
您可以看到两个接口文本表示的统一 diff。如您所见,增量编译器检测到对 b
方法的返回值类型的更改。
本节解释了为什么依赖于公有方法返回值类型的类型推断并不总是合适的。但是,这是一个重要的设计问题,因此我们无法给出固定规则。此外,这种更改通常具有侵入性,而减少编译时间往往不是一个足够的动机。这也是我们从二进制兼容性和软件工程的角度讨论一些含义的原因。
考虑以下源文件 A.scala
import java.io._
object A {
def openFiles(list: List[File]) =
list.map(name => new FileWriter(name))
}
现在让我们考虑特征 A
的公有接口。请注意,openFiles
方法的返回值类型没有明确指定,而是通过类型推断计算为 List[FileWriter]
。假设在编写此源代码后,我们引入了某些客户端代码,然后将 A.scala
修改为如下所示
import java.io._
object A {
def openFiles(list: List[File]) =
Vector(list.map(name => new BufferedWriter(new FileWriter(name))): _*)
}
类型推断现在会将结果类型计算为 Vector[BufferedWriter]
;换句话说,更改实现会导致公有接口发生更改,并产生两个不良后果
val res: List[FileWriter] = A.openFiles(List(new File("foo.input")))
以下代码也会失效
val a: Seq[Writer] = new BufferedWriter(new FileWriter("bar.input"))
A.openFiles(List(new File("foo.input")))
我们如何避免这些问题?
当然,我们无法在一般情况下解决它们:如果我们想改变模块的接口,可能会导致中断。但是,我们通常可以从模块的接口中删除实现细节。例如,在上面的示例中,预期返回值类型可能更通用 - 即 Seq[Writer]
。也可能不是这样 - 这是一个设计选择,需要根据具体情况进行决定。但是,在本例中,我假设设计者选择 Seq[Writer]
,因为它在上述简化示例和上述代码的实际应用中都是一个合理的选择。
上面的客户端代码段现在将变为
val res: Seq[Writer] =
A.openFiles(List(new File("foo.input")))
val a: Seq[Writer] =
new BufferedWriter(new FileWriter("bar.input")) +:
A.openFiles(List(new File("foo.input")))
sbt 添加了一个扩展点,用户可以通过它在增量编译器尝试缓存类文件哈希之前有效地操作 Java 字节码(.class
文件)。这允许像 Ebean 这样的库在不破坏编译器缓存并每隔几秒钟重新运行编译的情况下与 sbt 一起使用。
这将编译任务拆分为几个子任务
previousCompile
:此任务返回该项目的先前持久化的 Analysis
对象。compileIncremental
:这是编译 Scala/Java 文件的逻辑核心。此任务实际上执行增量编译项目的任务,包括确保编译最少的源文件。在此方法之后,所有由 scalac + javac 生成的 .class 文件都将可用。manipulateByteCode
:这是一个桩任务,它接受 compileIncremental
结果并返回它。需要操作字节码的插件预计会用自己的实现覆盖此任务,并确保调用之前的行为。compile
: 此任务依赖于 manipulateBytecode
,然后持久化包含所有增量编译信息的 Analysis
对象。以下是如何在您自己的插件中挂钩新的 manipulateBytecode
键的示例
Compile / manipulateBytecode := {
val previous = (Compile / manipulateBytecode).value
// Note: This must return a new Compiler.CompileResult with our changes.
doManipulateBytecode(previous)
}
增量编译逻辑是在 https://github.com/sbt/sbt/blob/0.13/compile/inc/src/main/scala/inc/Incremental.scala 中实现的。有关增量重新编译策略的一些讨论可以在问题 #322、#288 和 #1010 中找到。