实现函数式语言的虚拟机有哪些明显的优化方法?

12

我正在开发一种中间语言和虚拟机,用于运行一个具有一些"棘手"特性的函数式语言:

  • 词法命名空间(闭包)
  • 动态增长的调用栈
  • 缓慢的整型(大数)

这个中间语言是基于栈的,使用一个简单的哈希表来存储当前命名空间。为了让你了解它是什么样子,这里有一个麦卡锡91函数的例子:

# McCarthy 91: M(n) = n - 10 if n > 100 else M(M(n + 11))
.sub M
    args
    sto n

    rcl n
    float 100
    gt
    .if
        .sub
            rcl n
            float 10
            sub
        .end
        .sub
            rcl n
            float 11
            add
            list 1
            rcl M
            call-fast
            list 1
            rcl M
            tail
        .end
    call-fast
.end

这个"大循环"非常简单:

  • 获取指令
  • 增加指令指针(或程序计数器)
  • 执行指令

除了storcl和许多其他指令之外,还有三个函数调用指令:

  • call 复制名称空间(深复制)并将指令指针推入调用堆栈中
  • call-fast相同,但只创建浅拷贝
  • tail 基本上是一个'goto'

实现非常简单。为了给你更好的想法,这里只列出从“大循环”的中间随机选取的一小段代码(更新,请参见下文)。

    } else if inst == 2 /* STO */ {
        local[data] = stack[len(stack) - 1]
        if code[ip + 1][0] != 3 {
            stack = stack[:len(stack) - 1]
        } else {
            ip++
        }
    } else if inst == 3 /* RCL */ {
        stack = append(stack, local[data])
    } else if inst == 12 /* .END */ {
        outer = outer[:len(outer) - 1]
        ip = calls[len(calls) - 1]
        calls = calls[:len(calls) - 1]
    } else if inst == 20 /* CALL */ {
        calls = append(calls, ip)
        cp := make(Local, len(local))
        copy(cp, local)
        outer = append(outer, &cp)
        x := stack[len(stack) - 1]
        stack = stack[:len(stack) - 1]
        ip = x.(int)
    } else if inst == 21 /* TAIL */ {
        x := stack[len(stack) - 1]
        stack = stack[:len(stack) - 1]
        ip = x.(int)
问题是:使用值为-10000调用McCarthy91 16次,近乎不差地花费了3秒钟(在优化去除深度复制之后,它会添加近乎1秒)。
我的问题是:有哪些常见的技术可用于优化这种语言的解释?是否有任何易于实施的特别有效方法?
我在列表(参数、各种堆栈、命名空间的映射切片等)中使用了切片,所以我在很多地方都做了类似的事情:call_stack[:len(call_stack) - 1]。目前,我真的不知道哪些代码片段使得这个程序变得缓慢。非常感谢您提供任何提示,尽管我主要寻求一般的优化策略。
另外:
我可以通过规避我的调用约定大大减少执行时间。指令list<n>从堆栈获取n个参数,并将它们的列表推回到堆栈上,指令args弹出该列表并将每个项目推回到堆栈上。首先检查函数是否使用正确数量的参数进行调用,其次是能够调用具有可变参数列表的函数(即(defun f x:xs))。去掉这个,并添加一个指令sto* <x>,将sto <x>; rcl <x>替换掉,我可以把它降到2秒。仍然不是很好,而且我无论如何都必须有这个list/args的业务。 :)
还有一个(这是一个很长的问题,我知道,抱歉):
使用pprof分析程序几乎没有告诉我任何信息(如果这不是很明显,我是Go的新手)。以下是pprof报告的前3个项目:
  16   6.1%   6.1%       16   6.1% sweep               pkg/runtime/mgc0.c:745
   9   3.4%   9.5%        9   3.4% fmt.(*fmt).fmt_qc   pkg/fmt/format.go:323
   4   1.5%  13.0%        4   1.5% fmt.(*fmt).integer  pkg/fmt/format.go:248

以下是我迄今为止所做的更改:

  • 我移除了哈希表。现在,我只传递指向数组的指针,并且只有在必要时才有效地复制局部作用域。
  • 我用整数操作码替换了指令名称。之前,我浪费了很多时间比较字符串。
  • call-fast指令已经被删除(在其他更改后速度提升已不可测量)。
  • 与其拥有“int”、“float”和“str”指令,我只有一个eval,并在编译时(字节码的编译)对常量进行求值。然后eval只推送对它们的引用。
  • 在更改了.if的语义之后,我可以摆脱这些伪函数。现在是.if.else.endif,具有类似于.sub的隐式goto和块语义。(一些示例代码

在实现词法分析器、解析器和字节码编译器之后,速度略有下降,但并不太糟糕。在1.2秒内计算MC(-10000)16次,使其评估420万个字节码指令。这里是它生成的代码样例(来自这里)。

整个项目都在github上


4
请不要在词法作用域语言中使用哈希表和任何其他名称查找方式!这完全没有意义。您的编译器可以静态地分配寄存器。推断每个lambda抽象所捕获的环境集非常容易。 - SK-logic
我还不太明白你的意思。你能否举个简短的例子,比如针对“rcl”和“sto”? - Stefano Palazzo
2
使用数字槽位来表示参数、变量和闭包变量。引入操作码,如“ldarg N”、“starg N”、“ldloc N”、“stloc N”、“ldenv N”、“stenv N”。在编译时将变量名解析为数字。通过比较每个 lambda 抽象点的自由变量列表和绑定变量列表来构建捕获变量列表。引入一个特殊指令来构建闭包实例(类似于带有要捕获的变量列表的调用)。这是大多数函数式语言的实现方式(无论是 VM 还是本地)。它可以非常高效。 - SK-logic
1
有趣的问题,尽管我认为答案更像一本书 :-). 无论如何,这个问题有着悠久的历史,你可能更容易将其表述为优化抽象机器,但可以参考Simon Jones(或其他Haskeller)的许多论文以及STG机器及其优化的描述。 - Kristopher Micinski
@SK-logic 我摆脱了哈希表,这使得巨大的不同。之前需要三秒钟才能完成的相同代码现在只需要0.8秒(而且,作为奖励,我对所有变量的位置有了更好的理解)。 - Stefano Palazzo
显示剩余2条评论
2个回答

9

1
@StefanoPalazzo 你也可以看一下如何优化 CPS,因为这是许多编译器在幕后使用的技术。 - Kristopher Micinski

5
你的解释器应该有高效的算法表示各种概念。在哈希表上进行深复制看起来像是一个可怕的想法,但我看到你已经移除了它。
(是的,使用数组切片进行堆栈弹出操作似乎不太妥当。您应该确保它们确实具有预期的算法复杂度,否则使用专用数据结构(...堆栈)。我通常对使用Python列表或PHP哈希表等多用途数据结构进行此用途持谨慎态度,因为它们不一定设计得很好以处理此特定用例,但可能切片确保在所有情况下都具有O(1)的推入和弹出成本。)
处理环境的最佳方式是使用数字索引而不是变量(de Bruijn 索引(最后一个绑定变量为 0),或 de Bruijn 级别(第一个绑定变量为 0)),只要它们不需要被实体化。这样,您只需保留一个动态调整大小的数组作为环境,并且访问它非常快速。如果您有一级闭包,您还需要“捕获”环境,这将更加昂贵:您必须将其部分复制到专用结构中,或者对整个环境使用不可变结构。只有实验才能说明,但我的经验是,选择快速可变的环境结构并为闭包构建付出更高的代价比始终具有更多簿记的不可变结构更好;当然,您应该进行使用分析以仅捕获闭包中必要的变量。
最后,一旦您排除了与算法选择相关的低效源,热点区域将是:
  • 垃圾回收(绝对是个难题;如果你不想成为垃圾回收专家,那么你应该认真考虑重用现有的运行时);您可能正在使用宿主语言的垃圾回收(在解释语言中的堆分配被翻译成在实现语言中的堆分配,具有相同的生命周期),在您展示的代码片段中并不清楚;这种策略非常适合以简单的方式获得相当有效的东西。

  • 数值计算实现;当您操作的整数实际上很小时,有各种各样的技巧可以提高效率。您最好重用已经投入大量精力的人的工作,因此我强烈建议您例如重用GMP库。然后再次,如果您的主机语言支持bignum,则也可以重用其支持,在您的情况下是Go的math/big包。

  • 解释器循环的低级设计。在像您这样的“简单字节码”语言中(每个字节码指令被转换为一小部分本地指令,而不是具有高级语义的复杂字节码,例如Parrot字节码),实际的“循环和字节码分发”代码可能成为瓶颈。已经有很多关于编写这种字节码分派循环的最佳方法的研究,以避免if/then/else的级联(跳转表),从主机处理器分支预测中受益,简化控制流程等。这被称为线程化代码,有许多(相当简单)不同的技术:直接线程化,间接线程化...如果您想查看一些研究,例如Anton Ertl的工作高效解释器的结构和性能2003年,以及后来的上下文线程化:一种灵活高效的虚拟机解释器调度技术。这些技术的好处往往与处理器敏感度相关,因此您应该自己测试各种可能性。

虽然STG工作很有趣(Peyton-Jones的编程语言实现书非常好),但它有点偏向于惰性求值。关于为严格的函数式语言设计高效字节码,我的参考资料是Xavier Leroy 1990年关于ZINC机器的论文:ZINC实验:ML语言的经济实现,这是实现ML语言的开创性工作,并仍在OCaml语言的实现中使用:既有字节码编译器,又有本地编译器,但字节码仍使用改进版的ZINC机器。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接