Real World OCaml

编译器后端:字节码和原生代码

这是对书籍 Real World OCaml 中章节 编译器后端:字节码和原生代码 的改编,经许可在此转载。

一旦 OCaml 通过了类型检查阶段,它就可以停止发出语法和类型错误,并开始将格式良好的模块编译成可执行代码的过程。

在本章中,我们将涵盖以下主题

  • 其中模式匹配被优化的无类型中间 lambda 代码

  • 字节码 ocamlc 编译器和 ocamlrun 解释器

  • 原生代码 ocamlopt 代码生成器,以及原生代码的调试和性能分析

无类型 Lambda 表达式

第一个代码生成阶段将所有静态类型信息消除到一个更简单的中间lambda 表达式中。lambda 表达式丢弃了更高级别的构造,例如模块和对象,并用更简单的值(如记录和函数指针)替换它们。模式匹配也会被分析并编译成高度优化的自动机。

lambda 表达式是丢弃 OCaml 类型信息并将源代码映射到在 值的内存表示 中描述的运行时内存模型的关键阶段。此阶段还执行一些优化,最显著的是将模式匹配语句转换为更优化但更低级的语句。

模式匹配优化

如果你在命令行中添加 -dlambda 指令,编译器会以 s-表达式语法转储 lambda 表达式。让我们用它来了解更多关于 OCaml 模式匹配引擎是如何工作的,方法是构建三个不同的模式匹配并比较它们的 lambda 表达式。

让我们首先使用四个普通变体创建一个简单的穷举模式匹配

type t = | Alice | Bob | Charlie | David

let test v =
  match v with
  | Alice   -> 100
  | Bob     -> 101
  | Charlie -> 102
  | David   -> 103

这段代码的 lambda 输出如下所示

$ ocamlc -dlambda -c pattern_monomorphic_large.ml 2>&1
(setglobal Pattern_monomorphic_large!
  (let
    (test/272 =
       (function v/274[int] : int
         (switch* v/274
          case int 0: 100
          case int 1: 101
          case int 2: 102
          case int 3: 103)))
    (makeblock 0 test/272)))

理解这种内部形式的每个细节并不重要,而且它明确地没有文档记录,因为它可能会在不同的编译器版本之间发生变化。尽管有这些警告,但从阅读中出现了一些有趣的要点

  • 不再提及模块或类型。全局值是通过 setglobal 创建的,OCaml 值是通过 makeblock 创建的。块是你在 值的内存表示 中应该记住的运行时值。

  • 模式匹配已转换为一个 switch case,它根据 v 的头部标签跳转到正确的 case。回想一下,没有参数的变体在内存中按其出现的顺序存储为整数。模式匹配引擎知道这一点,并将模式转换为一个高效的跳转表。

  • 值由一个唯一的名称寻址,该名称通过追加数字(例如,v/1014)来区分隐藏的值。早期阶段的类型安全检查确保这些低级访问永远不会违反运行时内存安全,因此这一层不进行任何动态检查。不合理地使用不安全的功能(例如 Obj.magic 模块)仍然很容易在此级别导致崩溃。

编译器计算一个跳转表以处理所有四种情况。如果我们将变体的数量减少到两个,那么就不需要计算这个表的复杂性了

type t = | Alice | Bob

let test v =
  match v with
  | Alice   -> 100
  | Bob     -> 101

这段代码的 lambda 输出现在完全不同了

$ ocamlc -dlambda -c pattern_monomorphic_small.ml 2>&1
(setglobal Pattern_monomorphic_small!
  (let (test/270 = (function v/272[int] : int (if v/272 101 100)))
    (makeblock 0 test/270)))

编译器发出更简单的条件跳转,而不是设置跳转表,因为它静态地确定可能的变体范围足够小。最后,让我们考虑一段代码,它与我们的第一个模式匹配示例基本相同,但使用多态变体而不是普通变体

let test v =
  match v with
  | `Alice   -> 100
  | `Bob     -> 101
  | `Charlie -> 102
  | `David   -> 103

此代码的 lambda 表达式也反映了多态变体的运行时表示

$ ocamlc -dlambda -c pattern_polymorphic.ml 2>&1
(setglobal Pattern_polymorphic!
  (let
    (test/267 =
       (function v/269[int] : int
         (if (>= v/269 482771474) (if (>= v/269 884917024) 100 102)
           (if (>= v/269 3306965) 101 103))))
    (makeblock 0 test/267)))

我们在 变体 中提到过,对多态变体进行模式匹配效率稍低,现在应该更清楚为什么会出现这种情况了。多态变体具有一个通过对变体名称进行哈希计算得到的运行时值,因此编译器无法像对普通变体那样使用跳转表。相反,它创建一个决策树,在尽可能少的比较中将哈希值与输入变量进行比较。

模式匹配是 OCaml 编程的重要组成部分。在实际代码中,你经常会遇到对复杂数据结构进行深度嵌套的模式匹配。一篇描述 OCaml 中实现的基本算法的优秀论文是 Fabrice Le Fessant 和 Luc Maranget 的 "优化模式匹配"

这篇论文描述了经典模式匹配编译中使用的回溯算法,以及一些 OCaml 特定的优化,例如使用穷尽信息和通过静态异常进行控制流优化。当然,你不需要理解所有这些内容才能使用模式匹配,但它会让你了解为什么模式匹配在 OCaml 中是一个如此高效的语言结构。

模式匹配基准测试

让我们对这三种模式匹配技术进行基准测试,以更准确地量化它们的运行时成本。Core_bench 模块会运行这些测试数千次,并计算结果的统计方差。你需要 opam install core_bench 来获取这个库

open Core
open Core_bench

module Monomorphic = struct
  type t =
    | Alice
    | Bob
    | Charlie
    | David

  let bench () =
    let convert v =
      match v with
      | Alice -> 100
      | Bob -> 101
      | Charlie -> 102
      | David -> 103
    in
    List.iter
      ~f:(fun v -> ignore (convert v))
      [ Alice; Bob; Charlie; David ]
end

module Monomorphic_small = struct
  type t =
    | Alice
    | Bob

  let bench () =
    let convert v =
      match v with
      | Alice -> 100
      | Bob -> 101
    in
    List.iter
      ~f:(fun v -> ignore (convert v))
      [ Alice; Bob; Alice; Bob ]
end

module Polymorphic = struct
  type t =
    [ `Alice
    | `Bob
    | `Charlie
    | `David
    ]

  let bench () =
    let convert v =
      match v with
      | `Alice -> 100
      | `Bob -> 101
      | `Charlie -> 102
      | `David -> 103
    in
    List.iter
      ~f:(fun v -> ignore (convert v))
      [ `Alice; `Bob; `Alice; `Bob ]
end

let benchmarks =
  [ "Monomorphic large pattern", Monomorphic.bench
  ; "Monomorphic small pattern", Monomorphic_small.bench
  ; "Polymorphic large pattern", Polymorphic.bench
  ]

let () =
  List.map benchmarks ~f:(fun (name, test) ->
      Bench.Test.create ~name test)
  |> Bench.make_command
  |> Command_unix.run

构建和执行此示例默认情况下会运行大约 30 秒,你将看到结果以一个整齐的表格形式总结。

$ opam exec -- dune exec -- ./bench_patterns.exe -ascii -quota 0.25
Estimated testing time 750ms (3 benchmarks x 250ms). Change using '-quota'.

  Name                        Time/Run   Percentage
 --------------------------- ---------- ------------
  Monomorphic large pattern     6.54ns       67.89%
  Monomorphic small pattern     9.63ns      100.00%
  Polymorphic large pattern     9.63ns       99.97%

这些结果证实了我们之前通过检查 lambda 代码获得的性能假设。运行时间最短的是来自小型条件模式匹配,而多态变体模式匹配是最慢的。在这些示例中,差异并不十分显著,但您可以使用相同的技术深入了解您自己的源代码,并缩小任何性能瓶颈的范围。

lambda 形式主要是一个通往我们将在接下来介绍的字节码可执行格式的桥梁。与浏览编译后的可执行文件的原生汇编代码相比,查看此阶段的文本输出通常更容易。

生成可移植字节码

在生成 lambda 形式之后,我们非常接近拥有可执行代码。此时,OCaml 工具链分叉成两个独立的编译器。我们将首先描述字节码编译器,它由两部分组成

ocamlc:将文件编译成与 lambda 形式紧密映射的字节码

ocamlrun:一个可执行字节码的可移植解释器

使用字节码的最大优势在于简单性、可移植性和编译速度。从 lambda 形式到字节码的映射非常简单,这导致了可预测(但缓慢)的执行速度。

字节码解释器实现了一个基于栈的虚拟机。OCaml 栈和一个关联的累加器存储由以下内容组成的值:

long:对应于 OCaml int 类型的数值

block:包含块头和一个内存地址,该地址包含数据字段,这些数据字段包含由整数索引的更多 OCaml 值

code offset:相对于起始代码地址的数值

解释器虚拟机总共只有七个寄存器

  • 程序计数器
  • 栈、异常和参数指针
  • 累加器
  • 环境和全局数据

您可以通过 -dinstr 以文本形式显示字节码指令。尝试在我们之前的模式匹配示例之一中使用此选项

$ ocamlc -dinstr pattern_monomorphic_small.ml 2>&1
	branch L2
L1:	acc 0
	branchifnot L3
	const 101
	return 1
L3:	const 100
	return 1
L2:	closure L1, 0
	push
	acc 0
	makeblock 1, 0
	pop 1
	setglobal Pattern_monomorphic_small!

前面的字节码已从 lambda 形式简化为一组由解释器串行执行的简单指令。

总共有大约 140 条指令,但大多数只是常见操作的微小变体(例如,特定元数的函数应用)。您可以在网上找到完整详细信息。

字节码指令集从哪里来?

字节码解释器比编译后的原生代码慢得多,但对于没有 JIT 编译器的解释器来说,它的性能仍然非常出色。它的效率可以追溯到 Xavier Leroy 在 1990 年的开创性工作,"ZINC 实验:ML 语言的经济实现"

这篇论文为严格求值的函数式语言(如 OCaml)的指令集的实现奠定了理论基础。现代 OCaml 中的字节码解释器仍然基于 ZINC 模型。原生代码编译器使用不同的模型,因为它使用 CPU 寄存器进行函数调用,而不是像字节码解释器那样始终在栈上传递参数。

了解字节码解释器和原生编译器的不同实现背后的推理对于任何有抱负的语言黑客来说都是一项非常有益的练习。

编译和链接字节码

ocamlc 命令将单个 ml 文件编译成具有 cmo 扩展名的字节码文件。编译后的字节码文件与关联的 cmi 接口匹配,该接口包含导出到其他编译单元的类型签名。

一个典型的 OCaml 库由多个源文件组成,因此也包含多个 cmo 文件,这些文件都需要作为命令行参数才能从其他代码中使用该库。编译器可以通过使用 -a 标志将这些多个文件组合成一个更方便的单个归档文件。字节码归档文件由 cma 扩展名表示。

库中的各个对象按照构建库文件时指定的顺序链接为常规 cmo 文件。如果库中的对象文件在程序的其他地方没有被引用,则除非 -linkall 标志强制包含,否则它不会包含在最终二进制文件中。此行为类似于 C 如何处理对象文件和归档文件(分别为 .o.a)。

然后将字节码文件与 OCaml 标准库链接在一起以生成可执行程序。.cmo 参数在命令行上的呈现顺序定义了编译单元在运行时初始化的顺序。请记住,OCaml 没有像 C 那样只有一个 main 函数,因此此链接顺序比 C 程序中的重要。

执行字节码

字节码运行时包含三个部分:字节码解释器、GC 和一组实现基本操作的 C 函数。字节码包含在需要时调用这些 C 函数的指令。

OCaml 链接器默认生成针对标准 OCaml 运行时的字节码,因此需要了解从其他库(默认情况下未加载)引用的任何 C 函数。

这些额外库的信息可以在链接字节码归档文件时指定

$ ocamlc -a -o mylib.cma a.cmo b.cmo -dllib -lmylib

dllib 标志将参数嵌入到归档文件中。任何随后链接此归档文件的包也将包含额外的 C 链接指令。这反过来又允许解释器在执行字节码时动态加载外部库符号。

您还可以生成一个完整的独立可执行文件,该文件将 ocamlrun 解释器与字节码捆绑在一个二进制文件中。这称为自定义运行时模式,构建方法如下

$ ocamlc -a -o mylib.cma -custom a.cmo b.cmo -cclib -lmylib

自定义模式与原生代码编译最相似,因为两者都生成独立的可执行文件。编译字节码还有很多其他选项(特别是使用共享库或构建自定义运行时)。完整详细信息可以在OCaml中找到。

如果您在可执行规则中指定 byte_complete 模式,则 Dune 可以构建一个自包含的字节码可执行文件。例如,此 dune 文件将生成一个 prog.bc.exe 目标

(executable
  (name prog)
  (modules prog)
  (modes byte byte_complete))

将 OCaml 字节码嵌入到 C 中

使用字节码编译器的一个结果是,最终链接阶段必须由 ocamlc 执行。但是,您有时可能希望将您的 OCaml 代码嵌入到现有的 C 应用程序中。OCaml 也通过 -output-obj 指令支持这种操作模式。

此模式导致 ocamlc 输出一个包含程序 OCaml 部分的字节码的对象文件,以及一个 caml_startup 函数。所有 OCaml 模块都像可执行文件一样链接到此对象文件中作为字节码。

然后,可以使用标准 C 编译器将此对象文件与 C 代码链接,只需要字节码运行时库(安装为 libcamlrun.a)。创建可执行文件只需要将运行时库与字节码对象文件链接即可。以下是一个示例,说明它们是如何组合在一起的。

创建两个包含单行打印语句的 OCaml 源文件

let () = print_endline "hello embedded world 1"
let () = print_endline "hello embedded world 2"

接下来,创建一个作为主入口点的 C 文件

#include <stdio.h>
#include <caml/alloc.h>
#include <caml/mlvalues.h>
#include <caml/memory.h>
#include <caml/callback.h>

int
main (int argc, char **argv)
{
  printf("Before calling OCaml\n");
  fflush(stdout);
  caml_startup (argv);
  printf("After calling OCaml\n");
  return 0;
}

现在将 OCaml 文件编译成一个独立的对象文件

$ rm -f embed_out.c
$ ocamlc -output-obj -o embed_out.o embed_me1.ml embed_me2.ml

在此之后,您不再需要 OCaml 编译器,因为 embed_out.o 已将所有 OCaml 代码编译并链接到一个对象文件中。使用 gcc 编译输出二进制文件以进行测试

$ gcc -fPIC -Wall -I`ocamlc -where` -L`ocamlc -where` -ltermcap -lm -ldl \
  -o finalbc.native main.c embed_out.o -lcamlrun
$ ./finalbc.native
Before calling OCaml
hello embedded world 1
hello embedded world 2
After calling OCaml

您可以通过向命令行添加 -verbose 来检查 ocamlc 正在调用的命令,以帮助找出 GCC 命令行,以防您遇到问题。您甚至可以通过指定 .c 输出文件扩展名而不是我们之前使用的 .o 来获取 -output-obj 结果的 C 源代码

$ ocamlc -output-obj -o embed_out.c embed_me1.ml embed_me2.ml

像这样嵌入 OCaml 代码可以让您编写与任何使用 C 编译器的环境交互的 OCaml 代码。您甚至可以通过使用 Callback 模块在 OCaml 代码中注册命名入口点来从 C 代码返回到 OCaml。OCaml 手册的与 C 交互部分对此进行了详细说明。

编译快速原生代码

原生代码编译器最终是大多数生产 OCaml 代码所使用的工具。它将 lambda 形式编译成快速的原生代码可执行文件,并进行跨模块内联和字节码解释器未执行的其他优化传递。需要注意的是,要确保与字节码运行时兼容,因此使用任一工具链编译的相同代码应以相同的方式运行。

ocamlopt 命令是原生代码编译器的前端,并且与 ocamlc 具有非常相似的接口。它也接受 mlmli 文件,但将其编译为

  • 一个包含原生对象代码的 .o 文件

  • 一个包含链接和跨模块优化额外信息的 .cmx 文件

  • 一个与字节码编译器相同的已编译接口文件 .cmi

当编译器将模块链接到可执行文件中时,它使用 cmx 文件的内容在编译单元之间执行跨模块内联。对于在其模块外部频繁使用的标准库函数,这可以显着提高速度。

.cmx.o 文件的集合也可以通过向编译器传递 -a 标志链接到 .cmxa 归档文件中。但是,与字节码版本不同,您必须将各个 cmx 文件保留在编译器搜索路径中,以便它们可用于跨模块内联。如果不这样做,编译仍然会成功,但您将错过一项重要的优化,并且二进制文件会更慢。

检查汇编输出

原生代码编译器生成汇编语言,然后将其传递给系统汇编器以编译成对象文件。您可以通过向编译器命令行传递 -S 标志来使 ocamlopt 输出汇编代码。

汇编代码高度特定于体系结构,因此以下讨论假设 Intel 或 AMD 64 位平台。我们使用 -inline 20-nodynlink 生成了示例代码,因为最好使用编译器支持的完整优化来生成汇编代码。即使这些优化使代码更难阅读,它也会为您提供有关 CPU 上执行内容的更准确的画面。不要忘记,如果您在更详细的汇编代码中迷路,可以使用前面提到的 lambda 代码获得代码的稍微更高层次的画面。

多态比较的影响

我们在映射和哈希表中警告过您,使用多态比较既方便又危险。现在让我们准确地看看在汇编语言级别上的区别是什么。

首先,让我们创建一个比较函数,在其中我们明确注释了类型,以便编译器知道仅比较整数

let cmp (a:int) (b:int) =
  if a > b then a else b

现在将其编译成汇编代码并读取生成的 compare_mono.S 文件。

$ ocamlopt -S compare_mono.ml

此文件扩展名在某些平台(如 Linux)上可能是小写。如果您以前从未见过汇编语言,那么其内容可能会让人感到害怕。虽然您需要学习 x86 汇编才能完全理解它,但我们将在本节中尝试提供一些基本说明,以发现其中的模式。cmp 函数实现的摘录如下所示

_camlCompare_mono__cmp_1008:
        .cfi_startproc
.L101:
        cmpq    %rbx, %rax
        jle     .L100
        ret
        .align  2
.L100:
        movq    %rbx, %rax
        ret
        .cfi_endproc

_camlCompare_mono__cmp_1008 是一个汇编标签,它是由模块名(Compare_mono)和函数名(cmp_1008)计算得出的。函数名的数字后缀直接来自 lambda 表达式(可以使用 -dlambda 进行检查,但在这种情况下没有必要)。

cmp 的参数通过 %rbx%rax 寄存器传递,并使用 jle(如果小于或等于则跳转)指令进行比较。这需要两个参数都是立即数才能工作。现在让我们看看如果我们的 OCaml 代码省略类型注释并改为多态比较会发生什么。

let cmp a b =
  if a > b then a else b

使用 -S 编译此代码会导致同一函数的汇编输出变得更加复杂。

_camlCompare_poly__cmp_1008:
        .cfi_startproc
        subq    $24, %rsp
        .cfi_adjust_cfa_offset  24
.L101:
        movq    %rax, 8(%rsp)
        movq    %rbx, 0(%rsp)
        movq    %rax, %rdi
        movq    %rbx, %rsi
        leaq    _caml_greaterthan(%rip), %rax
        call    _caml_c_call
.L102:
        leaq    _caml_young_ptr(%rip), %r11
        movq    (%r11), %r15
        cmpq    $1, %rax
        je      .L100
        movq    8(%rsp), %rax
        addq    $24, %rsp
        .cfi_adjust_cfa_offset  -24
        ret
        .cfi_adjust_cfa_offset  24
        .align  2
.L100:
        movq    0(%rsp), %rax
        addq    $24, %rsp
        .cfi_adjust_cfa_offset  -24
        ret
        .cfi_adjust_cfa_offset  24
        .cfi_endproc

.cfi 指令是汇编提示,包含调用帧信息,允许调试器提供更有意义的回溯,并且它们对运行时性能没有影响。请注意,其余的实现不再是简单的寄存器比较。相反,参数被推送到堆栈(%rsp 寄存器),并且通过将指向 caml_greaterthan 的指针放在 %rax 中并跳转到 caml_c_call 来调用 C 函数。

x86_64 架构上的 OCaml 将次要堆的位置缓存在 %r15 寄存器中,因为它在 OCaml 函数中被频繁引用。次要堆指针也可以由被调用的 C 代码更改(例如,当它分配 OCaml 值时),因此在从 caml_greaterthan 调用返回后会恢复 %r15。最后,比较的返回值从堆栈中弹出并返回。

多态比较的基准测试

您不必完全理解汇编语言的复杂性就能看出,这种多态比较比之前简单的单态整数比较要重得多。让我们再次通过编写一个包含这两个函数的快速 Core_bench 测试来确认这个假设。

open Core
open Core_bench

let polymorphic_compare () =
  let cmp a b = Stdlib.(if a > b then a else b) in
  for i = 0 to 1000 do
    ignore(cmp 0 i)
  done

let monomorphic_compare () =
  let cmp (a:int) (b:int) = Stdlib.(if a > b then a else b) in
  for i = 0 to 1000 do
    ignore(cmp 0 i)
  done

let tests =
  [ "Polymorphic comparison", polymorphic_compare;
    "Monomorphic comparison", monomorphic_compare ]

let () =
  List.map tests ~f:(fun (name,test) -> Bench.Test.create ~name test)
  |> Bench.make_command
  |> Command_unix.run

运行此测试显示了两者之间存在相当大的运行时差异。

$ opam exec -- dune exec -- ./bench_poly_and_mono.exe -ascii -quota 1
Estimated testing time 2s (2 benchmarks x 1s). Change using '-quota'.

  Name                       Time/Run   Percentage
 ------------------------ ------------ ------------
  Polymorphic comparison   4_050.20ns      100.00%
  Monomorphic comparison     471.75ns       11.65%

我们看到多态比较的速度慢了近 10 倍!这些结果不应被过于重视,因为这是一个非常狭窄的测试,就像所有此类微基准测试一样,它不能代表更复杂的代码库。但是,如果您正在构建在紧密的内部循环中运行许多迭代的数值代码,则值得手动查看生成的汇编代码,看看是否可以对其进行手动优化。

从 Core 内部访问 Stdlib 模块

在上面比较多态和单态比较的基准测试中,您可能已经注意到我们在比较函数前面加了 Stdlib。这是因为 Core 模块显式地重新定义了 ><= 运算符,使其专门用于对 int 类型进行操作,如映射和哈希表中所述。您可以随时通过 Stdlib 模块访问任何 OCaml 标准库函数,就像我们在基准测试中所做的那样。

调试原生代码二进制文件

原生代码编译器构建可以使用传统系统调试器(如 GNU gdb)调试的可执行文件。您需要使用 -g 选项编译您的库以将调试信息添加到输出中,就像您在使用 C 编译器时需要做的那样。

当库在调试模式下编译时,额外的调试信息会被插入到输出汇编中。这些包括您之前在概要分析输出中注意到的 CFI 存根(例如,.cfi_start_proc.cfi_end_proc 用于分隔 OCaml 函数调用)。

理解名称改编

那么如何在像 gdb 这样的交互式调试器中引用 OCaml 函数呢?您需要了解的第一件事是如何将 OCaml 函数名称编译成已编译对象文件中的符号名称,此过程通常称为名称改编

每个 OCaml 源文件都编译成一个原生对象文件,该文件必须导出一组唯一的符号以符合 C 二进制接口。这意味着任何可能被另一个编译单元使用的 OCaml 值都需要映射到一个符号名称。此映射必须考虑 OCaml 语言特性,例如嵌套模块、匿名函数以及相互遮蔽的变量名称。

转换遵循命名变量和函数的一些简单规则。

  • 符号以 caml 和本地模块名称为前缀,并将点替换为下划线。

  • 后面跟着双下划线 __ 后缀和变量名。

  • 变量名也以 _ 和一个数字作为后缀。这是 lambda 编译的结果,它用模块内的唯一值替换每个变量名。您可以通过检查 ocamlopt-dlambda 输出来确定此数字。

在不检查中间编译器输出的情况下,很难预测匿名函数。如果您需要调试它们,通常更容易修改源代码以将匿名函数 let-bind 到一个变量名。

使用 GNU 调试器进行交互式断点

让我们看看一些使用 GNU gdb 进行交互式调试的名称改编示例。

让我们编写一个相互递归函数,从列表中选择交替的值。这不是尾递归,因此当我们单步执行时,我们的堆栈大小会增长。

open Core

let rec take =
  function
  |[] -> []
  |hd::tl -> hd :: (skip tl)
and skip =
  function
  |[] -> []
  |_::tl -> take tl

let () =
  take [1;2;3;4;5;6;7;8;9]
  |> List.map ~f:string_of_int
  |> String.concat ~sep:","
  |> print_endline

使用调试符号编译并运行它。您应该会看到以下输出。

(executable
  (name      alternate_list)
  (libraries core))
$ opam exec -- dune build alternate_list.exe
$ ./_build/default/alternate_list.exe -ascii -quota 1
1,3,5,7,9

现在我们可以在 gdb 中交互式地运行它。

$ gdb ./alternate_list.native
GNU gdb (GDB) 7.4.1-debian
Copyright (C) 2012 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-linux-gnu".
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>...
Reading symbols from /home/avsm/alternate_list.native...done.
(gdb)

gdb 提示符允许您输入调试指令。让我们将程序设置为在第一次调用 take 之前中断。

(gdb) break camlAlternate_list__take_69242
Breakpoint 1 at 0x5658d0: file alternate_list.ml, line 5.

我们按照前面定义的名称改编规则使用了 C 符号名称。确定完整名称的一种便捷方法是使用 Tab 键自动完成。只需输入名称的一部分并按 <tab> 键即可查看可能的完成列表。

设置断点后,开始执行程序。

(gdb) run
Starting program: /home/avsm/alternate_list.native
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".

Breakpoint 1, camlAlternate_list__take_69242 () at alternate_list.ml:5
4         function

二进制文件已运行到第一次 take 调用并停止,等待进一步的指令。GDB 具有许多功能,因此让我们继续执行程序并在几次递归后检查回溯。

(gdb) cont
Continuing.

Breakpoint 1, camlAlternate_list__take_69242 () at alternate_list.ml:5
4         function
(gdb) cont
Continuing.

Breakpoint 1, camlAlternate_list__take_69242 () at alternate_list.ml:5
4         function
(gdb) bt
#0  camlAlternate_list__take_69242 () at alternate_list.ml:4
#1  0x00000000005658e7 in camlAlternate_list__take_69242 () at alternate_list.ml:6
#2  0x00000000005658e7 in camlAlternate_list__take_69242 () at alternate_list.ml:6
#3  0x00000000005659f7 in camlAlternate_list__entry () at alternate_list.ml:14
#4  0x0000000000560029 in caml_program ()
#5  0x000000000080984a in caml_start_program ()
#6  0x00000000008099a0 in ?? ()
#7  0x0000000000000000 in ?? ()
(gdb) clear camlAlternate_list__take_69242
Deleted breakpoint 1
(gdb) cont
Continuing.
1,3,5,7,9
[Inferior 1 (process 3546) exited normally]

cont 命令在断点暂停后恢复执行,bt 显示堆栈回溯,clear 删除断点以便应用程序可以执行到完成。GDB 还有许多我们这里不介绍的其他功能,但您可以通过 Mark Shinwell 关于"OCaml 中的实际调试"的演讲查看更多指南。

OCaml 原生代码的一个非常有用的特性是 C 和 OCaml 共享同一个堆栈。这意味着 GDB 回溯可以为您提供程序运行时库中正在发生的事情的组合视图。这包括对 C 库的任何调用,甚至如果您在将 OCaml 运行时作为库嵌入的环境中,还可以包括从 C 层回调到 OCaml 的任何调用。

分析原生代码

记录和分析应用程序花费执行时间的位置称为性能分析。OCaml 原生代码二进制文件可以像任何其他 C 二进制文件一样进行分析,方法是使用前面描述的名称改编在 OCaml 变量名称和分析器输出之间进行映射。

大多数分析工具都受益于二进制文件中包含的一些检测。OCaml 支持两个这样的工具。

  • GNU gprof,用于测量执行时间和调用图。

  • 现代 Linux 版本中的Perf分析框架。

请注意,许多其他对原生二进制文件进行操作的工具(例如 Valgrind)只要程序链接了 -g 标志以嵌入调试符号,就可以很好地与 OCaml 一起使用。

Gprof

gprof 通过记录哪些函数相互调用以及这些调用在程序执行期间花费的时间的调用图来生成 OCaml 程序的执行概要。

gprof 获取精确信息需要在编译链接二进制文件时将 -p 标志传递给原生代码编译器。这会生成额外的代码,在程序执行时将概要信息记录到名为 gmon.out 的文件中。然后可以使用 gprof 检查此概要信息。

Perf

Perf 是 gprof 的一种更现代的替代方案,它不需要您检测二进制文件。相反,它使用硬件计数器和二进制文件中的调试信息来准确记录信息。

在已编译的二进制文件上运行 Perf 以首先记录信息。我们将使用我们之前编写的写屏障基准测试,它测量内存分配与就地修改。

$ perf record -g ./barrier_bench.native
Estimated testing time 20s (change using -quota SECS).

  Name        Time (ns)             Time 95ci   Percentage
  ----        ---------             ---------   ----------
  mutable     7_306_219   7_250_234-7_372_469        96.83
  immutable   7_545_126   7_537_837-7_551_193       100.00

[ perf record: Woken up 11 times to write data ]
[ perf record: Captured and wrote 2.722 MB perf.data (~118926 samples) ]
perf record -g ./barrier.native
Estimated testing time 20s (change using -quota SECS).

  Name        Time (ns)             Time 95ci   Percentage
  ----        ---------             ---------   ----------
  mutable     7_306_219   7_250_234-7_372_469        96.83
  immutable   7_545_126   7_537_837-7_551_193       100.00

[ perf record: Woken up 11 times to write data ]
[ perf record: Captured and wrote 2.722 MB perf.data (~118926 samples) ]

完成后,您可以交互式地浏览结果。

$ perf report -g
+  48.86%  barrier.native  barrier.native  [.] camlBarrier__test_immutable_69282
+  30.22%  barrier.native  barrier.native  [.] camlBarrier__test_mutable_69279
+  20.22%  barrier.native  barrier.native  [.] caml_modify

此跟踪大致反映了基准测试本身的结果。可变基准测试由对 test_mutable 的调用和运行时中的 caml_modify 写屏障函数的组合组成。这加起来占应用程序执行时间的略微一半以上。

Perf 有越来越多的其他命令,允许您存档这些运行并将其相互比较。您可以在主页上阅读更多内容。

使用帧指针获取更准确的跟踪

尽管 Perf 不需要在二进制文件中添加显式探针,但它确实需要了解如何展开函数调用,以便内核可以准确记录每个事件的函数回溯。从 Linux 3.9 开始,内核支持使用 DWARF 调试信息来解析程序堆栈,当将 -g 标志传递给 OCaml 编译器时会发出该信息。为了获得更准确的堆栈解析,我们需要编译器回退到使用与 C 函数调用相同的约定。在 64 位 Intel 系统上,这意味着一个称为帧指针的特殊寄存器用于记录函数调用历史。以这种方式使用帧指针意味着速度会下降(通常约为 3-5%),因为它不再可用于通用用途。

因此,OCaml 使帧指针成为一项可选功能,可用于提高 Perf 跟踪的分辨率。opam 提供了一个编译器开关,用于使用帧指针激活编译 OCaml。

$ opam switch create 4.13+fp ocaml-variants.4.13.1+options ocaml-option-fp

使用帧指针会更改 OCaml 调用约定,但 opam 会处理使用新接口重新编译所有库。

在 C 中嵌入原生代码

原生代码编译器通常会链接一个完整的可执行文件,但也可以像字节码编译器一样输出一个独立的原生对象文件。此对象文件除了运行时库之外,不再依赖于 OCaml。

原生代码运行时与字节码运行时是不同的库,并作为 OCaml 标准库目录中的 libasmrun.a 安装。

尝试使用本章前面字节码嵌入示例中的相同源文件来进行此自定义链接。

$ ocamlopt -output-obj -o embed_native.o embed_me1.ml embed_me2.ml
$ gcc -Wall -I `ocamlc -where` -o final.native embed_native.o main.c \
   -L `ocamlc -where` -lasmrun -ltermcap -lm -ldl
$ ./final.native
Before calling OCaml
hello embedded world 1
hello embedded world 2
After calling OCaml

embed_native.o 是一个独立的目标文件,除了运行时库之外,没有对 OCaml 代码的进一步引用,就像字节码运行时一样。请记住,库的链接顺序在现代 GNU 工具链中非常重要(尤其是在 Ubuntu 11.10 及更高版本中使用),这些工具链以单遍方式从左到右解析符号。

激活调试运行时

尽管您尽了最大努力,但很容易在某些组件(例如 C 绑定)中引入错误,导致堆不变式被破坏。OCaml 包含一个 libasmrund.a 运行时库变体,该变体使用额外的调试检查进行编译,这些检查在每个垃圾回收周期中执行额外的内存完整性检查。运行这些额外的检查将在更接近损坏点的程序中止,并帮助隔离 C 代码中的错误。

要使用调试库,只需使用 -runtime-variant d 标志链接您的程序。

$ ocamlopt -runtime-variant d -verbose -o hello.native hello.ml
+ as  -o 'hello.o' '/tmp/build_cd0b96_dune/camlasmd3c336.s'
+ as  -o '/tmp/build_cd0b96_dune/camlstartup9d55d0.o' '/tmp/build_cd0b96_dune/camlstartup2b2cd3.s'
+ gcc -O2 -fno-strict-aliasing -fwrapv -pthread -Wall -Wdeclaration-after-statement -fno-common -fexcess-precision=standard -fno-tree-vrp -ffunction-sections  -Wl,-E  -o 'hello.native'  '-L/home/yminsky/.opam/rwo-4.13.1/lib/ocaml'  '/tmp/build_cd0b96_dune/camlstartup9d55d0.o' '/home/yminsky/.opam/rwo-4.13.1/lib/ocaml/std_exit.o' 'hello.o' '/home/yminsky/.opam/rwo-4.13.1/lib/ocaml/stdlib.a' '/home/yminsky/.opam/rwo-4.13.1/lib/ocaml/libasmrund.a' -lm -ldl
$ ./hello.native
### OCaml runtime: debug mode ###
Initial minor heap size: 256k words
Initial major heap size: 992k bytes
Initial space overhead: 120%
Initial max overhead: 500%
Initial heap increment: 15%
Initial allocation policy: 2
Initial smoothing window: 1
Hello OCaml World!

文件扩展名总结

我们已经了解了编译器如何使用中间文件来存储编译工具链的各个阶段。以下是在一个地方列出的所有文件的速查表。

  • .ml 是编译单元模块实现的源文件。
  • .mli 是编译单元模块接口的源文件。如果缺少,则从 .ml 文件生成。
  • .cmi 是对应 .mli 源文件的已编译模块接口。
  • .cmo 是模块实现的已编译字节码目标文件。
  • .cma 是打包到单个文件中的字节码目标文件库。
  • .o 是已由系统 cc 编译为本机目标文件的 C 源文件。
  • .cmt 是模块实现的类型化抽象语法树。
  • .cmti 是模块接口的类型化抽象语法树。
  • .annot 是用于显示 typed 的旧式注释文件,已被 cmt 文件取代。

本机代码编译器还会生成一些其他文件。

  • .o 是模块实现的已编译本机目标文件。
  • .cmx 包含用于链接和目标文件跨模块优化的额外信息。
  • .cmxa.a 分别是 cmxo 单元的库,存储在 cmxaa 文件中。这些文件总是需要一起使用。
  • .S.s 是如果指定了 -S 的汇编语言输出。

仍然需要帮助?

帮助改进我们的文档

鼓励您在 Real World OCaml GitHub 存储库中为本页的原始源代码做出贡献。

OCaml

创新。社区。安全。