此文档页面特定于 Scala 2 中提供的功能,这些功能已在 Scala 3 中删除或被替代功能取代。除非另有说明,此页面中的所有代码示例均假定你使用的是 Scala 2。
Lukas Rytz (2018)
Andrew Marki (2022)
Scala 2.12/2.13 内联器和优化器
简介
- Scala 编译器具有一个编译时优化器,可在版本 2.12 和 2.13 中使用,但尚未在 Scala 3 中使用。
- 不要在开发期间启用优化器:它会中断增量编译,并使编译器变慢。仅在 CI 上进行测试和构建版本时启用它。
- 使用
-opt:local
启用方法本地优化。此选项对二进制兼容性是安全的,但通常不会自行提高性能。 - 使用
-opt:inline:[PATTERN]
启用内联以及方法本地优化。- 发布库时,不要从依赖项中内联,它会破坏二进制兼容性。使用
-opt:inline:my.package.**
仅从库中的包中内联。 - 使用全局内联 (
-opt:inline:**
) 编译应用程序时,确保运行时类路径与编译时类路径完全相同。
- 发布库时,不要从依赖项中内联,它会破坏二进制兼容性。使用
- 仅当启用内联器时,
@inline
注解才有效。它告诉内联器始终尝试内联带注释的方法或调用站点。 - 如果没有
@inline
注解,内联器通常会内联高阶方法和转发器方法。主要目标是消除由于作为参数传递的函数而导致的巨态调用站点,并消除值装箱。其他优化委托给 JVM。
阅读更多内容以了解更多信息。
简介
Scala 编译器自 2.0 版本以来就包含了一个内联器。闭包消除和死代码消除已添加到 2.1 中。那是第一个 Scala 优化器,由 Iulian Dragos 编写和维护。随着时间的推移,他不断改进这些功能,并将它们合并到 -optimise
标志下(后来美国化为 -optimize
),该标志在 Scala 2.11 中仍然可用。
优化器已针对 Scala 2.12 重写,以变得更加可靠和强大——并通过调用新标志 -opt
来绕过拼写问题。此帖子介绍了如何在 Scala 2.12 和 2.13 中使用优化器:它做了什么、如何工作以及有哪些限制。
2.13.9 简化了选项。此页面使用简化形式。
动机
为什么 Scala 编译器甚至有一个 JVM 字节码优化器?JVM 是一个经过高度优化的运行时,它具有一个即时 (JIT) 编译器,受益于二十多年的调整。这是因为 JVM 无法正确优化某些众所周知的代码模式。这些模式在 Scala 等函数式语言中很常见。(越来越多的带有 lambda 的 Java 代码正在迎头赶上,并在运行时显示出相同的性能问题。)
最重要的两种模式是“巨态分派”(也称为“内联问题”)和值装箱。如果您想在 Scala 的上下文中了解有关这些问题的更多信息,您可以观看 我在 2015 年 Scala Days 上的演讲(从 26:13 开始)。
Scala 优化器的目标是生成 JVM 可以快速执行的字节码。另一个目标是避免执行 JVM 已经可以很好地执行的任何优化。
这意味着如果 JIT 编译器得到改进以更好地处理这些模式,Scala 优化器将来可能会过时。事实上,随着 GraalVM 的出现,这个未来可能比你想象的更近!但现在,我们深入了解 Scala 优化器的一些详细信息。
约束和假设
Scala 优化器必须在相当严格的约束范围内进行改进
- 优化器只更改方法体,但从不更改类或方法的签名。无论是否启用优化器,生成的字节码都具有相同的(二进制)接口。
- 我们不假设在运行优化器时已知整个程序(所有用户代码及其所有依赖项,它们共同组成一个应用程序)。在运行时类路径上可能存在我们在编译时看不到的类:我们可能正在编译库或应用程序的组件。这意味着
- 即使在编译时没有定义此类覆盖的类,每个非 final 方法都可能被覆盖
- 因此,我们只能内联可以在编译时解析的方法:final 方法、
object
中的方法以及接收器类型精确已知的方法(例如,在(new A).f
中,接收器已知恰好是A
,而不是A
的子类型)。
- 优化器不会破坏使用反射的应用程序。这是由以上两点得出的:对类的更改可以通过反射观察到,并且可以动态加载和实例化其他类。
但是,即使在这些约束范围内,优化器执行的某些更改也可以在运行时观察到
-
内联方法从调用堆栈中消失。
-
内联一个方法可能会延迟定义该方法的类的类加载。
- 优化器假设模块(单例,如
object O
)永远不会是null
。-
如果模块在其超类中加载,则此假设可能是错误的。以下示例在正常编译时会抛出
NullPointerException
,但在启用优化器编译时会打印0
class A { println(Test.f) } object Test extends A { @inline def f = 0 def main(args: Array[String]): Unit = () }
-
可以使用
-opt:-assume-modules-non-null
禁用此假设,这会导致在优化后的代码中进行额外的空检查。
-
-
优化器会删除某些内置模块的不必要的加载,例如
scala.Predef
和scala.runtime.ScalaRunTime
。这意味着可以跳过或延迟这些模块的初始化(构造)。- 例如,在
def f = 1 -> ""
中,方法Predef.->
被内联,并且对Predef
的访问被消除。生成代码为def f = new Tuple2(1, "")
。 - 可以使用
-opt:-allow-skip-core-module-init
禁用此假设
- 例如,在
- 优化器消除了未使用的
C.getClass
调用,这可能会延迟类加载。可以使用-opt:-allow-skip-class-loading
禁用此功能。
二进制兼容性
Scala小版本之间是二进制兼容的,例如2.12.6和2.12.7。Scala生态系统中的许多库也是如此。这些二进制兼容性承诺是Scala优化器不能在所有地方启用的主要原因。
原因是将一个类中的方法内联到另一个类中会改变被访问的(二进制)接口
class C {
private[this] var x = 0
@inline final def inc(): Int = { x += 1; x }
}
在内联调用站点c.inc()
时,生成代码不再调用inc
,而是直接访问字段x
。由于该字段是私有的(也在字节码中),因此仅允许在类C
本身内内联inc
。尝试从任何其他类访问x
将在运行时导致IllegalAccessError
。
然而,在许多情况下,Scala 源代码中的实现细节在字节码中会变为公开
class C {
private def x = 0
@inline final def m: Int = x
}
object C {
def t(c: C) = c.x
}
Scala 允许访问伴生对象 C
中的私有方法 x
。然而,在字节码中,伴生 C$
的类文件不允许访问 C
的私有方法。出于这个原因,Scala 编译器将 x
的名称“破坏”为 C$$x
,并使该方法变为公开。
这意味着 m
可以内联到 C
之外的类中,因为生成代码调用 C.C$$x
而不是 C.m
。不幸的是,这破坏了 Scala 的二进制兼容性承诺:公开方法 m
调用私有方法 x
的事实被认为是实现细节,在定义 C
的库的小版本中可能会更改。
更简单地说,假设方法 m
有缺陷,并在小版本中更改为 def m = if (fullMoon) 1 else x
。通常,用户只需将新版本放在类路径中即可。但是,如果 c.m
的旧版本在编译时内联,则在运行时类路径中拥有 C 的新版本将无法修复该缺陷。
为了安全地使用 Scala 优化器,用户需要确保编译时和运行时类路径相同。这对库开发人员有深远的影响:发布给其他项目使用的库不应内联类路径中的代码。可以使用 -opt:inline:my.package.**
将内联器配置为内联库本身的代码。
此限制的原因是,如 sbt 之类的依赖管理工具通常会选择较新版本的传递依赖项。例如,如果库 A
依赖于 core-1.1.1
,B
依赖于 core-1.1.2
,并且应用程序依赖于 A
和 B
,则构建工具会将 core-1.1.2
放在类路径中。如果 core-1.1.1
中的代码在编译时内联到 A
中,则由于二进制不兼容性,它可能会在运行时中断。
使用和与优化器交互
用于启用优化器的编译器标志是 -opt
。运行 scalac -opt:help
将显示如何使用该标志。
默认情况下(没有任何编译器标志,或带有 -opt:default
),Scala 编译器会消除不可达的代码,但不会运行任何其他优化。
-opt:local
启用所有方法局部优化,例如
- 消除加载未使用值的代码
- 重写编译时已知结果的 null 和
isInstanceOf
检查 - 消除在方法中创建且不会逃逸该方法的值框,如
java.lang.Integer
或scala.runtime.DoubleRef
可以禁用单个优化。例如,-opt:local,-nullness-tracking
禁用空值优化。
仅方法局部优化通常不会对性能产生任何积极影响,因为源代码通常没有不必要的装箱或空检查。但是,局部优化通常可以在内联后应用,因此实际上是内联和局部优化相结合可以提高程序性能。
-opt:inline
除了方法局部优化之外,还启用内联。但是,为了避免意外的二进制兼容性问题,我们还需要告诉编译器它允许内联哪些代码。这是通过在选项后指定一个模式来完成的,该模式用于选择要内联的包、类和方法。示例
-opt:inline:my.library.**
启用从在包my.library
或其任何子包中定义的任何类进行内联。库中的内联对于二进制兼容性是安全的,因此可以发布生成的二进制文件。即使在运行时类路径中将它的某个依赖项更新到较新的次要版本,它仍将正常工作。-opt:inline:<sources>
,其中模式是字符串文字<sources>
,启用从当前编译器调用中正在编译的源文件集中进行内联。此选项还可用于编译库。如果库的源文件跨多个 sbt 项目拆分,则仅在每个项目内执行内联。请注意,在增量编译中,内联仅发生在正在重新编译的源中——但在任何情况下,建议仅在 CI 和发布构建中启用优化器(并在构建前运行clean
)。-opt:inline:**
允许从每个类(包括 JDK)进行内联。在编译应用程序时,此选项启用完全优化。为避免二进制不兼容性,必须确保运行时类路径与编译时类路径相同,包括 Java 标准库。
运行 scalac -opt:help
可说明如何使用编译器标志。
内联启发式和 @inline
启用内联时,它会根据启发式自动选择用于内联的调用点。
如引言中所述,Scala 优化器的主要目标是消除巨态分派和值装箱。为了防止本文过长,后续文章将包括对具体示例的分析,这些示例说明了内联启发式选择了哪些调用点。
尽管如此,了解启发式的工作原理还是很有用的,因此这里有一个概述
- 带注释
@noinline
的方法或调用点不会内联。 - 内联器不会内联到转发器方法中。
- 带注释
@inline
的方法或调用点会内联。 - 带有函数文字作为参数的高阶方法会内联。
- 调用点方法的参数函数转发给被调用者的更高阶方法会内联。
- 带有
IntRef
/DoubleRef
/ … 参数的方法会内联。当嵌套方法更新外部方法的变量时,这些变量会装箱到XRef
对象中。在内联嵌套方法后,通常可以消除这些框。 - 转发器、工厂方法和简单方法会内联。示例包括简单的闭包主体,如
_ + 1
,以及合成方法(可能带有装箱/拆箱改编),如桥接。
为了防止方法超出 JVM 的方法大小限制,内联器有大小限制。当指令数超过某个阈值时,内联到方法中会停止。
如上所述,@inline
和 @noinline
注释是程序员影响内联决策的唯一方法。一般来说,我们的建议是避免使用这些注释。如果您发现可以通过注释方法来修复内联启发式的问题,我们非常希望听到它们,例如以 错误报告 的形式。
相关轶事:在 Scala 编译器和标准库(它们在启用优化器的情况下构建)中,大约有 330 个带 @inline
注释的方法。删除所有这些注释并重新构建项目对编译器的性能没有任何影响。因此,这些注释用意良好且无害,但实际上是不必要的。
对于专家用户,@inline
注释可用于手动调整对性能至关重要的代码,而不会降低抽象性。如果您有一个属于此类别的项目,请 告诉我们,我们有兴趣了解更多信息!
最后,请注意,@inline
注释仅在启用内联器时才有效,而默认情况下不会启用。原因是为了避免引入意外的二进制不兼容性,如 上文所述。
内联器警告
当调用位置无法内联时,内联器会发出警告。默认情况下,不会单独发出这些警告,而只在编译结束时作为摘要发出(类似于弃用警告)。
$> scalac Test.scala '-opt:inline:**'
warning: there was one inliner warning; re-run enabling -Wopt for details, or try -help
one warning found
$> scalac Test.scala '-opt:inline:**' -Wopt
Test.scala:3: warning: C::f()I is annotated @inline but could not be inlined:
The method is not final and may be overridden.
def t = f
^
one warning found
默认情况下,内联器会对无法内联的带 @inline
注释的方法调用发出警告。以下是上述命令中编译的源代码
class C {
@inline def f = 1
def t = f // cannot inline: C.f is not final
}
object T extends C {
override def t = f // can inline: T.f is final
}
-Wopt
标志有更多配置。使用 -Wopt:_
,会对启发式方法选中的但无法内联的每个调用位置发出警告。另请参见 -Wopt:help
。
内联器日志
如果您对内联器对您的代码所做的操作感到好奇(甚至持怀疑态度),可以使用 -Vinline
详细标志来生成内联器工作的跟踪信息
package my.project
class C {
def f(a: Array[Int]) = a.map(_ + 1)
}
$> scalac Test.scala '-opt:inline:**' -Vinline my/project/C.f
Inlining into my/project/C.f
inlined scala/Predef$.intArrayOps (the callee is annotated `@inline`). Before: 15 ins, after: 30 ins.
inlined scala/collection/ArrayOps$.map$extension (the callee is a higher-order method, the argument for parameter (evidence$6: Function1) is a function literal). Before: 30 ins, after: 94 ins.
inlined scala/runtime/ScalaRunTime$.array_length (the callee is annotated `@inline`). Before: 94 ins, after: 110 ins.
[...]
rewrote invocations of closure allocated in my/project/C.f with body $anonfun$f$1: INVOKEINTERFACE scala/Function1.apply (Ljava/lang/Object;)Ljava/lang/Object; (itf)
inlined my/project/C.$anonfun$f$1 (the callee is a synthetic forwarder method). Before: 654 ins, after: 666 ins.
inlined scala/runtime/BoxesRunTime.boxToInteger (the callee is a forwarder method with boxing adaptation). Before: 666 ins, after: 674 ins.