22

不要感到震惊。这是很多文字,但恐怕如果不提供一些详细信息,我将无法真正展示这一切的全部内容(并且可能会得到很多并不能真正解决我的问题的答案)。这绝对不是一项任务(正如某人在他的评论中荒谬地声称的那样)。

先决条件

由于这个问题可能根本无法回答,除非至少设置了一些先决条件,所以这里是先决条件:

  • 应解释虚拟机代码。不禁止可能存在 JIT 编译器,但设计应针对解释器。
  • VM 应该是基于寄存器的,而不是基于堆栈的。
  • 答案可能既不假设有一组固定的寄存器,也不假设它们的数量是无限的,两者都可能是这种情况。

此外,我们需要更好地定义“更好”。有几个属性必须考虑:

  1. VM 代码在磁盘上的存储空间。当然,您总是可以在这里放弃所有优化并仅压缩代码,但这对(2)有负面影响。
  2. 解码速度。如果将代码转换为可以直接执行的东西需要太长时间,那么存储代码的最佳方式是无用的。
  3. 内存中的存储空间。此代码必须在有或没有进一步解码的情况下直接可执行,但如果涉及进一步解码,则在执行期间和每次执行指令时完成此编码(在加载代码时仅完成一次解码计入第 2 项)。
  4. 代码的执行速度(考虑到常见的解释器技术)。
  5. VM 的复杂性以及为其编写解释器的难度。
  6. VM 自身需要的资源量。(如果 VM 运行的代码大小为 2 KB 并且执行速度比眨眼快,这不是一个好的设计,但是它需要 150 MB 来执行此操作,并且它的启动时间远高于代码的运行时间它执行)

现在举例说明我实际上所说的或多或少的操作码。看起来实际上设置了操作码的数量,因为每次操作需要一个操作码。然而它并不那么容易。

同一操作的多个操作码

您可以进行类似的操作

ADD R1, R2, R3

将 R1 和 R2 的值相加,将结果写入 R3。现在考虑以下特殊情况:

ADD R1, R2, R2
ADD R1, 1, R1

这些是您可以在许多应用程序中找到的常见操作。您可以使用已经存在的操作码来表达它们(除非您需要不同的操作码,因为最后一个操作码具有 int 值而不是寄存器)。但是,您也可以为这些创建特殊的操作码:

ADD2 R1, R2
INC R1

和之前一样。优势在哪里?ADD2 只需要两个参数,而不是 3,INC 甚至只需要一个。因此,这可以在磁盘和/或内存中进行更紧凑的编码。由于将任何一种形式转换为另一种形式也很容易,因此解码步骤可以在两种方式之间转换以表达这些陈述。不过,我不确定这两种形式会在多大程度上影响执行速度。

将两个操作码组合成一个

现在让我们假设您有一个 ADD_RRR(R 代表寄存器)和一个 LOAD 来将数据加载到寄存器中。

LOAD value, R2
ADD_RRR R1, R2, R3

您可以拥有这两个操作码并始终在整个代码中使用这样的结构......或者您可以将它们组合成一个新的操作码,名为 ADD_RMR(M 代表内存)

ADD_RMR R1, value, R3

数据类型与操作码

假设您有 16 位整数和 32 位整数作为本机类型。寄存器是 32 位的,因此任何一种数据类型都适合。现在,当您添加两个寄存器时,您可以将数据类型设为参数:

ADD int16, R1, R2, R3
ADD int32, R1, R2, R3

例如,有符号和无符号整数也是如此。这样 ADD 可以是一个短操作码,一个字节,然后你有另一个字节(或者可能只是 4 位)告诉 VM 如何解释寄存器(它们是 16 位还是 32 位值)。或者您可以废弃类型编码,而使用两个操作码:

ADD16 R1, R2, R3
ADD32 R1, R2, R3

有人可能会说两者完全相同 - 只需将第一种方式解释为 16 位操作码即可。是的,但是一个非常天真的解释器可能看起来完全不同。例如,如果每个操作码有一个函数并使用 switch 语句进行调度(不是最好的方法,函数调用开销,switch 语句也可能不是最优的,我知道),两个操作码可能如下所示:

case ADD16: add16(p1, p2, p3); break; // pX pointer to register
case ADD32: add32(p1, p2, p3); break;

每个功能都以某种添加为中心。第二个可能看起来像这样:

case ADD: add(type, p1, p2, p3); break;

// ...
// and the function

void add (enum Type type, Register p1, Register p2, Register p3)
{
    switch (type) {
       case INT16: //...
       case INT32: // ...
    }
}

将子交换机添加到主交换机或将子调度表添加到主调度表。当然,无论类型是否显式,解释器都可以做任何一种方式,但根据操作码设计,任何一种方式都会让开发人员感觉更原生。

元操作码

由于没有更好的名字,我会这样称呼他们。这些操作码本身没有任何意义,它们只是改变了后面的操作码的含义。就像著名的 WIDE 运算符:

ADD R1, R2, R3
WIDE
ADD R1, R2, R3

例如,在第二种情况下,寄存器是 16 位的(因此您可以添加更多),在第一种情况下只有 8 个。或者,您不能有这样的元操作码,并且有一个 ADD 和一个 ADD_WIDE 操作码。像 WIDE 这样的元操作码避免使用 SUB_WIDE、MUL_WIDE 等,因为您始终可以在所有其他正常操作码之前添加 WIDE(始终只有一个操作码)。缺点是单独的操作码变得毫无意义,您必须始终检查它之前的操作码是否是元操作码。此外,VM 必须为每个线程存储一个额外的状态(例如,我们现在是否处于宽模式)并在下一条指令之后再次删除该状态。甚至 CPU 也有这样的操作码(例如 x86 LOCK 操作码)。

如何找到一个好的权衡???

当然,您拥有的操作码越多,开关/调度表就会变得越大,在磁盘或内存中表达这些代码所需的位数就越多(尽管您可以更有效地将它们存储在数据不存在的磁盘上必须由 VM 直接执行);此外,VM 将变得更加复杂,代码行数也更多——另一方面,操作码越强大:您越来越接近每个表达式,即使是复杂的表达式,都将在一个操作码中结束的地步。

选择小的操作码可以很容易地对 VM 进行编码,并且我猜会导致非常紧凑的操作码 - 另一方面,这意味着您可能需要大量的操作码来执行简单的任务,并且每个不经常使用的表达式都必须成为某种(本机)函数调用,因为它不能使用任何操作码。

我在 Internet 上阅读了很多关于各种 VM 的信息,但没有任何消息来源能够真正做出良好且公平的权衡。设计 VM 就像设计 CPU,有些 CPU 的操作码很少,它们速度很快,但您也需要很多这样的 CPU。并且有许多操作码的 CPU,有些非常慢,但你需要更少的操作码来表达相同的代码。看起来“越多越好”的CPU完全赢得了消费市场,而“越少越好”的CPU只能在服务器市场或超级计算机业务的某些部分生存。虚拟机呢?

4

4 回答 4

8

老实说,我认为这在很大程度上取决于 VM 的用途,类似于处理器设计在很大程度上取决于处理器的主要用途。

换句话说,您最好能够确定您的 VM 的常见用例场景,以便您可以建立可能需要的功能,也可以建立那些不太可能经常需要的功能。

我当然明白,您可能正在设想一个抽象的、非常通用的虚拟机,它可以用作其他编程语言的内部/后端实现?

但是,我觉得,重要的是要意识到并强调实际上没有任何东西的“通用理想”实现,即一旦您保持通用和抽象,您将不可避免地面临需要做出妥协的情况。

理想情况下,这些妥协将基于您的代码的实际使用场景,因此这些妥协实际上是基于您可以在不费力气的情况下做出的明智假设和简化。

换句话说,我会考虑您的 VM 的目标是什么?它主要如何用于您的愿景?你想要达到的目标是什么?

这将帮助您提出要求并帮助您进行简化,以便您可以根据合理的假设设计指令集。

如果您希望您的 VM 主要用于编程语言进行数字运算,您可能希望通过提供大量低级原语以及对宽数据类型的支持来寻找一个相当强大的数学运算基础。

另一方面,如果您将服务器作为 OO 语言的后端,您将需要研究优化相应的低级指令(即哈希/字典)。

一般来说,我会建议在开始时保持指令集尽可能简单和直观,并且只有在证明将它们放置在适当位置确实有用(即配置文件和操作码转储)并且确实会导致性能时才添加特殊指令获得。因此,这在很大程度上取决于您的 VM 将拥有的第一个“客户”。

如果您真的渴望研究更多涉及的方法,您甚至可以研究在运行时动态优化指令集,使用模式匹配来查找字节码中常见的操作码,以便派生更多抽象实现,以便您可以转换使用自定义的运行时生成的操作码动态地处理您的字节码。

于 2009-06-11T01:21:06.863 回答
5

对于软件性能而言,如果所有操作码的长度相同会更容易,因此您可以拥有一个巨大的 switch 语句,而不必检查可能已由前面的修饰符操作码设置的各种选项位。

我认为您没有问过的两个问题是易于编写将编程语言转换为 VM 代码的编译器,以及易于编写执行 VM 代码的解释器。使用更少的操作码,这两者都更容易。(但不要太少。例如,如果你省略了除法操作码,那么你就有机会学习如何编写好的除法函数。好的除法函数比简单的函数要难得多。)

于 2009-06-10T06:25:52.977 回答
2

我更喜欢简约指令集,因为可以组合成一个操作码。例如,由两个 4 位指令字段组成的操作码可以使用 256 条目跳转表进行调度。由于分派开销是解释性能的主要瓶颈,因此性能提高了两倍,因为只需要分派每秒的指令。实现简约但有效的指令集的一种方法是累加器/存储设计。

于 2010-10-23T11:25:43.967 回答
0

更少的操作码,原子的,本质上。

但是,如果频繁使用某些操作码的组合,则将其添加为单个指令。

例如,很多高级 PL 都有更简单的“if”和“goto”指令,但它们也有组合的“while”、“for”、“do-while”或“repeat-until”指令,基于根据前面的说明。

于 2019-08-13T15:39:39.457 回答