性能分析
速度
为什么 OCaml 很快? 实际上,先退一步问问,OCaml 真的很快吗? 我们如何才能让程序更快? 在本章中,我们将研究编译 OCaml 程序到机器码时实际上会发生什么。 这将有助于理解为什么 OCaml 不仅仅是一种很棒的编程语言,而且是一种非常快的语言。 它还会帮助你帮助编译器为你生成更好的机器码。 我认为,对于程序员来说,了解在你输入 ocamlopt
到得到可以运行的可执行文件之间发生了什么也是一件好事。
但是你需要了解一些汇编语言才能充分利用本章内容。 不要害怕! 我会通过将汇编语言翻译成 C 类伪代码来帮助你(毕竟 C 只不过是一种可移植的汇编语言)。
汇编语言基础
本章中的示例都在 x86 Linux 机器上编译。 x86 自然是一台 32 位机器,因此 x86 的“字”长度为 4 字节 (= 32 位)。 在这个级别上,OCaml 主要处理字大小的对象,因此你需要记住乘以 4 来获得以字节为单位的大小。
为了刷新你的记忆,x86 只有少量通用寄存器,每个寄存器可以存储一个字。 Linux 汇编器在寄存器名前面加上 %
。 寄存器有:%eax
、%ebx
、%ecx
、%edx
、%esi
、%edi
、%ebp
(用于堆栈帧的特殊寄存器)和 %esp
(堆栈指针)。
Linux 汇编器(与其他 Unix 汇编器相同,但与 MS 派生的汇编器相反)将寄存器/内存之间的移动写为
movl from, to
因此 movl %ebx, %eax
表示“将寄存器 %ebx
的内容复制到寄存器 %eax
中”(反之亦然)。
我们将会看到的大部分汇编语言将不会是像 movl
这样的机器码指令,而是被称为 **汇编指令**。 这些指令以 .(点)开头,它们实际上是 *指示* 汇编器做某事。 以下是 Linux 汇编器的常见指令
.text
**文本** 是 Unix 中表示“程序代码”的方式。 **文本段** 指的是可执行文件中存储程序代码的部分。 .text
指令切换汇编器,使其开始写入文本段。
.data
类似地,.data
指令切换汇编器,使其开始写入可执行文件的数据段(部分)。
.globl foo
foo:
这声明了一个名为 foo
的全局符号。 它的意思是下一个要来的内容的地址可以命名为 foo
。 仅写 foo:
而不写前面的 .globl
指令会声明一个局部符号(仅限于当前文件)。
.long 12345
.byte 9
.ascii "hello"
.space 4
.long
将一个字(4 字节)写入当前段。 .byte
写入单个字节。 .ascii
写入字节字符串(不是以空字符结尾的)。 .space
写入给定数量的零字节。 通常你会在数据段中使用这些指令。
“hello, world” 程序
足够了汇编。 将以下程序放入名为 smallest.ml
的文件中
print_string "hello, world\n"
并使用以下命令将其编译为原生代码可执行文件
ocamlopt -S smallest.ml -o smallest
-S
(大写 S)告诉编译器留下汇编语言文件(称为 smallest.s
- 小写 s)而不是删除它。
以下是 smallest.s
文件中经过编辑的重点内容,并添加了我的注释。 首先是数据段
.data
.long 4348 ; header for string
.globl Smallest__1
lest__1:
.ascii "hello, world\12" ; string
.space 2 ; padding ..
.byte 2 ; .. after string
接下来是文本(程序代码)段
.text
.globl Smallest__entry ; entry point to the program
lest__entry:
; this is equivalent to the C pseudo-code:
; Pervasives.output_string (stdout, &Smallest__1)
movl $Smallest__1, %ebx
movl Pervasives + 92, %eax ; Pervasives + 92 == stdout
call Pervasives__output_string_212
; return 1
movl $1, %eax
ret
在 C 语言中,所有内容都必须放在函数内。 想想一下,你不能在 C 语言中只写 printf ("hello, world\n");
,而必须把它放在 main () { ... }
内。 在 OCaml 中,你可以在顶层有命令,而不需要放在函数内。 但是当我们将其翻译成汇编语言时,我们将这些命令放在哪里? 需要有一种方法从外部调用这些命令,因此需要对它们进行标记。 从代码片段可以看出,OCaml 通过获取文件名 (smallest.ml
),将其大写并添加 __entry
,从而构成一个名为 Smallest__entry
的符号来引用此文件中顶层的命令。
现在看看 OCaml 生成的代码。 原始代码是 print_string "hello, world\n"
,但 OCaml 编译了等效的 Pervasives.output_string stdout "hello, world\n"
。 为什么? 如果你查看 pervasives.ml
,你就会明白为什么
let print_string s = output_string stdout s
OCaml *内联* 了这个函数。 **内联** - 从定义中扩展函数 - 有时可以提高性能,因为它避免了额外函数调用的开销,并且可以为优化器提供更多机会来发挥作用。 有时内联不好,因为它会导致代码膨胀,从而破坏处理器缓存所做的良好工作(此外,函数调用在现代处理器上实际上并不昂贵)。 OCaml 会内联像这样简单的调用,因为它们本质上没有风险,几乎总是会导致性能的小幅提升。
我们还能注意到什么? 调用约定似乎是,前两个参数分别通过 %eax
和 %ebx
寄存器传递。 其他参数可能会在堆栈上传递,但我们稍后会看到这一点。
C 程序有一个简单的存储字符串的约定,称为 **ASCIIZ**。 这仅仅意味着字符串以 ASCII 形式存储,后面跟着一个尾部的 NUL (\0
) 字符。 OCaml 以不同的方式存储字符串,正如我们在上面的数据段中看到的那样。 这个字符串的存储方式如下
4 byte header: 4348
the string: h e l l o , SP w o r l d \n
padding: \0 \0 \002
首先,填充使字符串的总长度达到一个完整的字数(在本例中为 4 个字,16 个字节)。 填充经过精心设计,以便你可以计算出字符串的实际长度(以字节为单位),前提是你知道分配给字符串的 *字* 的总数。 这种编码是明确的(你可以自己证明这一点)。
具有显式长度字符串的一个好处是,你可以在字符串中表示包含 ASCII NUL (\0
) 字符的字符串,而这在 C 语言中很难做到。 然而,另一方面,如果你将 OCaml 字符串传递给一些 C 原生代码,你需要注意这一点:如果它包含 ASCII NUL,那么 C 的 str*
函数将无法正常工作。
其次是头部。 OCaml 中的每个 boxed(分配的)对象都有一个头部,它告诉垃圾收集器对象的大小(以字为单位),以及关于对象包含内容的信息。 将数字 4348 写成二进制
length of the object in words: 0000 0000 0000 0000 0001 00 (4 words)
color (used by GC): 00
tag: 1111 1100 (String_tag)
有关 OCaml 中堆分配对象的格式的更多信息,请参见 /usr/include/caml/mlvalues.h
。
一个不寻常的事情是,代码将指向字符串开头的指针(即标题后的第一个词)传递给Pervasives.output_string
。这意味着output_string
必须从指针中减去4才能得到标题,从而确定字符串的长度。
在这个简单的示例中,我错过了什么?嗯,上面的文本段落并非全部内容。如果OCaml能够将这个简单的Hello World程序转换为上面所示的五行汇编语言,那就太好了。但是,在实际程序中,究竟是什么调用了Smallest__entry
呢?为此,OCaml包含了大量的引导代码,这些代码会执行一些操作,例如启动垃圾收集器、分配和初始化内存、调用库中的初始化器等等。OCaml将所有这些代码静态链接到最终的可执行文件中,这就是为什么我最终得到的程序(在Linux上)大小为95,442字节。尽管如此,程序的启动时间仍然是无法测量的(低于毫秒),相比之下,启动一个合理的Java程序需要几秒钟,而启动一个Perl脚本则需要一秒左右。
尾递归
我们在第6章中提到,OCaml可以将尾递归函数调用转换为简单的循环。这是真的吗?让我们看看简单的尾递归编译成什么样子。
let rec loop () =
print_string "I go on forever ...";
loop ()
let () = loop ()
该文件名为tail.ml
,因此遵循OCaml的函数命名惯例,我们的函数将被命名为Tail__loop_nnn
(其中nnn
是OCaml追加的某个唯一数字,用来区分相同名称的函数)。
以下是上面定义的loop
函数的汇编代码。
.text
.globl Tail__loop_56
Tail__loop_56:
.L100:
; Print the string
movl $Tail__2, %ebx
movl Pervasives + 92, %eax
call Pervasives__output_string_212
.L101:
; The following movl is in fact obsolete:
movl $1, %eax
; Jump back to the .L100 label above (ie. loop forever)
jmp .L100
因此,这相当具有说服力。调用Tail__loop_56
将首先打印字符串,然后跳回顶部,然后打印字符串,然后跳回,如此循环往复。这是一个简单的循环,而不是递归函数调用,因此它不使用任何堆栈空间。
旁白:类型在哪里?
正如我们在很多场合提到的,OCaml是静态类型的,因此在编译时,OCaml知道loop
的类型是unit -> unit
。它知道"hello, world\n"
的类型是string
。它不会尝试将此信息传达给output_string
函数。output_string
期望channel
和string
作为参数,实际上它也得到了这些参数。如果我们传递一个int
而不是string
,会发生什么情况呢?
这本质上是不可能的情况。因为OCaml在编译时知道类型,所以它不需要在运行时处理类型或检查类型。在纯OCaml中,无法“欺骗”编译器生成对Pervasives.output_string stdout 1
的调用。这种错误将在编译时通过类型推断标记出来,因此永远不会被编译。
结果是,当我们将OCaml代码编译成汇编语言时,类型信息大多不再需要,尤其是在我们上面讨论的、类型在编译时完全已知的案例中,而且没有发生多态性。
在编译时完全知道所有类型是一项重大的性能优势,因为它完全避免了在运行时进行动态类型检查。例如,将此与Java方法调用进行比较:obj.method ()
。这是一个昂贵的操作,因为您需要找到obj
所属的具体类,然后查找该方法,并且您可能需要在每次调用任何方法时都执行所有这些操作。对对象进行强制类型转换是另一个需要在Java中运行时执行大量工作的案例。在OCaml的静态类型中,所有这些操作都不被允许。
多态类型
正如您可能从上面的讨论中猜到的那样,多态性(即编译器在编译时没有函数的完全已知类型)可能会对性能产生影响。假设我们需要一个函数来计算两个整数中的最大值。我们的第一个尝试是
# let max a b =
if a > b then a else b;;
val max : 'a -> 'a -> 'a = <fun>
这很简单,但请记住,OCaml中的>(大于)运算符是多态的。它的类型是'a -> 'a -> bool
,这意味着我们上面定义的max
函数将是多态的。
# let max a b =
if a > b then a else b;;
val max : 'a -> 'a -> 'a = <fun>
这确实反映在OCaml为该函数生成的代码中,该代码非常复杂。
.text
.globl Max__max_56
Max__max_56:
; Reserve two words of stack space.
subl $8, %esp
; Save the first and second arguments (a and b) on the stack.
movl %eax, 4(%esp)
movl %ebx, 0(%esp)
; Call the C "greaterthan" function (in the OCaml library).
pushl %ebx
pushl %eax
movl $greaterthan, %eax
call caml_c_call
.L102:
addl $8, %esp
; If the C "greaterthan" function returned 1, jump to .L100
cmpl $1, %eax
je .L100
; Returned 0, so get argument a which we previously saved on
; the stack and return it.
movl 4(%esp), %eax
addl $8, %esp
ret
; Returned 1, so get argument b which we previously saved on
; the stack and return it.
.L100:
movl 0(%esp), %eax
addl $8, %esp
ret
基本上,>操作是通过调用OCaml库中的一个C函数来完成的。这显然不会很有效,不像我们可以为执行>生成一些快速的内联汇编语言那样高效。
这绝不是完全的损失。我们只需要向OCaml编译器提示,这些参数实际上是整数。然后OCaml将生成一个专门的max
版本,该版本只适用于int
参数。
# let max (a : int) (b : int) =
if a > b then a else b;;
val max : int -> int -> int = <fun>
以下是为该函数生成的汇编代码。
.text
.globl Max_int__max_56
Max_int__max_56:
; Single assembly instruction "cmpl" for performing the > op.
cmpl %ebx, %eax
; If %ebx > %eax, jump to .L100
jle .L100
; Just return argument a.
ret
; Return argument b.
.L100:
movl %ebx, %eax
ret
这只有5行汇编代码,而且尽可能简单。
这段代码怎么样?
# let max a b =
if a > b then a else b;;
val max : 'a -> 'a -> 'a = <fun>
# let () = print_int (max 2 3);;
3
OCaml是否足够聪明,可以内联max
函数并将其专门化为适用于整数?令人失望的是,答案是否定的。OCaml仍然必须生成外部Max.max
符号(因为这是一个模块,因此该函数可能从模块外部调用),并且它不会内联该函数。
以下是另一个变体。
# let max a b =
if a > b then a else b in
print_int (max 2 3);;
3
- : unit = ()
令人失望的是,尽管此代码中max
的定义是本地的(它无法从模块外部调用),但OCaml仍然没有专门化该函数。
教训:如果您有一个无意中是多态的函数,那么您可以通过为一个或多个参数指定类型来帮助编译器。
整数的表示形式、标记位、堆分配值
OCaml中的整数有一些特殊之处。其中之一是整数是31位实体,而不是32位实体。那么“缺失”的位发生了什么?
将此写入int.ml
print_int 3
并使用ocamlopt -S int.ml -o int
编译,以在int.s
中生成汇编语言。回想一下我们上面的讨论,我们期望OCaml将print_int
函数内联为output_string (string_of_int 3)
,并且检查汇编语言输出我们可以看到,OCaml确实是这样做的。
.text
.globl Int__entry
Int__entry:
; Call Pervasives.string_of_int (3)
movl $7, %eax
call Pervasives__string_of_int_152
; Call Pervasives.output_string (stdout, result_of_previous)
movl %eax, %ebx
movl Pervasives + 92, %eax
call Pervasives__output_string_212
重要的代码是movl $7, %eax
。它展示了两个方面:首先,整数是未装箱的(没有在堆上分配),而是直接在寄存器%eax
中传递给函数。这很快。但是其次,我们看到传递的数字是7,而不是3。
这是OCaml中整数表示形式的结果。整数的最低位用作标记——我们将在后面看到它是用来做什么的。最高31位是实际的整数。7的二进制表示形式为111,因此最低位的标记位为1,最高31位构成二进制数11 = 3。要从OCaml表示形式转换为整数,请除以2并向下取整。
为什么需要标记位呢?该位用于区分整数和指向堆上的结构的指针,只有在调用多态函数时才需要进行区分。在上面的案例中,我们正在调用string_of_int
,参数只能是int
,因此永远不会参考标记位。尽管如此,为了避免对整数使用两种内部表示形式,OCaml中的所有整数都携带标记位。
要理解为什么标记位是真正必要的以及为什么它位于当前位置,需要了解一些关于指针的背景知识。
在Sparc、MIPS和Alpha等RISC芯片的世界中,指针必须是字对齐的。例如,在较旧的32位Sparc上,不可能创建和使用未对齐到4的倍数(字节)的指针。尝试使用它会导致处理器异常,这意味着基本上您的程序会发生段错误。这样做的原因仅仅是为了简化内存访问。如果只需要处理字对齐的访问,那么设计CPU的内存子系统就会简单得多。
由于历史原因(因为x86是从8位芯片衍生出来的),x86一直支持未对齐的内存访问,尽管如果您将所有内存访问对齐到4的倍数,那么速度会更快。
尽管如此,OCaml中的所有指针都是对齐的——即对于32位处理器来说是4的倍数,对于64位处理器来说是8的倍数。这意味着OCaml中任何指针的最低位始终为零。
因此,您可以看到,通过查看寄存器的最低位,您可以立即知道它存储的是指针(“标记”位为零),还是整数(标记位设置为1)。
还记得我们之前讨论中导致我们遇到很多麻烦的多态>函数吗?我们查看了汇编语言,发现OCaml在遇到>的多态形式时,会编译对名为greaterthan
的C函数的调用。该函数接收两个参数,分别位于寄存器%eax
和%ebx
中。但是,greaterthan
可以使用整数、浮点数、字符串、不透明对象...来调用。它如何知道%eax
和%ebx
指向什么?
它使用以下决策树。
- 标记位为1:比较两个整数并返回。
- 标记位为0:
%eax
和%ebx
必须指向两个堆分配的内存块。查看内存块的标题字,特别是标题字的最低8位,这些位标记了块的内容。- String_tag:比较两个字符串。
- Double_tag:比较两个浮点数。
- 等等。
请注意,因为>的类型是'a -> 'a -> bool
,所以两个参数的类型必须相同。编译器应该在编译时强制执行此规则。我假设greaterthan
可能包含在运行时进行健全性检查的代码。
浮点数
默认情况下,浮点数是装箱的(在堆上分配)。将此保存为float.ml
并使用ocamlopt -S float.ml -o float
编译。
print_float 3.0
该数字没有像上面对整数那样直接在%eax
寄存器中传递给string_of_float
。相反,它是在数据段中静态创建的。
.data
.long 2301
.globl Float__1
Float__1:
.double 3.0
并且一个指向浮点数的指针在%eax
中传递。
movl $Float__1, %eax
call Pervasives__string_of_float_157
请注意浮点数的结构:它有一个标题(2301),后面跟着该数字本身的8字节(2字)表示形式。可以通过将其写成二进制形式来解码标题。
Length of the object in words: 0000 0000 0000 0000 0000 10 (8 bytes)
Color: 00
Tag: 1111 1101 (Double_tag)
string_of_float
不是多态的,但是假设我们有一个多态函数foo : 'a -> unit
,它接收一个多态参数。如果我们使用包含7的%eax
调用foo
,那么这等同于foo 3
,而如果我们使用包含指向上面Float__1
的指针的%eax
调用foo
,那么这等同于foo 3.0
。
数组
之前我提到过 OCaml 的目标之一是数值计算。数值计算对向量和矩阵进行了大量操作,而向量和矩阵本质上是浮点数数组。为了加快速度,OCaml 实现了一个 **无箱浮点数数组** 的特殊技巧。这意味着在 float array
(浮点数数组)类型对象的特殊情况下,OCaml 会像 C 语言一样存储它们。
double array[10];
… 而不是在堆上拥有指向十个单独分配的浮点数的指针数组。
让我们在实践中看看。
let a = Array.create 10 0.0;;
for i = 0 to 9 do
a.(i) <- float_of_int i
done
我将使用 -unsafe
选项编译这段代码以移除边界检查(为了简化我们这里的内容)。第一行创建了数组,它被编译成一个简单的 C 语言调用。
pushl $Arrayfloats__1 ; Boxed float 0.0
pushl $21 ; The integer 10
movl $make_vect, %eax ; Address of the C function to call
call caml_c_call
; ...
movl %eax, Arrayfloats ; Store the resulting pointer to the
; array at this place on the heap.
循环被编译成这段相对简单的汇编语言。
movl $1, %eax ; Let %eax = 0. %eax is going to store i.
cmpl $19, %eax ; If %eax > 9, then jump out of the
jg .L100 ; loop (to label .L100 at the end).
.L101: ; This is the start of the loop body.
movl Arrayfloats, %ecx ; Address of the array to %ecx.
movl %eax, %ebx ; Copy i to %ebx.
sarl $1, %ebx ; Remove the tag bit from %ebx by
; shifting it right 1 place. So %ebx
; now contains the real integer i.
pushl %ebx ; Convert %ebx to a float.
fildl (%esp)
addl $4, %esp
fstpl -4(%ecx, %eax, 4) ; Store the float in the array at the ith
; position.
addl $2, %eax ; i := i + 1
cmpl $19, %eax ; If i <= 9, loop around again.
jle .L101
.L100:
重要的语句是将浮点数存储到数组中的语句。它是:
fstpl -4(%ecx, %eax, 4)
汇编语法相当复杂,但括号表达式 -4(%ecx, %eax, 4)
意味着“在地址 %ecx + 4*%eax - 4
处”。回想一下,%eax
是 i 的 OCaml 表示,带有标记位,所以它本质上等于 i*2+1
,所以让我们代入并将其乘开。
%ecx + 4*%eax - 4
= %ecx + 4*(i*2+1) - 4
= %ecx + 4*i*2 + 4 - 4
= %ecx + 8*i
(数组中的每个浮点数都是 8 字节长。)
所以浮点数数组是无箱的,正如预期的那样。
部分应用函数和闭包
OCaml 如何编译仅部分应用的函数?让我们编译这段代码。
Array.map ((+) 2) [|1; 2; 3; 4; 5|]
如果你还记得语法,[| ... |]
声明一个数组(在本例中是一个 int array
),((+) 2)
是一个闭包 - “将 2 加到事物上的函数”。
编译这段代码揭示了一些有趣的新特性。首先是分配数组的代码。
movl $24, %eax ; Allocate 5 * 4 + 4 = 24 bytes of memory.
call caml_alloc
leal 4(%eax), %eax ; Let %eax point to 4 bytes into the
; allocated memory.
所有堆分配都具有相同的格式:4 字节头 + 数据。在本例中,数据是 5 个整数,因此我们为头分配 4 个字节,为数据分配 5 * 4 个字节。我们将指针更新为指向第一个数据字,即分配的内存块中的第 4 个字节。
接下来,OCaml 生成代码来初始化数组。
movl $5120, -4(%eax)
movl $3, (%eax)
movl $5, 4(%eax)
movl $7, 8(%eax)
movl $9, 12(%eax)
movl $11, 16(%eax)
头字是 5120,如果用二进制写出来,它表示一个包含 5 个字的块,标记为零。零标记意味着它是一个“结构化块”,也称为数组。我们还将数字 1、2、3、4 和 5 复制到数组中的适当位置。注意使用了整数的 OCaml 表示。因为这是一个结构化块,所以垃圾收集器将扫描该块中的每个字,并且 GC 需要能够区分整数和指向其他堆分配块的指针(GC 无法访问有关该数组的类型信息)。
接下来创建闭包 ((+) 2)
。闭包由数据段中分配的这个块表示。
.data
.long 3319
.globl Closure__1
Closure__1:
.long caml_curry2
.long 5
.long Closure__fun_58
头是 3319,表示一个长度为 3 个字的 Closure_tag
。块中的 3 个字是函数 caml_curry2
的地址、整数 2 和该函数的地址。
.text
.globl Closure__fun_58
Closure__fun_58:
; The function takes two arguments, %eax and %ebx.
; This line causes the function to return %eax + %ebx - 1.
lea -1(%eax, %ebx), %eax
ret
这个函数做什么呢?从表面上看,它将两个参数相加,然后减去 1。但请记住,%eax
和 %ebx
是整数的 OCaml 表示。让我们将它们表示为
%eax = 2 * a + 1
%ebx = 2 * b + 1
其中 a
和 b
是实际的整数参数。所以这个函数返回
%eax + %ebx - 1
= 2 * a + 1 + 2 * b + 1 - 1
= 2 * a + 2 * b + 1
= 2 * (a + b) + 1
换句话说,这个函数返回了 a + b
的和的 OCaml 整数表示。这个函数是 (+)
!
(实际上比这更微妙 - 为了快速执行数学运算,OCaml 使用了 x86 地址硬件,而这种方式可能不是 x86 设计者所预期的。)
所以回到我们的闭包 - 我们不会深入了解 caml_curry2
函数的细节,但只是说这个闭包是将参数 2
应用于函数 (+)
,等待第二个参数。正如预期的那样。
对 Array.map
函数的实际调用很难理解,但我们考察 OCaml 的主要要点是,代码
- 确实使用显式闭包调用了
Array.map
。 - 没有尝试内联调用并将其转换为循环。
以这种方式调用 Array.map
无疑比手动编写遍历数组的循环更慢。开销主要在于必须对数组的每个元素进行闭包评估,而且这不像将闭包内联为函数那样快(即使这种优化是可能的)。但是,如果你有一个比 ((+) 2)
更大的闭包,开销就会减少。FP 版本还可以节省编写命令式循环的昂贵程序员时间。
性能分析工具
你可以对 OCaml 程序执行两种类型的性能分析。
- 获取字节码的执行次数。
- 获取本地代码的真实性能分析。
ocamlcp
和 ocamlprof
程序对字节码进行性能分析。以下是一个示例。
let rec iterate r x_init i =
if i = 1 then x_init
else
let x = iterate r x_init (i - 1) in
r *. x *. (1.0 -. x)
let () =
Random.self_init ();
Graphics.open_graph " 640x480";
for x = 0 to 640 do
let r = 4.0 *. float_of_int x /. 640.0 in
for i = 0 to 39 do
let x_init = Random.float 1.0 in
let x_final = iterate r x_init 500 in
let y = int_of_float (x_final *. 480.) in
Graphics.plot x y
done
done;
Gc.print_stat stdout
并且可以使用以下命令运行和编译。
$ ocamlcp -p a graphics.cma graphtest.ml -o graphtest
$ ./graphtest
$ ocamlprof graphtest.ml
ocamlprof
添加了注释 (* nnn *)
,显示了代码的每个部分被调用的次数。
使用操作系统的本地性能分析支持对本地代码进行性能分析。在 Linux 的情况下,我们使用 gprof
。另一种选择是 perf,如下所述。
我们使用 -p
选项编译它,该选项告诉编译器为 gprof
包含性能分析信息。
在正常运行程序后,性能分析代码会将一个名为 gmon.out
的文件转储出来,我们可以用 gprof
解释它。
$ gprof ./a.out
Flat profile:
Each sample counts as 0.01 seconds.
% cumulative self self total
time seconds seconds calls s/call s/call name
10.86 0.57 0.57 2109 0.00 0.00 sweep_slice
9.71 1.08 0.51 1113 0.00 0.00 mark_slice
7.24 1.46 0.38 4569034 0.00 0.00 Sieve__code_begin
6.86 1.82 0.36 9171515 0.00 0.00 Stream__set_data_140
6.57 2.17 0.34 12741964 0.00 0.00 fl_merge_block
6.29 2.50 0.33 4575034 0.00 0.00 Stream__peek_154
5.81 2.80 0.30 12561656 0.00 0.00 alloc_shr
5.71 3.10 0.30 3222 0.00 0.00 oldify_mopup
4.57 3.34 0.24 12561656 0.00 0.00 allocate_block
4.57 3.58 0.24 9171515 0.00 0.00 modify
4.38 3.81 0.23 8387342 0.00 0.00 oldify_one
3.81 4.01 0.20 12561658 0.00 0.00 fl_allocate
3.81 4.21 0.20 4569034 0.00 0.00 Sieve__filter_56
3.62 4.40 0.19 6444 0.00 0.00 empty_minor_heap
3.24 4.57 0.17 3222 0.00 0.00 oldify_local_roots
2.29 4.69 0.12 4599482 0.00 0.00 Stream__slazy_221
2.10 4.80 0.11 4597215 0.00 0.00 darken
1.90 4.90 0.10 4596481 0.00 0.00 Stream__fun_345
1.52 4.98 0.08 4575034 0.00 0.00 Stream__icons_207
1.52 5.06 0.08 4575034 0.00 0.00 Stream__junk_165
1.14 5.12 0.06 1112 0.00 0.00 do_local_roots
[ etc. ]
perf
在 Linux 上使用 假设 perf
已安装,并且你的程序使用 -g
(或 ocamlbuild 标记 debug
)编译为本地代码,你只需要输入以下命令。
perf record --call-graph=dwarf -- ./foo.native a b c d
perf report
第一个命令使用参数 a b c d
启动 foo.native
并将性能分析信息记录到 perf.data
中;第二个命令启动一个交互式程序来探索调用图。选项 --call-graph=dwarf
使 perf 了解 OCaml 的调用约定(对于旧版本的 perf
,可能需要在 OCaml 中启用帧指针;opam 提供了合适的编译器开关,例如 4.02.1+fp
)。
Instruments
在 macOS 上使用 macOS 附带一个名为 Instruments
的性能监控和调试应用程序,它附带 CPU 计数器、时间性能分析器和系统跟踪模板。
启动它并选择你想要的模板后,你必须在启动应用程序之前开始记录。
当你启动应用程序时,实时结果将显示在 Instruments 的浏览器中。
从那里,你可以点击你的程序并深入了解哪些函数执行时间最长。
总结
总之,以下是一些关于如何从你的程序中获得最佳性能的技巧。
- 尽可能简单地编写你的程序。如果运行时间过长,对其进行性能分析以找出它在哪里花费时间,并将优化集中在这些区域。
- 检查意外的多态性,并为编译器添加类型提示。
- 闭包比简单的函数调用更慢,但它们提高了可维护性和可读性。
- 作为最后的手段,用 C 语言重写程序中的热点(但首先检查 OCaml 编译器生成的汇编语言,看看你是否能做得比它更好)。
- 性能可能取决于外部因素(数据库查询的速度?网络的速度?)。如果是这样的话,无论你进行多少优化,都无济于事。
进一步阅读
你可以通过阅读 OCaml 手册中的(“将 C 语言与 OCaml 接口”)一章以及查看 mlvalues.h
头文件来了解更多关于 OCaml 如何表示不同类型的信息。