问题 对于实现功能语言的虚拟机,有哪些明显的优化?


我正在研究一种中间语言和一个虚拟机来运行具有几个“有问题”属性的函数式语言:

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

中间语言是基于堆栈的,具有当前命名空间的简单哈希表。这样就可以了解它的外观 McCarthy91 功能:

# 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 基本上是'转到''

实施非常简单。为了给你一个更好的主意,这里只是一个来自“大循环”中间的随机片段(更新,见下文)

    } 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)

问题是这样:调用McCarthy91 16次,值为-10000,接近没有差别,3秒(在优化掉深拷贝之后,增加了近一秒)。

我的问题是:优化这种语言的解释有哪些常用技巧?有没有悬挂水果?

我使用切片作为我的列表(参数,各种堆栈,命名空间的映射片段......),所以我在这个地方做了这样的事情: 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,隐含的getos和块语义类似于 .sub。 (一些示例代码

在实现词法分析器,解析器和字节码编译器之后,速度有点下降,但并非如此。计算MC(-10000)16次使其在1.2秒内评估420万字节码指令。这里的 它生成的代码示例 (从 这个)。

整件事都在github上


5015
2018-06-12 08:17


起源

请不要使用哈希表和任何其他类型的名称查找词汇范围的语言!它没有任何意义。您的编译器可以静态分配寄存器。为每个lambda抽象推断捕获的环境集非常容易。 - SK-logic
我真的不明白你的意思。你能举起一个简短的例子,比如“rcl”和“sto”吗? - Stefano Palazzo
使用数字槽作为参数,变量和闭包变量。引入像'ldarg N','starg N','ldloc N','stloc N','ldenv N','stenv N'等操作码。编译时将变量名称解析为数字。通过比较每个lambda抽象点的自由变量和绑定变量列表来构建捕获的变量列表。介绍一个用于构建闭包实例的特殊指令(应该类似于带有要捕获的变量列表的调用)。这是大多数功能语言的实现方式(VM和本机)。它可以非常有效。 - SK-logic
这很有道理,正是我希望得到的东西。我会记得在有机会实现这个问题时更新问题。谢谢到目前为止! - Stefano Palazzo
有趣的问题,虽然我怀疑答案会更像是一本书:-)。在任何情况下,这个问题都有很长的历史,你可能在优化抽象机器方面有更多的运气,但是看看许多Simon Jones(或其他类似的)纸张,以及STG机​​器及其类似物的描述优化。 - Kristopher Micinski


答案:


关于你可以优化的事情有几十年的研究:


8
2018-06-12 12:55



第二个链接真的很棒,非常感谢! - Stefano Palazzo
@StefanoPalazzo你也可以看看如何优化CPS,因为这是许多编译器在底层使用的。 - Kristopher Micinski


您应该为解释器的各种概念提供有效的算法表示。在哈希表上做深层复制看起来像一个可怕的想法,但我发现你已经删除了它。

(是的,使用数组切片的堆栈弹出操作看起来很可疑。你应该确保它们确实具有预期的算法复杂性,或者使用专用的数据结构(...堆栈)。我一般都很谨慎使用全部 - 目的数据结构,如Python列表或用于此用途的PHP哈希表,因为它们不一定能很好地处理这个特定的用例,但它可能确保切片确保在所有情况下都有O(1)推送和弹出成本。)

处理环境的最佳方法,只要它们不需要具体化,就是使用数字索引而不是变量(de Bruijn索引(0表示最后绑定的变量)或de Bruijn级别(0表示变量绑定)首先)。这样你只能为环境保留一个动态调整大小的数组,并且访问速度非常快。如果你有一流的闭包,你还需要 捕获 环境,这将是更昂贵的:你必须在专用结构中复制它的一部分,或在整个环境中使用不可变的结构。只有实验才能说明,但我的经验是,快速变化的环境结构和支付更高的成本进行封闭构建比拥有更多簿记的不可变结构更好;当然,您应该进行使用分析以仅捕获闭包中的必要变量。

最后,一旦你找到了与你的算法选择相关的低效率源,那么热门领域将是:

  • 垃圾收集(绝对是一个难题;如果你不想成为GC专家,你应该认真考虑重用现有的运行时);您可能正在使用主机语言的GC(解释语言中的堆分配被转换为实现语言中的堆分配,具有相同的生命周期),在您显示的代码段中并不清楚;这种策略非常适合以简单的方式获得合理有效的东西

  • 数字实现;当你操纵的整数实际上很小时,有各种各样的黑客有效。您最好的办法是重复使用已投入大量精力的人的工作,因此我强烈建议您重复使用 GMP库。再说一遍,如果有bignum,你也可以重用你的主语言支持,在你的情况下是Go的 数学/大 包。

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

虽然STG的工作很有意思(Peyton-Jones关于编程语言实现的书非常出色),但它有点倾向于懒惰的评估。关于严格函数式语言的高效字节码的设计,我的参考是Xavier Leroy 1990年在ZINC机器上的工作: ZINC实验:ML语言的经济实施这是实现ML语言的开创性工作,并且仍然在OCaml语言的实现中使用:有字节码和本机编译器,但字节码仍然使用美化的ZINC机器。


4
2018-06-16 17:33