性能分析

速度

为什么 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期望channelstring作为参数,实际上它也得到了这些参数。如果我们传递一个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

其中 ab 是实际的整数参数。所以这个函数返回

%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 程序执行两种类型的性能分析。

  1. 获取字节码的执行次数。
  2. 获取本地代码的真实性能分析。

ocamlcpocamlprof 程序对字节码进行性能分析。以下是一个示例。

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. ]

在 Linux 上使用 perf

假设 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)。

在 macOS 上使用 Instruments

macOS 附带一个名为 Instruments 的性能监控和调试应用程序,它附带 CPU 计数器、时间性能分析器和系统跟踪模板。

启动它并选择你想要的模板后,你必须在启动应用程序之前开始记录

当你启动应用程序时,实时结果将显示在 Instruments 的浏览器中。

macOS Instruments

从那里,你可以点击你的程序并深入了解哪些函数执行时间最长。

总结

总之,以下是一些关于如何从你的程序中获得最佳性能的技巧。

  1. 尽可能简单地编写你的程序。如果运行时间过长,对其进行性能分析以找出它在哪里花费时间,并将优化集中在这些区域。
  2. 检查意外的多态性,并为编译器添加类型提示。
  3. 闭包比简单的函数调用更慢,但它们提高了可维护性和可读性。
  4. 作为最后的手段,用 C 语言重写程序中的热点(但首先检查 OCaml 编译器生成的汇编语言,看看你是否能做得比它更好)。
  5. 性能可能取决于外部因素(数据库查询的速度?网络的速度?)。如果是这样的话,无论你进行多少优化,都无济于事。

进一步阅读

你可以通过阅读 OCaml 手册中的(“将 C 语言与 OCaml 接口”)一章以及查看 mlvalues.h 头文件来了解更多关于 OCaml 如何表示不同类型的信息。

仍然需要帮助?

帮助改进我们的文档

所有 OCaml 文档都是开源的。发现错误或不清楚的地方?提交拉取请求。

OCaml

创新。社区。安全。