优化器

语言
此文档页面特定于 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.Predefscala.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.1B 依赖于 core-1.1.2,并且应用程序依赖于 AB,则构建工具会将 core-1.1.2 放在类路径中。如果 core-1.1.1 中的代码在编译时内联到 A 中,则由于二进制不兼容性,它可能会在运行时中断。

使用和与优化器交互

用于启用优化器的编译器标志是 -opt。运行 scalac -opt:help 将显示如何使用该标志。

默认情况下(没有任何编译器标志,或带有 -opt:default),Scala 编译器会消除不可达的代码,但不会运行任何其他优化。

-opt:local 启用所有方法局部优化,例如

  • 消除加载未使用值的代码
  • 重写编译时已知结果的 null 和 isInstanceOf 检查
  • 消除在方法中创建且不会逃逸该方法的值框,如 java.lang.Integerscala.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.

此页面的贡献者