C ++用于虚拟机的循环优化

时间:2016-02-18 19:21:27

标签: c++ optimization compiler-optimization vm-implementation

上下文

<小时/>

我的问题是双重的(实际上是两个问题),但非常基本*。但首先,我将针对某些背景显示一些相关代码。对于TL; DR'肉和土豆',请跳到底部查看实际问题。

*(我假设回答者在尝试回答之前已经知道发生了什么/虚拟机如何从根本上运作)。

如上所述,我正在编写一个(玩具)VM,它执行自定义字节代码指令集。

(这里的省略号仅代表某些情况的遗漏)

以下是我的代码片段:

for (ip = 0; (ip < _PROGRAM_SIZE || !cstackempty); ip++) {
        if (breakPending) { break; }

            switch (_instr) {

               case INST::PUSH: {
                   AssertAbort(wontoverflow(1), "Stack overflow (1 byte)");
                   cmd_ "PUSH";
                   push(_incbyte);
                   printStack();
                   break;
               }

        ...
               case INST::ADD: {
                   AssertAbort(stackhas(2), "Can't pop stack to add 2 bytes. Stack does not contain 2 bytes");
                   cmd_ "ADD";
                   byte popped8_a = pop();
                   byte popped8_b = pop();
                   byte result = popped8_a + popped8_b;
                   push(result);
                   cmd_ " "; cmd_(byte)result;
                   printStack();
                   break;
               }

               case INST::ADD16: {
                   AssertAbort(stackhas(4), "Can't pop stack to add 4 bytes. Stack does not contain 4 bytes");
                   cmd_ "ADD16";
                   u16 popped16_a = pop16();
                   u16 popped16_b = pop16();
                   u16 result = popped16_a + popped16_b;
                   push16(result);
                   cmd << " "; cmd << (u16)result;
                   printStack();
                   break;
               }
        ...
            }
}

只是因为它是相关的,我会提到 _cstack 是调用堆栈,因此 !cstackempty 宏,它会检查如果调用quits(退出for循环)之前调用为空,只是因为它是最后一条正在执行的指令(因为最后一条指令可能是函数的一部分,甚至是返回)。此外, ip (指令指针)只是一个无符号长long(u64), _PROGRAM_SIZE (程序大小,以字节为单位)。 instr 是一个字节,是对当前指令的引用(1个字节)。

<小时/> 肉和土豆

问题1 :由于我正在为每个块/大小写初始化两个可变大小的新整数(分段为块以避免重新声明错误等),因此将它们声明为for以上循环在速度,分配延迟,程序大小等方面有帮助吗?

问题2 :在这种情况下,continue会比break更快,并且有更快的方法来执行这样的条件循环,例如某种goto -pointer-to-label,如this post,与实现无关,或以某种方式避免continuebreak的费用?

总结一下,我的优先级是速度,然后内存成本(速度,效率),然后文件大小(VM的优先级)。

2 个答案:

答案 0 :(得分:3)

在回答具体问题之前,请注意:没有任何CPU直接执行C ++。因此,语言级别的此类微优化问题在很大程度上取决于编译器,软件运行时环境和目标硬件。完全有可能一种技术在你今天使用的编译器上运行得更好,但在你明天使用的编译器上更糟糕。类似于硬件选择,如CPU架构。

获得明确答案的唯一方法是在现实情况下对其进行基准测试,而了解基准测试结果的唯一方法通常是深入了解生成的程序集。如果这种优化对您很重要,请考虑学习一下有关开发体系结构的汇编语言。

鉴于此,我将选择一个特定的编译器(gcc)和一个通用架构(x86)并在该上下文中回答。对于其他选择,细节会略有不同,但我希望对于任何体面的编译器和硬件组合,广泛的笔划都是相似的。

问题1

申报地点无关紧要。声明本身甚至没有真正变成代码 - 它只是生成代码的定义和用法。

例如,考虑下面一个简单循环的两个变体(外部sink()方法就是为了避免优化赋值给a):

声明内圈

int func(int* num) {
  for (unsigned int i=0; i<100; i++) {
    int a = *num + *num;
    sink(a);
    sink(a);
  }
}

外部声明

int func(int* num) {
  int a;
  for (unsigned int i=0; i<100; i++) {
    a = *num + *num;
    sink(a);
    sink(a);
  }
}

我们可以使用godbolt编译器资源管理器轻松检查为firstsecond变体生成的程序集。它们是相同的 - 这是循环:

.L2:
        mov     ebp, DWORD PTR [r12]
        add     ebx, 1
        add     ebp, ebp
        mov     edi, ebp
        call    sink(int)
        mov     edi, ebp
        call    sink(int)
        cmp     ebx, 100
        jne     .L2

基本上声明不会生成任何代码 - 只有赋值才会生成。

问题2

这里需要注意的是,在硬件级别,没有“break”或“continue”等指令。你真的只有跳跃,无论是有条件的还是没有,这些都是基本的。中断和继续将转换为跳跃。在你的情况下,在交换机内部中断,,其中break是循环中的最后一个语句,并且switch内部的continue具有完全相同的效果,所以我期待它们的编译方式相同,但让我们检查一下。

让我们使用这个测试用例:

int func(unsigned int num, int iters) {
  for (; iters > 0; iters--) {
    switch (num) {
      case 0:
        sinka();
        break;
      case 1:
        sinkb();
        break;
      case 2:
        sinkc();
        break;
      case 3:
        sinkd();
        break;
      case 4:
        sinkd();
        break;
    }
  }
}

它使用break来存在这种情况。这是x86上gcc 4.4.7的godbolt output,忽略了函数序言:

.L13:
        cmp     ebp, 4
        ja      .L3
        jmp     [QWORD PTR [r13+r12*8]] # indirect jump
.L9:
        .quad   .L4
        .quad   .L5
        .quad   .L6
        .quad   .L7
        .quad   .L8
.L4:
        call    sinka()
        jmp     .L3
.L5:
        call    sinkb()
        jmp     .L3
.L6
        call    sinkc()
        jmp     .L3
.L7
        call    sinkd()
        jmp     .L3
.L8
        call    sinkd()
.L3:
        sub     ebx, 1
        test    ebx, ebx
        jg      .L13

这里,编译选择了跳转表方法。 num的值用于查找跳转地址(该表是.quad指令系列),然后间接跳转到标签L4到L8之一。中断变为jmp .L3,执行循环逻辑。

请注意,跳转表不是编译开关的唯一方法 - 如果我使用4个或更少的case语句,则编译选择了一系列分支。

让我们尝试相同的示例,但每个break都替换为continue

int func(unsigned int num, int iters) {
  for (; iters > 0; iters--) {
    switch (num) {
      case 0:
        sinka();
        continue;
... [16 lines omitted] ...
    }
  }
}

正如您现在可能已经猜到的那样,the results are identical - 来自特定的编译器和目标。 continue语句和break语句意味着完全相同的控制流,所以我希望对于大多数开启优化的正常编译器都是如此。

答案 1 :(得分:1)

对于问题2,处理器应该能够合理地处理中断,因为它实际上是一个总是在汇编中出现的分支,所以它不应该引起太多问题。这应该意味着没有管道冲洗的原因,因为分支预测单元应该正确。我相信问题1已在上面得到解答。