VM解释器 - 加权性能的好处和更大的指令集/调度循环的缺点

9 c++ performance instruction-set vm-implementation

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

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

然而,事实证明现实无法进一步发展 - 256远远不足以涵盖有符号和无符号8,16,32和64位整数,浮点数和双精度数,指针运算,寻址的不同组合.一种选择是不实现字节和短路,但目标是制作支持完整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
Run Code Online (Sandbox Code Playgroud)

此示例包含以下说明:

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

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

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

我还希望有一些人,在汇编方面表达对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);
}
Run Code Online (Sandbox Code Playgroud)

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

REG从offset返回对寄存器值T的引用

GLOB从缓存的全局偏移量(有效绝对地址)中重新引用全局值

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

有些人可能会建议不要使用宏,但使用模板时,它的可读性要低得多.这样代码非常明显.

编辑:我想在这个问题上添加几点:

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

  • 我可以进行额外的解码,只有8位宽的指令,但是,这可能会增加另一个跳转 - 跳转到处理该指令的任何地方,然后浪费时间跳转到处理特定寻址方案或解码操作的情况和更复杂的情况执行方法.在第一种情况下,调度循环仍然增长,再添加另一个跳转.第二种选择 - 寄存器操作可以用于解码寻址,但是为了解决任何问题,将需要具有更多编译时间未知的更复杂的指令.我不确定这将如何与更短的调度循环叠加,再次,不确定我的"更短和更长的调度循环"如何与装配指令,他们需要的内存和速度方面的短或长相关他们的执行.

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

对我而言,正如我目前对论点进行加权,"多指令"方法似乎具有最佳表现的最大机会,当然,我的理论是关于在L1缓存中拟合"扩展调度循环".所以这里是您的专业知识和经验发挥作用的地方.既然已经缩小了上下文并提供了一些支持想法,那么通过减少较慢的解释代码量,更大的指令集的好处是否优于本机代码的大小增加可能更容易得到更具体的答案.

我的说明大小数据基于这些统计数据.

MSa*_*ers 5

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

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

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

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


Mat*_*son 3

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

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

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

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

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

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

我确实看到一个小问题:加载 32 位常量需要将其进行 32 位对齐才能获得最佳性能。我不知道 Java VM 如何(或是否)处理这个问题。