【问题标题】:VM Design: More opcodes or less opcodes? What is better?VM 设计:更多操作码还是更少操作码?什么是更好的?
【发布时间】:2010-11-01 15:04:02
【问题描述】:

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

先决条件

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

  • 应解释虚拟机代码。不禁止可能有 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只能在服务器市场或超级计算机业务的某些部分生存。虚拟机呢?

【问题讨论】:

  • 这不是问题,更像是一个作业。
  • @Javier:这是一个非常明确的问题:“更多操作码还是更少操作码?哪个更好?”。但是,如果没有先决条件,则无法回答(因为最佳答案很大程度上取决于这些)。我宁愿有一种感觉,你甚至连前十句话都没有读过,却做出了这样的嘲讽。我可以对您在此处发布的第二个问题提出相同的主张并投票支持关闭它。

标签: performance interpreter opcode vm-implementation


【解决方案1】:

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

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

【讨论】:

  • 我没有要求编译器,因为没有设置将被翻译成 VM 代码的语言。可能有数百种语言。我只对已经编译的代码感兴趣。关于编写 VM 有多难,这是我对“更好”的定义的第 5 期。相同的长度可能是一个好点(对此表示赞成),但它并没有真正回答如何在操作码少和操作码多之间找到平衡。
  • "我只对已经编译好的代码感兴趣。" ——啊?在定义代码可以编译成的操作码集之前,如何编译代码?源代码是 Java 或 C++ 或 Haskell 或其他,目标代码是你的 VM 的机器语言,编译器必须进行翻译。
  • VM 将要执行的所有初始代码都将是手工制作的(如 CPU 的汇编编程)。可以将高级语言翻译成该 VM 的编译器将在很久以后出现,在这里我并不关心编写这样一个编译器有多难或多容易(这也很大程度上取决于高级语言)——只要虚拟机正在“完成”,没有无法编译的语言。只需 8 个操作码就可以实现这种完整性。
【解决方案2】:

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

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

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

但是,我觉得,重要的是要认识到并强调任何事物都没有“通用理想”的实现,也就是说,一旦你保持通用和抽象,你将不可避免地面临需要做出妥协。

理想情况下,这些折衷方案将基于您的代码的实际使用场景,因此这些折衷方案实际上是基于您无需费力就可以做出的明智假设和简化。

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

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

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

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

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

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

【讨论】:

  • 它应该像一个CPU。 CPU 运行从低级 C 数字运算到高级 C++ 的代码。它不应该是一个目的非常有限的,它应该是通用的,并且应该可以运行今天用通用编程语言编写的几乎所有东西。有点像 LLVM,但 LLVM 是为先编译后执行而设计的,不适合解释。
  • 是的,但是 CPU 显然也专门用于某些用途,其中一些用途比其他用途更重要 - 同样,处理器设计有不同的理念(例如 CISC 与 RISC),您基本上是在尝试将您的问题两全其美。
【解决方案3】:

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

【讨论】:

  • 我没有对它进行基准测试,但“无间隙”开关不是总是同样快吗?毕竟调度是一个 O(1) 操作,只是跳转到正确的案例。如果一个微小的 switch 调度得更快,这只能是 CPU 缓存或更简单的内存访问等原因造成的,但从算法设计的 POV 来看,switch 的大小应该与它的性能无关,不是吗?
【解决方案4】:

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

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

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

【讨论】:

    猜你喜欢
    • 2020-11-27
    • 2016-12-12
    • 2011-07-09
    • 1970-01-01
    • 2011-06-07
    • 1970-01-01
    • 2014-09-17
    • 1970-01-01
    • 2011-06-21
    相关资源
    最近更新 更多