第 23 章 使用 Flambda 進行最佳化

1 概述

Flambda 是指 OCaml 4.03 版之後,原生碼編譯器提供的一系列最佳化過程的術語。

Flambda 旨在讓使用者更容易編寫慣用的 OCaml 程式碼,而不會產生效能損失。

若要使用 Flambda 最佳化器,必須將 -flambda 選項傳遞給 OCaml 的 configure 指令稿。(沒有支援單一編譯器可以在 Flambda 和非 Flambda 模式下運作。)使用 Flambda 編譯的程式碼不能與未使用 Flambda 編譯的程式碼連結到同一個程式中。嘗試這樣做會導致編譯器錯誤。

可以使用 -config 選項呼叫特定的 ocamlopt 並尋找任何以「flambda:」開頭的行,來判斷其是否使用 Flambda。如果存在這樣的行,且表示「true」,則表示支援 Flambda,否則不支援。

Flambda 跨越不同的編譯單元提供完整的最佳化,前提是目前正在編譯的單元的相依性的 .cmx 檔案可用。(編譯單元對應於單個 .ml 原始碼檔案。)但是,它尚未完全作為整個程式的編譯器運作:例如,不支援跨越一整組編譯單元來消除無用程式碼。

產生位元組碼時,目前不支援使用 Flambda 進行最佳化。

一般而言,Flambda 不應影響現有程式的語意。此規則有兩個例外:可能會消除正在進行基準測試的純程式碼(請參閱23.14節),以及使用不安全操作的程式碼的行為變更(請參閱23.15節)。

Flambda 尚未最佳化陣列或字串的邊界檢查。它也不會從使用者在程式碼中寫入的任何斷言中取得最佳化的提示。

請參閱本章結尾的詞彙表,了解下方使用的技術術語的定義。

2 命令列旗標

Flambda 最佳化器提供各種可用於控制其行為的命令列旗標。參考章節中提供了每個旗標的詳細說明。這些章節也說明了特定旗標所採用的任何引數。

常用選項

-O2
執行比平常更多的最佳化。可能會延長編譯時間。(此旗標是 23.5 節中描述的一組特定參數的縮寫。)
-O3
執行比平常更多的最佳化,可能包括展開遞迴函式。可能會顯著延長編譯時間。
-Oclassic
在函式定義點而非在呼叫點做出內聯決策。這反映了未使用 Flambda 的 OCaml 編譯器的行為。與使用新的 Flambda 內聯啟發式方法(例如 -O2)進行編譯相比,它會產生較小的 .cmx 檔案、較短的編譯時間以及可能執行速度較慢的程式碼。使用 -Oclassic 時,只有本節中描述的下列選項相關:-inlining-report-inline。如果使用本節中描述的任何其他選項,則行為未定義,並且可能在未來版本的編譯器中造成錯誤。
-inlining-report
發出 .inlining 檔案(每輪最佳化一個),其中顯示內聯器做出的所有決策。

較少使用的選項

-remove-unused-arguments
即使引數未特製化,也要移除未使用的函式引數。這可能會產生些微的效能損失。請參閱23.10.3節。
-unbox-closures
透過特製化的引數而非閉包傳遞自由變數(一種減少配置的最佳化)。請參閱23.9.3節。這可能會產生些微的效能損失。

進階選項,僅在詳細調整時需要

-inline
行為取決於是否使用 -Oclassic
  • 當不在 -Oclassic 模式時,-inline 會限制在任何推測性內聯搜尋期間考量內聯的函式總大小。(請參閱23.3.6節。)請注意,此參數不會控制任何特定函式是否可以內聯的評估。將其提高到過高的量不一定會導致更多函式被內聯。
  • -Oclassic 模式下,-inline 的行為與先前版本的編譯器相同:它是要考慮內聯的函式最大大小。請參閱23.3.1節。
-inline-toplevel
-inline 等效,但在推測性內聯從頂層開始時使用。請參閱23.3.6節。不在 -Oclassic 模式下使用。
-inline-branch-factor
控制內聯器如何評估程式碼路徑是否可能是熱或冷路徑。請參閱23.3.5節。
-inline-alloc-cost, -inline-branch-cost, -inline-call-cost
控制內聯器如何評估與各種操作相關的執行期效能損失。請參閱23.3.5節。
-inline-indirect-cost, -inline-prim-cost
同樣地。
-inline-lifting-benefit
控制在頂層內聯函子。請參閱23.3.5節。
-inline-max-depth
任何推測性內聯搜尋的最大深度。請參閱23.3.6節。
-inline-max-unroll
在任何推測性內聯搜尋期間,任何遞迴函式展開的最大深度。請參閱23.3.6節。
-no-unbox-free-vars-of-closures
不要解箱閉包變數。請參閱23.9.1節。
-no-unbox-specialised-args
不要解箱已特製化函式的引數。請參閱23.9.2節。
-rounds
要執行的最佳化輪次數。請參閱23.2.1節。
-unbox-closures-factor
使用 -unbox-closures 時,用於計算效益的縮放因子。請參閱23.9.3節。
註解

2.1 依輪次指定最佳化參數

Flambda 在輪次中運作:一輪包含某個轉換序列,然後可以重複該序列以獲得更令人滿意的結果。可以使用 -rounds 參數手動設定輪次數(儘管在使用預先定義的最佳化層級時,例如使用 -O2-O3 時,不需要這樣做)。對於高最佳化,輪次數可以設定為 3 或 4。

可以每輪套用的命令列旗標,例如名稱中帶有 -cost 的旗標,接受以下形式的引數

n | round=n[,...]

旗標 -Oclassic-O2-O3 會在所有其他旗標之前套用,這表示可以覆寫某些參數,而無需指定給定最佳化層級通常調用的每個參數。

3 內聯

內聯是指將函式的程式碼複製到呼叫函式的位置。函式的程式碼將被其參數與對應引數的繫結包圍。

內聯的目的是

這些目標的達成,往往不單單是透過內聯本身,也透過編譯器因內聯而能夠執行的其他最佳化。

當遞迴呼叫一個函數(在該函數的定義內,或在同一個相互遞迴群組中的另一個函數內)被內聯時,這個過程也被稱為展開(unrolling)。這有點類似於迴圈剝離(loop peeling)。例如,給定以下程式碼

let rec fact x =
  if x = 0 then
    1
  else
    x * fact (x - 1)

let n = fact 4

在呼叫點 fact 4 展開一次會產生(fact 的主體保持不變):

let n =
  if 4 = 0 then
    1
  else
    4 * fact (4 - 1)

這簡化為:

let n = 4 * fact 3

相較於編譯器的先前版本,Flambda 提供了顯著增強的內聯能力。

附註:何時執行內聯

內聯與所有其他 Flambda 最佳化過程一起執行,也就是說,在閉包轉換之後。相較於在閉包轉換之前可能更直接的實作方式,這有三個特別的優點:

3.1 傳統內聯啟發法

-Oclassic 模式中,Flambda 內聯器的行為模仿了編譯器的先前版本。(程式碼仍然可能會受到編譯器先前版本未執行的進一步最佳化:函子可能會被內聯,常數會被提升,而未使用的程式碼會被消除,所有這些都在本章的其他地方描述。請參閱 23.3.323.8.123.10 各節)。在函數的定義站點,會測量函數的主體。如果滿足以下條件,它將被標記為符合內聯條件(因此會在每個直接呼叫點被內聯):

編譯器的非 Flambda 版本無法內聯包含另一個函數定義的函數。然而,-Oclassic 允許這樣做。此外,非 Flambda 版本也無法內聯僅因先前的內聯過程而公開的函數,但 -Oclassic 再次允許這樣做。例如:

module M : sig
  val i : int
end = struct
  let f x =
    let g y = x + y in
    g
  let h = f 3
  let i = h 4  (* h is correctly discovered to be g and inlined *)
end

所有這些都與正常的 Flambda 模式形成對比,也就是說,沒有 -Oclassic,在這種情況下:

Flambda 模式在下一節中描述。

3.2 “Flambda” 內聯啟發法概述

每當編譯器配置為 Flambda 且未指定 -Oclassic 時,都會使用 Flambda 內聯啟發法,在呼叫點做出內聯決策。這有助於在上下文很重要的情況下。例如:

let f b x =
  if b then
    x
  else
    ... big expression ...

let g x = f true x

在這種情況下,我們希望將 f 內聯到 g 中,因為可以消除條件跳轉,程式碼大小應該會減少。如果內聯決策是在宣告 f 後在沒有看到它的使用情況下做出的,那麼它的大小可能使其不符合內聯條件;但在呼叫點,可以知道它的最終大小。此外,這個函數可能不應該系統地內聯:如果 b 是未知的,或者實際上是 false,那麼權衡程式碼大小的巨大增加幾乎沒有好處。在現有的非 Flambda 內聯器中,這不是一個大問題,因為內聯鏈很快就被截斷了。然而,這導致過度使用過大的內聯參數,例如 -inline 10000

更詳細地說,在每個呼叫點都會執行以下程序:

對同一個相互遞迴群組中其他函數的呼叫在遞迴函數內的內聯,會受到下面描述的展開深度的控制。這確保函數不會被過度展開。(只有在選擇 -O3 最佳化等級和/或傳遞帶有大於零的參數的 -inline-max-unroll 標記時,才會啟用展開。)

3.3 處理特定的語言建構

函子

與普通函數相比,沒有什麼特別的因素會阻止函子進行內聯。對內聯器來說,它們看起來都一樣,只是函子被標記為這樣。

對頂層函子的應用傾向於內聯。(可以調整此偏差:請參閱下面 -inline-lifting-benefit 的文件。)

不在頂層的函子的應用,例如在某些其他表達式內的局部模組中,內聯器會將它們視為與普通函數呼叫相同。

一級模組

如果內聯器知道將要呼叫哪個特定函數,它將能夠考慮內聯對一級模組中函數的呼叫。包含模組中一組函數的頂級模組記錄本身不會阻止內聯。

物件

目前,Flambda 不會內聯對物件的方法呼叫。

3.4 內聯報告

如果向編譯器提供 -inlining-report 選項,那麼將會發出一個對應於每一輪最佳化的檔案。對於 OCaml 原始檔 *basename*.ml,這些檔案的名稱為 *basename*.*round*.inlining.org,其中 *round* 是一個從零開始的整數。在這些檔案中,它們的格式為「org 模式」,會找到描述內聯器所做決策的英文散文。

3.5 內聯效益評估

內聯通常會導致程式碼大小增加,如果沒有加以控制,不僅可能導致可執行檔過於龐大和編譯時間過長,還可能由於更差的局部性而導致效能下降。因此,Flambda 內聯器會權衡程式碼大小的變化與預期的執行時效能效益,而效益的計算基於編譯器觀察到可能因內聯而移除的操作次數。

例如,給定以下程式碼:

let f b x =
  if b then
    x
  else
    ... big expression ...

let g x = f true x

將觀察到內聯 f 將會移除:

形式上,執行時效能效益的估計是透過首先加總因內聯和隨後簡化內聯主體而已知會移除的操作成本來計算的。各種操作種類的個別成本可以使用各種 -inline-...-cost 標記來調整,如下所示。成本指定為整數。所有這些標記都接受一個單一參數,該參數使用 23.2.1 節中詳述的約定來描述此類整數。

-inline-alloc-cost
配置的成本。
-inline-branch-cost
分支的成本。
-inline-call-cost
直接函數呼叫的成本。
-inline-indirect-cost
間接函數呼叫的成本。
-inline-prim-cost
基本操作的成本。基本操作包含算術和記憶體存取等操作。

(預設值在下面的 23.5 節中描述。)

然後,初始效益值會按一個因數縮放,該因數嘗試補償程式碼中的當前點(如果處於一定數量的條件分支之下)可能為冷路徑的事實。(Flambda 目前不計算熱路徑和冷路徑。)該因數(估計的內聯器確實處於路徑的機率)計算為 1/(1 + f)d,其中 f-inline-branch-factor 設定,d 是目前點的分支巢狀深度。當內聯器下降到更深層的巢狀分支時,內聯的好處就會減少。

產生的效益值稱為估計效益

程式碼大小的變化也會被估計:嚴格來說,它應該是機器碼大小的變化,但由於內聯器無法使用它,因此會使用近似值。

如果估計的效益超過程式碼大小的增加,那麼將保留該函數的內聯版本。否則,該函數將不會被內聯。

頂層函子的應用將獲得額外的好處(可以通過 -inline-lifting-benefit 標記控制),以便在這種情況下傾向於保持內聯版本。

3.6 投機控制

如上所述,有三個參數會限制在投機期間搜尋內聯機會:

這些參數最終會受到提供給相應命令列標記的參數(或其預設值)的限制:

特別要注意-inline 不具有其在先前編譯器或 -Oclassic 模式中的含義。在這兩種情況下,-inline 實際上是對內聯效益的一些基本評估。然而,在 Flambda 內聯模式下,它對應於對搜尋的限制;對效益的評估是獨立的,如上所述。

當開始推測時,內聯閾值會從 -inline 設定的值開始(如果適用,則為 -inline-toplevel,請參閱上方)。在做出推測性的內聯決策後,閾值會減少被內聯函數的程式碼大小。如果閾值耗盡,降至零或以下,則不會執行進一步的推測。

內聯深度從零開始,每次內聯器深入另一個函數時都會增加一。每次內聯器離開這樣的函數時,它就會減少一。如果深度超過 -inline-max-depth 設定的值,則推測會停止。此參數旨在作為一種通用後備機制,以應對內聯閾值無法充分控制搜尋的情況。

展開深度適用於同一相互遞迴函數群組內的呼叫。每次執行此類呼叫的內聯時,在檢查產生的主體時,深度會增加一。如果深度達到 -inline-max-unroll 設定的限制,則推測會停止。

4 特殊化

內聯器可能會發現對遞迴函數的呼叫點,其中已知關於引數的一些資訊:例如,它們可能等於目前作用域中的其他變數。在這種情況下,將函數特殊化為這些引數可能是有益的。這是透過複製函數的宣告(以及任何其他參與任何相同相互遞迴宣告的函數)並記錄有關引數的額外資訊來完成的。由這些資訊增強的引數稱為特殊化引數。為了盡量確保不會無用地執行特殊化,只有當可以證明引數是不變的時才會將其特殊化:換句話說,在遞迴函數本身執行期間,引數永遠不會改變。

除非被屬性覆蓋(見下文),否則在以下情況下不會嘗試函數的特殊化:

編譯器可以證明遞迴群組內多個函數的函數引數不變性(儘管這有一些限制,如下例所示)。

應該注意的是,閉包拆箱傳遞(見下文)可以在非遞迴函數上引入特殊化引數。(目前編譯器中沒有其他地方這樣做。)

範例:著名的 List.iter 函數

這個函數可能會這樣寫

let rec iter f l =
  match l with
  | [] -> ()
  | h :: t ->
    f h;
    iter f t

並像這樣使用

let print_int x =
  print_endline (Int.to_string x)

let run xs =
  iter print_int (List.rev xs)

iter 的引數 f 是不變的,因此可以將該函數特殊化

let run xs =
  let rec iter' f l =
    (* The compiler knows: f holds the same value as foo throughout iter'. *)
    match l with
    | [] -> ()
    | h :: t ->
      f h;
      iter' f t
  in
  iter' print_int (List.rev xs)

編譯器記錄下來,對於函數 iter’,引數 f 特殊化為常數閉包 print_int。這表示 iter’ 的主體可以簡化

let run xs =
  let rec iter' f l =
    (* The compiler knows: f holds the same value as foo throughout iter'. *)
    match l with
    | [] -> ()
    | h :: t ->
      print_int h;  (* this is now a direct call *)
      iter' f t
  in
  iter' print_int (List.rev xs)

print_int 的呼叫確實可以內聯

let run xs =
  let rec iter' f l =
    (* The compiler knows: f holds the same value as foo throughout iter'. *)
    match l with
    | [] -> ()
    | h :: t ->
      print_endline (Int.to_string h);
      iter' f t
  in
  iter' print_int (List.rev xs)

現在可以移除未使用的特殊化引數 f,留下

let run xs =
  let rec iter' l =
    match l with
    | [] -> ()
    | h :: t ->
      print_endline (Int.to_string h);
      iter' t
  in
  iter' (List.rev xs)
關於不變參數的題外話。

編譯器目前無法偵測到以下情況下的不變性。

let rec iter_swap f g l =
  match l with
  | [] -> ()
  | 0 :: t ->
    iter_swap g f l
  | h :: t ->
    f h;
    iter_swap f g t

4.1 特殊化效益評估

特殊化的效益評估方式與內聯類似。特殊化的引數資訊可能表示可以簡化正在特殊化的函數主體:被移除的操作會累積到效益中。然後,將此與重複(特殊化)函數宣告的大小一起評估,以對比原始函數呼叫的大小。

5 參數的預設設定

預設設定(不使用 -Oclassic 時)是使用以下參數進行一輪最佳化。

參數設定
-inline10
-inline-branch-factor0.1
-inline-alloc-cost7
-inline-branch-cost5
-inline-call-cost5
-inline-indirect-cost4
-inline-prim-cost3
-inline-lifting-benefit1300
-inline-toplevel160
-inline-max-depth1
-inline-max-unroll0
-unbox-closures-factor10

5.1 -O2 最佳化層級的設定

當指定 -O2 時,會執行兩輪最佳化。第一輪使用預設參數(見上文)。第二輪使用以下參數。

參數設定
-inline25
-inline-branch-factor與預設相同
-inline-alloc-cost預設值的兩倍
-inline-branch-cost預設值的兩倍
-inline-call-cost預設值的兩倍
-inline-indirect-cost預設值的兩倍
-inline-prim-cost預設值的兩倍
-inline-lifting-benefit與預設相同
-inline-toplevel400
-inline-max-depth2
-inline-max-unroll與預設相同
-unbox-closures-factor與預設相同

5.2 -O3 最佳化層級的設定

當指定 -O3 時,會執行三輪最佳化。前兩輪與 -O2 相同。第三輪使用以下參數。

參數設定
-inline50
-inline-branch-factor與預設相同
-inline-alloc-cost預設值的三倍
-inline-branch-cost預設值的三倍
-inline-call-cost預設值的三倍
-inline-indirect-cost預設值的三倍
-inline-prim-cost預設值的三倍
-inline-lifting-benefit與預設相同
-inline-toplevel800
-inline-max-depth3
-inline-max-unroll1
-unbox-closures-factor與預設相同

6 內聯和特殊化的手動控制

如果內聯器被證明難以處理,並且拒絕內聯特定函數,或者如果觀察到的內聯決策因其他原因不令程式設計人員滿意,則可以由程式設計人員直接在原始碼中指示內聯行為。一個可能適合這樣做的情境是,當程式設計人員(而不是編譯器)知道特定函數呼叫位於冷程式碼路徑上時。可能希望防止內聯函數,以便使熱路徑上的程式碼大小保持較小,從而提高區域性。

內聯器使用屬性來導引。對於非遞迴函數(以及遞迴函數的一步展開,儘管 @unroll 對於此目的更清楚),支援以下屬性:

@@inline always@@inline never
附加到函數或函子的宣告,這些會指示內聯器始終或永不內聯,而不考慮大小/效益計算。(如果該函數是遞迴的,則會替換主體,並且不會針對遞迴呼叫點採取特殊動作。)沒有引數的 @@inline 等同於 @@inline always
@inlined always@inlined never
附加到函數應用,這些會同樣地指示內聯器。呼叫點上的這些屬性會覆蓋可能存在於相應宣告中的任何其他屬性。沒有引數的 @inlined 等同於 @inlined always@@inlined hint 等同於 @@inline always,但如果函數應用無法內聯,則不會觸發警告 55。

對於遞迴函數,相關屬性為

@@specialise always@@specialise never
附加到函數或函子的宣告,這會指示內聯器在具有適當的上下文知識的情況下,始終或永不特殊化函數,而不考慮大小/效益計算。沒有引數的 @@specialise 等同於 @@specialise always
@specialised always@specialised never
附加到函數應用,這會同樣地指示內聯器。呼叫點上的此屬性會覆蓋可能存在於相應宣告中的任何其他屬性。(請注意,只有當存在一個或多個值已知的不變參數時,才會特殊化該函數。)沒有引數的 @specialised 等同於 @specialised always
@unrolled n
此屬性附加到函數應用,並且始終採用整數引數。每次內聯器看到該屬性時,其行為如下
  • 如果 n 為零或小於零,則不會發生任何事。
  • 否則,被呼叫的函數會在其呼叫點被替換,其主體經過重寫,使得對該函數或同一相互遞迴群組中的任何其他函數的任何遞迴呼叫都使用屬性 unrolled(n − 1) 進行註釋。內聯可能會繼續在該主體上進行。
因此,n 的行為就像「展開的最大深度」。

如果發現無法遵守 @inlined@specialised 屬性的註釋,則會發出編譯器警告。

範例說明屬性的正確位置
module F (M : sig type t end) = struct
  let[@inline never] bar x =
    x * 3

  let foo x =
    (bar [@inlined]) (42 + x)
end [@@inline never]

module X = F [@inlined] (struct type t = int end)

7 簡化

與內聯一起執行的簡化會傳播關於哪些變數在執行時保留哪些值的資訊(稱為近似值)。還會追蹤變數和符號之間的某些關係:例如,可能已知某些變數始終保留與其他某些變數相同的值;或者可能已知某些變數始終保留某些符號指向的值。

在以下情況下,傳播可以幫助消除配置:

let f x y =
  ...
  let p = x, y in
  ...
  ... (fst p) ... (snd p) ...

來自 p 的投影可以替換為對變數 xy 的使用,這可能表示 p 變得未使用。

簡化傳遞執行的傳播對於發現哪些函數流向間接呼叫點也很重要。這可以使此類呼叫點轉換為直接呼叫點,這使其有資格進行內聯轉換。

請注意,即使在 safe-string 模式下,也不會傳播有關字串內容的任何資訊,因為目前無法保證它們在給定程式中是不可變的。

8 其他程式碼移動轉換

8.1 常數提升

當發現運算式為常數時,它們會被提升到符號繫結,也就是說,它們會靜態配置在物件檔案中,當它們評估為裝箱值時。此類常數可能是簡單的數值常數,例如浮點數 42.0,或更複雜的值,例如常數閉包。

將常數提升到頂層會減少執行時的配置。

編譯器旨在共享提升到頂層的常數,以使沒有重複的定義。但是,如果 .cmx 檔案對編譯器隱藏,則可能無法實現最大程度的共享。

關於浮點數陣列的注意事項

以下語言語義專門適用於常數浮點數陣列。(「常數浮點數陣列」是指完全由編譯時已知的浮點數組成的陣列。常見的情況是文字,例如 [| 42.0; 43.0; |]。)

8.2 提升頂層 let 綁定

頂層 let 表達式可能會被提升為符號綁定,以確保對應的綁定變數不會被閉包捕獲。如果發現給定綁定的定義表達式是常數,則會將其綁定為常數(技術術語是let-symbol綁定)。

否則,該符號會綁定到一個(靜態分配的)預先分配的區塊,其中包含一個欄位。在執行時,將會評估定義的表達式,並將區塊的第一個欄位填入產生的值。這種初始化符號綁定會導致額外的一次間接訪問,但由於符號的位址在編譯時已知,因此可以確保值的用法不會被閉包捕獲。

應該注意的是,對應於初始化符號綁定的區塊會永遠保持活動狀態,因為它們出現在物件檔案中 GC 根的靜態表格中。表達式的這種延長的生命週期有時可能會令人驚訝。如果希望建立一些不具有這種延長生命週期的非常數值(例如在編寫 GC 測試時),則可以在函式內部建立並使用它,並且該函式的應用點(可能在頂層) — 或實際上是函式宣告本身 — 會被標記為永不內聯。此技術可防止提升有問題的值的定義(當然,假設它不是常數)。

9 取消裝箱轉換

本節中的轉換與分割已裝箱(也就是說,非立即)的值有關。它們的主要目的是減少分配,這往往會產生具有較低變異數和較小尾部的執行時效能分析。

9.1 取消裝箱閉包變數

除非提供 -no-unbox-free-vars-of-closures,否則會啟用此轉換。

出現在閉包環境中的變數本身可能是已裝箱的值。因此,它們可能會被分割成更多的閉包變數,每個變數對應於原始閉包變數的一些投影。此轉換稱為取消裝箱閉包變數取消裝箱閉包的自由變數。只有在合理確定對應的函式主體內沒有使用已裝箱的自由變數本身時,才會應用此轉換。

範例

在以下程式碼中,編譯器觀察到從函式 f 返回的閉包包含一個變數 pair(在 f 的主體中是自由的),該變數可以分割為兩個獨立的變數。

let f x0 x1 =
  let pair = x0, x1 in
  Printf.printf "foo\n";
  fun y ->
    fst pair + snd pair + y

經過一些簡化後,得到

let f x0 x1 =
  let pair_0 = x0 in
  let pair_1 = x1 in
  Printf.printf "foo\n";
  fun y ->
    pair_0 + pair_1 + y

然後得到

let f x0 x1 =
  Printf.printf "foo\n";
  fun y ->
    x0 + x1 + y

已消除 pair 的分配。

如果此轉換會導致閉包包含的閉包變數是之前的兩倍以上,則此轉換不會執行。

9.2 取消裝箱特化引數

除非提供 -no-unbox-specialised-args,否則會啟用此轉換。

在編譯過程中,函式的一個或多個不變引數可能會特化為特定值。當這些值本身被裝箱時,對應的特化引數可能會被分割成更多特化引數,這些引數對應於函式主體中出現的已裝箱值的投影。此轉換稱為取消裝箱特化引數。只有在合理確定函式內未使用已裝箱的引數本身時,才會應用此轉換。

如果所討論的函式參與遞迴群組,則可以基於不變引數之間的資料流,立即在群組中複製取消裝箱特化引數。

範例

在給定以下程式碼後,編譯器會將 loop 內聯到 f 中,然後觀察到 inv 是不變的,並且始終是由將 4243 新增至函式 f 的引數 x 所形成的 pair。

let rec loop inv xs =
  match xs with
  | [] -> fst inv + snd inv
  | x::xs -> x + loop2 xs inv
and loop2 ys inv =
  match ys with
  | [] -> 4
  | y::ys -> y - loop inv ys

let f x =
  Printf.printf "%d\n" (loop (x + 42, x + 43) [1; 2; 3])

由於函式的引數足夠少,因此將會新增更多特化引數。經過一些簡化後,得到

let f x =
  let rec loop' xs inv_0 inv_1 =
    match xs with
    | [] -> inv_0 + inv_1
    | x::xs -> x + loop2' xs inv_0 inv_1
  and loop2' ys inv_0 inv_1 =
    match ys with
    | [] -> 4
    | y::ys -> y - loop' ys inv_0 inv_1
  in
  Printf.printf "%d\n" (loop' [1; 2; 3] (x + 42) (x + 43))

已移除 f 內 pair 的分配。(由於 loop'loop2' 的兩個閉包是常數,它們也將被提升到頂層,而不會產生執行時分配的損失。即使未執行取消裝箱特化引數的轉換,也會發生這種情況。)

取消裝箱特化引數的轉換永遠不會引入額外的分配。

如果取消裝箱引數會導致原始函式具有足夠多的引數,從而抑制尾呼叫最佳化,則此轉換不會取消裝箱引數。

此轉換的實作方式是建立一個接受原始引數的包裝函式。同時,原始函式會被重新命名,並且會新增額外引數,這些引數對應於取消裝箱的特化引數;此新函式會從包裝函式呼叫。然後,包裝函式將在直接呼叫站點內聯。事實上,除非正在使用 -unbox-closures,否則所有呼叫站點都將是直接的,因為它們最初在特化函式時是由編譯器產生的。(在 -unbox-closures 的情況下,其他函式可能會出現特化引數;在這種情況下,可能會出現間接呼叫,並且由於必須透過包裝函式彈回,這些呼叫會產生少量損失。用於 -unbox-closures直接呼叫代理技術不會被取消裝箱特化引數的轉換使用。)

9.3 取消裝箱閉包

預設不會啟用此轉換。可以使用 -unbox-closures 旗標啟用此轉換。

此轉換會將閉包變數取代為特化引數。目的是使更多閉包變成封閉的。當所涉及的函式無法內聯或特化時,它作為減少分配的一種手段特別適用。例如,某些非遞迴函式可能太大而無法內聯;或者某些遞迴函式可能沒有提供特化的機會,可能是因為其唯一引數是 unit 類型。

目前,啟用此轉換時,實際執行時效能可能會略有損失,但是由於分配減少,可能會獲得更穩定的效能。建議開發人員進行實驗以確定該選項是否對其程式碼有利。(預計將來可以消除效能下降的情況。)

簡單範例

在以下程式碼中(當 g 太大而無法內聯時通常會發生這種情況),x 的值通常會透過 g 的閉包傳遞到 + 函式的應用程式。

let f x =
  let g y =
    x + y
  in
  (g [@inlined never]) 42

取消裝箱閉包會導致 gx 的值作為引數傳遞給 g,而不是透過其閉包傳遞。這表示 g 的閉包變成常數,並且可以提升到頂層,從而消除執行時分配。

此轉換的實作方式是,以取消裝箱特化引數時所用的方式新增一個新的包裝函式。閉包變數在包裝函式中仍然是自由的,但目的是當包裝函式在直接呼叫站點內聯時,相關的值會透過新的特化引數直接傳遞給主函式。

新增這樣的包裝函式會懲罰對函式的間接呼叫(這些呼叫可能會存在於任意位置;請記住,例如,此轉換並非僅應用於編譯器由於特化而產生的函式),因為此類呼叫會透過包裝函式彈回。為了減輕這種情況,如果函式的大小相對於要移除的自由變數數量來說足夠小,則此轉換將複製該函式以獲得兩個版本:原始版本(用於間接呼叫,因為我們無法做得更好)和前面段落中所述的包裝函式/重寫函式對。包裝函式/重寫函式對將僅用於函式的直接呼叫站點。(在這種情況下,包裝函式稱為直接呼叫代理,因為它在直接呼叫站點取代了另一個函式 — 用於間接呼叫的未變更版本。)

可以使用 -unbox-closures-factor 命令列旗標(該旗標接受一個整數)來調整將函式視為大到無法複製的點。複製的優點會按整數縮放,然後再針對大小進行評估。

更難的範例

在以下程式碼中,有兩個閉包變數通常會導致閉包分配。一個稱為 fv,出現在函式 baz 內部;另一個稱為 z,出現在函式 bar 內部。在這個玩具(但複雜)的範例中,我們再次使用屬性來模擬 baz 的第一個引數太大而無法內聯的典型情況。

let foo c =
  let rec bar zs fv =
    match zs with
    | [] -> []
    | z::zs ->
      let rec baz f = function
        | [] -> []
        | a::l -> let r = fv + ((f [@inlined never]) a) in r :: baz f l
      in
      (map2 (fun y -> z + y) [z; 2; 3; 4]) @ bar zs fv
  in
  Printf.printf "%d" (List.length (bar [1; 2; 3; 4] c))

-O3 -unbox-closures 套用到此程式碼所產生的程式碼會透過函式引數傳遞自由變數,以便在此範例中消除所有閉包分配(除了可能在 printf 內部執行的任何分配)。

10 移除未使用的程式碼和值

10.1 移除多餘的 let 表達式

只要簡化傳遞移除未使用的 let 綁定,並且其對應的定義表達式具有「無效應」。請參閱下面的「效果處理」一節,以了解此術語的精確定義。

10.2 移除多餘的程式結構

此轉換與移除定義表達式無效應的 let 表達式類似。它改為對符號綁定進行運作,移除那些無效應的符號綁定。

10.3 移除未使用的引數

預設僅針對特化引數啟用此轉換。可以使用 -remove-unused-arguments 旗標為所有引數啟用此轉換。

此步驟會分析函式,以判斷哪些引數未使用。移除的方式是建立一個包裝函式,該函式將在每個直接呼叫點內聯,接受原始引數,然後在呼叫原始函式之前捨棄未使用的引數。因此,如果原始函式通常是間接呼叫的,則此轉換可能會不利,因為此類呼叫現在會透過包裝函式傳遞。(用於在取消封裝閉包變數期間減少這種懲罰的直接呼叫替代技術 (如上所述) 尚未應用於移除未使用引數的步驟。)

10.4 移除未使用的閉包變數

此轉換會在整個編譯單元中執行分析,以判斷是否存在從未使用過的閉包變數。然後會消除這些閉包變數。(請注意,這必須是全單元分析,因為由於內聯,從特定閉包投影出的閉包變數可能已傳播到程式碼中的任意位置。)

11 其他程式碼轉換

11.1 將非逸出參考轉換為可變變數

Flambda 執行一個簡單的分析,類似於編譯器中其他地方執行的分析,該分析可以將 ref 轉換為可變變數,然後這些變數可以保存在暫存器中 (或根據需要在堆疊上),而不是在 OCaml 堆積上分配。只有在可以證明相關參考不會逸出其定義範圍時,才會發生這種情況。

11.2 將閉包變數替換為特殊化引數

此轉換會發現已知等於特殊化引數的閉包變數。此類閉包變數會被替換為特殊化引數;然後可以透過「移除未使用的閉包變數」步驟來移除閉包變數 (請參閱下文)。

12 效果的處理

Flambda 優化器會對運算式進行分類,以判斷運算式是否

這是透過判斷執行運算式時可能發生的效果共效果來完成的。效果說明運算式可能如何影響世界;共效果說明世界可能如何影響運算式。

效果分類如下

無效果
運算式不會變更世界的觀測狀態。例如,它不得寫入任何可變儲存空間、呼叫任意外部函式或變更控制流程 (例如,透過引發例外)。請注意,配置歸類為「無效果」(請參閱下文)。
  • 編譯器假設具有無效果的運算式 (其結果未使用) 可以消除。(這種情況通常發生在相關運算式是 let 的定義運算式的情況下;在這種情況下,let 運算式將被消除。) 編譯器進一步假設,具有無效果的此類運算式可能會重複 (因此可能會執行多次)。
  • 例如,來自配置點的例外狀況 (例如「記憶體不足」) 或從 finalizer 或訊號處理常式傳播的例外狀況,被視為「來自以太的效果」,因此在此處我們判斷有效性時會忽略它們。在某些平台上可能會導致硬體陷阱的浮點運算也適用此規則。
僅產生效果
運算式不會變更世界的觀測狀態,但可能會透過執行配置來影響垃圾收集器的狀態。僅具有產生效果且結果未使用的運算式可能會被編譯器消除。但是,與「無效果」的運算式不同,此類運算式永遠不符合重複的條件。
任意效果
所有其他運算式。

共效果只有一種分類

無共效果
運算式不會觀察其他運算式的效果 (如上所述)。例如,它不得從任何可變儲存空間讀取或呼叫任意外部函式。

編譯器假設,在符合資料相依性的情況下,具有無效果且無共效果的運算式可以相對於其他運算式重新排序。

13 靜態配置模組的編譯

能夠靜態配置的模組編譯 (例如,對應於整個編譯單元的模組,而不是依賴於執行階段計算值的 first-class 模組) 最初遵循用於位元組碼的策略。一系列 let 綁定 (可能穿插著任意效果) 包圍著一個會變成模組區塊的記錄建立。Flambda 特有的轉換如下:這些綁定會提升為頂層符號,如上所述。

14 優化抑制

特別是在編寫在迴圈中執行非副作用演算法的效能評估套件時,可能會發現優化器完全省略了正在評估效能的程式碼。可以使用 Sys.opaque_identity 函式來防止此行為 (實際上它的行為與一般的 OCaml 函式相同,並且沒有任何「魔術」語義)。應查閱 Sys 模組的文件以取得更多詳細資訊。

15 不安全操作的使用

Flambda 簡化步驟的行為表示,某些不安全的操作 (在沒有 Flambda 或使用先前版本的編譯器時可能是安全的) 不得使用。這特別是指 Obj 模組中的函式。

特別是,禁止變更任何不是可變的值 (例如使用 Obj.set_fieldObj.set_tag)。(從 C stub 返回的值一律視為可變。) 如果編譯器偵測到此類寫入,則會發出警告 59 — 但它無法在所有情況下都發出警告。以下是一個會觸發警告的程式碼範例

let f x =
  let a = 42, x in
  (Obj.magic a : int ref) := 1;
  fst a

這不安全的原因是簡化步驟認為 fst a 持有值 42;而且確實必須如此,除非透過不安全的操作破壞了型別健全性。

如果必須編寫會觸發警告 59 的程式碼,但已知該程式碼實際上是正確的 (對於某些正確的定義而言),則可以使用 Sys.opaque_identity 在執行不安全操作之前包裝該值。執行此操作時必須格外小心,以確保在正確的位置新增不透明度。必須強調的是,這種使用 Sys.opaque_identity 的方式僅適用於例外情況。不應在一般程式碼中使用它,或嘗試引導優化器。

例如,此程式碼會返回整數 1

let f x =
  let a = Sys.opaque_identity (42, x) in
  (Obj.magic a : int ref) := 1;
  fst a

但是,下列程式碼仍然會返回 42

let f x =
  let a = 42, x in
  Sys.opaque_identity (Obj.magic a : int ref) := 1;
  fst a

Flambda 執行的高級別內聯可能會暴露先前認為正確的程式碼中的錯誤。例如,請小心不要新增聲稱某些可變值始終是立即的型別註解,如果可能進行不安全的操作將其更新為已裝箱的值。

16 詞彙表

本手冊的此章節使用以下術語。

呼叫點
請參閱下方的直接呼叫點間接呼叫點
封閉函式
一個函式,其主體沒有自由變數,除了其參數和任何在相同 (可能相互遞迴) 宣告中繫結的其他函式。
閉包
函式的執行階段表示。這包括指向函式程式碼的指標,以及函式主體中使用的任何變數的值,這些變數實際上是在函式外部的封閉範圍中定義的。此類變數的值 (統稱為環境) 是必要的,因為可能會從此類變數的原始繫結不再有效的範圍中調用該函式。使用 let rec 定義的一組可能相互遞迴的函式都會共用一個閉包。(開發人員注意事項:在 Flambda 原始程式碼中,閉包始終對應於一個函式;一組閉包是指這樣的一組函式。)
閉包變數
在給定函式的閉包中保存的環境成員。
常數
編譯器在編譯時已知其值的實體 (通常是運算式)。常數性可能是來自原始程式碼的顯式,或由 Flambda 優化器推斷的。
常數閉包
在物件檔案中靜態配置的閉包。這種閉包的環境部分幾乎總是空的。
定義運算式
let x = e in e’ 中的運算式 e
直接呼叫點
程式碼中呼叫函式的位置,並且在編譯時已知它將始終是哪個函式。
間接呼叫點
程式碼中呼叫函式的位置,但未知其是否為直接呼叫點
程式
形成單一編譯單元定義的符號繫結集合 (即 .cmx 檔案)。
特殊化引數
函式的一個引數,已知它在執行階段始終持有特定值。這些引數由內聯器在特殊化遞迴函式時引入;以及 unbox-closures 步驟。(請參閱 23.4 節。)
符號
參考物件檔案或可執行映像檔中特定位置的名稱。在該特定位置會有一些常數值。可以使用作業系統特定的工具 (例如 Linux 上的 objdump) 來檢查符號。
符號繫結
類似於 let 運算式,但作用於目標檔案中定義的符號層級。符號的位址是固定的,但它可以綁定到常數和非常數的表達式。
頂層
目前程式中,未包含在任何函式宣告內的表達式。
變數
一個具名的實體,透過 let 運算式、模式匹配結構或類似的方式,綁定到某個 OCaml 值。