9

我正在开发一个简单的虚拟机,我正处于十字路口。

我最初的目标是使用字节长指令,因此是一个小循环和一个快速计算的 goto 调度。

然而,事实证明离它更远了——256 远不足以涵盖有符号和无符号的 8、16、32 和 64 位整数、浮点数和双精度数、指针操作以及不同的寻址组合。一种选择是不实现 byte 和 shorts,但目标是制作一个支持完整 C 子集以及向量操作的 VM,因为它们几乎无处不在,尽管在不同的实现中。

所以我切换到 16 位指令,所以现在我还能够添加便携式 SIMD 内在函数和更多编译的常用例程,这些例程通过不被解释真正节省了性能。还有全局地址的缓存,最初编译为基指针偏移量,第一次编译地址时,它只是覆盖偏移量和指令,以便下次直接跳转,代价是集合中的额外指令指令对全局的每次使用。

由于我没有处于分析阶段,我处于两难境地,额外的指令是否值得更多的灵活性,更多指令的存在以及因此没有来回复制指令是否会弥补增加的调度循环大小?请记住,这些说明只是一些组装说明,例如:

    .globl  __Z20assign_i8u_reg8_imm8v
    .def    __Z20assign_i8u_reg8_imm8v; .scl    2;  .type   32; .endef
__Z20assign_i8u_reg8_imm8v:
LFB13:
    .cfi_startproc
    movl    _ip, %eax
    movb    3(%eax), %cl
    movzbl  2(%eax), %eax
    movl    _sp, %edx
    movb    %cl, (%edx,%eax)
    addl    $4, _ip
    ret
    .cfi_endproc
LFE13:
    .p2align 2,,3
    .globl  __Z18assign_i8u_reg_regv
    .def    __Z18assign_i8u_reg_regv;   .scl    2;  .type   32; .endef
__Z18assign_i8u_reg_regv:
LFB14:
    .cfi_startproc
    movl    _ip, %edx
    movl    _sp, %eax
    movzbl  3(%edx), %ecx
    movb    (%ecx,%eax), %cl
    movzbl  2(%edx), %edx
    movb    %cl, (%eax,%edx)
    addl    $4, _ip
    ret
    .cfi_endproc
LFE14:
    .p2align 2,,3
    .globl  __Z24assign_i8u_reg_globCachev
    .def    __Z24assign_i8u_reg_globCachev; .scl    2;  .type   32; .endef
__Z24assign_i8u_reg_globCachev:
LFB15:
    .cfi_startproc
    movl    _ip, %eax
    movl    _sp, %edx
    movl    4(%eax), %ecx
    addl    %edx, %ecx
    movl    %ecx, 4(%eax)
    movb    (%ecx), %cl
    movzwl  2(%eax), %eax
    movb    %cl, (%eax,%edx)
    addl    $8, _ip
    ret
    .cfi_endproc
LFE15:
    .p2align 2,,3
    .globl  __Z19assign_i8u_reg_globv
    .def    __Z19assign_i8u_reg_globv;  .scl    2;  .type   32; .endef
__Z19assign_i8u_reg_globv:
LFB16:
    .cfi_startproc
    movl    _ip, %eax
    movl    4(%eax), %edx
    movb    (%edx), %cl
    movzwl  2(%eax), %eax
    movl    _sp, %edx
    movb    %cl, (%edx,%eax)
    addl    $8, _ip
    ret
    .cfi_endproc

此示例包含以下说明:

  • 将无符号字节从立即值分配给寄存器
  • 将无符号字节从寄存器分配到寄存器
  • 将全局偏移量中的无符号字节分配给寄存器,缓存并更改为直接指令
  • 将全局偏移量中的无符号字节分配给寄存器(现在缓存的先前版本)
  • ... 等等...

自然,当我为它生成编译器时,我将能够测试生产代码中的指令流,并优化内存中指令的排列,将常用的指令打包在一起,获得更多的缓存命中。

我只是很难确定这样的策略是否是一个好主意,膨胀会弥补灵活性,但性能呢?更多的编译例程会弥补更大的调度循环吗?是否值得缓存全局地址?

我还希望有人,在汇编中体面地表达对 GCC 生成的代码质量的意见 - 是否有任何明显的低效率和优化空间?把情况说清楚,有一个sp指针,指向实现寄存器的栈(没有其他栈),ip逻辑上是当前指令指针,gp是全局指针(不被引用,作为偏移量访问)。

编辑:另外,这是我执行指令的基本格式:

INSTRUCTION assign_i8u_reg16_glob() { // assign unsigned byte to reg from global offset
    FETCH(globallAddressCache);
    REG(quint8, i.d16_1) = GLOB(quint8);
    INC(globallAddressCache);
}

FETCH 返回对结构的引用,指令根据操作码使用该结构

REG 从偏移量返回对寄存器值 T 的引用

GLOB 从缓存的全局偏移量(实际上是绝对地址)返回对全局值的引用

INC 只是将指令指针增加指令的大小。

有些人可能会建议不要使用宏,但是使用模板时它的可读性要低得多。这样代码就很明显了。

编辑:我想对这个问题补充几点:

  • 我可以选择“仅寄存器操作”解决方案,它只能在寄存器和“内存”之间移动数据——无论是全局还是堆。在这种情况下,每个“全局”和堆访问都必须复制值、修改或使用它,然后将其移回更新。这样,我有一个更短的调度循环,但每条处理非寄存器数据的指令都有一些额外的指令。因此,困境是多几倍的本地代码和更长的直接跳转,或者多几倍的解释指令和更短的调度循环。一个短的调度循环会给我足够的性能来弥补额外和昂贵的内存操作吗?也许较短和较长的调度循环之间的增量不足以产生真正的影响?就缓存命中而言,就装配跳跃的成本而言。

  • 我可以进行额外的解码并且只有 8 位宽的指令,但是,这可能会增加另一个跳转 - 跳转到处理该指令的任何位置,然后在跳转到处理特定寻址方案的情况或解码操作以及更复杂的情况上浪费时间执行方法。在第一种情况下,dispatch 循环仍然在增长,并且增加了另一个跳转。第二个选项 - 寄存器操作可用于解码寻址,但需要更复杂的指令和更多未知的编译时间才能寻址任何内容。我不确定这将如何与更短的调度循环叠加,再次不确定我的“更短和更长的调度循环”如何与汇编指令、它们需要的内存和速度方面被认为是短或长的内容相关联他们的执行。

  • 我可以选择“多指令”解决方案——调度循环要大几倍,但它仍然使用预先计算的直接跳转。复杂寻址是针对每条指令的特定和优化的,并编译为本机,因此“仅寄存器”方法所需的额外内存操作将被编译并主要在寄存器上执行,这有利于性能。通常,这个想法是向指令集添加更多内容,但也增加了可以预先编译并在单个“指令”中完成的工作量。单独的指令集也意味着更长的调度循环、更长的跳转(尽管可以优化以最小化)、更少的缓存命中,但问题是多少?考虑到每个“指令”只是一些汇编指令,大约 7-8k 指令的汇编片段被认为是正常的还是太多了?考虑到平均指令大小在 2-3b 左右变化,这不应该超过 20k 的内存,足以完全适应大多数 L1 缓存。但这不是具体的数学,只是我在谷歌上搜索的东西,所以也许我的“计算”有问题?或者也许它不是那样工作的?我在缓存机制方面没有那么丰富的经验。

对我来说,当我目前权衡这些论点时,“多指令”方法似乎最有可能获得最佳性能,当然,前提是我关于在 L1 缓存中拟合“扩展调度循环”的理论成立。因此,这就是您的专业知识和经验发挥作用的地方。既然上下文已经缩小并且提出了一些支持性想法,那么通过减少较慢的解释代码的数量,也许更容易给出一个更具体的答案.

我的指令大小数据基于这些统计数据

4

5 回答 5

5

您可能需要考虑分离 VM ISA 及其实现。

例如,在我编写的 VM 中,我有一个“直接加载值”指令。指令流中的下一个值没有被解码为指令,而是作为值加载到寄存器中。您可以考虑这一宏指令或两个单独的值。

我实现的另一条指令是“加载常量值”,它从内存中加载一个常量(使用常量表的基地址和偏移量)。因此,指令流中的一个常见模式是load value direct (index); load constant value. 您的 VM 实现可能会识别此模式并使用单个优化实现来处理这对。

显然,如果您有足够的位,您可以使用其中的一些来识别寄存器。对于 8 位,可能需要一个用于所有操作的寄存器。但同样,您可以添加另一条指令with register X来修改下一个操作。在您的 C++ 代码中,该指令只会设置currentRegister其他指令使用的指针。

于 2013-08-12T11:42:58.143 回答
3

更多的编译例程会弥补更大的调度循环吗?

我认为您不喜欢为某些指令使用带有第二个字节的额外操作码的单字节指令?我认为 16 位操作码的解码效率可能低于 8 位 + 额外字节,假设额外字节本身不太常见或难以解码。

如果是我,我会努力让编译器(不一定是具有“一切”的成熟编译器,而是一个基本模型)使用一组相当有限的“指令”。保持代码生成部分相当灵活,以便以后可以轻松更改实际编码。一旦你完成了这项工作,你就可以尝试各种编码,看看性能和其他方面的结果。

对于没有做过这两种选择的人来说,你的很多小问题都很难回答。从这个意义上说,我从来没有写过虚拟机,但我已经在几个反汇编程序、指令集模拟器和类似的东西上工作过。在解释语言方面,我还实现了几种不同类型的语言。

您可能还想考虑一种 JIT 方法,在这种方法中,您无需加载字节码,而是解释字节码并为相关架构生成直接机器码。

GCC 代码看起来并不糟糕,但是有几个地方的代码取决于前一条指令的值——这在现代处理器中并不是很好。不幸的是,我没有看到任何解决方案——这是一个“代码太短而无法随机播放”的问题——添加更多指令显然是行不通的。

我确实看到了一个小问题:加载 32 位常量将要求它是 32 位对齐的,以获得最佳性能。我不知道 Java VM 如何(或是否)处理这个问题。

于 2013-08-12T08:58:18.043 回答
1

我认为你问错了问题,并不是因为这是一个不好的问题,相反,这是一个有趣的主题,我怀疑很多人和我一样对结果感兴趣。

但是,到目前为止,没有人分享类似的经验,所以我想你可能需要做一些开创性的工作。与其想知道使用哪种方法并在样板代码的实现上浪费时间,不如专注于创建一个描述语言结构和属性的“反射”组件,使用虚拟方法创建一个漂亮的多态结构,而不用担心性能,创建您可以在运行时组装模块化组件,一旦您建立了对象层次结构,甚至可以选择使用声明性语言。由于您似乎使用 Qt,因此您已经完成了一半的工作。然后你可以使用树形结构来分析和生成各种不同的代码——编译的 C 代码或特定 VM 实现的字节码,其中你可以创建多个,

我认为,如果您在没有事先给出具体答案的情况下就该主题进行开创性的研究,我认为这组建议将更有益,它将使您可以轻松地测试所有场景并根据实际表现而不是个人假设和其他人的。然后也许你可以分享结果并用性能数据回答你的问题。

于 2013-08-13T16:33:57.087 回答
0

以字节为单位的指令长度已经以同样的方式处理了很长一段时间。显然,当您希望执行如此多类型的操作时,限制为 256 条指令并不是一件好事。

这就是为什么有一个前缀值。回到 gameboy 架构中,没有足够的空间来包含所需的 256 位控制指令,这就是为什么使用一个操作码作为前缀指令的原因。这保留了原始的 256 个操作码以及以该前缀字节开头的 256 个操作码。

例如:一项操作可能如下所示:D6 FF=SUB A, 0xFF

但是前缀指令将显示为:CB D6 FF=SET 2, (HL)

如果处理器读取CB它会立即开始查找另一个 256 个操作码的指令集。

今天的 x86 架构也是如此。本质上,任何带有前缀0F的指令都是另一个指令集的一部分。

使用您用于仿真器的执行方式,这是扩展指令集的最佳方式。16 位操作码会占用比必要更多的空间,并且前缀不提供这么长的搜索。

于 2013-08-19T22:53:03.770 回答
0

您应该决定的一件事是您希望在代码文件大小效率、缓存效率和原始执行速度效率之间取得什么平衡。根据您正在解释的代码的编码模式,将每条指令(无论其在代码文件中的长度如何)转换为包含指针和整数的结构可能会有所帮助。第一个指针将指向一个函数,该函数接受一个指向指令信息结构以及执行上下文的指针。因此,主执行循环将类似于:

do
{
  pc = pc->func(pc, &context);
} while(pc);

与“添加短立即指令”相关的函数将类似于:

INSTRUCTION *add_instruction(INSTRUCTION *pc, EXECUTION_CONTEXT *context)
{
  context->op_stack[0] += pc->operand;
  return pc+1;
}

而“添加长立即数”将是: INSTRUCTION *add_instruction(INSTRUCTION *pc, EXECUTION_CONTEXT *context) { context->op_stack[0] += (uint32_t)pc->operand + ((int64_t)(pc[1].operand ) << 32); 返回 pc+2; }

并且与“添加本地”指令相关的功能是:

INSTRUCTION *add_instruction(INSTRUCTION *pc, EXECUTION_CONTEXT *context)
{
  CONTEXT_ITEM *op_stack = context->op_stack;
  op_stack[0].asInt64 += op_stack[pc->operand].asInt64;
  return pc+1;
}

您的“可执行文件”将由压缩的字节码格式组成,但它们随后会被翻译成指令表,从而在运行时解码指令时消除一定程度的间接性。

于 2014-10-20T03:00:17.947 回答