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

VM interpreter - weighting performance benefits and drawbacks of larger instruction set / dispatch loop

本文关键字:性能 加权 缺点 循环 调度 解释器 指令集 VM      更新时间:2023-10-16

我正在开发一个简单的VM,我正处于十字路口的中间。

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

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

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

由于我不在分析阶段,我处于两难境地,额外的指令是否值得更大的灵活性,更多指令的存在以及因此来回复制指令的缺失是否可以弥补增加的调度循环大小?请记住,每个指令只是几个汇编指令,例如:

.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从offset

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

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

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

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

  • 我可以去一个"仅寄存器操作"的解决方案,只能在寄存器和"内存"之间移动数据-全局或堆。在这种情况下,每次"全局"访问和堆访问都必须复制值,修改或使用它,并将其移回更新。这样我就有了一个更短的分派循环,但是对于每条处理非寄存器数据的指令都有一些额外的指令。因此,两难的选择是,使用更长的直接跳转的本地代码增加几倍,或者使用更短的分派循环的解释指令增加几倍。一个简短的调度循环是否能够提供足够的性能来弥补额外的和昂贵的内存操作?也许较短和较长的调度循环之间的差异不足以产生真正的差异?就缓存命中而言,就汇编跳转的成本而言。

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

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

对我来说,当我目前权衡这些参数时,"多指令"方法似乎最有可能获得最佳性能,当然,前提是我关于在L1缓存中适合"扩展调度循环"的理论是正确的。所以这就是你的专业知识和经验发挥作用的地方。既然上下文已经缩小,并且提出了一些支持思想,那么通过减少较慢的解释代码的数量,更大指令集的好处是否优于本机代码的大小增加,可能会更容易给出一个更具体的答案。

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

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

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

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

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

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

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

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

对于没有做过这两个选项的人来说,你的很多小问题都很难回答。我从来没有在这个意义上写过一个VM,但是我做过几个反汇编器,指令集模拟器和类似的东西。就解释语言而言,我还实现了几种不同类型的语言。

您可能还想考虑JIT方法,在这种方法中,您不是加载字节码,而是解释字节码并为所讨论的体系结构生成直接的机器码。

GCC代码看起来并不可怕,但是有几个地方的代码依赖于前面指令的值——这在现代处理器中并不好。不幸的是,我没有看到任何解决方案-这是一个"太短的代码打乱"的问题-添加更多的指令显然是行不通的。

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

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

然而,到目前为止还没有人分享类似的经验,所以我想你可能需要做一些开创性的工作。不要纠结于该使用哪种方法,也不要把时间浪费在样板代码的实现上,而是专注于创建一个描述语言结构和属性的"反射"组件,用虚拟方法创建一个漂亮的多态结构,不用担心性能,创建可以在运行时组装的模块化组件,甚至可以在建立对象层次结构后使用声明性语言。既然您似乎在使用Qt,那么您就有一半的工作要做。然后,您可以使用树结构来分析和生成各种不同的代码-编译C代码或特定VM实现的字节码,您可以创建多个,您甚至可以使用它以编程方式为VM生成C代码,而不是手工输入所有代码。

我认为这组建议将更有益的情况下,你求助于开拓的主题没有一个具体的答案提前,它会让你很容易测试所有的场景,并作出决定基于实际表现,而不是个人的假设和其他人的。然后也许你可以分享结果,用性能数据回答你的问题。

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

这就是为什么有一个前缀值。在gameboy架构中,没有足够的空间包含所需的256位控制指令,这就是为什么使用一个操作码作为前缀指令。这保留了原来的256个操作码,如果从前缀字节开始,则会增加256个操作码。

例如:一个操作可能看起来像这样:D6 FF=SUB A, 0xFF

,但是一个带前缀的指令将表示为:CB D6 FF=SET 2, (HL)

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

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

对于您正在使用的模拟器的执行类型,这是扩展指令集的最佳方式。16位的操作码会占用比必要的更多的空间,而且前缀不能提供这么长的搜索。

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

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

与"add short immediate指令"相关联的函数应该是这样的:

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

while "add long immediate"将是:指令*add_instruction(指令*pc, EXECUTION_CONTEXT *context){背景-> op_stack [0] + = (uint32_t) pc ->操作数+ ((int64_t) (pc [1] .operand) & lt; & lt;32);返回pc + 2;}

和与"add local"指令相关联的函数是:

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

你的"可执行文件"将由压缩的字节码格式组成,但它们随后会被翻译成指令表,从而消除了在运行时解码指令时的间接级别。