第 23 章 使用 Flambda 进行优化

1 概述

Flambda 是用于描述从 OCaml 4.03 开始的原生代码编译器提供的一系列优化传递的术语。

Flambda 的目标是简化编写惯用 OCaml 代码而不产生性能损失的过程。

要使用 Flambda 优化器,需要将 -flambda 选项传递给 OCaml 的 configure 脚本。(不支持单个编译器同时以 Flambda 和非 Flambda 模式运行。)使用 Flambda 编译的代码不能链接到与未使用 Flambda 编译的代码相同的程序中。尝试这样做会导致编译器错误。

特定的 ocamlopt 是否使用 Flambda 可以通过使用 -config 选项调用它并查找以“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 内联

内联是指将函数的代码复制到调用函数的位置。函数的代码将被其参数绑定到相应参数的绑定所包围。

内联的目标是

这些目标通常不仅通过内联本身实现,还通过编译器能够由于内联而执行的其他优化来实现。

当对函数进行递归调用(在该函数的定义中或同一互递归组中的另一个函数中)进行内联时,该过程也称为*展开*。这有点类似于循环展开。例如,给定以下代码

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,见上文)设置的值开始。在做出推测性内联决策时,阈值将减少被内联函数的代码大小。如果阈值耗尽,达到或低于零,则不会执行进一步的推测。

内联深度从零开始,每次内联器下降到另一个函数时增加 1。然后,每次内联器离开该函数时减少 1。如果深度超过 -inline-max-depth 设置的值,则推测停止。此参数旨在作为内联阈值无法充分控制搜索的情况的通用后备。

展开深度适用于同一组互递归函数内的调用。每次执行此类调用的内联时,在检查结果主体时,深度都会增加 1。如果深度达到 -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

对的分配已被消除。

如果此转换会导致闭包包含比之前多两倍的闭包变量,则不会执行此转换。

9.2 特殊化参数的拆箱

除非提供 -no-unbox-specialised-args,否则此转换将启用。

在编译期间,函数的一个或多个不变参数可能会专门化为特定值。当此类值本身被装箱时,相应的专门化参数可以拆分为更多专门化参数,这些参数对应于函数体中发生的从装箱值投影出来的投影。此转换称为特殊化参数的拆箱。仅当有合理的确定性表明装箱参数本身在函数中未使用时,才会应用此转换。

如果相关函数涉及递归组,则特殊化参数的拆箱可以根据不变参数之间的数据流立即在整个组中复制。

示例

在给定以下代码后,编译器将内联 loopf 中,然后观察 inv 是不变的,并且始终是通过将 4243 添加到函数 f 的参数 x 形成的对。

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 中对的分配。(由于 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

闭包的拆箱导致 g 内部 x 的值作为参数传递给 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 表达式将被消除。)还假设此类没有副作用的表达式可以被复制(因此可能执行多次)。
  • 来自分配点的异常,例如“内存不足”或从终结器或信号处理程序传播的异常,被视为“来自以太的副作用”,因此在此处确定是否有副作用时被忽略。对于某些平台上可能导致硬件陷阱的浮点运算也是如此。
仅生成性副作用
表达式不会改变世界的可观察状态,除非通过执行分配可能影响垃圾收集器的状态。仅具有生成性副作用且结果未使用的表达式可以被编译器消除。但是,与“无副作用”的表达式不同,此类表达式永远不会有资格进行复制。
任意副作用
所有其他表达式。

协同效应只有一个分类

无协同效应
表达式不会观察其他表达式的副作用(如上所述)。例如,它不得从任何可变存储读取或调用任意外部函数。

编译器假设,在数据依赖性的前提下,既没有副作用也没有协同效应的表达式可以相对于其他表达式重新排序。

13 静态分配模块的编译

能够静态分配的模块(例如,对应于整个编译单元的模块,而不是依赖于运行时计算的值的一级模块)的编译最初遵循用于字节码的策略。一系列 let 绑定(可能与任意副作用交错)围绕着成为模块块的记录创建。Flambda 特定的转换如下:这些绑定被提升到顶层符号,如上所述。

14 优化抑制

特别是在编写在循环中运行无副作用算法的基准测试套件时,可能会发现优化器完全省略了正在测试的代码。可以通过使用 Sys.opaque_identity 函数(它确实表现为一个普通的 OCaml 函数,并且不具有任何“魔法”语义)来防止此行为。有关更多详细信息,请参阅 Sys 模块的文档。

15 不安全操作的使用

Flambda 简化过程的行为意味着某些不安全的操作(在没有 Flambda 或使用编译器的早期版本时可能是安全的)不得使用。这特别指的是在 Obj 模块中找到的函数。

特别是,禁止更改任何不可变的值(例如使用 Obj.set_fieldObj.set_tag)。(从 C 存根返回的值始终被视为可变的。)如果编译器检测到此类写入,它将发出警告 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 优化器推断得出。
常量闭包
在目标文件中静态分配的闭包。几乎总是这种情况,此类闭包的环境部分为空。
定义表达式
表达式 elet x = e in e’ 中。
直接调用点
程序代码中调用函数的位置,并且在编译时已知它将始终调用哪个函数。
间接调用点
程序代码中调用函数的位置,但不知道是直接调用点
程序
构成单个编译单元(即 .cmx 文件)定义的一组符号绑定
专门化参数
函数的参数,已知在运行时始终持有特定值。这些是由内联器在专门化递归函数时引入的;以及 unbox-closures 传递。(参见第 23.4 节)。
符号
引用目标文件或可执行映像中特定位置的名称。在那个特定位置将是某个常量值。可以使用特定于操作系统的工具(例如 Linux 上的 objdump)检查符号。
符号绑定
类似于 let 表达式,但在目标文件中定义的符号级别工作。符号的地址是固定的,但它可以绑定到常量和非常量表达式。
顶层
当前程序中未包含在任何函数声明中的表达式。
变量
一个命名实体,其中某个 OCaml 值通过 let 表达式、模式匹配构造或类似方式绑定。