最佳化器

語言
此文件頁面特定於 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)中很常見。(越來越多的 Java 程式碼使用 lambda,並在執行時期顯示出相同的效能問題。)

這類模式中最重要的兩個是「巨態分派」(也稱為「內聯問題」)和值封裝。如果您想進一步了解這些問題在 Scala 中的脈絡,可以觀看 我在 Scala Days 2015 的演講(從 26:13 開始)

Scala 最佳化器的目標是產生 JVM 可以快速執行的位元組碼。另一個目標是避免執行 JVM 已能順利執行的任何最佳化。

這表示如果 JIT 編譯器改進以更好地處理這些模式,Scala 最佳化器未來可能會變得過時。事實上,隨著 GraalVM 的到來,那個未來可能比你想像的更近!不過現在,我們深入探討 Scala 最佳化器的一些細節。

限制和假設

Scala 最佳化器必須在相當嚴格的限制內進行改進

  • 最佳化器只變更方法主體,但絕不變更類別或方法的簽章。產生的位元組碼具有相同的(二進位)介面,無論是否啟用最佳化器。
  • 我們不假設在執行最佳化器時已知整個程式(所有使用者程式碼加上所有相依性,這些相依性共同組成應用程式)。執行階段類別路徑中可能有些類別在編譯階段看不到:我們可能正在編譯函式庫,或僅編譯應用程式的元件。這表示
    • 每個非最終方法都有可能被覆寫,即使在編譯階段沒有任何類別定義此類覆寫
    • 因此,我們只能內嵌可在編譯階段解析的方法:最終方法、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 停用,這會在最佳化程式碼中產生額外的 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 會停用 nullness 最佳化。

單獨使用局部方法最佳化通常不會對效能產生任何正面影響,因為原始碼通常沒有不必要的裝箱或空值檢查。但是,局部最佳化通常可以在內嵌後套用,因此真正能改善程式效能的是內嵌和局部最佳化的結合。

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

此頁面的貢獻者