第 27 章:编译器后端:字节码与原生代码(The Compiler Backend: Bytecode and Native Code)
原文:Anil Madhavapeddy and Yaron Minsky, Real World OCaml: Functional Programming for the Masses, Second Edition, Chapter 27。维护者已确认本书为开源书籍,可翻译并发布用于学习研究。
一旦 OCaml 通过类型检查阶段,它就可以停止发出语法和类型错误,并开始把格式良好的模块编译成可执行代码。
本章会讨论以下主题:
- 未类型化的中间 lambda 代码,模式匹配会在这里被优化。
- 字节码
ocamlc编译器与ocamlrun解释器。 - 原生代码
ocamlopt代码生成器,以及原生代码的调试和性能分析。
27.1 未类型化 Lambda 形式(The Untyped Lambda Form)
第一个代码生成阶段会把所有静态类型信息消除,转成更简单的中间 lambda 形式(lambda form)。lambda 形式会丢弃模块和对象这类高层构造,并用记录和函数指针等更简单的值替换它们。模式匹配也会被分析,并编译成高度优化的自动机。
lambda 形式是丢弃 OCaml 类型信息、并把源代码映射到第 24 章“值的内存表示”中描述的运行时内存模型的关键阶段。这个阶段也会执行一些优化,最值得注意的是把模式匹配语句转换为更优化但更低层的语句。
27.1.1 模式匹配优化(Pattern Matching Optimization)
如果在命令行中加入 -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构造。这些块就是第 24 章“值的内存表示”中介绍过的运行时值。 - 模式匹配已经变成一个 switch case,会根据
v的头部 tag 跳到正确分支。回忆一下,没有参数的变体会按出现顺序以内存中的整数保存。模式匹配引擎知道这一点,并把模式转换成高效跳转表。 - 值通过唯一名称寻址,遮蔽的值会通过附加数字来区分,例如
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)))
我们在第 7 章“变体”中提到过,对多态变体做模式匹配会稍微低效一些。现在原因应该更清楚了。多态变体拥有一个通过哈希变体名计算出的运行时值,因此编译器不能像处理普通变体那样使用跳转表。相反,它会创建一棵决策树,用尽可能少的比较把这些哈希值与输入变量比较。
模式匹配是 OCaml 编程的重要组成部分。在真实代码中,经常会遇到对复杂数据结构进行深层嵌套模式匹配的情况。Fabrice Le Fessant 和 Luc Maranget 的论文 “Optimizing pattern matching” 很好地描述了 OCaml 实现的基础算法。
这篇论文描述了经典模式匹配编译中使用的回溯算法,也描述了一些 OCaml 特有优化,例如利用穷尽性信息,以及通过静态异常进行控制流优化。当然,仅仅为了使用模式匹配,并不要求理解这些全部内容;但它能帮助你理解为什么模式匹配在 OCaml 中是一种如此高效的语言构造。
27.1.2 对模式匹配做基准测试(Benchmarking Pattern Matching)
我们对这三种模式匹配技术做基准测试,以更准确地量化它们的运行时成本。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 秒,你会看到结果以整洁表格汇总:
$ 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 形式主要是通往下一节要介绍的字节码可执行格式的垫脚石。与其穿过已编译可执行文件生成的原生汇编代码,查看这一阶段的文本输出通常更容易。
27.2 生成可移植字节码(Generating Portable Bytecode)
生成 lambda 形式之后,我们已经非常接近可执行代码。OCaml 工具链在这一点会分叉为两个独立编译器。先介绍字节码编译器,它由两个部件组成:
ocamlc:把文件编译成与 lambda 形式接近映射的字节码。ocamlrun:执行字节码的可移植解释器。
使用字节码的主要优势是简单、可移植和编译速度快。从 lambda 形式到字节码的映射很直接,因此执行速度可预测(但较慢)。
字节码解释器实现了一个基于栈的虚拟机。OCaml 栈和相关累加器保存由以下内容组成的值:
long:对应 OCamlint类型的值。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 条,但多数只是常见操作的细小变体,例如特定元数的函数应用。完整细节可以在这里找到。
字节码指令集从何而来?(Where Did the Bytecode Instruction Set Come From?)
字节码解释器比编译后的原生代码慢得多,但对一个没有 JIT 编译器的解释器来说,它的性能仍然相当出色。它的效率可以追溯到 Xavier Leroy 在 1990 年的开创性工作:“The ZINC experiment: An Economical Implementation of the ML Language”。
这篇论文为实现适用于 OCaml 这类严格求值函数式语言的指令集奠定了理论基础。现代 OCaml 中的字节码解释器仍然基于 ZINC 模型。原生代码编译器使用不同模型,因为它会使用 CPU 寄存器进行函数调用,而不是像字节码解释器那样总是在栈上传递参数。
理解字节码解释器和原生编译器不同实现背后的推理,对任何正在成长中的语言黑客来说都是非常有用的练习。
27.2.1 编译和链接字节码(Compiling and Linking Bytecode)
ocamlc 命令会把单个 ml 文件编译成扩展名为 cmo 的字节码文件。已编译字节码文件会与关联的 cmi 接口匹配,后者包含导出给其他编译单元的类型签名。
一个典型 OCaml 库由多个源文件组成,因此会有多个 cmo 文件;从其他代码使用该库时,这些文件都需要作为命令行参数传入。编译器可以使用 -a 标志,把这些多个文件组合成一个更方便的单个归档文件。字节码归档使用 cma 扩展名表示。
库中的单个对象会按构建库文件时指定的顺序,以普通 cmo 文件的形式链接。如果库中的某个对象文件没有在程序其他地方被引用,它就不会被包含进最终二进制文件,除非 -linkall 标志强制包含它。这种行为类似于 C 处理对象文件和归档(分别为 .o 和 .a)的方式。
随后,字节码文件会与 OCaml 标准库链接在一起,产生一个可执行程序。命令行中 .cmo 参数出现的顺序,定义了运行时初始化编译单元的顺序。请记住,OCaml 没有像 C 那样的单个 main 函数,因此这个链接顺序比在 C 程序中更重要。
27.2.2 执行字节码(Executing Bytecode)
字节码运行时由三部分组成:字节码解释器、GC,以及一组实现原语操作的 C 函数。需要时,字节码会包含调用这些 C 函数的指令。
默认情况下,OCaml 链接器会生成面向标准 OCaml 运行时的字节码,因此它需要知道其他库中引用的、但默认不加载的任何 C 函数。
这些额外库的信息可以在链接字节码归档时指定:
$ ocamlc -a -o mylib.cma a.cmo b.cmo -dllib -lmylib
dllib 标志会把这些参数嵌入归档文件。随后链接这个归档的任何包也都会包含额外的 C 链接指令。这反过来允许解释器在执行字节码时动态加载外部库符号。
也可以生成一个完整的独立可执行文件,把 ocamlrun 解释器和字节码打包到单个二进制文件中。这称为自定义运行时(custom runtime)模式,构建方式如下:
$ ocamlc -a -o mylib.cma -custom a.cmo b.cmo -cclib -lmylib
自定义模式是与原生代码编译最相似的模式,因为二者都会生成独立可执行文件。用于编译字节码的其他选项还有不少,尤其是共享库和构建自定义运行时。完整细节可以在 OCaml 手册中找到。
如果在 executable 规则中指定 byte_complete 模式,Dune 可以构建一个自包含字节码可执行文件。例如,下面这个 dune 文件会生成一个 prog.bc.exe 目标:
(executable
(name prog)
(modules prog)
(modes byte byte_complete))
27.2.3 在 C 中嵌入 OCaml 字节码(Embedding OCaml Bytecode in 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 交互章节对此有详细说明。
27.3 编译快速原生代码(Compiling Fast Native Code)
原生代码编译器最终是大多数生产 OCaml 代码都会经过的工具。它会把 lambda 形式编译成快速原生代码可执行文件,并执行跨模块内联以及字节码解释器不会执行的额外优化 pass。编译器会小心确保与字节码运行时兼容,因此同一段代码用任一工具链编译时,都应该表现一致。
ocamlopt 命令是原生代码编译器的前端,拥有与 ocamlc 非常相似的接口。它同样接收 ml 和 mli 文件,但会把它们编译成:
- 包含原生对象代码的
.o文件。 - 包含用于链接和跨模块优化的额外信息的
.cmx文件。 - 与字节码编译器相同的
.cmi已编译接口文件。
当编译器把模块链接成可执行文件时,它会使用 cmx 文件内容跨编译单元执行跨模块内联。对于经常在模块外使用的标准库函数,这可能带来显著提速。
把 -a 标志传给编译器,也可以把一组 .cmx 和 .o 文件链接成 .cmxa 归档。不过,与字节码版本不同,必须把单个 cmx 文件保留在编译器搜索路径中,以便它们可用于跨模块内联。如果不这样做,编译仍然会成功,但你会错过一项重要优化,得到更慢的二进制文件。
27.3.1 检查汇编输出(Inspecting Assembly Output)
原生代码编译器会生成汇编语言,随后把它传给系统汇编器,编译成对象文件。可以向编译器命令行传入 -S 标志,让 ocamlopt 输出汇编。
汇编代码高度依赖架构,因此下面的讨论假定使用 Intel 或 AMD 64 位平台。我们使用 -inline 20 和 -nodynlink 生成示例代码,因为最好在编译器支持的完整优化下生成汇编代码。虽然这些优化会让代码稍微更难读,但它会让你更准确地了解 CPU 上实际执行的内容。别忘了,如果在更冗长的汇编中迷失,可以使用前面介绍的 lambda 代码获得稍微高层一些的代码图景。
多态比较的影响(The Impact of Polymorphic Comparison)
我们在第 15 章“映射与哈希表”中提醒过,使用多态比较既方便又危险。现在看看在汇编语言层面,差异究竟是什么。
先创建一个比较函数,其中显式标注了类型,因此编译器知道只有整数会被比较:
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(jump if less than or equal)指令比较。这要求两个参数都是立即整数才能工作。现在看看,如果 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 指令是包含调用帧信息(Call Frame Information)的汇编器提示,让调试器能提供更合理的回溯;它们对运行时性能没有影响。注意,其余实现已经不再是简单寄存器比较。相反,参数会被压入栈(%rsp 寄存器),然后通过把指向 caml_greaterthan 的指针放进 %rax 并跳转到 caml_c_call,调用一个 C 函数。
在 x86_64 架构上,OCaml 会把小堆位置缓存在 %r15 寄存器中,因为它在 OCaml 函数中会被频繁引用。被调用的 C 代码也可能改变小堆指针,例如它分配 OCaml 值时;因此,从 caml_greaterthan 调用返回后需要恢复 %r15。最后,比较返回值会从栈中弹出并返回。
对多态比较做基准测试(Benchmarking Polymorphic Comparison)
不需要完全理解汇编语言的复杂细节,也能看出这种多态比较比前面的单态整数比较重得多。我们再次写一个简单的 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
运行后可以看到二者之间有相当显著的运行时差异:
$ 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 模块(Accessing Stdlib Modules from Within Core)
在前面的多态与单态比较基准中,你可能注意到我们给比较函数加上了 Stdlib 前缀。这是因为 Core 模块显式重新定义了 >、< 和 = 运算符,让它们专门操作 int 类型,这一点在第 15 章“映射与哈希表”中解释过。总是可以像基准测试中这样,通过 Stdlib 模块访问任何 OCaml 标准库函数。
27.3.2 调试原生代码二进制文件(Debugging Native Code Binaries)
原生代码编译器构建的可执行文件可以使用传统系统调试器调试,例如 GNU gdb。需要用 -g 选项编译库,把调试信息添加到输出中,就像使用 C 编译器时一样。
库以调试模式编译时,额外调试信息会被插入输出汇编中。这包括你在前面性能分析输出中注意到的 CFI 存根,例如用于界定一次 OCaml 函数调用的 .cfi_start_proc 和 .cfi_end_proc。
理解名称修饰(Understanding Name Mangling)
那么,在 gdb 这样的交互式调试器中,如何引用 OCaml 函数呢?首先需要知道 OCaml 函数名如何编译成已编译对象文件中的符号名,这个过程通常称为名称修饰(name mangling)。
每个 OCaml 源文件都会被编译成一个原生对象文件,而为了遵守 C 二进制接口,它必须导出一组唯一符号。这意味着任何可能被其他编译单元使用的 OCaml 值,都需要映射到符号名。这个映射必须考虑嵌套模块、匿名函数和互相遮蔽的变量名等 OCaml 语言特性。
对具名变量和函数来说,转换遵循一些直接规则:
- 符号以
caml和局部模块名为前缀,其中点号替换为下划线。 - 接着是双下划线
__后缀和变量名。 - 变量名还会加上一个
_和数字作为后缀。这是 lambda 编译的结果,它会把每个变量名替换成模块内唯一的值。可以检查ocamlopt的-dlambda输出来确定这个数字。
不检查编译器中间输出时,匿名函数很难预测。如果需要调试它们,通常更容易修改源代码,把匿名函数用 let 绑定到一个变量名上。
使用 GNU 调试器的交互式断点(Interactive Breakpoints with the GNU Debugger)
通过 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))
$ 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 的演讲 “Real-world debugging in OCaml” 查看更多指南。
OCaml 原生代码的一个非常有用的特性是,C 和 OCaml 共享同一个栈。这意味着 GDB 回溯可以给出程序和运行时库中正在发生事情的组合视图。如果处在把 OCaml 运行时作为库嵌入的环境中,这也包括对 C 库的任何调用,甚至包括从 C 层回调到 OCaml。
27.3.3 原生代码性能分析(Profiling Native Code)
记录和分析应用把执行时间花在哪里,称为性能分析(performance profiling)。OCaml 原生代码二进制文件可以像任何其他 C 二进制文件一样被分析,只需要使用前面描述的名称修饰规则,在 OCaml 变量名和 profiler 输出之间做映射。
大多数性能分析工具都会受益于二进制文件中包含的一些插桩。OCaml 支持两类这样的工具:
- GNU
gprof,用于测量执行时间和调用图。 - 现代 Linux 版本中的 Perf 性能分析框架。
注意,许多其他操作原生二进制文件的工具,例如 Valgrind,只要程序用 -g 标志链接以嵌入调试符号,就能很好地与 OCaml 配合。
Gprof
gprof 会通过记录哪些函数互相调用的调用图,以及记录程序执行期间这些调用花费的时间,为 OCaml 程序生成执行概况。
要从 gprof 得到精确信息,需要在编译和链接二进制文件时都把 -p 标志传给原生代码编译器。这会生成额外代码,在程序执行时把 profile 信息记录到一个名为 gmon.out 的文件中。随后,可以用 gprof 检查这些 profile 信息。
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
这段 trace 大致反映了基准测试本身的结果。可变基准由 test_mutable 调用和运行时中的 caml_modify 写屏障函数组合而成。二者合计略高于应用执行时间的一半。
Perf 拥有越来越多其他命令,允许归档这些运行结果并互相比较。可以在主页阅读更多内容。
使用帧指针获得更准确的 Trace(Using the Frame Pointer to Get More Accurate Traces)
虽然 Perf 不需要向二进制文件中添加显式探针,但它确实需要理解如何展开函数调用,以便内核能为每个事件准确记录函数回溯。自 Linux 3.9 起,内核已经支持使用 DWARF 调试信息解析程序栈;当把 -g 标志传给 OCaml 编译器时,就会发出这类信息。为了获得更准确的栈解析,需要让编译器退回到使用与 C 相同的函数调用约定。在 64 位 Intel 系统上,这意味着使用一个称为帧指针(frame pointer)的特殊寄存器来记录函数调用历史。以这种方式使用帧指针意味着会变慢,通常大约 3-5%,因为它不能再用于通用目的。
因此,OCaml 把帧指针做成一个可选特性,可用于提高 Perf trace 的分辨率。opam 提供了一个编译器 switch,可以编译启用帧指针的 OCaml:
$ opam switch create 4.13+fp ocaml-variants.4.13.1+options ocaml-option-fp
使用帧指针会改变 OCaml 调用约定,但 opam 会负责用新接口重新编译所有库。
27.3.4 在 C 中嵌入原生代码(Embedding Native Code in C)
原生代码编译器通常会链接一个完整可执行文件,但也能像字节码编译器一样输出一个独立原生对象文件。除了运行时库之外,这个对象文件不再依赖 OCaml。
原生代码运行时与字节码运行时是不同的库,并以 libasmrun.a 的名字安装在 OCaml 标准库目录中。
使用本章前面字节码嵌入示例中的同一组源文件,尝试这种自定义链接:
$ 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 及之后版本使用的工具链会在单趟中从左到右解析符号。
激活调试运行时(Activating the Debug Runtime)
尽管已经尽力小心,仍然很容易在某些组件中引入 bug,例如 C 绑定,从而导致堆不变量被破坏。OCaml 包含运行时库的 libasmrund.a 变体,它带有额外调试检查,会在每个垃圾回收周期执行额外内存完整性检查。运行这些额外检查会让程序在更接近损坏点的位置中止,从而帮助隔离 C 代码中的 bug。
要使用调试库,只需用 -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!
27.4 文件扩展名总结(Summarizing the File Extensions)
我们已经看到,编译器会用中间文件保存编译工具链的各个阶段。下面把所有这些扩展名放在一处,作为速查表。
.ml是编译单元模块实现的源文件。.mli是编译单元模块接口的源文件。如果缺失,会从.ml文件生成。.cmi是从对应.mli源文件生成的已编译模块接口。.cmo是模块实现的已编译字节码对象文件。.cma是把字节码对象文件打包到单个文件中的库。.o是由系统cc编译成原生对象文件的 C 源文件。.cmt是模块实现的类型化抽象语法树。.cmti是模块接口的类型化抽象语法树。.annot是用于显示typed信息的旧式注解文件,已被cmt文件取代。
原生代码编译器还会生成一些额外文件。
.o是模块实现的已编译原生对象文件。.cmx包含用于对象文件链接和跨模块优化的额外信息。.cmxa和.a是由cmx与o单元组成的库,分别保存在cmxa和a文件中。这些文件总是需要一起使用。- 如果指定
-S,.S或.s是汇编语言输出。