另一种选择是使用 CPS 并完全避免显式函数数组。在这种情况下,尾调用优化仍然适用。
我不知道你是如何生成代码的,但我们不要不合理地假设在某个时候你有一组 VM 指令要为执行做准备。每条指令仍然表示为一个函数,但它接收的是延续函数而不是程序计数器。
这是最简单的例子:
type opcode = Add of int | Sub of int
let make_instr opcode cont =
match opcode with
| Add x -> fun data -> Printf.printf "add %d %d\n" data x; cont (data + x)
| Sub x -> fun data -> Printf.printf "sub %d %d\n" data x; cont (data - x)
let compile opcodes =
Array.fold_right make_instr opcodes (fun x -> x)
用法(查看推断类型):
# #use "cpsvm.ml";;
type opcode = Add of int | Sub of int
val make_instr : opcode -> (int -> 'a) -> int -> 'a = <fun>
val compile : opcode array -> int -> int = <fun>
# let code = [| Add 13; Add 42; Sub 7 |];;
val code : opcode array = [|Add 13; Add 42; Sub 7|]
# let fn = compile code;;
val fn : int -> int = <fun>
# fn 0;;
add 0 13
add 13 42
sub 55 7
- : int = 48
更新:
在此模型中很容易引入 [条件] 分支。 if 延续由两个参数构成:iftrue-continuation 和 iffalse-continuation,但与所有其他延续函数具有相同的类型。问题是我们不知道在向后分支的情况下是什么构成了这些延续(向后,因为我们从尾部编译到头部)。这很容易通过破坏性更新来克服(尽管如果您使用高级语言进行编译,可能会有更优雅的解决方案):只需留下“漏洞”并稍后在编译器达到分支目标时填充它们。
示例实现(我使用了字符串标签而不是整数指令指针,但这并不重要):
type label = string
type opcode =
Add of int | Sub of int
| Label of label | Jmp of label | Phi of (int -> bool) * label * label
let make_instr labels opcode cont =
match opcode with
| Add x -> fun data -> Printf.printf "add %d %d\n" data x; cont (data + x)
| Sub x -> fun data -> Printf.printf "sub %d %d\n" data x; cont (data - x)
| Label label -> (Hashtbl.find labels label) := cont; cont
| Jmp label ->
let target = Hashtbl.find labels label in
(fun data -> Printf.printf "jmp %s\n" label; !target data)
| Phi (cond, tlabel, flabel) ->
let tcont = Hashtbl.find labels tlabel
and fcont = Hashtbl.find labels flabel in
(fun data ->
let b = cond data in
Printf.printf "branch on %d to %s\n"
data (if b then tlabel else flabel);
(if b then !tcont else !fcont) data)
let compile opcodes =
let id = fun x -> x in
let labels = Hashtbl.create 17 in
Array.iter (function
| Label label -> Hashtbl.add labels label (ref id)
| _ -> ())
opcodes;
Array.fold_right (make_instr labels) opcodes id
为了清楚起见,我使用了两次传递,但很容易看出可以一次完成。
这是一个简单的循环,可以通过上面的代码编译执行:
let code = [|
Label "entry";
Phi (((<) 0), "body", "exit");
Label "body";
Sub 1;
Jmp "entry";
Label "exit" |]
执行跟踪:
# let fn = compile code;;
val fn : int -> int = <fun>
# fn 3;;
branch on 3 to body
sub 3 1
jmp entry
branch on 2 to body
sub 2 1
jmp entry
branch on 1 to body
sub 1 1
jmp entry
branch on 0 to exit
- : int = 0
更新 2:
在性能方面,CPS 表示可能比基于数组的表示更快,因为在线性执行的情况下没有间接性。延续函数直接存储在指令闭包中。在基于数组的实现中,它必须首先增加程序计数器并执行数组访问(具有额外的边界检查开销)。
我已经制定了一些基准来证明这一点。下面是一个基于数组的解释器的实现:
type opcode =
Add of int | Sub of int
| Jmp of int | Phi of (int -> bool) * int * int
| Ret
let compile opcodes =
let instr_array = Array.make (Array.length opcodes) (fun _ data -> data)
in Array.iteri (fun i opcode ->
instr_array.(i) <- match opcode with
| Add x -> (fun pc data ->
let cont = instr_array.(pc + 1) in cont (pc + 1) (data + x))
| Sub x -> (fun pc data ->
let cont = instr_array.(pc + 1) in cont (pc + 1) (data - x))
| Jmp pc -> (fun _ data ->
let cont = instr_array.(pc) in cont (pc + 1) data)
| Phi (cond, tbranch, fbranch) ->
(fun _ data ->
let pc = (if cond data then tbranch else fbranch) in
let cont = instr_array.(pc) in
cont pc data)
| Ret -> fun _ data -> data)
opcodes;
instr_array
let code = [|
Phi (((<) 0), 1, 3);
Sub 1;
Jmp 0;
Ret
|]
let () =
let fn = compile code in
let result = fn.(0) 0 500_000_000 in
Printf.printf "%d\n" result
让我们看看它与上面基于 CPS 的解释器相比如何(当然,所有调试跟踪都被剥离了)。我在 Linux/amd64 上使用了 OCaml 3.12.0 本机编译器。每个程序运行 5 次。
array: mean = 13.7 s, stddev = 0.24
CPS: mean = 11.4 s, stddev = 0.20
因此,即使在紧密循环中,CPS 的性能也比数组好得多。如果我们展开循环并将一条sub 指令替换为五条,则数字会发生变化:
array: mean = 5.28 s, stddev = 0.065
CPS: mean = 4.14 s, stddev = 0.309
有趣的是,这两种实现实际上都击败了 OCaml 字节码解释器。在我的机器上执行以下循环需要 17 秒:
for i = 500_000_000 downto 0 do () done